feat: improve feature modal navigation
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user