feat: add multilingual interface support
This commit is contained in:
@@ -7,6 +7,7 @@ RUN npm ci --omit=dev
|
|||||||
|
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
|
COPY i18n.js ./
|
||||||
COPY app.js ./
|
COPY app.js ./
|
||||||
COPY styles.css ./
|
COPY styles.css ./
|
||||||
COPY assets ./assets
|
COPY assets ./assets
|
||||||
|
|||||||
@@ -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
|
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
|
view and selected feature. Invalid or incomplete map parameters fall back to
|
||||||
the normal default view.
|
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.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
const i18n = XProI18n.createI18n();
|
||||||
|
const t = i18n.t;
|
||||||
|
|
||||||
const DEFAULT_CENTER = [-8.65, 39.55];
|
const DEFAULT_CENTER = [-8.65, 39.55];
|
||||||
const DEFAULT_ZOOM = 7;
|
const DEFAULT_ZOOM = 7;
|
||||||
const DEFAULT_ROTATION = 0;
|
const DEFAULT_ROTATION = 0;
|
||||||
@@ -41,12 +44,20 @@ const els = {
|
|||||||
parcelSearchInput: document.getElementById("parcel-search-input"),
|
parcelSearchInput: document.getElementById("parcel-search-input"),
|
||||||
parcelSearchStatus: document.getElementById("parcel-search-status"),
|
parcelSearchStatus: document.getElementById("parcel-search-status"),
|
||||||
backgroundSelect: document.getElementById("background-select"),
|
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"),
|
featureModal: document.getElementById("feature-modal"),
|
||||||
featureModalDialog: document.querySelector(".feature-modal-dialog"),
|
featureModalDialog: document.querySelector(".feature-modal-dialog"),
|
||||||
featureModalClose: document.getElementById("feature-modal-close"),
|
featureModalClose: document.getElementById("feature-modal-close"),
|
||||||
featureModalContent: document.getElementById("feature-modal-content"),
|
featureModalContent: document.getElementById("feature-modal-content"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
localizeDocument();
|
||||||
|
populateLanguageOptions();
|
||||||
|
|
||||||
// A view link must be complete before it is applied. This prevents a typo or
|
// 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
|
// a partially copied URL from leaving the map in an arbitrary half-restored
|
||||||
// state.
|
// state.
|
||||||
@@ -57,8 +68,7 @@ let applyingSharedMapState = false;
|
|||||||
const softBasemapLayer = new ol.layer.Tile({
|
const softBasemapLayer = new ol.layer.Tile({
|
||||||
source: new ol.source.XYZ({
|
source: new ol.source.XYZ({
|
||||||
url: "https://{a-d}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png",
|
url: "https://{a-d}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png",
|
||||||
attributions:
|
attributions: t("softAttribution"),
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,8 +81,7 @@ const satelliteLayer = new ol.layer.Tile({
|
|||||||
visible: false,
|
visible: false,
|
||||||
source: new ol.source.XYZ({
|
source: new ol.source.XYZ({
|
||||||
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
attributions:
|
attributions: t("satelliteAttribution"),
|
||||||
'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',
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +168,16 @@ let featureModalRestoreRequestId = 0;
|
|||||||
const map = new ol.Map({
|
const map = new ol.Map({
|
||||||
target: "map",
|
target: "map",
|
||||||
controls: ol.control
|
controls: ol.control
|
||||||
.defaults.defaults({ rotate: false })
|
.defaults.defaults({
|
||||||
|
rotate: false,
|
||||||
|
zoomOptions: {
|
||||||
|
zoomInTipLabel: t("zoomIn"),
|
||||||
|
zoomOutTipLabel: t("zoomOut"),
|
||||||
|
},
|
||||||
|
attributionOptions: {
|
||||||
|
tipLabel: t("attribution"),
|
||||||
|
},
|
||||||
|
})
|
||||||
.extend([
|
.extend([
|
||||||
createVisibleLayersExtentControl(),
|
createVisibleLayersExtentControl(),
|
||||||
createAreaZoomControl(),
|
createAreaZoomControl(),
|
||||||
@@ -195,6 +213,14 @@ if (window.matchMedia(SMALL_SCREEN_MEDIA_QUERY).matches) {
|
|||||||
|
|
||||||
els.parcelSearchForm.addEventListener("submit", searchParcela);
|
els.parcelSearchForm.addEventListener("submit", searchParcela);
|
||||||
els.backgroundSelect.addEventListener("change", syncBackground);
|
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.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
|
||||||
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
|
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
|
||||||
els.featureModalClose.addEventListener("click", closeFeatureModal);
|
els.featureModalClose.addEventListener("click", closeFeatureModal);
|
||||||
@@ -210,7 +236,9 @@ els.featureModal.addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
if (!els.featureModal.hidden) {
|
if (!els.settingsModal.hidden) {
|
||||||
|
closeSettingsModal();
|
||||||
|
} else if (!els.featureModal.hidden) {
|
||||||
closeFeatureModal();
|
closeFeatureModal();
|
||||||
} else if (areaZoomInteraction.getActive()) {
|
} else if (areaZoomInteraction.getActive()) {
|
||||||
setAreaZoomMode(false);
|
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() {
|
function createNorthPointerIcon() {
|
||||||
const icon = document.createElement("img");
|
const icon = document.createElement("img");
|
||||||
icon.className = "north-pointer-icon";
|
icon.className = "north-pointer-icon";
|
||||||
@@ -267,7 +343,7 @@ function createNorthPointerControl() {
|
|||||||
const control = new ol.control.Rotate({
|
const control = new ol.control.Rotate({
|
||||||
autoHide: true,
|
autoHide: true,
|
||||||
label: createNorthPointerIcon(),
|
label: createNorthPointerIcon(),
|
||||||
tipLabel: "Reset map orientation to north",
|
tipLabel: t("resetNorth"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resetting the view while compass orientation remains subscribed lets the
|
// Resetting the view while compass orientation remains subscribed lets the
|
||||||
@@ -288,8 +364,8 @@ function createVisibleLayersExtentControl() {
|
|||||||
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.title = "Zoom to visible layers";
|
button.title = t("zoomVisibleLayers");
|
||||||
button.setAttribute("aria-label", "Zoom to visible layers");
|
button.setAttribute("aria-label", t("zoomVisibleLayers"));
|
||||||
button.textContent = "⤢";
|
button.textContent = "⤢";
|
||||||
button.addEventListener("click", () => fitToVisibleLayers());
|
button.addEventListener("click", () => fitToVisibleLayers());
|
||||||
|
|
||||||
@@ -303,8 +379,8 @@ function createShareControl() {
|
|||||||
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.title = "Copy link to this map view";
|
button.title = t("copyMapLink");
|
||||||
button.setAttribute("aria-label", "Copy link to this map view");
|
button.setAttribute("aria-label", t("copyMapLink"));
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "material-symbols-outlined";
|
icon.className = "material-symbols-outlined";
|
||||||
icon.setAttribute("aria-hidden", "true");
|
icon.setAttribute("aria-hidden", "true");
|
||||||
@@ -323,14 +399,14 @@ async function copyCurrentViewUrl() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!navigator.clipboard?.writeText) {
|
if (!navigator.clipboard?.writeText) {
|
||||||
throw new Error("Clipboard access is unavailable.");
|
throw new Error(t("clipboardUnavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
showMessage("Link to this map view copied to your clipboard.");
|
showMessage(t("mapLinkCopied"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
showMessage("Unable to copy the map link. Please copy the address from your browser.", {
|
showMessage(t("mapLinkCopyFailed"), {
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,8 +418,8 @@ function createAreaZoomControl() {
|
|||||||
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.title = "Zoom to area";
|
button.title = t("zoomArea");
|
||||||
button.setAttribute("aria-label", "Zoom to area");
|
button.setAttribute("aria-label", t("zoomArea"));
|
||||||
button.setAttribute("aria-pressed", "false");
|
button.setAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
const icon = document.createElement("img");
|
const icon = document.createElement("img");
|
||||||
@@ -397,19 +473,19 @@ async function toggleDeviceCompass() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
updateDeviceCompassButton("Device compass requires HTTPS; HTTP works only on localhost.");
|
updateDeviceCompassButton(t("compassRequiresHttps"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!("DeviceOrientationEvent" in window)) {
|
if (!("DeviceOrientationEvent" in window)) {
|
||||||
updateDeviceCompassButton("Device compass is unavailable on this device.");
|
updateDeviceCompassButton(t("compassUnavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const permission = await requestDeviceOrientationPermission();
|
const permission = await requestDeviceOrientationPermission();
|
||||||
if (permission !== "granted") {
|
if (permission !== "granted") {
|
||||||
updateDeviceCompassButton("Device compass permission was not granted.");
|
updateDeviceCompassButton(t("compassPermissionDenied"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,10 +500,10 @@ async function toggleDeviceCompass() {
|
|||||||
window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass);
|
window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
updateDeviceCompassButton("Device compass orientation is on.");
|
updateDeviceCompassButton(t("compassOn"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
updateDeviceCompassButton("Unable to start the device compass.");
|
updateDeviceCompassButton(t("compassStartFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +530,7 @@ function stopDeviceCompass() {
|
|||||||
deviceCompassSource = null;
|
deviceCompassSource = null;
|
||||||
lastCompassHeading = null;
|
lastCompassHeading = null;
|
||||||
deviceCompassActive = false;
|
deviceCompassActive = false;
|
||||||
updateDeviceCompassButton("Device compass orientation is off.");
|
updateDeviceCompassButton(t("compassOff"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncMapToDeviceCompass(event) {
|
function syncMapToDeviceCompass(event) {
|
||||||
@@ -527,8 +603,8 @@ function updateDeviceCompassButton(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = deviceCompassActive
|
const label = deviceCompassActive
|
||||||
? "Turn off device compass orientation"
|
? t("compassTurnOff")
|
||||||
: "Align map with device compass";
|
: t("compassAlign");
|
||||||
deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive));
|
deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive));
|
||||||
deviceCompassButton.setAttribute("aria-label", label);
|
deviceCompassButton.setAttribute("aria-label", label);
|
||||||
deviceCompassButton.title = message || label;
|
deviceCompassButton.title = message || label;
|
||||||
@@ -563,7 +639,7 @@ function showMessage(message, { type = "info", duration = MESSAGE_DURATIONS[type
|
|||||||
const dismiss = document.createElement("button");
|
const dismiss = document.createElement("button");
|
||||||
dismiss.className = "message-dismiss";
|
dismiss.className = "message-dismiss";
|
||||||
dismiss.type = "button";
|
dismiss.type = "button";
|
||||||
dismiss.setAttribute("aria-label", "Dismiss message");
|
dismiss.setAttribute("aria-label", t("dismissMessage"));
|
||||||
dismiss.textContent = "×";
|
dismiss.textContent = "×";
|
||||||
|
|
||||||
const progress = document.createElement("span");
|
const progress = document.createElement("span");
|
||||||
@@ -593,7 +669,7 @@ function setAreaZoomMode(active) {
|
|||||||
areaZoomButton?.setAttribute("aria-pressed", String(active));
|
areaZoomButton?.setAttribute("aria-pressed", String(active));
|
||||||
areaZoomButton?.setAttribute(
|
areaZoomButton?.setAttribute(
|
||||||
"title",
|
"title",
|
||||||
active ? "Exit area zoom (Esc)" : "Zoom to area",
|
active ? t("exitAreaZoom") : t("zoomArea"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -621,12 +697,12 @@ async function searchParcela(event) {
|
|||||||
|
|
||||||
const parcelNumber = els.parcelSearchInput.value.trim();
|
const parcelNumber = els.parcelSearchInput.value.trim();
|
||||||
if (!parcelNumber) {
|
if (!parcelNumber) {
|
||||||
updateParcelSearchStatus("Enter a parcel number to search.", "error");
|
updateParcelSearchStatus(t("enterParcel"), "error");
|
||||||
showMessage("Enter a parcel number to search.", { type: "warning" });
|
showMessage(t("enterParcel"), { type: "warning" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParcelSearchStatus("Searching for parcel…");
|
updateParcelSearchStatus(t("searchingParcel"));
|
||||||
parcelSearchSource.clear();
|
parcelSearchSource.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -642,7 +718,7 @@ async function searchParcela(event) {
|
|||||||
const response = await fetch(`${API_WMS}?${params}`);
|
const response = await fetch(`${API_WMS}?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const featureCollection = await response.json();
|
||||||
@@ -651,8 +727,9 @@ async function searchParcela(event) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
updateParcelSearchStatus(`No parcel found with number “${parcelNumber}”.`, "error");
|
const notFoundMessage = t("noParcelFound", { parcelNumber });
|
||||||
showMessage(`No parcel found with number “${parcelNumber}”.`, { type: "warning" });
|
updateParcelSearchStatus(notFoundMessage, "error");
|
||||||
|
showMessage(notFoundMessage, { type: "warning" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,13 +741,13 @@ async function searchParcela(event) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const searchMessage = features.length === 1
|
const searchMessage = features.length === 1
|
||||||
? `Found parcel ${parcelNumber}.`
|
? t("foundParcel", { parcelNumber })
|
||||||
: `Found ${features.length} parcels matching ${parcelNumber}.`;
|
: t("foundParcels", { count: features.length, parcelNumber });
|
||||||
updateParcelSearchStatus(searchMessage, "success");
|
updateParcelSearchStatus(searchMessage, "success");
|
||||||
showMessage(searchMessage);
|
showMessage(searchMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const searchMessage = error.message || "Unable to search for that parcel.";
|
const searchMessage = error.message || t("parcelSearchFailed");
|
||||||
updateParcelSearchStatus(searchMessage, "error");
|
updateParcelSearchStatus(searchMessage, "error");
|
||||||
showMessage(searchMessage, { type: "error" });
|
showMessage(searchMessage, { type: "error" });
|
||||||
}
|
}
|
||||||
@@ -689,7 +766,7 @@ function buildParcelSearchFilter(parcelNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLayers() {
|
async function loadLayers() {
|
||||||
updateStatus("Loading WMS layers...");
|
updateStatus(t("loadingWmsLayers"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const capabilities = await loadCapabilities();
|
const capabilities = await loadCapabilities();
|
||||||
@@ -706,10 +783,10 @@ async function loadLayers() {
|
|||||||
}
|
}
|
||||||
restoreSharedFeatureModal();
|
restoreSharedFeatureModal();
|
||||||
|
|
||||||
updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success");
|
updateStatus("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const loadMessage = error.message || "Unable to load the WMS service.";
|
const loadMessage = error.message || t("wmsLoadFailed");
|
||||||
updateStatus(loadMessage, "error");
|
updateStatus(loadMessage, "error");
|
||||||
showMessage(loadMessage, { type: "error" });
|
showMessage(loadMessage, { type: "error" });
|
||||||
availableLayers = [];
|
availableLayers = [];
|
||||||
@@ -733,7 +810,7 @@ async function loadCapabilities() {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GeoServer connection failed with status ${response.status}.`);
|
throw new Error(t("geoserverConnectionFailed", { status: response.status }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.text();
|
return response.text();
|
||||||
@@ -771,7 +848,7 @@ function renderLayerList() {
|
|||||||
|
|
||||||
if (!userVisibleLayers.length) {
|
if (!userVisibleLayers.length) {
|
||||||
els.layerList.className = "layer-list empty";
|
els.layerList.className = "layer-list empty";
|
||||||
els.layerList.textContent = "No published layers were returned by the service.";
|
els.layerList.textContent = t("noPublishedLayers");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,20 +1047,20 @@ async function renderFeatureModal(feature, isParcel, requestId) {
|
|||||||
const properties = feature.properties || {};
|
const properties = feature.properties || {};
|
||||||
const featureId = isParcel ? properties.cod_parcela : properties.cod_predio;
|
const featureId = isParcel ? properties.cod_parcela : properties.cod_predio;
|
||||||
|
|
||||||
content.append(createFeatureHeading(isParcel ? "Parcel" : "Building", featureId));
|
content.append(createFeatureHeading(isParcel ? t("parcel") : t("building"), featureId));
|
||||||
content.append(createFeatureDetails([["Area", formatFeatureArea(feature)]]));
|
content.append(createFeatureDetails([[t("area"), formatFeatureArea(feature)]]));
|
||||||
|
|
||||||
if (isParcel) {
|
if (isParcel) {
|
||||||
if (isDwellingParcel(feature)) {
|
if (isDwellingParcel(feature)) {
|
||||||
const inhabited = document.createElement("p");
|
const inhabited = document.createElement("p");
|
||||||
inhabited.className = "feature-inhabited";
|
inhabited.className = "feature-inhabited";
|
||||||
inhabited.textContent = "Inhabited";
|
inhabited.textContent = t("inhabited");
|
||||||
content.append(inhabited);
|
content.append(inhabited);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (properties.predio_uuid) {
|
if (properties.predio_uuid) {
|
||||||
content.append(createFeatureNavigation(
|
content.append(createFeatureNavigation(
|
||||||
"View corresponding building",
|
t("viewCorrespondingBuilding"),
|
||||||
BUILDING_LAYER_NAME,
|
BUILDING_LAYER_NAME,
|
||||||
properties.predio_uuid,
|
properties.predio_uuid,
|
||||||
));
|
));
|
||||||
@@ -993,7 +1070,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
|
|||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "feature-associations";
|
section.className = "feature-associations";
|
||||||
const heading = document.createElement("h3");
|
const heading = document.createElement("h3");
|
||||||
heading.textContent = "Associated parcels";
|
heading.textContent = t("associatedParcels");
|
||||||
section.append(heading);
|
section.append(heading);
|
||||||
|
|
||||||
if (parcels.length) {
|
if (parcels.length) {
|
||||||
@@ -1006,10 +1083,13 @@ async function renderFeatureModal(feature, isParcel, requestId) {
|
|||||||
parcel.properties?.parcela_uuid,
|
parcel.properties?.parcela_uuid,
|
||||||
);
|
);
|
||||||
link.className = "feature-association";
|
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");
|
const label = document.createElement("span");
|
||||||
label.className = "feature-association-label";
|
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");
|
const area = document.createElement("span");
|
||||||
area.className = "feature-association-area";
|
area.className = "feature-association-area";
|
||||||
area.textContent = formatFeatureArea(parcel);
|
area.textContent = formatFeatureArea(parcel);
|
||||||
@@ -1017,7 +1097,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
|
|||||||
if (isDwellingParcel(parcel)) {
|
if (isDwellingParcel(parcel)) {
|
||||||
const inhabited = document.createElement("span");
|
const inhabited = document.createElement("span");
|
||||||
inhabited.className = "feature-association-inhabited";
|
inhabited.className = "feature-association-inhabited";
|
||||||
inhabited.textContent = "Inhabited";
|
inhabited.textContent = t("inhabited");
|
||||||
link.append(inhabited);
|
link.append(inhabited);
|
||||||
}
|
}
|
||||||
item.append(link);
|
item.append(link);
|
||||||
@@ -1026,7 +1106,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
|
|||||||
section.append(list);
|
section.append(list);
|
||||||
} else {
|
} else {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.textContent = "No associated parcels were found.";
|
empty.textContent = t("noAssociatedParcels");
|
||||||
section.append(empty);
|
section.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,8 +1128,8 @@ function createFeatureModalShareButton() {
|
|||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "feature-modal-share";
|
button.className = "feature-modal-share";
|
||||||
button.title = "Copy link to this map view";
|
button.title = t("copyMapLink");
|
||||||
button.setAttribute("aria-label", "Copy link to this map view");
|
button.setAttribute("aria-label", t("copyMapLink"));
|
||||||
|
|
||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "material-symbols-outlined";
|
icon.className = "material-symbols-outlined";
|
||||||
@@ -1232,7 +1312,7 @@ function formatFeatureArea(feature) {
|
|||||||
const area = Number(areaEntry?.[1]);
|
const area = Number(areaEntry?.[1]);
|
||||||
|
|
||||||
if (Number.isFinite(area) && area >= 0) {
|
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, {
|
const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, {
|
||||||
@@ -1240,8 +1320,8 @@ function formatFeatureArea(feature) {
|
|||||||
});
|
});
|
||||||
const calculatedArea = geometry?.getArea?.();
|
const calculatedArea = geometry?.getArea?.();
|
||||||
return Number.isFinite(calculatedArea)
|
return Number.isFinite(calculatedArea)
|
||||||
? `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
|
? `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
|
||||||
: "Area unavailable";
|
: t("areaUnavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitToLayerExtent(layer) {
|
function fitToLayerExtent(layer) {
|
||||||
@@ -1264,8 +1344,8 @@ function fitToVisibleLayers() {
|
|||||||
.map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"));
|
.map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"));
|
||||||
|
|
||||||
if (!projectedExtents.length) {
|
if (!projectedExtents.length) {
|
||||||
updateStatus("Select a layer with a published extent to zoom to it.", "error");
|
updateStatus(t("selectLayerToZoom"), "error");
|
||||||
showMessage("Select a layer with a published extent to zoom to it.", { type: "warning" });
|
showMessage(t("selectLayerToZoom"), { type: "warning" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.295753 33.036644">
|
<svg xmlns="http://www.w3.org/2000/svg" width="51.110092" height="17.62863" viewBox="0 0 51.110092 17.62863">
|
||||||
<g transform="translate(-15.909682 -167.46353)">
|
<g transform="translate(-23.402038 -175.28522) matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)">
|
||||||
<path fill="#1574c3" d="m 51.662106,186.67656 h 1.857378 v -3.80966 h 0.59653 c 1.423537,0 2.074297,-0.10846 2.630155,-0.4474 0.81345,-0.51518 1.315077,-1.51844 1.315077,-2.67082 0,-1.15239 -0.48807,-2.10142 -1.35575,-2.63016 -0.569415,-0.33894 -1.24729,-0.46095 -2.562367,-0.46095 h -2.481023 z m 1.857378,-5.58569 v -2.65727 h 0.623645 c 0.610087,0 0.894795,0.0271 1.179502,0.0949 0.5423,0.14913 0.86768,0.62365 0.86768,1.24729 0,0.48807 -0.21692,0.86768 -0.610087,1.0846 -0.284708,0.14913 -0.786335,0.23048 -1.51844,0.23048 z m 5.500524,5.58569 h 1.803148 v -4.17571 c -0.04067,-1.11172 0.528742,-1.73536 1.613342,-1.77603 v -1.73536 h -0.135575 c -0.772777,0 -1.152387,0.21692 -1.6269,0.90835 v -0.73211 h -1.654015 z m 7.683272,-7.6871 c -2.182758,0 -3.972348,1.77603 -3.972348,3.93167 0,2.1692 1.78959,3.93168 3.985905,3.93168 2.182758,0 3.999463,-1.76248 3.999463,-3.87745 0,-2.23699 -1.748918,-3.9859 -4.01302,-3.9859 z m 0.01356,1.65401 c 1.206618,0 2.182758,1.01681 2.182758,2.27766 0,1.26085 -0.97614,2.27766 -2.1692,2.27766 -1.220175,0 -2.182758,-1.01681 -2.182758,-2.30477 0,-1.23374 0.97614,-2.25055 2.1692,-2.25055 z" transform="matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)"/>
|
<path fill="#1574c3" d="m 51.662106,186.67656 h 1.857378 v -3.80966 h 0.59653 c 1.423537,0 2.074297,-0.10846 2.630155,-0.4474 0.81345,-0.51518 1.315077,-1.51844 1.315077,-2.67082 0,-1.15239 -0.48807,-2.10142 -1.35575,-2.63016 -0.569415,-0.33894 -1.24729,-0.46095 -2.562367,-0.46095 h -2.481023 z m 1.857378,-5.58569 v -2.65727 h 0.623645 c 0.610087,0 0.894795,0.0271 1.179502,0.0949 0.5423,0.14913 0.86768,0.62365 0.86768,1.24729 0,0.48807 -0.21692,0.86768 -0.610087,1.0846 -0.284708,0.14913 -0.786335,0.23048 -1.51844,0.23048 z m 5.500524,5.58569 h 1.803148 v -4.17571 c -0.04067,-1.11172 0.528742,-1.73536 1.613342,-1.77603 v -1.73536 h -0.135575 c -0.772777,0 -1.152387,0.21692 -1.6269,0.90835 v -0.73211 h -1.654015 z m 7.683272,-7.6871 c -2.182758,0 -3.972348,1.77603 -3.972348,3.93167 0,2.1692 1.78959,3.93168 3.985905,3.93168 2.182758,0 3.999463,-1.76248 3.999463,-3.87745 0,-2.23699 -1.748918,-3.9859 -4.01302,-3.9859 z m 0.01356,1.65401 c 1.206618,0 2.182758,1.01681 2.182758,2.27766 0,1.26085 -0.97614,2.27766 -2.1692,2.27766 -1.220175,0 -2.182758,-1.01681 -2.182758,-2.30477 0,-1.23374 0.97614,-2.25055 2.1692,-2.25055 z"/>
|
||||||
<path fill="#f8a621" d="m 41.157585,186.71507 h 2.209873 l 2.33189,-3.45716 2.399677,3.45716 h 2.155643 l -3.47072,-4.98916 3.47072,-5.02983 h -2.155643 l -2.399677,3.49784 -2.33189,-3.49784 h -2.209873 l 3.443605,5.02983 z" transform="matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)"/>
|
<path fill="#f8a621" d="m 41.157585,186.71507 h 2.209873 l 2.33189,-3.45716 2.399677,3.45716 h 2.155643 l -3.47072,-4.98916 3.47072,-5.02983 h -2.155643 l -2.399677,3.49784 -2.33189,-3.49784 h -2.209873 l 3.443605,5.02983 z"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
satelliteAttribution:
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
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 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
satelliteAttribution:
|
||||||
|
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — 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 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
satelliteAttribution:
|
||||||
|
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — 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);
|
||||||
+73
-19
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XPro Map Visualizer</title>
|
<title data-i18n="pageTitle">XPro Map Visualizer</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0" />
|
||||||
<link rel="stylesheet" href="./vendor/ol.css" />
|
<link rel="stylesheet" href="./vendor/ol.css" />
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
id="message-region"
|
id="message-region"
|
||||||
class="message-region"
|
class="message-region"
|
||||||
aria-label="Application messages"
|
aria-label="Application messages"
|
||||||
|
data-i18n-aria-label="applicationMessages"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-relevant="additions"
|
aria-relevant="additions"
|
||||||
></div>
|
></div>
|
||||||
@@ -26,13 +27,18 @@
|
|||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">☰</span>
|
<span aria-hidden="true">☰</span>
|
||||||
<span class="visually-hidden">Open map controls</span>
|
<span class="visually-hidden" data-i18n="openMapControls">Open map controls</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<aside id="sidebar" class="sidebar" aria-label="Map controls">
|
<aside
|
||||||
|
id="sidebar"
|
||||||
|
class="sidebar"
|
||||||
|
aria-label="Map controls"
|
||||||
|
data-i18n-aria-label="mapControls"
|
||||||
|
>
|
||||||
<button id="sidebar-close" class="sidebar-toggle sidebar-close" type="button">
|
<button id="sidebar-close" class="sidebar-toggle sidebar-close" type="button">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
<span class="visually-hidden">Close map controls</span>
|
<span class="visually-hidden" data-i18n="closeMapControls">Close map controls</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
@@ -40,12 +46,23 @@
|
|||||||
<img class="brand-logo" src="./assets/brand/xpro-logo-simple.svg" alt="XPro" />
|
<img class="brand-logo" src="./assets/brand/xpro-logo-simple.svg" alt="XPro" />
|
||||||
<span class="visually-hidden">XPro</span>
|
<span class="visually-hidden">XPro</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="brand-subtitle">Map Visualizer</p>
|
<div class="brand-footer">
|
||||||
|
<p class="brand-subtitle" data-i18n="mapVisualizer">Map Visualizer</p>
|
||||||
|
<button
|
||||||
|
id="settings-open"
|
||||||
|
class="settings-open"
|
||||||
|
type="button"
|
||||||
|
aria-label="Open settings"
|
||||||
|
data-i18n-aria-label="openSettings"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>Find</h2>
|
<h2 data-i18n="find">Find</h2>
|
||||||
</div>
|
</div>
|
||||||
<form id="parcel-search-form" class="parcel-search-form">
|
<form id="parcel-search-form" class="parcel-search-form">
|
||||||
<input
|
<input
|
||||||
@@ -54,44 +71,51 @@
|
|||||||
type="search"
|
type="search"
|
||||||
inputmode="text"
|
inputmode="text"
|
||||||
placeholder="e.g. 57 or 2669.1"
|
placeholder="e.g. 57 or 2669.1"
|
||||||
|
data-i18n-placeholder="parcelExample"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
aria-label="Parcel number"
|
aria-label="Parcel number"
|
||||||
|
data-i18n-aria-label="parcelNumber"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit">Find on map</button>
|
<button type="submit" data-i18n="findOnMap">Find on map</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="parcel-search-status" class="status" aria-live="polite"></p>
|
<p id="parcel-search-status" class="status" aria-live="polite"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>Layers</h2>
|
<h2 data-i18n="layers">Layers</h2>
|
||||||
<span id="layer-count" class="badge">0</span>
|
<span id="layer-count" class="badge">0</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="status" class="status">Waiting for connection.</p>
|
<p id="status" class="status" data-i18n="waitingForConnection">Waiting for connection.</p>
|
||||||
<div id="layer-list" class="layer-list empty">
|
<div id="layer-list" class="layer-list empty" data-i18n="loadingPublishedLayers">
|
||||||
Loading published layers.
|
Loading published layers.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>Background</h2>
|
<h2 data-i18n="baseMap">Base map</h2>
|
||||||
</div>
|
</div>
|
||||||
<label class="background-selector" for="background-select">
|
<div class="background-selector">
|
||||||
<span>Base map</span>
|
<select
|
||||||
<select id="background-select" name="background">
|
id="background-select"
|
||||||
<option value="soft">Soft minimal</option>
|
name="background"
|
||||||
<option value="openstreetmap">OpenStreetMap</option>
|
aria-label="Base map"
|
||||||
<option value="satellite">Satellite imagery</option>
|
data-i18n-aria-label="baseMap"
|
||||||
|
>
|
||||||
|
<option value="soft" data-i18n="softMinimal">Soft minimal</option>
|
||||||
|
<option value="openstreetmap" data-i18n="openStreetMap">OpenStreetMap</option>
|
||||||
|
<option value="satellite" data-i18n="satelliteImagery">Satellite imagery</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="map-wrap">
|
<main class="map-wrap">
|
||||||
<div id="map" aria-label="Map view"></div>
|
<div id="map" aria-label="Map view" data-i18n-aria-label="mapView"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="feature-modal" class="feature-modal" hidden>
|
<div id="feature-modal" class="feature-modal" hidden>
|
||||||
@@ -107,15 +131,45 @@
|
|||||||
class="feature-modal-close"
|
class="feature-modal-close"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close feature information"
|
aria-label="Close feature information"
|
||||||
|
data-i18n-aria-label="closeFeatureInformation"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<div id="feature-modal-content"></div>
|
<div id="feature-modal-content"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-modal" class="settings-modal" hidden>
|
||||||
|
<div class="settings-modal-backdrop" data-settings-modal-close></div>
|
||||||
|
<section
|
||||||
|
class="settings-modal-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="settings-modal-title"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id="settings-modal-close"
|
||||||
|
class="settings-modal-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close settings"
|
||||||
|
data-i18n-aria-label="closeSettings"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 id="settings-modal-title" data-i18n="settings">Settings</h2>
|
||||||
|
<form id="settings-form" class="settings-form">
|
||||||
|
<label for="language-select">
|
||||||
|
<span data-i18n="applicationLanguage">Application language</span>
|
||||||
|
<select id="language-select" name="language"></select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" data-i18n="applySettings">Apply</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="./vendor/ol.js"></script>
|
<script src="./vendor/ol.js"></script>
|
||||||
|
<script src="./i18n.js"></script>
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --env-file-if-exists=.env server.js"
|
"start": "node --env-file-if-exists=.env server.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ol": "10.6.1"
|
"ol": "10.6.1"
|
||||||
|
|||||||
+93
-4
@@ -241,14 +241,49 @@ body {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
.brand-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
/* Extend through the brand's reserved close-button clearance so the gear
|
||||||
|
and sidebar close controls share the same right-hand axis. */
|
||||||
|
margin-right: -60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-open {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-open:hover,
|
||||||
|
.settings-open:focus-visible {
|
||||||
|
background: rgba(31, 122, 98, 0.1);
|
||||||
|
outline: 2px solid rgba(31, 122, 98, 0.25);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-open .material-symbols-outlined {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -370,6 +405,10 @@ button:disabled {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.status.error {
|
.status.error {
|
||||||
color: #9d2e21;
|
color: #9d2e21;
|
||||||
}
|
}
|
||||||
@@ -427,7 +466,12 @@ button:disabled {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-modal {
|
.settings-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-modal,
|
||||||
|
.settings-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -436,13 +480,53 @@ button:disabled {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-modal-backdrop {
|
.feature-modal-backdrop,
|
||||||
|
.settings-modal-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(31, 42, 46, 0.35);
|
background: rgba(31, 42, 46, 0.35);
|
||||||
backdrop-filter: blur(1px);
|
backdrop-filter: blur(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-modal-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 380px);
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-dialog h2 {
|
||||||
|
padding-right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-modal-dialog {
|
.feature-modal-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(100%, 360px);
|
width: min(100%, 360px);
|
||||||
@@ -506,9 +590,12 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-details dd {
|
.feature-details dd {
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-inhabited {
|
.feature-inhabited {
|
||||||
@@ -564,7 +651,9 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-association-label {
|
.feature-association-label {
|
||||||
|
min-width: 0;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-association-area {
|
.feature-association-area {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
test("the Docker image includes every application script referenced by the page", () => {
|
||||||
|
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
|
||||||
|
const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8");
|
||||||
|
const applicationScripts = [...html.matchAll(/<script src="\.\/([^"]+\.js)"/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((scriptPath) => !scriptPath.startsWith("vendor/"));
|
||||||
|
|
||||||
|
assert.ok(applicationScripts.length > 0);
|
||||||
|
applicationScripts.forEach((scriptPath) => {
|
||||||
|
assert.ok(fs.existsSync(path.join(root, scriptPath)), `${scriptPath} must exist`);
|
||||||
|
assert.match(
|
||||||
|
dockerfile,
|
||||||
|
new RegExp(`^COPY ${scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\.\\/$`, "m"),
|
||||||
|
`${scriptPath} must be copied by the Dockerfile`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
|
const {
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
TRANSLATIONS,
|
||||||
|
createI18n,
|
||||||
|
detectLanguage,
|
||||||
|
normalizeLanguage,
|
||||||
|
} = require("../i18n.js");
|
||||||
|
|
||||||
|
function createStorage(initialValue) {
|
||||||
|
let value = initialValue;
|
||||||
|
return {
|
||||||
|
getItem() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
setItem(key, nextValue) {
|
||||||
|
value = nextValue;
|
||||||
|
},
|
||||||
|
removeItem() {
|
||||||
|
value = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("normalizes exact and base language tags", () => {
|
||||||
|
assert.equal(normalizeLanguage("pt-PT"), "pt-PT");
|
||||||
|
assert.equal(normalizeLanguage("pt-BR"), "pt-PT");
|
||||||
|
assert.equal(normalizeLanguage("en-US"), "en");
|
||||||
|
assert.equal(normalizeLanguage("fr"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers a stored language over browser preferences", () => {
|
||||||
|
const storage = createStorage("en");
|
||||||
|
assert.equal(detectLanguage({ storage, languages: ["pt-PT"] }), "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the first supported browser language and otherwise falls back", () => {
|
||||||
|
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr", "pt-BR"] }), "pt-PT");
|
||||||
|
assert.equal(detectLanguage({ storage: createStorage(), languages: ["es-MX"] }), "es");
|
||||||
|
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr"] }), DEFAULT_LANGUAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("persists language changes and interpolates translated values", () => {
|
||||||
|
const storage = createStorage();
|
||||||
|
const i18n = createI18n({ storage, languages: ["en"] });
|
||||||
|
|
||||||
|
assert.equal(i18n.setLanguage("pt"), "pt-PT");
|
||||||
|
assert.equal(storage.getItem(), "pt-PT");
|
||||||
|
assert.equal(
|
||||||
|
i18n.t("noParcelFound", { parcelNumber: "57" }),
|
||||||
|
"Não foi encontrada a parcela com o número “57”.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser-default mode removes the preference and follows the browser language", () => {
|
||||||
|
const storage = createStorage("en");
|
||||||
|
const i18n = createI18n({ storage, languages: ["pt-PT"] });
|
||||||
|
|
||||||
|
assert.equal(i18n.setLanguage(null), "pt-PT");
|
||||||
|
assert.equal(i18n.preference, null);
|
||||||
|
assert.equal(storage.getItem(), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses English for missing translations and the key for unknown messages", () => {
|
||||||
|
const storage = createStorage("pt-PT");
|
||||||
|
const originalTranslation = TRANSLATIONS["pt-PT"].zoomIn;
|
||||||
|
delete TRANSLATIONS["pt-PT"].zoomIn;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const i18n = createI18n({ storage, languages: [] });
|
||||||
|
assert.equal(i18n.t("zoomIn"), TRANSLATIONS.en.zoomIn);
|
||||||
|
assert.equal(i18n.t("notARealMessage"), "notARealMessage");
|
||||||
|
} finally {
|
||||||
|
TRANSLATIONS["pt-PT"].zoomIn = originalTranslation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all shipped languages contain the default translation keys", () => {
|
||||||
|
const defaultKeys = Object.keys(TRANSLATIONS[DEFAULT_LANGUAGE]).sort();
|
||||||
|
Object.entries(TRANSLATIONS).forEach(([language, translations]) => {
|
||||||
|
assert.deepEqual(Object.keys(translations).sort(), defaultKeys, language);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user