Feature(NSI): support using NSI-images as icon, test on shops

This commit is contained in:
Pieter Vander Vennet 2025-01-10 14:09:54 +01:00
parent 80192f003a
commit b913ea867f
14 changed files with 1173790 additions and 71 deletions

View file

@ -1,5 +1,6 @@
{
"#":"no-translations",
"#no-index": "yes",
"#dont-translate": "*",
"#filter": "no-auto",
"pointRendering": [

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -109,7 +109,7 @@
},
{
"icon": {
"builtin": "id_presets.shop_rendering",
"builtin": "nsi_brand.icon",
"override": {
"render": "./assets/layers/id_presets/maki-shop.svg",
"+mappings": [

View file

@ -95,7 +95,7 @@
"reset:translations": "vite-node scripts/generateTranslations.ts -- --ignore-weblate",
"generate:layouts": "vite-node scripts/generateLayouts.ts",
"generate:docs": "rm -rf Docs/Themes/* && rm -rf Docs/Layers/* && rm -rf Docs/TagInfo && mkdir Docs/TagInfo && export NODE_OPTIONS=\"--max-old-space-size=16000\" && vite-node scripts/generateDocs.ts && vite-node scripts/generateTaginfoProjectFiles.ts",
"generate:layeroverview": "export NODE_OPTIONS=\"--max-old-space-size=8192\" && vite-node scripts/generateLayerOverview.ts",
"generate:layeroverview": "export NODE_OPTIONS=\"--max-old-space-size=16000\" && vite-node scripts/generateLayerOverview.ts",
"generate:mapcomplete-changes-theme": "export NODE_OPTIONS=\"--max-old-space-size=8192\" && vite-node scripts/generateLayerOverview.ts -- --generate-change-map",
"refresh:layeroverview": "export NODE_OPTIONS=\"--max-old-space-size=8192\" && vite-node scripts/generateLayerOverview.ts -- --force",
"generate:licenses": "vite-node scripts/generateLicenseInfo.ts -- --no-fail",

View file

@ -5,10 +5,9 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
import ScriptUtils from "./ScriptUtils"
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 { FilterConfigOptionJson } from "../src/Models/ThemeConfig/Json/FilterConfigJson"
import { TagUtils } from "../src/Logic/Tags/TagUtils"
import { And } from "../src/Logic/Tags/And"
import { TagRenderingConfigJson } from "../src/Models/ThemeConfig/Json/TagRenderingConfigJson"
class DownloadNsiLogos extends Script {
constructor() {
@ -129,34 +128,56 @@ 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,
osmTags: NameSuggestionIndex.asFilterTags(item),
})
})
const mappings = items.map(item => ({
if: NameSuggestionIndex.asFilterTags(item),
then: nsi.getIconUrl(item, type),
}))
console.log("Checking for shadow-mappings...")
for (let i = mappings.length - 1; i >= 0 ; i--) {
const condition = TagUtils.Tag(mappings[i].if)
if(i % 100 === 0){
console.log("Checking for shadow-mappings...",i,"/",mappings.length )
}
const shadowsSomething = mappings.some((m,j) => {
if(i===j ){
return false
}
return condition.shadows(TagUtils.Tag(m.if))
})
// If this one matches, the other one will match as well
// We can thus remove this one in favour of the other one
if(shadowsSomething){
mappings.splice(i, 1)
}
}
const iconsTr: TagRenderingConfigJson = <any>{
strict: true,
id: "icon",
mappings,
}
const config: LayerConfigJson = {
"#dont-translate": "*",
"#no-index": "yes",
id: "nsi_" + type,
source: "special:library",
description: {
en: "Exposes part of the NSI to reuse in other themes, e.g. for rendering",
},
pointRendering: null,
tagRenderings: [
iconsTr,
],
filter: [
<any>{
id: type,

View file

@ -497,6 +497,8 @@ class LayerOverviewUtils extends Script {
priviliged.delete(key)
})
// These two get a free pass
priviliged.delete("summary")
priviliged.delete("last_click")
@ -527,7 +529,7 @@ class LayerOverviewUtils extends Script {
writeFileSync(
"./src/assets/generated/known_layers.json",
JSON.stringify({
layers: Array.from(sharedLayers.values()).filter((l) => l.id !== "favourite"),
layers: Array.from(sharedLayers.values()).filter((l) => !(l["#no-index"] === "yes")),
})
)
}

View file

@ -1,15 +1,20 @@
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { Utils } from "../Utils"
import known_layers from "../assets/generated/known_layers.json"
import * as known_layers from "../assets/generated/known_layers.json"
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
export class AllSharedLayers {
public static sharedLayers: Map<string, LayerConfig> = AllSharedLayers.getSharedLayers()
public static getSharedLayersConfigs(): Map<string, LayerConfigJson> {
const sharedLayers = new Map<string, LayerConfigJson>()
for (const layer of known_layers["layers"]) {
// @ts-ignore
sharedLayers.set(layer.id, layer)
for (const layer of (known_layers).layers) {
if(layer.id === undefined){
console.error("Layer without id! "+JSON.stringify(layer).slice(0,80), known_layers.layers.length)
continue
}else{
console.log("Loaded",layer.id)
}
sharedLayers.set(layer.id, <any> layer)
}
return sharedLayers

View file

@ -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'.
*

View file

@ -6,6 +6,8 @@ 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
@ -52,6 +54,7 @@ export interface NSIItem {
}
export default class NameSuggestionIndex {
public static readonly supportedTypes = ["brand", "flag", "operator", "transit"] as const
private readonly nsiFile: Readonly<NSIFile>
private readonly nsiWdFile: Readonly<
@ -399,4 +402,27 @@ export default class NameSuggestionIndex {
}
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 }] })
}
}

View file

@ -36,11 +36,8 @@ export class PruneFilters extends DesugaringStep<LayerConfigJson>{
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()}
let basetags = TagUtils.Tag(option.osmTags)
return {...option, osmTags: (<TagsFilter>TagUtils.removeKnownParts(basetags ,sourceTags)).asJson()}
})
const countAfter = newOptions.length
if(countAfter !== countBefore){

View file

@ -159,11 +159,15 @@ class ExpandTagRendering extends Conversion<
ctx: ConversionContext
): QuestionableTagRenderingConfigJson[] {
const trs = this.convertOnce(spec, ctx)
const result = []
if(!Array.isArray(trs)){
ctx.err("Result of lookup for "+spec+" is not iterable; got "+trs)
return undefined
}
for (const tr of trs) {
if (typeof tr === "string" || tr["builtin"] !== undefined) {
const stable = this.convert(tr, ctx.inOperation("recursive_resolve"))
.map(tr => this.pruneMappings(tr, ctx))
result.push(...stable)
if (this._options?.addToContext) {
for (const tr of stable) {
@ -181,6 +185,40 @@ class ExpandTagRendering extends Conversion<
return result
}
private pruneMappings(tagRendering: QuestionableTagRenderingConfigJson, ctx: ConversionContext): QuestionableTagRenderingConfigJson{
if(!tagRendering["strict"]){
return tagRendering
}
const before = tagRendering.mappings?.length ?? 0
const alwaysTags = TagUtils.Tag(this._self.source["osmTags"])
const newMappings = tagRendering.mappings?.filter(mapping => {
const condition = TagUtils.Tag( mapping.if)
return condition.shadows(alwaysTags);
}).map(mapping => {
const newIf =TagUtils.removeKnownParts(
TagUtils.Tag(mapping.if), alwaysTags )
if(typeof newIf === "boolean"){
throw "Invalid removeKnownParts"
}
return {
...mapping,
if: newIf.asJson()
}
})
const after = newMappings?.length ?? 0
if(before - after > 0){
ctx.info(`Pruned mappings for ${tagRendering.id}, from ${before} to ${after} (removed ${before - after})`)
}
const tr = {
...tagRendering,
mappings: newMappings
}
delete tr["strict"]
return tr
}
private lookup(name: string, ctx: ConversionContext): TagRenderingConfigJson[] | undefined {
const direct = this.directLookup(name)
@ -285,11 +323,12 @@ class ExpandTagRendering extends Conversion<
const state = this._state
if (typeof tr === "string") {
let lookup
if (this._state.tagRenderings !== null) {
lookup = this.lookup(tr, ctx)
const lookup = this.lookup(tr, ctx)
if(lookup){
return lookup
}
}
if (lookup === undefined) {
if (
this._state.sharedLayers?.size > 0 &&
ctx.path.at(-1) !== "icon" &&
@ -298,7 +337,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(", "),
)
}
@ -308,7 +347,7 @@ class ExpandTagRendering extends Conversion<
tr +
" \n Did you perhaps forget to add the layer as prefix, such as `icons." +
tr +
"`? "
"`? ",
)
}
@ -319,8 +358,6 @@ class ExpandTagRendering extends Conversion<
},
]
}
return lookup
}
if (tr["builtin"] !== undefined) {
let names: string | string[] = tr["builtin"]
@ -356,6 +393,9 @@ class ExpandTagRendering extends Conversion<
let candidates = Array.from(state.tagRenderings.keys())
if (name.indexOf(".") > 0) {
const [layerName] = name.split(".")
if(layerName === undefined){
ctx.err("Layername is undefined", name)
}
let layer = state.sharedLayers.get(layerName)
if (layerName === this._self?.id) {
layer = this._self
@ -363,7 +403,7 @@ class ExpandTagRendering extends Conversion<
if (layer === undefined) {
const candidates = Utils.sortedByLevenshteinDistance(
layerName,
Array.from(state.sharedLayers.keys()),
Utils.NoNull(Array.from(state.sharedLayers.keys())),
(s) => s
)
if (state.sharedLayers.size === 0) {
@ -1017,7 +1057,8 @@ class ExpandIconBadges extends DesugaringStep<PointRenderingConfigJson> {
class PreparePointRendering extends Fuse<PointRenderingConfigJson> {
constructor(state: DesugaringContext, layer: LayerConfigJson) {
super(
"Prepares point renderings by expanding 'icon' and 'iconBadges'",
"Prepares point renderings by expanding 'icon' and 'iconBadges'." +
" A tagRendering from the host tagRenderings will be substituted in",
new On(
"marker",
new Each(

View file

@ -110,10 +110,8 @@ export class DoesImageExist extends DesugaringStep<string> {
}
if (!this._knownImagePaths.has(image)) {
if (this.doesPathExist === undefined) {
context.err(
`Image with path ${image} not found or not attributed; it is used in ${context}`
)
if (this.doesPathExist === undefined || image.indexOf("nsi/logos/") >= 0) {
// pass
} else if (!this.doesPathExist(image) ) {
context.err(
`Image with path ${image} does not exist.\n Check for typo's and missing directories in the path. `

View file

@ -4,8 +4,16 @@ import { TagConfigJson } from "./TagConfigJson"
export interface IconConfigJson {
/**
* question: What icon should be used?
*
* To reuse icons from a different layer of a library:
* - The library layer has, within tagRenderings one which will output the URL of the image (e.g. mappings: {"if": "shop=xyz", then: "./assets/icons/shop_xyz.png"})
* - Use "layer_id.tagrendering_id"
*
* Note that if you reuse icons from a different icon set, you'll probably want to use `override` to set a default rendering
*
*
* types: <span class="text-lg font-bold">Use a different icon depending on the value of some attributes</span> ; icon
* suggestions: return Constants.defaultPinIcons.map(i => ({if: "value="+i, then: i, icon: i}))
* suggestions: return [ "nsi_brand.icon", "nsi_operator.icon", "id_presets.shop_rendering", ...Constants.defaultPinIcons.map(i => ({if: "value="+i, then: i, icon: i}))]
*/
icon: string | MinimalTagRenderingConfigJson | { builtin: string; override: any }
/**