forked from MapComplete/MapComplete
Merge develop
This commit is contained in:
commit
baa7379fbf
7880 changed files with 2079327 additions and 39792 deletions
|
|
@ -88,7 +88,9 @@ export default class InitialMapPositioning {
|
|||
return
|
||||
}
|
||||
const targetLayer = layoutToUse.getMatchingLayer(osmObject.tags)
|
||||
this.zoom.setData(Math.max(this.zoom.data, targetLayer.minzoom))
|
||||
if(targetLayer){
|
||||
this.zoom.setData(Math.max(this.zoom.data, targetLayer.minzoom))
|
||||
}
|
||||
const [lat, lon] = osmObject.centerpoint()
|
||||
this.location.setData({ lon, lat })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
|
||||
import ThemeConfig, { MinimalThemeInformation } from "../Models/ThemeConfig/ThemeConfig"
|
||||
import { QueryParameters } from "./Web/QueryParameters"
|
||||
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
|
||||
import { FixedUiElement } from "../UI/Base/FixedUiElement"
|
||||
import { Utils } from "../Utils"
|
||||
import LZString from "lz-string"
|
||||
import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
|
||||
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
|
||||
import known_layers from "../assets/generated/known_layers.json"
|
||||
|
|
@ -15,10 +13,10 @@ import questions from "../assets/generated/layers/questions.json"
|
|||
import { DoesImageExist, PrevalidateTheme } from "../Models/ThemeConfig/Conversion/Validation"
|
||||
import { DesugaringContext } from "../Models/ThemeConfig/Conversion/Conversion"
|
||||
import { TagRenderingConfigJson } from "../Models/ThemeConfig/Json/TagRenderingConfigJson"
|
||||
import Hash from "./Web/Hash"
|
||||
import { QuestionableTagRenderingConfigJson } from "../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
|
||||
import { ThemeConfigJson } from "../Models/ThemeConfig/Json/ThemeConfigJson"
|
||||
import { ValidateThemeAndLayers } from "../Models/ThemeConfig/Conversion/ValidateThemeAndLayers"
|
||||
import * as theme_overview from "../assets/generated/theme_overview.json"
|
||||
|
||||
export default class DetermineTheme {
|
||||
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
|
||||
|
|
@ -87,14 +85,15 @@ export default class DetermineTheme {
|
|||
"The layout to load into MapComplete"
|
||||
).data
|
||||
const id = layoutId?.toLowerCase()
|
||||
const layouts = AllKnownLayouts.allKnownLayouts
|
||||
if (layouts.size() == 0) {
|
||||
const themes: MinimalThemeInformation[] = theme_overview.themes
|
||||
if (themes.length == 0) {
|
||||
throw "Build failed or running, no layouts are known at all"
|
||||
}
|
||||
if (layouts.getConfig(id) === undefined) {
|
||||
const themeInfo = themes.find(th => th.id === id)
|
||||
if (themeInfo === undefined) {
|
||||
const alternatives = Utils.sortedByLevenshteinDistance(
|
||||
id,
|
||||
Array.from(layouts.keys()),
|
||||
themes.map(th => th.id),
|
||||
(i) => i
|
||||
).slice(0, 3)
|
||||
const msg = `No builtin map theme with name ${layoutId} exists. Perhaps you meant one of ${alternatives.join(
|
||||
|
|
@ -102,7 +101,10 @@ export default class DetermineTheme {
|
|||
)}`
|
||||
throw msg
|
||||
}
|
||||
return layouts.get(id)
|
||||
// Actually fetch the theme
|
||||
|
||||
const config = await Utils.downloadJsonCached<ThemeConfigJson>("./assets/generated/themes/"+id+".json", 1000*60*60*60)
|
||||
return new ThemeConfig(config, true)
|
||||
}
|
||||
|
||||
private static getSharedTagRenderings(): Map<string, QuestionableTagRenderingConfigJson> {
|
||||
|
|
@ -152,9 +154,6 @@ export default class DetermineTheme {
|
|||
json = {
|
||||
id: json.id,
|
||||
description: json.description,
|
||||
descriptionTail: {
|
||||
en: "<div class='alert'>Layer only mode.</div> The loaded custom theme actually isn't a custom theme, but only contains a layer.",
|
||||
},
|
||||
icon,
|
||||
title: json.name,
|
||||
layers: [json],
|
||||
|
|
|
|||
|
|
@ -184,9 +184,7 @@ export default class LayerState {
|
|||
", but this layer was not loaded"
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
"Linking filter and isDisplayed-states of " + layer.id + " and " + layer.filterIsSameAs
|
||||
)
|
||||
// We make a shallow copy, reusing the same underlying stores
|
||||
const copy = new FilteredLayer(layer, toReuse.appliedFilters, toReuse.isDisplayed)
|
||||
filteredLayers.set(layer.id, copy)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,14 @@
|
|||
import { Utils } from "../../Utils"
|
||||
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
|
||||
export class ThemeMetaTagging {
|
||||
public static readonly themeName = "usersettings"
|
||||
public static readonly themeName = "usersettings"
|
||||
|
||||
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
|
||||
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
|
||||
feat.properties._description
|
||||
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
|
||||
?.at(1)
|
||||
)
|
||||
Utils.AddLazyProperty(
|
||||
feat.properties,
|
||||
"_d",
|
||||
() => feat.properties._description?.replace(/</g, "<")?.replace(/>/g, ">") ?? ""
|
||||
)
|
||||
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
|
||||
((feat) => {
|
||||
const e = document.createElement("div")
|
||||
e.innerHTML = feat.properties._d
|
||||
return Array.from(e.getElementsByTagName("a")).filter(
|
||||
(a) => a.href.match(/mastodon|en.osm.town/) !== null
|
||||
)[0]?.href
|
||||
})(feat)
|
||||
)
|
||||
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
|
||||
((feat) => {
|
||||
const e = document.createElement("div")
|
||||
e.innerHTML = feat.properties._d
|
||||
return Array.from(e.getElementsByTagName("a")).filter(
|
||||
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
|
||||
)[0]?.href
|
||||
})(feat)
|
||||
)
|
||||
Utils.AddLazyProperty(
|
||||
feat.properties,
|
||||
"_mastodon_candidate",
|
||||
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
|
||||
)
|
||||
feat.properties["__current_backgroun"] = "initial_value"
|
||||
}
|
||||
}
|
||||
public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
|
||||
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
|
||||
Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/</g,'<')?.replace(/>/g,'>') ?? '' )
|
||||
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_a', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.href.match(/mastodon|en.osm.town/) !== null)[0]?.href }) (feat) )
|
||||
Utils.AddLazyProperty(feat.properties, '_mastodon_link', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.getAttribute("rel")?.indexOf('me') >= 0)[0]?.href})(feat) )
|
||||
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate', () => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a )
|
||||
feat.properties['__current_backgroun'] = 'initial_value'
|
||||
}
|
||||
}
|
||||
|
|
@ -130,39 +130,38 @@ export class And extends TagsFilter {
|
|||
* t1.shadows(t2) // => false
|
||||
* t2.shadows(t0) // => 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 {
|
||||
if (!(other instanceof And)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const phrases: TagsFilter[] = other instanceof And ? other.and : [other];
|
||||
// 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) {
|
||||
let matchFound = false
|
||||
for (const otherTag of other.and) {
|
||||
matchFound = selfTag.shadows(otherTag)
|
||||
if (matchFound) {
|
||||
break
|
||||
let shadowsSome = false;
|
||||
let shadowsAll = true;
|
||||
for (let i = 0; i < phrases.length; i++){
|
||||
const otherTag = phrases[i]
|
||||
const doesShadow = selfTag.shadows(otherTag)
|
||||
if(doesShadow){
|
||||
shadowedOthers[i] = true;
|
||||
}
|
||||
shadowsSome ||= doesShadow;
|
||||
shadowsAll &&= doesShadow;
|
||||
}
|
||||
if (!matchFound) {
|
||||
return false
|
||||
// If A => X and A => Y, then
|
||||
// A&B implies X&Y. We discovered an A that implies all needed values
|
||||
if (shadowsAll) {
|
||||
return true;
|
||||
}
|
||||
if (!shadowsSome) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return !shadowedOthers.some(v => !v);
|
||||
}
|
||||
|
||||
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))
|
||||
* ^---------^
|
||||
* 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.
|
||||
* @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~*"]}]} )
|
||||
* expr.removePhraseConsideredKnown(new Tag("club","climbing"), false) // => expr
|
||||
*/
|
||||
removePhraseConsideredKnown(
|
||||
public removePhraseConsideredKnown(
|
||||
knownExpression: TagsFilter,
|
||||
value: boolean
|
||||
): (TagsFilterClosed & OptimizedTag) | boolean {
|
||||
const newAnds: TagsFilter[] = []
|
||||
for (const tag of this.and) {
|
||||
if (tag instanceof And) {
|
||||
console.trace("Improper optimization")
|
||||
throw (
|
||||
"Optimize expressions before using removePhraseConsideredKnown. Found an AND in an AND: " +
|
||||
this.asHumanString()
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export class Or extends TagsFilter {
|
|||
return false
|
||||
}
|
||||
|
||||
|
||||
shadows(other: TagsFilter): boolean {
|
||||
if (other instanceof Or) {
|
||||
for (const selfTag of this.or) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
|
|||
import { ExpressionSpecification } from "maplibre-gl"
|
||||
import { RegexTag } from "./RegexTag"
|
||||
import { OptimizedTag } from "./TagTypes"
|
||||
import { Or } from "./Or"
|
||||
import { And } from "./And"
|
||||
|
||||
export class Tag extends TagsFilter {
|
||||
public key: string
|
||||
|
|
@ -148,6 +150,12 @@ export class Tag extends TagsFilter {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,11 +133,11 @@ export class TagUtils {
|
|||
"\n" +
|
||||
"```json\n" +
|
||||
"{\n" +
|
||||
' "mappings": [\n' +
|
||||
" \"mappings\": [\n" +
|
||||
" {\n" +
|
||||
' "if":"key:={some_other_key}",\n' +
|
||||
' "then": "...",\n' +
|
||||
' "hideInAnswer": "some_other_key="\n' +
|
||||
" \"if\":\"key:={some_other_key}\",\n" +
|
||||
" \"then\": \"...\",\n" +
|
||||
" \"hideInAnswer\": \"some_other_key=\"\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
"}\n" +
|
||||
|
|
@ -175,10 +175,10 @@ export class TagUtils {
|
|||
"\n" +
|
||||
"```json\n" +
|
||||
"{\n" +
|
||||
' "osmTags": {\n' +
|
||||
' "or": [\n' +
|
||||
' "amenity=school",\n' +
|
||||
' "amenity=kindergarten"\n' +
|
||||
" \"osmTags\": {\n" +
|
||||
" \"or\": [\n" +
|
||||
" \"amenity=school\",\n" +
|
||||
" \"amenity=kindergarten\"\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" +
|
||||
"\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" +
|
||||
"you are building your theme.\n" +
|
||||
"\n" +
|
||||
|
|
@ -205,18 +205,18 @@ export class TagUtils {
|
|||
"\n" +
|
||||
"```json\n" +
|
||||
"{\n" +
|
||||
' "and": [\n' +
|
||||
' "key=value",\n' +
|
||||
" \"and\": [\n" +
|
||||
" \"key=value\",\n" +
|
||||
" {\n" +
|
||||
' "or": [\n' +
|
||||
' "other_key=value",\n' +
|
||||
' "other_key=some_other_value"\n' +
|
||||
" \"or\": [\n" +
|
||||
" \"other_key=value\",\n" +
|
||||
" \"other_key=some_other_value\"\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
' "key_which_should_be_missing=",\n' +
|
||||
' "key_which_should_have_a_value~*",\n' +
|
||||
' "key~.*some_regex_a*_b+_[a-z]?",\n' +
|
||||
' "height<1"\n' +
|
||||
" \"key_which_should_be_missing=\",\n" +
|
||||
" \"key_which_should_have_a_value~*\",\n" +
|
||||
" \"key~.*some_regex_a*_b+_[a-z]?\",\n" +
|
||||
" \"height<1\"\n" +
|
||||
" ]\n" +
|
||||
"}\n" +
|
||||
"```\n" +
|
||||
|
|
@ -246,7 +246,7 @@ export class TagUtils {
|
|||
|
||||
static asProperties(
|
||||
tags: TagsFilter | TagsFilter[],
|
||||
baseproperties: Record<string, string> = {}
|
||||
baseproperties: Record<string, string> = {},
|
||||
) {
|
||||
if (Array.isArray(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: boolean
|
||||
allowRegex: boolean,
|
||||
): Record<string, (string | RegexTag)[]>
|
||||
static SplitKeysRegex(
|
||||
tagsFilters: UploadableTag[],
|
||||
allowRegex: boolean
|
||||
allowRegex: boolean,
|
||||
): Record<string, (string | RegexTag)[]> {
|
||||
const keyValues: Record<string, (string | RegexTag)[]> = {}
|
||||
tagsFilters = [...tagsFilters] // copy all, use as queue
|
||||
|
|
@ -307,7 +307,7 @@ export class TagUtils {
|
|||
if (typeof key !== "string") {
|
||||
console.error(
|
||||
"Invalid type to flatten the multiAnswer: key is a regex too",
|
||||
tagsFilter
|
||||
tagsFilter,
|
||||
)
|
||||
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 = ""
|
||||
context: string | ConversionContext = "",
|
||||
): TagsFilterClosed {
|
||||
try {
|
||||
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(
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
{},
|
||||
)}`
|
||||
})
|
||||
|
||||
|
|
@ -661,7 +661,7 @@ export class TagUtils {
|
|||
*/
|
||||
public static removeShadowedElementsFrom(
|
||||
blacklist: TagsFilter[],
|
||||
listToFilter: TagsFilter[]
|
||||
listToFilter: TagsFilter[],
|
||||
): TagsFilter[] {
|
||||
return listToFilter.filter((tf) => !blacklist.some((guard) => guard.shadows(tf)))
|
||||
}
|
||||
|
|
@ -690,6 +690,17 @@ export class TagUtils {
|
|||
return result
|
||||
}
|
||||
|
||||
public static removeKnownParts(tag: TagsFilter, known: TagsFilter, valueOfKnown = true): TagsFilter | boolean{
|
||||
const tagOrBool = And.construct([tag]).optimize()
|
||||
if(tagOrBool === true || tagOrBool === false){
|
||||
return tagOrBool
|
||||
}
|
||||
if(tagOrBool instanceof And){
|
||||
return tagOrBool.removePhraseConsideredKnown(known, valueOfKnown)
|
||||
}
|
||||
return tagOrBool
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if at least one element of the 'guards' shadows one element of the 'listToFilter'.
|
||||
*
|
||||
|
|
@ -699,7 +710,7 @@ export class TagUtils {
|
|||
*/
|
||||
public static containsEquivalents(
|
||||
guards: ReadonlyArray<TagsFilter>,
|
||||
listToFilter: ReadonlyArray<TagsFilter>
|
||||
listToFilter: ReadonlyArray<TagsFilter>,
|
||||
): boolean {
|
||||
return listToFilter.some((tf) => guards.some((guard) => guard.shadows(tf)))
|
||||
}
|
||||
|
|
@ -743,7 +754,7 @@ export class TagUtils {
|
|||
values.push(i + "")
|
||||
}
|
||||
return values
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Utils.NoNull(spec)
|
||||
}
|
||||
|
|
@ -751,13 +762,13 @@ export class TagUtils {
|
|||
private static ParseTagUnsafe(json: TagConfigJson, context: string = ""): TagsFilterClosed {
|
||||
if (json === undefined) {
|
||||
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 (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(
|
||||
json
|
||||
json,
|
||||
)}`
|
||||
}
|
||||
if (json["and"] !== undefined) {
|
||||
|
|
@ -839,13 +850,13 @@ export class TagUtils {
|
|||
return new RegexTag(
|
||||
withRegex.key,
|
||||
new RegExp(".+", "si" + withRegex.modifier),
|
||||
withRegex.invert
|
||||
withRegex.invert,
|
||||
)
|
||||
}
|
||||
return new RegexTag(
|
||||
withRegex.key,
|
||||
new RegExp("^(" + value + ")$", "s" + withRegex.modifier),
|
||||
withRegex.invert
|
||||
withRegex.invert,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -967,10 +978,19 @@ export class TagUtils {
|
|||
return ["", "## `" + mode + "` " + doc.name, "", doc.docs, "", ""].join("\n")
|
||||
}),
|
||||
"## " +
|
||||
TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") +
|
||||
" Logical comparators",
|
||||
TagUtils.comparators.map((comparator) => "`" + comparator[0] + "`").join(" ") +
|
||||
" Logical comparators",
|
||||
TagUtils.numberAndDateComparisonDocs,
|
||||
TagUtils.logicalOperator,
|
||||
].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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ export abstract class TagsFilter {
|
|||
|
||||
/**
|
||||
* 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import * as nsiFeatures from "../../../node_modules/name-suggestion-index/dist/featureCollection.json"
|
||||
import { LocationConflation } from "@rapideditor/location-conflation"
|
||||
import type { Feature, MultiPolygon } from "geojson"
|
||||
import type { Feature, FeatureCollection, MultiPolygon } from "geojson"
|
||||
import { Utils } from "../../Utils"
|
||||
import * as turf from "@turf/turf"
|
||||
import { Mapping } from "../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import { Tag } from "../Tags/Tag"
|
||||
import { TypedTranslation } from "../../UI/i18n/Translation"
|
||||
import { RegexTag } from "../Tags/RegexTag"
|
||||
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
|
||||
import { TagUtils } from "../Tags/TagUtils"
|
||||
|
||||
/**
|
||||
* Main name suggestion index file
|
||||
|
|
@ -42,17 +43,18 @@ interface NSIEntry {
|
|||
* Represents a single brand/operator/flagpole/...
|
||||
*/
|
||||
export interface NSIItem {
|
||||
displayName: string
|
||||
id: string
|
||||
readonly displayName: string
|
||||
readonly id: string
|
||||
locationSet: {
|
||||
include: string[]
|
||||
exclude: string[]
|
||||
}
|
||||
tags: Record<string, string>
|
||||
fromTemplate?: boolean
|
||||
readonly tags: Readonly<Record<string, string>>
|
||||
readonly fromTemplate?: boolean
|
||||
}
|
||||
|
||||
export default class NameSuggestionIndex {
|
||||
|
||||
public static readonly supportedTypes = ["brand", "flag", "operator", "transit"] as const
|
||||
private readonly nsiFile: Readonly<NSIFile>
|
||||
private readonly nsiWdFile: Readonly<
|
||||
|
|
@ -64,7 +66,7 @@ export default class NameSuggestionIndex {
|
|||
>
|
||||
>
|
||||
|
||||
private static loco = new LocationConflation(nsiFeatures) // Some additional boundaries
|
||||
private loco: LocationConflation // Some additional boundaries
|
||||
|
||||
private _supportedTypes: string[]
|
||||
|
||||
|
|
@ -77,10 +79,12 @@ export default class NameSuggestionIndex {
|
|||
logos: { wikidata?: string; facebook?: string }
|
||||
}
|
||||
>
|
||||
>
|
||||
>,
|
||||
features: Readonly<FeatureCollection>,
|
||||
) {
|
||||
this.nsiFile = nsiFile
|
||||
this.nsiWdFile = nsiWdFile
|
||||
this.loco = new LocationConflation(features)
|
||||
}
|
||||
|
||||
private static inited: NameSuggestionIndex = undefined
|
||||
|
|
@ -89,12 +93,12 @@ export default class NameSuggestionIndex {
|
|||
if (NameSuggestionIndex.inited) {
|
||||
return NameSuggestionIndex.inited
|
||||
}
|
||||
const [nsi, nsiWd] = await Promise.all(
|
||||
["assets/data/nsi/nsi.json", "assets/data/nsi/wikidata.min.json"].map((url) =>
|
||||
Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30)
|
||||
)
|
||||
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) =>
|
||||
Utils.downloadJsonCached(url, 1000 * 60 * 60 * 24 * 30),
|
||||
),
|
||||
)
|
||||
NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"])
|
||||
NameSuggestionIndex.inited = new NameSuggestionIndex(<any>nsi, <any>nsiWd["wikidata"], <any>features)
|
||||
return NameSuggestionIndex.inited
|
||||
}
|
||||
|
||||
|
|
@ -125,13 +129,13 @@ export default class NameSuggestionIndex {
|
|||
try {
|
||||
return Utils.downloadJsonCached<Record<string, number>>(
|
||||
`./assets/data/nsi/stats/${type}.${c.toUpperCase()}.json`,
|
||||
24 * 60 * 60 * 1000
|
||||
24 * 60 * 60 * 1000,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("Could not fetch " + type + " statistics due to", e)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
stats = Utils.NoNull(stats)
|
||||
if (stats.length === 1) {
|
||||
|
|
@ -172,17 +176,17 @@ export default class NameSuggestionIndex {
|
|||
public async generateMappings(
|
||||
type: string,
|
||||
tags: Record<string, string>,
|
||||
country: string[],
|
||||
country?: string[],
|
||||
location?: [number, number],
|
||||
options?: {
|
||||
/**
|
||||
* If set, sort by frequency instead of alphabetically
|
||||
*/
|
||||
sortByFrequency: boolean
|
||||
}
|
||||
},
|
||||
): Promise<Mapping[]> {
|
||||
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) {
|
||||
if (key.startsWith("_")) {
|
||||
continue
|
||||
|
|
@ -193,7 +197,7 @@ export default class NameSuggestionIndex {
|
|||
key,
|
||||
value,
|
||||
country.join(";"),
|
||||
location
|
||||
location,
|
||||
)
|
||||
if (!actualBrands) {
|
||||
continue
|
||||
|
|
@ -201,8 +205,7 @@ export default class NameSuggestionIndex {
|
|||
for (const nsiItem of actualBrands) {
|
||||
const tags = nsiItem.tags
|
||||
const frequency = frequencies[nsiItem.displayName]
|
||||
const logos = this.nsiWdFile[nsiItem.tags[type + ":wikidata"]]?.logos
|
||||
const iconUrl = logos?.facebook ?? logos?.wikidata
|
||||
const iconUrl = this.getIconExternalUrl(nsiItem, type)
|
||||
const hasIcon = iconUrl !== undefined
|
||||
let icon = undefined
|
||||
if (hasIcon) {
|
||||
|
|
@ -239,7 +242,7 @@ export default class NameSuggestionIndex {
|
|||
}
|
||||
|
||||
public supportedTags(
|
||||
type: "operator" | "brand" | "flag" | "transit" | string
|
||||
type: "operator" | "brand" | "flag" | "transit" | string,
|
||||
): Record<string, string[]> {
|
||||
const tags: Record<string, string[]> = {}
|
||||
const keys = Object.keys(this.nsiFile.nsi)
|
||||
|
|
@ -262,7 +265,7 @@ export default class NameSuggestionIndex {
|
|||
* Returns a list of all brands/operators
|
||||
* @param type
|
||||
*/
|
||||
public allPossible(type: "brand" | "operator"): NSIItem[] {
|
||||
public allPossible(type: string): NSIItem[] {
|
||||
const options: NSIItem[] = []
|
||||
const tags = this.supportedTags(type)
|
||||
for (const osmKey in tags) {
|
||||
|
|
@ -284,10 +287,10 @@ export default class NameSuggestionIndex {
|
|||
type: string,
|
||||
tags: { key: string; value: string }[],
|
||||
country: string = undefined,
|
||||
location: [number, number] = undefined
|
||||
location: [number, number] = undefined,
|
||||
): NSIItem[] {
|
||||
return tags.flatMap((tag) =>
|
||||
this.getSuggestionsForKV(type, tag.key, tag.value, country, location)
|
||||
this.getSuggestionsForKV(type, tag.key, tag.value, country, location),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +313,7 @@ export default class NameSuggestionIndex {
|
|||
key: string,
|
||||
value: string,
|
||||
country: string = undefined,
|
||||
location: [number, number] = undefined
|
||||
location: [number, number] = undefined,
|
||||
): NSIItem[] {
|
||||
const path = `${type}s/${key}/${value}`
|
||||
const entry = this.nsiFile.nsi[path]
|
||||
|
|
@ -351,7 +354,7 @@ export default class NameSuggestionIndex {
|
|||
const key = i.locationSet.include?.join(";") + "-" + i.locationSet.exclude?.join(";")
|
||||
const fromCache = NameSuggestionIndex.resolvedSets[key]
|
||||
const resolvedSet =
|
||||
fromCache ?? NameSuggestionIndex.loco.resolveLocationSet(i.locationSet)
|
||||
fromCache ?? this.loco.resolveLocationSet(i.locationSet)
|
||||
if (!fromCache) {
|
||||
NameSuggestionIndex.resolvedSets[key] = resolvedSet
|
||||
}
|
||||
|
|
@ -374,9 +377,52 @@ export default class NameSuggestionIndex {
|
|||
center: [number, number],
|
||||
options: {
|
||||
sortByFrequency: boolean
|
||||
}
|
||||
},
|
||||
): Promise<Mapping[]> {
|
||||
const nsi = await NameSuggestionIndex.getNsiIndex()
|
||||
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
|
||||
}
|
||||
private static readonly brandPrefix = ["name", "alt_name", "operator","brand"] as const
|
||||
|
||||
/**
|
||||
* An NSI-item might have tags such as `name=X`, `alt_name=brand X`, `brand=X`, `brand:wikidata`, `shop=Y`, `service:abc=yes`
|
||||
* Many of those tags are all added, but having only one of them is a good indication that it should match this item.
|
||||
*
|
||||
* This method is a heuristic which attempts to move all the brand-related tags into an `or` but still requiring the `shop` and other tags
|
||||
*
|
||||
* (More of an extension method on NSIItem)
|
||||
*/
|
||||
static asFilterTags(item: NSIItem): string | { and: TagConfigJson[] } | { or: TagConfigJson[] } {
|
||||
let brandDetection: string[] = []
|
||||
let required: string[] = []
|
||||
const tags: Record<string, string> = item.tags
|
||||
for (const k in tags) {
|
||||
if (NameSuggestionIndex.brandPrefix.some(br => k === br || k.startsWith(br + ":"))) {
|
||||
brandDetection.push(k + "=" + tags[k])
|
||||
} else {
|
||||
required.push(k + "=" + tags[k])
|
||||
}
|
||||
}
|
||||
return <TagConfigJson>TagUtils.optimzeJson({ and: [...required, { or: brandDetection }] })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Utils } from "../../Utils"
|
||||
import type { FeatureCollection } from "geojson"
|
||||
import ScriptUtils from "../../../scripts/ScriptUtils"
|
||||
|
||||
export interface TagInfoStats {
|
||||
/**
|
||||
|
|
@ -39,12 +40,12 @@ export default class TagInfo {
|
|||
let url: string
|
||||
if (value) {
|
||||
url = `${this._backend}api/4/tag/stats?key=${encodeURIComponent(
|
||||
key
|
||||
key,
|
||||
)}&value=${encodeURIComponent(value)}`
|
||||
} else {
|
||||
url = `${this._backend}api/4/key/stats?key=${encodeURIComponent(key)}`
|
||||
}
|
||||
return await Utils.downloadJsonCached<TagInfoStats>(url, 1000 * 60 * 60)
|
||||
return await Utils.downloadJsonCached<TagInfoStats>(url, 1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,10 +70,10 @@ export default class TagInfo {
|
|||
}
|
||||
const countriesFC: FeatureCollection = await Utils.downloadJsonCached<FeatureCollection>(
|
||||
"https://download.geofabrik.de/index-v1-nogeom.json",
|
||||
24 * 1000 * 60 * 60
|
||||
24 * 1000 * 60 * 60,
|
||||
)
|
||||
TagInfo._geofabrikCountries = countriesFC.features.map(
|
||||
(f) => <GeofabrikCountryProperties>f.properties
|
||||
(f) => <GeofabrikCountryProperties>f.properties,
|
||||
)
|
||||
return TagInfo._geofabrikCountries
|
||||
}
|
||||
|
|
@ -98,7 +99,7 @@ export default class TagInfo {
|
|||
private static async getDistributionsFor(
|
||||
countryCode: string,
|
||||
key: string,
|
||||
value?: string
|
||||
value?: string,
|
||||
): Promise<TagInfoStats> {
|
||||
if (!countryCode) {
|
||||
return undefined
|
||||
|
|
@ -110,30 +111,43 @@ export default class TagInfo {
|
|||
try {
|
||||
return await ti.getStats(key, value)
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch info for", countryCode, key, value, "due to", e)
|
||||
console.warn("Could not fetch info from taginfo for", countryCode, key, value, "due to", e, "Taginfo country specific instance is ", ti._backend)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly blacklist = ["VI", "GF", "PR"]
|
||||
|
||||
public static async getGlobalDistributionsFor(
|
||||
/**
|
||||
* Get a taginfo object for every supportedCountry. This statistic is handled by 'f' and written into the passed in object
|
||||
* @param writeInto
|
||||
* @param f
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
public static async getGlobalDistributionsFor<T>(
|
||||
writeInto: Record<string, T>,
|
||||
f: ((stats: TagInfoStats) => T),
|
||||
key: string,
|
||||
value?: string
|
||||
): Promise<Record<string, TagInfoStats>> {
|
||||
value?: string,
|
||||
): Promise<number> {
|
||||
const countriesAll = await this.geofabrikCountries()
|
||||
const countries = countriesAll
|
||||
.map((c) => c["iso3166-1:alpha2"]?.[0])
|
||||
.filter((c) => !!c && TagInfo.blacklist.indexOf(c) < 0)
|
||||
const perCountry: Record<string, TagInfoStats> = {}
|
||||
const results = await Promise.all(
|
||||
countries.map((country) => TagInfo.getDistributionsFor(country, key, value))
|
||||
)
|
||||
for (let i = 0; i < countries.length; i++) {
|
||||
const countryCode = countries[i]
|
||||
if (results[i]) {
|
||||
perCountry[countryCode] = results[i]
|
||||
|
||||
let downloaded = 0
|
||||
for (const country of countries) {
|
||||
if(writeInto[country] !== undefined){
|
||||
continue
|
||||
}
|
||||
const r = await TagInfo.getDistributionsFor(country, key, value)
|
||||
if(r === undefined){
|
||||
continue
|
||||
}
|
||||
downloaded ++
|
||||
writeInto[country] = f(r)
|
||||
}
|
||||
return perCountry
|
||||
return downloaded
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue