Add search for filters

This commit is contained in:
Pieter Vander Vennet 2024-08-26 17:24:12 +02:00
parent 1378c1a779
commit c94393e825
24 changed files with 405 additions and 254 deletions

View file

@ -9,6 +9,7 @@
"id": "open_now",
"options": [
{
"emoji": "⏰",
"question": {
"en": "Open now",
"nl": "Nu open",
@ -268,6 +269,7 @@
{
"question": {
"en": "No preference towards dogs",
"nl": "Geen voorkeur voor honden",
"de": "Keine Bevorzugung von Hunden",
"cs": "Bez preference psů"
}
@ -275,22 +277,27 @@
{
"question": {
"en": "Dogs allowed",
"nl": "Honden toegelaten",
"de": "Hunde erlaubt",
"cs": "Psi povoleny"
},
"emoji": "🐕",
"osmTags": {
"or": [
"dog=unleashed",
"dog=yes"
]
}
},
"icon": "./assets/layers/questions/dogs_allowed.svg"
},
{
"question": {
"en": "No dogs allowed",
"nl": "Geen honden toegelaten",
"de": "Keine Hunde erlaubt",
"cs": "Psi nejsou povoleni"
},
"icon": "./assets/layers/questions/no_dogs.svg",
"osmTags": "dog=no"
}
]
@ -304,6 +311,7 @@
"de": "Internetzugang vorhanden",
"cs": "Nabízí internet"
},
"icon": "wifi",
"osmTags": {
"or": [
"internet_access=wlan",
@ -355,6 +363,7 @@
"cs": "Má bezlepkovou nabídku",
"de": "Hat glutenfreie Angebote"
},
"icon": "./assets/layers/questions/glutenfree.svg",
"osmTags": {
"or": [
"diet:gluten_free=yes",
@ -374,6 +383,7 @@
"cs": "Má nabídku bez laktózy",
"de": "Hat laktosefreie Angebote"
},
"icon": "./assets/layers/questions/lactose_free.svg",
"osmTags": {
"or": [
"diet:lactose_free=yes",

View file

@ -341,6 +341,7 @@
"mappings": [
{
"if": "cuisine=pizza",
"icon": "🍕",
"then": {
"en": "This is a pizzeria",
"nl": "Dit is een pizzeria",
@ -354,6 +355,7 @@
},
{
"if": "cuisine=friture",
"icon": "🍟",
"then": {
"en": "This is a friture",
"nl": "Dit is een frituur",
@ -365,6 +367,7 @@
},
{
"if": "cuisine=pasta",
"icon": "🍝",
"then": {
"en": "Mainly serves pasta",
"nl": "Dit is een pastazaak",
@ -378,6 +381,7 @@
},
{
"if": "cuisine=kebab",
"icon": "🥙",
"then": {
"en": "This is kebab shop",
"nl": "Dit is een kebabzaak",
@ -391,6 +395,7 @@
},
{
"if": "cuisine=sandwich",
"icon": "🥪",
"then": {
"en": "This is a sandwich shop",
"nl": "Dit is een broodjeszaak",
@ -402,6 +407,7 @@
},
{
"if": "cuisine=burger",
"icon": "🍔",
"then": {
"en": "Burgers are served here",
"nl": "Dit is een hamburgerrestaurant",
@ -415,6 +421,7 @@
},
{
"if": "cuisine=sushi",
"icon": "\uD83C\uDF63",
"then": {
"en": "Sushi is served here",
"nl": "Dit is een sushirestaurant",
@ -427,6 +434,7 @@
},
{
"if": "cuisine=coffee",
"icon": "☕",
"then": {
"en": "Coffee is served here",
"nl": "Dit is een koffiezaak",
@ -439,6 +447,7 @@
},
{
"if": "cuisine=italian",
"icon": "🇮🇹",
"then": {
"en": "This is an Italian restaurant (which serves more than pasta and pizza)",
"nl": "Dit is een Italiaans restaurant (dat meer dan enkel pasta of pizza verkoopt)",
@ -451,6 +460,7 @@
},
{
"if": "cuisine=french",
"icon": "🇫🇷",
"then": {
"en": "French dishes are served here",
"nl": "Dit is een Frans restaurant",
@ -463,6 +473,7 @@
},
{
"if": "cuisine=chinese",
"icon":"🇨🇳",
"then": {
"en": "Chinese dishes are served here",
"nl": "Dit is een Chinees restaurant",
@ -475,6 +486,7 @@
},
{
"if": "cuisine=greek",
"icon": "🇬🇷",
"then": {
"en": "Greek dishes are served here",
"nl": "Dit is een Grieks restaurant",
@ -487,6 +499,7 @@
},
{
"if": "cuisine=indian",
"icon": "🇮🇳",
"then": {
"en": "Indian dishes are served here",
"nl": "Dit is een Indisch restaurant",
@ -499,6 +512,7 @@
},
{
"if": "cuisine=turkish",
"icon": "🇹🇷",
"then": {
"en": "Turkish dishes are served here",
"nl": "Dit is een Turks restaurant (dat meer dan enkel kebab verkoopt)",
@ -511,6 +525,7 @@
},
{
"if": "cuisine=thai",
"icon": "🇹🇭",
"then": {
"en": "Thai dishes are served here",
"nl": "Dit is een Thaïs restaurant",
@ -519,9 +534,42 @@
"ca": "Aquí es serveixen plats tailandesos",
"cs": "Podávají se zde thajské pokrmy"
}
},
{
"if": "cuisine=mexican ",
"icon": "\uD83D\uDC14",
"then": {
"en": "Mexican dishes are served here",
"nl": "Dit is een mexicaans restaurant"
}
},
{
"if": "cuisine=japanese ",
"icon": "🇯🇵",
"then": {
"en": "Japanese dishes are served here",
"nl": "Dit is een japans restaurant"
}
},
{
"if": "cuisine=chicken ",
"icon": "\uD83D\uDC14",
"then": {
"en": "Chicken based dishes are served here",
"nl": "Dit is een kiprestaurant"
}
},
{
"if": "cuisine=seafood ",
"icon": "\uD83D\uDC1F",
"then": {
"en": "Seafood dishes are served here",
"nl": "Dit is een vis- en zeerestaurant"
}
}
],
"id": "Cuisine"
"id": "Cuisine",
"filter": true
},
{
"id": "show-menu-image",
@ -1291,6 +1339,7 @@
"es": "Tiene menú vegetariano",
"fr": "A un menu végétarien"
},
"icon": "./assets/layers/food/Vegetarian-mark.svg",
"osmTags": {
"or": [
"diet:vegetarian=yes",

View file

@ -569,7 +569,7 @@
},
{
"if": "dog=no",
"icon": "\uD83D\uDC15 ⃠",
"icon": "./assets/layers/questions/no_dogs.svg",
"then": {
"en": "Dogs are <b>not</b> allowed",
"nl": "honden zijn <b>niet</b> toegelaten",

View file

@ -3,14 +3,17 @@
"title": {
"en": "Changes made with MapComplete"
},
"description": {
"en": "This maps shows all the changes made with MapComplete"
},
"shortDescription": {
"en": "Shows changes made by MapComplete"
},
"description": {
"en": "This maps shows all the changes made with MapComplete"
},
"icon": "./assets/svg/logo.svg",
"hideFromOverview": true,
"startLat": 0,
"startLon": 0,
"startZoom": 1,
"layers": [
{
"id": "mapcomplete-changes",

View file

@ -3883,6 +3883,19 @@
"question": "Gratis toegankelijk"
}
}
},
"10": {
"options": {
"0": {
"question": "Geen voorkeur voor honden"
},
"1": {
"question": "Honden toegelaten"
},
"2": {
"question": "Geen honden toegelaten"
}
}
}
}
},

View file

@ -4724,6 +4724,17 @@ h2.group {
background-color: #58cd2722;
}
.badge {
display: flex;
align-items: center;
white-space: nowrap;
border-radius: 999rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
border: 1px solid var(--subtle-detail-color-light-contrast);
background-color: var(--low-interaction-background);
}
.alert {
/* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */
background-color: var(--alert-color);

View file

@ -13,11 +13,17 @@ export default class FilterSearch implements GeocodingProvider {
}
async search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
return []
return this.searchDirectly(query)
}
private searchDirectly(query: string): SearchResult[] {
const possibleFilters: SearchResult[] = []
if (query.length === 0) {
return []
}
if(!Utils.isEmoji(query)){
query = Utils.simplifyStringForSearch(query)
}
for (const layer of this._state.layout.layers) {
if (!Array.isArray(layer.filters)) {
continue
@ -26,16 +32,16 @@ export default class FilterSearch implements GeocodingProvider {
for (let i = 0; i < filter.options.length; i++) {
const option = filter.options[i]
if (option === undefined) {
console.log("No options for", filter)
continue
}
const terms = [option.question.txt,
...(option.searchTerms?.[Locale.language.data] ?? option.searchTerms?.["en"] ?? [])].flatMap(term => term.split(" "))
let terms = ([option.question.txt,
...(option.searchTerms?.[Locale.language.data] ?? option.searchTerms?.["en"] ?? [])]
.flatMap(term => [term, ...term?.split(" ")]))
terms = terms.map(t => Utils.simplifyStringForSearch(t))
terms.push(option.emoji)
Utils.NoNullInplace(terms)
const levehnsteinD = Math.min(...
terms.map(entry => {
const simplified = Utils.simplifyStringForSearch(entry)
return Utils.levenshteinDistance(query, simplified.slice(0, query.length))
}))
terms.map(entry => Utils.levenshteinDistance(query, entry.slice(0, query.length))))
if (levehnsteinD / query.length > 0.25) {
continue
}
@ -51,8 +57,10 @@ export default class FilterSearch implements GeocodingProvider {
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
if (Utils.isEmoji(query)) {
return new ImmutableStore(this.searchDirectly(query))
}
query = Utils.simplifyStringForSearch(query)
return new ImmutableStore(this.searchDirectly(query))
}

View file

@ -48,7 +48,6 @@ export class RecentSearch {
}
results.push(simple)
}
console.log("Setting", results)
prefs.setData(JSON.stringify(results))
})
@ -59,7 +58,6 @@ export class RecentSearch {
if (!osm_id) {
return
}
console.log("Selected element is", selected)
if (["node", "way", "relation"].indexOf(osm_type) < 0) {
return
}
@ -86,7 +84,6 @@ export class RecentSearch {
seenIds.add(id)
}
}
console.log(">>>", arr)
this._seenThisSession.set(arr)
}
}

View file

@ -10,10 +10,7 @@ import {
SetDefault,
} from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import {
MinimalTagRenderingConfigJson,
TagRenderingConfigJson,
} from "../Json/TagRenderingConfigJson"
import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
import { Utils } from "../../../Utils"
import RewritableConfigJson from "../Json/RewritableConfigJson"
import SpecialVisualizations from "../../../UI/SpecialVisualizations"
@ -21,7 +18,7 @@ import Translations from "../../../UI/i18n/Translations"
import { Translation } from "../../../UI/i18n/Translation"
import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json"
import { AddContextToTranslations } from "./AddContextToTranslations"
import FilterConfigJson from "../Json/FilterConfigJson"
import FilterConfigJson, { FilterConfigOptionJson } from "../Json/FilterConfigJson"
import predifined_filters from "../../../../assets/layers/filters/filters.json"
import { TagConfigJson } from "../Json/TagConfigJson"
import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson"
@ -33,7 +30,7 @@ import LineRenderingConfigJson from "../Json/LineRenderingConfigJson"
import { ConversionContext } from "./ConversionContext"
import { ExpandRewrite } from "./ExpandRewrite"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { Translatable } from "../Json/Translatable"
import FilterConfig, { FilterConfigOption } from "../FilterConfig"
class ExpandFilter extends DesugaringStep<LayerConfigJson> {
private static readonly predefinedFilters = ExpandFilter.load_filters()
@ -41,9 +38,11 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
constructor(state: DesugaringContext) {
super(
"Expands filters: replaces a shorthand by the value found in 'filters.json'. If the string is formatted 'layername.filtername, it will be looked up into that layer instead. If a tagRendering sets 'filter', this filter will also be included",
["Expands filters: replaces a shorthand by the value found in 'filters.json'.",
"If the string is formatted 'layername.filtername, it will be looked up into that layer instead. If a tagRendering sets 'filter', this filter will also be included",
""].join(" "),
["filter"],
"ExpandFilter"
"ExpandFilter",
)
this._state = state
}
@ -56,6 +55,38 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
return filters
}
private static buildFilterFromTagRendering(tr: TagRenderingConfigJson, context: ConversionContext): FilterConfigJson {
if (!(tr.mappings?.length >= 1)) {
context.err(
"Found a matching tagRendering to base a filter on, but this tagRendering does not contain any mappings",
)
}
const options = (<QuestionableTagRenderingConfigJson>tr).mappings.map((mapping) => {
let icon : string= mapping.icon?.["path"] ?? mapping.icon
let emoji: string = undefined
if(Utils.isEmoji(icon)){
emoji = icon
icon = undefined
}
return (<FilterConfigOptionJson>{
question: mapping.then,
osmTags: mapping.if,
searchTerms: mapping.searchTerms,
icon, emoji
})
})
// Add default option
options.unshift({
question: tr["question"] ?? Translations.t.general.filterPanel.allTypes,
osmTags: undefined,
searchTerms: undefined,
})
return ({
id: tr["id"],
options,
})
}
convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
if (json?.filter === undefined || json?.filter === null) {
return json // Nothing to change here
@ -68,6 +99,16 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
const newFilters: FilterConfigJson[] = []
const filters = <(FilterConfigJson | string)[]>json.filter
function filterExists(filterName: string): boolean{
return filters.some((existing) => {
const id: string = existing["id"] ?? existing
return (
filterName === id ||
(filterName.startsWith("filters.") && filterName.endsWith("." + id))
)
})
}
/**
* Checks all tagRendering. If a tagrendering has 'filter' set, add this filter to the layer config
*/
@ -76,18 +117,18 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
if (!tagRendering?.filter) {
continue
}
if (tagRendering.filter === true) {
if(filterExists(tagRendering["id"])){
continue
}
filters.push(ExpandFilter.buildFilterFromTagRendering(tagRendering, context.enters("tagRenderings", i, "filter")))
continue
}
for (const filterName of tagRendering.filter ?? []) {
if (typeof filterName !== "string") {
context.enters("tagRenderings", i, "filter").err("Not a string: " + filterName)
}
const exists = filters.some((existing) => {
const id: string = existing["id"] ?? existing
return (
filterName === id ||
(filterName.startsWith("filters.") && filterName.endsWith("." + id))
)
})
if (exists) {
if (filterExists(filterName)) {
continue
}
if (!filterName) {
@ -99,7 +140,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
}
/**
* Create filters based on builtin filters
* Create filters based on builtin filters or create them based on the tagRendering
*/
for (let i = 0; i < filters.length; i++) {
const filter = filters[i]
@ -115,28 +156,8 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
json.tagRenderings.find((tr) => !!tr && tr["id"] === filter)
)
if (matchingTr) {
if (!(matchingTr.mappings?.length >= 1)) {
context
.enters("filter", i)
.err(
"Found a matching tagRendering to base a filter on, but this tagRendering does not contain any mappings"
)
}
const options = (<QuestionableTagRenderingConfigJson> matchingTr).mappings.map((mapping) => ({
question: mapping.then,
osmTags: mapping.if,
searchTerms: mapping.searchTerms
}))
options.unshift({
question: matchingTr["question"] ?? Translations.t.general.filterPanel.allTypes,
osmTags: undefined,
searchTerms: undefined
})
newFilters.push({
id: filter,
options,
})
const filter = ExpandFilter.buildFilterFromTagRendering(matchingTr, context.enters("filter", i))
newFilters.push(filter)
continue
}
@ -145,7 +166,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
const split = filter.split(".")
if (split.length > 2) {
context.err(
"invalid filter name: " + filter + ", expected `layername.filterid`"
"invalid filter name: " + filter + ", expected `layername.filterid`",
)
}
const layer = this._state.sharedLayers.get(split[0])
@ -154,7 +175,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
}
const expectedId = split[1]
const expandedFilter = (<(FilterConfigJson | string)[]>layer.filter).find(
(f) => typeof f !== "string" && f.id === expectedId
(f) => typeof f !== "string" && f.id === expectedId,
)
if (expandedFilter === undefined) {
context.err("Did not find filter with name " + filter)
@ -172,7 +193,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
const suggestions = Utils.sortedByLevenshteinDistance(
filter,
Array.from(ExpandFilter.predefinedFilters.keys()),
(t) => t
(t) => t,
)
context
.enter(filter)
@ -180,7 +201,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
"While searching for predefined filter " +
filter +
": this filter is not found. Perhaps you meant one of: " +
suggestions
suggestions,
)
}
newFilters.push(found)
@ -217,12 +238,12 @@ class ExpandTagRendering extends Conversion<
noHardcodedStrings?: false | boolean
// If set, a question will be added to the 'sharedTagRenderings'. Should only be used for 'questions.json'
addToContext?: false | boolean
}
},
) {
super(
"Converts a tagRenderingSpec into the full tagRendering, e.g. by substituting the tagRendering by the shared-question and reusing the builtins",
[],
"ExpandTagRendering"
"ExpandTagRendering",
)
this._state = state
this._self = self
@ -242,7 +263,7 @@ class ExpandTagRendering extends Conversion<
public convert(
spec: string | any,
ctx: ConversionContext
ctx: ConversionContext,
): QuestionableTagRenderingConfigJson[] {
const trs = this.convertOnce(spec, ctx)
@ -355,8 +376,8 @@ class ExpandTagRendering extends Conversion<
found,
ConversionContext.construct(
[layer.id, "tagRenderings", found["id"]],
["AddContextToTranslations"]
)
["AddContextToTranslations"],
),
)
matchingTrs[i] = found
}
@ -384,7 +405,7 @@ class ExpandTagRendering extends Conversion<
ctx.warn(
`A literal rendering was detected: ${tr}
Did you perhaps forgot to add a layer name as 'layername.${tr}'? ` +
Array.from(state.sharedLayers.keys()).join(", ")
Array.from(state.sharedLayers.keys()).join(", "),
)
}
@ -394,7 +415,7 @@ class ExpandTagRendering extends Conversion<
tr +
" \n Did you perhaps forget to add the layer as prefix, such as `icons." +
tr +
"`? "
"`? ",
)
}
@ -431,7 +452,7 @@ class ExpandTagRendering extends Conversion<
"An object calling a builtin can only have keys `builtin` or `override`, but a key with name `" +
key +
"` was found. This won't be picked up! The full object is: " +
JSON.stringify(tr)
JSON.stringify(tr),
)
}
@ -450,7 +471,7 @@ class ExpandTagRendering extends Conversion<
const candidates = Utils.sortedByLevenshteinDistance(
layerName,
Array.from(state.sharedLayers.keys()),
(s) => s
(s) => s,
)
if (state.sharedLayers.size === 0) {
ctx.warn(
@ -458,7 +479,7 @@ class ExpandTagRendering extends Conversion<
name +
": layer " +
layerName +
" not found for now, but ignoring as this is a bootstrapping run. "
" not found for now, but ignoring as this is a bootstrapping run. ",
)
} else {
ctx.err(
@ -467,13 +488,13 @@ class ExpandTagRendering extends Conversion<
": layer " +
layerName +
" not found. Maybe you meant one of " +
candidates.slice(0, 3).join(", ")
candidates.slice(0, 3).join(", "),
)
}
continue
}
candidates = Utils.NoNull(layer.tagRenderings.map((tr) => tr["id"])).map(
(id) => layerName + "." + id
(id) => layerName + "." + id,
)
}
candidates = Utils.sortedByLevenshteinDistance(name, candidates, (i) => i)
@ -482,7 +503,7 @@ class ExpandTagRendering extends Conversion<
name +
" was not found.\n\tDid you mean one of " +
candidates.join(", ") +
"?\n(Hint: did you add a new label and are you trying to use this label at the same time? Run 'reset:layeroverview' first"
"?\n(Hint: did you add a new label and are you trying to use this label at the same time? Run 'reset:layeroverview' first",
)
continue
}
@ -507,13 +528,13 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
super(
"If no 'inline' is set on the freeform key, it will be automatically added. If no special renderings are used, it'll be set to true",
["freeform.inline"],
"DetectInline"
"DetectInline",
)
}
convert(
json: QuestionableTagRenderingConfigJson,
context: ConversionContext
context: ConversionContext,
): QuestionableTagRenderingConfigJson {
if (json.freeform === undefined) {
return json
@ -536,7 +557,7 @@ class DetectInline extends DesugaringStep<QuestionableTagRenderingConfigJson> {
if (json.freeform.inline === true) {
context.err(
"'inline' is set, but the rendering contains a special visualisation...\n " +
spec[key]
spec[key],
)
}
json = JSON.parse(JSON.stringify(json))
@ -559,7 +580,7 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
super(
"Adds a 'questions'-object if no question element is added yet",
["tagRenderings"],
"AddQuestionBox"
"AddQuestionBox",
)
}
@ -583,18 +604,18 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
json.tagRenderings = [...json.tagRenderings]
const allSpecials: Exclude<RenderingSpecification, string>[] = <any>(
ValidationUtils.getAllSpecialVisualisations(
<QuestionableTagRenderingConfigJson[]>json.tagRenderings
<QuestionableTagRenderingConfigJson[]>json.tagRenderings,
).filter((spec) => typeof spec !== "string")
)
const questionSpecials = allSpecials.filter((sp) => sp.func.funcName === "questions")
const noLabels = questionSpecials.filter(
(sp) => sp.args.length === 0 || sp.args[0].trim() === ""
(sp) => sp.args.length === 0 || sp.args[0].trim() === "",
)
if (noLabels.length > 1) {
context.err(
"Multiple 'questions'-visualisations found which would show _all_ questions. Don't do this"
"Multiple 'questions'-visualisations found which would show _all_ questions. Don't do this",
)
}
@ -602,9 +623,9 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
const allLabels = new Set(
[].concat(
...json.tagRenderings.map(
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? []
)
)
(tr) => (<QuestionableTagRenderingConfigJson>tr).labels ?? [],
),
),
)
const seen: Set<string> = new Set()
for (const questionSpecial of questionSpecials) {
@ -625,7 +646,7 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
"\n Whitelisted: " +
used.join(", ") +
"\n Blacklisted: " +
blacklisted.join(", ")
blacklisted.join(", "),
)
}
for (const usedLabel of used) {
@ -635,7 +656,7 @@ export class AddQuestionBox extends DesugaringStep<LayerConfigJson> {
usedLabel +
"`, but this label doesn't exist.\n" +
" Available labels are " +
Array.from(allLabels).join(", ")
Array.from(allLabels).join(", "),
)
}
seen.add(usedLabel)
@ -668,7 +689,7 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
super(
"Add some editing elements, such as the delete button or the move button if they are configured. These used to be handled by the feature info box, but this has been replaced by special visualisation elements",
[],
"AddEditingElements"
"AddEditingElements",
)
this._desugaring = desugaring
this.builtinQuestions = Array.from(this._desugaring.tagRenderings?.values() ?? [])
@ -698,13 +719,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
json.tagRenderings = [...(json.tagRenderings ?? [])]
const allIds = new Set<string>(json.tagRenderings.map((tr) => tr["id"]))
const specialVisualisations = ValidationUtils.getAllSpecialVisualisations(
<any>json.tagRenderings
<any>json.tagRenderings,
)
const usedSpecialFunctions = new Set(
specialVisualisations.map((sv) =>
typeof sv === "string" ? undefined : sv.func.funcName
)
typeof sv === "string" ? undefined : sv.func.funcName,
),
)
/***** ADD TO TOP ****/
@ -772,7 +793,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
super(
"Converts a 'special' translation into a regular translation which uses parameters",
["special"],
"RewriteSpecial"
"RewriteSpecial",
)
}
@ -868,7 +889,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
}
})
| any,
context: ConversionContext
context: ConversionContext,
): any {
const special = input["special"]
if (special === undefined) {
@ -878,7 +899,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const type = special["type"]
if (type === undefined) {
context.err(
"A 'special'-block should define 'type' to indicate which visualisation should be used"
"A 'special'-block should define 'type' to indicate which visualisation should be used",
)
return undefined
}
@ -888,10 +909,10 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const options = Utils.sortedByLevenshteinDistance(
type,
SpecialVisualizations.specialVisualizations,
(sp) => sp.funcName
(sp) => sp.funcName,
)
context.err(
`Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md`
`Special visualisation '${type}' not found. Did you perhaps mean ${options[0].funcName}, ${options[1].funcName} or ${options[2].funcName}?\n\tFor all known special visualisations, please see https://github.com/pietervdvn/MapComplete/blob/develop/Docs/SpecialRenderings.md`,
)
return undefined
}
@ -912,7 +933,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
const byDistance = Utils.sortedByLevenshteinDistance(
wrongArg,
argNamesList,
(x) => x
(x) => x,
)
return `Unexpected argument in special block at ${context} with name '${wrongArg}'. Did you mean ${
byDistance[0]
@ -931,8 +952,8 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
`Obligated parameter '${arg.name}' in special rendering of type ${
vis.funcName
} not found.\n The full special rendering specification is: '${JSON.stringify(
input
)}'\n ${arg.name}: ${arg.doc}`
input,
)}'\n ${arg.name}: ${arg.doc}`,
)
}
}
@ -1034,7 +1055,7 @@ export class RewriteSpecial extends DesugaringStep<TagRenderingConfigJson> {
continue
}
Utils.WalkPath(path.path, json, (leaf, travelled) =>
RewriteSpecial.convertIfNeeded(leaf, context.enter(travelled))
RewriteSpecial.convertIfNeeded(leaf, context.enter(travelled)),
)
}
@ -1068,7 +1089,7 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
} = badgesJson[i]
const expanded = this._expand.convert(
<QuestionableTagRenderingConfigJson>iconBadge.then,
context.enters("iconBadges", i)
context.enters("iconBadges", i),
)
if (expanded === undefined) {
iconBadges.push(iconBadge)
@ -1079,7 +1100,7 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
...expanded.map((resolved) => ({
if: iconBadge.if,
then: <MinimalTagRenderingConfigJson>resolved,
}))
})),
)
}
@ -1096,11 +1117,11 @@ class PreparePointRendering extends Fuse<PointRenderingConfigJson> {
new Each(
new On(
"icon",
new FirstOf(new ExpandTagRendering(state, layer, { applyCondition: false }))
)
)
new FirstOf(new ExpandTagRendering(state, layer, { applyCondition: false })),
),
new ExpandIconBadges(state, layer)
),
),
new ExpandIconBadges(state, layer),
)
}
}
@ -1110,7 +1131,7 @@ class SetFullNodeDatabase extends DesugaringStep<LayerConfigJson> {
super(
"sets the fullNodeDatabase-bit if needed",
["fullNodeDatabase"],
"SetFullNodeDatabase"
"SetFullNodeDatabase",
)
}
@ -1139,7 +1160,7 @@ class ExpandMarkerRenderings extends DesugaringStep<IconConfigJson> {
super(
"Expands tagRenderings in the icons, if needed",
["icon", "color"],
"ExpandMarkerRenderings"
"ExpandMarkerRenderings",
)
this._layer = layer
this._state = state
@ -1171,7 +1192,7 @@ class AddFavouriteBadges extends DesugaringStep<LayerConfigJson> {
super(
"Adds the favourite heart to the title and the rendering badges",
[],
"AddFavouriteBadges"
"AddFavouriteBadges",
)
}
@ -1196,7 +1217,7 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
super(
"Adds the 'rating'-element if a reviews-element is used in the tagRenderings",
["titleIcons"],
"AddRatingBadge"
"AddRatingBadge",
)
}
@ -1216,7 +1237,7 @@ export class AddRatingBadge extends DesugaringStep<LayerConfigJson> {
const specialVis: Exclude<RenderingSpecification, string>[] = <
Exclude<RenderingSpecification, string>[]
>ValidationUtils.getAllSpecialVisualisations(<any>json.tagRenderings).filter(
(rs) => typeof rs !== "string"
(rs) => typeof rs !== "string",
)
const funcs = new Set<string>(specialVis.map((rs) => rs.func.funcName))
@ -1232,12 +1253,12 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
super(
"The auto-icon creates a (non-clickable) title icon based on a tagRendering which has icons",
["titleIcons"],
"AutoTitleIcon"
"AutoTitleIcon",
)
}
private createTitleIconsBasedOn(
tr: QuestionableTagRenderingConfigJson
tr: QuestionableTagRenderingConfigJson,
): TagRenderingConfigJson | undefined {
const mappings: { if: TagConfigJson; then: string }[] = tr.mappings
?.filter((m) => m.icon !== undefined)
@ -1267,7 +1288,7 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
return undefined
}
return this.createTitleIconsBasedOn(<any>tr)
})
}),
)
json.titleIcons.splice(allAutoIndex, 1, ...generated)
return json
@ -1297,7 +1318,7 @@ export class AutoTitleIcon extends DesugaringStep<LayerConfigJson> {
.warn(
"TagRendering with id " +
trId +
" does not have any icons, not generating an icon for this"
" does not have any icons, not generating an icon for this",
)
continue
}
@ -1312,7 +1333,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
super(
"If no source is given, automatically derives the osmTags by 'or'-ing all the preset tags",
["source"],
"DeriveSource"
"DeriveSource",
)
}
@ -1322,7 +1343,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
}
if (!json.presets) {
context.err(
"No source tags given. Trying to derive the source-tags based on the presets, but no presets are given"
"No source tags given. Trying to derive the source-tags based on the presets, but no presets are given",
)
return json
}
@ -1348,7 +1369,7 @@ class DeriveSource extends DesugaringStep<LayerConfigJson> {
export class PrepareLayer extends Fuse<LayerConfigJson> {
constructor(
state: DesugaringContext,
options?: { addTagRenderingsToContext?: false | boolean }
options?: { addTagRenderingsToContext?: false | boolean },
) {
super(
"Fully prepares and expands a layer for the LayerConfig.",
@ -1361,8 +1382,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new Concat(
new ExpandTagRendering(state, layer, {
addToContext: options?.addTagRenderingsToContext ?? false,
})
)
}),
),
),
new On("tagRenderings", new Each(new DetectInline())),
new AddQuestionBox(),
@ -1375,11 +1396,11 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new On<PointRenderingConfigJson[], LayerConfigJson>(
"pointRendering",
(layer) =>
new Each(new On("marker", new Each(new ExpandMarkerRenderings(state, layer))))
new Each(new On("marker", new Each(new ExpandMarkerRenderings(state, layer)))),
),
new On<PointRenderingConfigJson[], LayerConfigJson>(
"pointRendering",
(layer) => new Each(new PreparePointRendering(state, layer))
(layer) => new Each(new PreparePointRendering(state, layer)),
),
new SetDefault("titleIcons", ["icons.defaults"]),
new AddRatingBadge(),
@ -1388,9 +1409,9 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new On(
"titleIcons",
(layer) =>
new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true }))
new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true })),
),
new ExpandFilter(state)
new ExpandFilter(state),
)
}
}

