Source: modules/SelectionTool.js

/**
 * @module modules/State.js
 * @name State
 * @copyright 2023 3Liz
 * @license MPL-2.0
 */
import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js';

import {
    LinearRing,
    LineString,
    MultiLineString,
    MultiPoint,
    MultiPolygon,
    Point,
    Polygon,
} from 'ol/geom.js';

import * as olExtent from 'ol/extent.js';
import GML3 from 'ol/format/GML3.js';
import GeoJSON from 'ol/format/GeoJSON.js';

import WFS from '../modules/WFS.js';

import {Vector as VectorSource} from 'ol/source.js';
import {Vector as VectorLayer} from 'ol/layer.js';
import { Feature } from 'ol';

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

    constructor() {

        this._layers = [];
        this._allFeatureTypeSelected = [];

        this._bufferValue = 0;

        this._bufferLayer = new VectorLayer({
            source: new VectorSource({wrapX: false}),
        });
        this._bufferLayer.setProperties({
            name: 'LizmapSelectionToolBufferLayer'
        });

        mainLizmap.map.addToolLayer(this._bufferLayer);

        this._geomOperator = 'intersects';

        this._newAddRemove = ['new', 'add', 'remove'];
        this._newAddRemoveSelected = this._newAddRemove[0];

        // Verifying WFS layers
        const featureTypes = mainLizmap.initialConfig.vectorLayerFeatureTypeList;
        if (featureTypes.length === 0) {
            if (document.getElementById('button-selectiontool')) {
                document.getElementById('button-selectiontool').parentNode.remove();
            }
            return false;
        }

        const config = mainLizmap.config;
        const layersSorted = [];

        for (const attributeLayerName in config.attributeLayers) {
            if (config.attributeLayers.hasOwnProperty(attributeLayerName)) {
                for (const featureType of featureTypes) {
                    const lname = mainLizmap.getNameByTypeName(featureType.Name);

                    if (attributeLayerName === lname
                        && lname in config.layers
                        && config.layers[lname]['geometryType'] != 'none'
                        && config.layers[lname]['geometryType'] != 'unknown') {

                        layersSorted[config.attributeLayers[attributeLayerName].order] = {
                            name: lname,
                            title: config.layers[lname].title
                        };
                    }
                }
            }
        }

        for (const params of layersSorted) {
            if (params !== undefined) {
                this._layers.push(params);
                this._allFeatureTypeSelected.push(params.name);
            }
        }

        if (this._layers.length === 0) {
            if (document.getElementById('button-selectiontool')) {
                document.getElementById('button-selectiontool').parentNode.remove();
            }
            return false;
        }

        // Listen to digitizing tool to query a selection when tool is active and a feature (buffered or not) is drawn
        mainEventDispatcher.addListener(
            () => {
                if(this.isActive && mainLizmap.digitizing.featureDrawn){
                    // We only handle a single drawn feature currently
                    if (mainLizmap.digitizing.featureDrawn.length > 1){
                        // Erase the previous feature
                        mainLizmap.digitizing._eraseFeature(mainLizmap.digitizing.featureDrawn[0]);
                        mainLizmap.digitizing.saveFeatureDrawn();
                    }

                    let selectionFeature = mainLizmap.digitizing.featureDrawn[0];

                    if (selectionFeature) {
                        // Handle buffer if any
                        this._bufferLayer.getSource().clear();
                        if (this._bufferValue > 0) {
                            Promise.all([
                                import(/* webpackChunkName: 'OLparser' */ 'jsts/org/locationtech/jts/io/OL3Parser.js'),
                                import(/* webpackChunkName: 'BufferOp' */ 'jsts/org/locationtech/jts/operation/buffer/BufferOp.js')
                            ]).then(([{ default: OLparser }, { default: BufferOp }]) => {
                                const parser = new OLparser();
                                parser.inject(
                                    Point,
                                    LineString,
                                    LinearRing,
                                    Polygon,
                                    MultiPoint,
                                    MultiLineString,
                                    MultiPolygon
                                );

                                // Convert the OpenLayers geometry to a JSTS geometry
                                const jstsGeom = parser.read(selectionFeature.getGeometry());

                                // Create a buffer
                                const jstsbBufferedGeom = BufferOp.bufferOp(jstsGeom, this._bufferValue);

                                const bufferedFeature = new Feature();
                                bufferedFeature.setGeometry(parser.write(jstsbBufferedGeom));

                                this._bufferLayer.getSource().addFeature(bufferedFeature);

                                selectionFeature = this.featureDrawnBuffered;
                            });
                        }

                        for (const featureType of this.allFeatureTypeSelected) {
                            const lConfig = mainLizmap.config.layers[featureType];
                            let typeName = featureType;
                            if ('typename' in lConfig) {
                                typeName = lConfig.typename;
                            } else if ('shortname' in lConfig) {
                                typeName = lConfig.shortname;
                            }

                            // Yo avoid applying reverseAxis (not supported by QGIS GML Parser)
                            // Choose a srsName without reverseAxis
                            let srsName = lConfig.crs;
                            if (srsName == 'EPSG:4326') {
                                srsName = 'CRS:84';
                            }
                            const gml = new GML3({srsName:srsName});

                            // Get the geometry in the layer projection
                            let geom = selectionFeature.getGeometry().clone();
                            geom.transform(mainLizmap.map.getView().getProjection().getCode(), lConfig.crs);

                            // TODO create a geometry collection from the selection draw?
                            const gmlNode = gml.writeGeometryNode(geom);

                            const serializer = new XMLSerializer();

                            let spatialFilter = this._geomOperator + `($geometry, geom_from_gml('${serializer.serializeToString(gmlNode.firstChild)}'))`;


                            let rFilter = lConfig?.request_params?.filter;
                            if( rFilter ){
                                rFilter = rFilter.replace( typeName + ':', '');
                                spatialFilter = rFilter + ' AND ' + spatialFilter;
                            }

                            // Add exp_filter, for example if set by another tool( filter module )
                            // Often 'filter' is not set because filtertoken is set instead
                            // But in this case, exp_filter must also been set and must be added
                            let eFilter = lConfig?.request_params?.exp_filter;
                            if( eFilter ){
                                spatialFilter = eFilter +' AND '+ spatialFilter;
                            }

                            const wfs = new WFS();
                            const wfsParams = {
                                TYPENAME: typeName,
                                EXP_FILTER: spatialFilter
                            };

                            // Apply limit to bounding box config
                            if (mainLizmap.config?.limitDataToBbox === 'True') {
                                wfsParams['BBOX'] = mainLizmap.map.getView().calculateExtent();
                                wfsParams['SRSNAME'] = mainLizmap.map.getView().getProjection().getCode();
                            }

                            // Restrict to current geometry extent for performance
                            // But not with 'disjoint' to get features
                            if (this._geomOperator !== 'disjoint') {
                                let geomExtent = geom.getExtent();
                                if (olExtent.getArea(geomExtent) == 0) {
                                    geomExtent = olExtent.buffer(geomExtent, 0.000001);
                                }
                                wfsParams['BBOX'] = geomExtent;
                                wfsParams['SRSNAME'] = lConfig.crs;
                            }

                            wfs.getFeature(wfsParams).then(response => {
                                const features = (new GeoJSON()).readFeatures(response);

                                // Array of feature ids matching geometry condition
                                let featureIds = features.map(feature => feature.getId().split('.')[1]);

                                if (this.newAddRemoveSelected === 'add' ) { // Add to selection
                                    featureIds = config.layers[featureType]['selectedFeatures'].concat(featureIds);
                                    // Remove duplicates
                                    featureIds = [...new Set(featureIds)];
                                } else if (this.newAddRemoveSelected === 'remove' ) { // Remove from selection
                                    const toRemove = new Set(featureIds);
                                    featureIds = config.layers[featureType]['selectedFeatures'].filter( x => !toRemove.has(x) );
                                }

                                mainLizmap.config.layers[featureType]['selectedFeatures'] = featureIds;
                                lizMap.events.triggerEvent("layerSelectionChanged",
                                    {
                                        'featureType': featureType,
                                        'featureIds': mainLizmap.config.layers[featureType]['selectedFeatures'],
                                        'updateDrawing': true
                                    }
                                );

                            });
                        }
                    }
                }
            },
            ['digitizing.featureDrawn', 'digitizing.editionEnds']
        );

        // Change buffer visibility on digitizing.visibility event
        mainEventDispatcher.addListener(
            () => {
                this._bufferLayer.setVisible(mainLizmap.digitizing.visibility);
            },
            ['digitizing.visibility']
        );

        // Erase buffer on digitizing.erase event
        mainEventDispatcher.addListener(
            () => {
                this._bufferLayer.getSource().clear();
            },
            ['digitizing.erase']
        );

        mainLizmap.lizmap3.events.on({
            minidockclosed: (event) => {
                if (event.id === 'selectiontool'){
                    mainLizmap.digitizing.toolSelected = 'deactivate';
                }
            }
        });
    }

    /**
     * Is selection tool active or not
     * @todo active state should be set on UI's events
     * @readonly
     * @memberof SelectionTool
     * @returns {boolean}
     */
    get isActive() {
        const isActive = document.getElementById('button-selectiontool')?.parentElement?.classList?.contains('active');
        return isActive ? true : false;
    }

    get layers() {
        return this._layers;
    }

    get bufferLayer() {
        return this._bufferLayer;
    }

    get bufferValue() {
        return this._bufferValue;
    }

    set bufferValue(bufferValue) {
        this._bufferValue = isNaN(bufferValue) ? 0 : bufferValue;

        mainEventDispatcher.dispatch('selection.bufferValue');
    }

    get featureDrawnBuffered() {
        const features = this._bufferLayer.getSource().getFeatures();
        if (features.length) {
            return features[0];
        }
        return null;
    }

    // List of WFS format
    get exportFormats() {
        return mainLizmap.initialConfig.vectorLayerResultFormat.filter(
            format => !['GML2', 'GML3', 'GEOJSON'].includes(format.toUpperCase())
        );
    }

    // Selection is exportable if :
    // - one single feature type is selected in list
    // - there is at least one feature selected
    get isExportable(){
        return (this._allFeatureTypeSelected.length === 1 && this.selectedFeaturesCount);
    }

    get selectedFeaturesCount() {
        let count = 0;

        for (const featureType of this.allFeatureTypeSelected) {
            if (featureType in mainLizmap.config.layers &&
                'selectedFeatures' in mainLizmap.config.layers[featureType]
                && mainLizmap.config.layers[featureType]['selectedFeatures'].length) {
                count += mainLizmap.config.layers[featureType]['selectedFeatures'].length;
            }
        }

        return count;
    }

    get filterActive() {
        return mainLizmap.lizmap3.lizmapLayerFilterActive;
    }

    set filterActive(active) {
        mainLizmap.lizmap3.lizmapLayerFilterActive = active;
    }

    get filteredFeaturesCount() {
        let count = 0;

        for (const featureType of this.allFeatureTypeSelected) {
            if (featureType in mainLizmap.config.layers &&
                'filteredFeatures' in mainLizmap.config.layers[featureType]) {
                count += mainLizmap.config.layers[featureType]['filteredFeatures'].length;
            }
        }

        return count;
    }

    get allFeatureTypeSelected() {
        return this._allFeatureTypeSelected;
    }

    set allFeatureTypeSelected(featureType) {
        if (this._allFeatureTypeSelected !== featureType) {
            if (featureType === 'selectable-layers') {
                this._allFeatureTypeSelected = this.layers.map(layer => layer.name);
            } else if (featureType === 'selectable-visible-layers') {
                this._allFeatureTypeSelected = this.layers.map(layer => layer.name).filter(layerName => {
                    for (let index = 0; index < mainLizmap.lizmap3.map.layers.length; index++) {
                        if (mainLizmap.lizmap3.map.layers[index].visibility
                            && mainLizmap.lizmap3.map.layers[index].name === layerName) {
                            return true;
                        }
                    }
                });
            } else {
                this._allFeatureTypeSelected = [featureType];
            }

            mainEventDispatcher.dispatch('selectionTool.allFeatureTypeSelected');
        }
    }

    set geomOperator(geomOperator) {
        if (this._geomOperator !== geomOperator) {
            this._geomOperator = geomOperator;
        }
    }

    get newAddRemoveSelected() {
        return this._newAddRemoveSelected;
    }

    set newAddRemoveSelected(newAddRemove) {
        if (this._newAddRemove.includes(newAddRemove)) {
            this._newAddRemoveSelected = newAddRemove;

            mainEventDispatcher.dispatch('selectionTool.newAddRemoveSelected');
        }
    }

    disable() {
        if (this.isActive) {
            document.getElementById('button-selectiontool').click();
        }
    }

    unselect() {
        for (const featureType of this.allFeatureTypeSelected) {
            mainLizmap.lizmap3.events.triggerEvent('layerfeatureunselectall',
                {'featureType': featureType, 'updateDrawing': true}
            );
        }
        mainLizmap.digitizing.drawLayer.getSource().clear();
        this._bufferLayer.getSource().clear();
    }

    filter() {
        if (this.filteredFeaturesCount) {
            for (const featureType of this.allFeatureTypeSelected) {
                mainLizmap.lizmap3.events.triggerEvent('layerfeatureremovefilter',
                    {'featureType': featureType}
                );
            }
            this.filterActive = null;
        } else {
            for (const featureType of this.allFeatureTypeSelected) {
                if (featureType in mainLizmap.config.layers &&
                    'selectedFeatures' in mainLizmap.config.layers[featureType]
                    && mainLizmap.config.layers[featureType]['selectedFeatures'].length) {
                    this.filterActive = featureType;

                    mainLizmap.lizmap3.events.triggerEvent('layerfeaturefilterselected',
                        {'featureType': featureType}
                    );
                }
            }
        }
    }

    // Invert selection on a single layer
    invert(mfeatureType) {
        const featureType = mfeatureType ? mfeatureType : this.allFeatureTypeSelected[0];

        if (featureType in mainLizmap.config.layers &&
            'selectedFeatures' in mainLizmap.config.layers[featureType]
            && mainLizmap.config.layers[featureType]['selectedFeatures'].length) {

            // Get all features
            mainLizmap.lizmap3.getFeatureData(featureType, null, null, 'extent', false, null, null,
                (aName, aFilter, cFeatures) => {
                    const invertSelectionIds = [];
                    for (const feat of cFeatures) {
                        const fid = feat.id.split('.')[1];

                        if (!mainLizmap.config.layers[aName]['selectedFeatures'].includes(fid)) {
                            invertSelectionIds.push(fid);
                        }
                    }

                    mainLizmap.config.layers[featureType]['selectedFeatures'] = invertSelectionIds;

                    mainLizmap.lizmap3.events.triggerEvent('layerSelectionChanged',
                        {
                            'featureType': featureType,
                            'featureIds': mainLizmap.config.layers[featureType]['selectedFeatures'],
                            'updateDrawing': true
                        }
                    );
                });
        }
    }

    export(format) {
        if (format === 'GML') {
            format = 'GML3';
        }
        mainLizmap.lizmap3.exportVectorLayer(this._allFeatureTypeSelected[0], format, false);
    }

    /**
     * select layer's features with a feature and a geometry operator
     * @param targetFeatureType
     * @param selectionFeature - selection feature in map projection
     * @param geomOperator
     */
    selectLayerFeaturesFromSelectionFeature(targetFeatureType, selectionFeature, geomOperator = 'intersects'){

        const lConfig = lizMap.config.layers[targetFeatureType];

        const GML3Format = new GML3({srsName: mainLizmap.projection});

        const geomAsGML = GML3Format.writeGeometryNode( selectionFeature.getGeometry());

        let spatialFilter = geomOperator+"($geometry, geom_from_gml('" + geomAsGML.innerHTML + "'))";

        if( 'request_params' in lConfig && 'filter' in lConfig['request_params'] ){
            let rFilter = lConfig['request_params']['filter'];
            if( rFilter ){
                rFilter = rFilter.replace( targetFeatureType + ':', '');
                spatialFilter = rFilter +' AND '+ spatialFilter;
            }
        }
        if( 'request_params' in lConfig && 'exp_filter' in lConfig['request_params'] ){
            // Add exp_filter, for example if set by another tool( filter module )
            // Often 'filter' is not set because filtertoken is set instead
            // But in this case, exp_filter must also been set and must be added
            let eFilter = lConfig['request_params']['exp_filter'];
            if( eFilter ){
                spatialFilter = eFilter +' AND '+ spatialFilter;
            }
        }
        let limitDataToBbox = false;
        if ( 'limitDataToBbox' in lizMap.config.options && lizMap.config.options.limitDataToBbox == 'True'){
            limitDataToBbox = true;
        }
        let getFeatureUrlData = lizMap.getVectorLayerWfsUrl( targetFeatureType, spatialFilter, null, null, limitDataToBbox );

        // add BBox to restrict to geom bbox but not with some geometry operator
        if (geomOperator !== 'disjoint'){
            getFeatureUrlData['options']['BBOX'] = selectionFeature.getGeometry().getExtent().join();
            getFeatureUrlData['options']['SRSNAME'] = lConfig.crs;
        }

        // get features
        fetch(getFeatureUrlData['url'], {
            method: "POST",
            body: new URLSearchParams(getFeatureUrlData['options'])
        }).then(response => {
            return response.json();
        }).then(result => {
            const features = (new GeoJSON()).readFeatures(result, {
                featureProjection: mainLizmap.projection
            });

            let sfIds = features.map(feat => feat.getId().split('.')[1]);

            if (this._newAddRemoveSelected === 'add') {
                sfIds = lConfig['selectedFeatures'].concat(sfIds);
                for (let i = 0; i < sfIds.length; ++i) {
                    for (let j = i + 1; j < sfIds.length; ++j) {
                        if (sfIds[i] === sfIds[j])
                            sfIds.splice(j--, 1);
                    }
                }
            } else if (this._newAddRemoveSelected === 'remove') {
                let asfIds = lConfig['selectedFeatures'].concat([]);
                for (let i = 0; i < sfIds.length; ++i) {
                    let asfIdIdx = asfIds.indexOf(sfIds[i]);
                    if (asfIdIdx != -1)
                        asfIds.splice(asfIdIdx, 1);
                }
                sfIds = asfIds;
            }
            lConfig['selectedFeatures'] = sfIds;
            lizMap.events.triggerEvent("layerSelectionChanged",
                {
                    'featureType': targetFeatureType,
                    'featureIds': lConfig['selectedFeatures'],
                    'updateDrawing': true
                }
            );
        });
    }
}