Source: assets/js/map.js

import parse from 'wellknown'
import maplibregl from 'maplibre-gl'
import { capitalize, startCase } from 'lodash'
import { getApiToken, getFreshApiToken } from './os-api-token.js'

const lineColor = '#000000'
const fillColor = '#008'
const fillOpacity = 0.4
const boundaryLineColor = '#f00'
const boundaryLineOpacity = 1
const pointOpacity = 0.8
const pointRadius = 5
const pointColor = '#008'
const popupMaxListLength = 10
const defaultOsMapStyle = '/public/static/map-layers/OS_VTS_3857_Light.json'
const fallbackMapStyle = 'https://api.maptiler.com/maps/basic-v2/style.json?key=ncAXR9XEn7JgHBLguAUw'

/**
 * @typedef {Object} MapGeometry
 * @property {string} geo
 * @property {string} [reference]
 * @property {string} [name]
 */

/**
 * Creates a Map instance.
 * @param {MapOptions} opts - The options for creating the map.
 * @constructor
 */
/**
 * Options for creating a Map instance.
 * @typedef {Object} MapOptions
 * @property {string} containerId - Required - The ID of the HTML container element for the map.
 * @property {string[] | MapGeometry[]} data - Required - An array of URLs or WKT geometries to be added to the map.
 * @property {string} [boundaryGeoJsonUrl] - Optional - The URL of the boundary GeoJSON to be added to the map.
 * @property {boolean} [interactive] - Optional - Indicates whether the map should be interactive. Default is true.
 * @property {boolean} [wktFormat] - Optional - Indicates whether the data is in WKT format. Default is false.
 * @property {number[]} [boundingBox] - Optional - The bounding box coordinates [minX, minY, maxX, maxY] to set the initial view of the map.
 */
export class Map {
  constructor (opts) {
    this.opts = opts
    this.bbox = this.opts.boundingBox ?? null
    this.map = new maplibregl.Map({
      container: this.opts.containerId,
      style: this.opts.style ?? defaultOsMapStyle,
      zoom: 11,
      center: [-0.1298779, 51.4959698],
      interactive: this.opts.interactive ?? true,
      transformRequest: (url, resourceType) => {
        if (url.indexOf('api.os.uk') > -1) {
          if (!/[?&]key=/.test(url)) url += '?key=null'

          const requestToMake = {
            url: url + '&srs=3857'
          }

          const token = getApiToken()
          requestToMake.headers = {
            Authorization: 'Bearer ' + token
          }

          return requestToMake
        }
      }
    })

    // Add map controls
    this.addControls(this.opts.interactive)

    this.map.on('load', async () => {
      this.setFirstMapLayerId()

      if (this.opts.boundaryGeoJsonUrl) this.addBoundaryGeoJsonToMap(this.opts.boundaryGeoJsonUrl)
      if (opts.wktFormat) this.addWktDataToMap(this.opts.data)
      else await this.addGeoJsonUrlsToMap(this.opts.data)

      // Move the map to the bounding box
      if (this.bbox && this.bbox.length === 2) {
        try {
          this.setMapViewToBoundingBox(this.bbox)
        } catch (error) {
          console.warn('Could not set map to bounding box', error?.message, this.bbox)
        }
      }

      if (opts.interactive) this.addPopupToMap()
    })
  }

  addControls (interactive = true) {
    this.map.addControl(new maplibregl.ScaleControl(), 'bottom-left')

    if (interactive) {
      this.map.addControl(new maplibregl.NavigationControl())
      this.map.addControl(new maplibregl.FullscreenControl())
    }
  }

  setFirstMapLayerId () {
    const layers = this.map.getStyle().layers

    // Find the index of the first symbol layer in the map style
    for (let i = 0; i < layers.length; i++) {
      if (layers[i].type === 'symbol') {
        this.firstMapLayerId = layers[i].id
        break
      }
    }
  }

  addWktDataToMap (geometriesWkt) {
    const geometries = []
    const features = []
    for (let index = 0; index < geometriesWkt.length; ++index) {
      const item = geometriesWkt[index]
      const geometryWkt = (typeof item === 'string') ? { geo: item } : item
      const geometry = parse(geometryWkt.geo)

      if (!geometry) {
        console.error('Invalid WKT geometry format', geometryWkt)
        return
      }
      geometries.push(geometry)

      if (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon' || geometry.type === 'Point' || geometry.type === 'MultiPoint') {
        features.push({
          type: 'Feature',
          geometry
        })
      }
    }

    const sourceName = 'dataset'
    const source = {
      type: 'geojson',
      data: {
        type: 'FeatureCollection',
        features
      }
    }
    this.map.addSource(sourceName, source)

    this.map.addLayer({
      id: 'dataset-poly',
      type: 'fill',
      source: sourceName,
      layout: {},
      paint: {
        'fill-color': fillColor,
        'fill-opacity': fillOpacity
      },
      filter: ['==', '$type', 'Polygon']
    }, this.firstMapLayerId)

    this.map.addLayer({
      id: 'dataset-poly-border',
      type: 'line',
      source: sourceName,
      layout: {},
      paint: {
        'line-color': lineColor,
        'line-width': 1
      },
      filter: ['==', '$type', 'Polygon']
    })

    this.map.addLayer({
      id: 'dataset-poly-point',
      type: 'circle',
      source: sourceName,
      paint: {
        'circle-radius': pointRadius,
        'circle-color': pointColor,
        'circle-opacity': pointOpacity
      },
      filter: ['==', '$type', 'Point']
    })

    this.bbox = calculateBoundingBoxFromGeometries(geometries.map(g => g.coordinates))
  }

