forked from MapComplete/MapComplete
		
	Merge develop
This commit is contained in:
		
						commit
						cc22c5b0fb
					
				
					 17 changed files with 537 additions and 134 deletions
				
			
		
							
								
								
									
										185
									
								
								UI/Input/LengthInput.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								UI/Input/LengthInput.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,185 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Svg from "../../Svg"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import {GeoOperations} from "../../Logic/GeoOperations"; | ||||
| import DirectionInput from "./DirectionInput"; | ||||
| import {RadioButton} from "./RadioButton"; | ||||
| import {FixedInputElement} from "./FixedInputElement"; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Selects a length after clicking on the minimap, in meters | ||||
|  */ | ||||
| export default class LengthInput extends InputElement<string> { | ||||
|     private readonly _location: UIEventSource<Loc>; | ||||
| 
 | ||||
|     public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly value: UIEventSource<string>; | ||||
|     private background; | ||||
| 
 | ||||
|     constructor(mapBackground: UIEventSource<any>, | ||||
|                 location: UIEventSource<Loc>, | ||||
|                 value?: UIEventSource<string>) { | ||||
|         super(); | ||||
|         this._location = location; | ||||
|         this.value = value ?? new UIEventSource<string>(undefined); | ||||
|         this.background = mapBackground; | ||||
|         this.SetClass("block") | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<string> { | ||||
|         return this.value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(str: string): boolean { | ||||
|         const t = Number(str) | ||||
|         return !isNaN(t) && t >= 0 && t <= 360; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const modeElement = new RadioButton([ | ||||
|             new FixedInputElement("Measure", "measure"), | ||||
|             new FixedInputElement("Move", "move") | ||||
|         ]) | ||||
|         // @ts-ignore
 | ||||
|         let map = undefined | ||||
|         if (!Utils.runningFromConsole) { | ||||
|             map = DirectionInput.constructMinimap({ | ||||
|                 background: this.background, | ||||
|                 allowMoving: false, | ||||
|                 location: this._location, | ||||
|                 leafletOptions: { | ||||
|                     tap: true | ||||
|                 } | ||||
|             }) | ||||
|         } | ||||
|         const element = new Combine([ | ||||
|             new Combine([Svg.length_crosshair_svg().SetStyle( | ||||
|                 `position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`) | ||||
|             ]) | ||||
|                 .SetClass("block length-crosshair-svg relative") | ||||
|                 .SetStyle("z-index: 1000; visibility: hidden"), | ||||
|             map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"), | ||||
|         ]) | ||||
|             .SetClass("relative block bg-white border border-black rounded-3xl overflow-hidden") | ||||
|             .ConstructElement() | ||||
| 
 | ||||
| 
 | ||||
|         this.RegisterTriggers(element, map?.leafletMap) | ||||
|         element.style.overflow = "hidden" | ||||
|         element.style.display = "block" | ||||
|          | ||||
|       return element | ||||
|     } | ||||
| 
 | ||||
|     private RegisterTriggers(htmlElement: HTMLElement, leafletMap: UIEventSource<L.Map>) { | ||||
| 
 | ||||
|         let firstClickXY: [number, number] = undefined | ||||
|         let lastClickXY: [number, number] = undefined | ||||
|         const self = this; | ||||
|          | ||||
| 
 | ||||
|         function onPosChange(x: number, y: number, isDown: boolean, isUp?: boolean) { | ||||
|             if (x === undefined || y === undefined) { | ||||
|                 // Touch end
 | ||||
|                 firstClickXY = undefined; | ||||
|                 lastClickXY = undefined; | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const rect = htmlElement.getBoundingClientRect(); | ||||
|             // From the central part of location
 | ||||
|             const dx = x - rect.left; | ||||
|             const dy = y - rect.top; | ||||
|             if (isDown) { | ||||
|                 if (lastClickXY === undefined && firstClickXY === undefined) { | ||||
|                     firstClickXY = [dx, dy]; | ||||
|                 } else if (firstClickXY !== undefined && lastClickXY === undefined) { | ||||
|                     lastClickXY = [dx, dy] | ||||
|                 } else if (firstClickXY !== undefined && lastClickXY !== undefined) { | ||||
|                     // we measure again
 | ||||
|                     firstClickXY = [dx, dy] | ||||
|                     lastClickXY = undefined; | ||||
|                 } | ||||
|             } | ||||
|             if (isUp) { | ||||
|                 const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0])) | ||||
|                 if (distance > 15) { | ||||
|                     lastClickXY = [dx, dy] | ||||
|                 } | ||||
| 
 | ||||
| 
 | ||||
|             } else if (lastClickXY !== undefined) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             const measurementCrosshair = htmlElement.getElementsByClassName("length-crosshair-svg")[0] as HTMLElement | ||||
|              | ||||
|             const measurementCrosshairInner: HTMLElement = <HTMLElement>measurementCrosshair.firstChild | ||||
|             if (firstClickXY === undefined) { | ||||
|                 measurementCrosshair.style.visibility = "hidden" | ||||
|             } else { | ||||
|                 measurementCrosshair.style.visibility = "unset" | ||||
|                 measurementCrosshair.style.left = firstClickXY[0] + "px"; | ||||
|                 measurementCrosshair.style.top = firstClickXY[1] + "px" | ||||
| 
 | ||||
|                 const angle = 180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx) / Math.PI; | ||||
|                 const angleGeo = (angle + 270) % 360 | ||||
|                 measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)`; | ||||
| 
 | ||||
|                 const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0])) | ||||
|                 measurementCrosshairInner.style.width = (distance * 2) + "px" | ||||
|                 measurementCrosshairInner.style.marginLeft = -distance + "px" | ||||
|                 measurementCrosshairInner.style.marginTop = -distance + "px" | ||||
| 
 | ||||
| 
 | ||||
|                 const leaflet = leafletMap?.data | ||||
|                 if (leaflet) { | ||||
|                     const first = leaflet.layerPointToLatLng(firstClickXY) | ||||
|                     const last = leaflet.layerPointToLatLng([dx, dy]) | ||||
|                     const geoDist = Math.floor(GeoOperations.distanceBetween([first.lng, first.lat], [last.lng, last.lat]) * 100000) / 100 | ||||
|                     self.value.setData("" + geoDist) | ||||
|                 } | ||||
| 
 | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         htmlElement.ontouchstart = (ev: TouchEvent) => { | ||||
|             onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.ontouchmove = (ev: TouchEvent) => { | ||||
|             onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.ontouchend = (ev: TouchEvent) => { | ||||
|             onPosChange(undefined, undefined, false, true); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.onmousedown = (ev: MouseEvent) => { | ||||
|             onPosChange(ev.clientX, ev.clientY, true); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.onmouseup = (ev) => { | ||||
|             onPosChange(ev.clientX, ev.clientY, false, true); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.onmousemove = (ev: MouseEvent) => { | ||||
|             onPosChange(ev.clientX, ev.clientY, false); | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -13,6 +13,8 @@ import {Utils} from "../../Utils"; | |||
| import Loc from "../../Models/Loc"; | ||||
| import {Unit} from "../../Customizations/JSON/Denomination"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import LengthInput from "./LengthInput"; | ||||
| import {GeoOperations} from "../../Logic/GeoOperations"; | ||||
| 
 | ||||
| interface TextFieldDef { | ||||
|     name: string, | ||||
|  | @ -21,14 +23,16 @@ interface TextFieldDef { | |||
|     reformat?: ((s: string, country?: () => string) => string), | ||||
|     inputHelper?: (value: UIEventSource<string>, options?: { | ||||
|         location: [number, number], | ||||
|         mapBackgroundLayer?: UIEventSource<any> | ||||
|         mapBackgroundLayer?: UIEventSource<any>, | ||||
|         args: (string | number | boolean)[] | ||||
|         feature?: any | ||||
|     }) => InputElement<string>, | ||||
| 
 | ||||
|     inputmode?: string | ||||
| } | ||||
| 
 | ||||
| export default class ValidatedTextField { | ||||
| 
 | ||||
|     public static bestLayerAt: (location: UIEventSource<Loc>, preferences: UIEventSource<string[]>) => any | ||||
| 
 | ||||
|     public static tpList: TextFieldDef[] = [ | ||||
|         ValidatedTextField.tp( | ||||
|  | @ -63,6 +67,83 @@ export default class ValidatedTextField { | |||
|                 return [year, month, day].join('-'); | ||||
|             }, | ||||
|             (value) => new SimpleDatePicker(value)), | ||||
|         ValidatedTextField.tp( | ||||
|             "direction", | ||||
|             "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)", | ||||
|             (str) => { | ||||
|                 str = "" + str; | ||||
|                 return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360 | ||||
|             }, str => str, | ||||
|             (value, options) => { | ||||
|                 const args = options.args ?? [] | ||||
|                 let zoom = 19 | ||||
|                 if (args[0]) { | ||||
|                     zoom = Number(args[0]) | ||||
|                     if (isNaN(zoom)) { | ||||
|                         throw "Invalid zoom level for argument at 'length'-input" | ||||
|                     } | ||||
|                 } | ||||
|                 const location = new UIEventSource<Loc>({ | ||||
|                     lat: options.location[0], | ||||
|                     lon: options.location[1], | ||||
|                     zoom: zoom | ||||
|                 }) | ||||
|                 if (args[1]) { | ||||
|                     // We have a prefered map!
 | ||||
|                     options.mapBackgroundLayer = ValidatedTextField.bestLayerAt( | ||||
|                         location, new UIEventSource<string[]>(args[1].split(",")) | ||||
|                     ) | ||||
|                 } | ||||
|                 const di = new DirectionInput(options.mapBackgroundLayer, location, value) | ||||
|                 di.SetStyle("height: 20rem;"); | ||||
| 
 | ||||
|                 return di; | ||||
|             }, | ||||
|             "numeric" | ||||
|         ), | ||||
|         ValidatedTextField.tp( | ||||
|             "length", | ||||
|             "A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma seperated) ], e.g. `[\"21\", \"map,photo\"]", | ||||
|             (str) => { | ||||
|                 const t = Number(str) | ||||
|                 return !isNaN(t) | ||||
|             }, | ||||
|             str => str, | ||||
|             (value, options) => { | ||||
|                 const args = options.args ?? [] | ||||
|                 let zoom = 19 | ||||
|                 if (args[0]) { | ||||
|                     zoom = Number(args[0]) | ||||
|                     if (isNaN(zoom)) { | ||||
|                         throw "Invalid zoom level for argument at 'length'-input" | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 // Bit of a hack: we project the centerpoint to the closes point on the road - if available
 | ||||
|                 if(options.feature){ | ||||
|                     const lonlat: [number, number] = [...options.location] | ||||
|                     lonlat.reverse() | ||||
|                     options.location = <[number,number]> GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates | ||||
|                     options.location.reverse() | ||||
|                 } | ||||
|                 options.feature | ||||
|                  | ||||
|                 const location = new UIEventSource<Loc>({ | ||||
|                     lat: options.location[0], | ||||
|                     lon: options.location[1], | ||||
|                     zoom: zoom | ||||
|                 }) | ||||
|                 if (args[1]) { | ||||
|                     // We have a prefered map!
 | ||||
|                     options.mapBackgroundLayer = ValidatedTextField.bestLayerAt( | ||||
|                         location, new UIEventSource<string[]>(args[1].split(",")) | ||||
|                     ) | ||||
|                 } | ||||
|                 const li = new LengthInput(options.mapBackgroundLayer, location, value) | ||||
|                 li.SetStyle("height: 20rem;") | ||||
|                 return li; | ||||
|             } | ||||
|         ), | ||||
|         ValidatedTextField.tp( | ||||
|             "wikidata", | ||||
|             "A wikidata identifier, e.g. Q42", | ||||
|  | @ -113,22 +194,6 @@ export default class ValidatedTextField { | |||
|             undefined, | ||||
|             undefined, | ||||
|             "numeric"), | ||||
|         ValidatedTextField.tp( | ||||
|             "direction", | ||||
|             "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)", | ||||
|             (str) => { | ||||
|                 str = "" + str; | ||||
|                 return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360 | ||||
|             }, str => str, | ||||
|             (value, options) => { | ||||
|                 return new DirectionInput(options.mapBackgroundLayer , new UIEventSource<Loc>({ | ||||
|                     lat: options.location[0], | ||||
|                     lon: options.location[1], | ||||
|                     zoom: 19 | ||||
|                 }),value); | ||||
|             }, | ||||
|             "numeric" | ||||
|         ), | ||||
|         ValidatedTextField.tp( | ||||
|             "float", | ||||
|             "A decimal", | ||||
|  | @ -222,6 +287,7 @@ export default class ValidatedTextField { | |||
|      * {string (typename) --> TextFieldDef} | ||||
|      */ | ||||
|     public static AllTypes = ValidatedTextField.allTypesDict(); | ||||
| 
 | ||||
|     public static InputForType(type: string, options?: { | ||||
|         placeholder?: string | BaseUIElement, | ||||
|         value?: UIEventSource<string>, | ||||
|  | @ -233,7 +299,9 @@ export default class ValidatedTextField { | |||
|         country?: () => string, | ||||
|         location?: [number /*lat*/, number /*lon*/], | ||||
|         mapBackgroundLayer?: UIEventSource<any>, | ||||
|         unit?: Unit | ||||
|         unit?: Unit, | ||||
|         args?: (string | number | boolean)[] // Extra arguments for the inputHelper,
 | ||||
|         feature?: any | ||||
|     }): InputElement<string> { | ||||
|         options = options ?? {}; | ||||
|         options.placeholder = options.placeholder ?? type; | ||||
|  | @ -247,7 +315,7 @@ export default class ValidatedTextField { | |||
|                 if (str === undefined) { | ||||
|                     return false; | ||||
|                 } | ||||
|                 if(options.unit) { | ||||
|                 if (options.unit) { | ||||
|                     str = options.unit.stripUnitParts(str) | ||||
|                 } | ||||
|                 return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country); | ||||
|  | @ -268,7 +336,7 @@ export default class ValidatedTextField { | |||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         if(options.unit) { | ||||
|         if (options.unit) { | ||||
|             // We need to apply a unit.
 | ||||
|             // This implies:
 | ||||
|             // We have to create a dropdown with applicable denominations, and fuse those values
 | ||||
|  | @ -288,17 +356,16 @@ export default class ValidatedTextField { | |||
|                 input, | ||||
|                 unitDropDown, | ||||
|                 // combine the value from the textfield and the dropdown into the resulting value that should go into OSM
 | ||||
|                 (text, denom) => denom?.canonicalValue(text, true) ?? undefined,  | ||||
|                 (text, denom) => denom?.canonicalValue(text, true) ?? undefined, | ||||
|                 (valueWithDenom: string) => { | ||||
|                     // Take the value from OSM and feed it into the textfield and the dropdown
 | ||||
|                     const withDenom = unit.findDenomination(valueWithDenom); | ||||
|                     if(withDenom === undefined) | ||||
|                     { | ||||
|                     if (withDenom === undefined) { | ||||
|                         // Not a valid value at all - we give it undefined and leave the details up to the other elements
 | ||||
|                         return [undefined, undefined] | ||||
|                     } | ||||
|                     const [strippedText, denom] = withDenom | ||||
|                     if(strippedText === undefined){ | ||||
|                     if (strippedText === undefined) { | ||||
|                         return [undefined, undefined] | ||||
|                     } | ||||
|                     return [strippedText, denom] | ||||
|  | @ -306,18 +373,20 @@ export default class ValidatedTextField { | |||
|             ).SetClass("flex") | ||||
|         } | ||||
|         if (tp.inputHelper) { | ||||
|             const helper =  tp.inputHelper(input.GetValue(), { | ||||
|             const helper = tp.inputHelper(input.GetValue(), { | ||||
|                 location: options.location, | ||||
|                 mapBackgroundLayer: options.mapBackgroundLayer | ||||
| 
 | ||||
|                 mapBackgroundLayer: options.mapBackgroundLayer, | ||||
|                 args: options.args, | ||||
|                 feature: options.feature | ||||
|             }) | ||||
|             input = new CombinedInputElement(input, helper, | ||||
|                 (a, _) => a, // We can ignore b, as they are linked earlier
 | ||||
|                 a => [a, a] | ||||
|                 ); | ||||
|             ); | ||||
|         } | ||||
|         return input; | ||||
|     } | ||||
| 
 | ||||
|     public static HelpText(): string { | ||||
|         const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n") | ||||
|         return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations | ||||
|  | @ -329,7 +398,9 @@ export default class ValidatedTextField { | |||
|                       reformat?: ((s: string, country?: () => string) => string), | ||||
|                       inputHelper?: (value: UIEventSource<string>, options?: { | ||||
|                           location: [number, number], | ||||
|                           mapBackgroundLayer: UIEventSource<any> | ||||
|                           mapBackgroundLayer: UIEventSource<any>, | ||||
|                           args: string[], | ||||
|                           feature: any | ||||
|                       }) => InputElement<string>, | ||||
|                       inputmode?: string): TextFieldDef { | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue