607 lines
22 KiB
Vue
607 lines
22 KiB
Vue
<template>
|
|
<v-layout row fill-height wrap>
|
|
<v-flex xs12 id="map" :style="{ height: mapElementHeight }"></v-flex>
|
|
|
|
<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>
|
|
</v-layout>
|
|
</template>
|
|
|
|
<script>
|
|
import Feature from 'ol/Feature';
|
|
import Map from 'ol/Map';
|
|
import LineString from 'ol/geom/LineString';
|
|
import Point from 'ol/geom/Point';
|
|
import TileLayer from 'ol/layer/Tile';
|
|
import VectorLayer from 'ol/layer/Vector';
|
|
import VectorSource from 'ol/source/Vector';
|
|
import XYZ from 'ol/source/XYZ';
|
|
import View from 'ol/View';
|
|
import { defaults as defaultControls, Rotate } from 'ol/control';
|
|
import { fromLonLat, toLonLat } from 'ol/proj';
|
|
import {
|
|
Circle as CircleStyle, Fill, Icon, Stroke, Style, Text,
|
|
} from 'ol/style';
|
|
|
|
import compassIcon from '@/assets/compass.svg';
|
|
import compassNorthIcon from '@/assets/compassNorth.svg';
|
|
import unknownMarkerIcon from '@/assets/unknownMarker.svg';
|
|
import * as constants from '@/constants';
|
|
import { distance } from '@/tools';
|
|
|
|
const MAIN_VECTOR_LAYER_NAME = 'MAIN';
|
|
const REPORTS_MARKERS_VECTOR_LAYER_NAME = 'REPORTS_MARKERS';
|
|
|
|
export default {
|
|
computed: {
|
|
headingInRadiansFromNorth() {
|
|
if (this.heading !== null) {
|
|
// in radians from North
|
|
return 1.0 * this.heading * (Math.PI / 180);
|
|
}
|
|
return null;
|
|
},
|
|
isInAutorotateMap() {
|
|
return this.isRecenterButtonShown ? false : this.hasUserAutorotateMap;
|
|
},
|
|
mapElementHeight() {
|
|
// Recompute automatically the map element max height
|
|
// Use logic from VToolbar to compute the toolbar height
|
|
// FIXME: Ugly hack, easier way I found.
|
|
let toolbarHeight = 56;
|
|
if (this.$vuetify.breakpoint.mdAndUp) {
|
|
toolbarHeight = 64;
|
|
} else if (this.$vuetify.breakpoint.width > this.$vuetify.breakpoint.height) {
|
|
toolbarHeight = 48;
|
|
}
|
|
return `${this.$vuetify.breakpoint.height - toolbarHeight}px`;
|
|
},
|
|
olCenter() {
|
|
// Compute center in OL coordinates system
|
|
return fromLonLat([this.center[1], this.center[0]]);
|
|
},
|
|
olPolyline() {
|
|
// Compute the polyline in OL coordinates system
|
|
return this.polyline.map(item => fromLonLat([item[1], item[0]]));
|
|
},
|
|
olPosition() {
|
|
// Compute the current position in OL coordinates system
|
|
if (this.positionLatLng) {
|
|
return fromLonLat([this.positionLatLng[1], this.positionLatLng[0]]);
|
|
}
|
|
return null;
|
|
},
|
|
radiusFromAccuracy() {
|
|
// Compute the radius (in pixels) based on GPS accuracy, taking
|
|
// into account the current zoom level
|
|
if (this.accuracy && this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD) {
|
|
// Formula coming from https://wiki.openstreetmap.org/wiki/Zoom_levels.
|
|
const accuracyInPixels = this.accuracy / (
|
|
(constants.EARTH_RADIUS * 2 * Math.PI * Math.cos(this.positionLatLng[0]
|
|
* (Math.PI / 180)))
|
|
/ (2 ** (this.zoom + 8))
|
|
);
|
|
if (accuracyInPixels > constants.POSITION_MARKER_RADIUS) {
|
|
return accuracyInPixels;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
reportDetailsID() {
|
|
// Get the currently shown report details ID
|
|
return this.$store.state.reportDetails.id;
|
|
},
|
|
tileServer() {
|
|
const tileServerSetting = this.$store.state.settings.tileServer;
|
|
if (tileServerSetting in constants.TILE_SERVERS) {
|
|
return constants.TILE_SERVERS[tileServerSetting];
|
|
}
|
|
// Remove the protocol part, avoid easily avoidable unsecured
|
|
// content over HTTPS.
|
|
const firstColon = tileServerSetting.indexOf(':');
|
|
return tileServerSetting.substring(firstColon + 1);
|
|
},
|
|
},
|
|
data() {
|
|
const $t = this.$t.bind(this);
|
|
return {
|
|
attribution: $t('map.attribution'),
|
|
hasUserAutorotateMap: this.$store.state.settings.shouldAutorotateMap,
|
|
isProgrammaticMove: true,
|
|
map: null,
|
|
maxZoom: constants.MAX_ZOOM,
|
|
minZoom: constants.MIN_ZOOM,
|
|
isRecenterButtonShown: false,
|
|
// Variables for easy access to map feature and layers
|
|
accuracyFeature: null,
|
|
reportsMarkersFeatures: {},
|
|
reportsMarkersVectorSource: null,
|
|
mainVectorSource: null,
|
|
polylineFeature: null,
|
|
positionFeature: null,
|
|
reportLatLngFeature: null,
|
|
};
|
|
},
|
|
methods: {
|
|
deleteReportMarker(markerID) {
|
|
const feature = this.reportsMarkersFeatures[markerID];
|
|
if (feature) {
|
|
this.reportsMarkersVectorSource.removeFeature(feature);
|
|
delete this.reportsMarkersFeatures[markerID]; // careful to delete the item itself
|
|
}
|
|
},
|
|
drawReportMarker(marker, addedMarkersIDs) {
|
|
if ((addedMarkersIDs && !addedMarkersIDs.has(marker.id))
|
|
|| this.reportsMarkersFeatures[marker.id]
|
|
) {
|
|
// Skip the marker if it was not added or is already on the map
|
|
return;
|
|
}
|
|
|
|
// Create a Feature for the marker, to add it on the map
|
|
const reportMarkerFeature = new Feature({
|
|
geometry: new Point(fromLonLat([marker.latLng[1], marker.latLng[0]])),
|
|
id: marker.id,
|
|
});
|
|
reportMarkerFeature.setStyle(new Style({
|
|
image: new Icon({
|
|
anchor: constants.ICON_ANCHOR,
|
|
scale: (
|
|
marker.id === this.reportDetailsID
|
|
? constants.LARGE_ICON_SCALE
|
|
: constants.NORMAL_ICON_SCALE
|
|
),
|
|
src: constants.REPORT_TYPES[marker.type].marker,
|
|
}),
|
|
}));
|
|
// Add the marker to the map and keep a reference to it
|
|
this.reportsMarkersFeatures[marker.id] = reportMarkerFeature;
|
|
this.reportsMarkersVectorSource.addFeature(reportMarkerFeature);
|
|
},
|
|
handleClick(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
let isClickOnMarker = false;
|
|
if (this.map) {
|
|
this.map.forEachFeatureAtPixel(event.pixel, (feature, layer) => {
|
|
if (layer.get('name') !== REPORTS_MARKERS_VECTOR_LAYER_NAME) {
|
|
return;
|
|
}
|
|
isClickOnMarker = true;
|
|
this.$store.dispatch(
|
|
'showReportDetails',
|
|
{ id: feature.get('id'), userAsked: true },
|
|
);
|
|
});
|
|
}
|
|
|
|
if (!isClickOnMarker && this.onPress) {
|
|
// Reverse coordinates as OL uses lng first.
|
|
const coords = toLonLat(event.coordinate).reverse();
|
|
this.onPress(coords);
|
|
}
|
|
return false;
|
|
},
|
|
hideRecenterButton() {
|
|
if (this.isRecenterButtonShown) {
|
|
this.isRecenterButtonShown = false;
|
|
}
|
|
},
|
|
onMoveEnd() {
|
|
const view = this.map.getView();
|
|
if (this.onMapCenterUpdate) {
|
|
const mapCenterLonLat = toLonLat(view.getCenter());
|
|
this.onMapCenterUpdate([mapCenterLonLat[1], mapCenterLonLat[0]]);
|
|
}
|
|
if (this.onMapZoomUpdate) {
|
|
this.onMapZoomUpdate(view.getZoom());
|
|
}
|
|
},
|
|
recenterMap() {
|
|
this.isProgrammaticMove = true;
|
|
|
|
this.hideRecenterButton();
|
|
|
|
const view = this.map.getView();
|
|
view.setCenter(this.olCenter);
|
|
if (this.isInAutorotateMap) {
|
|
view.setRotation(-this.headingInRadiansFromNorth);
|
|
} else {
|
|
view.setRotation(0);
|
|
}
|
|
view.setZoom(this.zoom);
|
|
},
|
|
setPositionFeatureStyle() {
|
|
if (!this.map || !this.positionFeature) {
|
|
return;
|
|
}
|
|
|
|
const positionFeatureStyle = this.positionFeature.getStyle();
|
|
|
|
// If heading is specified
|
|
if (this.headingInRadiansFromNorth !== null) {
|
|
const rotation = (this.isInAutorotateMap
|
|
? -Math.PI / 2
|
|
: (
|
|
this.headingInRadiansFromNorth
|
|
+ this.map.getView().getRotation()
|
|
- Math.PI / 2
|
|
)
|
|
);
|
|
// Check current style and update rotation if an arrow is already drawn
|
|
if (positionFeatureStyle) {
|
|
const TextStyle = positionFeatureStyle.getText();
|
|
if (TextStyle) {
|
|
TextStyle.setRotation(rotation);
|
|
return;
|
|
}
|
|
}
|
|
// Replace style by an arrow otherwise
|
|
this.positionFeature.setStyle(new Style({
|
|
text: new Text({
|
|
textBaseline: 'middle',
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
rotation,
|
|
font: '30px sans-serif',
|
|
text: '►',
|
|
fill: new Fill({
|
|
color: '#3399CC',
|
|
}),
|
|
stroke: new Stroke({
|
|
color: '#fff',
|
|
width: 2,
|
|
}),
|
|
}),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// No heading specified, force circle if a circle is not already
|
|
// displayed
|
|
if (!positionFeatureStyle || !positionFeatureStyle.getImage()) {
|
|
this.positionFeature.setStyle(new Style({
|
|
image: new CircleStyle({
|
|
radius: constants.POSITION_MARKER_RADIUS,
|
|
fill: new Fill({
|
|
color: '#3399CC',
|
|
}),
|
|
stroke: new Stroke({
|
|
color: '#fff',
|
|
width: 2,
|
|
}),
|
|
}),
|
|
}));
|
|
}
|
|
},
|
|
showRecenterButton() {
|
|
if (!this.isRecenterButtonShown) {
|
|
this.isRecenterButtonShown = true;
|
|
}
|
|
},
|
|
},
|
|
mounted() {
|
|
// Create accuracy circle feature
|
|
this.accuracyFeature = new Feature({
|
|
geometry: this.olPosition ? new Point(this.olPosition) : null,
|
|
});
|
|
this.accuracyFeature.setStyle(new Style({
|
|
image: new CircleStyle({
|
|
radius: this.radiusFromAccuracy,
|
|
fill: new Fill({
|
|
color: 'rgba(51, 153, 204, 0.25)',
|
|
}),
|
|
stroke: new Stroke({
|
|
color: '#3399CC',
|
|
width: 2,
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
// Create position marker feature
|
|
this.positionFeature = new Feature({
|
|
geometry: this.olPosition ? new Point(this.olPosition) : null,
|
|
});
|
|
this.setPositionFeatureStyle();
|
|
|
|
// Create polyline feature
|
|
this.polylineFeature = new Feature({
|
|
geometry: new LineString(this.olPolyline),
|
|
});
|
|
|
|
// Initialize the map
|
|
this.isProgrammaticMove = true;
|
|
this.mainVectorSource = new VectorSource({
|
|
features: [
|
|
this.accuracyFeature,
|
|
this.positionFeature,
|
|
this.polylineFeature,
|
|
],
|
|
});
|
|
// Initialize markers
|
|
this.reportsMarkersVectorSource = new VectorSource();
|
|
this.markers.forEach(marker => this.drawReportMarker(marker, null));
|
|
// Create the rotate label
|
|
const rotateLabel = document.createElement('img');
|
|
rotateLabel.src = (this.isInAutorotateMap
|
|
? compassIcon
|
|
: compassNorthIcon
|
|
);
|
|
rotateLabel.style.width = '100%';
|
|
rotateLabel.style.height = '100%';
|
|
// Create the map object
|
|
this.map = new Map({
|
|
controls: defaultControls({
|
|
attributionOptions: {
|
|
collapsible: false,
|
|
},
|
|
rotateOptions: {
|
|
autoHide: false,
|
|
label: rotateLabel,
|
|
resetNorth: () => {
|
|
// Switch autorotate mode
|
|
this.hasUserAutorotateMap = !this.hasUserAutorotateMap;
|
|
},
|
|
},
|
|
zoom: false,
|
|
}),
|
|
layers: [
|
|
new TileLayer({
|
|
source: new XYZ({
|
|
url: this.tileServer,
|
|
attributions: this.attribution,
|
|
}),
|
|
}),
|
|
new VectorLayer({
|
|
name: MAIN_VECTOR_LAYER_NAME,
|
|
source: this.mainVectorSource,
|
|
}),
|
|
new VectorLayer({
|
|
name: REPORTS_MARKERS_VECTOR_LAYER_NAME,
|
|
source: this.reportsMarkersVectorSource,
|
|
}),
|
|
],
|
|
target: 'map',
|
|
view: new View({
|
|
center: this.olCenter,
|
|
maxZoom: this.maxZoom,
|
|
minZoom: this.minZoom,
|
|
rotation: 0,
|
|
zoom: this.zoom,
|
|
}),
|
|
});
|
|
|
|
// Add click handler
|
|
this.map.on('singleclick', this.handleClick);
|
|
|
|
// Show recenter button on dragging the map
|
|
this.map.on('pointerdrag', () => {
|
|
this.isProgrammaticMove = false;
|
|
// Ensure correct orientation of position arrow
|
|
// TODO: Orientation should be correct while rotating as well
|
|
this.setPositionFeatureStyle();
|
|
this.showRecenterButton();
|
|
});
|
|
this.map.on('moveend', this.onMoveEnd);
|
|
|
|
// Set pointer to hover when hovering a report marker
|
|
this.map.on('pointermove', (event) => {
|
|
if (event.dragging) {
|
|
return;
|
|
}
|
|
|
|
const hit = this.map.hasFeatureAtPixel(event.pixel, {
|
|
layerFilter: layer => layer.get('name') === REPORTS_MARKERS_VECTOR_LAYER_NAME,
|
|
});
|
|
this.map.getTargetElement().style.cursor = hit ? 'pointer' : '';
|
|
});
|
|
},
|
|
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: {
|
|
isInAutorotateMap(newValue) {
|
|
this.map.getControls().forEach((control) => {
|
|
const controlItem = control;
|
|
if (controlItem instanceof Rotate) {
|
|
controlItem.label_.src = (newValue
|
|
? compassIcon
|
|
: compassNorthIcon
|
|
);
|
|
}
|
|
});
|
|
},
|
|
mapElementHeight() {
|
|
// Force a redraw of the map after a few milliseconds and the DOM
|
|
// has updated
|
|
setTimeout(() => this.map.updateSize(), 200);
|
|
},
|
|
markers(newMarkers, oldMarkers) {
|
|
// Map should have been created
|
|
if (this.reportsMarkersVectorSource === null) {
|
|
return;
|
|
}
|
|
|
|
// Compute the diff between old and new marker arrays, to determine
|
|
// added and removed markers
|
|
const oldIDs = new Set(oldMarkers.map(item => item.id));
|
|
const newIDs = new Set(newMarkers.map(item => item.id));
|
|
|
|
// Add new markers to the map
|
|
const addedMarkersIDs = new Set([...newIDs].filter(x => !oldIDs.has(x)));
|
|
this.markers.forEach(marker => this.drawReportMarker(marker, addedMarkersIDs));
|
|
|
|
// Remove removed markers from the map
|
|
const removedMarkersIDs = [...oldIDs].filter(x => !newIDs.has(x));
|
|
removedMarkersIDs.forEach(id => this.deleteReportMarker(id));
|
|
},
|
|
olCenter(newOlCenter) {
|
|
if (!this.map) {
|
|
// Map should have been created
|
|
return;
|
|
}
|
|
|
|
const view = this.map.getView();
|
|
if (!this.isRecenterButtonShown) {
|
|
// 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(this.center, 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) {
|
|
// Only open the details if the box was not just closed
|
|
if (this.$store.state.reportDetails.previousId !== closestReport.id) {
|
|
this.$store.dispatch('showReportDetails', { id: closestReport.id, userAsked: false });
|
|
}
|
|
} else {
|
|
this.$store.dispatch('hideReportDetails');
|
|
}
|
|
}
|
|
|
|
// Update view
|
|
view.setCenter(newOlCenter);
|
|
if (this.isInAutorotateMap) {
|
|
view.setRotation(-this.headingInRadiansFromNorth);
|
|
} else {
|
|
view.setRotation(0);
|
|
}
|
|
view.setZoom(this.zoom);
|
|
}
|
|
},
|
|
olPolyline(newOlPolyline) {
|
|
if (this.polylineFeature) {
|
|
// Update polyline trace
|
|
this.polylineFeature.setGeometry(new LineString(newOlPolyline));
|
|
}
|
|
},
|
|
olPosition(newOlPosition) {
|
|
if (this.positionFeature) {
|
|
// Update position marker position
|
|
this.positionFeature.setGeometry(
|
|
newOlPosition ? new Point(newOlPosition) : null,
|
|
);
|
|
this.setPositionFeatureStyle();
|
|
}
|
|
if (this.accuracyFeature) {
|
|
// Update accuracy circle position and radius
|
|
this.accuracyFeature.setGeometry(
|
|
newOlPosition ? new Point(newOlPosition) : null,
|
|
);
|
|
this.accuracyFeature.getStyle().getImage().setRadius(this.radiusFromAccuracy);
|
|
}
|
|
},
|
|
reportDetailsID(newID, oldID) {
|
|
[oldID, newID].forEach((id) => {
|
|
// We actually have to delete and recreate the feature,
|
|
// OpenLayer won't refresh the view if we only change the
|
|
// size. :/
|
|
this.deleteReportMarker(id);
|
|
const marker = this.markers.find(item => item.id === id);
|
|
if (marker) {
|
|
this.drawReportMarker(marker, null);
|
|
}
|
|
});
|
|
},
|
|
reportLatLng(newReportLatLng) {
|
|
// Eventually remove old marker
|
|
if (this.reportLatLngFeature && this.mainVectorSource) {
|
|
this.mainVectorSource.removeFeature(this.reportLatLngFeature);
|
|
this.reportLatLngFeature = null;
|
|
}
|
|
|
|
// Create unknown report marker if needed
|
|
if (newReportLatLng && this.mainVectorSource) {
|
|
this.reportLatLngFeature = new Feature({
|
|
geometry: new Point(fromLonLat([newReportLatLng[1], newReportLatLng[0]])),
|
|
});
|
|
this.reportLatLngFeature.setStyle(new Style({
|
|
image: new Icon({
|
|
anchor: constants.ICON_ANCHOR,
|
|
scale: constants.NORMAL_ICON_SCALE,
|
|
src: unknownMarkerIcon,
|
|
}),
|
|
}));
|
|
this.mainVectorSource.addFeature(this.reportLatLngFeature);
|
|
}
|
|
},
|
|
zoom(newZoom) {
|
|
if (!this.map) {
|
|
// Map should have been created
|
|
return;
|
|
}
|
|
if (!this.isRecenterButtonShown) {
|
|
// Handle programmatic navigation
|
|
this.map.getView().setZoom(newZoom);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fill-width {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
#map .ol-control button {
|
|
height: 3em !important;
|
|
width: 3em !important;
|
|
}
|
|
|
|
#map .ol-rotate {
|
|
background: none !important;
|
|
}
|
|
|
|
#map .ol-rotate button {
|
|
background-color: white !important;
|
|
border: 1px solid rgba(0, 0, 0, .87);
|
|
border-radius: 100%;
|
|
}
|
|
</style>
|