Merge develop

This commit is contained in:
Pieter Vander Vennet 2024-05-02 23:56:31 +02:00
commit ef3e27ee8b
399 changed files with 38592 additions and 44846 deletions

View file

@ -2,10 +2,15 @@ import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { QueryParameters } from "../Web/QueryParameters"
import Hash from "../Web/Hash"
import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
import { OsmObject } from "../Osm/OsmObject"
import Constants from "../../Models/Constants"
/**
* This actor is responsible to set the map location.
* It will attempt to
* - Set the map to the position of the selected element
* - Set the map to the position as passed in by the query parameters (if available)
* - Set the map to the position remembered in LocalStorage (if available)
* - Set the map to the layout default
@ -16,6 +21,7 @@ export default class InitialMapPositioning {
public zoom: UIEventSource<number>
public location: UIEventSource<{ lon: number; lat: number }>
public useTerrain: Store<boolean>
constructor(layoutToUse: LayoutConfig) {
function localStorageSynced(
key: string,
@ -38,6 +44,8 @@ export default class InitialMapPositioning {
return src
}
const initialHash = Hash.hash.data
// -- Location control initialization
this.zoom = localStorageSynced(
"z",
@ -62,5 +70,19 @@ export default class InitialMapPositioning {
lon.setData(loc.lon)
})
this.useTerrain = new ImmutableStore<boolean>(layoutToUse.enableTerrain)
if (initialHash?.match(/^(node|way|relation)\/[0-9]+$/)) {
const [type, id] = initialHash.split("/")
OsmObjectDownloader.RawDownloadObjectAsync(type, Number(id), Constants.osmAuthConfig.url + "/").then(osmObject => {
if (osmObject === "deleted") {
return
}
const targetLayer = layoutToUse.getMatchingLayer(osmObject.tags)
this.zoom.setData(Math.max(this.zoom.data, targetLayer.minzoom))
const [lat, lon] = osmObject.centerpoint()
this.location.setData({ lon, lat })
})
}
}
}

View file

@ -127,7 +127,9 @@ export abstract class OsmObject {
return result
}
// The centerpoint of the feature, as [lat, lon]
/** The centerpoint of the feature, as [lat, lon]
*
*/
public abstract centerpoint(): [number, number]
public abstract asGeoJson(): any

View file

