Source: control/ControlLegendManager.js

import OlControl from 'ol/control/Control'
import Collection from 'ol/Collection'
import { unByKey } from 'ol/Observable'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import LineString from 'ol/geom/LineString'
import Polygon from 'ol/geom/Polygon'

import OlExtElement from 'ol-ext/util/element'
import Legend from 'ol-ext/legend/Legend'

import isNil from 'lodash/isNil'

/**
* @typedef {Object} Mapviewer
*/

/**
 * Gestion des légendes associés a des calques
 */

const texts = {
  title: 'Légende',
  closeMenu: 'Fermer ce menu',
  noLegendVisible: 'Aucune légende visible',
}

/** regroupe les types de géométrie dans leur base (le style d'une ligne est le même que multiligne par exemple) */
const geometryBaseType = {
  Point: 'Point',
  LineString: 'LineString',
  LinearRing: 'LineString',
  Polygon: 'Polygon',
  MultiPoint: 'Point',
  MultiLineString: 'LineString',
  MultiPolygon: 'Polygon',
  GeometryCollection: 'GeometryCollection',
  Circle: 'Polygon',
}
class ControlLegendManager extends OlControl {
  /**
   *  @type {ol.Collection}
   * */
  legends = new Collection()

  menuListeners = []

  constructor (options) {
    options = {
      ...options,
      title: texts.title,
    }

    const className = options.className !== undefined ? options.className : ''

    const classNames = (className || '') + ' kmapv-bottom-tools kmapv-legend-manager top-of-bottom-bar hidden' + (options.target ? '' : ' ol-unselectable ol-control')
    const element = OlExtElement.create('DIV', {
      className: classNames,
    })

    super({
      element,
      target: options.target,
    })

    /**
    * @type {Mapviewer}
    */
    this.viewer = options.viewer

    // menu principal
    this.menuMain = OlExtElement.create('DIV', {
      className: 'tools',
    })

    // header du menu template
    const headerTemplateMenu = OlExtElement.create('DIV', {
      className: 'header',
      html: `<div>${options.title}</div>`,
    })

    OlExtElement.create('I', {
      className: 'kmapv-icon xs kmapv-icon-cancel',
      title: texts.closeMenu,
      parent: headerTemplateMenu,
      style: {
        visibility: options?.closeable === false ? 'hidden' : 'visible',
      },
      on: {
        click: () => {
          this.hide()
        },
      },
    })

    element.appendChild(headerTemplateMenu)
    element.appendChild(this.menuMain)

    // quand on retire une legende, on supprime tout ses listeners
    this.legends.on('remove', (event) => {
      event.element.listeners.forEach(unByKey)
    })
  }

  isLegendVisible (legendDefinition, layer) {
    const { minZoom = 0, maxZoom = Infinity } = legendDefinition

    if (!layer.isVisible()) {
      return false
    }

    if (isNil(minZoom) || isNil(maxZoom)) {
      return true
    }
    const currentZoom = this.viewer.getZoom()

    return currentZoom > minZoom && currentZoom <= maxZoom
  }

  checkLegendsVisibility () {
    this.legends.forEach(legend => {
      legend.legendDefinitions.forEach(legendDefinition => {
        const isVisible = this.isLegendVisible(legendDefinition, legend.layer)
        if (legendDefinition.visible !== isVisible) {
          legendDefinition.visible = isVisible
          this.dispatchEvent({ type: 'legendDefinition:visible', legendDefinition, visible: isVisible })
        }
      })
    })
  }

  onMapViewChange (changeViewEvent) {
    if (changeViewEvent.oldTarget) {
      changeViewEvent.oldTarget.un('change', this.checkLegendsVisibility.bind(this))
    }
    changeViewEvent.target.getView().on('change', this.checkLegendsVisibility.bind(this))
    this.checkLegendsVisibility()
  }

