Localize base map labels for all Gramps languages (#1522)

The OpenFreeMap styles label places in English, or in Latin and local
script. Labels reading a name now show the name in the Gramps language,
falling back to the local name, in both the light and the dark style.
Regional Gramps language codes are mapped to the languages in the
tiles.

Fixes #1460
This commit is contained in:
David Straub
2026-10-12 18:29:16 +02:00
committed by GitHub
parent 6a0ae5a216
commit 5d68d1c563
2 changed files with 161 additions and 7 deletions
+55 -6
View File
@@ -48,6 +48,51 @@ export function getOhmNameLocales(lang) {
return OHM_NAME_LOCALES[lang] ?? [lang]
}
// OpenFreeMap name locales for Gramps languages whose code is not an
// OpenFreeMap name suffix. The tiles have names in a fixed set of languages
// without regional variants (name:pt, name:zh-Hans). Other Gramps languages
// use their code as is.
const BASE_NAME_LOCALES = {
de_AT: ['de'],
en_GB: ['en'],
nb: ['no'],
nn: ['no'],
pt_BR: ['pt'],
pt_PT: ['pt'],
zh_CN: ['zh-Hans', 'zh'],
zh_HK: ['zh-Hant', 'zh'],
zh_TW: ['zh-Hant', 'zh'],
}
// Returns the OpenFreeMap name locales for a Gramps language code.
export function getBaseNameLocales(lang) {
return BASE_NAME_LOCALES[lang] ?? [lang]
}
// Whether an expression reads a name property such as name, name_en or
// name:latin.
function readsName(expression) {
if (!Array.isArray(expression) || expression[0] === 'literal') return false
if (expression[0] === 'get') return /^name([:_]|$)/.test(expression[1])
return expression.slice(1).some(readsName)
}
// Returns a copy of an OpenFreeMap style with labels in the given locales.
// The style's own labels prefer English names, so each label reading a name
// is reset to the local name, which Diplomat then localizes.
export function localizeBaseStyle(style, locales) {
const localized = structuredClone(style)
localized.layers.forEach(layer => {
if (readsName(layer.layout?.['text-field'])) {
// eslint-disable-next-line no-param-reassign
layer.layout['text-field'] = ['get', 'name']
}
Diplomat.prepareLayer(layer)
})
Diplomat.localizeLayers(localized.layers, locales)
return localized
}
// Returns a copy of an OpenHistoricalMap style with labels in the given
// locales.
export function localizeOhmStyle(style, locales) {
@@ -380,11 +425,15 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) {
return Boolean(this._map?.style && this._map.isStyleLoaded())
}
get _locales() {
// The name locales of the Gramps language in the given style. Without
// browser locales, a place lacking a name in the Gramps language shows its
// local name.
_getLocales(style) {
const lang = this.appState?.i18n?.lang
// Without browser locales, a place lacking a name in the Gramps language
// shows its local name.
return lang ? getOhmNameLocales(lang) : Diplomat.getLocales()
if (!lang) return Diplomat.getLocales()
return style === MAP_STYLE_OHM
? getOhmNameLocales(lang)
: getBaseNameLocales(lang)
}
_handleLayerChange(e) {
@@ -418,7 +467,7 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) {
const style =
this.mapStyle === MAP_STYLE_OHM ? MAP_STYLE_OHM : MAP_STYLE_BASE
const url = this._getStyleUrl(style)
const locales = style === MAP_STYLE_OHM ? this._locales : []
const locales = this._getLocales(style)
const key = `${url} ${locales.join(',')}`
if (key === this._appliedKey) {
this._pendingKey = undefined
@@ -442,7 +491,7 @@ class GrampsjsMap extends GrampsjsAppStateMixin(LitElement) {
json =
style === MAP_STYLE_OHM
? localizeOhmStyle(json, locales)
: structuredClone(json)
: localizeBaseStyle(json, locales)
// Layers add themselves on the first load; later styles keep them.
const contributors = this._appliedKey
? this._slottedChildren.filter(
+106 -1
View File
@@ -1,7 +1,9 @@
import {afterEach, describe, it, expect, vi} from 'vitest'
import {
getBaseNameLocales,
getOhmNameLocales,
localizeBaseStyle,
localizeOhmStyle,
} from '../../src/components/GrampsjsMap.js'
import {frontendLanguages} from '../../src/strings.js'
@@ -75,8 +77,99 @@ describe('getOhmNameLocales', () => {
})
})
// The label expressions of the OpenFreeMap styles.
const OFM_NAME = [
'case',
['has', 'name:nonlatin'],
['concat', ['get', 'name:latin'], '\n', ['get', 'name:nonlatin']],
['coalesce', ['get', 'name_en'], ['get', 'name']],
]
const OFM_STYLE = {
version: 8,
sources: {},
layers: [
{id: 'background', type: 'background'},
{
id: 'label_city',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'place',
layout: {'text-field': OFM_NAME},
},
{
id: 'highway-name-major',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'transportation_name',
layout: {'text-field': OFM_NAME, 'symbol-placement': 'line'},
},
{
id: 'highway-shield',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'transportation_name',
layout: {'text-field': ['to-string', ['get', 'ref']]},
},
],
}
describe('localizeBaseStyle', () => {
it('localizes name labels in a copy', () => {
const original = structuredClone(OFM_STYLE)
const localized = localizeBaseStyle(OFM_STYLE, ['de'])
expect(OFM_STYLE).to.deep.equal(original)
const textField = JSON.stringify(localized.layers[1].layout['text-field'])
expect(textField).to.contain('"name:de"')
expect(textField).not.to.contain('name_en')
expect(textField).not.to.contain('name:latin')
})
it('reads the legacy German field for road names', () => {
const localized = localizeBaseStyle(OFM_STYLE, ['de'])
const textField = JSON.stringify(localized.layers[2].layout['text-field'])
expect(textField).to.contain('"name:de"')
expect(textField).to.contain('"name_de"')
})
it('keeps layers without name labels', () => {
const localized = localizeBaseStyle(OFM_STYLE, ['de'])
expect(localized.layers[0]).to.deep.equal(OFM_STYLE.layers[0])
expect(localized.layers[3]).to.deep.equal(OFM_STYLE.layers[3])
})
it('can localize an already localized style again', () => {
const german = localizeBaseStyle(OFM_STYLE, ['de'])
const french = localizeBaseStyle(german, ['fr'])
const textField = JSON.stringify(french.layers[1].layout['text-field'])
expect(textField).to.contain('"name:fr"')
expect(textField).not.to.contain('"name:de"')
})
})
describe('getBaseNameLocales', () => {
it('maps regional languages to the languages in the tiles', () => {
expect(getBaseNameLocales('pt_BR')).to.deep.equal(['pt'])
expect(getBaseNameLocales('zh_TW')).to.deep.equal(['zh-Hant', 'zh'])
expect(getBaseNameLocales('nn')).to.deep.equal(['no'])
})
it('keeps plain language codes', () => {
expect(getBaseNameLocales('de')).to.deep.equal(['de'])
})
it('gives every frontend language valid locale tags', () => {
frontendLanguages.forEach(lang => {
const locales = getBaseNameLocales(lang)
locales.forEach(locale => expect(locale).not.to.contain('_'))
// The first locale is the collation locale.
expect(() => new Intl.Collator(locales[0])).not.to.throw()
})
})
})
describe('map style switching', () => {
const BASE = {version: 8, name: 'base', sources: {}, layers: []}
const BASE = {...OFM_STYLE, name: 'base'}
const originalFetch = globalThis.fetch
afterEach(() => {
@@ -199,6 +292,18 @@ describe('map style switching', () => {
expect(JSON.stringify(applied[2].layers[1])).to.contain('name_fr')
})
it('localizes the base map and relocalizes it when the language changes', async () => {
const {map, applied} = await createBaseMap({
ohm: deferred(OHM),
base: deferred(BASE),
})
expect(JSON.stringify(applied[0].layers[1])).to.contain('"name:de"')
map.appState = {...map.appState, i18n: {lang: 'fr', strings: {}}}
await map._syncStyle()
expect(applied).to.have.length(2)
expect(JSON.stringify(applied[1].layers[1])).to.contain('"name:fr"')
})
it('reports a failed load and selects the style still shown', async () => {
const ohm = deferred(null)
ohm.resolve()