mapcomplete/Logic/Osm/Changes.ts

488 lines
18 KiB
TypeScript
Raw Normal View History

import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject";
import State from "../../State";
2020-10-27 01:01:34 +01:00
import {UIEventSource} from "../UIEventSource";
2021-01-02 19:09:49 +01:00
import Constants from "../../Models/Constants";
import OsmChangeAction from "./Actions/OsmChangeAction";
import {ChangeDescription} from "./Actions/ChangeDescription";
import {Utils} from "../../Utils";
2021-07-22 14:25:23 +02:00
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";
2020-06-24 00:35:19 +02:00
/**
* Handles all changes made to OSM.
* Needs an authenticator via OsmConnection
*/
export class Changes {
2021-04-23 12:56:56 +02:00
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<ChangeDescription[]> = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
private _nextId: number = -1; // Newly assigned ID's are negative
2021-07-13 00:40:27 +02:00
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
2021-09-26 23:35:26 +02:00
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 = `<osmChange version='0.6' generator='Mapcomplete ${Constants.vNumber}'>`;
if (newElements.length > 0) {
changes +=
"\n<create>\n" +
newElements.map(e => e.ChangesetXML(csId)).join("\n") +
"</create>";
2020-06-24 00:35:19 +02:00
}
if (changedElements.length > 0) {
changes +=
"\n<modify>\n" +
changedElements.map(e => e.ChangesetXML(csId)).join("\n") +
"\n</modify>";
2020-06-24 00:35:19 +02:00
}
if (deletedElements.length > 0) {
changes +=
"\n<delete>\n" +
deletedElements.map(e => e.ChangesetXML(csId)).join("\n") +
"\n</delete>"
2020-08-06 19:42:10 +02:00
}
changes += "</osmChange>";
return changes;
2020-06-24 00:35:19 +02:00
}
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()
2021-09-26 23:35:26 +02:00
.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((<GeoLocationPointProperties>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"])
}
}
2021-11-09 02:03:32 +01:00
return Math.min(...changedObjectCoordinates.map(coor =>
Math.min(...recentLocationPoints.map(gpsPoint => {
const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint)
2021-11-09 02:03:32 +01:00
return GeoOperations.distanceBetween(coor, otherCoor) * 1000
}))
))
}
public async applyAction(action: OsmChangeAction): Promise<void> {
const changeDescriptions = await action.Perform(this)
2021-11-09 02:03:32 +01:00
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<string, string>): 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<boolean> {
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<void> {
const self = this;
2021-09-26 23:35:26 +02:00
try {
// At last, we build the changeset and upload
const pending = self.pendingChanges.data;
const pendingPerTheme = new Map<string, ChangeDescription[]>()
for (const changeDescription of pending) {
const theme = changeDescription.meta.theme
if (!pendingPerTheme.has(theme)) {
pendingPerTheme.set(theme, [])
}
pendingPerTheme.get(theme).push(changeDescription)
}
2021-11-09 02:03:32 +01:00
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([]);
}
2021-09-26 23:35:26 +02:00
} 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 {
2021-09-26 23:35:26 +02:00
self.isUploading.setData(false)
}
}
private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
newObjects: OsmObject[],
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} {
const objects: Map<string, OsmObject> = new Map<string, OsmObject>()
const states: Map<string, "unchanged" | "created" | "modified" | "deleted"> = 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;
2020-06-24 00:35:19 +02:00
}
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 = <OsmNode>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 = <OsmWay>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 = <OsmRelation>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) => {
2021-07-15 00:39:11 +02:00
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
}
2020-06-24 00:35:19 +02:00
}