const DEFAULT_CENTER = [-8.65, 39.55]; const API_CAPABILITIES = "/api/capabilities"; const API_WMS = "/api/wms"; const PARCEL_LAYER_NAME = "xpro:parcelas"; const DWELLING_PARCEL_LAYER_NAME = "xpro:parcelas_habitacao"; const FEATURE_INFO_LAYER_NAMES = new Set(["parcelas", "predios"]); const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientation"]; const COMPASS_HEADING_DEADBAND = 2; const SMALL_SCREEN_MEDIA_QUERY = "(max-width: 920px)"; 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, }, // Keep a slightly oversized image while the compass rotates the view so a // small heading change does not require a new WMS image every time. ratio: 1.5, crossOrigin: "anonymous", }); const wmsLayer = new ol.layer.Image({ opacity: 0.8, visible: false, source: wmsSource, }); const dwellingParcelSource = new ol.source.Vector(); const dwellingParcelLayer = new ol.layer.Vector({ source: dwellingParcelSource, // The pale halo keeps the red dwelling boundary distinct over every // supported basemap and the existing parcel fills. style: [ new ol.style.Style({ stroke: new ol.style.Stroke({ color: "rgba(255, 255, 255, 0.9)", width: 6 }), }), new ol.style.Style({ stroke: new ol.style.Stroke({ color: "#d71920", width: 3.5 }), }), ], }); 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; let deviceCompassButton; let deviceCompassStatus; let deviceCompassActive = false; let deviceCompassSource; let lastCompassHeading; let pendingCompassHeading; let compassAnimationFrame; let deviceCompassFallbackTimer; const map = new ol.Map({ target: "map", controls: ol.control .defaults.defaults({ rotate: false }) .extend([ createVisibleLayersExtentControl(), createAreaZoomControl(), ...(shouldShowDeviceCompassControl() ? [createDeviceCompassControl()] : []), new ol.control.Rotate({ autoHide: true, label: createNorthPointerIcon(), tipLabel: "Reset map orientation to north", }), ]), layers: [ softBasemapLayer, openStreetMapLayer, satelliteLayer, wmsLayer, dwellingParcelLayer, parcelSearchLayer, ], view: new ol.View({ center: ol.proj.fromLonLat(DEFAULT_CENTER), zoom: 7, }), }); map.addInteraction(areaZoomInteraction); let availableLayers = []; let selectedLayers = []; // Keep the existing responsive breakpoint as the source of truth for the // initial panel state. The menu remains available through the open control. if (window.matchMedia(SMALL_SCREEN_MEDIA_QUERY).matches) { setSidebarCollapsed(true, { focus: false }); } 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, { focus = true } = {}) { els.appShell.classList.toggle("sidebar-is-collapsed", collapsed); els.sidebar.inert = collapsed; els.sidebarOpen.setAttribute("aria-expanded", String(!collapsed)); if (collapsed && focus) { 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 createDeviceCompassControl() { const element = document.createElement("div"); element.className = "ol-device-compass ol-unselectable ol-control"; const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-pressed", "false"); const status = document.createElement("span"); status.id = "device-compass-status"; status.className = "visually-hidden"; status.setAttribute("role", "status"); button.setAttribute("aria-describedby", status.id); const icon = createNorthPointerIcon(); icon.classList.add("device-compass-icon"); button.append(icon); button.addEventListener("click", toggleDeviceCompass); element.append(button, status); deviceCompassButton = button; deviceCompassStatus = status; updateDeviceCompassButton(); return new ol.control.Control({ element }); } function shouldShowDeviceCompassControl() { return window.matchMedia?.("(any-pointer: coarse)").matches || navigator.maxTouchPoints > 0; } async function toggleDeviceCompass() { if (deviceCompassActive) { stopDeviceCompass(); return; } if (!window.isSecureContext) { updateDeviceCompassButton("Device compass requires HTTPS; HTTP works only on localhost."); return; } if (!("DeviceOrientationEvent" in window)) { updateDeviceCompassButton("Device compass is unavailable on this device."); return; } try { const permission = await requestDeviceOrientationPermission(); if (permission !== "granted") { updateDeviceCompassButton("Device compass permission was not granted."); return; } deviceCompassSource = null; lastCompassHeading = null; deviceCompassActive = true; window.addEventListener(DEVICE_ORIENTATION_EVENTS[0], syncMapToDeviceCompass); // Prefer an absolute event, but retain support for browsers (notably iOS) // that expose only the standard orientation event. deviceCompassFallbackTimer = window.setTimeout(() => { if (!deviceCompassSource && deviceCompassActive) { window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); } }, 300); updateDeviceCompassButton("Device compass orientation is on."); } catch (error) { console.error(error); updateDeviceCompassButton("Unable to start the device compass."); } } function requestDeviceOrientationPermission() { if (typeof DeviceOrientationEvent.requestPermission !== "function") { return Promise.resolve("granted"); } // iOS requires this call to be made directly from the button interaction. return DeviceOrientationEvent.requestPermission(); } function stopDeviceCompass() { DEVICE_ORIENTATION_EVENTS.forEach((eventName) => { window.removeEventListener(eventName, syncMapToDeviceCompass); }); clearTimeout(deviceCompassFallbackTimer); deviceCompassFallbackTimer = null; if (compassAnimationFrame) { cancelAnimationFrame(compassAnimationFrame); } compassAnimationFrame = null; pendingCompassHeading = null; deviceCompassSource = null; lastCompassHeading = null; deviceCompassActive = false; updateDeviceCompassButton("Device compass orientation is off."); } function syncMapToDeviceCompass(event) { // Firefox and some Android browsers dispatch both streams. Once the // absolute stream arrives, never let a relative update overwrite it. if (event.type === "deviceorientationabsolute") { deviceCompassSource = "absolute"; clearTimeout(deviceCompassFallbackTimer); window.removeEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass); } else if (deviceCompassSource === "absolute") { return; } else { deviceCompassSource = "standard"; } const heading = getCompassHeading(event); if (heading == null || headingDifference(heading, lastCompassHeading) < COMPASS_HEADING_DEADBAND) { return; } pendingCompassHeading = heading; if (compassAnimationFrame) { return; } compassAnimationFrame = requestAnimationFrame(() => { compassAnimationFrame = null; lastCompassHeading = pendingCompassHeading; // OpenLayers applies a view rotation in the inverse direction of the map // image. Match the device heading by rotating the view the other way. map.getView().setRotation((-pendingCompassHeading * Math.PI) / 180); }); } function getCompassHeading(event) { if (typeof event.webkitCompassHeading === "number" && Number.isFinite(event.webkitCompassHeading)) { return normalizeDegrees(event.webkitCompassHeading); } if (typeof event.alpha !== "number" || !Number.isFinite(event.alpha)) { return null; } // Standard orientation events report a counter-clockwise alpha angle. Add // the current screen angle so the heading remains correct in landscape. return normalizeDegrees(360 - event.alpha + getScreenOrientationAngle()); } function getScreenOrientationAngle() { const angle = window.screen?.orientation?.angle ?? window.orientation ?? 0; return Number.isFinite(Number(angle)) ? Number(angle) : 0; } function normalizeDegrees(degrees) { return ((degrees % 360) + 360) % 360; } function headingDifference(first, second) { if (second == null) { return Infinity; } return Math.abs(normalizeDegrees(first - second + 180) - 180); } function updateDeviceCompassButton(message) { if (!deviceCompassButton) { return; } const label = deviceCompassActive ? "Turn off device compass orientation" : "Align map with device compass"; deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive)); deviceCompassButton.setAttribute("aria-label", label); deviceCompassButton.title = message || label; if (message && deviceCompassStatus) { deviceCompassStatus.textContent = message; } } 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(); await loadDwellingParcels(); 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 = []; dwellingParcelSource.clear(); dwellingParcelLayer.setVisible(false); 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(), }); dwellingParcelLayer.setVisible( selectedLayers.some((name) => { const layerName = unqualifiedLayerName(name); return layerName === "parcelas" || layerName === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME); }), ); } async function loadDwellingParcels() { const publishedDwellingLayer = availableLayers.find( (layer) => unqualifiedLayerName(layer.name) === unqualifiedLayerName(DWELLING_PARCEL_LAYER_NAME), ); dwellingParcelSource.clear(); if (!publishedDwellingLayer) { console.warn("The dwelling parcel layer is not published by GeoServer."); return; } try { const params = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: publishedDwellingLayer.name, outputFormat: "application/json", srsName: "EPSG:3857", }); const response = await fetch(`${API_WMS}?${params}`); if (!response.ok) { throw new Error(`Dwelling parcel request failed with status ${response.status}.`); } const featureCollection = await response.json(); const features = new ol.format.GeoJSON().readFeatures(featureCollection, { featureProjection: "EPSG:3857", }); dwellingParcelSource.addFeatures(features); } catch (error) { // Keep the normal map usable when a deployment has not yet published the // optional layer; the error remains available for operational diagnosis. console.error(error); } } 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"); }