Logic: better support for tag optimization and simplifying expressions

This commit is contained in:
Pieter Vander Vennet 2025-01-09 20:39:21 +01:00
parent bd228a6129
commit a3d26db84a
11 changed files with 430 additions and 260 deletions

View file

@ -1,9 +1,14 @@
import Script from "./Script" import Script from "./Script"
import NameSuggestionIndex, { NSIItem } from "../src/Logic/Web/NameSuggestionIndex" import NameSuggestionIndex, { NSIItem } from "../src/Logic/Web/NameSuggestionIndex"
import * as nsiWD from "../node_modules/name-suggestion-index/dist/wikidata.min.json" import * as nsiWD from "../node_modules/name-suggestion-index/dist/wikidata.min.json"
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs" import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"
import ScriptUtils from "./ScriptUtils" import ScriptUtils from "./ScriptUtils"
import { Utils } from "../src/Utils" import { Utils } from "../src/Utils"
import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson"
import FilterConfigJson, { FilterConfigOptionJson } from "../src/Models/ThemeConfig/Json/FilterConfigJson"
import { TagConfigJson } from "../src/Models/ThemeConfig/Json/TagConfigJson"
import { TagUtils } from "../src/Logic/Tags/TagUtils"
import { And } from "../src/Logic/Tags/And"
class DownloadNsiLogos extends Script { class DownloadNsiLogos extends Script {
constructor() { constructor() {
@ -43,7 +48,7 @@ class DownloadNsiLogos extends Script {
await ScriptUtils.DownloadFileTo(logos.facebook, path) await ScriptUtils.DownloadFileTo(logos.facebook, path)
// Validate // Validate
const content = readFileSync(path, "utf8") const content = readFileSync(path, "utf8")
if (content.startsWith('{"error"')) { if (content.startsWith("{\"error\"")) {
unlinkSync(path) unlinkSync(path)
console.error("Attempted to fetch", logos.facebook, " but this gave an error") console.error("Attempted to fetch", logos.facebook, " but this gave an error")
} else { } else {
@ -86,12 +91,8 @@ class DownloadNsiLogos extends Script {
return false return false
} }
async main(): Promise<void> {
await this.downloadFor("operator")
await this.downloadFor("brand")
}
async downloadFor(type: "brand" | "operator"): Promise<void> { async downloadFor(type: string): Promise<void> {
const nsi = await NameSuggestionIndex.getNsiIndex() const nsi = await NameSuggestionIndex.getNsiIndex()
const items = nsi.allPossible(type) const items = nsi.allPossible(type)
const basePath = "./public/assets/data/nsi/logos/" const basePath = "./public/assets/data/nsi/logos/"
@ -109,7 +110,7 @@ class DownloadNsiLogos extends Script {
downloadCount++ downloadCount++
} }
return downloaded return downloaded
}) }),
) )
for (let j = 0; j < results.length; j++) { for (let j = 0; j < results.length; j++) {
let didDownload = results[j] let didDownload = results[j]
@ -124,6 +125,63 @@ class DownloadNsiLogos extends Script {
} }
} }
} }
private async generateRendering(type: string) {
const nsi = await NameSuggestionIndex.getNsiIndex()
const items = nsi.allPossible(type)
const brandPrefix = [type, "name", "alt_name", "operator","brand"]
const filterOptions: FilterConfigOptionJson[] = items.map(item => {
let brandDetection: string[] = []
let required: string[] = []
const tags: Record<string, string> = item.tags
for (const k in tags) {
if (brandPrefix.some(br => k === br || k.startsWith(br + ":"))) {
brandDetection.push(k + "=" + tags[k])
} else {
required.push(k + "=" + tags[k])
}
}
const osmTags = <TagConfigJson>TagUtils.optimzeJson({ and: [...required, { or: brandDetection }] })
return ({
question: item.displayName,
icon: nsi.getIconUrl(item, type),
osmTags,
})
})
const config: LayerConfigJson = {
"#dont-translate": "*",
id: "nsi_" + type,
source: "special:library",
description: {
en: "Exposes part of the NSI to reuse in other themes, e.g. for rendering",
},
pointRendering: null,
filter: [
<any> {
id: type,
strict: true,
options: [{question: type}, ...filterOptions],
},
],
allowMove: false,
}
const path = "./assets/layers/nsi_" + type
mkdirSync(path, { recursive: true })
writeFileSync(path + "/nsi_" + type + ".json", JSON.stringify(config, null, " "))
console.log("Written", path)
}
async main(): Promise<void> {
const nsi = await NameSuggestionIndex.getNsiIndex()
const types = ["brand", "operator"]
for (const type of types) {
await this.generateRendering(type)
// await this.downloadFor(type)
}
}
} }
new DownloadNsiLogos().run() new DownloadNsiLogos().run()

View file

