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