mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat/localize map(#1017) * feat :map localization * fix: address map-localizatio
This commit is contained in:
9
public/mapbox-gl-rtl-text.min.js
vendored
Normal file
9
public/mapbox-gl-rtl-text.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -50,6 +50,7 @@ import { escapeHtml } from '@/utils/sanitize';
|
||||
import { tokenizeForMatch, matchKeyword, matchesAnyKeyword, findMatchingKeywords } from '@/utils/keyword-match';
|
||||
import { t } from '@/services/i18n';
|
||||
import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index';
|
||||
import { localizeMapLabels } from '@/utils/map-locale';
|
||||
import {
|
||||
INTEL_HOTSPOTS,
|
||||
CONFLICT_ZONES,
|
||||
@@ -424,6 +425,7 @@ export class DeckGLMap {
|
||||
this.initMapLibre();
|
||||
|
||||
this.maplibreMap?.on('load', () => {
|
||||
localizeMapLabels(this.maplibreMap);
|
||||
this.rebuildTechHQSupercluster();
|
||||
this.rebuildDatacenterSupercluster();
|
||||
this.initDeck();
|
||||
@@ -482,6 +484,16 @@ export class DeckGLMap {
|
||||
}
|
||||
|
||||
private initMapLibre(): void {
|
||||
// Load the RTL text plugin for correct Arabic/Hebrew glyph joining.
|
||||
// Self-hosted in public/ to avoid CSP issues with external CDN scripts.
|
||||
// Lazy-loaded — only fetched when a RTL text-field is actually rendered.
|
||||
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
|
||||
maplibregl.setRTLTextPlugin(
|
||||
'/mapbox-gl-rtl-text.min.js',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const preset = VIEW_PRESETS[this.state.view];
|
||||
const initialTheme = getCurrentTheme();
|
||||
|
||||
@@ -4748,6 +4760,7 @@ export class DeckGLMap {
|
||||
// setStyle() replaces all sources/layers — reset guard so country layers are re-added
|
||||
this.countryGeoJsonLoaded = false;
|
||||
this.maplibreMap.once('style.load', () => {
|
||||
localizeMapLabels(this.maplibreMap);
|
||||
this.loadCountryBoundaries();
|
||||
this.updateCountryLayerPaint(theme);
|
||||
// Re-render deck.gl overlay after style swap — interleaved layers need
|
||||
|
||||
@@ -453,6 +453,9 @@ export class UnifiedSettings {
|
||||
html += `<option value="${lang.code}"${selected}>${lang.flag} ${lang.label}</option>`;
|
||||
}
|
||||
html += `</select>`;
|
||||
if (currentLang === 'vi') {
|
||||
html += `<div class="ai-flow-toggle-desc">${t('components.languageSelector.mapLabelsFallbackVi')}</div>`;
|
||||
}
|
||||
|
||||
// Data Management section
|
||||
html += `<div class="ai-flow-section-label">${t('components.settings.dataManagementLabel')}</div>`;
|
||||
|
||||
@@ -1587,7 +1587,8 @@
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"languageSelector": {
|
||||
"selectLanguage": "Select Language"
|
||||
"selectLanguage": "Select Language",
|
||||
"mapLabelsFallbackVi": "Map labels currently fall back to English for Vietnamese."
|
||||
},
|
||||
"serviceStatus": {
|
||||
"checkingServices": "Checking services...",
|
||||
@@ -2333,4 +2334,3 @@
|
||||
"all": "All"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1346,7 +1346,8 @@
|
||||
"openSettings": "Mở Cài đặt"
|
||||
},
|
||||
"languageSelector": {
|
||||
"selectLanguage": "Chọn Ngôn ngữ"
|
||||
"selectLanguage": "Chọn Ngôn ngữ",
|
||||
"mapLabelsFallbackVi": "Nhãn bản đồ hiện sẽ dùng tiếng Anh khi chọn tiếng Việt."
|
||||
},
|
||||
"serviceStatus": {
|
||||
"checkingServices": "Đang kiểm tra dịch vụ...",
|
||||
|
||||
104
src/utils/map-locale.ts
Normal file
104
src/utils/map-locale.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { getCurrentLanguage } from '@/services/i18n';
|
||||
|
||||
const LANG_TO_TILE_FIELD: Record<string, string> = {
|
||||
en: 'name:en',
|
||||
bg: 'name:bg',
|
||||
cs: 'name:cs',
|
||||
fr: 'name:fr',
|
||||
de: 'name:de',
|
||||
el: 'name:el',
|
||||
es: 'name:es',
|
||||
it: 'name:it',
|
||||
pl: 'name:pl',
|
||||
pt: 'name:pt',
|
||||
nl: 'name:nl',
|
||||
sv: 'name:sv',
|
||||
ru: 'name:ru',
|
||||
ar: 'name:ar',
|
||||
zh: 'name:zh',
|
||||
ja: 'name:ja',
|
||||
ko: 'name:ko',
|
||||
ro: 'name:ro',
|
||||
tr: 'name:tr',
|
||||
th: 'name:th',
|
||||
// vi — not available in CARTO Streets v1 tiles
|
||||
};
|
||||
|
||||
type Expression = [string, ...unknown[]];
|
||||
|
||||
interface MapStyleLayer {
|
||||
id: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface MapStyle {
|
||||
layers?: MapStyleLayer[];
|
||||
}
|
||||
|
||||
interface LocalizableMap {
|
||||
getStyle?: () => MapStyle | null | undefined;
|
||||
getLayoutProperty?: (layerId: string, property: 'text-field') => unknown;
|
||||
setLayoutProperty?: (layerId: string, property: 'text-field', value: Expression) => void;
|
||||
}
|
||||
|
||||
export function getLocalizedNameField(lang?: string): string {
|
||||
const code = lang ?? getCurrentLanguage();
|
||||
return LANG_TO_TILE_FIELD[code] ?? 'name:en';
|
||||
}
|
||||
|
||||
export function getLocalizedNameExpression(lang?: string): Expression {
|
||||
const field = getLocalizedNameField(lang);
|
||||
|
||||
if (field === 'name:en') {
|
||||
return ['coalesce', ['get', 'name:en'], ['get', 'name']];
|
||||
}
|
||||
|
||||
return ['coalesce', ['get', field], ['get', 'name:en'], ['get', 'name']];
|
||||
}
|
||||
|
||||
export function isLocalizableTextField(textField: unknown): boolean {
|
||||
if (!textField) return false;
|
||||
|
||||
if (typeof textField === 'string') {
|
||||
return /\{name[^}]*\}/.test(textField);
|
||||
}
|
||||
|
||||
if (typeof textField === 'object') {
|
||||
const s = JSON.stringify(textField);
|
||||
const hasName =
|
||||
s.includes('"name"') ||
|
||||
s.includes('"name:') ||
|
||||
s.includes('"name_en"') ||
|
||||
s.includes('"name_int"') ||
|
||||
s.includes('{name');
|
||||
return hasName;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function localizeMapLabels(map: LocalizableMap | null | undefined): void {
|
||||
if (!map) return;
|
||||
|
||||
const style = map?.getStyle?.();
|
||||
if (!style?.layers) return;
|
||||
|
||||
const expr = getLocalizedNameExpression();
|
||||
|
||||
for (const layer of style.layers) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
|
||||
let textField: unknown;
|
||||
try {
|
||||
textField = map.getLayoutProperty?.(layer.id, 'text-field');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isLocalizableTextField(textField)) continue;
|
||||
|
||||
try {
|
||||
map.setLayoutProperty?.(layer.id, 'text-field', expr);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
416
tests/map-locale.test.mts
Normal file
416
tests/map-locale.test.mts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transformSync } from 'esbuild';
|
||||
|
||||
async function loadMapLocale(defaultLang = 'en') {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = resolve(__dirname, '..', 'src', 'utils', 'map-locale.ts');
|
||||
const source = readFileSync(sourcePath, 'utf-8');
|
||||
const patched = source.replace(
|
||||
"import { getCurrentLanguage } from '@/services/i18n';",
|
||||
`const getCurrentLanguage = () => '${defaultLang}';`,
|
||||
);
|
||||
const transformed = transformSync(patched, {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'es2020',
|
||||
});
|
||||
const dataUrl = `data:text/javascript;base64,${Buffer.from(transformed.code).toString('base64')}`;
|
||||
return import(dataUrl);
|
||||
}
|
||||
|
||||
// Load module twice: once for English (default) and once for Arabic (non-Latin, RTL)
|
||||
const enMod = await loadMapLocale('en');
|
||||
const arMod = await loadMapLocale('ar');
|
||||
|
||||
const {
|
||||
getLocalizedNameField,
|
||||
getLocalizedNameExpression,
|
||||
isLocalizableTextField,
|
||||
localizeMapLabels,
|
||||
} = enMod;
|
||||
|
||||
// ── getLocalizedNameField ───────────────────────────────────────────
|
||||
|
||||
describe('getLocalizedNameField', () => {
|
||||
it('returns mapped tile field for supported language', () => {
|
||||
assert.equal(getLocalizedNameField('ko'), 'name:ko');
|
||||
});
|
||||
|
||||
it('falls back to name:en for unsupported language', () => {
|
||||
assert.equal(getLocalizedNameField('xx'), 'name:en');
|
||||
});
|
||||
|
||||
it('falls back to name:en for Vietnamese (no CARTO tile field)', () => {
|
||||
assert.equal(getLocalizedNameField('vi'), 'name:en');
|
||||
});
|
||||
|
||||
it('returns correct field for every mapped language', () => {
|
||||
const expected: Record<string, string> = {
|
||||
en: 'name:en', bg: 'name:bg', cs: 'name:cs', fr: 'name:fr',
|
||||
de: 'name:de', el: 'name:el', es: 'name:es', it: 'name:it',
|
||||
pl: 'name:pl', pt: 'name:pt', nl: 'name:nl', sv: 'name:sv',
|
||||
ru: 'name:ru', ar: 'name:ar', zh: 'name:zh', ja: 'name:ja',
|
||||
ko: 'name:ko', ro: 'name:ro', tr: 'name:tr', th: 'name:th',
|
||||
};
|
||||
for (const [lang, field] of Object.entries(expected)) {
|
||||
assert.equal(getLocalizedNameField(lang), field, `lang=${lang}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to name:en for empty string', () => {
|
||||
assert.equal(getLocalizedNameField(''), 'name:en');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLocalizedNameExpression ───────────────────────────────────────
|
||||
|
||||
describe('getLocalizedNameExpression', () => {
|
||||
it('returns simplified English coalesce expression', () => {
|
||||
assert.deepEqual(
|
||||
getLocalizedNameExpression('en'),
|
||||
['coalesce', ['get', 'name:en'], ['get', 'name']],
|
||||
);
|
||||
});
|
||||
|
||||
it('returns localized-first coalesce expression for non-English language', () => {
|
||||
assert.deepEqual(
|
||||
getLocalizedNameExpression('fr'),
|
||||
['coalesce', ['get', 'name:fr'], ['get', 'name:en'], ['get', 'name']],
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 3-element coalesce for CJK languages', () => {
|
||||
for (const lang of ['zh', 'ja', 'ko']) {
|
||||
const expr = getLocalizedNameExpression(lang);
|
||||
assert.equal(expr.length, 4, `lang=${lang} should have coalesce + 3 gets`);
|
||||
assert.deepEqual(expr[1], ['get', `name:${lang}`]);
|
||||
assert.deepEqual(expr[2], ['get', 'name:en']);
|
||||
assert.deepEqual(expr[3], ['get', 'name']);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 3-element coalesce for Arabic (RTL)', () => {
|
||||
const expr = getLocalizedNameExpression('ar');
|
||||
assert.deepEqual(expr, ['coalesce', ['get', 'name:ar'], ['get', 'name:en'], ['get', 'name']]);
|
||||
});
|
||||
|
||||
it('Vietnamese falls back to English expression (no tile field)', () => {
|
||||
assert.deepEqual(
|
||||
getLocalizedNameExpression('vi'),
|
||||
['coalesce', ['get', 'name:en'], ['get', 'name']],
|
||||
);
|
||||
});
|
||||
|
||||
it('unknown language falls back to English expression', () => {
|
||||
assert.deepEqual(
|
||||
getLocalizedNameExpression('xx'),
|
||||
['coalesce', ['get', 'name:en'], ['get', 'name']],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses getCurrentLanguage() when no arg is passed (English module)', () => {
|
||||
// enMod was loaded with getCurrentLanguage = () => 'en'
|
||||
const expr = enMod.getLocalizedNameExpression();
|
||||
assert.deepEqual(expr, ['coalesce', ['get', 'name:en'], ['get', 'name']]);
|
||||
});
|
||||
|
||||
it('uses getCurrentLanguage() when no arg is passed (Arabic module)', () => {
|
||||
// arMod was loaded with getCurrentLanguage = () => 'ar'
|
||||
const expr = arMod.getLocalizedNameExpression();
|
||||
assert.deepEqual(expr, ['coalesce', ['get', 'name:ar'], ['get', 'name:en'], ['get', 'name']]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isLocalizableTextField ──────────────────────────────────────────
|
||||
|
||||
describe('isLocalizableTextField', () => {
|
||||
describe('string tokens', () => {
|
||||
it('accepts standard name tokens', () => {
|
||||
assert.equal(isLocalizableTextField('{name_en}'), true);
|
||||
assert.equal(isLocalizableTextField('{name}'), true);
|
||||
assert.equal(isLocalizableTextField('{name:latin}'), true);
|
||||
assert.equal(isLocalizableTextField('{name:en}'), true);
|
||||
assert.equal(isLocalizableTextField('{name_int}'), true);
|
||||
});
|
||||
|
||||
it('rejects non-name string tokens', () => {
|
||||
assert.equal(isLocalizableTextField('{housenumber}'), false);
|
||||
assert.equal(isLocalizableTextField('{ref}'), false);
|
||||
assert.equal(isLocalizableTextField('{class}'), false);
|
||||
assert.equal(isLocalizableTextField('{route}'), false);
|
||||
});
|
||||
|
||||
it('accepts mixed tokens containing a name field', () => {
|
||||
// Rare but possible: "{name}\n{name:en}" bilingual labels
|
||||
assert.equal(isLocalizableTextField('{name}\n{name:en}'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('falsy / non-object values', () => {
|
||||
it('rejects null', () => assert.equal(isLocalizableTextField(null), false));
|
||||
it('rejects undefined', () => assert.equal(isLocalizableTextField(undefined), false));
|
||||
it('rejects empty string', () => assert.equal(isLocalizableTextField(''), false));
|
||||
it('rejects false', () => assert.equal(isLocalizableTextField(false), false));
|
||||
it('rejects zero', () => assert.equal(isLocalizableTextField(0), false));
|
||||
});
|
||||
|
||||
describe('expression arrays', () => {
|
||||
it('accepts expression referencing name', () => {
|
||||
assert.equal(
|
||||
isLocalizableTextField(['coalesce', ['get', 'name:en'], ['get', 'name']]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts expression referencing name_en', () => {
|
||||
assert.equal(isLocalizableTextField(['get', 'name_en']), true);
|
||||
});
|
||||
|
||||
it('accepts already-localized coalesce expression', () => {
|
||||
// After localizeMapLabels runs, text-fields become this
|
||||
assert.equal(
|
||||
isLocalizableTextField(['coalesce', ['get', 'name:fr'], ['get', 'name:en'], ['get', 'name']]),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects expression without name references', () => {
|
||||
assert.equal(isLocalizableTextField(['get', 'ref']), false);
|
||||
assert.equal(isLocalizableTextField(['coalesce', ['get', 'class']]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop objects', () => {
|
||||
it('accepts stop objects with name tokens', () => {
|
||||
assert.equal(
|
||||
isLocalizableTextField({ stops: [[8, '{name_en}'], [13, '{name}']] }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stop objects without name tokens', () => {
|
||||
assert.equal(
|
||||
isLocalizableTextField({ stops: [[8, '{ref}'], [13, '{class}']] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('format expressions', () => {
|
||||
it('accepts MapLibre format expressions containing name', () => {
|
||||
// Some styles use: ["format", ["get","name"], {}, "\n", {}, ["get","name:en"], {"font-scale":0.8}]
|
||||
const formatExpr = ['format', ['get', 'name'], {}, '\n', {}, ['get', 'name:en'], { 'font-scale': 0.8 }];
|
||||
assert.equal(isLocalizableTextField(formatExpr), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── localizeMapLabels ───────────────────────────────────────────────
|
||||
|
||||
describe('localizeMapLabels', () => {
|
||||
/** Helper to build a mock MapLibre map for testing. */
|
||||
function createMockMap(
|
||||
layers: Array<{ id: string; type: string }>,
|
||||
textFields: Map<string, unknown>,
|
||||
opts?: { getThrows?: Set<string>; setThrows?: Set<string> },
|
||||
) {
|
||||
const setCalls: Array<{ id: string; value: unknown }> = [];
|
||||
return {
|
||||
setCalls,
|
||||
map: {
|
||||
getStyle: () => ({ layers }),
|
||||
getLayoutProperty: (layerId: string, prop: string) => {
|
||||
assert.equal(prop, 'text-field');
|
||||
if (opts?.getThrows?.has(layerId)) throw new Error('layer removed');
|
||||
return textFields.get(layerId);
|
||||
},
|
||||
setLayoutProperty: (layerId: string, prop: string, value: unknown) => {
|
||||
assert.equal(prop, 'text-field');
|
||||
if (opts?.setThrows?.has(layerId)) throw new Error('cannot set');
|
||||
setCalls.push({ id: layerId, value });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('rewrites only localizable symbol text-field properties', () => {
|
||||
const layers = [
|
||||
{ id: 'waterway_label', type: 'symbol' },
|
||||
{ id: 'place_city', type: 'symbol' },
|
||||
{ id: 'housenumber', type: 'symbol' },
|
||||
{ id: 'landcover', type: 'fill' },
|
||||
{ id: 'removed_during_pass', type: 'symbol' },
|
||||
{ id: 'set_fails', type: 'symbol' },
|
||||
];
|
||||
|
||||
const textFields = new Map<string, unknown>([
|
||||
['waterway_label', '{name_en}'],
|
||||
['place_city', { stops: [[8, '{name_en}'], [13, '{name}']] }],
|
||||
['housenumber', '{housenumber}'],
|
||||
['set_fails', '{name}'],
|
||||
]);
|
||||
|
||||
const { map, setCalls } = createMockMap(layers, textFields, {
|
||||
getThrows: new Set(['removed_during_pass']),
|
||||
setThrows: new Set(['set_fails']),
|
||||
});
|
||||
|
||||
localizeMapLabels(map);
|
||||
|
||||
assert.deepEqual(
|
||||
setCalls,
|
||||
[
|
||||
{ id: 'waterway_label', value: ['coalesce', ['get', 'name:en'], ['get', 'name']] },
|
||||
{ id: 'place_city', value: ['coalesce', ['get', 'name:en'], ['get', 'name']] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('is safe when style is missing', () => {
|
||||
assert.doesNotThrow(() => localizeMapLabels({ getStyle: () => null }));
|
||||
assert.doesNotThrow(() => localizeMapLabels({}));
|
||||
});
|
||||
|
||||
it('is safe when map is null or undefined', () => {
|
||||
assert.doesNotThrow(() => localizeMapLabels(null));
|
||||
assert.doesNotThrow(() => localizeMapLabels(undefined));
|
||||
});
|
||||
|
||||
it('handles empty layers array', () => {
|
||||
const map = { getStyle: () => ({ layers: [] }) };
|
||||
assert.doesNotThrow(() => localizeMapLabels(map));
|
||||
});
|
||||
|
||||
it('skips fill/line/circle layers entirely', () => {
|
||||
const layers = [
|
||||
{ id: 'water', type: 'fill' },
|
||||
{ id: 'roads', type: 'line' },
|
||||
{ id: 'points', type: 'circle' },
|
||||
];
|
||||
const { map, setCalls } = createMockMap(layers, new Map());
|
||||
localizeMapLabels(map);
|
||||
assert.equal(setCalls.length, 0);
|
||||
});
|
||||
|
||||
it('is idempotent — calling twice produces identical result', () => {
|
||||
const layers = [{ id: 'place_city', type: 'symbol' }];
|
||||
const textFields = new Map<string, unknown>([['place_city', '{name_en}']]);
|
||||
const { map, setCalls } = createMockMap(layers, textFields);
|
||||
|
||||
localizeMapLabels(map);
|
||||
assert.equal(setCalls.length, 1);
|
||||
|
||||
// Simulate the text-field being set to the coalesce expression
|
||||
textFields.set('place_city', setCalls[0]!.value);
|
||||
|
||||
// Second call: should still set (expression contains "name" references)
|
||||
// but the value will be identical — no functional change
|
||||
localizeMapLabels(map);
|
||||
assert.equal(setCalls.length, 2);
|
||||
assert.deepEqual(setCalls[0]!.value, setCalls[1]!.value);
|
||||
});
|
||||
|
||||
it('produces correct Arabic expression when loaded with ar language', () => {
|
||||
const layers = [{ id: 'place_country', type: 'symbol' }];
|
||||
const textFields = new Map<string, unknown>([['place_country', '{name_en}']]);
|
||||
const setCalls: Array<{ id: string; value: unknown }> = [];
|
||||
const map = {
|
||||
getStyle: () => ({ layers }),
|
||||
getLayoutProperty: (_id: string) => textFields.get(_id),
|
||||
setLayoutProperty: (id: string, _prop: string, value: unknown) => {
|
||||
setCalls.push({ id, value });
|
||||
},
|
||||
};
|
||||
|
||||
arMod.localizeMapLabels(map);
|
||||
|
||||
assert.equal(setCalls.length, 1);
|
||||
assert.deepEqual(setCalls[0]!.value, [
|
||||
'coalesce',
|
||||
['get', 'name:ar'],
|
||||
['get', 'name:en'],
|
||||
['get', 'name'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression: real CARTO CDN dark-matter layer patterns ───────────
|
||||
|
||||
describe('CARTO dark-matter style compatibility', () => {
|
||||
// Exact text-field patterns from the live CARTO CDN style
|
||||
const CARTO_LAYERS: Array<{ id: string; type: string; tf: unknown; shouldLocalize: boolean }> = [
|
||||
{ id: 'waterway_label', type: 'symbol', tf: '{name_en}', shouldLocalize: true },
|
||||
{ id: 'watername_ocean', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'watername_sea', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'watername_lake', type: 'symbol', tf: { stops: [[8, '{name_en}'], [13, '{name}']] }, shouldLocalize: true },
|
||||
{ id: 'place_hamlet', type: 'symbol', tf: { stops: [[8, '{name_en}'], [14, '{name}']] }, shouldLocalize: true },
|
||||
{ id: 'place_country_1', type: 'symbol', tf: '{name_en}', shouldLocalize: true },
|
||||
{ id: 'place_capital_dot_z7', type: 'symbol', tf: '{name_en}', shouldLocalize: true },
|
||||
{ id: 'poi_stadium', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'poi_park', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'roadname_minor', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'roadname_major', type: 'symbol', tf: '{name}', shouldLocalize: true },
|
||||
{ id: 'housenumber', type: 'symbol', tf: '{housenumber}', shouldLocalize: false },
|
||||
];
|
||||
|
||||
for (const { id, tf, shouldLocalize } of CARTO_LAYERS) {
|
||||
it(`${shouldLocalize ? 'localizes' : 'skips'} "${id}" (text-field: ${JSON.stringify(tf).slice(0, 40)})`, () => {
|
||||
assert.equal(isLocalizableTextField(tf), shouldLocalize);
|
||||
});
|
||||
}
|
||||
|
||||
it('localizes all expected layers and skips housenumber in full mock', () => {
|
||||
const layers = CARTO_LAYERS.map((l) => ({ id: l.id, type: l.type }));
|
||||
const textFields = new Map<string, unknown>(CARTO_LAYERS.map((l) => [l.id, l.tf]));
|
||||
const { map, setCalls } = createMockMap(layers, textFields);
|
||||
|
||||
localizeMapLabels(map);
|
||||
|
||||
const localizedIds = new Set(setCalls.map((c) => c.id));
|
||||
for (const layer of CARTO_LAYERS) {
|
||||
if (layer.shouldLocalize) {
|
||||
assert.ok(localizedIds.has(layer.id), `expected "${layer.id}" to be localized`);
|
||||
} else {
|
||||
assert.ok(!localizedIds.has(layer.id), `expected "${layer.id}" to NOT be localized`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Helper (duplicated for this describe block) */
|
||||
function createMockMap(
|
||||
layers: Array<{ id: string; type: string }>,
|
||||
textFields: Map<string, unknown>,
|
||||
) {
|
||||
const setCalls: Array<{ id: string; value: unknown }> = [];
|
||||
return {
|
||||
setCalls,
|
||||
map: {
|
||||
getStyle: () => ({ layers }),
|
||||
getLayoutProperty: (layerId: string) => textFields.get(layerId),
|
||||
setLayoutProperty: (layerId: string, _prop: string, value: unknown) => {
|
||||
setCalls.push({ id: layerId, value });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── RTL plugin file existence ───────────────────────────────────────
|
||||
|
||||
describe('RTL text plugin', () => {
|
||||
it('self-hosted mapbox-gl-rtl-text.min.js exists in public/', () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pluginPath = resolve(__dirname, '..', 'public', 'mapbox-gl-rtl-text.min.js');
|
||||
const content = readFileSync(pluginPath, 'utf-8');
|
||||
assert.ok(content.length > 10_000, 'RTL plugin should be at least 10KB');
|
||||
// Verify it's actually the mapbox RTL plugin (contains its module signature)
|
||||
assert.ok(
|
||||
content.includes('mapboxgl') || content.includes('RTLTextPlugin') || content.includes('applyArabicShaping'),
|
||||
'RTL plugin file should contain expected identifiers',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user