diff --git a/Logic/ExtraFunctions.ts b/Logic/ExtraFunctions.ts index a25a7c75d..ce571546a 100644 --- a/Logic/ExtraFunctions.ts +++ b/Logic/ExtraFunctions.ts @@ -85,7 +85,6 @@ class IntersectionFunc implements ExtraFunction { const bbox = BBox.get(feat) for (const layerId of layerIds) { - console.log("Calculating the intersection with layer ", layerId) const otherLayers = params.getFeaturesWithin(layerId, bbox) if (otherLayers === undefined) { continue; diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 6738c096c..ca704536a 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -60,6 +60,12 @@ export default class FeaturePipeline { private readonly localStorageSavers = new Map() private readonly metataggingRecalculated = new UIEventSource(undefined) + /** + * Keeps track of all raw OSM-nodes. + * Only initialized if 'type_node' is defined as layer + */ + public readonly fullNodeDatabase? : FullNodeDatabaseSource + constructor( handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void, state: MapState) { @@ -129,7 +135,14 @@ export default class FeaturePipeline { this.freshnesses.set(id, new TileFreshnessCalculator()) if (id === "type_node") { - // Handles by the 'FullNodeDatabaseSource' + + this.fullNodeDatabase = new FullNodeDatabaseSource( + filteredLayer, + tile => { + new RegisteringAllFromFeatureSourceActor(tile, state.allElements) + perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) + tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + }); continue; } @@ -248,17 +261,8 @@ export default class FeaturePipeline { }) }) - - if (state.layoutToUse.trackAllNodes) { - const fullNodeDb = new FullNodeDatabaseSource( - state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0], - tile => { - new RegisteringAllFromFeatureSourceActor(tile, state.allElements) - perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) - tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) - }) - - osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => fullNodeDb.handleOsmJson(osmJson, tileId)) + if(this.fullNodeDatabase !== undefined){ + osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => this.fullNodeDatabase.handleOsmJson(osmJson, tileId)) } diff --git a/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts b/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts index d02ae5858..d2e9ec95b 100644 --- a/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts @@ -9,6 +9,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy() private readonly onTileLoaded: (tile: (Tiled & FeatureSourceForLayer)) => void; private readonly layer: FilteredLayer + private readonly nodeByIds = new Map(); constructor( layer: FilteredLayer, @@ -31,6 +32,7 @@ export default class FullNodeDatabaseSource implements TileHierarchyosmObj; nodesById.set(osmNode.id, osmNode) + this.nodeByIds.set(osmNode.id, osmNode) } const parentWaysByNodeId = new Map() @@ -49,6 +51,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy { nodesById.get(nodeId).tags["parent_ways"] = JSON.stringify(allWays.map(w => w.tags)) + nodesById.get(nodeId).tags["parent_way_ids"] = JSON.stringify(allWays.map(w => w.id)) }) const now = new Date() const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({ @@ -62,6 +65,16 @@ export default class FullNodeDatabaseSource implements TileHierarchy c.withinRangeOfM)) + // Init coordianteinfo with undefined but the same length as coordinates const coordinateInfo: { lngLat: [number, number], identicalTo?: number, @@ -236,6 +266,8 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { }[] }[] = coordinates.map(_ => undefined) + + // First loop: gather all information... for (let i = 0; i < coordinates.length; i++) { if (coordinateInfo[i] !== undefined) { @@ -243,8 +275,11 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { continue } const coor = coordinates[i] - // Check closeby (and probably identical) point further in the coordinate list, mark them as duplicate + // Check closeby (and probably identical) points further in the coordinate list, mark them as duplicate for (let j = i + 1; j < coordinates.length; j++) { + // We look into the 'future' of the way and mark those 'future' locations as being the same as this location + // The continue just above will make sure they get ignored + // This code is important to 'close' ways if (GeoOperations.distanceBetween(coor, coordinates[j]) < 0.1) { coordinateInfo[j] = { lngLat: coor, @@ -280,6 +315,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { } } + // Sort by distance, closest first closebyNodes.sort((n0, n1) => { return n0.d - n1.d }) @@ -292,8 +328,9 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { } + + // Second loop: figure out which point moves where without creating conflicts let conflictFree = true; - do { conflictFree = true; for (let i = 0; i < coordinateInfo.length; i++) { diff --git a/Logic/Osm/Actions/DeleteAction.ts b/Logic/Osm/Actions/DeleteAction.ts index a5be01448..4673fecad 100644 --- a/Logic/Osm/Actions/DeleteAction.ts +++ b/Logic/Osm/Actions/DeleteAction.ts @@ -37,7 +37,7 @@ export default class DeleteAction extends OsmChangeAction { } - protected async CreateChangeDescriptions(changes: Changes): Promise { + public async CreateChangeDescriptions(changes: Changes): Promise { const osmObject = await OsmObject.DownloadObjectAsync(this._id) diff --git a/Logic/Osm/Actions/ReplaceGeometryAction.ts b/Logic/Osm/Actions/ReplaceGeometryAction.ts index 3b4aa31ef..c45784db0 100644 --- a/Logic/Osm/Actions/ReplaceGeometryAction.ts +++ b/Logic/Osm/Actions/ReplaceGeometryAction.ts @@ -11,28 +11,36 @@ import ChangeTagAction from "./ChangeTagAction"; import {And} from "../../Tags/And"; import {Utils} from "../../../Utils"; import {OsmConnection} from "../OsmConnection"; +import {GeoJSONObject} from "@turf/turf"; +import FeaturePipeline from "../../FeatureSource/FeaturePipeline"; +import DeleteAction from "./DeleteAction"; export default class ReplaceGeometryAction extends OsmChangeAction { + /** + * The target feature - mostly used for the metadata + */ private readonly feature: any; private readonly state: { - osmConnection: OsmConnection + osmConnection: OsmConnection, + featurePipeline: FeaturePipeline }; private readonly wayToReplaceId: string; private readonly theme: string; /** - * The target coordinates that should end up in OpenStreetMap + * 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] */ private readonly targetCoordinates: [number, number][]; /** * If a target coordinate is close to another target coordinate, 'identicalTo' will point to the first index. - * @private */ private readonly identicalTo: number[] private readonly newTags: Tag[] | undefined; constructor( state: { - osmConnection: OsmConnection + osmConnection: OsmConnection, + featurePipeline: FeaturePipeline }, feature: any, wayToReplaceId: string, @@ -54,6 +62,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction { } else if (geom.type === "Polygon") { coordinates = geom.coordinates[0] } + this.targetCoordinates = coordinates this.identicalTo = coordinates.map(_ => undefined) @@ -68,21 +77,18 @@ export default class ReplaceGeometryAction extends OsmChangeAction { } } } - - - this.targetCoordinates = coordinates this.newTags = options.newTags } + // noinspection JSUnusedGlobalSymbols public async getPreview(): Promise { - const {closestIds, allNodesById} = await this.GetClosestIds(); + const {closestIds, allNodesById, detachedNodeIds} = await this.GetClosestIds(); console.debug("Generating preview, identicals are ",) - const preview = closestIds.map((newId, i) => { + const preview: GeoJSONObject[] = closestIds.map((newId, i) => { if (this.identicalTo[i] !== undefined) { return undefined } - if (newId === undefined) { return { type: "Feature", @@ -110,6 +116,24 @@ export default class ReplaceGeometryAction extends OsmChangeAction { } }; }) + + for (const detachedNodeId of detachedNodeIds) { + const origPoint = allNodesById.get(detachedNodeId).centerpoint() + const feature = { + type: "Feature", + properties: { + "detach": "yes", + "id": "replace-geometry-detach-" + detachedNodeId + }, + geometry: { + type: "Point", + coordinates: [origPoint[1], origPoint[0]] + } + }; + preview.push(feature) + } + + return new StaticFeatureSource(Utils.NoNull(preview), false) } @@ -119,7 +143,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction { const allChanges: ChangeDescription[] = [] const actualIdsToUse: number[] = [] - const {closestIds, osmWay} = await this.GetClosestIds() + const {closestIds, osmWay, detachedNodeIds} = await this.GetClosestIds() for (let i = 0; i < closestIds.length; i++) { if (this.identicalTo[i] !== undefined) { @@ -170,7 +194,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction { allChanges.push(...await addExtraTags.CreateChangeDescriptions(changes)) } - // AT the very last: actually change the nodes of the way! + // Actually change the nodes of the way! allChanges.push({ type: "way", id: osmWay.id, @@ -185,6 +209,43 @@ export default class ReplaceGeometryAction extends OsmChangeAction { }) + // Some nodes might need to be deleted + if (detachedNodeIds.length > 0) { + + 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)" + } + for (const nodeId of detachedNodeIds) { + const osmNode = nodeDb.GetNode(nodeId) + const parentWayIds: number[] = JSON.parse(osmNode.tags["parent_way_ids"]) + const index = parentWayIds.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; + } + parentWayIds.splice(index, 1) + osmNode.tags["parent_way_ids"] = JSON.stringify(parentWayIds) + if(parentWayIds.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, + }) + + } + } + + + } + return allChanges } @@ -193,84 +254,124 @@ export default class ReplaceGeometryAction extends OsmChangeAction { * @constructor * @private */ - private async GetClosestIds(): Promise<{ closestIds: number[], allNodesById: Map, osmWay: OsmWay }> { + private 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, + detachedNodeIds: number[] + }> { // 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 - // TODO FIXME: detect intersections with other ways if moved - 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) - const parsed = OsmObject.ParseObjects(rawData.elements); - const allNodesById = new Map() - const allNodes = parsed.filter(o => o.type === "node") - for (const node of allNodes) { - allNodesById.set(node.id, node) - } + 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") + /** - * Allright! We know all the nodes of the original way and all the nodes of the target coordinates. - * For each of the target coordinates, we search the closest, already existing point and reuse this point + * For every already existing OSM-point, we calculate the distance to every target point */ - const closestIds = [] - const distances = [] - for (let i = 0; i < this.targetCoordinates.length; i++) { - const target = this.targetCoordinates[i]; - let closestDistance = undefined - let closestId = undefined; - for (const osmNode of allNodes) { - - const cp = osmNode.centerpoint() - const d = GeoOperations.distanceBetween(target, [cp[1], cp[0]]) - if (closestId === undefined || closestDistance > d) { - closestId = osmNode.id - closestDistance = d + const distances = new Map distance (or undefined if a duplicate)*/>(); + for (const node of allNodes) { + 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]]) } - closestIds.push(closestId) - distances.push(closestDistance) + distances.set(node.id, nodeDistances) } - // Next step: every closestId can only occur once in the list - // We skip the ones which are identical - console.log("Erasing double ids") - for (let i = 0; i < closestIds.length; i++) { - if (this.identicalTo[i] !== undefined) { - closestIds[i] = closestIds[this.identicalTo[i]] - continue - } - const closestId = closestIds[i] - for (let j = i + 1; j < closestIds.length; j++) { - if (this.identicalTo[j] !== undefined) { - continue + /** + * 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; + const closestIds = this.targetCoordinates.map(_ => undefined) + /** + * The list of nodes that are _not_ used anymore, typically if there are less targetCoordinates then source coordinates + */ + const unusedIds = [] + 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 } - const otherClosestId = closestIds[j] - if (closestId !== otherClosestId) { - continue + }) + + 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; + } } - // We have two occurences of 'closestId' - we only keep the closest instance! - const di = distances[i] - const dj = distances[j] - if (di < dj) { - closestIds[j] = undefined + + // 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 { - closestIds[i] = undefined + // Seems like all the targetCoordinates have found a source point + unusedIds.push(candidate) } } - } + } while (candidate !== undefined) - const osmWay = parsed[parsed.length - 1] - if (osmWay.type !== "way") { - throw "WEIRD: expected an OSM-way as last element here!" + // If there are still unused values in 'distances', they are definitively unused + distances.forEach((_, nodeId) => { + unusedIds.push(nodeId) + }) + + { + // Some extra data is included for rendering + 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) + } + return {closestIds, allNodesById, osmWay, detachedNodeIds: unusedIds}; } - return {closestIds, allNodesById, osmWay}; } diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 56b5feb47..831c05800 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -384,8 +384,8 @@ export class Changes { states.set(o.type + "/" + o.id, "unchanged") } - let changed = false; for (const change of changes) { + let changed = false; const id = change.type + "/" + change.id if (!objects.has(id)) { // The object hasn't been seen before, so it doesn't exist yet and is newly created by its very definition @@ -493,7 +493,7 @@ export class Changes { } - if (changed && state === "unchanged") { + if (changed && states.get(id) === "unchanged") { states.set(id, "modified") } } @@ -520,6 +520,7 @@ export class Changes { }) + console.debug("Calculated the pending changes: ", result.newObjects.length,"new; ", result.modifiedObjects.length,"modified;",result.deletedObjects,"deleted") return result } } \ No newline at end of file diff --git a/Models/Constants.ts b/Models/Constants.ts index 25ea8a2ce..330f11c96 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import {Utils} from "../Utils"; export default class Constants { - public static vNumber = "0.13.0-alpha-8"; + public static vNumber = "0.13.0-alpha-9"; public static ImgurApiKey = '7070e7167f0a25a' public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" diff --git a/Models/ThemeConfig/LayoutConfig.ts b/Models/ThemeConfig/LayoutConfig.ts index 1d9fe8cef..7fb99c32d 100644 --- a/Models/ThemeConfig/LayoutConfig.ts +++ b/Models/ThemeConfig/LayoutConfig.ts @@ -63,7 +63,6 @@ export default class LayoutConfig { this.credits = json.credits; this.version = json.version; this.language = []; - this.trackAllNodes = false if (typeof json.language === "string") { this.language = [json.language]; @@ -101,7 +100,6 @@ export default class LayoutConfig { this.tileLayerSources = (json.tileLayerSources ?? []).map((config, i) => new TilesourceConfig(config, `${this.id}.tileLayerSources[${i}]`)) const layerInfo = LayoutConfig.ExtractLayers(json, official, context); this.layers = layerInfo.layers - this.trackAllNodes = layerInfo.extractAllNodes this.clustering = { diff --git a/UI/Popup/ImportButton.ts b/UI/Popup/ImportButton.ts index 88c862cbc..2c5d7cf7d 100644 --- a/UI/Popup/ImportButton.ts +++ b/UI/Popup/ImportButton.ts @@ -232,7 +232,7 @@ ${Utils.special_visualizations_importRequirementDocs} onCancel: () => void): BaseUIElement { const self = this; const confirmationMap = Minimap.createMiniMap({ - allowMoving: false, + allowMoving: state.featureSwitchIsDebugging.data ?? false, background: state.backgroundLayer }) confirmationMap.SetStyle("height: 20rem; overflow: hidden").SetClass("rounded-xl") @@ -297,6 +297,13 @@ export class ConflateButton extends AbstractImportButton { return feature.geometry.type === "LineString" || (feature.geometry.type === "Polygon" && feature.geometry.coordinates.length === 1) } + getLayerDependencies(argsRaw: string[]): string[] { + const deps = super.getLayerDependencies(argsRaw); + // Force 'type_node' as dependency + deps.push("type_node") + return deps; + } + constructElement(state: FeaturePipelineState, args: { max_snap_distance: string; snap_onto_layers: string; icon: string; text: string; tags: string; newTags: UIEventSource; targetLayer: string }, tagSource: UIEventSource, guiState: DefaultGuiState, feature: any, onCancelClicked: () => void): BaseUIElement { diff --git a/assets/layers/conflation/conflation.json b/assets/layers/conflation/conflation.json index a42f5b589..3ee868157 100644 --- a/assets/layers/conflation/conflation.json +++ b/assets/layers/conflation/conflation.json @@ -15,7 +15,15 @@ "mapRendering": [ { "location": "point", - "icon": "addSmall:#000", + "icon": { + "render": "addSmall:#000", + "mappings": [ + { + "if": "detach=yes", + "then": "circle:white;close:#c33" + } + ] + }, "iconSize": "10,10,center" }, { diff --git a/assets/themes/grb_import/grb.json b/assets/themes/grb_import/grb.json index 8df3955e2..6088c8292 100644 --- a/assets/themes/grb_import/grb.json +++ b/assets/themes/grb_import/grb.json @@ -302,6 +302,25 @@ } ] }, + { + "id": "service_ways", + "name": "Service roads", + "description": "A seperate layer with service roads, as to remove them from the intersection testing", + "source": { + "osmTags": "highway=service" + }, + "mapRendering": [ + { + "width": 4, + "color": "#888888" + } + ], + "title": { + "render": "Service road" + }, + "tagRenderings": [] + + }, { "id": "generic_osm_object", "name": "All OSM Objects", @@ -466,7 +485,7 @@ "name": "GRB geometries", "title": "GRB outline", "calculatedTags": [ - "_overlaps_with_buildings=feat.overlapWith('OSM-buildings')", + "_overlaps_with_buildings=feat.overlapWith('OSM-buildings').filter(f => f.feat.properties.id.indexOf('-') < 0)", "_overlaps_with=feat.get('_overlaps_with_buildings').filter(f => f.overlap > 1 /* square meter */ )[0] ?? ''", "_overlap_absolute=feat.get('_overlaps_with')?.overlap", "_overlap_percentage=Math.round(100 * feat.get('_overlap_absolute') / feat.get('_surface')) ", @@ -551,6 +570,12 @@ "_osm_obj:id~*", "addr:street~*", "addr:housenumber~*", + { + "or": [ + "addr:street~*", + "addr:housenumber~*" + ] + }, { "or": [ "addr:street!:={_osm_obj:addr:street}",