Source: modules/Tooltip.js

/**
 * @module modules/Tooltip.js
 * @name Tooltip
 * @copyright 2024 3Liz
 * @license MPL-2.0
 */
import { mainEventDispatcher, mainLizmap } from '../modules/Globals.js';
import { TooltipLayersConfig } from './config/Tooltip.js';
import WMS from '../modules/WMS.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import { Circle, Fill, Stroke, Style } from 'ol/style.js';
import { Reader, createOlStyleFunction, getLayer, getStyle } from '@nieuwlandgeo/sldreader/src/index.js';

/**
 * @class
 * @name Tooltip
 */
export default class Tooltip {

    /**
     * Create the tooltip Map
     * @param {Map}        map        - OpenLayers map
     * @param {TooltipLayersConfig} tooltipLayersConfig - The config tooltipLayers
     * @param {object}        lizmap3       - The old lizmap object
     */
    constructor(map, tooltipLayersConfig, lizmap3) {
        this._map = map;
        this._tooltipLayersConfig = tooltipLayersConfig;
        this._lizmap3 = lizmap3;
        this._activeTooltipLayer;
        this._tooltipLayers = new Map();
        this.activeLayerOrder = null;

        mainLizmap.state.rootMapGroup.addListener(
            evt => {
                this._applyFilter(evt.name);
            },
            'layer.filter.token.changed'
        );
    }

    /**
     * Activate tooltip for a layer order
     * @param {number} layerOrder a layer order
     */
    activate(layerOrder) {
        // If the layer order is empty, deactivate the tooltip
        if (layerOrder === "") {
            this.deactivate();
            return;
        }

        // Remove previous layer if any
        this._map.removeToolLayer(this._activeTooltipLayer);

        const layerTooltipCfg = this._tooltipLayersConfig.layerConfigs[layerOrder];
        const layerName = layerTooltipCfg.name;
        const tooltipLayer = this._tooltipLayers.get(layerName);
        this._displayGeom = layerTooltipCfg.displayGeom;
        this._displayLayerStyle = layerTooltipCfg.displayLayerStyle;

        // Styles
        const fill = new Fill({
            color: 'transparent',
        });

        const stroke = new Stroke({
            color: 'rgba(255, 255, 255, 0.01)', // 'transparent' doesn't work for lines. Is it a bug in OL?
        });

        const hoverColor = layerTooltipCfg.colorGeom;

        const hoverStyle = feature => {
            if (['Polygon', 'MultiPolygon'].includes(feature.getGeometry().getType())) {
                return new Style({
                    fill: fill,
                    stroke: new Stroke({
                        color: hoverColor,
                        width: 3
                    }),
                });
            } else if (['LineString', 'MultiLineString'].includes(feature.getGeometry().getType())) {
                return new Style({
                    stroke: new Stroke({
                        color: hoverColor,
                        width: 6
                    }),
                });
            } else if (['Point', 'MultiPoint'].includes(feature.getGeometry().getType())) {
                return new Style({
                    image: new Circle({
                        fill: fill,
                        stroke: new Stroke({
                            color: hoverColor,
                            width: 3
                        }),
                        radius: 5,
                    }),
                    fill: fill,
                    stroke: new Stroke({
                        color: hoverColor,
                        width: 3
                    }),
                });
            }
            return undefined;
        }

        if (tooltipLayer) {
            this._activeTooltipLayer = tooltipLayer;
            this._applyFilter(layerName);
        } else {
            const url = `${lizUrls.service.replace('service?','features/tooltips?')}&layerId=${layerTooltipCfg.id}`;

            const vectorStyle = new Style({
                image: new Circle({
                    fill: fill,
                    stroke: stroke,
                    radius: 5,
                }),
                fill: fill,
                stroke: stroke,
            });

            // Initially hidden, will be set to 1 when features are loaded and filter is applied
            // to avoid visual flickering
            // Using the visible property of the layer does not work
            this._activeTooltipLayer = new VectorLayer({
                opacity: 0,
                source: new VectorSource({
                    url: url,
                    format: new GeoJSON(),
                }),
                style: vectorStyle
            });

            // Handle points layers with QGIS style
            if (this._displayLayerStyle) {
                const wmsParams = {
                    LAYERS: layerName
                };

                const wms = new WMS();

                wms.getStyles(wmsParams).then((response) => {
                    const sldObject = Reader(response);

                    const sldLayer = getLayer(sldObject);
                    const style = getStyle(sldLayer);
                    const featureTypeStyle = style.featuretypestyles[0];

                    const olStyleFunction = createOlStyleFunction(featureTypeStyle, {
                        imageLoadedCallback: () => {
                            // Signal OpenLayers to redraw the layer when an image icon has loaded.
                            // On redraw, the updated symbolizer with the correct image scale will be used to draw the icon.
                            this._activeTooltipLayer.changed();
                        },
                    });

                    this._activeTooltipLayer.setStyle(olStyleFunction);
                });
            }

            // Load tooltip layer
            this._activeTooltipLayer.once('sourceready', () => {
                this._applyFilter(layerName);
                mainEventDispatcher.dispatch('tooltip.loaded');
            });

            this._activeTooltipLayer.getSource().on('featuresloaderror', () => {
                this._lizmap3.addMessage(lizDict['tooltip.loading.error'], 'danger', true);
                console.warn(`Tooltip layer '${layerName}' could not be loaded.`);
            });

            mainEventDispatcher.dispatch('tooltip.loading');

            this._tooltipLayers.set(layerName, this._activeTooltipLayer);
        }

        this._map.addToolLayer(this._activeTooltipLayer);

        const tooltip = document.getElementById('tooltip');

        let currentFeature;

        this._onPointerMove = event => {
            const pixel = this._map.getEventPixel(event.originalEvent);
            const target = event.originalEvent.target;

            if (currentFeature) {
                currentFeature.setStyle(undefined);
            }

            if (event.dragging) {
                tooltip.style.visibility = 'hidden';
                currentFeature = undefined;
                return;
            }

            const feature = target.closest('.ol-control')
                ? undefined
                : this._map.forEachFeatureAtPixel(pixel, feature => {
                    return feature; // returning a truthy value stop detection
                }, {
                    hitTolerance: 5,
                    layerFilter: layerCandidate => layerCandidate == this._activeTooltipLayer
                });

            if (feature) {
                // Set hover style if `Display geom` is true
                if (this._displayGeom){
                    feature.setStyle(hoverStyle);
                }
                // Increase point size on hover
                else if (this._displayLayerStyle){
                    const olStyleFunction = this._activeTooltipLayer.getStyleFunction();
                    const mapResolution = this._map.getView().getResolution();
                    const olStyle = olStyleFunction(feature, mapResolution);

                    const newStyle = [];
                    for (const style of olStyle) {
                        const clonedStyle = style.clone();
                        // If the style is a Circle, increase its radius
                        // We could increase the scale but pixels are blurry
                        const newRadius = clonedStyle.getImage().getRadius?.() * 1.5;
                        if (newRadius) {
                            clonedStyle.getImage().setRadius(newRadius);
                        } else {
                            // If the style is not a Circle, we can still increase the scale
                            const newScale = clonedStyle.getImage().getScale() * 1.5;
                            clonedStyle.getImage().setScale(newScale);
                        }
                        newStyle.push(clonedStyle);
                    }

                    feature.setStyle(newStyle);
                }

                // Display tooltip
                tooltip.style.left = pixel[0] + 'px';
                tooltip.style.top = pixel[1] + 'px';
                const tooltipHTML = feature.get('tooltip');
                if (feature !== currentFeature && tooltip) {
                    tooltip.style.visibility = 'visible';
                    tooltip.innerHTML = tooltipHTML;
                }
            } else {
                tooltip.style.visibility = 'hidden';
            }
            currentFeature = feature;
        };

        this._map.on('pointermove', this._onPointerMove);

        this._onPointerLeave = () => {
            currentFeature = undefined;
            tooltip.style.visibility = 'hidden';
        };

        this._map.getTargetElement().addEventListener('pointerleave', this._onPointerLeave);

        // Dispatch event to notify that the tooltip is activated
        this.activeLayerOrder = layerOrder;
        mainEventDispatcher.dispatch('tooltip.activated', { layerOrder: layerOrder });
    }

