Source: components/geomap/map.js

// OpenLayers map code inspired by this example:
// https://openlayers.org/en/latest/examples/wmts-layer-from-capabilities.html
// HINT: Use setRenderReprojectionEdges(true) on WMTS tilelayer for debugging

import WMTS, { optionsFromCapabilities } from 'ol/source/WMTS'
import WMTSCapabilities from 'ol/format/WMTSCapabilities'
import Map from 'ol/Map'
import TileLayer from 'ol/layer/Tile'
import View from 'ol/View'
import { get as getProjection } from 'ol/proj'
import { register } from 'ol/proj/proj4'
import proj4 from 'proj4'
import { epsg25832proj } from '@dataforsyningen/saul'
import VectorSource from 'ol/source/Vector'
import VectorLayer from 'ol/layer/Vector'
import Feature from 'ol/Feature'
import Polygon from 'ol/geom/Polygon'
import Point from 'ol/geom/Point'
import TileState from 'ol/TileState.js'
import { Icon, Style } from 'ol/style'
import { defaults as defaultControls } from 'ol/control'
import { defaults as defaultInteractions } from 'ol/interaction/defaults'
import { configuration } from '../../modules/configuration.js'
import { getViewSyncMapListener } from '../../modules/sync-view'
import { generateParcelVectorLayer } from '../../custom-plugins/plugin-parcel'
import { addPointerLayerToMap, getUpdateMapPointerFunction } from '../../custom-plugins/plugin-pointer'
import { addFootprintLayerToMap, getUpdateMapFootprintFunction } from '../../custom-plugins/plugin-footprint.js'
import { state, autorun } from '../../state/index.js'
import pointerSvg from '@dataforsyningen/designsystem/assets/icons/pointer-position.svg'
import { retryOptions, fetchWithRetry } from '@dataforsyningen/retry/index.js'

// Set the default retry timeout
retryOptions.timeout = 200

/**
 * Web component that displays a map.
 */
export class SkraaFotoMap extends HTMLElement {

  // public properties
  api_stac_token = configuration.API_STAC_TOKEN
  projection
  parser = new WMTSCapabilities()
  map = null
  center
  sync = false
  self_sync = true
  icon_layer
  update_pointer_function
  update_footprint_function
  update_view_function
  parcels_function
  advanced = false

  template = `
    <div class="geographic-map">
      <skraafoto-compass direction="north"></skraafoto-compass>
      ${ configuration.ENABLE_GEOLOCATION && this.advanced ? `<skraafoto-geolocation></skraafoto-geolocation>`: '' }
    </div>
  `

  // getters
  static get observedAttributes() {
    return [
      'data-center',
      'hidden'
    ]
  }

  constructor() {
    super()

    // Define and register EPSG:25832 projection, since OpenLayers doesn't know about it (yet).
    epsg25832proj(proj4)
    register(proj4)
    this.projection = getProjection('EPSG:25832')

    // Create a vector layer for the user's position marker
    this.userPositionLayer = new VectorLayer({
      source: new VectorSource()
    })
  }

  // methods

  createDOM() {
    this.innerHTML = this.template
  }

  generateMap(center, zoom) {
    // Switch to datafordeler might be preferable
    return fetch(`https://services.datafordeler.dk/DKskaermkort/topo_skaermkort_daempet/1.0.0/wmts?username=${ configuration.API_DHM_TOKENA }&password=${ configuration.API_DHM_TOKENB }&service=WMTS&request=GetCapabilities`)
    .then((response) => {
      return response.text()
    })
    .then((xml) => {
      const result = this.parser.read(xml)
      const options = optionsFromCapabilities(result, {
        layer: 'topo_skaermkort_daempet',
        matrixSet: 'View1'
      })
      // Add retry to tiles
      options.tileLoadFunction = function (tile, src) {
        fetchWithRetry(src)
          .then(response => {
            if (!response.ok) {
              tile.setState(TileState.ERROR)
            }
            return response.blob()
          })
          .then(blob => {
            tile.getImage().src = URL.createObjectURL(blob)
          })
          .catch((e) => {
            tile.setState(TileState.ERROR)
          })
      }

      let controls
      if (this.advanced) {
        controls = defaultControls({rotate: false, attribution: false})
      } else {
        controls = defaultControls({rotate: false, attribution: false, zoom: false})
      }

      let interactions
      if (this.advanced) {
        interactions = defaultInteractions()
      } else {
        interactions = defaultInteractions({dragPan: false, pinchZoom: true, mouseWheelZoom: false})
      }

      const view = new View({
        projection: this.projection,
        center: center,
        zoom: zoom
      })

      const map = new Map({
        layers: [
          new TileLayer({
            opacity: 1,
            source: new WMTS(options)
          }),
          this.userPositionLayer,
        ],
        target: this.querySelector('.geographic-map'),
        view: view,
        controls: controls,
        interactions: interactions
      })

      map.on('rendercomplete', () => {
        this.rendercompleteHandler()
      })

      this.update_view_function = getViewSyncMapListener(this, map)

      if (configuration.ENABLE_POINTER) {
        addPointerLayerToMap(map)
        this.update_pointer_function = getUpdateMapPointerFunction(map)
        window.addEventListener('updatePointer', this.update_pointer_function)
      }
      
      // Display footprint on map
      addFootprintLayerToMap(map)
      this.update_footprint_function = getUpdateMapFootprintFunction(map)
      window.addEventListener('updateFootprint', this.update_footprint_function)
    
      return map
    })
  }

