diff --git a/Logic/FeatureSource/Sources/GeoJsonSource.ts b/Logic/FeatureSource/Sources/GeoJsonSource.ts index df7270255..ed9bcb61c 100644 --- a/Logic/FeatureSource/Sources/GeoJsonSource.ts +++ b/Logic/FeatureSource/Sources/GeoJsonSource.ts @@ -13,6 +13,7 @@ import {GeoOperations} from "../../GeoOperations"; export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; + public readonly state = new UIEventSource(undefined) public readonly name; public readonly isOsmCache: boolean public readonly layer: FilteredLayer; @@ -80,6 +81,7 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { const self = this; Utils.downloadJson(url) .then(json => { + self.state.setData("loaded") if (json.features === undefined || json.features === null) { return; } @@ -135,7 +137,10 @@ export default class GeoJsonSource implements FeatureSourceForLayer, Tiled { eventSource.setData(eventSource.data.concat(newFeatures)) - }).catch(msg => console.debug("Could not load geojson layer", url, "due to", msg)) + }).catch(msg => { + console.debug("Could not load geojson layer", url, "due to", msg); + self.state.setData({error: msg}) + }) } } diff --git a/Logic/FeatureSource/TiledFeatureSource/DynamicGeoJsonTileSource.ts b/Logic/FeatureSource/TiledFeatureSource/DynamicGeoJsonTileSource.ts index 0c37711ce..65117fe3f 100644 --- a/Logic/FeatureSource/TiledFeatureSource/DynamicGeoJsonTileSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/DynamicGeoJsonTileSource.ts @@ -1,7 +1,6 @@ import FilteredLayer from "../../../Models/FilteredLayer"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import {UIEventSource} from "../../UIEventSource"; -import Loc from "../../../Models/Loc"; import DynamicTileSource from "./DynamicTileSource"; import {Utils} from "../../../Utils"; import GeoJsonSource from "../Sources/GeoJsonSource"; @@ -14,7 +13,7 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource { constructor(layer: FilteredLayer, registerLayer: (layer: FeatureSourceForLayer & Tiled) => void, state: { - locationControl: UIEventSource + locationControl?: UIEventSource<{zoom?: number}> currentBounds: UIEventSource }) { const source = layer.layerDef.source diff --git a/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts b/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts index b97468730..99209b309 100644 --- a/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts @@ -1,7 +1,6 @@ import FilteredLayer from "../../../Models/FilteredLayer"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import {UIEventSource} from "../../UIEventSource"; -import Loc from "../../../Models/Loc"; import TileHierarchy from "./TileHierarchy"; import {Tiles} from "../../../Models/TileRange"; import {BBox} from "../../BBox"; @@ -19,30 +18,29 @@ export default class DynamicTileSource implements TileHierarchy (FeatureSourceForLayer & Tiled), state: { currentBounds: UIEventSource; - locationControl: UIEventSource + locationControl?: UIEventSource<{zoom?: number}> } ) { const self = this; this.loadedTiles = new Map() - const neededTiles = state.locationControl.map( - location => { + const neededTiles = state.currentBounds.map( + bounds => { + if (bounds === undefined) { + // We'll retry later + return undefined + } + if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) { // No need to download! - the layer is disabled return undefined; } - if (location.zoom < layer.layerDef.minzoom) { + if (state.locationControl?.data?.zoom !== undefined && state.locationControl.data.zoom < layer.layerDef.minzoom) { // No need to download! - the layer is disabled return undefined; } - // Yup, this is cheating to just get the bounds here - const bounds = state.currentBounds.data - if (bounds === undefined) { - // We'll retry later - return undefined - } const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest()) if (tileRange.total > 10000) { console.error("Got a really big tilerange, bounds and location might be out of sync") @@ -55,7 +53,7 @@ export default class DynamicTileSource implements TileHierarchy { console.log("Tiled geojson source ", layer.layerDef.id, " needs", neededIndexes) diff --git a/Logic/Web/IdbLocalStorage.ts b/Logic/Web/IdbLocalStorage.ts index 1a7ac4544..78930e011 100644 --- a/Logic/Web/IdbLocalStorage.ts +++ b/Logic/Web/IdbLocalStorage.ts @@ -8,18 +8,24 @@ import {Utils} from "../../Utils"; export class IdbLocalStorage { - public static Get(key: string, options?: { defaultValue?: T, whenLoaded?: (t: T) => void }): UIEventSource { + public static Get(key: string, options?: { defaultValue?: T, whenLoaded?: (t: T | null) => void }): UIEventSource { const src = new UIEventSource(options?.defaultValue, "idb-local-storage:" + key) if (Utils.runningFromConsole) { return src; } + src.addCallback(v => idb.set(key, v)) + idb.get(key).then(v => { src.setData(v ?? options?.defaultValue); if (options?.whenLoaded !== undefined) { options?.whenLoaded(v) } + }).catch(err => { + console.warn("Loading from local storage failed due to", err) + if (options?.whenLoaded !== undefined) { + options?.whenLoaded(null) + } }) - src.addCallback(v => idb.set(key, v)) return src; } diff --git a/UI/ImportFlow/AskMetadata.ts b/UI/ImportFlow/AskMetadata.ts index 46562be4c..203d44b3c 100644 --- a/UI/ImportFlow/AskMetadata.ts +++ b/UI/ImportFlow/AskMetadata.ts @@ -9,6 +9,14 @@ import {DropDown} from "../Input/DropDown"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import BaseUIElement from "../BaseUIElement"; import {FixedUiElement} from "../Base/FixedUiElement"; +import {RadioButton} from "../Input/RadioButton"; +import {FixedInputElement} from "../Input/FixedInputElement"; +import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; +import {InputElement} from "../Input/InputElement"; +import Img from "../Base/Img"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import {And} from "../../Logic/Tags/And"; +import Toggleable from "../Base/Toggleable"; export class AskMetadata extends Combine implements FlowStep<{ features: any[], @@ -27,14 +35,14 @@ export class AskMetadata extends Combine implements FlowStep<{ }>; public readonly IsValid: UIEventSource; - constructor(params: ({ features: any[], layer: LayerConfig })) { + constructor(params: ({ features: any[], theme: string })) { const introduction = ValidatedTextField.ForType("text").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-introduction-text"), inputStyle: "width: 100%" }) - const wikilink = ValidatedTextField.ForType("string").ConstructInputElement({ + const wikilink = ValidatedTextField.ForType("url").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-wikilink-text"), inputStyle: "width: 100%" }) @@ -44,26 +52,6 @@ export class AskMetadata extends Combine implements FlowStep<{ inputStyle: "width: 100%" }) - let options: { value: string, shown: BaseUIElement }[] = AllKnownLayouts.layoutsList - .filter(th => th.layers.some(l => l.id === params.layer.id)) - .filter(th => th.id !== "personal") - .map(th => ({ - value: th.id, - shown: th.title - })) - - options.splice(0, 0, { - shown: new FixedUiElement("Select a theme"), - value: undefined - }) - - const theme = new DropDown("Which theme should be linked in the note?", options) - - ValidatedTextField.ForType("string").ConstructInputElement({ - value: LocalStorageSource.Get("import-helper-theme-text"), - inputStyle: "width: 100%" - }) - super([ new Title("Set metadata"), "Before adding " + params.features.length + " notes, please provide some extra information.", @@ -73,7 +61,20 @@ export class AskMetadata extends Combine implements FlowStep<{ source.SetClass("w-full border border-black"), "On what wikipage can one find more information about this import?", wikilink.SetClass("w-full border border-black"), - theme + new VariableUiElement(wikilink.GetValue().map(wikilink => { + try{ + const url = new URL(wikilink) + if(url.hostname.toLowerCase() !== "wiki.openstreetmap.org"){ + return new FixedUiElement("Expected a link to wiki.openstreetmap.org").SetClass("alert"); + } + + if(url.pathname.toLowerCase() === "/wiki/main_page"){ + return new FixedUiElement("Nope, the home page isn't allowed either. Enter the URL of a proper wikipage documenting your import").SetClass("alert"); + } + }catch(e){ + return new FixedUiElement("Not a valid URL").SetClass("alert") + } + })) ]); this.SetClass("flex flex-col") @@ -83,16 +84,30 @@ export class AskMetadata extends Combine implements FlowStep<{ wikilink: wikilink.GetValue().data, intro, source: source.GetValue().data, - theme: theme.GetValue().data - + theme: params.theme } - }, [wikilink.GetValue(), source.GetValue(), theme.GetValue()]) + }, [wikilink.GetValue(), source.GetValue()]) this.IsValid = this.Value.map(obj => { if (obj === undefined) { return false; } - return obj.theme !== undefined && obj.features !== undefined && obj.wikilink !== undefined && obj.intro !== undefined && obj.source !== undefined; + if ([ obj.features, obj.intro, obj.wikilink, obj.source].some(v => v === undefined)){ + console.log("Obj is", obj) + return false; + } + + try{ + const url = new URL(obj.wikilink) + if(url.hostname.toLowerCase() !== "wiki.openstreetmap.org"){ + return false; + } + }catch(e){ + return false + } + + return true; + }) } diff --git a/UI/ImportFlow/CompareToAlreadyExistingNotes.ts b/UI/ImportFlow/CompareToAlreadyExistingNotes.ts index 6054e3434..868504cde 100644 --- a/UI/ImportFlow/CompareToAlreadyExistingNotes.ts +++ b/UI/ImportFlow/CompareToAlreadyExistingNotes.ts @@ -3,7 +3,6 @@ import {FlowStep} from "./FlowStep"; import {BBox} from "../../Logic/BBox"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {DesugaringContext} from "../../Models/ThemeConfig/Conversion/Conversion"; import CreateNoteImportLayer from "../../Models/ThemeConfig/Conversion/CreateNoteImportLayer"; import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; import GeoJsonSource from "../../Logic/FeatureSource/Sources/GeoJsonSource"; @@ -17,7 +16,6 @@ import {ImportUtils} from "./ImportUtils"; import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json"; import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; import Title from "../Base/Title"; -import Toggle from "../Input/Toggle"; import Loading from "../Base/Loading"; import {FixedUiElement} from "../Base/FixedUiElement"; import {VariableUiElement} from "../Base/VariableUIElement"; @@ -27,19 +25,19 @@ import {LayerConfigJson} from "../../Models/ThemeConfig/Json/LayerConfigJson"; /** * Filters out points for which the import-note already exists, to prevent duplicates */ -export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, geojson: any }> { +export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, features: any[], theme: string }> { public IsValid: UIEventSource - public Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, geojson: any }> + public Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, features: any[] , theme: string}> - constructor(state, params: { bbox: BBox, layer: LayerConfig, geojson: { features: any[] } }) { + constructor(state, params: { bbox: BBox, layer: LayerConfig, features: any[], theme: string }) { const layerConfig = known_layers.layers.filter(l => l.id === params.layer.id)[0] if (layerConfig === undefined) { console.error("WEIRD: layer not found in the builtin layer overview") } - const importLayerJson = new CreateNoteImportLayer(365).convertStrict(layerConfig, "CompareToAlreadyExistingNotes") + const importLayerJson = new CreateNoteImportLayer(150).convertStrict(layerConfig, "CompareToAlreadyExistingNotes") const importLayer = new LayerConfig(importLayerJson, "import-layer-dynamic") const flayer: FilteredLayer = { appliedFilters: new UIEventSource>(new Map()), @@ -47,12 +45,13 @@ export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ layerDef: importLayer } const allNotesWithinBbox = new GeoJsonSource(flayer, params.bbox.padAbsolute(0.0001)) + allNotesWithinBbox.features.map(f => MetaTagging.addMetatags( f, { memberships: new RelationsTracker(), - getFeaturesWithin: (layerId, bbox: BBox) => [], - getFeatureById: (id: string) => undefined + getFeaturesWithin: () => [], + getFeatureById: () => undefined }, importLayer, state, @@ -84,9 +83,9 @@ export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ }) - const maxDistance = new UIEventSource(5) + const maxDistance = new UIEventSource(10) - const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(params.geojson, alreadyOpenImportNotes.features + const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(params, alreadyOpenImportNotes.features .map(ff => ({features: ff.map(ff => ff.feature)})), maxDistance) @@ -103,34 +102,53 @@ export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ new Title("Compare with already existing 'to-import'-notes"), new VariableUiElement( alreadyOpenImportNotes.features.map(notesWithImport => { + if(allNotesWithinBbox.state.data !== undefined && allNotesWithinBbox.state.data["error"] !== undefined){ + return new FixedUiElement("Loading notes failed: "+allNotesWithinBbox.state.data["error"] ) + } if (allNotesWithinBbox.features.data === undefined || allNotesWithinBbox.features.data.length === 0) { return new Loading("Fetching notes from OSM") } if (notesWithImport.length === 0) { - return new FixedUiElement("No previous note to import found").SetClass("thanks") + return new FixedUiElement("No previous import notes found").SetClass("thanks") } return new Combine([ + "The red elements on the next map are all the data points from your dataset. There are "+params.features.length+" elements in your dataset.", map, - "The following (red) elements are elements to import which are nearby a matching element that is already up for import. These won't be imported", + + new VariableUiElement( partitionedImportPoints.map(({noNearby, hasNearby}) => { + + if(noNearby.length === 0){ + // Nothing can be imported + return new FixedUiElement("All of the proposed points have (or had) an import note already").SetClass("alert w-full block").SetStyle("padding: 0.5rem") + } + + if(hasNearby.length === 0){ + // All points can be imported + return new FixedUiElement("All of the proposed points have don't have a previous import note nearby").SetClass("thanks w-full block").SetStyle("padding: 0.5rem") - new Toggle( - new FixedUiElement("All of the proposed points have (or had) an import note already").SetClass("alert w-full block").SetStyle("padding: 0.5rem"), - new VariableUiElement(partitionedImportPoints.map(({noNearby}) => noNearby.length + " elements can be imported")).SetClass("thanks p-8"), - partitionedImportPoints.map(({noNearby}) => noNearby.length === 0) - ).SetClass("w-full"), - comparisonMap, + } + + return new Combine([ + new FixedUiElement(hasNearby.length+" points do have an already existing import note within "+maxDistance.data+" meter.").SetClass("alert"), + "These data points will not be imported and are shown as red dots on the map below", + comparisonMap.SetClass("w-full") + ]).SetClass("w-full") + })) + + ]).SetClass("flex flex-col") - }, [allNotesWithinBbox.features]) + }, [allNotesWithinBbox.features, allNotesWithinBbox.state]) ), ]); this.SetClass("flex flex-col") this.Value = partitionedImportPoints.map(({noNearby}) => ({ - geojson: {features: noNearby, type: "FeatureCollection"}, + features: noNearby, bbox: params.bbox, - layer: params.layer + layer: params.layer, + theme: params.theme })) this.IsValid = alreadyOpenImportNotes.features.map(ff => { diff --git a/UI/ImportFlow/ConfirmProcess.ts b/UI/ImportFlow/ConfirmProcess.ts index 9c4663935..0cdf28be7 100644 --- a/UI/ImportFlow/ConfirmProcess.ts +++ b/UI/ImportFlow/ConfirmProcess.ts @@ -8,14 +8,13 @@ import Title from "../Base/Title"; import {SubtleButton} from "../Base/SubtleButton"; import Svg from "../../Svg"; import {Utils} from "../../Utils"; -import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; -export class ConfirmProcess extends Combine implements FlowStep<{ features: any[], layer: LayerConfig }> { +export class ConfirmProcess extends Combine implements FlowStep<{ features: any[], theme: string }> { public IsValid: UIEventSource - public Value: UIEventSource<{ features: any[], layer: LayerConfig }> + public Value: UIEventSource<{ features: any[],theme: string }> - constructor(v: { features: any[], layer: LayerConfig }) { + constructor(v: { features: any[], theme: string }) { const toConfirm = [ new Combine(["I have read the ", new Link("import guidelines on the OSM wiki", "https://wiki.openstreetmap.org/wiki/Import_guidelines", true)]), @@ -35,13 +34,13 @@ export class ConfirmProcess extends Combine implements FlowStep<{ features: any[ type:"FeatureCollection", features: v.features } - Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson), "prepared_import_"+v.layer.id+".geojson",{ + Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson), "prepared_import_"+v.theme+".geojson",{ mimetype: "application/vnd.geo+json" }) }) ]); this.SetClass("link-underline") this.IsValid = licenseClear.GetValue().map(selected => toConfirm.length == selected.length) - this.Value = new UIEventSource<{ features: any[], layer: LayerConfig }>(v) + this.Value = new UIEventSource<{ features: any[], theme: string }>(v) } } \ No newline at end of file diff --git a/UI/ImportFlow/ConflationChecker.ts b/UI/ImportFlow/ConflationChecker.ts index b197d4268..192442043 100644 --- a/UI/ImportFlow/ConflationChecker.ts +++ b/UI/ImportFlow/ConflationChecker.ts @@ -32,24 +32,25 @@ import {ImportUtils} from "./ImportUtils"; /** * Given the data to import, the bbox and the layer, will query overpass for similar items */ -export default class ConflationChecker extends Combine implements FlowStep<{ features: any[], layer: LayerConfig }> { +export default class ConflationChecker extends Combine implements FlowStep<{ features: any[], theme: string }> { public readonly IsValid public readonly Value constructor( state, - params: { bbox: BBox, layer: LayerConfig, geojson: any }) { + params: { bbox: BBox, layer: LayerConfig, theme: string, features: any[] }) { const bbox = params.bbox.padAbsolute(0.0001) const layer = params.layer; - const toImport = params.geojson; + const toImport: {features: any[]} = params; let overpassStatus = new UIEventSource<{ error: string } | "running" | "success" | "idle" | "cached">("idle") const cacheAge = new UIEventSource(undefined); const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>("importer-overpass-cache-" + layer.id, { + whenLoaded: (v) => { - if (v !== undefined) { + if (v !== undefined && v !== null) { console.log("Loaded from local storage:", v) const [geojson, date] = v; const timeDiff = (new Date().getTime() - date.getTime()) / 1000; @@ -213,12 +214,15 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea })), new Title("Live data on OSM"), + "The "+toImport.features.length+" red elements on the following map are all your import candidates.", + new VariableUiElement(geojson.map(geojson => new FixedUiElement((geojson?.features?.length ?? "No") + " elements are loaded from OpenStreetMap which match the layer "+layer.id+". Zooming in might be needed to show them"))), osmLiveData, new Combine(["The live data is shown if the zoomlevel is at least ", zoomLevel, ". The current zoom level is ", new VariableUiElement(osmLiveData.location.map(l => "" + l.zoom))]).SetClass("flex"), new Title("Nearby features"), new Combine(["The following map shows features to import which have an OSM-feature within ", nearbyCutoff, "meter"]).SetClass("flex"), - new FixedUiElement("The red elements on the following map will not be imported!").SetClass("alert"), + new VariableUiElement(toImportWithNearby.features.map(feats => + new FixedUiElement("The "+ feats.length +" red elements on the following map will not be imported!").SetClass("alert"))), "Set the range to 0 or 1 if you want to import them all", matchedFeaturesMap]).SetClass("flex flex-col") @@ -246,7 +250,6 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea ]) this.Value = paritionedImport.map(feats => ({features: feats?.noNearby, layer: params.layer})) - this.Value.addCallbackAndRun(v => console.log("ConflationChecker-step value is ", v)) this.IsValid = this.Value.map(v => v?.features !== undefined && v.features.length > 0) } diff --git a/UI/ImportFlow/ImportHelperGui.ts b/UI/ImportFlow/ImportHelperGui.ts index 294a28049..b577c6cfd 100644 --- a/UI/ImportFlow/ImportHelperGui.ts +++ b/UI/ImportFlow/ImportHelperGui.ts @@ -22,6 +22,7 @@ import LoginToImport from "./LoginToImport"; import {MapPreview} from "./MapPreview"; import LeftIndex from "../Base/LeftIndex"; import {SubtleButton} from "../Base/SubtleButton"; +import SelectTheme from "./SelectTheme"; export default class ImportHelperGui extends LeftIndex { constructor() { @@ -31,14 +32,15 @@ export default class ImportHelperGui extends LeftIndex { FlowPanelFactory .start("Introduction", new Introdution()) .then("Login", _ => new LoginToImport(state)) - .then("Select file", _ => new RequestFile()) - .then("Inspect attributes", geojson => new PreviewPanel(state, geojson)) - .then("Inspect data", geojson => new MapPreview(state, geojson)) - .then("Compare with open notes", v => new CompareToAlreadyExistingNotes(state, v)) - .then("Compare with existing data", v => new ConflationChecker(state, v)) - .then("License and community check", v => new ConfirmProcess(v)) - .then("Metadata", (v: { features: any[], layer: LayerConfig }) => new AskMetadata(v)) - .finish("Note creation", v => new CreateNotes(state, v)); + .then("Select file", _ => new RequestFile()) + .then("Inspect attributes", geojson => new PreviewPanel(state, geojson)) + .then("Inspect data", geojson => new MapPreview(state, geojson)) + .then("Select theme", v => new SelectTheme(v)) + .then("Compare with open notes", v => new CompareToAlreadyExistingNotes(state, v)) + .then("Compare with existing data", v => new ConflationChecker(state, v)) + .then("License and community check", (v : {features: any[], theme: string}) => new ConfirmProcess(v)) + .then("Metadata", (v: { features: any[], layer: LayerConfig, theme: string }) => new AskMetadata(v)) + .finish("Note creation", v => new CreateNotes(state, v)); const toc = new List( titles.map((title, i) => new VariableUiElement(furthestStep.map(currentStep => { diff --git a/UI/ImportFlow/LoginToImport.ts b/UI/ImportFlow/LoginToImport.ts index 68991e53b..3c5df6e67 100644 --- a/UI/ImportFlow/LoginToImport.ts +++ b/UI/ImportFlow/LoginToImport.ts @@ -12,6 +12,7 @@ import Toggle from "../Input/Toggle"; import {SubtleButton} from "../Base/SubtleButton"; import Svg from "../../Svg"; import MoreScreen from "../BigComponents/MoreScreen"; +import CheckBoxes from "../Input/Checkboxes"; export default class LoginToImport extends Combine implements FlowStep { readonly IsValid: UIEventSource; @@ -21,7 +22,9 @@ export default class LoginToImport extends Combine implements FlowStep LoginToImport.whitelist.indexOf(ud.uid) >= 0 || ud.csCount >= Constants.userJourney.importHelperUnlock) + const check = new CheckBoxes([new VariableUiElement(state.osmConnection.userDetails.map(ud => t.loginIsCorrect.Subs(ud)))]) + const isValid = state.osmConnection.userDetails.map(ud => + LoginToImport.whitelist.indexOf(ud.uid) >= 0 || ud.csCount >= Constants.userJourney.importHelperUnlock) super([ new Title(t.userAccountTitle), new LoginToggle( @@ -33,7 +36,8 @@ export default class LoginToImport extends Combine implements FlowStep state.osmConnection.LogOut()) + .onClick(() => state.osmConnection.LogOut()), + check ]); })), t.loginRequired, @@ -46,6 +50,6 @@ export default class LoginToImport extends Combine implements FlowStep(state) - this.IsValid = isValid; + this.IsValid = isValid.map(isValid => isValid && check.GetValue().data.length > 0, [check.GetValue()]); } } \ No newline at end of file diff --git a/UI/ImportFlow/MapPreview.ts b/UI/ImportFlow/MapPreview.ts index 0d632a472..0c329f2b9 100644 --- a/UI/ImportFlow/MapPreview.ts +++ b/UI/ImportFlow/MapPreview.ts @@ -42,9 +42,9 @@ class PreviewPanel extends ScrollableFullScreen { /** * Shows the data to import on a map, asks for the correct layer to be selected */ -export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, geojson: any }> { +export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, features: any[] }> { public readonly IsValid: UIEventSource; - public readonly Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, geojson: any }> + public readonly Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, features: any[] }> constructor( state: UserRelatedState, @@ -153,7 +153,7 @@ export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer: this.Value = bbox.map(bbox => ({ bbox, - geojson, + features: geojson.features, layer: layerPicker.GetValue().data }), [layerPicker.GetValue()]) diff --git a/UI/ImportFlow/RequestFile.ts b/UI/ImportFlow/RequestFile.ts index dd6d877e8..eaad85a5a 100644 --- a/UI/ImportFlow/RequestFile.ts +++ b/UI/ImportFlow/RequestFile.ts @@ -34,13 +34,13 @@ class FileSelector extends InputElementMap { +export class RequestFile extends Combine implements FlowStep<{features: any[]}> { public readonly IsValid: UIEventSource /** * The loaded GeoJSON */ - public readonly Value: UIEventSource + public readonly Value: UIEventSource<{features: any[]}> constructor() { const t = Translations.t.importHelper.selectFile; diff --git a/UI/ImportFlow/SelectTheme.ts b/UI/ImportFlow/SelectTheme.ts new file mode 100644 index 000000000..45f0508c7 --- /dev/null +++ b/UI/ImportFlow/SelectTheme.ts @@ -0,0 +1,125 @@ +import {FlowStep} from "./FlowStep"; +import Combine from "../Base/Combine"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; +import {InputElement} from "../Input/InputElement"; +import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts"; +import {FixedInputElement} from "../Input/FixedInputElement"; +import Img from "../Base/Img"; +import Title from "../Base/Title"; +import {RadioButton} from "../Input/RadioButton"; +import {And} from "../../Logic/Tags/And"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import Toggleable from "../Base/Toggleable"; +import {BBox} from "../../Logic/BBox"; + +export default class SelectTheme extends Combine implements FlowStep<{ + features: any[], + theme: string, + layer: LayerConfig, + bbox: BBox, +}> { + + public readonly Value: UIEventSource<{ + features: any[], + theme: string, + layer: LayerConfig, + bbox: BBox, + }>; + public readonly IsValid: UIEventSource; + + constructor(params: ({ features: any[], layer: LayerConfig, bbox: BBox, })) { + + let options: InputElement[] = AllKnownLayouts.layoutsList + .filter(th => th.layers.some(l => l.id === params.layer.id)) + .filter(th => th.id !== "personal") + .map(th => new FixedInputElement( + new Combine([ + new Img(th.icon).SetClass("block h-12 w-12 br-4"), + new Title( th.title) + ]).SetClass("flex items-center"), + th.id)) + + + const themeRadios = new RadioButton(options, { + selectFirstAsDefault: false + }) + + + + const applicablePresets = themeRadios.GetValue().map(theme => { + if(theme === undefined){ + return [] + } + // we get the layer with the correct ID via the actual theme config, as the actual theme might have different presets due to overrides + const themeConfig = AllKnownLayouts.layoutsList.find(th => th.id === theme) + const layer = themeConfig.layers.find(l => l.id === params.layer.id) + return layer.presets + }) + + + const nonMatchedElements = applicablePresets.map(presets => { + if(presets === undefined || presets.length === 0){ + return undefined + } + return params.features.filter(feat => !presets.some(preset => new And(preset.tags).matchesProperties(feat.properties))) + }) + + super([ + new Title("Select a theme"), + "All of the following themes will show the import notes. However, the note on OpenStreetMap can link to only one single theme. Choose which theme that the created notes will link to", + themeRadios, + new VariableUiElement(applicablePresets.map(applicablePresets => { + if(themeRadios.GetValue().data === undefined){ + return undefined + } + if(applicablePresets === undefined || applicablePresets.length === 0){ + return new FixedUiElement("This theme has no presets loaded. As a result, imports won't work here").SetClass("alert") + } + },[themeRadios.GetValue()])), + new VariableUiElement(nonMatchedElements.map(unmatched => { + if(unmatched === undefined || unmatched.length === 0){ + return + } + return new Combine([new FixedUiElement(unmatched.length+" objects dont match any presets").SetClass("alert"), + ...applicablePresets.data.map(preset => preset.title.txt +" needs tags "+ preset.tags.map(t => t.asHumanString()).join(" & ")), + , + new Toggleable( new Title( "The following elements don't match any of the presets"), + new Combine( unmatched.map(feat => JSON.stringify(feat.properties))).SetClass("flex flex-col") + ) + + ]) .SetClass("flex flex-col") + + })) + ]); + this.SetClass("flex flex-col") + + this.Value = themeRadios.GetValue().map(theme => ({ + features: params.features, + layer: params.layer, + bbox: params.bbox, + theme + })) + + this.IsValid = this.Value.map(obj => { + if (obj === undefined) { + return false; + } + if ([obj.theme, obj.features].some(v => v === undefined)){ + return false; + } + if(applicablePresets.data === undefined || applicablePresets.data.length === 0){ + return false + } + if((nonMatchedElements.data?.length??0) > 0){ + return false; + } + + return true; + + }, [applicablePresets]) + } + + +} \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index a78bd1296..ca538defe 100644 --- a/langs/en.json +++ b/langs/en.json @@ -280,7 +280,8 @@ "inspectLooksCorrect": "These values look correct", "lockNotice": "This page is locked. You need {importHelperUnlock} changesets before you can access here.", "locked": "You need at least {importHelperUnlock} to use the import helper", - "loggedInWith": "You are currently logged in as {name} and have made {csCount} changesets", + "loggedInWith": "You are currently logged in as {name} and have made {csCount} changesets", + "loginIsCorrect": "{name} is the correct account to create the import notes with.", "loginRequired": "You have to be logged in to continue", "mapPreview": { "autodetected": "The layer was automatically deducted based on the properties", diff --git a/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts b/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts new file mode 100644 index 000000000..6b6043491 --- /dev/null +++ b/tests/Models/ThemeConfig/Conversion/PrepareLayer.spec.ts @@ -0,0 +1,75 @@ +import {describe} from 'mocha' +import {expect} from 'chai' +import {LayoutConfigJson} from "../../../../Models/ThemeConfig/Json/LayoutConfigJson"; +import {LayerConfigJson} from "../../../../Models/ThemeConfig/Json/LayerConfigJson"; +import {PrepareTheme} from "../../../../Models/ThemeConfig/Conversion/PrepareTheme"; +import {TagRenderingConfigJson} from "../../../../Models/ThemeConfig/Json/TagRenderingConfigJson"; +import LayoutConfig from "../../../../Models/ThemeConfig/LayoutConfig"; +import * as bookcaseLayer from "../../../../assets/generated/layers/public_bookcase.json" +import LayerConfig from "../../../../Models/ThemeConfig/LayerConfig"; +import {ExtractImages} from "../../../../Models/ThemeConfig/Conversion/FixImages"; +import * as cyclofix from "../../../../assets/generated/themes/cyclofix.json" + + +const themeConfigJson: LayoutConfigJson = { + + description: "Descr", + icon: "", + layers: [ + { + builtin: "public_bookcase", + override: { + source: { + geoJson: "xyz" + } + } + } + ], + maintainer: "", + startLat: 0, + startLon: 0, + startZoom: 0, + title: { + en: "Title" + }, + version: "", + id: "test" +} + +describe("PrepareTheme", () => { + + it("should apply overrideAll", () => { + + const sharedLayers = new Map() + sharedLayers.set("public_bookcase", bookcaseLayer["default"]) + let themeConfigJsonPrepared = new PrepareTheme({ + tagRenderings: new Map(), + sharedLayers: sharedLayers + }).convert( themeConfigJson, "test").result + const themeConfig = new LayoutConfig(themeConfigJsonPrepared); + const layerUnderTest = themeConfig.layers.find(l => l.id === "public_bookcase") + expect(layerUnderTest.source.geojsonSource).eq("xyz") + + }) +}) + + +describe("ExtractImages", () => { + it("should find all images in a themefile", () => { + const images = new Set(new ExtractImages(true, new Map()).convertStrict( cyclofix, "test")) + const expectedValues = [ + './assets/layers/bike_repair_station/repair_station.svg', + './assets/layers/bike_repair_station/repair_station_pump.svg', + './assets/layers/bike_repair_station/broken_pump.svg', + './assets/layers/bike_repair_station/pump.svg', + './assets/themes/cyclofix/fietsambassade_gent_logo_small.svg', + './assets/layers/bike_repair_station/pump_example_manual.jpg', + './assets/layers/bike_repair_station/pump_example.png', + './assets/layers/bike_repair_station/pump_example_round.jpg', + './assets/layers/bike_repair_station/repair_station_example_2.jpg', + 'close'] + for (const expected of expectedValues) { + expect(images).contains(expected) + } + }) +}) \ No newline at end of file