forked from MapComplete/MapComplete
		
	First working version of snapping to already existing ways from the add-UI (still too slow though), partial fix of #436
This commit is contained in:
		
							parent
							
								
									bf2d634208
								
							
						
					
					
						commit
						0a01561d37
					
				
					 15 changed files with 460 additions and 143 deletions
				
			
		|  | @ -14,11 +14,11 @@ import {UIEventSource} from "../../Logic/UIEventSource"; | |||
| import {FixedUiElement} from "../../UI/Base/FixedUiElement"; | ||||
| import SourceConfig from "./SourceConfig"; | ||||
| import {TagsFilter} from "../../Logic/Tags/TagsFilter"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import {Unit} from "./Denomination"; | ||||
| import DeleteConfig from "./DeleteConfig"; | ||||
| import FilterConfig from "./FilterConfig"; | ||||
| import PresetConfig from "./PresetConfig"; | ||||
| 
 | ||||
| export default class LayerConfig { | ||||
|     static WAYHANDLING_DEFAULT = 0; | ||||
|  | @ -35,7 +35,7 @@ export default class LayerConfig { | |||
|     isShown: TagRenderingConfig; | ||||
|     minzoom: number; | ||||
|     minzoomVisible: number; | ||||
|     maxzoom:number; | ||||
|     maxzoom: number; | ||||
|     title?: TagRenderingConfig; | ||||
|     titleIcons: TagRenderingConfig[]; | ||||
|     icon: TagRenderingConfig; | ||||
|  | @ -51,12 +51,7 @@ export default class LayerConfig { | |||
|     public readonly deletion: DeleteConfig | null; | ||||
|     public readonly allowSplit: boolean | ||||
| 
 | ||||
|     presets: { | ||||
|         title: Translation, | ||||
|         tags: Tag[], | ||||
|         description?: Translation, | ||||
|         preciseInput?: { preferredBackground?: string } | ||||
|     }[]; | ||||
|     presets: PresetConfig[]; | ||||
| 
 | ||||
|     tagRenderings: TagRenderingConfig[]; | ||||
|     filters: FilterConfig[]; | ||||
|  | @ -149,17 +144,41 @@ export default class LayerConfig { | |||
|         this.minzoomVisible = json.minzoomVisible ?? this.minzoom; | ||||
|         this.wayHandling = json.wayHandling ?? 0; | ||||
|         this.presets = (json.presets ?? []).map((pr, i) => { | ||||
|             if (pr.preciseInput === true) { | ||||
|                 pr.preciseInput = { | ||||
|                     preferredBackground: undefined | ||||
|              | ||||
|             let preciseInput = undefined; | ||||
|             if(pr.preciseInput !== undefined){ | ||||
|                 if (pr.preciseInput === true) { | ||||
|                     pr.preciseInput = { | ||||
|                         preferredBackground: undefined | ||||
|                     } | ||||
|                 } | ||||
|                 let snapToLayers: string[]; | ||||
|                 if (typeof pr.preciseInput.snapToLayer === "string") { | ||||
|                     snapToLayers = [pr.preciseInput.snapToLayer] | ||||
|                 } else { | ||||
|                     snapToLayers = pr.preciseInput.snapToLayer | ||||
|                 } | ||||
|                  | ||||
|                 let preferredBackground : string[] | ||||
|                 if (typeof pr.preciseInput.preferredBackground === "string") { | ||||
|                     preferredBackground = [pr.preciseInput.preferredBackground] | ||||
|                 } else { | ||||
|                     preferredBackground = pr.preciseInput.preferredBackground | ||||
|                 } | ||||
|                 preciseInput = { | ||||
|                     preferredBackground: preferredBackground, | ||||
|                     snapToLayers: snapToLayers, | ||||
|                     maxSnapDistance: pr.preciseInput.maxSnapDistance ?? 10 | ||||
|                 } | ||||
|             } | ||||
|             return { | ||||
|           | ||||
|             const config : PresetConfig= { | ||||
|                 title: Translations.T(pr.title, `${context}.presets[${i}].title`), | ||||
|                 tags: pr.tags.map((t) => FromJSON.SimpleTag(t)), | ||||
|                 description: Translations.T(pr.description, `${context}.presets[${i}].description`), | ||||
|                 preciseInput: pr.preciseInput | ||||
|                 preciseInput: preciseInput, | ||||
|             } | ||||
|             return config; | ||||
|         }); | ||||
| 
 | ||||
|         /** Given a key, gets the corresponding property from the json (or the default if not found | ||||
|  | @ -407,12 +426,15 @@ export default class LayerConfig { | |||
|         } | ||||
| 
 | ||||
|         function render(tr: TagRenderingConfig, deflt?: string) { | ||||
|             if(tags === undefined){ | ||||
|                 return deflt | ||||
|             } | ||||
|             const str = tr?.GetRenderValue(tags.data)?.txt ?? deflt; | ||||
|             return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); | ||||
|         } | ||||
| 
 | ||||
|         const iconSize = render(this.iconSize, "40,40,center").split(","); | ||||
|         const dashArray = render(this.dashArray).split(" ").map(Number); | ||||
|         const dashArray = render(this.dashArray)?.split(" ")?.map(Number); | ||||
|         let color = render(this.color, "#00f"); | ||||
| 
 | ||||
|         if (color.startsWith("--")) { | ||||
|  | @ -445,24 +467,26 @@ export default class LayerConfig { | |||
| 
 | ||||
|         const iconUrlStatic = render(this.icon); | ||||
|         const self = this; | ||||
|         const mappedHtml = tags.map((tgs) => { | ||||
|             function genHtmlFromString(sourcePart: string): BaseUIElement { | ||||
|                 const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`; | ||||
|                 let html: BaseUIElement = new FixedUiElement( | ||||
|                     `<img src="${sourcePart}" style="${style}" />` | ||||
|                 ); | ||||
|                 const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/); | ||||
|                 if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { | ||||
|                     html = new Combine([ | ||||
|                         (Svg.All[match[1] + ".svg"] as string).replace( | ||||
|                             /#000000/g, | ||||
|                             match[2] | ||||
|                         ), | ||||
|                     ]).SetStyle(style); | ||||
|                 } | ||||
|                 return html; | ||||
|             } | ||||
| 
 | ||||
|         function genHtmlFromString(sourcePart: string, rotation: string): BaseUIElement { | ||||
|             const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`; | ||||
|             let html: BaseUIElement = new FixedUiElement( | ||||
|                 `<img src="${sourcePart}" style="${style}" />` | ||||
|             ); | ||||
|             const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/); | ||||
|             if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { | ||||
|                 html = new Combine([ | ||||
|                     (Svg.All[match[1] + ".svg"] as string).replace( | ||||
|                         /#000000/g, | ||||
|                         match[2] | ||||
|                     ), | ||||
|                 ]).SetStyle(style); | ||||
|             } | ||||
|             return html; | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         const mappedHtml = tags?.map((tgs) => { | ||||
|             // What do you mean, 'tgs' is never read?
 | ||||
|             // It is read implicitly in the 'render' method
 | ||||
|             const iconUrl = render(self.icon); | ||||
|  | @ -473,7 +497,7 @@ export default class LayerConfig { | |||
|                 iconUrl.split(";").filter((prt) => prt != "") | ||||
|             ); | ||||
|             for (const sourcePart of sourceParts) { | ||||
|                 htmlParts.push(genHtmlFromString(sourcePart)); | ||||
|                 htmlParts.push(genHtmlFromString(sourcePart, rotation)); | ||||
|             } | ||||
| 
 | ||||
|             let badges = []; | ||||
|  | @ -489,7 +513,7 @@ export default class LayerConfig { | |||
|                         .filter((prt) => prt != ""); | ||||
| 
 | ||||
|                     for (const badgePartStr of partDefs) { | ||||
|                         badgeParts.push(genHtmlFromString(badgePartStr)); | ||||
|                         badgeParts.push(genHtmlFromString(badgePartStr, "0")); | ||||
|                     } | ||||
| 
 | ||||
|                     const badgeCompound = new Combine(badgeParts).SetStyle( | ||||
|  | @ -499,7 +523,7 @@ export default class LayerConfig { | |||
|                     badges.push(badgeCompound); | ||||
|                 } else { | ||||
|                     htmlParts.push( | ||||
|                         genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt) | ||||
|                         genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt, "0") | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|  | @ -533,7 +557,7 @@ export default class LayerConfig { | |||
| 
 | ||||
|         return { | ||||
|             icon: { | ||||
|                 html: new VariableUiElement(mappedHtml), | ||||
|                 html: mappedHtml === undefined ? new FixedUiElement(self.icon.render.txt) : new VariableUiElement(mappedHtml), | ||||
|                 iconSize: [iconW, iconH], | ||||
|                 iconAnchor: [anchorW, anchorH], | ||||
|                 popupAnchor: [0, 3 - anchorH], | ||||
|  |  | |||
|  | @ -226,7 +226,21 @@ export interface LayerConfigJson { | |||
|          * If 'preferredBackgroundCategory' is set, the element will attempt to pick a background layer of that category. | ||||
|          */ | ||||
|         preciseInput?: true | { | ||||
|             preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string | ||||
|             /** | ||||
|              * The type of background picture | ||||
|              */ | ||||
|             preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string | string [], | ||||
|             /** | ||||
|              * If specified, these layers will be shown to and the new point will be snapped towards it | ||||
|              */ | ||||
|             snapToLayer?: string | string[], | ||||
|             /** | ||||
|              * If specified, a new point will only be snapped if it is within this range. | ||||
|              * Distance in meter | ||||
|              * | ||||
|              * Default: 10 | ||||
|              */ | ||||
|             maxSnapDistance?: number | ||||
|         } | ||||
|     }[], | ||||
| 
 | ||||
|  |  | |||
|  | @ -99,7 +99,7 @@ export default class LayoutConfig { | |||
|         this.defaultBackgroundId = json.defaultBackgroundId; | ||||
|         this.layers = LayoutConfig.ExtractLayers(json, this.units, official, context); | ||||
| 
 | ||||
|         // ALl the layers are constructed, let them share tags in now!
 | ||||
|         // ALl the layers are constructed, let them share tagRenderings now!
 | ||||
|         const roaming: { r, source: LayerConfig }[] = [] | ||||
|         for (const layer of this.layers) { | ||||
|             roaming.push({r: layer.GetRoamingRenderings(), source: layer}); | ||||
|  |  | |||
							
								
								
									
										16
									
								
								Customizations/JSON/PresetConfig.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								Customizations/JSON/PresetConfig.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import {Translation} from "../../UI/i18n/Translation"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| 
 | ||||
| export default interface PresetConfig { | ||||
|     title: Translation, | ||||
|     tags: Tag[], | ||||
|     description?: Translation, | ||||
|     /** | ||||
|      * If precise input is set, then an extra map is shown in which the user can drag the map to the precise location | ||||
|      */ | ||||
|     preciseInput?: { | ||||
|         preferredBackground?: string[], | ||||
|         snapToLayers?: string[], | ||||
|         maxSnapDistance?: number | ||||
|     } | ||||
| } | ||||
|  | @ -14,7 +14,7 @@ export interface ChangeDescription { | |||
|         lat: number, | ||||
|         lon: number | ||||
|     } | { | ||||
|         // Coordinates are only used for rendering
 | ||||
|         // Coordinates are only used for rendering. They should be lon, lat
 | ||||
|         locations: [number, number][] | ||||
|         nodes: number[], | ||||
|     } | { | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ import OsmChangeAction from "./OsmChangeAction"; | |||
| import {Changes} from "../Changes"; | ||||
| import {ChangeDescription} from "./ChangeDescription"; | ||||
| import {And} from "../../Tags/And"; | ||||
| import {OsmWay} from "../OsmObject"; | ||||
| import {GeoOperations} from "../../GeoOperations"; | ||||
| 
 | ||||
| export default class CreateNewNodeAction extends OsmChangeAction { | ||||
| 
 | ||||
|  | @ -10,13 +12,20 @@ export default class CreateNewNodeAction extends OsmChangeAction { | |||
|     private readonly _lat: number; | ||||
|     private readonly _lon: number; | ||||
| 
 | ||||
|     public newElementId : string = undefined | ||||
|      | ||||
|     constructor(basicTags: Tag[], lat: number, lon: number) { | ||||
|     public newElementId: string = undefined | ||||
|     private readonly _snapOnto: OsmWay; | ||||
|     private readonly _reusePointDistance: number; | ||||
| 
 | ||||
|     constructor(basicTags: Tag[], lat: number, lon: number, options?: { snapOnto: OsmWay, reusePointWithinMeters?: number }) { | ||||
|         super() | ||||
|         this._basicTags = basicTags; | ||||
|         this._lat = lat; | ||||
|         this._lon = lon; | ||||
|         if(lat === undefined || lon === undefined){ | ||||
|             throw "Lat or lon are undefined!" | ||||
|         } | ||||
|         this._snapOnto = options?.snapOnto; | ||||
|         this._reusePointDistance = options.reusePointWithinMeters ?? 1 | ||||
|     } | ||||
| 
 | ||||
|     CreateChangeDescriptions(changes: Changes): ChangeDescription[] { | ||||
|  | @ -24,7 +33,7 @@ export default class CreateNewNodeAction extends OsmChangeAction { | |||
|         const properties = { | ||||
|             id: "node/" + id | ||||
|         } | ||||
|         this.newElementId = "node/"+id | ||||
|         this.newElementId = "node/" + id | ||||
|         for (const kv of this._basicTags) { | ||||
|             if (typeof kv.value !== "string") { | ||||
|                 throw "Invalid value: don't use a regex in a preset" | ||||
|  | @ -32,16 +41,68 @@ export default class CreateNewNodeAction extends OsmChangeAction { | |||
|             properties[kv.key] = kv.value; | ||||
|         } | ||||
| 
 | ||||
|         return [{ | ||||
|         const newPointChange: ChangeDescription = { | ||||
|             tags: new And(this._basicTags).asChange(properties), | ||||
|             type: "node", | ||||
|             id: id, | ||||
|             changes:{ | ||||
|             changes: { | ||||
|                 lat: this._lat, | ||||
|                 lon: this._lon | ||||
|             } | ||||
|         }] | ||||
|         } | ||||
|         if (this._snapOnto === undefined) { | ||||
|             return [newPointChange] | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         // Project the point onto the way
 | ||||
| 
 | ||||
|         const geojson = this._snapOnto.asGeoJson() | ||||
|         const projected = GeoOperations.nearestPoint(geojson, [this._lon, this._lat]) | ||||
|         const index = projected.properties.index | ||||
|         // We check that it isn't close to an already existing point
 | ||||
|         let reusedPointId = undefined; | ||||
|         const prev = <[number, number]>geojson.geometry.coordinates[index] | ||||
|         if (GeoOperations.distanceBetween(prev, <[number, number]>projected.geometry.coordinates) * 1000 < this._reusePointDistance) { | ||||
|             // We reuse this point instead!
 | ||||
|             reusedPointId = this._snapOnto.nodes[index] | ||||
|         } | ||||
|         const next = <[number, number]>geojson.geometry.coordinates[index + 1] | ||||
|         if (GeoOperations.distanceBetween(next, <[number, number]>projected.geometry.coordinates) * 1000 < this._reusePointDistance) { | ||||
|             // We reuse this point instead!
 | ||||
|             reusedPointId = this._snapOnto.nodes[index + 1] | ||||
|         } | ||||
|         if (reusedPointId !== undefined) { | ||||
|             console.log("Reusing an existing point:", reusedPointId) | ||||
|             this.newElementId = "node/" + reusedPointId | ||||
| 
 | ||||
|             return [{ | ||||
|                 tags: new And(this._basicTags).asChange(properties), | ||||
|                 type: "node", | ||||
|                 id: reusedPointId | ||||
|             }] | ||||
|         } | ||||
|          | ||||
|         const locations = [...this._snapOnto.coordinates] | ||||
|         locations.forEach(coor => coor.reverse()) | ||||
|         console.log("Locations are: ", locations) | ||||
|         const ids = [...this._snapOnto.nodes] | ||||
|          | ||||
|         locations.splice(index + 1, 0, [this._lon, this._lat]) | ||||
|         ids.splice(index + 1, 0, id) | ||||
|          | ||||
|         // Allright, we have to insert a new point in the way
 | ||||
|         return [ | ||||
|             newPointChange, | ||||
|             { | ||||
|                 type:"way", | ||||
|                 id: this._snapOnto.id, | ||||
|                 changes: { | ||||
|                     locations: locations, | ||||
|                     nodes: ids | ||||
|                 } | ||||
|             } | ||||
|         ] | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -27,9 +27,6 @@ export class Changes { | |||
|     private readonly previouslyCreated : OsmObject[] = [] | ||||
| 
 | ||||
|     constructor() { | ||||
|        this.isUploading.addCallbackAndRun(uploading => { | ||||
|            console.trace("Is uploading changed:", uploading) | ||||
|        }) | ||||
|     } | ||||
| 
 | ||||
|     private static createChangesetFor(csId: string, | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import { Utils } from "../Utils"; | |||
| 
 | ||||
| export default class Constants { | ||||
|      | ||||
|     public static vNumber = "0.9.0-rc0"; | ||||
|     public static vNumber = "0.9.0-rc2"; | ||||
| 
 | ||||
|     // The user journey states thresholds when a new feature gets unlocked
 | ||||
|     public static userJourney = { | ||||
|  |  | |||
|  | @ -9,19 +9,16 @@ import Combine from "../Base/Combine"; | |||
| import Translations from "../i18n/Translations"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| import LocationInput from "../Input/LocationInput"; | ||||
| import {InputElement} from "../Input/InputElement"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; | ||||
| import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; | ||||
| import Hash from "../../Logic/Web/Hash"; | ||||
| import PresetConfig from "../../Customizations/JSON/PresetConfig"; | ||||
| import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; | ||||
| 
 | ||||
| /* | ||||
| * The SimpleAddUI is a single panel, which can have multiple states: | ||||
|  | @ -32,17 +29,12 @@ import Hash from "../../Logic/Web/Hash"; | |||
|  */ | ||||
| 
 | ||||
| /*private*/ | ||||
| interface PresetInfo { | ||||
|     description: string | Translation, | ||||
| interface PresetInfo extends PresetConfig { | ||||
|     name: string | BaseUIElement, | ||||
|     icon: () => BaseUIElement, | ||||
|     tags: Tag[], | ||||
|     layerToAddTo: { | ||||
|         layerDef: LayerConfig, | ||||
|         isDisplayed: UIEventSource<boolean> | ||||
|     }, | ||||
|     preciseInput?: { | ||||
|         preferredBackground?: string | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -65,24 +57,43 @@ export default class SimpleAddUI extends Toggle { | |||
| 
 | ||||
|         const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) | ||||
| 
 | ||||
| 
 | ||||
|         function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) { | ||||
|             console.trace("Creating a new point") | ||||
|             const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {snapOnto: snapOntoWay}) | ||||
|             State.state.changes.applyAction(newElementAction) | ||||
|             selectedPreset.setData(undefined) | ||||
|             isShown.setData(false) | ||||
|             State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( | ||||
|                 newElementAction.newElementId | ||||
|             )) | ||||
|             console.log("Did set selected element to", State.state.allElements.ContainingFeatures.get( | ||||
|                 newElementAction.newElementId | ||||
|             )) | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         const addUi = new VariableUiElement( | ||||
|             selectedPreset.map(preset => { | ||||
|                     if (preset === undefined) { | ||||
|                         return presetsOverview | ||||
|                     } | ||||
|                     return SimpleAddUI.CreateConfirmButton(preset, | ||||
|                         (tags, location) => { | ||||
|                         const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon) | ||||
|                             State.state.changes.applyAction(newElementAction) | ||||
|                             selectedPreset.setData(undefined) | ||||
|                             isShown.setData(false) | ||||
|                             State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( | ||||
|                                 newElementAction.newElementId | ||||
|                             )) | ||||
|                             console.log("Did set selected element to",State.state.allElements.ContainingFeatures.get( | ||||
|                                 newElementAction.newElementId | ||||
|                             )) | ||||
|                         }, () => { | ||||
|                         (tags, location, snapOntoWayId?: string) => { | ||||
|                             if (snapOntoWayId === undefined) { | ||||
|                                 createNewPoint(tags, location, undefined) | ||||
|                             } else { | ||||
|                                 OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => { | ||||
|                                     createNewPoint(tags, location,<OsmWay> way) | ||||
|                                     return true; | ||||
|                                 }) | ||||
|                             } | ||||
| 
 | ||||
| 
 | ||||
|                         }, | ||||
| 
 | ||||
| 
 | ||||
|                         () => { | ||||
|                             selectedPreset.setData(undefined) | ||||
|                         }) | ||||
|                 } | ||||
|  | @ -115,11 +126,11 @@ export default class SimpleAddUI extends Toggle { | |||
| 
 | ||||
| 
 | ||||
|     private static CreateConfirmButton(preset: PresetInfo, | ||||
|                                        confirm: (tags: any[], location: { lat: number, lon: number }) => void, | ||||
|                                        confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, | ||||
|                                        cancel: () => void): BaseUIElement { | ||||
| 
 | ||||
|         let location = State.state.LastClickLocation; | ||||
|         let preciseInput: InputElement<Loc> = undefined | ||||
|         let preciseInput: LocationInput = undefined | ||||
|         if (preset.preciseInput !== undefined) { | ||||
|             const locationSrc = new UIEventSource({ | ||||
|                 lat: location.data.lat, | ||||
|  | @ -132,9 +143,22 @@ export default class SimpleAddUI extends Toggle { | |||
|                 backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground)) | ||||
|             } | ||||
| 
 | ||||
|             let features: UIEventSource<{ feature: any }[]> = 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))) | ||||
|             } | ||||
| 
 | ||||
|             const tags = TagUtils.KVtoProperties(preset.tags ?? []); | ||||
|             console.log("Opening precise input ", preset.preciseInput, "with tags", tags) | ||||
|             preciseInput = new LocationInput({ | ||||
|                 mapBackground: backgroundLayer, | ||||
|                 centerLocation: locationSrc | ||||
|                 centerLocation: locationSrc, | ||||
|                 snapTo: features, | ||||
|                 snappedPointTags: tags, | ||||
|                 maxSnapDistance: preset.preciseInput.maxSnapDistance | ||||
| 
 | ||||
|             }) | ||||
|             preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") | ||||
|  | @ -148,7 +172,7 @@ export default class SimpleAddUI extends Toggle { | |||
|             ]).SetClass("flex flex-col") | ||||
|         ).SetClass("font-bold break-words") | ||||
|             .onClick(() => { | ||||
|                 confirm(preset.tags, (preciseInput?.GetValue() ?? location).data); | ||||
|                 confirm(preset.tags, (preciseInput?.GetValue() ?? location).data, preciseInput?.snappedOnto?.data?.properties?.id); | ||||
|             }); | ||||
| 
 | ||||
|         if (preciseInput !== undefined) { | ||||
|  | @ -242,8 +266,8 @@ export default class SimpleAddUI extends Toggle { | |||
|                 // The layer is not displayed and we cannot enable the layer control -> we skip
 | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
|             if(layer.layerDef.name === undefined){ | ||||
| 
 | ||||
|             if (layer.layerDef.name === undefined) { | ||||
|                 // this is a parlty hidden layer
 | ||||
|                 continue; | ||||
|             } | ||||
|  | @ -258,6 +282,7 @@ export default class SimpleAddUI extends Toggle { | |||
|                     tags: preset.tags, | ||||
|                     layerToAddTo: layer, | ||||
|                     name: preset.title, | ||||
|                     title: preset.title, | ||||
|                     description: preset.description, | ||||
|                     icon: icon, | ||||
|                     preciseInput: preset.preciseInput | ||||
|  |  | |||
|  | @ -6,28 +6,114 @@ import BaseLayer from "../../Models/BaseLayer"; | |||
| 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 LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import ShowDataLayer from "../ShowDataLayer"; | ||||
| 
 | ||||
| export default class LocationInput extends InputElement<Loc> { | ||||
| 
 | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private _centerLocation: UIEventSource<Loc>; | ||||
|     private readonly mapBackground : UIEventSource<BaseLayer>; | ||||
|     private readonly mapBackground: UIEventSource<BaseLayer>; | ||||
|     private readonly _snapTo: UIEventSource<{ feature: any }[]> | ||||
|     private readonly _value: UIEventSource<Loc> | ||||
|     private readonly _snappedPoint: UIEventSource<any> | ||||
|     private readonly _maxSnapDistance: number | ||||
|     private readonly _snappedPointTags: any; | ||||
|     public readonly snappedOnto: UIEventSource<any> = new UIEventSource<any>(undefined) | ||||
| 
 | ||||
|     constructor(options?: { | ||||
|     constructor(options: { | ||||
|         mapBackground?: UIEventSource<BaseLayer>, | ||||
|         centerLocation?: UIEventSource<Loc>, | ||||
|         snapTo?: UIEventSource<{ feature: any }[]>, | ||||
|         maxSnapDistance?: number, | ||||
|         snappedPointTags?: any, | ||||
|         requiresSnapping?: boolean, | ||||
|         centerLocation: UIEventSource<Loc>, | ||||
|     }) { | ||||
|         super(); | ||||
|         options = options ?? {} | ||||
|         options.centerLocation = options.centerLocation ?? new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1}) | ||||
|         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 | ||||
|         if (this._snapTo === undefined) { | ||||
|             this._value = this._centerLocation; | ||||
|         } else { | ||||
|             const self = this; | ||||
| 
 | ||||
|         this.mapBackground = options.mapBackground ?? State.state.backgroundLayer | ||||
|             let matching_layer: UIEventSource<string> | ||||
| 
 | ||||
|             if (self._snappedPointTags !== undefined) { | ||||
|                 matching_layer = State.state.layoutToUse.map(layout => { | ||||
| 
 | ||||
|                     for (const layer of layout.layers) { | ||||
|                         if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) { | ||||
|                             return layer.id | ||||
|                         } | ||||
|                     } | ||||
|                     console.error("No matching layer found for tags ", self._snappedPointTags) | ||||
|                     return "matchpoint" | ||||
|                 }) | ||||
|             } else { | ||||
|                 matching_layer = new UIEventSource<string>("matchpoint") | ||||
|             } | ||||
| 
 | ||||
|             this._snappedPoint = options.centerLocation.map(loc => { | ||||
|                 if (loc === undefined) { | ||||
|                     return undefined; | ||||
|                 } | ||||
| 
 | ||||
|                 // We reproject the location onto every 'snap-to-feature' and select the closest
 | ||||
| 
 | ||||
|                 let min = undefined; | ||||
|                 let matchedWay = undefined; | ||||
|                 for (const feature of self._snapTo.data) { | ||||
|                     const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat]) | ||||
|                     if (min === undefined) { | ||||
|                         min = nearestPointOnLine | ||||
|                         matchedWay = feature.feature; | ||||
|                         continue; | ||||
|                     } | ||||
| 
 | ||||
|                     if (min.properties.dist > nearestPointOnLine.properties.dist) { | ||||
|                         min = nearestPointOnLine | ||||
|                         matchedWay = feature.feature; | ||||
| 
 | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 if (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 | ||||
|             }, [this._snapTo]) | ||||
| 
 | ||||
|             this._value = this._snappedPoint.map(f => { | ||||
|                 const [lon, lat] = f.geometry.coordinates; | ||||
|                 return { | ||||
|                     lon: lon, lat: lat, zoom: undefined  | ||||
|                 } | ||||
|             }) | ||||
| 
 | ||||
|         } | ||||
|         this.mapBackground = options.mapBackground ?? State.state.backgroundLayer ?? new UIEventSource(AvailableBaseLayers.osmCarto) | ||||
|         this.SetClass("block h-full") | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<Loc> { | ||||
|         return this._centerLocation; | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: Loc): boolean { | ||||
|  | @ -35,41 +121,88 @@ export default class LocationInput extends InputElement<Loc> { | |||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const map = new Minimap( | ||||
|             { | ||||
|                 location: this._centerLocation, | ||||
|                 background: this.mapBackground | ||||
|             } | ||||
|         ) | ||||
|         map.leafletMap.addCallbackAndRunD(leaflet => { | ||||
|             leaflet.setMaxBounds( | ||||
|                 leaflet.getBounds().pad(0.15) | ||||
|         try { | ||||
|             const map = new Minimap( | ||||
|                 { | ||||
|                     location: this._centerLocation, | ||||
|                     background: this.mapBackground | ||||
|                 } | ||||
|             ) | ||||
|         }) | ||||
|             map.leafletMap.addCallbackAndRunD(leaflet => { | ||||
|                 leaflet.setMaxBounds( | ||||
|                     leaflet.getBounds().pad(0.15) | ||||
|                 ) | ||||
|             }) | ||||
| 
 | ||||
|         this.mapBackground.map(layer => { | ||||
|             if (this._snapTo !== undefined) { | ||||
|                 new ShowDataLayer(this._snapTo, map.leafletMap, State.state.layoutToUse, false, false) | ||||
| 
 | ||||
|             const leaflet = map.leafletMap.data | ||||
|             if (leaflet === undefined || layer === undefined) { | ||||
|                 return; | ||||
|                 const matchPoint = this._snappedPoint.map(loc => { | ||||
|                     if (loc === undefined) { | ||||
|                         return [] | ||||
|                     } | ||||
|                     return [{feature: loc}]; | ||||
|                 }) | ||||
|                 if (this._snapTo) { | ||||
|                     let layout = LocationInput.matchLayout | ||||
|                     if (this._snappedPointTags !== undefined) { | ||||
|                         layout = State.state.layoutToUse | ||||
|                     } | ||||
|                     new ShowDataLayer( | ||||
|                         matchPoint, | ||||
|                         map.leafletMap, | ||||
|                         layout, | ||||
|                         false, false | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             leaflet.setMaxZoom(layer.max_zoom) | ||||
|             leaflet.setMinZoom(layer.max_zoom - 3) | ||||
|             leaflet.setZoom(layer.max_zoom - 1) | ||||
|             this.mapBackground.map(layer => { | ||||
|                 const leaflet = map.leafletMap.data | ||||
|                 if (leaflet === undefined || layer === undefined) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|         }, [map.leafletMap]) | ||||
|         return new Combine([ | ||||
|             new Combine([ | ||||
|                 Svg.crosshair_empty_ui() | ||||
|                     .SetClass("block relative") | ||||
|                     .SetStyle("left: -1.25rem; top: -1.25rem; width: 2.5rem; height: 2.5rem") | ||||
|             ]).SetClass("block w-0 h-0 z-10 relative") | ||||
|                 .SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"), | ||||
|             map | ||||
|                 .SetClass("z-0 relative block w-full h-full bg-gray-100") | ||||
|                 leaflet.setMaxZoom(layer.max_zoom) | ||||
|                 leaflet.setMinZoom(layer.max_zoom - 3) | ||||
|                 leaflet.setZoom(layer.max_zoom - 1) | ||||
| 
 | ||||
|         ]).ConstructElement(); | ||||
|             }, [map.leafletMap]) | ||||
|             return new Combine([ | ||||
|                 new Combine([ | ||||
|                     Svg.crosshair_empty_ui() | ||||
|                         .SetClass("block relative") | ||||
|                         .SetStyle("left: -1.25rem; top: -1.25rem; width: 2.5rem; height: 2.5rem") | ||||
|                 ]).SetClass("block w-0 h-0 z-10 relative") | ||||
|                     .SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"), | ||||
|                 map | ||||
|                     .SetClass("z-0 relative block w-full h-full bg-gray-100") | ||||
| 
 | ||||
|             ]).ConstructElement(); | ||||
|         } catch (e) { | ||||
|             console.error("Could not generate LocationInputElement:", e) | ||||
|             return undefined; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static readonly matchLayout = new UIEventSource(new LayoutConfig({ | ||||
|         description: "Matchpoint style", | ||||
|         icon: "./assets/svg/crosshair-empty.svg", | ||||
|         id: "matchpoint", | ||||
|         language: ["en"], | ||||
|         layers: [{ | ||||
|             id: "matchpoint", source: { | ||||
|                 osmTags: {and: []} | ||||
|             }, | ||||
|             icon: "./assets/svg/crosshair-empty.svg" | ||||
|         }], | ||||
|         maintainer: "MapComplete", | ||||
|         startLat: 0, | ||||
|         startLon: 0, | ||||
|         startZoom: 0, | ||||
|         title: "Location input", | ||||
|         version: "0" | ||||
| 
 | ||||
|     })); | ||||
| 
 | ||||
| } | ||||
|  | @ -16,9 +16,9 @@ export default class ShowDataLayer { | |||
|     private readonly _leafletMap: UIEventSource<L.Map>; | ||||
|     private _cleanCount = 0; | ||||
|     private readonly _enablePopups: boolean; | ||||
|     private readonly _features: UIEventSource<{ feature: any}[]> | ||||
|     private readonly _features: UIEventSource<{ feature: any }[]> | ||||
| 
 | ||||
|     constructor(features: UIEventSource<{ feature: any}[]>, | ||||
|     constructor(features: UIEventSource<{ feature: any }[]>, | ||||
|                 leafletMap: UIEventSource<L.Map>, | ||||
|                 layoutToUse: UIEventSource<LayoutConfig>, | ||||
|                 enablePopups = true, | ||||
|  | @ -85,7 +85,9 @@ export default class ShowDataLayer { | |||
|                     console.error(e) | ||||
|                 } | ||||
|             } | ||||
|             State.state.selectedElement.ping() | ||||
|             if (self._enablePopups) { | ||||
|                 State.state.selectedElement.ping() | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         features.addCallback(() => update()); | ||||
|  | @ -106,13 +108,12 @@ export default class ShowDataLayer { | |||
|         // We have to convert them to the appropriate icon
 | ||||
|         // Click handling is done in the next step
 | ||||
| 
 | ||||
|         const tagSource = State.state.allElements.getEventSourceById(feature.properties.id) | ||||
|         const layer: LayerConfig = this._layerDict[feature._matching_layer_id]; | ||||
| 
 | ||||
|         if (layer === undefined) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const tagSource = feature.properties.id === undefined ? new UIEventSource<any>(feature.properties) : State.state.allElements.getEventSourceById(feature.properties.id) | ||||
|         const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)); | ||||
|         const baseElement = style.icon.html; | ||||
|         if (!this._enablePopups) { | ||||
|  | @ -146,8 +147,8 @@ export default class ShowDataLayer { | |||
|             autoPan: true, | ||||
|             closeOnEscapeKey: true, | ||||
|             closeButton: false, | ||||
|             autoPanPaddingTopLeft: [15,15], | ||||
|              | ||||
|             autoPanPaddingTopLeft: [15, 15], | ||||
| 
 | ||||
|         }, leafletLayer); | ||||
| 
 | ||||
|         leafletLayer.bindPopup(popup); | ||||
|  | @ -191,7 +192,7 @@ export default class ShowDataLayer { | |||
|                 ) { | ||||
|                     leafletLayer.openPopup() | ||||
|                 } | ||||
|                 if(feature.id !== feature.properties.id){ | ||||
|                 if (feature.id !== feature.properties.id) { | ||||
|                     console.trace("Not opening the popup for", feature) | ||||
|                 } | ||||
| 
 | ||||
|  |  | |||
|  | @ -53,6 +53,11 @@ | |||
|       "description": { | ||||
|         "en": "A bollard in the road", | ||||
|         "nl": "Een paaltje in de weg" | ||||
|       }, | ||||
|       "preciseInput": { | ||||
|         "preferredBackground": ["photo"], | ||||
|         "snapToLayer": "cycleways_and_roads", | ||||
|         "maxSnapDistance": 25 | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|  | @ -66,6 +71,11 @@ | |||
|       "description": { | ||||
|         "en": "Cycle barrier, slowing down cyclists", | ||||
|         "nl": "Fietshekjes, voor het afremmen van fietsers" | ||||
|       }, | ||||
|       "preciseInput": { | ||||
|         "preferredBackground": ["photo"], | ||||
|         "snapToLayer": "cycleways_and_roads", | ||||
|         "maxSnapDistance": 25 | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|  |  | |||
|  | @ -66,6 +66,11 @@ | |||
|       "description": { | ||||
|         "en": "Crossing for pedestrians and/or cyclists", | ||||
|         "nl": "Oversteekplaats voor voetgangers en/of fietsers" | ||||
|       }, | ||||
|       "preciseInput": { | ||||
|         "preferredBackground": ["photo"], | ||||
|         "snapToLayer": "cycleways_and_roads", | ||||
|         "maxSnapDistance": 25 | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|  | @ -79,6 +84,11 @@ | |||
|       "description": { | ||||
|         "en": "Traffic signal on a road", | ||||
|         "nl": "Verkeerslicht op een weg" | ||||
|       }, | ||||
|       "preciseInput": { | ||||
|         "preferredBackground": ["photo"], | ||||
|         "snapToLayer": "cycleways_and_roads", | ||||
|         "maxSnapDistance": 25 | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|  |  | |||
|  | @ -16,14 +16,14 @@ | |||
|     "en", | ||||
|     "nl" | ||||
|   ], | ||||
|   "maintainer": "", | ||||
|   "maintainer": "MapComplete", | ||||
|   "defaultBackgroundId": "CartoDB.Voyager", | ||||
|   "icon": "./assets/themes/cycle_infra/cycle-infra.svg", | ||||
|   "version": "0", | ||||
|   "startLat": 51, | ||||
|   "startLon": 3.75, | ||||
|   "startZoom": 11, | ||||
|   "widenFactor": 0, | ||||
|   "widenFactor": 0.05, | ||||
|   "socialImage": "./assets/themes/cycle_infra/cycle-infra.svg", | ||||
|   "enableDownload": true, | ||||
|   "layers": [ | ||||
|  |  | |||
							
								
								
									
										66
									
								
								test.ts
									
										
									
									
									
								
							
							
						
						
									
										66
									
								
								test.ts
									
										
									
									
									
								
							|  | @ -2,40 +2,49 @@ import {UIEventSource} from "./Logic/UIEventSource"; | |||
| import LayoutConfig from "./Customizations/JSON/LayoutConfig"; | ||||
| import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; | ||||
| import State from "./State"; | ||||
| import LocationInput from "./UI/Input/LocationInput"; | ||||
| import Loc from "./Models/Loc"; | ||||
| import {VariableUiElement} from "./UI/Base/VariableUIElement"; | ||||
| import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; | ||||
| 
 | ||||
| const layout = new UIEventSource<LayoutConfig>(AllKnownLayouts.allKnownLayouts.get("bookcases")) | ||||
| const layout = new UIEventSource<LayoutConfig>(AllKnownLayouts.allKnownLayouts.get("cycle_infra")) | ||||
| State.state = new State(layout.data) | ||||
| 
 | ||||
| const features = new UIEventSource<{ feature: any }[]>([ | ||||
|     { | ||||
|         feature: { | ||||
|             "type": "Feature", | ||||
|             "properties": {"amenity": "public_bookcase", "id": "node/123"}, | ||||
| 
 | ||||
|             id: "node/123", | ||||
|             _matching_layer_id: "public_bookcase", | ||||
|             "properties": {}, | ||||
|             "geometry": { | ||||
|                 "type": "Point", | ||||
|                 "type": "LineString", | ||||
|                 "coordinates": [ | ||||
|                     3.220506906509399, | ||||
|                     51.215009243433094 | ||||
|                     [ | ||||
|                         3.219616413116455, | ||||
|                         51.215315026941276 | ||||
|                     ], | ||||
|                     [ | ||||
|                         3.221080899238586, | ||||
|                         51.21564432998662 | ||||
|                     ] | ||||
|                 ] | ||||
|             } | ||||
|         } | ||||
|     }, { | ||||
|     }, | ||||
|     { | ||||
|         feature: { | ||||
|             "type": "Feature", | ||||
|             "properties": { | ||||
|                 amenity: "public_bookcase", | ||||
|                 id: "node/456" | ||||
|             }, | ||||
|             _matching_layer_id: "public_bookcase", | ||||
|             id: "node/456", | ||||
|             "properties": {}, | ||||
|             "geometry": { | ||||
|                 "type": "Point", | ||||
|                 "type": "LineString", | ||||
|                 "coordinates": [ | ||||
|                     3.4243011474609375, | ||||
|                     51.138432319543924 | ||||
|                     [ | ||||
|                         3.220340609550476, | ||||
|                         51.21547967875836 | ||||
|                     ], | ||||
|                     [ | ||||
|                         3.2198095321655273, | ||||
|                         51.216390293480515 | ||||
|                     ] | ||||
|                 ] | ||||
|             } | ||||
|         } | ||||
|  | @ -43,5 +52,22 @@ const features = new UIEventSource<{ feature: any }[]>([ | |||
| ]) | ||||
| 
 | ||||
| features.data.map(f => State.state.allElements.addOrGetElement(f.feature)) | ||||
| 
 | ||||
| 
 | ||||
| const loc = new UIEventSource<Loc>({ | ||||
|     zoom: 19, | ||||
|     lat: 51.21547967875836, | ||||
|     lon: 3.220340609550476 | ||||
| }) | ||||
| const li = new LocationInput( | ||||
|     { | ||||
|         mapBackground: AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource<string | string[]>("map")), | ||||
|         snapTo: features, | ||||
|         snappedPointTags: { | ||||
|             "barrier": "cycle_barrier" | ||||
|         }, | ||||
|         maxSnapDistance: 15, | ||||
|         requiresSnapping: false, | ||||
|         centerLocation: loc | ||||
|     } | ||||
| ) | ||||
| li.SetStyle("height: 30rem").AttachTo("maindiv") | ||||
| new VariableUiElement(li.GetValue().map(JSON.stringify)).AttachTo("extradiv") | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue