Move map from Leaflet to OpenLayers

Also add a way to use a mock GPX trace as geolocation provider.
This commit is contained in:
Lucas Verney 2018-08-14 15:33:23 +02:00
parent 316527b575
commit 60f041f5a6
15 changed files with 1522 additions and 438 deletions

3
.gitignore vendored
View File

@ -9,7 +9,8 @@ yarn-error.log*
.zanata-cache .zanata-cache
po/*.po po/*.po
po/*.pot po/*.pot
tests/*.gpx tests/gpx/*
tests/mock_gpx.json
# Editor directories and files # Editor directories and files
.idea .idea

View File

@ -172,6 +172,8 @@ Icons are made from the original works:
licensed under CC BY-SA on Wikimedia. licensed under CC BY-SA on Wikimedia.
* [Pothole icon](https://commons.wikimedia.org/wiki/File:France_road_sign_A2a.svg) * [Pothole icon](https://commons.wikimedia.org/wiki/File:France_road_sign_A2a.svg)
licensed under CC BY-SA on Wikimedia. 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) * [Work icons](https://www.vecteezy.com/vector-art/87351-road-traffic-cartoon-icons-vector)
were designed by Vecteezy. were designed by Vecteezy.
* [Trash icon](https://pixabay.com/en/trash-waste-trashcan-garbage-99257/) is * [Trash icon](https://pixabay.com/en/trash-waste-trashcan-garbage-99257/) is

View File

@ -44,10 +44,12 @@ module.exports = {
}, },
}, },
resolve: { resolve: {
extensions: ['.js', '.vue', '.json'],
alias: { alias: {
'@': utils.resolve('src'), '@': utils.resolve('src'),
} },
extensions: ['.js', '.vue', '.json'],
// Load mock_gpx.json from tests first, tests/default then
modules: ['tests/', 'tests/default', 'node_modules'],
}, },
stats: { stats: {
children: false, children: false,

View File

@ -18,16 +18,15 @@
"file-saver": "^1.3.8", "file-saver": "^1.3.8",
"gps-to-gpx": "^1.4.0", "gps-to-gpx": "^1.4.0",
"howler": "^2.0.14", "howler": "^2.0.14",
"leaflet": "^1.3.1",
"leaflet-tracksymbol": "^1.0.8",
"material-icons": "^0.2.3", "material-icons": "^0.2.3",
"moment": "^2.22.2", "moment": "^2.22.2",
"nosleep.js": "^0.7.0", "nosleep.js": "^0.7.0",
"ol": "^5.1.3",
"roboto-fontface": "^0.9.0", "roboto-fontface": "^0.9.0",
"vue": "^2.5.2", "vue": "^2.5.2",
"vue-i18n": "^8.0.0", "vue-i18n": "^8.0.0",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue2-leaflet": "^1.0.2", "vuelayers": "^0.10.13",
"vuetify": "^1.1.11", "vuetify": "^1.1.11",
"vuex": "^3.0.1", "vuex": "^3.0.1",
"whatwg-fetch": "^2.0.4" "whatwg-fetch": "^2.0.4"

6
scripts/gps_to_js.py Normal file → Executable file
View File

@ -1,3 +1,4 @@
#!/usr/bin/env python
import json import json
import os import os
import sys import sys
@ -15,13 +16,12 @@ if __name__ == "__main__":
for track in gpx.tracks: for track in gpx.tracks:
for segment in track.segments: for segment in track.segments:
for point in segment.points: for point in segment.points:
# TODO: Other fields
json_out.append({ json_out.append({
'time': point.time.isoformat(), 'time': point.time.isoformat(),
'coords': { 'coords': {
'accuracy': point.horizontal_dilution, 'accuracy': point.horizontal_dilution,
'altitudeAccuracy': point.vertical_dilution, 'altitudeAccuracy': point.vertical_dilution,
'heading': None, 'heading': point.course,
'latitude': point.latitude, 'latitude': point.latitude,
'longitude': point.longitude 'longitude': point.longitude
} }
@ -30,5 +30,5 @@ if __name__ == "__main__":
break break
script_dir = os.path.dirname(os.path.realpath(__file__)) 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) json.dump(json_out, fh)

View File

@ -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>

View File

@ -1,38 +1,8 @@
<template> <template>
<div class="fill-height fill-width"> <div class="fill-height fill-width">
<v-lmap <div id="map" class="fill-height fill-width">
ref="map" </div>
: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" :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 <v-btn
absolute absolute
dark dark
@ -53,74 +23,82 @@
</template> </template>
<script> <script>
import L from 'leaflet'; // TODO: Map going outside of container + on resize ?
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; import 'ol/ol.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import Feature from 'ol/Feature';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; 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 { import {
LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline, Circle as CircleStyle, Fill, Icon, Stroke, Style, Text,
} from 'vue2-leaflet'; } from 'ol/style';
import compassNorthIcon from '@/assets/compassNorth.svg'; import compassNorthIcon from '@/assets/compassNorth.svg';
import unknownMarkerIcon from '@/assets/unknownMarker.svg'; import unknownMarkerIcon from '@/assets/unknownMarker.svg';
import * as constants from '@/constants'; import * as constants from '@/constants';
import { distance } from '@/tools'; import { distance } from '@/tools';
import LeafletTracksymbol from './LeafletTrackSymbol.vue';
import ReportMarker from './ReportMarker.vue';
// Fix for a bug in Leaflet default icon const MAIN_VECTOR_LAYER_NAME = 'MAIN';
// see https://github.com/PaulLeCam/react-leaflet/issues/255#issuecomment-261904061 const REPORTS_MARKERS_VECTOR_LAYER_NAME = 'REPORTS_MARKERS';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl,
iconUrl,
shadowUrl,
});
export default { 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: { computed: {
headingInRadiansFromNorth() { headingInRadiansFromNorth() {
if (this.heading !== null) { 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; return null;
}, },
radiusFromAccuracy() { radiusFromAccuracy() {
if (this.accuracy) {
// Compute the radius (in pixels) based on GPS accuracy, taking // Compute the radius (in pixels) based on GPS accuracy, taking
// into account the current zoom level // 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. // 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] (constants.EARTH_RADIUS * 2 * Math.PI * Math.cos(this.positionLatLng[0]
* (Math.PI / 180))) * (Math.PI / 180)))
/ (2 ** (this.zoom + 8)) / (2 ** (this.zoom + 8))
); );
if (accuracyInPixels > constants.POSITION_MARKER_RADIUS) {
return accuracyInPixels;
}
} }
return null; return null;
}, },
shouldDisplayAccuracy() { reportDetailsID() {
// Only display accuracy if circle is large enough // Get the currently shown report details ID
return ( return this.$store.state.reportDetails.id;
this.accuracy
&& this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD
&& this.radiusFromAccuracy > this.markerRadius
);
}, },
tileServer() { tileServer() {
const tileServerSetting = this.$store.state.settings.tileServer; const tileServerSetting = this.$store.state.settings.tileServer;
if (tileServerSetting in constants.TILE_SERVERS) { if (tileServerSetting in constants.TILE_SERVERS) {
return constants.TILE_SERVERS[tileServerSetting]; return constants.TILE_SERVERS[tileServerSetting];
} }
// Remove the protocol part, avoid easily avoidable unsecured
// content over HTTPS.
const firstColon = tileServerSetting.indexOf(':'); const firstColon = tileServerSetting.indexOf(':');
return tileServerSetting.substring(firstColon + 1); return tileServerSetting.substring(firstColon + 1);
}, },
@ -130,32 +108,132 @@ export default {
return { return {
attribution: $t('map.attribution'), attribution: $t('map.attribution'),
isProgrammaticMove: false, isProgrammaticMove: false,
isProgrammaticZoom: false,
map: null, map: null,
markerOptions: {
fill: true,
fillColor: '#00ff00',
fillOpacity: 1.0,
color: '#000000',
opacity: 1.0,
weight: 1,
},
markerRadius: 10.0,
maxZoom: constants.MAX_ZOOM, maxZoom: constants.MAX_ZOOM,
minZoom: constants.MIN_ZOOM, minZoom: constants.MIN_ZOOM,
isRecenterButtonShown: false, isRecenterButtonShown: false,
unknownMarkerIcon: L.icon({ // Variables for easy access to map feature and layers
iconAnchor: [20, 40], accuracyFeature: null,
iconSize: [40, 40], reportsMarkersFeatures: {},
iconUrl: unknownMarkerIcon, reportsMarkersVectorSource: null,
}), mainVectorSource: null,
polylineFeature: null,
positionFeature: null,
reportLatLngFeature: null,
}; };
}, },
methods: { methods: {
handleClick(event) { setPositionFeatureStyle() {
if (this.onPress) { const positionFeatureStyle = this.positionFeature.getStyle();
this.onPress(event.latlng); 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() { hideRecenterButton() {
if (this.isRecenterButtonShown) { if (this.isRecenterButtonShown) {
@ -163,49 +241,38 @@ export default {
} }
}, },
onMoveStart() { onMoveStart() {
if (!this.isProgrammaticMove && !this.isProgrammaticZoom) { if (!this.isProgrammaticMove) {
this.showRecenterButton(); this.showRecenterButton();
} }
}, },
onMoveEnd() { onMoveEnd() {
const view = this.map.getView();
if (this.onMapCenterUpdate) { if (this.onMapCenterUpdate) {
const mapCenter = this.map.getCenter(); const mapCenterLonLat = toLonLat(view.getCenter());
this.onMapCenterUpdate([mapCenter.lat, mapCenter.lng]); this.onMapCenterUpdate([mapCenterLonLat[1], mapCenterLonLat[0]]);
} }
if (this.onMapZoomUpdate) { if (this.onMapZoomUpdate) {
this.onMapZoomUpdate(this.map.getZoom()); this.onMapZoomUpdate(view.getZoom());
}
},
onZoomStart() {
if (!this.isProgrammaticZoom) {
this.showRecenterButton();
} }
}, },
recenterMap() { recenterMap() {
const view = this.map.getView();
const mapCenter = view.getCenter();
this.hideRecenterButton(); this.hideRecenterButton();
if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
const mapCenter = this.map.getCenter();
if ( if (
mapCenter.lat !== this.center[0] view.getZoom() !== this.zoom
&& mapCenter.lng !== this.center[1] || mapCenter[0] !== this.olCenter[0]
|| mapCenter[1] !== this.olCenter[1]
|| view.getRotation() !== 0
) { ) {
this.isProgrammaticMove = true; this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; }); this.map.once('moveend', () => { this.isProgrammaticMove = false; });
} }
this.map.setView(this.center, this.zoom); view.setCenter(this.olCenter);
}, view.setRotation(0);
showCompass() { view.setZoom(this.zoom);
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() { showRecenterButton() {
if (!this.isRecenterButtonShown) { if (!this.isRecenterButtonShown) {
@ -214,21 +281,114 @@ export default {
}, },
}, },
mounted() { mounted() {
this.map = this.$refs.map.mapObject; // Create accuracy circle feature
if (this.map.getZoom() !== this.zoom) { this.accuracyFeature = new Feature({
this.isProgrammaticZoom = true; geometry: this.olPosition ? new Point(this.olPosition) : null,
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); });
} this.accuracyFeature.setStyle(new Style({
const mapCenter = this.map.getCenter(); image: new CircleStyle({
if ( radius: this.radiusFromAccuracy,
mapCenter.lat !== this.center[0] fill: new Fill({
&& mapCenter.lng !== this.center[1] 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.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: { props: {
accuracy: Number, accuracy: Number,
@ -250,38 +410,55 @@ export default {
}, },
}, },
watch: { watch: {
zoom(newZoom) { reportDetailsID(newID, oldID) {
if (!this.map) { [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 // Map should have been created
if (this.reportsMarkersVectorSource === null) {
return; return;
} }
if (!this.isRecenterButtonShown) {
// Handle programmatic navigation // Compute the diff between old and new marker arrays, to determine
if (this.map.getZoom() !== newZoom) { // added and removed markers
this.isProgrammaticZoom = true; const oldIDs = new Set(oldMarkers.map(item => item.id));
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); const newIDs = new Set(newMarkers.map(item => item.id));
}
this.map.setZoom(newZoom); // 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) { if (!this.map) {
// Map should have been created // Map should have been created
return; return;
} }
const view = this.map.getView();
if (!this.isRecenterButtonShown) { if (!this.isRecenterButtonShown) {
const currentCenter = view.getCenter();
// Handle programmatic navigation // Handle programmatic navigation
if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
}
if ( if (
this.map.getCenter().lat !== newCenterLatLng[0] view.getZoom() !== this.zoom
&& this.map.getCenter().lng !== newCenterLatLng[1] || currentCenter[0] !== newOlCenter[0]
|| currentCenter[1] !== newOlCenter[1]
) { ) {
this.isProgrammaticMove = true; this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; }); this.map.once('moveend', () => { this.isProgrammaticMove = false; });
}
// Eventually display closest report // Eventually display closest report
const isReportDetailsAlreadyShown = this.$store.state.reportDetails.id; const isReportDetailsAlreadyShown = this.$store.state.reportDetails.id;
@ -291,7 +468,7 @@ export default {
const distances = this.markers.map( const distances = this.markers.map(
marker => ({ marker => ({
id: marker.id, id: marker.id,
distance: distance(newCenterLatLng, marker.latLng), distance: distance(this.center, marker.latLng),
}), }),
).filter(item => item.distance < constants.MIN_DISTANCE_REPORT_DETAILS); ).filter(item => item.distance < constants.MIN_DISTANCE_REPORT_DETAILS);
const closestReport = distances.reduce( // Get the closest one const closestReport = distances.reduce( // Get the closest one
@ -307,36 +484,95 @@ export default {
this.$store.dispatch('hideReportDetails'); 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> </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> <style scoped>
.fill-width { .fill-width {
width: 100%; width: 100%;
} }
</style> </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>

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<ReportErrorModal v-model="hasError"></ReportErrorModal> <ReportErrorModal v-model="hasError"></ReportErrorModal>
<v-bottom-sheet v-model="isActive"> <v-bottom-sheet v-model="isActive" persistent id="reportCardSheet">
<v-card> <v-card>
<v-container fluid> <v-container fluid>
<v-layout row wrap> <v-layout row wrap>
@ -84,6 +84,19 @@ export default {
}, },
mounted() { mounted() {
window.addEventListener('keydown', this.hideReportDialogOnEsc); 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: { props: {
value: Boolean, value: Boolean,

View File

@ -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>

View File

@ -13,98 +13,52 @@ import potholeIcon from '@/assets/pothole.svg';
export const VERSION = '0.1'; 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 = { export const REPORT_TYPES = {
accident: { accident: {
description: 'reportLabels.accidentDescription', description: 'reportLabels.accidentDescription',
label: 'reportLabels.accident', label: 'reportLabels.accident',
image: accidentIcon, image: accidentIcon,
marker: { marker: accidentMarker,
iconUrl: accidentMarker, markerLarge: accidentMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: accidentMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
gcum: { gcum: {
description: 'reportLabels.gcumDescription', description: 'reportLabels.gcumDescription',
label: 'reportLabels.gcum', label: 'reportLabels.gcum',
image: gcumIcon, image: gcumIcon,
marker: { marker: gcumMarker,
iconUrl: gcumMarker, markerLarge: gcumMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: gcumMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
interrupt: { interrupt: {
description: 'reportLabels.interruptDescription', description: 'reportLabels.interruptDescription',
label: 'reportLabels.interrupt', label: 'reportLabels.interrupt',
image: interruptIcon, image: interruptIcon,
marker: { marker: interruptMarker,
iconUrl: interruptMarker, markerLarge: interruptMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: interruptMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
misc: { misc: {
description: 'reportLabels.miscDescription', description: 'reportLabels.miscDescription',
label: 'reportLabels.misc', label: 'reportLabels.misc',
image: miscIcon, image: miscIcon,
marker: { marker: miscMarker,
iconUrl: miscMarker, markerLarge: miscMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: miscMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
obstacle: { obstacle: {
description: 'reportLabels.obstacleDescription', description: 'reportLabels.obstacleDescription',
label: 'reportLabels.obstacle', label: 'reportLabels.obstacle',
image: obstacleIcon, image: obstacleIcon,
marker: { marker: obstacleMarker,
iconUrl: obstacleMarker, markerLarge: obstacleMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: obstacleMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
pothole: { pothole: {
description: 'reportLabels.potholeDescription', description: 'reportLabels.potholeDescription',
label: 'reportLabels.pothole', label: 'reportLabels.pothole',
image: potholeIcon, image: potholeIcon,
marker: { marker: potholeMarker,
iconUrl: potholeMarker, markerLarge: potholeMarker,
iconSize: [40, 40],
iconAnchor: [20, 40],
},
markerLarge: {
iconUrl: potholeMarker,
iconSize: [60, 60],
iconAnchor: [30, 60],
},
}, },
}; };
// Display order of the report types // 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 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_USE_GPX = true;
export const MOCK_LOCATION_GPX_PLAYBACK_SPEED = 2.0; export const MOCK_LOCATION_GPX_PLAYBACK_SPEED = 2.0;
export const MOCK_LOCATION_UPDATE_INTERVAL = 5 * 1000; // in milliseconds 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 MAX_ZOOM = 18;
export const ACCURACY_DISPLAY_THRESHOLD = 100; // in meters 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'; let opencyclemapURL = 'https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png';
if (process.env.THUNDERFOREST_API_KEY) { if (process.env.THUNDERFOREST_API_KEY) {
opencyclemapURL += `?apikey=${process.env.THUNDERFOREST_API_KEY}`; opencyclemapURL += `?apikey=${process.env.THUNDERFOREST_API_KEY}`;
} }
export const TILE_SERVERS = { 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, opencyclemap: opencyclemapURL,
}; };
export const DEFAULT_TILE_SERVER = 'cartodb-voyager'; export const DEFAULT_TILE_SERVER = 'cartodb-voyager';

View File

@ -9,9 +9,11 @@ import {
MOCK_LOCATION_LNG_MIN, MOCK_LOCATION_LNG_MAX, MOCK_LOCATION_LNG_MIN, MOCK_LOCATION_LNG_MAX,
} from '@/constants'; } from '@/constants';
let mockGPX = null; let mockGPX = [];
if (process.env.NODE_ENV !== 'production') { 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) { export function distance(latLng1, latLng2) {
@ -37,7 +39,7 @@ export function mockLocationRandom() {
} }
const newLocation = { const newLocation = {
coords: { coords: {
accuracy: 10, // In meters accuracy: Math.random() * 100, // In meters
latitude: ( latitude: (
(Math.random() * (MOCK_LOCATION_LAT_MAX - MOCK_LOCATION_LAT_MIN)) (Math.random() * (MOCK_LOCATION_LAT_MAX - MOCK_LOCATION_LAT_MIN))
+ MOCK_LOCATION_LAT_MIN + MOCK_LOCATION_LAT_MIN
@ -55,8 +57,9 @@ export function mockLocationRandom() {
} }
export function mockLocationWithGPX(index, setPosition) { export function mockLocationWithGPX(index, setPosition) {
if (mockGPX[index]) {
setPosition(mockGPX[index]); setPosition(mockGPX[index]);
if (index < mockGPX.length) { if (mockGPX[index + 1]) {
const delay = ( const delay = (
moment(mockGPX[index + 1].time).valueOf() moment(mockGPX[index + 1].time).valueOf()
- moment(mockGPX[index].time).valueOf() - moment(mockGPX[index].time).valueOf()
@ -66,12 +69,13 @@ export function mockLocationWithGPX(index, setPosition) {
delay / MOCK_LOCATION_GPX_PLAYBACK_SPEED, delay / MOCK_LOCATION_GPX_PLAYBACK_SPEED,
); );
} }
}
} }
export function mockLocation(setPosition) { export function mockLocation(setPosition) {
if (MOCK_LOCATION_USE_GPX) { if (MOCK_LOCATION_USE_GPX) {
mockLocationWithGPX(0, setPosition); mockLocationWithGPX(0, setPosition);
return null; return -1; // Return a fake setInterval id
} }
setPosition(mockLocationRandom()); setPosition(mockLocationRandom());
return setInterval( return setInterval(

View File

@ -63,6 +63,7 @@ function handlePositionError(error) {
function setPosition(position) { function setPosition(position) {
const lastLocation = store.getters.getLastLocation; const lastLocation = store.getters.getLastLocation;
if (lastLocation !== null) { if (lastLocation !== null) {
// TODO: Should not be lastLocation
const distanceFromPreviousPoint = distance( const distanceFromPreviousPoint = distance(
[lastLocation.latitude, lastLocation.longitude], [lastLocation.latitude, lastLocation.longitude],
[position.coords.latitude, position.coords.longitude], [position.coords.latitude, position.coords.longitude],
@ -90,12 +91,11 @@ export default {
}, },
computed: { computed: {
currentLatLng() { currentLatLng() {
const currentLocation = this.$store.getters.getLastLocation;
// Check that this is a correct position // Check that this is a correct position
if (currentLocation === null) { if (this.currentLocation === null) {
return null; return null;
} }
return [currentLocation.latitude, currentLocation.longitude]; return [this.currentLocation.latitude, this.currentLocation.longitude];
}, },
currentLocation() { currentLocation() {
return this.$store.getters.getLastLocation || {}; return this.$store.getters.getLastLocation || {};
@ -202,7 +202,7 @@ export default {
}, },
showReportDialog(latlng) { showReportDialog(latlng) {
if (latlng) { if (latlng) {
this.reportLatLng = [latlng.lat, latlng.lng]; this.reportLatLng = [latlng[0], latlng[1]];
} else { } else {
this.reportLatLng = this.currentLatLng; this.reportLatLng = this.currentLatLng;
} }

1062
yarn.lock

File diff suppressed because it is too large Load Diff