Move map from Leaflet to OpenLayers
Also add a way to use a mock GPX trace as geolocation provider.
This commit is contained in:
parent
316527b575
commit
60f041f5a6
3
.gitignore
vendored
3
.gitignore
vendored
@ -9,7 +9,8 @@ yarn-error.log*
|
||||
.zanata-cache
|
||||
po/*.po
|
||||
po/*.pot
|
||||
tests/*.gpx
|
||||
tests/gpx/*
|
||||
tests/mock_gpx.json
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
|
@ -172,6 +172,8 @@ Icons are made from the original works:
|
||||
licensed under CC BY-SA on Wikimedia.
|
||||
* [Pothole icon](https://commons.wikimedia.org/wiki/File:France_road_sign_A2a.svg)
|
||||
licensed under CC BY-SA on Wikimedia.
|
||||
* [Compass icon](https://commons.wikimedia.org/wiki/File:Black_and_white_compass.svg)
|
||||
licensed in public domain on Wikimedia.
|
||||
* [Work icons](https://www.vecteezy.com/vector-art/87351-road-traffic-cartoon-icons-vector)
|
||||
were designed by Vecteezy.
|
||||
* [Trash icon](https://pixabay.com/en/trash-waste-trashcan-garbage-99257/) is
|
||||
|
@ -44,10 +44,12 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
alias: {
|
||||
'@': utils.resolve('src'),
|
||||
}
|
||||
},
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
// Load mock_gpx.json from tests first, tests/default then
|
||||
modules: ['tests/', 'tests/default', 'node_modules'],
|
||||
},
|
||||
stats: {
|
||||
children: false,
|
||||
|
@ -18,16 +18,15 @@
|
||||
"file-saver": "^1.3.8",
|
||||
"gps-to-gpx": "^1.4.0",
|
||||
"howler": "^2.0.14",
|
||||
"leaflet": "^1.3.1",
|
||||
"leaflet-tracksymbol": "^1.0.8",
|
||||
"material-icons": "^0.2.3",
|
||||
"moment": "^2.22.2",
|
||||
"nosleep.js": "^0.7.0",
|
||||
"ol": "^5.1.3",
|
||||
"roboto-fontface": "^0.9.0",
|
||||
"vue": "^2.5.2",
|
||||
"vue-i18n": "^8.0.0",
|
||||
"vue-router": "^3.0.1",
|
||||
"vue2-leaflet": "^1.0.2",
|
||||
"vuelayers": "^0.10.13",
|
||||
"vuetify": "^1.1.11",
|
||||
"vuex": "^3.0.1",
|
||||
"whatwg-fetch": "^2.0.4"
|
||||
|
6
scripts/gps_to_js.py
Normal file → Executable file
6
scripts/gps_to_js.py
Normal file → Executable file
@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@ -15,13 +16,12 @@ if __name__ == "__main__":
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
for point in segment.points:
|
||||
# TODO: Other fields
|
||||
json_out.append({
|
||||
'time': point.time.isoformat(),
|
||||
'coords': {
|
||||
'accuracy': point.horizontal_dilution,
|
||||
'altitudeAccuracy': point.vertical_dilution,
|
||||
'heading': None,
|
||||
'heading': point.course,
|
||||
'latitude': point.latitude,
|
||||
'longitude': point.longitude
|
||||
}
|
||||
@ -30,5 +30,5 @@ if __name__ == "__main__":
|
||||
break
|
||||
|
||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
with open(os.path.join(script_dir, '../src/tools/mock_gpx.json'), 'w') as fh:
|
||||
with open(os.path.join(script_dir, '../tests/mock_gpx.json'), 'w') as fh:
|
||||
json.dump(json_out, fh)
|
||||
|
@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Adapted from https://github.com/ais-one/vue2-leaflet-tracksymbol
|
||||
import L from 'leaflet';
|
||||
import 'leaflet-tracksymbol';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
heading: Number,
|
||||
latLng: [Object, Array],
|
||||
options: Object,
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
heading(newHeading) {
|
||||
this.mapObject.setHeading(newHeading);
|
||||
},
|
||||
latLng(newLatLng) {
|
||||
this.mapObject.setLatLng(newLatLng);
|
||||
},
|
||||
options(newOptions) {
|
||||
L.setOptions(this.mapObject, newOptions);
|
||||
},
|
||||
visible: 'setVisible',
|
||||
},
|
||||
mounted() {
|
||||
const options = Object.assign({}, this.options, { heading: this.heading });
|
||||
this.mapObject = L.trackSymbol(this.latLng, options);
|
||||
if (this.$parent._isMounted) {
|
||||
this.deferredMountedTo(this.$parent.mapObject);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.setVisible(false);
|
||||
},
|
||||
methods: {
|
||||
deferredMountedTo(parent) {
|
||||
this.parent = parent;
|
||||
const that = this.mapObject;
|
||||
for (let i = 0; i < this.$children.length; i += 1) {
|
||||
this.$children[i].deferredMountedTo(that);
|
||||
}
|
||||
if (this.visible) {
|
||||
this.mapObject.addTo(parent);
|
||||
}
|
||||
},
|
||||
setVisible(newVal, oldVal) {
|
||||
if (newVal === oldVal) {
|
||||
return;
|
||||
}
|
||||
if (this.mapObject) {
|
||||
if (newVal) {
|
||||
this.mapObject.addTo(this.parent);
|
||||
} else {
|
||||
this.parent.removeLayer(this.mapObject);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
@ -1,38 +1,8 @@
|
||||
<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>
|
||||
<div id="map" class="fill-height fill-width">
|
||||
</div>
|
||||
|
||||
<template v-if="positionLatLng">
|
||||
<v-lts v-if="heading !== null" :lat-lng="positionLatLng" :heading="headingInRadiansFromNorth" :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
|
||||
@ -53,74 +23,82 @@
|
||||
</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';
|
||||
// TODO: Map going outside of container + on resize ?
|
||||
import 'ol/ol.css';
|
||||
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 } from 'ol/control';
|
||||
import { fromLonLat, toLonLat } from 'ol/proj';
|
||||
import {
|
||||
LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline,
|
||||
} from 'vue2-leaflet';
|
||||
Circle as CircleStyle, Fill, Icon, Stroke, Style, Text,
|
||||
} from 'ol/style';
|
||||
|
||||
import compassNorthIcon from '@/assets/compassNorth.svg';
|
||||
import unknownMarkerIcon from '@/assets/unknownMarker.svg';
|
||||
import * as constants from '@/constants';
|
||||
import { distance } from '@/tools';
|
||||
import LeafletTracksymbol from './LeafletTrackSymbol.vue';
|
||||
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,
|
||||
});
|
||||
const MAIN_VECTOR_LAYER_NAME = 'MAIN';
|
||||
const REPORTS_MARKERS_VECTOR_LAYER_NAME = 'REPORTS_MARKERS';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'v-lmap': LMap,
|
||||
'v-ltilelayer': LTileLayer,
|
||||
'v-lmarker': LMarker,
|
||||
'v-lcirclemarker': LCircleMarker,
|
||||
'v-lcircle': LCircle,
|
||||
'v-lpolyline': LPolyline,
|
||||
'v-lts': LeafletTracksymbol,
|
||||
ReportMarker,
|
||||
},
|
||||
computed: {
|
||||
headingInRadiansFromNorth() {
|
||||
if (this.heading !== null) {
|
||||
return this.heading * (Math.PI / 180); // in radians from North
|
||||
// in radians from North
|
||||
return 1.0 * this.heading * (Math.PI / 180);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
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() {
|
||||
if (this.accuracy) {
|
||||
// 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.
|
||||
return this.accuracy / (
|
||||
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;
|
||||
},
|
||||
shouldDisplayAccuracy() {
|
||||
// Only display accuracy if circle is large enough
|
||||
return (
|
||||
this.accuracy
|
||||
&& this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD
|
||||
&& this.radiusFromAccuracy > this.markerRadius
|
||||
);
|
||||
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);
|
||||
},
|
||||
@ -130,32 +108,132 @@ export default {
|
||||
return {
|
||||
attribution: $t('map.attribution'),
|
||||
isProgrammaticMove: false,
|
||||
isProgrammaticZoom: false,
|
||||
map: null,
|
||||
markerOptions: {
|
||||
fill: true,
|
||||
fillColor: '#00ff00',
|
||||
fillOpacity: 1.0,
|
||||
color: '#000000',
|
||||
opacity: 1.0,
|
||||
weight: 1,
|
||||
},
|
||||
markerRadius: 10.0,
|
||||
maxZoom: constants.MAX_ZOOM,
|
||||
minZoom: constants.MIN_ZOOM,
|
||||
isRecenterButtonShown: false,
|
||||
unknownMarkerIcon: L.icon({
|
||||
iconAnchor: [20, 40],
|
||||
iconSize: [40, 40],
|
||||
iconUrl: unknownMarkerIcon,
|
||||
}),
|
||||
// Variables for easy access to map feature and layers
|
||||
accuracyFeature: null,
|
||||
reportsMarkersFeatures: {},
|
||||
reportsMarkersVectorSource: null,
|
||||
mainVectorSource: null,
|
||||
polylineFeature: null,
|
||||
positionFeature: null,
|
||||
reportLatLngFeature: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleClick(event) {
|
||||
if (this.onPress) {
|
||||
this.onPress(event.latlng);
|
||||
setPositionFeatureStyle() {
|
||||
const positionFeatureStyle = this.positionFeature.getStyle();
|
||||
const rotation = this.headingInRadiansFromNorth - Math.PI / 2;
|
||||
|
||||
// If heading is specified
|
||||
if (this.headingInRadiansFromNorth !== null) {
|
||||
// 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,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
},
|
||||
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) {
|
||||
@ -163,49 +241,38 @@ export default {
|
||||
}
|
||||
},
|
||||
onMoveStart() {
|
||||
if (!this.isProgrammaticMove && !this.isProgrammaticZoom) {
|
||||
if (!this.isProgrammaticMove) {
|
||||
this.showRecenterButton();
|
||||
}
|
||||
},
|
||||
onMoveEnd() {
|
||||
const view = this.map.getView();
|
||||
if (this.onMapCenterUpdate) {
|
||||
const mapCenter = this.map.getCenter();
|
||||
this.onMapCenterUpdate([mapCenter.lat, mapCenter.lng]);
|
||||
const mapCenterLonLat = toLonLat(view.getCenter());
|
||||
this.onMapCenterUpdate([mapCenterLonLat[1], mapCenterLonLat[0]]);
|
||||
}
|
||||
if (this.onMapZoomUpdate) {
|
||||
this.onMapZoomUpdate(this.map.getZoom());
|
||||
}
|
||||
},
|
||||
onZoomStart() {
|
||||
if (!this.isProgrammaticZoom) {
|
||||
this.showRecenterButton();
|
||||
this.onMapZoomUpdate(view.getZoom());
|
||||
}
|
||||
},
|
||||
recenterMap() {
|
||||
const view = this.map.getView();
|
||||
const mapCenter = view.getCenter();
|
||||
|
||||
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]
|
||||
view.getZoom() !== this.zoom
|
||||
|| mapCenter[0] !== this.olCenter[0]
|
||||
|| mapCenter[1] !== this.olCenter[1]
|
||||
|| view.getRotation() !== 0
|
||||
) {
|
||||
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);
|
||||
view.setCenter(this.olCenter);
|
||||
view.setRotation(0);
|
||||
view.setZoom(this.zoom);
|
||||
},
|
||||
showRecenterButton() {
|
||||
if (!this.isRecenterButtonShown) {
|
||||
@ -214,21 +281,114 @@ export default {
|
||||
},
|
||||
},
|
||||
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]
|
||||
) {
|
||||
// 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.map.once('moveend', () => { this.isProgrammaticMove = false; });
|
||||
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 = 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,
|
||||
},
|
||||
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,
|
||||
}),
|
||||
});
|
||||
this.map.once('moveend', () => {
|
||||
this.isProgrammaticMove = false;
|
||||
|
||||
this.map.on('click', this.handleClick);
|
||||
// Take care that OpenLayer map actually catches "pointerdown"
|
||||
// events and not "click" events. Then, we need an explicit event
|
||||
// handler for "click" to stop propagation to ReportCard component.
|
||||
document.querySelector('#map').addEventListener(
|
||||
'click',
|
||||
event => event.stopPropagation(),
|
||||
);
|
||||
this.map.on('movestart', this.onMoveStart);
|
||||
this.map.on('moveend', this.onMoveEnd);
|
||||
});
|
||||
|
||||
// Set pointer to hover when hovering a report marker
|
||||
this.map.on('pointermove', (event) => {
|
||||
if (event.dragging) {
|
||||
return;
|
||||
}
|
||||
this.map.setView(this.center, this.zoom);
|
||||
this.showCompass();
|
||||
|
||||
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,
|
||||
@ -250,38 +410,55 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
zoom(newZoom) {
|
||||
if (!this.map) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
markers(newMarkers, oldMarkers) {
|
||||
// Map should have been created
|
||||
if (this.reportsMarkersVectorSource === null) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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));
|
||||
},
|
||||
center(newCenterLatLng) {
|
||||
olCenter(newOlCenter) {
|
||||
if (!this.map) {
|
||||
// Map should have been created
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.map.getView();
|
||||
if (!this.isRecenterButtonShown) {
|
||||
const currentCenter = view.getCenter();
|
||||
// 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]
|
||||
view.getZoom() !== this.zoom
|
||||
|| currentCenter[0] !== newOlCenter[0]
|
||||
|| currentCenter[1] !== newOlCenter[1]
|
||||
) {
|
||||
this.isProgrammaticMove = true;
|
||||
this.map.once('moveend', () => { this.isProgrammaticMove = false; });
|
||||
}
|
||||
|
||||
// Eventually display closest report
|
||||
const isReportDetailsAlreadyShown = this.$store.state.reportDetails.id;
|
||||
@ -291,7 +468,7 @@ export default {
|
||||
const distances = this.markers.map(
|
||||
marker => ({
|
||||
id: marker.id,
|
||||
distance: distance(newCenterLatLng, marker.latLng),
|
||||
distance: distance(this.center, marker.latLng),
|
||||
}),
|
||||
).filter(item => item.distance < constants.MIN_DISTANCE_REPORT_DETAILS);
|
||||
const closestReport = distances.reduce( // Get the closest one
|
||||
@ -307,36 +484,95 @@ export default {
|
||||
this.$store.dispatch('hideReportDetails');
|
||||
}
|
||||
}
|
||||
|
||||
// Update view
|
||||
view.setCenter(newOlCenter);
|
||||
view.setRotation(0);
|
||||
view.setZoom(this.zoom);
|
||||
}
|
||||
this.map.setView(newCenterLatLng, 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);
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
const view = this.map.getView();
|
||||
if (!this.isRecenterButtonShown) {
|
||||
// Handle programmatic navigation
|
||||
if (view.getZoom() !== newZoom) {
|
||||
this.isProgrammaticMove = true;
|
||||
this.map.once('moveend', () => { this.isProgrammaticMove = false; });
|
||||
}
|
||||
view.setZoom(newZoom);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<ReportErrorModal v-model="hasError"></ReportErrorModal>
|
||||
<v-bottom-sheet v-model="isActive">
|
||||
<v-bottom-sheet v-model="isActive" persistent id="reportCardSheet">
|
||||
<v-card>
|
||||
<v-container fluid>
|
||||
<v-layout row wrap>
|
||||
@ -84,6 +84,19 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.hideReportDialogOnEsc);
|
||||
|
||||
// Use persistent mode and recreate the clicking outside event handler
|
||||
// here as Vuetify uses capture mode which has some issues with
|
||||
// OpenLayers events.
|
||||
const app = document.querySelector('[data-app]') || document.body;
|
||||
app.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (this.isActive && event.target.closest('#reportCardSheet') === null) {
|
||||
this.isActive = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
props: {
|
||||
value: Boolean,
|
||||
|
@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<v-lmarker :lat-lng="marker.latLng" :icon="icon" @click="onClick"></v-lmarker>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import L from 'leaflet';
|
||||
import { LMarker } from 'vue2-leaflet';
|
||||
|
||||
import { REPORT_TYPES } from '@/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'v-lmarker': LMarker,
|
||||
},
|
||||
props: {
|
||||
marker: Object,
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.$store.state.reportDetails.id === this.marker.id) {
|
||||
return L.icon(REPORT_TYPES[this.marker.type].markerLarge);
|
||||
}
|
||||
return L.icon(REPORT_TYPES[this.marker.type].marker);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCard: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$store.dispatch('showReportDetails', { id: this.marker.id, userAsked: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
@ -13,98 +13,52 @@ import potholeIcon from '@/assets/pothole.svg';
|
||||
|
||||
export const VERSION = '0.1';
|
||||
|
||||
export const NORMAL_ICON_SCALE = 0.625;
|
||||
export const LARGE_ICON_SCALE = 1.0;
|
||||
export const ICON_ANCHOR = [0, 0.5];
|
||||
export const REPORT_TYPES = {
|
||||
accident: {
|
||||
description: 'reportLabels.accidentDescription',
|
||||
label: 'reportLabels.accident',
|
||||
image: accidentIcon,
|
||||
marker: {
|
||||
iconUrl: accidentMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: accidentMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
marker: accidentMarker,
|
||||
markerLarge: accidentMarker,
|
||||
|
||||
},
|
||||
gcum: {
|
||||
description: 'reportLabels.gcumDescription',
|
||||
label: 'reportLabels.gcum',
|
||||
image: gcumIcon,
|
||||
marker: {
|
||||
iconUrl: gcumMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: gcumMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
|
||||
marker: gcumMarker,
|
||||
markerLarge: gcumMarker,
|
||||
},
|
||||
interrupt: {
|
||||
description: 'reportLabels.interruptDescription',
|
||||
label: 'reportLabels.interrupt',
|
||||
image: interruptIcon,
|
||||
marker: {
|
||||
iconUrl: interruptMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: interruptMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
marker: interruptMarker,
|
||||
markerLarge: interruptMarker,
|
||||
},
|
||||
misc: {
|
||||
description: 'reportLabels.miscDescription',
|
||||
label: 'reportLabels.misc',
|
||||
image: miscIcon,
|
||||
marker: {
|
||||
iconUrl: miscMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: miscMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
marker: miscMarker,
|
||||
markerLarge: miscMarker,
|
||||
},
|
||||
obstacle: {
|
||||
description: 'reportLabels.obstacleDescription',
|
||||
label: 'reportLabels.obstacle',
|
||||
image: obstacleIcon,
|
||||
marker: {
|
||||
iconUrl: obstacleMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: obstacleMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
marker: obstacleMarker,
|
||||
markerLarge: obstacleMarker,
|
||||
},
|
||||
pothole: {
|
||||
description: 'reportLabels.potholeDescription',
|
||||
label: 'reportLabels.pothole',
|
||||
image: potholeIcon,
|
||||
marker: {
|
||||
iconUrl: potholeMarker,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
},
|
||||
markerLarge: {
|
||||
iconUrl: potholeMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
},
|
||||
marker: potholeMarker,
|
||||
markerLarge: potholeMarker,
|
||||
},
|
||||
};
|
||||
// Display order of the report types
|
||||
@ -112,7 +66,7 @@ export const REPORT_TYPES_ORDER = ['gcum', 'interrupt', 'obstacle', 'pothole', '
|
||||
|
||||
export const MIN_DISTANCE_REPORT_DETAILS = 40; // in meters
|
||||
|
||||
export const MOCK_LOCATION = false;
|
||||
export const MOCK_LOCATION = true;
|
||||
export const MOCK_LOCATION_USE_GPX = true;
|
||||
export const MOCK_LOCATION_GPX_PLAYBACK_SPEED = 2.0;
|
||||
export const MOCK_LOCATION_UPDATE_INTERVAL = 5 * 1000; // in milliseconds
|
||||
@ -141,13 +95,14 @@ export const MIN_ZOOM = 10;
|
||||
export const MAX_ZOOM = 18;
|
||||
|
||||
export const ACCURACY_DISPLAY_THRESHOLD = 100; // in meters
|
||||
export const POSITION_MARKER_RADIUS = 10; // in pixels
|
||||
|
||||
let opencyclemapURL = 'https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png';
|
||||
if (process.env.THUNDERFOREST_API_KEY) {
|
||||
opencyclemapURL += `?apikey=${process.env.THUNDERFOREST_API_KEY}`;
|
||||
}
|
||||
export const TILE_SERVERS = {
|
||||
'cartodb-voyager': 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
|
||||
'cartodb-voyager': 'https://{a-c}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
|
||||
opencyclemap: opencyclemapURL,
|
||||
};
|
||||
export const DEFAULT_TILE_SERVER = 'cartodb-voyager';
|
||||
|
@ -9,9 +9,11 @@ import {
|
||||
MOCK_LOCATION_LNG_MIN, MOCK_LOCATION_LNG_MAX,
|
||||
} from '@/constants';
|
||||
|
||||
let mockGPX = null;
|
||||
let mockGPX = [];
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
mockGPX = require('@/tools/mock_gpx.json'); // eslint-disable-line global-require
|
||||
// Use a node_modules require here, this is handled by Webpack to fetch either
|
||||
// a custom mock_gpx.json or a default empty one.
|
||||
mockGPX = require('mock_gpx.json'); // eslint-disable-line global-require
|
||||
}
|
||||
|
||||
export function distance(latLng1, latLng2) {
|
||||
@ -37,7 +39,7 @@ export function mockLocationRandom() {
|
||||
}
|
||||
const newLocation = {
|
||||
coords: {
|
||||
accuracy: 10, // In meters
|
||||
accuracy: Math.random() * 100, // In meters
|
||||
latitude: (
|
||||
(Math.random() * (MOCK_LOCATION_LAT_MAX - MOCK_LOCATION_LAT_MIN))
|
||||
+ MOCK_LOCATION_LAT_MIN
|
||||
@ -55,8 +57,9 @@ export function mockLocationRandom() {
|
||||
}
|
||||
|
||||
export function mockLocationWithGPX(index, setPosition) {
|
||||
if (mockGPX[index]) {
|
||||
setPosition(mockGPX[index]);
|
||||
if (index < mockGPX.length) {
|
||||
if (mockGPX[index + 1]) {
|
||||
const delay = (
|
||||
moment(mockGPX[index + 1].time).valueOf()
|
||||
- moment(mockGPX[index].time).valueOf()
|
||||
@ -66,12 +69,13 @@ export function mockLocationWithGPX(index, setPosition) {
|
||||
delay / MOCK_LOCATION_GPX_PLAYBACK_SPEED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mockLocation(setPosition) {
|
||||
if (MOCK_LOCATION_USE_GPX) {
|
||||
mockLocationWithGPX(0, setPosition);
|
||||
return null;
|
||||
return -1; // Return a fake setInterval id
|
||||
}
|
||||
setPosition(mockLocationRandom());
|
||||
return setInterval(
|
||||
|
@ -63,6 +63,7 @@ function handlePositionError(error) {
|
||||
function setPosition(position) {
|
||||
const lastLocation = store.getters.getLastLocation;
|
||||
if (lastLocation !== null) {
|
||||
// TODO: Should not be lastLocation
|
||||
const distanceFromPreviousPoint = distance(
|
||||
[lastLocation.latitude, lastLocation.longitude],
|
||||
[position.coords.latitude, position.coords.longitude],
|
||||
@ -90,12 +91,11 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
currentLatLng() {
|
||||
const currentLocation = this.$store.getters.getLastLocation;
|
||||
// Check that this is a correct position
|
||||
if (currentLocation === null) {
|
||||
if (this.currentLocation === null) {
|
||||
return null;
|
||||
}
|
||||
return [currentLocation.latitude, currentLocation.longitude];
|
||||
return [this.currentLocation.latitude, this.currentLocation.longitude];
|
||||
},
|
||||
currentLocation() {
|
||||
return this.$store.getters.getLastLocation || {};
|
||||
@ -202,7 +202,7 @@ export default {
|
||||
},
|
||||
showReportDialog(latlng) {
|
||||
if (latlng) {
|
||||
this.reportLatLng = [latlng.lat, latlng.lng];
|
||||
this.reportLatLng = [latlng[0], latlng[1]];
|
||||
} else {
|
||||
this.reportLatLng = this.currentLatLng;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user