  drawLegend () {
    this.menuListeners.forEach(unByKey)
    this.menuMain.innerHTML = ''
    const ul = OlExtElement.create('ul', {
      parent: this.menuMain,
    })

    const hasLegendVisible = this.legends.getArray().some(({ visible }) => visible)
    const noVisibleLegend = OlExtElement.create('li', {
      // className: 'title',
      text: texts.noLegendVisible,
      style: {
        display: !hasLegendVisible ? '' : 'none',
      },
      parent: ul,
    })

    const sortedLegendDefinitions = this.legends
      .getArray()
      .reduce((acc, legend) => {
        const { legendDefinitions } = legend
        return acc.concat(
          legendDefinitions.map(legendDefinition => {
            return {
              legendDefinition,
              legend,
            }
          }))
      }, []).sort((a, b) => (b?.legendDefinition?.order || 0) - (a?.legendDefinition?.order || 0))

    sortedLegendDefinitions.forEach(({ legend, legendDefinition }) => {
      const { label, sublabel, legendValues, visible } = legendDefinition
      const legendContainer = OlExtElement.create('ul', {
        className: 'group',
        parent: ul,
        style: {
          display: visible,
        },
      })
      // titre
      const title = OlExtElement.create('li', {
        className: 'title',
        text: label,
        parent: legendContainer,
      })
      if (sublabel) {
        OlExtElement.create('label', {
          className: 'sublabel',
          text: `${sublabel}`,
          parent: title,
        })
      }

      // container des élements de légende
      const ulValues = OlExtElement.create('ul', {
        parent: legendContainer,
      })

      // on tri par ordre alphabétique, sauf les valeurs fallback qui vont apparaitre en dessous
      Object.entries(legendValues)
        .toSorted(([keyA, a], [keyB, b]) => {
          const aIsDefaultFallback = keyA.indexOf('__KMAPV_FALL_BACKVALUE__') === 0
          const bIsDefaultFallback = keyB.indexOf('__KMAPV_FALL_BACKVALUE__') === 0
          return aIsDefaultFallback
            ? 1
            : bIsDefaultFallback
              ? -1
              : a.label.toString().localeCompare(b.label.toString())
        })
        .forEach(([key, value]) => {
          OlExtElement.create('li', {
            className: 'value',
            html: `<img src="${value.img}"><span>${value.label}</span>`,
            parent: ulValues,
          })
        })

      // on masque les élements de légende quand un layer est non visible soit par une interraction utilisateur, soit par un zoom
      this.menuListeners.push(this.on('legendDefinition:visible', (event) => {
        if (event.legendDefinition === legendDefinition) {
          legendContainer.style.display = event.visible ? '' : 'none'

          const hasLegendVisible = this.legends
            .getArray()
            .some(({ legendDefinitions }) =>
              legendDefinitions.some(({ visible }) => visible))
          noVisibleLegend.style.display = !hasLegendVisible ? '' : 'none'
        }
      }))
    })
  }

  /**
   * Remove the interaction from its current map and attach it to the new map.
   * Subclasses may set up event handlers to get notified about changes to
   * the map here.
   * @param {ol.PluggableMap} map Map.
   */
  setMap (map) {
    const currentMap = this.getMap()
    if (currentMap) {
      currentMap.un('change:view', this.onMapViewChange.bind(this))
    }
    // si jamais on change de view (par exemple en changeant un fond de plan qui aurait un srid différent.)
    map.on('change:view', this.onMapViewChange.bind(this))
    this.onMapViewChange({ target: map })
    super.setMap(map)
  }

  setOptions (options) {
    if (options?.maxHeight) {
      this.element.style['max-height'] = options.maxHeight
    }
    if (options?.title) {
      this.element.querySelector('.header div').innerText = options.title
    }
    if (typeof options?.closeable === 'boolean') {
      const e = this.element.querySelector('.header i.kmapv-icon.xs.kmapv-icon-cancel')
      e.style.visibility = options?.closeable === false ? 'hidden' : 'visible'
    }
  }

