import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; import State from "../../State"; import {UIEventSource} from "../UIEventSource"; import Constants from "../../Models/Constants"; import OsmChangeAction from "./Actions/OsmChangeAction"; import {ChangeDescription} from "./Actions/ChangeDescription"; import {Utils} from "../../Utils"; import {LocalStorageSource} from "../Web/LocalStorageSource"; import SimpleMetaTagger from "../SimpleMetaTagger"; import CreateNewNodeAction from "./Actions/CreateNewNodeAction"; import FeatureSource from "../FeatureSource/FeatureSource"; import {ElementStorage} from "../ElementStorage"; import {GeoLocationPointProperties} from "../Actors/GeoLocationHandler"; import {GeoOperations} from "../GeoOperations"; import {ChangesetTag} from "./ChangesetHandler"; /** * Handles all changes made to OSM. * Needs an authenticator via OsmConnection */ export class Changes { public readonly name = "Newly added features" /** * All the newly created features as featureSource + all the modified features */ public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); public readonly pendingChanges: UIEventSource = LocalStorageSource.GetParsed("pending-changes", []) public readonly allChanges = new UIEventSource(undefined) private _nextId: number = -1; // Newly assigned ID's are negative private readonly isUploading = new UIEventSource(false); private readonly previouslyCreated: OsmObject[] = [] private readonly _leftRightSensitive: boolean; private _state : { allElements: ElementStorage; historicalUserLocations: FeatureSource } constructor(leftRightSensitive: boolean = false) { this._leftRightSensitive = leftRightSensitive; // We keep track of all changes just as well this.allChanges.setData([...this.pendingChanges.data]) // If a pending change contains a negative ID, we save that this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? []) // Note: a changeset might be reused which was opened just before and might have already used some ids // This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset } private static createChangesetFor(csId: string, allChanges: { modifiedObjects: OsmObject[], newObjects: OsmObject[], deletedObjects: OsmObject[] }): string { const changedElements = allChanges.modifiedObjects ?? [] const newElements = allChanges.newObjects ?? [] const deletedElements = allChanges.deletedObjects ?? [] let changes = ``; if (newElements.length > 0) { changes += "\n\n" + newElements.map(e => e.ChangesetXML(csId)).join("\n") + ""; } if (changedElements.length > 0) { changes += "\n\n" + changedElements.map(e => e.ChangesetXML(csId)).join("\n") + "\n"; } if (deletedElements.length > 0) { changes += "\n\n" + deletedElements.map(e => e.ChangesetXML(csId)).join("\n") + "\n" } changes += ""; return changes; } private static GetNeededIds(changes: ChangeDescription[]) { return Utils.Dedup(changes.filter(c => c.id >= 0) .map(c => c.type + "/" + c.id)) } /** * Returns a new ID and updates the value for the next ID */ public getNewID() { return this._nextId--; } /** * Uploads all the pending changes in one go. * Triggered by the 'PendingChangeUploader'-actor in Actors */ public flushChanges(flushreason: string = undefined) { if (this.pendingChanges.data.length === 0) { return; } if (this.isUploading.data) { console.log("Is already uploading... Abort") return; } console.log("Uploading changes due to: ", flushreason) this.isUploading.setData(true) this.flushChangesAsync() .then(_ => { this.isUploading.setData(false) console.log("Changes flushed!"); }) .catch(e => { this.isUploading.setData(false) console.error("Flushing changes failed due to", e); }) } private calculateDistanceToChanges(change: OsmChangeAction, changeDescriptions: ChangeDescription[]){ if (this._state === undefined) { // No state loaded -> we can't calculate... return; } if(!change.trackStatistics){ // Probably irrelevant, such as a new helper node return; } const now = new Date() const recentLocationPoints = this._state.historicalUserLocations.features.data.map(ff => ff.feature) .filter(feat => feat.geometry.type === "Point") .filter(feat => { const visitTime = new Date((feat.properties).date) // In seconds const diff = (now.getTime() - visitTime.getTime()) / 1000 return diff < Constants.nearbyVisitTime; }) if(recentLocationPoints.length === 0){ // Probably no GPS enabled/no fix return; } // The applicable points, contain information in their properties about location, time and GPS accuracy // They are all GeoLocationPointProperties // We walk every change and determine the closest distance possible // Only if the change itself does _not_ contain any coordinates, we fall back and search the original feature in the state const changedObjectCoordinates : [number, number][] = [] const feature = this._state.allElements.ContainingFeatures.get(change.mainObjectId) if(feature !== undefined){ changedObjectCoordinates.push(GeoOperations.centerpointCoordinates(feature)) } for (const changeDescription of changeDescriptions) { const chng : {lat: number, lon: number} | {coordinates : [number,number][]} | {members} = changeDescription.changes if(chng === undefined){ continue } if(chng["lat"] !== undefined){ changedObjectCoordinates.push([chng["lat"],chng["lon"]]) } if(chng["coordinates"] !== undefined){ changedObjectCoordinates.push(...chng["coordinates"]) } } return Math.min(...changedObjectCoordinates.map(coor => Math.min(...recentLocationPoints.map(gpsPoint => { const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint) return GeoOperations.distanceBetween(coor, otherCoor) * 1000 })) )) } public async applyAction(action: OsmChangeAction): Promise { const changeDescriptions = await action.Perform(this) changeDescriptions[0].meta.distanceToObject = this.calculateDistanceToChanges(action, changeDescriptions) this.applyChanges(changeDescriptions) } public applyChanges(changes: ChangeDescription[]) { console.log("Received changes:", changes) this.pendingChanges.data.push(...changes); this.pendingChanges.ping(); this.allChanges.data.push(...changes) this.allChanges.ping() } public useLocationHistory(state: { allElements: ElementStorage, historicalUserLocations: FeatureSource }){ this._state= state } public registerIdRewrites(mappings: Map): void { CreateNewNodeAction.registerIdRewrites(mappings) } /** * UPload the selected changes to OSM. * Returns 'true' if successfull and if they can be removed * @param pending * @private */ private async flushSelectChanges(pending: ChangeDescription[]): Promise { const self = this; const neededIds = Changes.GetNeededIds(pending) const osmObjects = await Promise.all(neededIds.map(id => OsmObject.DownloadObjectAsync(id))); if (this._leftRightSensitive) { osmObjects.forEach(obj => SimpleMetaTagger.removeBothTagging(obj.tags)) } console.log("Got the fresh objects!", osmObjects, "pending: ", pending) const changes: { newObjects: OsmObject[], modifiedObjects: OsmObject[] deletedObjects: OsmObject[] } = self.CreateChangesetObjects(pending, osmObjects) if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { console.log("No changes to be made") return true } const perType = Array.from( Utils.Hist(pending.filter(descr => descr.meta.changeType !== undefined && descr.meta.changeType !== null) .map(descr => descr.meta.changeType)), ([key, count]) => ( { key: key, value: count, aggregate: true })) const motivations = pending.filter(descr => descr.meta.specialMotivation !== undefined) .map(descr => ({ key: descr.meta.changeType + ":" + descr.type + "/" + descr.id, value: descr.meta.specialMotivation })) const distances = Utils.NoNull(pending.map(descr => descr.meta.distanceToObject)); distances.sort((a, b) => a - b) const perBinCount = Constants.distanceToChangeObjectBins.map(_ => 0) let j = 0; const maxDistances = Constants.distanceToChangeObjectBins for (let i = 0; i < maxDistances.length; i++){ const maxDistance = maxDistances[i]; // distances is sorted in ascending order, so as soon as one is to big, all the resting elements will be bigger too while(j < distances.length && distances[j] < maxDistance){ perBinCount[i] ++ j++ } } const perBinMessage = Utils.NoNull(perBinCount.map((count, i) => { if(count === 0){ return undefined } return { key: "change_within_"+maxDistances[i]+"m", value: count, aggregate:true } })) // This method is only called with changedescriptions for this theme const theme = pending[0].meta.theme const metatags : ChangesetTag[] = [{ key: "comment", value: "Adding data with #MapComplete for theme #" + theme }, { key: "theme", value: theme }, ...perType, ...motivations, ...perBinMessage ] await State.state.osmConnection.changesetHandler.UploadChangeset( (csId) => Changes.createChangesetFor("" + csId, changes), metatags ) console.log("Upload successfull!") return true; } private async flushChangesAsync(): Promise { const self = this; try { // At last, we build the changeset and upload const pending = self.pendingChanges.data; const pendingPerTheme = new Map() for (const changeDescription of pending) { const theme = changeDescription.meta.theme if (!pendingPerTheme.has(theme)) { pendingPerTheme.set(theme, []) } pendingPerTheme.get(theme).push(changeDescription) } const successes = await Promise.all(Array.from(pendingPerTheme, ([_, value]) => value) .map(async pendingChanges => { try { return await self.flushSelectChanges(pendingChanges); } catch (e) { console.error("Could not upload some changes:", e) return false } })) if (!successes.some(s => s == false)) { // All changes successfull, we clear the data! this.pendingChanges.setData([]); } } catch (e) { console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e) self.pendingChanges.setData([]) } finally { self.isUploading.setData(false) } } private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { newObjects: OsmObject[], modifiedObjects: OsmObject[] deletedObjects: OsmObject[] } { const objects: Map = new Map() const states: Map = new Map(); for (const o of downloadedOsmObjects) { objects.set(o.type + "/" + o.id, o) states.set(o.type + "/" + o.id, "unchanged") } for (const o of this.previouslyCreated) { objects.set(o.type + "/" + o.id, o) states.set(o.type + "/" + o.id, "unchanged") } let changed = false; for (const change of changes) { const id = change.type + "/" + change.id if (!objects.has(id)) { if (change.id >= 0) { throw "Did not get an object that should be known: " + id } // This is a new object that should be created states.set(id, "created") console.log("Creating object for changeDescription", change) let osmObj: OsmObject = undefined; switch (change.type) { case "node": const n = new OsmNode(change.id) n.lat = change.changes["lat"] n.lon = change.changes["lon"] osmObj = n break; case "way": const w = new OsmWay(change.id) w.nodes = change.changes["nodes"] osmObj = w break; case "relation": const r = new OsmRelation(change.id) r.members = change.changes["members"] osmObj = r break; } if (osmObj === undefined) { throw "Hmm? This is a bug" } objects.set(id, osmObj) this.previouslyCreated.push(osmObj) } const state = states.get(id) if (change.doDelete) { if (state === "created") { states.set(id, "unchanged") } else { states.set(id, "deleted") } } const obj = objects.get(id) // Apply tag changes for (const kv of change.tags ?? []) { const k = kv.k let v = kv.v if (v === "") { v = undefined; } const oldV = obj.tags[k] if (oldV === v) { continue; } obj.tags[k] = v; changed = true; } if (change.changes !== undefined) { switch (change.type) { case "node": // @ts-ignore const nlat = change.changes.lat; // @ts-ignore const nlon = change.changes.lon; const n = obj if (n.lat !== nlat || n.lon !== nlon) { n.lat = nlat; n.lon = nlon; changed = true; } break; case "way": const nnodes = change.changes["nodes"] const w = obj if (!Utils.Identical(nnodes, w.nodes)) { w.nodes = nnodes changed = true; } break; case "relation": const nmembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = change.changes["members"] const r = obj if (!Utils.Identical(nmembers, r.members, (a, b) => { return a.role === b.role && a.type === b.type && a.ref === b.ref })) { r.members = nmembers; changed = true; } break; } } if (changed && state === "unchanged") { states.set(id, "modified") } } const result = { newObjects: [], modifiedObjects: [], deletedObjects: [] } objects.forEach((v, id) => { const state = states.get(id) if (state === "created") { result.newObjects.push(v) } if (state === "modified") { result.modifiedObjects.push(v) } if (state === "deleted") { result.deletedObjects.push(v) } }) return result } }