UNPKG

name-suggestion-index

Version:

Canonical common brand names for OpenStreetMap

388 lines (333 loc) 15.7 kB
import { sortObject } from './sort_object.ts'; import type { Preset, Presets } from '@openstreetmap/id-tagging-schema'; import type { DissolvedMap, NsiData, NsiPath, NsiTree, NsiTreeProperties, OsmTags, IDPreset, WikidataMap } from './types.ts'; const withLocale = new Intl.Collator('en-US').compare; // specify 'en-US' for stable sorting // Imported JSON (will be inlined by bun) import treesJSON from '../config/trees.json' with {type: 'json'}; const trees: Record<NsiTree, NsiTreeProperties> = treesJSON.trees; /** Options for {@link buildIDPresets}. */ export interface BuildIDPresetsOptions { /** The id-tagging-schema source presets dictionary (the `presets` field of `presets.json`). */ sourcePresets: Presets; /** Map of QID → wikidata info, used to source preset `imageURL` values. */ wikidata?: WikidataMap; /** Map of NSI item id → dissolved record. Items present here become non-searchable. */ dissolved?: DissolvedMap; } /** Result of {@link buildIDPresets}. */ export interface BuildIDPresetsResult { /** The generated iD/Rapid presets, keyed by `<presetID>/<itemID>`. */ presets: Record<string, IDPreset>; /** Sorted list of NSI `tree/key/value` paths for which no source iD preset was found. */ missing: NsiPath[]; } /** Result of {@link pickBestChildPreset}. */ export interface PickBestChildPresetResult { /** The selected PresetID, if any */ presetID?: string; /** The selected Preset, if any */ preset?: Preset; } // Exceptions where the NSI `key/value` doesn't match the iD preset path `key/value`. // See also https://github.com/openstreetmap/iD/issues/11527 // id-tagging-schema occasionally moves their presets around, changing their presetIDs. const presetPathOverrides: Record<string, string> = { 'highway/bus_stop': 'public_transport/platform/bus_point', 'amenity/ferry_terminal': 'public_transport/station_ferry', 'amenity/college': 'education/college', 'amenity/driving_school': 'education/driving_school', 'amenity/dancing_school': 'education/dancing_school', 'amenity/kindergarten': 'education/kindergarten', 'amenity/language_school': 'education/language_school', 'amenity/music_school': 'education/music_school', 'amenity/prep_school': 'education/prep_school', 'amenity/school': 'education/school', 'amenity/university': 'education/university', 'emergency/water_rescue': 'emergency/lifeboat_station' }; // Tags that NSI allows to process as multi-valued const semicolonSplittedKeys = ['beauty', 'clothes', 'cuisine', 'healthcare:speciality', 'social_facility', 'sport', 'vending', 'waste']; // Prefer a wiki commons logo for these QIDs. // Related issues list: iD#6361, NSI#2798, NSI#3122, NSI#8042, NSI#8373 const preferCommons: Record<string, boolean> = { Q177054: true, // Burger King Q524757: true, // KFC Q779845: true, // CBA Q1205312: true, // In-N-Out Q10443115: true, // Carlings Q38076: true // McDonald's }; /** * Resolves the iD preset path to search under for a given NSI path. * @param tkv - The NSI `tree/key/value` path * @param k - The OSM key * @param v - The OSM value * @param kv - The combined `key/value` string * @param ferryIndex - Counter for the ferry presets: 0 → `type/route/ferry` (relation), 1 → `route/ferry` (way) * @returns The iD preset path string to search under */ function resolvePresetPath(tkv: NsiPath, k: string, v: string, kv: string, ferryIndex: number): string { if (tkv === 'transit/route/ferry') { return ferryIndex === 0 ? 'type/route/ferry' : 'route/ferry'; } if (k === 'route') return `type/route/${v}`; return presetPathOverrides[kv] || kv; } /** * Picks the most specific iD preset matching an NSI item's tags. * @param childPresets - Map of presetID → Preset for all candidates under the resolved path * @param tags - The OSM tags of the NSI item * @returns The best-matching presetID and Preset, or an empty object if none matched */ function pickBestChildPreset(childPresets: Map<string, Preset>, tags: OsmTags): PickBestChildPresetResult { if (childPresets.size === 0) return {}; if (childPresets.size === 1) { const entry = childPresets.entries().next().value; return entry ? { presetID: entry[0], preset: entry[1] } : {}; } // The best iD preset for an NSI entry is determined by count of tags that have // matched (more is better) and position for multi-value tags (e.g. cuisine) let matchTagsCount = 0; let matchSemicolonRating = 0; let matchPresetPath: string | undefined; let matchPreset: Preset | undefined; for (const [checkPresetPath, checkPreset] of childPresets) { const checkPresetTags = Object.entries(checkPreset.tags as OsmTags); let currentMatchSemicolonRating = 0; const isPresetMatch = checkPresetTags.every(kv => { const osmKey = kv[0]; const osmVal = kv[1]; const nsiVal = tags[osmKey]; if (!nsiVal) return false; if (semicolonSplittedKeys.includes(osmKey)) { const vals = nsiVal.split(';'); const findResult = vals.indexOf(osmVal); if (findResult === -1) return false; // For a smaller element index rating will be higher currentMatchSemicolonRating -= findResult; return true; } return (osmVal === nsiVal); }); // If rating of current element is higher than the saved one, we overwrite saved if (isPresetMatch && ( (checkPresetTags.length > matchTagsCount) || (checkPresetTags.length === matchTagsCount && currentMatchSemicolonRating > matchSemicolonRating) )) { matchTagsCount = checkPresetTags.length; matchSemicolonRating = currentMatchSemicolonRating; matchPresetPath = checkPresetPath; matchPreset = checkPreset; } } return matchPreset && matchPresetPath ? { presetID: matchPresetPath, preset: matchPreset } : {}; } /** * Picks a logo URL from wikidata for a given QID. We generally prefer to use the Facebook * account's avatar url as the logo, unless the QID appears in the `preferCommons` object. * @param qid - The Wikidata QID (e.g. `'Q177054'`) * @param wikidata - Map of QID → wikidata info containing logo URLs * @returns The logo URL string, or `undefined` if none is available */ function pickLogoURL(qid: string, wikidata: WikidataMap): string | undefined { const logoURLs = wikidata[qid] && wikidata[qid].logos; if (!logoURLs) return undefined; if (logoURLs.wikidata && preferCommons[qid]) return logoURLs.wikidata; if (logoURLs.facebook) return logoURLs.facebook; return logoURLs.wikidata; } /** * Collects search terms for an NSI item — its matchNames plus name-like tag values. * @param item - NSI item with optional `matchNames` and `tags` * @param primaryName - Regex matching primary name-like OSM keys (e.g. `name`, `brand`) * @param alternateName - Regex matching alternate name-like OSM keys (e.g. `alt_name`) * @param notName - Regex matching OSM key suffixes that are not name-like (e.g. `:type`, `:wikidata`) * @returns Set of lowercase search terms derived from the item */ function collectTerms( item: { matchNames?: string[]; tags: OsmTags }, primaryName: RegExp, alternateName: RegExp, notName: RegExp ): Set<string> { const terms = new Set(item.matchNames || []); for (const osmkey of Object.keys(item.tags)) { if (osmkey === 'name') continue; // Can exclude the `name` tag, as iD prioritizes it above `preset.terms` already if (notName.test(osmkey)) continue; // osmkey is not a namelike tag, skip if (primaryName.test(osmkey) || alternateName.test(osmkey)) { terms.add(item.tags[osmkey].toLowerCase()); } } return terms; } /** * Returns the `fields` array for an NSI preset when `^name` is being preserved. * * If we're preserving the `name` tag, make sure both "name" and "brand"/"operator" * fields are shown. This triggers iD to lock the brand/operator field but allow * edits to the "name" field. * @param t - The NSI tree name (e.g. 'brands', 'operators') * @param preset - The source iD preset, optionally carrying an `originalFields` list * @param preserveTags - Tag patterns being preserved for this item (e.g. `['^name']`) * @returns The `fields` array to use on the generated NSI preset */ function buildFields(t: string, preset: Preset & { originalFields?: string[] }, preserveTags: string[]): string[] { // `originalFields` is a workaround for an iD bug, see iD#12610 // We'll prefer it over `fields` to be backwards compatible with old versions // of iD (released between May 2026 and July 2026). This can eventually be // removed once there is no one using these old versions of iD. const fields = preset.originalFields || preset.fields || []; if (!preserveTags.some(s => s === '^name')) return fields; if (t === 'brands') return ['name', 'brand', ...fields]; if (t === 'operators') return ['name', 'operator', ...fields]; return fields; } /** * Build iD/Rapid presets from NSI data. * * This is a pure function: it does no I/O, performs no console output, and does not * mutate any of its inputs. Suitable for use in browser-based downstream projects * that fetch the NSI data on-the-fly. * * @param data - NSI category data indexed by `tree/key/value` path (the `_nsi.path` cache, or `nsi.json`'s `nsi` field) * @param opts - Sources for id-tagging-schema presets, wikidata logos, and dissolutions * @returns the generated presets plus a list of paths missing a source iD preset */ export function buildIDPresets(data: NsiData, opts: BuildIDPresetsOptions): BuildIDPresetsResult { const sourcePresets = opts.sourcePresets; const wikidata = opts.wikidata || {}; const dissolved = opts.dissolved || {}; // // First we'll match every NSI item to a source iD preset. // The source iD presets look like this: // // "amenity": { // "name": "Amenity" // "fields": […], // "geometry": […], // "tags": { // "amenity": "*" // }, // "searchable": false // }, // "amenity/fast_food": { // "name": "Fast Food", // "icon": "maki-fast-food", // "fields": […], // "geometry": […], // "terms": […], // "tags": { // "amenity": "fast_food" // } // }, // "amenity/fast_food/sandwich": { // "name": "Sandwich Fast Food", // "icon": "temaki-sandwich", // "fields": […], // "geometry": […], // "terms": […], // "tags": { // "amenity": "fast_food", // "cuisine": "sandwich" // } // }, // // There are a few special behaviors in the iD presets are important to us: // - They each have stable identifiers like `key`, `key/value`, `key/value/anothervalue` // - Presets with increasing specificity "inherit" fields from presets of less specificity // (e.g. the sandwich fast food preset inherits all the fields of the regular fast food preset) // - We can generate presets with NSI identifiers that hang off the end of this specificity chain // (e.g. "amenity/fast_food/sandwich/arbys-3c08fb") // - NSI identifiers will not collide with the preset identifiers (NSI ids don't look like tag values) // const targetPresets: Record<string, Preset> = {}; const missing = new Set<string>(); const paths = Object.keys(data); // Ferry hack! ⛴ // Append a duplicate tkv path for Ferry routes so we can generate them twice.. // These actually exist as 2 iD presets: // `type/route/ferry` - for a Route Relation // `route/ferry` - for a Way let ferryCount = 0; if (data['transit/route/ferry']) { paths.push('transit/route/ferry'); // add a duplicate tkv } for (const tkv of paths.sort(withLocale) as NsiPath[]) { const properties = data[tkv].properties || {}; const items = data[tkv].items; if (!Array.isArray(items) || !items.length) continue; const [t, k, v] = tkv.split('/', 3); // tkv = "tree/key/value" const tree = trees[t as NsiTree]; const kv = `${k}/${v}`; // Ferry hack! ⛴ - duplicated tkv generates `type/route/ferry` then `route/ferry` const ferryIndex = (tkv === 'transit/route/ferry') ? ferryCount++ : 0; const presetPath = resolvePresetPath(tkv, k, v, kv, ferryIndex); // Which wikidata tag is considered the "main" tag for this tree? const wdTag = tree.mainTag; // Primary/alternate names may be used as preset search terms const primaryName = new RegExp(tree.nameTags.primary, 'i'); const alternateName = new RegExp(tree.nameTags.alternate, 'i'); // There are a few exceptions to the name matching regexes. // Usually a tag suffix contains a language code like `name:en`, `name:ru` // but we want to exclude things like `operator:type`, `name:etymology`, etc.. // NOTE: here we intentionally exclude `:wikidata`, in `matcher.ts` we do not. const notName = /:(colour|type|left|right|etymology|pronunciation|wikipedia|wikidata)$/i; // Look for iD presets that would fit this NSI presetPath. const childPresets = new Map<string, Preset>(); for (const checkPath in sourcePresets) { if (checkPath.startsWith(presetPath)) { childPresets.set(checkPath, sourcePresets[checkPath]); } } for (const item of items) { const tags = item.tags; const qid = tags[wdTag]; if (!qid || !/^Q\d+$/.test(qid)) continue; // wikidata tag missing or looks wrong.. // Sometimes we can choose a more specific iD preset than `key/value`, // otherwise fall back to a generic like `amenity/yes`, `shop/yes`. let { presetID, preset } = pickBestChildPreset(childPresets, tags); if (!preset) { presetID = k; preset = sourcePresets[presetID]; missing.add(tkv); } if (!preset) continue; // *still* no match - bail out // Gather search terms - include all primary/alternate names and matchNames // (There is similar code in lib/matcher.ts) const terms = collectTerms(item, primaryName, alternateName, notName); // generate our target preset const targetID = `${presetID}/${item.id}`; const targetTags: OsmTags = {}; targetTags[wdTag] = tags[wdTag]; // add the `*:wikidata` tag for (const presetKey in preset.tags) { // prioritize NSI tags over iD preset tags (for `vending`, `cuisine`, etc) targetTags[presetKey] = tags[presetKey] || preset.tags[presetKey]; } const logoURL = pickLogoURL(qid, wikidata); const preserveTags = item.preserveTags || properties.preserveTags || []; const targetPreset: IDPreset = { name: item.displayName, locationSet: item.locationSet as {} & IDPreset['locationSet'], icon: preset.icon!, geometry: preset.geometry, fields: buildFields(t, preset, preserveTags), moreFields: preset.moreFields || [], tags: sortObject(targetTags), matchScore: 2 }; if (logoURL) targetPreset.imageURL = logoURL; if (terms.size) targetPreset.terms = Array.from(terms).sort(withLocale); if (preset.reference) targetPreset.reference = preset.reference; if (dissolved[item.id]) targetPreset.searchable = false; // dissolved/closed businesses if (preserveTags.length) targetPreset.preserveTags = preserveTags; // see NSI#10083 targetPreset.tags = sortObject(targetTags) as OsmTags; targetPreset.addTags = sortObject(Object.assign({}, item.tags, targetTags)) as OsmTags; targetPresets[targetID] = targetPreset; } } return { presets: targetPresets, missing: Array.from(missing).sort(withLocale) }; }