const i18n = XProI18n.createI18n(); const t = i18n.t; const DEFAULT_CENTER = [-8.65, 39.55]; const DEFAULT_ZOOM = 7; const DEFAULT_ROTATION = 0; const API_CAPABILITIES = "/api/capabilities"; const API_WMS = "/api/wms"; const PARCEL_LAYER_NAME = "xpro:parcelas"; const BUILDING_LAYER_NAME = "xpro:predios"; const DWELLING_PARCEL_LAYER_NAME = "xpro:parcelas_habitacao"; const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"]; const COMPASS_HEADING_DEADBAND = 2; const SMALL_SCREEN_MEDIA_QUERY = "(max-width: 920px)"; const MODAL_VIEWPORT_PADDING = 24; const MESSAGE_DURATIONS = { info: 5000, warning: 6500, error: 9000, }; const MAP_URL_PARAMS = { latitude: "map-lat", longitude: "map-lon", zoom: "map-zoom", rotation: "map-rotation", layers: "map-layers", background: "map-background", featureLayer: "map-feature-layer", featureId: "map-feature-id", }; const BACKGROUND_VALUES = new Set(["soft", "openstreetmap", "satellite"]); const els = { appShell: document.querySelector(".app-shell"), messageRegion: document.getElementById("message-region"), sidebar: document.getElementById("sidebar"), sidebarOpen: document.getElementById("sidebar-open"), sidebarClose: document.getElementById("sidebar-close"), status: document.getElementById("status"), layerList: document.getElementById("layer-list"), layerCount: document.getElementById("layer-count"), parcelSearchForm: document.getElementById("parcel-search-form"), 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. let sharedMapState = readSharedMapState(); let urlStateReady = false; 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: t("softAttribution"), }), }); const openStreetMapLayer = new ol.layer.Tile({ visible: false, source: new ol.source.OSM(), }); 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: t("satelliteAttribution"), }), }); // Use one WMS image for the viewport so GeoServer can de-conflict parcel // labels across the whole map, rather than placing the same label per tile. const wmsSource = new ol.source.ImageWMS({ url: API_WMS, params: { LAYERS: "", FORMAT: "image/png", TRANSPARENT: true, }, // Keep a slightly oversized image while the compass rotates the view so a // small heading change does not require a new WMS image every time. ratio: 1.5, crossOrigin: "anonymous", }); const wmsLayer = new ol.layer.Image({ opacity: 0.8, visible: false, source: wmsSource, }); const dwellingParcelSource = new ol.source.Vector(); const dwellingParcelLayer = new ol.layer.Vector({ source: dwellingParcelSource, // The pale halo keeps the red dwelling boundary distinct over every // supported basemap and the existing parcel fills. style: [ new ol.style.Style({ stroke: new ol.style.Stroke({ color: "rgba(255, 255, 255, 0.9)", width: 6 }), }), new ol.style.Style({ stroke: new ol.style.Stroke({ color: "#d71920", width: 3.5 }), }), ], }); const parcelSearchSource = new ol.source.Vector(); const parcelSearchLayer = new ol.layer.Vector({ source: parcelSearchSource, style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: "#d9582b", width: 3 }), fill: new ol.style.Fill({ color: "rgba(255, 201, 82, 0.22)" }), }), }); const selectedFeatureSource = new ol.source.Vector(); const selectedFeatureLayer = new ol.layer.Vector({ source: selectedFeatureSource, style: [ new ol.style.Style({ stroke: new ol.style.Stroke({ color: "rgba(255, 255, 255, 0.9)", width: 6 }), }), new ol.style.Style({ stroke: new ol.style.Stroke({ color: "#1877d1", width: 3.5 }), }), ], }); const areaZoomInteraction = new ol.interaction.DragBox({ className: "ol-area-zoom-box", condition: ol.events.condition.primaryAction, minArea: 16, }); areaZoomInteraction.setActive(false); let areaZoomButton; let deviceCompassButton; let deviceCompassStatus; let deviceCompassActive = false; let deviceCompassSource; let lastCompassHeading; let pendingCompassHeading; let compassAnimationFrame; let deviceCompassFallbackTimer; let modalDrag; let featureHoverRequest; let featureHoverRequestId = 0; let featureHoverTimer; let featureModalRequestId = 0; let featureModalRestoreRequestId = 0; const map = new ol.Map({ target: "map", controls: ol.control .defaults.defaults({ rotate: false, zoomOptions: { zoomInTipLabel: t("zoomIn"), zoomOutTipLabel: t("zoomOut"), }, attributionOptions: { tipLabel: t("attribution"), }, }) .extend([ createVisibleLayersExtentControl(), createAreaZoomControl(), ...(shouldShowDeviceCompassControl() ? [createDeviceCompassControl()] : []), createShareControl(), createNorthPointerControl(), ]), layers: [ softBasemapLayer, openStreetMapLayer, satelliteLayer, wmsLayer, dwellingParcelLayer, parcelSearchLayer, selectedFeatureLayer, ], view: new ol.View({ center: ol.proj.fromLonLat(sharedMapState.view?.center || DEFAULT_CENTER), zoom: sharedMapState.view?.zoom ?? DEFAULT_ZOOM, rotation: sharedMapState.view?.rotation ?? DEFAULT_ROTATION, }), }); map.addInteraction(areaZoomInteraction); let availableLayers = []; let selectedLayers = []; // Keep the existing responsive breakpoint as the source of truth for the // initial panel state. The menu remains available through the open control. if (window.matchMedia(SMALL_SCREEN_MEDIA_QUERY).matches) { setSidebarCollapsed(true, { focus: false }); } 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); els.featureModalContent.addEventListener("click", handleFeatureModalNavigation); els.featureModalDialog.addEventListener("pointerdown", startFeatureModalDrag); els.featureModalDialog.addEventListener("pointermove", dragFeatureModal); els.featureModalDialog.addEventListener("pointerup", stopFeatureModalDrag); els.featureModalDialog.addEventListener("pointercancel", stopFeatureModalDrag); els.featureModal.addEventListener("click", (event) => { if (event.target.matches("[data-feature-modal-close]")) { closeFeatureModal(); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { if (!els.settingsModal.hidden) { closeSettingsModal(); } else if (!els.featureModal.hidden) { closeFeatureModal(); } else if (areaZoomInteraction.getActive()) { setAreaZoomMode(false); } else if (!els.appShell.classList.contains("sidebar-is-collapsed")) { setSidebarCollapsed(true); } } }); window.addEventListener("resize", keepFeatureModalInViewport); window.addEventListener("popstate", restoreSharedMapState); map.on("singleclick", showFeatureInformation); map.on("pointermove", updateFeatureCursor); map.on("moveend", syncSharedMapStateToUrl); map.getView().on("change:rotation", syncSharedMapStateToUrl); map.getViewport().addEventListener("pointerleave", clearFeatureCursor); areaZoomInteraction.on("boxend", zoomToSelectedArea); areaZoomInteraction.on("boxcancel", () => { // A click or a very short drag is not a selection. Exit on the next frame so // cancelling a drag while disabling the interaction cannot recursively // trigger another cancellation. requestAnimationFrame(() => { if (areaZoomInteraction.getActive()) { setAreaZoomMode(false); } }); }); if (sharedMapState.background) { els.backgroundSelect.value = sharedMapState.background; syncBackground(); } loadLayers(); function setSidebarCollapsed(collapsed, { focus = true } = {}) { els.appShell.classList.toggle("sidebar-is-collapsed", collapsed); els.sidebar.inert = collapsed; els.sidebarOpen.setAttribute("aria-expanded", String(!collapsed)); if (collapsed && focus) { els.sidebarOpen.focus(); } } 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"; icon.src = "./assets/icons/north-pointer.svg"; icon.alt = ""; return icon; } function createNorthPointerControl() { const control = new ol.control.Rotate({ autoHide: true, label: createNorthPointerIcon(), tipLabel: t("resetNorth"), }); // Resetting the view while compass orientation remains subscribed lets the // next device event rotate it again. Stop the subscription when the north // pointer is used so its reset remains in effect. control.element.querySelector("button").addEventListener("click", () => { if (deviceCompassActive) { stopDeviceCompass(); } }); return control; } function createVisibleLayersExtentControl() { const element = document.createElement("div"); element.className = "ol-visible-layers-extent ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.title = t("zoomVisibleLayers"); button.setAttribute("aria-label", t("zoomVisibleLayers")); button.textContent = "⤢"; button.addEventListener("click", () => fitToVisibleLayers()); element.append(button); return new ol.control.Control({ element }); } function createShareControl() { const element = document.createElement("div"); element.className = "ol-share ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; 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"); icon.textContent = "share"; button.append(icon); button.addEventListener("click", copyCurrentViewUrl); element.append(button); return new ol.control.Control({ element }); } async function copyCurrentViewUrl() { // Ensure the copied address reflects state changes that may not yet have // reached the next map movement event. syncSharedMapStateToUrl(); try { if (!navigator.clipboard?.writeText) { throw new Error(t("clipboardUnavailable")); } await navigator.clipboard.writeText(window.location.href); showMessage(t("mapLinkCopied")); } catch (error) { console.error(error); showMessage(t("mapLinkCopyFailed"), { type: "error", }); } } function createAreaZoomControl() { const element = document.createElement("div"); element.className = "ol-area-zoom ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.title = t("zoomArea"); button.setAttribute("aria-label", t("zoomArea")); button.setAttribute("aria-pressed", "false"); const icon = document.createElement("img"); icon.className = "area-zoom-icon"; icon.src = "./assets/icons/area-zoom.svg"; icon.alt = ""; button.append(icon); button.addEventListener("click", () => setAreaZoomMode(!areaZoomInteraction.getActive())); element.append(button); areaZoomButton = button; return new ol.control.Control({ element }); } function createDeviceCompassControl() { const element = document.createElement("div"); element.className = "ol-device-compass ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-pressed", "false"); const status = document.createElement("span"); status.id = "device-compass-status"; status.className = "visually-hidden"; status.setAttribute("role", "status"); button.setAttribute("aria-describedby", status.id); const icon = createNorthPointerIcon(); icon.classList.add("device-compass-icon"); button.append(icon); button.addEventListener("click", toggleDeviceCompass); element.append(button, status); deviceCompassButton = button; deviceCompassStatus = status; updateDeviceCompassButton(); return new ol.control.Control({ element }); } function shouldShowDeviceCompassControl() { return window.matchMedia?.("(any-pointer: coarse)").matches || navigator.maxTouchPoints > 0; } async function toggleDeviceCompass() { if (deviceCompassActive) { stopDeviceCompass(); return; } if (!window.isSecureContext) { updateDeviceCompassButton(t("compassRequiresHttps")); return; } if (!("DeviceOrientationEvent" in window)) { updateDeviceCompassButton(t("compassUnavailable")); return; } try { const permission = await requestDeviceOrientationPermission(); if (permission !== "granted") { updateDeviceCompassButton(t("compassPermissionDenied")); return; } deviceCompassSource = null; lastCompassHeading = null; deviceCompassActive = true; window.addEventListener(DEVICE_ORIENTATION_EVENTS[0], syncMapToDeviceCompass); // Prefer an absolute event, but retain support for browsers (notably iOS) // that expose only the standard orientation event. deviceCompassFallbackTimer = window.setTimeout(() => { if (!deviceCompassSource && deviceCompassActive) { window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); } }, 300); updateDeviceCompassButton(t("compassOn")); } catch (error) { console.error(error); updateDeviceCompassButton(t("compassStartFailed")); } } function requestDeviceOrientationPermission() { if (typeof DeviceOrientationEvent.requestPermission !== "function") { return Promise.resolve("granted"); } // iOS requires this call to be made directly from the button interaction. return DeviceOrientationEvent.requestPermission(); } function stopDeviceCompass() { DEVICE_ORIENTATION_EVENTS.forEach((eventName) => { window.removeEventListener(eventName, syncMapToDeviceCompass); }); clearTimeout(deviceCompassFallbackTimer); deviceCompassFallbackTimer = null; if (compassAnimationFrame) { cancelAnimationFrame(compassAnimationFrame); } compassAnimationFrame = null; pendingCompassHeading = null; deviceCompassSource = null; lastCompassHeading = null; deviceCompassActive = false; updateDeviceCompassButton(t("compassOff")); } function syncMapToDeviceCompass(event) { // Firefox and some Android browsers dispatch both streams. Once the // absolute stream arrives, never let a relative update overwrite it. if (event.type === "deviceorientationabsolute") { deviceCompassSource = "absolute"; clearTimeout(deviceCompassFallbackTimer); window.removeEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); } else if (deviceCompassSource === "absolute") { return; } else { deviceCompassSource = "standard"; } const heading = getCompassHeading(event); if (heading == null || headingDifference(heading, lastCompassHeading) < COMPASS_HEADING_DEADBAND) { return; } pendingCompassHeading = heading; if (compassAnimationFrame) { return; } compassAnimationFrame = requestAnimationFrame(() => { compassAnimationFrame = null; lastCompassHeading = pendingCompassHeading; // OpenLayers applies a view rotation in the inverse direction of the map // image. Match the device heading by rotating the view the other way. map.getView().setRotation((-pendingCompassHeading * Math.PI) / 180); }); } function getCompassHeading(event) { if (typeof event.webkitCompassHeading === "number" && Number.isFinite(event.webkitCompassHeading)) { return normalizeDegrees(event.webkitCompassHeading); } if (typeof event.alpha !== "number" || !Number.isFinite(event.alpha)) { return null; } // Standard orientation events report a counter-clockwise alpha angle. Add // the current screen angle so the heading remains correct in landscape. return normalizeDegrees(360 - event.alpha + getScreenOrientationAngle()); } function getScreenOrientationAngle() { const angle = window.screen?.orientation?.angle ?? window.orientation ?? 0; return Number.isFinite(Number(angle)) ? Number(angle) : 0; } function normalizeDegrees(degrees) { return ((degrees % 360) + 360) % 360; } function headingDifference(first, second) { if (second == null) { return Infinity; } return Math.abs(normalizeDegrees(first - second + 180) - 180); } function updateDeviceCompassButton(message) { if (!deviceCompassButton) { return; } const label = deviceCompassActive ? t("compassTurnOff") : t("compassAlign"); deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive)); deviceCompassButton.setAttribute("aria-label", label); deviceCompassButton.title = message || label; if (message && deviceCompassStatus) { deviceCompassStatus.textContent = message; } } /** * Display a short, non-blocking application message. Callers can override * `duration` in milliseconds; errors have the longest default reading time. */ function showMessage(message, { type = "info", duration = MESSAGE_DURATIONS[type] } = {}) { if (!els.messageRegion || !message) { return; } const supportedTypes = new Set(Object.keys(MESSAGE_DURATIONS)); const messageType = supportedTypes.has(type) ? type : "info"; const displayDuration = Number.isFinite(duration) && duration > 0 ? duration : MESSAGE_DURATIONS[messageType]; const toast = document.createElement("div"); toast.className = `message message--${messageType}`; toast.setAttribute("role", messageType === "error" ? "alert" : "status"); toast.style.setProperty("--message-duration", `${displayDuration}ms`); const text = document.createElement("p"); text.className = "message-text"; text.textContent = message; const dismiss = document.createElement("button"); dismiss.className = "message-dismiss"; dismiss.type = "button"; dismiss.setAttribute("aria-label", t("dismissMessage")); dismiss.textContent = "×"; const progress = document.createElement("span"); progress.className = "message-progress"; progress.setAttribute("aria-hidden", "true"); let timer = window.setTimeout(removeMessage, displayDuration); function removeMessage() { if (timer === undefined) { return; } window.clearTimeout(timer); timer = undefined; toast.classList.add("is-leaving"); window.setTimeout(() => toast.remove(), 180); } dismiss.addEventListener("click", removeMessage, { once: true }); toast.append(text, dismiss, progress); els.messageRegion.append(toast); } function setAreaZoomMode(active) { areaZoomInteraction.setActive(active); map.getViewport().classList.toggle("area-zoom-is-active", active); areaZoomButton?.setAttribute("aria-pressed", String(active)); areaZoomButton?.setAttribute( "title", active ? t("exitAreaZoom") : t("zoomArea"), ); if (active) { clearFeatureCursor(); closeFeatureModal(); } } function zoomToSelectedArea() { const extent = areaZoomInteraction.getGeometry()?.getExtent(); if (extent && extent.every(Number.isFinite)) { map.getView().fit(extent, { padding: [64, 64, 64, 64], duration: 500, maxZoom: 19, }); } setAreaZoomMode(false); } async function searchParcela(event) { event.preventDefault(); const parcelNumber = els.parcelSearchInput.value.trim(); if (!parcelNumber) { updateParcelSearchStatus(t("enterParcel"), "error"); showMessage(t("enterParcel"), { type: "warning" }); return; } updateParcelSearchStatus(t("searchingParcel")); parcelSearchSource.clear(); try { const params = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: PARCEL_LAYER_NAME, outputFormat: "application/json", srsName: "EPSG:3857", CQL_FILTER: buildParcelSearchFilter(parcelNumber), }); const response = await fetch(`${API_WMS}?${params}`); if (!response.ok) { throw new Error(t("searchFailedStatus", { status: response.status })); } const featureCollection = await response.json(); const features = new ol.format.GeoJSON().readFeatures(featureCollection, { featureProjection: "EPSG:3857", }); if (!features.length) { const notFoundMessage = t("noParcelFound", { parcelNumber }); updateParcelSearchStatus(notFoundMessage, "error"); showMessage(notFoundMessage, { type: "warning" }); return; } parcelSearchSource.addFeatures(features); map.getView().fit(parcelSearchSource.getExtent(), { padding: [64, 64, 64, 64], duration: 500, maxZoom: features.length === 1 ? 19 : 16, }); const searchMessage = features.length === 1 ? t("foundParcel", { parcelNumber }) : t("foundParcels", { count: features.length, parcelNumber }); updateParcelSearchStatus(searchMessage, "success"); showMessage(searchMessage); } catch (error) { console.error(error); const searchMessage = error.message || t("parcelSearchFailed"); updateParcelSearchStatus(searchMessage, "error"); showMessage(searchMessage, { type: "error" }); } } function buildParcelSearchFilter(parcelNumber) { const escapedParcelNumber = escapeCqlLiteral(parcelNumber); // Whole parcel numbers identify a group: 278 includes sub-parcels such as // 278.1 and 278.2. A supplied sub-parcel number remains an exact lookup. if (/^\d+$/.test(parcelNumber)) { return `(cod_parcela = '${escapedParcelNumber}' OR cod_parcela LIKE '${escapedParcelNumber}.%')`; } return `cod_parcela = '${escapedParcelNumber}'`; } async function loadLayers() { updateStatus(t("loadingWmsLayers")); try { const capabilities = await loadCapabilities(); availableLayers = extractLayers(capabilities); selectedLayers = selectedLayersFromSharedMapState(sharedMapState); renderLayerList(); syncSelectedLayers(); await loadDwellingParcels(); // The normal first-load fit remains the fallback, but must not overwrite // the exact location represented by a shared view link. if (!sharedMapState.view) { fitToLayerExtent(availableLayers[0]); } restoreSharedFeatureModal(); updateStatus(""); } catch (error) { console.error(error); const loadMessage = error.message || t("wmsLoadFailed"); updateStatus(loadMessage, "error"); showMessage(loadMessage, { type: "error" }); availableLayers = []; selectedLayers = []; dwellingParcelSource.clear(); dwellingParcelLayer.setVisible(false); renderLayerList(); syncSelectedLayers(); } finally { // Do not replace a shared URL while layer discovery is still deciding // which layers it represents. Once ready, keep a canonical, copyable URL // for the current map state even if GeoServer was unavailable. urlStateReady = true; syncSharedMapStateToUrl(); } } async function loadCapabilities() { const url = new URL(API_CAPABILITIES, window.location.origin); const response = await fetch(url); if (!response.ok) { throw new Error(t("geoserverConnectionFailed", { status: response.status })); } return response.text(); } function extractLayers(xmlText) { const parser = new DOMParser(); const xml = parser.parseFromString(xmlText, "text/xml"); const capabilityNode = firstChildByName(xml.documentElement, "Capability"); const rootLayerNode = firstChildByName(capabilityNode, "Layer"); const nodes = collectNamedLayers(rootLayerNode); return nodes .map((layerNode) => { const name = textFromChild(layerNode, "Name"); const title = textFromChild(layerNode, "Title") || name; const bbox = firstChildByName(layerNode, "EX_GeographicBoundingBox"); const extent = bbox ? [ Number(textFromChild(bbox, "westBoundLongitude")), Number(textFromChild(bbox, "southBoundLatitude")), Number(textFromChild(bbox, "eastBoundLongitude")), Number(textFromChild(bbox, "northBoundLatitude")), ] : null; return { name, title, extent }; }) .filter((layer) => layer.name); } function renderLayerList() { const userVisibleLayers = availableLayers.filter((layer) => !isInternalLayer(layer.name)); els.layerCount.textContent = String(userVisibleLayers.length); if (!userVisibleLayers.length) { els.layerList.className = "layer-list empty"; els.layerList.textContent = t("noPublishedLayers"); return; } els.layerList.className = "layer-list"; els.layerList.replaceChildren(); userVisibleLayers.forEach((layer) => { const label = document.createElement("label"); label.className = "layer-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = selectedLayers.includes(layer.name); checkbox.addEventListener("change", () => { selectedLayers = checkbox.checked ? [...new Set([...selectedLayers, layer.name])] : selectedLayers.filter((name) => name !== layer.name); syncSelectedLayers(); }); const title = document.createElement("span"); title.className = "layer-title"; title.textContent = layer.title; label.append(checkbox, title); els.layerList.append(label); }); } function syncSelectedLayers() { const orderedSelectedLayers = availableLayers .map((layer) => layer.name) .filter((name) => selectedLayers.includes(name)); wmsLayer.setVisible(selectedLayers.length > 0); wmsSource.updateParams({ LAYERS: [...orderedSelectedLayers].reverse().join(","), _: Date.now(), }); dwellingParcelLayer.setVisible( selectedLayers.some((name) => { const layerName = unqualifiedLayerName(name); return layerName === "parcelas" || layerName === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME); }), ); clearFeatureCursor(); syncSharedMapStateToUrl(); } function isInternalLayer(name) { return unqualifiedLayerName(name) === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME); } async function loadDwellingParcels() { const publishedDwellingLayer = availableLayers.find( (layer) => unqualifiedLayerName(layer.name) === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME), ); dwellingParcelSource.clear(); if (!publishedDwellingLayer) { console.warn("The dwelling parcel layer is not published by GeoServer."); return; } try { const params = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: publishedDwellingLayer.name, outputFormat: "application/json", srsName: "EPSG:3857", }); const response = await fetch(`${API_WMS}?${params}`); if (!response.ok) { throw new Error(`Dwelling parcel request failed with status ${response.status}.`); } const featureCollection = await response.json(); const features = new ol.format.GeoJSON().readFeatures(featureCollection, { featureProjection: "EPSG:3857", }); dwellingParcelSource.addFeatures(features); } catch (error) { // Keep the normal map usable when a deployment has not yet published the // optional layer; the error remains available for operational diagnosis. console.error(error); } } async function showFeatureInformation(event) { if (areaZoomInteraction.getActive()) { return; } try { const feature = await getFeatureAtCoordinate(event.coordinate); if (feature) { openFeatureModal(feature); } } catch (error) { console.error(error); } } function updateFeatureCursor(event) { if (event.dragging || areaZoomInteraction.getActive()) { clearFeatureCursor(); return; } window.clearTimeout(featureHoverTimer); const requestId = ++featureHoverRequestId; const coordinate = [...event.coordinate]; featureHoverTimer = window.setTimeout(async () => { featureHoverRequest?.abort(); featureHoverRequest = new AbortController(); try { const feature = await getFeatureAtCoordinate(coordinate, featureHoverRequest.signal); if (requestId === featureHoverRequestId) { map.getViewport().style.cursor = feature ? "pointer" : ""; } } catch (error) { if (error.name !== "AbortError" && requestId === featureHoverRequestId) { map.getViewport().style.cursor = ""; } } }, 120); } function clearFeatureCursor() { window.clearTimeout(featureHoverTimer); featureHoverRequestId += 1; featureHoverRequest?.abort(); featureHoverRequest = undefined; map.getViewport().style.cursor = ""; } async function getFeatureAtCoordinate(coordinate, signal) { const queryLayers = availableLayers .map((layer) => layer.name) .filter( (name) => selectedLayers.includes(name) && FEATURE_INFO_LAYER_NAMES.has(unqualifiedLayerName(name)), ); if (!queryLayers.length) { return null; } const url = wmsSource.getFeatureInfoUrl(coordinate, map.getView().getResolution(), "EPSG:3857", { INFO_FORMAT: "application/json", QUERY_LAYERS: queryLayers.join(","), FEATURE_COUNT: 1, }); if (!url) { return null; } const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`Feature information failed with status ${response.status}.`); } return (await response.json()).features?.[0] || null; } function openFeatureModal(feature) { const layerName = unqualifiedLayerName(feature.id?.split(".")[0] || ""); const isParcel = layerName === "parcelas" || feature.properties?.cod_parcela != null; featureModalRestoreRequestId += 1; sharedMapState.feature = featureReference(feature, isParcel); const requestId = ++featureModalRequestId; highlightFeature(feature); els.featureModal.hidden = false; renderFeatureModal(feature, isParcel, requestId); els.featureModalClose.focus(); syncSharedMapStateToUrl(); } function highlightFeature(feature) { selectedFeatureSource.clear(); const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, { featureProjection: "EPSG:3857", }); if (geometry) { selectedFeatureSource.addFeature(new ol.Feature(geometry)); } } async function renderFeatureModal(feature, isParcel, requestId) { const content = document.createDocumentFragment(); const properties = feature.properties || {}; const featureId = isParcel ? properties.cod_parcela : properties.cod_predio; 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 = t("inhabited"); content.append(inhabited); } if (properties.predio_uuid) { content.append(createFeatureNavigation( t("viewCorrespondingBuilding"), BUILDING_LAYER_NAME, properties.predio_uuid, )); } } else { const parcels = await getFeaturesByProperty(PARCEL_LAYER_NAME, "predio_uuid", properties.predio_uuid); const section = document.createElement("section"); section.className = "feature-associations"; const heading = document.createElement("h3"); heading.textContent = t("associatedParcels"); section.append(heading); if (parcels.length) { const list = document.createElement("ul"); parcels.forEach((parcel) => { const item = document.createElement("li"); const link = createFeatureNavigation( "", PARCEL_LAYER_NAME, parcel.properties?.parcela_uuid, ); link.className = "feature-association"; 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 = t("parcelWithNumber", { parcelNumber: parcelNumber || t("unavailable"), }); const area = document.createElement("span"); area.className = "feature-association-area"; area.textContent = formatFeatureArea(parcel); link.append(label, area); if (isDwellingParcel(parcel)) { const inhabited = document.createElement("span"); inhabited.className = "feature-association-inhabited"; inhabited.textContent = t("inhabited"); link.append(inhabited); } item.append(link); list.append(item); }); section.append(list); } else { const empty = document.createElement("p"); empty.textContent = t("noAssociatedParcels"); section.append(empty); } content.append(section); } content.append(createFeatureModalShareButton()); // A linked-feature request may finish after a newer selection was made. if (!els.featureModal.hidden && requestId === featureModalRequestId) { els.featureModalContent.replaceChildren(content); } } function createFeatureModalShareButton() { const actions = document.createElement("div"); actions.className = "feature-modal-actions"; const button = document.createElement("button"); button.type = "button"; button.className = "feature-modal-share"; 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"); icon.textContent = "share"; button.append(icon); button.addEventListener("click", copyCurrentViewUrl); actions.append(button); return actions; } function createFeatureHeading(kind, id) { const heading = document.createElement("h2"); heading.id = "feature-modal-title"; heading.textContent = id ? `${kind} ${id}` : kind; return heading; } function createFeatureDetails(entries) { const details = document.createElement("dl"); details.className = "feature-details"; entries.forEach(([label, value]) => { const row = document.createElement("div"); const term = document.createElement("dt"); const description = document.createElement("dd"); term.textContent = label; description.textContent = value; row.append(term, description); details.append(row); }); return details; } function createFeatureNavigation(label, layerName, featureUuid) { const link = document.createElement("button"); link.type = "button"; link.className = "feature-navigation"; link.textContent = label; link.dataset.featureLayer = layerName; link.dataset.featureUuid = featureUuid || ""; return link; } async function handleFeatureModalNavigation(event) { const link = event.target.closest("[data-feature-layer][data-feature-uuid]"); if (!link.dataset.featureUuid) { return; } link.disabled = true; try { const idProperty = unqualifiedLayerName(link.dataset.featureLayer) === "parcelas" ? "parcela_uuid" : "predio_uuid"; const [feature] = await getFeaturesByProperty(link.dataset.featureLayer, idProperty, link.dataset.featureUuid); if (feature) { openFeatureModal(feature); } } catch (error) { console.error(error); } finally { link.disabled = false; } } async function getFeaturesByProperty(layerName, property, value) { if (!value) { return []; } const params = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: layerName, outputFormat: "application/json", srsName: "EPSG:3857", CQL_FILTER: `${property} = '${escapeCqlLiteral(value)}'`, }); const response = await fetch(`${API_WMS}?${params}`); if (!response.ok) { throw new Error(`Feature request failed with status ${response.status}.`); } return (await response.json()).features || []; } function isDwellingParcel(feature) { const parcelUuid = feature.properties?.parcela_uuid; return dwellingParcelSource.getFeatures().some( (dwellingParcel) => dwellingParcel.get("parcela_uuid") === parcelUuid, ); } function unqualifiedLayerName(name) { return name.split(":").at(-1); } function closeFeatureModal() { featureModalRequestId += 1; featureModalRestoreRequestId += 1; sharedMapState.feature = null; resetFeatureModalPosition(); els.featureModal.hidden = true; selectedFeatureSource.clear(); syncSharedMapStateToUrl(); } function startFeatureModalDrag(event) { if (event.button !== 0 || event.target.closest("button, a, input, select, textarea, label")) { return; } const dialogRect = els.featureModalDialog.getBoundingClientRect(); els.featureModalDialog.style.position = "fixed"; els.featureModalDialog.style.left = `${dialogRect.left}px`; els.featureModalDialog.style.top = `${dialogRect.top}px`; els.featureModalDialog.style.margin = "0"; els.featureModalDialog.classList.add("is-dragging"); modalDrag = { pointerId: event.pointerId, offsetX: event.clientX - dialogRect.left, offsetY: event.clientY - dialogRect.top, }; els.featureModalDialog.setPointerCapture(event.pointerId); } function dragFeatureModal(event) { if (!modalDrag || event.pointerId !== modalDrag.pointerId) { return; } const dialogRect = els.featureModalDialog.getBoundingClientRect(); const maxLeft = Math.max(MODAL_VIEWPORT_PADDING, window.innerWidth - dialogRect.width - MODAL_VIEWPORT_PADDING); const maxTop = Math.max(MODAL_VIEWPORT_PADDING, window.innerHeight - dialogRect.height - MODAL_VIEWPORT_PADDING); const left = Math.min(maxLeft, Math.max(MODAL_VIEWPORT_PADDING, event.clientX - modalDrag.offsetX)); const top = Math.min(maxTop, Math.max(MODAL_VIEWPORT_PADDING, event.clientY - modalDrag.offsetY)); els.featureModalDialog.style.left = `${left}px`; els.featureModalDialog.style.top = `${top}px`; } function stopFeatureModalDrag(event) { if (!modalDrag || event.pointerId !== modalDrag.pointerId) { return; } els.featureModalDialog.releasePointerCapture(event.pointerId); els.featureModalDialog.classList.remove("is-dragging"); modalDrag = undefined; } function keepFeatureModalInViewport() { if (els.featureModal.hidden || !els.featureModalDialog.style.left) { return; } const dialogRect = els.featureModalDialog.getBoundingClientRect(); const maxLeft = Math.max(MODAL_VIEWPORT_PADDING, window.innerWidth - dialogRect.width - MODAL_VIEWPORT_PADDING); const maxTop = Math.max(MODAL_VIEWPORT_PADDING, window.innerHeight - dialogRect.height - MODAL_VIEWPORT_PADDING); els.featureModalDialog.style.left = `${Math.min(maxLeft, Math.max(MODAL_VIEWPORT_PADDING, dialogRect.left))}px`; els.featureModalDialog.style.top = `${Math.min(maxTop, Math.max(MODAL_VIEWPORT_PADDING, dialogRect.top))}px`; } function resetFeatureModalPosition() { modalDrag = undefined; els.featureModalDialog.classList.remove("is-dragging"); els.featureModalDialog.style.removeProperty("position"); els.featureModalDialog.style.removeProperty("left"); els.featureModalDialog.style.removeProperty("top"); els.featureModalDialog.style.removeProperty("margin"); } function formatFeatureArea(feature) { const properties = feature.properties || {}; const areaEntry = Object.entries(properties).find(([name, value]) => value != null && /(^|[_\s])area([_\s]|$)|shape_area/i.test(name), ); const area = Number(areaEntry?.[1]); if (Number.isFinite(area) && area >= 0) { return `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(area)} m²`; } const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, { featureProjection: "EPSG:3857", }); const calculatedArea = geometry?.getArea?.(); return Number.isFinite(calculatedArea) ? `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(calculatedArea)} m²` : t("areaUnavailable"); } function fitToLayerExtent(layer) { if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) { return; } const projectedExtent = ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"); map.getView().fit(projectedExtent, { padding: [40, 40, 40, 40], duration: 500, maxZoom: 18, }); } function fitToVisibleLayers() { const visibleLayers = availableLayers.filter((layer) => selectedLayers.includes(layer.name)); const projectedExtents = visibleLayers .filter((layer) => layer.extent && layer.extent.every(Number.isFinite)) .map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857")); if (!projectedExtents.length) { updateStatus(t("selectLayerToZoom"), "error"); showMessage(t("selectLayerToZoom"), { type: "warning" }); return; } const combinedExtent = projectedExtents.reduce( (combined, extent) => [ Math.min(combined[0], extent[0]), Math.min(combined[1], extent[1]), Math.max(combined[2], extent[2]), Math.max(combined[3], extent[3]), ], [Infinity, Infinity, -Infinity, -Infinity], ); map.getView().fit(combinedExtent, { padding: [64, 64, 64, 64], duration: 500, maxZoom: 18, }); } function childElementsByName(node, localName) { return [...(node?.children || [])].filter((child) => child.localName === localName); } function firstChildByName(node, localName) { return childElementsByName(node, localName)[0] || null; } function textFromChild(node, localName) { return firstChildByName(node, localName)?.textContent?.trim() || ""; } function collectNamedLayers(node) { return childElementsByName(node, "Layer").flatMap((layerNode) => { const children = collectNamedLayers(layerNode); return textFromChild(layerNode, "Name") ? [layerNode, ...children] : children; }); } function updateStatus(message, tone) { els.status.textContent = message; els.status.className = tone ? `status ${tone}` : "status"; } function updateParcelSearchStatus(message, tone) { els.parcelSearchStatus.textContent = message; els.parcelSearchStatus.className = tone ? `status ${tone}` : "status"; } function escapeCqlLiteral(value) { return value.replaceAll("'", "''"); } function syncBackground() { const background = els.backgroundSelect.value; softBasemapLayer.setVisible(background === "soft"); openStreetMapLayer.setVisible(background === "openstreetmap"); satelliteLayer.setVisible(background === "satellite"); syncSharedMapStateToUrl(); } function readSharedMapState(url = new URL(window.location.href)) { const params = url.searchParams; const viewParamNames = [ MAP_URL_PARAMS.latitude, MAP_URL_PARAMS.longitude, MAP_URL_PARAMS.zoom, MAP_URL_PARAMS.rotation, ]; const hasViewParams = viewParamNames.some((name) => params.has(name)); const view = hasViewParams ? readSharedView(params) : null; const requestedBackground = params.get(MAP_URL_PARAMS.background); return { view, background: BACKGROUND_VALUES.has(requestedBackground) ? requestedBackground : null, // An empty value deliberately represents no visible published layers. layers: params.has(MAP_URL_PARAMS.layers) ? params .get(MAP_URL_PARAMS.layers) .split(",") .map((name) => name.trim()) .filter(Boolean) : null, feature: readSharedFeature(params), }; } function readSharedView(params) { const values = [ params.get(MAP_URL_PARAMS.latitude), params.get(MAP_URL_PARAMS.longitude), params.get(MAP_URL_PARAMS.zoom), params.get(MAP_URL_PARAMS.rotation), ]; if (values.some((value) => value === null || value.trim() === "")) { return null; } const [latitude, longitude, zoom, rotationDegrees] = values.map(Number); if ( !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(zoom) || !Number.isFinite(rotationDegrees) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || zoom < 0 || zoom > 28 || rotationDegrees < -360 || rotationDegrees > 360 ) { return null; } return { center: [longitude, latitude], zoom, rotation: (rotationDegrees * Math.PI) / 180, }; } function selectedLayersFromSharedMapState(state) { const visibleLayers = availableLayers.filter((layer) => !isInternalLayer(layer.name)); const defaultLayers = visibleLayers.map((layer) => layer.name); if (state.layers === null) { return defaultLayers; } const requestedLayers = new Set(state.layers); const matchedLayers = visibleLayers .map((layer) => layer.name) .filter((name) => requestedLayers.has(name)); // An explicit empty list is valid. A non-empty list with no known layers is // malformed or from another deployment, so retain the useful default. return state.layers.length === 0 || matchedLayers.length ? matchedLayers : defaultLayers; } function readSharedFeature(params) { const layerName = params.get(MAP_URL_PARAMS.featureLayer); const featureId = params.get(MAP_URL_PARAMS.featureId); if (!layerName || !featureId) { return null; } if (![PARCEL_LAYER_NAME, BUILDING_LAYER_NAME].includes(layerName)) { return null; } return { layerName, featureId }; } function featureReference(feature, isParcel) { const featureId = isParcel ? feature.properties?.parcela_uuid : feature.properties?.predio_uuid; if (typeof featureId !== "string" || !featureId.trim()) { return null; } return { layerName: isParcel ? PARCEL_LAYER_NAME : BUILDING_LAYER_NAME, featureId, }; } async function restoreSharedFeatureModal() { const feature = sharedMapState.feature; const restoreRequestId = ++featureModalRestoreRequestId; if (!feature) { if (!els.featureModal.hidden) { closeFeatureModal(); } return; } try { const idProperty = feature.layerName === PARCEL_LAYER_NAME ? "parcela_uuid" : "predio_uuid"; const [restoredFeature] = await getFeaturesByProperty(feature.layerName, idProperty, feature.featureId); if (restoreRequestId === featureModalRestoreRequestId && restoredFeature) { openFeatureModal(restoredFeature); } } catch (error) { // Keep the map usable if a linked feature no longer exists or GeoServer is // temporarily unavailable; the map view itself still restores normally. console.error(error); } } function restoreSharedMapState() { sharedMapState = readSharedMapState(); applyingSharedMapState = true; try { const viewState = sharedMapState.view; const view = map.getView(); if (viewState) { view.setCenter(ol.proj.fromLonLat(viewState.center)); view.setZoom(viewState.zoom); view.setRotation(viewState.rotation); } else { // A history entry without a valid map state behaves like a fresh visit. view.setCenter(ol.proj.fromLonLat(DEFAULT_CENTER)); view.setZoom(DEFAULT_ZOOM); view.setRotation(DEFAULT_ROTATION); } els.backgroundSelect.value = sharedMapState.background || "soft"; syncBackground(); if (availableLayers.length) { selectedLayers = selectedLayersFromSharedMapState(sharedMapState); renderLayerList(); syncSelectedLayers(); } restoreSharedFeatureModal(); } finally { applyingSharedMapState = false; } } function syncSharedMapStateToUrl() { if (!urlStateReady || applyingSharedMapState) { return; } const view = map.getView(); const center = view.getCenter(); const zoom = view.getZoom(); const rotation = view.getRotation(); if (!center || !Number.isFinite(zoom) || !Number.isFinite(rotation)) { return; } const [longitude, latitude] = ol.proj.toLonLat(center); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { return; } const url = new URL(window.location.href); url.searchParams.set(MAP_URL_PARAMS.latitude, formatUrlNumber(latitude, 6)); url.searchParams.set(MAP_URL_PARAMS.longitude, formatUrlNumber(longitude, 6)); url.searchParams.set(MAP_URL_PARAMS.zoom, formatUrlNumber(zoom, 3)); url.searchParams.set( MAP_URL_PARAMS.rotation, formatUrlNumber(normalizeRotationDegrees((rotation * 180) / Math.PI), 2), ); url.searchParams.set(MAP_URL_PARAMS.background, els.backgroundSelect.value); url.searchParams.set(MAP_URL_PARAMS.layers, selectedLayers.join(",")); if (sharedMapState.feature) { url.searchParams.set(MAP_URL_PARAMS.featureLayer, sharedMapState.feature.layerName); url.searchParams.set(MAP_URL_PARAMS.featureId, sharedMapState.feature.featureId); } else { url.searchParams.delete(MAP_URL_PARAMS.featureLayer); url.searchParams.delete(MAP_URL_PARAMS.featureId); } // Replacing, rather than pushing, avoids one browser-history entry for every // pan or zoom while still keeping normal back/forward navigation intact. window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`); } function normalizeRotationDegrees(rotation) { return ((rotation + 180) % 360 + 360) % 360 - 180; } function formatUrlNumber(value, fractionDigits) { return Number(value.toFixed(fractionDigits)).toString(); }