@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
644 lines (643 loc) • 28.1 kB
JavaScript
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);
}
}