diff --git a/package-lock.json b/package-lock.json index 000ed83ed..c411f3dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "open": "^8.4.0" }, "devDependencies": { + "@maplibre/maplibre-gl-style-spec": "^17.0.1", "chai": "^4.3.7", "create-serve": "^1.0.1", "esbuild": "^0.15.15", @@ -149,6 +150,33 @@ "node": ">=6.0.0" } }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-17.0.1.tgz", + "integrity": "sha512-pohuxZke5fAJmY7g9EM7tQHjFXOegG58R66tTGrHvdndJOr8hTDUOdgkmq3wCNNOJL8dIf014RVhvPua53P2ZQ==", + "dev": true, + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.0", + "csscolorparser": "~1.0.2", + "json-stringify-pretty-compact": "^2.0.0", + "minimist": "^1.2.5", + "rw": "^1.3.3", + "sort-object": "^0.3.2" + }, + "bin": { + "gl-style-composite": "bin/gl-style-composite", + "gl-style-format": "bin/gl-style-format", + "gl-style-migrate": "bin/gl-style-migrate", + "gl-style-validate": "bin/gl-style-validate" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "dev": true + }, "node_modules/@rushstack/ts-command-line": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.11.0.tgz", @@ -1987,6 +2015,12 @@ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, + "node_modules/json-stringify-pretty-compact": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz", + "integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==", + "dev": true + }, "node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -2860,6 +2894,12 @@ "protocol-buffers-schema": "^3.3.1" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3088,6 +3128,37 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, + "node_modules/sort-asc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", + "integrity": "sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz", + "integrity": "sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz", + "integrity": "sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==", + "dev": true, + "dependencies": { + "sort-asc": "^0.1.0", + "sort-desc": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3713,6 +3784,29 @@ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", "dev": true }, + "@maplibre/maplibre-gl-style-spec": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-17.0.1.tgz", + "integrity": "sha512-pohuxZke5fAJmY7g9EM7tQHjFXOegG58R66tTGrHvdndJOr8hTDUOdgkmq3wCNNOJL8dIf014RVhvPua53P2ZQ==", + "dev": true, + "requires": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.0", + "csscolorparser": "~1.0.2", + "json-stringify-pretty-compact": "^2.0.0", + "minimist": "^1.2.5", + "rw": "^1.3.3", + "sort-object": "^0.3.2" + }, + "dependencies": { + "@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "dev": true + } + } + }, "@rushstack/ts-command-line": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.11.0.tgz", @@ -4961,6 +5055,12 @@ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, + "json-stringify-pretty-compact": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz", + "integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==", + "dev": true + }, "kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -5638,6 +5738,12 @@ "protocol-buffers-schema": "^3.3.1" } }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5787,6 +5893,28 @@ } } }, + "sort-asc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", + "integrity": "sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==", + "dev": true + }, + "sort-desc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz", + "integrity": "sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==", + "dev": true + }, + "sort-object": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz", + "integrity": "sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==", + "dev": true, + "requires": { + "sort-asc": "^0.1.0", + "sort-desc": "^0.1.1" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index ac264fcb6..008bca188 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "open": "^8.4.0" }, "devDependencies": { + "@maplibre/maplibre-gl-style-spec": "^17.0.1", "chai": "^4.3.7", "create-serve": "^1.0.1", "esbuild": "^0.15.15", diff --git a/src/americana.js b/src/americana.js index 0a84c20b2..275360b62 100644 --- a/src/americana.js +++ b/src/americana.js @@ -255,22 +255,7 @@ function buildLayers() { lyrPlace.continent ); - // Resolve localized name placeholders. - let localizedNameExpression = Label.getLocalizedNameExpression(false); - let legacyLocalizedNameExpression = Label.getLocalizedNameExpression(true); - for (let layer of layers) { - if ( - "metadata" in layer && - "layout" in layer && - layer.metadata["americana:text-field-localized"] === true - ) { - // https://github.com/openmaptiles/openmaptiles/issues/769 - layer.layout["text-field"] = - layer["source-layer"] === "transportation_name" - ? legacyLocalizedNameExpression - : localizedNameExpression; - } - } + Label.localizeLayers(layers, Label.getLocales()); return layers; } @@ -316,6 +301,7 @@ maplibregl.setRTLTextPlugin( true ); +window.maplibregl = maplibregl; export const map = (window.map = new maplibregl.Map({ container: "map", // container id hash: "map", diff --git a/src/constants/label.js b/src/constants/label.js index 7287a0ee2..c52ee58ae 100644 --- a/src/constants/label.js +++ b/src/constants/label.js @@ -8,14 +8,9 @@ export function getLanguageFromURL(url) { } /** - * Returns a `coalesce` expression that resolves to the feature's name in a - * language that the user prefers. - * - * @param {boolean} includesLegacyFields - Whether to include the older fields - * that include underscores, for layers that have not transitioned to the - * colon syntax. + * Returns the languages that the user prefers. */ -export function getLocalizedNameExpression(includesLegacyFields) { +export function getLocales() { // Check the language "parameter" in the hash. let parameter = getLanguageFromURL(window.location)?.split(","); // Fall back to the user's language preference. @@ -32,6 +27,19 @@ export function getLocalizedNameExpression(includesLegacyFields) { components.pop(); } } + return locales; +} + +/** + * Returns a `coalesce` expression that resolves to the feature's name in a + * language that the user prefers. + * + * @param {[string]} locales - Locales of the name fields to include. + * @param {boolean} includesLegacyFields - Whether to include the older fields + * that include underscores, for layers that have not transitioned to the + * colon syntax. + */ +export function getLocalizedNameExpression(locales, includesLegacyFields) { if (locales.at(-1) === "en") { locales.push("latin"); } @@ -49,5 +57,212 @@ export function getLocalizedNameExpression(includesLegacyFields) { return ["coalesce", ...nameFields.map((f) => ["get", f])]; } -// Placeholder to be resolved by buildLayers() -export const localizedName = "$$localizedName"; +/** + * Replaces the value of a variable in the given `let` expression. + * + * @param {array} letExpr - Expression to update. + * @param {string} variable - Name of the variable to set. + * @param {*} value - The variable's new value. + */ +export function updateVariable(letExpr, variable, value) { + if (!letExpr || letExpr[0] !== "let") return; + + let variableNameIndex = letExpr.indexOf(variable); + if (variableNameIndex % 2 === 1) { + letExpr[variableNameIndex + 1] = value; + } +} + +/** + * Updates localizable variables at the top level of each layer's `text-field` expression based on the given locales. + * + * @param {[object]} layers - The style layers to localize. + * @param {[string]} locales - The locales to insert into each layer. + */ +export function localizeLayers(layers, locales) { + let localizedNameExpression = getLocalizedNameExpression(locales, false); + let legacyLocalizedNameExpression = getLocalizedNameExpression(locales, true); + + for (let layer of layers) { + if ("layout" in layer && "text-field" in layer.layout) { + let textField = layer.layout["text-field"]; + + updateVariable( + textField, + "localizedName", + // https://github.com/openmaptiles/openmaptiles/issues/769 + layer["source-layer"] === "transportation_name" + ? legacyLocalizedNameExpression + : localizedNameExpression + ); + + updateVariable(textField, "localizedCollator", [ + "collator", + { + "case-sensitive": false, + "diacritic-sensitive": true, + locale: locales[0], + }, + ]); + + // Only perform diacritic folding in English. English normally uses few diacritics except when labeling foreign place names on maps. + updateVariable(textField, "diacriticInsensitiveCollator", [ + "collator", + { + "case-sensitive": false, + "diacritic-sensitive": !/^en\b/.test(locales[0]), + locale: locales[0], + }, + ]); + } + } +} + +/** + * The name in the user's preferred language. + */ +export const localizedName = [ + "let", + "localizedName", + "", + ["var", "localizedName"], +]; + +/** + * Returns an expression that tests whether the target has the given prefix, + * respecting word boundaries. + */ +function startsWithExpression(target, candidatePrefix, collator) { + // "Quebec City" vs. "Québec", "Washington, D.C." vs. "Washington" + let wordBoundaries = " ,"; + return [ + "all", + [ + "==", + ["slice", target, 0, ["length", candidatePrefix]], + candidatePrefix, + collator, + ], + [ + "in", + [ + "slice", + // Pad the target in case the prefix matches exactly. + // "Montreal " vs. "Montréal" + ["concat", target, wordBoundaries[0]], + ["length", candidatePrefix], + ["+", ["length", candidatePrefix], 1], + ], + wordBoundaries, + ], + ]; +} + +function overwritePrefixExpression(target, newPrefix) { + return ["concat", newPrefix, ["slice", target, ["length", newPrefix]]]; +} + +/** + * Returns an expression that tests whether the target has the given suffix, + * respecting word boundaries. + */ +function endsWithExpression(target, candidateSuffix, collator) { + let wordBoundary = " "; + return [ + "all", + [ + "==", + ["slice", target, ["-", ["length", target], ["length", candidateSuffix]]], + candidateSuffix, + collator, + ], + [ + "==", + [ + "slice", + target, + ["-", ["-", ["length", target], ["length", candidateSuffix]], 1], + ["-", ["length", target], ["length", candidateSuffix]], + ], + wordBoundary, + ], + ]; +} + +function overwriteSuffixExpression(target, newSuffix) { + return [ + "concat", + ["slice", target, 0, ["-", ["length", target], ["length", newSuffix]]], + newSuffix, + ]; +} + +/** + * The name in the user's preferred language, followed by the name in the local + * language in parentheses if it differs. + */ +export const localizedNameWithLocalGloss = [ + "let", + "localizedName", + "", + "localizedCollator", + ["collator", {}], + "diacriticInsensitiveCollator", + ["collator", {}], + [ + "case", + // If the name in the preferred and local languages match exactly... + [ + "==", + ["var", "localizedName"], + ["get", "name"], + ["var", "localizedCollator"], + ], + // ...just pick one. + ["var", "localizedName"], + // If the name in the preferred language is the same as the name in the + // local language except for the omission of diacritics and/or the addition + // of a suffix (e.g., "City" in English)... + startsWithExpression( + ["var", "localizedName"], + ["get", "name"], + ["var", "diacriticInsensitiveCollator"] + ), + // ...then replace the common prefix with the local name. + overwritePrefixExpression(["var", "localizedName"], ["get", "name"]), + // If the name in the preferred language is the same as the name in the + // local language except for the omission of diacritics and/or the addition + // of a prefix (e.g., "City of" in English or "Ciudad de" in Spanish)... + endsWithExpression( + ["var", "localizedName"], + ["get", "name"], + ["var", "diacriticInsensitiveCollator"] + ), + // ...then replace the common suffix with the local name. + overwriteSuffixExpression(["var", "localizedName"], ["get", "name"]), + // Otherwise, gloss the name in the local language if it differs from the + // localized name. + [ + "format", + ["var", "localizedName"], + "\n", + "(\u200B", + { "font-scale": 0.8 }, + // GL JS lacks support for bidirectional isolating characters, so use a + // character from the localized name to insulate the parentheses from the + // embedded text's writing direction. Make it so small that GL JS doesn't + // bother rendering it. + ["concat", ["slice", ["var", "localizedName"], 0, 1], " "], + { "font-scale": 0.001 }, + ["get", "name"], + { "font-scale": 0.8 }, + ["concat", " ", ["slice", ["var", "localizedName"], 0, 1]], + { "font-scale": 0.001 }, + // A ZWSP prevents GL JS from combining this component with the preceding + // one, which would cause it to vanish along with the faux isolating + // character. + "\u200B)", + { "font-scale": 0.8 }, + ], + ], +]; diff --git a/src/layer/aeroway.js b/src/layer/aeroway.js index b816a411b..b190e78be 100644 --- a/src/layer/aeroway.js +++ b/src/layer/aeroway.js @@ -221,9 +221,6 @@ export const airportLabel = { ...iconLayout, }, source: "openmaptiles", - metadata: { - "americana:text-field-localized": true, - }, "source-layer": "aerodrome_label", }; @@ -246,9 +243,6 @@ export const minorAirportLabel = { "text-size": 10, }, source: "openmaptiles", - metadata: { - "americana:text-field-localized": true, - }, "source-layer": "aerodrome_label", }; diff --git a/src/layer/park.js b/src/layer/park.js index f17e3744a..0ae036420 100644 --- a/src/layer/park.js +++ b/src/layer/park.js @@ -49,9 +49,6 @@ export const label = { "symbol-sort-key": ["get", "rank"], }, source: "openmaptiles", - metadata: { - "americana:text-field-localized": true, - }, "source-layer": "park", }; @@ -103,8 +100,5 @@ export const parkLabel = { "symbol-sort-key": ["get", "rank"], }, source: "openmaptiles", - metadata: { - "americana:text-field-localized": true, - }, "source-layer": "poi", }; diff --git a/src/layer/place.js b/src/layer/place.js index c0058e5fa..201653964 100644 --- a/src/layer/place.js +++ b/src/layer/place.js @@ -80,9 +80,6 @@ export const village = { minzoom: 11, maxzoom: 14, "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const town = { @@ -146,9 +143,6 @@ export const town = { minzoom: 4, maxzoom: 13, "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const city = { @@ -187,7 +181,7 @@ export const city = { [11, 0.9], ], }, - "text-field": Label.localizedName, + "text-field": Label.localizedNameWithLocalGloss, "text-anchor": "bottom", "text-variable-anchor": [ "bottom", @@ -208,9 +202,7 @@ export const city = { minzoom: 4, maxzoom: 12, "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, + metadata: {}, }; export const state = { @@ -252,9 +244,6 @@ export const state = { maxzoom: 7, minzoom: 3, "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const countryOther = { id: "country_other", @@ -284,9 +273,6 @@ export const countryOther = { }, source: "openmaptiles", "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const country3 = { id: "country_3", @@ -317,9 +303,6 @@ export const country3 = { }, source: "openmaptiles", "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const country2 = { id: "country_2", @@ -350,9 +333,6 @@ export const country2 = { }, source: "openmaptiles", "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const country1 = { id: "country_1", @@ -391,9 +371,6 @@ export const country1 = { }, source: "openmaptiles", "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; export const continent = { id: "continent", @@ -414,7 +391,4 @@ export const continent = { source: "openmaptiles", maxzoom: 1, "source-layer": "place", - metadata: { - "americana:text-field-localized": true, - }, }; diff --git a/src/layer/transportation_label.js b/src/layer/transportation_label.js index c671a557f..e6c9bda68 100644 --- a/src/layer/transportation_label.js +++ b/src/layer/transportation_label.js @@ -119,9 +119,6 @@ export const label = { }, source: "openmaptiles", "source-layer": "transportation_name", - metadata: { - "americana:text-field-localized": true, - }, }; // A spacer label on each bridge to push any waterway label away from the bridge. diff --git a/src/layer/water.js b/src/layer/water.js index 443327c63..2426a1c8e 100644 --- a/src/layer/water.js +++ b/src/layer/water.js @@ -112,9 +112,6 @@ export const waterwayLabel = { "text-letter-spacing": 0.15, }, paint: labelPaintProperties, - metadata: { - "americana:text-field-localized": true, - }, }; //Lake labels rendered as a linear feature @@ -140,9 +137,6 @@ export const waterLabel = { "text-letter-spacing": 0.25, }, paint: labelPaintProperties, - metadata: { - "americana:text-field-localized": true, - }, }; //Lake labels rendered as a point feature @@ -169,7 +163,4 @@ export const waterPointLabel = { "text-letter-spacing": 0.25, }, paint: labelPaintProperties, - metadata: { - "americana:text-field-localized": true, - }, }; diff --git a/test/spec/label.js b/test/spec/label.js index 876d1095b..935f72ac7 100644 --- a/test/spec/label.js +++ b/test/spec/label.js @@ -2,6 +2,25 @@ import chai, { expect } from "chai"; import * as Label from "../../src/constants/label.js"; +import { expression } from "@maplibre/maplibre-gl-style-spec"; + +function localizedTextField(textField, locales) { + let layers = [ + { + layout: { + "text-field": textField, + }, + }, + ]; + Label.localizeLayers(layers, locales); + return layers[0].layout["text-field"]; +} + +function expressionContext(properties) { + return { + properties: () => properties, + }; +} describe("label", function () { describe("#getLanguageFromURL", function () { @@ -34,4 +53,329 @@ describe("label", function () { ).to.eql("the King's English"); }); }); + + describe("#getLocales", function () { + beforeEach(function () { + global.window = {}; + global.navigator = {}; + }); + afterEach(function () { + delete global.window; + delete global.navigator; + }); + + it("gets locales from preferences", function () { + window.location = new URL("http://localhost:1776/#map=1/2/3"); + navigator = { languages: ["tlh-UN", "ase"], language: "tlh" }; + expect(Label.getLocales()).to.eql(["tlh-UN", "tlh", "ase"]); + }); + it("gets locales from the URL", function () { + window.location = new URL( + "http://localhost:1776/#map=1/2/3&language=tlh-UN,ase" + ); + expect(Label.getLocales()).to.eql(["tlh-UN", "tlh", "ase"]); + }); + }); + + describe("#getLocalizedNameExpression", function () { + it("coalesces names in each locale", function () { + expect( + Label.getLocalizedNameExpression(["en-US", "en", "fr"], false) + ).to.eql([ + "coalesce", + ["get", "name:en-US"], + ["get", "name:en"], + ["get", "name:fr"], + ["get", "name"], + ]); + }); + it("falls back from English to Romanization", function () { + expect(Label.getLocalizedNameExpression(["en-US", "en"], false)).to.eql([ + "coalesce", + ["get", "name:en-US"], + ["get", "name:en"], + ["get", "name:latin"], + ["get", "name"], + ]); + }); + it("includes legacy fields", function () { + expect( + Label.getLocalizedNameExpression(["en-US", "en", "de"], true) + ).to.eql([ + "coalesce", + ["get", "name:en-US"], + ["get", "name:en"], + ["get", "name_en"], + ["get", "name:de"], + ["get", "name_de"], + ["get", "name"], + ]); + }); + }); + + describe("#updateVariable", function () { + it("replaces the value at the correct index", function () { + let expr = [ + "let", + "one", + "won", + "two", + "too", + "three", + "tree", + ["get", "fore"], + ]; + Label.updateVariable(expr, "one", 1); + Label.updateVariable(expr, "two", 2); + Label.updateVariable(expr, "three", 3); + expect(expr).to.eql([ + "let", + "one", + 1, + "two", + 2, + "three", + 3, + ["get", "fore"], + ]); + }); + it("avoids updating non-let expressions", function () { + let expr = ["get", "fore"]; + Label.updateVariable(expr, "fore", 4); + expect(expr).to.eql(["get", "fore"]); + }); + }); + + describe("#localizeLayers", function () { + it("updates localized name", function () { + let layers = [ + { + layout: { + "text-field": "Null Island", + }, + }, + { + layout: { + "text-field": [...Label.localizedName], + }, + }, + ]; + Label.localizeLayers(layers, ["en"]); + expect(layers[0].layout["text-field"]).to.eql("Null Island"); + expect(layers[1].layout["text-field"][2]).to.deep.include([ + "get", + "name:en", + ]); + }); + it("uses legacy name fields in transportation name layers", function () { + let layers = [ + { + "source-layer": "transportation_name", + layout: { + "text-field": [...Label.localizedName], + }, + }, + ]; + Label.localizeLayers(layers, ["en"]); + expect(layers[0].layout["text-field"][2]).to.deep.include([ + "get", + "name_en", + ]); + }); + it("updates collator", function () { + let layers = [ + { + layout: { + "text-field": [ + "let", + "localizedCollator", + "", + ["var", "localizedCollator"], + ], + }, + }, + ]; + Label.localizeLayers(layers, ["tlh"]); + expect(layers[0].layout["text-field"][2][0]).to.eql("collator"); + expect(layers[0].layout["text-field"][2][1]["case-sensitive"]).to.be + .false; + expect(layers[0].layout["text-field"][2][1]["diacritic-sensitive"]).to.be + .true; + expect(layers[0].layout["text-field"][2][1].locale).to.eql("tlh"); + }); + it("updates diacritic-insensitive collator in English", function () { + let layers = [ + { + layout: { + "text-field": [ + "let", + "diacriticInsensitiveCollator", + "", + ["var", "diacriticInsensitiveCollator"], + ], + }, + }, + ]; + Label.localizeLayers(layers, ["en-US"]); + expect(layers[0].layout["text-field"][2][0]).to.eql("collator"); + expect(layers[0].layout["text-field"][2][1]["case-sensitive"]).to.be + .false; + expect(layers[0].layout["text-field"][2][1]["diacritic-sensitive"]).to.be + .false; + expect(layers[0].layout["text-field"][2][1].locale).to.eql("en-US"); + }); + it("updates diacritic-insensitive collator in a language other than English", function () { + let layers = [ + { + layout: { + "text-field": [ + "let", + "diacriticInsensitiveCollator", + "", + ["var", "diacriticInsensitiveCollator"], + ], + }, + }, + ]; + Label.localizeLayers(layers, ["enm"]); + expect(layers[0].layout["text-field"][2][1]["diacritic-sensitive"]).to.be + .true; + }); + }); + + describe("#localizedName", function () { + let evaluatedExpression = (locales, properties) => + expression + .createExpression(localizedTextField([...Label.localizedName], ["en"])) + .value.expression.evaluate(expressionContext(properties)); + + it("is empty by default", function () { + expect( + expression + .createExpression(Label.localizedName) + .value.expression.evaluate( + expressionContext({ + name: "Null Island", + }) + ) + ).to.be.eql(""); + }); + it("localizes to preferred language", function () { + expect( + evaluatedExpression(["en"], { + "name:en": "Null Island", + name: "Insula Nullius", + }) + ).to.be.eql("Null Island"); + }); + }); + + describe("#localizedNameWithLocalGloss", function () { + let evaluatedExpression = (locales, properties) => + expression + .createExpression( + localizedTextField([...Label.localizedNameWithLocalGloss], locales) + ) + .value.expression.evaluate(expressionContext(properties)); + + let evaluatedLabelAndGloss = (locales, properties) => { + let evaluated = evaluatedExpression(locales, properties); + if (typeof evaluated === "string") { + return [evaluated, null]; + } + return [evaluated.sections[0].text, evaluated.sections[4].text]; + }; + + let expectGloss = ( + locale, + localized, + local, + expectedLabel, + expectedGloss + ) => { + let properties = { + name: local, + }; + properties[`name:${locale}`] = localized; + expect(evaluatedLabelAndGloss([locale], properties)).to.be.deep.equal([ + expectedLabel, + expectedGloss, + ]); + }; + + it("puts an unlocalized name by itself", function () { + expect( + evaluatedExpression(["en"], { + name: "Null Island", + }) + ).to.be.eql("Null Island"); + }); + it("glosses an anglicized name with the local name", function () { + let evaluated = evaluatedExpression(["en"], { + "name:en": "Null Island", + name: "Insula Nullius", + }); + + expect(evaluated.sections.length).to.be.eql(7); + expect(evaluated.sections[0].text).to.be.eql("Null Island"); + expect(evaluated.sections[1].text).to.be.eql("\n"); + expect(evaluated.sections[2].text).to.be.eql("(\u200B"); + expect(evaluated.sections[3].text).to.be.eql("Null Island"[0] + " "); + expect(evaluated.sections[4].text).to.be.eql("Insula Nullius"); + expect(evaluated.sections[5].text).to.be.eql(" " + "Null Island"[0]); + expect(evaluated.sections[6].text).to.be.eql("\u200B)"); + + expect(evaluated.sections[3].scale).to.be.below(0.1); + expect(evaluated.sections[4].scale).to.be.below(1); + expect(evaluated.sections[5].scale).to.be.below(0.1); + }); + it("deduplicates matching anglicized and local names", function () { + expectGloss("en", "Null Island", "Null Island", "Null Island", null); + expectGloss("en", "Null Island", "NULL Island", "Null Island", null); + expectGloss("en", "Montreal", "Montréal", "Montréal", null); + expectGloss("en", "Quebec City", "Québec", "Québec City", null); + expectGloss("en", "Da Nang", "Đà Nẵng", "Đà Nẵng", null); + expectGloss("en", "Nūll Island", "Ñüłl Íşlåńđ", "Ñüłl Íşlåńđ", null); + expectGloss("en", "New York City", "New York", "New York City", null); + expectGloss( + "en", + "Washington, D.C.", + "Washington", + "Washington, D.C.", + null + ); + expectGloss( + "en", + "Santiago de Querétaro", + "Querétaro", + "Santiago de Querétaro", + null + ); + + // Suboptimal but expected cases + + expectGloss("en", "Córdobaaa", "Córdoba", "Córdobaaa", "Córdoba"); + expectGloss( + "en", + "Derry", + "Derry/Londonderry", + "Derry", + "Derry/Londonderry" + ); + expectGloss("en", "L’Aquila", "L'Aquila", "L’Aquila", "L'Aquila"); + }); + it("glosses non-English localized name with lookalike local name", function () { + expectGloss( + "es", + "Los Ángeles", + "Los Angeles", + "Los Ángeles", + "Los Angeles" + ); + expectGloss("es", "Montreal", "Montréal", "Montreal", "Montréal"); + expectGloss("es", "Quebec", "Québec", "Quebec", "Québec"); + expectGloss("pl", "Ryga", "Rīga", "Ryga", "Rīga"); + expectGloss("pl", "Jurmała", "Jūrmala", "Jurmała", "Jūrmala"); + }); + }); });