diff --git a/app.js b/app.js index 78f764b..054263a 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ const DEFAULT_CENTER = [-8.65, 39.55]; const API_CAPABILITIES = "/api/capabilities"; const API_WMS = "/api/wms"; const PARCEL_LAYER_NAME = "xpro:parcelas"; +const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); const els = { appShell: document.querySelector(".app-shell"), @@ -15,6 +16,10 @@ const els = { parcelSearchInput: document.getElementById("parcel-search-input"), parcelSearchStatus: document.getElementById("parcel-search-status"), backgroundSelect: document.getElementById("background-select"), + featureModal: document.getElementById("feature-modal"), + featureModalClose: document.getElementById("feature-modal-close"), + featureModalKind: document.getElementById("feature-modal-kind"), + featureArea: document.getElementById("feature-area"), }; const softBasemapLayer = new ol.layer.Tile({ @@ -93,11 +98,22 @@ els.parcelSearchForm.addEventListener("submit", searchParcela); els.backgroundSelect.addEventListener("change", syncBackground); els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false)); els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true)); -document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !els.appShell.classList.contains("sidebar-is-collapsed")) { - setSidebarCollapsed(true); +els.featureModalClose.addEventListener("click", closeFeatureModal); +els.featureModal.addEventListener("click", (event) => { + if (event.target.matches("[data-feature-modal-close]")) { + closeFeatureModal(); } }); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + if (!els.featureModal.hidden) { + closeFeatureModal(); + } else if (!els.appShell.classList.contains("sidebar-is-collapsed")) { + setSidebarCollapsed(true); + } + } +}); +map.on("singleclick", showFeatureInformation); loadLayers(); @@ -299,6 +315,79 @@ function syncSelectedLayers() { }); } +async function showFeatureInformation(event) { + const queryLayers = availableLayers + .map((layer) => layer.name) + .filter( + (name) => selectedLayers.includes(name) && FEATURE_INFO_LAYER_NAMES.has(unqualifiedLayerName(name)), + ); + + if (!queryLayers.length) { + return; + } + + const url = wmsSource.getFeatureInfoUrl(event.coordinate, map.getView().getResolution(), "EPSG:3857", { + INFO_FORMAT: "application/json", + QUERY_LAYERS: queryLayers.join(","), + FEATURE_COUNT: 1, + }); + + if (!url) { + return; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Feature information failed with status ${response.status}.`); + } + + const feature = (await response.json()).features?.[0]; + if (feature) { + openFeatureModal(feature); + } + } catch (error) { + console.error(error); + } +} + +function openFeatureModal(feature) { + const layerName = unqualifiedLayerName(feature.id?.split(".")[0] || ""); + const isParcel = layerName === "parcelas" || feature.properties?.cod_parcela != null; + els.featureModalKind.textContent = isParcel ? "Parcel" : "Building"; + els.featureArea.textContent = formatFeatureArea(feature); + els.featureModal.hidden = false; + els.featureModalClose.focus(); +} + +function unqualifiedLayerName(name) { + return name.split(":").at(-1); +} + +function closeFeatureModal() { + els.featureModal.hidden = true; +} + +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(undefined, { 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(undefined, { maximumFractionDigits: 2 }).format(calculatedArea)} m²` + : "Area unavailable"; +} + function fitToLayerExtent(layer) { if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) { return; diff --git a/index.html b/index.html index 5c76aea..4f0933f 100644 --- a/index.html +++ b/index.html @@ -84,6 +84,33 @@
+ + diff --git a/styles.css b/styles.css index 1fc57ca..b44fec2 100644 --- a/styles.css +++ b/styles.css @@ -308,6 +308,86 @@ button:disabled { inset: 0; } +.feature-modal[hidden] { + display: none; +} + +.feature-modal { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + padding: 24px; + place-items: center; +} + +.feature-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(31, 42, 46, 0.35); + backdrop-filter: blur(3px); +} + +.feature-modal-dialog { + position: relative; + width: min(100%, 360px); + padding: 28px; + border: 1px solid var(--panel-border); + border-radius: 24px; + background: #fffefb; + box-shadow: var(--shadow); +} + +.feature-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; +} + +.feature-modal-kind { + margin-bottom: 6px; + color: var(--accent-strong); + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.feature-details { + margin: 20px 0 0; +} + +.feature-details div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 14px 0; + border-top: 1px solid var(--panel-border); +} + +.feature-details dt { + color: var(--muted); +} + +.feature-details dd { + margin: 0; + font-size: 1.1rem; + font-weight: 700; +} + .map-wrap .ol-zoom { top: 16px; right: 16px;