Source

components/shared/SimplicitiMap/LeafletMap.vue

<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 &copy; Esri &mdash; 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">&copy; <b>Jawg</b>Maps</a> &copy; <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:
                '&copy; OpenStreetMap France | &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
            }
          ),
        cBaseLayerSabatier: () =>
          L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
            attribution: '&#169; Simpliciti3',
            layers: '',
            format: 'image/png',
            transparent: true,
            styles: 'geotransv2',
          }),
        cBaseLayerSabatier2: () =>
          L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
            attribution: '&#169; Simpliciti3',
            layers: '',
            format: 'image/png',
            transparent: true,
            styles: 'alternative',
          }),
        cBaseLayerSabatier3: () =>
          L.tileLayer.wms('https://map3-devnt.geosab.eu?', {
            attribution: '&#169; 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:
              '&copy; <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>