forked from MapComplete/MapComplete
		
	Lots of refactoring, first version of the import helper
This commit is contained in:
		
							parent
							
								
									612b8136ad
								
							
						
					
					
						commit
						3402ac0954
					
				
					 54 changed files with 1104 additions and 315 deletions
				
			
		
							
								
								
									
										242
									
								
								UI/ImportFlow/ConflationChecker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								UI/ImportFlow/ConflationChecker.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,242 @@ | |||
| import {BBox} from "../../Logic/BBox"; | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Title from "../Base/Title"; | ||||
| import {Overpass} from "../../Logic/Osm/Overpass"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import RelationsTracker from "../../Logic/Osm/RelationsTracker"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {FlowStep} from "./FlowStep"; | ||||
| import Loading from "../Base/Loading"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Svg from "../../Svg"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import {IdbLocalStorage} from "../../Logic/Web/IdbLocalStorage"; | ||||
| import Minimap from "../Base/Minimap"; | ||||
| import BaseLayer from "../../Models/BaseLayer"; | ||||
| import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import Attribution from "../BigComponents/Attribution"; | ||||
| import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"; | ||||
| import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; | ||||
| import ValidatedTextField from "../Input/ValidatedTextField"; | ||||
| import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource"; | ||||
| import * as currentview from "../../assets/layers/current_view/current_view.json" | ||||
| import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json" | ||||
| import {GeoOperations} from "../../Logic/GeoOperations"; | ||||
| import FeatureInfoBox from "../Popup/FeatureInfoBox"; | ||||
| /** | ||||
|  * Given the data to import, the bbox and the layer, will query overpass for similar items | ||||
|  */ | ||||
| export default class ConflationChecker extends Combine implements FlowStep<any> { | ||||
| 
 | ||||
|     public readonly IsValid | ||||
|     public readonly Value | ||||
| 
 | ||||
|     constructor( | ||||
|         state, | ||||
|         params: { bbox: BBox, layer: LayerConfig, geojson: any }) { | ||||
|          | ||||
|          | ||||
|         const bbox = params.bbox.padAbsolute(0.0001) | ||||
|         const layer = params.layer; | ||||
|         const toImport = params.geojson; | ||||
|         let overpassStatus = new UIEventSource<{ error: string } | "running" | "success" | "idle" | "cached" >("idle") | ||||
|          | ||||
|         const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>("importer-overpass-cache-" + layer.id, { | ||||
|             whenLoaded: (v) => { | ||||
|                 if (v !== undefined) { | ||||
|                     console.log("Loaded from local storage:", v) | ||||
|                     const [geojson, date] = v; | ||||
|                     const timeDiff = (new Date().getTime() - date.getTime()) / 1000; | ||||
|                     console.log("The cache is ", timeDiff, "seconds old") | ||||
|                     if (timeDiff < 24 * 60 * 60) { | ||||
|                         // Recently cached! 
 | ||||
|                         overpassStatus.setData("cached") | ||||
|                         return; | ||||
|                     } | ||||
|                 } | ||||
|                 // Load the data!
 | ||||
|                 const url = Constants.defaultOverpassUrls[1] | ||||
|                 const relationTracker = new RelationsTracker() | ||||
|                 const overpass = new Overpass(params.layer.source.osmTags, [], url, new UIEventSource<number>(180), relationTracker, true) | ||||
|                 console.log("Loading from overpass!") | ||||
|                 overpassStatus.setData("running") | ||||
|                 overpass.queryGeoJson(bbox).then( | ||||
|                     ([data, date] ) => { | ||||
|                         console.log("Received overpass-data: ", data.features.length,"features are loaded at ", date); | ||||
|                         overpassStatus.setData("success") | ||||
|                         fromLocalStorage.setData([data, date]) | ||||
|                     },  | ||||
|                     (error) => {overpassStatus.setData({error})}) | ||||
| 
 | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
| 
 | ||||
|         const geojson : UIEventSource<any> = fromLocalStorage.map(d => { | ||||
|             if (d === undefined) { | ||||
|                 return undefined | ||||
|             } | ||||
|             return d[0] | ||||
|         }) | ||||
| 
 | ||||
|         const background = new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto) | ||||
|         const location = new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1}) | ||||
|         const currentBounds = new UIEventSource<BBox>(undefined) | ||||
|         const zoomLevel = ValidatedTextField.InputForType("pnat") | ||||
|         zoomLevel.SetClass("ml-1 border border-black") | ||||
|         zoomLevel.GetValue().syncWith(LocalStorageSource.Get("importer-zoom-level", "14"), true) | ||||
|         const osmLiveData = Minimap.createMiniMap({ | ||||
|             allowMoving: true, | ||||
|             location, | ||||
|             background, | ||||
|             bounds: currentBounds, | ||||
|             attribution: new Attribution(location, state.osmConnection.userDetails, undefined, currentBounds) | ||||
|         }) | ||||
|         osmLiveData.SetClass("w-full").SetStyle("height: 500px") | ||||
|         const preview = new StaticFeatureSource(geojson.map(geojson => { | ||||
|             if(geojson?.features === undefined){ | ||||
|                 return [] | ||||
|             } | ||||
|             const zoomedEnough: boolean = osmLiveData.location.data.zoom >= Number(zoomLevel.GetValue().data) | ||||
|             if(!zoomedEnough){ | ||||
|                 return [] | ||||
|             } | ||||
|             const bounds = osmLiveData.bounds.data | ||||
|             return geojson.features.filter(f => BBox.get(f).overlapsWith(bounds)) | ||||
|         }, [osmLiveData.bounds, zoomLevel.GetValue()]), false); | ||||
|          | ||||
|         | ||||
|         | ||||
|         new ShowDataLayer({ | ||||
|             layerToShow:new LayerConfig(currentview), | ||||
|             state, | ||||
|             leafletMap: osmLiveData.leafletMap, | ||||
|             enablePopups: undefined, | ||||
|             zoomToFeatures: true, | ||||
|             features: new StaticFeatureSource([ | ||||
|                 bbox.asGeoJson({}) | ||||
|             ], false) | ||||
|         }) | ||||
| 
 | ||||
| 
 | ||||
|         new ShowDataLayer({ | ||||
|             layerToShow:layer, | ||||
|             state, | ||||
|             leafletMap: osmLiveData.leafletMap, | ||||
|             popup: (tags, layer) => new FeatureInfoBox(tags, layer, state), | ||||
|             zoomToFeatures: false, | ||||
|             features: preview | ||||
|         }) | ||||
| 
 | ||||
|         new ShowDataLayer({ | ||||
|             layerToShow:new LayerConfig(import_candidate), | ||||
|             state, | ||||
|             leafletMap: osmLiveData.leafletMap, | ||||
|             popup: (tags, layer) => new FeatureInfoBox(tags, layer, state), | ||||
|             zoomToFeatures: false, | ||||
|             features: new StaticFeatureSource(toImport.features, false) | ||||
|         }) | ||||
|          | ||||
|         const nearbyCutoff = ValidatedTextField.InputForType("pnat") | ||||
|         nearbyCutoff.SetClass("ml-1 border border-black") | ||||
|         nearbyCutoff.GetValue().syncWith(LocalStorageSource.Get("importer-cutoff", "25"), true) | ||||
|          | ||||
|         const matchedFeaturesMap = Minimap.createMiniMap({ | ||||
|             allowMoving: true, | ||||
|             background | ||||
|         }) | ||||
|         matchedFeaturesMap.SetClass("w-full").SetStyle("height: 500px") | ||||
| 
 | ||||
|         // Featuresource showing OSM-features which are nearby a toImport-feature 
 | ||||
|         const nearbyFeatures = new StaticFeatureSource(geojson.map(osmData => { | ||||
|             if(osmData?.features === undefined){ | ||||
|                 return [] | ||||
|             } | ||||
|             const maxDist = Number(nearbyCutoff.GetValue().data) | ||||
|             return osmData.features.filter(f =>  | ||||
|                 toImport.features.some(imp =>  | ||||
|                     maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) ) | ||||
|         }, [nearbyCutoff.GetValue()]), false); | ||||
| 
 | ||||
|         // Featuresource showing OSM-features which are nearby a toImport-feature 
 | ||||
|         const toImportWithNearby = new StaticFeatureSource(geojson.map(osmData => { | ||||
|             if(osmData?.features === undefined){ | ||||
|                 return [] | ||||
|             } | ||||
|             const maxDist = Number(nearbyCutoff.GetValue().data) | ||||
|             return toImport.features.filter(imp => | ||||
|                 osmData.features.some(f => | ||||
|                     maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))) ) | ||||
|         }, [nearbyCutoff.GetValue()]), false); | ||||
| 
 | ||||
|         new ShowDataLayer({ | ||||
|             layerToShow:layer, | ||||
|             state, | ||||
|             leafletMap: matchedFeaturesMap.leafletMap, | ||||
|             popup: (tags, layer) => new FeatureInfoBox(tags, layer, state), | ||||
|             zoomToFeatures: true, | ||||
|             features: nearbyFeatures | ||||
|         }) | ||||
| 
 | ||||
|         new ShowDataLayer({ | ||||
|             layerToShow:new LayerConfig(import_candidate), | ||||
|             state, | ||||
|             leafletMap: matchedFeaturesMap.leafletMap, | ||||
|             popup: (tags, layer) => new FeatureInfoBox(tags, layer, state), | ||||
|             zoomToFeatures: false, | ||||
|             features: toImportWithNearby | ||||
|         }) | ||||
|          | ||||
|          | ||||
|         super([ | ||||
|             new Title("Comparison with existing data"), | ||||
|             new VariableUiElement(overpassStatus.map(d => { | ||||
|                 if (d === "idle") { | ||||
|                     return new Loading("Checking local storage...") | ||||
|                 } | ||||
|                 if (d["error"] !== undefined) { | ||||
|                     return new FixedUiElement("Could not load latest data from overpass: " + d["error"]).SetClass("alert") | ||||
|                 } | ||||
|                 if(d === "running"){ | ||||
|                     return new Loading("Querying overpass...") | ||||
|                 } | ||||
|                 if(d === "cached"){ | ||||
|                     return new FixedUiElement("Fetched data from local storage") | ||||
|                 } | ||||
|                 if(d === "success"){ | ||||
|                     return new FixedUiElement("Data loaded") | ||||
|                 } | ||||
|                 return new FixedUiElement("Unexpected state "+d).SetClass("alert") | ||||
|             })), | ||||
|             new VariableUiElement( | ||||
|                 geojson.map(geojson => { | ||||
|                     if (geojson === undefined) { | ||||
|                         return undefined; | ||||
|                     } | ||||
|                     return new SubtleButton(Svg.download_svg(), "Download the loaded geojson from overpass").onClick(() => { | ||||
|                         Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, "  "), "mapcomplete-" + layer.id + ".geojson", { | ||||
|                             mimetype: "application/json+geo" | ||||
|                         }) | ||||
|                     }); | ||||
|                 })), | ||||
| 
 | ||||
