feat: improve feature modal navigation

This commit is contained in:
2026-07-24 04:19:17 +01:00
parent bf44014dc1
commit ae95447dc8
3 changed files with 261 additions and 12 deletions
+191 -4
View File
@@ -2,6 +2,7 @@ const DEFAULT_CENTER = [-8.65, 39.55];
const API_CAPABILITIES = "/api/capabilities"; const API_CAPABILITIES = "/api/capabilities";
const API_WMS = "/api/wms"; const API_WMS = "/api/wms";
const PARCEL_LAYER_NAME = "xpro:parcelas"; const PARCEL_LAYER_NAME = "xpro:parcelas";
const BUILDING_LAYER_NAME = "xpro:predios";
const DWELLING_PARCEL_LAYER_NAME = "xpro:parcelas_habitacao"; const DWELLING_PARCEL_LAYER_NAME = "xpro:parcelas_habitacao";
const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]);
const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"]; const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"];
@@ -24,8 +25,7 @@ const els = {
featureModal: document.getElementById("feature-modal"), featureModal: document.getElementById("feature-modal"),
featureModalDialog: document.querySelector(".feature-modal-dialog"), featureModalDialog: document.querySelector(".feature-modal-dialog"),
featureModalClose: document.getElementById("feature-modal-close"), featureModalClose: document.getElementById("feature-modal-close"),
featureModalKind: document.getElementById("feature-modal-kind"), featureModalContent: document.getElementById("feature-modal-content"),
featureArea: document.getElementById("feature-area"),
}; };
const softBasemapLayer = new ol.layer.Tile({ 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({ const areaZoomInteraction = new ol.interaction.DragBox({
className: "ol-area-zoom-box", className: "ol-area-zoom-box",
condition: ol.events.condition.primaryAction, condition: ol.events.condition.primaryAction,
@@ -114,6 +127,7 @@ let modalDrag;
let featureHoverRequest; let featureHoverRequest;
let featureHoverRequestId = 0; let featureHoverRequestId = 0;
let featureHoverTimer; let featureHoverTimer;
let featureModalRequestId = 0;
const map = new ol.Map({ const map = new ol.Map({
target: "map", target: "map",
@@ -132,6 +146,7 @@ const map = new ol.Map({
wmsLayer, wmsLayer,
dwellingParcelLayer, dwellingParcelLayer,
parcelSearchLayer, parcelSearchLayer,
selectedFeatureLayer,
], ],
view: new ol.View({ view: new ol.View({
center: ol.proj.fromLonLat(DEFAULT_CENTER), center: ol.proj.fromLonLat(DEFAULT_CENTER),
@@ -154,6 +169,7 @@ els.backgroundSelect.addEventListener("change", syncBackground);
els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false)); els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true)); els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
els.featureModalClose.addEventListener("click", closeFeatureModal); els.featureModalClose.addEventListener("click", closeFeatureModal);
els.featureModalContent.addEventListener("click", handleFeatureModalNavigation);
els.featureModalDialog.addEventListener("pointerdown", startFeatureModalDrag); els.featureModalDialog.addEventListener("pointerdown", startFeatureModalDrag);
els.featureModalDialog.addEventListener("pointermove", dragFeatureModal); els.featureModalDialog.addEventListener("pointermove", dragFeatureModal);
els.featureModalDialog.addEventListener("pointerup", stopFeatureModalDrag); els.featureModalDialog.addEventListener("pointerup", stopFeatureModalDrag);
@@ -787,19 +803,190 @@ async function getFeatureAtCoordinate(coordinate, signal) {
function openFeatureModal(feature) { function openFeatureModal(feature) {
const layerName = unqualifiedLayerName(feature.id?.split(".")[0] || ""); const layerName = unqualifiedLayerName(feature.id?.split(".")[0] || "");
const isParcel = layerName === "parcelas" || feature.properties?.cod_parcela != null; const isParcel = layerName === "parcelas" || feature.properties?.cod_parcela != null;
els.featureModalKind.textContent = isParcel ? "Parcel" : "Building"; const requestId = ++featureModalRequestId;
els.featureArea.textContent = formatFeatureArea(feature); highlightFeature(feature);
els.featureModal.hidden = false; els.featureModal.hidden = false;
renderFeatureModal(feature, isParcel, requestId);
els.featureModalClose.focus(); 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) { function unqualifiedLayerName(name) {
return name.split(":").at(-1); return name.split(":").at(-1);
} }
function closeFeatureModal() { function closeFeatureModal() {
featureModalRequestId += 1;
resetFeatureModalPosition(); resetFeatureModalPosition();
els.featureModal.hidden = true; els.featureModal.hidden = true;
selectedFeatureSource.clear();
} }
function startFeatureModalDrag(event) { function startFeatureModalDrag(event) {
+1 -8
View File
@@ -101,14 +101,7 @@
> >
× ×
</button> </button>
<p id="feature-modal-kind" class="feature-modal-kind"></p> <div id="feature-modal-content"></div>
<h2 id="feature-modal-title">Feature information</h2>
<dl class="feature-details">
<div>
<dt>Area</dt>
<dd id="feature-area"></dd>
</div>
</dl>
</section> </section>
</div> </div>
</div> </div>
+69
View File
@@ -406,6 +406,75 @@ button:disabled {
font-weight: 700; font-weight: 700;
} }
.feature-inhabited {
margin: 14px 0 0;
color: #c62828;
font-weight: 700;
}
.feature-navigation,
.feature-association {
width: 100%;
margin-top: 16px;
}
.feature-associations {
margin-top: 20px;
border-top: 1px solid var(--panel-border);
padding-top: 14px;
}
.feature-associations h3 {
margin: 0;
font-size: 1rem;
}
.feature-associations ul {
display: grid;
gap: 10px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.feature-association {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 12px;
margin: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
border-bottom: 1px solid var(--panel-border);
background: transparent;
color: var(--ink);
box-shadow: none;
text-align: left;
}
.feature-association:hover,
.feature-association:focus-visible {
background: rgba(31, 122, 98, 0.08);
}
.feature-association-label {
font-weight: 700;
}
.feature-association-area {
color: var(--muted);
font-size: 0.9rem;
white-space: nowrap;
}
.feature-association-inhabited {
grid-column: 1 / -1;
color: #c62828;
font-size: 0.9rem;
font-weight: 700;
}
.map-wrap .ol-zoom { .map-wrap .ol-zoom {
top: 16px; top: 16px;
right: 16px; right: 16px;