From 0a01561d37a5feb7364b112bb8f230d7a7308d25 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sat, 7 Aug 2021 21:19:01 +0200 Subject: [PATCH] First working version of snapping to already existing ways from the add-UI (still too slow though), partial fix of #436 --- Customizations/JSON/LayerConfig.ts | 94 ++++++---- Customizations/JSON/LayerConfigJson.ts | 16 +- Customizations/JSON/LayoutConfig.ts | 2 +- Customizations/JSON/PresetConfig.ts | 16 ++ Logic/Osm/Actions/ChangeDescription.ts | 2 +- Logic/Osm/Actions/CreateNewNodeAction.ts | 75 +++++++- Logic/Osm/Changes.ts | 3 - Models/Constants.ts | 2 +- UI/BigComponents/SimpleAddUI.ts | 83 ++++++--- UI/Input/LocationInput.ts | 203 +++++++++++++++++---- UI/ShowDataLayer.ts | 17 +- assets/layers/barrier/barrier.json | 10 + assets/layers/crossings/crossings.json | 10 + assets/themes/cycle_infra/cycle_infra.json | 4 +- test.ts | 66 +++++-- 15 files changed, 460 insertions(+), 143 deletions(-) create mode 100644 Customizations/JSON/PresetConfig.ts diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index fa91e4d57..04093daa7 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -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( - `` - ); - 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( + `` + ); + 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], diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index aa4bce1b7..49342b290 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -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 } }[], diff --git a/Customizations/JSON/LayoutConfig.ts b/Customizations/JSON/LayoutConfig.ts index 8032701ea..d29e93844 100644 --- a/Customizations/JSON/LayoutConfig.ts +++ b/Customizations/JSON/LayoutConfig.ts @@ -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}); diff --git a/Customizations/JSON/PresetConfig.ts b/Customizations/JSON/PresetConfig.ts new file mode 100644 index 000000000..9f198289b --- /dev/null +++ b/Customizations/JSON/PresetConfig.ts @@ -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 + } +} \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeDescription.ts b/Logic/Osm/Actions/ChangeDescription.ts index aefab9c1c..f91d794fa 100644 --- a/Logic/Osm/Actions/ChangeDescription.ts +++ b/Logic/Osm/Actions/ChangeDescription.ts @@ -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[], } | { diff --git a/Logic/Osm/Actions/CreateNewNodeAction.ts b/Logic/Osm/Actions/CreateNewNodeAction.ts index 692271a12..56f240758 100644 --- a/Logic/Osm/Actions/CreateNewNodeAction.ts +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -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 + } + } + ] } diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 6c2acd0f9..5566407aa 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -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, diff --git a/Models/Constants.ts b/Models/Constants.ts index bc46f859a..ca119915b 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -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 = { diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 7c59503d7..a850841a9 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -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 - }, - 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, 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 = 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(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 diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index d54af6791..508df0245 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -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 { IsSelected: UIEventSource = new UIEventSource(false); private _centerLocation: UIEventSource; - private readonly mapBackground : UIEventSource; + private readonly mapBackground: UIEventSource; + private readonly _snapTo: UIEventSource<{ feature: any }[]> + private readonly _value: UIEventSource + private readonly _snappedPoint: UIEventSource + private readonly _maxSnapDistance: number + private readonly _snappedPointTags: any; + public readonly snappedOnto: UIEventSource = new UIEventSource(undefined) - constructor(options?: { + constructor(options: { mapBackground?: UIEventSource, - centerLocation?: UIEventSource, + snapTo?: UIEventSource<{ feature: any }[]>, + maxSnapDistance?: number, + snappedPointTags?: any, + requiresSnapping?: boolean, + centerLocation: UIEventSource, }) { super(); - options = options ?? {} - options.centerLocation = options.centerLocation ?? new UIEventSource({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 + + 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("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 { - return this._centerLocation; + return this._value; } IsValid(t: Loc): boolean { @@ -35,41 +121,88 @@ export default class LocationInput extends InputElement { } 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" + + })); + } \ No newline at end of file diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 05a720b0e..e6e318f00 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -16,9 +16,9 @@ export default class ShowDataLayer { private readonly _leafletMap: UIEventSource; 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, layoutToUse: UIEventSource, 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(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) } diff --git a/assets/layers/barrier/barrier.json b/assets/layers/barrier/barrier.json index ccd5479e9..d18f411e5 100644 --- a/assets/layers/barrier/barrier.json +++ b/assets/layers/barrier/barrier.json @@ -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 } } ], diff --git a/assets/layers/crossings/crossings.json b/assets/layers/crossings/crossings.json index bc3cf5f70..bbd132afc 100644 --- a/assets/layers/crossings/crossings.json +++ b/assets/layers/crossings/crossings.json @@ -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 } } ], diff --git a/assets/themes/cycle_infra/cycle_infra.json b/assets/themes/cycle_infra/cycle_infra.json index 901770305..cb3da7bd8 100644 --- a/assets/themes/cycle_infra/cycle_infra.json +++ b/assets/themes/cycle_infra/cycle_infra.json @@ -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": [ diff --git a/test.ts b/test.ts index 609529cbd..26d5dd637 100644 --- a/test.ts +++ b/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(AllKnownLayouts.allKnownLayouts.get("bookcases")) +const layout = new UIEventSource(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({ + zoom: 19, + lat: 51.21547967875836, + lon: 3.220340609550476 +}) +const li = new LocationInput( + { + mapBackground: AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource("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")