feat: show feature information on map click
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 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;
|
||||
|
||||
+27
@@ -84,6 +84,33 @@
|
||||
<main class="map-wrap">
|
||||
<div id="map" aria-label="Map view"></div>
|
||||
</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>
|
||||
|
||||
<script src="./vendor/ol.js"></script>
|
||||
|
||||
+80
@@ -308,6 +308,86 @@ button:disabled {
|
||||
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 {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
|
||||
Reference in New Issue
Block a user