doxa-maps/library/maps/localize-labels.js
Doxa Maps 5f053d992c The galaxy basemap was localizable all along: its label keys use an underscore, not a colon
exp-shepherd found research-dashboard rendering ENDONYMS in every locale — 中国 and Türkiye on a Russian
page — and fetching a glyph range for every script on screen. The reading was that its basemap carries no
localized names and the choice was to swap the basemap or give up on its labels. Measured against the live
tiles, neither is necessary: the names are there, under keys this chain never tried.

The galaxy style is VersaTiles Colorful on the SHORTBREAD schema, whose label keys are name_ar / name_de /
name_en, not name:ar. This coalesce only tried the colon spelling, so all 43 of that style's label layers
fell through to `name` — the local name — while the localizer ran and reported success. A running fix that
cannot reach its data looks exactly like a missing feature.

MEASURED, six z3/z4 tiles across Europe, Asia and Africa, identical key sets on every one: name_ar,
name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl, name_pt, name_uk. Of the eleven
locales we ship that is SEVEN in their own language — ar, de, en, es, fr, it, pt — with no change to the
basemap the Driver chose and no change to how the map looks.

hi, ro, ru and zh are genuinely absent from those tiles, so they now take name_en: one Latin script rather
than mixed CJK and Devanagari endonyms, which is also the single-script glyph saving. That is the same role
name:latin already plays for the OpenFreeMap styles, so it sits in the same position in the chain rather
than becoming a special case.

NOTHING CHANGES FOR THE OPENFREEMAP STYLES, by construction: the colon spelling stays first, so their
name:<lang> or name:latin still wins and the underscore entries are never reached. The order is also what
keeps the already-rewritten sentinel working — it tests for a chain beginning ["coalesce",["get","name:,
which this still is. Verified both, rather than assumed.

WHAT I DID NOT DO: swap the basemap. galaxy resolves to VersaTiles Colorful because the Driver asked for
blue oceans like the Mapbox map and that style's water measured closest (#bfd9f2; OpenFreeMap Liberty is
#9ebdff, noticeably stronger). Trading a look he chose for four locales' labels is his call, not mine, and
with this change those four get readable Latin labels anyway.

Verified: gates:pre 12/12. NOT measured here: glyph KB per locale, which needs a browser — rega has the
instrument and the before numbers.
2026-09-28 05:30:58 +00:00

98 lines
5.6 KiB
JavaScript

/**
* localize-labels.js — show the BASEMAP's place names in the reader's language, on both engines.
*
* WHY. Every map translated its own chrome but left the basemap's country, city and street names in
* the style's default (English for Mapbox, Latin + native script for OpenFreeMap), in all eleven
* locales. A reader in Arabic saw an Arabic legend over an English map.
*
* TWO ENGINES, TWO MECHANISMS — each the engine's own supported one:
* Mapbox map.setLanguage(lang). The engine localizes its own styles, including the Standard
* style's IMPORTED basemap, which a layer-by-layer rewrite cannot reach.
* MapLibre OpenFreeMap styles label from OSM name tags, so each basemap symbol layer's text-field
* is pointed at ['coalesce', name:<lang>, name:latin, name]: the translated name where
* OSM has one, else the style's Latin name, else the local name. English restores each
* layer's ORIGINAL expression (remembered per map), so switching back loses nothing.
*
* ★ ONLY THE BASEMAP'S LABELS. Plugins add their own symbol layers (zone titles, people-group names,
* counts) that read their OWN properties. A layer is rewritten only when its text-field reads an OSM
* name field (name, name:latin, name:nonlatin, name_en, name_int, name:en). Anything else is not a
* basemap label and is never touched.
*
* Fail-soft everywhere: a label that stays in the default language is a nicety lost, never a broken
* map.
*/
const OSM_NAME_FIELD = /\[\s*"get"\s*,\s*"name(?::latin|:nonlatin|:en|_en|_int)?"\s*\]|\{name(?::latin|:nonlatin|:en|_en|_int)?\}/
const originals = new WeakMap() // map -> Map(layerId -> original text-field)
function isMaplibreMap(map) {
return typeof window !== 'undefined' && !!window.maplibregl && map instanceof window.maplibregl.Map
}
/**
* Point the basemap's place-name labels at `lang` (every language, English included).
* Call after style.load, and again whenever the reader's language changes.
* @returns {number} how many label layers were localized (0 on Mapbox, which localizes internally)
*/
export function localizeBasemapLabels(map, lang) {
if (!map || !lang) return 0
const code = String(lang).split('-')[0].toLowerCase()
try {
if (!isMaplibreMap(map)) {
if (typeof map.setLanguage === 'function') map.setLanguage(code)
return 0
}
const style = map.getStyle && map.getStyle()
if (!style || !Array.isArray(style.layers)) return 0
let saved = originals.get(map)
if (!saved) { saved = new Map(); originals.set(map, saved) }
let n = 0
for (const layer of style.layers) {
if (layer.type !== 'symbol' || !layer.layout || layer.layout['text-field'] == null) continue
const current = layer.layout['text-field']
const json = JSON.stringify(current)
// Our own expression is recognisable by shape. Anything ELSE is the style's current original —
// re-saved every time, so a setStyle() that reuses layer ids with new expressions (a theme
// switch) never restores the PREVIOUS style's labels.
if (!json.startsWith('["coalesce",["get","name:')) {
if (!OSM_NAME_FIELD.test(json)) continue // a plugin's own label: leave it
saved.set(layer.id, current)
} else if (!saved.has(layer.id)) continue
// English too, not the style's own bilingual labels ("Россия" under "Russia"): those drew every
// native script at world view, so an English page fetched 13 extra glyph ranges (~600 KB, rega:
// Prayer Progress 18 requests / 777 KB in en against 5 / 185 KB in ar). One language per label.
// ★ AND THE UNDERSCORE SPELLING, because one of our basemaps writes it that way. The galaxy
// style (research-dashboard, my-upg-100-list) is VersaTiles Colorful on the Shortbread schema,
// whose label keys are name_ar / name_de / name_en …, NOT name:ar. This chain only tried the
// colon spelling, so every one of its 43 label layers coalesced down to `name` — the ENDONYM —
// and a Russian page rendered 中国 and Türkiye while fetching a glyph range for every script on
// screen. The localizer was running and could not help, which is the failure that looks like a
// basemap problem and is really a key-name problem.
//
// MEASURED against the live tiles (six z3/z4 tiles across Europe, Asia and Africa, identical key
// sets): name_ar, name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl,
// name_pt, name_uk. Of the eleven locales we ship that covers SEVEN — ar, de, en, es, fr, it, pt
// — in their own language, with no change to the basemap the Driver chose.
//
// hi, ro, ru and zh are genuinely absent from those tiles, so they take name_en: Latin script,
// one script, readable rather than mixed CJK and Devanagari endonyms. That is the same role
// name:latin already plays for the OpenFreeMap styles, which is why it sits in the same position
// rather than being a special case.
//
// Order matters and is not arbitrary: the colon spelling first so the OpenFreeMap styles behave
// exactly as before (their first entry still wins and nothing about them changes), then the
// underscore, then the two Latin fallbacks, then the local name.
const next = ['coalesce',
['get', 'name:' + code], ['get', 'name_' + code],
['get', 'name:latin'], ['get', 'name_en'],
['get', 'name']]
map.setLayoutProperty(layer.id, 'text-field', next)
n++
}
return n
} catch (_) {
return 0
}
}
export default localizeBasemapLabels