const DEFAULT_CENTER = [-8.65, 39.55]; const API_CAPABILITIES = "/api/capabilities"; const API_WMS = "/api/wms"; const PARCEL_LAYER_NAME = "xpro:parcelas"; 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"), }; 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 map = new ol.Map({ target: "map", layers: [softBasemapLayer, openStreetMapLayer, satelliteLayer, wmsLayer, parcelSearchLayer], view: new ol.View({ center: ol.proj.fromLonLat(DEFAULT_CENTER), zoom: 7, }), }); 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)); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !els.appShell.classList.contains("sidebar-is-collapsed")) { setSidebarCollapsed(true); } }); 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(); } } 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: `cod_parcela = '${escapeCqlLiteral(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 numbered ${parcelNumber}.`, "success", ); } catch (error) { console.error(error); updateParcelSearchStatus(error.message || "Unable to search for that parcel.", "error"); } } 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(); if (checkbox.checked) { fitToLayerExtent(layer); } }); 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(), }); } 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 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"); }