@ -130,39 +130,38 @@ export class And extends TagsFilter {
* t1.shadows(t2) // => false * t1.shadows(t2) // => false
* t2.shadows(t0) // => false * t2.shadows(t0) // => false
* t2.shadows(t1) // => false * t2.shadows(t1) // => false
*
*
* const t1 = new And([new Tag("shop","clothes"), new Or([new Tag("brand","XYZ"),new Tag("brand:wikidata","Q1234")])])
* const t2 = new And([new RegexTag("shop","mall",true), new Or([TagUtils.Tag("shop~*"), new Tag("craft","shoemaker")])])
* t1.shadows(t2) // => true
*/ */
shadows(other: TagsFilter): boolean { shadows(other: TagsFilter): boolean {
if (!(other instanceof And)) { const phrases: TagsFilter[] = other instanceof And ? other.and : [other];
return false // A phrase might be shadowed by a certain subsection. We keep track of this here
} const shadowedOthers = phrases.map(() => false)
for (const selfTag of this.and) { for (const selfTag of this.and) {
let matchFound = false let shadowsSome = false;
for (const otherTag of other.and) { let shadowsAll = true;
matchFound = selfTag.shadows(otherTag) for (let i = 0; i < phrases.length; i++){
if (matchFound) { const otherTag = phrases[i]
break const doesShadow = selfTag.shadows(otherTag)
if(doesShadow){
shadowedOthers[i] = true;
} }
shadowsSome ||= doesShadow;
shadowsAll &&= doesShadow;
} }
if (!matchFound) { // If A => X and A => Y, then
return false // A&B implies X&Y. We discovered an A that implies all needed values
if (shadowsAll) {
return true;
}
if (!shadowsSome) {
return false;
} }
} }
return !shadowedOthers.some(v => !v);
for (const otherTag of other.and) {
let matchFound = false
for (const selfTag of this.and) {
matchFound = selfTag.shadows(otherTag)
if (matchFound) {
break
}
}
if (!matchFound) {
return false
}
}
return true
} }
usedKeys(): string[] { usedKeys(): string[] {
@ -182,11 +181,13 @@ export class And extends TagsFilter {
} }
/** /**
* IN some contexts, some expressions can be considered true, e.g. * In some contexts, some expressions can be considered true, e.g.
* (X=Y | (A=B & X=Y)) * (X=Y | (A=B & X=Y))
* ^---------^ * ^---------^
* When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise. * When the evaluation hits (A=B & X=Y), we know _for sure_ that X=Y does _not_ match, as it would have matched the first clause otherwise.
* This means that the entire 'AND' is considered FALSE * This means that the entire 'AND' is considered FALSE in this case; but this is already handled by the first half.
* In other words: this long expression is equivalent to (A=B | X=Y).
*
* *
* @return only phrases that should be kept. * @return only phrases that should be kept.
* @param knownExpression The expression which is known in the subexpression and for which calculations can be done * @param knownExpression The expression which is known in the subexpression and for which calculations can be done
@ -204,13 +205,14 @@ export class And extends TagsFilter {
* const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} ) * const expr = <And> TagUtils.Tag({and: ["sport=climbing", {or:["club~*", "office~*"]}]} )
* expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr * expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr
*/ */
removePhraseConsideredKnown( public removePhraseConsideredKnown(
knownExpression: TagsFilter, knownExpression: TagsFilter,
value: boolean value: boolean
): (TagsFilterClosed & OptimizedTag) | boolean { ): (TagsFilterClosed & OptimizedTag) | boolean {
const newAnds: TagsFilter[] = [] const newAnds: TagsFilter[] = []
for (const tag of this.and) { for (const tag of this.and) {
if (tag instanceof And) { if (tag instanceof And) {
console.trace("Improper optimization")
throw ( throw (
"Optimize expressions before using removePhraseConsideredKnown. Found an AND in an AND: " + "Optimize expressions before using removePhraseConsideredKnown. Found an AND in an AND: " +
this.asHumanString() this.asHumanString()

View file

@ -83,6 +83,7 @@ export class Or extends TagsFilter {
return false return false
} }
shadows(other: TagsFilter): boolean { shadows(other: TagsFilter): boolean {
if (other instanceof Or) { if (other instanceof Or) {
for (const selfTag of this.or) { for (const selfTag of this.or) {

View file

@ -4,6 +4,8 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { ExpressionSpecification } from "maplibre-gl" import { ExpressionSpecification } from "maplibre-gl"
import { RegexTag } from "./RegexTag" import { RegexTag } from "./RegexTag"
import { OptimizedTag } from "./TagTypes" import { OptimizedTag } from "./TagTypes"
import { Or } from "./Or"
import { And } from "./And"
export class Tag extends TagsFilter { export class Tag extends TagsFilter {
public key: string public key: string
@ -148,6 +150,12 @@ export class Tag extends TagsFilter {
return other.matchesProperties({ [this.key]: this.value }) return other.matchesProperties({ [this.key]: this.value })
} }
} }
if(other instanceof Or){
return other.or.some(other => this.shadows(other))
}
if(other instanceof And){
return !other.and.some(other => !this.shadows(other))
}
return false return false
} }

View file

@ -133,11 +133,11 @@ export class TagUtils {
"\n" + "\n" +
"```json\n" + "```json\n" +
"{\n" + "{\n" +
' "mappings": [\n' + " \"mappings\": [\n" +
" {\n" + " {\n" +
' "if":"key:={some_other_key}",\n' + " \"if\":\"key:={some_other_key}\",\n" +
' "then": "...",\n' + " \"then\": \"...\",\n" +
' "hideInAnswer": "some_other_key="\n' + " \"hideInAnswer\": \"some_other_key=\"\n" +
" }\n" + " }\n" +
" ]\n" + " ]\n" +
"}\n" + "}\n" +
@ -175,10 +175,10 @@ export class TagUtils {
"\n" + "\n" +
"```json\n" + "```json\n" +
"{\n" + "{\n" +
' "osmTags": {\n' + " \"osmTags\": {\n" +
' "or": [\n' + " \"or\": [\n" +
' "amenity=school",\n' + " \"amenity=school\",\n" +
' "amenity=kindergarten"\n' + " \"amenity=kindergarten\"\n" +
" ]\n" + " ]\n" +
" }\n" + " }\n" +
"}\n" + "}\n" +
@ -194,7 +194,7 @@ export class TagUtils {
"If the schema-files note a type [`TagConfigJson`](https://github.com/pietervdvn/MapComplete/blob/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" + "If the schema-files note a type [`TagConfigJson`](https://github.com/pietervdvn/MapComplete/blob/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" +
"\n" + "\n" +
"In some cases, not every type of tags-filter can be used. For example, _rendering_ an option with a regex is\n" + "In some cases, not every type of tags-filter can be used. For example, _rendering_ an option with a regex is\n" +
'fine (`"if": "brand~[Bb]randname", "then":" The brand is Brandname"`); but this regex can not be used to write a value\n' + "fine (`\"if\": \"brand~[Bb]randname\", \"then\":\" The brand is Brandname\"`); but this regex can not be used to write a value\n" +
"into the database. The theme loader will however refuse to work with such inconsistencies and notify you of this while\n" + "into the database. The theme loader will however refuse to work with such inconsistencies and notify you of this while\n" +
"you are building your theme.\n" + "you are building your theme.\n" +
"\n" + "\n" +
@ -205,18 +205,18 @@ export class TagUtils {
"\n" + "\n" +
"```json\n" + "```json\n" +
"{\n" + "{\n" +
' "and": [\n' + " \"and\": [\n" +
' "key=value",\n' + " \"key=value\",\n" +
" {\n" + " {\n" +
' "or": [\n' + " \"or\": [\n" +
' "other_key=value",\n' + " \"other_key=value\",\n" +
' "other_key=some_other_value"\n' + " \"other_key=some_other_value\"\n" +
" ]\n" + " ]\n" +
" },\n" + " },\n" +
' "key_which_should_be_missing=",\n' + " \"key_which_should_be_missing=\",\n" +
' "key_which_should_have_a_value~*",\n' + " \"key_which_should_have_a_value~*\",\n" +
' "key~.*some_regex_a*_b+_[a-z]?",\n' + " \"key~.*some_regex_a*_b+_[a-z]?\",\n" +
' "height<1"\n' + " \"height<1\"\n" +
" ]\n" + " ]\n" +
"}\n" + "}\n" +
"```\n" + "```\n" +
@ -246,7 +246,7 @@ export class TagUtils {
static asProperties( static asProperties(
tags: TagsFilter | TagsFilter[], tags: TagsFilter | TagsFilter[],
baseproperties: Record<string, string> = {} baseproperties: Record<string, string> = {},
) { ) {
if (Array.isArray(tags)) { if (Array.isArray(tags)) {
tags = new And(tags) tags = new And(tags)
@ -274,11 +274,11 @@ export class TagUtils {
static SplitKeysRegex(tagsFilters: UploadableTag[], allowRegex: false): Record<string, string[]> static SplitKeysRegex(tagsFilters: UploadableTag[], allowRegex: false): Record<string, string[]>
static SplitKeysRegex( static SplitKeysRegex(
tagsFilters: UploadableTag[], tagsFilters: UploadableTag[],
allowRegex: boolean allowRegex: boolean,
): Record<string, (string | RegexTag)[]> ): Record<string, (string | RegexTag)[]>
static SplitKeysRegex( static SplitKeysRegex(
tagsFilters: UploadableTag[], tagsFilters: UploadableTag[],
allowRegex: boolean allowRegex: boolean,
): Record<string, (string | RegexTag)[]> { ): Record<string, (string | RegexTag)[]> {
const keyValues: Record<string, (string | RegexTag)[]> = {} const keyValues: Record<string, (string | RegexTag)[]> = {}
tagsFilters = [...tagsFilters] // copy all, use as queue tagsFilters = [...tagsFilters] // copy all, use as queue
@ -307,7 +307,7 @@ export class TagUtils {
if (typeof key !== "string") { if (typeof key !== "string") {
console.error( console.error(
"Invalid type to flatten the multiAnswer: key is a regex too", "Invalid type to flatten the multiAnswer: key is a regex too",
tagsFilter tagsFilter,
) )
throw "Invalid type to FlattenMultiAnswer: key is a regex too" throw "Invalid type to FlattenMultiAnswer: key is a regex too"
} }
@ -508,7 +508,7 @@ export class TagUtils {
public static Tag(json: TagConfigJson, context?: string | ConversionContext): TagsFilterClosed public static Tag(json: TagConfigJson, context?: string | ConversionContext): TagsFilterClosed
public static Tag( public static Tag(
json: TagConfigJson, json: TagConfigJson,
context: string | ConversionContext = "" context: string | ConversionContext = "",
): TagsFilterClosed { ): TagsFilterClosed {
try { try {
const ctx = typeof context === "string" ? context : context.path.join(".") const ctx = typeof context === "string" ? context : context.path.join(".")
@ -540,7 +540,7 @@ export class TagUtils {
throw `Error at ${context}: detected a non-uploadable tag at a location where this is not supported: ${t.asHumanString( throw `Error at ${context}: detected a non-uploadable tag at a location where this is not supported: ${t.asHumanString(
false, false,
false, false,
{} {},
)}` )}`
}) })
@ -661,7 +661,7 @@ export class TagUtils {
*/ */
public static removeShadowedElementsFrom( public static removeShadowedElementsFrom(
blacklist: TagsFilter[], blacklist: TagsFilter[],
listToFilter: TagsFilter[] listToFilter: TagsFilter[],
): TagsFilter[] { ): TagsFilter[] {
return listToFilter.filter((tf) => !blacklist.some((guard) => guard.shadows(tf))) return listToFilter.filter((tf) => !blacklist.some((guard) => guard.shadows(tf)))
} }
@ -699,7 +699,7 @@ export class TagUtils {
*/ */
public static containsEquivalents( public static containsEquivalents(
guards: ReadonlyArray<TagsFilter>, guards: ReadonlyArray<TagsFilter>,
listToFilter: ReadonlyArray<TagsFilter> listToFilter: ReadonlyArray<TagsFilter>,
): boolean { ): boolean {
return listToFilter.some((tf) => guards.some((guard) => guard.shadows(tf))) return listToFilter.some((tf) => guards.some((guard) => guard.shadows(tf)))
} }
@ -743,7 +743,7 @@ export class TagUtils {
values.push(i + "") values.push(i + "")
} }
return values return values
}) }),
) )
return Utils.NoNull(spec) return Utils.NoNull(spec)
} }
@ -751,13 +751,13 @@ export class TagUtils {
private static ParseTagUnsafe(json: TagConfigJson, context: string = ""): TagsFilterClosed { private static ParseTagUnsafe(json: TagConfigJson, context: string = ""): TagsFilterClosed {
if (json === undefined) { if (json === undefined) {
throw new Error( throw new Error(
`Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression` `Error while parsing a tag: 'json' is undefined in ${context}. Make sure all the tags are defined and at least one tag is present in a complex expression`,
) )
} }
if (typeof json != "string") { if (typeof json != "string") {
if (json["and"] !== undefined && json["or"] !== undefined) { if (json["and"] !== undefined && json["or"] !== undefined) {
throw `${context}: Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of "parent": {...}\` to trigger a replacement and not a fuse of values. The value is ${JSON.stringify( throw `${context}: Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of "parent": {...}\` to trigger a replacement and not a fuse of values. The value is ${JSON.stringify(
json json,
)}` )}`
} }
if (json["and"] !== undefined) { if (json["and"] !== undefined) {
@ -839,13 +839,13 @@ export class TagUtils {
return new RegexTag( return new RegexTag(
withRegex.key, withRegex.key,
new RegExp(".+", "si" + withRegex.modifier), new RegExp(".+", "si" + withRegex.modifier),
withRegex.invert withRegex.invert,
) )
} }
return new RegexTag( return new RegexTag(
withRegex.key, withRegex.key,
new RegExp("^(" + value + ")$", "s" + withRegex.modifier), new RegExp("^(" + value + ")$", "s" + withRegex.modifier),
withRegex.invert withRegex.invert,
) )
} }
@ -967,10 +967,19 @@ export class TagUtils {
return ["", "## `" + mode + "` " + doc.name, "", doc.docs, "", ""].join("\n") return ["", "## `" + mode + "` " + doc.name, "", doc.docs, "", ""].join("\n")
}), }),
"## " + "## " +
TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") + TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") +
" Logical comparators", " Logical comparators",
TagUtils.numberAndDateComparisonDocs, TagUtils.numberAndDateComparisonDocs,
TagUtils.logicalOperator, TagUtils.logicalOperator,
].join("\n") ].join("\n")
} }
static fromProperties(tags: Record<string, string>): TagConfigJson | boolean {
const opt = new And(Object.keys(tags).map(k => new Tag(k, tags[k]))).optimize()
if (opt === true || opt === false) {
return opt
}
return opt.asJson()
}
} }

View file

@ -9,7 +9,8 @@ export abstract class TagsFilter {
/** /**
* Indicates some form of equivalency: * Indicates some form of equivalency:
* if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties * if `this.shadows(t)`, then `this.matches(properties)` implies that `t.matches(properties)` for all possible properties.
* In other words: 'this' is a _stronger_ condition then 't'
*/ */
abstract shadows(other: TagsFilter): boolean abstract shadows(other: TagsFilter): boolean

View file

@ -41,14 +41,14 @@ interface NSIEntry {
* Represents a single brand/operator/flagpole/... * Represents a single brand/operator/flagpole/...
*/ */
export interface NSIItem { export interface NSIItem {
displayName: string readonly displayName: string
id: string readonly id: string
locationSet: { locationSet: {
include: string[] include: string[]
exclude: string[] exclude: string[]
} }
tags: Record<string, string> readonly tags: Readonly<Record<string, string>>
fromTemplate?: boolean readonly fromTemplate?: boolean
} }
export default class NameSuggestionIndex { export default class NameSuggestionIndex {
@ -77,7 +77,7 @@ export default class NameSuggestionIndex {
} }
> >
>, >,
features: Readonly<FeatureCollection> features: Readonly<FeatureCollection>,
) { ) {
this.nsiFile = nsiFile this.nsiFile = nsiFile
this.nsiWdFile = nsiWdFile this.nsiWdFile = nsiWdFile
@ -92,10 +92,10 @@ export default class NameSuggestionIndex {
} }
const [nsi, nsiWd, features] = await Promise.all( const [nsi, nsiWd, features] = await Promise.all(
["./assets/data/nsi/nsi.min.json", "./assets/data/nsi/wikidata.min.json", "./assets/data/nsi/featureCollection.min.json"].map((url) => ["./assets/data/nsi/nsi.min.json", "./assets/data/nsi/wikidata.min.json", "./assets/data/nsi/featureCollection.min.json"].map((url) =>
Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30) Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30),
) ),
) )
NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"], <any> features) NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"], <any>features)
return NameSuggestionIndex.inited return NameSuggestionIndex.inited
} }
@ -126,13 +126,13 @@ export default class NameSuggestionIndex {
try { try {
return Utils.downloadJsonCached<Record<string, number>>( return Utils.downloadJsonCached<Record<string, number>>(
`./assets/data/nsi/stats/${type}.${c.toUpperCase()}.json`, `./assets/data/nsi/stats/${type}.${c.toUpperCase()}.json`,
24 * 60 * 60 * 1000 24 * 60 * 60 * 1000,
) )
} catch (e) { } catch (e) {
console.error("Could not fetch " + type + " statistics due to", e) console.error("Could not fetch " + type + " statistics due to", e)
return undefined return undefined
} }
}) }),
) )
stats = Utils.NoNull(stats) stats = Utils.NoNull(stats)
if (stats.length === 1) { if (stats.length === 1) {
@ -173,17 +173,17 @@ export default class NameSuggestionIndex {
public async generateMappings( public async generateMappings(
type: string, type: string,
tags: Record<string, string>, tags: Record<string, string>,
country: string[], country?: string[],
location?: [number, number], location?: [number, number],
options?: { options?: {
/** /**
* If set, sort by frequency instead of alphabetically * If set, sort by frequency instead of alphabetically
*/ */
sortByFrequency: boolean sortByFrequency: boolean
} },
): Promise<Mapping[]> { ): Promise<Mapping[]> {
const mappings: (Mapping & { frequency: number })[] = [] const mappings: (Mapping & { frequency: number })[] = []
const frequencies = await NameSuggestionIndex.fetchFrequenciesFor(type, country) const frequencies = country !== undefined ? await NameSuggestionIndex.fetchFrequenciesFor(type, country) : {}
for (const key in tags) { for (const key in tags) {
if (key.startsWith("_")) { if (key.startsWith("_")) {
continue continue
@ -194,7 +194,7 @@ export default class NameSuggestionIndex {
key, key,
value, value,
country.join(";"), country.join(";"),
location location,
) )
if (!actualBrands) { if (!actualBrands) {
continue continue
@ -202,8 +202,7 @@ export default class NameSuggestionIndex {
for (const nsiItem of actualBrands) { for (const nsiItem of actualBrands) {
const tags = nsiItem.tags const tags = nsiItem.tags
const frequency = frequencies[nsiItem.displayName] const frequency = frequencies[nsiItem.displayName]
const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos const iconUrl = this.getIconExternalUrl(nsiItem, type)
const iconUrl = logos?.facebook ?? logos?.wikidata
const hasIcon = iconUrl !== undefined const hasIcon = iconUrl !== undefined
let icon = undefined let icon = undefined
if (hasIcon) { if (hasIcon) {
@ -240,7 +239,7 @@ export default class NameSuggestionIndex {
} }
public supportedTags( public supportedTags(
type: "operator" | "brand" | "flag" | "transit" | string type: "operator" | "brand" | "flag" | "transit" | string,
): Record<string, string[]> { ): Record<string, string[]> {
const tags: Record<string, string[]> = {} const tags: Record<string, string[]> = {}
const keys = Object.keys(this.nsiFile.nsi) const keys = Object.keys(this.nsiFile.nsi)
@ -263,7 +262,7 @@ export default class NameSuggestionIndex {
* Returns a list of all brands/operators * Returns a list of all brands/operators
* @param type * @param type
*/ */
public allPossible(type: "brand" | "operator"): NSIItem[] { public allPossible(type: string): NSIItem[] {
const options: NSIItem[] = [] const options: NSIItem[] = []
const tags = this.supportedTags(type) const tags = this.supportedTags(type)
for (const osmKey in tags) { for (const osmKey in tags) {
@ -285,10 +284,10 @@ export default class NameSuggestionIndex {
type: string, type: string,
tags: { key: string; value: string }[], tags: { key: string; value: string }[],
country: string = undefined, country: string = undefined,
location: [number, number] = undefined location: [number, number] = undefined,
): NSIItem[] { ): NSIItem[] {
return tags.flatMap((tag) => return tags.flatMap((tag) =>
this.getSuggestionsForKV(type, tag.key, tag.value, country, location) this.getSuggestionsForKV(type, tag.key, tag.value, country, location),
) )
} }
@ -311,7 +310,7 @@ export default class NameSuggestionIndex {
key: string, key: string,
value: string, value: string,
country: string = undefined, country: string = undefined,
location: [number, number] = undefined location: [number, number] = undefined,
): NSIItem[] { ): NSIItem[] {
const path = `${type}s/${key}/${value}` const path = `${type}s/${key}/${value}`
const entry = this.nsiFile.nsi[path] const entry = this.nsiFile.nsi[path]
@ -375,9 +374,29 @@ export default class NameSuggestionIndex {
center: [number, number], center: [number, number],
options: { options: {
sortByFrequency: boolean sortByFrequency: boolean
} },
): Promise<Mapping[]> { ): Promise<Mapping[]> {
const nsi = await NameSuggestionIndex.getNsiIndex() const nsi = await NameSuggestionIndex.getNsiIndex()
return nsi.generateMappings(key, tags, country, center, options) return nsi.generateMappings(key, tags, country, center, options)
} }
/**
* Where can we find the URL on the world wide web?
* Probably facebook! Don't use in the website, might expose people
* @param nsiItem
* @param type
*/
private getIconExternalUrl(nsiItem: NSIItem, type: string): string {
const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos
return logos?.facebook ?? logos?.wikidata
}
public getIconUrl(nsiItem: NSIItem, type: string) {
let icon = "./assets/data/nsi/logos/" + nsiItem.id
if (this.isSvg(nsiItem, type)) {
icon = icon + ".svg"
}
return icon
}
} }

View file

@ -0,0 +1,220 @@
import { DesugaringContext, DesugaringStep } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import FilterConfigJson, { FilterConfigOptionJson } from "../Json/FilterConfigJson"
import predifined_filters from "../../../../assets/layers/filters/filters.json"
import { TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
import { ConversionContext } from "./ConversionContext"
import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRenderingConfigJson"
import { Utils } from "../../../Utils"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { Tag } from "../../../Logic/Tags/Tag"
import { RegexTag } from "../../../Logic/Tags/RegexTag"
import { Or } from "../../../Logic/Tags/Or"
import Translations from "../../../UI/i18n/Translations"
import { FlatTag, OptimizedTag, TagsFilterClosed } from "../../../Logic/Tags/TagTypes"
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import { And } from "../../../Logic/Tags/And"
export class PruneFilters extends DesugaringStep<LayerConfigJson>{
constructor() {
super("Removes all filters which are impossible, e.g. because they conflict with the base tags", ["filter"],"PruneFilters")
}
private prune(sourceTags:FlatTag, filter: FilterConfigJson, context: ConversionContext): FilterConfigJson{
if(!filter.strict){
return filter
}
const countBefore = filter.options.length
const newOptions: FilterConfigOptionJson[] = filter.options.filter(option => {
if(!option.osmTags){
return true
}
const condition = <OptimizedTag & TagsFilterClosed> TagUtils.Tag(option.osmTags).optimize()
return condition.shadows(sourceTags);
}).map(option => {
if(!option.osmTags){
return option
}
let basetags: TagsFilter = <TagsFilter> And.construct([TagUtils.Tag(option.osmTags)]).optimize()
if(basetags instanceof And){
basetags = <TagsFilter> basetags.removePhraseConsideredKnown(sourceTags, true)
}
return {...option, osmTags: basetags.asJson()}
})
const countAfter = newOptions.length
if(countAfter !== countBefore){
context.enters("filter", filter.id ).info("Pruned "+(countBefore-countAfter)+" options away from filter (out of "+countBefore+")")
}
return {...filter, options: newOptions, strict: undefined}
}
public convert(json: LayerConfigJson, context: ConversionContext): LayerConfigJson {
if(!Array.isArray(json.filter) || typeof json.source === "string"){
return json
}
if(!json.source["osmTags"]){
return json
}
const sourceTags = TagUtils.Tag(json.source["osmTags"])
return {...json, filter: json.filter?.map(obj => this.prune(sourceTags, <FilterConfigJson> obj, context))}
}
}
export class ExpandFilter extends DesugaringStep<LayerConfigJson> {
private static readonly predefinedFilters = ExpandFilter.load_filters()
private _state: DesugaringContext
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. Note that pruning should still be done",
].join(" "),
["filter"],
"ExpandFilter",
)
this._state = state
}
private static load_filters(): Map<string, FilterConfigJson> {
const filters = new Map<string, FilterConfigJson>()
for (const filter of <FilterConfigJson[]>predifined_filters.filter) {
filters.set(filter.id, filter)
}
return filters
}
public 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 qtr = <QuestionableTagRenderingConfigJson>tr
const options = qtr.mappings.map((mapping) => {
let icon: string = mapping.icon?.["path"] ?? mapping.icon
let emoji: string = undefined
if (Utils.isEmoji(icon)) {
emoji = icon
icon = undefined
}
let osmTags = TagUtils.Tag(mapping.if)
if (qtr.multiAnswer && osmTags instanceof Tag) {
osmTags = new RegexTag(
osmTags.key,
new RegExp("^(.+;)?" + osmTags.value + "(;.+)$", "is"),
)
}
if (mapping.alsoShowIf) {
osmTags = new Or([osmTags, TagUtils.Tag(mapping.alsoShowIf)])
}
return <FilterConfigOptionJson>{
question: mapping.then,
osmTags: osmTags.asJson(),
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
}
if (json.filter["sameAs"] !== undefined) {
return json // Nothing to change here
}
const newFilters: FilterConfigJson[] = []
const filters = <(FilterConfigJson | string)[]>json.filter
/**
* 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]
if (filter === undefined) {
continue
}
if (typeof filter !== "string") {
newFilters.push(filter)
continue
}
const matchingTr = <TagRenderingConfigJson>(
json.tagRenderings.find((tr) => !!tr && tr["id"] === filter)
)
if (matchingTr) {
const filter = ExpandFilter.buildFilterFromTagRendering(
matchingTr,
context.enters("filter", i),
)
newFilters.push(filter)
continue
}
if (filter.indexOf(".") > 0) {
if (!(this._state.sharedLayers?.size > 0)) {
// This is a bootstrapping-run, we can safely ignore this
continue
}
const split = filter.split(".")
if (split.length > 2) {
context.err(
"invalid filter name: " + filter + ", expected `layername.filterid`",
)
}
const layer = this._state.sharedLayers.get(split[0])
if (layer === undefined) {
context.err("Layer '" + split[0] + "' not found")
}
const expectedId = split[1]
const expandedFilter = (<(FilterConfigJson | string)[]>layer.filter).find(
(f) => typeof f !== "string" && f.id === expectedId,
)
if (expandedFilter === undefined) {
context.err("Did not find filter with name " + filter)
} else {
newFilters.push(<FilterConfigJson>expandedFilter)
}
continue
}
// Search for the filter:
const found = ExpandFilter.predefinedFilters.get(filter)
if (found === undefined) {
const suggestions = Utils.sortedByLevenshteinDistance(
filter,
Array.from(ExpandFilter.predefinedFilters.keys()),
(t) => t,
)
context
.enter(filter)
.err(
"While searching for predefined filter " +
filter +
": this filter is not found. Perhaps you meant one of: " +
suggestions,
)
}
newFilters.push(found)
}
return { ...json, filter: newFilters }
}
}

View file

@ -10,10 +10,7 @@ import {
SetDefault, SetDefault,
} from "./Conversion" } from "./Conversion"
import { LayerConfigJson } from "../Json/LayerConfigJson" import { LayerConfigJson } from "../Json/LayerConfigJson"
import { import { MinimalTagRenderingConfigJson, TagRenderingConfigJson } from "../Json/TagRenderingConfigJson"
MinimalTagRenderingConfigJson,
TagRenderingConfigJson,
} from "../Json/TagRenderingConfigJson"
import { Utils } from "../../../Utils" import { Utils } from "../../../Utils"
import RewritableConfigJson from "../Json/RewritableConfigJson" import RewritableConfigJson from "../Json/RewritableConfigJson"
import SpecialVisualizations from "../../../UI/SpecialVisualizations" import SpecialVisualizations from "../../../UI/SpecialVisualizations"
@ -21,8 +18,7 @@ import Translations from "../../../UI/i18n/Translations"
import { Translation } from "../../../UI/i18n/Translation" import { Translation } from "../../../UI/i18n/Translation"
import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json" import tagrenderingconfigmeta from "../../../../src/assets/schemas/tagrenderingconfigmeta.json"
import { AddContextToTranslations } from "./AddContextToTranslations" import { AddContextToTranslations } from "./AddContextToTranslations"
import FilterConfigJson, { FilterConfigOptionJson } from "../Json/FilterConfigJson" import FilterConfigJson from "../Json/FilterConfigJson"
import predifined_filters from "../../../../assets/layers/filters/filters.json"
import { TagConfigJson } from "../Json/TagConfigJson" import { TagConfigJson } from "../Json/TagConfigJson"
import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson" import PointRenderingConfigJson, { IconConfigJson } from "../Json/PointRenderingConfigJson"
import ValidationUtils from "./ValidationUtils" import ValidationUtils from "./ValidationUtils"
@ -33,9 +29,7 @@ import LineRenderingConfigJson from "../Json/LineRenderingConfigJson"
import { ConversionContext } from "./ConversionContext" import { ConversionContext } from "./ConversionContext"
import { ExpandRewrite } from "./ExpandRewrite" import { ExpandRewrite } from "./ExpandRewrite"
import { TagUtils } from "../../../Logic/Tags/TagUtils" import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { Tag } from "../../../Logic/Tags/Tag" import { ExpandFilter, PruneFilters } from "./ExpandFilter"
import { RegexTag } from "../../../Logic/Tags/RegexTag"
import { Or } from "../../../Logic/Tags/Or"
class AddFiltersFromTagRenderings extends DesugaringStep<LayerConfigJson> { class AddFiltersFromTagRenderings extends DesugaringStep<LayerConfigJson> {
constructor() { constructor() {
@ -108,163 +102,6 @@ class AddFiltersFromTagRenderings extends DesugaringStep<LayerConfigJson> {
return { ...json, filter: filters } return { ...json, filter: filters }
} }
} }
class ExpandFilter extends DesugaringStep<LayerConfigJson> {
private static readonly predefinedFilters = ExpandFilter.load_filters()
private _state: DesugaringContext
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.",
].join(" "),
["filter"],
"ExpandFilter"
)
this._state = state
}
private static load_filters(): Map<string, FilterConfigJson> {
const filters = new Map<string, FilterConfigJson>()
for (const filter of <FilterConfigJson[]>predifined_filters.filter) {
filters.set(filter.id, filter)
}
return filters
}
public 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 qtr = <QuestionableTagRenderingConfigJson>tr
const options = qtr.mappings.map((mapping) => {
let icon: string = mapping.icon?.["path"] ?? mapping.icon
let emoji: string = undefined
if (Utils.isEmoji(icon)) {
emoji = icon
icon = undefined
}
let osmTags = TagUtils.Tag(mapping.if)
if (qtr.multiAnswer && osmTags instanceof Tag) {
osmTags = new RegexTag(
osmTags.key,
new RegExp("^(.+;)?" + osmTags.value + "(;.+)$", "is")
)
}
if (mapping.alsoShowIf) {
osmTags = new Or([osmTags, TagUtils.Tag(mapping.alsoShowIf)])
}
return <FilterConfigOptionJson>{
question: mapping.then,
osmTags: osmTags.asJson(),
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
}
if (json.filter["sameAs"] !== undefined) {
return json // Nothing to change here
}
const newFilters: FilterConfigJson[] = []
const filters = <(FilterConfigJson | string)[]>json.filter
/**
* 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]
if (filter === undefined) {
continue
}
if (typeof filter !== "string") {
newFilters.push(filter)
continue
}
const matchingTr = <TagRenderingConfigJson>(
json.tagRenderings.find((tr) => !!tr && tr["id"] === filter)
)
if (matchingTr) {
const filter = ExpandFilter.buildFilterFromTagRendering(
matchingTr,
context.enters("filter", i)
)
newFilters.push(filter)
continue
}
if (filter.indexOf(".") > 0) {
if (!(this._state.sharedLayers?.size > 0)) {
// This is a bootstrapping-run, we can safely ignore this
continue
}
const split = filter.split(".")
if (split.length > 2) {
context.err(
"invalid filter name: " + filter + ", expected `layername.filterid`"
)
}
const layer = this._state.sharedLayers.get(split[0])
if (layer === undefined) {
context.err("Layer '" + split[0] + "' not found")
}
const expectedId = split[1]
const expandedFilter = (<(FilterConfigJson | string)[]>layer.filter).find(
(f) => typeof f !== "string" && f.id === expectedId
)
if (expandedFilter === undefined) {
context.err("Did not find filter with name " + filter)
} else {
newFilters.push(<FilterConfigJson>expandedFilter)
}
continue
}
// Search for the filter:
const found = ExpandFilter.predefinedFilters.get(filter)
if (found === undefined) {
const suggestions = Utils.sortedByLevenshteinDistance(
filter,
Array.from(ExpandFilter.predefinedFilters.keys()),
(t) => t
)
context
.enter(filter)
.err(
"While searching for predefined filter " +
filter +
": this filter is not found. Perhaps you meant one of: " +
suggestions
)
}
newFilters.push(found)
}
return { ...json, filter: newFilters }
}
}
class ExpandTagRendering extends Conversion< class ExpandTagRendering extends Conversion<
| string | string
@ -1481,7 +1318,8 @@ export class PrepareLayer extends Fuse<LayerConfigJson> {
new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true })) new Concat(new ExpandTagRendering(state, layer, { noHardcodedStrings: true }))
), ),
new AddFiltersFromTagRenderings(), new AddFiltersFromTagRenderings(),
new ExpandFilter(state) new ExpandFilter(state),
new PruneFilters()
) )
} }
} }

View file

@ -20,6 +20,15 @@ export default interface FilterConfigJson {
* An id/name for this filter, used to set the URL parameters * An id/name for this filter, used to set the URL parameters
*/ */
id: string id: string
/**
* If set, the options will be pruned. Only items for which the filter match the layer source will be kept.
*
* For example, we import types of brands from the nsi. This contains a ton of items, e.g.
* [{question: "Brand X", osmTags: {"and": ["shop=clothes", "brand=Brand X]}, {osmTags: {"and": "shop=convenience", ...} ...} ]
* Of course, when making a layer about `shop=clothes`, we'll only want to keep the clothes shops.
* If set to strict and the source is `shop=clothes`, only those options which have shop=clothes will be returned
*/
strict?: boolean
/** /**
* The options for a filter * The options for a filter
* If there are multiple options these will be a list of radio buttons * If there are multiple options these will be a list of radio buttons

View file

@ -601,4 +601,9 @@ export interface LayerConfigJson {
* group: hidden * group: hidden
*/ */
snapName?: Translatable snapName?: Translatable
/**
* group: hidden
*/
"#dont-translate": "*"
} }