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