View file

@ -14,6 +14,7 @@ export type FilterConfigOption = {
question: Translation
searchTerms: Record<string, string[]>
icon?: string
emoji?: string
osmTags: TagsFilter | undefined
/* Only set if fields are present. Used to create `osmTags` (which are used to _actually_ filter) when the field is written*/
readonly originalTagsSpec: TagConfigJson
@ -108,7 +109,8 @@ export default class FilterConfig {
searchTerms: option.searchTerms,
fields,
originalTagsSpec: option.osmTags,
icon: option.icon
icon: option.icon,
emoji: option.emoji,
}
})

View file

@ -1,6 +1,20 @@
import { TagConfigJson } from "./TagConfigJson"
import { Translatable } from "./Translatable"
export interface FilterConfigOptionJson {
question: Translatable
searchTerms?: Record<string, string[]>
emoji?: string
icon?: string
osmTags?: TagConfigJson
default?: boolean
fields?: {
/**
* If name is `search`, use "_first_comment~.*{search}.*" as osmTags
*/
name: string
type?: string | "string"
}[]
}
export default interface FilterConfigJson {
/**
* An id/name for this filter, used to set the URL parameters
@ -34,20 +48,7 @@ export default interface FilterConfigJson {
* }
* ```
*/
options: {
question: Translatable
searchTerms?: Record<string, string[]>
icon?: string
osmTags?: TagConfigJson
default?: boolean
fields?: {
/**
* If name is `search`, use "_first_comment~.*{search}.*" as osmTags
*/
name: string
type?: string | "string"
}[]
}[]
options: FilterConfigOptionJson[]
/**
* Used for comments or to disable a check

View file

@ -226,5 +226,5 @@ export interface TagRenderingConfigJson {
/**
* This tagRendering can introduce this builtin filter
*/
filter?: string[]
filter?: string[] | true
}

