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:
pietervdvn 2021-08-07 21:19:01 +02:00
parent bf2d634208
commit 0a01561d37
15 changed files with 460 additions and 143 deletions

View file

@ -14,11 +14,11 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import {FixedUiElement} from "../../UI/Base/FixedUiElement"; import {FixedUiElement} from "../../UI/Base/FixedUiElement";
import SourceConfig from "./SourceConfig"; import SourceConfig from "./SourceConfig";
import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import {Unit} from "./Denomination"; import {Unit} from "./Denomination";
import DeleteConfig from "./DeleteConfig"; import DeleteConfig from "./DeleteConfig";
import FilterConfig from "./FilterConfig"; import FilterConfig from "./FilterConfig";
import PresetConfig from "./PresetConfig";
export default class LayerConfig { export default class LayerConfig {
static WAYHANDLING_DEFAULT = 0; static WAYHANDLING_DEFAULT = 0;
@ -35,7 +35,7 @@ export default class LayerConfig {
isShown: TagRenderingConfig; isShown: TagRenderingConfig;
minzoom: number; minzoom: number;
minzoomVisible: number; minzoomVisible: number;
maxzoom:number; maxzoom: number;
title?: TagRenderingConfig; title?: TagRenderingConfig;
titleIcons: TagRenderingConfig[]; titleIcons: TagRenderingConfig[];
icon: TagRenderingConfig; icon: TagRenderingConfig;
@ -51,12 +51,7 @@ export default class LayerConfig {
public readonly deletion: DeleteConfig | null; public readonly deletion: DeleteConfig | null;
public readonly allowSplit: boolean public readonly allowSplit: boolean
presets: { presets: PresetConfig[];
title: Translation,
tags: Tag[],
description?: Translation,
preciseInput?: { preferredBackground?: string }
}[];
tagRenderings: TagRenderingConfig[]; tagRenderings: TagRenderingConfig[];
filters: FilterConfig[]; filters: FilterConfig[];
@ -149,17 +144,41 @@ export default class LayerConfig {
this.minzoomVisible = json.minzoomVisible ?? this.minzoom; this.minzoomVisible = json.minzoomVisible ?? this.minzoom;
this.wayHandling = json.wayHandling ?? 0; this.wayHandling = json.wayHandling ?? 0;
this.presets = (json.presets ?? []).map((pr, i) => { this.presets = (json.presets ?? []).map((pr, i) => {
if (pr.preciseInput === true) {
pr.preciseInput = { let preciseInput = undefined;
preferredBackground: 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`), title: Translations.T(pr.title, `${context}.presets[${i}].title`),
tags: pr.tags.map((t) => FromJSON.SimpleTag(t)), tags: pr.tags.map((t) => FromJSON.SimpleTag(t)),
description: Translations.T(pr.description, `${context}.presets[${i}].description`), 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 /** 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) { function render(tr: TagRenderingConfig, deflt?: string) {
if(tags === undefined){
return deflt
}
const str = tr?.GetRenderValue(tags.data)?.txt ?? deflt; const str = tr?.GetRenderValue(tags.data)?.txt ?? deflt;
return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, "");
} }
const iconSize = render(this.iconSize, "40,40,center").split(","); 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"); let color = render(this.color, "#00f");
if (color.startsWith("--")) { if (color.startsWith("--")) {
@ -445,24 +467,26 @@ export default class LayerConfig {
const iconUrlStatic = render(this.icon); const iconUrlStatic = render(this.icon);
const self = this; 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? // What do you mean, 'tgs' is never read?
// It is read implicitly in the 'render' method // It is read implicitly in the 'render' method
const iconUrl = render(self.icon); const iconUrl = render(self.icon);
@ -473,7 +497,7 @@ export default class LayerConfig {
iconUrl.split(";").filter((prt) => prt != "") iconUrl.split(";").filter((prt) => prt != "")
); );
for (const sourcePart of sourceParts) { for (const sourcePart of sourceParts) {
htmlParts.push(genHtmlFromString(sourcePart)); htmlParts.push(genHtmlFromString(sourcePart, rotation));
} }
let badges = []; let badges = [];
@ -489,7 +513,7 @@ export default class LayerConfig {
.filter((prt) => prt != ""); .filter((prt) => prt != "");
for (const badgePartStr of partDefs) { for (const badgePartStr of partDefs) {
badgeParts.push(genHtmlFromString(badgePartStr)); badgeParts.push(genHtmlFromString(badgePartStr, "0"));
} }
const badgeCompound = new Combine(badgeParts).SetStyle( const badgeCompound = new Combine(badgeParts).SetStyle(
@ -499,7 +523,7 @@ export default class LayerConfig {
badges.push(badgeCompound); badges.push(badgeCompound);
} else { } else {
htmlParts.push( htmlParts.push(
genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt) genHtmlFromString(iconOverlay.then.GetRenderValue(tgs).txt, "0")
); );
} }
} }
@ -533,7 +557,7 @@ export default class LayerConfig {
return { return {
icon: { icon: {
html: new VariableUiElement(mappedHtml), html: mappedHtml === undefined ? new FixedUiElement(self.icon.render.txt) : new VariableUiElement(mappedHtml),
iconSize: [iconW, iconH], iconSize: [iconW, iconH],
iconAnchor: [anchorW, anchorH], iconAnchor: [anchorW, anchorH],
popupAnchor: [0, 3 - anchorH], popupAnchor: [0, 3 - anchorH],

View file

@ -226,7 +226,21 @@ export interface LayerConfigJson {
* If 'preferredBackgroundCategory' is set, the element will attempt to pick a background layer of that category. * If 'preferredBackgroundCategory' is set, the element will attempt to pick a background layer of that category.
*/ */
preciseInput?: true | { 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
} }
}[], }[],

View file

@ -99,7 +99,7 @@ export default class LayoutConfig {
this.defaultBackgroundId = json.defaultBackgroundId; this.defaultBackgroundId = json.defaultBackgroundId;
this.layers = LayoutConfig.ExtractLayers(json, this.units, official, context); 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 }[] = [] const roaming: { r, source: LayerConfig }[] = []
for (const layer of this.layers) { for (const layer of this.layers) {
roaming.push({r: layer.GetRoamingRenderings(), source: layer}); roaming.push({r: layer.GetRoamingRenderings(), source: layer});

View 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
}
}

View file

@ -14,7 +14,7 @@ export interface ChangeDescription {
lat: number, lat: number,
lon: number lon: number
} | { } | {
// Coordinates are only used for rendering // Coordinates are only used for rendering. They should be lon, lat
locations: [number, number][] locations: [number, number][]
nodes: number[], nodes: number[],
} | { } | {

View file

@ -3,6 +3,8 @@ import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes"; import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription"; import {ChangeDescription} from "./ChangeDescription";
import {And} from "../../Tags/And"; import {And} from "../../Tags/And";
import {OsmWay} from "../OsmObject";
import {GeoOperations} from "../../GeoOperations";
export default class CreateNewNodeAction extends OsmChangeAction { export default class CreateNewNodeAction extends OsmChangeAction {
@ -10,13 +12,20 @@ export default class CreateNewNodeAction extends OsmChangeAction {
private readonly _lat: number; private readonly _lat: number;
private readonly _lon: number; private readonly _lon: number;
public newElementId : string = undefined public newElementId: string = undefined
private readonly _snapOnto: OsmWay;
constructor(basicTags: Tag[], lat: number, lon: number) { private readonly _reusePointDistance: number;
constructor(basicTags: Tag[], lat: number, lon: number, options?: { snapOnto: OsmWay, reusePointWithinMeters?: number }) {
super() super()
this._basicTags = basicTags; this._basicTags = basicTags;
this._lat = lat; this._lat = lat;
this._lon = lon; 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[] { CreateChangeDescriptions(changes: Changes): ChangeDescription[] {
@ -24,7 +33,7 @@ export default class CreateNewNodeAction extends OsmChangeAction {
const properties = { const properties = {
id: "node/" + id id: "node/" + id
} }
this.newElementId = "node/"+id this.newElementId = "node/" + id
for (const kv of this._basicTags) { for (const kv of this._basicTags) {
if (typeof kv.value !== "string") { if (typeof kv.value !== "string") {
throw "Invalid value: don't use a regex in a preset" 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; properties[kv.key] = kv.value;
} }
return [{ const newPointChange: ChangeDescription = {
tags: new And(this._basicTags).asChange(properties), tags: new And(this._basicTags).asChange(properties),
type: "node", type: "node",
id: id, id: id,
changes:{ changes: {
lat: this._lat, lat: this._lat,
lon: this._lon 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
}
}
]
} }

View file

@ -27,9 +27,6 @@ export class Changes {
private readonly previouslyCreated : OsmObject[] = [] private readonly previouslyCreated : OsmObject[] = []
constructor() { constructor() {
this.isUploading.addCallbackAndRun(uploading => {
console.trace("Is uploading changed:", uploading)
})
} }
private static createChangesetFor(csId: string, private static createChangesetFor(csId: string,

View file

@ -2,7 +2,7 @@ import { Utils } from "../Utils";
export default class Constants { 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 // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

View file

@ -9,19 +9,16 @@ import Combine from "../Base/Combine";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants";
import LayerConfig from "../../Customizations/JSON/LayerConfig"; import LayerConfig from "../../Customizations/JSON/LayerConfig";
import {Tag} from "../../Logic/Tags/Tag";
import {TagUtils} from "../../Logic/Tags/TagUtils"; import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement"; import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import UserDetails from "../../Logic/Osm/OsmConnection"; import UserDetails from "../../Logic/Osm/OsmConnection";
import {Translation} from "../i18n/Translation";
import LocationInput from "../Input/LocationInput"; import LocationInput from "../Input/LocationInput";
import {InputElement} from "../Input/InputElement";
import Loc from "../../Models/Loc";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; 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: * The SimpleAddUI is a single panel, which can have multiple states:
@ -32,17 +29,12 @@ import Hash from "../../Logic/Web/Hash";
*/ */
/*private*/ /*private*/
interface PresetInfo { interface PresetInfo extends PresetConfig {
description: string | Translation,
name: string | BaseUIElement, name: string | BaseUIElement,
icon: () => BaseUIElement, icon: () => BaseUIElement,
tags: Tag[],
layerToAddTo: { layerToAddTo: {
layerDef: LayerConfig, layerDef: LayerConfig,
isDisplayed: UIEventSource<boolean> isDisplayed: UIEventSource<boolean>
},
preciseInput?: {
preferredBackground?: string
} }
} }
@ -65,24 +57,43 @@ export default class SimpleAddUI extends Toggle {
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) 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( const addUi = new VariableUiElement(
selectedPreset.map(preset => { selectedPreset.map(preset => {
if (preset === undefined) { if (preset === undefined) {
return presetsOverview return presetsOverview
} }
return SimpleAddUI.CreateConfirmButton(preset, return SimpleAddUI.CreateConfirmButton(preset,
(tags, location) => { (tags, location, snapOntoWayId?: string) => {
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon) if (snapOntoWayId === undefined) {
State.state.changes.applyAction(newElementAction) createNewPoint(tags, location, undefined)
selectedPreset.setData(undefined) } else {
isShown.setData(false) OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => {
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( createNewPoint(tags, location,<OsmWay> way)
newElementAction.newElementId return true;
)) })
console.log("Did set selected element to",State.state.allElements.ContainingFeatures.get( }
newElementAction.newElementId
))
}, () => { },
() => {
selectedPreset.setData(undefined) selectedPreset.setData(undefined)
}) })
} }
@ -115,11 +126,11 @@ export default class SimpleAddUI extends Toggle {
private static CreateConfirmButton(preset: PresetInfo, 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 { cancel: () => void): BaseUIElement {
let location = State.state.LastClickLocation; let location = State.state.LastClickLocation;
let preciseInput: InputElement<Loc> = undefined let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) { if (preset.preciseInput !== undefined) {
const locationSrc = new UIEventSource({ const locationSrc = new UIEventSource({
lat: location.data.lat, 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)) 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({ preciseInput = new LocationInput({
mapBackground: backgroundLayer, 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;") 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("flex flex-col")
).SetClass("font-bold break-words") ).SetClass("font-bold break-words")
.onClick(() => { .onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue() ?? location).data); confirm(preset.tags, (preciseInput?.GetValue() ?? location).data, preciseInput?.snappedOnto?.data?.properties?.id);
}); });
if (preciseInput !== undefined) { 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 // The layer is not displayed and we cannot enable the layer control -> we skip
continue; continue;
} }
if(layer.layerDef.name === undefined){ if (layer.layerDef.name === undefined) {
// this is a parlty hidden layer // this is a parlty hidden layer
continue; continue;
} }
@ -258,6 +282,7 @@ export default class SimpleAddUI extends Toggle {
tags: preset.tags, tags: preset.tags,
layerToAddTo: layer, layerToAddTo: layer,
name: preset.title, name: preset.title,
title: preset.title,
description: preset.description, description: preset.description,
icon: icon, icon: icon,
preciseInput: preset.preciseInput preciseInput: preset.preciseInput

View file

@ -6,28 +6,114 @@ import BaseLayer from "../../Models/BaseLayer";
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import Svg from "../../Svg"; import Svg from "../../Svg";
import State from "../../State"; 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> { export default class LocationInput extends InputElement<Loc> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private _centerLocation: UIEventSource<Loc>; 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>, mapBackground?: UIEventSource<BaseLayer>,
centerLocation?: UIEventSource<Loc>, snapTo?: UIEventSource<{ feature: any }[]>,
maxSnapDistance?: number,
snappedPointTags?: any,
requiresSnapping?: boolean,
centerLocation: UIEventSource<Loc>,
}) { }) {
super(); super();
options = options ?? {} this._snapTo = options.snapTo?.map(features => features?.filter(feat => feat.feature.geometry.type !== "Point"))
options.centerLocation = options.centerLocation ?? new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1}) this._maxSnapDistance = options.maxSnapDistance
this._centerLocation = options.centerLocation; 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") this.SetClass("block h-full")
} }
GetValue(): UIEventSource<Loc> { GetValue(): UIEventSource<Loc> {
return this._centerLocation; return this._value;
} }
IsValid(t: Loc): boolean { IsValid(t: Loc): boolean {
@ -35,41 +121,88 @@ export default class LocationInput extends InputElement<Loc> {
} }
protected InnerConstructElement(): HTMLElement { protected InnerConstructElement(): HTMLElement {
const map = new Minimap( try {
{ const map = new Minimap(
location: this._centerLocation, {
background: this.mapBackground location: this._centerLocation,
} background: this.mapBackground
) }
map.leafletMap.addCallbackAndRunD(leaflet => {
leaflet.setMaxBounds(
leaflet.getBounds().pad(0.15)
) )
}) 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 const matchPoint = this._snappedPoint.map(loc => {
if (leaflet === undefined || layer === undefined) { if (loc === undefined) {
return; 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) this.mapBackground.map(layer => {
leaflet.setMinZoom(layer.max_zoom - 3) const leaflet = map.leafletMap.data
leaflet.setZoom(layer.max_zoom - 1) if (leaflet === undefined || layer === undefined) {
return;
}
}, [map.leafletMap]) leaflet.setMaxZoom(layer.max_zoom)
return new Combine([ leaflet.setMinZoom(layer.max_zoom - 3)
new Combine([ leaflet.setZoom(layer.max_zoom - 1)
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(); }, [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"
}));
} }

View file

@ -16,9 +16,9 @@ export default class ShowDataLayer {
private readonly _leafletMap: UIEventSource<L.Map>; private readonly _leafletMap: UIEventSource<L.Map>;
private _cleanCount = 0; private _cleanCount = 0;
private readonly _enablePopups: boolean; 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>, leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>, layoutToUse: UIEventSource<LayoutConfig>,
enablePopups = true, enablePopups = true,
@ -85,7 +85,9 @@ export default class ShowDataLayer {
console.error(e) console.error(e)
} }
} }
State.state.selectedElement.ping() if (self._enablePopups) {
State.state.selectedElement.ping()
}
} }
features.addCallback(() => update()); features.addCallback(() => update());
@ -106,13 +108,12 @@ export default class ShowDataLayer {
// We have to convert them to the appropriate icon // We have to convert them to the appropriate icon
// Click handling is done in the next step // 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]; const layer: LayerConfig = this._layerDict[feature._matching_layer_id];
if (layer === undefined) { if (layer === undefined) {
return; 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 style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0));
const baseElement = style.icon.html; const baseElement = style.icon.html;
if (!this._enablePopups) { if (!this._enablePopups) {
@ -146,8 +147,8 @@ export default class ShowDataLayer {
autoPan: true, autoPan: true,
closeOnEscapeKey: true, closeOnEscapeKey: true,
closeButton: false, closeButton: false,
autoPanPaddingTopLeft: [15,15], autoPanPaddingTopLeft: [15, 15],
}, leafletLayer); }, leafletLayer);
leafletLayer.bindPopup(popup); leafletLayer.bindPopup(popup);
@ -191,7 +192,7 @@ export default class ShowDataLayer {
) { ) {
leafletLayer.openPopup() leafletLayer.openPopup()
} }
if(feature.id !== feature.properties.id){ if (feature.id !== feature.properties.id) {
console.trace("Not opening the popup for", feature) console.trace("Not opening the popup for", feature)
} }

View file

@ -53,6 +53,11 @@
"description": { "description": {
"en": "A bollard in the road", "en": "A bollard in the road",
"nl": "Een paaltje in de weg" "nl": "Een paaltje in de weg"
},
"preciseInput": {
"preferredBackground": ["photo"],
"snapToLayer": "cycleways_and_roads",
"maxSnapDistance": 25
} }
}, },
{ {
@ -66,6 +71,11 @@
"description": { "description": {
"en": "Cycle barrier, slowing down cyclists", "en": "Cycle barrier, slowing down cyclists",
"nl": "Fietshekjes, voor het afremmen van fietsers" "nl": "Fietshekjes, voor het afremmen van fietsers"
},
"preciseInput": {
"preferredBackground": ["photo"],
"snapToLayer": "cycleways_and_roads",
"maxSnapDistance": 25
} }
} }
], ],

View file

@ -66,6 +66,11 @@
"description": { "description": {
"en": "Crossing for pedestrians and/or cyclists", "en": "Crossing for pedestrians and/or cyclists",
"nl": "Oversteekplaats voor voetgangers en/of fietsers" "nl": "Oversteekplaats voor voetgangers en/of fietsers"
},
"preciseInput": {
"preferredBackground": ["photo"],
"snapToLayer": "cycleways_and_roads",
"maxSnapDistance": 25
} }
}, },
{ {
@ -79,6 +84,11 @@
"description": { "description": {
"en": "Traffic signal on a road", "en": "Traffic signal on a road",
"nl": "Verkeerslicht op een weg" "nl": "Verkeerslicht op een weg"
},
"preciseInput": {
"preferredBackground": ["photo"],
"snapToLayer": "cycleways_and_roads",
"maxSnapDistance": 25
} }
} }
], ],

View file

@ -16,14 +16,14 @@
"en", "en",
"nl" "nl"
], ],
"maintainer": "", "maintainer": "MapComplete",
"defaultBackgroundId": "CartoDB.Voyager", "defaultBackgroundId": "CartoDB.Voyager",
"icon": "./assets/themes/cycle_infra/cycle-infra.svg", "icon": "./assets/themes/cycle_infra/cycle-infra.svg",
"version": "0", "version": "0",
"startLat": 51, "startLat": 51,
"startLon": 3.75, "startLon": 3.75,
"startZoom": 11, "startZoom": 11,
"widenFactor": 0, "widenFactor": 0.05,
"socialImage": "./assets/themes/cycle_infra/cycle-infra.svg", "socialImage": "./assets/themes/cycle_infra/cycle-infra.svg",
"enableDownload": true, "enableDownload": true,
"layers": [ "layers": [

66
test.ts
View file

@ -2,40 +2,49 @@ import {UIEventSource} from "./Logic/UIEventSource";
import LayoutConfig from "./Customizations/JSON/LayoutConfig"; import LayoutConfig from "./Customizations/JSON/LayoutConfig";
import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; import {AllKnownLayouts} from "./Customizations/AllKnownLayouts";
import State from "./State"; 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) State.state = new State(layout.data)
const features = new UIEventSource<{ feature: any }[]>([ const features = new UIEventSource<{ feature: any }[]>([
{ {
feature: { feature: {
"type": "Feature", "type": "Feature",
"properties": {"amenity": "public_bookcase", "id": "node/123"}, "properties": {},
id: "node/123",
_matching_layer_id: "public_bookcase",
"geometry": { "geometry": {
"type": "Point", "type": "LineString",
"coordinates": [ "coordinates": [
3.220506906509399, [
51.215009243433094 3.219616413116455,
51.215315026941276
],
[
3.221080899238586,
51.21564432998662
]
] ]
} }
} }
}, { },
{
feature: { feature: {
"type": "Feature", "type": "Feature",
"properties": { "properties": {},
amenity: "public_bookcase",
id: "node/456"
},
_matching_layer_id: "public_bookcase",
id: "node/456",
"geometry": { "geometry": {
"type": "Point", "type": "LineString",
"coordinates": [ "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)) 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")