diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 8352331f8..fa91e4d57 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, @@ -70,6 +71,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) { @@ -373,8 +375,7 @@ export default class LayerConfig { public GenerateLeafletStyle( tags: UIEventSource, - clickable: boolean, - widthHeight = "100%" + clickable: boolean ): { icon: { html: BaseUIElement; diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index e4d3a597d..aa4bce1b7 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -298,4 +298,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 511f1ff18..bda08db56 100644 --- a/Customizations/JSON/LayoutConfig.ts +++ b/Customizations/JSON/LayoutConfig.ts @@ -66,7 +66,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 a58f581cb..f9eb69f4c 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -513,10 +513,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..9ae5f87ef --- /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; // Ignored as the geometryFixer will introduce this + } + 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/Actors/SelectedFeatureHandler.ts b/Logic/Actors/SelectedFeatureHandler.ts index 3aa5d8e5b..3cfaa079c 100644 --- a/Logic/Actors/SelectedFeatureHandler.ts +++ b/Logic/Actors/SelectedFeatureHandler.ts @@ -13,7 +13,7 @@ export default class SelectedFeatureHandler { private readonly _hash: UIEventSource; private readonly _selectedFeature: UIEventSource; - private static readonly _no_trigger_on = ["welcome","copyright","layers"] + private static readonly _no_trigger_on = ["welcome","copyright","layers","new"] private readonly _osmApiSource: OsmApiFeatureSource; constructor(hash: UIEventSource, @@ -60,7 +60,9 @@ export default class SelectedFeatureHandler { if(hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0){ return; // No valid feature selected } - // We should have a valid osm-ID and zoom to it + // We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure + try{ + OsmObject.DownloadObject(hash).addCallbackAndRunD(element => { const centerpoint = element.centerpoint(); console.log("Zooming to location for select point: ", centerpoint) @@ -68,6 +70,9 @@ export default class SelectedFeatureHandler { location.data.lon = centerpoint[1] location.ping(); }) + }catch(e){ + console.error("Could not download OSM-object with id", hash, " - probably a weird hash") + } } private downloadFeature(hash: string){ diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts new file mode 100644 index 000000000..9b4f2271d --- /dev/null +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -0,0 +1,162 @@ +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, mode?: { + generateNewGeometries: boolean + }) { + + this.name = "ChangesApplied(" + source.name + ")" + this.features = source.features + const seenChanges = new Set(); + const self = this; + let runningUpdate = false; + source.features.addCallbackAndRunD(features => { + if (runningUpdate) { + return; // No need to ping again + } + ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data, mode) + seenChanges.clear() + }) + + changes.pendingChanges.addCallbackAndRunD(changes => { + runningUpdate = true; + changes = changes.filter(ch => !seenChanges.has(ch)) + changes.forEach(c => seenChanges.add(c)) + ChangeApplicator.ApplyChanges(self.features.data, changes, mode) + source.features.ping() + runningUpdate = false; + }) + + + } + + + /** + * Returns true if the geometry is changed and the source should be pinged + */ + private static ApplyChanges(features: { feature: any; freshness: Date }[], cs: ChangeDescription[], mode: { generateNewGeometries: boolean }): boolean { + if (cs.length === 0 || features === undefined) { + return; + } + + console.log("Applying changes ", this.name, cs) + let geometryChanged = false; + 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) { + feature.id = feature.properties.id + features.push({ + feature: feature, + freshness: now + }) + console.log("Added a new feature: ", feature) + geometryChanged = true; + } + + // First, create the new features - they have a negative ID + // We don't set the properties yet though + if (mode?.generateNewGeometries) { + changesPerId.forEach(cs => { + cs + .forEach(change => { + if (change.id >= 0) { + return; // Nothing to do here, already created + } + + if (change.changes === undefined) { + // An update to the object - not the actual created + return; + } + + 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 f = feature.feature; + const id = f.properties.id; + 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 + f.properties[kv.k] = kv.v; + } + + // Apply other changes to the object + if (change.changes !== undefined) { + geometryChanged = true; + 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; + } + } + } + } + return geometryChanged + } +} \ No newline at end of file diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index c63fe89a3..be4f2b8c8 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -6,7 +6,6 @@ import FeatureDuplicatorPerLayer from "../FeatureSource/FeatureDuplicatorPerLaye import FeatureSource from "../FeatureSource/FeatureSource"; import {UIEventSource} from "../UIEventSource"; import LocalStorageSaver from "./LocalStorageSaver"; -import LayerConfig from "../../Customizations/JSON/LayerConfig"; import LocalStorageSource from "./LocalStorageSource"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; import Loc from "../../Models/Loc"; @@ -14,6 +13,8 @@ import GeoJsonSource from "./GeoJsonSource"; import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource"; import RegisteringFeatureSource from "./RegisteringFeatureSource"; import FilteredLayer from "../../Models/FilteredLayer"; +import {Changes} from "../Osm/Changes"; +import ChangeApplicator from "./ChangeApplicator"; export default class FeaturePipeline implements FeatureSource { @@ -22,10 +23,10 @@ export default class FeaturePipeline implements FeatureSource { public readonly name = "FeaturePipeline" constructor(flayers: UIEventSource, + changes: Changes, updater: FeatureSource, fromOsmApi: FeatureSource, layout: UIEventSource, - newPoints: FeatureSource, locationControl: UIEventSource, selectedElement: UIEventSource) { @@ -41,7 +42,9 @@ export default class FeaturePipeline implements FeatureSource { new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, new RegisteringFeatureSource( - updater) + new ChangeApplicator( + updater, changes + )) )), layout)); const geojsonSources: FeatureSource [] = GeoJsonSource @@ -49,8 +52,7 @@ export default class FeaturePipeline implements FeatureSource { .map(geojsonSource => { let source = new RegisteringFeatureSource( new FeatureDuplicatorPerLayer(flayers, - geojsonSource - )); + new ChangeApplicator(geojsonSource, changes))); if (!geojsonSource.isOsmCache) { source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features); } @@ -58,25 +60,24 @@ 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, + { + // We lump in the new points here + generateNewGeometries: true + } + ))))); const merged = new FeatureSourceMerger([ amendedOverpassSource, amendedOsmApiSource, amendedLocalStorageSource, - newPoints, ...geojsonSources ]); diff --git a/Logic/FeatureSource/FeatureSourceMerger.ts b/Logic/FeatureSource/FeatureSourceMerger.ts index e9901d1f5..26d9ce498 100644 --- a/Logic/FeatureSource/FeatureSourceMerger.ts +++ b/Logic/FeatureSource/FeatureSourceMerger.ts @@ -1,6 +1,10 @@ import FeatureSource from "./FeatureSource"; import {UIEventSource} from "../UIEventSource"; +/** + * Merges features from different featureSources + * Uses the freshest feature available in the case multiple sources offer data with the same identifier + */ export default class FeatureSourceMerger implements FeatureSource { public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); diff --git a/Logic/FeatureSource/FilteringFeatureSource.ts b/Logic/FeatureSource/FilteringFeatureSource.ts index 899cc1d5f..2d06c9dca 100644 --- a/Logic/FeatureSource/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/FilteringFeatureSource.ts @@ -94,16 +94,9 @@ export default class FilteringFeatureSource implements FeatureSource { return false; } - - return true; }); - console.log( - "Filtering layer source: input: ", - upstream.features.data?.length, - "output:", - newFeatures.length - ); + self.features.setData(newFeatures); if (missingLayers.size > 0) { console.error( diff --git a/Logic/FeatureSource/OsmApiFeatureSource.ts b/Logic/FeatureSource/OsmApiFeatureSource.ts index ec1c03a74..de90274d2 100644 --- a/Logic/FeatureSource/OsmApiFeatureSource.ts +++ b/Logic/FeatureSource/OsmApiFeatureSource.ts @@ -15,15 +15,19 @@ export default class OsmApiFeatureSource implements FeatureSource { public load(id: string) { - if(id.indexOf("-") >= 0){ + if (id.indexOf("-") >= 0) { // Newly added point - not yet in OSM return; } console.debug("Downloading", id, "from the OSM-API") OsmObject.DownloadObject(id).addCallbackAndRunD(element => { - const geojson = element.asGeoJson(); - geojson.id = geojson.properties.id; - this.features.setData([{feature: geojson, freshness: element.timestamp}]) + try { + const geojson = element.asGeoJson(); + geojson.id = geojson.properties.id; + this.features.setData([{feature: geojson, freshness: element.timestamp}]) + } catch (e) { + console.error(e) + } }) } @@ -58,7 +62,7 @@ export default class OsmApiFeatureSource implements FeatureSource { const bounds = Utils.tile_bounds(z, x, y); console.log("Loading OSM data tile", z, x, y, " with bounds", bounds) OsmObject.LoadArea(bounds, objects => { - const keptGeoJson: {feature:any, freshness: Date}[] = [] + const keptGeoJson: { feature: any, freshness: Date }[] = [] // Which layer does the object match? for (const object of objects) { @@ -69,7 +73,7 @@ export default class OsmApiFeatureSource implements FeatureSource { if (doesMatch) { const geoJson = object.asGeoJson(); geoJson._matching_layer_id = layer.id - keptGeoJson.push({feature: geoJson, freshness: object.timestamp}) + keptGeoJson.push({feature: geoJson, freshness: object.timestamp}) break; } 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..36bafcbee --- /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()}; + } + + CreateChangeDescriptions(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..692271a12 --- /dev/null +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -0,0 +1,48 @@ +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 extends OsmChangeAction { + + private readonly _basicTags: Tag[]; + private readonly _lat: number; + private readonly _lon: number; + + public newElementId : string = undefined + + constructor(basicTags: Tag[], lat: number, lon: number) { + super() + this._basicTags = basicTags; + this._lat = lat; + this._lon = lon; + } + + CreateChangeDescriptions(changes: Changes): ChangeDescription[] { + const id = changes.getNewID() + const properties = { + id: "node/" + id + } + this.newElementId = "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..0308ca8f6 --- /dev/null +++ b/Logic/Osm/Actions/OsmChangeAction.ts @@ -0,0 +1,23 @@ +/** + * 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 { + + private isUsed = false + + public Perform(changes: Changes) { + if (this.isUsed) { + throw "This ChangeAction is already used: " + this.constructor.name + } + this.isUsed = true; + return this.CreateChangeDescriptions(changes) + } + + protected abstract CreateChangeDescriptions(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..215cee840 --- /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() + } + + CreateChangeDescriptions(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..8ee1731e4 --- /dev/null +++ b/Logic/Osm/Actions/SplitAction.ts @@ -0,0 +1,238 @@ +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) + } + + CreateChangeDescriptions(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); + // 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 + } + if (k.startsWith("_") || k === "id") { + 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).CreateChangeDescriptions(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 ff50cc022..806af3ba7 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -1,81 +1,233 @@ -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 {Utils} from "../../Utils"; import {LocalStorageSource} from "../Web/LocalStorageSource"; /** * 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 = LocalStorageSource.GetParsed("pending-changes", []) private readonly isUploading = new UIEventSource(false); + + private readonly previouslyCreated : OsmObject[] = [] - /** - * 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() { + } + private static createChangesetFor(csId: string, + allChanges: { + modifiedObjects: OsmObject[], + newObjects: OsmObject[], + deletedObjects: 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.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)) + } + + 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) { - 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)) { + 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.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,194 +235,65 @@ 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) { return; } - if (flushreason !== undefined) { - console.log(flushreason) - } - this.uploadAll(); - } - - /** - * 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 newId = Changes._nextId; - Changes._nextId--; - - const id = "node/" + newId; - - - const properties = {id: id}; - - const geojson = { - "type": "Feature", - "properties": properties, - "id": 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: 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: newId, 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; - } - const self = this; - + if (this.isUploading.data) { + console.log("Is already uploading... Abort") return; } + + this.isUploading.setData(true) - - console.log("Beginning upload..."); + + console.log("Beginning upload... "+flushreason ?? ""); // At last, we build the changeset and upload - State.state.osmConnection.UploadChangeset( - State.state.layoutToUse.data, - State.state.allElements, - function (csId) { - - let modifications = ""; - for (const element of changedElements) { - if (!element.changed) { - continue; - } - modifications += element.ChangesetXML(csId) + "\n"; - } - - - let creations = ""; - for (const newElement of newElements) { - creations += newElement.ChangesetXML(csId); - } - - - let changes = ``; - - if (creations.length > 0) { - changes += - "" + - creations + - ""; - } - - if (modifications.length > 0) { - changes += - "\n" + - modifications + - "\n"; - } - - changes += ""; - - return changes; - }, - () => { - console.log("Upload successfull!") - self.newObjects.setData([]) - self.pending.setData([]); - self.isUploading.setData(false) - }, - () => self.isUploading.setData(false) - ); - }; - - - private uploadAll() { const self = this; + 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: { + newObjects: OsmObject[], + modifiedObjects: OsmObject[] + deletedObjects: OsmObject[] - 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); + } = self.CreateChangesetObjects(pending, osmObjects) + if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { + console.log("No changes to be made") + self.pendingChanges.setData([]) + self.isUploading.setData(false) + return true; // Unregister the callback } - } - neededIds = Utils.Dedup(neededIds); - OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => { - self.uploadChangesWithLatestVersions(knownElements) - }) + + State.state.osmConnection.UploadChangeset( + State.state.layoutToUse.data, + State.state.allElements, + (csId) => Changes.createChangesetFor(csId, changes), + () => { + console.log("Upload successfull!") + self.pendingChanges.setData([]); + self.isUploading.setData(false) + }, + () => { + console.log("Upload failed - trying again later") + return self.isUploading.setData(false); + } // Failed - mark to try again + ) + return true; + + }); + + } + 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/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 8fba43803..019d0efb2 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -53,6 +53,8 @@ export class ChangesetHandler { element.ping(); } + + } } diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 09ee7137c..fcc3da888 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -23,7 +23,7 @@ export abstract class OsmObject { this.id = id; this.type = type; this.tags = { - id: id + id: `${this.type}/${id}` } } @@ -51,7 +51,10 @@ export abstract class OsmObject { } const splitted = id.split("/"); const type = splitted[0]; - const idN = splitted[1]; + const idN = Number(splitted[1]); + if(idN <0){ + return; + } OsmObject.objectCache.set(id, src); const newContinuation = (element: OsmObject) => { @@ -68,6 +71,8 @@ export abstract class OsmObject { case("relation"): new OsmRelation(idN).Download(newContinuation); break; + default: + throw "Invalid object type:" + type + id; } return src; @@ -103,7 +108,7 @@ export abstract class OsmObject { if (OsmObject.referencingRelationsCache.has(id)) { return OsmObject.referencingRelationsCache.get(id); } - const relsSrc = new UIEventSource([]) + const relsSrc = new UIEventSource(undefined) OsmObject.referencingRelationsCache.set(id, relsSrc); Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`) .then(data => { @@ -123,7 +128,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 => { @@ -312,20 +317,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; @@ -360,7 +351,7 @@ export class OsmNode extends OsmObject { lat: number; lon: number; - constructor(id) { + constructor(id: number) { super("node", id); } @@ -368,9 +359,9 @@ export class OsmNode extends OsmObject { ChangesetXML(changesetId: string): string { let tags = this.TagsXML(); - return ' \n' + + return ' \n' + tags + - ' \n'; + ' \n'; } SaveExtraData(element) { @@ -413,9 +404,8 @@ export class OsmWay extends OsmObject { lat: number; lon: number; - constructor(id) { + constructor(id: number) { super("way", id); - } centerpoint(): [number, number] { @@ -432,7 +422,7 @@ export class OsmWay extends OsmObject { return ' \n' + nds + tags + - ' \n'; + ' \n'; } SaveExtraData(element, allNodes: OsmNode[]) { @@ -458,7 +448,7 @@ export class OsmWay extends OsmObject { this.nodes = element.nodes; } - asGeoJson() { + public asGeoJson() { return { "type": "Feature", "properties": this.tags, @@ -480,11 +470,14 @@ export class OsmWay extends OsmObject { export class OsmRelation extends OsmObject { - members; + public members: { + type: "node" | "way" | "relation", + ref: number, + role: string + }[]; - constructor(id) { + constructor(id: number) { super("relation", id); - } centerpoint(): [number, number] { diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index 5dbb14d01..6246b6790 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -24,12 +24,19 @@ export class Tag extends TagsFilter { matchesProperties(properties: any): boolean { for (const propertiesKey in properties) { + if(!properties.hasOwnProperty(propertiesKey)){ + continue + } if (this.key === propertiesKey) { const value = properties[propertiesKey]; + if(value === undefined){ + continue + } return value === this.value; } } // The tag was not found + if (this.value === "") { // and it shouldn't be found! return true; diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 167bf4478..2dfc2d655 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){ + private _callbacks: ((t: T) => (boolean | void | any)) [] = []; + + 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]); @@ -26,12 +32,6 @@ 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); @@ -63,11 +63,20 @@ export class UIEventSource { } - public addCallback(callback: ((latestData: T) => void)): UIEventSource { + /** + * Adds a callback + * + * If the result of the callback is 'true', the callback is considered finished and will be removed again + * @param callback + */ + public addCallback(callback: ((latestData: T) => (boolean | void | any))): UIEventSource { if (callback === console.log) { // 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; } @@ -87,8 +96,21 @@ export class UIEventSource { } public ping(): void { + let toDelete = undefined for (const callback of this._callbacks) { - callback(this.data); + if (callback(this.data) === true) { + // This callback wants to be deleted + if (toDelete === undefined) { + toDelete = [callback] + } else { + toDelete.push(callback) + } + } + } + if (toDelete !== undefined) { + for (const toDeleteElement of toDelete) { + this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1) + } } } @@ -101,12 +123,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,10 +181,10 @@ export class UIEventSource { return newSource; } - addCallbackAndRunD(callback: (data :T ) => void) { + addCallbackAndRunD(callback: (data: T) => void) { this.addCallbackAndRun(data => { - if(data !== undefined && data !== null){ - callback(data) + if (data !== undefined && data !== null) { + return callback(data) } }) } diff --git a/Models/Constants.ts b/Models/Constants.ts index 5b8c364bf..bc46f859a 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.4a"; + public static vNumber = "0.9.0-rc0"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { diff --git a/State.ts b/State.ts index 3ccf1eb32..fe2e2fdf7 100644 --- a/State.ts +++ b/State.ts @@ -14,14 +14,13 @@ import Loc from "./Models/Loc"; import Constants from "./Models/Constants"; import OverpassFeatureSource from "./Logic/Actors/OverpassFeatureSource"; -import LayerConfig from "./Customizations/JSON/LayerConfig"; 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 FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline"; -import { TagsFilter } from "./Logic/Tags/TagsFilter"; import FilteredLayer from "./Models/FilteredLayer"; +import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor"; /** * Contains the global state: a bunch of UI-event sources @@ -31,7 +30,7 @@ export default class State { // The singleton of the global state public static state: State; - public readonly layoutToUse = new UIEventSource(undefined); + public readonly layoutToUse = new UIEventSource(undefined, "layoutToUse"); /** The mapping from id -> UIEventSource @@ -44,7 +43,7 @@ export default class State { /** The leaflet instance of the big basemap */ - public leafletMap = new UIEventSource(undefined); + public leafletMap = new UIEventSource(undefined, "leafletmap"); /** * Background layer id */ @@ -62,7 +61,7 @@ export default class State { public osmApiFeatureSource: OsmApiFeatureSource; - public filteredLayers: UIEventSource = new UIEventSource([]); + public filteredLayers: UIEventSource = new UIEventSource([],"filteredLayers"); /** The latest element that was selected @@ -102,7 +101,7 @@ export default class State { /** * The map location: currently centered lat, lon and zoom */ - public readonly locationControl = new UIEventSource(undefined); + public readonly locationControl = new UIEventSource(undefined, "locationControl"); public backgroundLayer; public readonly backgroundLayerId: UIEventSource; @@ -187,11 +186,13 @@ export default class State { ).syncWith(LocalStorageSource.Get("lon")) ); - this.locationControl = new UIEventSource({ + this.locationControl.setData({ zoom: Utils.asFloat(zoom.data), lat: Utils.asFloat(lat.data), lon: Utils.asFloat(lon.data), - }).addCallback((latlonz) => { + }) + this.locationControl.addCallback((latlonz) => { + // Sync th location controls zoom.setData(latlonz.zoom); lat.setData(latlonz.lat); lon.setData(latlonz.lon); @@ -371,7 +372,10 @@ export default class State { this.allElements = new ElementStorage(); this.changes = new Changes(); - this.osmApiFeatureSource = new OsmApiFeatureSource(); + + new ChangeToElementsActor(this.changes, this.allElements) + + this.osmApiFeatureSource = new OsmApiFeatureSource() new PendingChangesUploader(this.changes, this.selectedElement); diff --git a/Svg.ts b/Svg.ts deleted file mode 100644 index 881ade40f..000000000 --- a/Svg.ts +++ /dev/null @@ -1,417 +0,0 @@ -import Img from "./UI/Base/Img"; -import {FixedUiElement} from "./UI/Base/FixedUiElement"; - -export default class Svg { - - - public static SocialImageForeground = " image/svg+xml 010110010011010110010011  010110010011010110010011  " - public static SocialImageForeground_img = Img.AsImageElement(Svg.SocialImageForeground) - public static SocialImageForeground_svg() { return new Img(Svg.SocialImageForeground, true);} - public static SocialImageForeground_ui() { return new FixedUiElement(Svg.SocialImageForeground_img);} - - public static add = " image/svg+xml " - public static add_img = Img.AsImageElement(Svg.add) - public static add_svg() { return new Img(Svg.add, true);} - public static add_ui() { return new FixedUiElement(Svg.add_img);} - - public static addSmall = " image/svg+xml " - public static addSmall_img = Img.AsImageElement(Svg.addSmall) - public static addSmall_svg() { return new Img(Svg.addSmall, true);} - public static addSmall_ui() { return new FixedUiElement(Svg.addSmall_img);} - - public static ampersand = " image/svg+xml " - public static ampersand_img = Img.AsImageElement(Svg.ampersand) - public static ampersand_svg() { return new Img(Svg.ampersand, true);} - public static ampersand_ui() { return new FixedUiElement(Svg.ampersand_img);} - - public static arrow_download = " " - public static arrow_download_img = Img.AsImageElement(Svg.arrow_download) - public static arrow_download_svg() { return new Img(Svg.arrow_download, true);} - public static arrow_download_ui() { return new FixedUiElement(Svg.arrow_download_img);} - - public static arrow_left_smooth = " image/svg+xml " - public static arrow_left_smooth_img = Img.AsImageElement(Svg.arrow_left_smooth) - public static arrow_left_smooth_svg() { return new Img(Svg.arrow_left_smooth, true);} - public static arrow_left_smooth_ui() { return new FixedUiElement(Svg.arrow_left_smooth_img);} - - public static arrow_left_thin = " " - public static arrow_left_thin_img = Img.AsImageElement(Svg.arrow_left_thin) - public static arrow_left_thin_svg() { return new Img(Svg.arrow_left_thin, true);} - public static arrow_left_thin_ui() { return new FixedUiElement(Svg.arrow_left_thin_img);} - - public static arrow_right_smooth = " image/svg+xml " - public static arrow_right_smooth_img = Img.AsImageElement(Svg.arrow_right_smooth) - public static arrow_right_smooth_svg() { return new Img(Svg.arrow_right_smooth, true);} - public static arrow_right_smooth_ui() { return new FixedUiElement(Svg.arrow_right_smooth_img);} - - public static back = " image/svg+xml " - public static back_img = Img.AsImageElement(Svg.back) - public static back_svg() { return new Img(Svg.back, true);} - public static back_ui() { return new FixedUiElement(Svg.back_img);} - - public static barrier = " " - public static barrier_img = Img.AsImageElement(Svg.barrier) - public static barrier_svg() { return new Img(Svg.barrier, true);} - public static barrier_ui() { return new FixedUiElement(Svg.barrier_img);} - - public static bug = " " - public static bug_img = Img.AsImageElement(Svg.bug) - public static bug_svg() { return new Img(Svg.bug, true);} - public static bug_ui() { return new FixedUiElement(Svg.bug_img);} - - public static camera_plus = " image/svg+xml " - public static camera_plus_img = Img.AsImageElement(Svg.camera_plus) - public static camera_plus_svg() { return new Img(Svg.camera_plus, true);} - public static camera_plus_ui() { return new FixedUiElement(Svg.camera_plus_img);} - - public static checkbox_empty = " " - public static checkbox_empty_img = Img.AsImageElement(Svg.checkbox_empty) - public static checkbox_empty_svg() { return new Img(Svg.checkbox_empty, true);} - public static checkbox_empty_ui() { return new FixedUiElement(Svg.checkbox_empty_img);} - - public static checkbox_filled = " " - public static checkbox_filled_img = Img.AsImageElement(Svg.checkbox_filled) - public static checkbox_filled_svg() { return new Img(Svg.checkbox_filled, true);} - public static checkbox_filled_ui() { return new FixedUiElement(Svg.checkbox_filled_img);} - - public static checkmark = "" - public static checkmark_img = Img.AsImageElement(Svg.checkmark) - public static checkmark_svg() { return new Img(Svg.checkmark, true);} - public static checkmark_ui() { return new FixedUiElement(Svg.checkmark_img);} - - public static circle = " " - public static circle_img = Img.AsImageElement(Svg.circle) - public static circle_svg() { return new Img(Svg.circle, true);} - public static circle_ui() { return new FixedUiElement(Svg.circle_img);} - - public static clock = " image/svg+xml " - public static clock_img = Img.AsImageElement(Svg.clock) - public static clock_svg() { return new Img(Svg.clock, true);} - public static clock_ui() { return new FixedUiElement(Svg.clock_img);} - - public static close = " image/svg+xml " - public static close_img = Img.AsImageElement(Svg.close) - public static close_svg() { return new Img(Svg.close, true);} - public static close_ui() { return new FixedUiElement(Svg.close_img);} - - public static compass = " image/svg+xml N S E W NW SW NE SE " - public static compass_img = Img.AsImageElement(Svg.compass) - public static compass_svg() { return new Img(Svg.compass, true);} - public static compass_ui() { return new FixedUiElement(Svg.compass_img);} - - public static copyright = " " - public static copyright_img = Img.AsImageElement(Svg.copyright) - public static copyright_svg() { return new Img(Svg.copyright, true);} - public static copyright_ui() { return new FixedUiElement(Svg.copyright_img);} - - public static cross_bottom_right = " image/svg+xml " - public static cross_bottom_right_img = Img.AsImageElement(Svg.cross_bottom_right) - public static cross_bottom_right_svg() { return new Img(Svg.cross_bottom_right, true);} - public static cross_bottom_right_ui() { return new FixedUiElement(Svg.cross_bottom_right_img);} - - public static crosshair_blue_center = " image/svg+xml " - public static crosshair_blue_center_img = Img.AsImageElement(Svg.crosshair_blue_center) - public static crosshair_blue_center_svg() { return new Img(Svg.crosshair_blue_center, true);} - public static crosshair_blue_center_ui() { return new FixedUiElement(Svg.crosshair_blue_center_img);} - - public static crosshair_blue = " image/svg+xml " - public static crosshair_blue_img = Img.AsImageElement(Svg.crosshair_blue) - public static crosshair_blue_svg() { return new Img(Svg.crosshair_blue, true);} - public static crosshair_blue_ui() { return new FixedUiElement(Svg.crosshair_blue_img);} - - public static crosshair_empty = " image/svg+xml " - public static crosshair_empty_img = Img.AsImageElement(Svg.crosshair_empty) - public static crosshair_empty_svg() { return new Img(Svg.crosshair_empty, true);} - public static crosshair_empty_ui() { return new FixedUiElement(Svg.crosshair_empty_img);} - - public static crosshair_locked = " image/svg+xml " - public static crosshair_locked_img = Img.AsImageElement(Svg.crosshair_locked) - public static crosshair_locked_svg() { return new Img(Svg.crosshair_locked, true);} - public static crosshair_locked_ui() { return new FixedUiElement(Svg.crosshair_locked_img);} - - public static crosshair = " image/svg+xml " - public static crosshair_img = Img.AsImageElement(Svg.crosshair) - public static crosshair_svg() { return new Img(Svg.crosshair, true);} - public static crosshair_ui() { return new FixedUiElement(Svg.crosshair_img);} - - public static delete_icon = " image/svg+xml " - public static delete_icon_img = Img.AsImageElement(Svg.delete_icon) - public static delete_icon_svg() { return new Img(Svg.delete_icon, true);} - public static delete_icon_ui() { return new FixedUiElement(Svg.delete_icon_img);} - - public static direction = " image/svg+xml " - public static direction_img = Img.AsImageElement(Svg.direction) - public static direction_svg() { return new Img(Svg.direction, true);} - public static direction_ui() { return new FixedUiElement(Svg.direction_img);} - - public static direction_gradient = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static direction_gradient_img = Img.AsImageElement(Svg.direction_gradient) - public static direction_gradient_svg() { return new Img(Svg.direction_gradient, true);} - public static direction_gradient_ui() { return new FixedUiElement(Svg.direction_gradient_img);} - - public static direction_masked = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static direction_masked_img = Img.AsImageElement(Svg.direction_masked) - public static direction_masked_svg() { return new Img(Svg.direction_masked, true);} - public static direction_masked_ui() { return new FixedUiElement(Svg.direction_masked_img);} - - public static direction_outline = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static direction_outline_img = Img.AsImageElement(Svg.direction_outline) - public static direction_outline_svg() { return new Img(Svg.direction_outline, true);} - public static direction_outline_ui() { return new FixedUiElement(Svg.direction_outline_img);} - - public static direction_stroke = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static direction_stroke_img = Img.AsImageElement(Svg.direction_stroke) - public static direction_stroke_svg() { return new Img(Svg.direction_stroke, true);} - public static direction_stroke_ui() { return new FixedUiElement(Svg.direction_stroke_img);} - - public static down = " image/svg+xml " - public static down_img = Img.AsImageElement(Svg.down) - public static down_svg() { return new Img(Svg.down, true);} - public static down_ui() { return new FixedUiElement(Svg.down_img);} - - public static download = " " - public static download_img = Img.AsImageElement(Svg.download) - public static download_svg() { return new Img(Svg.download, true);} - public static download_ui() { return new FixedUiElement(Svg.download_img);} - - public static envelope = " image/svg+xml " - public static envelope_img = Img.AsImageElement(Svg.envelope) - public static envelope_svg() { return new Img(Svg.envelope, true);} - public static envelope_ui() { return new FixedUiElement(Svg.envelope_img);} - - public static filter = " " - public static filter_img = Img.AsImageElement(Svg.filter) - public static filter_svg() { return new Img(Svg.filter, true);} - public static filter_ui() { return new FixedUiElement(Svg.filter_img);} - - public static floppy = " " - public static floppy_img = Img.AsImageElement(Svg.floppy) - public static floppy_svg() { return new Img(Svg.floppy, true);} - public static floppy_ui() { return new FixedUiElement(Svg.floppy_img);} - - public static gear = " " - public static gear_img = Img.AsImageElement(Svg.gear) - public static gear_svg() { return new Img(Svg.gear, true);} - public static gear_ui() { return new FixedUiElement(Svg.gear_img);} - - public static help = " " - public static help_img = Img.AsImageElement(Svg.help) - public static help_svg() { return new Img(Svg.help, true);} - public static help_ui() { return new FixedUiElement(Svg.help_img);} - - public static home = " " - public static home_img = Img.AsImageElement(Svg.home) - public static home_svg() { return new Img(Svg.home, true);} - public static home_ui() { return new FixedUiElement(Svg.home_img);} - - public static home_white_bg = " image/svg+xml " - public static home_white_bg_img = Img.AsImageElement(Svg.home_white_bg) - public static home_white_bg_svg() { return new Img(Svg.home_white_bg, true);} - public static home_white_bg_ui() { return new FixedUiElement(Svg.home_white_bg_img);} - - public static josm_logo = " JOSM Logotype 2019 image/svg+xml JOSM Logotype 2019 2019-08-05 Diamond00744 Public Domain " - public static josm_logo_img = Img.AsImageElement(Svg.josm_logo) - public static josm_logo_svg() { return new Img(Svg.josm_logo, true);} - public static josm_logo_ui() { return new FixedUiElement(Svg.josm_logo_img);} - - public static layers = " image/svg+xml " - public static layers_img = Img.AsImageElement(Svg.layers) - public static layers_svg() { return new Img(Svg.layers, true);} - public static layers_ui() { return new FixedUiElement(Svg.layers_img);} - - public static layersAdd = " image/svg+xml " - public static layersAdd_img = Img.AsImageElement(Svg.layersAdd) - public static layersAdd_svg() { return new Img(Svg.layersAdd, true);} - public static layersAdd_ui() { return new FixedUiElement(Svg.layersAdd_img);} - - public static length_crosshair = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static length_crosshair_img = Img.AsImageElement(Svg.length_crosshair) - public static length_crosshair_svg() { return new Img(Svg.length_crosshair, true);} - public static length_crosshair_ui() { return new FixedUiElement(Svg.length_crosshair_img);} - - public static location_circle = " image/svg+xml " - public static location_circle_img = Img.AsImageElement(Svg.location_circle) - public static location_circle_svg() { return new Img(Svg.location_circle, true);} - public static location_circle_ui() { return new FixedUiElement(Svg.location_circle_img);} - - public static location_empty = "" - public static location_empty_img = Img.AsImageElement(Svg.location_empty) - public static location_empty_svg() { return new Img(Svg.location_empty, true);} - public static location_empty_ui() { return new FixedUiElement(Svg.location_empty_img);} - - public static location = " " - public static location_img = Img.AsImageElement(Svg.location) - public static location_svg() { return new Img(Svg.location, true);} - public static location_ui() { return new FixedUiElement(Svg.location_img);} - - public static logo = " image/svg+xml " - public static logo_img = Img.AsImageElement(Svg.logo) - public static logo_svg() { return new Img(Svg.logo, true);} - public static logo_ui() { return new FixedUiElement(Svg.logo_img);} - - public static logout = " image/svg+xml " - public static logout_img = Img.AsImageElement(Svg.logout) - public static logout_svg() { return new Img(Svg.logout, true);} - public static logout_ui() { return new FixedUiElement(Svg.logout_img);} - - public static mapcomplete_logo = " OpenStreetMap logo 2011 image/svg+xml OpenStreetMap logo 2011 Ken Vermette April 2011 OpenStreetMap.org Replacement logo for OpenStreetMap Foundation OSM openstreetmap logo http://wiki.openstreetmap.org/wiki/File:Public-images-osm_logo.svg 010110010011010110010011  010110010011010110010011  " - public static mapcomplete_logo_img = Img.AsImageElement(Svg.mapcomplete_logo) - public static mapcomplete_logo_svg() { return new Img(Svg.mapcomplete_logo, true);} - public static mapcomplete_logo_ui() { return new FixedUiElement(Svg.mapcomplete_logo_img);} - - public static mapillary = "" - public static mapillary_img = Img.AsImageElement(Svg.mapillary) - public static mapillary_svg() { return new Img(Svg.mapillary, true);} - public static mapillary_ui() { return new FixedUiElement(Svg.mapillary_img);} - - public static mapillary_black = " image/svg+xml " - public static mapillary_black_img = Img.AsImageElement(Svg.mapillary_black) - public static mapillary_black_svg() { return new Img(Svg.mapillary_black, true);} - public static mapillary_black_ui() { return new FixedUiElement(Svg.mapillary_black_img);} - - public static min_zoom = " " - public static min_zoom_img = Img.AsImageElement(Svg.min_zoom) - public static min_zoom_svg() { return new Img(Svg.min_zoom, true);} - public static min_zoom_ui() { return new FixedUiElement(Svg.min_zoom_img);} - - public static min = " image/svg+xml " - public static min_img = Img.AsImageElement(Svg.min) - public static min_svg() { return new Img(Svg.min, true);} - public static min_ui() { return new FixedUiElement(Svg.min_img);} - - public static no_checkmark = " " - public static no_checkmark_img = Img.AsImageElement(Svg.no_checkmark) - public static no_checkmark_svg() { return new Img(Svg.no_checkmark, true);} - public static no_checkmark_ui() { return new FixedUiElement(Svg.no_checkmark_img);} - - public static or = " image/svg+xml " - public static or_img = Img.AsImageElement(Svg.or) - public static or_svg() { return new Img(Svg.or, true);} - public static or_ui() { return new FixedUiElement(Svg.or_img);} - - public static osm_copyright = " image/svg+xml " - public static osm_copyright_img = Img.AsImageElement(Svg.osm_copyright) - public static osm_copyright_svg() { return new Img(Svg.osm_copyright, true);} - public static osm_copyright_ui() { return new FixedUiElement(Svg.osm_copyright_img);} - - public static osm_logo_us = "" - public static osm_logo_us_img = Img.AsImageElement(Svg.osm_logo_us) - public static osm_logo_us_svg() { return new Img(Svg.osm_logo_us, true);} - public static osm_logo_us_ui() { return new FixedUiElement(Svg.osm_logo_us_img);} - - public static osm_logo = " OpenStreetMap logo 2011 image/svg+xml OpenStreetMap logo 2011 Ken Vermette April 2011 OpenStreetMap.org Replacement logo for OpenStreetMap Foundation OSM openstreetmap logo http://wiki.openstreetmap.org/wiki/File:Public-images-osm_logo.svg 010110010011010110010011 010110010011010110010011 " - public static osm_logo_img = Img.AsImageElement(Svg.osm_logo) - public static osm_logo_svg() { return new Img(Svg.osm_logo, true);} - public static osm_logo_ui() { return new FixedUiElement(Svg.osm_logo_img);} - - public static pencil = " " - public static pencil_img = Img.AsImageElement(Svg.pencil) - public static pencil_svg() { return new Img(Svg.pencil, true);} - public static pencil_ui() { return new FixedUiElement(Svg.pencil_img);} - - public static phone = " image/svg+xml " - public static phone_img = Img.AsImageElement(Svg.phone) - public static phone_svg() { return new Img(Svg.phone, true);} - public static phone_ui() { return new FixedUiElement(Svg.phone_img);} - - public static pin = " image/svg+xml " - public static pin_img = Img.AsImageElement(Svg.pin) - public static pin_svg() { return new Img(Svg.pin, true);} - public static pin_ui() { return new FixedUiElement(Svg.pin_img);} - - public static plus_zoom = " " - public static plus_zoom_img = Img.AsImageElement(Svg.plus_zoom) - public static plus_zoom_svg() { return new Img(Svg.plus_zoom, true);} - public static plus_zoom_ui() { return new FixedUiElement(Svg.plus_zoom_img);} - - public static plus = " image/svg+xml " - public static plus_img = Img.AsImageElement(Svg.plus) - public static plus_svg() { return new Img(Svg.plus, true);} - public static plus_ui() { return new FixedUiElement(Svg.plus_img);} - - public static pop_out = " Svg Vector Icons : http://www.onlinewebfonts.com/icon " - public static pop_out_img = Img.AsImageElement(Svg.pop_out) - public static pop_out_svg() { return new Img(Svg.pop_out, true);} - public static pop_out_ui() { return new FixedUiElement(Svg.pop_out_img);} - - public static reload = " " - public static reload_img = Img.AsImageElement(Svg.reload) - public static reload_svg() { return new Img(Svg.reload, true);} - public static reload_ui() { return new FixedUiElement(Svg.reload_img);} - - public static ring = " image/svg+xml " - public static ring_img = Img.AsImageElement(Svg.ring) - public static ring_svg() { return new Img(Svg.ring, true);} - public static ring_ui() { return new FixedUiElement(Svg.ring_img);} - - public static search = " " - public static search_img = Img.AsImageElement(Svg.search) - public static search_svg() { return new Img(Svg.search, true);} - public static search_ui() { return new FixedUiElement(Svg.search_img);} - - public static send_email = " image/svg+xml " - public static send_email_img = Img.AsImageElement(Svg.send_email) - public static send_email_svg() { return new Img(Svg.send_email, true);} - public static send_email_ui() { return new FixedUiElement(Svg.send_email_img);} - - public static share = " image/svg+xml " - public static share_img = Img.AsImageElement(Svg.share) - public static share_svg() { return new Img(Svg.share, true);} - public static share_ui() { return new FixedUiElement(Svg.share_img);} - - public static square = " image/svg+xml " - public static square_img = Img.AsImageElement(Svg.square) - public static square_svg() { return new Img(Svg.square, true);} - public static square_ui() { return new FixedUiElement(Svg.square_img);} - - public static star = " Created by potrace 1.15, written by Peter Selinger 2001-2017 " - public static star_img = Img.AsImageElement(Svg.star) - public static star_svg() { return new Img(Svg.star, true);} - public static star_ui() { return new FixedUiElement(Svg.star_img);} - - public static star_half = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static star_half_img = Img.AsImageElement(Svg.star_half) - public static star_half_svg() { return new Img(Svg.star_half, true);} - public static star_half_ui() { return new FixedUiElement(Svg.star_half_img);} - - public static star_outline = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static star_outline_img = Img.AsImageElement(Svg.star_outline) - public static star_outline_svg() { return new Img(Svg.star_outline, true);} - public static star_outline_ui() { return new FixedUiElement(Svg.star_outline_img);} - - public static star_outline_half = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " - public static star_outline_half_img = Img.AsImageElement(Svg.star_outline_half) - public static star_outline_half_svg() { return new Img(Svg.star_outline_half, true);} - public static star_outline_half_ui() { return new FixedUiElement(Svg.star_outline_half_img);} - - public static statistics = " Svg Vector Icons : http://www.onlinewebfonts.com/icon " - public static statistics_img = Img.AsImageElement(Svg.statistics) - public static statistics_svg() { return new Img(Svg.statistics, true);} - public static statistics_ui() { return new FixedUiElement(Svg.statistics_img);} - - public static translate = " " - public static translate_img = Img.AsImageElement(Svg.translate) - public static translate_svg() { return new Img(Svg.translate, true);} - public static translate_ui() { return new FixedUiElement(Svg.translate_img);} - - public static up = " " - public static up_img = Img.AsImageElement(Svg.up) - public static up_svg() { return new Img(Svg.up, true);} - public static up_ui() { return new FixedUiElement(Svg.up_img);} - - public static wikidata = " " - public static wikidata_img = Img.AsImageElement(Svg.wikidata) - public static wikidata_svg() { return new Img(Svg.wikidata, true);} - public static wikidata_ui() { return new FixedUiElement(Svg.wikidata_img);} - - public static wikimedia_commons_white = " Wikimedia Commons Logo " - public static wikimedia_commons_white_img = Img.AsImageElement(Svg.wikimedia_commons_white) - public static wikimedia_commons_white_svg() { return new Img(Svg.wikimedia_commons_white, true);} - public static wikimedia_commons_white_ui() { return new FixedUiElement(Svg.wikimedia_commons_white_img);} - - public static wikipedia = " Wikipedia logo version 2" - public static wikipedia_img = Img.AsImageElement(Svg.wikipedia) - public static wikipedia_svg() { return new Img(Svg.wikipedia, true);} - public static wikipedia_ui() { return new FixedUiElement(Svg.wikipedia_img);} - -public static All = {"SocialImageForeground.svg": Svg.SocialImageForeground,"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-download.svg": Svg.arrow_download,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-left-thin.svg": Svg.arrow_left_thin,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"back.svg": Svg.back,"barrier.svg": Svg.barrier,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkbox-empty.svg": Svg.checkbox_empty,"checkbox-filled.svg": Svg.checkbox_filled,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"copyright.svg": Svg.copyright,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair-empty.svg": Svg.crosshair_empty,"crosshair-locked.svg": Svg.crosshair_locked,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"direction_masked.svg": Svg.direction_masked,"direction_outline.svg": Svg.direction_outline,"direction_stroke.svg": Svg.direction_stroke,"down.svg": Svg.down,"download.svg": Svg.download,"envelope.svg": Svg.envelope,"filter.svg": Svg.filter,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"length-crosshair.svg": Svg.length_crosshair,"location-circle.svg": Svg.location_circle,"location-empty.svg": Svg.location_empty,"location.svg": Svg.location,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapcomplete_logo.svg": Svg.mapcomplete_logo,"mapillary.svg": Svg.mapillary,"mapillary_black.svg": Svg.mapillary_black,"min-zoom.svg": Svg.min_zoom,"min.svg": Svg.min,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-copyright.svg": Svg.osm_copyright,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"plus-zoom.svg": Svg.plus_zoom,"plus.svg": Svg.plus,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"star_outline.svg": Svg.star_outline,"star_outline_half.svg": Svg.star_outline_half,"statistics.svg": Svg.statistics,"translate.svg": Svg.translate,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 6ebf37a75..0c063b672 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -28,7 +28,7 @@ export default class Minimap extends BaseUIElement { super() options = options ?? {} this._background = options?.background ?? new UIEventSource(AvailableBaseLayers.osmCarto) - this._location = options?.location ?? new UIEventSource(undefined) + this._location = options?.location ?? new UIEventSource({lat: 0, lon: 0, zoom: 1}) this._id = "minimap" + Minimap._nextId; this._allowMoving = options.allowMoving ?? true; this._leafletoptions = options.leafletOptions ?? {} @@ -43,6 +43,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/PersonalLayersPanel.ts b/UI/BigComponents/PersonalLayersPanel.ts index 64032f27a..a2f9e6d2e 100644 --- a/UI/BigComponents/PersonalLayersPanel.ts +++ b/UI/BigComponents/PersonalLayersPanel.ts @@ -64,13 +64,11 @@ export default class PersonalLayersPanel extends VariableUiElement { private static CreateLayerToggle(layer: LayerConfig): Toggle { let icon :BaseUIElement =new Combine([ layer.GenerateLeafletStyle( new UIEventSource({id: "node/-1"}), - false, - "2em" + false ).icon.html]).SetClass("relative") let iconUnset =new Combine([ layer.GenerateLeafletStyle( new UIEventSource({id: "node/-1"}), - false, - "2em" + false ).icon.html]).SetClass("relative") iconUnset.SetStyle("opacity:0.1") diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 9d1fd1475..62ea506bb 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -20,6 +20,8 @@ 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"; +import Hash from "../../Logic/Web/Hash"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -61,11 +63,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,8 +72,16 @@ export default class SimpleAddUI extends Toggle { } return SimpleAddUI.CreateConfirmButton(preset, (tags, location) => { - createNewPoint(tags, location) + const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon) + State.state.changes.applyAction(newElementAction) selectedPreset.setData(undefined) + isShown.setData(false) + State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) + console.log("Did set selected element to",State.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) }, () => { selectedPreset.setData(undefined) }) @@ -121,16 +126,16 @@ export default class SimpleAddUI extends Toggle { lon: location.data.lon, zoom: 19 }); - + let backgroundLayer = undefined; - if(preset.preciseInput.preferredBackground){ - backgroundLayer= AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) + if (preset.preciseInput.preferredBackground) { + backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) } - + preciseInput = new LocationInput({ mapBackground: backgroundLayer, - centerLocation:locationSrc - + centerLocation: locationSrc + }) preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") } @@ -145,7 +150,7 @@ export default class SimpleAddUI extends Toggle { .onClick(() => { confirm(preset.tags, (preciseInput?.GetValue() ?? location).data); }); - + if (preciseInput !== undefined) { confirmButton = new Combine([preciseInput, confirmButton]) } @@ -241,7 +246,7 @@ export default class SimpleAddUI extends Toggle { for (const preset of presets) { const tags = TagUtils.KVtoProperties(preset.tags ?? []); - let icon:() => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource(tags), false).icon.html + let icon: () => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource(tags), false).icon.html .SetClass("w-12 h-12 block relative"); const presetInfo: PresetInfo = { tags: preset.tags, 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/Input/LocationInput.ts b/UI/Input/LocationInput.ts index d568e4443..d54af6791 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -42,7 +42,6 @@ export default class LocationInput extends InputElement { } ) map.leafletMap.addCallbackAndRunD(leaflet => { - console.log(leaflet.getBounds(), leaflet.getBounds().pad(0.15)) leaflet.setMaxBounds( leaflet.getBounds().pad(0.15) ) 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 b456c0ab9..1bd09cb14 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 new file mode 100644 index 000000000..f445263e3 --- /dev/null +++ b/UI/Popup/SplitRoadWizard.ts @@ -0,0 +1,155 @@ +import Toggle from "../Input/Toggle"; +import Svg from "../../Svg"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {SubtleButton} from "../Base/SubtleButton"; +import Minimap from "../Base/Minimap"; +import State from "../../State"; +import ShowDataLayer from "../ShowDataLayer"; +import {GeoOperations} from "../../Logic/GeoOperations"; +import {LeafletMouseEvent} from "leaflet"; +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/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()) + + /** + * A UI Element used for splitting roads + * + * @param id: The id of the road to remove + */ + constructor(id: string) { + + 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 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, 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 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, "splitRoadWay"); + new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false, "splitRoad: splitpoints") + + /** + * Handles a click on the overleaf map. + * Finds the closest intersection with the road and adds a point there, ready to confirm the cut. + * @param coordinates Clicked location, [lon, lat] + */ + function onMapClick(coordinates) { + // Get nearest point on the road + const pointOnRoad = GeoOperations.nearestPoint(roadElement, coordinates); // pointOnRoad is a geojson + + // Update point properties to let it match the layer + pointOnRoad.properties._cutposition = "yes"; + pointOnRoad["_matching_layer_id"] = "splitpositions"; + + // 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 + } + + // When clicked, pass clicked location coordinates to onMapClick function + miniMap.leafletMap.addCallbackAndRunD( + (leafletMap) => leafletMap.on("click", (mouseEvent: LeafletMouseEvent) => { + onMapClick([mouseEvent.latlng.lng, mouseEvent.latlng.lat]) + })) + + // Toggle between splitmap + const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone()); + splitButton.onClick( + () => { + splitClicked.setData(true) + } + ) + + // Only show the splitButton if logged in, else show login prompt + const loginBtn = t.loginToSplit.Clone() + .onClick(() => State.state.osmConnection.AttemptLogin()) + .SetClass("login-button-friendly"); + const splitToggle = new Toggle(splitButton, loginBtn, State.state.osmConnection.isLoggedIn) + + // Save button + const saveButton = new Button(t.split.Clone(), () => { + hasBeenSplit.setData(true) + const way = OsmObject.DownloadObject(id) + const partOfSrc = OsmObject.DownloadReferencingRelations(id); + let hasRun = false + way.map(way => { + const partOf = partOfSrc.data + if(way === undefined || partOf === undefined){ + return; + } + if(hasRun){ + return + } + hasRun = true + const splitAction = new SplitAction( + way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature) + ) + State.state.changes.applyAction(splitAction) + + }, [partOfSrc]) + + + }); + saveButton.SetClass("btn btn-primary mr-3"); + const disabledSaveButton = new Button("Split", undefined); + disabledSaveButton.SetClass("btn btn-disabled mr-3"); + // 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 = Translations.t.general.cancel.Clone() // Not using Button() element to prevent full width button + .SetClass("btn btn-secondary mr-3") + .onClick(() => { + splitPoints.setData([]); + splitClicked.setData(false); + }); + + cancelButton.SetClass("btn btn-secondary block"); + + const splitTitle = new Title(t.splitTitle); + + const mapView = new Combine([splitTitle, miniMap, new Combine([cancelButton, saveToggle]).SetClass("flex flex-row")]); + 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: ["en"], + startLon: 0, + startLat: 0, + 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, "(BUILTIN) SplitRoadWizard.ts") + + } +} \ No newline at end of file diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 041a74738..5eba4693a 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) { @@ -195,9 +198,7 @@ export default class TagRenderingQuestion extends Combine { oppositeTags.push(notSelected); } tags.push(TagUtils.FlattenMultiAnswer(oppositeTags)); - const actualTags = TagUtils.FlattenMultiAnswer(tags); - console.log("Converted ", indices.join(","), "into", actualTags.asHumanString(false, false, {}), "with elems", elements) - return actualTags; + return TagUtils.FlattenMultiAnswer(tags); }, (tags: TagsFilter) => { // {key --> values[]} diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 59225640f..335826628 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; @@ -85,9 +86,7 @@ export default class ShowDataLayer { console.error(e) } } - - - State.state.selectedElement.ping(); + State.state.selectedElement.ping() } features.addCallback(() => update()); @@ -131,6 +130,7 @@ export default class ShowDataLayer { }) }); } + private postProcessFeature(feature, leafletLayer: L.Layer) { const layer: LayerConfig = this._layerDict[feature._matching_layer_id]; if (layer === undefined) { @@ -160,7 +160,7 @@ export default class ShowDataLayer { leafletLayer.on("popupopen", () => { State.state.selectedElement.setData(feature) - + if (infobox === undefined) { const tags = State.state.allElements.getEventSourceById(feature.properties.id); infobox = new FeatureInfoBox(tags, layer); @@ -175,7 +175,7 @@ export default class ShowDataLayer { infobox.AttachTo(id) - infobox.Activate(); + infobox.Activate(); }); const self = this; State.state.selectedElement.addCallbackAndRunD(selected => { @@ -188,11 +188,13 @@ export default class ShowDataLayer { if (selected.properties.id === feature.properties.id) { // A small sanity check to prevent infinite loops: if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again - - && feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too - ) { + && feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too + ) { leafletLayer.openPopup() } + if(feature.id !== feature.properties.id){ + console.trace("Not opening the popup for", feature) + } } }) diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 5a38e8184..ec6c34b5c 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -56,9 +56,12 @@ export default class SpecialVisualizations { if (!tags.hasOwnProperty(key)) { continue; } - parts.push(key + "=" + tags[key]); + parts.push([key , tags[key] ?? "undefined" ]); } - return parts.join("
") + return new Table( + ["key","value"], + parts + ) })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;") }) }, @@ -127,6 +130,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/UI/SubstitutedTranslation.ts b/UI/SubstitutedTranslation.ts index 43352aa5b..361540e65 100644 --- a/UI/SubstitutedTranslation.ts +++ b/UI/SubstitutedTranslation.ts @@ -19,7 +19,6 @@ export class SubstitutedTranslation extends VariableUiElement { const extraMappings: SpecialVisualization[] = []; mapping?.forEach((value, key) => { - console.log("KV:", key, value) extraMappings.push( { funcName: key, @@ -73,11 +72,6 @@ export class SubstitutedTranslation extends VariableUiElement { } }[] { - if (extraMappings.length > 0) { - - console.log("Extra mappings are", extraMappings) - } - for (const knownSpecial of SpecialVisualizations.specialVisualizations.concat(extraMappings)) { // Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way' diff --git a/UI/i18n/Translation.ts b/UI/i18n/Translation.ts index e6e2dfba2..f153b1e65 100644 --- a/UI/i18n/Translation.ts +++ b/UI/i18n/Translation.ts @@ -109,9 +109,9 @@ export class Translation extends BaseUIElement { // @ts-ignore const date: Date = el; rtext = date.toLocaleString(); - } else if (el.ConstructElement() === undefined) { - console.error("InnerREnder is not defined", el); - throw "Hmmm, el.InnerRender is not defined?" + } else if (el.ConstructElement === undefined) { + console.error("ConstructElement is not defined", el); + throw "ConstructElement is not defined, you are working with a "+(typeof el)+":"+(el.constructor.name) } else { Translation.forcedLanguage = lang; // This is a very dirty hack - it'll bite me one day rtext = el.ConstructElement().innerHTML; diff --git a/Utils.ts b/Utils.ts index d2f954736..8414f8882 100644 --- a/Utils.ts +++ b/Utils.ts @@ -135,7 +135,20 @@ 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/svg/license_info.json b/assets/svg/license_info.json index cd3c5032e..0ea2d3a2d 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -1302,5 +1302,15 @@ "sources": [ "https://commons.wikimedia.org/wiki/File:Media-floppy.svg" ] + }, + { + "authors": [ + "The noun project - Basith Ibrahi" + ], + "path": "scissors.svg", + "license": "CC-BY 3.0", + "sources": [ + "https://commons.wikimedia.org/wiki/File:Media-floppy.svg" + ] } ] \ No newline at end of file diff --git a/assets/svg/scissors.svg b/assets/svg/scissors.svg new file mode 100644 index 000000000..6868fe515 --- /dev/null +++ b/assets/svg/scissors.svg @@ -0,0 +1,62 @@ + +image/svg+xml \ No newline at end of file diff --git a/assets/themes/cyclestreets/cyclestreets.json b/assets/themes/cyclestreets/cyclestreets.json index a5a56c7cc..04398254e 100644 --- a/assets/themes/cyclestreets/cyclestreets.json +++ b/assets/themes/cyclestreets/cyclestreets.json @@ -34,7 +34,7 @@ "startZoom": 14, "startLon": 3.2228, "maintainer": "MapComplete", - "widenfactor": 0.05, + "widenfactor": 0.01, "roamingRenderings": [ { "question": { @@ -276,7 +276,8 @@ }, "tagRenderings": [ "images" - ] + ], + "allowSplit": true } ] } \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index 5eca70ca4..94ec9719c 100644 --- a/langs/en.json +++ b/langs/en.json @@ -27,6 +27,14 @@ "intro": "MapComplete is an OpenStreetMap-viewer and editor, which shows you information about a specific theme.", "pickTheme": "Pick a theme below to get started." }, + "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", + "hasBeenSplit": "This way has been split" + }, "delete": { "delete": "Delete", "cancel": "Cancel", diff --git a/package.json b/package.json index d92fd0be7..b978834dc 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "index.js", "scripts": { "increase-memory": "export NODE_OPTIONS=--max_old_space_size=4096", - "start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*", + "start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/svg/* assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*", "test": "ts-node test/TestAll.ts", "init": "npm ci && npm run generate && npm run generate:editor-layer-index && npm run generate:layouts && npm run clean", "add-weblate-upstream": "git remote add weblate-layers https://hosted.weblate.org/git/mapcomplete/layer-translations/ ; git remote update weblate-layers", diff --git a/test.html b/test.html index 8b6c44878..e93cbd6f2 100644 --- a/test.html +++ b/test.html @@ -4,10 +4,16 @@ Small tests - - - - + + + + + + + + + + diff --git a/test.ts b/test.ts index 21ca94b74..e69de29bb 100644 --- a/test.ts +++ b/test.ts @@ -1,169 +0,0 @@ -import {OsmObject} from "./Logic/Osm/OsmObject"; -import DeleteButton from "./UI/Popup/DeleteWizard"; -import Combine from "./UI/Base/Combine"; -import State from "./State"; -import DeleteWizard from "./UI/Popup/DeleteWizard"; -import {UIEventSource} from "./Logic/UIEventSource"; -import {Tag} from "./Logic/Tags/Tag"; -import {QueryParameters} from "./Logic/Web/QueryParameters"; -import {Translation} from "./UI/i18n/Translation"; -import LocationInput from "./UI/Input/LocationInput"; -import Loc from "./Models/Loc"; -import {VariableUiElement} from "./UI/Base/VariableUIElement"; -import LengthInput from "./UI/Input/LengthInput"; -import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; -/*import ValidatedTextField from "./UI/Input/ValidatedTextField"; -import Combine from "./UI/Base/Combine"; -import {VariableUiElement} from "./UI/Base/VariableUIElement"; -import {UIEventSource} from "./Logic/UIEventSource"; -import TagRenderingConfig from "./Customizations/JSON/TagRenderingConfig"; -import State from "./State"; -import TagRenderingQuestion from "./UI/Popup/TagRenderingQuestion"; -import {SlideShow} from "./UI/Image/SlideShow"; -import {FixedUiElement} from "./UI/Base/FixedUiElement"; -import Img from "./UI/Base/Img"; -import {AttributedImage} from "./UI/Image/AttributedImage"; -import {Imgur} from "./Logic/ImageProviders/Imgur"; -import Minimap from "./UI/Base/Minimap"; -import Loc from "./Models/Loc"; -import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; -import ShowDataLayer from "./UI/ShowDataLayer"; -import LayoutConfig from "./Customizations/JSON/LayoutConfig"; -import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; - - -function TestSlideshow() { - const elems = new UIEventSource([ - new FixedUiElement("A"), - new FixedUiElement("qmsldkfjqmlsdkjfmqlskdjfmqlksdf").SetClass("text-xl"), - new Img("https://i.imgur.com/8lIQ5Hv.jpg"), - new AttributedImage("https://i.imgur.com/y5XudzW.jpg", Imgur.singleton), - new Img("https://www.grunge.com/img/gallery/the-real-reason-your-cat-sleeps-so-much/intro-1601496900.webp") - ]) - new SlideShow(elems).AttachTo("maindiv") -} - -function TestTagRendering() { - State.state = new State(undefined) - const tagsSource = new UIEventSource({ - id: "node/1" - }) - new TagRenderingQuestion( - tagsSource, - new TagRenderingConfig({ - multiAnswer: false, - freeform: { - key: "valve" - }, - question: "What valves are supported?", - render: "This pump supports {valve}", - mappings: [ - { - if: "valve=dunlop", - then: "This pump supports dunlop" - }, - { - if: "valve=shrader", - then: "shrader is supported", - } - ], - - }, undefined, "test"), - [] - ).AttachTo("maindiv") - new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv") -} - -function TestAllInputMethods() { - - new Combine(ValidatedTextField.tpList.map(tp => { - const tf = ValidatedTextField.InputForType(tp.name); - - return new Combine([tf, new VariableUiElement(tf.GetValue()).SetClass("alert")]); - })).AttachTo("maindiv") -} - -function TestMiniMap() { - - const location = new UIEventSource({ - lon: 4.84771728515625, - lat: 51.17920846421931, - zoom: 14 - }) - const map0 = new Minimap({ - location: location, - allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2]) - }) - map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red") - .AttachTo("maindiv") - - const layout = AllKnownLayouts.layoutsList[1] - State.state = new State(layout) - console.log("LAYOUT is", layout.id) - - const feature = { - "type": "Feature", - _matching_layer_id: "bike_repair_station", - "properties": { - id: "node/-1", - "amenity": "bicycle_repair_station" - }, - "geometry": { - "type": "Point", - "coordinates": [ - 4.84771728515625, - 51.17920846421931 - ] - } - } - - ; - - State.state.allElements.addOrGetElement(feature) - - const featureSource = new UIEventSource([{ - freshness: new Date(), - feature: feature - }]) - - new ShowDataLayer( - featureSource, - map0.leafletMap, - new UIEventSource(layout) - ) - - const map1 = new Minimap({ - location: location, - allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5]) - }, - ) - - map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black") - .AttachTo("extradiv") - - - new ShowDataLayer( - featureSource, - map1.leafletMap, - new UIEventSource(layout) - ) - - featureSource.ping() -} -//*/ - -const loc = new UIEventSource({ - zoom: 24, - lat: 51.21043, - lon: 3.21389 -}) -const li = new LengthInput( - AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource("map","photo")), - loc -) - li.SetStyle("height: 30rem; background: aliceblue;") - .AttachTo("maindiv") - -new VariableUiElement(li.GetValue().map(v => JSON.stringify(v, null, " "))).AttachTo("extradiv") \ No newline at end of file