mapcomplete/Logic/Osm/Actions/ReplaceGeometryAction.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

572 lines
22 KiB
TypeScript
Raw Normal View History

2023-06-01 02:52:21 +02:00
import OsmChangeAction, {PreviewableAction} 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"
2023-06-01 02:52:21 +02:00
import {And} from "../../Tags/And"
import {Utils} from "../../../Utils"
import {OsmConnection} from "../OsmConnection"
import {Feature} from "@turf/turf"
import {Geometry, LineString, Point} from "geojson"
2023-03-28 05:13:48 +02:00
import FullNodeDatabaseSource from "../../FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
2023-06-01 02:52:21 +02:00
export default class ReplaceGeometryAction extends OsmChangeAction implements PreviewableAction{
2021-12-23 03:36:03 +01:00
/**
* The target feature - mostly used for the metadata
*/
private readonly feature: any
private readonly state: {
2021-12-23 03:36:03 +01:00
osmConnection: OsmConnection
2023-03-28 05:13:48 +02:00
fullNodeDatabase?: FullNodeDatabaseSource
}
private readonly wayToReplaceId: string
private readonly theme: string
/**
2021-12-23 03:36:03 +01:00
* 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]
2022-02-22 14:13:41 +01:00
* 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
2023-06-01 02:52:21 +02:00
/**
* Not really the 'new' element, but the target that has been applied.
* Added for compatibility with other systems
*/
public readonly newElementId: string
constructor(
state: {
2023-06-01 02:52:21 +02:00
osmConnection: OsmConnection,
2023-03-28 05:13:48 +02:00
fullNodeDatabase?: FullNodeDatabaseSource
},
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
2023-06-01 02:52:21 +02:00
this.newElementId = wayToReplaceId
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]
}
2021-12-23 03:36:03 +01:00
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
}
public async getPreview(): Promise<FeatureSource> {
2022-01-05 16:36:08 +01:00
const { closestIds, allNodesById, detachedNodes, reprojectedNodes } =
await this.GetClosestIds()
2022-10-27 01:50:41 +02:00
const preview: Feature<Geometry>[] = closestIds.map((newId, i) => {
2021-11-07 16:34:51 +01:00
if (this.identicalTo[i] !== undefined) {
return undefined
}
2021-11-07 16:34:51 +01:00
if (newId === undefined) {
return {
type: "Feature",
properties: {
newpoint: "yes",
2022-01-05 16:36:08 +01:00
id: "replace-geometry-move-" + i,
},
geometry: {
type: "Point",
coordinates: this.targetCoordinates[i],
2022-09-08 21:40:48 +02:00
},
}
}
2022-01-05 16:36:08 +01:00
const origNode = allNodesById.get(newId)
return {
type: "Feature",
properties: {
move: "yes",
"osm-id": newId,
2022-01-05 16:36:08 +01:00
id: "replace-geometry-move-" + i,
"original-node-tags": JSON.stringify(origNode.tags),
},
geometry: {
type: "LineString",
coordinates: [[origNode.lon, origNode.lat], this.targetCoordinates[i]],
2022-09-08 21:40:48 +02:00
},
2022-01-05 16:36:08 +01:00
}
})
reprojectedNodes.forEach(({ newLat, newLon, nodeId }) => {
2022-01-05 16:36:08 +01:00
const origNode = allNodesById.get(nodeId)
2022-09-21 02:21:20 +02:00
const feature: Feature<LineString> = {
2022-01-05 16:36:08 +01:00
type: "Feature",
properties: {
move: "yes",
reprojection: "yes",
2022-01-05 16:36:08 +01:00
"osm-id": nodeId,
id: "replace-geometry-reproject-" + nodeId,
2022-01-05 16:36:08 +01:00
"original-node-tags": JSON.stringify(origNode.tags),
},
geometry: {
type: "LineString",
2022-01-05 16:36:08 +01:00
coordinates: [
[origNode.lon, origNode.lat],
[newLon, newLat],
2022-09-08 21:40:48 +02:00
],
},
}
2022-01-05 16:36:08 +01:00
preview.push(feature)
})
2021-12-23 03:36:03 +01:00
2022-01-01 01:59:50 +01:00
detachedNodes.forEach(({ reason }, id) => {
2022-01-05 16:36:08 +01:00
const origNode = allNodesById.get(id)
2022-09-21 02:21:20 +02:00
const feature: Feature<Point> = {
2021-12-23 03:36:03 +01:00
type: "Feature",
properties: {
detach: "yes",
2022-01-01 01:59:50 +01:00
id: "replace-geometry-detach-" + id,
2022-01-05 16:36:08 +01:00
"detach-reason": reason,
"original-node-tags": JSON.stringify(origNode.tags),
2021-12-23 03:36:03 +01:00
},
geometry: {
type: "Point",
2022-01-05 16:36:08 +01:00
coordinates: [origNode.lon, origNode.lat],
2022-09-08 21:40:48 +02:00
},
2021-12-23 03:36:03 +01:00
}
preview.push(feature)
2022-01-01 01:59:50 +01:00
})
2021-12-23 03:36:03 +01:00
return StaticFeatureSource.fromGeojson(Utils.NoNull(preview))
}
/**
2021-12-24 02:51:01 +01:00
* For 'this.feature`, gets a corresponding closest node that alreay exsists.
2022-01-05 16:36:08 +01:00
*
2021-12-24 02:51:01 +01:00
* This method contains the main logic for this module, as it decides which node gets moved where.
2022-01-05 16:36:08 +01:00
*
*/
2021-12-30 20:41:45 +01:00
public async GetClosestIds(): Promise<{
2021-12-23 03:36:03 +01:00
// 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<number, OsmNode>
osmWay: OsmWay
2022-01-01 01:59:50 +01:00
detachedNodes: Map<
number,
{
2022-01-05 16:36:08 +01:00
reason: string
hasTags: boolean
}
2022-09-08 21:40:48 +02:00
>
2022-01-05 16:36:08 +01:00
reprojectedNodes: Map<
number,
{
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number
distance: number
2022-01-05 16:36:08 +01:00
newLat: number
newLon: number
nodeId: number
2022-01-01 01:59:50 +01:00
}
>
2021-12-23 03:36:03 +01:00
}> {
// TODO FIXME: if a new point has to be created, snap to already existing ways
2021-12-23 03:36:03 +01:00
2023-03-28 05:13:48 +02:00
const nodeDb = this.state.fullNodeDatabase
2022-01-01 01:59:50 +01:00
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)"
}
2022-01-05 16:36:08 +01:00
const self = this
2021-12-23 03:36:03 +01:00
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`
2021-12-23 03:36:03 +01:00
const rawData = await Utils.downloadJsonCached(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements)
}
const allNodes = parsed.filter((o) => o.type === "node")
2022-01-05 16:36:08 +01:00
const osmWay = <OsmWay>parsed[parsed.length - 1]
if (osmWay.type !== "way") {
throw "WEIRD: expected an OSM-way as last element here!"
}
const allNodesById = new Map<number, OsmNode>()
for (const node of allNodes) {
allNodesById.set(node.id, <OsmNode>node)
}
/**
2021-12-30 20:41:45 +01:00
* For every already existing OSM-point, we calculate:
2022-01-05 16:36:08 +01:00
*
2021-12-30 20:41:45 +01:00
* - the distance to every target point.
* - Wether this node has (other) parent ways, which might restrict movement
* - Wether this node has tags set
2022-01-05 16:36:08 +01:00
*
2021-12-30 20:41:45 +01:00
* Having tags and/or being connected to another way indicate that there is some _relation_ with objects in the neighbourhood.
2022-01-05 16:36:08 +01:00
*
2021-12-30 20:41:45 +01:00
* 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
*/
2021-12-30 20:41:45 +01:00
const distances = new Map<
number /* osmId*/,
2022-01-05 16:36:08 +01:00
/** target coordinate index --> distance (or undefined if a duplicate)*/
number[]
>()
2022-09-08 21:40:48 +02:00
2022-01-01 01:59:50 +01:00
const nodeInfo = new Map<
number /* osmId*/,
{
2022-01-05 16:36:08 +01:00
distances: number[]
2022-01-01 01:59:50 +01:00
// Part of some other way then the one that should be replaced
partOfWay: boolean
hasTags: boolean
}
>()
2022-01-05 16:36:08 +01:00
2021-12-23 03:36:03 +01:00
for (const node of allNodes) {
2022-01-01 01:59:50 +01:00
const parentWays = nodeDb.GetParentWays(node.id)
2022-01-05 16:36:08 +01:00
if (parentWays === undefined) {
2022-01-01 01:59:50 +01:00
throw "PANIC: the way to replace has a node which has no parents at all. Is it deleted in the meantime?"
}
2022-01-05 16:36:08 +01:00
const parentWayIds = parentWays.data.map((w) => w.type + "/" + w.id)
2022-01-01 01:59:50 +01:00
const idIndex = parentWayIds.indexOf(this.wayToReplaceId)
2022-01-05 16:36:08 +01:00
if (idIndex < 0) {
2022-01-01 01:59:50 +01:00
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
2022-01-05 16:36:08 +01:00
const hasTags = Object.keys(node.tags).length > 1
2021-12-23 03:36:03 +01:00
const nodeDistances = this.targetCoordinates.map((_) => undefined)
for (let i = 0; i < this.targetCoordinates.length; i++) {
if (this.identicalTo[i] !== undefined) {
continue
}
2021-12-23 03:36:03 +01:00
const targetCoordinate = this.targetCoordinates[i]
const cp = node.centerpoint()
2022-01-05 16:36:08 +01:00
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
}
}
2021-12-23 03:36:03 +01:00
distances.set(node.id, nodeDistances)
2022-01-01 01:59:50 +01:00
nodeInfo.set(node.id, {
distances: nodeDistances,
partOfWay: partOfSomeWay,
2022-01-05 16:36:08 +01:00
hasTags,
2022-01-01 01:59:50 +01:00
})
}
2021-12-30 20:41:45 +01:00
const closestIds = this.targetCoordinates.map((_) => undefined)
2022-01-01 01:59:50 +01:00
const unusedIds = new Map<
number,
{
2022-01-05 16:36:08 +01:00
reason: string
hasTags: boolean
2022-01-01 01:59:50 +01:00
}
>()
2021-12-30 20:41:45 +01:00
{
2022-01-05 16:36:08 +01:00
// 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,
})
}
}
2022-01-05 16:36:08 +01:00
} 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,
2021-12-23 03:36:03 +01:00
})
2022-01-05 16:36:08 +01:00
})
2021-12-23 03:36:03 +01:00
2022-01-05 16:36:08 +01:00
const reprojectedNodes = new Map<
number,
{
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number
distance: number
2022-01-05 16:36:08 +01:00
newLat: number
newLon: number
nodeId: number
}
>()
{
// 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
}
2021-12-23 03:36:03 +01:00
2022-01-05 16:36:08 +01:00
// 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
2022-10-27 01:50:41 +02:00
const way = <Feature<LineString>>{
2022-01-05 16:36:08 +01:00
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: self.targetCoordinates,
2022-09-08 21:40:48 +02:00
},
2022-01-05 16:36:08 +01:00
}
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,
2022-01-05 16:36:08 +01:00
nodeId: id,
})
})
2022-01-05 16:36:08 +01:00
reprojectedNodes.forEach((_, nodeId) => unusedIds.delete(nodeId))
}
return { closestIds, allNodesById, osmWay, detachedNodes: unusedIds, reprojectedNodes }
}
2021-12-23 03:36:03 +01:00
2022-01-05 16:36:08 +01:00
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
2023-03-28 05:13:48 +02:00
const nodeDb = this.state.fullNodeDatabase
2022-01-05 16:36:08 +01:00
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,
2022-01-01 01:59:50 +01:00
})
2022-01-05 16:36:08 +01:00
const changeDescr = await newNodeAction.CreateChangeDescriptions(changes)
allChanges.push(...changeDescr)
actualIdsToUse.push(newNodeAction.newElementIdNumber)
} else {
const change = <ChangeDescription>{
id: closestId,
type: "node",
meta: {
theme: this.theme,
changeType: "move",
},
changes: { lon, lat },
}
2022-01-05 16:36:08 +01:00
actualIdsToUse.push(closestId)
allChanges.push(change)
}
2022-01-05 16:36:08 +01:00
}
2023-06-01 02:52:21 +02:00
console.log("Adding tags", this.newTags,"to conflated way nr", this.wayToReplaceId)
2022-01-05 16:36:08 +01:00
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()))
2022-01-05 16:36:08 +01:00
}
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
2022-01-26 21:40:38 +01:00
if (diff !== 0) {
return diff
}
return b.distance - a.distance
})
for (const reprojectedNode of proj) {
const change = <ChangeDescription>{
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,
])
}
}
2022-01-05 16:36:08 +01:00
// Actually change the nodes of the way!
allChanges.push({
type: "way",
id: osmWay.id,
changes: {
nodes: actualIdsToUse,
coordinates: newCoordinates,
2022-01-05 16:36:08 +01:00
},
meta: {
theme: this.theme,
changeType: "conflation",
2021-12-23 03:36:03 +01:00
},
2022-01-05 16:36:08 +01:00
})
// 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,
})
})
}
2022-01-05 16:36:08 +01:00
return allChanges
}
}