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"), sidebar: document.getElementById("sidebar"), sidebarOpen: document.getElementById("sidebar-open"), sidebarClose: document.getElementById("sidebar-close"), status: document.getElementById("status"), layerList: document.getElementById("layer-list"), layerCount: document.getElementById("layer-count"), parcelSearchForm: document.getElementById("parcel-search-form"), 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({ source: new ol.source.XYZ({ url: "https://{a-d}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png", attributions: '© OpenStreetMap contributors © CARTO', }), }); const openStreetMapLayer = new ol.layer.Tile({ visible: false, source: new ol.source.OSM(), }); const satelliteLayer = new ol.layer.Tile({ visible: false, source: new ol.source.XYZ({ url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attributions: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community', }), }); // Use one WMS image for the viewport so GeoServer can de-conflict parcel // labels across the whole map, rather than placing the same label per tile. const wmsSource = new ol.source.ImageWMS({ url: API_WMS, params: { LAYERS: "", FORMAT: "image/png", TRANSPARENT: true, }, ratio: 1, crossOrigin: "anonymous", }); const wmsLayer = new ol.layer.Image({ opacity: 0.8, visible: false, source: wmsSource, }); const parcelSearchSource = new ol.source.Vector(); const parcelSearchLayer = new ol.layer.Vector({ source: parcelSearchSource, style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: "#d9582b", width: 3 }), fill: new ol.style.Fill({ color: "rgba(255, 201, 82, 0.22)" }), }), }); const areaZoomInteraction = new ol.interaction.DragBox({ className: "ol-area-zoom-box", condition: ol.events.condition.primaryAction, minArea: 16, }); areaZoomInteraction.setActive(false); let areaZoomButton; const map = new ol.Map({ target: "map", controls: ol.control .defaults.defaults({ rotate: false }) .extend([ createVisibleLayersExtentControl(), createAreaZoomControl(), new ol.control.Rotate({ autoHide: true, label: createNorthPointerIcon(), tipLabel: "Reset map orientation to north", }), ]), layers: [softBasemapLayer, openStreetMapLayer, satelliteLayer, wmsLayer, parcelSearchLayer], view: new ol.View({ center: ol.proj.fromLonLat(DEFAULT_CENTER), zoom: 7, }), }); map.addInteraction(areaZoomInteraction); let availableLayers = []; let selectedLayers = []; els.parcelSearchForm.addEventListener("submit", searchParcela); els.backgroundSelect.addEventListener("change", syncBackground); els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false)); els.sidebarClose.addEventListener("click", () => 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 (areaZoomInteraction.getActive()) { setAreaZoomMode(false); } else if (!els.appShell.classList.contains("sidebar-is-collapsed")) { setSidebarCollapsed(true); } } }); map.on("singleclick", showFeatureInformation); areaZoomInteraction.on("boxend", zoomToSelectedArea); areaZoomInteraction.on("boxcancel", () => { // A click or a very short drag is not a selection. Exit on the next frame so // cancelling a drag while disabling the interaction cannot recursively // trigger another cancellation. requestAnimationFrame(() => { if (areaZoomInteraction.getActive()) { setAreaZoomMode(false); } }); }); loadLayers(); function setSidebarCollapsed(collapsed) { els.appShell.classList.toggle("sidebar-is-collapsed", collapsed); els.sidebar.inert = collapsed; els.sidebarOpen.setAttribute("aria-expanded", String(!collapsed)); if (collapsed) { els.sidebarOpen.focus(); } } function createNorthPointerIcon() { const icon = document.createElement("img"); icon.className = "north-pointer-icon"; icon.src = "./assets/icons/north-pointer.svg"; icon.alt = ""; return icon; } function createVisibleLayersExtentControl() { const element = document.createElement("div"); element.className = "ol-visible-layers-extent ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.title = "Zoom to visible layers"; button.setAttribute("aria-label", "Zoom to visible layers"); button.textContent = "⤢"; button.addEventListener("click", () => fitToVisibleLayers()); element.append(button); return new ol.control.Control({ element }); } function createAreaZoomControl() { const element = document.createElement("div"); element.className = "ol-area-zoom ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.title = "Zoom to area"; button.setAttribute("aria-label", "Zoom to area"); button.setAttribute("aria-pressed", "false"); const icon = document.createElement("img"); icon.className = "area-zoom-icon"; icon.src = "./assets/icons/area-zoom.svg"; icon.alt = ""; button.append(icon); button.addEventListener("click", () => setAreaZoomMode(!areaZoomInteraction.getActive())); element.append(button); areaZoomButton = button; return new ol.control.Control({ element }); } function setAreaZoomMode(active) { areaZoomInteraction.setActive(active); map.getViewport().classList.toggle("area-zoom-is-active", active); areaZoomButton?.setAttribute("aria-pressed", String(active)); areaZoomButton?.setAttribute( "title", active ? "Exit area zoom (Esc)" : "Zoom to area", ); if (active) { closeFeatureModal(); } } function zoomToSelectedArea() { const extent = areaZoomInteraction.getGeometry()?.getExtent(); if (extent && extent.every(Number.isFinite)) { map.getView().fit(extent, { padding: [64, 64, 64, 64], duration: 500, maxZoom: 19, }); } setAreaZoomMode(false); } async function searchParcela(event) { event.preventDefault(); const parcelNumber = els.parcelSearchInput.value.trim(); if (!parcelNumber) { updateParcelSearchStatus("Enter a parcel number to search.", "error"); return; } updateParcelSearchStatus("Searching for parcel…"); parcelSearchSource.clear(); try { const params = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: PARCEL_LAYER_NAME, outputFormat: "application/json", srsName: "EPSG:3857", CQL_FILTER: buildParcelSearchFilter(parcelNumber), }); const response = await fetch(`${API_WMS}?${params}`); if (!response.ok) { throw new Error(`Search failed with status ${response.status}.`); } const featureCollection = await response.json(); const features = new ol.format.GeoJSON().readFeatures(featureCollection, { featureProjection: "EPSG:3857", }); if (!features.length) { updateParcelSearchStatus(`No parcel found with number “${parcelNumber}”.`, "error"); return; } parcelSearchSource.addFeatures(features); map.getView().fit(parcelSearchSource.getExtent(), { padding: [64, 64, 64, 64], duration: 500, maxZoom: features.length === 1 ? 19 : 16, }); updateParcelSearchStatus( features.length === 1 ? `Found parcel ${parcelNumber}.` : `Found ${features.length} parcels matching ${parcelNumber}.`, "success", ); } catch (error) { console.error(error); updateParcelSearchStatus(error.message || "Unable to search for that parcel.", "error"); } } function buildParcelSearchFilter(parcelNumber) { const escapedParcelNumber = escapeCqlLiteral(parcelNumber); // Whole parcel numbers identify a group: 278 includes sub-parcels such as // 278.1 and 278.2. A supplied sub-parcel number remains an exact lookup. if (/^\d+$/.test(parcelNumber)) { return `(cod_parcela = '${escapedParcelNumber}' OR cod_parcela LIKE '${escapedParcelNumber}.%')`; } return `cod_parcela = '${escapedParcelNumber}'`; } async function loadLayers() { updateStatus("Loading WMS layers..."); try { const capabilities = await loadCapabilities(); availableLayers = extractLayers(capabilities); selectedLayers = availableLayers.map((layer) => layer.name); renderLayerList(); syncSelectedLayers(); fitToLayerExtent(availableLayers[0]); updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success"); } catch (error) { console.error(error); updateStatus(error.message || "Unable to load the WMS service.", "error"); availableLayers = []; selectedLayers = []; renderLayerList(); syncSelectedLayers(); } } async function loadCapabilities() { const url = new URL(API_CAPABILITIES, window.location.origin); const response = await fetch(url); if (!response.ok) { throw new Error(`GeoServer connection failed with status ${response.status}.`); } return response.text(); } function extractLayers(xmlText) { const parser = new DOMParser(); const xml = parser.parseFromString(xmlText, "text/xml"); const capabilityNode = firstChildByName(xml.documentElement, "Capability"); const rootLayerNode = firstChildByName(capabilityNode, "Layer"); const nodes = collectNamedLayers(rootLayerNode); return nodes .map((layerNode) => { const name = textFromChild(layerNode, "Name"); const title = textFromChild(layerNode, "Title") || name; const bbox = firstChildByName(layerNode, "EX_GeographicBoundingBox"); const extent = bbox ? [ Number(textFromChild(bbox, "westBoundLongitude")), Number(textFromChild(bbox, "southBoundLatitude")), Number(textFromChild(bbox, "eastBoundLongitude")), Number(textFromChild(bbox, "northBoundLatitude")), ] : null; return { name, title, extent }; }) .filter((layer) => layer.name); } function renderLayerList() { els.layerCount.textContent = String(availableLayers.length); if (!availableLayers.length) { els.layerList.className = "layer-list empty"; els.layerList.textContent = "No published layers were returned by the service."; return; } els.layerList.className = "layer-list"; els.layerList.replaceChildren(); availableLayers.forEach((layer) => { const label = document.createElement("label"); label.className = "layer-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = selectedLayers.includes(layer.name); checkbox.addEventListener("change", () => { selectedLayers = checkbox.checked ? [...new Set([...selectedLayers, layer.name])] : selectedLayers.filter((name) => name !== layer.name); syncSelectedLayers(); }); const title = document.createElement("span"); title.className = "layer-title"; title.textContent = layer.title; label.append(checkbox, title); els.layerList.append(label); }); } function syncSelectedLayers() { const orderedSelectedLayers = availableLayers .map((layer) => layer.name) .filter((name) => selectedLayers.includes(name)); wmsLayer.setVisible(selectedLayers.length > 0); wmsSource.updateParams({ LAYERS: [...orderedSelectedLayers].reverse().join(","), _: Date.now(), }); } async function showFeatureInformation(event) { if (areaZoomInteraction.getActive()) { return; } 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; } const projectedExtent = ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"); map.getView().fit(projectedExtent, { padding: [40, 40, 40, 40], duration: 500, maxZoom: 18, }); } function fitToVisibleLayers() { const visibleLayers = availableLayers.filter((layer) => selectedLayers.includes(layer.name)); const projectedExtents = visibleLayers .filter((layer) => layer.extent && layer.extent.every(Number.isFinite)) .map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857")); if (!projectedExtents.length) { updateStatus("Select a layer with a published extent to zoom to it.", "error"); return; } const combinedExtent = projectedExtents.reduce( (combined, extent) => [ Math.min(combined[0], extent[0]), Math.min(combined[1], extent[1]), Math.max(combined[2], extent[2]), Math.max(combined[3], extent[3]), ], [Infinity, Infinity, -Infinity, -Infinity], ); map.getView().fit(combinedExtent, { padding: [64, 64, 64, 64], duration: 500, maxZoom: 18, }); } function childElementsByName(node, localName) { return [...(node?.children || [])].filter((child) => child.localName === localName); } function firstChildByName(node, localName) { return childElementsByName(node, localName)[0] || null; } function textFromChild(node, localName) { return firstChildByName(node, localName)?.textContent?.trim() || ""; } function collectNamedLayers(node) { return childElementsByName(node, "Layer").flatMap((layerNode) => { const children = collectNamedLayers(layerNode); return textFromChild(layerNode, "Name") ? [layerNode, ...children] : children; }); } function updateStatus(message, tone) { els.status.textContent = message; els.status.className = tone ? `status ${tone}` : "status"; } function updateParcelSearchStatus(message, tone) { els.parcelSearchStatus.textContent = message; els.parcelSearchStatus.className = tone ? `status ${tone}` : "status"; } function escapeCqlLiteral(value) { return value.replaceAll("'", "''"); } function syncBackground() { const background = els.backgroundSelect.value; softBasemapLayer.setVisible(background === "soft"); openStreetMapLayer.setVisible(background === "openstreetmap"); satelliteLayer.setVisible(background === "satellite"); }