  async addGeoJsonUrlsToMap (geoJsonUrls) {
    geoJsonUrls.forEach(async (url, index) => {
      const name = `geometry-${index}`
      this.map.addSource(name, {
        type: 'geojson',
        data: url
      })

      this.map.addLayer({
        id: name,
        type: 'fill',
        source: name,
        layout: {},
        paint: {
          'fill-color': fillColor,
          'fill-opacity': fillOpacity
        }
      }, this.firstMapLayerId)

      this.map.addLayer({
        id: `${name}-point`,
        type: 'circle',
        source: name,
        paint: {
          'circle-radius': pointRadius,
          'circle-color': pointColor,
          'circle-opacity': pointOpacity
        },
        filter: ['==', '$type', 'Point']
      }, this.firstMapLayerId)

      this.map.addLayer({
        id: `${name}-border`,
        type: 'line',
        source: name,
        layout: {},
        paint: {
          'line-color': lineColor,
          'line-width': 1
        }
      }, this.firstMapLayerId)
    })

    if (!this.bbox || this.bbox.length === 0) {
      this.bbox = await generateBoundingBox(geoJsonUrls?.[0])
    }
  }

  addBoundaryGeoJsonToMap (geoJsonUrl) {
    this.map.addSource('boundary', {
      type: 'geojson',
      data: geoJsonUrl
    })

    this.map.addLayer({
      id: 'boundary',
      type: 'line',
      source: 'boundary',
      layout: {},
      paint: {
        'line-color': boundaryLineColor,
        'line-width': 2,
        'line-opacity': boundaryLineOpacity
      }
    }, this.firstMapLayerId)
  }

  setMapViewToBoundingBox (bbox) {
    this.map.fitBounds(bbox, { padding: 20, duration: 0, maxZoom: 11 })
  }

  addPopupToMap () {
    // The onclick callback relies on `properties` of a feature that we've set up using `.addSource()`
    this.map.on('click', (e) => {
      const features = this.map.queryRenderedFeatures(e.point).filter(f => f.layer.id.startsWith('geometry-'))
      if (!features.length) return

      const popupContent = document.createElement('div')
      popupContent.classList.add('govuk-!-padding-2')

      if (features.length > popupMaxListLength) {
        const tooMany = document.createElement('p')
        tooMany.classList.add('govuk-body-s')
        tooMany.textContent = `
          You clicked on ${features.length} features. <br/>
          Zoom in or turn off layers to narrow down your choice.`
        popupContent.appendChild(tooMany)
      } else {
        const list = document.createElement('ul')
        list.classList.add('app-c-map__popup-list', 'govuk-list')

        // add heading
        const heading = document.createElement('h4')
        heading.classList.add('govuk-heading-s')
        heading.textContent = `${features.length} ${features.length > 1 ? 'features' : 'feature'} selected`
        list.appendChild(heading)
        features.forEach(feature => {
          // create inset
          const inset = document.createElement('li')
          inset.classList.add('app-c-map__popup-list-item')

          // feature text content
          const textContent = document.createElement('p')
          textContent.classList.add('govuk-body-s', 'govuk-!-margin-top-0', 'govuk-!-margin-bottom-0')
          textContent.innerHTML = referencePopup(feature.properties)

          inset.appendChild(textContent)
          list.appendChild(inset)
        })
        popupContent.appendChild(list)
      }

      const popup = new maplibregl.Popup({
        maxWidth: '300px'
      })
        .setLngLat(e.lngLat)
        .setDOMContent(popupContent)
        .addTo(this.map)

      popup.getElement().onwheel = preventScroll(['.app-c-map__popup-list'])
    })

    this.map.getCanvas().style.cursor = 'pointer'

    this.map.on('mouseleave', () => {
      this.map.getCanvas().style.cursor = ''
    })
  }
}

