diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 48e96f4a2..f9efc9534 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -49,6 +49,7 @@ export default class LayerConfig { wayHandling: number; public readonly units: Unit[]; public readonly deletion: DeleteConfig | null + public readonly allowSplit: boolean presets: { title: Translation, @@ -67,6 +68,7 @@ export default class LayerConfig { context = context + "." + json.id; const self = this; this.id = json.id; + this.allowSplit = json.allowSplit ?? false; this.name = Translations.T(json.name, context + ".name"); if(json.description !== undefined){ diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index ca272ecb0..21f3e5c99 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -291,4 +291,9 @@ export interface LayerConfigJson { */ deletion?: boolean | DeleteConfigJson + /** + * IF set, a 'split this road' button is shown + */ + allowSplit?: boolean + } \ No newline at end of file diff --git a/Customizations/JSON/LayoutConfig.ts b/Customizations/JSON/LayoutConfig.ts index 12b9d5f76..ab597ea5a 100644 --- a/Customizations/JSON/LayoutConfig.ts +++ b/Customizations/JSON/LayoutConfig.ts @@ -65,7 +65,7 @@ export default class LayoutConfig { this.language = json.language; } if (this.language.length == 0) { - throw "No languages defined. Define at least one language" + throw `No languages defined. Define at least one language. (${context}.languages)` } if (json.title === undefined) { throw "Title not defined in " + this.id; diff --git a/InitUiElements.ts b/InitUiElements.ts index 0dbc7eaac..f7c344d45 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -420,10 +420,10 @@ export class InitUiElements { const source = new FeaturePipeline(state.filteredLayers, + State.state.changes, updater, state.osmApiFeatureSource, state.layoutToUse, - state.changes, state.locationControl, state.selectedElement); diff --git a/Logic/Actors/ChangeToElementsActor.ts b/Logic/Actors/ChangeToElementsActor.ts new file mode 100644 index 000000000..2f157866a --- /dev/null +++ b/Logic/Actors/ChangeToElementsActor.ts @@ -0,0 +1,36 @@ +import {ElementStorage} from "../ElementStorage"; +import {Changes} from "../Osm/Changes"; + +export default class ChangeToElementsActor { + constructor(changes: Changes, allElements: ElementStorage) { + changes.pendingChanges.addCallbackAndRun(changes => { + for (const change of changes) { + const id = change.type + "/" + change.id; + if (!allElements.has(id)) { +continue; // Will be picked up later on + } + const src = allElements.getEventSourceById(id) + + let changed = false; + for (const kv of change.tags ?? []) { + // Apply tag changes and ping the consumers + const k = kv.k + let v = kv.v + if (v === "") { + v = undefined; + } + if (src.data[k] === v) { + continue + } + changed = true; + src.data[k] = v; + } + if (changed) { + src.ping() + } + + + } + }) + } +} \ No newline at end of file diff --git a/Logic/Actors/PendingChangesUploader.ts b/Logic/Actors/PendingChangesUploader.ts index b2a8e100d..82adde57b 100644 --- a/Logic/Actors/PendingChangesUploader.ts +++ b/Logic/Actors/PendingChangesUploader.ts @@ -9,7 +9,7 @@ export default class PendingChangesUploader { constructor(changes: Changes, selectedFeature: UIEventSource) { const self = this; this.lastChange = new Date(); - changes.pending.addCallback(() => { + changes.pendingChanges.addCallback(() => { self.lastChange = new Date(); window.setTimeout(() => { @@ -54,7 +54,7 @@ export default class PendingChangesUploader { function onunload(e) { - if (changes.pending.data.length == 0) { + if(changes.pendingChanges.data.length == 0){ return; } changes.flushChanges("onbeforeunload - probably closing or something similar"); diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts new file mode 100644 index 000000000..58ba5174c --- /dev/null +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -0,0 +1,138 @@ +import FeatureSource from "./FeatureSource"; +import {UIEventSource} from "../UIEventSource"; +import {Changes} from "../Osm/Changes"; +import {ChangeDescription} from "../Osm/Actions/ChangeDescription"; +import {Utils} from "../../Utils"; +import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject"; + +/** + * Applies changes from 'Changes' onto a featureSource + */ +export default class ChangeApplicator implements FeatureSource { + public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; + public readonly name: string; + + constructor(source: FeatureSource, changes: Changes) { + + this.name = "ChangesApplied(" + source.name + ")" + this.features = source.features + + source.features.addCallbackAndRunD(features => { + ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data) + }) + + changes.pendingChanges.addCallbackAndRunD(changes => { + ChangeApplicator.ApplyChanges(source.features.data, changes) + source.features.ping() + }) + + + } + + + private static ApplyChanges(features: { feature: any, freshness: Date }[], cs: ChangeDescription[]) { + if (cs.length === 0 || features === undefined) { + return features; + } + + const changesPerId: Map = new Map() + for (const c of cs) { + const id = c.type + "/" + c.id + if (!changesPerId.has(id)) { + changesPerId.set(id, []) + } + changesPerId.get(id).push(c) + } + + + const now = new Date() + + function add(feature) { + features.push({ + feature: feature, + freshness: now + }) + } + + // First, create the new features - they have a negative ID + // We don't set the properties yet though + changesPerId.forEach(cs => { + cs.forEach(change => { + if (change.id >= 0) { + return; // Nothing to do here, already created + } + + + try { + + switch (change.type) { + case "node": + const n = new OsmNode(change.id) + n.lat = change.changes["lat"] + n.lon = change.changes["lon"] + const geojson = n.asGeoJson() + add(geojson) + break; + case "way": + const w = new OsmWay(change.id) + w.nodes = change.changes["nodes"] + add(w.asGeoJson()) + break; + case "relation": + const r = new OsmRelation(change.id) + r.members = change.changes["members"] + add(r.asGeoJson()) + break; + } + + } catch (e) { + console.error(e) + } + }) + }) + + + for (const feature of features) { + const id = feature.feature.properties.id; + const f = feature.feature; + if (!changesPerId.has(id)) { + continue; + } + + + const changed = {} + // Copy all the properties + Utils.Merge(f, changed) + // play the changes onto the copied object + + for (const change of changesPerId.get(id)) { + for (const kv of change.tags ?? []) { + // Apply tag changes and ping the consumers + const k = kv.k + let v = kv.v + if (v === "") { + v = undefined; + } + f.properties[k] = v; + } + + // Apply other changes to the object + if (change.changes !== undefined) { + switch (change.type) { + case "node": + // @ts-ignore + const coor: { lat, lon } = change.changes; + f.geometry.coordinates = [[coor.lon, coor.lat]] + break; + case "way": + f.geometry.coordinates = change.changes["locations"] + break; + case "relation": + console.error("Changes to relations are not yet supported") + break; + } + } + } + } + } +} \ No newline at end of file diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 340710503..6e77e0efd 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -13,6 +13,8 @@ import Loc from "../../Models/Loc"; import GeoJsonSource from "./GeoJsonSource"; import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource"; import RegisteringFeatureSource from "./RegisteringFeatureSource"; +import {Changes} from "../Osm/Changes"; +import ChangeApplicator from "./ChangeApplicator"; export default class FeaturePipeline implements FeatureSource { @@ -21,10 +23,10 @@ export default class FeaturePipeline implements FeatureSource { public readonly name = "FeaturePipeline" constructor(flayers: UIEventSource<{ isDisplayed: UIEventSource, layerDef: LayerConfig }[]>, + changes: Changes, updater: FeatureSource, fromOsmApi: FeatureSource, layout: UIEventSource, - newPoints: FeatureSource, locationControl: UIEventSource, selectedElement: UIEventSource) { @@ -40,13 +42,16 @@ export default class FeaturePipeline implements FeatureSource { new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, new RegisteringFeatureSource( - updater) + new ChangeApplicator( + updater, changes + )) )), layout)); const geojsonSources: FeatureSource [] = GeoJsonSource .ConstructMultiSource(flayers.data, locationControl) .map(geojsonSource => { - let source = new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, geojsonSource)); + let source = new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, + new ChangeApplicator(geojsonSource, changes))); if(!geojsonSource.isOsmCache){ source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features); } @@ -54,25 +59,19 @@ export default class FeaturePipeline implements FeatureSource { }); const amendedLocalStorageSource = - new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, new LocalStorageSource(layout)) + new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers,new ChangeApplicator( new LocalStorageSource(layout), changes)) )); - newPoints = new MetaTaggingFeatureSource(allLoadedFeatures, - new FeatureDuplicatorPerLayer(flayers, - new RegisteringFeatureSource(newPoints))); - const amendedOsmApiSource = new RememberingSource( new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, - - new RegisteringFeatureSource(fromOsmApi)))); + new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes))))); const merged = new FeatureSourceMerger([ amendedOverpassSource, amendedOsmApiSource, amendedLocalStorageSource, - newPoints, ...geojsonSources ]); diff --git a/Logic/Osm/Actions/Action.ts b/Logic/Osm/Actions/Action.ts deleted file mode 100644 index c09e20cb8..000000000 --- a/Logic/Osm/Actions/Action.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * An action is a change to the OSM-database - * It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object - */ -export default interface Action { - -} \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeDescription.ts b/Logic/Osm/Actions/ChangeDescription.ts new file mode 100644 index 000000000..aefab9c1c --- /dev/null +++ b/Logic/Osm/Actions/ChangeDescription.ts @@ -0,0 +1,30 @@ +export interface ChangeDescription { + + type: "node" | "way" | "relation", + /** + * Negative for a new objects + */ + id: number, + /* + v = "" or v = undefined to erase this tag + */ + tags?: { k: string, v: string }[], + + changes?: { + lat: number, + lon: number + } | { + // Coordinates are only used for rendering + locations: [number, number][] + nodes: number[], + } | { + members: { type: "node" | "way" | "relation", ref: number, role: string }[] + } + + /* + Set to delete the object + */ + doDelete?: boolean + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeTagAction.ts b/Logic/Osm/Actions/ChangeTagAction.ts new file mode 100644 index 000000000..1915d1858 --- /dev/null +++ b/Logic/Osm/Actions/ChangeTagAction.ts @@ -0,0 +1,52 @@ +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {TagsFilter} from "../../Tags/TagsFilter"; + +export default class ChangeTagAction extends OsmChangeAction { + private readonly _elementId: string; + private readonly _tagsFilter: TagsFilter; + private readonly _currentTags: any; + + constructor(elementId: string, tagsFilter: TagsFilter, currentTags: any) { + super(); + this._elementId = elementId; + this._tagsFilter = tagsFilter; + this._currentTags = currentTags; + } + + /** + * Doublechecks that no stupid values are added + */ + private static checkChange(kv: { k: string, v: string }): { k: string, v: string } { + const key = kv.k; + const value = kv.v; + if (key === undefined || key === null) { + console.log("Invalid key"); + return undefined; + } + if (value === undefined || value === null) { + console.log("Invalid value for ", key); + return undefined; + } + + if (key.startsWith(" ") || value.startsWith(" ") || value.endsWith(" ") || key.endsWith(" ")) { + console.warn("Tag starts with or ends with a space - trimming anyway") + } + + return {k: key.trim(), v: value.trim()}; + } + + Perform(changes: Changes): ChangeDescription [] { + const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange) + const typeId = this._elementId.split("/") + const type = typeId[0] + const id = Number(typeId [1]) + return [{ + // @ts-ignore + type: type, + id: id, + tags: changedTags + }] + } +} \ No newline at end of file diff --git a/Logic/Osm/Actions/CreateNewNodeAction.ts b/Logic/Osm/Actions/CreateNewNodeAction.ts new file mode 100644 index 000000000..28a656039 --- /dev/null +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -0,0 +1,44 @@ +import {Tag} from "../../Tags/Tag"; +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {And} from "../../Tags/And"; + +export default class CreateNewNodeAction implements OsmChangeAction { + + private readonly _basicTags: Tag[]; + private readonly _lat: number; + private readonly _lon: number; + + constructor(basicTags: Tag[], lat: number, lon: number) { + this._basicTags = basicTags; + this._lat = lat; + this._lon = lon; + } + + Perform(changes: Changes): ChangeDescription[] { + const id = changes.getNewID() + const properties = { + id: "node/" + id + } + for (const kv of this._basicTags) { + if (typeof kv.value !== "string") { + throw "Invalid value: don't use a regex in a preset" + } + properties[kv.key] = kv.value; + } + + return [{ + tags: new And(this._basicTags).asChange(properties), + type: "node", + id: id, + changes:{ + lat: this._lat, + lon: this._lon + } + }] + + } + + +} \ No newline at end of file diff --git a/Logic/Osm/DeleteAction.ts b/Logic/Osm/Actions/DeleteAction.ts similarity index 95% rename from Logic/Osm/DeleteAction.ts rename to Logic/Osm/Actions/DeleteAction.ts index 73cb066df..213bb0e86 100644 --- a/Logic/Osm/DeleteAction.ts +++ b/Logic/Osm/Actions/DeleteAction.ts @@ -1,9 +1,9 @@ -import {UIEventSource} from "../UIEventSource"; -import {Translation} from "../../UI/i18n/Translation"; -import Translations from "../../UI/i18n/Translations"; -import {OsmObject} from "./OsmObject"; -import State from "../../State"; -import Constants from "../../Models/Constants"; +import {UIEventSource} from "../../UIEventSource"; +import {Translation} from "../../../UI/i18n/Translation"; +import State from "../../../State"; +import {OsmObject} from "../OsmObject"; +import Translations from "../../../UI/i18n/Translations"; +import Constants from "../../../Models/Constants"; export default class DeleteAction { @@ -30,7 +30,7 @@ export default class DeleteAction { * Does actually delete the feature; returns the event source 'this.isDeleted' * If deletion is not allowed, triggers the callback instead */ - public DoDelete(reason: string, onNotAllowed : () => void): UIEventSource { + public DoDelete(reason: string, onNotAllowed : () => void): void { const isDeleted = this.isDeleted const self = this; let deletionStarted = false; @@ -75,8 +75,6 @@ export default class DeleteAction { } ) - - return isDeleted; } /** diff --git a/Logic/Osm/Actions/OsmChangeAction.ts b/Logic/Osm/Actions/OsmChangeAction.ts new file mode 100644 index 000000000..ecb9f3df8 --- /dev/null +++ b/Logic/Osm/Actions/OsmChangeAction.ts @@ -0,0 +1,16 @@ +/** + * An action is a change to the OSM-database + * It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object + */ +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; + +export default abstract class OsmChangeAction { + + + + public abstract Perform(changes: Changes): ChangeDescription[] + + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/RelationSplitlHandler.ts b/Logic/Osm/Actions/RelationSplitlHandler.ts new file mode 100644 index 000000000..601b2d136 --- /dev/null +++ b/Logic/Osm/Actions/RelationSplitlHandler.ts @@ -0,0 +1,20 @@ +/** + * The logic to handle relations after a way within + */ +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {OsmRelation, OsmWay} from "../OsmObject"; + +export default class RelationSplitlHandler extends OsmChangeAction{ + + constructor(partOf: OsmRelation[], newWayIds: number[], originalNodes: number[]) { + super() + } + + Perform(changes: Changes): ChangeDescription[] { + return []; + } + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/SplitAction.ts b/Logic/Osm/Actions/SplitAction.ts new file mode 100644 index 000000000..c17fd3fde --- /dev/null +++ b/Logic/Osm/Actions/SplitAction.ts @@ -0,0 +1,236 @@ +import {OsmRelation, OsmWay} from "../OsmObject"; +import {Changes} from "../Changes"; +import {GeoOperations} from "../../GeoOperations"; +import OsmChangeAction from "./OsmChangeAction"; +import {ChangeDescription} from "./ChangeDescription"; +import RelationSplitlHandler from "./RelationSplitlHandler"; + +interface SplitInfo { + originalIndex?: number, // or negative for new elements + lngLat: [number, number], + doSplit: boolean +} + +export default class SplitAction extends OsmChangeAction { + private readonly roadObject: any; + private readonly osmWay: OsmWay; + private _partOf: OsmRelation[]; + private readonly _splitPoints: any[]; + + constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) { + super() + this.osmWay = osmWay; + this.roadObject = wayGeoJson; + this._partOf = partOf; + this._splitPoints = splitPoints; + } + + private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { + const wayParts = [] + let currentPart = [] + for (const splitInfoElement of splitInfo) { + currentPart.push(splitInfoElement) + + if (splitInfoElement.doSplit) { + // We have to do a split! + // We add the current index to the currentParts, flush it and add it again + wayParts.push(currentPart) + currentPart = [splitInfoElement] + } + } + wayParts.push(currentPart) + return wayParts.filter(wp => wp.length > 0) + } + + Perform(changes: Changes): ChangeDescription[] { + const splitPoints = this._splitPoints + // We mark the new split points with a new id + console.log(splitPoints) + for (const splitPoint of splitPoints) { + splitPoint.properties["_is_split_point"] = true + } + + + const self = this; + const partOf = this._partOf + const originalElement = this.osmWay + const originalNodes = this.osmWay.nodes; + + // First, calculate splitpoints and remove points close to one another + const splitInfo = self.CalculateSplitCoordinates(splitPoints) + // Now we have a list with e.g. + // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] + + // Lets change 'originalIndex' to the actual node id first: + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + element.originalIndex = originalElement.nodes[element.originalIndex] + } else { + element.originalIndex = changes.getNewID(); + } + } + + // Next up is creating actual parts from this + const wayParts: SplitInfo[][] = SplitAction.SegmentSplitInfo(splitInfo); +console.log("WayParts", wayParts, "by", splitInfo) + // Allright! At this point, we have our new ways! + // Which one is the longest of them (and can keep the id)? + + let longest = undefined; + for (const wayPart of wayParts) { + if (longest === undefined) { + longest = wayPart; + continue + } + if (wayPart.length > longest.length) { + longest = wayPart + } + } + + const changeDescription: ChangeDescription[] = [] + // Let's create the new points as needed + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + continue; + } + changeDescription.push({ + type: "node", + id: element.originalIndex, + changes:{ + lon: element.lngLat[0], + lat: element.lngLat[1] + } + }) + } + + const newWayIds: number[] = [] + // Lets create OsmWays based on them + for (const wayPart of wayParts) { + + let isOriginal = wayPart === longest + if (isOriginal) { + // We change the actual element! + changeDescription.push({ + type:"way", + id: originalElement.id, + changes:{ + locations: wayPart.map(p => p.lngLat), + nodes: wayPart.map(p => p.originalIndex) + } + }) + } else { + let id = changes.getNewID(); + newWayIds.push(id) + + const kv = [] + for (const k in originalElement.tags) { + if(!originalElement.tags.hasOwnProperty(k)){ + continue + } + kv .push({k: k, v: originalElement.tags[k]}) + } + changeDescription.push({ + type:"way", + id:id, + tags: kv, + changes:{ + locations: wayPart.map(p => p.lngLat), + nodes: wayPart.map(p => p.originalIndex) + } + }) + } + + } + + // At last, we still have to check that we aren't part of a relation... + // At least, the order of the ways is identical, so we can keep the same roles + changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).Perform(changes)) + + // And we have our objects! + // Time to upload + + return changeDescription + } + + /** + * Calculates the actual points to split + * If another point is closer then ~5m, we reuse that point + */ + private CalculateSplitCoordinates( + splitPoints: any[], + toleranceInM = 5): SplitInfo[] { + + const allPoints = [...splitPoints]; + // We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... + const originalPoints: [number, number][] = this.roadObject.geometry.coordinates + // We project them onto the line (which should yield pretty much the same point + for (let i = 0; i < originalPoints.length; i++) { + let originalPoint = originalPoints[i]; + let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) + projected.properties["_is_split_point"] = false + projected.properties["_original_index"] = i + allPoints.push(projected) + } + // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them + // We sort this list so that the new points are at the same location + allPoints.sort((a, b) => a.properties.location - b.properties.location) + + // When this is done, we check that no now point is too close to an already existing point and no very small segments get created + + /* for (let i = allPoints.length - 1; i > 0; i--) { + + const point = allPoints[i]; + if (point.properties._original_index !== undefined) { + // This point is already in OSM - we have to keep it! + continue; + } + + if (i != allPoints.length - 1) { + const prevPoint = allPoints[i + 1] + const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the previous point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the previous point as a split point + prevPoint.properties._is_split_point = true + continue; + } + } + + if (i > 0) { + const nextPoint = allPoints[i - 1] + const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the next point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the next point as a split point + nextPoint.properties._is_split_point = true + // noinspection UnnecessaryContinueJS + continue; + } + } + // We don't have to remove this point... + }*/ + + const splitInfo: SplitInfo[] = [] + let nextId = -1 + + for (const p of allPoints) { + let index = p.properties._original_index + if (index === undefined) { + index = nextId; + nextId--; + } + const splitInfoElement = { + originalIndex: index, + lngLat: p.geometry.coordinates, + doSplit: p.properties._is_split_point + } + splitInfo.push(splitInfoElement) + } + + return splitInfo + } + + +} diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 001005ea0..18b67970e 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -1,81 +1,225 @@ -import {OsmNode, OsmObject} from "./OsmObject"; +import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; import State from "../../State"; -import {Utils} from "../../Utils"; import {UIEventSource} from "../UIEventSource"; import Constants from "../../Models/Constants"; -import FeatureSource from "../FeatureSource/FeatureSource"; -import {TagsFilter} from "../Tags/TagsFilter"; -import {Tag} from "../Tags/Tag"; -import {OsmConnection} from "./OsmConnection"; +import OsmChangeAction from "./Actions/OsmChangeAction"; +import {ChangeDescription} from "./Actions/ChangeDescription"; import {LocalStorageSource} from "../Web/LocalStorageSource"; +import {Utils} from "../../Utils"; /** * Handles all changes made to OSM. * Needs an authenticator via OsmConnection */ -export class Changes implements FeatureSource { +export class Changes { private static _nextId = -1; // Newly assigned ID's are negative public readonly name = "Newly added features" /** - * The newly created points, as a FeatureSource + * All the newly created features as featureSource + all the modified features */ public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); - /** - * All the pending changes - */ - public readonly pending = LocalStorageSource.GetParsed<{ elementId: string, key: string, value: string }[]>("pending-changes", []) - - /** - * All the pending new objects to upload - */ - private readonly newObjects = LocalStorageSource.GetParsed<{ id: number, lat: number, lon: number }[]>("newObjects", []) + public readonly pendingChanges = new UIEventSource([]) // LocalStorageSource.GetParsed("pending-changes", []) private readonly isUploading = new UIEventSource(false); - /** - * Adds a change to the pending changes - */ - private static checkChange(kv: { k: string, v: string }): { k: string, v: string } { - const key = kv.k; - const value = kv.v; - if (key === undefined || key === null) { - console.log("Invalid key"); - return undefined; - } - if (value === undefined || value === null) { - console.log("Invalid value for ", key); - return undefined; - } - - if (key.startsWith(" ") || value.startsWith(" ") || value.endsWith(" ") || key.endsWith(" ")) { - console.warn("Tag starts with or ends with a space - trimming anyway") - } - - return {k: key.trim(), v: value.trim()}; + constructor() { + this.isUploading.addCallbackAndRun(u => { + if (u) { + console.trace("Uploading set!") + } + }) } + public static createChangesetFor(csId: string, + allChanges: { + modifiedObjects?: OsmObject[], + newElements?: OsmObject[], + deletedElements?: OsmObject[] + }): string { - addTag(elementId: string, tagsFilter: TagsFilter, - tags?: UIEventSource) { - const eventSource = tags ?? State.state?.allElements.getEventSourceById(elementId); - const elementTags = eventSource.data; - const changes = tagsFilter.asChange(elementTags).map(Changes.checkChange) - if (changes.length == 0) { - return; + const changedElements = allChanges.modifiedObjects ?? [] + const newElements = allChanges.newElements ?? [] + const deletedElements = allChanges.deletedElements ?? [] + + 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)) + } + + private static 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") + } + + let changed = false; for (const change of changes) { - if (elementTags[change.k] !== change.v) { - elementTags[change.k] = change.v; - console.log("Applied ", change.k, "=", change.v) - // We use 'elementTags.id' here, as we might have retrieved with the id 'node/-1' as new point, but should use the rewritten id - this.pending.data.push({elementId: elementTags.id, key: change.k, value: change.v}); + const id = change.type + "/" + change.id + if (!objects.has(id)) { + // This is a new object that should be created + states.set(id, "created") + 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) + } + + 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.type[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") } } - this.pending.ping(); - eventSource.ping(); + + + 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 + } + + /** + * Returns a new ID and updates the value for the next ID + */ + public getNewID() { + return Changes._nextId--; } /** @@ -83,121 +227,16 @@ export class Changes implements FeatureSource { * Triggered by the 'PendingChangeUploader'-actor in Actors */ public flushChanges(flushreason: string = undefined) { - if (this.pending.data.length === 0) { + if (this.pendingChanges.data.length === 0) { + console.log("No pending changes") return; } if (flushreason !== undefined) { console.log(flushreason) } - this.uploadAll(); - } - - /** - * Returns a new ID and updates the value for the next ID - */ - public getNewID(){ - return Changes._nextId--; - } - - /** - * Create a new node element at the given lat/long. - * An internal OsmObject is created to upload later on, a geojson represention is returned. - * Note that the geojson version shares the tags (properties) by pointer, but has _no_ id in properties - */ - public createElement(basicTags: Tag[], lat: number, lon: number) { - console.log("Creating a new element with ", basicTags) - - const osmNode = new OsmNode(this.getNewID()); - const properties = {id: osmNode.id}; - const geojson = { - "type": "Feature", - "properties": properties, - "id": properties.id, - "geometry": { - "type": "Point", - "coordinates": [ - lon, - lat - ] - } - } - - // The basictags are COPIED, the id is included in the properties - // The tags are not yet written into the OsmObject, but this is applied onto a - const changes = []; - for (const kv of basicTags) { - if (typeof kv.value !== "string") { - throw "Invalid value: don't use a regex in a preset" - } - properties[kv.key] = kv.value; - changes.push({elementId:properties.id, key: kv.key, value: kv.value}) - } - - console.log("New feature added and pinged") - this.features.data.push({feature: geojson, freshness: new Date()}); - this.features.ping(); - - State.state.allElements.addOrGetElement(geojson).ping(); - - if (State.state.osmConnection.userDetails.data.backend !== OsmConnection.oauth_configs.osm.url) { - properties["_backend"] = State.state.osmConnection.userDetails.data.backend - } - - - this.newObjects.data.push({id: osmNode.id, lat: lat, lon: lon}) - this.pending.data.push(...changes) - this.pending.ping(); - this.newObjects.ping(); - return geojson; - } - - - private uploadChangesWithLatestVersions( - knownElements: OsmObject[]) { - const knownById = new Map(); - knownElements.forEach(knownElement => { - knownById.set(knownElement.type + "/" + knownElement.id, knownElement) - }) - - const newElements: OsmNode [] = this.newObjects.data.map(spec => { - const newElement = new OsmNode(spec.id); - newElement.lat = spec.lat; - newElement.lon = spec.lon; - return newElement - }) - - - // Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements', which maps the ids onto the elements - // We apply the changes on them - for (const change of this.pending.data) { - if (parseInt(change.elementId.split("/")[1]) < 0) { - // This is a new element - we should apply this on one of the new elements - for (const newElement of newElements) { - if (newElement.type + "/" + newElement.id === change.elementId) { - newElement.addTag(change.key, change.value); - } - } - } else { - knownById.get(change.elementId).addTag(change.key, change.value); - } - } - - // Small sanity check for duplicate information - let changedElements = []; - for (const elementId in knownElements) { - const element = knownElements[elementId]; - if (element.changed) { - changedElements.push(element); - } - } - if (changedElements.length == 0 && newElements.length == 0) { - console.log("No changes in any object - clearing"); - this.pending.setData([]) - this.newObjects.setData([]) - return; - } if (this.isUploading.data) { + console.log("Is uploading... Abort") return; } this.isUploading.setData(true) @@ -205,75 +244,45 @@ export class Changes implements FeatureSource { console.log("Beginning upload..."); // At last, we build the changeset and upload const self = this; - State.state.osmConnection.UploadChangeset( - State.state.layoutToUse.data, - State.state.allElements, - (csId) => Changes.createChangesetFor(csId,changedElements, newElements ), - () => { - // When done - console.log("Upload successfull!") - self.newObjects.setData([]) - self.pending.setData([]); - self.isUploading.setData(false) - }, - () => self.isUploading.setData(false) // Failed - mark to try again - ); - - - }; - - - public static createChangesetFor(csId: string, changedElements: OsmObject[], newElements: OsmObject[]): string { - - let modifications = ""; - for (const element of changedElements) { - modifications += element.ChangesetXML(csId) + "\n"; - } - - - let creations = ""; - for (const newElement of newElements) { - creations += newElement.ChangesetXML(csId); - } - - - let changes = ``; - - if (creations.length > 0) { - changes += - "\n\n" + - creations + - ""; - } - if (modifications.length > 0) { - changes += - "\n\n" + - modifications + - "\n"; - } - - changes += ""; - return changes; - } - - private uploadAll() { - const self = this; - - const pending = this.pending.data; - let neededIds: string[] = []; - for (const change of pending) { - const id = change.elementId; - if (parseFloat(id.split("/")[1]) < 0) { - // New element - we don't have to download this - } else { - neededIds.push(id); + const pending = self.pendingChanges.data; + const neededIds = Changes.GetNeededIds(pending) + console.log("Needed ids", neededIds) + OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => { + console.log("Got the fresh objects!", osmObjects, "pending: ", pending) + const changes = Changes.CreateChangesetObjects(pending, osmObjects) + console.log("Changes", changes) + if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { + console.log("No changes to be made") + this.pendingChanges.setData([]) + this.isUploading.setData(false) + return; } - } - neededIds = Utils.Dedup(neededIds); - OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => { - self.uploadChangesWithLatestVersions(knownElements) - }) + + State.state.osmConnection.UploadChangeset( + State.state.layoutToUse.data, + State.state.allElements, + (csId) => { + return Changes.createChangesetFor(csId, changes); + }, + () => { + // When done + console.log("Upload successfull!") + self.pendingChanges.setData([]); + self.isUploading.setData(false) + }, + () => self.isUploading.setData(false) // Failed - mark to try again + ) + + }); + + } + public applyAction(action: OsmChangeAction) { + const changes = action.Perform(this) + console.log("Received changes:", changes) + this.pendingChanges.data.push(...changes); + this.pendingChanges.ping(); + } } \ No newline at end of file diff --git a/Logic/Osm/CreateNewNodeAction.ts b/Logic/Osm/CreateNewNodeAction.ts deleted file mode 100644 index c0d9efba1..000000000 --- a/Logic/Osm/CreateNewNodeAction.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default class CreateNewNodeAction { - -} \ No newline at end of file diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index a47d26f85..70a93e906 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -125,7 +125,7 @@ export abstract class OsmObject { } const splitted = id.split("/"); const type = splitted[0]; - const idN = splitted[1]; + const idN = Number(splitted[1]); const src = new UIEventSource([]); OsmObject.historyCache.set(id, src); Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`).then(data => { @@ -314,20 +314,6 @@ export abstract class OsmObject { return this; } - public addTag(k: string, v: string): void { - if (k in this.tags) { - const oldV = this.tags[k]; - if (oldV == v) { - return; - } - console.log("Overwriting ", oldV, " with ", v, " for key ", k) - } - this.tags[k] = v; - if (v === undefined || v === "") { - delete this.tags[k]; - } - this.changed = true; - } abstract ChangesetXML(changesetId: string): string; @@ -481,7 +467,11 @@ export class OsmWay extends OsmObject { export class OsmRelation extends OsmObject { - public members; + public members: { + type: "node" | "way" | "relation", + ref: number, + role: string + }[]; constructor(id: number) { super("relation", id); diff --git a/Logic/Osm/RelationSplitlHandler.ts b/Logic/Osm/RelationSplitlHandler.ts deleted file mode 100644 index 7657e094f..000000000 --- a/Logic/Osm/RelationSplitlHandler.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * The logic to handle relations after a way within - */ -export default class RelationSplitlHandler { - - constructor() { - - } - - -} \ No newline at end of file diff --git a/Logic/Osm/SplitAction.ts b/Logic/Osm/SplitAction.ts deleted file mode 100644 index 6153f2bad..000000000 --- a/Logic/Osm/SplitAction.ts +++ /dev/null @@ -1,222 +0,0 @@ -import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; -import {GeoOperations} from "../GeoOperations"; -import State from "../../State"; -import {UIEventSource} from "../UIEventSource"; -import {Changes} from "./Changes"; - -interface SplitInfo { - originalIndex?: number, // or negative for new elements - lngLat: [number, number], - doSplit: boolean -} - -export default class SplitAction { - private readonly roadObject: any; - - /*** - * - * @param roadObject: the geojson of the road object. Properties.id must be the corresponding OSM-id - */ - constructor(roadObject: any) { - this.roadObject = roadObject; - } - - private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { - const wayParts = [] - let currentPart = [] - for (const splitInfoElement of splitInfo) { - currentPart.push(splitInfoElement) - - if (splitInfoElement.doSplit) { - // We have to do a split! - // We add the current index to the currentParts, flush it and add it again - wayParts.push(currentPart) - currentPart = [splitInfoElement] - } - } - wayParts.push(currentPart) - return wayParts - } - - public DoSplit(splitPoints: any[]) { - // We mark the new split points with a new id - for (const splitPoint of splitPoints) { - splitPoint.properties["_is_split_point"] = true - } - - - const self = this; - const id = this.roadObject.properties.id - const osmWay = >OsmObject.DownloadObject(id) - const partOf = OsmObject.DownloadReferencingRelations(id) - osmWay.map(originalElement => { - - if(originalElement === undefined || partOf === undefined){ - return; - } - - const changes = State.state?.changes ?? new Changes(); - // First, calculate splitpoints and remove points close to one another - const splitInfo = self.CalculateSplitCoordinates(splitPoints) - // Now we have a list with e.g. - // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] - - // Lets change 'originalIndex' to the actual node id first: - for (const element of splitInfo) { - if (element.originalIndex >= 0) { - element.originalIndex = originalElement.nodes[element.originalIndex] - } else { - element.originalIndex = changes.getNewID(); - } - } - - // Next up is creating actual parts from this - const wayParts = SplitAction.SegmentSplitInfo(splitInfo); - - // Allright! At this point, we have our new ways! - // Which one is the longest of them (and can keep the id)? - - let longest = undefined; - for (const wayPart of wayParts) { - if (longest === undefined) { - longest = wayPart; - continue - } - if (wayPart.length > longest.length) { - longest = wayPart - } - } - - const newOsmObjects: OsmObject[] = [] - const modifiedObjects: OsmObject[] = [] - // Let's create the new points as needed - for (const element of splitInfo) { - if (element.originalIndex >= 0) { - continue; - } - const node = new OsmNode(element.originalIndex) - node.lon = element.lngLat[0] - node.lat = element.lngLat[1] - newOsmObjects.push(node) - } - - const newWayIds: number[] = [] - // Lets create OsmWays based on them - for (const wayPart of wayParts) { - - let isOriginal = wayPart === longest - if(isOriginal){ - // We change the actual element! - originalElement.nodes = wayPart.map(p => p.originalIndex); - originalElement.changed = true; - modifiedObjects.push(originalElement) - }else{ - let id = changes.getNewID(); - const way = new OsmWay(id) - way.tags = originalElement.tags; - way.nodes = wayPart.map(p => p.originalIndex); - way.changed = true; - newOsmObjects.push(way) - newWayIds.push(way.id) - } - - } - - // At last, we still have to check that we aren't part of a relation... - // At least, the order of the ways is identical, so we can keep the same roles - - modifiedObjects.push(...SplitAction.UpdateRelations(partOf.data, newWayIds, originalElement)) - // And we have our objects! - // Time to upload - - console.log(Changes.createChangesetFor("123", modifiedObjects, newOsmObjects)) - }, [partOf]) - } - - private static UpdateRelations(data: OsmRelation[], newWayIds: number[], originalElement: OsmWay):OsmRelation[]{ - // TODO - return [] - } - - /** - * Calculates the actual points to split - * If another point is closer then ~5m, we reuse that point - */ - private CalculateSplitCoordinates( - splitPoints: any[], - toleranceInM = 5): SplitInfo[] { - - const allPoints = [...splitPoints]; - // We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... - const originalPoints: [number, number][] = this.roadObject.geometry.coordinates - // We project them onto the line (which should yield pretty much the same point - for (let i = 0; i < originalPoints.length; i++) { - let originalPoint = originalPoints[i]; - let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) - projected.properties["_is_split_point"] = false - projected.properties["_original_index"] = i - allPoints.push(projected) - } - // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them - // We sort this list so that the new points are at the same location - allPoints.sort((a, b) => a.properties.location - b.properties.location) - - // When this is done, we check that no now point is too close to an already existing point and no very small segments get created - - for (let i = allPoints.length - 1; i > 0; i--) { - - const point = allPoints[i]; - if (point.properties._original_index !== undefined) { - // This point is already in OSM - we have to keep it! - continue; - } - - if (i != allPoints.length - 1) { - const prevPoint = allPoints[i + 1] - const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the previous point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the previous point as a split point - prevPoint.properties._is_split_point = true - continue; - } - } - - if (i > 0) { - const nextPoint = allPoints[i - 1] - const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the next point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the next point as a split point - nextPoint.properties._is_split_point = true - // noinspection UnnecessaryContinueJS - continue; - } - } - // We don't have to remove this point... - } - - const splitInfo: SplitInfo[] = [] - let nextId = -1 - - for (const p of allPoints) { - let index = p.properties._original_index - if (index === undefined) { - index = nextId; - nextId--; - } - const splitInfoElement = { - originalIndex: index, - lngLat: p.geometry.coordinates, - doSplit: p.properties._is_split_point - } - splitInfo.push(splitInfoElement) - } - - return splitInfo - } - - -} diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index d18cec86f..b7b7629c9 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -2,21 +2,27 @@ import {Utils} from "../Utils"; export class UIEventSource { + private static allSources: UIEventSource[] = UIEventSource.PrepPerf(); public data: T; + public trace: boolean; private readonly tag: string; private _callbacks = []; - - private static allSources : UIEventSource[] = UIEventSource.PrepPerf(); - - static PrepPerf() : UIEventSource[]{ - if(Utils.runningFromConsole){ + + constructor(data: T, tag: string = "") { + this.tag = tag; + this.data = data; + UIEventSource.allSources.push(this); + } + + static PrepPerf(): UIEventSource[] { + if (Utils.runningFromConsole) { return []; } // @ts-ignore window.mapcomplete_performance = () => { console.log(UIEventSource.allSources.length, "uieventsources created"); const copy = [...UIEventSource.allSources]; - copy.sort((a,b) => b._callbacks.length - a._callbacks.length); + copy.sort((a, b) => b._callbacks.length - a._callbacks.length); console.log("Topten is:") for (let i = 0; i < 10; i++) { console.log(copy[i].tag, copy[i]); @@ -25,13 +31,7 @@ export class UIEventSource { } return []; } - - constructor(data: T, tag: string = "") { - this.tag = tag; - this.data = data; - UIEventSource.allSources.push(this); - } - + public static flatten(source: UIEventSource>, possibleSources: UIEventSource[]): UIEventSource { const sink = new UIEventSource(source.data?.data); @@ -68,6 +68,9 @@ export class UIEventSource { // This ^^^ actually works! throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead." } + if (this.trace) { + console.trace("Added a callback") + } this._callbacks.push(callback); return this; } @@ -101,12 +104,12 @@ export class UIEventSource { */ public map(f: ((t: T) => J), extraSources: UIEventSource[] = [], - g: ((j:J, t:T) => T) = undefined): UIEventSource { + g: ((j: J, t: T) => T) = undefined): UIEventSource { const self = this; const newSource = new UIEventSource( f(this.data), - "map("+this.tag+")" + "map(" + this.tag + ")" ); const update = function () { @@ -159,9 +162,9 @@ export class UIEventSource { return newSource; } - addCallbackAndRunD(callback: (data :T ) => void) { + addCallbackAndRunD(callback: (data: T) => void) { this.addCallbackAndRun(data => { - if(data !== undefined && data !== null){ + if (data !== undefined && data !== null) { callback(data) } }) diff --git a/State.ts b/State.ts index 8e4322d65..ec3c441da 100644 --- a/State.ts +++ b/State.ts @@ -19,6 +19,7 @@ import TitleHandler from "./Logic/Actors/TitleHandler"; import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader"; import {Relation} from "./Logic/Osm/ExtractRelations"; import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource"; +import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor"; /** * Contains the global state: a bunch of UI-event sources @@ -244,6 +245,9 @@ export default class State { this.allElements = new ElementStorage(); this.changes = new Changes(); + + new ChangeToElementsActor(this.changes, this.allElements) + this.osmApiFeatureSource = new OsmApiFeatureSource() new PendingChangesUploader(this.changes, this.selectedElement); diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 5fe60fff7..82c21ea5d 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -39,6 +39,7 @@ export default class Minimap extends BaseUIElement { div.style.width = "100%" div.style.minWidth = "40px" div.style.minHeight = "40px" + div.style.position = "relative" const wrapper = document.createElement("div") wrapper.appendChild(div) const self = this; diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 9d1fd1475..df29d2b72 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -20,6 +20,7 @@ import LocationInput from "../Input/LocationInput"; import {InputElement} from "../Input/InputElement"; import Loc from "../../Models/Loc"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; +import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -61,11 +62,6 @@ export default class SimpleAddUI extends Toggle { const selectedPreset = new UIEventSource(undefined); isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened - function createNewPoint(tags: any[], location: { lat: number, lon: number }) { - let feature = State.state.changes.createElement(tags, location.lat, location.lon); - State.state.selectedElement.setData(feature); - } - const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) const addUi = new VariableUiElement( @@ -75,7 +71,9 @@ export default class SimpleAddUI extends Toggle { } return SimpleAddUI.CreateConfirmButton(preset, (tags, location) => { - createNewPoint(tags, location) + let changes = + State.state.changes.applyAction(new CreateNewNodeAction(tags, location.lat, location.lon)) + State.state.selectedElement.setData(changes.newFeatures[0]); selectedPreset.setData(undefined) }, () => { selectedPreset.setData(undefined) diff --git a/UI/Image/DeleteImage.ts b/UI/Image/DeleteImage.ts index 6f8fbb856..2824c2184 100644 --- a/UI/Image/DeleteImage.ts +++ b/UI/Image/DeleteImage.ts @@ -5,6 +5,7 @@ import Combine from "../Base/Combine"; import State from "../../State"; import Svg from "../../Svg"; import {Tag} from "../../Logic/Tags/Tag"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export default class DeleteImage extends Toggle { @@ -15,14 +16,17 @@ export default class DeleteImage extends Toggle { .SetClass("rounded-full p-1") .SetStyle("color:white;background:#ff8c8c") .onClick(() => { - State.state?.changes?.addTag(tags.data.id, new Tag(key, oldValue), tags); + State.state?.changes?. + applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data)) }); const deleteButton = Translations.t.image.doDelete.Clone() .SetClass("block w-full pl-4 pr-4") .SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;") .onClick(() => { - State.state?.changes?.addTag(tags.data.id, new Tag(key, ""), tags); + State.state?.changes?.applyAction( + new ChangeTagAction( tags.data.id, new Tag(key, ""), tags.data) + ) }); const cancelButton = Translations.t.general.cancel.Clone().SetClass("bg-white pl-4 pr-4").SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;"); diff --git a/UI/Image/ImageUploadFlow.ts b/UI/Image/ImageUploadFlow.ts index 58d9a3760..d97829609 100644 --- a/UI/Image/ImageUploadFlow.ts +++ b/UI/Image/ImageUploadFlow.ts @@ -11,6 +11,7 @@ import FileSelectorButton from "../Input/FileSelectorButton"; import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader"; import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI"; import LayerConfig from "../../Customizations/JSON/LayerConfig"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export class ImageUploadFlow extends Toggle { @@ -28,7 +29,10 @@ export class ImageUploadFlow extends Toggle { key = imagePrefix + ":" + freeIndex; } console.log("Adding image:" + key, url); - State.state.changes.addTag(tags.id, new Tag(key, url), tagsSource); + State.state.changes + .applyAction(new ChangeTagAction( + tags.id, new Tag(key, url), tagsSource.data + )) }) diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts index 1b2ab4b76..146c404d4 100644 --- a/UI/Popup/DeleteWizard.ts +++ b/UI/Popup/DeleteWizard.ts @@ -3,7 +3,7 @@ import State from "../../State"; import Toggle from "../Input/Toggle"; import Translations from "../i18n/Translations"; import Svg from "../../Svg"; -import DeleteAction from "../../Logic/Osm/DeleteAction"; +import DeleteAction from "../../Logic/Osm/Actions/DeleteAction"; import {Tag} from "../../Logic/Tags/Tag"; import {UIEventSource} from "../../Logic/UIEventSource"; import {TagsFilter} from "../../Logic/Tags/TagsFilter"; @@ -19,6 +19,7 @@ import {Changes} from "../../Logic/Osm/Changes"; import {And} from "../../Logic/Tags/And"; import Constants from "../../Models/Constants"; import DeleteConfig from "../../Customizations/JSON/DeleteConfig"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export default class DeleteWizard extends Toggle { /** @@ -58,7 +59,9 @@ export default class DeleteWizard extends Toggle { }) } (State.state?.changes ?? new Changes()) - .addTag(id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource); + .applyAction(new ChangeTagAction( + id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data + )) } function doDelete(selected: TagsFilter) { diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index f35f73ceb..92a7e27e8 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -13,6 +13,7 @@ import SharedTagRenderings from "../../Customizations/SharedTagRenderings"; import BaseUIElement from "../BaseUIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; import DeleteWizard from "./DeleteWizard"; +import SplitRoadWizard from "./SplitRoadWizard"; export default class FeatureInfoBox extends ScrollableFullScreen { @@ -66,10 +67,6 @@ export default class FeatureInfoBox extends ScrollableFullScreen { renderings.push(questionBox); } - const hasMinimap = layerConfig.tagRenderings.some(tr => tr.hasMinimap()) - if (!hasMinimap) { - renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) - } if (layerConfig.deletion) { renderings.push( @@ -81,6 +78,19 @@ export default class FeatureInfoBox extends ScrollableFullScreen { )) } + if (layerConfig.allowSplit) { + renderings.push( + new VariableUiElement(tags.map(tags => tags.id).map(id => + new SplitRoadWizard(id)) + )) + } + + + const hasMinimap = layerConfig.tagRenderings.some(tr => tr.hasMinimap()) + if (!hasMinimap) { + renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) + } + renderings.push( new VariableUiElement( State.state.osmConnection.userDetails diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index c9cd65e56..60b0527f9 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -11,7 +11,9 @@ import Combine from "../Base/Combine"; import {Button} from "../Base/Button"; import Translations from "../i18n/Translations"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; -import SplitAction from "../../Logic/Osm/SplitAction"; +import SplitAction from "../../Logic/Osm/Actions/SplitAction"; +import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; +import Title from "../Base/Title"; export default class SplitRoadWizard extends Toggle { private static splitLayout = new UIEventSource(SplitRoadWizard.GetSplitLayout()) @@ -26,24 +28,25 @@ export default class SplitRoadWizard extends Toggle { const t = Translations.t.split; // Contains the points on the road that are selected to split on - contains geojson points with extra properties such as 'location' with the distance along the linestring - const splitPoints = new UIEventSource<{feature: any, freshness: Date}[]>([]); + const splitPoints = new UIEventSource<{ feature: any, freshness: Date }[]>([]); + + const hasBeenSplit = new UIEventSource(false) // Toggle variable between show split button and map const splitClicked = new UIEventSource(false); // Minimap on which you can select the points to be splitted - const miniMap = new Minimap({background: State.state.backgroundLayer}); - miniMap.SetStyle("width: 100%; height: 50rem;"); + const miniMap = new Minimap({background: State.state.backgroundLayer, allowMoving: false}); + miniMap.SetStyle("width: 100%; height: 24rem;"); // Define how a cut is displayed on the map // Load the road with given id on the minimap const roadElement = State.state.allElements.ContainingFeatures.get(id) - const splitAction = new SplitAction(roadElement) const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]); // Datalayer displaying the road and the cut points (if any) - new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true); - new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false) + new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true, "splitRoadWay"); + new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false, "splitRoad: splitpoints") /** * Handles a click on the overleaf map. @@ -60,7 +63,7 @@ export default class SplitRoadWizard extends Toggle { // let the state remember the point, to be able to retrieve it later by id State.state.allElements.addOrGetElement(pointOnRoad); - + // Add it to the list of all points and notify observers splitPoints.data.push({feature: pointOnRoad, freshness: new Date()}); // show the point on the data layer splitPoints.ping(); // not updated using .setData, so manually ping observers @@ -73,7 +76,7 @@ export default class SplitRoadWizard extends Toggle { })) // Toggle between splitmap - const splitButton = new SubtleButton(Svg.scissors_ui(), "Split road"); + const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone()); splitButton.onClick( () => { splitClicked.setData(true) @@ -83,45 +86,63 @@ export default class SplitRoadWizard extends Toggle { // Only show the splitButton if logged in, else show login prompt const splitToggle = new Toggle( splitButton, - t.loginToSplit.Clone().onClick(State.state.osmConnection.AttemptLogin), + t.loginToSplit.Clone().onClick(() => State.state.osmConnection.AttemptLogin()), State.state.osmConnection.isLoggedIn) // Save button - const saveButton = new Button("Split here", () => splitAction.DoSplit(splitPoints.data)); + const saveButton = new Button(t.split.Clone(), () => { + hasBeenSplit.setData(true) + OsmObject.DownloadObject(id).addCallbackAndRunD(way => { + OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD( + partOf => { + const splitAction = new SplitAction( + way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature) + ) + State.state.changes.applyAction(splitAction) + } + ) + + } + ) + + + }); saveButton.SetClass("block btn btn-primary"); const disabledSaveButton = new Button("Split here", undefined); disabledSaveButton.SetClass("block btn btn-disabled"); // Only show the save button if there are split points defined const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPoints.map((data) => data.length === 0)) - const cancelButton = new Button("Cancel", () => { + const cancelButton = new Button(Translations.t.general.cancel.Clone(), () => { splitClicked.setData(false); splitPoints.setData([]); + splitClicked.setData(false) }); cancelButton.SetClass("block btn btn-secondary"); - const splitTitle = t.splitTitle; - - const mapView = new Combine([splitTitle, miniMap, cancelButton, saveToggle]); - super(mapView, splitToggle, splitClicked); + const splitTitle = new Title(t.splitTitle); + const mapView = new Combine([splitTitle, miniMap, new Combine([cancelButton, saveToggle])]); + mapView.SetClass("question") + const confirm = new Toggle(mapView, splitToggle, splitClicked); + super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit) } private static GetSplitLayout(): LayoutConfig { return new LayoutConfig({ maintainer: "mapcomplete", - language: [], + language: ["en"], startLon: 0, startLat: 0, - description: undefined, + description: "Split points visualisations - built in at SplitRoadWizard.ts", icon: "", startZoom: 0, title: "Split locations", version: "", id: "splitpositions", layers: [{id: "splitpositions", source: {osmTags: "_cutposition=yes"}, icon: "./assets/svg/plus.svg"}] - }, true, "split road wizard layout") + }, true, "(BUILTIN) SplitRoadWizard.ts") } } \ No newline at end of file diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 20c0b00d2..8d63325d4 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -25,6 +25,7 @@ import BaseUIElement from "../BaseUIElement"; import {DropDown} from "../Input/DropDown"; import {Unit} from "../../Customizations/JSON/Denomination"; import InputElementWrapper from "../Input/InputElementWrapper"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; /** * Shows the question element. @@ -56,7 +57,9 @@ export default class TagRenderingQuestion extends Combine { const selection = inputElement.GetValue().data; if (selection) { (State.state?.changes ?? new Changes()) - .addTag(tags.data.id, selection, tags); + .applyAction(new ChangeTagAction( + tags.data.id, selection, tags.data + )) } if (options.afterSave) { diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index df45af45e..7eb718218 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -22,7 +22,8 @@ export default class ShowDataLayer { leafletMap: UIEventSource, layoutToUse: UIEventSource, enablePopups = true, - zoomToFeatures = false) { + zoomToFeatures = false, + name?:string) { this._leafletMap = leafletMap; this._enablePopups = enablePopups; this._features = features; @@ -60,6 +61,7 @@ export default class ShowDataLayer { } const allFeats = features.data.map(ff => ff.feature); + console.log("Rendering ",allFeats, "features at layer ", name) geoLayer = self.CreateGeojsonLayer(); for (const feat of allFeats) { if (feat === undefined) { @@ -85,9 +87,6 @@ export default class ShowDataLayer { console.error(e) } } - - - State.state.selectedElement.ping(); } features.addCallback(() => update()); diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 309060b36..1e4864912 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -126,6 +126,7 @@ export default class SpecialVisualizations { // This is a list of values idList = JSON.parse(value) } + for (const id of idList) { features.push({ freshness: new Date(), diff --git a/Utils.ts b/Utils.ts index cb8835656..da0672db1 100644 --- a/Utils.ts +++ b/Utils.ts @@ -136,6 +136,19 @@ export class Utils { return newArr; } + public static Identical(t1: T[], t2: T[], eq?: (t: T, t0: T) => boolean): boolean{ + if(t1.length !== t2.length){ + return false + } + eq = (a, b) => a === b + for (let i = 0; i < t1.length ; i++) { + if(!eq(t1[i] ,t2[i])){ + return false + } + } + return true; + } + public static MergeTags(a: any, b: any) { const t = {}; for (const k in a) { diff --git a/assets/themes/cyclestreets/cyclestreets.json b/assets/themes/cyclestreets/cyclestreets.json index 8a5956cd1..da3f5bcc2 100644 --- a/assets/themes/cyclestreets/cyclestreets.json +++ b/assets/themes/cyclestreets/cyclestreets.json @@ -273,7 +273,8 @@ }, "tagRenderings": [ "images" - ] + ], + "allowSplit": true } ] } \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index fed6347da..e632a1378 100644 --- a/langs/en.json +++ b/langs/en.json @@ -30,8 +30,10 @@ "split": { "split": "Split", "cancel": "Cancel", + "inviteToSplit": "Split this road", "loginToSplit": "You must be logged in to split a road", - "splitTitle": "Choose on the map where to split this road" + "splitTitle": "Choose on the map where to split this road", + "hasBeenSplit": "This way has been split" }, "delete": { "delete": "Delete", diff --git a/test.ts b/test.ts index 4dd3a5c71..b47699b0b 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,4 @@ -import SplitAction from "./Logic/Osm/SplitAction"; +import SplitAction from "./Logic/Osm/Actions/SplitAction"; import {GeoOperations} from "./Logic/GeoOperations"; const way = {