  /**
   * Ajouter des descripton de légende associées un calque
   * @param {Object} layer
   * @param {LegendOption[]} options
   */
  addLegends (layer, options) {
    const layerId = layer.get(this.viewer.commonLayer.propertiesName.ID_LAYER)
    const legend = {
      layerId,
      layer,
      legendDefinitions: [],
      listeners: [],
      visible: layer.isVisible(),
    }

    options.forEach(legendDefinition => {
      legend.legendDefinitions.push({
        ...legendDefinition,
        legendValues: {},
        visible: this.isLegendVisible(legendDefinition, layer),
      })
    })

    const getLegendImage = (feature, legendDefinition) => {
      const { additionalProperties = {}, zoom = Infinity } = legendDefinition
      let style = layer.getStyle()

      feature.setProperties(additionalProperties)
      // on calcule l'imagine sur maxZoom car il est inclusif
      const resolution = this.viewer.getMap().getView().getResolutionForZoom(zoom)
      // getLegendImage n'utilise pas la résolution, on l'utilise..
      style = typeof (style) === 'function' ? style(feature, resolution, { bypassCluster: true }) : style || []

      // exclu les styles ou on devrait modifier la géométrie..
      style = style.filter(({ usePointOnGeometryType }) => !usePointOnGeometryType)
      const canvas = Legend.getLegendImage({
        feature,
        style,
        margin: 5,
      })
      return canvas.toDataURL()
    }

    function addLegendValue (legendValues, signs, label, feature, legendDefinition, geometryType) {
      try {
        legendValues[signs] = {
          label,
          img: getLegendImage(feature, legendDefinition),
          geometryType,
        }
      } catch (err) {
        console.error('[ControlLegendManager]', err)
        legendValues[signs] = {
          label: `${label} - erreur (voir console)`,
          img: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTIgMmgyMHYxMGgtMlY0SDR2OS41ODZsNS01TDE0LjQxNCAxNEwxMyAxNS40MTRsLTQtNGwtNSA1VjIwaDh2Mkgyem0xMy41NDcgNWExIDEgMCAxIDAgMCAyYTEgMSAwIDAgMCAwLTJtLTMgMWEzIDMgMCAxIDEgNiAwYTMgMyAwIDAgMS02IDBtMy42MjUgNi43NTdMMTkgMTcuNTg2bDIuODI4LTIuODI5bDEuNDE1IDEuNDE1TDIwLjQxNCAxOWwyLjgyOSAyLjgyOGwtMS40MTUgMS40MTVMMTkgMjAuNDE0bC0yLjgyOCAyLjgyOWwtMS40MTUtMS40MTVMMTcuNTg2IDE5bC0yLjgyOS0yLjgyOHoiLz48L3N2Zz4=',
          geometryType,
        }
      }
    }

    // traitement des légendes statiques:
    legend.legendDefinitions
      .filter(({ values }) => !!values)
      .forEach(legendDefinition => {
        const { values, legendValues } = legendDefinition
        values.forEach(({ label, properties, geometryType }) => {
          // créer une feature avec le bon type et les propriétés
          const geometry = geometryType === 'Point'
            ? new Point([0, 0])
            : geometryType === 'LineString'
              ? new LineString([0, 0])
              : geometryType === 'Polygon'
                ? new Polygon([0, 0])
                : null
          const feature = new Feature(geometry)
          feature.setProperties(properties)

          const signs = `${JSON.stringify(properties)} :: ${geometryType}`
          if (!legendValues[signs]) {
            addLegendValue(legendValues, signs, label, feature, legendDefinition, geometryType)
          }
        })
      })

    const searchLegendInfo = (feature) => {
      const geometry = feature.getGeometry()
      if (!geometry) {
        // pas de géométrie, pas de légende
        return
      }
      const geometryType = geometryBaseType[geometry.getType()]
      // on ne traite que les légende dont on doit découvrir les propriétés
      legend.legendDefinitions
        .filter(({ values }) => !values)
        .forEach((legendDefinition) => {
          const { property, discoverValues, discoverValuesFallback, legendValues } = legendDefinition
          // TODO gérer GeometryCollection, il faudrait gérer leur sous-géométrie et leur géométries primaires
          let value = feature.get(property)
          let label = value

          if (discoverValues) {
            value = discoverValues[value] ? value : '__KMAPV_FALL_BACKVALUE__'
            label = discoverValues[value] || discoverValuesFallback?.label || value
          }
          const keepValue = value !== '__KMAPV_FALL_BACKVALUE__' || discoverValuesFallback
          const signs = `${value} :: ${geometryType}`
          let geometry = feature.getGeometry().clone()
          geometry = geometry ? geometry.clone() : undefined
          const purgedFeature = new Feature(geometry)
          purgedFeature.set(property, feature.get(property))

          if (!legendValues[signs] && keepValue) {
            addLegendValue(legendValues, signs, label, purgedFeature, legendDefinition, geometryType)
            this.drawLegend()
          }
        })
    }

    if (legend.legendDefinitions.some(({ values }) => !values)) {
      const source = this.viewer.dataLayer.getSourceLayer(layerId)

      source.getFeatures().forEach(searchLegendInfo)

      legend.listeners.push(source.on('addfeature', ({ feature }) => {
        searchLegendInfo(feature)
      }))
      legend.listeners.push(source.on('changefeature', ({ feature }) => {
        searchLegendInfo(feature)
      }))
      /* legend.listeners.push(source.on('removefeature', ({ feature }) => {
        searchLegendInfo(feature)
      })) */

      legend.listeners.push(this.viewer.on(`removeLayer:${layerId}`, () => {
        this.legends.remove(legend)
      }))
    }

    legend.listeners.push(layer.on('change:visible', this.checkLegendsVisibility.bind(this)))

    this.legends.push(legend)
    this.drawLegend()
  }

  show () {
    this.element.classList.remove('hidden')
    this.dispatchEvent({ type: 'change:visible', visible: true })
  }

  hide () {
    this.element.classList.add('hidden')
    this.dispatchEvent({ type: 'change:visible', visible: false })
  }

  isVisible () {
    return !this.element.classList.contains('hidden')
  }
}

export default ControlLegendManager