cygnal/src/components/Map.vue

337 lines
12 KiB
Vue

<template>
<div class="fill-height fill-width">
<v-lmap
ref="map"
:minZoom="this.minZoom"
:maxZoom="this.maxZoom"
:options="{ zoomControl: false }"
@click="handleClick"
@movestart="onMoveStart"
@moveend="onMoveEnd"
@zoomstart="onZoomStart"
>
<v-ltilelayer :url="tileServer" :attribution="attribution"></v-ltilelayer>
<template v-if="positionLatLng">
<v-lts v-if="heading !== null" :lat-lng="positionLatLng" :options="markerOptions"></v-lts>
<v-lcirclemarker
v-else
:lat-lng="positionLatLng"
:color="markerOptions.color"
:fillColor="markerOptions.fillColor"
:fillOpacity="1.0"
:weight="markerOptions.weight"
:radius="markerRadius"
>
</v-lcirclemarker>
<v-lcircle v-if="shouldDisplayAccuracy" :lat-lng="positionLatLng" :radius="radiusFromAccuracy"></v-lcircle>
</template>
<v-lpolyline :latLngs="polyline" :opacity="0.6" color="#00FF00"></v-lpolyline>
<v-lmarker v-if="reportLatLng" :lat-lng="reportLatLng" :icon="unknownMarkerIcon"></v-lmarker>
<ReportMarker v-for="marker in markers" :key="marker.id" :marker="marker"></ReportMarker>
</v-lmap>
<v-btn
absolute
dark
fab
large
bottom
left
color="blue"
class="overlayButton"
v-if="isRecenterButtonShown"
@click.native.stop="recenterMap"
role="button"
:aria-label="$t('buttons.recenterMap')"
>
<v-icon>my_location</v-icon>
</v-btn>
</div>
</template>
<script>
import L from 'leaflet';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
import {
LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline,
} from 'vue2-leaflet';
import Vue2LeafletTracksymbol from 'vue2-leaflet-tracksymbol';
import compassNorthIcon from '@/assets/compassNorth.svg';
import unknownMarkerIcon from '@/assets/unknownMarker.svg';
import * as constants from '@/constants';
import { distance } from '@/tools';
import ReportMarker from './ReportMarker.vue';
// Fix for a bug in Leaflet default icon
// see https://github.com/PaulLeCam/react-leaflet/issues/255#issuecomment-261904061
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl,
iconUrl,
shadowUrl,
});
export default {
components: {
'v-lmap': LMap,
'v-ltilelayer': LTileLayer,
'v-lmarker': LMarker,
'v-lcirclemarker': LCircleMarker,
'v-lcircle': LCircle,
'v-lpolyline': LPolyline,
'v-lts': Vue2LeafletTracksymbol,
ReportMarker,
},
computed: {
markerOptions() {
return {
fillColor: '#00ff00',
color: '#000000',
heading: this.heading * (Math.PI / 180), // in radians from North
weight: 1,
};
},
radiusFromAccuracy() {
if (this.accuracy) {
// Compute the radius (in pixels) based on GPS accuracy, taking
// into account the current zoom level
// Formula coming from https://wiki.openstreetmap.org/wiki/Zoom_levels.
return this.accuracy / (
(constants.EARTH_RADIUS * 2 * Math.PI * Math.cos(this.positionLatLng[0]
* (Math.PI / 180)))
/ (2 ** (this.zoom + 8))
);
}
return null;
},
shouldDisplayAccuracy() {
// Only display accuracy if circle is large enough
return (
this.accuracy
&& this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD
&& this.radiusFromAccuracy > this.markerRadius
);
},
tileServer() {
const tileServerSetting = this.$store.state.settings.tileServer;
if (tileServerSetting in constants.TILE_SERVERS) {
return constants.TILE_SERVERS[tileServerSetting];
}
const firstColon = tileServerSetting.indexOf(':');
return tileServerSetting.substring(firstColon + 1);
},
},
data() {
const $t = this.$t.bind(this);
return {
attribution: $t('map.attribution'),
isProgrammaticMove: false,
isProgrammaticZoom: false,
map: null,
markerRadius: 10.0,
maxZoom: constants.MAX_ZOOM,
minZoom: constants.MIN_ZOOM,
isRecenterButtonShown: false,
unknownMarkerIcon: L.icon({
iconAnchor: [20, 40],
iconSize: [40, 40],
iconUrl: unknownMarkerIcon,
}),
};
},
methods: {
handleClick(event) {
if (this.onPress) {
this.onPress(event.latlng);
}
},
hideRecenterButton() {
if (this.isRecenterButtonShown) {
this.isRecenterButtonShown = false;
}
},
onMoveStart() {
if (!this.isProgrammaticMove && !this.isProgrammaticZoom) {
this.showRecenterButton();
}
},
onMoveEnd() {
if (this.onMapCenterUpdate) {
const mapCenter = this.map.getCenter();
this.onMapCenterUpdate([mapCenter.lat, mapCenter.lng]);
}
if (this.onMapZoomUpdate) {
this.onMapZoomUpdate(this.map.getZoom());
}
},
onZoomStart() {
if (!this.isProgrammaticZoom) {
this.showRecenterButton();
}
},
recenterMap() {
this.hideRecenterButton();
if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
const mapCenter = this.map.getCenter();
if (
mapCenter.lat !== this.center[0]
&& mapCenter.lng !== this.center[1]
) {
this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; });
}
this.map.setView(this.center, this.zoom);
},
showCompass() {
const north = L.control({ position: 'topright' });
north.onAdd = () => {
const div = L.DomUtil.create('div', 'compassIcon legend');
div.innerHTML = `<img src="${compassNorthIcon}">`;
L.DomEvent.disableClickPropagation(div);
return div;
};
this.map.addControl(north);
},
showRecenterButton() {
if (!this.isRecenterButtonShown) {
this.isRecenterButtonShown = true;
}
},
},
mounted() {
this.map = this.$refs.map.mapObject;
if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
const mapCenter = this.map.getCenter();
if (
mapCenter.lat !== this.center[0]
&& mapCenter.lng !== this.center[1]
) {
this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; });
}
this.map.setView(this.center, this.zoom);
this.showCompass();
},
props: {
accuracy: Number,
center: {
type: Array,
required: true,
},
heading: Number, // in degrees, clockwise wrt north
markers: Array,
onPress: Function,
onMapCenterUpdate: Function,
onMapZoomUpdate: Function,
polyline: Array,
positionLatLng: Array,
reportLatLng: Array,
zoom: {
type: Number,
required: true,
},
},
watch: {
zoom(newZoom) {
if (!this.map) {
// Map should have been created
return;
}
if (!this.isRecenterButtonShown) {
// Handle programmatic navigation
if (this.map.getZoom() !== newZoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
this.map.setZoom(newZoom);
}
},
center(newCenterLatLng) {
if (!this.map) {
// Map should have been created
return;
}
if (!this.isRecenterButtonShown) {
// Handle programmatic navigation
if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
if (
this.map.getCenter().lat !== newCenterLatLng[0]
&& this.map.getCenter().lng !== newCenterLatLng[1]
) {
this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; });
// Eventually display closest report
const isReportDetailsAlreadyShown = this.$store.state.reportDetails.id;
const isReportDetailsOpenedByUser = this.$store.state.reportDetails.userAsked;
if (!isReportDetailsAlreadyShown || !isReportDetailsOpenedByUser) {
// Compute all markers distance, filter by max distance
const distances = this.markers.map(
marker => ({
id: marker.id,
distance: distance(newCenterLatLng, marker.latLng),
}),
).filter(item => item.distance < constants.MIN_DISTANCE_REPORT_DETAILS);
const closestReport = distances.reduce( // Get the closest one
(acc, item) => (
item.distance < acc.distance ? item : acc
),
{ distance: Number.MAX_VALUE, id: -1 },
);
// TODO: Take into account the history of positions for the direction
if (closestReport.id !== -1) {
this.$store.dispatch('showReportDetails', { id: closestReport.id, userAsked: false });
} else {
this.$store.dispatch('hideReportDetails');
}
}
}
this.map.setView(newCenterLatLng, this.zoom);
}
},
},
};
</script>
<style>
.application .leaflet-bar a {
color: black;
}
.compassIcon {
background-color: white;
border-radius: 50%;
width: 42px;
height: 42px;
box-shadow: 0 3px 5px -1px rgba(0,0,0,.2),0 6px 10px 0 rgba(0,0,0,.14),0 1px 18px 0 rgba(0,0,0,.12);
-webkite-box-shadow: 0 3px 5px -1px rgba(0,0,0,.2),0 6px 10px 0 rgba(0,0,0,.14),0 1px 18px 0 rgba(0,0,0,.12);
}
.compassIcon img {
width: 100%;
height: 100%;
}
</style>
<style scoped>
.fill-width {
width: 100%;
}
</style>