name-suggestion-index
Version:
Canonical common brand names for OpenStreetMap
388 lines (333 loc) • 15.7 kB
text/typescript
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)
};
}