  rendercompleteHandler() {
    this.toggleSpinner(false)
  }

  drawParcels(parcels) {
    if (!this.map || !parcels[0]) {
      return
    }
    // generate a map layer for parcel polygons
    const layer = generateParcelVectorLayer()

    parcels.forEach(parcel => {
      const polygon = parcel.map(coor => {
        return [coor[0], coor[1]]
      })
      layer.getSource().addFeature(new Feature({
        geometry: new Polygon([polygon])
      }))
    })

    // update map
    const oldLayer = this.map.getLayers().getArray().find((pLayer) => {
      return pLayer.get('title') === 'Parcels'
    })
    this.map.removeLayer(oldLayer)
    this.map.addLayer(layer)
  }

  generateIconLayer(center) {
    let icon_feature = new Feature({
      geometry: new Point([center[0], center[1]])
    })
    const icon_style = new Style({
      image: new Icon({
        src: pointerSvg,
        scale: 1,
        anchor: [0.5, 1]
      })
    })
    icon_feature.setStyle(icon_style)
    return new VectorLayer({
      source: new VectorSource({
        features: [icon_feature]
      })
    })
  }

  async createMap() {

    this.toggleSpinner(true)

    const center = state.marker.position

    if (!this.map) {
      this.map = await this.generateMap(center, (state.view.zoom + configuration.MAP_ZOOM_DIFFERENCE))
    } else if (this.map && this.icon_layer) {
      this.map.removeLayer(this.icon_layer)
    }

    const view = this.map.getView()
    view.setCenter(center)
    this.map.setView(view)

    this.icon_layer = this.generateIconLayer(center)
    this.map.addLayer(this.icon_layer)

    if (configuration.ENABLE_PARCEL) {
      this.drawParcels(state.parcels)
    }

    this.setupListeners()
  }

  setupListeners() {
    // When marker (crosshair) position changes in state, re-render the icon layer
    this.markerUpdateDisposer = autorun(() => {
      this.updateMap(state.marker)
    })

    // Sync view when view is changed in state
    this.viewUpdateDisposer = autorun(() => {
      this.updateMapView(state.view)
    })

    if (configuration.ENABLE_PARCEL) {
      this.parcelDisposer = autorun(() => {
        this.drawParcels(state.parcels)
      })
    }
  }

  /** Changes the view according to state */
  updateMapView(viewstate) {
    this.map.getView().setCenter(viewstate.position) 
  }

  /** Re-renders the icon layer when marker (crosshair) position changes in state. */
  updateMap(markerstate) {
    if (this.icon_layer) {
      this.map.removeLayer(this.icon_layer)
    }
    this.map.getView().setCenter(markerstate.position) 
    this.icon_layer = this.generateIconLayer(markerstate.position)
    this.map.addLayer(this.icon_layer)
  }

  /** Toggles displaying the loading spinner */
  toggleSpinner(isLoading) {
    if (isLoading) {
      // Attach a loading animation element while updating
      const spinner_element = document.createElement('ds-spinner')
      this.append(spinner_element)
    } else {
      // Removes loading animation elements
      this.querySelectorAll('ds-spinner').forEach(function(spinner) {
        spinner.remove()
      })
    }
  }


  // Lifecycle

  connectedCallback() {
    this.createDOM()
    this.createMap()
  }

  disconnectedCallback() {
    window.removeEventListener('updatePointer', this.update_pointer_function)
    window.removeEventListener('updateFootprint', this.update_footprint_function)
    this.markerUpdateDisposer()
    this.viewUpdateDisposer()
    if (configuration.ENABLE_PARCEL) {
      this.parcelDisposer()
    }
  }
}