import OsmChangeAction from "./OsmChangeAction"; import {Changes} from "../Changes"; import {ChangeDescription} from "./ChangeDescription"; import {Tag} from "../../Tags/Tag"; import FeatureSource from "../../FeatureSource/FeatureSource"; import {OsmNode, OsmObject, OsmWay} from "../OsmObject"; import {GeoOperations} from "../../GeoOperations"; import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource"; import CreateNewNodeAction from "./CreateNewNodeAction"; import ChangeTagAction from "./ChangeTagAction"; import {And} from "../../Tags/And"; import {Utils} from "../../../Utils"; import {OsmConnection} from "../OsmConnection"; import {Feature} from "@turf/turf"; import FeaturePipeline from "../../FeatureSource/FeaturePipeline"; export default class ReplaceGeometryAction extends OsmChangeAction { /** * The target feature - mostly used for the metadata */ private readonly feature: any; private readonly state: { osmConnection: OsmConnection, featurePipeline: FeaturePipeline }; private readonly wayToReplaceId: string; private readonly theme: string; /** * The target coordinates that should end up in OpenStreetMap. * This is identical to either this.feature.geometry.coordinates or -in case of a polygon- feature.geometry.coordinates[0] * Format: [lon, lat] */ private readonly targetCoordinates: [number, number][]; /** * If a target coordinate is close to another target coordinate, 'identicalTo' will point to the first index. */ private readonly identicalTo: number[] private readonly newTags: Tag[] | undefined; constructor( state: { osmConnection: OsmConnection, featurePipeline: FeaturePipeline }, feature: any, wayToReplaceId: string, options: { theme: string, newTags?: Tag[] } ) { super(wayToReplaceId, false); this.state = state; this.feature = feature; this.wayToReplaceId = wayToReplaceId; this.theme = options.theme; const geom = this.feature.geometry let coordinates: [number, number][] if (geom.type === "LineString") { coordinates = geom.coordinates } else if (geom.type === "Polygon") { coordinates = geom.coordinates[0] } this.targetCoordinates = coordinates this.identicalTo = coordinates.map(_ => undefined) for (let i = 0; i < coordinates.length; i++) { if (this.identicalTo[i] !== undefined) { continue } for (let j = i + 1; j < coordinates.length; j++) { const d = GeoOperations.distanceBetween(coordinates[i], coordinates[j]) if (d < 0.1) { this.identicalTo[j] = i } } } this.newTags = options.newTags } // noinspection JSUnusedGlobalSymbols public async getPreview(): Promise { const {closestIds, allNodesById, detachedNodes, reprojectedNodes} = await this.GetClosestIds(); const preview: Feature[] = closestIds.map((newId, i) => { if (this.identicalTo[i] !== undefined) { return undefined } if (newId === undefined) { return { type: "Feature", properties: { "newpoint": "yes", "id": "replace-geometry-move-" + i, }, geometry: { type: "Point", coordinates: this.targetCoordinates[i] } }; } const origNode = allNodesById.get(newId); return { type: "Feature", properties: { "move": "yes", "osm-id": newId, "id": "replace-geometry-move-" + i, "original-node-tags": JSON.stringify(origNode.tags) }, geometry: { type: "LineString", coordinates: [[origNode.lon, origNode.lat], this.targetCoordinates[i]] } }; }) reprojectedNodes.forEach(({newLat, newLon, nodeId}) => { const origNode = allNodesById.get(nodeId); const feature : Feature = { type: "Feature", properties: { "move": "yes", "reprojection": "yes", "osm-id": nodeId, "id": "replace-geometry-reproject-" + nodeId, "original-node-tags": JSON.stringify(origNode.tags) }, geometry: { type: "LineString", coordinates: [[origNode.lon, origNode.lat], [newLon, newLat]] } }; preview.push(feature) }) detachedNodes.forEach(({reason}, id) => { const origNode = allNodesById.get(id); const feature : Feature = { type: "Feature", properties: { "detach": "yes", "id": "replace-geometry-detach-" + id, "detach-reason": reason, "original-node-tags": JSON.stringify(origNode.tags) }, geometry: { type: "Point", coordinates: [origNode.lon, origNode.lat] } }; preview.push(feature) }) return StaticFeatureSource.fromGeojson(Utils.NoNull(preview)) } /** * For 'this.feature`, gets a corresponding closest node that alreay exsists. * * This method contains the main logic for this module, as it decides which node gets moved where. * */ public async GetClosestIds(): Promise<{ // A list of the same length as targetCoordinates, containing which OSM-point to move. If undefined, a new point will be created closestIds: number[], allNodesById: Map, osmWay: OsmWay, detachedNodes: Map, reprojectedNodes: Map }> { // TODO FIXME: if a new point has to be created, snap to already existing ways const nodeDb = this.state.featurePipeline.fullNodeDatabase; if (nodeDb === undefined) { throw "PANIC: replaceGeometryAction needs the FullNodeDatabase, which is undefined. This should be initialized by having the 'type_node'-layer enabled in your theme. (NB: the replacebutton has type_node as dependency)" } const self = this; let parsed: OsmObject[]; { // Gather the needed OsmObjects const splitted = this.wayToReplaceId.split("/"); const type = splitted[0]; const idN = Number(splitted[1]); if (idN < 0 || type !== "way") { throw "Invalid ID to conflate: " + this.wayToReplaceId } const url = `${this.state.osmConnection?._oauth_config?.url ?? "https://openstreetmap.org"}/api/0.6/${this.wayToReplaceId}/full`; const rawData = await Utils.downloadJsonCached(url, 1000) parsed = OsmObject.ParseObjects(rawData.elements); } const allNodes = parsed.filter(o => o.type === "node") const osmWay = parsed[parsed.length - 1] if (osmWay.type !== "way") { throw "WEIRD: expected an OSM-way as last element here!" } const allNodesById = new Map() for (const node of allNodes) { allNodesById.set(node.id, node) } /** * For every already existing OSM-point, we calculate: * * - the distance to every target point. * - Wether this node has (other) parent ways, which might restrict movement * - Wether this node has tags set * * Having tags and/or being connected to another way indicate that there is some _relation_ with objects in the neighbourhood. * * The Replace-geometry action should try its best to honour these. Some 'wiggling' is allowed (e.g. moving an entrance a bit), but these relations should not be broken.l */ const distances = new Map distance (or undefined if a duplicate)*/ number[]>(); const nodeInfo = new Map() for (const node of allNodes) { const parentWays = nodeDb.GetParentWays(node.id) if (parentWays === undefined) { throw "PANIC: the way to replace has a node which has no parents at all. Is it deleted in the meantime?" } const parentWayIds = parentWays.data.map(w => w.type + "/" + w.id) const idIndex = parentWayIds.indexOf(this.wayToReplaceId) if (idIndex < 0) { throw "PANIC: the way to replace has a node, which is _not_ part of this was according to the node..." } parentWayIds.splice(idIndex, 1) const partOfSomeWay = parentWayIds.length > 0 const hasTags = Object.keys(node.tags).length > 1; const nodeDistances = this.targetCoordinates.map(_ => undefined) for (let i = 0; i < this.targetCoordinates.length; i++) { if (this.identicalTo[i] !== undefined) { continue; } const targetCoordinate = this.targetCoordinates[i]; const cp = node.centerpoint() const d = GeoOperations.distanceBetween(targetCoordinate, [cp[1], cp[0]]) if (d > 25) { // This is too much to move continue } if (d < 3 || !(hasTags || partOfSomeWay)) { // If there is some relation: cap the move distance to 3m nodeDistances[i] = d; } } distances.set(node.id, nodeDistances) nodeInfo.set(node.id, { distances: nodeDistances, partOfWay: partOfSomeWay, hasTags }) } const closestIds = this.targetCoordinates.map(_ => undefined) const unusedIds = new Map(); { // Search best merge candidate /** * Then, we search the node that has to move the least distance and add this as mapping. * We do this until no points are left */ let candidate: number; let moveDistance: number; /** * The list of nodes that are _not_ used anymore, typically if there are less targetCoordinates then source coordinates */ do { candidate = undefined; moveDistance = Infinity; distances.forEach((distances, nodeId) => { const minDist = Math.min(...Utils.NoNull(distances)) if (moveDistance > minDist) { // We have found a candidate to move candidate = nodeId moveDistance = minDist } }) if (candidate !== undefined) { // We found a candidate... Search the corresponding target id: let targetId: number = undefined; let lowestDistance = Number.MAX_VALUE let nodeDistances = distances.get(candidate) for (let i = 0; i < nodeDistances.length; i++) { const d = nodeDistances[i] if (d !== undefined && d < lowestDistance) { lowestDistance = d; targetId = i; } } // This candidates role is done, it can be removed from the distance matrix distances.delete(candidate) if (targetId !== undefined) { // At this point, we have our target coordinate index: targetId! // Lets map it... closestIds[targetId] = candidate // To indicate that this targetCoordinate is taken, we remove them from the distances matrix distances.forEach(dists => { dists[targetId] = undefined }) } else { // Seems like all the targetCoordinates have found a source point unusedIds.set(candidate, { reason: "Unused by new way", hasTags: nodeInfo.get(candidate).hasTags }) } } } while (candidate !== undefined) } // If there are still unused values in 'distances', they are definitively unused distances.forEach((_, nodeId) => { unusedIds.set(nodeId, { reason: "Unused by new way", hasTags: nodeInfo.get(nodeId).hasTags }) }) const reprojectedNodes = new Map(); { // Lets check the unused ids: can they be detached or do they signify some relation with the object? unusedIds.forEach(({}, id) => { const info = nodeInfo.get(id) if (!(info.hasTags || info.partOfWay)) { // Nothing special here, we detach return } // The current node has tags and/or has an attached other building. // We should project them and move them onto the building on an appropriate place const node = allNodesById.get(id) // Project the node onto the target way to calculate the new coordinates const way = { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: self.targetCoordinates } }; const projected = GeoOperations.nearestPoint( way, [node.lon, node.lat] ) reprojectedNodes.set(id, { newLon: projected.geometry.coordinates[0], newLat: projected.geometry.coordinates[1], projectAfterIndex: projected.properties.index, distance: projected.properties.dist, nodeId: id }) }) reprojectedNodes.forEach((_, nodeId) => unusedIds.delete(nodeId)) } return {closestIds, allNodesById, osmWay, detachedNodes: unusedIds, reprojectedNodes}; } protected async CreateChangeDescriptions(changes: Changes): Promise { const nodeDb = this.state.featurePipeline.fullNodeDatabase; if (nodeDb === undefined) { throw "PANIC: replaceGeometryAction needs the FullNodeDatabase, which is undefined. This should be initialized by having the 'type_node'-layer enabled in your theme. (NB: the replacebutton has type_node as dependency)" } const {closestIds, osmWay, detachedNodes, reprojectedNodes} = await this.GetClosestIds() const allChanges: ChangeDescription[] = [] const actualIdsToUse: number[] = [] for (let i = 0; i < closestIds.length; i++) { if (this.identicalTo[i] !== undefined) { const j = this.identicalTo[i] actualIdsToUse.push(actualIdsToUse[j]) continue } const closestId = closestIds[i]; const [lon, lat] = this.targetCoordinates[i] if (closestId === undefined) { const newNodeAction = new CreateNewNodeAction( [], lat, lon, { allowReuseOfPreviouslyCreatedPoints: true, theme: this.theme, changeType: null }) const changeDescr = await newNodeAction.CreateChangeDescriptions(changes) allChanges.push(...changeDescr) actualIdsToUse.push(newNodeAction.newElementIdNumber) } else { const change = { id: closestId, type: "node", meta: { theme: this.theme, changeType: "move" }, changes: {lon, lat} } actualIdsToUse.push(closestId) allChanges.push(change) } } if (this.newTags !== undefined && this.newTags.length > 0) { const addExtraTags = new ChangeTagAction( this.wayToReplaceId, new And(this.newTags), osmWay.tags, { theme: this.theme, changeType: "conflation" } ) allChanges.push(...await addExtraTags.CreateChangeDescriptions(changes)) } const newCoordinates = [...this.targetCoordinates] { // Add reprojected nodes to the way const proj = Array.from(reprojectedNodes.values()) proj.sort((a, b) => { // Sort descending const diff = b.projectAfterIndex - a.projectAfterIndex; if (diff !== 0) { return diff } return b.distance - a.distance; }) for (const reprojectedNode of proj) { const change = { id: reprojectedNode.nodeId, type: "node", meta: { theme: this.theme, changeType: "move" }, changes: {lon: reprojectedNode.newLon, lat: reprojectedNode.newLat} } allChanges.push(change) actualIdsToUse.splice(reprojectedNode.projectAfterIndex + 1, 0, reprojectedNode.nodeId) newCoordinates.splice(reprojectedNode.projectAfterIndex + 1, 0, [reprojectedNode.newLon, reprojectedNode.newLat]) } } // Actually change the nodes of the way! allChanges.push({ type: "way", id: osmWay.id, changes: { nodes: actualIdsToUse, coordinates: newCoordinates }, meta: { theme: this.theme, changeType: "conflation" } }) // Some nodes might need to be deleted if (detachedNodes.size > 0) { detachedNodes.forEach(({hasTags, reason}, nodeId) => { const parentWays = nodeDb.GetParentWays(nodeId) const index = parentWays.data.map(w => w.id).indexOf(osmWay.id) if (index < 0) { console.error("ReplaceGeometryAction is trying to detach node " + nodeId + ", but it isn't listed as being part of way " + osmWay.id) return; } // We detachted this node - so we unregister parentWays.data.splice(index, 1) parentWays.ping(); if (hasTags) { // Has tags: we leave this node alone return; } if (parentWays.data.length != 0) { // Still part of other ways: we leave this node alone! return; } console.log("Removing node " + nodeId, "as it isn't needed anymore by any way") allChanges.push({ meta: { theme: this.theme, changeType: "delete" }, doDelete: true, type: "node", id: nodeId, }) }) } return allChanges } }