forked from MapComplete/MapComplete
Feature: allow to move and snap to a layer, fix #2120
This commit is contained in:
parent
eb89427bfc
commit
fdedb75954
34 changed files with 824 additions and 301 deletions
|
@ -1,10 +1,15 @@
|
|||
import { ChangeDescription } from "./ChangeDescription"
|
||||
import OsmChangeAction from "./OsmChangeAction"
|
||||
import { WayId } from "../../../Models/OsmFeature"
|
||||
import InsertPointIntoWayAction from "./InsertPointIntoWayAction"
|
||||
import { SpecialVisualizationState } from "../../../UI/SpecialVisualization"
|
||||
|
||||
export default class ChangeLocationAction extends OsmChangeAction {
|
||||
private readonly _id: number
|
||||
private readonly _newLonLat: [number, number]
|
||||
private readonly _meta: { theme: string; reason: string }
|
||||
private readonly state: SpecialVisualizationState
|
||||
private snapTo: WayId | undefined
|
||||
static metatags: {
|
||||
readonly key?: string
|
||||
readonly value?: string
|
||||
|
@ -21,28 +26,30 @@ export default class ChangeLocationAction extends OsmChangeAction {
|
|||
]
|
||||
|
||||
constructor(
|
||||
state: SpecialVisualizationState,
|
||||
id: string,
|
||||
newLonLat: [number, number],
|
||||
snapTo: WayId | undefined,
|
||||
meta: {
|
||||
theme: string
|
||||
reason: string
|
||||
}
|
||||
},
|
||||
) {
|
||||
super(id, true)
|
||||
this.state = state
|
||||
if (!id.startsWith("node/")) {
|
||||
throw "Invalid ID: only 'node/number' is accepted"
|
||||
}
|
||||
this._id = Number(id.substring("node/".length))
|
||||
this._newLonLat = newLonLat
|
||||
this.snapTo = snapTo
|
||||
this._meta = meta
|
||||
}
|
||||
|
||||
protected async CreateChangeDescriptions(): Promise<ChangeDescription[]> {
|
||||
const [lon, lat] = this._newLonLat
|
||||
const d: ChangeDescription = {
|
||||
changes: {
|
||||
lat: this._newLonLat[1],
|
||||
lon: this._newLonLat[0],
|
||||
},
|
||||
changes: { lon, lat },
|
||||
type: "node",
|
||||
id: this._id,
|
||||
meta: {
|
||||
|
@ -51,7 +58,21 @@ export default class ChangeLocationAction extends OsmChangeAction {
|
|||
specialMotivation: this._meta.reason,
|
||||
},
|
||||
}
|
||||
if (!this.snapTo) {
|
||||
return [d]
|
||||
}
|
||||
const snapToWay = await this.state.osmObjectDownloader.DownloadObjectAsync(this.snapTo, 0)
|
||||
if (snapToWay === "deleted") {
|
||||
return [d]
|
||||
}
|
||||
|
||||
return [d]
|
||||
const insertIntoWay = new InsertPointIntoWayAction(
|
||||
lat, lon, this._id, snapToWay, {
|
||||
allowReuseOfPreviouslyCreatedPoints: false,
|
||||
reusePointWithinMeters: 0.25,
|
||||
},
|
||||
).prepareChangeDescription()
|
||||
|
||||
return [d, { ...insertIntoWay, meta: d.meta }]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { ChangeDescription } from "./ChangeDescription"
|
|||
import { And } from "../../Tags/And"
|
||||
import { OsmWay } from "../OsmObject"
|
||||
import { GeoOperations } from "../../GeoOperations"
|
||||
import InsertPointIntoWayAction from "./InsertPointIntoWayAction"
|
||||
|
||||
export default class CreateNewNodeAction extends OsmCreateAction {
|
||||
/**
|
||||
|
@ -37,7 +38,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
theme: string
|
||||
changeType: "create" | "import" | null
|
||||
specialMotivation?: string
|
||||
}
|
||||
},
|
||||
) {
|
||||
super(null, basicTags !== undefined && basicTags.length > 0)
|
||||
this._basicTags = basicTags
|
||||
|
@ -101,72 +102,20 @@ export default class CreateNewNodeAction extends OsmCreateAction {
|
|||
return [newPointChange]
|
||||
}
|
||||
|
||||
// Project the point onto the way
|
||||
console.log("Snapping a node onto an existing way...")
|
||||
const geojson = this._snapOnto.asGeoJson()
|
||||
const projected = GeoOperations.nearestPoint(GeoOperations.outerRing(geojson), [
|
||||
this._lon,
|
||||
const change = new InsertPointIntoWayAction(
|
||||
this._lat,
|
||||
])
|
||||
const projectedCoor = <[number, number]>projected.geometry.coordinates
|
||||
const index = projected.properties.index
|
||||
console.log("Attempting to snap:", { geojson, projected, projectedCoor, index })
|
||||
// We check that it isn't close to an already existing point
|
||||
let reusedPointId = undefined
|
||||
let reusedPointCoordinates: [number, number] = undefined
|
||||
let outerring: [number, number][]
|
||||
this._lon,
|
||||
id,
|
||||
this._snapOnto,
|
||||
{
|
||||
reusePointWithinMeters: this._reusePointDistance,
|
||||
allowReuseOfPreviouslyCreatedPoints: this._reusePreviouslyCreatedPoint,
|
||||
},
|
||||
).prepareChangeDescription()
|
||||
|
||||
if (geojson.geometry.type === "LineString") {
|
||||
outerring = <[number, number][]>geojson.geometry.coordinates
|
||||
} else if (geojson.geometry.type === "Polygon") {
|
||||
outerring = <[number, number][]>geojson.geometry.coordinates[0]
|
||||
}
|
||||
|
||||
const prev = outerring[index]
|
||||
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._reusePointDistance) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index]
|
||||
reusedPointCoordinates = this._snapOnto.coordinates[index]
|
||||
}
|
||||
const next = outerring[index + 1]
|
||||
if (GeoOperations.distanceBetween(next, projectedCoor) < this._reusePointDistance) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index + 1]
|
||||
reusedPointCoordinates = this._snapOnto.coordinates[index + 1]
|
||||
}
|
||||
if (reusedPointId !== undefined) {
|
||||
this.setElementId(reusedPointId)
|
||||
return [
|
||||
{
|
||||
tags: new And(this._basicTags).asChange(properties),
|
||||
type: "node",
|
||||
id: reusedPointId,
|
||||
meta: this.meta,
|
||||
changes: { lat: reusedPointCoordinates[0], lon: reusedPointCoordinates[1] },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const locations = [
|
||||
...this._snapOnto.coordinates?.map(([lat, lon]) => <[number, number]>[lon, lat]),
|
||||
]
|
||||
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: {
|
||||
coordinates: locations,
|
||||
nodes: ids,
|
||||
},
|
||||
meta: this.meta,
|
||||
},
|
||||
{ ...change, meta: this.meta },
|
||||
]
|
||||
}
|
||||
|
||||
|
|
96
src/Logic/Osm/Actions/InsertPointIntoWayAction.ts
Normal file
96
src/Logic/Osm/Actions/InsertPointIntoWayAction.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { ChangeDescription } from "./ChangeDescription"
|
||||
import { GeoOperations } from "../../GeoOperations"
|
||||
import { OsmWay } from "../OsmObject"
|
||||
|
||||
export default class InsertPointIntoWayAction {
|
||||
private readonly _lat: number
|
||||
private readonly _lon: number
|
||||
private readonly _idToInsert: number
|
||||
private readonly _snapOnto: OsmWay
|
||||
private readonly _options: {
|
||||
allowReuseOfPreviouslyCreatedPoints?: boolean
|
||||
reusePointWithinMeters?: number
|
||||
}
|
||||
|
||||
constructor(
|
||||
lat: number,
|
||||
lon: number,
|
||||
idToInsert: number,
|
||||
snapOnto: OsmWay,
|
||||
options: {
|
||||
allowReuseOfPreviouslyCreatedPoints?: boolean
|
||||
reusePointWithinMeters?: number
|
||||
}
|
||||
){
|
||||
this._lat = lat
|
||||
this._lon = lon
|
||||
this._idToInsert = idToInsert
|
||||
this._snapOnto = snapOnto
|
||||
this._options = options
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to create the changedescription of the way where the point is inserted
|
||||
* Returns `undefined` if inserting failed
|
||||
*/
|
||||
public prepareChangeDescription(): Omit<ChangeDescription, "meta"> | undefined {
|
||||
|
||||
|
||||
// Project the point onto the way
|
||||
console.log("Snapping a node onto an existing way...")
|
||||
const geojson = this._snapOnto.asGeoJson()
|
||||
const projected = GeoOperations.nearestPoint(GeoOperations.outerRing(geojson), [
|
||||
this._lon,
|
||||
this._lat,
|
||||
])
|
||||
const projectedCoor = <[number, number]>projected.geometry.coordinates
|
||||
const index = projected.properties.index
|
||||
console.log("Attempting to snap:", { geojson, projected, projectedCoor, index })
|
||||
// We check that it isn't close to an already existing point
|
||||
let reusedPointId = undefined
|
||||
let reusedPointCoordinates: [number, number] = undefined
|
||||
let outerring: [number, number][]
|
||||
|
||||
if (geojson.geometry.type === "LineString") {
|
||||
outerring = <[number, number][]>geojson.geometry.coordinates
|
||||
} else if (geojson.geometry.type === "Polygon") {
|
||||
outerring = <[number, number][]>geojson.geometry.coordinates[0]
|
||||
}
|
||||
|
||||
const prev = outerring[index]
|
||||
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._options.reusePointWithinMeters) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index]
|
||||
reusedPointCoordinates = this._snapOnto.coordinates[index]
|
||||
}
|
||||
const next = outerring[index + 1]
|
||||
if (GeoOperations.distanceBetween(next, projectedCoor) < this._options.reusePointWithinMeters) {
|
||||
// We reuse this point instead!
|
||||
reusedPointId = this._snapOnto.nodes[index + 1]
|
||||
reusedPointCoordinates = this._snapOnto.coordinates[index + 1]
|
||||
}
|
||||
if (reusedPointId !== undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const locations = [
|
||||
...this._snapOnto.coordinates?.map(([lat, lon]) => <[number, number]>[lon, lat]),
|
||||
]
|
||||
const ids = [...this._snapOnto.nodes]
|
||||
|
||||
locations.splice(index + 1, 0, [this._lon, this._lat])
|
||||
ids.splice(index + 1, 0, this._idToInsert)
|
||||
|
||||
return {
|
||||
type: "way",
|
||||
id: this._snapOnto.id,
|
||||
changes: {
|
||||
coordinates: locations,
|
||||
nodes: ids,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -30,7 +30,7 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
super(
|
||||
"Converts the identifier of a builtin layer into the actual layer, or converts a 'builtin' syntax with override in the fully expanded form. Note that 'tagRenderings+' will be inserted before 'leftover-questions'",
|
||||
[],
|
||||
"SubstituteLayer"
|
||||
"SubstituteLayer",
|
||||
)
|
||||
this._state = state
|
||||
}
|
||||
|
@ -86,14 +86,14 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
(found["tagRenderings"] ?? []).length > 0
|
||||
) {
|
||||
context.err(
|
||||
`When overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`
|
||||
`When overriding a layer, an override is not allowed to override into tagRenderings. Use "+tagRenderings" or "tagRenderings+" instead to prepend or append some questions.`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
const trPlus = json["override"]["tagRenderings+"]
|
||||
if (trPlus) {
|
||||
let index = found.tagRenderings.findIndex(
|
||||
(tr) => tr["id"] === "leftover-questions"
|
||||
(tr) => tr["id"] === "leftover-questions",
|
||||
)
|
||||
if (index < 0) {
|
||||
index = found.tagRenderings.length
|
||||
|
@ -107,8 +107,8 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
} catch (e) {
|
||||
context.err(
|
||||
`Could not apply an override due to: ${e}.\nThe override is: ${JSON.stringify(
|
||||
json["override"]
|
||||
)}`
|
||||
json["override"],
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -132,9 +132,9 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
usedLabels.add(labels[forbiddenLabel])
|
||||
context.info(
|
||||
"Dropping tagRendering " +
|
||||
tr["id"] +
|
||||
" as it has a forbidden label: " +
|
||||
labels[forbiddenLabel]
|
||||
tr["id"] +
|
||||
" as it has a forbidden label: " +
|
||||
labels[forbiddenLabel],
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -143,7 +143,7 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
if (hideLabels.has(tr["id"])) {
|
||||
usedLabels.add(tr["id"])
|
||||
context.info(
|
||||
"Dropping tagRendering " + tr["id"] + " as its id is a forbidden label"
|
||||
"Dropping tagRendering " + tr["id"] + " as its id is a forbidden label",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -152,10 +152,10 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
usedLabels.add(tr["group"])
|
||||
context.info(
|
||||
"Dropping tagRendering " +
|
||||
tr["id"] +
|
||||
" as its group `" +
|
||||
tr["group"] +
|
||||
"` is a forbidden label"
|
||||
tr["id"] +
|
||||
" as its group `" +
|
||||
tr["group"] +
|
||||
"` is a forbidden label",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -166,8 +166,8 @@ class SubstituteLayer extends Conversion<string | LayerConfigJson, LayerConfigJs
|
|||
if (unused.length > 0) {
|
||||
context.err(
|
||||
"This theme specifies that certain tagrenderings have to be removed based on forbidden layers. One or more of these layers did not match any tagRenderings and caused no deletions: " +
|
||||
unused.join(", ") +
|
||||
"\n This means that this label can be removed or that the original tagRendering that should be deleted does not have this label anymore"
|
||||
unused.join(", ") +
|
||||
"\n This means that this label can be removed or that the original tagRendering that should be deleted does not have this label anymore",
|
||||
)
|
||||
}
|
||||
found.tagRenderings = filtered
|
||||
|
@ -184,7 +184,7 @@ class AddDefaultLayers extends DesugaringStep<LayoutConfigJson> {
|
|||
super(
|
||||
"Adds the default layers, namely: " + Constants.added_by_default.join(", "),
|
||||
["layers"],
|
||||
"AddDefaultLayers"
|
||||
"AddDefaultLayers",
|
||||
)
|
||||
this._state = state
|
||||
}
|
||||
|
@ -207,10 +207,10 @@ class AddDefaultLayers extends DesugaringStep<LayoutConfigJson> {
|
|||
if (alreadyLoaded.has(v.id)) {
|
||||
context.warn(
|
||||
"Layout " +
|
||||
context +
|
||||
" already has a layer with name " +
|
||||
v.id +
|
||||
"; skipping inclusion of this builtin layer"
|
||||
context +
|
||||
" already has a layer with name " +
|
||||
v.id +
|
||||
"; skipping inclusion of this builtin layer",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -226,14 +226,14 @@ class AddImportLayers extends DesugaringStep<LayoutConfigJson> {
|
|||
super(
|
||||
"For every layer in the 'layers'-list, create a new layer which'll import notes. (Note that priviliged layers and layers which have a geojson-source set are ignored)",
|
||||
["layers"],
|
||||
"AddImportLayers"
|
||||
"AddImportLayers",
|
||||
)
|
||||
}
|
||||
|
||||
convert(json: LayoutConfigJson, context: ConversionContext): LayoutConfigJson {
|
||||
if (!(json.enableNoteImports ?? true)) {
|
||||
context.info(
|
||||
"Not creating a note import layers for theme " + json.id + " as they are disabled"
|
||||
"Not creating a note import layers for theme " + json.id + " as they are disabled",
|
||||
)
|
||||
return json
|
||||
}
|
||||
|
@ -268,7 +268,7 @@ class AddImportLayers extends DesugaringStep<LayoutConfigJson> {
|
|||
try {
|
||||
const importLayerResult = creator.convert(
|
||||
layer,
|
||||
context.inOperation(this.name).enter(i1)
|
||||
context.inOperation(this.name).enter(i1),
|
||||
)
|
||||
if (importLayerResult !== undefined) {
|
||||
json.layers.push(importLayerResult)
|
||||
|
@ -288,7 +288,7 @@ class AddContextToTranslationsInLayout extends DesugaringStep<LayoutConfigJson>
|
|||
super(
|
||||
"Adds context to translations, including the prefix 'themes:json.id'; this is to make sure terms in an 'overrides' or inline layer are linkable too",
|
||||
["_context"],
|
||||
"AddContextToTranlationsInLayout"
|
||||
"AddContextToTranlationsInLayout",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -297,7 +297,7 @@ class AddContextToTranslationsInLayout extends DesugaringStep<LayoutConfigJson>
|
|||
// The context is used to generate the 'context' in the translation .It _must_ be `json.id` to correctly link into weblate
|
||||
return conversion.convert(
|
||||
json,
|
||||
ConversionContext.construct([json.id], ["AddContextToTranslation"])
|
||||
ConversionContext.construct([json.id], ["AddContextToTranslation"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -307,7 +307,7 @@ class ApplyOverrideAll extends DesugaringStep<LayoutConfigJson> {
|
|||
super(
|
||||
"Applies 'overrideAll' onto every 'layer'. The 'overrideAll'-field is removed afterwards",
|
||||
["overrideAll", "layers"],
|
||||
"ApplyOverrideAll"
|
||||
"ApplyOverrideAll",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -336,7 +336,7 @@ class ApplyOverrideAll extends DesugaringStep<LayoutConfigJson> {
|
|||
layer.tagRenderings = tagRenderingsPlus
|
||||
} else {
|
||||
let index = layer.tagRenderings.findIndex(
|
||||
(tr) => tr["id"] === "leftover-questions"
|
||||
(tr) => tr["id"] === "leftover-questions",
|
||||
)
|
||||
if (index < 0) {
|
||||
index = layer.tagRenderings.length - 1
|
||||
|
@ -357,14 +357,9 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
|
||||
constructor(state: DesugaringContext) {
|
||||
super(
|
||||
`If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)
|
||||
|
||||
Note that these layers are added _at the start_ of the layer list, meaning that they will see _every_ feature.
|
||||
Furthermore, \`passAllFeatures\` will be set, so that they won't steal away features from further layers.
|
||||
Some layers (e.g. \`all_buildings_and_walls\' or \'streets_with_a_name\') are invisible, so by default, \'force_load\' is set too.
|
||||
`,
|
||||
`If a layer has a dependency on another layer, these layers are added automatically on the theme. (For example: defibrillator depends on 'walls_and_buildings' to snap onto. This layer is added automatically)`,
|
||||
["layers"],
|
||||
"AddDependencyLayersToTheme"
|
||||
"AddDependencyLayersToTheme",
|
||||
)
|
||||
this._state = state
|
||||
}
|
||||
|
@ -373,7 +368,7 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
alreadyLoaded: LayerConfigJson[],
|
||||
allKnownLayers: Map<string, LayerConfigJson>,
|
||||
themeId: string,
|
||||
context: ConversionContext
|
||||
context: ConversionContext,
|
||||
): { config: LayerConfigJson; reason: string }[] {
|
||||
const dependenciesToAdd: { config: LayerConfigJson; reason: string }[] = []
|
||||
const loadedLayerIds: Set<string> = new Set<string>(alreadyLoaded.map((l) => l?.id))
|
||||
|
@ -391,12 +386,13 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
reason: string
|
||||
context?: string
|
||||
neededBy: string
|
||||
checkHasSnapName: boolean
|
||||
}[] = []
|
||||
|
||||
for (const layerConfig of alreadyLoaded) {
|
||||
try {
|
||||
const layerDeps = DependencyCalculator.getLayerDependencies(
|
||||
new LayerConfig(layerConfig, themeId + "(dependencies)")
|
||||
new LayerConfig(layerConfig, themeId + "(dependencies)"),
|
||||
)
|
||||
dependencies.push(...layerDeps)
|
||||
} catch (e) {
|
||||
|
@ -413,7 +409,11 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
for (const dependency of dependencies) {
|
||||
if (loadedLayerIds.has(dependency.neededLayer)) {
|
||||
// We mark the needed layer as 'mustLoad'
|
||||
alreadyLoaded.find((l) => l.id === dependency.neededLayer).forceLoad = true
|
||||
const loadedLayer = alreadyLoaded.find((l) => l.id === dependency.neededLayer)
|
||||
loadedLayer.forceLoad = true
|
||||
if(dependency.checkHasSnapName && !loadedLayer.snapName){
|
||||
context.enters("layer dependency").err("Layer "+dependency.neededLayer+" is loaded because "+dependency.reason+"; so it must specify a `snapName`. This is used in the sentence `move this point to snap it to {snapName}`")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -436,10 +436,10 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
if (dep === undefined) {
|
||||
const message = [
|
||||
"Loading a dependency failed: layer " +
|
||||
unmetDependency.neededLayer +
|
||||
" is not found, neither as layer of " +
|
||||
themeId +
|
||||
" nor as builtin layer.",
|
||||
unmetDependency.neededLayer +
|
||||
" is not found, neither as layer of " +
|
||||
themeId +
|
||||
" nor as builtin layer.",
|
||||
reason,
|
||||
"Loaded layers are: " + alreadyLoaded.map((l) => l.id).join(","),
|
||||
]
|
||||
|
@ -455,11 +455,12 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
})
|
||||
loadedLayerIds.add(dep.id)
|
||||
unmetDependencies = unmetDependencies.filter(
|
||||
(d) => d.neededLayer !== unmetDependency.neededLayer
|
||||
(d) => d.neededLayer !== unmetDependency.neededLayer,
|
||||
)
|
||||
}
|
||||
} while (unmetDependencies.length > 0)
|
||||
|
||||
|
||||
return dependenciesToAdd
|
||||
}
|
||||
|
||||
|
@ -477,12 +478,12 @@ class AddDependencyLayersToTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
layers,
|
||||
allKnownLayers,
|
||||
theme.id,
|
||||
context
|
||||
context,
|
||||
)
|
||||
if (dependencies.length > 0) {
|
||||
for (const dependency of dependencies) {
|
||||
context.info(
|
||||
"Added " + dependency.config.id + " to the theme. " + dependency.reason
|
||||
"Added " + dependency.config.id + " to the theme. " + dependency.reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -530,7 +531,7 @@ class WarnForUnsubstitutedLayersInTheme extends DesugaringStep<LayoutConfigJson>
|
|||
super(
|
||||
"Generates a warning if a theme uses an unsubstituted layer",
|
||||
["layers"],
|
||||
"WarnForUnsubstitutedLayersInTheme"
|
||||
"WarnForUnsubstitutedLayersInTheme",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -542,7 +543,7 @@ class WarnForUnsubstitutedLayersInTheme extends DesugaringStep<LayoutConfigJson>
|
|||
context
|
||||
.enter("layers")
|
||||
.err(
|
||||
"No layers are defined. You must define at least one layer to have a valid theme"
|
||||
"No layers are defined. You must define at least one layer to have a valid theme",
|
||||
)
|
||||
return json
|
||||
}
|
||||
|
@ -566,10 +567,10 @@ class WarnForUnsubstitutedLayersInTheme extends DesugaringStep<LayoutConfigJson>
|
|||
|
||||
context.warn(
|
||||
"The theme " +
|
||||
json.id +
|
||||
" has an inline layer: " +
|
||||
layer["id"] +
|
||||
". This is discouraged."
|
||||
json.id +
|
||||
" has an inline layer: " +
|
||||
layer["id"] +
|
||||
". This is discouraged.",
|
||||
)
|
||||
}
|
||||
return json
|
||||
|
@ -578,6 +579,7 @@ class WarnForUnsubstitutedLayersInTheme extends DesugaringStep<LayoutConfigJson>
|
|||
|
||||
class PostvalidateTheme extends DesugaringStep<LayoutConfigJson> {
|
||||
private readonly _state: DesugaringContext
|
||||
|
||||
constructor(state: DesugaringContext) {
|
||||
super("Various validation steps when everything is done", [], "PostvalidateTheme")
|
||||
this._state = state
|
||||
|
@ -596,13 +598,13 @@ class PostvalidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
}
|
||||
const sameBasedOn = <LayerConfigJson[]>(
|
||||
json.layers.filter(
|
||||
(l) => l["_basedOn"] === layer["_basedOn"] && l["id"] !== layer.id
|
||||
(l) => l["_basedOn"] === layer["_basedOn"] && l["id"] !== layer.id,
|
||||
)
|
||||
)
|
||||
const minZoomAll = Math.min(...sameBasedOn.map((sbo) => sbo.minzoom))
|
||||
|
||||
const sameNameDetected = sameBasedOn.some(
|
||||
(same) => JSON.stringify(layer["name"]) === JSON.stringify(same["name"])
|
||||
(same) => JSON.stringify(layer["name"]) === JSON.stringify(same["name"]),
|
||||
)
|
||||
if (!sameNameDetected) {
|
||||
// The name is unique, so it'll won't be confusing
|
||||
|
@ -611,12 +613,12 @@ class PostvalidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
if (minZoomAll < layer.minzoom) {
|
||||
context.err(
|
||||
"There are multiple layers based on " +
|
||||
basedOn +
|
||||
". The layer with id " +
|
||||
layer.id +
|
||||
" has a minzoom of " +
|
||||
layer.minzoom +
|
||||
", and has a name set. Another similar layer has a lower minzoom. As such, the layer selection might show 'zoom in to see features' even though some of the features are already visible. Set `\"name\": null` for this layer and eventually remove the 'name':null for the other layer."
|
||||
basedOn +
|
||||
". The layer with id " +
|
||||
layer.id +
|
||||
" has a minzoom of " +
|
||||
layer.minzoom +
|
||||
", and has a name set. Another similar layer has a lower minzoom. As such, the layer selection might show 'zoom in to see features' even though some of the features are already visible. Set `\"name\": null` for this layer and eventually remove the 'name':null for the other layer.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -636,17 +638,17 @@ class PostvalidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
const closeLayers = Utils.sortedByLevenshteinDistance(
|
||||
sameAs,
|
||||
json.layers,
|
||||
(l) => l["id"]
|
||||
(l) => l["id"],
|
||||
).map((l) => l["id"])
|
||||
context
|
||||
.enters("layers", config.id, "filter", "sameAs")
|
||||
.err(
|
||||
"The layer " +
|
||||
config.id +
|
||||
" follows the filter state of layer " +
|
||||
sameAs +
|
||||
", but no layer with this name was found.\n\tDid you perhaps mean one of: " +
|
||||
closeLayers.slice(0, 3).join(", ")
|
||||
config.id +
|
||||
" follows the filter state of layer " +
|
||||
sameAs +
|
||||
", but no layer with this name was found.\n\tDid you perhaps mean one of: " +
|
||||
closeLayers.slice(0, 3).join(", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -654,6 +656,7 @@ class PostvalidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
return json
|
||||
}
|
||||
}
|
||||
|
||||
export class PrepareTheme extends Fuse<LayoutConfigJson> {
|
||||
private state: DesugaringContext
|
||||
|
||||
|
@ -661,7 +664,7 @@ export class PrepareTheme extends Fuse<LayoutConfigJson> {
|
|||
state: DesugaringContext,
|
||||
options?: {
|
||||
skipDefaultLayers: false | boolean
|
||||
}
|
||||
},
|
||||
) {
|
||||
super(
|
||||
"Fully prepares and expands a theme",
|
||||
|
@ -683,7 +686,7 @@ export class PrepareTheme extends Fuse<LayoutConfigJson> {
|
|||
: new AddDefaultLayers(state),
|
||||
new AddDependencyLayersToTheme(state),
|
||||
new AddImportLayers(),
|
||||
new PostvalidateTheme(state)
|
||||
new PostvalidateTheme(state),
|
||||
)
|
||||
this.state = state
|
||||
}
|
||||
|
@ -698,13 +701,13 @@ export class PrepareTheme extends Fuse<LayoutConfigJson> {
|
|||
const needsNodeDatabase = result.layers?.some((l: LayerConfigJson) =>
|
||||
l.tagRenderings?.some((tr) =>
|
||||
ValidationUtils.getSpecialVisualisations(<any>tr)?.some(
|
||||
(special) => special.needsNodeDatabase
|
||||
)
|
||||
)
|
||||
(special) => special.needsNodeDatabase,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (needsNodeDatabase) {
|
||||
context.info(
|
||||
"Setting 'enableNodeDatabase' as this theme uses a special visualisation which needs to keep track of _all_ nodes"
|
||||
"Setting 'enableNodeDatabase' as this theme uses a special visualisation which needs to keep track of _all_ nodes",
|
||||
)
|
||||
result.enableNodeDatabase = true
|
||||
}
|
||||
|
|
|
@ -33,8 +33,8 @@ export default class DependencyCalculator {
|
|||
*/
|
||||
public static getLayerDependencies(
|
||||
layer: LayerConfig
|
||||
): { neededLayer: string; reason: string; context?: string; neededBy: string }[] {
|
||||
const deps: { neededLayer: string; reason: string; context?: string; neededBy: string }[] =
|
||||
): { neededLayer: string; reason: string; context?: string; neededBy: string, checkHasSnapName: boolean }[] {
|
||||
const deps: { neededLayer: string; reason: string; context?: string; neededBy: string, checkHasSnapName: boolean }[] =
|
||||
[]
|
||||
|
||||
for (let i = 0; layer.presets !== undefined && i < layer.presets.length; i++) {
|
||||
|
@ -51,6 +51,7 @@ export default class DependencyCalculator {
|
|||
reason: `preset \`${preset.title.textFor("en")}\` snaps to this layer`,
|
||||
context: `${layer.id}.presets[${i}]`,
|
||||
neededBy: layer.id,
|
||||
checkHasSnapName: true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -62,6 +63,7 @@ export default class DependencyCalculator {
|
|||
reason: "a tagrendering needs this layer",
|
||||
context: tr.id,
|
||||
neededBy: layer.id,
|
||||
checkHasSnapName: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -97,6 +99,7 @@ export default class DependencyCalculator {
|
|||
"] which calculates the value for " +
|
||||
currentKey,
|
||||
neededBy: layer.id,
|
||||
checkHasSnapName: false
|
||||
})
|
||||
|
||||
return []
|
||||
|
|
|
@ -579,4 +579,13 @@ export interface LayerConfigJson {
|
|||
* iftrue: Do not write 'change_within_x_m' and do not indicate that this was done by survey
|
||||
*/
|
||||
enableMorePrivacy?: boolean
|
||||
|
||||
/**
|
||||
* question: When a feature is snapped to this name, how should this item be called?
|
||||
*
|
||||
* In the move wizard, the option `snap object onto {snapName}` is shown
|
||||
*
|
||||
* group: hidden
|
||||
*/
|
||||
snapName?: Translatable
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import { Overpass } from "../../Logic/Osm/Overpass"
|
|||
import Constants from "../Constants"
|
||||
import { QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson"
|
||||
import MarkdownUtils from "../../Utils/MarkdownUtils"
|
||||
import { And } from "../../Logic/Tags/And"
|
||||
|
||||
export default class LayerConfig extends WithContextLoader {
|
||||
public static readonly syncSelectionAllowed = ["no", "local", "theme-only", "global"] as const
|
||||
|
@ -48,6 +49,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
public readonly allowSplit: boolean
|
||||
public readonly shownByDefault: boolean
|
||||
public readonly doCount: boolean
|
||||
public readonly snapName?: Translation
|
||||
/**
|
||||
* In seconds
|
||||
*/
|
||||
|
@ -97,12 +99,13 @@ export default class LayerConfig extends WithContextLoader {
|
|||
mercatorCrs: json.source["mercatorCrs"],
|
||||
idKey: json.source["idKey"],
|
||||
},
|
||||
json.id
|
||||
json.id,
|
||||
)
|
||||
}
|
||||
|
||||
this.allowSplit = json.allowSplit ?? false
|
||||
this.name = Translations.T(json.name, translationContext + ".name")
|
||||
this.snapName = Translations.T(json.snapName, translationContext + ".snapName")
|
||||
|
||||
if (json.description !== undefined) {
|
||||
if (Object.keys(json.description).length === 0) {
|
||||
|
@ -116,7 +119,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
if (json.calculatedTags !== undefined) {
|
||||
if (!official) {
|
||||
console.warn(
|
||||
`Unofficial theme ${this.id} with custom javascript! This is a security risk`
|
||||
`Unofficial theme ${this.id} with custom javascript! This is a security risk`,
|
||||
)
|
||||
}
|
||||
this.calculatedTags = []
|
||||
|
@ -186,7 +189,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
tags: pr.tags.map((t) => TagUtils.SimpleTag(t)),
|
||||
description: Translations.T(
|
||||
pr.description,
|
||||
`${translationContext}.presets.${i}.description`
|
||||
`${translationContext}.presets.${i}.description`,
|
||||
),
|
||||
preciseInput: preciseInput,
|
||||
exampleImages: pr.exampleImages,
|
||||
|
@ -200,7 +203,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
|
||||
if (json.lineRendering) {
|
||||
this.lineRendering = Utils.NoNull(json.lineRendering).map(
|
||||
(r, i) => new LineRenderingConfig(r, `${context}[${i}]`)
|
||||
(r, i) => new LineRenderingConfig(r, `${context}[${i}]`),
|
||||
)
|
||||
} else {
|
||||
this.lineRendering = []
|
||||
|
@ -208,7 +211,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
|
||||
if (json.pointRendering) {
|
||||
this.mapRendering = Utils.NoNull(json.pointRendering).map(
|
||||
(r, i) => new PointRenderingConfig(r, `${context}[${i}](${this.id})`)
|
||||
(r, i) => new PointRenderingConfig(r, `${context}[${i}](${this.id})`),
|
||||
)
|
||||
} else {
|
||||
this.mapRendering = []
|
||||
|
@ -220,7 +223,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
r.location.has("centroid") ||
|
||||
r.location.has("projected_centerpoint") ||
|
||||
r.location.has("start") ||
|
||||
r.location.has("end")
|
||||
r.location.has("end"),
|
||||
)
|
||||
|
||||
if (
|
||||
|
@ -242,7 +245,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
Constants.priviliged_layers.indexOf(<any>this.id) < 0 &&
|
||||
this.source !== null /*library layer*/ &&
|
||||
!this.source?.geojsonSource?.startsWith(
|
||||
"https://api.openstreetmap.org/api/0.6/notes.json"
|
||||
"https://api.openstreetmap.org/api/0.6/notes.json",
|
||||
)
|
||||
) {
|
||||
throw (
|
||||
|
@ -261,7 +264,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
typeof tr !== "string" &&
|
||||
tr["builtin"] === undefined &&
|
||||
tr["id"] === undefined &&
|
||||
tr["rewrite"] === undefined
|
||||
tr["rewrite"] === undefined,
|
||||
) ?? []
|
||||
if (missingIds?.length > 0 && official) {
|
||||
console.error("Some tagRenderings of", this.id, "are missing an id:", missingIds)
|
||||
|
@ -272,8 +275,8 @@ export default class LayerConfig extends WithContextLoader {
|
|||
(tr, i) =>
|
||||
new TagRenderingConfig(
|
||||
<QuestionableTagRenderingConfigJson>tr,
|
||||
this.id + ".tagRenderings[" + i + "]"
|
||||
)
|
||||
this.id + ".tagRenderings[" + i + "]",
|
||||
),
|
||||
)
|
||||
if (json.units !== undefined && !Array.isArray(json.units)) {
|
||||
throw (
|
||||
|
@ -283,7 +286,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
)
|
||||
}
|
||||
this.units = (json.units ?? []).flatMap((unitJson, i) =>
|
||||
Unit.fromJson(unitJson, this.tagRenderings, `${context}.unit[${i}]`)
|
||||
Unit.fromJson(unitJson, this.tagRenderings, `${context}.unit[${i}]`),
|
||||
)
|
||||
|
||||
if (
|
||||
|
@ -359,7 +362,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
|
||||
public GetBaseTags(): Record<string, string> {
|
||||
return TagUtils.changeAsProperties(
|
||||
this.source?.osmTags?.asChange({ id: "node/-1" }) ?? [{ k: "id", v: "node/-1" }]
|
||||
this.source?.osmTags?.asChange({ id: "node/-1" }) ?? [{ k: "id", v: "node/-1" }],
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -372,7 +375,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
neededLayer: string
|
||||
}[] = [],
|
||||
addedByDefault = false,
|
||||
canBeIncluded = true
|
||||
canBeIncluded = true,
|
||||
): string {
|
||||
const extraProps: string[] = []
|
||||
extraProps.push("This layer is shown at zoomlevel **" + this.minzoom + "** and higher")
|
||||
|
@ -380,32 +383,32 @@ export default class LayerConfig extends WithContextLoader {
|
|||
if (canBeIncluded) {
|
||||
if (addedByDefault) {
|
||||
extraProps.push(
|
||||
"**This layer is included automatically in every theme. This layer might contain no points**"
|
||||
"**This layer is included automatically in every theme. This layer might contain no points**",
|
||||
)
|
||||
}
|
||||
if (this.shownByDefault === false) {
|
||||
extraProps.push(
|
||||
"This layer is not visible by default and must be enabled in the filter by the user. "
|
||||
"This layer is not visible by default and must be enabled in the filter by the user. ",
|
||||
)
|
||||
}
|
||||
if (this.title === undefined) {
|
||||
extraProps.push(
|
||||
"Elements don't have a title set and cannot be toggled nor will they show up in the dashboard. If you import this layer in your theme, override `title` to make this toggleable."
|
||||
"Elements don't have a title set and cannot be toggled nor will they show up in the dashboard. If you import this layer in your theme, override `title` to make this toggleable.",
|
||||
)
|
||||
}
|
||||
if (this.name === undefined && this.shownByDefault === false) {
|
||||
extraProps.push(
|
||||
"This layer is not visible by default and the visibility cannot be toggled, effectively resulting in a fully hidden layer. This can be useful, e.g. to calculate some metatags. If you want to render this layer (e.g. for debugging), enable it by setting the URL-parameter layer-<id>=true"
|
||||
"This layer is not visible by default and the visibility cannot be toggled, effectively resulting in a fully hidden layer. This can be useful, e.g. to calculate some metatags. If you want to render this layer (e.g. for debugging), enable it by setting the URL-parameter layer-<id>=true",
|
||||
)
|
||||
}
|
||||
if (this.name === undefined) {
|
||||
extraProps.push(
|
||||
"Not visible in the layer selection by default. If you want to make this layer toggable, override `name`"
|
||||
"Not visible in the layer selection by default. If you want to make this layer toggable, override `name`",
|
||||
)
|
||||
}
|
||||
if (this.mapRendering.length === 0) {
|
||||
extraProps.push(
|
||||
"Not rendered on the map by default. If you want to rendering this on the map, override `mapRenderings`"
|
||||
"Not rendered on the map by default. If you want to rendering this on the map, override `mapRenderings`",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -415,12 +418,12 @@ export default class LayerConfig extends WithContextLoader {
|
|||
"<img src='../warning.svg' height='1rem'/>",
|
||||
"This layer is loaded from an external source, namely ",
|
||||
"`" + this.source.geojsonSource + "`",
|
||||
].join("\n\n")
|
||||
].join("\n\n"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
extraProps.push(
|
||||
"This layer can **not** be included in a theme. It is solely used by [special renderings](SpecialRenderings.md) showing a minimap with custom data."
|
||||
"This layer can **not** be included in a theme. It is solely used by [special renderings](SpecialRenderings.md) showing a minimap with custom data.",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -430,7 +433,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
usingLayer = [
|
||||
"## Themes using this layer",
|
||||
MarkdownUtils.list(
|
||||
(usedInThemes ?? []).map((id) => `[${id}](https://mapcomplete.org/${id})`)
|
||||
(usedInThemes ?? []).map((id) => `[${id}](https://mapcomplete.org/${id})`),
|
||||
),
|
||||
]
|
||||
} else if (this.source !== null) {
|
||||
|
@ -446,15 +449,31 @@ export default class LayerConfig extends WithContextLoader {
|
|||
" into the layout as it depends on it: ",
|
||||
dep.reason,
|
||||
"(" + dep.context + ")",
|
||||
].join(" ")
|
||||
].join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
let presets: string[] = []
|
||||
if (this.presets.length > 0) {
|
||||
|
||||
presets = [
|
||||
"## Presets",
|
||||
"The following options to create new points are included:",
|
||||
MarkdownUtils.list(this.presets.map(preset => {
|
||||
let snaps = ""
|
||||
if (preset.preciseInput?.snapToLayers) {
|
||||
snaps = " (snaps to layers " + preset.preciseInput.snapToLayers.map(id => `\`${id}\``).join(", ") + ")"
|
||||
}
|
||||
return "**" + preset.title.txt + "** which has the following tags:" + new And(preset.tags).asHumanString(true) + snaps
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
for (const revDep of Utils.Dedup(layerIsNeededBy?.get(this.id) ?? [])) {
|
||||
extraProps.push(
|
||||
["This layer is needed as dependency for layer", `[${revDep}](#${revDep})`].join(
|
||||
" "
|
||||
)
|
||||
" ",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -465,10 +484,10 @@ export default class LayerConfig extends WithContextLoader {
|
|||
.filter((values) => values.key !== "id")
|
||||
.map((values) => {
|
||||
const embedded: string[] = values.values?.map((v) =>
|
||||
Link.OsmWiki(values.key, v, true).SetClass("mr-2").AsMarkdown()
|
||||
Link.OsmWiki(values.key, v, true).SetClass("mr-2").AsMarkdown(),
|
||||
) ?? ["_no preset options defined, or no values in them_"]
|
||||
const statistics = `https://taghistory.raifer.tech/?#***/${encodeURIComponent(
|
||||
values.key
|
||||
values.key,
|
||||
)}/`
|
||||
const tagInfo = `https://taginfo.openstreetmap.org/keys/${values.key}#values`
|
||||
return [
|
||||
|
@ -483,7 +502,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
: `[${values.type}](../SpecialInputElements.md#${values.type})`,
|
||||
embedded.join(" "),
|
||||
]
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
let quickOverview: string[] = []
|
||||
|
@ -493,7 +512,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
"this quick overview is incomplete",
|
||||
MarkdownUtils.table(
|
||||
["attribute", "type", "values which are supported by this layer"],
|
||||
tableRows
|
||||
tableRows,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
@ -527,19 +546,19 @@ export default class LayerConfig extends WithContextLoader {
|
|||
const parts = neededTags["and"]
|
||||
tagsDescription.push(
|
||||
"Elements must match **all** of the following expressions:",
|
||||
parts.map((p, i) => i + ". " + p.asHumanString(true, false, {})).join("\n")
|
||||
parts.map((p, i) => i + ". " + p.asHumanString(true, false, {})).join("\n"),
|
||||
)
|
||||
} else if (neededTags["or"]) {
|
||||
const parts = neededTags["or"]
|
||||
tagsDescription.push(
|
||||
"Elements must match **any** of the following expressions:",
|
||||
parts.map((p) => " - " + p.asHumanString(true, false, {})).join("\n")
|
||||
parts.map((p) => " - " + p.asHumanString(true, false, {})).join("\n"),
|
||||
)
|
||||
} else {
|
||||
tagsDescription.push(
|
||||
"Elements must match the expression **" +
|
||||
neededTags.asHumanString(true, false, {}) +
|
||||
"**"
|
||||
neededTags.asHumanString(true, false, {}) +
|
||||
"**",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -559,6 +578,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
].join("\n\n"),
|
||||
MarkdownUtils.list(extraProps),
|
||||
...usingLayer,
|
||||
...presets,
|
||||
...tagsDescription,
|
||||
"## Supported attributes",
|
||||
quickOverview,
|
||||
|
@ -583,4 +603,35 @@ export default class LayerConfig extends WithContextLoader {
|
|||
public isLeftRightSensitive(): boolean {
|
||||
return this.lineRendering.some((lr) => lr.leftRightSensitive)
|
||||
}
|
||||
|
||||
public getMostMatchingPreset(tags: Record<string, string>): PresetConfig {
|
||||
const presets = this.presets
|
||||
if (!presets) {
|
||||
return undefined
|
||||
}
|
||||
const matchingPresets = presets
|
||||
.filter((pr) => new And(pr.tags).matchesProperties(tags))
|
||||
let mostShadowed = matchingPresets[0]
|
||||
let mostShadowedTags = new And(mostShadowed.tags)
|
||||
for (let i = 1; i < matchingPresets.length; i++) {
|
||||
const pr = matchingPresets[i]
|
||||
const prTags = new And(pr.tags)
|
||||
if (mostShadowedTags.shadows(prTags)) {
|
||||
if (!prTags.shadows(mostShadowedTags)) {
|
||||
// We have a new most shadowed item
|
||||
mostShadowed = pr
|
||||
mostShadowedTags = prTags
|
||||
} else {
|
||||
// Both shadow each other: abort
|
||||
mostShadowed = undefined
|
||||
break
|
||||
}
|
||||
} else if (!prTags.shadows(mostShadowedTags)) {
|
||||
// The new contender does not win, but it might defeat the current contender
|
||||
mostShadowed = undefined
|
||||
break
|
||||
}
|
||||
}
|
||||
return mostShadowed ?? matchingPresets[0]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import { TagUtils } from "../../Logic/Tags/TagUtils"
|
||||
import type { WayId } from "../../Models/OsmFeature"
|
||||
|
||||
/**
|
||||
* An advanced location input, which has support to:
|
||||
|
@ -45,11 +46,16 @@
|
|||
}
|
||||
export let snapToLayers: string[] | undefined = undefined
|
||||
export let targetLayer: LayerConfig | undefined = undefined
|
||||
/**
|
||||
* If a 'targetLayer' is given, objects of this layer will be shown as well to avoid duplicates
|
||||
* If you want to hide some of them, blacklist them here
|
||||
*/
|
||||
export let dontShow: string[] = []
|
||||
export let maxSnapDistance: number = undefined
|
||||
export let presetProperties: Tag[] = []
|
||||
let presetPropertiesUnpacked = TagUtils.KVtoProperties(presetProperties)
|
||||
|
||||
export let snappedTo: UIEventSource<string | undefined>
|
||||
export let snappedTo: UIEventSource<WayId | undefined>
|
||||
|
||||
let preciseLocation: UIEventSource<{ lon: number; lat: number }> = new UIEventSource<{
|
||||
lon: number
|
||||
|
@ -57,7 +63,7 @@
|
|||
}>(undefined)
|
||||
|
||||
const map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
|
||||
let initialMapProperties: Partial<MapProperties> & { location } = {
|
||||
export let mapProperties: Partial<MapProperties> & { location } = {
|
||||
zoom: new UIEventSource<number>(19),
|
||||
maxbounds: new UIEventSource(undefined),
|
||||
/*If no snapping needed: the value is simply the map location;
|
||||
|
@ -77,8 +83,11 @@
|
|||
|
||||
if (targetLayer) {
|
||||
// Show already existing items
|
||||
const featuresForLayer = state.perLayer.get(targetLayer.id)
|
||||
let featuresForLayer: FeatureSource = state.perLayer.get(targetLayer.id)
|
||||
if (featuresForLayer) {
|
||||
if (dontShow) {
|
||||
featuresForLayer = new StaticFeatureSource(featuresForLayer.features.map(feats => feats.filter(f => dontShow.indexOf(f.properties.id) < 0)))
|
||||
}
|
||||
new ShowDataLayer(map, {
|
||||
layer: targetLayer,
|
||||
features: featuresForLayer,
|
||||
|
@ -104,13 +113,13 @@
|
|||
const snappedLocation = new SnappingFeatureSource(
|
||||
new FeatureSourceMerger(...Utils.NoNull(snapSources)),
|
||||
// We snap to the (constantly updating) map location
|
||||
initialMapProperties.location,
|
||||
mapProperties.location,
|
||||
{
|
||||
maxDistance: maxSnapDistance ?? 15,
|
||||
allowUnsnapped: true,
|
||||
snappedTo,
|
||||
snapLocation: value,
|
||||
}
|
||||
},
|
||||
)
|
||||
const withCorrectedAttributes = new StaticFeatureSource(
|
||||
snappedLocation.features.mapD((feats) =>
|
||||
|
@ -124,8 +133,8 @@
|
|||
...f,
|
||||
properties,
|
||||
}
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// The actual point to be created, snapped at the new location
|
||||
new ShowDataLayer(map, {
|
||||
|
@ -139,7 +148,7 @@
|
|||
<LocationInput
|
||||
{map}
|
||||
on:click
|
||||
mapProperties={initialMapProperties}
|
||||
{mapProperties}
|
||||
value={preciseLocation}
|
||||
initialCoordinate={coordinate}
|
||||
maxDistanceInMeters={50}
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
import Relocation from "../../assets/svg/Relocation.svelte"
|
||||
import LockClosed from "@babeard/svelte-heroicons/solid/LockClosed"
|
||||
import Key from "@babeard/svelte-heroicons/solid/Key"
|
||||
import Snap from "../../assets/svg/Snap.svelte"
|
||||
|
||||
/**
|
||||
* Renders a single icon.
|
||||
|
@ -152,6 +153,8 @@
|
|||
<LockClosed class={clss} {color} />
|
||||
{:else if icon === "key"}
|
||||
<Key class={clss} {color} />
|
||||
{:else if icon === "snap"}
|
||||
<Snap class={clss} />
|
||||
{:else if Utils.isEmoji(icon)}
|
||||
<span style={`font-size: ${emojiHeight}; line-height: ${emojiHeight}`}>
|
||||
{icon}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
import type { MapProperties } from "../../Models/MapProperties"
|
||||
import type { Feature, Point } from "geojson"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import LocationInput from "../InputElement/Helpers/LocationInput.svelte"
|
||||
import OpenBackgroundSelectorButton from "../BigComponents/OpenBackgroundSelectorButton.svelte"
|
||||
import Geosearch from "../BigComponents/Geosearch.svelte"
|
||||
import If from "../Base/If.svelte"
|
||||
|
@ -21,6 +20,8 @@
|
|||
import ChevronLeft from "@babeard/svelte-heroicons/solid/ChevronLeft"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Icon from "../Map/Icon.svelte"
|
||||
import NewPointLocationInput from "../BigComponents/NewPointLocationInput.svelte"
|
||||
import type { WayId } from "../../Models/OsmFeature"
|
||||
|
||||
export let state: ThemeViewState
|
||||
|
||||
|
@ -36,20 +37,22 @@
|
|||
|
||||
let newLocation = new UIEventSource<{ lon: number; lat: number }>(undefined)
|
||||
|
||||
function initMapProperties() {
|
||||
let snappedTo = new UIEventSource<WayId | undefined>(undefined)
|
||||
|
||||
function initMapProperties(reason: MoveReason) {
|
||||
return <any>{
|
||||
allowMoving: new UIEventSource(true),
|
||||
allowRotating: new UIEventSource(false),
|
||||
allowZooming: new UIEventSource(true),
|
||||
bounds: new UIEventSource(undefined),
|
||||
location: new UIEventSource({ lon, lat }),
|
||||
minzoom: new UIEventSource($reason.minZoom),
|
||||
minzoom: new UIEventSource(reason.minZoom),
|
||||
rasterLayer: state.mapProperties.rasterLayer,
|
||||
zoom: new UIEventSource($reason?.startZoom ?? 16),
|
||||
zoom: new UIEventSource(reason?.startZoom ?? 16),
|
||||
}
|
||||
}
|
||||
|
||||
let moveWizardState = new MoveWizardState(id, layer.allowMove, state)
|
||||
let moveWizardState = new MoveWizardState(id, layer.allowMove, layer, state)
|
||||
if (moveWizardState.reasons.length === 1) {
|
||||
reason.setData(moveWizardState.reasons[0])
|
||||
}
|
||||
|
@ -57,8 +60,8 @@
|
|||
let currentMapProperties: MapProperties = undefined
|
||||
</script>
|
||||
|
||||
<LoginToggle {state}>
|
||||
{#if moveWizardState.reasons.length > 0}
|
||||
{#if moveWizardState.reasons.length > 0}
|
||||
<LoginToggle {state}>
|
||||
{#if $notAllowed}
|
||||
<div class="m-2 flex rounded-lg bg-gray-200 p-2">
|
||||
<Move_not_allowed class="m-2 h-8 w-8" />
|
||||
|
@ -81,7 +84,7 @@
|
|||
<span class="flex flex-col p-2">
|
||||
{#if currentStep === "reason" && moveWizardState.reasons.length > 1}
|
||||
{#each moveWizardState.reasons as reasonSpec}
|
||||
<button
|
||||
<button class="flex justify-start"
|
||||
on:click={() => {
|
||||
reason.setData(reasonSpec)
|
||||
currentStep = "pick_location"
|
||||
|
@ -93,10 +96,16 @@
|
|||
{/each}
|
||||
{:else if currentStep === "pick_location" || currentStep === "reason"}
|
||||
<div class="relative h-64 w-full">
|
||||
<LocationInput
|
||||
mapProperties={(currentMapProperties = initMapProperties())}
|
||||
<NewPointLocationInput
|
||||
mapProperties={(currentMapProperties = initMapProperties($reason))}
|
||||
value={newLocation}
|
||||
initialCoordinate={{ lon, lat }}
|
||||
{state}
|
||||
coordinate={{ lon, lat }}
|
||||
{snappedTo}
|
||||
maxSnapDistance={$reason.maxSnapDistance ?? 5}
|
||||
snapToLayers={$reason.snapTo}
|
||||
targetLayer={layer}
|
||||
dontShow={[id]}
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0">
|
||||
<OpenBackgroundSelectorButton {state} />
|
||||
|
@ -116,7 +125,7 @@
|
|||
<button
|
||||
class="primary w-full"
|
||||
on:click={() => {
|
||||
moveWizardState.moveFeature(newLocation.data, reason.data, featureToMove)
|
||||
moveWizardState.moveFeature(newLocation.data, snappedTo.data, reason.data, featureToMove)
|
||||
currentStep = "moved"
|
||||
}}
|
||||
>
|
||||
|
@ -155,5 +164,5 @@
|
|||
</span>
|
||||
</AccordionSingle>
|
||||
{/if}
|
||||
{/if}
|
||||
</LoginToggle>
|
||||
</LoginToggle>
|
||||
{/if}
|
||||
|
|
|
@ -12,6 +12,8 @@ import { Feature, Point } from "geojson"
|
|||
import SvelteUIElement from "../Base/SvelteUIElement"
|
||||
import Relocation from "../../assets/svg/Relocation.svelte"
|
||||
import Location from "../../assets/svg/Location.svelte"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import { WayId } from "../../Models/OsmFeature"
|
||||
|
||||
export interface MoveReason {
|
||||
text: Translation | string
|
||||
|
@ -24,25 +26,40 @@ export interface MoveReason {
|
|||
startZoom: number
|
||||
minZoom: number
|
||||
eraseAddressFields: false | boolean
|
||||
/**
|
||||
* Snap to these layers
|
||||
*/
|
||||
snapTo?: string[]
|
||||
maxSnapDistance?: number
|
||||
}
|
||||
|
||||
export class MoveWizardState {
|
||||
public readonly reasons: ReadonlyArray<MoveReason>
|
||||
|
||||
public readonly moveDisallowedReason = new UIEventSource<Translation>(undefined)
|
||||
private readonly layer: LayerConfig
|
||||
private readonly _state: SpecialVisualizationState
|
||||
private readonly featureToMoveId: string
|
||||
|
||||
constructor(id: string, options: MoveConfig, state: SpecialVisualizationState) {
|
||||
/**
|
||||
* Initialize the movestate for the feature of the given ID
|
||||
* @param id of the feature that should be moved
|
||||
* @param options
|
||||
* @param layer
|
||||
* @param state
|
||||
*/
|
||||
constructor(id: string, options: MoveConfig, layer: LayerConfig, state: SpecialVisualizationState) {
|
||||
this.layer = layer
|
||||
this._state = state
|
||||
this.reasons = MoveWizardState.initReasons(options)
|
||||
this.featureToMoveId = id
|
||||
this.reasons = this.initReasons(options)
|
||||
if (this.reasons.length > 0) {
|
||||
this.checkIsAllowed(id)
|
||||
}
|
||||
}
|
||||
|
||||
private static initReasons(options: MoveConfig): MoveReason[] {
|
||||
private initReasons(options: MoveConfig): MoveReason[] {
|
||||
const t = Translations.t.move
|
||||
|
||||
const reasons: MoveReason[] = []
|
||||
if (options.enableRelocation) {
|
||||
reasons.push({
|
||||
|
@ -72,20 +89,52 @@ export class MoveWizardState {
|
|||
eraseAddressFields: false,
|
||||
})
|
||||
}
|
||||
|
||||
const tags = this._state.featureProperties.getStore(this.featureToMoveId).data
|
||||
const matchingPresets = this.layer.presets.filter(preset => preset.preciseInput.snapToLayers && new And(preset.tags).matchesProperties(tags))
|
||||
const matchingPreset = matchingPresets.flatMap(pr => pr.preciseInput?.snapToLayers)
|
||||
for (const layerId of matchingPreset) {
|
||||
const snapOntoLayer = this._state.layout.getLayer(layerId)
|
||||
const text = <Translation> t.reasons.reasonSnapTo.PartialSubsTr("name", snapOntoLayer.snapName)
|
||||
reasons.push({
|
||||
text,
|
||||
invitingText: text,
|
||||
icon: "snap",
|
||||
changesetCommentValue: "snap",
|
||||
lockBounds: true,
|
||||
includeSearch: false,
|
||||
background: "photo",
|
||||
startZoom: 19,
|
||||
minZoom: 16,
|
||||
eraseAddressFields: false,
|
||||
snapTo: [snapOntoLayer.id],
|
||||
maxSnapDistance: 5,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
public async moveFeature(
|
||||
loc: { lon: number; lat: number },
|
||||
snappedTo: WayId,
|
||||
reason: MoveReason,
|
||||
featureToMove: Feature<Point>
|
||||
featureToMove: Feature<Point>,
|
||||
) {
|
||||
const state = this._state
|
||||
if(snappedTo !== undefined){
|
||||
this.moveDisallowedReason.set(Translations.t.move.partOfAWay)
|
||||
}
|
||||
await state.changes.applyAction(
|
||||
new ChangeLocationAction(featureToMove.properties.id, [loc.lon, loc.lat], {
|
||||
reason: reason.changesetCommentValue,
|
||||
theme: state.layout.id,
|
||||
})
|
||||
new ChangeLocationAction(state,
|
||||
featureToMove.properties.id,
|
||||
[loc.lon, loc.lat],
|
||||
snappedTo,
|
||||
{
|
||||
reason: reason.changesetCommentValue,
|
||||
theme: state.layout.id,
|
||||
}),
|
||||
)
|
||||
featureToMove.properties._lat = loc.lat
|
||||
featureToMove.properties._lon = loc.lon
|
||||
|
@ -104,8 +153,8 @@ export class MoveWizardState {
|
|||
{
|
||||
changeType: "relocated",
|
||||
theme: state.layout.id,
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1995,35 +1995,8 @@ export default class SpecialVisualizations {
|
|||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const translation = tagSource.map((tags) => {
|
||||
const presets = state.layout.getMatchingLayer(tags)?.presets
|
||||
if(!presets){
|
||||
return undefined
|
||||
}
|
||||
const matchingPresets = presets
|
||||
.filter((pr) => pr.description !== undefined)
|
||||
.filter((pr) => new And(pr.tags).matchesProperties(tags))
|
||||
let mostShadowed = matchingPresets[0]
|
||||
let mostShadowedTags = new And(mostShadowed.tags)
|
||||
for (let i = 1; i < matchingPresets.length; i++) {
|
||||
const pr = matchingPresets[i]
|
||||
const prTags = new And(pr.tags)
|
||||
if (mostShadowedTags.shadows(prTags)) {
|
||||
if (!prTags.shadows(mostShadowedTags)) {
|
||||
// We have a new most shadowed item
|
||||
mostShadowed = pr
|
||||
mostShadowedTags = prTags
|
||||
} else {
|
||||
// Both shadow each other: abort
|
||||
mostShadowed = undefined
|
||||
break
|
||||
}
|
||||
} else if (!prTags.shadows(mostShadowedTags)) {
|
||||
// The new contender does not win, but it might defeat the current contender
|
||||
mostShadowed = undefined
|
||||
break
|
||||
}
|
||||
}
|
||||
return mostShadowed?.description ?? matchingPresets[0]?.description
|
||||
const layer = state.layout.getMatchingLayer(tags)
|
||||
return layer?.getMostMatchingPreset(tags)?.description
|
||||
})
|
||||
return new VariableUiElement(translation)
|
||||
}
|
||||
|
|
|
@ -417,6 +417,9 @@ export class TypedTranslation<T extends Record<string, any>> extends Translation
|
|||
key: string,
|
||||
replaceWith: Translation
|
||||
): TypedTranslation<Omit<T, K>> {
|
||||
if(replaceWith === undefined){
|
||||
return this
|
||||
}
|
||||
const newTranslations: Record<string, string> = {}
|
||||
const toSearch = "{" + key + "}"
|
||||
const missingLanguages = new Set<string>(Object.keys(this.translations))
|
||||
|
|
4
src/assets/svg/Snap.svelte
Normal file
4
src/assets/svg/Snap.svelte
Normal file
|
@ -0,0 +1,4 @@
|
|||
<script>
|
||||
export let color = "#000000"
|
||||
</script>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) --> <svg {...$$restProps} on:click on:mouseover on:mouseenter on:mouseleave on:keydown on:focus width="120" height="120" viewBox="0 0 120 120" version="1.1" id="svg1" xml:space="preserve" inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)" sodipodi:docname="snap.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#999999" borderopacity="1" inkscape:showpageshadow="2" inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" showguides="true" inkscape:zoom="4.5168066" inkscape:cx="40.51535" inkscape:cy="42.39721" inkscape:window-width="1920" inkscape:window-height="995" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1"><sodipodi:guide position="315.49944,61.936443" orientation="0,-1" id="guide2" inkscape:locked="false" /></sodipodi:namedview><defs id="defs1" /><g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-5,-5)"><path id="path1-2" style="fill:#808080;fill-opacity:1;stroke-width:3.93092" d="m 72.294942,72.07284 c 0.948679,-0.909931 1.380066,-2.234124 1.148907,-3.527969 L 69.995854,51.284128 C 69.614243,49.146951 67.469637,47.282834 65.914649,48.798037 L 59.882054,54.677556 44.886208,39.828561 c -1.383257,-1.369713 -3.807955,-0.909932 -4.298111,-0.288099 -1.707154,2.165786 -0.138139,3.968458 0.177549,4.304093 l 14.45874,15.372473 -6.032604,5.879513 c -1.554665,1.51554 0.253754,3.707343 2.380393,4.143751 l 17.16644,3.89041 c 1.287478,0.264342 2.622313,-0.132882 3.556328,-1.057866 z" sodipodi:nodetypes="cccccsssccccc" /><g id="g8" /><g id="g10" transform="rotate(-175.99037,61.199753,60.156378)"><g id="path1" inkscape:transform-center-x="1.8238832" inkscape:transform-center-y="31.570993"><path style="color:{color};fill:{color};stroke-linecap:round;-inkscape-stroke:none" d="M 10,90 45,10" id="path3" /><path id="path4" style="color:{color};fill:{color}009;stroke-linecap:round;-inkscape-stroke:none" d="M 45.097656,5.0019531 A 5,5 0 0 0 43.177734,5.34375 5,5 0 0 0 40.419922,7.9960938 L 35.865234,18.40625 c 3.405007,0.669609 6.469474,2.331825 8.867188,4.679688 L 49.580078,12.003906 A 5,5 0 0 0 47.003906,5.4199219 5,5 0 0 0 45.097656,5.0019531 Z M 22.177734,49.691406 5.4199219,87.996094 a 5,5 0 0 0 2.5761719,6.583984 5,5 0 0 0 6.5839842,-2.576172 L 31.621094,53.052734 c -3.513941,-0.175553 -6.76611,-1.396873 -9.44336,-3.361328 z" /></g><path style="fill:{color};fill-opacity:1;stroke:{color};stroke-width:0;stroke-linecap:round;stroke-opacity:1" id="path9" sodipodi:type="arc" sodipodi:cx="32.616085" sodipodi:cy="35.55938" sodipodi:rx="12.741771" sodipodi:ry="12.741771" sodipodi:start="0" sodipodi:end="6.26046" sodipodi:open="true" sodipodi:arc-type="arc" d="M 45.357856,35.55938 A 12.741771,12.741771 0 0 1 32.688475,48.300945 12.741771,12.741771 0 0 1 19.875137,35.704157 12.741771,12.741771 0 0 1 32.398925,22.81946 12.741771,12.741771 0 0 1 45.354566,35.269844" /></g><path style="fill:#808080;fill-opacity:1;stroke:{color};stroke-width:0;stroke-linecap:round;stroke-opacity:1" id="path10" sodipodi:type="arc" sodipodi:cx="26.067774" sodipodi:cy="26.136267" sodipodi:rx="12.741771" sodipodi:ry="12.741771" sodipodi:start="0" sodipodi:end="6.26046" sodipodi:open="true" sodipodi:arc-type="arc" d="M 38.809545,26.136267 A 12.741771,12.741771 0 0 1 26.140164,38.877832 12.741771,12.741771 0 0 1 13.326826,26.281044 12.741771,12.741771 0 0 1 25.850614,13.396347 12.741771,12.741771 0 0 1 38.806255,25.846731" /></g></svg>
|
Loading…
Add table
Add a link
Reference in a new issue