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 // noinspection JSUnusedGlobalSymbols
public async getPreview(): Promise<FeatureSource> { 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) => { const preview: GeoJSONObject[] = closestIds.map((newId, i) => {
if (this.identicalTo[i] !== undefined) { if (this.identicalTo[i] !== undefined) {
return undefined return undefined
@ -92,7 +92,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
type: "Feature", type: "Feature",
properties: { properties: {
"newpoint": "yes", "newpoint": "yes",
"id": "replace-geometry-move-" + i "id": "replace-geometry-move-" + i,
}, },
geometry: { geometry: {
type: "Point", type: "Point",
@ -100,33 +100,60 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
} }
}; };
} }
const origPoint = allNodesById.get(newId).centerpoint()
const origNode = allNodesById.get(newId);
return { return {
type: "Feature", type: "Feature",
properties: { properties: {
"move": "yes", "move": "yes",
"osm-id": newId, "osm-id": newId,
"id": "replace-geometry-move-" + i "id": "replace-geometry-move-" + i,
"original-node-tags": JSON.stringify(origNode.tags)
}, },
geometry: { geometry: {
type: "LineString", 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) => { detachedNodes.forEach(({reason}, id) => {
const origPoint = allNodesById.get(id).centerpoint() const origNode = allNodesById.get(id);
const feature = { const feature = {
type: "Feature", type: "Feature",
properties: { properties: {
"detach": "yes", "detach": "yes",
"id": "replace-geometry-detach-" + id, "id": "replace-geometry-detach-" + id,
"detach-reason":reason "detach-reason": reason,
"original-node-tags": JSON.stringify(origNode.tags)
}, },
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: [origPoint[1], origPoint[0]] coordinates: [origNode.lon, origNode.lat]
} }
}; };
preview.push(feature) 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[] = [] // A list of the same length as targetCoordinates, containing which OSM-point to move. If undefined, a new point will be created
const actualIdsToUse: number[] = [] 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; const nodeDb = this.state.featurePipeline.fullNodeDatabase;
if (nodeDb === undefined) { 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)" 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 nodeInfo = new Map<number /* osmId*/, {
const detachedNodeIds = Array.from(detachedNodes.keys()); 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++) { for (let i = 0; i < closestIds.length; i++) {
if (this.identicalTo[i] !== undefined) { if (this.identicalTo[i] !== undefined) {
const j = this.identicalTo[i] const j = this.identicalTo[i]
@ -214,35 +477,40 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
// Some nodes might need to be deleted // Some nodes might need to be deleted
if (detachedNodeIds.length > 0) { const detachedNodeIds = Array.from(detachedNodes.keys());
if (detachedNodes.size > 0) {
for (const nodeId of detachedNodeIds) { detachedNodes.forEach(({hasTags, reason}, nodeId) => {
const parentWays = nodeDb.GetParentWays(nodeId) const parentWays = nodeDb.GetParentWays(nodeId)
const index = parentWays.data.map(w => w.id).indexOf(osmWay.id) const index = parentWays.data.map(w => w.id).indexOf(osmWay.id)
if(index < 0){ if (index < 0) {
console.error("ReplaceGeometryAction is trying to detach node "+nodeId+", but it isn't listed as being part of way "+osmWay.id) console.error("ReplaceGeometryAction is trying to detach node " + nodeId + ", but it isn't listed as being part of way " + osmWay.id)
continue; return;
} }
// We detachted this node - so we unregister // We detachted this node - so we unregister
parentWays.data.splice(index, 1) parentWays.data.splice(index, 1)
parentWays.ping(); parentWays.ping();
if(parentWays.data.length == 0){
// This point has no other ways anymore - lets clean it! if (hasTags) {
console.log("Removing node "+nodeId, "as it isn't needed anymore by any way") // Has tags: we leave this node alone
return;
allChanges.push({
meta: {
theme: this.theme,
changeType: "delete"
},
doDelete: true,
type: "node",
id: nodeId,
})
} }
} 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 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 // This is probably part of a relation which hasn't been fully downloaded
continue; continue;
} }
const cp = node.centerpoint(); this.coordinates.push(node.centerpoint());
this.coordinates.push(cp); latSum += node.lat
latSum += cp[0] lonSum += node.lon
lonSum += cp[1]
} }
let count = this.coordinates.length; let count = this.coordinates.length;
this.lat = latSum / count; this.lat = latSum / count;

View file

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