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:
parent
3e8ec40330
commit
74a42abe72
28
src/App.vue
28
src/App.vue
@ -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' });
|
||||||
|
37
src/components/LocationError.vue
Normal file
37
src/components/LocationError.vue
Normal 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>
|
@ -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>
|
||||||
|
|
||||||
|
<template v-if="positionLatLng">
|
||||||
<v-lts v-if="heading !== null" :lat-lng="positionLatLng" :options="markerOptions"></v-lts>
|
<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-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>
|
<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-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,20 +100,93 @@ 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() {
|
},
|
||||||
|
data() {
|
||||||
return {
|
return {
|
||||||
fillColor: '#00ff00',
|
attribution: this.$t('map.attribution'),
|
||||||
color: '#000000',
|
isProgrammaticMove: false,
|
||||||
heading: this.heading * (Math.PI / 180),
|
isProgrammaticZoom: false,
|
||||||
weight: 1,
|
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() {
|
||||||
this.map = this.$refs.map.mapObject;
|
this.map = this.$refs.map.mapObject;
|
||||||
@ -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
42
src/components/Modal.vue
Normal 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>
|
@ -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>
|
||||||
|
|
||||||
|
84
src/components/ShareMapViewModal.vue
Normal file
84
src/components/ShareMapViewModal.vue
Normal 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>
|
@ -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}`;
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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 });
|
||||||
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
@ -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() {
|
||||||
|
if (this.$route.name !== 'SharedMap') {
|
||||||
|
// Only enable NoSleep in normal map view (with position tracking).
|
||||||
this.setNoSleep();
|
this.setNoSleep();
|
||||||
this.initializePositionWatching();
|
this.initializePositionWatching();
|
||||||
|
}
|
||||||
this.$store.dispatch('fetchReports');
|
this.$store.dispatch('fetchReports');
|
||||||
window.addEventListener('keydown', this.hideReportDialogOnEsc);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user