diff --git a/src/americana.js b/src/americana.js index 15e0f8ae8..275360b62 100644 --- a/src/americana.js +++ b/src/americana.js @@ -301,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 34872434e..9277fd9b1 100644 --- a/src/constants/label.js +++ b/src/constants/label.js @@ -118,10 +118,134 @@ export function localizeLayers(layers, locales) { } } -// Placeholder to be resolved by buildLayers() +/** + * The name in the user's preferred language. + */ export const localizedName = [ "let", "localizedName", "", ["var", "localizedName"], ]; + +/** + * 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", + [ + "==", + ["var", "localizedName"], + ["get", "name"], + ["var", "localizedCollator"], + ], + ["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), replace the common prefix with the local name. + [ + "all", + [ + "==", + ["slice", ["var", "localizedName"], 0, ["length", ["get", "name"]]], + ["get", "name"], + ["var", "diacriticInsensitiveCollator"], + ], + [ + "in", + [ + "slice", + // "Montreal" vs. "Montréal" + ["concat", ["var", "localizedName"], " "], + ["length", ["get", "name"]], + ["+", ["length", ["get", "name"]], 1], + ], + // "Quebec City" vs. "Québec", "Washington, D.C." vs. "Washington" + " ,", + ], + ], + [ + "concat", + ["get", "name"], + ["slice", ["var", "localizedName"], ["length", ["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), replace the common suffix with the local name. + [ + "all", + [ + "==", + [ + "slice", + ["var", "localizedName"], + [ + "-", + ["length", ["var", "localizedName"]], + ["length", ["get", "name"]], + ], + ], + ["get", "name"], + ["var", "diacriticInsensitiveCollator"], + ], + [ + "==", + [ + "slice", + ["var", "localizedName"], + [ + "-", + [ + "-", + ["length", ["var", "localizedName"]], + ["length", ["get", "name"]], + ], + 1, + ], + [ + "-", + ["length", ["var", "localizedName"]], + ["length", ["get", "name"]], + ], + ], + " ", + ], + ], + [ + "concat", + [ + "slice", + ["var", "localizedName"], + 0, + [ + "-", + ["length", ["var", "localizedName"]], + ["length", ["get", "name"]], + ], + ], + ["get", "name"], + ], + // 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/place.js b/src/layer/place.js index 280fb4cc8..201653964 100644 --- a/src/layer/place.js +++ b/src/layer/place.js @@ -181,124 +181,7 @@ export const city = { [11, 0.9], ], }, - "text-field": [ - "let", - "localizedName", - "", - "localizedCollator", - ["collator", {}], - "diacriticInsensitiveCollator", - ["collator", {}], - [ - "case", - [ - "==", - ["var", "localizedName"], - ["get", "name"], - ["var", "localizedCollator"], - ], - ["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), replace the common prefix with the local name. - [ - "all", - [ - "==", - ["slice", ["var", "localizedName"], 0, ["length", ["get", "name"]]], - ["get", "name"], - ["var", "diacriticInsensitiveCollator"], - ], - [ - "in", - [ - "slice", - // "Montreal" vs. "Montréal" - ["concat", ["var", "localizedName"], " "], - ["length", ["get", "name"]], - ["+", ["length", ["get", "name"]], 1], - ], - // "Quebec City" vs. "Québec", "Washington, D.C." vs. "Washington" - " ,", - ], - ], - [ - "concat", - ["get", "name"], - ["slice", ["var", "localizedName"], ["length", ["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), replace the common suffix with the local name. - [ - "all", - [ - "==", - [ - "slice", - ["var", "localizedName"], - [ - "-", - ["length", ["var", "localizedName"]], - ["length", ["get", "name"]], - ], - ], - ["get", "name"], - ["var", "diacriticInsensitiveCollator"], - ], - [ - "==", - [ - "slice", - ["var", "localizedName"], - [ - "-", - [ - "-", - ["length", ["var", "localizedName"]], - ["length", ["get", "name"]], - ], - 1, - ], - [ - "-", - ["length", ["var", "localizedName"]], - ["length", ["get", "name"]], - ], - ], - " ", - ], - ], - [ - "concat", - [ - "slice", - ["var", "localizedName"], - 0, - [ - "-", - ["length", ["var", "localizedName"]], - ["length", ["get", "name"]], - ], - ], - ["get", "name"], - ], - // 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 }, - ], - ], - ], + "text-field": Label.localizedNameWithLocalGloss, "text-anchor": "bottom", "text-variable-anchor": [ "bottom", diff --git a/test/spec/label.js b/test/spec/label.js index acdb42fa6..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 () { @@ -137,7 +156,7 @@ describe("label", function () { }, { layout: { - "text-field": Label.localizedName, + "text-field": [...Label.localizedName], }, }, ]; @@ -153,7 +172,7 @@ describe("label", function () { { "source-layer": "transportation_name", layout: { - "text-field": Label.localizedName, + "text-field": [...Label.localizedName], }, }, ]; @@ -223,4 +242,140 @@ describe("label", function () { .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"); + }); + }); });