feat: add multilingual interface support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.295753 33.036644">
|
||||
<g transform="translate(-15.909682 -167.46353)">
|
||||
<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="#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)"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="51.110092" height="17.62863" viewBox="0 0 51.110092 17.62863">
|
||||
<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"/>
|
||||
<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>
|
||||
</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>
|
||||
<meta charset="UTF-8" />
|
||||
<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="./vendor/ol.css" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
@@ -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"
|
||||
></div>
|
||||
@@ -26,13 +27,18 @@
|
||||
aria-expanded="true"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<div class="sidebar-content">
|
||||
<div class="brand">
|
||||
@@ -40,12 +46,23 @@
|
||||
<img class="brand-logo" src="./assets/brand/xpro-logo-simple.svg" alt="XPro" />
|
||||
<span class="visually-hidden">XPro</span>
|
||||
</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>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Find</h2>
|
||||
<h2 data-i18n="find">Find</h2>
|
||||
</div>
|
||||
<form id="parcel-search-form" class="parcel-search-form">
|
||||
<input
|
||||
@@ -54,44 +71,51 @@
|
||||
type="search"
|
||||
inputmode="text"
|
||||
placeholder="e.g. 57 or 2669.1"
|
||||
data-i18n-placeholder="parcelExample"
|
||||
autocomplete="off"
|
||||
aria-label="Parcel number"
|
||||
data-i18n-aria-label="parcelNumber"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Find on map</button>
|
||||
<button type="submit" data-i18n="findOnMap">Find on map</button>
|
||||
</form>
|
||||
<p id="parcel-search-status" class="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Layers</h2>
|
||||
<h2 data-i18n="layers">Layers</h2>
|
||||
<span id="layer-count" class="badge">0</span>
|
||||
</div>
|
||||
<p id="status" class="status">Waiting for connection.</p>
|
||||
<div id="layer-list" class="layer-list empty">
|
||||
<p id="status" class="status" data-i18n="waitingForConnection">Waiting for connection.</p>
|
||||
<div id="layer-list" class="layer-list empty" data-i18n="loadingPublishedLayers">
|
||||
Loading published layers.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Background</h2>
|
||||
<h2 data-i18n="baseMap">Base map</h2>
|
||||
</div>
|
||||
<label class="background-selector" for="background-select">
|
||||
<span>Base map</span>
|
||||
<select id="background-select" name="background">
|
||||
<option value="soft">Soft minimal</option>
|
||||
<option value="openstreetmap">OpenStreetMap</option>
|
||||
<option value="satellite">Satellite imagery</option>
|
||||
<div class="background-selector">
|
||||
<select
|
||||
id="background-select"
|
||||
name="background"
|
||||
aria-label="Base map"
|
||||
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>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="feature-modal" class="feature-modal" hidden>
|
||||
@@ -107,15 +131,45 @@
|
||||
class="feature-modal-close"
|
||||
type="button"
|
||||
aria-label="Close feature information"
|
||||
data-i18n-aria-label="closeFeatureInformation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div id="feature-modal-content"></div>
|
||||
</section>
|
||||
</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>
|
||||
|
||||
<script src="./vendor/ol.js"></script>
|
||||
<script src="./i18n.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node --env-file-if-exists=.env server.js"
|
||||
"start": "node --env-file-if-exists=.env server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"ol": "10.6.1"
|
||||
|
||||
+93
-4
@@ -241,14 +241,49 @@ body {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
.brand-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
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);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: 400;
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -370,6 +405,10 @@ button:disabled {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #9d2e21;
|
||||
}
|
||||
@@ -427,7 +466,12 @@ button:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-modal {
|
||||
.settings-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-modal,
|
||||
.settings-modal {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
@@ -436,13 +480,53 @@ button:disabled {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.feature-modal-backdrop {
|
||||
.feature-modal-backdrop,
|
||||
.settings-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(31, 42, 46, 0.35);
|
||||
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 {
|
||||
position: relative;
|
||||
width: min(100%, 360px);
|
||||
@@ -506,9 +590,12 @@ button:disabled {
|
||||
}
|
||||
|
||||
.feature-details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.feature-inhabited {
|
||||
@@ -564,7 +651,9 @@ button:disabled {
|
||||
}
|
||||
|
||||
.feature-association-label {
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.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