feat: add multilingual interface support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user