forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			373 lines
		
	
	
		
			No EOL
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			373 lines
		
	
	
		
			No EOL
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {OsmCreateAction} from "./OsmChangeAction";
 | 
						|
import {Tag} from "../../Tags/Tag";
 | 
						|
import {Changes} from "../Changes";
 | 
						|
import {ChangeDescription} from "./ChangeDescription";
 | 
						|
import FeaturePipelineState from "../../State/FeaturePipelineState";
 | 
						|
import {BBox} from "../../BBox";
 | 
						|
import {TagsFilter} from "../../Tags/TagsFilter";
 | 
						|
import {GeoOperations} from "../../GeoOperations";
 | 
						|
import FeatureSource from "../../FeatureSource/FeatureSource";
 | 
						|
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource";
 | 
						|
import CreateNewNodeAction from "./CreateNewNodeAction";
 | 
						|
import CreateNewWayAction from "./CreateNewWayAction";
 | 
						|
 | 
						|
 | 
						|
export interface MergePointConfig {
 | 
						|
    withinRangeOfM: number,
 | 
						|
    ifMatches: TagsFilter,
 | 
						|
    mode: "reuse_osm_point" | "move_osm_point"
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * CreateWayWithPointreuse will create a 'CoordinateInfo' for _every_ point in the way to be created.
 | 
						|
 *
 | 
						|
 * The CoordinateInfo indicates the action to take, e.g.:
 | 
						|
 *
 | 
						|
 * - Create a new point
 | 
						|
 * - Reuse an existing OSM point (and don't move it)
 | 
						|
 * - Reuse an existing OSM point (and leave it where it is)
 | 
						|
 * - Reuse another Coordinate info (and don't do anything else with it)
 | 
						|
 *
 | 
						|
 */
 | 
						|
interface CoordinateInfo {
 | 
						|
    /**
 | 
						|
     * The new coordinate
 | 
						|
     */
 | 
						|
    lngLat: [number, number],
 | 
						|
    /**
 | 
						|
     * If set: indicates that this point is identical to an earlier point in the way and that that point should be used.
 | 
						|
     * This is especially needed in closed ways, where the last CoordinateInfo will have '0' as identicalTo
 | 
						|
     */
 | 
						|
    identicalTo?: number,
 | 
						|
    /**
 | 
						|
     * Information about the closebyNode which might be reused
 | 
						|
     */
 | 
						|
    closebyNodes?: {
 | 
						|
        /**
 | 
						|
         * Distance in meters between the target coordinate and this candidate coordinate
 | 
						|
         */
 | 
						|
        d: number,
 | 
						|
        node: any,
 | 
						|
        config: MergePointConfig
 | 
						|
    }[]
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * More or less the same as 'CreateNewWay', except that it'll try to reuse already existing points
 | 
						|
 */
 | 
						|
export default class CreateWayWithPointReuseAction extends OsmCreateAction {
 | 
						|
    public newElementId: string = undefined;
 | 
						|
    public newElementIdNumber: number = undefined
 | 
						|
    private readonly _tags: Tag[];
 | 
						|
    /**
 | 
						|
     * lngLat-coordinates
 | 
						|
     * @private
 | 
						|
     */
 | 
						|
    private _coordinateInfo: CoordinateInfo[];
 | 
						|
    private _state: FeaturePipelineState;
 | 
						|
    private _config: MergePointConfig[];
 | 
						|
 | 
						|
    constructor(tags: Tag[],
 | 
						|
                coordinates: [number, number][],
 | 
						|
                state: FeaturePipelineState,
 | 
						|
                config: MergePointConfig[]
 | 
						|
    ) {
 | 
						|
        super(null, true);
 | 
						|
        this._tags = tags;
 | 
						|
        this._state = state;
 | 
						|
        this._config = config;
 | 
						|
 | 
						|
        // The main logic of this class: the coordinateInfo contains all the changes
 | 
						|
        this._coordinateInfo = this.CalculateClosebyNodes(coordinates);
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
    public async getPreview(): Promise<FeatureSource> {
 | 
						|
 | 
						|
        const features = []
 | 
						|
        let geometryMoved = false;
 | 
						|
        for (let i = 0; i < this._coordinateInfo.length; i++) {
 | 
						|
            const coordinateInfo = this._coordinateInfo[i];
 | 
						|
            if (coordinateInfo.identicalTo !== undefined) {
 | 
						|
                continue
 | 
						|
            }
 | 
						|
            if (coordinateInfo.closebyNodes === undefined || coordinateInfo.closebyNodes.length === 0) {
 | 
						|
 | 
						|
                const newPoint = {
 | 
						|
                    type: "Feature",
 | 
						|
                    properties: {
 | 
						|
                        "newpoint": "yes",
 | 
						|
                        id: "new-geometry-with-reuse-" + i
 | 
						|
                    },
 | 
						|
                    geometry: {
 | 
						|
                        type: "Point",
 | 
						|
                        coordinates: coordinateInfo.lngLat
 | 
						|
                    }
 | 
						|
                };
 | 
						|
                features.push(newPoint)
 | 
						|
                continue
 | 
						|
            }
 | 
						|
 | 
						|
            const reusedPoint = coordinateInfo.closebyNodes[0]
 | 
						|
            if (reusedPoint.config.mode === "move_osm_point") {
 | 
						|
                const moveDescription = {
 | 
						|
                    type: "Feature",
 | 
						|
                    properties: {
 | 
						|
                        "move": "yes",
 | 
						|
                        "osm-id": reusedPoint.node.properties.id,
 | 
						|
                        "id": "new-geometry-move-existing" + i,
 | 
						|
                        "distance": GeoOperations.distanceBetween(coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates)
 | 
						|
                    },
 | 
						|
                    geometry: {
 | 
						|
                        type: "LineString",
 | 
						|
                        coordinates: [reusedPoint.node.geometry.coordinates, coordinateInfo.lngLat]
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                features.push(moveDescription)
 | 
						|
 | 
						|
            } else {
 | 
						|
                // The geometry is moved, the point is reused
 | 
						|
                geometryMoved = true
 | 
						|
 | 
						|
                const reuseDescription = {
 | 
						|
                    type: "Feature",
 | 
						|
                    properties: {
 | 
						|
                        "move": "no",
 | 
						|
                        "osm-id": reusedPoint.node.properties.id,
 | 
						|
                        "id": "new-geometry-reuse-existing" + i,
 | 
						|
                        "distance": GeoOperations.distanceBetween(coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates)
 | 
						|
                    },
 | 
						|
                    geometry: {
 | 
						|
                        type: "LineString",
 | 
						|
                        coordinates: [coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates]
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                features.push(reuseDescription)
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (geometryMoved) {
 | 
						|
 | 
						|
            const coords: [number, number][] = []
 | 
						|
            for (const info of this._coordinateInfo) {
 | 
						|
                if (info.identicalTo !== undefined) {
 | 
						|
                    coords.push(coords[info.identicalTo])
 | 
						|
                    continue
 | 
						|
                }
 | 
						|
 | 
						|
                if (info.closebyNodes === undefined || info.closebyNodes.length === 0) {
 | 
						|
                    coords.push(coords[info.identicalTo])
 | 
						|
                    continue
 | 
						|
                }
 | 
						|
 | 
						|
                const closest = info.closebyNodes[0]
 | 
						|
                if (closest.config.mode === "reuse_osm_point") {
 | 
						|
                    coords.push(closest.node.geometry.coordinates)
 | 
						|
                } else {
 | 
						|
                    coords.push(info.lngLat)
 | 
						|
                }
 | 
						|
 | 
						|
            }
 | 
						|
            const newGeometry = {
 | 
						|
                type: "Feature",
 | 
						|
                properties: {
 | 
						|
                    "resulting-geometry": "yes",
 | 
						|
                    "id": "new-geometry"
 | 
						|
                },
 | 
						|
                geometry: {
 | 
						|
                    type: "LineString",
 | 
						|
                    coordinates: coords
 | 
						|
                }
 | 
						|
            }
 | 
						|
            features.push(newGeometry)
 | 
						|
 | 
						|
        }
 | 
						|
        return StaticFeatureSource.fromGeojson(features)
 | 
						|
    }
 | 
						|
 | 
						|
    public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
 | 
						|
        const theme = this._state?.layoutToUse?.id
 | 
						|
        const allChanges: ChangeDescription[] = []
 | 
						|
        const nodeIdsToUse: { lat: number, lon: number, nodeId?: number }[] = []
 | 
						|
        for (let i = 0; i < this._coordinateInfo.length; i++) {
 | 
						|
            const info = this._coordinateInfo[i]
 | 
						|
            const lat = info.lngLat[1]
 | 
						|
            const lon = info.lngLat[0]
 | 
						|
 | 
						|
            if (info.identicalTo !== undefined) {
 | 
						|
                nodeIdsToUse.push(nodeIdsToUse[info.identicalTo])
 | 
						|
                continue
 | 
						|
            }
 | 
						|
            if (info.closebyNodes === undefined || info.closebyNodes[0] === undefined) {
 | 
						|
                const newNodeAction = new CreateNewNodeAction([], lat, lon, {
 | 
						|
                    allowReuseOfPreviouslyCreatedPoints: true,
 | 
						|
                    changeType: null,
 | 
						|
                    theme
 | 
						|
                })
 | 
						|
 | 
						|
                allChanges.push(...(await newNodeAction.CreateChangeDescriptions(changes)))
 | 
						|
 | 
						|
                nodeIdsToUse.push({
 | 
						|
                    lat, lon,
 | 
						|
                    nodeId: newNodeAction.newElementIdNumber
 | 
						|
                })
 | 
						|
                continue
 | 
						|
 | 
						|
            }
 | 
						|
 | 
						|
            const closestPoint = info.closebyNodes[0]
 | 
						|
            const id = Number(closestPoint.node.properties.id.split("/")[1])
 | 
						|
            if (closestPoint.config.mode === "move_osm_point") {
 | 
						|
                allChanges.push({
 | 
						|
                    type: "node",
 | 
						|
                    id,
 | 
						|
                    changes: {
 | 
						|
                        lat, lon
 | 
						|
                    },
 | 
						|
                    meta: {
 | 
						|
                        theme,
 | 
						|
                        changeType: null
 | 
						|
                    }
 | 
						|
                })
 | 
						|
            }
 | 
						|
            nodeIdsToUse.push({lat, lon, nodeId: id})
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        const newWay = new CreateNewWayAction(this._tags, nodeIdsToUse, {
 | 
						|
            theme
 | 
						|
        })
 | 
						|
 | 
						|
        allChanges.push(...(await newWay.CreateChangeDescriptions(changes)))
 | 
						|
        this.newElementId = newWay.newElementId
 | 
						|
        this.newElementIdNumber = newWay.newElementIdNumber
 | 
						|
        return allChanges
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Calculates the main changes.
 | 
						|
     */
 | 
						|
    private CalculateClosebyNodes(coordinates: [number, number][]): CoordinateInfo[] {
 | 
						|
 | 
						|
        const bbox = new BBox(coordinates)
 | 
						|
        const state = this._state
 | 
						|
        const allNodes = [].concat(...state?.featurePipeline?.GetFeaturesWithin("type_node", bbox.pad(1.2))??[])
 | 
						|
        const maxDistance = Math.max(...this._config.map(c => c.withinRangeOfM))
 | 
						|
 | 
						|
        // Init coordianteinfo with undefined but the same length as coordinates
 | 
						|
        const coordinateInfo: {
 | 
						|
            lngLat: [number, number],
 | 
						|
            identicalTo?: number,
 | 
						|
            closebyNodes?: {
 | 
						|
                d: number,
 | 
						|
                node: any,
 | 
						|
                config: MergePointConfig
 | 
						|
            }[]
 | 
						|
        }[] = coordinates.map(_ => undefined)
 | 
						|
 | 
						|
 | 
						|
        // First loop: gather all information...
 | 
						|
        for (let i = 0; i < coordinates.length; i++) {
 | 
						|
 | 
						|
            if (coordinateInfo[i] !== undefined) {
 | 
						|
                // Already seen, probably a duplicate coordinate
 | 
						|
                continue
 | 
						|
            }
 | 
						|
            const coor = coordinates[i]
 | 
						|
            // 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,
 | 
						|
                        identicalTo: i
 | 
						|
                    }
 | 
						|
                    break;
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            // Gather the actual info for this point
 | 
						|
 | 
						|
            // Lets search applicable points and determine the merge mode
 | 
						|
            const closebyNodes: {
 | 
						|
                d: number,
 | 
						|
                node: any,
 | 
						|
                config: MergePointConfig
 | 
						|
            }[] = []
 | 
						|
            for (const node of allNodes) {
 | 
						|
                const center = node.geometry.coordinates
 | 
						|
                const d = GeoOperations.distanceBetween(coor, center)
 | 
						|
                if (d > maxDistance) {
 | 
						|
                    continue
 | 
						|
                }
 | 
						|
 | 
						|
                for (const config of this._config) {
 | 
						|
                    if (d > config.withinRangeOfM) {
 | 
						|
                        continue
 | 
						|
                    }
 | 
						|
                    if (!config.ifMatches.matchesProperties(node.properties)) {
 | 
						|
                        continue
 | 
						|
                    }
 | 
						|
                    closebyNodes.push({node, d, config})
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            // Sort by distance, closest first
 | 
						|
            closebyNodes.sort((n0, n1) => {
 | 
						|
                return n0.d - n1.d
 | 
						|
            })
 | 
						|
 | 
						|
            coordinateInfo[i] = {
 | 
						|
                identicalTo: undefined,
 | 
						|
                lngLat: coor,
 | 
						|
                closebyNodes
 | 
						|
            }
 | 
						|
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        // 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++) {
 | 
						|
 | 
						|
                const coorInfo = coordinateInfo[i]
 | 
						|
                if (coorInfo.identicalTo !== undefined) {
 | 
						|
                    continue
 | 
						|
                }
 | 
						|
                if (coorInfo.closebyNodes === undefined || coorInfo.closebyNodes[0] === undefined) {
 | 
						|
                    continue
 | 
						|
                }
 | 
						|
 | 
						|
                for (let j = i + 1; j < coordinates.length; j++) {
 | 
						|
                    const other = coordinateInfo[j]
 | 
						|
                    if (other.closebyNodes === undefined || other.closebyNodes[0] === undefined) {
 | 
						|
                        continue
 | 
						|
                    }
 | 
						|
 | 
						|
                    if (coorInfo.closebyNodes[0] === undefined) {
 | 
						|
                        continue
 | 
						|
                    }
 | 
						|
 | 
						|
                    if (other.closebyNodes[0].node === coorInfo.closebyNodes[0].node) {
 | 
						|
                        conflictFree = false
 | 
						|
                        // We have found a conflict!
 | 
						|
                        // We only keep the closest point
 | 
						|
                        if (other.closebyNodes[0].d > coorInfo.closebyNodes[0].d) {
 | 
						|
                            other.closebyNodes.shift()
 | 
						|
                        } else {
 | 
						|
                            coorInfo.closebyNodes.shift()
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
        } while (!conflictFree)
 | 
						|
 | 
						|
 | 
						|
        return coordinateInfo
 | 
						|
    }
 | 
						|
 | 
						|
} |