diff --git a/app.js b/app.js
index 78f764b..054263a 100644
--- a/app.js
+++ b/app.js
@@ -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 FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]);
const els = {
appShell: document.querySelector(".app-shell"),
@@ -15,6 +16,10 @@ const els = {
parcelSearchInput: document.getElementById("parcel-search-input"),
parcelSearchStatus: document.getElementById("parcel-search-status"),
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({
@@ -93,11 +98,22 @@ els.parcelSearchForm.addEventListener("submit", searchParcela);
els.backgroundSelect.addEventListener("change", syncBackground);
els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
-document.addEventListener("keydown", (event) => {
- if (event.key === "Escape" && !els.appShell.classList.contains("sidebar-is-collapsed")) {
- setSidebarCollapsed(true);
+els.featureModalClose.addEventListener("click", closeFeatureModal);
+els.featureModal.addEventListener("click", (event) => {
+ 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();
@@ -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) {
if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) {
return;
diff --git a/index.html b/index.html
index 5c76aea..4f0933f 100644
--- a/index.html
+++ b/index.html
@@ -84,6 +84,33 @@