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_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) {