@ -62,7 +62,7 @@ export default class OsmObjectDownloader {
if (idN < 0) {
obj = this.constructObject(<"node" | "way" | "relation">type, idN)
} else {
obj = await this.RawDownloadObjectAsync(type, idN, maxCacheAgeInSecs)
obj = await OsmObjectDownloader.RawDownloadObjectAsync(type, idN, this.backend, maxCacheAgeInSecs)
}
if (obj === "deleted") {
return obj
@ -211,13 +211,22 @@ export default class OsmObjectDownloader {
}
}
private async RawDownloadObjectAsync(
/**
* Only to be used in exceptional cases
* @param type
* @param idN
* @param backend
* @param maxCacheAgeInSecs
* @constructor
*/
public static async RawDownloadObjectAsync(
type: string,
idN: number,
backend: string,
maxCacheAgeInSecs?: number
): Promise<OsmObject | "deleted"> {
const full = type !== "node" ? "/full" : ""
const url = `${this.backend}api/0.6/${type}/${idN}${full}`
const url = `${backend}api/0.6/${type}/${idN}${full}`
const rawData = await Utils.downloadJsonCachedAdvanced(
url,
(maxCacheAgeInSecs ?? 10) * 1000
@ -227,7 +236,7 @@ export default class OsmObjectDownloader {
}
// A full query might contain more then just the requested object (e.g. nodes that are part of a way, where we only want the way)
const parsed = OsmObject.ParseObjects(rawData["content"].elements)
// Lets fetch the object we need
// Let us fetch the object we need
for (const osmObject of parsed) {
if (osmObject.type !== type) {
continue

View file

@ -435,7 +435,7 @@ export default class SimpleMetaTaggers {
() => feature.properties["_country"]
)
let canonical =
denomination?.canonicalValue(value, defaultDenom == denomination) ??
denomination?.canonicalValue(value, defaultDenom == denomination, unit.inverted) ??
undefined
if (canonical === value) {
break

View file

@ -331,6 +331,9 @@ export default class LinkedDataLoader {
return
}
output[key] = output[key].map((v) => applyF(v))
if(!output[key].some(v => v !== undefined)){
delete output[key]
}
}
function asBoolean(key: string, invert: boolean = false) {
@ -379,6 +382,7 @@ export default class LinkedDataLoader {
}
return "€" + Number(p)
})
if (output["charge"] && output["timeUnit"]) {
const duration =
Number(output["chargeEnd"] ?? "1") - Number(output["chargeStart"] ?? "0")

View file

@ -1,6 +1,7 @@
import { Translation, TypedTranslation } from "../UI/i18n/Translation"
import { DenominationConfigJson } from "./ThemeConfig/Json/UnitConfigJson"
import Translations from "../UI/i18n/Translations"
import { Validator } from "../UI/InputElement/Validator"
/**
* A 'denomination' is one way to write a certain quantity.
@ -15,6 +16,7 @@ export class Denomination {
public readonly alternativeDenominations: string[]
public readonly human: TypedTranslation<{ quantity: string }>
public readonly humanSingular?: Translation
private readonly _validator: Validator
private constructor(
canonical: string,
@ -24,7 +26,8 @@ export class Denomination {
addSpace: boolean,
alternativeDenominations: string[],
_human: TypedTranslation<{ quantity: string }>,
_humanSingular?: Translation
_humanSingular: Translation,
validator: Validator
) {
this.canonical = canonical
this._canonicalSingular = _canonicalSingular
@ -34,9 +37,10 @@ export class Denomination {
this.alternativeDenominations = alternativeDenominations
this.human = _human
this.humanSingular = _humanSingular
this._validator = validator
}
public static fromJson(json: DenominationConfigJson, context: string) {
public static fromJson(json: DenominationConfigJson, validator: Validator, context: string) {
context = `${context}.unit(${json.canonicalDenomination})`
const canonical = json.canonicalDenomination.trim()
if (canonical === undefined) {
@ -68,7 +72,8 @@ export class Denomination {
json.addSpace ?? false,
json.alternativeDenomination?.map((v) => v.trim()) ?? [],
humanTexts,
Translations.T(json.humanSingular, context + "humanSingular")
Translations.T(json.humanSingular, context + "humanSingular"),
validator
)
}
@ -81,7 +86,8 @@ export class Denomination {
this.addSpace,
this.alternativeDenominations,
this.human,
this.humanSingular
this.humanSingular,
this._validator
)
}
@ -94,7 +100,8 @@ export class Denomination {
this.addSpace,
[this.canonical, ...this.alternativeDenominations],
this.human,
this.humanSingular
this.humanSingular,
this._validator
)
}
@ -103,19 +110,21 @@ export class Denomination {
* @param value the value from OSM
* @param actAsDefault if set and the value can be parsed as number, will be parsed and trimmed
*
* import Validators from "../UI/InputElement/Validators"
*
* const unit = Denomination.fromJson({
* canonicalDenomination: "m",
* alternativeDenomination: ["meter"],
* human: {
* en: "{quantity} meter"
* }
* }, "test")
* unit.canonicalValue("42m", true) // =>"42 m"
* unit.canonicalValue("42", true) // =>"42 m"
* unit.canonicalValue("42 m", true) // =>"42 m"
* unit.canonicalValue("42 meter", true) // =>"42 m"
* unit.canonicalValue("42m", true) // =>"42 m"
* unit.canonicalValue("42", true) // =>"42 m"
* }, Validators.get("float"), "test")
* unit.canonicalValue("42m", true, false) // =>"42 m"
* unit.canonicalValue("42", true, false) // =>"42 m"
* unit.canonicalValue("42 m", true, false) // =>"42 m"
* unit.canonicalValue("42 meter", true, false) // =>"42 m"
* unit.canonicalValue("42m", true, false) // =>"42 m"
* unit.canonicalValue("42", true, false) // =>"42 m"
*
* // Should be trimmed if canonical is empty
* const unit = Denomination.fromJson({
@ -124,22 +133,26 @@ export class Denomination {
* human: {
* en: "{quantity} meter"
* }
* }, "test")
* unit.canonicalValue("42m", true) // =>"42"
* unit.canonicalValue("42", true) // =>"42"
* unit.canonicalValue("42 m", true) // =>"42"
* unit.canonicalValue("42 meter", true) // =>"42"
* }, Validators.get("float"), "test")
* unit.canonicalValue("42m", true, false) // =>"42"
* unit.canonicalValue("42", true, false) // =>"42"
* unit.canonicalValue("42 m", true, false) // =>"42"
* unit.canonicalValue("42 meter", true, false) // =>"42"
*
*
*/
public canonicalValue(value: string, actAsDefault: boolean): string {
public canonicalValue(value: string, actAsDefault: boolean, inverted: boolean): string {
if (value === undefined) {
return undefined
}
const stripped = this.StrippedValue(value, actAsDefault)
const stripped = this.StrippedValue(value, actAsDefault, inverted)
if (stripped === null) {
return null
}
if(inverted){
return (stripped + "/" + this.canonical).trim()
}
if (stripped === "1" && this._canonicalSingular !== undefined) {
return ("1 " + this._canonicalSingular).trim()
}
@ -153,7 +166,7 @@ export class Denomination {
*
* Returns null if it doesn't match this unit
*/
public StrippedValue(value: string, actAsDefault: boolean): string {
public StrippedValue(value: string, actAsDefault: boolean, inverted: boolean): string {
if (value === undefined) {
return undefined
}
@ -171,10 +184,16 @@ export class Denomination {
function substr(key) {
if (self.prefix) {
return value.substr(key.length).trim()
} else {
return value.substring(0, value.length - key.length).trim()
return value.substring(key.length).trim()
}
let trimmed = value.substring(0, value.length - key.length).trim()
if(!inverted){
return trimmed
}
if(trimmed.endsWith("/")){
trimmed = trimmed.substring(0, trimmed.length - 1).trim()
}
return trimmed
}
if (this.canonical !== "" && startsWith(this.canonical.toLowerCase())) {
@ -199,11 +218,13 @@ export class Denomination {
return null
}
const parsed = Number(value.trim())
if (!isNaN(parsed)) {
return value.trim()
if(!this._validator.isValid(value.trim())){
return null
}
return this._validator.reformat(value.trim())
}
return null
withValidator(validator: Validator) {
return new Denomination(this.canonical, this._canonicalSingular, this.useIfNoUnitGiven, this.prefix, this.addSpace, this.alternativeDenominations, this.human, this.humanSingular, validator)
}
}

View file

@ -33,6 +33,12 @@ export class UpdateLegacyLayer extends DesugaringStep<
delete config["overpassTags"]
}
if(config.allowMove?.["enableImproveAccuraccy"]){
// Fix common misspelling: 'accuracy' is often typo'ed as 'accuraCCy'
config.allowMove["enableImproveAccuracy"] = config.allowMove["enableImproveAccuraccy"]
delete config.allowMove["enableImproveAccuraccy"]
}
for (const preset of config.presets ?? []) {
const preciseInput = preset["preciseInput"]
if (typeof preciseInput === "boolean") {

View file

@ -230,7 +230,7 @@ class ExpandTagRendering extends Conversion<
}
for (let foundTr of indirect) {
foundTr = Utils.Clone<any>(foundTr)
ctx.Merge(tagRenderingConfigJson["override"] ?? {}, foundTr)
ctx.MergeObjectsForOverride(tagRenderingConfigJson["override"] ?? {}, foundTr)
foundTr["id"] = tagRenderingConfigJson["id"] ?? foundTr["id"]
result.push(foundTr)
}

View file

@ -1647,6 +1647,10 @@ export class ValidateLayer extends Conversion<
}
}
if(json.allowMove?.["enableAccuraccy"] !== undefined){
context.enters("allowMove", "enableAccuracy").err("`enableAccuracy` is written with two C in the first occurrence and only one in the last")
}
return { raw: json, parsed: layerConfig }
}
}

View file

@ -291,17 +291,24 @@ export interface LayerConfigJson {
forceLoad?: false | boolean
/**
* Presets for this layer.
* A preset shows up when clicking the map on a without data (or when right-clicking/long-pressing);
* it will prompt the user to add a new point.
* <div class='flex'>
* <div>
* Presets for this layer.
*
* The most important aspect are the tags, which define which tags the new point will have;
* The title is shown in the dialog, along with the first sentence of the description.
* A preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.
*
* Upon confirmation, the full description is shown beneath the buttons - perfect to add pictures and examples.
* When the contributor wishes to add a point to OpenStreetMap, they'll:
*
* Note: the icon of the preset is determined automatically based on the tags and the icon above. Don't worry about that!
* NB: if no presets are defined, the popup to add new points doesn't show up at all
* 1. Press the 'add new point'-button
* 2. Choose a preset from the list of all presets
* 3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown
* 4. Confirm the location
* 5. A new point will be created with the attributes that were defined in the preset
*
* If no presets are defined, the button which invites to add a new preset will not be shown.
*</div>
* <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/>
*</div>
*
* group: presets
*/
@ -512,6 +519,7 @@ export interface LayerConfigJson {
/**
* Either a list with [{"key": "unitname", "key2": {"quantity": "unitname", "denominations": ["denom", "denom"]}}]
*
* Use `"inverted": true` if the amount should be _divided_ by the denomination, e.g. for charge over time (`€5/day`)
*
* @see UnitConfigJson
*
@ -519,7 +527,7 @@ export interface LayerConfigJson {
*/
units?: (
| UnitConfigJson
| Record<string, string | { quantity: string; denominations: string[]; canonical?: string }>
| Record<string, string | { quantity: string; denominations: string[]; canonical?: string, inverted?: boolean }>
)[]
/**

View file

@ -157,17 +157,16 @@ export interface LayoutConfigJson {
* types: hidden | layer | hidden
* group: layers
* suggestions: return Array.from(layers.keys()).map(key => ({if: "value="+key, then: "<b>"+key+"</b> (builtin) - "+layers.get(key).description}))
* Every layer contains a description of which feature to display - the overpassTags which are queried.
* Instead of running one query for every layer, the query is fused.
*
* Afterwards, every layer is given the list of features.
* Every layer takes away the features that match with them*, and give the leftovers to the next layers.
* A theme must contain at least one layer.
*
* This implies that the _order_ of the layers is important in the case of features with the same tags;
* as the later layers might never receive their feature.
* A layer contains all features of a single type, for example "shops", "bicycle pumps", "benches".
* Note that every layer contains a specification of attributes that it should match. MapComplete will fetch the relevant data from either overpass, the OSM-API or the cache server.
* If a feature can match multiple layers, the first matching layer in the list will be used.
* This implies that the _order_ of the layers is important.
*
* *layers can also remove 'leftover'-features if the leftovers overlap with a feature in the layer itself
*
* <div class='hidden-in-studio'>
* Note that builtin layers can be reused. Either put in the name of the layer to reuse, or use {builtin: "layername", override: ...}
*
* The 'override'-object will be copied over the original values of the layer, which allows to change certain aspects of the layer
@ -194,6 +193,7 @@ export interface LayoutConfigJson {
* "override": {"minzoom": 12}
* }
*```
* </div>
*/
layers: (
| LayerConfigJson
@ -362,7 +362,7 @@ export interface LayoutConfigJson {
/**
* question: Should the 'download as CSV'- and 'download as Geojson'-buttons be enabled?
* iftrue: Enable the option to download the map as CSV and GeoJson
* iffalse: Enable the option to download the map as CSV and GeoJson
* iffalse: Disable the option to download the map as CSV and GeoJson
* ifunset: MapComplete default: Enable the option to download the map as CSV and GeoJson
* group: feature_switches
*/

View file

@ -97,18 +97,6 @@ export default class LayerConfig extends WithContextLoader {
this.allowSplit = json.allowSplit ?? false
this.name = Translations.T(json.name, translationContext + ".name")
if (json.units !== undefined && !Array.isArray(json.units)) {
throw (
"At " +
context +
".units: the 'units'-section should be a list; you probably have an object there"
)
}
this.units = [].concat(
...(json.units ?? []).map((unitJson, i) =>
Unit.fromJson(unitJson, `${context}.unit[${i}]`)
)
)
if (json.description !== undefined) {
if (Object.keys(json.description).length === 0) {
@ -280,6 +268,18 @@ export default class LayerConfig extends WithContextLoader {
this.id + ".tagRenderings[" + i + "]"
)
)
if (json.units !== undefined && !Array.isArray(json.units)) {
throw (
"At " +
context +
".units: the 'units'-section should be a list; you probably have an object there"
)
}
this.units = [].concat(
...(json.units ?? []).map((unitJson, i) =>
Unit.fromJson(unitJson, this.tagRenderings,`${context}.unit[${i}]`)
)
)
if (
json.filter !== undefined &&
@ -368,7 +368,6 @@ export default class LayerConfig extends WithContextLoader {
canBeIncluded = true
): BaseUIElement {
const extraProps: (string | BaseUIElement)[] = []
extraProps.push("This layer is shown at zoomlevel **" + this.minzoom + "** and higher")
if (canBeIncluded) {
@ -424,7 +423,7 @@ export default class LayerConfig extends WithContextLoader {
if (!addedByDefault) {
if (usedInThemes?.length > 0) {
usingLayer = [
new Title("Themes using this layer", 4),
new Title("Themes using this layer", 2),
new List(
(usedInThemes ?? []).map(
(id) => new Link(id, "https://mapcomplete.org/" + id)

View file

@ -12,6 +12,8 @@ import { VariableUiElement } from "../../UI/Base/VariableUIElement"
import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
import SvelteUIElement from "../../UI/Base/SvelteUIElement"
import DynamicMarker from "../../UI/Map/DynamicMarker.svelte"
import { UIElement } from "../../UI/UIElement"
import Img from "../../UI/Base/Img"
export class IconConfig extends WithContextLoader {
public static readonly defaultIcon = new IconConfig({ icon: "pin", color: "#ff9939" })
@ -121,9 +123,14 @@ export default class PointRenderingConfig extends WithContextLoader {
context + ".rotationAlignment"
)
}
private static FromHtmlMulti(multiSpec: string, tags: Store<Record<string, string>>) {
private static FromHtmlMulti(multiSpec: string, tags: Store<Record<string, string>>): BaseUIElement {
const icons: IconConfig[] = []
for (const subspec of multiSpec.split(";")) {
if(subspec.startsWith("http://") || subspec.startsWith("https://")){
icons.push(new IconConfig({icon: subspec}))
continue
}
const [icon, color] = subspec.split(":")
icons.push(new IconConfig({ icon, color }))
}

View file

@ -52,6 +52,7 @@ export default class TagRenderingConfig {
public readonly renderIconClass?: string
public readonly question?: TypedTranslation<object>
public readonly questionhint?: TypedTranslation<object>
public readonly questionHintIsMd?: boolean
public readonly condition?: TagsFilter
public readonly invalidValues?: TagsFilter
/**
@ -80,7 +81,7 @@ export default class TagRenderingConfig {
public readonly classes: string[] | undefined
constructor(
config: string | TagRenderingConfigJson | QuestionableTagRenderingConfigJson,
config: string | TagRenderingConfigJson | (QuestionableTagRenderingConfigJson & {questionHintIsMd?: boolean}),
context?: string
) {
let json = <string | QuestionableTagRenderingConfigJson>config
@ -136,6 +137,7 @@ export default class TagRenderingConfig {
this.render = Translations.T(<any>json.render, translationKey + ".render")
this.question = Translations.T(json.question, translationKey + ".question")
this.questionhint = Translations.T(json.questionHint, translationKey + ".questionHint")
this.questionHintIsMd = json["questionHintIsMd"] ?? false
this.description = Translations.T(json.description, translationKey + ".description")
this.editButtonAriaLabel = Translations.T(
json.editButtonAriaLabel,

View file

@ -2,6 +2,10 @@ import BaseUIElement from "../UI/BaseUIElement"
import { Denomination } from "./Denomination"
import UnitConfigJson from "./ThemeConfig/Json/UnitConfigJson"
import unit from "../../assets/layers/unit/unit.json"
import { QuestionableTagRenderingConfigJson } from "./ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import TagRenderingConfig from "./ThemeConfig/TagRenderingConfig"
import Validators, { ValidatorType } from "../UI/InputElement/Validators"
import { Validator } from "../UI/InputElement/Validator"
export class Unit {
private static allUnits = this.initUnits()
@ -10,14 +14,20 @@ export class Unit {
public readonly denominationsSorted: Denomination[]
public readonly eraseInvalid: boolean
public readonly quantity: string
private readonly _validator: Validator
public readonly inverted: boolean
constructor(
quantity: string,
appliesToKeys: string[],
applicableDenominations: Denomination[],
eraseInvalid: boolean
eraseInvalid: boolean,
validator: Validator,
inverted: boolean = false
) {
this.quantity = quantity
this._validator = validator
this.inverted = inverted
this.appliesToKeys = new Set(appliesToKeys)
this.denominations = applicableDenominations
this.eraseInvalid = eraseInvalid
@ -66,13 +76,47 @@ export class Unit {
static fromJson(
json:
| UnitConfigJson
| Record<string, string | { quantity: string; denominations: string[] }>,
| Record<string, string | { quantity: string; denominations: string[], inverted?: boolean }>,
tagRenderings: TagRenderingConfig[],
ctx: string
): Unit[] {
if (!json.appliesToKey && !json.quantity) {
return this.loadFromLibrary(<any>json, ctx)
let types: Record<string, ValidatorType> = {}
for (const tagRendering of tagRenderings) {
if (tagRendering.freeform?.type) {
types[tagRendering.freeform.key] = tagRendering.freeform.type
}
}
return [this.parse(<UnitConfigJson>json, ctx)]
if (!json.appliesToKey && !json.quantity) {
return this.loadFromLibrary(<any>json, types, ctx)
}
return this.parse(<UnitConfigJson>json, types, ctx)
}
private static parseDenomination(json: UnitConfigJson, validator: Validator, appliesToKey: string, ctx: string): Unit {
const applicable = json.applicableUnits.map((u, i) =>
Denomination.fromJson(u, validator, `${ctx}.units[${i}]`)
)
if (
json.defaultInput &&
!applicable.some((denom) => denom.canonical.trim() === json.defaultInput)
) {
throw `${ctx}: no denomination has the specified default denomination. The default denomination is '${
json.defaultInput
}', but the available denominations are ${applicable
.map((denom) => denom.canonical)
.join(", ")}`
}
return new Unit(
json.quantity ?? "",
appliesToKey === undefined ? undefined : [appliesToKey],
applicable,
json.eraseInvalidValues ?? false,
validator
)
}
/**
@ -113,7 +157,7 @@ export class Unit {
* ]
* }, "test")
*/
private static parse(json: UnitConfigJson, ctx: string): Unit {
private static parse(json: UnitConfigJson, types: Record<string, ValidatorType>, ctx: string): Unit[] {
const appliesTo = json.appliesToKey
for (let i = 0; i < (appliesTo ?? []).length; i++) {
let key = appliesTo[i]
@ -127,32 +171,22 @@ export class Unit {
}
// Some keys do have unit handling
const applicable = json.applicableUnits.map((u, i) =>
Denomination.fromJson(u, `${ctx}.units[${i}]`)
)
if (
json.defaultInput &&
!applicable.some((denom) => denom.canonical.trim() === json.defaultInput)
) {
throw `${ctx}: no denomination has the specified default denomination. The default denomination is '${
json.defaultInput
}', but the available denominations are ${applicable
.map((denom) => denom.canonical)
.join(", ")}`
const units: Unit[] = []
if (appliesTo === undefined) {
units.push(this.parseDenomination(json, Validators.get("float"), undefined, ctx))
}
return new Unit(
json.quantity ?? "",
appliesTo,
applicable,
json.eraseInvalidValues ?? false
)
for (const key of appliesTo ?? []) {
const validator = Validators.get(types[key] ?? "float")
units.push(this.parseDenomination(json, validator, undefined, ctx))
}
return units
}
private static initUnits(): Map<string, Unit> {
const m = new Map<string, Unit>()
const units = (<UnitConfigJson[]>unit.units).map((json, i) =>
this.parse(json, "unit.json.units." + i)
const units = (<UnitConfigJson[]>unit.units).flatMap((json, i) =>
this.parse(json, {}, "unit.json.units." + i)
)
for (const unit of units) {
@ -179,17 +213,19 @@ export class Unit {
private static loadFromLibrary(
spec: Record<
string,
string | { quantity: string; denominations: string[]; canonical?: string }
string | { quantity: string; denominations: string[]; canonical?: string, inverted?: boolean }
>,
types: Record<string, ValidatorType>,
ctx: string
): Unit[] {
const units: Unit[] = []
for (const key in spec) {
const toLoad = spec[key]
const validator = Validators.get(types[key] ?? "float")
if (typeof toLoad === "string") {
const loaded = this.getFromLibrary(toLoad, ctx)
units.push(
new Unit(loaded.quantity, [key], loaded.denominations, loaded.eraseInvalid)
new Unit(loaded.quantity, [key], loaded.denominations, loaded.eraseInvalid, validator, toLoad["inverted"])
)
continue
}
@ -213,12 +249,13 @@ export class Unit {
const denoms = toLoad.denominations
.map((d) => d.toLowerCase())
.map((d) => fetchDenom(d))
.map(d => d.withValidator(validator))
if (toLoad.canonical) {
const canonical = fetchDenom(toLoad.canonical)
const canonical = fetchDenom(toLoad.canonical).withValidator(validator)
denoms.unshift(canonical.withBlankCanonical())
}
units.push(new Unit(loaded.quantity, [key], denoms, loaded.eraseInvalid))
units.push(new Unit(loaded.quantity, [key], denoms, loaded.eraseInvalid, validator, toLoad["inverted"]))
}
return units
}
@ -240,7 +277,7 @@ export class Unit {
}
const defaultDenom = this.getDefaultDenomination(country)
for (const denomination of this.denominationsSorted) {
const bare = denomination.StrippedValue(valueWithDenom, defaultDenom === denomination)
const bare = denomination.StrippedValue(valueWithDenom, defaultDenom === denomination, this.inverted)
if (bare !== null) {
return [bare, denomination]
}
@ -253,10 +290,13 @@ export class Unit {
return undefined
}
const [stripped, denom] = this.findDenomination(value, country)
const human = denom?.human
if(this.inverted ){
return human.Subs({quantity: stripped+"/"})
}
if (stripped === "1") {
return denom?.humanSingular ?? stripped
}
const human = denom?.human
if (human === undefined) {
return stripped ?? value
}
@ -266,6 +306,10 @@ export class Unit {
public toOsm(value: string, denomination: string) {
const denom = this.denominations.find((d) => d.canonical === denomination)
if(this.inverted){
return value+"/"+denom._canonicalSingular
}
const space = denom.addSpace ? " " : ""
if (denom.prefix) {
return denom.canonical + space + value
@ -273,7 +317,7 @@ export class Unit {
return value + space + denom.canonical
}
public getDefaultDenomination(country: () => string) {
public getDefaultDenomination(country: () => string): Denomination {
for (const denomination of this.denominations) {
if (denomination.useIfNoUnitGiven === true) {
return denomination

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { marked } from "marked"
export let src: string
export let srcWritable: UIEventSource<string> = undefined
srcWritable?.addCallbackAndRunD(t => {
src = t
})
if(src !== undefined && typeof src !== "string") {
console.trace("Got a non-string object in Markdown", src)
throw "Markdown.svelte got a non-string object"
}
</script>
{#if src?.length > 0}
{@html marked.parse(src)}
{/if}

View file

@ -1,73 +1,21 @@
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Title from "./Title"
import List from "./List"
import Link from "./Link"
import { marked } from "marked"
import { parse as parse_html } from "node-html-parser"
import {default as turndown} from "turndown"
import { Utils } from "../../Utils"
export default class TableOfContents extends Combine {
private readonly titles: Title[]
export default class TableOfContents {
constructor(
elements: Combine | Title[],
options?: {
noTopLevel: false | boolean
maxDepth?: number
}
) {
let titles: Title[]
if (elements instanceof Combine) {
titles = TableOfContents.getTitles(elements.getElements()) ?? []
} else {
titles = elements ?? []
}
let els: { level: number; content: BaseUIElement }[] = []
for (const title of titles) {
let content: BaseUIElement
if (title.title instanceof Translation) {
content = title.title.Clone()
} else if (title.title instanceof FixedUiElement) {
content = new FixedUiElement(title.title.content)
} else if (Utils.runningFromConsole) {
content = new FixedUiElement(title.AsMarkdown())
} else if (title["title"] !== undefined) {
content = new FixedUiElement(title.title.ConstructElement().textContent)
} else {
console.log("Not generating a title for ", title)
continue
}
const vis = new Link(content, "#" + title.id)
els.push({ level: title.level, content: vis })
}
const minLevel = Math.min(...els.map((e) => e.level))
if (options?.noTopLevel) {
els = els.filter((e) => e.level !== minLevel)
}
if (options?.maxDepth) {
els = els.filter((e) => e.level <= options.maxDepth + minLevel)
}
super(TableOfContents.mergeLevel(els).map((el) => el.SetClass("mt-2")))
this.SetClass("flex flex-col")
this.titles = titles
}
private static getTitles(elements: BaseUIElement[]): Title[] {
const titles = []
for (const uiElement of elements) {
if (uiElement instanceof Combine) {
titles.push(...TableOfContents.getTitles(uiElement.getElements()))
} else if (uiElement instanceof Title) {
titles.push(uiElement)
}
}
return titles
private static asLinkableId(text: string): string {
return text
?.replace(/ /g, "-")
?.replace(/[?#.;:/]/, "")
?.toLowerCase() ?? ""
}
private static mergeLevel(
@ -88,7 +36,7 @@ export default class TableOfContents extends Combine {
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
level: maxLevel - 1
})
running = []
}
@ -97,24 +45,81 @@ export default class TableOfContents extends Combine {
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
level: maxLevel - 1
})
}
return TableOfContents.mergeLevel(result)
}
AsMarkdown(): string {
const depthIcons = ["1.", " -", " +", " *"]
const lines = ["## Table of contents\n"]
const minLevel = Math.min(...this.titles.map((t) => t.level))
for (const title of this.titles) {
const prefix = depthIcons[title.level - minLevel] ?? " ~"
const text = title.title.AsMarkdown().replace("\n", "")
const link = title.id
lines.push(prefix + " [" + text + "](#" + link + ")")
public static insertTocIntoMd(md: string): string {
const htmlSource = <string>marked.parse(md)
const el = parse_html(htmlSource)
const structure = TableOfContents.generateStructure(<any>el)
let firstTitle = structure[1]
let minDepth = undefined
do {
minDepth = Math.min(...structure.map(s => s.depth))
const minDepthCount = structure.filter(s => s.depth === minDepth)
if (minDepthCount.length > 1) {
break
}
// Erase a single top level heading
structure.splice(structure.findIndex(s => s.depth === minDepth), 1)
} while (structure.length > 0)
if (structure.length <= 1) {
return md
}
const separators = {
1: " -",
2: " +",
3: " *"
}
return lines.join("\n") + "\n\n"
let toc = ""
let topLevelCount = 0
for (const el of structure) {
const depthDiff = el.depth - minDepth
let link = `[${el.title}](#${TableOfContents.asLinkableId(el.title)})`
if (depthDiff === 0) {
topLevelCount++
toc += `${topLevelCount}. ${link}\n`
} else if (depthDiff <= 3) {
toc += `${separators[depthDiff]} ${link}\n`
}
}
const heading = Utils.Times(() => "#", firstTitle.depth)
toc = heading +" Table of contents\n\n"+toc
const original = el.outerHTML
const firstTitleIndex = original.indexOf(firstTitle.el.outerHTML)
const tocHtml = (<string>marked.parse(toc))
const withToc = original.substring(0, firstTitleIndex) + tocHtml + original.substring(firstTitleIndex)
const htmlToMd = new turndown()
return htmlToMd.turndown(withToc)
}
public static generateStructure(html: Element): { depth: number, title: string, el: Element }[] {
if (html === undefined) {
return []
}
return [].concat(...Array.from(html.childNodes ?? []).map(
child => {
const tag: string = child["tagName"]?.toLowerCase()
if (!tag) {
return []
}
if (tag.match(/h[0-9]/)) {
const depth = Number(tag.substring(1))
return [{ depth, title: child.textContent, el: child }]
}
return TableOfContents.generateStructure(<Element>child)
}
))
}
}

View file

@ -0,0 +1,56 @@
<script lang="ts">
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Pop_out from "../../assets/svg/Pop_out.svelte"
import Tr from "../Base/Tr.svelte"
import Icon from "../Map/Icon.svelte"
export let state: SpecialVisualizationState
let theme = state.layout?.id ?? ""
let config: ExtraLinkConfig = state.layout.extraLink
console.log(">>>",config)
const isIframe = window !== window.top
let basepath = window.location.host
let showWelcomeMessageSwitch = state.featureSwitches.featureSwitchWelcomeMessage
const t = Translations.t.general
const href = state.mapProperties.location.map(
(loc) => {
const subs = {
...loc,
theme: theme,
basepath,
language: Locale.language.data
}
return Utils.SubstituteKeys(config.href, subs)
},
[state.mapProperties.zoom]
)
</script>
{#if config !== undefined &&
!(config.requirements.has("iframe") && !isIframe) &&
!(config.requirements.has("no-iframe") && isIframe) &&
!(config.requirements.has("welcome-message") && !$showWelcomeMessageSwitch) &&
!(config.requirements.has("no-welcome-message") && $showWelcomeMessageSwitch)}
<div class="links-as-button">
<a href={$href} target={config.newTab ? "_blank" : ""} rel="noopener"
class="flex pointer-events-auto rounded-full border-black">
<Icon icon={config.icon} clss="w-6 h-6 m-2"/>
{#if config.text}
<Tr t={config.text} />
{:else}
<Tr t={t.screenToSmall.Subs({theme: state.layout.title})} />
{/if}
</a>
</div>
{/if}

View file

@ -1,101 +0,0 @@
import { UIElement } from "../UIElement"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Img from "../Base/Img"
import { SubtleButton } from "../Base/SubtleButton"
import Toggle from "../Input/Toggle"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
interface ExtraLinkButtonState {
layout: { id: string; title: Translation }
featureSwitches: { featureSwitchWelcomeMessage: Store<boolean> }
mapProperties: {
location: Store<{ lon: number; lat: number }>
zoom: Store<number>
}
}
export default class ExtraLinkButton extends UIElement {
private readonly _config: ExtraLinkConfig
private readonly state: ExtraLinkButtonState
constructor(state: ExtraLinkButtonState, config: ExtraLinkConfig) {
super()
this.state = state
this._config = config
}
protected InnerRender(): BaseUIElement {
if (this._config === undefined) {
return undefined
}
const c = this._config
const isIframe = window !== window.top
if (c.requirements?.has("iframe") && !isIframe) {
return undefined
}
if (c.requirements?.has("no-iframe") && isIframe) {
return undefined
}
let link: BaseUIElement
const theme = this.state.layout?.id ?? ""
const basepath = window.location.host
const href = this.state.mapProperties.location.map(
(loc) => {
const subs = {
...loc,
theme: theme,
basepath,
language: Locale.language.data,
}
return Utils.SubstituteKeys(c.href, subs)
},
[this.state.mapProperties.zoom]
)
let img: BaseUIElement = Svg.pop_out_svg()
if (c.icon !== undefined) {
img = new Img(c.icon).SetClass("h-6")
}
let text: Translation
if (c.text === undefined) {
text = Translations.t.general.screenToSmall.Subs({
theme: this.state.layout.title,
})
} else {
text = c.text.Clone()
}
link = new SubtleButton(img, text, {
url: href,
newTab: c.newTab,
})
if (c.requirements?.has("no-welcome-message")) {
link = new Toggle(
undefined,
link,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
if (c.requirements?.has("welcome-message")) {
link = new Toggle(
link,
undefined,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
return link
}
}

View file

@ -119,7 +119,7 @@
<Tr t={t.conflicting.intro} />
{/if}
{#if $different.length > 0}
{#each $different as key}
{#each $different as key (key)}
<div class="mx-2 rounded-2xl">
<ComparisonAction
{key}
@ -136,7 +136,7 @@
{#if $missing.length > 0}
{#if currentStep === "init"}
{#each $missing as key}
{#each $missing as key (key)}
<div class:glowing-shadow={applyAllHovered} class="mx-2 rounded-2xl">
<ComparisonAction
{key}
@ -174,7 +174,7 @@
{#if readonly}
<div class="w-full overflow-x-auto">
<div class="flex h-32 w-max gap-x-2">
{#each $unknownImages as image}
{#each $unknownImages as image (image)}
<AttributedImage
imgClass="h-32 w-max shrink-0"
image={{ url: image }}
@ -184,7 +184,7 @@
</div>
</div>
{:else}
{#each $unknownImages as image}
{#each $unknownImages as image (image)}
<LinkableImage
{tags}
{state}

View file

@ -1,46 +0,0 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
/**
* @deprecated
*/
export class FixedInputElement<T> extends InputElement<T> {
private readonly value: UIEventSource<T>
private readonly _comparator: (t0: T, t1: T) => boolean
private readonly _el: HTMLElement
constructor(
rendering: BaseUIElement | string,
value: T | UIEventSource<T>,
comparator: (t0: T, t1: T) => boolean = undefined
) {
super()
this._comparator = comparator ?? ((t0, t1) => t0 == t1)
if (value instanceof UIEventSource) {
this.value = value
} else {
this.value = new UIEventSource<T>(value)
}
this._el = document.createElement("span")
const e = Translations.W(rendering)?.ConstructElement()
if (e) {
this._el.appendChild(e)
}
}
GetValue(): UIEventSource<T> {
return this.value
}
IsValid(t: T): boolean {
return this._comparator(t, this.value.data)
}
protected InnerConstructElement(): HTMLElement {
return this._el
}
}

View file

@ -1,161 +0,0 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
/**
* @deprecated
*/
export class RadioButton<T> extends InputElement<T> {
private static _nextId = 0
private readonly value: UIEventSource<T>
private _elements: InputElement<T>[]
private _selectFirstAsDefault: boolean
private _dontStyle: boolean
constructor(
elements: InputElement<T>[],
options?: {
selectFirstAsDefault?: true | boolean
dontStyle?: boolean
value?: UIEventSource<T>
}
) {
super()
options = options ?? {}
this._selectFirstAsDefault = options.selectFirstAsDefault ?? true
this._elements = Utils.NoNull(elements)
this.value = options?.value ?? new UIEventSource<T>(undefined)
this._dontStyle = options.dontStyle ?? false
}
IsValid(t: T): boolean {
for (const inputElement of this._elements) {
if (inputElement.IsValid(t)) {
return true
}
}
return false
}
GetValue(): UIEventSource<T> {
return this.value
}
protected InnerConstructElement(): HTMLElement {
const elements = this._elements
const selectFirstAsDefault = this._selectFirstAsDefault
const selectedElementIndex: UIEventSource<number> = new UIEventSource<number>(null)
const value = UIEventSource.flatten(
selectedElementIndex.map((selectedIndex) => {
if (selectedIndex !== undefined && selectedIndex !== null) {
return elements[selectedIndex].GetValue()
}
}),
elements.map((e) => e?.GetValue())
)
value.syncWith(this.value)
if (selectFirstAsDefault) {
value.addCallbackAndRun((selected) => {
if (selected === undefined) {
for (const element of elements) {
const v = element.GetValue().data
if (v !== undefined) {
value.setData(v)
break
}
}
}
})
}
for (let i = 0; i < elements.length; i++) {
// If an element is clicked, the radio button corresponding with it should be selected as well
elements[i]?.onClick(() => {
selectedElementIndex.setData(i)
})
elements[i].GetValue().addCallback(() => {
selectedElementIndex.setData(i)
})
}
const groupId = "radiogroup" + RadioButton._nextId
RadioButton._nextId++
const form = document.createElement("form")
const inputs = []
const wrappers: HTMLElement[] = []
for (let i1 = 0; i1 < elements.length; i1++) {
let element = elements[i1]
const labelHtml = element.ConstructElement()
if (labelHtml === undefined) {
continue
}
const input = document.createElement("input")
input.id = "radio" + groupId + "-" + i1
input.name = groupId
input.type = "radio"
input.classList.add("cursor-pointer", "p-1", "mr-2")
if (!this._dontStyle) {
input.classList.add("p-1", "ml-2", "pl-2", "pr-0", "m-3", "mr-0")
}
input.onchange = () => {
if (input.checked) {
selectedElementIndex.setData(i1)
}
}
inputs.push(input)
const label = document.createElement("label")
label.appendChild(labelHtml)
label.htmlFor = input.id
label.classList.add("flex", "w-full", "cursor-pointer")
if (!this._dontStyle) {
labelHtml.classList.add("p-2")
}
const block = document.createElement("div")
block.appendChild(input)
block.appendChild(label)
block.classList.add("flex", "w-full")
if (!this._dontStyle) {
block.classList.add("m-1", "border", "border-gray-400")
}
block.style.borderRadius = "1.5rem"
wrappers.push(block)
form.appendChild(block)
}
value.addCallbackAndRun((selected: T) => {
let somethingChecked = false
for (let i = 0; i < inputs.length; i++) {
let input = inputs[i]
input.checked = !somethingChecked && elements[i].IsValid(selected)
somethingChecked = somethingChecked || input.checked
if (input.checked) {
wrappers[i].classList.remove("border-gray-400")
wrappers[i].classList.add("border-attention")
} else {
wrappers[i].classList.add("border-gray-400")
wrappers[i].classList.remove("border-attention")
}
}
})
this.SetClass("flex flex-col")
return form
}
}

View file

@ -5,8 +5,8 @@
import { UIEventSource } from "../../../Logic/UIEventSource"
import BasicTagInput from "../../Studio/TagInput/BasicTagInput.svelte"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import nmd from "nano-markdown"
import FromHtml from "../../Base/FromHtml.svelte"
import Markdown from "../../Base/Markdown.svelte"
export let value: UIEventSource<undefined | string>
export let args: string[] = []
let uploadableOnly: boolean = args[0] === "uploadableOnly"
@ -34,6 +34,6 @@
{#if $dropdownFocussed}
<div class="m-2 border border-dashed border-black p-2">
<b>{documentation.name}</b>
<FromHtml src={nmd(documentation.docs)} />
<Markdown src={documentation.docs} />
</div>
{/if}

View file

@ -91,11 +91,6 @@
return
}
if (unit !== undefined && isNaN(Number(v))) {
value.setData(undefined)
return
}
feedback?.setData(undefined)
if (selectedUnit.data) {
value.setData(unit.toOsm(v, selectedUnit.data))

View file

@ -28,6 +28,7 @@ import TagValidator from "./Validators/TagValidator"
import IdValidator from "./Validators/IdValidator"
import SlopeValidator from "./Validators/SlopeValidator"
import VeloparkValidator from "./Validators/VeloparkValidator"
import CurrencyValidator from "./Validators/CurrencyValidator"
export type ValidatorType = (typeof Validators.availableTypes)[number]
@ -60,6 +61,7 @@ export default class Validators {
"id",
"slope",
"velopark",
"currency"
] as const
public static readonly AllValidators: ReadonlyArray<Validator> = [
@ -89,6 +91,7 @@ export default class Validators {
new IdValidator(),
new SlopeValidator(),
new VeloparkValidator(),
new CurrencyValidator()
]
private static _byType = Validators._byTypeConstructor()

View file

@ -0,0 +1,73 @@
import { Validator } from "../Validator"
import { Utils } from "../../../Utils"
export default class CurrencyValidator extends Validator {
private readonly segmenter: Intl.Segmenter
private readonly symbolToCurrencyMapping: Map<string, string>
private readonly supportedCurrencies: Set<string>
constructor() {
super("currency", "Validates monetary amounts")
if (Intl.Segmenter === undefined || Utils.runningFromConsole) {
// Librewolf doesn't support this
return
}
let locale = "en-US"
if(!Utils.runningFromConsole){
locale??= navigator.language
}
this.segmenter = new Intl.Segmenter(locale, {
granularity: "word"
})
const mapping: Map<string, string> = new Map<string, string>()
const supportedCurrencies: Set<string> = new Set(Intl.supportedValuesOf("currency"))
this.supportedCurrencies = supportedCurrencies
for (const currency of supportedCurrencies) {
const symbol = (0).toLocaleString(
locale,
{
style: "currency",
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
).replace(/\d/g, "").trim()
mapping.set(symbol.toLowerCase(), currency)
}
this.symbolToCurrencyMapping = mapping
}
reformat(s: string): string {
if (!this.segmenter) {
return s
}
const parts = Array.from(this.segmenter.segment(s)).map(i => i.segment).filter(part => part.trim().length > 0)
if(parts.length !== 2){
return s
}
const mapping = this.symbolToCurrencyMapping
let currency: string = undefined
let amount = undefined
for (const part of parts) {
const lc = part.toLowerCase()
if (this.supportedCurrencies.has(part.toUpperCase())) {
currency = part.toUpperCase()
continue
}
if (mapping.has(lc)) {
currency = mapping.get(lc)
continue
}
amount = part
}
if(amount === undefined || currency === undefined){
return s
}
return amount+" "+currency
}
}

View file

@ -3,7 +3,7 @@ import UrlValidator from "./UrlValidator"
export default class VeloparkValidator extends UrlValidator {
constructor() {
super("velopark", "A custom element to allow copy-pasting velopark-pages")
super("velopark", "A special URL-validator that checks the domain name and rewrites to the correct velopark format.")
}
getFeedback(s: string): Translation {

View file

@ -34,6 +34,7 @@
import { LinkIcon } from "@babeard/svelte-heroicons/mini"
import Square_rounded from "../../assets/svg/Square_rounded.svelte"
import Bug from "../../assets/svg/Bug.svelte"
import Pop_out from "../../assets/svg/Pop_out.svelte"
/**
* Renders a single icon.
@ -123,6 +124,9 @@
<AddSmall {color} class={clss} />
{:else if icon === "link"}
<LinkIcon style="--svg-color: {color}" class={twMerge(clss, "apply-fill")} />
{:else if icon === "popout"}
<LinkIcon style="--svg-color: {color}" />
{:else}
<img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true" alt="" />
{/if}

View file

@ -38,11 +38,13 @@
{#if !allCalculatedTags.has(key)}
<tr>
<td>{key}</td>
<td>
<td style="width: 75%">
{#if $tags[key] === undefined}
<i>undefined</i>
{:else if $tags[key] === ""}
<i>Empty string</i>
{:else if typeof $tags[key] === "object"}
<div class="literal-code" >{JSON.stringify($tags[key])}</div>
{:else}
{$tags[key]}
{/if}

View file

@ -30,7 +30,8 @@
import { placeholder } from "../../../Utils/placeholder"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Tag } from "../../../Logic/Tags/Tag"
import { get, writable } from "svelte/store"
import { get } from "svelte/store"
import Markdown from "../../Base/Markdown.svelte"
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
@ -68,13 +69,17 @@
/**
* Prepares and fills the checkedMappings
*/
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) {
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig): void {
mappings = confg.mappings?.filter((m) => {
if (typeof m.hideInAnswer === "boolean") {
return !m.hideInAnswer
}
return !m.hideInAnswer.matchesProperties(tgs)
})
selectedMapping = mappings?.findIndex(mapping => mapping.if.matchesProperties(tgs) || mapping.alsoShowIf?.matchesProperties(tgs))
if(selectedMapping < 0){
selectedMapping = undefined
}
// We received a new config -> reinit
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
@ -85,7 +90,7 @@
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0))
) {
const seenFreeforms = []
// Initial setup of the mappings
// Initial setup of the mappings; detect checked mappings
checkedMappings = [
...confg.mappings.map((mapping) => {
if (mapping.hideInAnswer === true) {
@ -97,7 +102,7 @@
seenFreeforms.push(newProps[confg.freeform.key])
}
return matches
}),
})
]
if (tgs !== undefined && confg.freeform) {
@ -128,6 +133,8 @@
freeformInput.set(undefined)
}
feedback.setData(undefined)
}
$: {
@ -171,6 +178,9 @@
checkedMappings,
tags.data
)
if(state.featureSwitches.featureSwitchIsDebugging.data){
console.log("Constructing change spec from", {freeform: $freeformInput, selectedMapping, checkedMappings, currentTags: tags.data}, " --> ", selectedTags)
}
} catch (e) {
console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined
@ -203,7 +213,7 @@
dispatch("saved", { config, applied: selectedTags })
const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, {
theme: tags.data["_orig_theme"] ?? state.layout.id,
changeType: "answer",
changeType: "answer"
})
freeformInput.set(undefined)
selectedMapping = undefined
@ -255,15 +265,19 @@
</div>
{#if config.questionhint}
<div class="max-h-60 overflow-y-auto">
<SpecialTranslation
t={config.questionhint}
{tags}
{state}
{layer}
feature={selectedElement}
/>
</div>
{#if config.questionHintIsMd}
<Markdown srcWritable={ config.questionhint.current} />
{:else}
<div class="max-h-60 overflow-y-auto">
<SpecialTranslation
t={config.questionhint}
{tags}
{state}
{layer}
feature={selectedElement}
/>
</div>
{/if}
{/if}
</legend>

View file

@ -64,10 +64,14 @@
)
</script>
{#if unit.inverted}
<div class="bold px-2">/</div>
{/if}
<select bind:value={$selectedUnit}>
{#each unit.denominations as denom (denom.canonical)}
<option value={denom.canonical}>
{#if $isSingle}
{#if $isSingle || unit.inverted}
<Tr t={denom.humanSingular} />
{:else}
<Tr t={denom.human.Subs({ quantity: "" })} />

View file

@ -20,12 +20,16 @@
import NextButton from "../Base/NextButton.svelte"
import BackButton from "../Base/BackButton.svelte"
import DeleteButton from "./DeleteButton.svelte"
import StudioHashSetter from "./StudioHashSetter"
const layerSchema: ConfigMeta[] = <any>layerSchemaRaw
export let state: EditLayerState
export let backToStudio: () => void
new StudioHashSetter("layer", state.selectedTab, state.getStoreFor(["id"]))
let messages = state.messages
let hasErrors = messages.mapD(
(m: ConversionMessage[]) => m.filter((m) => m.level === "error").length
@ -127,14 +131,14 @@
{/each}
{:else}
<div class="m4 h-full overflow-y-auto">
<TabbedGroup>
<TabbedGroup tab={state.selectedTab}>
<div slot="title0" class="flex">
General properties
<ErrorIndicatorForRegion firstPaths={firstPathsFor("Basic")} {state} />
</div>
<div class="flex flex-col mb-8" slot="content0">
<Region {state} configs={perRegion["Basic"]} />
<DeleteButton {state} {backToStudio} objectType="layer"/>
<DeleteButton {state} {backToStudio} objectType="layer" />
</div>
<div slot="title1" class="flex">
@ -185,19 +189,18 @@
Below, you'll find the raw configuration file in `.json`-format. This is mostly for
debugging purposes, but you can also edit the file directly if you want.
</div>
<div class="literal-code overflow-y-auto h-full" style="min-height: 75%">
<RawEditor {state} />
</div>
<ShowConversionMessages messages={$messages} />
<div class="flex h-full w-full flex-row justify-between overflow-y-auto">
<div class="literal-code h-full w-5/6 overflow-y-auto">
<RawEditor {state} />
</div>
<div class="h-full w-1/6">
<div>
The testobject (which is used to render the questions in the 'information panel'
item has the following tags:
</div>
<AllTagsPanel tags={state.testTags} />
<div class="flex w-full flex-col">
<div>
The testobject (which is used to render the questions in the 'information panel'
item has the following tags:
</div>
<AllTagsPanel tags={state.testTags} />
</div>
</div>
</TabbedGroup>

View file

@ -42,6 +42,11 @@ export abstract class EditJsonState<T> {
public readonly configuration: UIEventSource<Partial<T>> = new UIEventSource<Partial<T>>({})
public readonly messages: Store<ConversionMessage[]>
/**
* The tab in the UI that is selected, used for deeplinks
*/
public readonly selectedTab: UIEventSource<number> = new UIEventSource<number>(0)
/**
* The EditLayerUI shows a 'schemaBasedInput' for this path to pop advanced questions out
*/
@ -508,6 +513,9 @@ export class EditThemeState extends EditJsonState<LayoutConfigJson> {
}
const prepare = this.buildValidation(state)
const context = ConversionContext.construct([], ["prepare"])
if(configuration.layers){
Utils.NoNullInplace(configuration.layers)
}
try {
prepare.convert(<LayoutConfigJson>configuration, context)
} catch (e) {

View file

@ -9,12 +9,15 @@
import RawEditor from "./RawEditor.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import DeleteButton from "./DeleteButton.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import StudioHashSetter from "./StudioHashSetter"
export let state: EditThemeState
export let osmConnection: OsmConnection
export let backToStudio: () => void
let schema: ConfigMeta[] = state.schema.filter((schema) => schema.path.length > 0)
new StudioHashSetter("theme", state.selectedTab, state.getStoreFor(["id"]))
export let selfLayers: { owner: number; id: string }[]
export let otherLayers: { owner: number; id: string }[]
@ -94,7 +97,7 @@
<div class="m4 h-full overflow-y-auto">
<!-- {Object.keys(perRegion).join(";")} -->
<TabbedGroup>
<TabbedGroup tab={state.selectedTab}>
<div slot="title0">Basic properties</div>
<div slot="content0" class="mb-8">
<Region configs={perRegion["basic"]} path={[]} {state} title="Basic properties" />
@ -123,11 +126,10 @@
Below, you'll find the raw configuration file in `.json`-format. This is mostly for
debugging purposes, but you can also edit the file directly if you want.
</div>
<ShowConversionMessages messages={$messages} />
<div class="literal-code h-full w-full">
<div class="literal-code overflow-y-auto h-full" style="min-height: 75%">
<RawEditor {state} />
</div>
</div>
<ShowConversionMessages messages={$messages} />
</TabbedGroup>
</div>
</div>

View file

@ -5,12 +5,12 @@
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import nmd from "nano-markdown"
import type { QuestionableTagRenderingConfigJson } from "../../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson.js"
import type { TagRenderingConfigJson } from "../../Models/ThemeConfig/Json/TagRenderingConfigJson"
import FromHtml from "../Base/FromHtml.svelte"
import ShowConversionMessage from "./ShowConversionMessage.svelte"
import NextButton from "../Base/NextButton.svelte"
import Markdown from "../Base/Markdown.svelte"
export let state: EditLayerState
export let path: ReadonlyArray<string | number>
@ -62,13 +62,6 @@
let messages = state.messagesFor(path)
let description = schema.description
if (description) {
try {
description = nmd(description)
} catch (e) {
console.error("Could not convert description to markdown", { description })
}
}
</script>
<div class="flex">
@ -82,7 +75,7 @@
{/if}
</NextButton>
{#if description}
<FromHtml src={description} />
<Markdown src={description}/>
{/if}
{#each $messages as message}
<ShowConversionMessage {message} />

View file

@ -8,6 +8,7 @@
import QuestionPreview from "./QuestionPreview.svelte"
import SchemaBasedMultiType from "./SchemaBasedMultiType.svelte"
import ShowConversionMessage from "./ShowConversionMessage.svelte"
import Markdown from "../Base/Markdown.svelte"
export let state: EditLayerState
export let schema: ConfigMeta
@ -30,10 +31,9 @@
schema.description = undefined
}
const subparts: ConfigMeta = state
const subparts: ConfigMeta[] = state
.getSchemaStartingWith(schema.path)
.filter((part) => part.path.length - 1 === schema.path.length)
let messages = state.messagesFor(path)
const currentValue: UIEventSource<any[]> = state.getStoreFor(path)
@ -97,9 +97,7 @@
<h3>{schema.path.at(-1)}</h3>
{#if subparts.length > 0}
<span class="subtle">
{schema.description}
</span>
<Markdown src={schema.description}/>
{/if}
{#if $currentValue === undefined}
No array defined

View file

@ -3,7 +3,6 @@
import type { ConfigMeta } from "./configMeta"
import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import nmd from "nano-markdown"
import type { QuestionableTagRenderingConfigJson } from "../../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import EditLayerState from "./EditLayerState"
import { onDestroy } from "svelte"
@ -68,11 +67,12 @@
type = type.substring(0, type.length - 2)
}
const configJson: QuestionableTagRenderingConfigJson = {
const configJson: QuestionableTagRenderingConfigJson & {questionHintIsMd: boolean} = {
id: path.join("_"),
render: rendervalue,
question: schema.hints.question,
questionHint: nmd(schema.description),
questionHint: schema.description,
questionHintIsMd: true,
freeform:
schema.type === "boolean"
? undefined

View file

@ -8,9 +8,8 @@
import { onDestroy } from "svelte"
import SchemaBasedInput from "./SchemaBasedInput.svelte"
import type { JsonSchemaType } from "./jsonSchema"
// @ts-ignore
import nmd from "nano-markdown"
import ShowConversionMessage from "./ShowConversionMessage.svelte"
import type { Translatable } from "../../Models/ThemeConfig/Json/Translatable"
/**
* If 'types' is defined: allow the user to pick one of the types to input.
@ -41,10 +40,11 @@
if (lastIsString) {
types.splice(types.length - 1, 1)
}
const configJson: QuestionableTagRenderingConfigJson = {
const configJson: QuestionableTagRenderingConfigJson & {questionHintIsMd: boolean}= {
id: "TYPE_OF:" + path.join("_"),
question: "Which subcategory is needed for " + schema.path.at(-1) + "?",
questionHint: nmd(schema.description),
question: schema.hints.question ?? "Which subcategory is needed for " + schema.path.at(-1) + "?",
questionHint: schema.description,
questionHintIsMd: true,
mappings: types
.map((opt) => opt.trim())
.filter((opt) => opt.length > 0)

View file

@ -0,0 +1,11 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Hash from "../../Logic/Web/Hash"
export default class StudioHashSetter {
constructor(mode: "layer" | "theme", tab: Store<number>, name: Store<string>) {
tab.mapD(tab => {
Hash.hash.setData(mode + "/" + name.data + "/" + tab)
}
, [name])
}
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import NextButton from "./Base/NextButton.svelte"
import { Store, UIEventSource } from "../Logic/UIEventSource"
import EditLayerState, { EditThemeState } from "./Studio/EditLayerState"
import EditLayerState, { EditJsonState, EditThemeState } from "./Studio/EditLayerState"
import EditLayer from "./Studio/EditLayer.svelte"
import Loading from "../assets/svg/Loading.svelte"
import StudioServer from "./Studio/StudioServer"
@ -30,6 +30,7 @@
import Tr from "./Base/Tr.svelte"
import Add from "../assets/svg/Add.svelte"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import Hash from "../Logic/Web/Hash"
export let studioUrl =
window.location.hostname === "127.0.0.2"
@ -43,11 +44,11 @@
)
let osmConnection = new OsmConnection({
oauth_token,
checkOnlineRegularly: true,
checkOnlineRegularly: true
})
const expertMode = UIEventSource.asBoolean(
osmConnection.GetPreference("studio-expert-mode", "false", {
documentation: "Indicates if more options are shown in mapcomplete studio",
documentation: "Indicates if more options are shown in mapcomplete studio"
})
)
expertMode.addCallbackAndRunD((expert) => console.log("Expert mode is", expert))
@ -61,12 +62,12 @@
l["success"]?.filter((l) => l.category === "layers")
)
$: selfLayers = layers.mapD(
(ls) =>
ls.filter(
(l) => l.owner === uid.data && l.id.toLowerCase().includes(layerFilterTerm.toLowerCase())
),
[uid]
)
(ls) =>
ls.filter(
(l) => l.owner === uid.data && l.id.toLowerCase().includes(layerFilterTerm.toLowerCase())
),
[uid]
)
$: otherLayers = layers.mapD(
(ls) =>
ls.filter(
@ -132,16 +133,17 @@
const version = meta.version
async function editLayer(event: Event) {
async function editLayer(event: { detail }): Promise<EditLayerState> {
const layerId: { owner: number; id: string } = event["detail"]
state = "loading"
editLayerState.startSavingUpdates(false)
editLayerState.configuration.setData(await studio.fetch(layerId.id, "layers", layerId.owner))
editLayerState.startSavingUpdates()
state = "editing_layer"
return editLayerState
}
async function editTheme(event: Event) {
async function editTheme(event: { detail }): Promise<EditThemeState> {
const id: { id: string; owner: number } = event["detail"]
state = "loading"
editThemeState.startSavingUpdates(false)
@ -149,6 +151,7 @@
editThemeState.configuration.setData(layout)
editThemeState.startSavingUpdates()
state = "editing_theme"
return editThemeState
}
async function createNewLayer() {
@ -162,23 +165,50 @@
marker: [
{
icon: "circle",
color: "white",
},
],
},
color: "white"
}
]
}
],
tagRenderings: ["images"],
lineRendering: [
{
width: 1,
color: "blue",
},
],
color: "blue"
}
]
}
editLayerState.configuration.setData(initialLayerConfig)
editLayerState.startSavingUpdates()
state = "editing_layer"
}
async function selectStateBasedOnHash() {
const hash = Hash.hash.data
if (!hash) {
return
}
console.log("Selecting state based on ", hash)
const [mode, id, tab] = hash.split("/")
// Not really an event, we just set the 'detail'
const event = {
detail: {
id,
owner: uid.data
}
}
const statePromise: Promise<EditJsonState<any>> = mode === "layer" ? editLayer(event) : editTheme(event)
const state = await statePromise
state.selectedTab.setData(Number(tab))
}
selectStateBasedOnHash()
function backToStudio() {
console.log("Back to studio")
state = undefined
Hash.hash.setData(undefined)
}
</script>
<If condition={layersWithErr.map((d) => d?.["error"] !== undefined)}>
@ -191,8 +221,8 @@
<li>Try again in a few minutes</li>
<li>
Contact <a href="https://app.element.io/#/room/#MapComplete:matrix.org">
the MapComplete community via the chat.
</a>
the MapComplete community via the chat.
</a>
Someone might be able to help you
</li>
<li>
@ -257,9 +287,7 @@
<BackButton
clss="small p-1"
imageClass="w-8 h-8"
on:click={() => {
state = undefined
}}
on:click={() => backToStudio()}
>
MapComplete Studio
</BackButton>
@ -306,9 +334,7 @@
<BackButton
clss="small p-1"
imageClass="w-8 h-8"
on:click={() => {
state = undefined
}}
on:click={() => backToStudio()}
>
MapComplete Studio
</BackButton>
@ -348,30 +374,23 @@
{:else if state === "editing_layer"}
<EditLayer
state={editLayerState}
backToStudio={() => {
state = undefined
}}
{backToStudio}
>
<BackButton
clss="small p-1"
imageClass="w-8 h-8"
on:click={() => {
state = undefined
}}
on:click={() => backToStudio()}
>
MapComplete Studio
</BackButton>
</EditLayer>
{:else if state === "editing_theme"}
<EditTheme state={editThemeState} selfLayers={$selfLayers} otherLayers={$otherLayers} {osmConnection} backToStudio={() => {
state = undefined
}}>
<EditTheme state={editThemeState} selfLayers={$selfLayers} otherLayers={$otherLayers} {osmConnection}
{backToStudio}>
<BackButton
clss="small p-1"
imageClass="w-8 h-8"
on:click={() => {
state = undefined
}}
on:click={() => backToStudio()}
>
MapComplete Studio
</BackButton>

View file

@ -34,7 +34,6 @@
import { Utils } from "../Utils"
import Hotkeys from "./Base/Hotkeys"
import LevelSelector from "./BigComponents/LevelSelector.svelte"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton"
import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte"
import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte"
import type { RasterLayerPolygon } from "../Models/RasterLayers"
@ -73,6 +72,7 @@
import PrivacyPolicy from "./BigComponents/PrivacyPolicy.svelte"
import { BBox } from "../Logic/BBox"
import ReviewsOverview from "./Reviews/ReviewsOverview.svelte"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton.svelte"
export let state: ThemeViewState
let layout = state.layout
@ -260,9 +260,7 @@
/>
</MapControlButton>
{/if}
<ToSvelte
construct={() => new ExtraLinkButton(state, layout.extraLink).SetClass("pointer-events-auto")}
/>
<ExtraLinkButton {state} />
<UploadingImageCounter featureId="*" showThankYou={false} {state} />
<PendingChangesIndicator {state} />
<If condition={state.featureSwitchIsTesting}>
@ -285,9 +283,9 @@
<div class="flex w-full items-end justify-between px-4">
<div class="flex flex-col">
<If condition={featureSwitches.featureSwitchEnableLogin}>
{#if state.layout.hasPresets() || state.layout.hasNoteLayer()}
{#if (state.layout.hasPresets() && state.layout.enableAddNewPoints) || state.layout.hasNoteLayer()}
<button
class="pointer-events-auto w-fit"
class="pointer-events-auto w-fit low-interaction"
class:disabled={$currentZoom < Constants.minZoomLevelToAddNewPoint}
on:click={() => {
state.openNewDialog()

View file

@ -1,8 +1,8 @@
<script lang="ts">
import nmd from "nano-markdown"
import { createEventDispatcher } from "svelte"
import WalkthroughStep from "./WalkthroughStep.svelte"
import FromHtml from "../Base/FromHtml.svelte"
import Markdown from "../Base/Markdown.svelte"
/**
* Markdown
@ -31,5 +31,5 @@
totalPages={pages.length}
pageNumber={currentPage}
>
<FromHtml src={nmd(pages[currentPage])} />
<Markdown src={pages[currentPage]} />
</WalkthroughStep>

View file

@ -11,7 +11,7 @@
</script>
<div class="link-underline flex h-full w-full flex-col justify-between">
<div class="overflow-y-auto">
<div class="overflow-y-auto h-full">
<slot />
</div>

View file

@ -263,6 +263,9 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
return res
}
public static NoNull<T>(array: T[] | undefined): (T[] | undefined)
public static NoNull<T>(array: undefined): undefined
public static NoNull<T>(array: T[]): T[]
public static NoNull<T>(array: T[]): NonNullable<T>[] {
return <any>array?.filter((o) => o !== undefined && o !== null)
}
@ -507,7 +510,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
let result = ""
while (match) {
const [_, normal, key, leftover] = match
let v = tags === undefined ? undefined : tags[key]
let v = tags?.[key]
if (v !== undefined && v !== null) {
if (v["toISOString"] != undefined) {
// This is a date, probably the timestamp of the object
@ -1039,7 +1042,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
Utils._download_cache.set(url, { promise, timestamp: new Date().getTime() })
return await promise
}
public static async downloadJson(
url: string,
headers?: Record<string, string>
): Promise<object | []>
public static async downloadJson<T>(
url: string,
headers?: Record<string, string>
): Promise<T>
public static async downloadJson(
url: string,
headers?: Record<string, string>
@ -1649,4 +1659,12 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
}
return n + Utils._metrixPrefixes[index]
}
static NoNullInplace(layers: any[]):void {
for (let i = layers.length - 1; i >= 0; i--) {
if(layers[i] === null || layers[i] === undefined){
layers.splice(i, 1)
}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -9965,7 +9965,7 @@
"group": "presets"
},
"type": "array",
"description": "Presets for this layer.\nA preset shows up when clicking the map on a without data (or when right-clicking/long-pressing);\nit will prompt the user to add a new point.\nThe most important aspect are the tags, which define which tags the new point will have;\nThe title is shown in the dialog, along with the first sentence of the description.\nUpon confirmation, the full description is shown beneath the buttons - perfect to add pictures and examples.\nNote: the icon of the preset is determined automatically based on the tags and the icon above. Don't worry about that!\nNB: if no presets are defined, the popup to add new points doesn't show up at all"
"description": "<div class='flex'>\n <div>\nPresets for this layer.\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<a class='block m-2 min-w-64' href='./Docs/Screenshots/AddNewItemScreencast.webm' target='_blank'> <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/></a>\n</div>"
},
{
"path": [
@ -10275,6 +10275,10 @@
"if": "value=food",
"then": "food - A layer showing restaurants and fast-food amenities (with a special rendering for friteries)"
},
{
"if": "value=food_courts",
"then": "food_courts - Food courts with a variety of food options."
},
{
"if": "value=ghost_bike",
"then": "ghost_bike - A layer showing memorials for cyclists, killed in road accidents"
@ -10407,6 +10411,10 @@
"if": "value=osm_community_index",
"then": "osm_community_index - A layer showing the OpenStreetMap Communities"
},
{
"if": "value=outdoor_seating",
"then": "outdoor_seating - Outdoor seating areas, usually located near cafes and restaurants."
},
{
"if": "value=parcel_lockers",
"then": "parcel_lockers - Layer showing parcel lockers for collecting and sending parcels."
@ -11646,7 +11654,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -12918,7 +12930,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -14223,7 +14239,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -15544,7 +15564,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -16864,7 +16888,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -18185,7 +18213,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},

View file

@ -556,6 +556,10 @@
"if": "value=food",
"then": "<b>food</b> (builtin) - A layer showing restaurants and fast-food amenities (with a special rendering for friteries)"
},
{
"if": "value=food_courts",
"then": "<b>food_courts</b> (builtin) - Food courts with a variety of food options."
},
{
"if": "value=ghost_bike",
"then": "<b>ghost_bike</b> (builtin) - A layer showing memorials for cyclists, killed in road accidents"
@ -688,6 +692,10 @@
"if": "value=osm_community_index",
"then": "<b>osm_community_index</b> (builtin) - A layer showing the OpenStreetMap Communities"
},
{
"if": "value=outdoor_seating",
"then": "<b>outdoor_seating</b> (builtin) - Outdoor seating areas, usually located near cafes and restaurants."
},
{
"if": "value=parcel_lockers",
"then": "<b>parcel_lockers</b> (builtin) - Layer showing parcel lockers for collecting and sending parcels."
@ -1315,7 +1323,7 @@
"type": "boolean"
},
"presets": {
"description": "Presets for this layer.\nA preset shows up when clicking the map on a without data (or when right-clicking/long-pressing);\nit will prompt the user to add a new point.\n\nThe most important aspect are the tags, which define which tags the new point will have;\nThe title is shown in the dialog, along with the first sentence of the description.\n\nUpon confirmation, the full description is shown beneath the buttons - perfect to add pictures and examples.\n\nNote: the icon of the preset is determined automatically based on the tags and the icon above. Don't worry about that!\nNB: if no presets are defined, the popup to add new points doesn't show up at all\n\ngroup: presets",
"description": "<div class='flex'>\n <div>\nPresets for this layer.\n\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\n\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\n\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<a class='block m-2 min-w-64' href='./Docs/Screenshots/AddNewItemScreencast.webm' target='_blank'> <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/></a>\n</div>\n\ngroup: presets",
"type": "array",
"items": {
"type": "object",
@ -1908,7 +1916,7 @@
"type": "string"
}
],
"description": "Every layer contains a description of which feature to display - the overpassTags which are queried.\nInstead of running one query for every layer, the query is fused.\nAfterwards, every layer is given the list of features.\nEvery layer takes away the features that match with them*, and give the leftovers to the next layers.\nThis implies that the _order_ of the layers is important in the case of features with the same tags;\nas the later layers might never receive their feature.\n*layers can also remove 'leftover'-features if the leftovers overlap with a feature in the layer itself\nNote that builtin layers can be reused. Either put in the name of the layer to reuse, or use {builtin: \"layername\", override: ...}\nThe 'override'-object will be copied over the original values of the layer, which allows to change certain aspects of the layer\nFor example: If you would like to use layer nature reserves, but only from a specific operator (eg. Natuurpunt) you would use the following in your theme:\n```\n\"layer\": {\n \"builtin\": \"nature_reserve\",\n \"override\": {\"source\":\n {\"osmTags\": {\n \"+and\":[\"operator=Natuurpunt\"]\n }\n }\n }\n}\n```\nIt's also possible to load multiple layers at once, for example, if you would like for both drinking water and benches to start at the zoomlevel at 12, you would use the following:\n```\n\"layer\": {\n \"builtin\": [\"benches\", \"drinking_water\"],\n \"override\": {\"minzoom\": 12}\n}\n```"
"description": "A theme must contain at least one layer.\nA layer contains all features of a single type, for example \"shops\", \"bicycle pumps\", \"benches\".\nNote that every layer contains a specification of attributes that it should match. MapComplete will fetch the relevant data from either overpass, the OSM-API or the cache server.\nIf a feature can match multiple layers, the first matching layer in the list will be used.\nThis implies that the _order_ of the layers is important.\n<div class='hidden-in-studio'>\nNote that builtin layers can be reused. Either put in the name of the layer to reuse, or use {builtin: \"layername\", override: ...}\nThe 'override'-object will be copied over the original values of the layer, which allows to change certain aspects of the layer\nFor example: If you would like to use layer nature reserves, but only from a specific operator (eg. Natuurpunt) you would use the following in your theme:\n```\n\"layer\": {\n \"builtin\": \"nature_reserve\",\n \"override\": {\"source\":\n {\"osmTags\": {\n \"+and\":[\"operator=Natuurpunt\"]\n }\n }\n }\n}\n```\nIt's also possible to load multiple layers at once, for example, if you would like for both drinking water and benches to start at the zoomlevel at 12, you would use the following:\n```\n\"layer\": {\n \"builtin\": [\"benches\", \"drinking_water\"],\n \"override\": {\"minzoom\": 12}\n}\n```\n</div>"
},
{
"path": [
@ -12195,7 +12203,7 @@
"group": "presets"
},
"type": "array",
"description": "Presets for this layer.\nA preset shows up when clicking the map on a without data (or when right-clicking/long-pressing);\nit will prompt the user to add a new point.\nThe most important aspect are the tags, which define which tags the new point will have;\nThe title is shown in the dialog, along with the first sentence of the description.\nUpon confirmation, the full description is shown beneath the buttons - perfect to add pictures and examples.\nNote: the icon of the preset is determined automatically based on the tags and the icon above. Don't worry about that!\nNB: if no presets are defined, the popup to add new points doesn't show up at all"
"description": "<div class='flex'>\n <div>\nPresets for this layer.\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<a class='block m-2 min-w-64' href='./Docs/Screenshots/AddNewItemScreencast.webm' target='_blank'> <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/></a>\n</div>"
},
{
"path": [
@ -12510,6 +12518,10 @@
"if": "value=food",
"then": "food - A layer showing restaurants and fast-food amenities (with a special rendering for friteries)"
},
{
"if": "value=food_courts",
"then": "food_courts - Food courts with a variety of food options."
},
{
"if": "value=ghost_bike",
"then": "ghost_bike - A layer showing memorials for cyclists, killed in road accidents"
@ -12642,6 +12654,10 @@
"if": "value=osm_community_index",
"then": "osm_community_index - A layer showing the OpenStreetMap Communities"
},
{
"if": "value=outdoor_seating",
"then": "outdoor_seating - Outdoor seating areas, usually located near cafes and restaurants."
},
{
"if": "value=parcel_lockers",
"then": "parcel_lockers - Layer showing parcel lockers for collecting and sending parcels."
@ -13908,7 +13924,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -15229,7 +15249,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -16584,7 +16608,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -17954,7 +17982,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -19323,7 +19355,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -20693,7 +20729,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -32813,7 +32853,7 @@
"group": "presets"
},
"type": "array",
"description": "Presets for this layer.\nA preset shows up when clicking the map on a without data (or when right-clicking/long-pressing);\nit will prompt the user to add a new point.\nThe most important aspect are the tags, which define which tags the new point will have;\nThe title is shown in the dialog, along with the first sentence of the description.\nUpon confirmation, the full description is shown beneath the buttons - perfect to add pictures and examples.\nNote: the icon of the preset is determined automatically based on the tags and the icon above. Don't worry about that!\nNB: if no presets are defined, the popup to add new points doesn't show up at all"
"description": "<div class='flex'>\n <div>\nPresets for this layer.\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<a class='block m-2 min-w-64' href='./Docs/Screenshots/AddNewItemScreencast.webm' target='_blank'> <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/></a>\n</div>"
},
{
"path": [
@ -33133,6 +33173,10 @@
"if": "value=food",
"then": "food - A layer showing restaurants and fast-food amenities (with a special rendering for friteries)"
},
{
"if": "value=food_courts",
"then": "food_courts - Food courts with a variety of food options."
},
{
"if": "value=ghost_bike",
"then": "ghost_bike - A layer showing memorials for cyclists, killed in road accidents"
@ -33265,6 +33309,10 @@
"if": "value=osm_community_index",
"then": "osm_community_index - A layer showing the OpenStreetMap Communities"
},
{
"if": "value=outdoor_seating",
"then": "outdoor_seating - Outdoor seating areas, usually located near cafes and restaurants."
},
{
"if": "value=parcel_lockers",
"then": "parcel_lockers - Layer showing parcel lockers for collecting and sending parcels."
@ -34558,7 +34606,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -35928,7 +35980,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -37333,7 +37389,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -38752,7 +38812,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -40170,7 +40234,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -41589,7 +41657,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},
@ -43424,7 +43496,7 @@
"group": "feature_switches",
"question": "Should the 'download as CSV'- and 'download as Geojson'-buttons be enabled?",
"iftrue": "Enable the option to download the map as CSV and GeoJson",
"iffalse": "Enable the option to download the map as CSV and GeoJson",
"iffalse": "Disable the option to download the map as CSV and GeoJson",
"ifunset": "MapComplete default: Enable the option to download the map as CSV and GeoJson"
},
"type": "boolean",

View file

@ -663,7 +663,11 @@
},
{
"if": "value=velopark",
"then": "<b>velopark</b> A custom element to allow copy-pasting velopark-pages"
"then": "<b>velopark</b> A special URL-validator that checks the domain name and rewrites to the correct velopark format."
},
{
"if": "value=currency",
"then": "<b>currency</b> Validates monetary amounts"
}
]
},

View file

@ -27,6 +27,7 @@
--alert-foreground-color: var(--foreground-color);
--low-interaction-background: #eeeeee;
--low-interaction-background-50: #eeeeee90;
--low-interaction-foreground: black;
--low-interaction-contrast: #ff00ff;
@ -264,6 +265,10 @@ button.primary:not(.no-image-background) svg path, .button.primary:not(.no-image
transition: all 250ms;
}
button.disabled.low-interaction, .button.disabled.low-interaction {
background-color: var(--low-interaction-background-50);
}
button.disabled, .button.disabled {
cursor: default;
@ -274,6 +279,7 @@ button.disabled, .button.disabled {
}
button.disabled:hover, .button.disabled:hover {
cursor: default;
border: 2px dashed var(--button-background);
@ -494,7 +500,7 @@ textarea {
}
.literal-code {
.literal-code, code {
/* A codeblock */
display: inline-block;
background-color: lightgray;

View file

@ -30,8 +30,8 @@ async function getAvailableLayers(): Promise<Set<string>> {
try {
const host = new URL(Constants.VectorTileServer).host
const status: { layers: string[] } = await Promise.any([
// Utils.downloadJson("https://" + host + "/summary/status.json"),
timeout(0),
Utils.downloadJson<{layers}>("https://" + host + "/summary/status.json"),
timeout(2500),
])
return new Set<string>(status.layers)
} catch (e) {

View file

@ -29,7 +29,7 @@ async function getAvailableLayers(): Promise<Set<string>> {
try {
const host = new URL(Constants.VectorTileServer).host
const status = await Promise.any([
// Utils.downloadJson("https://" + host + "/summary/status.json"),
Utils.downloadJson("https://" + host + "/summary/status.json"),
timeout(0)
])
return new Set<string>(status.layers)