diff --git a/Logic/Actors/SelectedFeatureHandler.ts b/Logic/Actors/SelectedFeatureHandler.ts index 95a744df6..c76febd41 100644 --- a/Logic/Actors/SelectedFeatureHandler.ts +++ b/Logic/Actors/SelectedFeatureHandler.ts @@ -2,12 +2,13 @@ import {UIEventSource} from "../UIEventSource"; import {OsmObject} from "../Osm/OsmObject"; import Loc from "../../Models/Loc"; import {ElementStorage} from "../ElementStorage"; +import FeaturePipeline from "../FeatureSource/FeaturePipeline"; /** * Makes sure the hash shows the selected element and vice-versa. */ export default class SelectedFeatureHandler { - private static readonly _no_trigger_on = ["welcome", "copyright", "layers", "new"] + private static readonly _no_trigger_on = new Set(["welcome", "copyright", "layers", "new", "", undefined]) hash: UIEventSource; private readonly state: { selectedElement: UIEventSource @@ -17,30 +18,35 @@ export default class SelectedFeatureHandler { hash: UIEventSource, state: { selectedElement: UIEventSource, - allElements: ElementStorage; + allElements: ElementStorage, + featurePipeline: FeaturePipeline } ) { this.hash = hash; - this.state = state - - // Getting a blank hash clears the selected element - hash.addCallback(h => { + + + // If the hash changes, set the selected element correctly + function setSelectedElementFromHash(h){ if (h === undefined || h === "") { + // Hash has been cleared - we clear the selected element state.selectedElement.setData(undefined); }else{ + // we search the element to select const feature = state.allElements.ContainingFeatures.get(h) if(feature !== undefined){ state.selectedElement.setData(feature) } } - }) + } + + hash.addCallback(setSelectedElementFromHash) + // IF the selected element changes, set the hash correctly state.selectedElement.addCallback(feature => { if (feature === undefined) { - console.trace("Resetting hash") - if (SelectedFeatureHandler._no_trigger_on.indexOf(hash.data) < 0) { + if (SelectedFeatureHandler._no_trigger_on.has(hash.data)) { hash.setData("") } } @@ -50,13 +56,26 @@ export default class SelectedFeatureHandler { hash.setData(h) } }) + + state.featurePipeline.newDataLoadedSignal.addCallbackAndRunD(_ => { + // New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet + if(hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)){ + // This is an invalid hash anyway + return; + } + if(state.selectedElement.data !== undefined){ + // We already have something selected + return; + } + setSelectedElementFromHash(hash.data) + }) } // If a feature is selected via the hash, zoom there public zoomToSelectedFeature(location: UIEventSource) { const hash = this.hash.data; - if (hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0) { + if (hash === undefined || SelectedFeatureHandler._no_trigger_on.has(hash)) { return; // No valid feature selected } // We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure diff --git a/Logic/Actors/TitleHandler.ts b/Logic/Actors/TitleHandler.ts index 520be0ed8..b5bc6f151 100644 --- a/Logic/Actors/TitleHandler.ts +++ b/Logic/Actors/TitleHandler.ts @@ -2,65 +2,38 @@ import {UIEventSource} from "../UIEventSource"; import Translations from "../../UI/i18n/Translations"; import Locale from "../../UI/i18n/Locale"; import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer"; -import {ElementStorage} from "../ElementStorage"; import Combine from "../../UI/Base/Combine"; -import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; -class TitleElement extends UIEventSource { +export default class TitleHandler { + constructor(state) { + const currentTitle: UIEventSource = state.selectedElement.map( + selected => { + console.log("UPdating title") - private readonly _layoutToUse: UIEventSource; - private readonly _selectedFeature: UIEventSource; - private readonly _allElementsStorage: ElementStorage; - - constructor(layoutToUse: UIEventSource, - selectedFeature: UIEventSource, - allElementsStorage: ElementStorage) { - super("MapComplete"); - - this._layoutToUse = layoutToUse; - this._selectedFeature = selectedFeature; - this._allElementsStorage = allElementsStorage; - - this.syncWith( - this._selectedFeature.map( - selected => { - const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete" - - if (selected === undefined) { - return defaultTitle - } - - const layout = layoutToUse.data; - const tags = selected.properties; - - - for (const layer of layout.layers) { - if (layer.title === undefined) { - continue; - } - if (layer.source.osmTags.matchesProperties(tags)) { - const tagsSource = allElementsStorage.getEventSourceById(tags.id) - const title = new TagRenderingAnswer(tagsSource, layer.title) - return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText; - } - } + const layout = state.layoutToUse.data + const defaultTitle = Translations.WT(layout?.title)?.txt ?? "MapComplete" + if (selected === undefined) { return defaultTitle } - , [Locale.language, layoutToUse] - ) + + const tags = selected.properties; + for (const layer of layout.layers) { + if (layer.title === undefined) { + continue; + } + if (layer.source.osmTags.matchesProperties(tags)) { + const tagsSource = state.allElements.getEventSourceById(tags.id) + const title = new TagRenderingAnswer(tagsSource, layer.title) + return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText; + } + } + return defaultTitle + }, [Locale.language, state.layoutToUse] ) - } - -} - -export default class TitleHandler { - constructor(layoutToUse: UIEventSource, - selectedFeature: UIEventSource, - allElementsStorage: ElementStorage) { - new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => { + currentTitle.addCallbackAndRunD(title => { document.title = title }) } diff --git a/Logic/ExtraFunction.ts b/Logic/ExtraFunction.ts index dd75ac356..8b6834e09 100644 --- a/Logic/ExtraFunction.ts +++ b/Logic/ExtraFunction.ts @@ -88,7 +88,7 @@ export class ExtraFunction { { name: "distanceTo", doc: "Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object", - args: ["longitude", "latitude"] + args: ["feature OR featureID OR longitude", "undefined OR latitude"] }, (featuresPerLayer, feature) => { return (arg0, lat) => { @@ -128,10 +128,10 @@ export class ExtraFunction { private static readonly ClosestNObjectFunc = new ExtraFunction( { name: "closestn", - doc: "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. " + - "Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet laoded)\n\n" + + doc: "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature (excluding the feature itself). In the case of ways/polygons, only the centerpoint is considered. " + + "Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet loaded)\n\n" + "If a 'unique tag key' is given, the tag with this key will only appear once (e.g. if 'name' is given, all features will have a different name)", - args: ["list of features", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"] + args: ["list of features or layer name", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"] }, (params, feature) => { return (features, amount, uniqueTag, maxDistanceInMeters) => ExtraFunction.GetClosestNFeatures(params, feature, features, { diff --git a/Logic/FeatureSource/Actors/LocalStorageSaverActor.ts b/Logic/FeatureSource/Actors/SaveTileToLocalStorageActor.ts similarity index 71% rename from Logic/FeatureSource/Actors/LocalStorageSaverActor.ts rename to Logic/FeatureSource/Actors/SaveTileToLocalStorageActor.ts index 9cb9ce7f2..331168bb8 100644 --- a/Logic/FeatureSource/Actors/LocalStorageSaverActor.ts +++ b/Logic/FeatureSource/Actors/SaveTileToLocalStorageActor.ts @@ -4,15 +4,13 @@ * Technically, more an Actor then a featuresource, but it fits more neatly this ay */ import {FeatureSourceForLayer} from "../FeatureSource"; -import {Utils} from "../../../Utils"; -export default class LocalStorageSaverActor { +export default class SaveTileToLocalStorageActor { public static readonly storageKey: string = "cached-features"; - constructor(source: FeatureSourceForLayer, x: number, y: number, z: number) { + constructor(source: FeatureSourceForLayer, tileIndex: number) { source.features.addCallbackAndRunD(features => { - const index = Utils.tile_index(z, x, y) - const key = `${LocalStorageSaverActor.storageKey}-${source.layer.layerDef.id}-${index}` + const key = `${SaveTileToLocalStorageActor.storageKey}-${source.layer.layerDef.id}-${tileIndex}` const now = new Date().getTime() if (features.length == 0) { diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts index 0507c733d..bab2225bf 100644 --- a/Logic/FeatureSource/ChangeApplicator.ts +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -1,202 +1,85 @@ -import FeatureSource, {IndexedFeatureSource} from "./FeatureSource"; +import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource} from "./FeatureSource"; import {UIEventSource} from "../UIEventSource"; import {Changes} from "../Osm/Changes"; -import {ChangeDescription} from "../Osm/Actions/ChangeDescription"; +import {ChangeDescription, ChangeDescriptionTools} from "../Osm/Actions/ChangeDescription"; import {Utils} from "../../Utils"; +import FilteredLayer from "../../Models/FilteredLayer"; import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject"; -/** - * A feature source containing exclusively new elements - */ -export class NewGeometryChangeApplicatorFeatureSource implements FeatureSource{ - - public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; - public readonly name: string = "newFeatures"; - constructor(changes: Changes) { - const seenChanges = new Set(); - changes.pendingChanges.addCallbackAndRunD(changes => { - for (const change of changes) { - if(seenChanges.has(change)){ - continue - } - seenChanges.add(change) - - if(change.id < 0){ - // This is a new object! - } - - } - }) - } - -} /** - * Applies changes from 'Changes' onto a featureSource + * Applies geometry changes from 'Changes' onto every feature of a featureSource */ -export default class ChangeApplicator implements FeatureSource { - public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; +export default class ChangeGeometryApplicator implements FeatureSourceForLayer { + public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); public readonly name: string; private readonly source: IndexedFeatureSource; private readonly changes: Changes; - private readonly mode?: { - generateNewGeometries: boolean - }; + public readonly layer: FilteredLayer - constructor(source: IndexedFeatureSource, changes: Changes, mode?: { - generateNewGeometries: boolean - }) { + constructor(source: (IndexedFeatureSource & FeatureSourceForLayer), changes: Changes) { this.source = source; this.changes = changes; - this.mode = mode; + this.layer = source.layer this.name = "ChangesApplied(" + source.name + ")" - this.features = source.features - const seenChanges = new Set(); + this.features = new UIEventSource<{ feature: any; freshness: Date }[]>(undefined) + const self = this; - let runningUpdate = false; - source.features.addCallbackAndRunD(features => { - if (runningUpdate) { - return; // No need to ping again - } - self.ApplyChanges() - seenChanges.clear() - }) + source.features.addCallbackAndRunD(_ => self.update()) + + changes.allChanges.addCallbackAndRunD(_ => self.update()) - changes.pendingChanges.addCallbackAndRunD(changes => { - runningUpdate = true; - changes = changes.filter(ch => !seenChanges.has(ch)) - changes.forEach(c => seenChanges.add(c)) - self.ApplyChanges() - source.features.ping() - runningUpdate = false; - }) } + private update() { + const upstreamFeatures = this.source.features.data + const upstreamIds = this.source.containedIds.data + const changesToApply = this.changes.allChanges.data + ?.filter(ch => + // Does upsteram have this element? If not, we skip + upstreamIds.has(ch.type + "/" + ch.id) && + // Are any (geometry) changes defined? + ch.changes !== undefined && + // Ignore new elements, they are handled by the NewGeometryFromChangesFeatureSource + ch.id > 0) - /** - * Returns true if the geometry is changed and the source should be pinged - */ - private ApplyChanges(): boolean { - const cs = this.changes.pendingChanges.data - const features = this.source.features.data - const loadedIds = this.source.containedIds - if (cs.length === 0 || features === undefined) { + if (changesToApply === undefined || changesToApply.length === 0) { + // No changes to apply! + // Pass the original feature and lets continue our day + this.features.setData(upstreamFeatures); 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 (!loadedIds.has(id)) { - continue + const changesPerId = new Map() + for (const ch of changesToApply) { + const key = ch.type + "/" + ch.id + if(changesPerId.has(key)){ + changesPerId.get(key).push(ch) + }else{ + changesPerId.set(key, [ch]) } - if (!changesPerId.has(id)) { - changesPerId.set(id, []) - } - changesPerId.get(id).push(c) } - if (changesPerId.size === 0) { - // The current feature source set doesn't contain any changed feature, so we can safely skip - return; - } - - 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 (this.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)) { + const newFeatures: { feature: any, freshness: Date }[] = [] + for (const feature of upstreamFeatures) { + const changesForFeature = changesPerId.get(feature.feature.properties.id) + if (changesForFeature === undefined) { + // No changes for this element + newFeatures.push(feature) 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; - } - } + + // Allright! We have a feature to rewrite! + const copy = { + ...feature } + // We only apply the last change as that one'll have the latest geometry + const change = changesForFeature[changesForFeature.length - 1] + copy.feature.geometry = ChangeDescriptionTools.getGeojsonGeometry(change) + newFeatures.push(copy) } - return geometryChanged + this.features.setData(newFeatures) + } + } \ No newline at end of file diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 80f1949f1..2fe254eb1 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -1,7 +1,7 @@ import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import FilteringFeatureSource from "./Sources/FilteringFeatureSource"; import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter"; -import FeatureSource, {FeatureSourceForLayer, FeatureSourceState, Tiled} from "./FeatureSource"; +import FeatureSource, {FeatureSourceForLayer, FeatureSourceState, IndexedFeatureSource, Tiled} from "./FeatureSource"; import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource"; import {UIEventSource} from "../UIEventSource"; import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy"; @@ -14,13 +14,14 @@ import GeoJsonSource from "./Sources/GeoJsonSource"; import Loc from "../../Models/Loc"; import WayHandlingApplyingFeatureSource from "./Sources/WayHandlingApplyingFeatureSource"; import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor"; -import {Utils} from "../../Utils"; import TiledFromLocalStorageSource from "./TiledFeatureSource/TiledFromLocalStorageSource"; -import LocalStorageSaverActor from "./Actors/LocalStorageSaverActor"; +import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor"; import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource"; import {BBox} from "../GeoOperations"; import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger"; import RelationsTracker from "../Osm/RelationsTracker"; +import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource"; +import ChangeGeometryApplicator from "./ChangeApplicator"; export default class FeaturePipeline implements FeatureSourceState { @@ -29,10 +30,12 @@ export default class FeaturePipeline implements FeatureSourceState { public readonly runningQuery: UIEventSource; public readonly timeout: UIEventSource; public readonly somethingLoaded: UIEventSource = new UIEventSource(false) + public readonly newDataLoadedSignal: UIEventSource = new UIEventSource(undefined) private readonly overpassUpdater: OverpassFeatureSource private readonly relationTracker: RelationsTracker private readonly perLayerHierarchy: Map; + constructor( handleFeatureSource: (source: FeatureSourceForLayer) => void, state: { @@ -49,6 +52,7 @@ export default class FeaturePipeline implements FeatureSourceState { const self = this const updater = new OverpassFeatureSource(state); + updater.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(updater)) this.overpassUpdater = updater; this.sufficientlyZoomed = updater.sufficientlyZoomed this.runningQuery = updater.runningQuery @@ -56,16 +60,17 @@ export default class FeaturePipeline implements FeatureSourceState { this.relationTracker = updater.relationsTracker // Register everything in the state' 'AllElements' new RegisteringAllFromFeatureSourceActor(updater) + const perLayerHierarchy = new Map() this.perLayerHierarchy = perLayerHierarchy - const patchedHandleFeatureSource = function (src: FeatureSourceForLayer) { + const patchedHandleFeatureSource = function (src: FeatureSourceForLayer & IndexedFeatureSource) { // This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile const srcFiltered = new FilteringFeatureSource(state, new WayHandlingApplyingFeatureSource( - src, + new ChangeGeometryApplicator(src, state.changes) ) ) handleFeatureSource(srcFiltered) @@ -90,6 +95,7 @@ export default class FeaturePipeline implements FeatureSourceState { (src) => { new RegisteringAllFromFeatureSourceActor(src) hierarchy.registerTile(src); + src.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(src)) }, state) continue } @@ -103,6 +109,7 @@ export default class FeaturePipeline implements FeatureSourceState { registerTile: (tile) => { new RegisteringAllFromFeatureSourceActor(tile) addToHierarchy(tile, id) + tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) } }) } else { @@ -113,6 +120,7 @@ export default class FeaturePipeline implements FeatureSourceState { registerTile: (tile) => { new RegisteringAllFromFeatureSourceActor(tile) addToHierarchy(tile, id) + tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) } }), state @@ -122,64 +130,90 @@ export default class FeaturePipeline implements FeatureSourceState { } // Actually load data from the overpass source - new PerLayerFeatureSourceSplitter(state.filteredLayers, (source) => TiledFeatureSource.createHierarchy(source, { layer: source.layer, registerTile: (tile) => { // We save the tile data for the given layer to local storage - const [z, x, y] = Utils.tile_from_index(tile.tileIndex) - new LocalStorageSaverActor(tile, x, y, z) + new SaveTileToLocalStorageActor(tile, tile.tileIndex) addToHierarchy(tile, source.layer.layerDef.id); } - }), new RememberingSource(updater)) + }), + new RememberingSource(updater)) + + + // Also load points/lines that are newly added. + const newGeometry = new NewGeometryFromChangesFeatureSource(state.changes) + new RegisteringAllFromFeatureSourceActor(newGeometry) + // A NewGeometryFromChangesFeatureSource does not split per layer, so we do this next + new PerLayerFeatureSourceSplitter(state.filteredLayers, + (perLayer) => { + // We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this + addToHierarchy(perLayer, perLayer.layer.layerDef.id) + // AT last, we always apply the metatags whenever possible + perLayer.features.addCallbackAndRunD(_ => self.applyMetaTags(perLayer)) + }, + newGeometry + ) // Whenever fresh data comes in, we need to update the metatagging - updater.features.addCallback(_ => { + self.newDataLoadedSignal.stabilized(1000).addCallback(src => { + console.log("Got an update from ", src.name) self.updateAllMetaTagging() }) } + + private applyMetaTags(src: FeatureSourceForLayer){ + const self = this + MetaTagging.addMetatags( + src.features.data, + { + memberships: this.relationTracker, + getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox) + }, + src.layer.layerDef, + { + includeDates: true, + // We assume that the non-dated metatags are already set by the cache generator + includeNonDates: !src.layer.layerDef.source.isOsmCacheLayer + } + ) + } private updateAllMetaTagging() { console.log("Updating the meta tagging") const self = this; this.perLayerHierarchy.forEach(hierarchy => { hierarchy.loadedTiles.forEach(src => { - MetaTagging.addMetatags( - src.features.data, - { - memberships: this.relationTracker, - getFeaturesWithin: (layerId, bbox: BBox) => self.GetFeaturesWithin(layerId, bbox) - }, - src.layer.layerDef - ) + self.applyMetaTags(src) }) }) } - - public GetAllFeaturesWithin(bbox: BBox): any[][]{ + + public GetAllFeaturesWithin(bbox: BBox): any[][] { const self = this const tiles = [] Array.from(this.perLayerHierarchy.keys()) .forEach(key => tiles.push(...self.GetFeaturesWithin(key, bbox))) return tiles; } - - public GetFeaturesWithin(layerId: string, bbox: BBox): any[][]{ + + public GetFeaturesWithin(layerId: string, bbox: BBox): any[][] { const requestedHierarchy = this.perLayerHierarchy.get(layerId) if (requestedHierarchy === undefined) { + console.warn("Layer ", layerId, "is not defined. Try one of ", Array.from(this.perLayerHierarchy.keys())) return undefined; } return TileHierarchyTools.getTiles(requestedHierarchy, bbox) .filter(featureSource => featureSource.features?.data !== undefined) .map(featureSource => featureSource.features.data.map(fs => fs.feature)) } - - public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void){ - Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => { + + public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void) { + Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => { TileHierarchyTools.getTiles(hierarchy, bbox).forEach(handleTile) }) } diff --git a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts index bb39660fa..d16e2c558 100644 --- a/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts +++ b/Logic/FeatureSource/PerLayerFeatureSourceSplitter.ts @@ -1,4 +1,4 @@ -import FeatureSource, {FeatureSourceForLayer} from "./FeatureSource"; +import FeatureSource, {FeatureSourceForLayer, Tiled} from "./FeatureSource"; import {UIEventSource} from "../UIEventSource"; import FilteredLayer from "../../Models/FilteredLayer"; import SimpleFeatureSource from "./Sources/SimpleFeatureSource"; @@ -12,10 +12,13 @@ import SimpleFeatureSource from "./Sources/SimpleFeatureSource"; export default class PerLayerFeatureSourceSplitter { constructor(layers: UIEventSource, - handleLayerData: (source: FeatureSourceForLayer) => void, - upstream: FeatureSource) { + handleLayerData: (source: FeatureSourceForLayer & Tiled) => void, + upstream: FeatureSource, + options?:{ + handleLeftovers?: (featuresWithoutLayer: any[]) => void + }) { - const knownLayers = new Map() + const knownLayers = new Map() function update() { const features = upstream.features.data; @@ -30,7 +33,7 @@ export default class PerLayerFeatureSourceSplitter { // Note that this splitter is only run when it is invoked by the overpass feature source, so we can't be sure in which layer it should go const featuresPerLayer = new Map(); - + const noLayerFound = [] function addTo(layer: FilteredLayer, feature: { feature, freshness }) { const id = layer.layerDef.id const list = featuresPerLayer.get(id) @@ -51,6 +54,7 @@ export default class PerLayerFeatureSourceSplitter { break; } } + noLayerFound.push(f) } } @@ -75,6 +79,11 @@ export default class PerLayerFeatureSourceSplitter { featureSource.features.setData(features) } } + + // AT last, the leftovers are handled + if(options?.handleLeftovers !== undefined && noLayerFound.length > 0){ + options.handleLeftovers(noLayerFound) + } } layers.addCallback(_ => update()) diff --git a/Logic/FeatureSource/Sources/FeatureSourceMerger.ts b/Logic/FeatureSource/Sources/FeatureSourceMerger.ts index 574258455..fb349ae5d 100644 --- a/Logic/FeatureSource/Sources/FeatureSourceMerger.ts +++ b/Logic/FeatureSource/Sources/FeatureSourceMerger.ts @@ -3,12 +3,12 @@ * Uses the freshest feature available in the case multiple sources offer data with the same identifier */ import {UIEventSource} from "../../UIEventSource"; -import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"; +import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FilteredLayer from "../../../Models/FilteredLayer"; import {BBox} from "../../GeoOperations"; import {Utils} from "../../../Utils"; -export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled { +export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource { public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); public readonly name; @@ -16,6 +16,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled private readonly _sources: UIEventSource; public readonly tileIndex: number; public readonly bbox: BBox; + public readonly containedIds: UIEventSource> = new UIEventSource>(new Set()) constructor(layer: FilteredLayer, tileIndex: number, bbox: BBox, sources: UIEventSource) { this.tileIndex = tileIndex; @@ -54,7 +55,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled // We seed the dictionary with the previously loaded features const oldValues = this.features.data ?? []; for (const oldValue of oldValues) { - all.set(oldValue.feature.id + oldValue.feature._matching_layer_id, oldValue) + all.set(oldValue.feature.id, oldValue) } for (const source of this._sources.data) { @@ -62,7 +63,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled continue; } for (const f of source.features.data) { - const id = f.feature.properties.id + f.feature._matching_layer_id; + const id = f.feature.properties.id; if (!all.has(id)) { // This is a new feature somethingChanged = true; @@ -90,6 +91,7 @@ export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled all.forEach((value, _) => { newList.push(value) }) + this.containedIds.setData(new Set(all.keys())) this.features.setData(newList); } diff --git a/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts b/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts new file mode 100644 index 000000000..f4619b2aa --- /dev/null +++ b/Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource.ts @@ -0,0 +1,90 @@ +import {Changes} from "../../Osm/Changes"; +import {OsmNode, OsmRelation, OsmWay} from "../../Osm/OsmObject"; +import FeatureSource from "../FeatureSource"; +import {UIEventSource} from "../../UIEventSource"; +import {ChangeDescription} from "../../Osm/Actions/ChangeDescription"; + +export class NewGeometryFromChangesFeatureSource implements FeatureSource { + // This class name truly puts the 'Java' into 'Javascript' + + /** + * A feature source containing exclusively new elements + */ + public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); + public readonly name: string = "newFeatures"; + + constructor(changes: Changes) { + + const seenChanges = new Set(); + const features = this.features.data; + const self = this; + + changes.pendingChanges + .map(changes => changes.filter(ch => + // only new objects allowed + ch.id < 0 && + // The change is an update to the object (e.g. tags or geometry) - not the actual create + ch.changes !== undefined && + // If tags is undefined, this is probably a new point that is part of a split road + ch.tags !== undefined && + // Already handled + !seenChanges.has(ch))) + .addCallbackAndRunD(changes => { + + if (changes.length === 0) { + return; + } + + const now = new Date(); + + function add(feature) { + feature.id = feature.properties.id + features.push({ + feature: feature, + freshness: now + }) + console.warn("Added a new feature: ", JSON.stringify(feature)) + } + + for (const change of changes) { + seenChanges.add(change) + try { + const tags = {} + for (const kv of change.tags) { + tags[kv.k] = kv.v + } + tags["id"] = change.type+"/"+change.id + switch (change.type) { + case "node": + const n = new OsmNode(change.id) + n.tags = tags + 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.tags = tags + w.nodes = change.changes["nodes"] + w.coordinates = change.changes["coordinates"].map(coor => coor.reverse()) + add(w.asGeoJson()) + break; + case "relation": + const r = new OsmRelation(change.id) + r.tags = tags + r.members = change.changes["members"] + add(r.asGeoJson()) + break; + } + } catch (e) { + console.error("Could not generate a new geometry to render on screen for:", e) + } + + } + + self.features.ping() + }) + } + +} \ No newline at end of file diff --git a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts index d4c316ec4..ad8d7be5d 100644 --- a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts +++ b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts @@ -1,16 +1,19 @@ import {UIEventSource} from "../../UIEventSource"; import FilteredLayer from "../../../Models/FilteredLayer"; -import {FeatureSourceForLayer} from "../FeatureSource"; +import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; +import {BBox} from "../../GeoOperations"; +import {Utils} from "../../../Utils"; -export default class SimpleFeatureSource implements FeatureSourceForLayer { +export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled { public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); public readonly name: string = "SimpleFeatureSource"; public readonly layer: FilteredLayer; + public readonly bbox: BBox = BBox.global; + public readonly tileIndex: number = Utils.tile_index(0, 0, 0); constructor(layer: FilteredLayer) { - this.name = "SimpleFeatureSource("+layer.layerDef.id+")" + this.name = "SimpleFeatureSource(" + layer.layerDef.id + ")" this.layer = layer } - } \ No newline at end of file diff --git a/Logic/FeatureSource/TiledFeatureSource/TileHierarchyMerger.ts b/Logic/FeatureSource/TiledFeatureSource/TileHierarchyMerger.ts index 10d0c1742..7b9f44b9c 100644 --- a/Logic/FeatureSource/TiledFeatureSource/TileHierarchyMerger.ts +++ b/Logic/FeatureSource/TiledFeatureSource/TileHierarchyMerger.ts @@ -1,6 +1,6 @@ import TileHierarchy from "./TileHierarchy"; import {UIEventSource} from "../../UIEventSource"; -import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"; +import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FilteredLayer from "../../../Models/FilteredLayer"; import {Utils} from "../../../Utils"; import {BBox} from "../../GeoOperations"; @@ -11,9 +11,9 @@ export class TileHierarchyMerger implements TileHierarchy> = new Map>(); public readonly layer: FilteredLayer; - private _handleTile: (src: FeatureSourceForLayer, index: number) => void; + private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void; - constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer, index: number) => void) { + constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void) { this.layer = layer; this._handleTile = handleTile; } diff --git a/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts b/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts index 7f3e5776e..0e4e091a3 100644 --- a/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts @@ -4,7 +4,7 @@ import {UIEventSource} from "../../UIEventSource"; import Loc from "../../../Models/Loc"; import TileHierarchy from "./TileHierarchy"; import {Utils} from "../../../Utils"; -import LocalStorageSaverActor from "../Actors/LocalStorageSaverActor"; +import SaveTileToLocalStorageActor from "../Actors/SaveTileToLocalStorageActor"; import {BBox} from "../../GeoOperations"; export default class TiledFromLocalStorageSource implements TileHierarchy { @@ -17,7 +17,7 @@ export default class TiledFromLocalStorageSource implements TileHierarchy { @@ -76,7 +76,7 @@ export default class TiledFromLocalStorageSource implements TileHierarchy coor.reverse()) + return w.asGeoJson().geometry + case "relation": + const r = new OsmRelation(change.id) + r.members = change.changes["members"] + return r.asGeoJson().geometry + } + } } \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeTagAction.ts b/Logic/Osm/Actions/ChangeTagAction.ts index 36bafcbee..784f4d4dc 100644 --- a/Logic/Osm/Actions/ChangeTagAction.ts +++ b/Logic/Osm/Actions/ChangeTagAction.ts @@ -37,7 +37,7 @@ export default class ChangeTagAction extends OsmChangeAction { return {k: key.trim(), v: value.trim()}; } - CreateChangeDescriptions(changes: Changes): ChangeDescription [] { + async CreateChangeDescriptions(changes: Changes): Promise { const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange) const typeId = this._elementId.split("/") const type = typeId[0] diff --git a/Logic/Osm/Actions/CreateNewNodeAction.ts b/Logic/Osm/Actions/CreateNewNodeAction.ts index 86cacab20..4841180bd 100644 --- a/Logic/Osm/Actions/CreateNewNodeAction.ts +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -27,7 +27,7 @@ export default class CreateNewNodeAction extends OsmChangeAction { this._reusePointDistance = options?.reusePointWithinMeters ?? 1 } - CreateChangeDescriptions(changes: Changes): ChangeDescription[] { + async CreateChangeDescriptions(changes: Changes): Promise { const id = changes.getNewID() const properties = { id: "node/" + id @@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmChangeAction { type: "way", id: this._snapOnto.id, changes: { - locations: locations, + coordinates: locations, nodes: ids } } diff --git a/Logic/Osm/Actions/DeleteAction.ts b/Logic/Osm/Actions/DeleteAction.ts index 178a26927..25542bf18 100644 --- a/Logic/Osm/Actions/DeleteAction.ts +++ b/Logic/Osm/Actions/DeleteAction.ts @@ -159,7 +159,7 @@ export default class DeleteAction { canBeDeleted: false, reason: t.notEnoughExperience }) - return; + return true; // unregister this caller! } if (!useTheInternet) { @@ -167,13 +167,14 @@ export default class DeleteAction { } // All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations - OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => { + OsmObject.DownloadReferencingRelations(id).then(rels => { hasRelations.setData(rels.length > 0) }) - OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => { + OsmObject.DownloadReferencingWays(id).then(ways => { hasWays.setData(ways.length > 0) }) + return true; // unregister to only run once }) diff --git a/Logic/Osm/Actions/OsmChangeAction.ts b/Logic/Osm/Actions/OsmChangeAction.ts index 0308ca8f6..784b192ba 100644 --- a/Logic/Osm/Actions/OsmChangeAction.ts +++ b/Logic/Osm/Actions/OsmChangeAction.ts @@ -17,7 +17,7 @@ export default abstract class OsmChangeAction { return this.CreateChangeDescriptions(changes) } - protected abstract CreateChangeDescriptions(changes: Changes): ChangeDescription[] + protected abstract CreateChangeDescriptions(changes: Changes): Promise } \ No newline at end of file diff --git a/Logic/Osm/Actions/RelationSplitHandler.ts b/Logic/Osm/Actions/RelationSplitHandler.ts new file mode 100644 index 000000000..dfca2d0fa --- /dev/null +++ b/Logic/Osm/Actions/RelationSplitHandler.ts @@ -0,0 +1,142 @@ +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {OsmObject, OsmRelation, OsmWay} from "../OsmObject"; + +export interface RelationSplitInput { + relation: OsmRelation, + originalWayId: number, + allWayIdsInOrder: number[], + originalNodes: number[], + allWaysNodesInOrder: number[][] +} + +/** + * When a way is split and this way is part of a relation, the relation should be updated too to have the new segment if relevant. + */ +export default class RelationSplitHandler extends OsmChangeAction { + + constructor(input: RelationSplitInput) { + super() + } + + async CreateChangeDescriptions(changes: Changes): Promise { + return []; + } + + +} + + +/** + * A simple strategy to split relations: + * -> Download the way members just before and just after the original way + * -> Make sure they are still aligned + * + * Note that the feature might appear multiple times. + */ +export class InPlaceReplacedmentRTSH extends OsmChangeAction { + private readonly _input: RelationSplitInput; + + constructor(input: RelationSplitInput) { + super(); + this._input = input; + } + + /** + * Returns which node should border the member at the given index + */ + private async targetNodeAt(i: number, first: boolean) { + const member = this._input.relation.members[i] + if (member === undefined) { + return undefined + } + if (member.type === "node") { + return member.ref + } + if (member.type === "way") { + const osmWay = await OsmObject.DownloadObjectAsync("way/" + member.ref) + const nodes = osmWay.nodes + if (first) { + return nodes[0] + } else { + return nodes[nodes.length - 1] + } + } + if (member.type === "relation") { + return undefined + } + return undefined; + } + + async CreateChangeDescriptions(changes: Changes): Promise { + + const wayId = this._input.originalWayId + const relation = this._input.relation + const members = relation.members + const originalNodes = this._input.originalNodes; + const firstNode = originalNodes[0] + const lastNode = originalNodes[originalNodes.length - 1] + const newMembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = [] + + for (let i = 0; i < members.length; i++) { + const member = members[i]; + if (member.type !== "way" || member.ref !== wayId) { + newMembers.push(member) + continue; + } + + const nodeIdBefore = await this.targetNodeAt(i - 1, false) + const nodeIdAfter = await this.targetNodeAt(i + 1, true) + + const firstNodeMatches = nodeIdBefore === undefined || nodeIdBefore === firstNode + const lastNodeMatches =nodeIdAfter === undefined || nodeIdAfter === lastNode + + if (firstNodeMatches && lastNodeMatches) { + // We have a classic situation, forward situation + for (const wId of this._input.allWayIdsInOrder) { + newMembers.push({ + ref: wId, + type: "way", + role: member.role + }) + } + continue; + } + + const firstNodeMatchesRev = nodeIdBefore === undefined || nodeIdBefore === lastNode + const lastNodeMatchesRev =nodeIdAfter === undefined || nodeIdAfter === firstNode + if (firstNodeMatchesRev || lastNodeMatchesRev) { + // We (probably) have a reversed situation, backward situation + for (let i1 = this._input.allWayIdsInOrder.length - 1; i1 >= 0; i1--){ + // Iterate BACKWARDS + const wId = this._input.allWayIdsInOrder[i1]; + newMembers.push({ + ref: wId, + type: "way", + role: member.role + }) + } + continue; + } + + // Euhm, allright... Something weird is going on, but let's not care too much + // Lets pretend this is forward going + for (const wId of this._input.allWayIdsInOrder) { + newMembers.push({ + ref: wId, + type: "way", + role: member.role + }) + } + + } + + return [{ + id: relation.id, + type: "relation", + changes: {members: newMembers} + }]; + } + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/RelationSplitlHandler.ts b/Logic/Osm/Actions/RelationSplitlHandler.ts deleted file mode 100644 index 591051fca..000000000 --- a/Logic/Osm/Actions/RelationSplitlHandler.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The logic to handle relations after a way within - */ -import OsmChangeAction from "./OsmChangeAction"; -import {Changes} from "../Changes"; -import {ChangeDescription} from "./ChangeDescription"; -import {OsmRelation} 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 index 8ee1731e4..cee912dca 100644 --- a/Logic/Osm/Actions/SplitAction.ts +++ b/Logic/Osm/Actions/SplitAction.ts @@ -1,9 +1,9 @@ -import {OsmRelation, OsmWay} from "../OsmObject"; +import {OsmObject, OsmWay} from "../OsmObject"; import {Changes} from "../Changes"; import {GeoOperations} from "../../GeoOperations"; import OsmChangeAction from "./OsmChangeAction"; import {ChangeDescription} from "./ChangeDescription"; -import RelationSplitlHandler from "./RelationSplitlHandler"; +import RelationSplitHandler from "./RelationSplitHandler"; interface SplitInfo { originalIndex?: number, // or negative for new elements @@ -12,17 +12,13 @@ interface SplitInfo { } export default class SplitAction extends OsmChangeAction { - private readonly roadObject: any; - private readonly osmWay: OsmWay; - private _partOf: OsmRelation[]; - private readonly _splitPoints: any[]; + private readonly wayId: string; + private readonly _splitPointsCoordinates: [number, number] []// lon, lat - constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) { + constructor(wayId: string, splitPointCoordinates: [number, number][]) { super() - this.osmWay = osmWay; - this.roadObject = wayGeoJson; - this._partOf = partOf; - this._splitPoints = splitPoints; + this.wayId = wayId; + this._splitPointsCoordinates = splitPointCoordinates } private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { @@ -42,26 +38,17 @@ export default class SplitAction extends OsmChangeAction { 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 - } - - + async CreateChangeDescriptions(changes: Changes): Promise { const self = this; - const partOf = this._partOf - const originalElement = this.osmWay - const originalNodes = this.osmWay.nodes; + const originalElement = await OsmObject.DownloadObjectAsync(this.wayId) + const originalNodes = originalElement.nodes; // First, calculate splitpoints and remove points close to one another - const splitInfo = self.CalculateSplitCoordinates(splitPoints) + const splitInfo = self.CalculateSplitCoordinates(originalElement) // 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: + // Lets change 'originalIndex' to the actual node id first (or assign a new id if needed): for (const element of splitInfo) { if (element.originalIndex >= 0) { element.originalIndex = originalElement.nodes[element.originalIndex] @@ -102,25 +89,30 @@ export default class SplitAction extends OsmChangeAction { }) } - const newWayIds: number[] = [] + // The ids of all the ways (including the original) + const allWayIdsInOrder: number[] = [] + + const allWaysNodesInOrder: number[][] = [] // Lets create OsmWays based on them for (const wayPart of wayParts) { let isOriginal = wayPart === longest if (isOriginal) { // We change the actual element! + const nodeIds = wayPart.map(p => p.originalIndex) changeDescription.push({ type: "way", id: originalElement.id, changes: { - locations: wayPart.map(p => p.lngLat), - nodes: wayPart.map(p => p.originalIndex) + coordinates: wayPart.map(p => p.lngLat), + nodes: nodeIds } }) + allWayIdsInOrder.push(originalElement.id) + allWaysNodesInOrder.push(nodeIds) } else { let id = changes.getNewID(); - newWayIds.push(id) - + // Copy the tags from the original object onto the new const kv = [] for (const k in originalElement.tags) { if (!originalElement.tags.hasOwnProperty(k)) { @@ -131,22 +123,35 @@ export default class SplitAction extends OsmChangeAction { } kv.push({k: k, v: originalElement.tags[k]}) } + const nodeIds = wayPart.map(p => p.originalIndex) changeDescription.push({ type: "way", id: id, tags: kv, changes: { - locations: wayPart.map(p => p.lngLat), - nodes: wayPart.map(p => p.originalIndex) + coordinates: wayPart.map(p => p.lngLat), + nodes: nodeIds } }) - } + allWayIdsInOrder.push(id) + allWaysNodesInOrder.push(nodeIds) + } } // 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)) + const relations = await OsmObject.DownloadReferencingRelations(this.wayId) + for (const relation of relations) { + const changDescrs = await new RelationSplitHandler({ + relation: relation, + allWayIdsInOrder: allWayIdsInOrder, + originalNodes: originalNodes, + allWaysNodesInOrder: allWaysNodesInOrder, + originalWayId: originalElement.id + }).CreateChangeDescriptions(changes) + changeDescription.push(...changDescrs) + } // And we have our objects! // Time to upload @@ -158,75 +163,96 @@ export default class SplitAction extends OsmChangeAction { * Calculates the actual points to split * If another point is closer then ~5m, we reuse that point */ - private CalculateSplitCoordinates( - splitPoints: any[], - toleranceInM = 5): SplitInfo[] { + private CalculateSplitCoordinates(osmWay: OsmWay, toleranceInM = 5): SplitInfo[] { + const wayGeoJson = osmWay.asGeoJson() + // Should be [lon, lat][] + const originalPoints = osmWay.coordinates.map(c => <[number, number]>c.reverse()) + const allPoints: { + coordinates: [number, number], + isSplitPoint: boolean, + originalIndex?: number, // Original index + dist: number, // Distance from the nearest point on the original line + location: number // Distance from the start of the way + }[] = this._splitPointsCoordinates.map(c => { + // From the turf.js docs: + // The properties object will contain three values: + // - `index`: closest point was found on nth line part, + // - `dist`: distance between pt and the closest point, + // `location`: distance along the line between start and the closest point. + let projected = GeoOperations.nearestPoint(wayGeoJson, c) + return ({ + coordinates: c, + isSplitPoint: true, + dist: projected.properties.dist, + location: projected.properties.location + }); + }) - 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 + // We have a bunch of coordinates here: [ [lon, lon], [lat, lon], ...] ... + // We project them onto the line (which should yield pretty much the same point and add them to allPoints 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) + let projected = GeoOperations.nearestPoint(wayGeoJson, originalPoint) + allPoints.push({ + coordinates: originalPoint, + isSplitPoint: false, + location: projected.properties.location, + originalIndex: i, + dist: projected.properties.dist + }) } // 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) + allPoints.sort((a, b) => a.location - b.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... - }*/ + for (let i = allPoints.length - 2; i >= 1; i--) { + // We 'merge' points with already existing nodes if they are close enough to avoid closeby elements + + // Note the loop bounds: we skip the first two and last two elements: + // The first and last element are always part of the original way and should be kept + // Furthermore, we run in reverse order as we'll delete elements on the go + + const point = allPoints[i] + if (point.originalIndex !== undefined) { + // We keep the original points + continue + } + if (point.dist * 1000 >= toleranceInM) { + // No need to remove this one + continue + } + + // At this point, 'dist' told us the point is pretty close to an already existing point. + // Lets see which (already existing) point is closer and mark it as splitpoint + const nextPoint = allPoints[i + 1] + const prevPoint = allPoints[i - 1] + const distToNext = nextPoint.location - point.location + const distToPrev = prevPoint.location - point.location + let closest = nextPoint + if (distToNext > distToPrev) { + closest = prevPoint + } + + // Ok, we have a closest point! + closest.isSplitPoint = true; + allPoints.splice(i, 1) + + } const splitInfo: SplitInfo[] = [] - let nextId = -1 + let nextId = -1 // Note: these IDs are overwritten later on, no need to use a global counter here for (const p of allPoints) { - let index = p.properties._original_index + let index = p.originalIndex if (index === undefined) { index = nextId; nextId--; } const splitInfoElement = { originalIndex: index, - lngLat: p.geometry.coordinates, - doSplit: p.properties._is_split_point + lngLat: p.coordinates, + doSplit: p.isSplitPoint } splitInfo.push(splitInfoElement) } diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index f36ffa4e0..aac1f08e9 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -21,12 +21,15 @@ export class Changes { */ public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); - public readonly pendingChanges = LocalStorageSource.GetParsed("pending-changes", []) + public readonly pendingChanges: UIEventSource = LocalStorageSource.GetParsed("pending-changes", []) + public readonly allChanges = new UIEventSource(undefined) private readonly isUploading = new UIEventSource(false); private readonly previouslyCreated: OsmObject[] = [] constructor() { + // We keep track of all changes just as well + this.allChanges.setData([...this.pendingChanges.data]) } private static createChangesetFor(csId: string, @@ -146,10 +149,13 @@ export class Changes { } public applyAction(action: OsmChangeAction) { - const changes = action.Perform(this) - console.log("Received changes:", changes) - this.pendingChanges.data.push(...changes); - this.pendingChanges.ping(); + action.Perform(this).then(changes => { + console.log("Received changes:", changes) + this.pendingChanges.data.push(...changes); + this.pendingChanges.ping(); + this.allChanges.data.push(...changes) + this.allChanges.ping() + }) } private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index a09c1b25c..a10ec8d0c 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -1,6 +1,7 @@ import {Utils} from "../../Utils"; import * as polygon_features from "../../assets/polygon-features.json"; import {UIEventSource} from "../UIEventSource"; +import {BBox} from "../GeoOperations"; export abstract class OsmObject { @@ -9,11 +10,12 @@ export abstract class OsmObject { protected static backendURL = OsmObject.defaultBackend; private static polygonFeatures = OsmObject.constructPolygonFeatures() private static objectCache = new Map>(); - private static referencingWaysCache = new Map>(); - private static referencingRelationsCache = new Map>(); private static historyCache = new Map>(); type: string; id: number; + /** + * The OSM tags as simple object + */ tags: {} = {}; version: number; public changed: boolean = false; @@ -37,7 +39,7 @@ export abstract class OsmObject { this.backendURL = url; } - static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource { + public static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource { let src: UIEventSource; if (OsmObject.objectCache.has(id)) { src = OsmObject.objectCache.get(id) @@ -47,80 +49,62 @@ export abstract class OsmObject { return src; } } else { - src = new UIEventSource(undefined) + src = UIEventSource.FromPromise(OsmObject.DownloadObjectAsync(id)) } + + OsmObject.objectCache.set(id, src); + return src; + } + + static async DownloadObjectAsync(id: string): Promise { const splitted = id.split("/"); const type = splitted[0]; const idN = Number(splitted[1]); if (idN < 0) { - return; + return undefined; } - - OsmObject.objectCache.set(id, src); - const newContinuation = (element: OsmObject) => { - src.setData(element) - } - switch (type) { case("node"): - new OsmNode(idN).Download(newContinuation); - break; + return await new OsmNode(idN).Download(); case("way"): - new OsmWay(idN).Download(newContinuation); - break; + return await new OsmWay(idN).Download(); case("relation"): - new OsmRelation(idN).Download(newContinuation); - break; + return await new OsmRelation(idN).Download(); default: - throw "Invalid object type:" + type + id; + throw ("Invalid object type:" + type + id); } - return src; } + /** * Downloads the ways that are using this node. * Beware: their geometry will be incomplete! */ - public static DownloadReferencingWays(id: string): UIEventSource { - if (OsmObject.referencingWaysCache.has(id)) { - return OsmObject.referencingWaysCache.get(id); - } - const waysSrc = new UIEventSource([]) - OsmObject.referencingWaysCache.set(id, waysSrc); - Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`) - .then(data => { - const ways = data.elements.map(wayInfo => { + public static DownloadReferencingWays(id: string): Promise { + return Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`).then( + data => { + return data.elements.map(wayInfo => { const way = new OsmWay(wayInfo.id) way.LoadData(wayInfo) return way }) - waysSrc.setData(ways) - }) - return waysSrc; + } + ) } /** * Downloads the relations that are using this feature. * Beware: their geometry will be incomplete! */ - public static DownloadReferencingRelations(id: string): UIEventSource { - if (OsmObject.referencingRelationsCache.has(id)) { - return OsmObject.referencingRelationsCache.get(id); - } - const relsSrc = new UIEventSource(undefined) - OsmObject.referencingRelationsCache.set(id, relsSrc); - Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`) - .then(data => { - const rels = data.elements.map(wayInfo => { - const rel = new OsmRelation(wayInfo.id) - rel.LoadData(wayInfo) - rel.SaveExtraData(wayInfo) - return rel - }) - relsSrc.setData(rels) - }) - return relsSrc; + public static async DownloadReferencingRelations(id: string): Promise { + const data = await Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`) + return data.elements.map(wayInfo => { + const rel = new OsmRelation(wayInfo.id) + rel.LoadData(wayInfo) + rel.SaveExtraData(wayInfo) + return rel + }) } public static DownloadHistory(id: string): UIEventSource { @@ -158,18 +142,11 @@ export abstract class OsmObject { } // bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds) - public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) { - const minlon = bounds[0][1] - const maxlon = bounds[1][1] - const minlat = bounds[1][0] - const maxlat = bounds[0][0]; - const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` - Utils.downloadJson(url).then(data => { - const elements: any[] = data.elements; - const objects = OsmObject.ParseObjects(elements) - callback(objects); - - }) + public static async LoadArea(bbox: BBox): Promise { + const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}` + const data = await Utils.downloadJson(url) + const elements: any[] = data.elements; + return OsmObject.ParseObjects(elements); } public static DownloadAll(neededIds, forceRefresh = true): UIEventSource { @@ -283,39 +260,34 @@ export abstract class OsmObject { return tags; } - Download(continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) { + /** + * Downloads the object, a full download for ways and relations + * @constructor + */ + async Download(): Promise { const self = this; const full = this.type !== "way" ? "" : "/full"; const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`; - Utils.downloadJson(url).then(data => { - + return await Utils.downloadJson(url).then(data => { const element = data.elements.pop(); - let nodes = [] if (self.type === "way" && data.elements.length >= 0) { nodes = OsmObject.ParseObjects(data.elements) } + if (self.type === "rellation") { + throw "We should save some extra data" + } + self.LoadData(element) self.SaveExtraData(element, nodes); - const meta = { - "_last_edit:contributor": element.user, - "_last_edit:contributor:uid": element.uid, - "_last_edit:changeset": element.changeset, - "_last_edit:timestamp": new Date(element.timestamp), - "_version_number": element.version - } - if (OsmObject.backendURL !== OsmObject.defaultBackend) { self.tags["_backend"] = OsmObject.backendURL - meta["_backend"] = OsmObject.backendURL; } - - continuation(self, meta); + return this; } ); - return this; } @@ -389,18 +361,10 @@ export class OsmNode extends OsmObject { } } -export interface OsmObjectMeta { - "_last_edit:contributor": string, - "_last_edit:contributor:uid": number, - "_last_edit:changeset": number, - "_last_edit:timestamp": Date, - "_version_number": number - -} - export class OsmWay extends OsmObject { nodes: number[]; + // The coordinates of the way, [lat, lon][] coordinates: [number, number][] = [] lat: number; lon: number; @@ -455,12 +419,16 @@ export class OsmWay extends OsmObject { } public asGeoJson() { + let coordinates : ([number, number][] | [number, number][][]) = this.coordinates.map(c => <[number, number]>c.reverse()); + if(this.isPolygon()){ + coordinates = [coordinates] + } return { "type": "Feature", "properties": this.tags, "geometry": { "type": this.isPolygon() ? "Polygon" : "LineString", - "coordinates": this.coordinates.map(c => [c[1], c[0]]) + "coordinates": coordinates } } } @@ -511,7 +479,7 @@ ${members}${tags} this.members = element.members; } - asGeoJson() { + asGeoJson(): any { throw "Not Implemented" } } \ No newline at end of file diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 705c6174a..42282ea2a 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -60,7 +60,12 @@ export class UIEventSource { run(); return source; - + } + + public static FromPromise(promise : Promise): UIEventSource{ + const src = new UIEventSource(undefined) + promise.then(d => src.setData(d)) + return src } /** @@ -191,6 +196,14 @@ export class UIEventSource { } }) } + + addCallbackD(callback: (data: T) => void) { + this.addCallback(data => { + if (data !== undefined && data !== null) { + return callback(data) + } + }) + } } export class UIEventSourceTools { diff --git a/Logic/Web/Hash.ts b/Logic/Web/Hash.ts index ff1df3616..0c463c689 100644 --- a/Logic/Web/Hash.ts +++ b/Logic/Web/Hash.ts @@ -9,7 +9,7 @@ export default class Hash { public static hash: UIEventSource = Hash.Get(); /** - * Gets the current string, including the pound sign + * Gets the current string, including the pound sign if there is any * @constructor */ public static Current(): string { diff --git a/Logic/Web/QueryParameters.ts b/Logic/Web/QueryParameters.ts index 065766e47..36ecc7680 100644 --- a/Logic/Web/QueryParameters.ts +++ b/Logic/Web/QueryParameters.ts @@ -127,7 +127,6 @@ export class QueryParameters { parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data)) } // Don't pollute the history every time a parameter changes - history.replaceState(null, "", "?" + parts.join("&") + Hash.Current()); } diff --git a/Models/Constants.ts b/Models/Constants.ts index 0b62cc0cc..b3f31280e 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.10.0"; + public static vNumber = "0.10.0-alpha-0"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { diff --git a/State.ts b/State.ts index 54af37fd4..105e7ef05 100644 --- a/State.ts +++ b/State.ts @@ -433,7 +433,7 @@ export default class State { }) .ping(); - new TitleHandler(this.layoutToUse, this.selectedElement, this.allElements); + new TitleHandler(this); } private static asFloat(source: UIEventSource): UIEventSource { diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 26909a00e..963d39db1 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -16,6 +16,11 @@ export interface MinimapOptions { lastClickLocation?: UIEventSource<{ lat: number, lon: number }> } +export interface MinimapObj { + readonly leafletMap: UIEventSource, + installBounds(factor: number | BBox, showRange?: boolean) : void +} + export default class Minimap { /** * A stub implementation. The actual implementation is injected later on, but only in the browser. @@ -25,6 +30,6 @@ export default class Minimap { /** * Construct a minimap */ - public static createMiniMap: (options: MinimapOptions) => BaseUIElement & { readonly leafletMap: UIEventSource } + public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) } \ No newline at end of file diff --git a/UI/Base/MinimapImplementation.ts b/UI/Base/MinimapImplementation.ts index a55bc7e38..5ebf74fc7 100644 --- a/UI/Base/MinimapImplementation.ts +++ b/UI/Base/MinimapImplementation.ts @@ -7,9 +7,9 @@ import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import {BBox} from "../../Logic/GeoOperations"; import * as L from "leaflet"; import {Map} from "leaflet"; -import Minimap, {MinimapOptions} from "./Minimap"; +import Minimap, {MinimapObj, MinimapOptions} from "./Minimap"; -export default class MinimapImplementation extends BaseUIElement { +export default class MinimapImplementation extends BaseUIElement implements MinimapObj { private static _nextId = 0; public readonly leafletMap: UIEventSource private readonly _id: string; @@ -44,6 +44,65 @@ export default class MinimapImplementation extends BaseUIElement { Minimap.createMiniMap = options => new MinimapImplementation(options) } + public installBounds(factor: number | BBox, showRange?: boolean) { + this.leafletMap.addCallbackD(leaflet => { + console.log("Installing max bounds") + + let bounds; + if (typeof factor === "number") { + bounds = leaflet.getBounds() + leaflet.setMaxBounds(bounds.pad(factor)) + }else{ + // @ts-ignore + leaflet.setMaxBounds(factor.toLeaflet()) + bounds = leaflet.getBounds() + } + + if (showRange) { + const data = { + type: "FeatureCollection", + features: [{ + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + bounds.getEast(), + bounds.getNorth() + ], + [ + bounds.getWest(), + bounds.getNorth() + ], + [ + bounds.getWest(), + bounds.getSouth() + ], + + [ + bounds.getEast(), + bounds.getSouth() + ], + [ + bounds.getEast(), + bounds.getNorth() + ] + ] + } + }] + } + // @ts-ignore + L.geoJSON(data, { + style: { + color: "#f00", + weight: 2, + opacity: 0.4 + } + }).addTo(leaflet) + } + }) + } + protected InnerConstructElement(): HTMLElement { const div = document.createElement("div") div.id = this._id; diff --git a/UI/BigComponents/FullWelcomePaneWithTabs.ts b/UI/BigComponents/FullWelcomePaneWithTabs.ts index eba8b60b3..a4a3e826a 100644 --- a/UI/BigComponents/FullWelcomePaneWithTabs.ts +++ b/UI/BigComponents/FullWelcomePaneWithTabs.ts @@ -65,7 +65,8 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen { const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)] const now = new Date() - const date = now.getFullYear()+"-"+Utils.TwoDigits(now.getMonth()+1)+"-"+Utils.TwoDigits(now.getDate()) + const lastWeek = new Date(now.getDate() - 7 * 24 * 60 * 60 * 1000) + const date = lastWeek.getFullYear()+"-"+Utils.TwoDigits(lastWeek.getMonth()+1)+"-"+Utils.TwoDigits(lastWeek.getDate()) const osmcha_link = `https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%22${date}%22%2C%22value%22%3A%222021-01-01%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D` tabsWithAboutMc.push({ diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 673f1d325..57f651e9a 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -20,6 +20,7 @@ import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; import PresetConfig from "../../Models/ThemeConfig/PresetConfig"; import FilteredLayer from "../../Models/FilteredLayer"; import {And} from "../../Logic/Tags/And"; +import {BBox} from "../../Logic/GeoOperations"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -39,8 +40,6 @@ interface PresetInfo extends PresetConfig { export default class SimpleAddUI extends Toggle { constructor(isShown: UIEventSource) { - - const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone()) .onClick(() => State.state.osmConnection.AttemptLogin()); const readYourMessages = new Combine([ @@ -52,7 +51,8 @@ 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 - + State.state.LastClickLocation.addCallback( _ => selectedPreset.setData(undefined)) + const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) @@ -82,11 +82,7 @@ export default class SimpleAddUI extends Toggle { return true; }) } - - }, - - () => { selectedPreset.setData(undefined) }) @@ -98,9 +94,9 @@ export default class SimpleAddUI extends Toggle { new Toggle( new Toggle( new Toggle( - Translations.t.general.add.stillLoading.Clone().SetClass("alert"), addUi, - State.state.featurePipeline.runningQuery + Translations.t.general.add.stillLoading.Clone().SetClass("alert"), + State.state.featurePipeline.somethingLoaded ), Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"), State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints) @@ -126,6 +122,7 @@ export default class SimpleAddUI extends Toggle { let location = State.state.LastClickLocation; let preciseInput: LocationInput = undefined if (preset.preciseInput !== undefined) { + // We uncouple the event source const locationSrc = new UIEventSource({ lat: location.data.lat, lon: location.data.lon, @@ -137,24 +134,48 @@ export default class SimpleAddUI extends Toggle { backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) } - let features: UIEventSource<{ feature: any }[]> = undefined + let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined + let mapBounds: UIEventSource = undefined if (preset.preciseInput.snapToLayers) { - // We have to snap to certain layers. - // Lets fetch tehm - const asSet = new Set(preset.preciseInput.snapToLayers) - features = State.state.featurePipeline.features.map(f => f.filter(feat => asSet.has(feat.feature._matching_layer_id))) + snapToFeatures = new UIEventSource<{ feature: any }[]>([]) + mapBounds = new UIEventSource(undefined) } + + const tags = TagUtils.KVtoProperties(preset.tags ?? []); preciseInput = new LocationInput({ mapBackground: backgroundLayer, centerLocation: locationSrc, - snapTo: features, + snapTo: snapToFeatures, snappedPointTags: tags, - maxSnapDistance: preset.preciseInput.maxSnapDistance - + maxSnapDistance: preset.preciseInput.maxSnapDistance, + bounds: mapBounds }) preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") + + + if (preset.preciseInput.snapToLayers) { + // We have to snap to certain layers. + // Lets fetch them + + let loadedBbox : BBox= undefined + mapBounds?.addCallbackAndRunD(bbox => { + if(loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)){ + // All is already there + // return; + } + + bbox = bbox.pad(2); + loadedBbox = bbox; + const allFeatures: {feature: any}[] = [] + preset.preciseInput.snapToLayers.forEach(layerId => { + State.state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature :f})))) + }) + snapToFeatures.setData(allFeatures) + }) + } + } diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index a18c887f8..a350e38d3 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -7,7 +7,7 @@ import Combine from "../Base/Combine"; import Svg from "../../Svg"; import State from "../../State"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; -import {GeoOperations} from "../../Logic/GeoOperations"; +import {BBox, GeoOperations} from "../../Logic/GeoOperations"; import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"; import * as L from "leaflet"; import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"; @@ -38,6 +38,8 @@ export default class LocationInput extends InputElement { private readonly _snappedPoint: UIEventSource private readonly _maxSnapDistance: number private readonly _snappedPointTags: any; + private readonly _bounds: UIEventSource; + public readonly _matching_layer: UIEventSource; constructor(options: { mapBackground?: UIEventSource, @@ -46,32 +48,33 @@ export default class LocationInput extends InputElement { snappedPointTags?: any, requiresSnapping?: boolean, centerLocation: UIEventSource, + bounds?: UIEventSource }) { super(); this._snapTo = options.snapTo?.map(features => features?.filter(feat => feat.feature.geometry.type !== "Point")) this._maxSnapDistance = options.maxSnapDistance this._centerLocation = options.centerLocation; this._snappedPointTags = options.snappedPointTags + this._bounds = options.bounds; if (this._snapTo === undefined) { this._value = this._centerLocation; } else { const self = this; - let matching_layer: UIEventSource if (self._snappedPointTags !== undefined) { - matching_layer = State.state.layoutToUse.map(layout => { + this._matching_layer = State.state.layoutToUse.map(layout => { for (const layer of layout.layers) { if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) { - return layer.id + return layer } } console.error("No matching layer found for tags ", self._snappedPointTags) - return "matchpoint" + return LocationInput.matchLayer }) } else { - matching_layer = new UIEventSource("matchpoint") + this._matching_layer = new UIEventSource(LocationInput.matchLayer) } this._snappedPoint = options.centerLocation.map(loc => { @@ -83,7 +86,7 @@ export default class LocationInput extends InputElement { let min = undefined; let matchedWay = undefined; - for (const feature of self._snapTo.data) { + for (const feature of self._snapTo.data ?? []) { const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat]) if (min === undefined) { min = nearestPointOnLine @@ -98,19 +101,17 @@ export default class LocationInput extends InputElement { } } - if (min.properties.dist * 1000 > self._maxSnapDistance) { + if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) { if (options.requiresSnapping) { return undefined } else { return { "type": "Feature", - "_matching_layer_id": matching_layer.data, "properties": options.snappedPointTags ?? min.properties, "geometry": {"type": "Point", "coordinates": [loc.lon, loc.lat]} } } } - min._matching_layer_id = matching_layer?.data ?? "matchpoint" min.properties = options.snappedPointTags ?? min.properties self.snappedOnto.setData(matchedWay) return min @@ -144,84 +145,40 @@ export default class LocationInput extends InputElement { location: this._centerLocation, background: this.mapBackground, attribution: this.mapBackground !== State.state.backgroundLayer, - lastClickLocation: clickLocation + lastClickLocation: clickLocation, + bounds: this._bounds } ) clickLocation.addCallbackAndRunD(location => this._centerLocation.setData(location)) - map.leafletMap.addCallbackAndRunD(leaflet => { - const bounds = leaflet.getBounds() - leaflet.setMaxBounds(bounds.pad(0.15)) - const data = { - type: "FeatureCollection", - features: [{ - "type": "Feature", - "geometry": { - "type": "LineString", - "coordinates": [ - [ - bounds.getEast(), - bounds.getNorth() - ], - [ - bounds.getWest(), - bounds.getNorth() - ], - [ - bounds.getWest(), - bounds.getSouth() - ], - [ - bounds.getEast(), - bounds.getSouth() - ], - [ - bounds.getEast(), - bounds.getNorth() - ] - ] - } - }] - } - // @ts-ignore - L.geoJSON(data, { - style: { - color: "#f00", - weight: 2, - opacity: 0.4 - } - }).addTo(leaflet) - }) + map.installBounds(0.15, true); if (this._snapTo !== undefined) { - + + // Show the lines to snap to + new ShowDataMultiLayer({ + features: new StaticFeatureSource(this._snapTo, true), + enablePopups: false, + zoomToFeatures: false, + leafletMap: map.leafletMap, + layers: State.state.filteredLayers + } + ) + // Show the central point const matchPoint = this._snappedPoint.map(loc => { if (loc === undefined) { return [] } return [{feature: loc}]; }) - if (this._snapTo) { - if (this._snappedPointTags === undefined) { - // No special tags - we show a default crosshair - new ShowDataLayer({ - features: new StaticFeatureSource(matchPoint), - enablePopups: false, - zoomToFeatures: false, - leafletMap: map.leafletMap, - layerToShow: LocationInput.matchLayer - }) - }else{ - new ShowDataMultiLayer({ - features: new StaticFeatureSource(matchPoint), - enablePopups: false, - zoomToFeatures: false, - leafletMap: map.leafletMap, - layers: State.state.filteredLayers - } - ) - } - } + new ShowDataLayer({ + features: new StaticFeatureSource(matchPoint, true), + enablePopups: false, + zoomToFeatures: false, + leafletMap: map.leafletMap, + layerToShow: this._matching_layer.data + }) + } this.mapBackground.map(layer => { diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index 694ef48cb..b7e33546b 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -130,7 +130,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { if (!userbadge) { return undefined } - return new Combine(editElements) + return new Combine(editElements).SetClass("flex flex-col") } )) renderings.push(editors) diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index ca0980592..01fb00d05 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -5,13 +5,12 @@ import {SubtleButton} from "../Base/SubtleButton"; import Minimap from "../Base/Minimap"; import State from "../../State"; import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"; -import {GeoOperations} from "../../Logic/GeoOperations"; +import {BBox, GeoOperations} from "../../Logic/GeoOperations"; import {LeafletMouseEvent} from "leaflet"; import Combine from "../Base/Combine"; import {Button} from "../Base/Button"; import Translations from "../i18n/Translations"; import SplitAction from "../../Logic/Osm/Actions/SplitAction"; -import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; import Title from "../Base/Title"; import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"; @@ -21,9 +20,12 @@ export default class SplitRoadWizard extends Toggle { private static splitLayerStyling = new LayerConfig({ id: "splitpositions", source: {osmTags: "_cutposition=yes"}, - icon: "./assets/svg/plus.svg" + icon: {render: "circle:white;./assets/svg/scissors.svg"}, + iconSize: {render: "30,30,center"}, }, "(BUILTIN) SplitRoadWizard.ts", true) + public dialogIsOpened: UIEventSource + /** * A UI Element used for splitting roads * @@ -40,30 +42,40 @@ export default class SplitRoadWizard extends Toggle { // Toggle variable between show split button and map const splitClicked = new UIEventSource(false); + // Load the road with given id on the minimap + const roadElement = State.state.allElements.ContainingFeatures.get(id) // Minimap on which you can select the points to be splitted - const miniMap = Minimap.createMiniMap({background: State.state.backgroundLayer, allowMoving: false}); - miniMap.SetStyle("width: 100%; height: 24rem;"); + const miniMap = Minimap.createMiniMap( + { + background: State.state.backgroundLayer, + allowMoving: true, + leafletOptions: { + minZoom: 14 + } + }); + miniMap.SetStyle("width: 100%; height: 24rem") + .SetClass("rounded-xl overflow-hidden"); + + miniMap.installBounds(BBox.get(roadElement)) // 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 ShowDataMultiLayer({ - features: new StaticFeatureSource(roadEventSource, true), - layers: State.state.filteredLayers, - leafletMap: miniMap.leafletMap, - enablePopups: false, - zoomToFeatures: true - }) new ShowDataLayer({ features: new StaticFeatureSource(splitPoints, true), leafletMap: miniMap.leafletMap, zoomToFeatures: false, enablePopups: false, - layerToShow: SplitRoadWizard.splitLayerStyling + layerToShow: SplitRoadWizard.splitLayerStyling + }) + + new ShowDataMultiLayer({ + features: new StaticFeatureSource([roadElement]), + layers: State.state.filteredLayers, + leafletMap: miniMap.leafletMap, + enablePopups: false, + zoomToFeatures: true }) /** @@ -72,12 +84,25 @@ export default class SplitRoadWizard extends Toggle { * @param coordinates Clicked location, [lon, lat] */ function onMapClick(coordinates) { + // First, we check if there is another, already existing point nearby + const points = splitPoints.data.map((f, i) => [f.feature, i]) + .filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5) + .map(p => p[1]) + .sort() + .reverse() + if (points.length > 0) { + for (const point of points) { + splitPoints.data.splice(point, 1) + } + splitPoints.ping() + return; + } + // 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); @@ -94,7 +119,7 @@ export default class SplitRoadWizard extends Toggle { })) // Toggle between splitmap - const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone()); + const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone().SetClass("text-lg font-bold")); splitButton.onClick( () => { splitClicked.setData(true) @@ -110,27 +135,9 @@ export default class SplitRoadWizard extends Toggle { // 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) + State.state.changes.applyAction(new SplitAction(id, splitPoints.data.map(ff => ff.feature.geometry.coordinates))) + }) - }, [partOfSrc]) - - - }); saveButton.SetClass("btn btn-primary mr-3"); const disabledSaveButton = new Button("Split", undefined); disabledSaveButton.SetClass("btn btn-disabled mr-3"); @@ -152,5 +159,6 @@ export default class SplitRoadWizard extends Toggle { mapView.SetClass("question") const confirm = new Toggle(mapView, splitToggle, splitClicked); super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit) + this.dialogIsOpened = splitClicked } } \ No newline at end of file diff --git a/UI/ShowDataLayer/ShowDataLayer.ts b/UI/ShowDataLayer/ShowDataLayer.ts index 73a68247c..cdda7889b 100644 --- a/UI/ShowDataLayer/ShowDataLayer.ts +++ b/UI/ShowDataLayer/ShowDataLayer.ts @@ -37,8 +37,8 @@ export default class ShowDataLayer { this._layerToShow = options.layerToShow; const self = this; - features.addCallback(() => self.update(options)); - options.leafletMap.addCallback(() => self.update(options)); + features.addCallback(_ => self.update(options)); + options.leafletMap.addCallback(_ => self.update(options)); this.update(options); @@ -83,13 +83,17 @@ export default class ShowDataLayer { mp.removeLayer(this.geoLayer); } + this.geoLayer= this.CreateGeojsonLayer() const allFeats = this._features.data; - this.geoLayer = this.CreateGeojsonLayer(); for (const feat of allFeats) { if (feat === undefined) { continue } - this.geoLayer.addData(feat); + try{ + this.geoLayer.addData(feat); + }catch(e){ + console.error("Could not add ", feat, "to the geojson layer in leaflet") + } } mp.addLayer(this.geoLayer) @@ -122,7 +126,8 @@ export default class ShowDataLayer { } const tagSource = feature.properties.id === undefined ? new UIEventSource(feature.properties) : State.state.allElements.getEventSourceById(feature.properties.id) - const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)); + const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0) + const style = layer.GenerateLeafletStyle(tagSource, clickable); const baseElement = style.icon.html; if (!this._enablePopups) { baseElement.SetStyle("cursor: initial !important") @@ -132,7 +137,7 @@ export default class ShowDataLayer { html: baseElement.ConstructElement(), className: style.icon.className, iconAnchor: style.icon.iconAnchor, - iconUrl: style.icon.iconUrl, + iconUrl: style.icon.iconUrl ?? "./assets/svg/bug.svg", popupAnchor: style.icon.popupAnchor, iconSize: style.icon.iconSize }) diff --git a/Utils.ts b/Utils.ts index 606dccb32..f3e82d574 100644 --- a/Utils.ts +++ b/Utils.ts @@ -8,7 +8,7 @@ export class Utils { * However, ts-node crashes when it sees 'document'. When running from console, we flag this and disable all code where document is needed. * This is a workaround and yet another hack */ - public static runningFromConsole = false; + public static runningFromConsole = typeof window === "undefined"; public static readonly assets_path = "./assets/svg/"; public static externalDownloadFunction: (url: string) => Promise; private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"] diff --git a/assets/layers/bench/bench.json b/assets/layers/bench/bench.json index a2cf274dd..7f2c93fcf 100644 --- a/assets/layers/bench/bench.json +++ b/assets/layers/bench/bench.json @@ -606,7 +606,7 @@ "fi": "Lisää uusi penkki", "pl": "Dodaj nową ławkę" }, - "presiceInput": { + "preciseInput": { "preferredBackground": "photo" } } diff --git a/assets/layers/crossings/crossings.json b/assets/layers/crossings/crossings.json index 876656d1f..0d033f2d7 100644 --- a/assets/layers/crossings/crossings.json +++ b/assets/layers/crossings/crossings.json @@ -97,6 +97,7 @@ } ], "tagRenderings": [ + "images", { "question": { "en": "What kind of crossing is this?", diff --git a/assets/layers/drinking_water/drinking_water.json b/assets/layers/drinking_water/drinking_water.json index f6acd494b..3645da855 100644 --- a/assets/layers/drinking_water/drinking_water.json +++ b/assets/layers/drinking_water/drinking_water.json @@ -49,7 +49,7 @@ }, "calculatedTags": [ "_closest_other_drinking_water_id=feat.closest('drinking_water')?.id", - "_closest_other_drinking_water_distance=Math.floor(feat.distanceTo(feat.closest('drinking_water')) * 1000)" + "_closest_other_drinking_water_distance=Math.floor(feat.distanceTo(feat.closest('drinking_water')).distance * 1000)" ], "minzoom": 13, "wayHandling": 1, diff --git a/assets/svg/license_info.json b/assets/svg/license_info.json index f8e8840db..0bee39135 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -1153,10 +1153,10 @@ "path": "scissors.svg", "license": "CC-BY 3.0", "authors": [ - "The noun project - Basith Ibrahi" + "The noun project - Icons8" ], "sources": [ - "https://commons.wikimedia.org/wiki/File:Media-floppy.svg" + "https://commons.wikimedia.org/wiki/File:Scissors_-_The_Noun_Project.svg" ] }, { @@ -1377,4 +1377,4 @@ "https://www.wikipedia.org/" ] } -] \ No newline at end of file +] diff --git a/assets/svg/plus.svg b/assets/svg/plus.svg index dcff18237..385b17152 100644 --- a/assets/svg/plus.svg +++ b/assets/svg/plus.svg @@ -1,20 +1,59 @@ - - - + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="97.287025" + height="97.287033" + viewBox="0 0 97.287025 97.287033" + version="1.1" + id="svg132" + style="fill:none" + sodipodi:docname="plus.svg" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"> + + + + image/svg+xml + + + + + + + + diff --git a/assets/svg/scissors.svg b/assets/svg/scissors.svg index 1b4c786d4..7c8df5cd0 100644 --- a/assets/svg/scissors.svg +++ b/assets/svg/scissors.svg @@ -1,69 +1 @@ - -image/svg+xml - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/assets/themes/cycle_infra/cycle_infra.json b/assets/themes/cycle_infra/cycle_infra.json index b91804ea9..7446095d9 100644 --- a/assets/themes/cycle_infra/cycle_infra.json +++ b/assets/themes/cycle_infra/cycle_infra.json @@ -24,7 +24,7 @@ "startLat": 51, "startLon": 3.75, "startZoom": 11, - "widenFactor": 0.05, + "widenFactor": 1, "socialImage": "./assets/themes/cycle_infra/cycle-infra.svg", "enableDownload": true, "layers": [ diff --git a/assets/themes/cyclestreets/cyclestreets.json b/assets/themes/cyclestreets/cyclestreets.json index 8b607ea37..e9d9b4e95 100644 --- a/assets/themes/cyclestreets/cyclestreets.json +++ b/assets/themes/cyclestreets/cyclestreets.json @@ -68,7 +68,7 @@ ] }, "then": { - "nl": "Deze straat i een fietsstraat", + "nl": "Deze straat is een fietsstraat", "en": "This street is a cyclestreet", "ja": "この通りはcyclestreetだ", "nb_NO": "Denne gaten er en sykkelvei" @@ -276,8 +276,10 @@ }, "tagRenderings": [ "images" - ], - "allowSplit": false + ] } - ] + ], + "overrideAll": { + "allowSplit": true + } } \ No newline at end of file diff --git a/css/mobile.css b/css/mobile.css index f863483dd..a0b6cb896 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -39,7 +39,7 @@ Contains tweaks for small screens } @media only screen and (max-width: 768px) { - .leaflet-control-attribution { + #leafletDiv .leaflet-control-attribution { display: none; } diff --git a/scripts/CycleHighwayFix.ts b/scripts/CycleHighwayFix.ts index 94a3db1b4..b6e4509d6 100644 --- a/scripts/CycleHighwayFix.ts +++ b/scripts/CycleHighwayFix.ts @@ -10,7 +10,7 @@ writeFileSync("cycleHighwayFix.osc", "", "utf8") const ids = JSON.parse(readFileSync("export.geojson", "utf-8")).features.map(f => f.properties["@id"]) console.log(ids) -ids.map(id => OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(relations => { +ids.map(id => OsmObject.DownloadReferencingRelations(id).then(relations => { console.log(relations) const changeparts = relations.filter(relation => relation.tags["cycle_highway"] == "yes" && relation.tags["note:state"] == undefined) .map(relation => { @@ -18,5 +18,4 @@ ids.map(id => OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rela return relation.ChangesetXML(undefined) }) appendFileSync("cycleHighwayFix.osc", changeparts.join("\n"), "utf8") - return true; })) \ No newline at end of file diff --git a/scripts/ScriptUtils.ts b/scripts/ScriptUtils.ts index d150215e7..3551ba5f8 100644 --- a/scripts/ScriptUtils.ts +++ b/scripts/ScriptUtils.ts @@ -56,7 +56,7 @@ export default class ScriptUtils { const headers = options?.headers ?? {} headers.accept = "application/json" - + console.log("Fetching", url) const urlObj = new URL(url) https.get({ host: urlObj.host, @@ -75,6 +75,7 @@ export default class ScriptUtils { res.addListener('end', function () { const result = parts.join("") try { + console.log("Fetched", result) resolve(JSON.parse(result)) } catch (e) { console.error("Could not parse the following as JSON:", result) diff --git a/scripts/generateCache.ts b/scripts/generateCache.ts index c894d5442..5a8377125 100644 --- a/scripts/generateCache.ts +++ b/scripts/generateCache.ts @@ -201,7 +201,10 @@ function postProcess(allFeatures: FeatureSource, theme: LayoutConfig, relationsT } }, layer, - false); + { + includeDates: false, + includeNonDates: true + }); const createdTiles = [] // At this point, we have all the features of the entire area. diff --git a/test.ts b/test.ts index c34a6c303..86a1c8f6e 100644 --- a/test.ts +++ b/test.ts @@ -1,16 +1,50 @@ -const client_token = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" +import SplitRoadWizard from "./UI/Popup/SplitRoadWizard"; +import State from "./State"; +import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; +import MinimapImplementation from "./UI/Base/MinimapImplementation"; +import {UIEventSource} from "./Logic/UIEventSource"; +import FilteredLayer from "./Models/FilteredLayer"; +import {And} from "./Logic/Tags/And"; -const image_id = '196804715753265'; -const api_url = 'https://graph.mapillary.com/' + image_id + '?fields=thumb_1024_url&&access_token=' + client_token; -fetch(api_url, - { - headers: {'Authorization': 'OAuth ' + client_token} +const layout = AllKnownLayouts.allKnownLayouts.get("cyclestreets") +State.state = new State(layout) +MinimapImplementation.initialize() +const feature = { + "type": "Feature", + "properties": { + id: "way/1234", + "highway":"residential", + "cyclestreet":"yes" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 3.2207107543945312, + 51.21978729870313 + ], + [ + 3.2198524475097656, + 51.21899435057332 + ], + [ + 3.2155394554138184, + 51.21617188199714 + ] + ] } -).then(response => { - return response.json() -}).then( - json => { - const thumbnail_url = json["thumb_1024"] - console.log(thumbnail_url) - } -) \ No newline at end of file +} + +State.state.allElements.addOrGetElement(feature) +State.state.filteredLayers = new UIEventSource( + layout.layers.map( l => ({ + layerDef :l, + appliedFilters: new UIEventSource(undefined), + isDisplayed: new UIEventSource(undefined) + })) +) + +const splitroad = new SplitRoadWizard("way/1234") + splitroad.AttachTo("maindiv") + +splitroad.dialogIsOpened.setData(true) diff --git a/test/ImageAttribution.spec.ts b/test/ImageAttribution.spec.ts index c83d9efa1..ac2109644 100644 --- a/test/ImageAttribution.spec.ts +++ b/test/ImageAttribution.spec.ts @@ -10,7 +10,7 @@ Utils.runningFromConsole = true; export default class ImageAttributionSpec extends T { constructor() { super( - "ImageAttribution Tests", [ + "imageattribution", [ [ "Should find all the images", () => { diff --git a/test/ImageSearcher.spec.ts b/test/ImageSearcher.spec.ts index 49254fd62..9b3d713ac 100644 --- a/test/ImageSearcher.spec.ts +++ b/test/ImageSearcher.spec.ts @@ -8,7 +8,7 @@ Utils.runningFromConsole = true; export default class ImageSearcherSpec extends T { constructor() { - super("ImageSearcher", [ + super("imagesearcher", [ [ "Should find images", () => { diff --git a/test/OsmConnection.spec.ts b/test/OsmConnection.spec.ts index a2f7cca47..0b6cf3b47 100644 --- a/test/OsmConnection.spec.ts +++ b/test/OsmConnection.spec.ts @@ -12,7 +12,7 @@ export default class OsmConnectionSpec extends T { private static _osm_token = "LJFmv2nUicSNmBNsFeyCHx5KKx6Aiesx8pXPbX4n" constructor() { - super("OsmConnectionSpec-test", [ + super("osmconnection", [ ["login on dev", () => { const osmConn = new OsmConnection(false, false, diff --git a/test/OsmObject.spec.ts b/test/OsmObject.spec.ts index 563c5ade6..e26dae4d6 100644 --- a/test/OsmObject.spec.ts +++ b/test/OsmObject.spec.ts @@ -1,27 +1,26 @@ import T from "./TestHelper"; import {OsmObject} from "../Logic/Osm/OsmObject"; import ScriptUtils from "../scripts/ScriptUtils"; +import {UIEventSource} from "../Logic/UIEventSource"; export default class OsmObjectSpec extends T { + private static async runTest(){ + const ways = await OsmObject.DownloadReferencingWays("node/1124134958") + if(ways === undefined){ + throw "Did not get the ways" + } + if (ways.length !== 4) { + throw "Expected 4 ways but got "+ways.length + } + } + + constructor() { - super("OsmObject", [ + super("osmobject", [ [ "Download referencing ways", () => { - let downloaded = false; - OsmObject.DownloadReferencingWays("node/1124134958").addCallbackAndRunD(ways => { - downloaded = true; - console.log(ways) - }) - let timeout = 10 - while (!downloaded && timeout >= 0) { - ScriptUtils.sleep(1000) - - timeout--; - } - if (!downloaded) { - throw "Timeout: referencing ways not found" - } + OsmObjectSpec.runTest().then(_ => console.log("Referencing ways test is done (async)")) } ] diff --git a/test/RelationSplitHandler.spec.ts b/test/RelationSplitHandler.spec.ts new file mode 100644 index 000000000..8a5174da3 --- /dev/null +++ b/test/RelationSplitHandler.spec.ts @@ -0,0 +1,66 @@ +import T from "./TestHelper"; +import {InPlaceReplacedmentRTSH} from "../Logic/Osm/Actions/RelationSplitHandler"; +import {OsmObject, OsmRelation} from "../Logic/Osm/OsmObject"; +import {Changes} from "../Logic/Osm/Changes"; +import {equal} from "assert"; + +export default class RelationSplitHandlerSpec extends T { + + private static async split(): Promise { + // Lets mimick a split action of https://www.openstreetmap.org/way/295132739 + + const relation: OsmRelation = await OsmObject.DownloadObjectAsync("relation/9572808") + const originalNodeIds = [5273988967, + 170497153, + 1507524582, + 4524321710, + 170497155, + 170497157, + 170497158, + 3208166179, + 1507524610, + 170497160, + 3208166178, + 1507524573, + 1575932830, + 6448669326] + + const withSplit = [[5273988967, + 170497153, + 1507524582, + 4524321710, + 170497155, + 170497157, + 170497158], + [ + 3208166179, + 1507524610, + 170497160, + 3208166178, + 1507524573, + 1575932830, + 6448669326]] + + const splitter = new InPlaceReplacedmentRTSH( + { + relation: relation, + originalWayId: 295132739, + allWayIdsInOrder: [295132739, -1], + originalNodes: originalNodeIds, + allWaysNodesInOrder: withSplit + }) + const changeDescription = await splitter.CreateChangeDescriptions(new Changes()) + const allIds = changeDescription[0].changes["members"].map(m => m.ref).join(",") + const expected = "687866206,295132739,-1,690497698" + if (allIds.indexOf(expected) < 0) { + throw "Invalid order or the split ways. If this suddenly breaks, the parent relation at https://osm.org/relation/9572808 has probably changed and the test must be updated" + } + } + + constructor() { + super("relationsplithandler", [ + ["split 295132739", + () => RelationSplitHandlerSpec.split().then(_ => console.log("OK"))] + ]); + } +} \ No newline at end of file diff --git a/test/Tag.spec.ts b/test/Tag.spec.ts index 96afa16fc..a85d3c58d 100644 --- a/test/Tag.spec.ts +++ b/test/Tag.spec.ts @@ -16,7 +16,7 @@ Utils.runningFromConsole = true; export default class TagSpec extends T { constructor() { - super("Tags", [ + super("tag", [ ["Tag replacement works in translation", () => { const tr = new Translation({ "en": "Test {key} abc" diff --git a/test/TestAll.ts b/test/TestAll.ts index 8eff8b56d..bd0ec5657 100644 --- a/test/TestAll.ts +++ b/test/TestAll.ts @@ -1,36 +1,16 @@ -import {Utils} from "../Utils"; -Utils.runningFromConsole = true; import TagSpec from "./Tag.spec"; import ImageAttributionSpec from "./ImageAttribution.spec"; import GeoOperationsSpec from "./GeoOperations.spec"; import ImageSearcherSpec from "./ImageSearcher.spec"; import ThemeSpec from "./Theme.spec"; import UtilsSpec from "./Utils.spec"; -import OsmConnectionSpec from "./OsmConnection.spec"; -import T from "./TestHelper"; -import {FixedUiElement} from "../UI/Base/FixedUiElement"; -import Combine from "../UI/Base/Combine"; import OsmObjectSpec from "./OsmObject.spec"; import ScriptUtils from "../scripts/ScriptUtils"; import UnitsSpec from "./Units.spec"; +import RelationSplitHandlerSpec from "./RelationSplitHandler.spec"; -export default class TestAll { - private needsBrowserTests: T[] = [new OsmConnectionSpec()] - - public testAll(): void { - Utils.runningFromConsole = false - for (const test of this.needsBrowserTests.concat(allTests)) { - if (test.failures.length > 0) { - new Combine([new FixedUiElement("TEST FAILED: " + test.name).SetStyle("background: red"), - ...test.failures]) - .AttachTo("maindiv") - throw "Some test failed" - } - } - } -} ScriptUtils.fixUtils() const allTests = [ new OsmObjectSpec(), @@ -40,12 +20,34 @@ const allTests = [ new ImageSearcherSpec(), new ThemeSpec(), new UtilsSpec(), - new UnitsSpec() + new UnitsSpec(), + new RelationSplitHandlerSpec() ] +let args = [...process.argv] +args.splice(0, 2) +args = args.map(a => a.toLowerCase()) -for (const test of allTests) { - if (test.failures.length > 0) { - throw "Some test failed: " + test.failures.join(", ") +const allFailures: { testsuite: string, name: string, msg: string } [] = [] +let testsToRun = allTests +if (args.length > 0) { + testsToRun = allTests.filter(t => args.indexOf(t.name) >= 0) +} + +if(testsToRun.length == 0){ + throw "No tests found" +} + +for (let i = 0; i < testsToRun.length; i++){ + const test = testsToRun[i]; + ScriptUtils.erasableLog(" Running test", i, "/", allTests.length) + allFailures.push(...(test.Run() ?? [])) + +} +if (allFailures.length > 0) { + for (const failure of allFailures) { + console.error(" !! " + failure.testsuite + "." + failure.name + " failed due to: " + failure.msg) } -} \ No newline at end of file + throw "Some test failed" +} +console.log("All tests successful: ", allTests.map(t => t.name).join(", ")) diff --git a/test/TestHelper.ts b/test/TestHelper.ts index f080c0aa6..971b64396 100644 --- a/test/TestHelper.ts +++ b/test/TestHelper.ts @@ -1,23 +1,31 @@ export default class T { - public readonly failures: string[] = [] public readonly name: string; + private readonly _tests: [string, (() => void)][]; constructor(testsuite: string, tests: [string, () => void][]) { this.name = testsuite - for (const [name, test] of tests) { + this._tests = tests; + } + + /** + * RUns the test, returns the error messages. + * Returns an empty list if successful + * @constructor + */ + public Run() : ({testsuite: string, name: string, msg: string} []) { + const failures: {testsuite: string, name: string, msg: string} [] = [] + for (const [name, test] of this._tests) { try { test(); } catch (e) { - this.failures.push(name); - console.warn(`>>> Failed test in ${this.name}: ${name}because${e}`); + failures.push({testsuite: this.name, name: name, msg: ""+e}); } } - if (this.failures.length == 0) { - console.log(`All tests of ${testsuite} done!`) + if (failures.length == 0) { + return undefined } else { - console.warn(this.failures.length, `tests of ${testsuite} failed :(`) - console.log("Failed tests: ", this.failures.join(",")) + return failures } } diff --git a/test/Theme.spec.ts b/test/Theme.spec.ts index d4b071d6e..cb6cc2756 100644 --- a/test/Theme.spec.ts +++ b/test/Theme.spec.ts @@ -8,7 +8,7 @@ Utils.runningFromConsole = true; export default class ThemeSpec extends T { constructor() { - super("Theme tests", + super("theme", [ ["Nested overrides work", () => { diff --git a/test/Units.spec.ts b/test/Units.spec.ts index 3d316535a..e0ffdee10 100644 --- a/test/Units.spec.ts +++ b/test/Units.spec.ts @@ -6,7 +6,7 @@ import {Denomination} from "../Models/Denomination"; export default class UnitsSpec extends T { constructor() { - super("Units", [ + super("units", [ ["Simple canonicalize", () => { const unit = new Denomination({ diff --git a/test/Utils.spec.ts b/test/Utils.spec.ts index 0975621e1..fd630257d 100644 --- a/test/Utils.spec.ts +++ b/test/Utils.spec.ts @@ -39,7 +39,7 @@ export default class UtilsSpec extends T { } constructor() { - super("Utils", [ + super("utils", [ ["Sort object keys", () => { const o = { x: 'x',