export const calculateBoundingBoxFromGeometries = (geometries) => {
  let minX = Infinity
  let minY = Infinity
  let maxX = -Infinity
  let maxY = -Infinity

  if (!geometries) return []

  const pullOutCoordinates = (geometry) => {
    if (Array.isArray(geometry[0])) {
      geometry.forEach(pullOutCoordinates)
    } else {
      const [x, y] = geometry

      // if x or y isn't a valid number log an error and continue
      if (isNaN(x) || isNaN(y)) {
        console.error('Invalid coordinates', x, y)
        return
      }

      minX = Math.min(minX, x)
      minY = Math.min(minY, y)
      maxX = Math.max(maxX, x)
      maxY = Math.max(maxY, y)
    }
  }

  pullOutCoordinates(geometries)

  // Return the bounding box
  return [[minX, minY], [maxX, maxY]]
}

// Prevents scrolling of the page when the user triggers the wheel event on a div
// while still allowing scrolling of any specified scrollable child elements.
// Params:
//  scrollableChildElements: an array of class names of potential scrollable elements
const preventScroll = (scrollableChildElements = []) => {
  return (e) => {
    const closestClassName = scrollableChildElements.find((c) => {
      return e.target.closest(c) != null
    })

    if (!closestClassName) {
      e.preventDefault()
      return false
    }

    const list = e.target.closest(closestClassName)

    if (!list) {
      e.preventDefault()
      return false
    }

    const verticalScroll = list.scrollHeight > list.clientHeight
    if (!verticalScroll) { e.preventDefault() }

    return false
  }
}

/**
 * Contents of a popup element shows when user clicks on a geometry (point or polygon).
 *
 * @param {Object} options
 * @param {string} [options.dataset] dataset
 * @param {string} [options.reference] reference, can be a HTML string
 * @param {string} [options.name] name
 * @returns {string}
 */
function referencePopup ({ dataset, reference, name }) {
  return `
      ${capitalize(startCase(dataset)) || ''}<br/>
      <strong>Ref:</strong> ${reference || ''}<br/>
      ${name ?? ''}`.trim()
}

/**
 * @param {string} geoJsonUrl
 * @returns {Promise<string[]>}
 */
export const generatePaginatedGeoJsonLinks = async (geoJsonUrl) => {
  const geoJsonLinks = [geoJsonUrl]
  const initialResponse = await fetch(geoJsonUrl)
  const initialData = await initialResponse.json()

  // return if no pagination is needed
  if (!initialData.links || !initialData.links.last) {
    return geoJsonLinks
  }

  const lastLink = new URL(initialData.links.last)
  const limit = parseInt(lastLink.searchParams.get('limit'))
  const lastOffset = parseInt(lastLink.searchParams.get('offset'))

  if (!limit || !lastOffset) {
    console.error('Invalid pagination links', lastLink)
    return geoJsonLinks
  }

  // create a loop to generate the links
  for (let offset = limit; offset <= lastOffset; offset += limit) {
    const newLink = new URL(geoJsonUrl)
    newLink.searchParams.set('offset', `${offset}`)

    geoJsonLinks.push(newLink.toString())
  }

  return geoJsonLinks
}

export const generateBoundingBox = async (boundaryGeoJsonUrl) => {
  if (!boundaryGeoJsonUrl) return []

  const res = await fetch(boundaryGeoJsonUrl)
  const boundaryGeoJson = await res.json()
  const coordinates = boundaryGeoJson?.features?.[0]?.geometry?.coordinates ?? null

  return calculateBoundingBoxFromGeometries(coordinates)
}

export const createMapFromServerContext = async () => {
  const { containerId, geometries, mapType, geoJsonUrl, boundaryGeoJsonUrl } = window.serverContext
  const options = {
    containerId,
    data: geometries,
    boundaryGeoJsonUrl,
    interactive: mapType !== 'static',
    wktFormat: geoJsonUrl === undefined
  }

  // fetch initial token
  try {
    await getFreshApiToken()
  } catch (error) {
    console.error('Error fetching OS Map API token', error.message)
    options.style = fallbackMapStyle
  }

  // if the geoJsonUrl is provided, generate the paginated GeoJSON links
  if (geoJsonUrl) {
    options.data = await generatePaginatedGeoJsonLinks(geoJsonUrl)
  }

  if (options.boundaryGeoJsonUrl) {
    options.boundingBox = await generateBoundingBox(boundaryGeoJsonUrl)
  }

  // if any of the required properties are missing, return null
  if (!options.containerId || !options.data) {
    console.log('Missing required properties (containerId, geometries) on window.serverContext', window.serverContext)
    return null
  }

  return new Map(options)
}

try {
  window.map = await createMapFromServerContext()
  window.map.map.on('error', err => {
    console.warn('map error', err)
  })
} catch (error) {
  console.error('Error creating map', error)
}