From c5b4cdf45098c08dd4cae1c288c245a2f292b75a Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 16 May 2024 00:12:50 +0200 Subject: [PATCH] NSI: add script to download logos and statistics, dynamically inject extra mappings, hide low-priority mappings if applicable --- .gitignore | 3 + langs/en.json | 7 +- package-lock.json | 22 +++- package.json | 5 +- scripts/ScriptUtils.ts | 3 +- scripts/downloadNsiLogos.ts | 113 +++++++++++++++++ scripts/generateStats.ts | 88 ++++++++++--- src/Logic/Web/NameSuggestionIndex.ts | 120 ++++++++++++++---- src/Logic/Web/TagInfo.ts | 15 ++- src/Models/ThemeConfig/TagRenderingConfig.ts | 93 +++++++++----- src/UI/Base/Markdown.svelte | 7 +- .../BigComponents/SelectedElementView.svelte | 3 +- src/UI/InputElement/InputHelper.svelte | 3 - src/UI/Popup/TagRendering/Questionbox.svelte | 11 +- .../TagRenderingEditableDynamic.svelte | 32 +++++ .../TagRenderingMappingInput.svelte | 16 ++- .../TagRendering/TagRenderingQuestion.svelte | 12 +- .../TagRenderingQuestionDynamic.svelte | 20 +-- 18 files changed, 459 insertions(+), 114 deletions(-) create mode 100644 scripts/downloadNsiLogos.ts diff --git a/.gitignore b/.gitignore index 6c9311979..dc8529f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ missing_translations.txt .DS_Store Svg.ts data/ +src/assets/generated/nsi_stats/brand.json +src/assets/generated/nsi_stats/brand.summarized.json + Folder.DotSettings.user index_*.ts .~lock.* diff --git a/langs/en.json b/langs/en.json index 14f4c43ba..71baf5eff 100644 --- a/langs/en.json +++ b/langs/en.json @@ -290,6 +290,7 @@ "loginToStart": "Log in to answer this question", "loginWithOpenStreetMap": "Login with OpenStreetMap", "logout": "Log out", + "mappingsAreHidden": "Some options are hidden. Use search to show more options.", "menu": { "aboutMapComplete": "About MapComplete", "filter": "Filter data" @@ -686,11 +687,11 @@ "intro": "Privacy is important - for both the individual and for society. MapComplete tries to respect your privacy as much as possible - up to the point no annoying cookie banner is needed. However, we still would like to inform you which information is gathered and shared, under which circumstances and why these trade-offs are made.", "items": { "changesYouMake": "The changes you made", - "username": "Your username", "date": "When this change is made", - "theme": "The theme you used while making the change", + "distanceIndicator": "An indication of how close you were to changed objects. Other mappers can use this information to determine if a change was made based on survey or on remote research", "language": "The language of the user interface", - "distanceIndicator": "An indication of how close you were to changed objects. Other mappers can use this information to determine if a change was made based on survey or on remote research" + "theme": "The theme you used while making the change", + "username": "Your username" }, "miscCookies": "MapComplete integrates with various other services, especially to load images of features. Images are hosted on various third-party servers, which might set cookies on their own.", "miscCookiesTitle": "Other cookies", diff --git a/package-lock.json b/package-lock.json index 401741f7c..fdada7215 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mapcomplete", - "version": "0.42.5", + "version": "0.42.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mapcomplete", - "version": "0.42.5", + "version": "0.42.6", "license": "GPL-3.0-or-later", "dependencies": { "@comunica/core": "^3.0.1", @@ -24,6 +24,7 @@ "@turf/length": "^6.5.0", "@turf/turf": "^6.5.0", "@types/dompurify": "^3.0.2", + "@types/follow-redirects": "^1.14.4", "@types/pg": "^8.10.9", "@types/qrcode-generator": "^1.0.6", "@types/showdown": "^2.0.0", @@ -39,6 +40,7 @@ "email-validator": "^2.0.4", "escape-html": "^1.0.3", "fake-dom": "^1.0.4", + "follow-redirects": "^1.15.6", "geojson2svg": "^1.3.3", "html-to-image": "^1.11.11", "i18next-client": "^1.11.4", @@ -6568,6 +6570,14 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/@types/follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.14", "license": "MIT" @@ -24194,6 +24204,14 @@ "@types/estree": { "version": "1.0.0" }, + "@types/follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==", + "requires": { + "@types/node": "*" + } + }, "@types/geojson": { "version": "7946.0.14" }, diff --git a/package.json b/package.json index 25f564176..add61e543 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "housekeeping": "git pull && npx update-browserslist-db@latest && npm run weblate-fix-heavy && npm run generate && npm run generate:docs && npm run generate:contributor-list && vite-node scripts/fetchLanguages.ts && vite-node scripts/generateSunnyUnlabeled.ts && npm run format && git add assets/ langs/ Docs/ **/*.ts Docs/* src/* && git commit -m 'chore: automated housekeeping...'", "reuse-compliance": "reuse lint", "backup:images": "vite-node scripts/generateImageAnalysis.ts -- ~/data/imgur-image-backup/", + "downloadNsiLogos": "vite-node scripts/downloadNsiLogos.ts", "dloadVelopark": "vite-node scripts/velopark/veloParkToGeojson.ts ", "compareVelopark": "vite-node scripts/velopark/compare.ts -- velopark_nonsynced_.geojson ~/Projecten/OSM/Fietsberaad/2024-02-02\\ Fietsenstallingen_OSM_met_velopark_ref.geojson\n", "scrapeWebsites": "vite-node scripts/importscripts/compareWebsiteData.ts -- ~/Downloads/ShopsWithWebsiteNodes.csv ~/data/scraped_websites/", @@ -142,6 +143,7 @@ "@turf/length": "^6.5.0", "@turf/turf": "^6.5.0", "@types/dompurify": "^3.0.2", + "@types/follow-redirects": "^1.14.4", "@types/pg": "^8.10.9", "@types/qrcode-generator": "^1.0.6", "@types/showdown": "^2.0.0", @@ -157,6 +159,7 @@ "email-validator": "^2.0.4", "escape-html": "^1.0.3", "fake-dom": "^1.0.4", + "follow-redirects": "^1.15.6", "geojson2svg": "^1.3.3", "html-to-image": "^1.11.11", "i18next-client": "^1.11.4", @@ -241,4 +244,4 @@ "typescript": "^4.7.4", "vite": "^4.5.3" } -} \ No newline at end of file +} diff --git a/scripts/ScriptUtils.ts b/scripts/ScriptUtils.ts index ff65aa73e..87c3acceb 100644 --- a/scripts/ScriptUtils.ts +++ b/scripts/ScriptUtils.ts @@ -1,11 +1,10 @@ import * as fs from "fs" import { existsSync, lstatSync, readdirSync, readFileSync } from "fs" import { Utils } from "../src/Utils" -import * as https from "https" +import {https} from "follow-redirects" import { LayoutConfigJson } from "../src/Models/ThemeConfig/Json/LayoutConfigJson" import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson" import xml2js from "xml2js" -import { resolve } from "node:dns" export default class ScriptUtils { public static fixUtils() { diff --git a/scripts/downloadNsiLogos.ts b/scripts/downloadNsiLogos.ts new file mode 100644 index 000000000..4abb89b41 --- /dev/null +++ b/scripts/downloadNsiLogos.ts @@ -0,0 +1,113 @@ +import Script from "./Script" +import NameSuggestionIndex, { NSIItem } from "../src/Logic/Web/NameSuggestionIndex" +import * as nsiWD from "../node_modules/name-suggestion-index/dist/wikidata.min.json" +import { existsSync, writeFileSync } from "fs" +import ScriptUtils from "./ScriptUtils" +import { Utils } from "../src/Utils" +import { WikimediaImageProvider } from "../src/Logic/ImageProviders/WikimediaImageProvider" +import { renameSync } from "node:fs" + +export default class DownloadNsiLogos extends Script { + constructor() { + super("Downloads all images of the NSI") + } + + private async getWikimediaUrl(startUrl: string) { + if (!startUrl) { + return startUrl + } + + + } + + private async downloadLogo(nsiItem: NSIItem, type: string, basePath: string) { + try { + return await this.downloadLogoUnsafe(nsiItem, type, basePath) + } catch (e) { + console.error("Could not download", nsiItem.displayName, "due to", e) + return false + } + } + + private async downloadLogoUnsafe(nsiItem: NSIItem, type: string, basePath: string) { + if (nsiItem === undefined) { + return false + } + let path = basePath + nsiItem.id + + const logos = nsiWD["wikidata"][nsiItem?.tags?.[type + ":wikidata"]]?.logos + + if (NameSuggestionIndex.isSvg(nsiItem, type)) { + path = path + ".svg" + } + + if (existsSync(path)) { + return false + } + + + if (!logos) { + return false + } + if (logos.facebook) { + // Facebook logo's are generally better and square + await ScriptUtils.DownloadFileTo(logos.facebook, path) + return true + } + if (logos.wikidata) { + let url: string = logos.wikidata + console.log("Downloading", url) + let ttl = 10 + do { + ttl-- + const dloaded = await Utils.downloadAdvanced(url, { + "User-Agent": "MapComplete NSI scraper/0.1 (https://github.com/pietervdvn/MapComplete; pietervdvn@posteo.net)" + }) + const redirect: string | undefined = dloaded["redirect"] + if (redirect) { + console.log("Got a redirect from", url, "to", redirect) + url = redirect + continue + } + if ((logos.wikidata).toLowerCase().endsWith(".svg")) { + console.log("Written SVG", path) + if(!path.endsWith(".svg")){ + throw "Undetected svg path:"+logos.wikidata + } + writeFileSync(path, dloaded["content"], "utf8") + return true + } + + console.log("Got data from", url, "-->", path) + await ScriptUtils.DownloadFileTo(url, path) + return true + } while (ttl > 0) + + return false + } + + return false + + } + + async main(args: string[]): Promise { + const type = "brand" + const items = NameSuggestionIndex.allPossible(type) + const basePath = "./public/assets/data/nsi/logos/" + let downloadCount = 0 + const stepcount = 100 + for (let i = 0; i < items.length; i += stepcount) { + if (i % 100 === 0) { + console.log(i + "/" + items.length, "downloaded " + downloadCount) + } + await Promise.all(Utils.TimesT(stepcount, j => j).map(async j => { + const downloaded = await this.downloadLogo(items[i + j], type, basePath) + if (downloaded) { + downloadCount++ + } + })) + } + } +} + +new DownloadNsiLogos().run() diff --git a/scripts/generateStats.ts b/scripts/generateStats.ts index a387bd91e..96ca4c398 100644 --- a/scripts/generateStats.ts +++ b/scripts/generateStats.ts @@ -7,7 +7,7 @@ import ScriptUtils from "./ScriptUtils" import TagRenderingConfig from "../src/Models/ThemeConfig/TagRenderingConfig" import { And } from "../src/Logic/Tags/And" import Script from "./Script" -import NameSuggestionIndex, { NSIItem } from "../src/Logic/Web/NameSuggestionIndex" +import NameSuggestionIndex from "../src/Logic/Web/NameSuggestionIndex" import TagInfo, { TagInfoStats } from "../src/Logic/Web/TagInfo" class Utilities { @@ -18,6 +18,7 @@ class Utilities { } return newR } + } class GenerateStats extends Script { @@ -61,9 +62,7 @@ class GenerateStats extends Script { await Promise.all( Array.from(keysAndTags.keys()).map(async (key) => { const values = keysAndTags.get(key) - const data = await Utils.downloadJson( - `https://taginfo.openstreetmap.org/api/4/key/stats?key=${key}` - ) + const data = await TagInfo.global.getStats(key) const count = data.data.find((item) => item.type === "all").count keyTotal.set(key, count) console.log(key, "-->", count) @@ -72,10 +71,8 @@ class GenerateStats extends Script { tagTotal.set(key, new Map()) await Promise.all( Array.from(values).map(async (value) => { - const tagData = await Utils.downloadJson( - `https://taginfo.openstreetmap.org/api/4/tag/stats?key=${key}&value=${value}` - ) - const count = tagData.data.find((item) => item.type === "all").count + const tagData: TagInfoStats= await TagInfo.global.getStats(key, value) + const count = tagData.data .find((item) => item.type === "all").count tagTotal.get(key).set(value, count) console.log(key + "=" + value, "-->", count) }) @@ -98,20 +95,74 @@ class GenerateStats extends Script { ) } - async createNameSuggestionIndexFile() { - const type = "brand" + private summarizeNSI(sourcefile: string, pathNoExtension: string): void { + const data = >>JSON.parse(readFileSync(sourcefile, "utf8")) + + const allCountries: Set = new Set() + for (const brand in data) { + const perCountry = data[brand] + for (const country in perCountry) { + allCountries.add(country) + const count = perCountry[country] + if (count === 0) { + delete perCountry[country] + } + } + } + + const pathOut = pathNoExtension + ".summarized.json" + writeFileSync(pathOut, JSON.stringify( + data, null, " "), "utf8") + console.log("Written", pathOut) + + const allBrands = Object.keys(data) + allBrands.sort() + for (const country of allCountries) { + const summary = >{} + for (const brand of allBrands) { + const count = data[brand][country] + if (count > 2) { // EĆ©ntje is geentje + // We ignore count == 1 as they are rather exceptional + summary[brand] = data[brand][country] + } + } + + const countryPath = pathNoExtension + "." + country + ".json" + writeFileSync(countryPath, JSON.stringify(summary), "utf8") + console.log("Written", countryPath) + } + } + + + async createNameSuggestionIndexFile(basepath: string,type: "brand" | "operator") { + const path = basepath+type+'.json' let allBrands = >>{} - const path = "./src/assets/generated/nsi_stats/" + type + ".json" if (existsSync(path)) { allBrands = JSON.parse(readFileSync(path, "utf8")) console.log("Loaded",Object.keys(allBrands).length," previously loaded brands") } let lastWrite = new Date() - const allBrandNames: string[] = NameSuggestionIndex.allPossible(type) - for (const brand of allBrandNames) { + let skipped = 0 + const allBrandNames: string[] = Utils.Dedup(NameSuggestionIndex.allPossible(type).map(item => item.tags[type])) + for (let i = 0; i < allBrandNames.length; i++){ + if(i % 100 == 0){ + console.log("Downloading ",i+"/"+allBrandNames.length,"; skipped",skipped) + } + const brand = allBrandNames[i] + if(!!allBrands[brand] && Object.keys(allBrands[brand]).length == 0){ + delete allBrands[brand] + console.log("Deleted", brand, "as no entries at all") + } if(allBrands[brand] !== undefined){ - console.log("Skipping", brand,", already loaded") - continue + const max = Math.max(...Object.values(allBrands[brand])) + skipped++ + if(max < 0){ + console.log("HMMMM:", allBrands[brand]) + delete allBrands[brand] + + }else{ + continue + } } const distribution: Record = Utilities.mapValues(await TagInfo.getGlobalDistributionsFor(type, brand), s => s.data.find(t => t.type === "all").count) allBrands[brand] = distribution @@ -128,8 +179,11 @@ class GenerateStats extends Script { } async main(_: string[]) { - // this.createOptimizationFile() - await this.createNameSuggestionIndexFile() + await this.createOptimizationFile() + const type = "brand" + const basepath = "./src/assets/generated/stats/" + await this.createNameSuggestionIndexFile(basepath, type) + this.summarizeNSI(basepath+type+".json", "./public/assets/data/stats/"+type) } diff --git a/src/Logic/Web/NameSuggestionIndex.ts b/src/Logic/Web/NameSuggestionIndex.ts index 439d77a61..604852072 100644 --- a/src/Logic/Web/NameSuggestionIndex.ts +++ b/src/Logic/Web/NameSuggestionIndex.ts @@ -1,12 +1,15 @@ import * as nsi from "../../../node_modules/name-suggestion-index/dist/nsi.json" +import * as nsiWD from "../../../node_modules/name-suggestion-index/dist/wikidata.min.json" + import * as nsiFeatures from "../../../node_modules/name-suggestion-index/dist/featureCollection.json" import { LocationConflation } from "@rapideditor/location-conflation" -import type { Feature, FeatureCollection, MultiPolygon } from "geojson" -import * as turf from "@turf/turf" -import { Utils } from "../../Utils" -import TagInfo from "./TagInfo" import type { Feature, 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" /** * Main name suggestion index file @@ -48,9 +51,7 @@ export interface NSIItem { include: string[], exclude: string[] } - tags: { - [key: string]: string - } + tags: Record fromTemplate?: boolean } @@ -71,15 +72,87 @@ export default class NameSuggestionIndex { return this._supportedTypes } - public static async buildTaginfoCountsPerCountry(type = "brand", key: string, value: string) { - const allData: { nsi: NSIItem, stats }[] = [] - const brands = NameSuggestionIndex.getSuggestionsFor(type, key, value) - for (const brand of brands) { - const brandValue = brand.tags[type] - const allStats = await TagInfo.getGlobalDistributionsFor(type, brandValue) - allData.push({ nsi: brand, stats: allStats }) + + /** + * Fetches the data files for a single country. Note that it contains _all_ entries having this brand, not for a single type of object + * @param type + * @param countries + * @private + */ + private static async fetchFrequenciesFor(type: string, countries: string[]) { + let stats = await Promise.all(countries.map(c => { + try { + + return Utils.downloadJsonCached>(`./assets/data/nsi/stats/${type}.${c.toUpperCase()}.json`, 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) { + return stats[0] } - return allData + const merged = stats[0] + for (let i = 1; i < stats.length; i++) { + for (const countryCode in stats[i]) { + merged[countryCode] = (merged[countryCode] ?? 0) + stats[i][countryCode] + } + } + return merged + } + + public static isSvg(nsiItem: NSIItem, type: string): boolean | undefined { + const logos = nsiWD["wikidata"][nsiItem?.tags?.[type + ":wikidata"]]?.logos + if(nsiItem.id === "axa-2f6feb"){ + console.trace(">>> HI") + } + if (!logos) { + return undefined + } + if (logos.facebook) { + return false + } + const url: string = logos.wikidata + if (url.toLowerCase().endsWith(".svg")) { + return true + } + return false + } + + public static async generateMappings(type: string, key: string, value: string, country: string[], location?: [number, number]) { + const mappings: Mapping[] = [] + const frequencies = await NameSuggestionIndex.fetchFrequenciesFor(type, country) + const actualBrands = NameSuggestionIndex.getSuggestionsFor(type, key, value, country.join(";"), location) + for (const nsiItem of actualBrands) { + const tags = nsiItem.tags + const frequency = frequencies[nsiItem.displayName] + const logos = nsiWD["wikidata"][nsiItem.tags[type + ":wikidata"]]?.logos + let iconUrl = logos?.facebook ?? logos?.wikidata + const hasIcon = iconUrl !== undefined + let icon = undefined + if (hasIcon) { + // Using works fine without an extension for JPG and PNG, but _not_ svg :( + icon = "./assets/data/nsi/logos/" + nsiItem.id + if (NameSuggestionIndex.isSvg(nsiItem, type)) { + console.log("Is svg:", nsiItem.displayName) + icon = icon + ".svg" + } + } + mappings.push({ + if: new Tag(type, tags[type]), + addExtraTags: Object.keys(tags).filter(k => k !== type).map(k => new Tag(k, tags[k])), + then: new TypedTranslation<{}>({ "*": nsiItem.displayName }), + hideInAnswer: false, + ifnot: undefined, + alsoShowIf: undefined, + icon, + iconClass: "medium", + priorityIf: frequency > 0 ? new RegexTag("id", /.*/) : undefined, + searchTerms: { "*": [nsiItem.displayName, nsiItem.id] } + }) + } + return mappings } public static supportedTags(type: "operator" | "brand" | "flag" | "transit" | string): Record { @@ -101,26 +174,27 @@ export default class NameSuggestionIndex { return tags } - public static allPossible(type: "brand" | "operator"): string[] { - const options: string[] = [] + /** + * Returns a list of all brands/operators + * @param type + */ + public static allPossible(type: "brand" | "operator"): NSIItem[] { + const options: NSIItem[] = [] const tags = NameSuggestionIndex.supportedTags(type) for (const osmKey in tags) { const values = tags[osmKey] for (const osmValue of values) { const suggestions = this.getSuggestionsFor(type, osmKey, osmValue) - for (const suggestion of suggestions) { - const value = suggestion.tags[type] - options.push(value) - } + options.push(...suggestions) } } - return Utils.Dedup(options) + return (options) } /** * * @param path - * @param country + * @param country: a string containing one or more country codes, separated by ";" * @param location: center point of the feature, should be [lon, lat] */ public static getSuggestionsFor(type: string, key: string, value: string, country: string = undefined, location: [number, number] = undefined): NSIItem[] { diff --git a/src/Logic/Web/TagInfo.ts b/src/Logic/Web/TagInfo.ts index 213a81ac7..0039bf8c5 100644 --- a/src/Logic/Web/TagInfo.ts +++ b/src/Logic/Web/TagInfo.ts @@ -38,9 +38,9 @@ export default class TagInfo { public async getStats(key: string, value?: string): Promise { let url: string if (value) { - url = `${this._backend}api/4/tag/stats?key=${key}&value=${value}` + url = `${this._backend}api/4/tag/stats?key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}` } else { - url = `${this._backend}api/4/key/stats?key=${key}` + url = `${this._backend}api/4/key/stats?key=${encodeURIComponent(key)}` } return await Utils.downloadJsonCached(url, 1000 * 60 * 60) } @@ -97,20 +97,21 @@ export default class TagInfo { return undefined } try { - return await ti.getStats(key, value) + return await ti.getStats(key, value) } catch (e) { console.warn("Could not fetch info for", countryCode,key,value, "due to", e) return undefined } } + private static readonly blacklist =["VI","GF","PR"] public static async getGlobalDistributionsFor(key: string, value?: string): Promise> { - const countries = await this.geofabrikCountries() + 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 = {} - const results = await Promise.all(countries.map(country => TagInfo.getDistributionsFor(country?.["iso3166-1:alpha2"]?.[0], key, value))) + const results = await Promise.all(countries.map(country => TagInfo.getDistributionsFor(country, key, value))) for (let i = 0; i < countries.length; i++){ - const country = countries[i] - const countryCode = country["iso3166-1:alpha2"]?.[0] + const countryCode = countries[i] if(results[i]){ perCountry[countryCode] = results[i] } diff --git a/src/Models/ThemeConfig/TagRenderingConfig.ts b/src/Models/ThemeConfig/TagRenderingConfig.ts index bcc3c969f..ac5ee404a 100644 --- a/src/Models/ThemeConfig/TagRenderingConfig.ts +++ b/src/Models/ThemeConfig/TagRenderingConfig.ts @@ -10,20 +10,22 @@ import Combine from "../../UI/Base/Combine" import Title from "../../UI/Base/Title" import Link from "../../UI/Base/Link" import List from "../../UI/Base/List" -import { - MappingConfigJson, - QuestionableTagRenderingConfigJson, -} from "./Json/QuestionableTagRenderingConfigJson" +import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson" import { FixedUiElement } from "../../UI/Base/FixedUiElement" import Validators, { ValidatorType } from "../../UI/InputElement/Validators" import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" import { RegexTag } from "../../Logic/Tags/RegexTag" +import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" +import NameSuggestionIndex from "../../Logic/Web/NameSuggestionIndex" +import { GeoOperations } from "../../Logic/GeoOperations" +import { Feature } from "geojson" -export interface Icon {} +export interface Icon { +} export interface Mapping { readonly if: UploadableTag - readonly alsoShowIf: Tag | undefined + readonly alsoShowIf?: Tag readonly ifnot?: UploadableTag readonly then: TypedTranslation readonly icon: string @@ -75,13 +77,13 @@ export default class TagRenderingConfig { public readonly multiAnswer: boolean - public readonly mappings?: Mapping[] + public readonly mappings: Mapping[] public readonly editButtonAriaLabel?: Translation public readonly labels: string[] public readonly classes: string[] | undefined constructor( - config: string | TagRenderingConfigJson | (QuestionableTagRenderingConfigJson & {questionHintIsMd?: boolean}), + config: string | TagRenderingConfigJson | (QuestionableTagRenderingConfigJson & { questionHintIsMd?: boolean }), context?: string ) { let json = config @@ -201,7 +203,7 @@ export default class TagRenderingConfig { ) ?? [], inline: json.freeform.inline ?? false, default: json.freeform.default, - helperArgs: json.freeform.helperArgs, + helperArgs: json.freeform.helperArgs } if (json.freeform["extraTags"] !== undefined) { throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})` @@ -249,6 +251,8 @@ export default class TagRenderingConfig { commonIconSize ) ) + }else{ + this.mappings = [] } if (!json.multiAnswer && this.mappings !== undefined && this.question !== undefined) { @@ -319,7 +323,7 @@ export default class TagRenderingConfig { multiAnswer?: boolean, isQuestionable?: boolean, commonSize: string = "small" - ) { + ): Mapping { const ctx = `${translationKey}.mappings.${i}` if (mapping.if === undefined) { throw `Invalid mapping: "if" is not defined` @@ -395,7 +399,7 @@ export default class TagRenderingConfig { iconClass, addExtraTags, searchTerms: mapping.searchTerms, - priorityIf: prioritySearch, + priorityIf: prioritySearch } if (isQuestionable) { if (hideInAnswer !== true && mp.if !== undefined && !mp.if.isUsableAsAnswer()) { @@ -497,7 +501,7 @@ export default class TagRenderingConfig { then: new TypedTranslation( this.render.replace("{" + this.freeform.key + "}", leftover).translations, this.render.context - ), + ) }) } } @@ -588,7 +592,7 @@ export default class TagRenderingConfig { key: commonKey, values: Utils.NoNull( values.map((arr) => arr.filter((item) => item.k === commonKey)[0]?.v) - ), + ) } } @@ -603,7 +607,7 @@ export default class TagRenderingConfig { return { key, type: this.freeform.type, - values, + values } } catch (e) { console.error("Could not create FreeformValues for tagrendering", this.id) @@ -691,7 +695,7 @@ export default class TagRenderingConfig { // Either no mappings, or this is a radio-button selected freeform value const tag = new And([ new Tag(this.freeform.key, freeformValue), - ...(this.freeform.addExtraTags ?? []), + ...(this.freeform.addExtraTags ?? []) ]) const newProperties = tag.applyOn(currentProperties) if (this.invalidValues?.matchesProperties(newProperties)) { @@ -715,7 +719,7 @@ export default class TagRenderingConfig { selectedMappings.push( new And([ new Tag(this.freeform.key, freeformValue), - ...(this.freeform.addExtraTags ?? []), + ...(this.freeform.addExtraTags ?? []) ]) ) } @@ -743,12 +747,12 @@ export default class TagRenderingConfig { if (useFreeform) { return new And([ new Tag(this.freeform.key, freeformValue), - ...(this.freeform.addExtraTags ?? []), + ...(this.freeform.addExtraTags ?? []) ]) } else if (singleSelectedMapping !== undefined) { return new And([ this.mappings[singleSelectedMapping].if, - ...(this.mappings[singleSelectedMapping].addExtraTags ?? []), + ...(this.mappings[singleSelectedMapping].addExtraTags ?? []) ]) } else { console.error("TagRenderingConfig.ConstructSpecification has a weird fallback for", { @@ -756,7 +760,7 @@ export default class TagRenderingConfig { singleSelectedMapping, multiSelectedMapping, currentProperties, - useFreeform, + useFreeform }) return undefined @@ -771,8 +775,8 @@ export default class TagRenderingConfig { Link.OsmWiki(this.freeform.key), new Combine([ "This is rendered with ", - new FixedUiElement(this.render.txt).SetClass("code font-bold"), - ]), + new FixedUiElement(this.render.txt).SetClass("code font-bold") + ]) ] } @@ -785,8 +789,8 @@ export default class TagRenderingConfig { new Combine([ new FixedUiElement(m.then.txt).SetClass("font-bold"), " corresponds with ", - m.if.asHumanString(true, false, {}), - ]), + m.if.asHumanString(true, false, {}) + ]) ] if (m.hideInAnswer === true) { msgs.push("_This option cannot be chosen as answer_") @@ -794,7 +798,7 @@ export default class TagRenderingConfig { if (m.ifnot !== undefined) { msgs.push( "Unselecting this answer will add " + - m.ifnot.asHumanString(true, false, {}) + m.ifnot.asHumanString(true, false, {}) ) } return msgs @@ -809,7 +813,7 @@ export default class TagRenderingConfig { "This tagrendering is only visible in the popup if the following condition is met:", new FixedUiElement( (this.condition.optimize()).asHumanString(true, false, {}) - ).SetClass("code"), + ).SetClass("code") ]) } @@ -817,7 +821,7 @@ export default class TagRenderingConfig { if (this.labels?.length > 0) { labels = new Combine([ "This tagrendering has labels ", - ...this.labels.map((label) => new FixedUiElement(label).SetClass("code")), + ...this.labels.map((label) => new FixedUiElement(label).SetClass("code")) ]).SetClass("flex") } @@ -826,16 +830,16 @@ export default class TagRenderingConfig { this.description, this.question !== undefined ? new Combine([ - "The question is ", - new FixedUiElement(this.question.txt).SetClass("font-bold bold"), - ]) + "The question is ", + new FixedUiElement(this.question.txt).SetClass("font-bold bold") + ]) : new FixedUiElement( - "This tagrendering has no question and is thus read-only" - ).SetClass("italic"), + "This tagrendering has no question and is thus read-only" + ).SetClass("italic"), new Combine(withRender), mappings, condition, - labels, + labels ]).SetClass("flex flex-col") } @@ -860,4 +864,29 @@ export default class TagRenderingConfig { return Utils.NoNull(tags) } + +} + +export class TagRenderingConfigUtils { + + public static withNameSuggestionIndex(config: TagRenderingConfig, tags: UIEventSource>, feature?: Feature): Store { + if(config.freeform?.type !== "nsi"){ + return new ImmutableStore(config) + } + const extraMappings = tags.mapD(tags => tags._country).bindD(country => { + const [k, v] = ("" + config.freeform.helperArgs[0]).split("=") + const center = GeoOperations.centerpointCoordinates(feature) + return UIEventSource.FromPromise(NameSuggestionIndex.generateMappings(config.freeform.key, k, v, country.split(";"), center)) + }) + return extraMappings.map(extraMappings => { + if(!extraMappings || extraMappings.length == 0){ + return config + } + const clone: TagRenderingConfig = Object.create(config) + /// SHHHTTT, this is not cheating at all! + clone.mappings.splice(clone.mappings.length, 0, ...extraMappings) + return clone + }) + } + } diff --git a/src/UI/Base/Markdown.svelte b/src/UI/Base/Markdown.svelte index 4b91764ab..e6d33acc5 100644 --- a/src/UI/Base/Markdown.svelte +++ b/src/UI/Base/Markdown.svelte @@ -1,8 +1,9 @@ + + diff --git a/src/UI/Popup/TagRendering/TagRenderingMappingInput.svelte b/src/UI/Popup/TagRendering/TagRenderingMappingInput.svelte index 94f316c35..40d287f03 100644 --- a/src/UI/Popup/TagRendering/TagRenderingMappingInput.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingMappingInput.svelte @@ -28,11 +28,11 @@ export let mappingIsSelected: boolean /** - * If there are many mappings, we might hide it. + * If there are many mappings, we might hide it, e.g. because of search. * This is the searchterm where it might hide */ export let searchTerm: undefined | UIEventSource - + export let hideUnlessSearched = false $: { if (selectedElement !== undefined || mapping !== undefined) { searchTerm.setData(undefined) @@ -42,17 +42,21 @@ let matchesTerm: Store | undefined = searchTerm?.map( (search) => { + search = search?.trim() if (!search) { + if(hideUnlessSearched){ + if (mapping.priorityIf?.matchesProperties(tags.data)) { + return true + } + return false + } return true } if (mappingIsSelected) { return true } - search = search.toLowerCase() // There is a searchterm - this might hide the mapping - if (mapping.priorityIf?.matchesProperties(tags.data)) { - return true - } + search = search.toLowerCase() if (mapping.then.txt.toLowerCase().indexOf(search) >= 0) { return true } diff --git a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte index 14f255c63..91a036a95 100644 --- a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -293,6 +293,7 @@ let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined) let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0 let question = config.question + let hideMappingsUnlessSearchedFor = config.mappings.length > 8 && config.mappings.some(m => m.priorityIf) $: question = config.question if (state?.osmConnection) { onDestroy( @@ -335,7 +336,7 @@ {/if} - {#if config.mappings?.length >= 8} + {#if config.mappings?.length >= 8 || hideMappingsUnlessSearchedFor} + {#if hideMappingsUnlessSearchedFor} +
+ +
+ {/if} {/if} + + {#if config.freeform?.key && !(mappings?.length > 0)} > -export let selectedElement: Feature | undefined + +export let selectedElement: Feature export let state: SpecialVisualizationState -export let layer: LayerConfig = undefined +export let layer: LayerConfig | undefined +export let selectedTags: UploadableTag = undefined +export let extraTags: UIEventSource> = new UIEventSource({}) +export let allowDeleteOfFreeform: boolean = false -export let highlightedRendering: UIEventSource = undefined -export let clss = undefined let dynamicConfig = TagRenderingConfigUtils.withNameSuggestionIndex(config, tags, selectedElement) -