Add a way to share a specific map position

Also rework the way Map component is handled to make it cleaner and more
efficient.

Fix for issue #23.
This commit is contained in:
Lucas Verney 2018-07-21 20:00:37 +02:00
parent 3e8ec40330
commit 74a42abe72
14 changed files with 664 additions and 273 deletions

View File

@ -7,13 +7,13 @@
<v-toolbar-title v-text="title" class="ma-0"></v-toolbar-title> <v-toolbar-title v-text="title" class="ma-0"></v-toolbar-title>
</router-link> </router-link>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-menu offset-y class="menu"> <v-menu offset-y class="menu" v-if="$route.name === 'Onboarding' || $route.name === 'Map' || $route.name === 'SharedMap'">
<v-btn slot="activator" icon role="button" :aria-label="$t('buttons.menu')"> <v-btn slot="activator" icon role="button" :aria-label="$t('buttons.menu')">
<v-icon>more_vert</v-icon> <v-icon>more_vert</v-icon>
</v-btn> </v-btn>
<v-list> <v-list>
<v-list-tile @click="goToMap"> <v-list-tile @click="isShareMapViewModalShown = true" v-if="isShareMapViewMenuEntryVisible">
<v-list-tile-title>{{ $t("menu.Map") }}</v-list-tile-title> <v-list-tile-title>{{ $t("menu.shareMapView") }}</v-list-tile-title>
</v-list-tile> </v-list-tile>
<v-list-tile @click="goToAbout"> <v-list-tile @click="goToAbout">
<v-list-tile-title>{{ $t("menu.About") }}</v-list-tile-title> <v-list-tile-title>{{ $t("menu.About") }}</v-list-tile-title>
@ -23,35 +23,47 @@
</v-list-tile> </v-list-tile>
</v-list> </v-list>
</v-menu> </v-menu>
<v-btn icon role="button" :aria-label="$t('buttons.back')" v-else @click="goBack">
<v-icon>arrow_back</v-icon>
</v-btn>
<div> <div>
<v-progress-linear v-if="isLoading" :indeterminate="true" class="progressBar"></v-progress-linear> <v-progress-linear v-if="isLoading" :indeterminate="true" class="progressBar"></v-progress-linear>
</div> </div>
</v-toolbar> </v-toolbar>
<v-content> <v-content>
<router-view/> <ShareMapViewModal v-model="isShareMapViewModalShown"></ShareMapViewModal>
<router-view></router-view>
</v-content> </v-content>
</v-app> </v-app>
</template> </template>
<script> <script>
import ShareMapViewModal from '@/components/ShareMapViewModal.vue';
export default { export default {
components: {
ShareMapViewModal,
},
computed: { computed: {
isLoading() { isLoading() {
return this.$store.state.isLoading; return this.$store.state.isLoading;
}, },
isShareMapViewMenuEntryVisible() {
return this.$store.state.map.center.every(item => item !== null);
},
}, },
data() { data() {
return { return {
title: 'Cycl\'Assist', isShareMapViewModalShown: false,
title: "Cycl'Assist",
}; };
}, },
name: 'App',
methods: { methods: {
goToAbout() { goToAbout() {
this.$router.push({ name: 'About' }); this.$router.push({ name: 'About' });
}, },
goToMap() { goBack() {
this.$router.push({ name: 'Map' }); this.$router.go(-1);
}, },
goToSettings() { goToSettings() {
this.$router.push({ name: 'Settings' }); this.$router.push({ name: 'Settings' });

View File

@ -0,0 +1,37 @@
<template>
<div>
<p class="text-xs-center">{{ error }}</p>
<p class="text-xs-center">
<v-btn role="button" color="blue" dark @click="retryFunction">{{ $t('misc.retry') }}</v-btn>
</p>
<p>{{ $t('misc.or') }}</p>
<p>
<AddressInput
:label="$t('locationPicker.pickALocationManually')"
:onInput="onManualLocationPicker"
></AddressInput>
</p>
</div>
</template>
<script>
import AddressInput from '@/components/AddressInput.vue';
export default {
components: {
AddressInput,
},
methods: {
onManualLocationPicker(value) {
this.$store.dispatch(
'setCurrentPosition',
{ latLng: [value.latlng.lat, value.latlng.lng] },
);
},
},
props: {
error: String,
retryFunction: Function,
},
};
</script>

View File

@ -1,12 +1,33 @@
<template> <template>
<div class="fill-height fill-width"> <div class="fill-height fill-width">
<v-lmap ref="map" :minZoom="this.minZoom" :maxZoom="this.maxZoom" :options="{ zoomControl: false }" @click="handleClick" @movestart="onMoveStart" @zoomstart="onZoomStart"> <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> <v-ltilelayer :url="tileServer" :attribution="attribution"></v-ltilelayer>
<v-lts v-if="heading !== null" :lat-lng="positionLatLng" :options="markerOptions"></v-lts> <template v-if="positionLatLng">
<v-lcirclemarker v-else :lat-lng="positionLatLng" :color="markerOptions.color" :fillColor="markerOptions.fillColor" :fillOpacity="1.0" :weight="markerOptions.weight" :radius="markerRadius"></v-lcirclemarker> <v-lts v-if="heading !== null" :lat-lng="positionLatLng" :options="markerOptions"></v-lts>
<v-lcirclemarker
v-else
:lat-lng="positionLatLng"
:color="markerOptions.color"
:fillColor="markerOptions.fillColor"
:fillOpacity="1.0"
:weight="markerOptions.weight"
:radius="markerRadius"
>
</v-lcirclemarker>
<v-lcircle v-if="shouldDisplayAccuracy" :lat-lng="positionLatLng" :radius="radiusFromAccuracy"></v-lcircle>
</template>
<v-lcircle v-if="shouldDisplayAccuracy" :lat-lng="positionLatLng" :radius="radiusFromAccuracy"></v-lcircle>
<v-lpolyline :latLngs="polyline" :opacity="0.6" color="#00FF00"></v-lpolyline> <v-lpolyline :latLngs="polyline" :opacity="0.6" color="#00FF00"></v-lpolyline>
<v-lmarker v-if="reportLatLng" :lat-lng="reportLatLng" :icon="unknownMarkerIcon"></v-lmarker> <v-lmarker v-if="reportLatLng" :lat-lng="reportLatLng" :icon="unknownMarkerIcon"></v-lmarker>
@ -21,7 +42,7 @@
left left
color="blue" color="blue"
class="overlayButton" class="overlayButton"
v-if="recenterButton" v-if="isRecenterButtonShown"
@click.native.stop="recenterMap" @click.native.stop="recenterMap"
role="button" role="button"
:aria-label="$t('buttons.recenterMap')" :aria-label="$t('buttons.recenterMap')"
@ -56,21 +77,20 @@ export default {
components: { components: {
ReportMarker, 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: { computed: {
markerOptions() {
return {
fillColor: '#00ff00',
color: '#000000',
heading: this.heading * (Math.PI / 180), // in radians from North
weight: 1,
};
},
radiusFromAccuracy() { radiusFromAccuracy() {
if (this.accuracy) { 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 / ( return 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))) /
@ -80,19 +100,92 @@ export default {
return null; return null;
}, },
shouldDisplayAccuracy() { shouldDisplayAccuracy() {
// Only display accuracy if circle is large enough
return ( return (
this.accuracy && this.accuracy &&
this.accuracy < 100 && this.accuracy < constants.ACCURACY_DISPLAY_THRESHOLD &&
this.radiusFromAccuracy > this.markerRadius this.radiusFromAccuracy > this.markerRadius
); );
}, },
markerOptions() { },
return { data() {
fillColor: '#00ff00', return {
color: '#000000', attribution: this.$t('map.attribution'),
heading: this.heading * (Math.PI / 180), isProgrammaticMove: false,
weight: 1, 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 = `<img src="${compassNorthIcon}">`;
L.DomEvent.disableClickPropagation(div);
return div;
}; };
this.map.addControl(north);
},
showRecenterButton() {
if (!this.isRecenterButtonShown) {
this.isRecenterButtonShown = true;
}
}, },
}, },
mounted() { mounted() {
@ -101,31 +194,66 @@ export default {
this.isProgrammaticZoom = true; this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
} }
const mapCenter = this.map.getCenter();
if ( if (
this.map.getCenter().lat !== this.positionLatLng[0] && mapCenter.lat !== this.center[0] &&
this.map.getCenter().lng !== this.positionLatLng[1] mapCenter.lng !== this.center[1]
) { ) {
this.isProgrammaticMove = true; this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; }); this.map.once('moveend', () => { this.isProgrammaticMove = false; });
} }
this.map.setView(this.positionLatLng, this.zoom); this.map.setView(this.center, this.zoom);
this.showCompass(); 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: { watch: {
positionLatLng(newPositionLatLng) { zoom(newZoom) {
if (!this.map) { if (!this.map) {
// Map should have been created // Map should have been created
return; 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 // Handle programmatic navigation
if (this.map.getZoom() !== this.zoom) { if (this.map.getZoom() !== this.zoom) {
this.isProgrammaticZoom = true; this.isProgrammaticZoom = true;
this.map.once('zoomend', () => { this.isProgrammaticZoom = false; }); this.map.once('zoomend', () => { this.isProgrammaticZoom = false; });
} }
if ( if (
this.map.getCenter().lat !== newPositionLatLng[0] && this.map.getCenter().lat !== newCenterLatLng[0] &&
this.map.getCenter().lng !== newPositionLatLng[1] this.map.getCenter().lng !== newCenterLatLng[1]
) { ) {
this.isProgrammaticMove = true; this.isProgrammaticMove = true;
this.map.once('moveend', () => { this.isProgrammaticMove = false; }); this.map.once('moveend', () => { this.isProgrammaticMove = false; });
@ -138,7 +266,7 @@ export default {
const distances = this.markers.map( const distances = this.markers.map(
marker => ({ marker => ({
id: marker.id, id: marker.id,
distance: distance(newPositionLatLng, marker.latLng), distance: distance(newCenterLatLng, 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
@ -155,82 +283,10 @@ export default {
} }
} }
} }
this.map.setView(newPositionLatLng, this.zoom); this.map.setView(newCenterLatLng, this.zoom);
} }
}, },
}, },
data() {
return {
attribution: 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> 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 = `<img src="${compassNorthIcon}">`;
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);
},
},
}; };
</script> </script>

42
src/components/Modal.vue Normal file
View File

@ -0,0 +1,42 @@
<template>
<v-dialog v-model="isModalShown" max-width="290">
<slot></slot>
</v-dialog>
</template>
<script>
export default {
beforeDestroy() {
window.removeEventListener('keydown', this.hideModalOnEsc);
},
computed: {
isModalShown: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
},
},
},
methods: {
hideModalOnEsc(event) {
let isEscape = false;
if ('key' in event) {
isEscape = (event.key === 'Escape' || event.key === 'Esc');
} else {
isEscape = (event.keyCode === 27);
}
if (isEscape) {
this.isModalShown = false;
}
},
},
mounted() {
window.addEventListener('keydown', this.hideModalOnEsc);
},
props: {
value: Boolean,
},
};
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-dialog v-model="error" max-width="290"> <Modal v-model="error">
<v-card> <v-card>
<v-card-title class="subheading">{{ $t('reportDialog.unableToSendTitle') }}</v-card-title> <v-card-title class="subheading">{{ $t('reportDialog.unableToSendTitle') }}</v-card-title>
@ -22,7 +22,7 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </Modal>
<v-bottom-sheet v-model="isActive"> <v-bottom-sheet v-model="isActive">
<v-card> <v-card>
<v-container fluid> <v-container fluid>
@ -38,16 +38,16 @@
<script> <script>
import { REPORT_TYPES, REPORT_TYPES_ORDER } from '@/constants'; import { REPORT_TYPES, REPORT_TYPES_ORDER } from '@/constants';
import Modal from '@/components/Modal.vue';
import ReportTile from './ReportTile.vue'; import ReportTile from './ReportTile.vue';
export default { export default {
components: { beforeDestroy() {
ReportTile, window.removeEventListener('keydown', this.hideReportDialogOnEsc);
}, },
props: { components: {
value: Boolean, Modal,
lat: Number, ReportTile,
lng: Number,
}, },
computed: { computed: {
isActive: { isActive: {
@ -55,6 +55,9 @@ export default {
return this.value; return this.value;
}, },
set(val) { set(val) {
if (val === false) {
this.onHide();
}
this.$emit('input', val); this.$emit('input', val);
}, },
}, },
@ -67,18 +70,37 @@ export default {
}; };
}, },
methods: { methods: {
hideReportDialogOnEsc(event) {
let isEscape = false;
if ('key' in event) {
isEscape = (event.key === 'Escape' || event.key === 'Esc');
} else {
isEscape = (event.keyCode === 27);
}
if (isEscape) {
this.isActive = false;
}
},
saveReport(type) { saveReport(type) {
this.isActive = !this.isActive; this.isActive = false;
return this.$store.dispatch('saveReport', { return this.$store.dispatch('saveReport', {
type, type,
lat: this.lat, lat: this.latLng[0],
lng: this.lng, lng: this.latLng[1],
}).catch((exc) => { }).catch((exc) => {
console.error(exc); console.error(exc);
this.error = exc; this.error = exc;
}); });
}, },
}, },
mounted() {
window.addEventListener('keydown', this.hideReportDialogOnEsc);
},
props: {
value: Boolean,
latLng: Array,
onHide: Function,
},
}; };
</script> </script>

View File

@ -0,0 +1,84 @@
<template>
<Modal v-model="isActive">
<v-card>
<v-card-title class="headline">{{ $t('shareMapViewModal.shareCurrentMapView') }}</v-card-title>
<v-card-text>
{{ $t('shareMapViewModal.copyURLToShareCurrentMapView') }}
<v-text-field ref="shareLinkRef" readonly :hint="shareLinkHint" @click="copyShareLink" v-model="shareMapViewURL" prepend-icon="share"></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="green darken-1"
@click="isActive = false"
dark
large
role="button"
>
{{ $t('misc.ok') }}
</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</Modal>
</template>
<script>
import { DEFAULT_ZOOM } from '@/constants';
import Modal from '@/components/Modal.vue';
export default {
components: {
Modal,
},
computed: {
isActive: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
},
},
shareMapViewURL() {
const currentMapCenter = this.$store.state.map.center;
if (!currentMapCenter.every(item => item !== null)) {
return null;
}
const currentMapZoom = this.$store.state.map.zoom || DEFAULT_ZOOM;
const path = this.$router.resolve({
name: 'SharedMap',
params: {
lat: currentMapCenter[0],
lng: currentMapCenter[1],
zoom: currentMapZoom,
},
}).href;
return `${window.location.origin}/${path}`;
},
},
data() {
return {
shareLinkHint: null,
};
},
methods: {
copyShareLink() {
this.$refs.shareLinkRef.$el.querySelector('input').select();
if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
document.execCommand('copy');
this.shareLinkHint = this.$t('shareMapViewModal.copiedToClipboard');
}
},
},
props: {
value: Boolean,
},
};
</script>

View File

@ -113,19 +113,21 @@ 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 = 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 // Minimal ratio between upvotes and downvotes needed for a report to be shown
export const REPORT_VOTES_THRESHOLD = 0.5; 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 DEFAULT_ZOOM = 17;
export const MIN_ZOOM = 10; export const MIN_ZOOM = 10;
export const MAX_ZOOM = 18; export const MAX_ZOOM = 18;
export const ACCURACY_DISPLAY_THRESHOLD = 100; // in meters
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}`;

View File

@ -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." "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": { "buttons": {
"back": "Back",
"close": "Close", "close": "Close",
"downvote": "Downvote", "downvote": "Downvote",
"menu": "Menu", "menu": "Menu",
@ -36,13 +37,17 @@
"invalidSelection": "Invalid selection", "invalidSelection": "Invalid selection",
"pickALocationManually": "pick a location manually" "pickALocationManually": "pick a location manually"
}, },
"map": {
"attribution": "Map data © <a href=\"http://openstreetmap.org\">OpenStreetMap</a> contributors"
},
"menu": { "menu": {
"About": "Help", "About": "Help",
"Map": "Map", "shareMapView": "Share map view",
"Settings": "Settings" "Settings": "Settings"
}, },
"misc": { "misc": {
"discard": "Discard", "discard": "Discard",
"ok": "OK",
"or": "or", "or": "or",
"retry": "Retry", "retry": "Retry",
"spaceBeforeDoublePunctuations": "" "spaceBeforeDoublePunctuations": ""
@ -74,5 +79,10 @@
"save": "Save", "save": "Save",
"skipOnboarding": "Skip onboarding", "skipOnboarding": "Skip onboarding",
"tileServer": "Map tiles server" "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"
} }
} }

View File

@ -1,6 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import Router from 'vue-router'; import Router from 'vue-router';
import About from '@/components/About.vue'; import About from '@/views/About.vue';
import Map from '@/views/Map.vue'; import Map from '@/views/Map.vue';
import Onboarding from '@/views/Onboarding.vue'; import Onboarding from '@/views/Onboarding.vue';
import Settings from '@/views/Settings.vue'; import Settings from '@/views/Settings.vue';
@ -14,6 +14,11 @@ export default new Router({
name: 'About', name: 'About',
component: About, component: About,
}, },
{
path: '/map=:zoom/:lat/:lng',
name: 'SharedMap',
component: Map,
},
{ {
path: '/map', path: '/map',
name: 'Map', name: 'Map',

View File

@ -1,13 +1,19 @@
import moment from 'moment'; import moment from 'moment';
import * as api from '@/api'; import * as api from '@/api';
import * as constants from '@/constants';
import i18n from '@/i18n'; import i18n from '@/i18n';
import { import {
INTRO_WAS_SEEN, INTRO_WAS_SEEN,
IS_LOADING,
IS_DONE_LOADING, IS_DONE_LOADING,
IS_LOADING,
PUSH_REPORT, PUSH_REPORT,
SET_CURRENT_MAP_CENTER,
SET_CURRENT_MAP_ZOOM,
SET_CURRENT_POSITION,
SET_LOCATION_ERROR,
SET_LOCATION_WATCHER_ID,
SET_SETTING, SET_SETTING,
SHOW_REPORT_DETAILS, SHOW_REPORT_DETAILS,
STORE_REPORTS, STORE_REPORTS,
@ -61,3 +67,49 @@ export function setSetting({ commit }, { setting, value }) {
export function markIntroAsSeen({ commit }) { export function markIntroAsSeen({ commit }) {
return commit(INTRO_WAS_SEEN); 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 });
}

View File

@ -1,7 +1,12 @@
export const INTRO_WAS_SEEN = 'INTRO_WAS_SEEN'; 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_DONE_LOADING = 'IS_DONE_LOADING';
export const IS_LOADING = 'IS_LOADING';
export const PUSH_REPORT = 'PUSH_REPORT'; 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 SET_SETTING = 'SET_SETTING';
export const SHOW_REPORT_DETAILS = 'SHOW_REPORT_DETAILS'; export const SHOW_REPORT_DETAILS = 'SHOW_REPORT_DETAILS';
export const STORE_REPORTS = 'STORE_REPORTS'; export const STORE_REPORTS = 'STORE_REPORTS';

View File

@ -54,6 +54,18 @@ if (storageAvailable('localStorage')) {
export const initialState = { export const initialState = {
hasGoneThroughIntro: false, hasGoneThroughIntro: false,
isLoading: 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: { reportDetails: {
id: null, id: null,
userAsked: null, userAsked: null,
@ -71,11 +83,37 @@ export const mutations = {
[types.INTRO_WAS_SEEN](state) { [types.INTRO_WAS_SEEN](state) {
state.hasGoneThroughIntro = true; state.hasGoneThroughIntro = true;
}, },
[types.IS_DONE_LOADING](state) {
state.isLoading = false;
},
[types.IS_LOADING](state) { [types.IS_LOADING](state) {
state.isLoading = true; state.isLoading = true;
}, },
[types.IS_DONE_LOADING](state) { [types.PUSH_REPORT](state, { report }) {
state.isLoading = false; 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 }) { [types.SET_SETTING](state, { setting, value }) {
if (storageAvailable('localStorage')) { if (storageAvailable('localStorage')) {
@ -90,12 +128,4 @@ export const mutations = {
[types.STORE_REPORTS](state, { reports }) { [types.STORE_REPORTS](state, { reports }) {
state.reports = 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);
}
},
}; };

View File

@ -2,8 +2,20 @@
<v-container fluid fill-height class="no-padding"> <v-container fluid fill-height class="no-padding">
<v-layout row wrap fill-height> <v-layout row wrap fill-height>
<ReportCard></ReportCard> <ReportCard></ReportCard>
<v-flex xs12 fill-height v-if="latLng"> <v-flex xs12 fill-height v-if="mapCenter">
<Map :positionLatLng="latLng" :reportLatLng="reportLatLng" :polyline="positionHistory" :heading="heading" :accuracy="accuracy" :markers="reportsMarkers" :onPress="showReportDialog"></Map> <Map
:accuracy="accuracy"
:center="mapCenter"
:heading="heading"
:markers="reportsMarkers"
:onMapCenterUpdate="onMapCenterUpdate"
:onMapZoomUpdate="onMapZoomUpdate"
:onPress="showReportDialog"
:polyline="positionHistory"
:positionLatLng="currentLatLng"
:reportLatLng="reportLatLng"
:zoom="mapZoom"
></Map>
<v-btn <v-btn
absolute absolute
dark dark
@ -16,25 +28,15 @@
@click.native.stop="() => showReportDialog()" @click.native.stop="() => showReportDialog()"
role="button" role="button"
:aria-label="$t('buttons.reportProblem')" :aria-label="$t('buttons.reportProblem')"
v-if="!hasCenterProvidedByRoute"
> >
<v-icon>report_problem</v-icon> <v-icon>report_problem</v-icon>
</v-btn> </v-btn>
<ReportDialog v-model="dialog" :lat="reportLat" :lng="reportLng"></ReportDialog> <ReportDialog v-model="isReportDialogVisible" :latLng="reportLatLng" :onHide="resetReportLatLng"></ReportDialog>
</v-flex> </v-flex>
<v-flex xs12 sm6 offset-sm3 md4 offset-md4 fill-height v-else class="pa-3"> <v-flex xs12 sm6 offset-sm3 md4 offset-md4 fill-height v-else class="pa-3">
<template v-if="error"> <LocationError :error="error" :retryFunction="initializePositionWatching" v-if="error"></LocationError>
<p class="text-xs-center">{{ error }}</p> <p class="text-xs-center" v-else>{{ $t('geolocation.fetching') }}</p>
<p class="text-xs-center">
<v-btn role="button" color="blue" dark @click="initializePositionWatching">Retry</v-btn>
</p>
<p>{{ $t('misc.or') }}</p>
<p>
<AddressInput :label="$t('locationPicker.pickALocationManually')" :onInput="onManualLocationPicker" v-model="manualLocation"></AddressInput>
</p>
</template>
<template v-else>
<p class="text-xs-center">{{ $t('geolocation.fetching') }}</p>
</template>
</v-flex> </v-flex>
</v-layout> </v-layout>
</v-container> </v-container>
@ -43,35 +45,117 @@
<script> <script>
import NoSleep from 'nosleep.js'; import NoSleep from 'nosleep.js';
import AddressInput from '@/components/AddressInput.vue'; import LocationError from '@/components/LocationError.vue';
import Map from '@/components/Map.vue'; import Map from '@/components/Map.vue';
import ReportCard from '@/components/ReportCard.vue'; import ReportCard from '@/components/ReportCard.vue';
import ReportDialog from '@/components/ReportDialog/index.vue'; import ReportDialog from '@/components/ReportDialog/index.vue';
import * as constants from '@/constants'; import * as constants from '@/constants';
import { distance, mockLocation } from '@/tools'; import { distance, mockLocation } from '@/tools';
import i18n from '@/i18n';
import store from '@/store'; import store from '@/store';
function handlePositionError(error) {
// TODO: Not translated when changing locale
store.dispatch('setLocationError', { error: error.code });
}
function setPosition(position) {
const currentLatLng = store.state.location.currentLatLng;
if (currentLatLng) {
const distanceFromPreviousPoint = distance(
[currentLatLng[0], currentLatLng[1]],
[position.coords.latitude, position.coords.longitude],
);
if (distanceFromPreviousPoint > constants.UPDATE_REPORTS_DISTANCE_THRESHOLD) {
store.dispatch('fetchReports');
}
}
store.dispatch(
'setCurrentPosition',
{
accuracy: position.coords.accuracy ? position.coords.accuracy : null,
heading: (
(position.coords.heading !== null && !isNaN(position.coords.heading))
? position.coords.heading
: null
),
latLng: [position.coords.latitude, position.coords.longitude],
},
);
}
export default { export default {
beforeDestroy() {
this.disableNoSleep();
this.$store.dispatch('hideReportDetails');
},
beforeRouteEnter(to, from, next) {
if (to.name !== 'SharedMap') {
// Check that intro was seen except if we are in SharedMap view.
// This is required in order to ensure NoSleep works well.
if (!store.state.hasGoneThroughIntro) {
return next({ name: 'Onboarding', replace: true });
}
}
return next();
},
components: { components: {
AddressInput, LocationError,
Map, Map,
ReportCard, ReportCard,
ReportDialog, ReportDialog,
}, },
beforeRouteEnter(to, from, next) {
if (!store.state.hasGoneThroughIntro) {
return next({ name: 'Onboarding', replace: true });
}
return next();
},
beforeDestroy() {
this.disableNoSleep();
this.disablePositionWatching();
window.removeEventListener('keydown', this.hideReportDialogOnEsc);
this.$store.dispatch('hideReportDetails');
},
computed: { computed: {
accuracy() {
return this.$store.state.location.accuracy;
},
currentLatLng() {
const currentLatLng = this.$store.state.location.currentLatLng;
// Check that this is a correct position
if (currentLatLng.some(item => item === null)) {
return null;
}
return currentLatLng;
},
error() {
const errorCode = this.$store.state.location.error;
if (errorCode) {
let errorString = `${i18n.t('geolocation.errorFetchingPosition')} `;
if (errorCode === 1) {
errorString += i18n.t('geolocation.permissionDenied');
} else if (errorCode === 2) {
errorString += i18n.t('geolocation.positionUnavailable');
} else {
errorString += i18n.t('geolocation.timeout');
}
return errorString;
}
return null;
},
hasCenterProvidedByRoute() {
return this.$route.params.lat && this.$route.params.lng;
},
heading() {
return this.$store.state.location.heading;
},
mapCenter() {
if (this.hasCenterProvidedByRoute) {
return [this.$route.params.lat, this.$route.params.lng];
}
return this.currentLatLng;
},
mapZoom() {
return (
this.$route.params.zoom
? parseInt(this.$route.params.zoom, 10)
: constants.DEFAULT_ZOOM
);
},
positionHistory() {
return this.$store.state.location.positionHistory;
},
reportsMarkers() { reportsMarkers() {
return this.$store.getters.notDismissedReports.map(report => ({ return this.$store.getters.notDismissedReports.map(report => ({
id: report.id, id: report.id,
@ -79,98 +163,62 @@ export default {
latLng: [report.attributes.lat, report.attributes.lng], latLng: [report.attributes.lat, report.attributes.lng],
})); }));
}, },
reportLatLng() {
if (this.dialog && this.reportLat && this.reportLng) {
return [this.reportLat, this.reportLng];
}
return null;
},
}, },
data() { data() {
return { return {
accuracy: null, isReportDialogVisible: false,
centering: false,
dialog: false,
error: null,
heading: null,
latLng: null,
manualLocation: null,
noSleep: null, noSleep: null,
positionHistory: [], reportLatLng: null,
reportLat: null,
reportLng: null,
watchID: null,
}; };
}, },
methods: { methods: {
initializePositionWatching() {
this.error = null; // Reset any error
this.disablePositionWatching(); // Ensure at most one at the same time
if (constants.MOCK_LOCATION) {
this.setPosition(mockLocation());
this.watchID = setInterval(
() => this.setPosition(mockLocation()),
constants.MOCK_LOCATION_UPDATE_INTERVAL,
);
} else {
if (!('geolocation' in navigator)) {
this.error = this.$t('geolocation.unavailable');
}
this.watchID = navigator.geolocation.watchPosition(
this.setPosition,
this.handlePositionError,
{
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 27000,
},
);
}
},
disablePositionWatching() {
if (this.watchID !== null) {
if (constants.MOCK_LOCATION) {
clearInterval(this.watchID);
} else {
navigator.geolocation.clearWatch(this.watchID);
}
}
},
handlePositionError(error) {
this.error = `${this.$t('geolocation.errorFetchingPosition')} `;
if (error.code === 1) {
this.error += this.$t('geolocation.permissionDenied');
} else if (error.code === 2) {
this.error += this.$t('geolocation.positionUnavailable');
} else {
this.error += this.$t('geolocation.timeout');
}
},
setPosition(position) {
if (this.latLng) {
const distanceFromPreviousPoint = distance(
[this.latLng[0], this.latLng[1]],
[position.coords.latitude, position.coords.longitude],
);
if (distanceFromPreviousPoint > constants.UPDATE_REPORTS_DISTANCE_THRESHOLD) {
this.$store.dispatch('fetchReports');
}
}
this.latLng = [position.coords.latitude, position.coords.longitude];
this.positionHistory.push(this.latLng);
this.heading = null;
if (position.coords.heading !== null && !isNaN(position.coords.heading)) {
this.heading = position.coords.heading;
}
this.accuracy = position.coords.accuracy ? position.coords.accuracy : null;
},
disableNoSleep() { disableNoSleep() {
if (this.noSleep) { if (this.noSleep) {
this.noSleep.disable(); this.noSleep.disable();
} }
}, },
initializePositionWatching() {
if (this.$store.state.location.watcherID !== null) {
// Already watching location, no need to add another watcher
return;
}
this.$store.dispatch('setLocationError', { error: null }); // Reset any error
// Set up a watcher
let watchID = null;
if (constants.MOCK_LOCATION) {
setPosition(mockLocation());
watchID = setInterval(
() => setPosition(mockLocation()),
constants.MOCK_LOCATION_UPDATE_INTERVAL,
);
} else {
if (!('geolocation' in navigator)) {
this.$store.dispatch('setLocationError', { error: this.$t('geolocation.unavailable') });
return;
}
watchID = navigator.geolocation.watchPosition(
setPosition,
handlePositionError,
{
enableHighAccuracy: true,
maximumAge: 30000,
},
);
}
this.$store.dispatch('setLocationWatcherId', { id: watchID });
},
onMapCenterUpdate(center) {
this.$store.dispatch('setCurrentMapCenter', { center });
},
onMapZoomUpdate(zoom) {
this.$store.dispatch('setCurrentMapZoom', { zoom });
},
resetReportLatLng() {
this.reportLatLng = null;
},
setNoSleep() { setNoSleep() {
if (this.$store.state.settings.preventSuspend) { if (this.$store.state.settings.preventSuspend) {
this.noSleep = new NoSleep(); this.noSleep = new NoSleep();
@ -179,34 +227,20 @@ export default {
}, },
showReportDialog(latlng) { showReportDialog(latlng) {
if (latlng) { if (latlng) {
this.reportLat = latlng.lat; this.reportLatLng = [latlng.lat, latlng.lng];
this.reportLng = latlng.lng;
} else { } else {
this.reportLat = this.latLng[0]; this.reportLatLng = this.currentLatLng;
this.reportLng = this.latLng[1];
} }
this.dialog = !this.dialog; this.isReportDialogVisible = true;
},
hideReportDialogOnEsc(event) {
let isEscape = false;
if ('key' in event) {
isEscape = (event.key === 'Escape' || event.key === 'Esc');
} else {
isEscape = (event.keyCode === 27);
}
if (isEscape) {
this.dialog = false;
}
},
onManualLocationPicker(value) {
this.latLng = [value.latlng.lat, value.latlng.lng];
}, },
}, },
mounted() { mounted() {
this.setNoSleep(); if (this.$route.name !== 'SharedMap') {
this.initializePositionWatching(); // Only enable NoSleep in normal map view (with position tracking).
this.setNoSleep();
this.initializePositionWatching();
}
this.$store.dispatch('fetchReports'); this.$store.dispatch('fetchReports');
window.addEventListener('keydown', this.hideReportDialogOnEsc);
}, },
}; };
</script> </script>