forked from MapComplete/MapComplete
First working version of a width measurment tool
This commit is contained in:
parent
40400591d0
commit
4fa9159da1
12 changed files with 300 additions and 110 deletions
|
@ -2,11 +2,12 @@ import {InputElement} from "./InputElement";
|
|||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import Combine from "../Base/Combine";
|
||||
import Svg from "../../Svg";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import {Utils} from "../../Utils";
|
||||
import Loc from "../../Models/Loc";
|
||||
import Minimap from "../Base/Minimap";
|
||||
import {GeoOperations} from "../../Logic/GeoOperations";
|
||||
import DirectionInput from "./DirectionInput";
|
||||
import {RadioButton} from "./RadioButton";
|
||||
import {FixedInputElement} from "./FixedInputElement";
|
||||
|
||||
|
||||
/**
|
||||
|
@ -19,13 +20,15 @@ export default class LengthInput extends InputElement<string> {
|
|||
private readonly value: UIEventSource<string>;
|
||||
private background;
|
||||
|
||||
constructor(mapBackground: UIEventSource<any>,
|
||||
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> {
|
||||
|
@ -33,83 +36,150 @@ export default class LengthInput extends InputElement<string> {
|
|||
}
|
||||
|
||||
IsValid(str: string): boolean {
|
||||
const t = Number(str);
|
||||
const t = Number(str)
|
||||
return !isNaN(t) && t >= 0 && t <= 360;
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
|
||||
let map: BaseUIElement = new FixedUiElement("")
|
||||
const modeElement = new RadioButton([
|
||||
new FixedInputElement("Measure", "measure"),
|
||||
new FixedInputElement("Move", "move")
|
||||
])
|
||||
// @ts-ignore
|
||||
let map = undefined
|
||||
if (!Utils.runningFromConsole) {
|
||||
map = new Minimap({
|
||||
map = DirectionInput.constructMinimap({
|
||||
background: this.background,
|
||||
allowMoving: true,
|
||||
location: this._location
|
||||
allowMoving: false,
|
||||
location: this._location,
|
||||
leafletOptions: {
|
||||
tap: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const element = new Combine([
|
||||
Svg.direction_stroke_svg().SetStyle(
|
||||
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${this.value.data ?? 0}deg);`)
|
||||
.SetClass("direction-svg relative")
|
||||
.SetStyle("z-index: 1000"),
|
||||
map.SetClass("w-full h-full absolute top-0 left-O rounded-full overflow-hidden"),
|
||||
new Combine([Svg.length_crosshair_ui().SetStyle(
|
||||
`visibility: hidden; position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`)
|
||||
])
|
||||
.SetClass("block length-crosshair-svg relative")
|
||||
.SetStyle("z-index: 1000"),
|
||||
map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"),
|
||||
])
|
||||
.SetStyle("position:relative;display:block;width: min(100%, 25em); height: min(100% , 25em); background:white; border: 1px solid black; border-radius: 999em")
|
||||
.SetClass("relative block bg-white border border-black rounded-3xl overflow-hidden")
|
||||
.ConstructElement()
|
||||
|
||||
|
||||
this.value.addCallbackAndRunD(rotation => {
|
||||
const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement
|
||||
cone.style.transform = `rotate(${rotation}deg)`;
|
||||
|
||||
})
|
||||
|
||||
this.RegisterTriggers(element)
|
||||
this.RegisterTriggers(element, map?.leafletMap)
|
||||
element.style.overflow = "hidden"
|
||||
|
||||
return element;
|
||||
element.style.display = "block"
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
private RegisterTriggers(htmlElement: HTMLElement) {
|
||||
const self = this;
|
||||
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;
|
||||
}
|
||||
|
||||
function onPosChange(x: number, y: number) {
|
||||
const rect = htmlElement.getBoundingClientRect();
|
||||
const dx = -(rect.left + rect.right) / 2 + x;
|
||||
const dy = (rect.top + rect.bottom) / 2 - y;
|
||||
const angle = 180 * Math.atan2(dy, dx) / Math.PI;
|
||||
const angleGeo = Math.floor((450 - angle) % 360);
|
||||
self.value.setData("" + angleGeo)
|
||||
// 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) {
|
||||
console.log(firstClickXY, [dx, dy], "pixel origin", leaflet.getPixelOrigin())
|
||||
const first = leaflet.layerPointToLatLng(firstClickXY)
|
||||
const last = leaflet.layerPointToLatLng([dx, dy])
|
||||
console.log(first, last)
|
||||
const geoDist = Math.floor(GeoOperations.distanceBetween([first.lng, first.lat], [last.lng, last.lat]) * 100000) / 100
|
||||
console.log("First", first, "last", last, "d", geoDist)
|
||||
self.value.setData("" + geoDist)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
htmlElement.ontouchmove = (ev: TouchEvent) => {
|
||||
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY);
|
||||
htmlElement.ontouchstart = (ev: TouchEvent) => {
|
||||
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
htmlElement.ontouchstart = (ev: TouchEvent) => {
|
||||
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY);
|
||||
htmlElement.ontouchmove = (ev: TouchEvent) => {
|
||||
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
let isDown = false;
|
||||
htmlElement.ontouchend = (ev: TouchEvent) => {
|
||||
onPosChange(undefined, undefined, false, true);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
htmlElement.onmousedown = (ev: MouseEvent) => {
|
||||
isDown = true;
|
||||
onPosChange(ev.clientX, ev.clientY);
|
||||
onPosChange(ev.clientX, ev.clientY, true);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
htmlElement.onmouseup = (ev) => {
|
||||
isDown = false;
|
||||
onPosChange(ev.clientX, ev.clientY, false, true);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
htmlElement.onmousemove = (ev: MouseEvent) => {
|
||||
if (isDown) {
|
||||
onPosChange(ev.clientX, ev.clientY);
|
||||
}
|
||||
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,79 @@ 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){
|
||||
}
|
||||
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 +190,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 +283,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 +295,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 +311,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 +332,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 +352,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 +369,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 +394,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