diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ab3ed..eccd094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # MapTiler SDK Changelog +## 2.4.2 +### Bug Fixes +- The language switching is now more robust and preserves the original formatting from the style (`Map.setPrimaryLangage()`) (https://github.com/maptiler/maptiler-sdk-js/pull/134) + ## 2.4.1 ### Bug Fixes - The class `AJAXError` is now imported as part of the `maplibregl` namespace (CommonJS limitation from Maplibre GL JS) (https://github.com/maptiler/maptiler-sdk-js/pull/129) diff --git a/package-lock.json b/package-lock.json index 59674ff..2b2469f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@maptiler/sdk", - "version": "2.4.1", + "version": "2.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@maptiler/sdk", - "version": "2.3.1", + "version": "2.4.2", "license": "BSD-3-Clause", "dependencies": { "@maplibre/maplibre-gl-style-spec": "^20.3.1", diff --git a/package.json b/package.json index 3169cda..3c5e49d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@maptiler/sdk", - "version": "2.4.1", + "version": "2.4.2", "description": "The Javascript & TypeScript map SDK tailored for MapTiler Cloud", "module": "dist/maptiler-sdk.mjs", "types": "dist/maptiler-sdk.d.ts", diff --git a/src/Map.ts b/src/Map.ts index 57ca4c9..da04791 100644 --- a/src/Map.ts +++ b/src/Map.ts @@ -24,7 +24,15 @@ import type { ReferenceMapStyle, MapStyleVariant } from "@maptiler/client"; import { config, MAPTILER_SESSION_ID, type SdkConfig } from "./config"; import { defaults } from "./defaults"; import { MaptilerLogoControl } from "./MaptilerLogoControl"; -import { combineTransformRequest, displayNoWebGlWarning, displayWebGLContextLostWarning } from "./tools"; +import { + changeFirstLanguage, + checkNamePattern, + combineTransformRequest, + computeLabelsLocalizationMetrics, + displayNoWebGlWarning, + displayWebGLContextLostWarning, + replaceLanguage, +} from "./tools"; import { getBrowserLanguage, Language, type LanguageInfo } from "./language"; import { styleToStyle } from "./mapstyle"; import { MaptilerTerrainControl } from "./MaptilerTerrainControl"; @@ -190,6 +198,8 @@ export class Map extends maplibregl.Map { private terrainAnimationDuration = 1000; private monitoredStyleUrls!: Set; private styleInProcess = false; + private originalLabelStyle = new window.Map(); + private isStyleLocalized = false; constructor(options: MapOptions) { displayNoWebGlWarning(options.container); @@ -730,6 +740,7 @@ export class Map extends maplibregl.Map { style: null | ReferenceMapStyle | MapStyleVariant | StyleSpecification | string, options?: StyleSwapOptions & StyleOptions, ): this { + this.originalLabelStyle.clear(); this.minimap?.setStyle(style); this.forceLanguageUpdate = true; @@ -1003,7 +1014,7 @@ export class Map extends maplibregl.Map { let langStr = Language.LOCAL.flag; // will be overwritten below - let replacer: ExpressionSpecification | string = `{${langStr}}`; + let replacer: ExpressionSpecification = ["get", langStr]; if (languageNonStyle.flag === Language.VISITOR.flag) { langStr = getBrowserLanguage().flag; @@ -1049,23 +1060,32 @@ export class Map extends maplibregl.Map { ]; } else if (languageNonStyle.flag === Language.AUTO.flag) { langStr = getBrowserLanguage().flag; - replacer = ["case", ["has", langStr], ["get", langStr], ["get", Language.LOCAL.flag]]; + replacer = ["coalesce", ["get", langStr], ["get", Language.LOCAL.flag]]; } // This is for using the regular names as {name} else if (languageNonStyle === Language.LOCAL) { langStr = Language.LOCAL.flag; - replacer = `{${langStr}}`; + replacer = ["get", langStr]; } // This section is for the regular language ISO codes else { langStr = languageNonStyle.flag; - replacer = ["case", ["has", langStr], ["get", langStr], ["get", Language.LOCAL.flag]]; + replacer = ["coalesce", ["get", langStr], ["get", Language.LOCAL.flag]]; } const { layers } = this.getStyle(); + // True if it's the first time the language is updated for the current style + const firstPassOnStyle = this.originalLabelStyle.size === 0; + + // Analisis on all the label layers to check the languages being used + if (firstPassOnStyle) { + const labelsLocalizationMetrics = computeLabelsLocalizationMetrics(layers, this); + this.isStyleLocalized = Object.keys(labelsLocalizationMetrics.localized).length > 0; + } + for (const genericLayer of layers) { // Only symbole layer can have a layout with text-field if (genericLayer.type !== "symbol") { @@ -1102,17 +1122,49 @@ export class Map extends maplibregl.Map { continue; } - const textFieldLayoutProp = this.getLayoutProperty(id, "text-field"); + let textFieldLayoutProp: string | maplibregl.ExpressionSpecification; - // If the label is not about a name, then we don't translate it - if ( - typeof textFieldLayoutProp === "string" && - (textFieldLayoutProp.toLowerCase().includes("ref") || textFieldLayoutProp.toLowerCase().includes("housenumber")) - ) { - continue; + // Keeping a copy of the text-field sub-object as it is in the original style + if (firstPassOnStyle) { + textFieldLayoutProp = this.getLayoutProperty(id, "text-field"); + this.originalLabelStyle.set(id, textFieldLayoutProp); + } else { + textFieldLayoutProp = this.originalLabelStyle.get(id) as string | maplibregl.ExpressionSpecification; + } + + // From this point, the value of textFieldLayoutProp is as in the original version of the style + // and never a mofified version + + // Testing the different case where the text-field property should NOT be updated: + if (typeof textFieldLayoutProp === "string") { + // When the original style is localized (this.isStyleLocalized is true), we do not modify the {name} because they are + // very likely to be only fallbacks. + // When the original style is not localized (this.isStyleLocalized is false), the occurences of "{name}" + // should be replaced by localized versions with fallback to local language. + + const { contains, exactMatch } = checkNamePattern(textFieldLayoutProp, this.isStyleLocalized); + + // If the current text-fiels does not contain any "{name:xx}" pattern + if (!contains) continue; + + // In case of an exact match, we replace by an object representation of the label + if (exactMatch) { + this.setLayoutProperty(id, "text-field", replacer); + } else { + // In case of a non-exact match (such as "foo {name:xx} bar" or "foo {name} bar", depending on localization) + // we create a "concat" object expresion composed of the original elements with new replacer + // in-betweem + const newReplacer = replaceLanguage(textFieldLayoutProp, replacer, this.isStyleLocalized); + + this.setLayoutProperty(id, "text-field", newReplacer); + } } - this.setLayoutProperty(id, "text-field", replacer); + // The value of text-field is an object + else { + const newReplacer = changeFirstLanguage(textFieldLayoutProp, replacer, this.isStyleLocalized); + this.setLayoutProperty(id, "text-field", newReplacer); + } } } diff --git a/src/tools.ts b/src/tools.ts index dc25885..7a8c2bc 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,9 +1,10 @@ import maplibregl from "maplibre-gl"; -import type { RequestParameters, ResourceType, RequestTransformFunction } from "maplibre-gl"; +import type { RequestParameters, ResourceType, RequestTransformFunction, SymbolLayerSpecification } from "maplibre-gl"; import { defaults } from "./defaults"; import { config } from "./config"; import { MAPTILER_SESSION_ID } from "./config"; import { localCacheTransformRequest } from "./caching"; +import type { Map as MapSDK } from "./Map"; export function enableRTL() { // Prevent this from running server side @@ -226,3 +227,218 @@ export function displayWebGLContextLostWarning(container: HTMLElement | string) actualContainer.appendChild(errorMessageDiv); // throw new Error(webglError); } + +/** + * Return true if the provided piece of style expression has the form ["get", "name:XX"] + * with XX being a 2-letter is code for languages + */ +function isGetNameLanguage(subExpr: unknown, localized: boolean): boolean { + if (!Array.isArray(subExpr)) return false; + if (subExpr.length !== 2) return false; + if (subExpr[0] !== "get") return false; + if (typeof subExpr[1] !== "string") return false; + if (localized && !subExpr[1].startsWith("name:")) return false; + if (!localized && subExpr[1] !== "name") return false; + + return true; +} + +/** + * In a text-field style property (as an object, not a string) the languages that are specified as + * ["get", "name:XX"] are replaced by the proved replacer (also an object). + * This replacement happened regardless of how deep in the object the flag is. + * Note that it does not replace the occurences of ["get", "name"] (local names) + */ +export function changeFirstLanguage( + origExpr: maplibregl.ExpressionSpecification, + replacer: maplibregl.ExpressionSpecification, + localized: boolean, +): maplibregl.ExpressionSpecification { + const expr = structuredClone(origExpr) as maplibregl.ExpressionSpecification; + + const exploreNode = (subExpr: maplibregl.ExpressionSpecification | string) => { + if (typeof subExpr === "string") return; + + for (let i = 0; i < subExpr.length; i += 1) { + if (isGetNameLanguage(subExpr[i], localized)) { + subExpr[i] = structuredClone(replacer); + } else { + exploreNode(subExpr[i] as maplibregl.ExpressionSpecification | string); + } + } + }; + + // The provided expression could be directly a ["get", "name:xx"] + if (isGetNameLanguage(expr, localized)) { + return replacer; + } + + exploreNode(expr); + return expr; +} + +/** + * If `localized` is `true`, it checks for the pattern "{name:xx}" (with "xx" being a language iso string). + * If `localized` is `false`, it check for {name}. + * In a exact way or is a loose way (such as "foo {name:xx}" or "foo {name} bar") + */ +export function checkNamePattern(str: string, localized: boolean): { contains: boolean; exactMatch: boolean } { + const regex = localized ? /\{name:\S+\}/ : /\{name\}/; + return { + contains: regex.test(str), + exactMatch: new RegExp(`^${regex.source}$`).test(str), + }; +} + +/** + * Replaces the occurences of {name:xx} in a string by a provided object-expression to return a concat object expression + */ +export function replaceLanguage( + origLang: string, + newLang: maplibregl.ExpressionSpecification, + localized: boolean, +): maplibregl.ExpressionSpecification { + const regex = localized ? /\{name:\S+\}/ : /\{name\}/; + const elementsToConcat = origLang.split(regex); + + const allElements = elementsToConcat.flatMap((item, i) => + i === elementsToConcat.length - 1 ? [item] : [item, newLang], + ); + + const expr = ["concat", ...allElements] as maplibregl.ExpressionSpecification; + return expr; +} + +/** + * Find languages used in string label definition. + * The returned array contains languages such as "en", "fr" but + * can also contain null that stand for the use of {name} + */ +export function findLanguageStr(str: string): Array { + const regex = /\{name(?:\:(?\S+))?\}/g; + const languageUsed = [] as Array; + + while (true) { + const match = regex.exec(str); + if (!match) break; + + // The is a match + const language = match.groups?.language ?? null; + + // The language is non-null if provided {name:xx} + // but if provided {name} then language will be null + languageUsed.push(language); + } + return languageUsed; +} + +function isGetNameLanguageAndFind(subExpr: unknown): { isLanguage: boolean; localization: string | null } | null { + // Not language expression + if (!Array.isArray(subExpr)) return null; + if (subExpr.length !== 2) return null; + if (subExpr[0] !== "get") return null; + if (typeof subExpr[1] !== "string") return null; + + // Is non localized language + if (subExpr[1].trim() === "name") { + return { + isLanguage: true, + localization: null, + }; + } + + // Is a localized language + if (subExpr[1].trim().startsWith("name:")) { + return { + isLanguage: true, + localization: subExpr[1].trim().split(":").pop() as string, + }; + } + + return null; +} + +/** + * Find languages used in object label definition. + * The returned array contains languages such as "en", "fr" but + * can also contain null that stand for the use of {name} + */ +export function findLanguageObj(origExpr: maplibregl.ExpressionSpecification): Array { + const languageUsed = [] as Array; + const expr = structuredClone(origExpr) as maplibregl.ExpressionSpecification; + + const exploreNode = ( + subExpr: maplibregl.ExpressionSpecification | string | Array, + ) => { + if (typeof subExpr === "string") return; + + for (let i = 0; i < subExpr.length; i += 1) { + const result = isGetNameLanguageAndFind(subExpr[i]); + if (result) { + languageUsed.push(result.localization); + } else { + exploreNode(subExpr[i] as maplibregl.ExpressionSpecification | string); + } + } + }; + + exploreNode([expr]); + return languageUsed; +} + +export function computeLabelsLocalizationMetrics( + layers: maplibregl.LayerSpecification[], + map: MapSDK, +): { unlocalized: number; localized: { [k: string]: number } } { + const languages: Array[] = []; + + for (const genericLayer of layers) { + // Only symbole layer can have a layout with text-field + if (genericLayer.type !== "symbol") { + continue; + } + + const layer = genericLayer as SymbolLayerSpecification; + const { id, layout } = layer; + + if (!layout) { + continue; + } + + if (!("text-field" in layout)) { + continue; + } + + const textFieldLayoutProp: string | maplibregl.ExpressionSpecification = map.getLayoutProperty(id, "text-field"); + + if (!textFieldLayoutProp) { + continue; + } + + if (typeof textFieldLayoutProp === "string") { + const l = findLanguageStr(textFieldLayoutProp); + languages.push(l); + } else { + const l = findLanguageObj(textFieldLayoutProp); + languages.push(l); + } + } + + const flatLanguages = languages.flat(); + const localizationMetrics: { unlocalized: number; localized: { [k: string]: number } } = { + unlocalized: 0, + localized: {}, + }; + + for (const lang of flatLanguages) { + if (lang === null) { + localizationMetrics.unlocalized += 1; + } else { + if (!(lang in localizationMetrics.localized)) { + localizationMetrics.localized[lang] = 0; + } + localizationMetrics.localized[lang] += 1; + } + } + return localizationMetrics; +} diff --git a/tsconfig.json b/tsconfig.json index 879d3bb..14e795f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,10 @@ "compilerOptions": { "baseUrl": "src", "moduleResolution": "Node", - "target": "ES2020", + "target": "es2021", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["es2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */