UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

1,118 lines 51.6 kB
// SPDX-License-Identifier: Apache-2.0
import DrawingFeature, { DrawingShape } from './drawingFeature.js';
import { Collection, Feature } from 'ol';
import { Circle as CircleGeom, Geometry, LinearRing, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon } from 'ol/geom.js';
import { createBox, createRegularPolygon } from 'ol/interaction/Draw.js';
import { Circle, Fill, RegularShape, Stroke, Style, Text } from 'ol/style.js';
import { Draw, Modify, Snap, Translate } from 'ol/interaction.js';
import VectorSource from 'ol/source/Vector.js';
import VectorLayer from 'ol/layer/Vector.js';
import { getPointResolution } from 'ol/proj.js';
import { always, never, platformModifierKeyOnly, primaryAction } from 'ol/events/condition.js';
import { unByKey } from 'ol/Observable.js';
import { ensurePolygonIsProperlyClosed, getAreaOfPolygon, getDistance, getHalfPoint, getLabelStyle, getLengthAsMetricText, getRadiusDataForCircle, unByKeyAll } from '../utils/olutils.js';
import { ContextMenu, EntryInteractionType } from '../../components/map/tools/contextmenu.js';
import { formatCoordinates } from '../geometrytools.js';
import { v4 as uuidv4 } from 'uuid';
import { isAlternateMouseClick, isPrimaryPointerAction } from '../state/userinteractionevent.js';
import { calculateCenterAndMinRadius, createScaledAndRotatedGeometry, getGeometryForRendering, shouldAllowVertexInsertionForShape } from './shapeConstraints.js';
import Transform from 'ol-ext/interaction/Transform.js';
function getLineStroke(strokeType, lineWidth) {
    switch (strokeType) {
        case 'full':
        case 'double':
            return undefined;
        case 'dash':
            return [3 * lineWidth, 5 * lineWidth];
        case 'dot':
            // minimal length creates dots with rounded lineCap
            return [0.01, 5 * lineWidth];
    }
}
function extractVerticesFromGeometry(geometry) {
    let vertices = [];
    // Extract coordinates depending on coordinate array depth
    if (geometry instanceof Point) {
        vertices = [geometry.getCoordinates()];
    }
    else if (geometry instanceof MultiPoint || geometry instanceof LineString || geometry instanceof LinearRing) {
        vertices = geometry.getCoordinates();
    }
    else if (geometry instanceof Polygon || geometry instanceof MultiLineString) {
        vertices = geometry.getCoordinates().flat();
    }
    else if (geometry instanceof MultiPolygon) {
        vertices = geometry
            .getCoordinates()
            .flat()
            .map((coordinateList) => coordinateList.flat());
    }
    return new MultiPoint(vertices);
}
export default class OlDrawing {
    map;
    // Writes drawn features into the specified location in the extended state
    stateLocation;
    context;
    deleteHandler;
    modifiableFeatures = new Collection([]);
    draw = null;
    modify = null;
    snap = null;
    editContextMenu = null;
    currentShape = null;
    fixedLineLength = 0;
    fixedSquareSide = 0;
    fixedRectangleWidth = 0;
    fixedRectangleHeight = 0;
    drawingText = '';
    drawingSource;
    drawingLayer;
    lastClosestFeature = null;
    translate = null;
    transform = null;
    modifyFeatureChangeListeners = [];
    hoverListener = null;
    selectListener = null;
    hoveredTextFeatureId = null;
    defaultStyle;
    updateGeometryInState = (olFeature) => {
        const idx = this.drawingState.features.findIndex((f) => f.id === olFeature.getId());
        const drawingFeature = this.drawingState.features[idx];
        if (idx > -1) {
            this.drawingState.features[idx].geojson = DrawingFeature.geojsonFromOlFeature(olFeature, drawingFeature.type);
        }
    };
    get state() {
        return this.context.stateManager.state;
    }
    get drawingState() {
        return this.state.extendedState[this.stateLocation];
    }
    get config() {
        return this.context.configManager.Config;
    }
    constructor(mapComponent, stateLocation, context, deleteHandler) {
        this.map = mapComponent;
        this.stateLocation = stateLocation;
        this.context = context;
        this.deleteHandler = deleteHandler;
        this.drawingSource = new VectorSource({ features: new Collection() });
        this.drawingSource.on('addfeature', (e) => this.onFeatureAdded(e));
        this.drawingLayer = new VectorLayer({
            source: this.drawingSource,
            zIndex: 1001,
            properties: {
                addToPrintedLayers: true,
                altitudeMode: 'clampToGround'
            }
        });
        this.defaultStyle = new Modify({ source: this.drawingSource }).getOverlay().getStyleFunction();
        this.map.subscribe(`extendedState.${this.stateLocation}.activeTool`, (_oldTool, newTool) => newTool === null ? this.removeDrawInteraction() : this.addDrawInteraction(newTool));
        // Could be useful logic if we decide that the mobile UI should rely use a dedicated button
        // to remove a selected vetex from a polygon/line (rather than using a longpress context menu)
        // this.map.olMap.on("click", (e) => {
        //   if (this.state.interface.swipeupPanelContent === "drawing" &&
        //     this.modify?.getActive() &&
        //     this.modifiableFeatures.getArray().length > 0
        //   ) {
        //     const mapCoordinate = this.map.olMap.getCoordinateFromPixel(e.pixel);
        //     const hasEditableVertex = this.hasEditableVertexAtCoordinate(mapCoordinate)
        //     console.log("Map Coordinate:", mapCoordinate, e.pixel, hasEditableVertex);
        //   }
        // })
        this.map.subscribe(new RegExp(String.raw `extendedState.${this.stateLocation}.features.*\.selected`, 'g'), (_old, _new) => this.updateModifiableFeatures());
        // OlCesium duplicates drawn shapes when 3D view is open if its eventListener is not removed
        this.map.subscribe('globe.loaded', () => {
            if (this.state.globe.loaded) {
                this.drawingSource
                    .getListeners('addfeature')
                    ?.forEach((l) => this.drawingSource.removeEventListener('addfeature', l));
                this.drawingSource.on('addfeature', (e) => this.onFeatureAdded(e));
            }
        });
    }
    addModifyInteraction() {
        this.removeModifyInteraction();
        const vertexStyle = new DrawingFeature(DrawingShape.Point, this.drawingState, this.config.drawing).getVertexStyle(true);
        this.modify = new Modify({
            features: this.modifiableFeatures,
            // Feature editing is triggered by: 1) primary action = click or touch, 2) alternate mouse click = remove vertex
            // If another tool is exclusively modifying, this interaction will be prevented from reacting via canExecute()
            condition: (e) => (isPrimaryPointerAction(e) ||
                isAlternateMouseClick(e)) &&
                this.canExecute('map.modify'),
            deleteCondition: never,
            // For square/rectangle, we disable vertex insertion only on the hovered shape.
            insertVertexCondition: (e) => primaryAction(e) && this.shouldAllowVertexInsertionAtEvent(e),
            style: (feature) => {
                this.applyScaleRotateOnModifyStyle(feature);
                return vertexStyle;
            },
            snapToPointer: true,
            pixelTolerance: this.map.pixelTolerance
        });
        this.map.olMap.addInteraction(this.modify);
        this.modify.on('modifystart', (e) => {
            this.clearModifyFeatureChangeListeners();
            e.features.forEach((olFeature) => {
                const geometry = olFeature.getGeometry();
                if (!geometry) {
                    return;
                }
                const shapeType = this.drawingState.features.find((f) => f.id === olFeature.getId())?.type;
                if (shapeType === DrawingShape.Square || shapeType === DrawingShape.Rectangle) {
                    olFeature.set('modifyGeometry', { geometry: geometry.clone() }, true);
                }
                else if (shapeType === DrawingShape.Disk) {
                    const listener = olFeature.on('change', (e) => {
                        const geometry = e.target.getGeometry();
                        if (!geometry) {
                            return;
                        }
                        if (geometry.getType() === 'Circle') {
                            const circle = geometry;
                            const newRadius = circle.getRadius();
                            const properties = circle.getProperties();
                            const { pointer } = properties;
                            const oldRadiusLine = new LineString([circle.getCenter(), pointer]);
                            oldRadiusLine.scale(newRadius / oldRadiusLine.getLength());
                            circle.setProperties({
                                ...properties,
                                pointer: oldRadiusLine.getLastCoordinate()
                            });
                        }
                    });
                    this.modifyFeatureChangeListeners.push(listener);
                }
            });
        });
        // Update the modified geometries in the state
        this.modify.on('modifyend', (e) => {
            this.clearModifyFeatureChangeListeners();
            e.features.forEach((olFeature) => {
                const shapeType = this.drawingState.features.find((f) => f.id === olFeature.getId())?.type;
                if (shapeType === DrawingShape.Square || shapeType === DrawingShape.Rectangle) {
                    const modifyGeometry = olFeature.get('modifyGeometry');
                    if (modifyGeometry?.geometry) {
                        olFeature.setGeometry(modifyGeometry.geometry);
                    }
                    olFeature.unset('modifyGeometry', true);
                }
                this.updateGeometryInState(olFeature); //NOSONAR(typescript:S7728) Collection<?> is a custom Implementation with custom forEach
            });
        });
    }
    addSnapInteraction() {
        this.removeSnapInteraction();
        // Activate snapping on all existing drawing shapes
        this.snap = new Snap({ source: this.drawingSource, pixelTolerance: this.map.pixelTolerance });
        this.map.olMap.addInteraction(this.snap);
    }
    addTranslateInteraction() {
        this.removeTranslateInteraction();
        this.translate = new Translate({
            condition: (event) => {
                return this.translate != null && primaryAction(event);
            },
            layers: [this.drawingLayer]
        });
        this.map.olMap.addInteraction(this.translate);
    }
    removeTranslateInteraction() {
        if (this.translate) {
            this.map.olMap.removeInteraction(this.translate);
            this.translate = null;
        }
    }
    addTransformInteraction() {
        this.transform = new Transform({
            enableRotatedTransform: false,
            features: new Collection(this.lastClosestFeature ? [this.lastClosestFeature] : []),
            hitTolerance: this.map.pixelTolerance,
            translateFeature: false,
            scale: true,
            rotate: true,
            keepAspectRatio: always,
            keepRectangle: false,
            translate: false,
            stretch: false,
            pointRadius: function (f) {
                const radius = f.get('radius') || 10;
                return [radius, radius];
            }
        });
        this.map.olMap.addInteraction(this.transform);
        if (this.lastClosestFeature) {
            this.transform.select(this.lastClosestFeature, true);
            this.lastClosestFeature.changed();
        }
    }
    removeTransformInteraction() {
        if (this.transform) {
            this.map.olMap.removeInteraction(this.transform);
            this.transform = null;
            this.lastClosestFeature?.changed();
        }
    }
    /**
     * Adds a context menu to the map with a single entry 'remove vertex'. The menu is configured to open
     * when the user does an alternate click ( = context event) on or near a vertex of a modifiable feature.
     */
    addEditContextMenu() {
        this.removeEditContextMenu();
        const menuEntries = [
            {
                entry: 'Remove Vertex',
                callback: (_evt, mapCoordinate) => {
                    const successful = this.removeLastInteractedVertex();
                    if (!successful) {
                        const errorMessage = `It's not possible to remove vertex at ${formatCoordinates(mapCoordinate, this.config.general.locale)}`;
                        this.state.infobox.elements.push({
                            id: uuidv4(),
                            text: errorMessage,
                            type: 'warning'
                        });
                    }
                },
                prepare: this.prepareMenuEntriesForVertex
            },
            {
                entry: 'Move Shape',
                callback: (_evt, _mapCoordinate) => {
                    if (this.lastClosestFeature) {
                        this.removeModifyInteraction();
                        this.addTranslateInteraction();
                        this.map.olMap.on('singleclick', () => {
                            this.removeTranslateInteraction();
                            this.addModifyInteraction();
                        });
                    }
                },
                prepare: this.prepareMenuEntriesForShape
            },
            {
                entry: 'Rotate/Scale Shape',
                callback: (_evt, _mapCoordinate) => {
                    if (this.lastClosestFeature) {
                        this.removeModifyInteraction();
                        this.addTransformInteraction();
                        this.map.olMap.on('singleclick', () => {
                            this.removeTransformInteraction();
                            this.addModifyInteraction();
                        });
                    }
                },
                prepare: this.prepareMenuEntriesForShape
            },
            {
                entry: 'Remove Shape',
                callback: (_evt, _mapCoordinate) => {
                    this.removeLastInteractedShape();
                },
                prepare: this.prepareMenuEntriesForShape
            }
        ];
        const conditionToOpen = (_evt, mapCoordinate) => {
            if (!this.modify?.getActive() || this.modifiableFeatures.getArray().length === 0) {
                return false;
            }
            // Only proceed if there is an editable vertex under the mouse pointer
            return this.hasEditableShapeAtCoordinate(mapCoordinate) || this.hasEditableVertexAtCoordinate(mapCoordinate);
        };
        this.editContextMenu = new ContextMenu(this.context, menuEntries, true, conditionToOpen);
    }
    addEditInteractions() {
        if (!this.modify) {
            this.addModifyInteraction();
        }
        if (!this.editContextMenu) {
            this.addEditContextMenu();
        }
        this.addHoverListener();
        // Always recreate snap interaction to get snapping behavior on latest features
        this.addSnapInteraction();
    }
    removeEditInteractions() {
        this.removeModifyInteraction();
        this.removeEditContextMenu();
        this.removeHoverListener();
        if (!this.draw) {
            this.removeSnapInteraction();
        }
    }
    addHoverListener() {
        this.hoverListener ??= this.map.olMap.on('pointermove', (e) => this.updateHoveredTextFeature(e));
    }
    removeHoverListener() {
        if (this.hoverListener) {
            unByKey(this.hoverListener);
            this.hoverListener = null;
        }
        this.setHoveredTextFeature(null);
    }
    addSelectListener() {
        this.selectListener ??= this.map.olMap.on('singleclick', (e) => this.selectClickedFeature(e));
    }
    removeSelectListener() {
        if (this.selectListener) {
            unByKey(this.selectListener);
            this.selectListener = null;
        }
    }
    /**
     * Lets the user select the shapes to edit directly in the map, as an alternative to the drawing list:
     * a click selects the shape under the cursor exclusively, a click with the platform modifier key (ctrl / meta)
     * adds the shape to the current selection or removes it from it. A click outside of any shape clears the selection.
     */
    selectClickedFeature(e) {
        // While drawing, moving or transforming a shape, the click belongs to the interaction in progress
        if (this.draw || this.translate || this.transform || !this.canExecute('map.select')) {
            return;
        }
        const clickedFeatureId = this.findFeatureIdAtPixel(e.pixel);
        if (platformModifierKeyOnly(e)) {
            const clickedFeature = this.drawingState.features.find((f) => f.id === clickedFeatureId);
            if (clickedFeature) {
                this.setFeatureSelected(clickedFeature, !clickedFeature.selected);
            }
        }
        else {
            this.drawingState.features.forEach((f) => this.setFeatureSelected(f, f.id === clickedFeatureId));
        }
    }
    /**
     * Only writes to the state if the selection of the shape really changed, so that the map and the drawing
     * panel are not refreshed for the shapes that are not concerned by the click.
     */
    setFeatureSelected(dFeature, selected) {
        if (dFeature.selected !== selected) {
            dFeature.selected = selected;
        }
    }
    findFeatureIdAtPixel(pixel) {
        const clickedFeature = this.map.olMap.forEachFeatureAtPixel(pixel, (feature, layer) => (feature instanceof Feature && layer === this.drawingLayer ? feature : undefined), {
            hitTolerance: this.map.pixelTolerance
        });
        return clickedFeature ? (clickedFeature.getId() ?? null) : null;
    }
    prepareMenuEntriesForVertex = (_e, mapCoordinate) => {
        if (!this.hasEditableVertexAtCoordinate(mapCoordinate)) {
            return EntryInteractionType.NOT_SHOWN;
        }
        if (this.isSquareRectangleOrText(this.getDrawingShapeType(this.lastClosestFeature))) {
            return EntryInteractionType.NOT_SHOWN;
        }
        return EntryInteractionType.ENABLED;
    };
    prepareMenuEntriesForShape = (_e, mapCoordinate) => {
        return this.hasEditableShapeAtCoordinate(mapCoordinate)
            ? EntryInteractionType.ENABLED
            : EntryInteractionType.NOT_SHOWN;
    };
    /**
     Check if there is a vertex of a selected (=editable) feature within the pixel tolerance of the clicked coordinates.
     */
    hasEditableVertexAtCoordinate(coordinate) {
        const filter = (olFeature) => this.modifiableFeatures.getArray().some((editFeature) => olFeature == editFeature);
        // Use filter to only query currently selected features
        const closestElements = this.getClosestVertexAndFeature(coordinate, filter);
        if (closestElements) {
            const closestVertex = closestElements[0];
            const vertexAsPixel = this.map.olMap.getPixelFromCoordinate(closestVertex);
            const clickAsPixel = this.map.olMap.getPixelFromCoordinate(coordinate);
            const dx = vertexAsPixel[0] - clickAsPixel[0];
            const dy = vertexAsPixel[1] - clickAsPixel[1];
            const distanceToVertex = Math.hypot(dx, dy);
            if (distanceToVertex < this.map.pixelTolerance) {
                return true;
            }
        }
        return false;
    }
    hasEditableShapeAtCoordinate(coordinate) {
        const filter = (olFeature) => this.modifiableFeatures.getArray().some((editFeature) => olFeature == editFeature);
        // Use filter to only query currently selected features
        const closestElements = this.getClosestVertexAndFeature(coordinate, filter);
        if (closestElements) {
            const closestFeature = closestElements[1];
            return closestFeature.getGeometry()?.containsXY(coordinate[0], coordinate[1]) ?? false;
        }
        return false;
    }
    /**
     Returns the closest vertex and feature to a coordinate from the drawing source.
     The feature source can be pre-filtered via an optional filter function.
     */
    getClosestVertexAndFeature(coordinate, filter = () => true) {
        const olFeature = this.drawingSource.getClosestFeatureToCoordinate(coordinate, filter);
        const geometry = olFeature?.getGeometry();
        if (geometry && olFeature) {
            this.lastClosestFeature = olFeature;
            const vertices = extractVerticesFromGeometry(geometry);
            const closestVertex = vertices.getClosestPoint(coordinate);
            return [closestVertex, olFeature];
        }
        this.lastClosestFeature = null;
        return undefined;
    }
    /**
     Deletes the vertex the user interacted with last via the modify interaction. Handled events are defined by the
     modify option properties 'condition' and 'insertVertexCondition'.
     */
    removeLastInteractedVertex() {
        return this.modify?.removePoint();
    }
    removeLastInteractedShape() {
        if (this.lastClosestFeature) {
            this.deleteHandler(this.lastClosestFeature.getId());
            this.lastClosestFeature = null;
            return true;
        }
        return false;
    }
    /**
     * Adds features to the drawing source if they are missing and updates their style each time a property changes.
     * Adding them to the source is only necessary if the feature originates from a deserialized state and not
     * from a drawing action in the map.
     *
     * @param {DrawingFeature[]} dFeatures - An array of `DrawingFeature` objects to be added.
     */
    addFeatures(dFeatures) {
        for (const df of dFeatures) {
            let olFeature = this.getOlFeatureFromDrawingSource(df.id);
            if (olFeature === null) {
                olFeature = this.createOlFeature(df);
                this.drawingSource.addFeature(olFeature);
            }
            df.onChange = (df) => olFeature.setStyle((f) => this.getStyle(df, f));
            df.onChange(df);
        }
    }
    /**
     * Deletes the provided features from the drawing source.
     *
     * @param {DrawingFeature[]} dFeatures - The list of features to be deleted.
     */
    deleteFeatures(dFeatures) {
        for (const df of dFeatures) {
            const toRemove = this.getOlFeatureFromDrawingSource(df.id);
            if (toRemove !== null) {
                this.drawingSource.removeFeature(toRemove);
            }
        }
        this.updateModifiableFeatures();
    }
    /**
     * Restrict modify interaction to the currently selected features via updating the features collection
     */
    updateModifiableFeatures() {
        const selectedDrawingFeatures = this.drawingState.features.filter((f) => f.selected);
        this.modifiableFeatures.clear();
        for (const df of selectedDrawingFeatures) {
            const olFeature = this.getOlFeatureFromDrawingSource(df.id);
            if (olFeature) {
                this.modifiableFeatures.push(olFeature);
            }
        }
        // Only activate interaction if there are features to modify
        if (this.modifiableFeatures.getLength() > 0) {
            this.addEditInteractions();
        }
        else {
            this.removeEditInteractions();
        }
    }
    /**
     * Handles the addition of a feature to the vector source. This method is triggered when a new feature is drawn
     * and added to the vector source at end of the draw interaction.
     * It creates a `DrawingFeature` to save in the state, containing a unique id and the feature geometry as a geojson.
     * To identify the ol feature in the map, it receives the same id as the `DrawingFeature`.
     *
     * @param {VectorSourceEvent} e - The add-feature event.
     */
    onFeatureAdded(e) {
        // Cancel if the shape or feature isn't defined or the feature is already in the state
        if (this.currentShape === null || !e.feature || this.isOlFeatureInState(e.feature)) {
            return;
        }
        const olFeature = e.feature;
        const dFeature = new DrawingFeature(this.currentShape, this.drawingState, this.config.drawing);
        this.applyDrawingText(dFeature);
        olFeature.setId(dFeature.id);
        dFeature.geojson = DrawingFeature.geojsonFromOlFeature(olFeature, dFeature.type);
        dFeature.addToState();
    }
    getOlFeatureFromDrawingSource(id) {
        return this.drawingSource.getFeatureById(id);
    }
    isOlFeatureInState(feature) {
        if (!feature.getId()) {
            return false;
        }
        return this.drawingState.features.map((f) => f.id).includes(feature.getId());
    }
    createOlFeature(dFeature) {
        const olFeature = DrawingFeature.olFeatureFromGeoJson(dFeature.geojson);
        olFeature.setId(dFeature.id);
        olFeature.setStyle((f) => this.getStyle(dFeature, f));
        return olFeature;
    }
    setFixedLineLength(length) {
        this.fixedLineLength = Number.isNaN(length) ? 0 : length;
    }
    setFixedSquareSide(length) {
        this.fixedSquareSide = Number.isNaN(length) ? 0 : length;
    }
    setFixedRectangleWidth(width) {
        this.fixedRectangleWidth = Number.isNaN(width) ? 0 : width;
    }
    setFixedRectangleHeight(height) {
        this.fixedRectangleHeight = Number.isNaN(height) ? 0 : height;
    }
    setDrawingText(text) {
        this.drawingText = text;
    }
    /**
     * A text shape displays the text entered in the tool parameters instead of its generated default name.
     * If no text has been entered, the default name is kept so that the shape stays visible on the map.
     */
    applyDrawingText(dFeature) {
        if (dFeature.type === DrawingShape.Text && this.drawingText.trim().length > 0) {
            dFeature.name = this.drawingText;
        }
    }
    createLineStringFixedLength(coordinates, geom) {
        this.fixLastLength(this.fixedLineLength, coordinates);
        geom = geom ?? new LineString(coordinates);
        geom.setCoordinates(coordinates);
        return geom;
    }
    createSquareFixedLength(coordinates, geom, proj) {
        this.fixLastLength(this.fixedSquareSide, coordinates, Math.SQRT2);
        const poly = createRegularPolygon(4)(coordinates, geom, proj);
        return poly;
    }
    createPolygonFixedLength(coordinates, geom) {
        const coord = coordinates[0];
        this.fixLastLength(this.fixedLineLength, coord);
        geom = geom ?? new Polygon([coord]);
        geom.setCoordinates([coord]);
        return geom;
    }
    createDiskFixedLength(coordinates, geom) {
        const coord = coordinates;
        this.fixLastLength(this.fixedLineLength, coord);
        const radius = new LineString(coord).getLength();
        geom ??= new CircleGeom(coord[0], radius);
        geom.setCenterAndRadius(coord[0], radius);
        geom.setProperties({
            azimuth: (90 - Math.atan2(coord[1][1] - coord[0][1], coord[1][0] - coord[0][0]) * (180 / Math.PI) + 360) % 360,
            pointer: coord[1]
        });
        return geom;
    }
    createRectangleFixedSides(coordinates, geom, proj) {
        if (coordinates.length < 2) {
            geom = geom ?? createBox()(coordinates, geom, proj);
            return geom;
        }
        // If no fixed dimensions are set, behave like the normal box drawing.
        if (this.fixedRectangleWidth <= 0 && this.fixedRectangleHeight <= 0) {
            geom = geom ?? createBox()(coordinates, geom, proj);
            return createBox()(coordinates, geom, proj);
        }
        const coords = coordinates;
        const start = coords[0];
        const pointer = coords[1];
        // Decide in which quadrant the user is dragging.
        const signX = pointer[0] >= start[0] ? 1 : -1;
        const signY = pointer[1] >= start[1] ? 1 : -1;
        // Convert "meters" (or whatever your getDistance returns) to map units along X and Y.
        const xUnit = [start[0] + 1, start[1]];
        const yUnit = [start[0], start[1] + 1];
        const metersPerMapUnitX = getDistance([start, xUnit], this.state.projection);
        const metersPerMapUnitY = getDistance([start, yUnit], this.state.projection);
        // Guard against division by 0 in weird edge cases.
        if (metersPerMapUnitX <= 0 || metersPerMapUnitY <= 0) {
            geom = geom ?? createBox()(coordinates, geom, proj);
            return createBox()(coordinates, geom, proj);
        }
        const dxMapUnits = (this.fixedRectangleWidth / metersPerMapUnitX) * signX;
        const dyMapUnits = (this.fixedRectangleHeight / metersPerMapUnitY) * signY;
        if (dxMapUnits != 0 && dyMapUnits != 0) {
            // Overwrite width and height
            coords[1] = [start[0] + dxMapUnits, start[1] + dyMapUnits];
        }
        else if (dxMapUnits != 0) {
            // Overwrite only width
            coords[1] = [start[0] + dxMapUnits, pointer[1]];
        }
        else if (dyMapUnits != 0) {
            // Overwrite only height
            coords[1] = [pointer[0], start[1] + dyMapUnits];
        }
        geom = geom ?? createBox()(coords, geom, proj);
        return createBox()(coords, geom, proj);
    }
    addDrawInteraction(tool) {
        this.removeDrawInteraction();
        this.currentShape = tool;
        let geomFunction = undefined;
        let olTool;
        switch (tool) {
            case DrawingShape.Point:
                olTool = 'Point';
                break;
            case DrawingShape.Polyline:
                olTool = 'LineString';
                geomFunction = this.createLineStringFixedLength.bind(this);
                break;
            case DrawingShape.Polygon:
                olTool = 'Polygon';
                geomFunction = this.createPolygonFixedLength.bind(this);
                break;
            case DrawingShape.Disk:
                olTool = 'Circle';
                geomFunction = this.createDiskFixedLength.bind(this);
                break;
            case DrawingShape.Square:
                olTool = 'Circle';
                geomFunction = this.createSquareFixedLength.bind(this);
                break;
            case DrawingShape.Rectangle:
                olTool = 'Circle';
                geomFunction = this.createRectangleFixedSides.bind(this);
                break;
            case DrawingShape.FreehandPolyline:
                olTool = 'LineString';
                break;
            case DrawingShape.FreehandPolygon:
                olTool = 'Polygon';
                break;
            case DrawingShape.Text:
                olTool = 'Point';
                break;
        }
        this.draw = new Draw({
            source: this.drawingSource,
            type: olTool,
            freehand: tool == DrawingShape.FreehandPolyline || tool == DrawingShape.FreehandPolygon,
            stopClick: true,
            geometryFunction: geomFunction,
            // Default condition for ol drawing is noModifierKeys(e)
            // canExecute: If another tool is exclusively drawing, this interaction will be prevented from reacting
            condition: (e) => primaryAction(e) && this.canExecute('map.draw'),
            style: (f) => {
                const sketchFeature = new DrawingFeature(tool, this.drawingState, this.config.drawing);
                this.applyDrawingText(sketchFeature);
                return this.getStyle(sketchFeature, f);
            }
        });
        this.draw.on('drawstart', () => {
            this.disableContextMenu();
        });
        this.draw.on('drawend', () => {
            this.enableContextMenu();
            this.draw?.removeLastPoint();
            this.draw?.finishDrawing();
        });
        this.map.olMap.addInteraction(this.draw);
        this.addSnapInteraction();
    }
    centerViewOnFeature(drawingFeature) {
        const olFeature = this.getOlFeatureFromDrawingSource(drawingFeature.id);
        const extent = olFeature?.getGeometry()?.getExtent();
        if (extent) {
            const minResolution = this.config.search?.minResolution ?? 0.5;
            this.context.mapManager.zoomToExtent(extent, minResolution);
        }
    }
    // TODO Move as much parameters as possible into DrawingFeature
    getStyle(dFeature, olFeature) {
        const geometry = getGeometryForRendering(olFeature);
        if (!geometry) {
            return [];
        }
        const measureFont = 'Bold ' + dFeature.measureFontSize + 'px/1 ' + dFeature.font;
        const nameFont = 'Bold ' + dFeature.nameFontSize + 'px/1 ' + dFeature.font;
        const measureColor = 'rgba(0, 0, 0, 0.4)';
        const defaultStyle = new Style({
            geometry: geometry,
            // need to explicitly mention lineCap: round, so that MapfishPrint does not use butted as default
            stroke: new Stroke({
                color: dFeature.strokeColor,
                width: dFeature.strokeWidth,
                lineCap: 'round',
                lineDash: getLineStroke(dFeature.lineStroke, dFeature.strokeWidth)
            }),
            fill: new Fill({ color: dFeature.fillColor }),
            image: dFeature.type === DrawingShape.Text
                ? // A text shape is only made of its text, its anchor point is not drawn
                    undefined
                : new Circle({
                    radius: dFeature.strokeWidth, // Points are using default stroke parameters
                    fill: new Fill({ color: dFeature.strokeColor })
                }),
            text: new Text({
                text: dFeature.displayName ? dFeature.name : '',
                font: nameFont,
                textBaseline: 'middle',
                /*offsetY: dFeature.type == DrawingShape.Point ? 2 * dFeature.nameFontSize : 1.2 * dFeature.nameFontSize,*/
                fill: new Fill({ color: dFeature.nameColor }),
                overflow: true,
                stroke: dFeature.type == DrawingShape.Text
                    ? new Stroke({
                        color: '#ffffff',
                        width: 8
                    })
                    : undefined
            })
        });
        const labelStyle = new Style({
            text: new Text({
                font: measureFont,
                padding: [2, 2, 2, 2],
                textBaseline: 'bottom',
                offsetY: -1 * dFeature.nameFontSize,
                fill: new Fill({ color: dFeature.measureColor })
            }),
            image: new RegularShape({
                radius: 6,
                points: 3,
                angle: Math.PI,
                displacement: [0, 8],
                fill: new Fill({ color: measureColor })
            })
        });
        const styles = [defaultStyle];
        const addLabel = (position, text) => {
            const labelStyleToAdd = getLabelStyle(position, text, labelStyle);
            if (labelStyleToAdd) {
                styles.push(labelStyleToAdd);
            }
        };
        const appendText = (text) => {
            const textStyle = defaultStyle.getText();
            let existingText = textStyle.getText() ?? '';
            if (existingText.trim().length > 0) {
                existingText += '\n';
            }
            textStyle.setText(existingText + text);
        };
        // If the shape is being constructed (ex. it is a polygon for which only two points are placed yet)
        if (geometry.getType() == 'LineString') {
            if (dFeature.type !== DrawingShape.Polyline && dFeature.type !== DrawingShape.FreehandPolyline) {
                return [];
            }
            // arrows
            const createArrowStyle = (pos, rot) => {
                const view = this.context.mapManager.getMap().getView();
                const proj = view.getProjection();
                const res = view.getResolution();
                const pointRes = getPointResolution(proj, res, pos);
                const arrowLength = 5 * pointRes * dFeature.strokeWidth;
                const arrowGeom = new LineString([
                    [pos[0] - 1.2 * arrowLength, pos[1] - arrowLength],
                    pos,
                    [pos[0] - 1.2 * arrowLength, pos[1] + arrowLength]
                ]);
                arrowGeom.rotate(rot, pos);
                const stroke = defaultStyle.getStroke()?.clone();
                stroke?.setLineDash(null);
                return new Style({
                    geometry: arrowGeom,
                    stroke: stroke || undefined
                });
            };
            const pushArrowStyles = (start, end, startArrow, endArrow, ratio) => {
                const dx = end[0] - start[0];
                const dy = end[1] - start[1];
                const p1 = [start[0] + ratio * dx, start[1] + ratio * dy];
                const p2 = [end[0] - ratio * dx, end[1] - ratio * dy];
                // arrows
                if (startArrow) {
                    const rotation = Math.atan2(-dy, -dx);
                    styles.push(createArrowStyle(p1, rotation));
                }
                if (endArrow) {
                    const rotation = Math.atan2(dy, dx);
                    styles.push(createArrowStyle(p2, rotation));
                }
            };
            // create arrow styles according to spec
            if (dFeature.arrowStyle !== 'none') {
                const doStartArrow = dFeature.arrowStyle == 'start' || dFeature.arrowStyle == 'both';
                const doEndArrow = dFeature.arrowStyle == 'end' || dFeature.arrowStyle == 'both';
                if (dFeature.arrowPosition === 'each') {
                    geometry.forEachSegment(function (start, end) {
                        pushArrowStyles(start, end, doStartArrow, doEndArrow, 0);
                    });
                }
                else if (dFeature.arrowPosition === 'mid') {
                    geometry.forEachSegment(function (start, end) {
                        pushArrowStyles(start, end, doStartArrow, doEndArrow, 0.4);
                    });
                }
                else if (dFeature.arrowPosition === 'whole') {
                    const coords = geometry.getCoordinates();
                    pushArrowStyles(coords[0], coords[1], doStartArrow, false, 0);
                    pushArrowStyles(coords[coords.length - 2], coords[coords.length - 1], false, doEndArrow, 0);
                }
            }
        }
        if (dFeature.type == DrawingShape.Text) {
            // A text shape displays only its text, there is no measure to add
        }
        else if (dFeature.type == DrawingShape.Point || geometry.getType() === 'Point') {
            addLabel(geometry, dFeature.getCoordText(geometry.getCoordinates()));
        }
        else if (dFeature.type == DrawingShape.Polyline) {
            const lineString = geometry;
            if (dFeature.measureInformation === 'full') {
                lineString.forEachSegment((a, b) => addLabel(getHalfPoint([a, b]), dFeature.getLengthText(getDistance([a, b], this.state.projection))));
            }
            if (dFeature.measureInformation === 'simple' || dFeature.measureInformation === 'full') {
                appendText(getLengthAsMetricText(lineString.getLength()));
            }
        }
        else if (dFeature.type == DrawingShape.Polygon) {
            const polygon = geometry;
            const segments = ensurePolygonIsProperlyClosed(polygon);
            const lineString = new LineString(segments);
            if (dFeature.measureInformation === 'full') {
                lineString.forEachSegment((a, b) => addLabel(getHalfPoint([a, b]), dFeature.getLengthText(getDistance([a, b], this.state.projection))));
            }
            if (dFeature.measureInformation === 'simple' || dFeature.measureInformation === 'full') {
                appendText(dFeature.getAreaText(getAreaOfPolygon(polygon, this.state.projection)));
                appendText(getLengthAsMetricText(lineString.getLength()));
            }
        }
        else if (dFeature.type == DrawingShape.Disk && geometry instanceof CircleGeom) {
            if (dFeature.measureInformation !== 'none') {
                const radiusDataForCircle = getRadiusDataForCircle(geometry, this.state.projection, defaultStyle, new Stroke({ color: measureColor, width: 2 }));
                styles.push(radiusDataForCircle.style);
                const lengthText = dFeature.getLengthText(radiusDataForCircle.radiusMeter);
                const azimuthText = dFeature.getAzimuthText(geometry);
                const textTemplate = dFeature.measureInformation === 'simple' ? lengthText : lengthText + ', ' + azimuthText;
                addLabel(getHalfPoint(radiusDataForCircle.radiusLine), textTemplate);
            }
        }
        else if (dFeature.type == DrawingShape.FreehandPolygon) {
            const polygon = geometry;
            ensurePolygonIsProperlyClosed(polygon);
            addLabel(polygon.getInteriorPoint(), dFeature.getAreaText(getAreaOfPolygon(polygon, this.state.projection)));
            addLabel(new Point(polygon.getCoordinates()[0][0]), dFeature.getLengthText(getDistance(polygon.getCoordinates()[0], this.state.projection)));
        }
        else if (dFeature.type == DrawingShape.FreehandPolyline) {
            const line = geometry;
            addLabel(new Point(line.getCoordinates()[0]), dFeature.getLengthText(getDistance(line.getCoordinates(), this.state.projection)));
        }
        else if (dFeature.type == DrawingShape.Rectangle) {
            const rect = geometry;
            const segment1 = [rect.getCoordinates()[0][0], rect.getCoordinates()[0][1]];
            const segment2 = [rect.getCoordinates()[0][1], rect.getCoordinates()[0][2]];
            if (dFeature.measureInformation === 'full') {
                addLabel(getHalfPoint(segment1), dFeature.getLengthText(getDistance(segment1, this.state.projection)));
                addLabel(getHalfPoint(segment2), dFeature.getLengthText(getDistance(segment2, this.state.projection)));
            }
            addLabel(rect.getInteriorPoint(), dFeature.getAreaText(getAreaOfPolygon(rect, this.state.projection)));
        }
        else if (dFeature.type == DrawingShape.Square) {
            const square = geometry;
            if (dFeature.measureInformation === 'full') {
                const segment = [square.getCoordinates()[0][0], square.getCoordinates()[0][1]];
                addLabel(getHalfPoint(segment), dFeature.getLengthText(getDistance(segment, this.state.projection)));
            }
            addLabel(square.getInteriorPoint(), dFeature.getAreaText(getAreaOfPolygon(square, this.state.projection)));
        }
        if (this.shouldRenderVertices(dFeature)) {
            const vertexStyle = dFeature.getVertexStyle();
            // Add a node style to every vertex of the geometry
            vertexStyle.setGeometry(function (f) {
                const geom = getGeometryForRendering(f);
                if (geom && geom instanceof Geometry) {
                    return extractVerticesFromGeometry(geom);
                }
            });
            styles.push(vertexStyle);
        }
        return styles;
    }
    /**
     * Vertices are rendered on the shapes selected for editing. A text shape is an exception: as its geometry is
     * a single anchor point hidden behind the text, its vertex is only revealed while the cursor hovers the text.
     */
    shouldRenderVertices(dFeature) {
        if (!dFeature.selected || this.transform != null) {
            return false;
        }
        return dFeature.type !== DrawingShape.Text || dFeature.id === this.hoveredTextFeatureId;
    }
    removeDrawInteraction() {
        if (this.draw) {
            this.map.olMap.removeInteraction(this.draw);
            this.draw = null;
        }
    }
    removeModifyInteraction() {
        this.clearModifyFeatureChangeListeners();
        if (this.modify) {
            this.map.olMap.removeInteraction(this.modify);
            this.modify = null;
        }
    }
    getDrawingShapeType(olFeature) {
        if (!olFeature) {
            return undefined;
        }
        return this.drawingState.features.find((f) => f.id === olFeature.getId())?.type;
    }
    isSquareOrRectangle(shapeType) {
        return shapeType === DrawingShape.Square || shapeType === DrawingShape.Rectangle;
    }
    isSquareRectangleOrText(shapeType) {
        return shapeType === DrawingShape.Square || shapeType === DrawingShape.Rectangle || shapeType === DrawingShape.Text;
    }
    shouldAllowVertexInsertionAtEvent(e) {
        const hoveredFeature = this.getHoveredModifiableFeatureAtPixel(e.pixel);
        return shouldAllowVertexInsertionForShape(this.getDrawingShapeType(hoveredFeature));
    }
    /**
     * A text shape is only made of its text: its anchor point is hidden behind it. To let the user know where to
     * grab the shape, the anchor vertex is revealed as long as the cursor hovers the text.
     */
    updateHoveredTextFeature(e) {
        if (e.dragging) {
            return;
        }
        // Only the selected shapes display their vertices, so there is nothing to look for otherwise
        const hasSelectedText = this.drawingState.features.some((f) => f.selected && f.type === DrawingShape.Text);
        this.setHoveredTextFeature(hasSelectedText ? this.findHoveredTextFeatureIdAtPixel(e.pixel) : null);
    }
    setHoveredTextFeature(featureId) {
        if (featureId === this.hoveredTextFeatureId) {
            return;
        }
        const previousFeatureId = this.hoveredTextFeatureId;
        this.hoveredTextFeatureId = featureId;
        // Redraw the shapes whose hovered state changed, so that their vertex appears or disappears
        for (const id of [previousFeatureId, featureId]) {
            if (id) {
                this.getOlFeatureFromDrawingSource(id)?.changed();
            }
        }
    }
    findHoveredTextFeatureIdAtPixel(pixel) {
        // The text of a shape takes part in the hit detection, so hovering anywhere on it is enough
        const hoveredFeature = this.map.olMap.forEachFeatureAtPixel(pixel, (feature, layer) => {
            if (!(feature instanceof Feature) || layer !== this.drawingLayer) {
                return undefined;
            }
            return this.getDrawingShapeType(feature) === DrawingShape.Text ? feature : undefined;
        }, {
            hitTolerance: this.map.pixelTolerance
        });
        return hoveredFeature instanceof Feature ? (hoveredFeature.getId() ?? null) : null;
    }
    getHoveredModifiableFeatureAtPixel(pixel) {
        const modifiableFeatureSet = new Set(this.modifiableFeatures.getArray());
        const hoveredFeature = this.map.olMap.forEachFeatureAtPixel(pixel, (feature, layer) => {
            if (!(feature instanceof Feature) || layer !== this.drawingLayer || !modifiableFeatureSet.has(feature)) {
                return undefined;
            }
            return feature;
        }, {
            hitTolerance: this.map.pixelTolerance
        });
        return hoveredFeature instanceof Feature ? hoveredFeature : null;
    }
    applyScaleRotateOnModifyStyle(modifyPointFeature) {
        const modifyPointGeometry = modifyPointFeature.getGeometry();
        if (!(modifyPointGeometry instanceof Point)) {
            return;
        }
        const currentPoint = modifyPointGeometry.getCoordinates();
        const features = modifyPointFeature.get('features');
        if (!features) {
            return;
        }
        features.forEach((olFeature) => {
            const shapeType = this.getDrawingShapeType(olFeature);
            if (!this.isSquareOrRectangle(shapeType)) {
                return;
            }
            const modifyGeometry = olFeature.get('modifyGeometry');
            if (!modifyGeometry?.geometry) {
                return;
            }
            if (!modifyGeometry.point ||
                !modifyGeometry.geometry0 ||
                !modifyGeometry.center ||
                modifyGeometry.minRadius === undefined) {
                modifyGeometry.point = [...currentPoint];
                modifyGeometry.geometry0 = modifyGeometry.geometry;
                const geometryData = calculateCenterAndMinRadius(modifyGeometry.geometry0);
                modifyGeometry.center = geometryData.center;
                modifyGeometry.minRadius = geometryData.minRadius;
            }
            if (!modifyGeometry.geometry0 || !modifyGeometry.center || modifyGeometry.minRadius === undefined) {
                return;
            }
            modifyGeometry.geometry = createScaledAndRotatedGeometry(modifyGeometry.geometry0, modifyGeometry.center, modifyGeometry.minRadius, modifyGeometry.point, currentPoint);
        });
    }
    clearModifyFeatureChangeListeners() {
        if (this.modifyFeatureChangeListeners.length === 0) {
            return;
        }
        unByKeyAll(this.modifyFeatureChangeListeners);
        this.modifyFeatureChangeListeners.length = 0;
    }
    removeSnapInteraction() {
        if (this.snap) {
            this.map.olMap.removeInteraction(this.snap);
            this.snap = null;
        }
    }
    removeEditContextMenu() {
        if (this.editContextMenu) {
            this.editContextMenu.remove();
            this.editContextMenu = null;
        }
    }
    registerInteractions() {
        this.context.userInteractionManager.registerListener('map.draw', true, this.stateLocation);
        this.context.userInteractionManager.registerListener('map.modify', true, this.stateLocation);
        this.context.userInteractionManager.registerListener('map.snap', true, this.stateLocation);
        // Clicking on the map selects the drawn shapes, so feature selection is taken over from the other tools
        this.context.userInteractionManager.registerListener('map.select', true, this.stateLocation);
        this.addSelectListener();
    }
    unregisterInteractions() {
        this.removeDrawInteraction();
        this.removeEditInteractions();
        this.removeSelectListener();
        this.context.userInteractionManager.unregisterListener('map.draw', this.stateLocation);
        this.context.userInteractionManager.unregisterListener('map.modify', this.stateLocation);
        this.context.userInteractionManager.unregisterListener('map.snap', this.stateLocation);
        this.context.userInteractionManager.unregisterListener('map.select', this.stateLocation);
    }
    canExecute(event) {
        return this.context.userInteractionManager.canListenerExecute(event, this.stateLocation);
    }
    disableContextMenu() {
        this.context.userInteractionManager.registerListener('map.contextmenu', true, this.stateLocation);
    }
    enableContextMenu() {
        this.context.userInteractionManager.unregisterListener('map.contextmenu', this.stateLocation);
    }
    fixLastLength(length, coordinates, scale = 1) {
        const coord = coordinates;
        if (coord.length > 1 && length > 0) {
            const lastLine = [coord[coord.length - 2], coord[coord.length - 1]];
            coord[coord.length - 1] = new LineString(lastLine).getCoordinateAt(length / (getDistance(lastLine, this.state.projection) * scale));
        }
    }
}