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_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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user