<template lang="pug">
.map(ref="map")
</template>
<script>
import 'leaflet/dist/leaflet.css'
import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css' // Re-uses images from ~leaflet package
import L, { latLng } from 'leaflet'
import 'leaflet-defaulticon-compatibility'
import 'leaflet.marker.slideto'
//require('leaflet-routing-machine'); //Required for snaping polylines into streets (routing)
import $ from 'jquery'
import Vue from 'vue'
import mapMixin from '@/mixins/map.js'
import { mapGetters } from 'vuex'
import { getQueryStringValue } from '@/utils/querystring'
import { useMapContextMenu } from '@/components/shared/SimplicitiMap/MapContextMenu.vue'
import { getCenterLatLngFromPredefinedViewId } from '@/services/predefined-views.js'
import '@geoman-io/leaflet-geoman-free'
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css'
import { normalizeWmsOptions } from '@/services/map-service.js'
const { onContextMenuHandler } = useMapContextMenu()
window.L = L
L.PM.setOptIn(true) //By default, geometries are not editable
require('leaflet.gridlayer.googlemutant/dist/Leaflet.GoogleMutant.js')
const shouldLog =
(getQueryStringValue('verbose') || '').includes('1') ||
(getQueryStringValue('verbose') || '').includes('map')
window.Leaflet = L
require('leaflet.markercluster')
const defaultClusterOptions = {
//Show the markers when the user zoom in (maximum zoom level)
disableClusteringAtZoom: 18,
spiderfyOnMaxZoom: false,
}
export default {
mixins: [mapMixin],
inject: {
disableLeafletMapInvalidateSize: {
default: null,
},
},
data() {
return {
willDestroy: false,
initialized: false,
geometriesData: {},
layerGroups: {},
currentMapLayer: null,
}
},
computed: {
...mapGetters({
layout: 'app/layout',
}),
},
watch: {
/**
* Re-compute map size if layout change (only for location module / dynamic layout)
*/
layout: {
async handler() {
if (this.initialized && !this.layout.isSidebarExtended) {
this.invalidateSize()
}
},
deep: true,
},
disableLeafletMapInvalidateSize() {
if (this.disableLeafletMapInvalidateSize === false) {
this.$nextTick(() => this.invalidateSize())
}
},
},
/**
* Wait for DOM map object and initialize Leaflet map instance
*/
mounted() {
function waitForProperty(prop, callback, timeout = 1000) {
if (prop()) {
callback()
} else {
setTimeout(() => waitForProperty(prop, callback, timeout), timeout)
}
}
waitForProperty(
() => this.$refs.map,
async () => {
await this.initializeMap()
this.onResizeHandler = () => {
this.invalidateSize()
}
$(window).on('resize', this.onResizeHandler)
}
)
},
/**
* Destroy map instance and unbind resize event
*/
destroyed() {
this.willDestroy = true
this.map && this.map.remove()
$(window).off('resize', this.onResizeHandler)
},
methods: {
invalidateSize() {
setTimeout(() => {
this.$nextTick(() => {
if (!this._isDestroyed) {
if (this.disableLeafletMapInvalidateSize === true) {
console.log('LeafletMap::invalidateSize::skipped')
return
}
this.map && this.map.invalidateSize()
}
})
}, 500)
},
/**
* Retrieves all the available layer groups filtering those who have at least once child layer (geometry)
*/
getUsedLayerGroups() {
let layers = []
Object.keys(this.layerGroups || {}).forEach((layerName) => {
if (this.layerGroups[layerName].getLayers().length > 0) {
layers.push({
name: layerName,
})
}
})
return layers
},
getMap() {
return this.map
},
async waitForInitialization() {
const self = this
return new Promise((resolve, reject) => {
function wait() {
if (self.initialized) {
resolve()
} else {
setTimeout(() => {
wait()
}, 100)
}
}
wait()
})
},
async initializeMap() {
if (this._isBeingDestroyed) {
return
}
let center = [44.29240108529008, 0.21972656250000003]
await this.$store.dispatch('settings/syncFromCache', {
once: true,
})
let predefinedMapPoint = await getCenterLatLngFromPredefinedViewId(
this.$store.getters['settings/getParameter']('defaultMapPredefinedView')
)
if (predefinedMapPoint) {
center = [predefinedMapPoint.lat, predefinedMapPoint.lng]
/*console.log({
centerFromPredView: center,
})*/
}
this.map = L.map(this.$refs.map, {
pmIgnore: false,
maxZoom: 18,
center,
zoom: 6,
preferCanvas: true,
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
})
window.lm = this.map
window.lmw = this
this.map.on('contextmenu', function (e) {
onContextMenuHandler(e)
})
this.map.on('moveend', () =>
setTimeout(() => this.sortLayerGroupsByZIndex(), 500)
)
this.map.on('zoomend', () =>
setTimeout(() => this.sortLayerGroupsByZIndex(), 500)
)
let customTileLayers = {
Esri_WorldStreetMap: () =>
L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
{
attribution:
'Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012',
}
),
Jawg_Dark: () =>
L.tileLayer(
'https://{s}.tile.jawg.io/jawg-dark/{z}/{x}/{y}{r}.png?access-token={accessToken}',
{
attribution:
'<a href="http://jawg.io" title="Tiles Courtesy of Jawg Maps" target="_blank">© <b>Jawg</b>Maps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
minZoom: 0,
subdomains: 'abcd',
accessToken:
'Qvo6gdi0QDuU95OjFCxl5rHcYkrANV2UwIVar4HzyeP6R7OGhKN7ydt3cVUNhe0l',
}
),
OpenStreetMap_France: () =>
L.tileLayer(
'https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png',
{
attribution:
'© OpenStreetMap France | © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
),
cBaseLayerSabatier: () =>
L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
attribution: '© Simpliciti3',
layers: '',
format: 'image/png',
transparent: true,
styles: 'geotransv2',
}),
cBaseLayerSabatier2: () =>
L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
attribution: '© Simpliciti3',
layers: '',
format: 'image/png',
transparent: true,
styles: 'alternative',
}),
cBaseLayerSabatier3: () =>
L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
attribution: '© Simpliciti3',
layers: '',
format: 'image/png',
transparent: true,
styles: 'geotransv1',
}),
}
let customTileLayer =
customTileLayers[getQueryStringValue('tileLayer')] || null
if (customTileLayer) {
this.currentMapLayer = customTileLayer()
} else {
this.currentMapLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?{foo}',
{
foo: 'bar',
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
)
}
await this.$store.dispatch('settings/syncFromCache')
let wmsItem = this.$store.getters['settings/getParameter']('wmsItem')
if (Object.keys(wmsItem || {}).length > 0) {
this.currentMapLayer = L.tileLayer.wms(
wmsItem.wmsUrl,
normalizeWmsOptions(wmsItem.wmsOptions)
)
}
if (this._isBeingDestroyed) {
return
}
this.currentMapLayer.addTo(this.map)
this.map.on('zoomend', (e) => this.$emit('zoomend', this.map._zoom))
this.$emit('zoomend', this.map._zoom)
this.map.on('moveend', (e) => this.$emit('moveend'))
this.initialized = true
this.invalidateSize()
this.smoothZoomToDefaultZoomLevel()
},
smoothZoomToDefaultZoomLevel() {
setTimeout(() => {
let predefinedZoomLevel = parseInt(
this.$store.getters['settings/getParameter']('defaultMapZoomLevel')
)
if (predefinedZoomLevel && !!this.map) {
let centerPoint = this.map.getCenter()
this.map.flyTo(
[centerPoint.lat, centerPoint.lng],
predefinedZoomLevel,
{
animate: true,
duration: 1.5,
}
)
}
}, 2000)
},
/**
* Dynamically update current leaflet instance map layer tile with a new style
* (i.g: Changing Simpliciti map style on the fly)
*/
setMapLayerStyleTo(styleValue) {
this.currentMapLayer &&
this.currentMapLayer.setParams &&
this.currentMapLayer.setParams({
styles: styleValue,
})
},
/**
* Draw polylines reusing geometries
*/
drawPolylines(data = [], options = {}) {
return this.drawGeometries({
...options,
data,
/**
* POC: Snap poylines to street (using a routing machine that hits an OSM server)
*/
async generateLeafletGeometriesBulkAsync_DISABLED(items) {
return new Promise(async (resolveFn, rejectFn) => {
let arr = await Promise.all(
items.map((item) => {
return (async () => {
let coords = item.polyline.reduce(
(a, v) => a + (a ? ';' : '') + `${v[1]},${v[0]}`,
''
)
try {
let json = await window.fetch(
`http://osm-routing.geosab.eu/match/v1/car/${coords}?geometries=geojson`
)
json = await json.json()
if (
json.matchings &&
json.matchings[0] &&
json.matchings[0].geometry &&
json.matchings[0].geometry.coordinates
) {
let polyline = json.matchings[0].geometry.coordinates.map(
(arr) => arr.reverse()
)
return L.polyline(polyline, {
...item,
polyline: null,
})
}
} catch (err) {
//Skip snap
}
return L.polyline(item.polyline, {
...item,
polyline: null,
})
})()
})
)
resolveFn(arr)
})
},
generate(item) {
let p = L.polyline(
item.polyline || item.data,
options.itemOptions
? typeof options.itemOptions === 'function'
? options.itemOptions(item)
: options.itemOptions
: item.styles || {
...item,
polyline: null,
}
)
if (options.arrowheads) {
require('leaflet-arrowheads')
let arrowheadsOptions = options.arrowheads || {}
if (typeof options.arrowheads === 'function') {
arrowheadsOptions = options.arrowheads(item)
}
p = p.arrowheads(arrowheadsOptions)
}
if (options.onclick) {
p.on('click', options.onclick)
}
return p
},
after(params) {
let latLngs = data.reduce((a, v) => a.concat(v.polyline), [])
params.latLngs = latLngs
options.after && options.after.apply(this, [params])
},
})
},
/**
* Draw circles reusing geometries
*/
drawCircles(data = [], options = {}) {
return this.drawGeometries({
...options,
data,
generate(item) {
if (!item) {
return Vue.$log.warn(
'LeafletMap::drawCircles: Unable to generate geometry',
{
item,
}
)
}
return L.circle([item.lat, item.lng], {
color: item.color || 'blue',
fillColor: item.fillColor || '#fff',
fillOpacity: item.fillOpacity || 100,
radius: item.radius || 5,
weight: item.weight || 3,
//interactive: true,
})
},
after() {
options.after && options.after.apply(this, arguments)
},
})
},
/**
* Clears the layers for the passed layerGroupName.
* @param {String} layerGroupName - The name of the layer group to clear
* @param {Object} [options={}] - Optional additional options
* @param {boolean} [options.removeLayerGroup=false] - Whether or not to remove the Layer Group
*/
clearLayers(layerGroupName, options = {}) {
this.layerGroups &&
this.layerGroups[layerGroupName] &&
this.layerGroups[layerGroupName].clearLayers()
if (options.removeLayerGroup) {
delete this.layerGroups[layerGroupName]
}
},
configurePopup(geometry, data, options = {}) {
if (options.popup) {
geometry.properties = data
this.bindLeafletPopupComponent(geometry, options.popup, {
...(options.popupOptions || {}),
parent: this,
props: {
map: this.map,
//The popup component will have access to the geometry associated data via this.$parent.data
data,
},
})
}
},
/**
* Manually remove a layer group
* @param {*} layerGroupName
*/
removeLayerGroup(layerGroupName) {
if (typeof layerGroupName !== 'string') {
throw new Error('Expects layer group name (string)')
}
const layerGroup = this.layerGroups[layerGroupName]
if (layerGroup) {
layerGroup.clearLayers() //Will remove childs (markers, polylines, etc)
layerGroup.remove() //Will detach it from the map instance
delete this.layerGroups[layerGroupName] //Remove object
}
},
removeLayerGroupBy(filterHandler) {
Object.values(this.layerGroups)
.filter(filterHandler)
.forEach((layerGroup) => {
return this.removeLayerGroup(layerGroup.name)
})
},
/**
* Draw leaflet geometries reusing geometry objects for performance
*
* @param {String} options.data (Required) dataset to generate geometries from (IMPORTANT: an empty array will clear existing geometries!)
* @param {Function} options.generate (Required) function to handle render
* @param {String} options.layer (Required) Specify a layer group where the geometries will be added (created on the fly)
* @param {Function} options.after (Optional) Callback after geometries are succefully added to map
* @param {Function} options.update (Optional) function to handle update (Reuse geometries instances)
* @param {Number} options.layerOptions.zIndex (Optional) Draw priority (DESC)
* @param {Boolean} options.recreateLayerGroup (Optional) Default to false
*/
async drawGeometries(options = {}) {
if (!this.initialized) {
shouldLog &&
Vue.$log.warn('drawGeometries:The map is not yet initialized', {
options,
})
return new Promise((r, c) =>
setTimeout(() => this.drawGeometries(options).then(r).catch(c), 500)
)
}
if (!options.layer) {
throw new Error('options.layer required')
}
let geometries = []
//Layer group reuse
this.layerGroups = this.layerGroups || {}
let areGeometriesAddedIntoALayerGroup = !!options.layer
if (!areGeometriesAddedIntoALayerGroup) {
throw new Error('LEAFLETMAP_LAYER_REQUIRED')
}
//Recreating the layer groups is necessary if switching between clustering/non-clustering
if (options.recreateLayerGroup && !!this.layerGroups[options.layer]) {
this.layerGroups[options.layer].remove()
delete this.layerGroups[options.layer]
}
let isLayerGroupCreated =
options.layer && !!this.layerGroups[options.layer]
let layerGroup =
(options.layer && this.layerGroups[options.layer]) || null
//Will remove existing geometries in the existing layer
if (isLayerGroupCreated) {
if (
options.preserveExistingLayers !== true ||
options.data.length === 0
) {
layerGroup.clearLayers()
layerGroup.remove()
console.debug(`Clear layer childs ${options.layer}`)
}
}
//If geometries are mean to remain hidden, skip drawing entirely
if (options.data.length === 0) {
return
}
//Do not use vue reactive data inside leaflet (Remove reactivity) (This should improve performance)
/* try {
if (
(options.data.length > 0 && options.data[0]?.__ob__) ||
options.data.some((item) =>
Object.keys(item).some(
(key) =>
item[key]?.__ob__ ||
(item[key] instanceof Array &&
item[key].length > 0 &&
(item[key][0]?.__ob__ || false))
)
)
) {
options.data = [
...options.data.map((o) =>
Object.freeze({
//...R.omit(['__ob__'], JSON.parse(JSON.stringify(o))),
...o,
})
),
]
}
} catch (err) {
console.warn('LeafletMap::Fail to check for reactive data', {
err,
})
}*/
//Update feature: Allow to reuse existing geometries saving resources
let updatedIds = []
const filterOutUpdatedHandler = (item) => {
return (
updatedIds.length === 0 ||
(item.id !== undefined && !updatedIds.includes(item.id))
)
}
if (isLayerGroupCreated && options.update) {
options.data
.filter((i) => i.id !== undefined)
.forEach((item) => {
//Tries to match an existing geometry
let matchedGeometry = layerGroup
.getLayers()
.find((geometry) => geometry.externalId == item.id)
//i.g Zones render multiple layers with the same externalId (zoneId)
let matchedGeometries = layerGroup
.getLayers()
.filter((geometry) => geometry.externalId == item.id)
if (matchedGeometry) {
let hasUpdate =
options.update(matchedGeometry, item, matchedGeometries, {
layerGroup,
}) !== false
if (hasUpdate) {
updatedIds.push(item.id)
}
} /* else {
console.log('no matched geometry')
window.dd = {
item,
layers: [...layerGroup.getLayers()],
}
}*/
})
if (updatedIds.length > 0) {
console.log(
`LeafletMap::drawGeometries ${updatedIds.length} geometries updated`
)
}
if (options.afterUpdate) {
options.afterUpdate({
map: this.map,
updatedLayers: layerGroup
.getLayers()
.filter((l) => updatedIds.includes(l.externalId)),
notUpdatedLayers: layerGroup
.getLayers()
.filter((l) => !updatedIds.includes(l.externalId)),
})
}
}
//@TODO: Optimize
if (options.generate) {
let rawDataToGenerateGeometries = options.data.filter(
filterOutUpdatedHandler
)
/*if (options.data.length != rawDataToGenerateGeometries.length) {
console.log(
`drawGeometries ${
options.data.length - rawDataToGenerateGeometries.length
} items skipped in generation`
)
} else {
console.log('drawGeometries no items skipped in generation', {
updatedIds,
rawItems: rawDataToGenerateGeometries.map((i) => i.id),
})
}*/
rawDataToGenerateGeometries.forEach((item) => {
let generateResponse = options.generate(item, {
map: this.map,
})
if (!generateResponse) {
console.warn(
'LeafletMap.vue::drawGeometries.generate is supposed to return a valid Leaflet geometry. Given:',
generateResponse
)
}
generateResponse =
generateResponse instanceof Array
? generateResponse
: [generateResponse]
generateResponse = generateResponse.filter((geometry) => !!geometry)
generateResponse.forEach((geometry) => {
geometry.externalId = item.id
geometry.setStyle && geometry.setStyle({ ...item })
if (areGeometriesAddedIntoALayerGroup && isLayerGroupCreated) {
layerGroup.addLayer(geometry)
}
//Do not use a layer group, add the geometry to the map
if (!areGeometriesAddedIntoALayerGroup) {
geometry.addTo(this.map)
}
this.configurePopup(geometry, item, options)
geometries.push(geometry)
})
})
/*console.log('LeafletMap::after-generate', {
rawDataToGenerateGeometriesLen: rawDataToGenerateGeometries.length,
geometriesLen: geometries.length,
})*/
}
//If no geometries to add, skip
if (geometries.length === 0) {
return
}
if (!!options.generateLeafletGeometriesBulkAsync && !options.generate) {
geometries = await options.generateLeafletGeometriesBulkAsync(
options.data
)
if (areGeometriesAddedIntoALayerGroup && isLayerGroupCreated) {
layerGroup.addLayers(geometries)
}
if (!areGeometriesAddedIntoALayerGroup) {
this.map.addLayers(geometries)
}
}
//Hot-fix: Component is being destroyed in the meanwhile
if (this.willDestroy) {
return
}
//New layer group, add the geometries on creation
if (areGeometriesAddedIntoALayerGroup) {
if (!isLayerGroupCreated) {
let newLayerGroup
if (options.clusterOptions) {
newLayerGroup = L.markerClusterGroup({
...defaultClusterOptions,
...options.clusterOptions,
...(options.layerOptions || {}),
})
newLayerGroup.addLayers(geometries)
} else {
newLayerGroup = L.featureGroup(geometries, {
...(options.layerOptions || {}),
})
}
newLayerGroup.name = options.layer
//console.log('leaflet::layer-added::', newLayerGroup.name)
layerGroup = this.layerGroups[options.layer] = newLayerGroup
}
//Toggle group layer visible
if (options.visible === false) {
layerGroup.remove()
/*console.log(
`Layer ${options.layer} hidden with ${options.data.length}`
)*/
} else {
layerGroup.addTo(this.map)
/*console.log(
`Layer ${options.layer} visible with ${options.data.length}`
)*/
}
}
if (areGeometriesAddedIntoALayerGroup && options.zIndex) {
layerGroup.setZIndex(options.zIndex)
layerGroup.zIndex = options.zIndex
}
this.invalidateSize()
options.after &&
options.after({
geometries,
map: this.map,
vm: this,
})
this.$emit('draw')
setTimeout(() => {
this.sortLayerGroupsByZIndex()
}, 500)
setTimeout(() => {
this.sortLayerGroupsByZIndex()
}, 1500)
},
/**
* Ensure zIndex is respected calling bringToFront/bringToBack for every layer group child (geometry)
*/
sortLayerGroupsByZIndex() {
const zIndex = (layerGroup) =>
layerGroup.zIndex || layerGroup.options?.zIndex
//Any layer group without specified zIndex will draw before
Object.values(this.layerGroups)
.filter((layer) => !zIndex(layer))
.forEach(bringLayerGroupToFront)
Object.values(this.layerGroups)
.filter((layer) => zIndex(layer))
.sort((a, b) => {
return zIndex(a) < zIndex(b) ? -1 : 1
})
.forEach(bringLayerGroupToFront)
function bringLayerGroupToFront(layerGroup) {
//console.log('leaflet::sort::', layerGroup.name)
layerGroup.bringToFront()
layerGroup
.getLayers()
.forEach(
(geometry) => geometry.bringToFront && geometry.bringToFront()
)
}
},
},
}
</script>
<style lang="scss" scoped>
.map {
/*
Can't find the map?
Note: The map depends on parent size
min-width: 200px;
min-height: 200px;
*/
height: 100%;
height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */
}
</style>
Source