feat: add multilingual interface support

This commit is contained in:
2026-07-24 16:00:12 +01:00
parent d2567c59ce
commit 16383c2a3f
10 changed files with 788 additions and 84 deletions
+136 -56
View File
@@ -1,3 +1,6 @@
const i18n = XProI18n.createI18n();
const t = i18n.t;
const DEFAULT_CENTER = [-8.65, 39.55];
const DEFAULT_ZOOM = 7;
const DEFAULT_ROTATION = 0;
@@ -41,12 +44,20 @@ const els = {
parcelSearchInput: document.getElementById("parcel-search-input"),
parcelSearchStatus: document.getElementById("parcel-search-status"),
backgroundSelect: document.getElementById("background-select"),
settingsOpen: document.getElementById("settings-open"),
settingsModal: document.getElementById("settings-modal"),
settingsModalClose: document.getElementById("settings-modal-close"),
settingsForm: document.getElementById("settings-form"),
languageSelect: document.getElementById("language-select"),
featureModal: document.getElementById("feature-modal"),
featureModalDialog: document.querySelector(".feature-modal-dialog"),
featureModalClose: document.getElementById("feature-modal-close"),
featureModalContent: document.getElementById("feature-modal-content"),
};
localizeDocument();
populateLanguageOptions();
// A view link must be complete before it is applied. This prevents a typo or
// a partially copied URL from leaving the map in an arbitrary half-restored
// state.
@@ -57,8 +68,7 @@ let applyingSharedMapState = false;
const softBasemapLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
url: "https://{a-d}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png",
attributions:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
attributions: t("softAttribution"),
}),
});
@@ -71,8 +81,7 @@ const satelliteLayer = new ol.layer.Tile({
visible: false,
source: new ol.source.XYZ({
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attributions:
'Tiles © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
attributions: t("satelliteAttribution"),
}),
});
@@ -159,7 +168,16 @@ let featureModalRestoreRequestId = 0;
const map = new ol.Map({
target: "map",
controls: ol.control
.defaults.defaults({ rotate: false })
.defaults.defaults({
rotate: false,
zoomOptions: {
zoomInTipLabel: t("zoomIn"),
zoomOutTipLabel: t("zoomOut"),
},
attributionOptions: {
tipLabel: t("attribution"),
},
})
.extend([
createVisibleLayersExtentControl(),
createAreaZoomControl(),
@@ -195,6 +213,14 @@ if (window.matchMedia(SMALL_SCREEN_MEDIA_QUERY).matches) {
els.parcelSearchForm.addEventListener("submit", searchParcela);
els.backgroundSelect.addEventListener("change", syncBackground);
els.settingsOpen.addEventListener("click", openSettingsModal);
els.settingsModalClose.addEventListener("click", closeSettingsModal);
els.settingsForm.addEventListener("submit", applySettings);
els.settingsModal.addEventListener("click", (event) => {
if (event.target.matches("[data-settings-modal-close]")) {
closeSettingsModal();
}
});
els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
els.featureModalClose.addEventListener("click", closeFeatureModal);
@@ -210,7 +236,9 @@ els.featureModal.addEventListener("click", (event) => {
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (!els.featureModal.hidden) {
if (!els.settingsModal.hidden) {
closeSettingsModal();
} else if (!els.featureModal.hidden) {
closeFeatureModal();
} else if (areaZoomInteraction.getActive()) {
setAreaZoomMode(false);
@@ -255,6 +283,54 @@ function setSidebarCollapsed(collapsed, { focus = true } = {}) {
}
}
function localizeDocument() {
document.documentElement.lang = i18n.language;
document.querySelectorAll("[data-i18n]").forEach((element) => {
element.textContent = t(element.dataset.i18n);
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
});
}
function populateLanguageOptions() {
const browserOption = document.createElement("option");
browserOption.value = "browser";
browserOption.textContent = t("useBrowserDefault");
const languageOptions = i18n.supportedLanguages.map((language) => {
const option = document.createElement("option");
option.value = language;
option.lang = language;
option.textContent = XProI18n.TRANSLATIONS[language].languageName;
return option;
});
els.languageSelect.replaceChildren(browserOption, ...languageOptions);
els.languageSelect.value = i18n.preference || "browser";
}
function openSettingsModal() {
els.languageSelect.value = i18n.preference || "browser";
els.settingsModal.hidden = false;
els.languageSelect.focus();
}
function closeSettingsModal() {
els.settingsModal.hidden = true;
els.settingsOpen.focus();
}
function applySettings(event) {
event.preventDefault();
i18n.setLanguage(els.languageSelect.value === "browser" ? null : els.languageSelect.value);
// Reload so all transient statuses, map controls, and any open feature
// dialog are recreated consistently in the newly selected language. The
// current map view is already represented in the URL and is preserved.
window.location.reload();
}
function createNorthPointerIcon() {
const icon = document.createElement("img");
icon.className = "north-pointer-icon";
@@ -267,7 +343,7 @@ function createNorthPointerControl() {
const control = new ol.control.Rotate({
autoHide: true,
label: createNorthPointerIcon(),
tipLabel: "Reset map orientation to north",
tipLabel: t("resetNorth"),
});
// Resetting the view while compass orientation remains subscribed lets the
@@ -288,8 +364,8 @@ function createVisibleLayersExtentControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Zoom to visible layers";
button.setAttribute("aria-label", "Zoom to visible layers");
button.title = t("zoomVisibleLayers");
button.setAttribute("aria-label", t("zoomVisibleLayers"));
button.textContent = "⤢";
button.addEventListener("click", () => fitToVisibleLayers());
@@ -303,8 +379,8 @@ function createShareControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Copy link to this map view";
button.setAttribute("aria-label", "Copy link to this map view");
button.title = t("copyMapLink");
button.setAttribute("aria-label", t("copyMapLink"));
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
icon.setAttribute("aria-hidden", "true");
@@ -323,14 +399,14 @@ async function copyCurrentViewUrl() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
throw new Error(t("clipboardUnavailable"));
}
await navigator.clipboard.writeText(window.location.href);
showMessage("Link to this map view copied to your clipboard.");
showMessage(t("mapLinkCopied"));
} catch (error) {
console.error(error);
showMessage("Unable to copy the map link. Please copy the address from your browser.", {
showMessage(t("mapLinkCopyFailed"), {
type: "error",
});
}
@@ -342,8 +418,8 @@ function createAreaZoomControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Zoom to area";
button.setAttribute("aria-label", "Zoom to area");
button.title = t("zoomArea");
button.setAttribute("aria-label", t("zoomArea"));
button.setAttribute("aria-pressed", "false");
const icon = document.createElement("img");
@@ -397,19 +473,19 @@ async function toggleDeviceCompass() {
}
if (!window.isSecureContext) {
updateDeviceCompassButton("Device compass requires HTTPS; HTTP works only on localhost.");
updateDeviceCompassButton(t("compassRequiresHttps"));
return;
}
if (!("DeviceOrientationEvent" in window)) {
updateDeviceCompassButton("Device compass is unavailable on this device.");
updateDeviceCompassButton(t("compassUnavailable"));
return;
}
try {
const permission = await requestDeviceOrientationPermission();
if (permission !== "granted") {
updateDeviceCompassButton("Device compass permission was not granted.");
updateDeviceCompassButton(t("compassPermissionDenied"));
return;
}
@@ -424,10 +500,10 @@ async function toggleDeviceCompass() {
window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass);
}
}, 300);
updateDeviceCompassButton("Device compass orientation is on.");
updateDeviceCompassButton(t("compassOn"));
} catch (error) {
console.error(error);
updateDeviceCompassButton("Unable to start the device compass.");
updateDeviceCompassButton(t("compassStartFailed"));
}
}
@@ -454,7 +530,7 @@ function stopDeviceCompass() {
deviceCompassSource = null;
lastCompassHeading = null;
deviceCompassActive = false;
updateDeviceCompassButton("Device compass orientation is off.");
updateDeviceCompassButton(t("compassOff"));
}
function syncMapToDeviceCompass(event) {
@@ -527,8 +603,8 @@ function updateDeviceCompassButton(message) {
}
const label = deviceCompassActive
? "Turn off device compass orientation"
: "Align map with device compass";
? t("compassTurnOff")
: t("compassAlign");
deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive));
deviceCompassButton.setAttribute("aria-label", label);
deviceCompassButton.title = message || label;
@@ -563,7 +639,7 @@ function showMessage(message, { type = "info", duration = MESSAGE_DURATIONS[type
const dismiss = document.createElement("button");
dismiss.className = "message-dismiss";
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Dismiss message");
dismiss.setAttribute("aria-label", t("dismissMessage"));
dismiss.textContent = "×";
const progress = document.createElement("span");
@@ -593,7 +669,7 @@ function setAreaZoomMode(active) {
areaZoomButton?.setAttribute("aria-pressed", String(active));
areaZoomButton?.setAttribute(
"title",
active ? "Exit area zoom (Esc)" : "Zoom to area",
active ? t("exitAreaZoom") : t("zoomArea"),
);
if (active) {
@@ -621,12 +697,12 @@ async function searchParcela(event) {
const parcelNumber = els.parcelSearchInput.value.trim();
if (!parcelNumber) {
updateParcelSearchStatus("Enter a parcel number to search.", "error");
showMessage("Enter a parcel number to search.", { type: "warning" });
updateParcelSearchStatus(t("enterParcel"), "error");
showMessage(t("enterParcel"), { type: "warning" });
return;
}
updateParcelSearchStatus("Searching for parcel");
updateParcelSearchStatus(t("searchingParcel"));
parcelSearchSource.clear();
try {
@@ -642,7 +718,7 @@ async function searchParcela(event) {
const response = await fetch(`${API_WMS}?${params}`);
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}.`);
throw new Error(t("searchFailedStatus", { status: response.status }));
}
const featureCollection = await response.json();
@@ -651,8 +727,9 @@ async function searchParcela(event) {
});
if (!features.length) {
updateParcelSearchStatus(`No parcel found with number “${parcelNumber}”.`, "error");
showMessage(`No parcel found with number “${parcelNumber}”.`, { type: "warning" });
const notFoundMessage = t("noParcelFound", { parcelNumber });
updateParcelSearchStatus(notFoundMessage, "error");
showMessage(notFoundMessage, { type: "warning" });
return;
}
@@ -664,13 +741,13 @@ async function searchParcela(event) {
});
const searchMessage = features.length === 1
? `Found parcel ${parcelNumber}.`
: `Found ${features.length} parcels matching ${parcelNumber}.`;
? t("foundParcel", { parcelNumber })
: t("foundParcels", { count: features.length, parcelNumber });
updateParcelSearchStatus(searchMessage, "success");
showMessage(searchMessage);
} catch (error) {
console.error(error);
const searchMessage = error.message || "Unable to search for that parcel.";
const searchMessage = error.message || t("parcelSearchFailed");
updateParcelSearchStatus(searchMessage, "error");
showMessage(searchMessage, { type: "error" });
}
@@ -689,7 +766,7 @@ function buildParcelSearchFilter(parcelNumber) {
}
async function loadLayers() {
updateStatus("Loading WMS layers...");
updateStatus(t("loadingWmsLayers"));
try {
const capabilities = await loadCapabilities();
@@ -706,10 +783,10 @@ async function loadLayers() {
}
restoreSharedFeatureModal();
updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success");
updateStatus("");
} catch (error) {
console.error(error);
const loadMessage = error.message || "Unable to load the WMS service.";
const loadMessage = error.message || t("wmsLoadFailed");
updateStatus(loadMessage, "error");
showMessage(loadMessage, { type: "error" });
availableLayers = [];
@@ -733,7 +810,7 @@ async function loadCapabilities() {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GeoServer connection failed with status ${response.status}.`);
throw new Error(t("geoserverConnectionFailed", { status: response.status }));
}
return response.text();
@@ -771,7 +848,7 @@ function renderLayerList() {
if (!userVisibleLayers.length) {
els.layerList.className = "layer-list empty";
els.layerList.textContent = "No published layers were returned by the service.";
els.layerList.textContent = t("noPublishedLayers");
return;
}
@@ -970,20 +1047,20 @@ async function renderFeatureModal(feature, isParcel, requestId) {
const properties = feature.properties || {};
const featureId = isParcel ? properties.cod_parcela : properties.cod_predio;
content.append(createFeatureHeading(isParcel ? "Parcel" : "Building", featureId));
content.append(createFeatureDetails([["Area", formatFeatureArea(feature)]]));
content.append(createFeatureHeading(isParcel ? t("parcel") : t("building"), featureId));
content.append(createFeatureDetails([[t("area"), formatFeatureArea(feature)]]));
if (isParcel) {
if (isDwellingParcel(feature)) {
const inhabited = document.createElement("p");
inhabited.className = "feature-inhabited";
inhabited.textContent = "Inhabited";
inhabited.textContent = t("inhabited");
content.append(inhabited);
}
if (properties.predio_uuid) {
content.append(createFeatureNavigation(
"View corresponding building",
t("viewCorrespondingBuilding"),
BUILDING_LAYER_NAME,
properties.predio_uuid,
));
@@ -993,7 +1070,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
const section = document.createElement("section");
section.className = "feature-associations";
const heading = document.createElement("h3");
heading.textContent = "Associated parcels";
heading.textContent = t("associatedParcels");
section.append(heading);
if (parcels.length) {
@@ -1006,10 +1083,13 @@ async function renderFeatureModal(feature, isParcel, requestId) {
parcel.properties?.parcela_uuid,
);
link.className = "feature-association";
link.setAttribute("aria-label", `View parcel ${parcel.properties?.cod_parcela || ""}`.trim());
const parcelNumber = parcel.properties?.cod_parcela || "";
link.setAttribute("aria-label", t("viewParcel", { parcelNumber }).trim());
const label = document.createElement("span");
label.className = "feature-association-label";
label.textContent = `Parcel ${parcel.properties?.cod_parcela || "Unavailable"}`;
label.textContent = t("parcelWithNumber", {
parcelNumber: parcelNumber || t("unavailable"),
});
const area = document.createElement("span");
area.className = "feature-association-area";
area.textContent = formatFeatureArea(parcel);
@@ -1017,7 +1097,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
if (isDwellingParcel(parcel)) {
const inhabited = document.createElement("span");
inhabited.className = "feature-association-inhabited";
inhabited.textContent = "Inhabited";
inhabited.textContent = t("inhabited");
link.append(inhabited);
}
item.append(link);
@@ -1026,7 +1106,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
section.append(list);
} else {
const empty = document.createElement("p");
empty.textContent = "No associated parcels were found.";
empty.textContent = t("noAssociatedParcels");
section.append(empty);
}
@@ -1048,8 +1128,8 @@ function createFeatureModalShareButton() {
const button = document.createElement("button");
button.type = "button";
button.className = "feature-modal-share";
button.title = "Copy link to this map view";
button.setAttribute("aria-label", "Copy link to this map view");
button.title = t("copyMapLink");
button.setAttribute("aria-label", t("copyMapLink"));
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
@@ -1232,7 +1312,7 @@ function formatFeatureArea(feature) {
const area = Number(areaEntry?.[1]);
if (Number.isFinite(area) && area >= 0) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(area)} m²`;
return `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(area)} m²`;
}
const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, {
@@ -1240,8 +1320,8 @@ function formatFeatureArea(feature) {
});
const calculatedArea = geometry?.getArea?.();
return Number.isFinite(calculatedArea)
? `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
: "Area unavailable";
? `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
: t("areaUnavailable");
}
function fitToLayerExtent(layer) {
@@ -1264,8 +1344,8 @@ function fitToVisibleLayers() {
.map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"));
if (!projectedExtents.length) {
updateStatus("Select a layer with a published extent to zoom to it.", "error");
showMessage("Select a layer with a published extent to zoom to it.", { type: "warning" });
updateStatus(t("selectLayerToZoom"), "error");
showMessage(t("selectLayerToZoom"), { type: "warning" });
return;
}