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");
}