    /**
     * Deactivate tooltip
     */
    deactivate() {
        this._map.removeToolLayer(this._activeTooltipLayer);
        if (this._onPointerMove) {
            this._map.un('pointermove', this._onPointerMove);
        }
        if (this._onPointerLeave) {
            this._map.getTargetElement().removeEventListener('pointerleave', this._onPointerLeave);
        }

        // Dispatch event to notify that the tooltip is deactivated
        this.activeLayerOrder = null;
        mainEventDispatcher.dispatch('tooltip.deactivated');
    }

    _applyFilter(layerName) {
        const tooltipLayer = this._tooltipLayers.get(layerName);

        if (!tooltipLayer) {
            // No tooltip layer for this feature type
            return;
        }

        const expFilter = mainLizmap.state.rootMapGroup.getMapLayerByName(layerName).itemState.expressionFilter;
        let featureIds = [];

        const hideFilteredFeatures = () => {
            for (const feature of tooltipLayer.getSource().getFeatures()) {
                // If the feature id is not in the list, hide it
                if (featureIds.length === 0 || featureIds.includes(feature.getId())) {
                    feature.setStyle(null);
                } else {
                    feature.setStyle(new Style({}));
                }
            }
            // Display the layer now all styles are applied
            tooltipLayer.setOpacity(1);
        };

        if(!expFilter) {
            hideFilteredFeatures();
            return;
        }

        if (expFilter.startsWith('$id IN ')) {
            const re = /[() ]/g;
            featureIds = expFilter.replace('$id IN ', '').replace(re, '').split(',').map(Number);
            hideFilteredFeatures();
        } else {
            const wfsParams = {
                TYPENAME: layerName,
                // No geometry needed
                GEOMETRYNAME: 'none',
                // Force to return only the featureId
                PROPERTYNAME: 'no_feature_properties',
                // Filter
                EXP_FILTER: expFilter
            };
            mainLizmap.wfs.getFeature(wfsParams).then(result => {
                featureIds = result.features.map(f => parseInt(f.id.split('.')[1]));
                hideFilteredFeatures();
            });
        }
    }
}