diff --git a/app.js b/app.js index ca37197..d92b389 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 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"]; @@ -24,8 +25,7 @@ const els = { featureModal: document.getElementById("feature-modal"), featureModalDialog: document.querySelector(".feature-modal-dialog"), featureModalClose: document.getElementById("feature-modal-close"), - featureModalKind: document.getElementById("feature-modal-kind"), - featureArea: document.getElementById("feature-area"), + featureModalContent: document.getElementById("feature-modal-content"), }; const softBasemapLayer = new ol.layer.Tile({ @@ -95,6 +95,19 @@ const parcelSearchLayer = new ol.layer.Vector({ }), }); +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, @@ -114,6 +127,7 @@ let modalDrag; let featureHoverRequest; let featureHoverRequestId = 0; let featureHoverTimer; +let featureModalRequestId = 0; const map = new ol.Map({ target: "map", @@ -132,6 +146,7 @@ const map = new ol.Map({ wmsLayer, dwellingParcelLayer, parcelSearchLayer, + selectedFeatureLayer, ], view: new ol.View({ center: ol.proj.fromLonLat(DEFAULT_CENTER), @@ -154,6 +169,7 @@ els.backgroundSelect.addEventListener("change", syncBackground); 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); @@ -787,19 +803,190 @@ async function getFeatureAtCoordinate(coordinate, signal) { 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); + const requestId = ++featureModalRequestId; + highlightFeature(feature); els.featureModal.hidden = false; + renderFeatureModal(feature, isParcel, requestId); els.featureModalClose.focus(); } +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 ? "Parcel" : "Building", featureId)); + content.append(createFeatureDetails([["Area", formatFeatureArea(feature)]])); + + if (isParcel) { + if (isDwellingParcel(feature)) { + const inhabited = document.createElement("p"); + inhabited.className = "feature-inhabited"; + inhabited.textContent = "Inhabited"; + content.append(inhabited); + } + + if (properties.predio_uuid) { + content.append(createFeatureNavigation( + "View corresponding building", + 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 = "Associated parcels"; + 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"; + link.setAttribute("aria-label", `View parcel ${parcel.properties?.cod_parcela || ""}`.trim()); + const label = document.createElement("span"); + label.className = "feature-association-label"; + label.textContent = `Parcel ${parcel.properties?.cod_parcela || "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 = "Inhabited"; + link.append(inhabited); + } + item.append(link); + list.append(item); + }); + section.append(list); + } else { + const empty = document.createElement("p"); + empty.textContent = "No associated parcels were found."; + section.append(empty); + } + + content.append(section); + } + + // A linked-feature request may finish after a newer selection was made. + if (!els.featureModal.hidden && requestId === featureModalRequestId) { + els.featureModalContent.replaceChildren(content); + } +} + +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; resetFeatureModalPosition(); els.featureModal.hidden = true; + selectedFeatureSource.clear(); } function startFeatureModalDrag(event) { diff --git a/index.html b/index.html index 5722024..6bba7de 100644 --- a/index.html +++ b/index.html @@ -101,14 +101,7 @@ > × -
-