UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

962 lines (961 loc) 44.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, primaryAction } from 'ol/events/condition.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; drawingSource; drawingLayer; lastClosestFeature = null; translate = null; transform = null; modifyFeatureChangeListeners = []; 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(); } // Always recreate snap interaction to get snapping behavior on latest features this.addSnapInteraction(); } removeEditInteractions() { this.removeModifyInteraction(); this.removeEditContextMenu(); if (!this.draw) { this.removeSnapInteraction(); } } prepareMenuEntriesForVertex = (_e, mapCoordinate) => { if (!this.hasEditableVertexAtCoordinate(mapCoordinate)) { return EntryInteractionType.NOT_SHOWN; } if (this.isSquareOrRectangle(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); 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; } 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); geom = geom ?? new CircleGeom(coord[0], getDistance(coord, this.state.projection)); geom.setCenterAndRadius(coord[0], getDistance(coord, this.state.projection)); 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(); // Block feature selection while drawing by registering 'map.select' exclusively this.context.userInteractionManager.registerListener('map.select', true, this.stateLocation); 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; } 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) => this.getStyle(new DrawingFeature(tool, this.drawingState, this.config.drawing), f) }); this.draw.on('drawend', () => { 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: 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 }) }); 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.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) { if (dFeature.measureInformation !== 'none') { const radiusDataForCircle = getRadiusDataForCircle(geometry, defaultStyle, new Stroke({ color: measureColor, width: 2 })); styles.push(radiusDataForCircle.style); const lengthText = dFeature.getLengthText(radiusDataForCircle.radius); 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 (dFeature.selected && this.transform == null) { 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; } removeDrawInteraction() { if (this.draw) { this.map.olMap.removeInteraction(this.draw); this.draw = null; } // Reactivate feature selection by unregistering 'map.select' this.context.userInteractionManager.unregisterListener('map.select', this.stateLocation); } 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; } shouldAllowVertexInsertionAtEvent(e) { const hoveredFeature = this.getHoveredModifiableFeatureAtPixel(e.pixel); return shouldAllowVertexInsertionForShape(this.getDrawingShapeType(hoveredFeature)); } 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); } unregisterInteractions() { this.removeDrawInteraction(); this.removeEditInteractions(); this.context.userInteractionManager.unregisterListener('map.draw', this.stateLocation); this.context.userInteractionManager.unregisterListener('map.modify', this.stateLocation); this.context.userInteractionManager.unregisterListener('map.snap', this.stateLocation); } canExecute(event) { return this.context.userInteractionManager.canListenerExecute(event, 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)); } } }