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 @@ - - - - + + + + diff --git a/i18n.js b/i18n.js new file mode 100644 index 0000000..e269fe7 --- /dev/null +++ b/i18n.js @@ -0,0 +1,358 @@ +(function initializeI18n(globalScope) { + "use strict"; + + const DEFAULT_LANGUAGE = "en"; + const STORAGE_KEY = "xprov-language"; + const TRANSLATIONS = { + en: { + languageName: "English", + pageTitle: "XPro Map Visualizer", + applicationMessages: "Application messages", + openMapControls: "Open map controls", + mapControls: "Map controls", + closeMapControls: "Close map controls", + mapVisualizer: "Map Visualizer", + openSettings: "Open settings", + closeSettings: "Close settings", + settings: "Settings", + applySettings: "Apply", + useBrowserDefault: "Use browser default", + find: "Find", + parcelExample: "e.g. 57 or 2669.1", + parcelNumber: "Parcel number", + findOnMap: "Find on map", + layers: "Layers", + waitingForConnection: "Waiting for connection.", + loadingPublishedLayers: "Loading published layers.", + baseMap: "Base map", + softMinimal: "Soft minimal", + openStreetMap: "OpenStreetMap", + satelliteImagery: "Satellite imagery", + language: "Language", + applicationLanguage: "Application language", + mapView: "Map view", + closeFeatureInformation: "Close feature information", + resetNorth: "Reset map orientation to north", + zoomVisibleLayers: "Zoom to visible layers", + copyMapLink: "Copy link to this map view", + clipboardUnavailable: "Clipboard access is unavailable.", + mapLinkCopied: "Link to this map view copied to your clipboard.", + mapLinkCopyFailed: + "Unable to copy the map link. Please copy the address from your browser.", + zoomArea: "Zoom to area", + exitAreaZoom: "Exit area zoom (Esc)", + compassRequiresHttps: "Device compass requires HTTPS; HTTP works only on localhost.", + compassUnavailable: "Device compass is unavailable on this device.", + compassPermissionDenied: "Device compass permission was not granted.", + compassOn: "Device compass orientation is on.", + compassStartFailed: "Unable to start the device compass.", + compassOff: "Device compass orientation is off.", + compassTurnOff: "Turn off device compass orientation", + compassAlign: "Align map with device compass", + dismissMessage: "Dismiss message", + enterParcel: "Enter a parcel number to search.", + searchingParcel: "Searching for parcel…", + searchFailedStatus: "Parcel search failed (status {status}).", + noParcelFound: "No parcel found with number “{parcelNumber}”.", + foundParcel: "Found parcel {parcelNumber}.", + foundParcels: "Found {count} parcels matching {parcelNumber}.", + parcelSearchFailed: "Unable to search for that parcel.", + loadingWmsLayers: "Loading WMS layers…", + wmsLoadFailed: "Unable to load the WMS service.", + geoserverConnectionFailed: "GeoServer connection failed (status {status}).", + noPublishedLayers: "No published layers were returned by the service.", + selectLayerToZoom: "Select a layer with a published extent to zoom to it.", + parcel: "Parcel", + building: "Building", + area: "Area", + areaUnavailable: "Area unavailable", + inhabited: "Inhabited", + viewCorrespondingBuilding: "View corresponding building", + associatedParcels: "Associated parcels", + viewParcel: "View parcel {parcelNumber}", + parcelWithNumber: "Parcel {parcelNumber}", + unavailable: "Unavailable", + noAssociatedParcels: "No associated parcels were found.", + zoomIn: "Zoom in", + zoomOut: "Zoom out", + attribution: "Attributions", + softAttribution: + '© OpenStreetMap contributors © CARTO', + satelliteAttribution: + 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community', + }, + es: { + languageName: "Español", + pageTitle: "Visualizador de Mapas XPro", + applicationMessages: "Mensajes de la aplicación", + openMapControls: "Abrir controles del mapa", + mapControls: "Controles del mapa", + closeMapControls: "Cerrar controles del mapa", + mapVisualizer: "Visualizador de Mapas", + openSettings: "Abrir configuración", + closeSettings: "Cerrar configuración", + settings: "Configuración", + applySettings: "Aplicar", + useBrowserDefault: "Usar el idioma predeterminado del navegador", + find: "Buscar", + parcelExample: "p. ej., 57 o 2669.1", + parcelNumber: "Número de parcela", + findOnMap: "Localizar en el mapa", + layers: "Capas", + waitingForConnection: "Esperando conexión.", + loadingPublishedLayers: "Cargando capas publicadas.", + baseMap: "Mapa base", + softMinimal: "Minimalista suave", + openStreetMap: "OpenStreetMap", + satelliteImagery: "Imágenes por satélite", + language: "Idioma", + applicationLanguage: "Idioma de la aplicación", + mapView: "Vista del mapa", + closeFeatureInformation: "Cerrar información del elemento", + resetNorth: "Restablecer la orientación del mapa hacia el norte", + zoomVisibleLayers: "Ajustar a las capas visibles", + copyMapLink: "Copiar enlace a esta vista del mapa", + clipboardUnavailable: "El acceso al portapapeles no está disponible.", + mapLinkCopied: "El enlace a esta vista se ha copiado al portapapeles.", + mapLinkCopyFailed: + "No se pudo copiar el enlace. Copie la dirección desde el navegador.", + zoomArea: "Ampliar un área", + exitAreaZoom: "Salir de la ampliación de área (Esc)", + compassRequiresHttps: + "La brújula del dispositivo requiere HTTPS; HTTP solo funciona en localhost.", + compassUnavailable: "La brújula no está disponible en este dispositivo.", + compassPermissionDenied: "No se concedió permiso para usar la brújula.", + compassOn: "La orientación mediante la brújula está activada.", + compassStartFailed: "No se pudo iniciar la brújula del dispositivo.", + compassOff: "La orientación mediante la brújula está desactivada.", + compassTurnOff: "Desactivar la orientación mediante la brújula", + compassAlign: "Alinear el mapa con la brújula", + dismissMessage: "Descartar mensaje", + enterParcel: "Introduzca un número de parcela para buscar.", + searchingParcel: "Buscando la parcela…", + searchFailedStatus: "La búsqueda de la parcela falló (estado {status}).", + noParcelFound: "No se encontró la parcela con el número «{parcelNumber}».", + foundParcel: "Se encontró la parcela {parcelNumber}.", + foundParcels: "Se encontraron {count} parcelas que coinciden con {parcelNumber}.", + parcelSearchFailed: "No se pudo buscar esa parcela.", + loadingWmsLayers: "Cargando capas WMS…", + wmsLoadFailed: "No se pudo cargar el servicio WMS.", + geoserverConnectionFailed: "La conexión con GeoServer falló (estado {status}).", + noPublishedLayers: "El servicio no devolvió ninguna capa publicada.", + selectLayerToZoom: "Seleccione una capa con extensión publicada para encuadrarla.", + parcel: "Parcela", + building: "Edificio", + area: "Área", + areaUnavailable: "Área no disponible", + inhabited: "Habitada", + viewCorrespondingBuilding: "Ver el edificio correspondiente", + associatedParcels: "Parcelas asociadas", + viewParcel: "Ver parcela {parcelNumber}", + parcelWithNumber: "Parcela {parcelNumber}", + unavailable: "No disponible", + noAssociatedParcels: "No se encontraron parcelas asociadas.", + zoomIn: "Acercar", + zoomOut: "Alejar", + attribution: "Atribuciones", + softAttribution: + '© Colaboradores de OpenStreetMap © CARTO', + satelliteAttribution: + 'Mosaicos © Esri — Fuente: Esri, Maxar, Earthstar Geographics y la comunidad de usuarios de SIG', + }, + "pt-PT": { + languageName: "Português", + pageTitle: "Visualizador de Mapas XPro", + applicationMessages: "Mensagens da aplicação", + openMapControls: "Abrir controlos do mapa", + mapControls: "Controlos do mapa", + closeMapControls: "Fechar controlos do mapa", + mapVisualizer: "Visualizador de Mapas", + openSettings: "Abrir definições", + closeSettings: "Fechar definições", + settings: "Definições", + applySettings: "Aplicar", + useBrowserDefault: "Usar predefinição do navegador", + find: "Pesquisar", + parcelExample: "p. ex. 57 ou 2669.1", + parcelNumber: "Número da parcela", + findOnMap: "Localizar no mapa", + layers: "Camadas", + waitingForConnection: "A aguardar ligação.", + loadingPublishedLayers: "A carregar camadas publicadas.", + baseMap: "Mapa base", + softMinimal: "Minimalista suave", + openStreetMap: "OpenStreetMap", + satelliteImagery: "Imagem de satélite", + language: "Idioma", + applicationLanguage: "Idioma da aplicação", + mapView: "Vista do mapa", + closeFeatureInformation: "Fechar informação do elemento", + resetNorth: "Repor a orientação do mapa para norte", + zoomVisibleLayers: "Ajustar às camadas visíveis", + copyMapLink: "Copiar ligação para esta vista do mapa", + clipboardUnavailable: "O acesso à área de transferência não está disponível.", + mapLinkCopied: "A ligação para esta vista foi copiada para a área de transferência.", + mapLinkCopyFailed: + "Não foi possível copiar a ligação. Copie o endereço a partir do navegador.", + zoomArea: "Ampliar uma área", + exitAreaZoom: "Sair da ampliação de área (Esc)", + compassRequiresHttps: + "A bússola do dispositivo requer HTTPS; HTTP funciona apenas em localhost.", + compassUnavailable: "A bússola não está disponível neste dispositivo.", + compassPermissionDenied: "A permissão para usar a bússola não foi concedida.", + compassOn: "A orientação pela bússola está ativa.", + compassStartFailed: "Não foi possível iniciar a bússola do dispositivo.", + compassOff: "A orientação pela bússola está desativada.", + compassTurnOff: "Desativar orientação pela bússola", + compassAlign: "Alinhar mapa com a bússola", + dismissMessage: "Dispensar mensagem", + enterParcel: "Introduza um número de parcela para pesquisar.", + searchingParcel: "A pesquisar a parcela…", + searchFailedStatus: "A pesquisa da parcela falhou (estado {status}).", + noParcelFound: "Não foi encontrada a parcela com o número “{parcelNumber}”.", + foundParcel: "Parcela {parcelNumber} encontrada.", + foundParcels: "Foram encontradas {count} parcelas correspondentes a {parcelNumber}.", + parcelSearchFailed: "Não foi possível pesquisar essa parcela.", + loadingWmsLayers: "A carregar camadas WMS…", + wmsLoadFailed: "Não foi possível carregar o serviço WMS.", + geoserverConnectionFailed: "A ligação ao GeoServer falhou (estado {status}).", + noPublishedLayers: "O serviço não devolveu camadas publicadas.", + selectLayerToZoom: "Selecione uma camada com extensão publicada para a enquadrar.", + parcel: "Parcela", + building: "Prédio", + area: "Área", + areaUnavailable: "Área indisponível", + inhabited: "Habitada", + viewCorrespondingBuilding: "Ver prédio correspondente", + associatedParcels: "Parcelas associadas", + viewParcel: "Ver parcela {parcelNumber}", + parcelWithNumber: "Parcela {parcelNumber}", + unavailable: "Indisponível", + noAssociatedParcels: "Não foram encontradas parcelas associadas.", + zoomIn: "Ampliar", + zoomOut: "Reduzir", + attribution: "Atribuições", + softAttribution: + '© Colaboradores do OpenStreetMap © CARTO', + satelliteAttribution: + 'Mosaicos © Esri — Fonte: Esri, Maxar, Earthstar Geographics e comunidade de utilizadores de SIG', + }, + }; + + function normalizeLanguage(language) { + if (typeof language !== "string") { + return null; + } + + const normalized = language.replace("_", "-").toLowerCase(); + return Object.keys(TRANSLATIONS).find((candidate) => { + const candidateNormalized = candidate.toLowerCase(); + return candidateNormalized === normalized || candidateNormalized.split("-")[0] === normalized.split("-")[0]; + }) || null; + } + + function readStoredLanguage(storage) { + try { + return normalizeLanguage(storage?.getItem(STORAGE_KEY)); + } catch { + return null; + } + } + + function detectLanguage({ storage, languages = [] } = {}) { + const storedLanguage = readStoredLanguage(storage); + if (storedLanguage) { + return storedLanguage; + } + + for (const language of languages) { + const supportedLanguage = normalizeLanguage(language); + if (supportedLanguage) { + return supportedLanguage; + } + } + + return DEFAULT_LANGUAGE; + } + + function detectBrowserLanguage(languages = []) { + for (const language of languages) { + const supportedLanguage = normalizeLanguage(language); + if (supportedLanguage) { + return supportedLanguage; + } + } + + return DEFAULT_LANGUAGE; + } + + function createI18n({ + storage = globalScope.localStorage, + languages = globalScope.navigator?.languages || [globalScope.navigator?.language], + } = {}) { + const browserLanguage = detectBrowserLanguage(languages); + let preference = readStoredLanguage(storage); + let language = preference || browserLanguage; + + function translate(key, values = {}) { + const template = TRANSLATIONS[language]?.[key] + ?? TRANSLATIONS[DEFAULT_LANGUAGE]?.[key] + ?? key; + return template.replace(/\{(\w+)\}/g, (match, name) => + Object.hasOwn(values, name) ? String(values[name]) : match, + ); + } + + return { + get language() { + return language; + }, + get locale() { + return language; + }, + get preference() { + return preference; + }, + get supportedLanguages() { + return Object.keys(TRANSLATIONS); + }, + setLanguage(nextLanguage) { + const useBrowserDefault = nextLanguage == null || nextLanguage === "browser"; + preference = useBrowserDefault + ? null + : normalizeLanguage(nextLanguage) || DEFAULT_LANGUAGE; + language = preference || browserLanguage; + try { + if (preference) { + storage?.setItem(STORAGE_KEY, preference); + } else { + storage?.removeItem(STORAGE_KEY); + } + } catch { + // A blocked storage API must not prevent language selection. + } + return language; + }, + t: translate, + tp(singularKey, pluralKey, count, values = {}) { + return translate(count === 1 ? singularKey : pluralKey, { ...values, count }); + }, + }; + } + + const exported = { + DEFAULT_LANGUAGE, + STORAGE_KEY, + TRANSLATIONS, + normalizeLanguage, + detectLanguage, + detectBrowserLanguage, + createI18n, + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = exported; + } + + globalScope.XProI18n = exported; +})(typeof window === "undefined" ? globalThis : window); diff --git a/index.html b/index.html index 00e10a6..dc81318 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - XPro Map Visualizer + XPro Map Visualizer @@ -14,6 +14,7 @@ id="message-region" class="message-region" aria-label="Application messages" + data-i18n-aria-label="applicationMessages" aria-live="polite" aria-relevant="additions" > @@ -26,13 +27,18 @@ aria-expanded="true" > - Open map controls + Open map controls -