View file

@ -385,11 +385,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
this.geosearch = new CombinedSearcher(
new CoordinateSearch(),
new FilterSearch(this),
//new LocalElementSearch(this, 5),
//new OpenStreetMapIdSearch(this),
// new PhotonSearch(), // new NominatimGeocoding(),
new LocalElementSearch(this, 5),
new CoordinateSearch(),
new OpenStreetMapIdSearch(this),
new PhotonSearch(), // new NominatimGeocoding(),
this.featureSwitches.featureSwitchBackToThemeOverview.data ? new ThemeSearch(this) : undefined
)
@ -652,7 +652,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
},
Translations.t.hotkeyDocumentation.openFilterPanel,
() => {
console.log("S pressed")
if (this.featureSwitches.featureSwitchFilter.data) {
this.guistate.openFilterView()
}

View file

@ -14,6 +14,7 @@
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { Utils } from "../../Utils"
import Icon from "../Map/Icon.svelte"
export let filteredLayer: FilteredLayer
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined)
@ -76,8 +77,8 @@
<Dropdown value={getStateFor(filter)}>
{#each filter.options as option, i}
<option value={i}>
{#if Utils.isEmoji(option.icon)}
{option.icon}
{#if option.emoji}
{option.emoji}
{/if}
<Tr t={option.question} />
</option>

View file

@ -1,20 +1,20 @@
<script lang="ts">
import type { ActiveFilter } from "../../Logic/State/LayerState"
import { Badge } from "flowbite-svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Tr from "../Base/Tr.svelte"
import Icon from "../Map/Icon.svelte"
import { Badge } from "flowbite-svelte"
import FilterOption from "./FilterOption.svelte"
import { XMarkIcon } from "@babeard/svelte-heroicons/mini"
export let state: SpecialVisualizationState
export let activeFilter: ActiveFilter
let { control, layer, filter } = activeFilter
let option = control.map(c => {
if (typeof c === "number") {
return filter.options[c]
}
return filter.options[0]
})
let option = control.map(c => filter.options[c] ?? filter.options[0])
</script>
<Badge dismissable large border rounded color="dark" on:close={() =>{ console.log( "dismiss"); return control.setData(undefined) }}>
<Tr cls="whitespace-nowrap" t={$option.question} />
</Badge>
<div class="badge">
<FilterOption option={$option} />
<button on:click={() => control.setData(undefined)}>
<XMarkIcon class="w-5 h-5 pl-1" color="gray" />
</button>
</div>

View file

@ -1,17 +1,24 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Badge } from "flowbite-svelte"
import ActiveFilter from "./ActiveFilter.svelte"
export let state: SpecialVisualizationState
let activeFilters = state.layerState.activeFilters
import { default as ActiveFilterSvelte } from "./ActiveFilter.svelte"
import type { ActiveFilter } from "../../Logic/State/LayerState"
export let activeFilters: ActiveFilter[]
function clear() {
for (const activeFilter of activeFilters) {
activeFilter.control.setData(undefined)
}
}
</script>
{#if activeFilters.length > 0}
<div class="flex flex-wrap gap-y-1 gap-x-1 button-unstyled">
{#each $activeFilters as activeFilter (activeFilter)}
<ActiveFilter {activeFilter} {state} />
{#each activeFilters as activeFilter (activeFilter)}
<ActiveFilterSvelte {activeFilter} />
{/each}
<button class="as-link subtle" on:click={() => clear()}>
Clear filters
</button>
</div>
{/if}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import type { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import Tr from "../Base/Tr.svelte"
import Icon from "../Map/Icon.svelte"
export let option : FilterConfigOption
</script>
<Icon icon={option.icon ?? option.emoji} clss="w-5 h-5" emojiHeight="14px" />
<Tr t={option.question} />

View file

@ -1,14 +1,10 @@
<script lang="ts">
import type FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import type { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Filter from "../../assets/svg/Filter.svelte"
import Tr from "../Base/Tr.svelte"
import type { FilterPayload } from "../../Logic/Geocoding/GeocodingProvider"
import { createEventDispatcher } from "svelte"
import { FilterIcon as FilterSolid } from "@rgossiaux/svelte-heroicons/solid"
import { FilterIcon as FilterOutline } from "@rgossiaux/svelte-heroicons/outline"
import Icon from "../Map/Icon.svelte"
import SearchResultUtils from "./SearchResultUtils"
export let entry: {
category: "filter",
@ -18,38 +14,19 @@
export let state: SpecialVisualizationState
let dispatch = createEventDispatcher<{ select }>()
let flayer = state.layerState.filteredLayers.get(layer.id)
let filtercontrol = flayer.appliedFilters.get(filter.id)
let isActive = filtercontrol.map(c => c === index)
function apply() {
for (const [name, otherLayer] of state.layerState.filteredLayers) {
if(name === layer.id){
otherLayer.isDisplayed.setData(true)
continue
}
otherLayer.isDisplayed.setData(false)
}
if(filtercontrol.data === index){
filtercontrol.setData(undefined)
}else{
filtercontrol.setData(index)
}
SearchResultUtils.apply(entry.payload, state)
dispatch("select")
}
</script>
<button on:click={() => apply()}>
{#if $isActive}
<FilterSolid class="w-8 h-8 shrink-0" />
{:else}
<FilterOutline class="w-8 h-8 shrink-0" />
{/if}
<Tr t={option.question} />
<div class="subtle">
{layer.id}
<div class="flex flex-col items-start">
<div class="flex items-center gap-x-1">
<Icon icon={option.icon ?? option.emoji} clss="w-12 h-12 mr-2" emojiHeight="14px" />
<Tr cls="whitespace-nowrap" t={option.question} />
</div>
</div>
</button>

View file

@ -23,6 +23,7 @@
import ThemeViewState from "../../Models/ThemeViewState"
import GeocodingFeatureSource from "../../Logic/Geocoding/GeocodingFeatureSource"
import MoreScreen from "../BigComponents/MoreScreen"
import SearchResultUtils from "./SearchResultUtils"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox>
@ -81,7 +82,6 @@
return
}
const result = await searcher.search(searchContentsData, { bbox: bounds.data, limit: 10 })
console.log("Results are", result)
if (result.length == 0) {
feedback = Translations.t.general.search.nothing.txt
focusOnSearch()
@ -91,11 +91,13 @@
if (poi.category === "theme") {
const theme = <MinimalLayoutInformation>poi.payload
const url = MoreScreen.createUrlFor(theme, false)
console.log("Found a theme, going to", url)
// @ts-ignore
window.location = url
return
}
if(poi.category === "filter"){
SearchResultUtils.apply(poi.payload, state)
}
if(poi.category === "filter"){
return // Should not happen
}
@ -120,7 +122,6 @@
continue
}
selectedElement?.setData(found)
console.log("Found an element that probably matches:", selectedElement?.data)
break
}
}
@ -146,7 +147,6 @@
return Stores.holdDefined(bounds.bindD(bbox => searcher.suggest(search, { bbox, limit: 15 })))
}
)
suggestions.addCallbackAndRun(suggestions => console.log(">>> suggestions are", suggestions))
let geocededFeatures= new GeocodingFeatureSource(suggestions.stabilized(250))
state.featureProperties.trackFeatureSource(geocededFeatures)

View file

@ -11,10 +11,9 @@
</script>
{#if entry.category === "theme"}
<ThemeResult {entry} />
<ThemeResult {entry} on:select />
{:else if entry.category === "filter"}
<FilterResult {entry} {state} />
<FilterResult {entry} {state} on:select />
{:else}
<GeocodeResult {entry} {state} />
<GeocodeResult {entry} {state} on:select />
{/if}

View file

@ -0,0 +1,25 @@
import { SpecialVisualizationState } from "../SpecialVisualization"
import { FilterPayload } from "../../Logic/Geocoding/GeocodingProvider"
export default class SearchResultUtils {
static apply(payload: FilterPayload, state: SpecialVisualizationState) {
const { layer, filter, index, option } = payload
let flayer = state.layerState.filteredLayers.get(layer.id)
let filtercontrol = flayer.appliedFilters.get(filter.id)
for (const [name, otherLayer] of state.layerState.filteredLayers) {
if (name === layer.id) {
otherLayer.isDisplayed.setData(true)
continue
}
otherLayer.isDisplayed.setData(false)
}
if (filtercontrol.data === index) {
filtercontrol.setData(undefined)
} else {
filtercontrol.setData(index)
}
}
}

View file

@ -8,14 +8,16 @@
import MoreScreen from "../BigComponents/MoreScreen"
import type { GeocodeResult, SearchResult } from "../../Logic/Geocoding/GeocodingProvider"
import ActiveFilters from "./ActiveFilters.svelte"
import Constants from "../../Models/Constants"
import type { ActiveFilter } from "../../Logic/State/LayerState"
export let state: SpecialVisualizationState
export let results: SearchResult[]
export let searchTerm: Store<string>
export let isFocused: UIEventSource<boolean>
let hasActiveFilters = state.layerState.activeFilters.map(af => af.length > 0)
let activeFilters: Store<ActiveFilter[]> = state.layerState.activeFilters.map(fs => fs.filter(f => Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0))
console.log("Results are", results)
let hasActiveFilters = activeFilters.map(afs => afs.length > 0)
let recentlySeen: Store<GeocodeResult[]> = state.recentlySearched.seenThisSession
let recentThemes = state.userRelatedState.recentlyVisitedThemes.mapD(thms => thms.filter(th => th !== state.layout.id).slice(0, 3))
@ -24,7 +26,7 @@
<div class="relative w-full h-full collapsable " class:collapsed={!$isFocused && !$hasActiveFilters}>
<div class="searchbox normal-background">
<ActiveFilters {state} />
<ActiveFilters activeFilters={$activeFilters} />
{#if $isFocused}
{#if $searchTerm.length > 0 && results === undefined}
<div class="flex justify-center m-4 my-8">
@ -64,7 +66,7 @@
</h3>
{#each $recentThemes as themeId (themeId)}
<SearchResultSvelte
entry={{payload: MoreScreen.officialThemesById.get(themeId), display_name: themeId, lat: 0, lon: 0}}
entry={{payload: MoreScreen.officialThemesById.get(themeId), osm_id: themeId, category: "theme"}}
{state}
on:select />
{/each}

View file

@ -1,4 +1,5 @@
import DOMPurify from "dompurify"
export class Utils {
/**
* In the 'deploy'-step, some code needs to be run by ts-node.
@ -1771,22 +1772,26 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
}
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)
static NoNullInplace<T>(items: T[]): T[] {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i] === null || items[i] === undefined) {
items.splice(i, 1)
}
}
return items
}
private static emojiRegex = /[\p{Extended_Pictographic}🛰]$/u
private static emojiRegex = /[\p{Extended_Pictographic}🛰]/u
/**
* Returns 'true' if the given string contains at least one and only emoji characters
*
* Utils.isEmoji("⛰\uFE0F") // => true
* Utils.isEmoji("🇧🇪") // => true
* Utils.isEmoji("🍕") // => true
*/
public static isEmoji(string: string) {
return Utils.emojiRegex.test(string)
return Utils.emojiRegex.test(string) ||
/[🇦-🇿]{2}/u.test(string) // flags, see https://stackoverflow.com/questions/53360006/detect-with-regex-if-emoji-is-country-flag
}
}

View file

@ -382,6 +382,17 @@ h2.group {
background-color: #58cd2722;
}
.badge {
display: flex;
align-items: center;
white-space: nowrap;
border-radius: 999rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
border: 1px solid var(--subtle-detail-color-light-contrast);
background-color: var(--low-interaction-background);
}
.alert {
/* The class to convey important information, e.g. 'invalid', 'something went wrong', 'warning: testmode', ... */
background-color: var(--alert-color);