forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			565 lines
		
	
	
	
		
			22 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			565 lines
		
	
	
	
		
			22 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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"
 | |
| import { Geometry, LineString, Point, Polygon } from "geojson"
 | |
| 
 | |
| 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<FeatureSource> {
 | |
|         const { closestIds, allNodesById, detachedNodes, reprojectedNodes } =
 | |
|             await this.GetClosestIds()
 | |
|         const preview: Feature<Geometry>[] = 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<LineString> = {
 | |
|                 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<Point> = {
 | |
|                 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<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
 | |
|                 distance: 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 ?? "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 = <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 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
 | |
|                 distance: 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 = <Feature<LineString>>{
 | |
|                     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<ChangeDescription[]> {
 | |
|         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 = <ChangeDescription>{
 | |
|                     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 = <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,
 | |
|                 ])
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // 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
 | |
|     }
 | |
| }
 |