More work on conflation logic

This commit is contained in:
Pieter Vander Vennet 2022-01-05 16:36:08 +01:00
parent d9ad4daaf6
commit 8485773a1d
3 changed files with 315 additions and 213 deletions

View file

@ -81,7 +81,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
// noinspection JSUnusedGlobalSymbols
public async getPreview(): Promise<FeatureSource> {
const {closestIds, allNodesById, detachedNodes} = await this.GetClosestIds();
const {closestIds, allNodesById, detachedNodes, reprojectedNodes} = await this.GetClosestIds();
const preview: GeoJSONObject[] = closestIds.map((newId, i) => {
if (this.identicalTo[i] !== undefined) {
return undefined
@ -92,7 +92,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
type: "Feature",
properties: {
"newpoint": "yes",
"id": "replace-geometry-move-" + i
"id": "replace-geometry-move-" + i,
},
geometry: {
type: "Point",
@ -100,33 +100,60 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
};
}
const origPoint = allNodesById.get(newId).centerpoint()
const origNode = allNodesById.get(newId);
return {
type: "Feature",
properties: {
"move": "yes",
"osm-id": newId,
"id": "replace-geometry-move-" + i
"id": "replace-geometry-move-" + i,
"original-node-tags": JSON.stringify(origNode.tags)
},
geometry: {
type: "LineString",
coordinates: [[origPoint[1], origPoint[0]], this.targetCoordinates[i]]
coordinates: [[origNode.lon, origNode.lat], this.targetCoordinates[i]]
}
};
})
reprojectedNodes.forEach(({newLat, newLon, nodeId}) => {
const origNode = allNodesById.get(nodeId);
const 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 origPoint = allNodesById.get(id).centerpoint()
const origNode = allNodesById.get(id);
const feature = {
type: "Feature",
properties: {
"detach": "yes",
"id": "replace-geometry-detach-" + id,
"detach-reason":reason
"detach-reason": reason,
"original-node-tags": JSON.stringify(origNode.tags)
},
geometry: {
type: "Point",
coordinates: [origPoint[1], origPoint[0]]
coordinates: [origNode.lon, origNode.lat]
}
};
preview.push(feature)
@ -137,18 +164,254 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
/**
* 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<{
const allChanges: ChangeDescription[] = []
const actualIdsToUse: number[] = []
// 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,
detachedNodes: Map<number, {
reason: string,
hasTags: boolean
}>,
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,
newLat: number,
newLon: number,
nodeId: number
}>
}> {
// 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}/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 = <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)
}
/**
* 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<number /* osmId*/,
/** target coordinate index --> distance (or undefined if a duplicate)*/
number[]>();
const {closestIds, osmWay, detachedNodes} = await this.GetClosestIds()
const detachedNodeIds = Array.from(detachedNodes.keys());
const nodeInfo = new Map<number /* osmId*/, {
distances: number[],
// Part of some other way then the one that should be replaced
partOfWay: boolean,
hasTags: boolean
}>()
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<number, {
reason: string,
hasTags: boolean
}>();
{
// 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<number, {
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number,
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
}
// 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]
)
console.trace("Node "+id+" should be kept and projected to ", projected)
reprojectedNodes.set(id, {
newLon: projected.geometry.coordinates[0],
newLat: projected.geometry.coordinates[1],
projectAfterIndex: projected.properties.index,
nodeId: id
})
})
reprojectedNodes.forEach((_, nodeId) => unusedIds.delete(nodeId))
}
return {closestIds, allNodesById, osmWay, detachedNodes: unusedIds, reprojectedNodes};
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
throw "Use reprojectedNodes!" // TODO FIXME
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]
@ -214,35 +477,40 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
// Some nodes might need to be deleted
if (detachedNodeIds.length > 0) {
const detachedNodeIds = Array.from(detachedNodes.keys());
for (const nodeId of detachedNodeIds) {
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)
continue;
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(parentWays.data.length == 0){
// This point has no other ways anymore - lets clean it!
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,
})
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,
})
})
}
@ -250,180 +518,5 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
return allChanges
}
/**
* 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<number, OsmNode>,
osmWay: OsmWay,
detachedNodes: Map<number, {
reason: string
}>
}> {
// TODO FIXME: cap move length on points which are embedded into other ways (ev. disconnect them)
// 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)"
}
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}/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")
/**
* 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<number /* osmId*/,
/** target coordinate index --> distance (or undefined if a duplicate)*/
number[]>();
const nodeInfo = new Map<number /* osmId*/, {
distances :number[],
// Part of some other way then the one that should be replaced
partOfWay: boolean,
hasTags: boolean
}>()
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 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()
nodeDistances[i] = GeoOperations.distanceBetween(targetCoordinate, [cp[1], cp[0]])
}
distances.set(node.id, nodeDistances)
nodeInfo.set(node.id, {
distances: nodeDistances,
partOfWay: partOfSomeWay,
hasTags: Object.keys(node.tags).length > 1
})
}
const closestIds = this.targetCoordinates.map(_ => undefined)
const unusedIds = new Map<number, {
reason: string
}>();
{
/**
* 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"
})
}
}
} 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"
})
})
{
// Some extra data is included for rendering
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)
}
return {closestIds, allNodesById, osmWay, detachedNodes: unusedIds};
}
}
}

View file

@ -391,10 +391,9 @@ export class OsmWay extends OsmObject {
// This is probably part of a relation which hasn't been fully downloaded
continue;
}
const cp = node.centerpoint();
this.coordinates.push(cp);
latSum += cp[0]
lonSum += cp[1]
this.coordinates.push(node.centerpoint());
latSum += node.lat
lonSum += node.lon
}
let count = this.coordinates.length;
this.lat = latSum / count;

View file

@ -31,6 +31,10 @@
"icon": {
"render": "circle:#0f0",
"mappings": [
{
"if": "reprojection=yes",
"then": "ring:#f00"
},
{
"if": "move=no",
"then": "ring:#0f0"
@ -42,6 +46,12 @@
{
"location": "start",
"icon": "square:#f00",
"mappings": [
{
"if": "reprojection=yes",
"then": "ring:#f00"
}
],
"iconSize": {
"render": "10,10,center",
"mappings": [