UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

644 lines (643 loc) 28.1 kB
import DrawingFeature, { DrawingShape } from './drawingFeature'; import StateManager from '../../tools/state/statemanager'; import { Collection, Feature } from 'ol'; import { Geometry, LineString, Point, Polygon, Circle as CircleGeom, MultiPoint, MultiLineString, LinearRing, MultiPolygon } from 'ol/geom'; import { createBox, createRegularPolygon } from 'ol/interaction/Draw'; import { Style, Stroke, Text, Fill, RegularShape, Circle } from 'ol/style'; import { Modify, Snap, Draw } from 'ol/interaction'; import VectorSource from 'ol/source/Vector'; import VectorLayer from 'ol/layer/Vector'; import GeoJSON from 'ol/format/GeoJSON'; import { never, noModifierKeys, primaryAction } from 'ol/events/condition'; import ConfigManager from '../../tools/configuration/configmanager'; import MapManager from '../../tools/state/mapManager'; import UserInteractionManager from '../../tools/state/userInteractionManager'; import { getDistance, getArea } from '../../tools/utils/olutils'; import { ContextMenu } from '../map/tools/contextmenu'; import { formatCoordinates } from '../../tools/geometrytools'; import { v4 as uuidv4 } from 'uuid'; import { isAlternateMouseClick, isPrimaryPointerAction } from '../../tools/state/userinteractionevent'; function getHalfPoint(coordinates) { return new Point(new LineString(coordinates).getCoordinateAt(0.5)); } function 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) * scale)); } } 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 { constructor(map, toolName) { Object.defineProperty(this, "map", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "toolName", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "state", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "configManager", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "userInteractionManager", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "drawingState", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "drawingSource", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "modifiableFeatures", { enumerable: true, configurable: true, writable: true, value: new Collection([]) }); Object.defineProperty(this, "draw", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "modify", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "snap", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "editContextMenu", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "currentShape", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "fixedLength", { enumerable: true, configurable: true, writable: true, value: 0 }); this.map = map; this.toolName = toolName; this.state = StateManager.getInstance().state; this.configManager = ConfigManager.getInstance(); this.userInteractionManager = UserInteractionManager.getInstance(); this.drawingState = this.state.extendedState.drawing; this.drawingSource = new VectorSource({ features: new Collection() }); this.drawingSource.on('addfeature', (e) => this.onFeatureAdded(e)); this.map.olMap.addLayer(new VectorLayer({ source: this.drawingSource, zIndex: 1001, properties: { addToPrintedLayers: true, altitudeMode: 'clampToGround' } })); this.map.stateManager.subscribe('extendedState.drawing.activeTool', (_oldTool, newTool) => newTool === null ? this.removeDrawInteraction() : this.addDrawInteraction(newTool)); this.map.stateManager.subscribe(/extendedState.drawing.features.*\.selected/, (_old, _new) => this.updateModifiableFeatures()); // OlCesium duplicates drawn shapes when 3D view is open if its eventListener is not removed StateManager.getInstance().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(); 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, insertVertexCondition: primaryAction, style: new DrawingFeature(1, {}, '').getVertexStyle(true), snapToPointer: true, pixelTolerance: this.map.pixelTolerance }); this.map.olMap.addInteraction(this.modify); // Update the modified geometries in the state this.modify.on('modifyend', (e) => { e.features.forEach((olFeature) => { const idx = this.drawingState.features.findIndex((f) => f.id === olFeature.getId()); if (idx > -1) { this.drawingState.features[idx].geojson = this.getGeoJsonFromOlFeature(olFeature); } }); }); } 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); } /** * 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.configManager.Config.general.locale)}`; this.state.infobox.elements.push({ id: uuidv4(), text: errorMessage, type: 'warning' }); } } } ]; 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.hasEditableVertexAtCoordinate(mapCoordinate); }; this.editContextMenu = new ContextMenu(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(); } /** 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.sqrt(dx * dx + dy * dy); if (distanceToVertex < this.map.pixelTolerance) { return true; } } 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) { const vertices = extractVerticesFromGeometry(geometry); const closestVertex = vertices.getClosestPoint(coordinate); return [closestVertex, olFeature]; } 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(); } /** * 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) { dFeatures.forEach((df) => { 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) { dFeatures.forEach((df) => { 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(); selectedDrawingFeatures.forEach((df) => { 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); olFeature.setId(dFeature.id); dFeature.geojson = this.getGeoJsonFromOlFeature(olFeature); dFeature.addToState(); } getGeoJsonFromOlFeature(olFeature) { // GeoJson does not support disks, so we create our own definition if (this.currentShape == DrawingShape.Disk) { const disk = olFeature.getGeometry(); return { type: 'Feature', geometry: { type: 'Disk', center: disk.getCenter(), radius: disk.getRadius() } }; } else { return JSON.parse(new GeoJSON().writeFeature(olFeature)); } } 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 geometry = dFeature.geojson.geometry; let olFeature; if (geometry.type == 'Disk') { olFeature = new Feature(new CircleGeom(geometry.center, geometry.radius)); } else { olFeature = new Feature(new GeoJSON().readFeatures(dFeature.geojson)[0].getGeometry()); } olFeature.setId(dFeature.id); olFeature.setStyle((f) => this.getStyle(dFeature, f)); return olFeature; } setFixedLength(length) { this.fixedLength = Number.isNaN(length) ? 0 : length; } createLineStringFixedLength(coordinates, geom) { fixLastLength(this.fixedLength, coordinates); geom = geom ?? new LineString(coordinates); geom.setCoordinates(coordinates); return geom; } createSquareFixedLength(coordinates, geom, proj) { fixLastLength(this.fixedLength, coordinates, Math.SQRT2); return createRegularPolygon(4)(coordinates, geom, proj); } createPolygonFixedLength(coordinates, geom) { const coord = coordinates[0]; fixLastLength(this.fixedLength, coord); geom = geom ?? new Polygon([coord]); geom.setCoordinates([coord]); return geom; } createDiskFixedLength(coordinates, geom) { const coord = coordinates; fixLastLength(this.fixedLength, coord); geom = geom ?? new CircleGeom(coord[0], getDistance(coord)); geom.setCenterAndRadius(coord[0], getDistance(coord)); return geom; } addDrawInteraction(tool) { this.removeDrawInteraction(); // Block feature selection while drawing by registering 'map.select' exclusively this.userInteractionManager.registerListener('map.select', true, this.toolName); 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 = createBox(); 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) => noModifierKeys(e) && this.canExecute('map.draw'), style: (f) => this.getStyle(new DrawingFeature(tool, {}, ''), 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 = ConfigManager.getInstance().Config.search.minResolution; MapManager.getInstance().zoomToExtent(extent, minResolution); } } // TODO Move as much parameters as possible into DrawingFeature getStyle(dFeature, olFeature) { const geometry = olFeature.getGeometry(); 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({ stroke: new Stroke({ color: dFeature.strokeColor, width: 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: 'bottom', offsetY: dFeature.type == DrawingShape.Point ? 2 * dFeature.nameFontSize : 1.2 * dFeature.nameFontSize, fill: new Fill({ color: dFeature.nameColor }) }) }); 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) => { if (text != '') { const style = labelStyle.clone(); style.setGeometry(position); style.getText().setText(text); styles.push(style); } }; // If the shape is being constructed (ex. it is a polygon for which only two points are placed yet) if (geometry.getType() == 'LineString' && dFeature.type !== DrawingShape.Polyline && dFeature.type !== DrawingShape.FreehandPolyline) { return []; } if (dFeature.type == DrawingShape.Point || geometry.getType() === 'Point') { addLabel(geometry, dFeature.getCoordText(geometry.getCoordinates())); } else if (dFeature.type == DrawingShape.Polyline) { geometry.forEachSegment((a, b) => addLabel(getHalfPoint([a, b]), dFeature.getLengthText(getDistance([a, b])))); } else if (dFeature.type == DrawingShape.Polygon) { const polygon = geometry; const segments = this.ensurePolygonIsProperlyClosed(polygon); new LineString(segments).forEachSegment((a, b) => addLabel(getHalfPoint([a, b]), dFeature.getLengthText(getDistance([a, b])))); addLabel(polygon.getInteriorPoint(), dFeature.getAreaText(getArea(polygon))); } else if (dFeature.type == DrawingShape.Disk) { const radius = geometry.getRadius(); const center = geometry.getCenter(); const radiusLine = [center, [center[0] + radius, center[1]]]; const radiusLineStyle = defaultStyle.clone(); radiusLineStyle.setStroke(new Stroke({ color: measureColor, width: dFeature.strokeWidth })); radiusLineStyle.getText().setText(''); radiusLineStyle.setGeometry(dFeature.displayMeasure ? new LineString(radiusLine) : new LineString([])); styles.push(radiusLineStyle); addLabel(getHalfPoint(radiusLine), dFeature.getLengthText(radius)); } else if (dFeature.type == DrawingShape.FreehandPolygon) { const polygon = geometry; this.ensurePolygonIsProperlyClosed(polygon); addLabel(polygon.getInteriorPoint(), dFeature.getAreaText(getArea(polygon))); addLabel(new Point(polygon.getCoordinates()[0][0]), dFeature.getLengthText(getDistance(polygon.getCoordinates()[0]))); } else if (dFeature.type == DrawingShape.FreehandPolyline) { const line = geometry; addLabel(new Point(line.getCoordinates()[0]), dFeature.getLengthText(getDistance(line.getCoordinates()))); } 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]]; addLabel(getHalfPoint(segment1), dFeature.getLengthText(getDistance(segment1))); addLabel(getHalfPoint(segment2), dFeature.getLengthText(getDistance(segment2))); addLabel(rect.getInteriorPoint(), dFeature.getAreaText(getArea(rect))); } else if (dFeature.type == DrawingShape.Square) { const square = geometry; const segment = [square.getCoordinates()[0][0], square.getCoordinates()[0][1]]; addLabel(getHalfPoint(segment), dFeature.getLengthText(getDistance(segment))); addLabel(square.getInteriorPoint(), dFeature.getAreaText(getArea(square))); } if (dFeature.selected) { const vertexStyle = dFeature.getVertexStyle(); // Add a node style to every vertex of the geometry vertexStyle.setGeometry(function (f) { const geom = f?.getGeometry(); if (geom && geom instanceof Geometry) { return extractVerticesFromGeometry(geom); } }); styles.push(vertexStyle); } return styles; } ensurePolygonIsProperlyClosed(polygon) { const coordinates = polygon.getCoordinates()[0]; let segments = [...coordinates]; if (coordinates.length > 2 && coordinates[0][0] != coordinates[coordinates.length - 1][0]) { segments = [...coordinates, coordinates[0]]; polygon.setCoordinates([segments]); } return segments; } removeDrawInteraction() { if (this.draw) { this.map.olMap.removeInteraction(this.draw); this.draw = null; } // Reactivate feature selection by unregistering 'map.select' this.userInteractionManager.unregisterListener('map.select', this.toolName); } removeModifyInteraction() { if (this.modify) { this.map.olMap.removeInteraction(this.modify); this.modify = null; } } 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.userInteractionManager.registerListener('map.draw', true, this.toolName); this.userInteractionManager.registerListener('map.modify', true, this.toolName); this.userInteractionManager.registerListener('map.snap', true, this.toolName); } unregisterInteractions() { this.removeDrawInteraction(); this.removeEditInteractions(); this.userInteractionManager.unregisterListener('map.draw', this.toolName); this.userInteractionManager.unregisterListener('map.modify', this.toolName); this.userInteractionManager.unregisterListener('map.snap', this.toolName); } canExecute(event) { return this.userInteractionManager.canListenerExecute(event, this.toolName); } }