feat: show feature information on map click

This commit is contained in:
2026-07-22 07:21:03 +01:00
parent ec922c22ff
commit c60840e4c1
3 changed files with 199 additions and 3 deletions
+92 -3
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 FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]);
const els = { const els = {
appShell: document.querySelector(".app-shell"), appShell: document.querySelector(".app-shell"),
@@ -15,6 +16,10 @@ const els = {
parcelSearchInput: document.getElementById("parcel-search-input"), parcelSearchInput: document.getElementById("parcel-search-input"),
parcelSearchStatus: document.getElementById("parcel-search-status"), parcelSearchStatus: document.getElementById("parcel-search-status"),
backgroundSelect: document.getElementById("background-select"), backgroundSelect: document.getElementById("background-select"),
featureModal: document.getElementById("feature-modal"),
featureModalClose: document.getElementById("feature-modal-close"),
featureModalKind: document.getElementById("feature-modal-kind"),
featureArea: document.getElementById("feature-area"),
}; };
const softBasemapLayer = new ol.layer.Tile({ const softBasemapLayer = new ol.layer.Tile({
@@ -93,11 +98,22 @@ els.parcelSearchForm.addEventListener("submit", searchParcela);
els.backgroundSelect.addEventListener("change", syncBackground); 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));
document.addEventListener("keydown", (event) => { els.featureModalClose.addEventListener("click", closeFeatureModal);
if (event.key === "Escape" && !els.appShell.classList.contains("sidebar-is-collapsed")) { els.featureModal.addEventListener("click", (event) => {
setSidebarCollapsed(true); if (event.target.matches("[data-feature-modal-close]")) {
closeFeatureModal();
} }
}); });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (!els.featureModal.hidden) {
closeFeatureModal();
} else if (!els.appShell.classList.contains("sidebar-is-collapsed")) {
setSidebarCollapsed(true);
}
}
});
map.on("singleclick", showFeatureInformation);
loadLayers(); loadLayers();
@@ -299,6 +315,79 @@ function syncSelectedLayers() {
}); });
} }
async function showFeatureInformation(event) {
const queryLayers = availableLayers
.map((layer) => layer.name)
.filter(
(name) => selectedLayers.includes(name) && FEATURE_INFO_LAYER_NAMES.has(unqualifiedLayerName(name)),
);
if (!queryLayers.length) {
return;
}
const url = wmsSource.getFeatureInfoUrl(event.coordinate, map.getView().getResolution(), "EPSG:3857", {
INFO_FORMAT: "application/json",
QUERY_LAYERS: queryLayers.join(","),
FEATURE_COUNT: 1,
});
if (!url) {
return;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Feature information failed with status ${response.status}.`);
}
const feature = (await response.json()).features?.[0];
if (feature) {
openFeatureModal(feature);
}
} catch (error) {
console.error(error);
}
}
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);
els.featureModal.hidden = false;
els.featureModalClose.focus();
}
function unqualifiedLayerName(name) {
return name.split(":").at(-1);
}
function closeFeatureModal() {
els.featureModal.hidden = true;
}
function formatFeatureArea(feature) {
const properties = feature.properties || {};
const areaEntry = Object.entries(properties).find(([name, value]) =>
value != null && /(^|[_\s])area([_\s]|$)|shape_area/i.test(name),
);
const area = Number(areaEntry?.[1]);
if (Number.isFinite(area) && area >= 0) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(area)} m²`;
}
const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, {
featureProjection: "EPSG:3857",
});
const calculatedArea = geometry?.getArea?.();
return Number.isFinite(calculatedArea)
? `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
: "Area unavailable";
}
function fitToLayerExtent(layer) { function fitToLayerExtent(layer) {
if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) { if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) {
return; return;
+27
View File
@@ -84,6 +84,33 @@
<main class="map-wrap"> <main class="map-wrap">
<div id="map" aria-label="Map view"></div> <div id="map" aria-label="Map view"></div>
</main> </main>
<div id="feature-modal" class="feature-modal" hidden>
<div class="feature-modal-backdrop" data-feature-modal-close></div>
<section
class="feature-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="feature-modal-title"
>
<button
id="feature-modal-close"
class="feature-modal-close"
type="button"
aria-label="Close feature information"
>
×
</button>
<p id="feature-modal-kind" class="feature-modal-kind"></p>
<h2 id="feature-modal-title">Feature information</h2>
<dl class="feature-details">
<div>
<dt>Area</dt>
<dd id="feature-area"></dd>
</div>
</dl>
</section>
</div>
</div> </div>
<script src="./vendor/ol.js"></script> <script src="./vendor/ol.js"></script>
+80
View File
@@ -308,6 +308,86 @@ button:disabled {
inset: 0; inset: 0;
} }
.feature-modal[hidden] {
display: none;
}
.feature-modal {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
padding: 24px;
place-items: center;
}
.feature-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(31, 42, 46, 0.35);
backdrop-filter: blur(3px);
}
.feature-modal-dialog {
position: relative;
width: min(100%, 360px);
padding: 28px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: #fffefb;
box-shadow: var(--shadow);
}
.feature-modal-close {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: transparent;
color: var(--ink);
box-shadow: none;
font-size: 1.5rem;
line-height: 1;
}
.feature-modal-kind {
margin-bottom: 6px;
color: var(--accent-strong);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.feature-details {
margin: 20px 0 0;
}
.feature-details div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--panel-border);
}
.feature-details dt {
color: var(--muted);
}
.feature-details dd {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.map-wrap .ol-zoom { .map-wrap .ol-zoom {
top: 16px; top: 16px;
right: 16px; right: 16px;