|             new Title("Live data on OSM"), | ||||
|             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 <b>not</b> be imported!").SetClass("alert"), | ||||
|             "Set the range to 0 or 1 if you want to import them all", | ||||
|             matchedFeaturesMap | ||||
|         ]) | ||||
| 
 | ||||
|         this.IsValid = new UIEventSource(false) | ||||
|         this.Value = new UIEventSource(undefined) | ||||
|     }  | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										156
									
								
								UI/ImportFlow/DataPanel.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								UI/ImportFlow/DataPanel.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,156 @@ | |||
| import Combine from "../Base/Combine"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {BBox} from "../../Logic/BBox"; | ||||
| import UserRelatedState from "../../Logic/State/UserRelatedState"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; | ||||
| import BaseLayer from "../../Models/BaseLayer"; | ||||
| import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import Minimap from "../Base/Minimap"; | ||||
| import Attribution from "../BigComponents/Attribution"; | ||||
| import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"; | ||||
| import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; | ||||
| import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import Table from "../Base/Table"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {FlowStep} from "./FlowStep"; | ||||
| import {Layer} from "leaflet"; | ||||
| 
 | ||||
| /** | ||||
|  * Shows the data to import on a map, asks for the correct layer to be selected | ||||
|  */ | ||||
| export class DataPanel extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, geojson: any }>{ | ||||
|     public readonly IsValid: UIEventSource<boolean>; | ||||
|     public readonly Value: UIEventSource<{ bbox: BBox, layer: LayerConfig, geojson: any }> | ||||
|      | ||||
|     constructor( | ||||
|         state: UserRelatedState, | ||||
|         geojson: { features: { properties: any, geometry: { coordinates: [number, number] } }[] }) { | ||||
|         const t = Translations.t.importHelper; | ||||
| 
 | ||||
|         const propertyKeys = new Set<string>() | ||||
|         console.log("Datapanel input got ", geojson) | ||||
|         for (const f of geojson.features) { | ||||
|             Object.keys(f.properties).forEach(key => propertyKeys.add(key)) | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         const availableLayers = AllKnownLayouts.AllPublicLayers().filter(l => l.name !== undefined && Constants.priviliged_layers.indexOf(l.id) < 0) | ||||
|         const layerPicker = new DropDown("Which layer does this import match with?", | ||||
|             [{shown: t.selectLayer, value: undefined}].concat(availableLayers.map(l => ({ | ||||
|                 shown: l.name, | ||||
|                 value: l | ||||
|             }))) | ||||
|         ) | ||||
| 
 | ||||
|         let autodetected = new UIEventSource(false) | ||||
|         for (const layer of availableLayers) { | ||||
|             const mismatched = geojson.features.some(f => | ||||
|                 !layer.source.osmTags.matchesProperties(f.properties) | ||||
|             ) | ||||
|             if (!mismatched) { | ||||
|                 layerPicker.GetValue().setData(layer); | ||||
|                 layerPicker.GetValue().addCallback(_ => autodetected.setData(false)) | ||||
|                 autodetected.setData(true) | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         const withId = geojson.features.map((f, i) => { | ||||
|             const copy = Utils.Clone(f) | ||||
|             copy.properties.id = "to-import/" + i | ||||
|             return copy | ||||
|         }) | ||||
| 
 | ||||
|         const matching: UIEventSource<{ properties: any, geometry: { coordinates: [number, number] } }[]> = layerPicker.GetValue().map((layer: LayerConfig) => { | ||||
|             if (layer === undefined) { | ||||
|                 return undefined; | ||||
|             } | ||||
|             const matching: { properties: any, geometry: { coordinates: [number, number] } }[] = [] | ||||
| 
 | ||||
|             for (const feature of withId) { | ||||
|                 if (layer.source.osmTags.matchesProperties(feature.properties)) { | ||||
|                     matching.push(feature) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return matching | ||||
|         }) | ||||
|         const background = new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto) | ||||
|         const location = new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1}) | ||||
|         const currentBounds = new UIEventSource<BBox>(undefined) | ||||
|         const map = Minimap.createMiniMap({ | ||||
|             allowMoving: true, | ||||
|             location, | ||||
|             background, | ||||
|             bounds: currentBounds, | ||||
|             attribution: new Attribution(location, state.osmConnection.userDetails, undefined, currentBounds) | ||||
|         }) | ||||
|         map.SetClass("w-full").SetStyle("height: 500px") | ||||
| 
 | ||||
|         new ShowDataMultiLayer({ | ||||
|             layers: new UIEventSource<FilteredLayer[]>(AllKnownLayouts.AllPublicLayers().map(l => ({ | ||||
|                 layerDef: l, | ||||
|                 isDisplayed: new UIEventSource<boolean>(true), | ||||
|                 appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined) | ||||
|             }))), | ||||
|             zoomToFeatures: true, | ||||
|             features: new StaticFeatureSource(matching, false), | ||||
|             state: { | ||||
|                 ...state, | ||||
|                 filteredLayers: new UIEventSource<FilteredLayer[]>(undefined), | ||||
|                 backgroundLayer: background | ||||
|             }, | ||||
|             leafletMap: map.leafletMap, | ||||
| 
 | ||||
|         }) | ||||
|         var bbox = matching.map(feats => BBox.bboxAroundAll(feats.map(f => new BBox([f.geometry.coordinates])))) | ||||
| 
 | ||||
|         super([ | ||||
|             "Has " + geojson.features.length + " features", | ||||
|             layerPicker, | ||||
|             new Toggle("Automatically detected layer", undefined, autodetected), | ||||
|             new Table(["", "Key", "Values", "Unique values seen"], | ||||
|                 Array.from(propertyKeys).map(key => { | ||||
|                     const uniqueValues = Utils.Dedup(Utils.NoNull(geojson.features.map(f => f.properties[key]))) | ||||
|                     uniqueValues.sort() | ||||
|                     return [geojson.features.filter(f => f.properties[key] !== undefined).length + "", key, uniqueValues.join(", "), "" + uniqueValues.length] | ||||
|                 }) | ||||
|             ).SetClass("zebra-table table-auto"), | ||||
|             new VariableUiElement(matching.map(matching => { | ||||
|                 if (matching === undefined) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 const diff = geojson.features.length - matching.length; | ||||
|                 if (diff === 0) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 const obligatory = layerPicker.GetValue().data?.source?.osmTags?.asHumanString(false, false, {}); | ||||
|                 return new FixedUiElement(`${diff} features will _not_ match this layer. Make sure that all obligatory objects are present: ${obligatory}`).SetClass("alert"); | ||||
|             })), | ||||
|             map | ||||
|         ]); | ||||
| 
 | ||||
|         this.Value = bbox.map(bbox => | ||||
|             ({ | ||||
|                 bbox, | ||||
|                 geojson, | ||||
|                 layer: layerPicker.GetValue().data | ||||
|             }), [layerPicker.GetValue()]) | ||||
|         this.IsValid = matching.map(matching => { | ||||
|             if (matching === undefined) { | ||||
|                 return false | ||||
|             } | ||||
|             const diff = geojson.features.length - matching.length; | ||||
|             return diff === 0; | ||||
|         }) | ||||
|          | ||||
|     } | ||||
| } | ||||
							
								
								
									
										105
									
								
								UI/ImportFlow/FlowStep.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								UI/ImportFlow/FlowStep.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,105 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Svg from "../../Svg"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| 
 | ||||
| export interface FlowStep<T> extends BaseUIElement{ | ||||
|     readonly IsValid: UIEventSource<boolean> | ||||
|     readonly Value: UIEventSource<T> | ||||
| } | ||||
| 
 | ||||
| export class FlowPanelFactory<T> { | ||||
|     private _initial: FlowStep<any>; | ||||
|     private _steps: ((x: any) => FlowStep<any>)[]; | ||||
|     private _stepNames: string[]; | ||||
|      | ||||
|     private constructor(initial: FlowStep<any>, steps: ((x:any) => FlowStep<any>)[], stepNames: string[]) { | ||||
|         this._initial = initial; | ||||
|         this._steps = steps; | ||||
|         this._stepNames = stepNames; | ||||
|     } | ||||
|      | ||||
|     public static start<TOut> (step: FlowStep<TOut>): FlowPanelFactory<TOut>{ | ||||
|         return new FlowPanelFactory(step, [], []) | ||||
|     } | ||||
|      | ||||
|     public then<TOut>(name: string, construct: ((t:T) => FlowStep<TOut>)): FlowPanelFactory<TOut>{ | ||||
|         return new FlowPanelFactory<TOut>( | ||||
|             this._initial, | ||||
|             this._steps.concat([construct]), | ||||
|             this._stepNames.concat([name]) | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     public finish(construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)) : BaseUIElement { | ||||
|         // Construct all the flowpanels step by step (in reverse order)
 | ||||
|         const nextConstr : ((t:any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined) | ||||
|         nextConstr.push(construct) | ||||
|          | ||||
|         for (let i = this._steps.length - 1; i >= 0; i--){ | ||||
|             const createFlowStep : (value) => FlowStep<any> = this._steps[i]; | ||||
|             nextConstr[i] = (value, backButton) => { | ||||
|                 console.log("Creating flowSTep ", this._stepNames[i]) | ||||
|                 const flowStep = createFlowStep(value) | ||||
|                 return new FlowPanel(flowStep, nextConstr[i + 1], backButton); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         return new FlowPanel(this._initial, nextConstr[0],undefined) | ||||
|     } | ||||
|      | ||||
| } | ||||
| 
 | ||||
| export class FlowPanel<T> extends Toggle { | ||||
|      | ||||
|     constructor( | ||||
|         initial: (FlowStep<T>), | ||||
|         constructNextstep:  ((input: T, backButton: BaseUIElement) => BaseUIElement), | ||||
|         backbutton?: BaseUIElement | ||||
|     ) { | ||||
|         const t = Translations.t.general; | ||||
|          | ||||
|         const currentStepActive = new UIEventSource(true); | ||||
| 
 | ||||
|         let nextStep: UIEventSource<BaseUIElement>= new UIEventSource<BaseUIElement>(undefined) | ||||
|         const backButtonForNextStep = new SubtleButton(Svg.back_svg(), t.back).onClick(() => { | ||||
|             currentStepActive.setData(true) | ||||
|         }) | ||||
|          | ||||
|         let elements : (BaseUIElement | string)[] = [] | ||||
|         if(initial !== undefined){ | ||||
|             // Startup the flow
 | ||||
|             elements = [ | ||||
|                 initial, | ||||
|                 new Combine([ | ||||
|                     backbutton, | ||||
|                     new Toggle( | ||||
|                         new SubtleButton(Svg.back_svg().SetStyle("transform: rotate(180deg);"), t.next).onClick(() => { | ||||
|                             const v = initial.Value.data; | ||||
|                             nextStep.setData(constructNextstep(v, backButtonForNextStep)) | ||||
|                             currentStepActive.setData(false) | ||||
|                         }), | ||||
|                         "Select a valid value to continue", | ||||
|                         initial.IsValid | ||||
|                     ) | ||||
|                 ]).SetClass("flex w-full justify-end space-x-2") | ||||
|                | ||||
|             ] | ||||
|         } | ||||
|          | ||||
|          | ||||
|         super( | ||||
|             new Combine(elements).SetClass("h-full flex flex-col justify-between"), | ||||
|             new VariableUiElement(nextStep), | ||||
|             currentStepActive | ||||
|         ); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|      | ||||
| } | ||||
							
								
								
									
										67
									
								
								UI/ImportFlow/ImportHelperGui.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								UI/ImportFlow/ImportHelperGui.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,67 @@ | |||
| import Combine from "../Base/Combine"; | ||||
| import {LoginToggle} from "../Popup/LoginButton"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import LanguagePicker from "../LanguagePicker"; | ||||
| import BackToIndex from "../BigComponents/BackToIndex"; | ||||
| import UserRelatedState from "../../Logic/State/UserRelatedState"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import MoreScreen from "../BigComponents/MoreScreen"; | ||||
| import MinimapImplementation from "../Base/MinimapImplementation"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import {FlowPanel, FlowPanelFactory} from "./FlowStep"; | ||||
| import {RequestFile} from "./RequestFile"; | ||||
| import {DataPanel} from "./DataPanel"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import ConflationChecker from "./ConflationChecker"; | ||||
| 
 | ||||
| export default class ImportHelperGui extends LoginToggle { | ||||
|     constructor() { | ||||
|         const t = Translations.t.importHelper; | ||||
| 
 | ||||
|         const state = new UserRelatedState(undefined) | ||||
| 
 | ||||
|         // We disable the userbadge, as various 'showData'-layers will give a read-only view in this case
 | ||||
|         state.featureSwitchUserbadge.setData(false) | ||||
| 
 | ||||
|         const leftContents: BaseUIElement[] = [ | ||||
|             new BackToIndex().SetClass("block pl-4"), | ||||
|             LanguagePicker.CreateLanguagePicker(Translations.t.importHelper.title.SupportedLanguages())?.SetClass("mt-4 self-end flex-col"), | ||||
|         ].map(el => el?.SetClass("pl-4")) | ||||
| 
 | ||||
|         const leftBar = new Combine([ | ||||
|             new Combine(leftContents).SetClass("sticky top-4 m-4") | ||||
|         ]).SetClass("block w-full md:w-2/6 lg:w-1/6") | ||||
| 
 | ||||
| 
 | ||||
|         const mainPanel =  | ||||
|             FlowPanelFactory | ||||
|                 .start(new RequestFile()) | ||||
|                 .then("datapanel", geojson => new DataPanel(state, geojson)) | ||||
|                 .then("conflation", v => new ConflationChecker(state, v)) | ||||
|                 .finish(_ => new FixedUiElement("All done!")) | ||||
|          | ||||
|         super( | ||||
|             new Toggle( | ||||
|                 new Combine([ | ||||
|                     leftBar, | ||||
|                     mainPanel.SetClass("m-8 w-full mb-24") | ||||
|                 ]).SetClass("h-full block md:flex") | ||||
| 
 | ||||
|                 , | ||||
|                 new Combine([ | ||||
|                     t.lockNotice.Subs(Constants.userJourney), | ||||
|                     MoreScreen.CreateProffessionalSerivesButton() | ||||
|                 ]) | ||||
| 
 | ||||
|                 , | ||||
|                 state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.importHelperUnlock)), | ||||
| 
 | ||||
|             "Login needed...", | ||||
|             state) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| MinimapImplementation.initialize() | ||||
| new ImportHelperGui().AttachTo("main") | ||||
							
								
								
									
										145
									
								
								UI/ImportFlow/RequestFile.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								UI/ImportFlow/RequestFile.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,145 @@ | |||
| import Combine from "../Base/Combine"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Title from "../Base/Title"; | ||||
| import InputElementMap from "../Input/InputElementMap"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import FileSelectorButton from "../Input/FileSelectorButton"; | ||||
| import {FlowStep} from "./FlowStep"; | ||||
| 
 | ||||
| class FileSelector extends InputElementMap<FileList, { name: string, contents: Promise<string> }> { | ||||
|     constructor(label: BaseUIElement) { | ||||
|         super( | ||||
|             new FileSelectorButton(label, {allowMultiple: false, acceptType: "*"}), | ||||
|             (x0, x1) => { | ||||
|                 // Total hack: x1 is undefined is the backvalue - we effectively make this a one-way-story
 | ||||
|                 return x1 === undefined || x0 === x1; | ||||
|             }, | ||||
|             filelist => { | ||||
|                 if (filelist === undefined) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 const file = filelist.item(0) | ||||
|                 return {name: file.name, contents: file.text()} | ||||
|             }, | ||||
|             _ => undefined | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * The first step in the import flow: load a file and validate that it is a correct geojson or CSV file | ||||
|  */ | ||||
| export class RequestFile extends Combine implements FlowStep<any> { | ||||
| 
 | ||||
|     public readonly IsValid: UIEventSource<boolean> | ||||
|     /** | ||||
|      * The loaded GeoJSON | ||||
|      */ | ||||
|     public readonly Value: UIEventSource<any> | ||||
| 
 | ||||
|     constructor() { | ||||
|         const t = Translations.t.importHelper; | ||||
|         const csvSelector = new FileSelector(new SubtleButton(undefined, t.selectFile)) | ||||
|         const loadedFiles = new VariableUiElement(csvSelector.GetValue().map(file => { | ||||
|             if (file === undefined) { | ||||
|                 return t.noFilesLoaded.SetClass("alert") | ||||
|             } | ||||
|             return t.loadedFilesAre.Subs({file: file.name}).SetClass("thanks") | ||||
|         })) | ||||
| 
 | ||||
|         const text = UIEventSource.flatten( | ||||
|             csvSelector.GetValue().map(v => { | ||||
|                 if (v === undefined) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 return UIEventSource.FromPromise(v.contents) | ||||
|             })) | ||||
| 
 | ||||
|         const asGeoJson: UIEventSource<any | { error: string }> = text.map(src => { | ||||
|             if (src === undefined) { | ||||
|                 return undefined | ||||
|             } | ||||
|             try { | ||||
|                 const parsed = JSON.parse(src) | ||||
|                 if (parsed["type"] !== "FeatureCollection") { | ||||
|                     return {error: "The loaded JSON-file is not a geojson-featurecollection"} | ||||
|                 } | ||||
|                 if (parsed.features.some(f => f.geometry.type != "Point")) { | ||||
|                     return {error: "The loaded JSON-file should only contain points"} | ||||
|                 } | ||||
|                 return parsed; | ||||
| 
 | ||||
|             } catch (e) { | ||||
|                 // Loading as CSV
 | ||||
|                 const lines = src.split("\n") | ||||
|                 const header = lines[0].split(",") | ||||
|                 lines.splice(0, 1) | ||||
|                 if (header.indexOf("lat") < 0 || header.indexOf("lon") < 0) { | ||||
|                     return {error: "The header does not contain `lat` or `lon`"} | ||||
|                 } | ||||
| 
 | ||||
|                 const features = [] | ||||
|                 for (let i = 0; i < lines.length; i++) { | ||||
|                     const line = lines[i]; | ||||
|                     if (line.trim() === "") { | ||||
|                         continue | ||||
|                     } | ||||
|                     const attrs = line.split(",") | ||||
|                     const properties = {} | ||||
|                     for (let i = 0; i < header.length; i++) { | ||||
|                         properties[header[i]] = attrs[i]; | ||||
|                     } | ||||
|                     const coordinates = [Number(properties["lon"]), Number(properties["lat"])] | ||||
|                     delete properties["lat"] | ||||
|                     delete properties["lon"] | ||||
|                     if (coordinates.some(isNaN)) { | ||||
|                         return {error: "A coordinate could not be parsed for line " + (i + 2)} | ||||
|                     } | ||||
|                     const f = { | ||||
|                         type: "Feature", | ||||
|                         properties, | ||||
|                         geometry: { | ||||
|                             type: "Point", | ||||
|                             coordinates | ||||
|                         } | ||||
|                     }; | ||||
|                     features.push(f) | ||||
|                 } | ||||
| 
 | ||||
|                 return { | ||||
|                     type: "FeatureCollection", | ||||
|                     features | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
| 
 | ||||
|         const errorIndicator = new VariableUiElement(asGeoJson.map(v => { | ||||
|             if (v === undefined) { | ||||
|                 return undefined; | ||||
|             } | ||||
|             if (v?.error === undefined) { | ||||
|                 return undefined; | ||||
|             } | ||||
|             return new FixedUiElement(v?.error).SetClass("alert"); | ||||
|         })) | ||||
| 
 | ||||
|         super([ | ||||
| 
 | ||||
|             new Title(t.title, 1), | ||||
|             t.description, | ||||
|             csvSelector, | ||||
|             loadedFiles, | ||||
|             errorIndicator | ||||
| 
 | ||||
|         ]); | ||||
|         this.IsValid = asGeoJson.map(geojson => geojson !== undefined && geojson["error"] === undefined) | ||||
|         this.Value = asGeoJson | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue