sotlas-frontend/src/views/Map.vue

489 wiersze
16 KiB
Vue

<template>
<div class="map-layout" ref="mapLayout">
<MglMap v-if="showMap && mapStyle" :mapStyle="mapStyle" :bounds.sync="bounds" :fitBoundsOptions="fitBoundsOptions" :center="center" :zoom="zoom" :dragRotate="false" :attributionControl="false" class="map" @load="onMapLoaded" @click="onMapClicked" @contextmenu="onMapRightClicked">
<MglGeolocateControl :positionOptions="{ enableHighAccuracy: true }" :fitBoundsOptions="{ maxZoom: 12.5 }" :trackUserLocation="true" position="top-right" />
<MglNavigationControl position="top-right" :showCompass="false" />
<MglScaleControl position="bottom-left" />
<MglAttributionControl :compact="$mq.mobile" position="bottom-right" />
<!-- Note: these are not true Mapbox GL controls that get added via addControl(), as those don't mix well with Vue.js templating.
Instead, we simply put all our custom non-Mapbox controls in the top left corner where they don't clash with any builtin controls. -->
<div class="mapboxgl-ctrl-top-left">
<MapFilterControl ref="filterControl" position="top-left" @startFiltering="filtering = true" @stopFiltering="filtering = false" />
<MapOptionsControl ref="optionsControl" position="top-left" />
<MapDownloadControl position="top-left" />
</div>
<MglPopup v-if="loadingPopupCoordinates" key="loading" :coordinates="loadingPopupCoordinates" :showed="true" anchor="bottom" @added="onPopupAdded">
<div class="loading-ring-wrapper">
<LoadingRing />
</div>
</MglPopup>
<SummitPopup v-if="summit" :summit="summit" :lastSpot="lastSummitSpot" :nextAlert="nextSummitAlert" @close="onPopupClosed" />
<MapRoute v-for="route in persistentRoutes" :key="route.id" :route="route" />
<MapInfoPopup v-if="infoCoordinates !== null" :coordinates="infoCoordinates" @close="infoCoordinates = null" />
<MapDraw ref="draw" />
<MapWebcams v-if="mapOptions.webcams" />
</MglMap>
<div v-if="browserNotSupported" class="browser-not-supported">Your browser does not support WebGL, which is required to render this map. <strong>iOS 17 users: There is a bug in iOS 17 that can sometimes cause this error. Restarting Safari (closing/killing it completely) resolves the issue temporarily.</strong></div>
<div v-if="zoomWarning" class="zoom-warning">Zoom in to see all filtered/spotted summits</div>
<SwisstopoInfo />
<BasemapAtInfo />
<b-loading :is-full-page="false" :active="filtering || !showMap || !mapStyle" />
</div>
</template>
<script>
import axios from 'axios'
import mapboxgl from 'mapbox-gl'
import utils from '../mixins/utils.js'
import smptracks from '../mixins/smptracks.js'
import mapstyle from '../mixins/mapstyle.js'
import longtouch from '../mixins/longtouch.js'
import { MglMap, MglPopup, MglNavigationControl, MglGeolocateControl, MglScaleControl, MglAttributionControl } from 'vue-mapbox'
import MapFilterControl from '../components/MapFilterControl.vue'
import MapOptionsControl from '../components/MapOptionsControl.vue'
import MapDownloadControl from '../components/MapDownloadControl.vue'
import LoadingRing from '../components/LoadingRing.vue'
import SummitPopup from '../components/SummitPopup.vue'
import MapRoute from '../components/MapRoute.vue'
import MapInfoPopup from '../components/MapInfoPopup.vue'
import MapDraw from '../components/MapDraw.vue'
import MapWebcams from '../components/MapWebcams.vue'
import SwisstopoInfo from '../components/SwisstopoInfo.vue'
import BasemapAtInfo from '../components/BasemapAtInfo.vue'
export default {
name: 'Map',
components: {
MglMap, MglPopup, MglNavigationControl, MglGeolocateControl, MglScaleControl, MglAttributionControl, MapFilterControl, MapOptionsControl, MapDownloadControl, LoadingRing, SummitPopup, MapRoute, MapInfoPopup, MapDraw, MapWebcams, SwisstopoInfo, BasemapAtInfo
},
mixins: [utils, smptracks, mapstyle, longtouch],
created () {
this.map = null
},
mounted () {
if (!mapboxgl.supported()) {
this.browserNotSupported = true
}
// Check for summit code or coordinates first; if present, start map right there
if (this.$route.params.summitCode) {
this.fetchSummit(this.$route.params.summitCode)
.then(summit => {
if (summit) {
this.bounds = [[summit.coordinates.longitude, summit.coordinates.latitude], [summit.coordinates.longitude, summit.coordinates.latitude]]
this.fitBoundsOptions = this.makeFitBoundsOptions()
}
this.showMap = true
})
} else if (this.$route.params.coordinates) {
this.center = this.$route.params.coordinates.split(/,/).reverse()
if (this.$route.params.zoom) {
this.zoom = parseFloat(this.$route.params.zoom)
}
this.showMap = true
} else if (this.$route.params.region) {
this.getRegionBounds(this.$route.params.region)
.then(bounds => {
this.bounds = bounds
this.fitBoundsOptions = { animate: false, padding: { left: 60, top: 40, right: 60, bottom: 40 } }
this.showMap = true
})
} else {
if (localStorage.getItem('bounds')) {
try {
this.bounds = JSON.parse(localStorage.getItem('bounds'))
} catch (e) {}
this.showMap = true
} else {
axios.get(process.env.VUE_APP_API_URL + '/my_coordinates')
.then(response => {
if (response.data.latitude && response.data.longitude) {
this.center = [response.data.longitude, response.data.latitude]
this.zoom = 8
} else {
this.bounds = [[186, 75], [-172, -59]]
}
})
.finally(() => {
this.showMap = true
})
}
}
},
props: {
clickFuzz: {
type: Number,
default: 10
}
},
data () {
return {
showMap: false,
bounds: undefined,
fitBoundsOptions: undefined,
center: undefined,
zoom: 14,
loadingPopupCoordinates: null,
summit: null,
leavingRoute: false,
zoomWarning: false,
browserNotSupported: false,
filtering: false,
infoCoordinates: null,
persistentRoutes: []
}
},
watch: {
bounds (val) {
localStorage.setItem('bounds', JSON.stringify(Array.isArray(val) ? val : val.toArray()))
this.updateMapURL()
if (this.map) {
this.zoomWarning = (this.map.getZoom() < 3 && (this.$refs.filterControl.isActive() || this.$refs.optionsControl.spotsShown()))
}
},
'$route' (to, from) {
this.updateRoute()
},
summit () {
if (!this.summit) {
return
}
if (!this.isSummitValid(this.summit)) {
this.$refs.optionsControl.showInactive()
}
this.persistentRoutes = []
},
routes () {
if (this.routes.length !== 0) {
this.persistentRoutes = this.routes
}
},
mapOptions: {
handler (newValue) {
this.updateLayers(this.map)
},
deep: true
}
},
computed: {
lastSummitSpot () {
if (!this.summit) {
return null
}
let spots = this.$store.state.spots.filter(spot => {
return (spot.summit.code === this.summit.code)
}).sort((a, b) => {
if (a.timeStamp > b.timeStamp) {
return -1
} else if (a.timeStamp < b.timeStamp) {
return 1
} else {
return 0
}
})
if (spots.length > 0) {
return spots[0]
} else {
return null
}
},
nextSummitAlert () {
if (!this.summit) {
return null
}
let alerts = this.$store.state.alerts.filter(alert => {
return (alert.summit.code === this.summit.code)
}).sort((a, b) => {
if (a.dateActivated > b.dateActivated) {
return 1
} else if (a.dateActivated < b.dateActivated) {
return -1
} else {
return 0
}
})
if (alerts.length > 0) {
return alerts[0]
} else {
return null
}
},
mapOptions () {
return this.$store.state.mapOptions
}
},
methods: {
onMapLoaded (event) {
this.map = event.map
this.map.touchZoomRotate.disableRotation();
['summits_circles_all', 'summits_circles', 'summits_inactive_circles'].forEach(layer => {
this.map.on('mouseenter', layer, () => {
if (!this.$refs.draw.isDrawing()) {
this.map.getCanvas().style.cursor = 'pointer'
}
})
this.map.on('mouseleave', layer, () => {
this.map.getCanvas().style.cursor = ''
})
})
this.updateLayers(this.map)
this.map.setLayoutProperty('summits_circles_all', 'visibility', 'visible')
this.installLongTouchHandler(this.map, (e) => {
this.infoCoordinates = {
latitude: e.lngLat.lat,
longitude: e.lngLat.lng
}
})
this.updateRoute()
axios.post(process.env.VUE_APP_API_URL + '/mapsession', { type: 'main' })
},
onMapClicked (event) {
if (this.$refs.draw.isDrawing() || event.mapboxEvent.originalEvent.hitMarker) {
return
}
// Search for summit circles with some padding/fuzz to make it easier to hit on mobile devices
let point = event.mapboxEvent.point
let bbox = [[point.x - this.clickFuzz, point.y - this.clickFuzz], [point.x + this.clickFuzz, point.y + this.clickFuzz]]
let features = this.map.queryRenderedFeatures(bbox, { layers: ['summits_circles_all', 'summits_circles', 'summits_inactive_circles'] })
if (features.length === 0) {
// User probably clicked outside any features; close any controls
this.$refs.filterControl.close()
this.$refs.optionsControl.close()
if (!this.summit) {
this.persistentRoutes = []
}
return
}
// Find the summit closest to where the user tapped
let minDistance = null
let chosenFeature = null
features.forEach(feature => {
let projected = this.map.project(feature.geometry.coordinates)
let distance = Math.pow(projected.x - point.x, 2) + Math.pow(projected.y - point.y, 2)
if (minDistance === null || distance < minDistance) {
minDistance = distance
chosenFeature = feature
}
})
if (chosenFeature) {
this.handleSummitClick(chosenFeature)
}
},
onMapRightClicked (event) {
this.infoCoordinates = {
latitude: event.mapboxEvent.lngLat.lat,
longitude: event.mapboxEvent.lngLat.lng
}
},
updateRoute () {
if (!this.$route.path.startsWith('/map') || this.lastSetUrl === this.$route.path) {
this.lastSetUrl = null
return
}
this.$nextTick(() => {
this.map.resize()
})
if (this.$route.params.summitCode) {
this.jumpToSummitCode(this.$route.params.summitCode)
return
} else if (this.$route.params.coordinates) {
let coords = this.$route.params.coordinates.split(/,/).reverse()
this.map.jumpTo({
center: coords,
zoom: this.$route.params.zoom ? parseFloat(this.$route.params.zoom) : 14
})
if (this.$route.query.popup) {
this.infoCoordinates = {
latitude: parseFloat(coords[1]),
longitude: parseFloat(coords[0])
}
}
} else if (this.$route.params.region) {
this.getRegionBounds(this.$route.params.region)
.then(bounds => {
this.map.fitBounds(bounds, { animate: true, padding: { left: 60, top: 40, right: 60, bottom: 40 } })
})
}
// Additionally a hash with summit code may be provided to open the popup
let matches = window.location.hash.match('^#/summits/(.+)$')
if (matches && (this.summit === null || matches[1] !== this.summit.code)) {
this.fetchSummit(matches[1])
.then(summit => {
this.summit = summit
})
}
},
onPopupClosed () {
this.summit = null
this.updateMapURL()
},
onPopupAdded (popup) {
popup.popup.options.focusAfterOpen = false
},
handleSummitClick (feature) {
this.loadingPopupCoordinates = feature.geometry.coordinates
this.summit = null
this.fetchSummit(feature.properties.code)
.then(summit => {
this.loadingPopupCoordinates = null
this.summit = summit
this.updateMapURL()
})
},
fetchSummit (summitCode) {
return axios.get(process.env.VUE_APP_API_URL + '/summits/' + summitCode)
.then(response => {
let summit = response.data
summit.photo = null
return summit
})
},
fetchAssociation (associationCode) {
return axios.get(process.env.VUE_APP_API_URL + '/associations/' + associationCode)
.then(response => {
return response.data
})
},
getRegionBounds (region) {
let assocReg = region.split('/')
return this.fetchAssociation(assocReg[0])
.then(association => {
let region = association.regions.find(el => el.code === assocReg[1])
if (region) {
// Add padding to bounds
let padding = 1 // km
region.bounds[0][0] -= padding / (Math.cos(region.bounds[0][1] * Math.PI / 180) * 111.32)
region.bounds[0][1] -= padding / 111.32
region.bounds[1][0] += padding / (Math.cos(region.bounds[0][1] * Math.PI / 180) * 111.32)
region.bounds[1][1] += padding / 111.32
return region.bounds
}
})
},
jumpToSummitCode (summitCode) {
if (this.summit != null && summitCode === this.summit.code) {
return
}
this.fetchSummit(summitCode)
.then(summit => {
if (!summit) {
return
}
this.jumpToSummit(summit)
})
},
jumpToSummit (summit) {
this.map.fitBounds([[summit.coordinates.longitude, summit.coordinates.latitude], [summit.coordinates.longitude, summit.coordinates.latitude]], this.makeFitBoundsOptions())
this.summit = summit
},
makeCoordinateLink (summitCode, longitude, latitude) {
if (summitCode.match(/^HB0?\//)) {
return 'https://map.geo.admin.ch/?swisssearch=' + latitude + ',' + longitude
} else {
return 'https://www.openstreetmap.org/?mlat=' + latitude + '&mlon=' + longitude + '&zoom=14'
}
},
updateMapURL () {
if (this.map) {
let url = '/map/coordinates/' + this.map.getCenter().toArray().reverse().map(a => { return a.toFixed(6) }).join(',') + '/' + this.map.getZoom().toFixed(1)
let hash = ''
if (this.summit && this.summit.code) {
hash = '#/summits/' + this.summit.code
}
let fullPath = url + hash
if (!this.leavingRoute && this.$router.currentRoute.fullPath !== fullPath) {
this.lastSetUrl = url
this.$router.replace(fullPath)
}
}
},
makeFitBoundsOptions () {
let offsetY = 0
let assumedPopupHeight = 430
let mapHeight = this.$refs.mapLayout.clientHeight
if (mapHeight < (assumedPopupHeight * 2)) {
// Popup probably won't fit the screen vertically if we center the map on the summit, so we shift the map down
offsetY = Math.max(0, Math.min(assumedPopupHeight - mapHeight / 2, mapHeight / 4 - 20))
}
return { offset: [0, offsetY], maxZoom: 14, animate: false }
}
},
provide () {
return {
map: this.map
}
},
beforeRouteEnter (to, from, next) {
next(vm => {
vm.leavingRoute = false
document.title = 'Map - SOTLAS'
})
},
beforeRouteLeave (to, from, next) {
if (!to.path.match(/^\/map/)) {
this.leavingRoute = true
}
next()
}
}
</script>
<style scoped>
.map-layout {
position: absolute;
touch-action: manipulation;
top: 3.25rem;
right: 0;
bottom: 0;
left: 0;
}
.map >>> .mapboxgl-popup {
max-width: 600px !important;
}
.loading-ring-wrapper {
margin: 1rem 0.5rem 0.5rem 0.5rem;
}
.zoom-warning {
position: absolute;
left: 50%;
top: 1.5rem;
transform: translate(-50%, 0);
background-color: #feffd2;
padding: 0.2em 0.5em;
border-radius: 0.5em;
text-align: center;
opacity: 0.9;
}
.browser-not-supported {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: #fdc5c9;
padding: 0.2em 0.5em;
border-radius: 0.5em;
}
</style>