diff --git a/Dockerfile b/Dockerfile
index 814398c..3fe3d69 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,6 +7,7 @@ RUN npm ci --omit=dev
COPY server.js ./
COPY index.html ./
+COPY i18n.js ./
COPY app.js ./
COPY styles.css ./
COPY assets ./assets
diff --git a/README.md b/README.md
index a524b92..4712c30 100644
--- a/README.md
+++ b/README.md
@@ -70,3 +70,15 @@ base map are changed. An open parcel or building information dialog is included
too. Copy the URL to share that exact state; opening it restores the same map
view and selected feature. Invalid or incomplete map parameters fall back to
the normal default view.
+
+## Translations
+
+The interface selects English, Spanish, or Portuguese from the browser
+preference on the first visit and remembers subsequent changes made in
+Settings. Unsupported languages and missing messages fall back to English.
+
+To add another language:
+
+1. Add a dictionary with the same keys to `TRANSLATIONS` in `i18n.js`.
+2. Translate every value, including accessibility labels and status messages.
+3. Run `npm test` to verify that the dictionary is complete.
diff --git a/app.js b/app.js
index 5eb8d3c..a11ddbb 100644
--- a/app.js
+++ b/app.js
@@ -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:
- '© OpenStreetMap contributors © CARTO',
+ 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 © Esri — 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;
}
diff --git a/assets/brand/xpro-logo-simple.svg b/assets/brand/xpro-logo-simple.svg
index eeb4527..da6656d 100644
--- a/assets/brand/xpro-logo-simple.svg
+++ b/assets/brand/xpro-logo-simple.svg
@@ -1,6 +1,6 @@
-