diff --git a/src/App.vue b/src/App.vue index 95c7519..e261ff9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,13 +7,13 @@ - + more_vert - - {{ $t("menu.Map") }} + + {{ $t("menu.shareMapView") }} {{ $t("menu.About") }} @@ -23,35 +23,47 @@ + + arrow_back + - + + diff --git a/src/components/Map.vue b/src/components/Map.vue index ed735a7..332ba74 100644 --- a/src/components/Map.vue +++ b/src/components/Map.vue @@ -1,12 +1,33 @@ - + - - + + + + + + + - @@ -21,7 +42,7 @@ left color="blue" class="overlayButton" - v-if="recenterButton" + v-if="isRecenterButtonShown" @click.native.stop="recenterMap" role="button" :aria-label="$t('buttons.recenterMap')" @@ -56,21 +77,20 @@ export default { components: { ReportMarker, }, - props: { - accuracy: { - type: Number, - default: null, - }, - heading: Number, // in degrees, clockwise wrt north - markers: Array, - onPress: Function, - polyline: Array, - positionLatLng: Array, - reportLatLng: Array, - }, 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))) / @@ -80,19 +100,92 @@ export default { return null; }, shouldDisplayAccuracy() { + // Only display accuracy if circle is large enough return ( this.accuracy && - this.accuracy < 100 && + this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD && this.radiusFromAccuracy > this.markerRadius ); }, - markerOptions() { - return { - fillColor: '#00ff00', - color: '#000000', - heading: this.heading * (Math.PI / 180), - weight: 1, + }, + data() { + return { + attribution: this.$t('map.attribution'), + isProgrammaticMove: false, + isProgrammaticZoom: false, + map: null, + markerRadius: 10.0, + maxZoom: constants.MAX_ZOOM, + minZoom: constants.MIN_ZOOM, + isRecenterButtonShown: false, + tileServer: constants.TILE_SERVERS[this.$store.state.settings.tileServer], + 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 = ``; + L.DomEvent.disableClickPropagation(div); + return div; }; + this.map.addControl(north); + }, + showRecenterButton() { + if (!this.isRecenterButtonShown) { + this.isRecenterButtonShown = true; + } }, }, mounted() { @@ -101,31 +194,66 @@ export default { this.isProgrammaticZoom = true; this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); } + const mapCenter = this.map.getCenter(); if ( - this.map.getCenter().lat !== this.positionLatLng[0] && - this.map.getCenter().lng !== this.positionLatLng[1] + mapCenter.lat !== this.center[0] && + mapCenter.lng !== this.center[1] ) { this.isProgrammaticMove = true; this.map.once('moveend', () => { this.isProgrammaticMove = false; }); } - this.map.setView(this.positionLatLng, this.zoom); + 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: { - positionLatLng(newPositionLatLng) { + zoom(newZoom) { if (!this.map) { // Map should have been created return; } - if (!this.recenterButton) { + 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 !== newPositionLatLng[0] && - this.map.getCenter().lng !== newPositionLatLng[1] + this.map.getCenter().lat !== newCenterLatLng[0] && + this.map.getCenter().lng !== newCenterLatLng[1] ) { this.isProgrammaticMove = true; this.map.once('moveend', () => { this.isProgrammaticMove = false; }); @@ -138,7 +266,7 @@ export default { const distances = this.markers.map( marker => ({ id: marker.id, - distance: distance(newPositionLatLng, marker.latLng), + distance: distance(newCenterLatLng, marker.latLng), }), ).filter(item => item.distance < constants.MIN_DISTANCE_REPORT_DETAILS); const closestReport = distances.reduce( // Get the closest one @@ -155,82 +283,10 @@ export default { } } } - this.map.setView(newPositionLatLng, this.zoom); + this.map.setView(newCenterLatLng, this.zoom); } }, }, - data() { - return { - attribution: 'Map data © OpenStreetMap contributors', - zoom: constants.DEFAULT_ZOOM, - markerRadius: 10.0, - minZoom: constants.MIN_ZOOM, - maxZoom: constants.MAX_ZOOM, - tileServer: constants.TILE_SERVERS[this.$store.state.settings.tileServer], - isMouseDown: false, - isProgrammaticZoom: false, - isProgrammaticMove: false, - recenterButton: false, - map: null, - unknownMarkerIcon: L.icon({ - iconUrl: unknownMarkerIcon, - iconSize: [40, 40], - iconAnchor: [20, 40], - }), - }; - }, - methods: { - handleClick(event) { - if (this.onPress) { - this.onPress(event.latlng); - } - }, - onMoveStart() { - if (!this.isProgrammaticMove) { - this.showRecenterButton(); - } - }, - onZoomStart() { - if (!this.isProgrammaticZoom) { - this.showRecenterButton(); - } - }, - showCompass() { - const north = L.control({ position: 'topright' }); - north.onAdd = () => { - const div = L.DomUtil.create('div', 'compassIcon legend'); - div.innerHTML = ``; - L.DomEvent.disableClickPropagation(div); - return div; - }; - this.map.addControl(north); - }, - showRecenterButton() { - if (!this.recenterButton) { - this.recenterButton = true; - } - }, - hideRecenterButton() { - if (this.recenterButton) { - this.recenterButton = false; - } - }, - recenterMap() { - this.hideRecenterButton(); - if (this.map.getZoom() !== this.zoom) { - this.isProgrammaticZoom = true; - this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); - } - if ( - this.map.getCenter().lat !== this.positionLatLng[0] && - this.map.getCenter().lng !== this.positionLatLng[1] - ) { - this.isProgrammaticMove = true; - this.map.once('moveend', () => { this.isProgrammaticMove = false; }); - } - this.map.setView(this.positionLatLng, this.zoom); - }, - }, }; diff --git a/src/components/Modal.vue b/src/components/Modal.vue new file mode 100644 index 0000000..927b163 --- /dev/null +++ b/src/components/Modal.vue @@ -0,0 +1,42 @@ + + + + + + + diff --git a/src/components/ReportDialog/index.vue b/src/components/ReportDialog/index.vue index 1f291e2..6f4af7e 100644 --- a/src/components/ReportDialog/index.vue +++ b/src/components/ReportDialog/index.vue @@ -1,6 +1,6 @@ - + {{ $t('reportDialog.unableToSendTitle') }} @@ -22,7 +22,7 @@ - + @@ -38,16 +38,16 @@ diff --git a/src/components/ShareMapViewModal.vue b/src/components/ShareMapViewModal.vue new file mode 100644 index 0000000..748ca77 --- /dev/null +++ b/src/components/ShareMapViewModal.vue @@ -0,0 +1,84 @@ + + + + {{ $t('shareMapViewModal.shareCurrentMapView') }} + + + {{ $t('shareMapViewModal.copyURLToShareCurrentMapView') }} + + + + + + + + + {{ $t('misc.ok') }} + + + + + + + + + diff --git a/src/constants.js b/src/constants.js index 7bae26b..43b2b22 100644 --- a/src/constants.js +++ b/src/constants.js @@ -113,19 +113,21 @@ 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_UPDATE_INTERVAL = 5 * 1000; +export const MOCK_LOCATION_UPDATE_INTERVAL = 5 * 1000; // in milliseconds -export const UPDATE_REPORTS_DISTANCE_THRESHOLD = 500; +export const UPDATE_REPORTS_DISTANCE_THRESHOLD = 500; // in meters // Minimal ratio between upvotes and downvotes needed for a report to be shown export const REPORT_VOTES_THRESHOLD = 0.5; -export const EARTH_RADIUS = 6378137; +export const EARTH_RADIUS = 6378137; // in meters export const DEFAULT_ZOOM = 17; export const MIN_ZOOM = 10; export const MAX_ZOOM = 18; +export const ACCURACY_DISPLAY_THRESHOLD = 100; // in meters + let opencyclemapURL = 'https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png'; if (process.env.THUNDERFOREST_API_KEY) { opencyclemapURL += `?apikey=${process.env.THUNDERFOREST_API_KEY}`; diff --git a/src/i18n/en.json b/src/i18n/en.json index 9f827fe..7bf28fd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -8,6 +8,7 @@ "usageDescription": "Use the button in the lower right corner to add a new report at your current location. To add a report elsewhere, do a click where you want the report to be shown. Press on a marker on the map to display more informations and report the problem as being still there or solved." }, "buttons": { + "back": "Back", "close": "Close", "downvote": "Downvote", "menu": "Menu", @@ -36,13 +37,17 @@ "invalidSelection": "Invalid selection", "pickALocationManually": "pick a location manually" }, + "map": { + "attribution": "Map data © OpenStreetMap contributors" + }, "menu": { "About": "Help", - "Map": "Map", + "shareMapView": "Share map view", "Settings": "Settings" }, "misc": { "discard": "Discard", + "ok": "OK", "or": "or", "retry": "Retry", "spaceBeforeDoublePunctuations": "" @@ -74,5 +79,10 @@ "save": "Save", "skipOnboarding": "Skip onboarding", "tileServer": "Map tiles server" + }, + "shareMapViewModal": { + "copiedToClipboard": "Copied to clipboard!", + "copyURLToShareCurrentMapView": "Copy the URL below to share the current map view.", + "shareCurrentMapView": "Share current map view" } } diff --git a/src/router/index.js b/src/router/index.js index 399a5d6..ea409ce 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Router from 'vue-router'; -import About from '@/components/About.vue'; +import About from '@/views/About.vue'; import Map from '@/views/Map.vue'; import Onboarding from '@/views/Onboarding.vue'; import Settings from '@/views/Settings.vue'; @@ -14,6 +14,11 @@ export default new Router({ name: 'About', component: About, }, + { + path: '/map=:zoom/:lat/:lng', + name: 'SharedMap', + component: Map, + }, { path: '/map', name: 'Map', diff --git a/src/store/actions.js b/src/store/actions.js index 43660bd..1dacbae 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -1,13 +1,19 @@ import moment from 'moment'; import * as api from '@/api'; +import * as constants from '@/constants'; import i18n from '@/i18n'; import { INTRO_WAS_SEEN, - IS_LOADING, IS_DONE_LOADING, + IS_LOADING, PUSH_REPORT, + SET_CURRENT_MAP_CENTER, + SET_CURRENT_MAP_ZOOM, + SET_CURRENT_POSITION, + SET_LOCATION_ERROR, + SET_LOCATION_WATCHER_ID, SET_SETTING, SHOW_REPORT_DETAILS, STORE_REPORTS, @@ -61,3 +67,49 @@ export function setSetting({ commit }, { setting, value }) { export function markIntroAsSeen({ commit }) { return commit(INTRO_WAS_SEEN); } + +export function setCurrentMapCenter({ commit, state }, { center }) { + if (state.map.center.some((item, index) => item !== center[index])) { + commit(SET_CURRENT_MAP_CENTER, { center }); + } +} + +export function setCurrentMapZoom({ commit, state }, { zoom }) { + if (state.map.zoom !== zoom) { + commit(SET_CURRENT_MAP_ZOOM, { zoom }); + } +} + +export function setCurrentPosition( + { commit, state }, + { accuracy = null, heading = null, latLng = null }, +) { + const locationState = state.location; + if ( + accuracy !== locationState.accuracy || + heading !== locationState.heading || + locationState.currentLatLng.some((item, index) => item !== latLng[index]) + ) { + // Throttle mutations if nothing has changed + commit(SET_CURRENT_POSITION, { accuracy, heading, latLng }); + } +} + +export function setLocationWatcherId({ commit }, { id }) { + return commit(SET_LOCATION_WATCHER_ID, { id }); +} + +export function setLocationError({ commit, state }, { error }) { + // Unregister location watcher + const watcherID = state.location.watcherID; + if (watcherID !== null) { + if (constants.MOCK_LOCATION) { + clearInterval(watcherID); + } else { + navigator.geolocation.clearWatch(watcherID); + } + } + + commit(SET_LOCATION_WATCHER_ID, { id: null }); + commit(SET_LOCATION_ERROR, { error }); +} diff --git a/src/store/mutations-types.js b/src/store/mutations-types.js index 8503b60..066ba13 100644 --- a/src/store/mutations-types.js +++ b/src/store/mutations-types.js @@ -1,7 +1,12 @@ export const INTRO_WAS_SEEN = 'INTRO_WAS_SEEN'; -export const IS_LOADING = 'IS_LOADING'; export const IS_DONE_LOADING = 'IS_DONE_LOADING'; +export const IS_LOADING = 'IS_LOADING'; export const PUSH_REPORT = 'PUSH_REPORT'; +export const SET_CURRENT_MAP_CENTER = 'SET_CURRENT_MAP_CENTER'; +export const SET_CURRENT_MAP_ZOOM = 'SET_CURRENT_MAP_ZOOM'; +export const SET_CURRENT_POSITION = 'SET_CURRENT_POSITION'; +export const SET_LOCATION_ERROR = 'SET_LOCATION_ERROR'; +export const SET_LOCATION_WATCHER_ID = 'SET_LOCATION_WATCHER_ID'; export const SET_SETTING = 'SET_SETTING'; export const SHOW_REPORT_DETAILS = 'SHOW_REPORT_DETAILS'; export const STORE_REPORTS = 'STORE_REPORTS'; diff --git a/src/store/mutations.js b/src/store/mutations.js index e91cdb6..a97df3f 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -54,6 +54,18 @@ if (storageAvailable('localStorage')) { export const initialState = { hasGoneThroughIntro: false, isLoading: false, + location: { + accuracy: null, + currentLatLng: [null, null], + error: null, + heading: null, // in degrees, clockwise wrt north + positionHistory: [], + watcherID: null, + }, + map: { + center: [null, null], + zoom: null, + }, reportDetails: { id: null, userAsked: null, @@ -71,11 +83,37 @@ export const mutations = { [types.INTRO_WAS_SEEN](state) { state.hasGoneThroughIntro = true; }, + [types.IS_DONE_LOADING](state) { + state.isLoading = false; + }, [types.IS_LOADING](state) { state.isLoading = true; }, - [types.IS_DONE_LOADING](state) { - state.isLoading = false; + [types.PUSH_REPORT](state, { report }) { + const reportIndex = state.reports.findIndex(item => item.id === report.id); + if (reportIndex === -1) { + state.reports.push(report); + } else { + Vue.set(state.reports, reportIndex, report); + } + }, + [types.SET_CURRENT_MAP_CENTER](state, { center }) { + Vue.set(state.map, 'center', center); + }, + [types.SET_CURRENT_MAP_ZOOM](state, { zoom }) { + Vue.set(state.map, 'zoom', zoom); + }, + [types.SET_CURRENT_POSITION](state, { accuracy, heading, latLng }) { + Vue.set(state.location, 'accuracy', accuracy); + Vue.set(state.location, 'currentLatLng', latLng); + Vue.set(state.location, 'heading', heading); + state.location.positionHistory.push(latLng); + }, + [types.SET_LOCATION_ERROR](state, { error }) { + Vue.set(state.location, 'error', error); + }, + [types.SET_LOCATION_WATCHER_ID](state, { id }) { + Vue.set(state.location, 'watcherID', id); }, [types.SET_SETTING](state, { setting, value }) { if (storageAvailable('localStorage')) { @@ -90,12 +128,4 @@ export const mutations = { [types.STORE_REPORTS](state, { reports }) { state.reports = reports; }, - [types.PUSH_REPORT](state, { report }) { - const reportIndex = state.reports.findIndex(item => item.id === report.id); - if (reportIndex === -1) { - state.reports.push(report); - } else { - Vue.set(state.reports, reportIndex, report); - } - }, }; diff --git a/src/components/About.vue b/src/views/About.vue similarity index 100% rename from src/components/About.vue rename to src/views/About.vue diff --git a/src/views/Map.vue b/src/views/Map.vue index dbd208f..f283b0d 100644 --- a/src/views/Map.vue +++ b/src/views/Map.vue @@ -2,8 +2,20 @@ - - + + showReportDialog()" role="button" :aria-label="$t('buttons.reportProblem')" + v-if="!hasCenterProvidedByRoute" > report_problem - + - - {{ error }} - - Retry - - {{ $t('misc.or') }} - - - - - - {{ $t('geolocation.fetching') }} - + + {{ $t('geolocation.fetching') }} @@ -43,35 +45,117 @@
{{ error }}
- Retry -
{{ $t('misc.or') }}
- -
{{ $t('geolocation.fetching') }}