836 lines
26 KiB
JavaScript
836 lines
26 KiB
JavaScript
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:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
}),
|
|
});
|
|
|
|
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 © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — 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
|
|
.filter((layer) => !isInternalLayer(layer.name))
|
|
.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() {
|
|
const userVisibleLayers = availableLayers.filter((layer) => !isInternalLayer(layer.name));
|
|
els.layerCount.textContent = String(userVisibleLayers.length);
|
|
|
|
if (!userVisibleLayers.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();
|
|
|
|
userVisibleLayers.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);
|
|
}),
|
|
);
|
|
}
|
|
|
|
function isInternalLayer(name) {
|
|
return unqualifiedLayerName(name) === 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");
|
|
}
|