1622 lines
51 KiB
JavaScript
1622 lines
51 KiB
JavaScript
const i18n = XProI18n.createI18n();
|
||
const t = i18n.t;
|
||
|
||
const DEFAULT_CENTER = [-8.65, 39.55];
|
||
const DEFAULT_ZOOM = 7;
|
||
const DEFAULT_ROTATION = 0;
|
||
const API_CAPABILITIES = "/api/capabilities";
|
||
const API_WMS = "/api/wms";
|
||
const PARCEL_LAYER_NAME = "xpro:parcelas";
|
||
const BUILDING_LAYER_NAME = "xpro:predios";
|
||
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 MODAL_VIEWPORT_PADDING = 24;
|
||
const MESSAGE_DURATIONS = {
|
||
info: 5000,
|
||
warning: 6500,
|
||
error: 9000,
|
||
};
|
||
const MAP_URL_PARAMS = {
|
||
latitude: "map-lat",
|
||
longitude: "map-lon",
|
||
zoom: "map-zoom",
|
||
rotation: "map-rotation",
|
||
layers: "map-layers",
|
||
background: "map-background",
|
||
featureLayer: "map-feature-layer",
|
||
featureId: "map-feature-id",
|
||
};
|
||
const BACKGROUND_VALUES = new Set(["soft", "openstreetmap", "satellite"]);
|
||
|
||
const els = {
|
||
appShell: document.querySelector(".app-shell"),
|
||
messageRegion: document.getElementById("message-region"),
|
||
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"),
|
||
settingsOpen: document.getElementById("settings-open"),
|
||
settingsModal: document.getElementById("settings-modal"),
|
||
settingsModalClose: document.getElementById("settings-modal-close"),
|
||
settingsForm: document.getElementById("settings-form"),
|
||
languageSelect: document.getElementById("language-select"),
|
||
featureModal: document.getElementById("feature-modal"),
|
||
featureModalDialog: document.querySelector(".feature-modal-dialog"),
|
||
featureModalClose: document.getElementById("feature-modal-close"),
|
||
featureModalContent: document.getElementById("feature-modal-content"),
|
||
};
|
||
|
||
localizeDocument();
|
||
populateLanguageOptions();
|
||
|
||
// A view link must be complete before it is applied. This prevents a typo or
|
||
// a partially copied URL from leaving the map in an arbitrary half-restored
|
||
// state.
|
||
let sharedMapState = readSharedMapState();
|
||
let urlStateReady = false;
|
||
let applyingSharedMapState = false;
|
||
|
||
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: t("softAttribution"),
|
||
}),
|
||
});
|
||
|
||
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: t("satelliteAttribution"),
|
||
}),
|
||
});
|
||
|
||
// 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 selectedFeatureSource = new ol.source.Vector();
|
||
const selectedFeatureLayer = new ol.layer.Vector({
|
||
source: selectedFeatureSource,
|
||
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: "#1877d1", width: 3.5 }),
|
||
}),
|
||
],
|
||
});
|
||
|
||
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;
|
||
let modalDrag;
|
||
let featureHoverRequest;
|
||
let featureHoverRequestId = 0;
|
||
let featureHoverTimer;
|
||
let featureModalRequestId = 0;
|
||
let featureModalRestoreRequestId = 0;
|
||
|
||
const map = new ol.Map({
|
||
target: "map",
|
||
controls: ol.control
|
||
.defaults.defaults({
|
||
rotate: false,
|
||
zoomOptions: {
|
||
zoomInTipLabel: t("zoomIn"),
|
||
zoomOutTipLabel: t("zoomOut"),
|
||
},
|
||
attributionOptions: {
|
||
tipLabel: t("attribution"),
|
||
},
|
||
})
|
||
.extend([
|
||
createVisibleLayersExtentControl(),
|
||
createAreaZoomControl(),
|
||
...(shouldShowDeviceCompassControl() ? [createDeviceCompassControl()] : []),
|
||
createShareControl(),
|
||
createNorthPointerControl(),
|
||
]),
|
||
layers: [
|
||
softBasemapLayer,
|
||
openStreetMapLayer,
|
||
satelliteLayer,
|
||
wmsLayer,
|
||
dwellingParcelLayer,
|
||
parcelSearchLayer,
|
||
selectedFeatureLayer,
|
||
],
|
||
view: new ol.View({
|
||
center: ol.proj.fromLonLat(sharedMapState.view?.center || DEFAULT_CENTER),
|
||
zoom: sharedMapState.view?.zoom ?? DEFAULT_ZOOM,
|
||
rotation: sharedMapState.view?.rotation ?? DEFAULT_ROTATION,
|
||
}),
|
||
});
|
||
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.settingsOpen.addEventListener("click", openSettingsModal);
|
||
els.settingsModalClose.addEventListener("click", closeSettingsModal);
|
||
els.settingsForm.addEventListener("submit", applySettings);
|
||
els.settingsModal.addEventListener("click", (event) => {
|
||
if (event.target.matches("[data-settings-modal-close]")) {
|
||
closeSettingsModal();
|
||
}
|
||
});
|
||
els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
|
||
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
|
||
els.featureModalClose.addEventListener("click", closeFeatureModal);
|
||
els.featureModalContent.addEventListener("click", handleFeatureModalNavigation);
|
||
els.featureModalDialog.addEventListener("pointerdown", startFeatureModalDrag);
|
||
els.featureModalDialog.addEventListener("pointermove", dragFeatureModal);
|
||
els.featureModalDialog.addEventListener("pointerup", stopFeatureModalDrag);
|
||
els.featureModalDialog.addEventListener("pointercancel", stopFeatureModalDrag);
|
||
els.featureModal.addEventListener("click", (event) => {
|
||
if (event.target.matches("[data-feature-modal-close]")) {
|
||
closeFeatureModal();
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
if (!els.settingsModal.hidden) {
|
||
closeSettingsModal();
|
||
} else if (!els.featureModal.hidden) {
|
||
closeFeatureModal();
|
||
} else if (areaZoomInteraction.getActive()) {
|
||
setAreaZoomMode(false);
|
||
} else if (!els.appShell.classList.contains("sidebar-is-collapsed")) {
|
||
setSidebarCollapsed(true);
|
||
}
|
||
}
|
||
});
|
||
window.addEventListener("resize", keepFeatureModalInViewport);
|
||
window.addEventListener("popstate", restoreSharedMapState);
|
||
map.on("singleclick", showFeatureInformation);
|
||
map.on("pointermove", updateFeatureCursor);
|
||
map.on("moveend", syncSharedMapStateToUrl);
|
||
map.getView().on("change:rotation", syncSharedMapStateToUrl);
|
||
map.getViewport().addEventListener("pointerleave", clearFeatureCursor);
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
|
||
if (sharedMapState.background) {
|
||
els.backgroundSelect.value = sharedMapState.background;
|
||
syncBackground();
|
||
}
|
||
|
||
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 localizeDocument() {
|
||
document.documentElement.lang = i18n.language;
|
||
document.querySelectorAll("[data-i18n]").forEach((element) => {
|
||
element.textContent = t(element.dataset.i18n);
|
||
});
|
||
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
|
||
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
|
||
});
|
||
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
|
||
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
|
||
});
|
||
}
|
||
|
||
function populateLanguageOptions() {
|
||
const browserOption = document.createElement("option");
|
||
browserOption.value = "browser";
|
||
browserOption.textContent = t("useBrowserDefault");
|
||
const languageOptions = i18n.supportedLanguages.map((language) => {
|
||
const option = document.createElement("option");
|
||
option.value = language;
|
||
option.lang = language;
|
||
option.textContent = XProI18n.TRANSLATIONS[language].languageName;
|
||
return option;
|
||
});
|
||
els.languageSelect.replaceChildren(browserOption, ...languageOptions);
|
||
els.languageSelect.value = i18n.preference || "browser";
|
||
}
|
||
|
||
function openSettingsModal() {
|
||
els.languageSelect.value = i18n.preference || "browser";
|
||
els.settingsModal.hidden = false;
|
||
els.languageSelect.focus();
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
els.settingsModal.hidden = true;
|
||
els.settingsOpen.focus();
|
||
}
|
||
|
||
function applySettings(event) {
|
||
event.preventDefault();
|
||
i18n.setLanguage(els.languageSelect.value === "browser" ? null : els.languageSelect.value);
|
||
// Reload so all transient statuses, map controls, and any open feature
|
||
// dialog are recreated consistently in the newly selected language. The
|
||
// current map view is already represented in the URL and is preserved.
|
||
window.location.reload();
|
||
}
|
||
|
||
function createNorthPointerIcon() {
|
||
const icon = document.createElement("img");
|
||
icon.className = "north-pointer-icon";
|
||
icon.src = "./assets/icons/north-pointer.svg";
|
||
icon.alt = "";
|
||
return icon;
|
||
}
|
||
|
||
function createNorthPointerControl() {
|
||
const control = new ol.control.Rotate({
|
||
autoHide: true,
|
||
label: createNorthPointerIcon(),
|
||
tipLabel: t("resetNorth"),
|
||
});
|
||
|
||
// Resetting the view while compass orientation remains subscribed lets the
|
||
// next device event rotate it again. Stop the subscription when the north
|
||
// pointer is used so its reset remains in effect.
|
||
control.element.querySelector("button").addEventListener("click", () => {
|
||
if (deviceCompassActive) {
|
||
stopDeviceCompass();
|
||
}
|
||
});
|
||
|
||
return control;
|
||
}
|
||
|
||
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 = t("zoomVisibleLayers");
|
||
button.setAttribute("aria-label", t("zoomVisibleLayers"));
|
||
button.textContent = "⤢";
|
||
button.addEventListener("click", () => fitToVisibleLayers());
|
||
|
||
element.append(button);
|
||
return new ol.control.Control({ element });
|
||
}
|
||
|
||
function createShareControl() {
|
||
const element = document.createElement("div");
|
||
element.className = "ol-share ol-unselectable ol-control";
|
||
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.title = t("copyMapLink");
|
||
button.setAttribute("aria-label", t("copyMapLink"));
|
||
const icon = document.createElement("span");
|
||
icon.className = "material-symbols-outlined";
|
||
icon.setAttribute("aria-hidden", "true");
|
||
icon.textContent = "share";
|
||
button.append(icon);
|
||
button.addEventListener("click", copyCurrentViewUrl);
|
||
|
||
element.append(button);
|
||
return new ol.control.Control({ element });
|
||
}
|
||
|
||
async function copyCurrentViewUrl() {
|
||
// Ensure the copied address reflects state changes that may not yet have
|
||
// reached the next map movement event.
|
||
syncSharedMapStateToUrl();
|
||
|
||
try {
|
||
if (!navigator.clipboard?.writeText) {
|
||
throw new Error(t("clipboardUnavailable"));
|
||
}
|
||
|
||
await navigator.clipboard.writeText(window.location.href);
|
||
showMessage(t("mapLinkCopied"));
|
||
} catch (error) {
|
||
console.error(error);
|
||
showMessage(t("mapLinkCopyFailed"), {
|
||
type: "error",
|
||
});
|
||
}
|
||
}
|
||
|
||
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 = t("zoomArea");
|
||
button.setAttribute("aria-label", t("zoomArea"));
|
||
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(t("compassRequiresHttps"));
|
||
return;
|
||
}
|
||
|
||
if (!("DeviceOrientationEvent" in window)) {
|
||
updateDeviceCompassButton(t("compassUnavailable"));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const permission = await requestDeviceOrientationPermission();
|
||
if (permission !== "granted") {
|
||
updateDeviceCompassButton(t("compassPermissionDenied"));
|
||
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(t("compassOn"));
|
||
} catch (error) {
|
||
console.error(error);
|
||
updateDeviceCompassButton(t("compassStartFailed"));
|
||
}
|
||
}
|
||
|
||
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(t("compassOff"));
|
||
}
|
||
|
||
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
|
||
? t("compassTurnOff")
|
||
: t("compassAlign");
|
||
deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive));
|
||
deviceCompassButton.setAttribute("aria-label", label);
|
||
deviceCompassButton.title = message || label;
|
||
if (message && deviceCompassStatus) {
|
||
deviceCompassStatus.textContent = message;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Display a short, non-blocking application message. Callers can override
|
||
* `duration` in milliseconds; errors have the longest default reading time.
|
||
*/
|
||
function showMessage(message, { type = "info", duration = MESSAGE_DURATIONS[type] } = {}) {
|
||
if (!els.messageRegion || !message) {
|
||
return;
|
||
}
|
||
|
||
const supportedTypes = new Set(Object.keys(MESSAGE_DURATIONS));
|
||
const messageType = supportedTypes.has(type) ? type : "info";
|
||
const displayDuration = Number.isFinite(duration) && duration > 0
|
||
? duration
|
||
: MESSAGE_DURATIONS[messageType];
|
||
const toast = document.createElement("div");
|
||
toast.className = `message message--${messageType}`;
|
||
toast.setAttribute("role", messageType === "error" ? "alert" : "status");
|
||
toast.style.setProperty("--message-duration", `${displayDuration}ms`);
|
||
|
||
const text = document.createElement("p");
|
||
text.className = "message-text";
|
||
text.textContent = message;
|
||
|
||
const dismiss = document.createElement("button");
|
||
dismiss.className = "message-dismiss";
|
||
dismiss.type = "button";
|
||
dismiss.setAttribute("aria-label", t("dismissMessage"));
|
||
dismiss.textContent = "×";
|
||
|
||
const progress = document.createElement("span");
|
||
progress.className = "message-progress";
|
||
progress.setAttribute("aria-hidden", "true");
|
||
|
||
let timer = window.setTimeout(removeMessage, displayDuration);
|
||
function removeMessage() {
|
||
if (timer === undefined) {
|
||
return;
|
||
}
|
||
|
||
window.clearTimeout(timer);
|
||
timer = undefined;
|
||
toast.classList.add("is-leaving");
|
||
window.setTimeout(() => toast.remove(), 180);
|
||
}
|
||
|
||
dismiss.addEventListener("click", removeMessage, { once: true });
|
||
toast.append(text, dismiss, progress);
|
||
els.messageRegion.append(toast);
|
||
}
|
||
|
||
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 ? t("exitAreaZoom") : t("zoomArea"),
|
||
);
|
||
|
||
if (active) {
|
||
clearFeatureCursor();
|
||
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(t("enterParcel"), "error");
|
||
showMessage(t("enterParcel"), { type: "warning" });
|
||
return;
|
||
}
|
||
|
||
updateParcelSearchStatus(t("searchingParcel"));
|
||
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(t("searchFailedStatus", { status: response.status }));
|
||
}
|
||
|
||
const featureCollection = await response.json();
|
||
const features = new ol.format.GeoJSON().readFeatures(featureCollection, {
|
||
featureProjection: "EPSG:3857",
|
||
});
|
||
|
||
if (!features.length) {
|
||
const notFoundMessage = t("noParcelFound", { parcelNumber });
|
||
updateParcelSearchStatus(notFoundMessage, "error");
|
||
showMessage(notFoundMessage, { type: "warning" });
|
||
return;
|
||
}
|
||
|
||
parcelSearchSource.addFeatures(features);
|
||
map.getView().fit(parcelSearchSource.getExtent(), {
|
||
padding: [64, 64, 64, 64],
|
||
duration: 500,
|
||
maxZoom: features.length === 1 ? 19 : 16,
|
||
});
|
||
|
||
const searchMessage = features.length === 1
|
||
? t("foundParcel", { parcelNumber })
|
||
: t("foundParcels", { count: features.length, parcelNumber });
|
||
updateParcelSearchStatus(searchMessage, "success");
|
||
showMessage(searchMessage);
|
||
} catch (error) {
|
||
console.error(error);
|
||
const searchMessage = error.message || t("parcelSearchFailed");
|
||
updateParcelSearchStatus(searchMessage, "error");
|
||
showMessage(searchMessage, { type: "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(t("loadingWmsLayers"));
|
||
|
||
try {
|
||
const capabilities = await loadCapabilities();
|
||
availableLayers = extractLayers(capabilities);
|
||
selectedLayers = selectedLayersFromSharedMapState(sharedMapState);
|
||
|
||
renderLayerList();
|
||
syncSelectedLayers();
|
||
await loadDwellingParcels();
|
||
// The normal first-load fit remains the fallback, but must not overwrite
|
||
// the exact location represented by a shared view link.
|
||
if (!sharedMapState.view) {
|
||
fitToLayerExtent(availableLayers[0]);
|
||
}
|
||
restoreSharedFeatureModal();
|
||
|
||
updateStatus("");
|
||
} catch (error) {
|
||
console.error(error);
|
||
const loadMessage = error.message || t("wmsLoadFailed");
|
||
updateStatus(loadMessage, "error");
|
||
showMessage(loadMessage, { type: "error" });
|
||
availableLayers = [];
|
||
selectedLayers = [];
|
||
dwellingParcelSource.clear();
|
||
dwellingParcelLayer.setVisible(false);
|
||
renderLayerList();
|
||
syncSelectedLayers();
|
||
} finally {
|
||
// Do not replace a shared URL while layer discovery is still deciding
|
||
// which layers it represents. Once ready, keep a canonical, copyable URL
|
||
// for the current map state even if GeoServer was unavailable.
|
||
urlStateReady = true;
|
||
syncSharedMapStateToUrl();
|
||
}
|
||
}
|
||
|
||
async function loadCapabilities() {
|
||
const url = new URL(API_CAPABILITIES, window.location.origin);
|
||
|
||
const response = await fetch(url);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(t("geoserverConnectionFailed", { 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 = t("noPublishedLayers");
|
||
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);
|
||
}),
|
||
);
|
||
clearFeatureCursor();
|
||
syncSharedMapStateToUrl();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
try {
|
||
const feature = await getFeatureAtCoordinate(event.coordinate);
|
||
if (feature) {
|
||
openFeatureModal(feature);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
function updateFeatureCursor(event) {
|
||
if (event.dragging || areaZoomInteraction.getActive()) {
|
||
clearFeatureCursor();
|
||
return;
|
||
}
|
||
|
||
window.clearTimeout(featureHoverTimer);
|
||
const requestId = ++featureHoverRequestId;
|
||
const coordinate = [...event.coordinate];
|
||
featureHoverTimer = window.setTimeout(async () => {
|
||
featureHoverRequest?.abort();
|
||
featureHoverRequest = new AbortController();
|
||
|
||
try {
|
||
const feature = await getFeatureAtCoordinate(coordinate, featureHoverRequest.signal);
|
||
if (requestId === featureHoverRequestId) {
|
||
map.getViewport().style.cursor = feature ? "pointer" : "";
|
||
}
|
||
} catch (error) {
|
||
if (error.name !== "AbortError" && requestId === featureHoverRequestId) {
|
||
map.getViewport().style.cursor = "";
|
||
}
|
||
}
|
||
}, 120);
|
||
}
|
||
|
||
function clearFeatureCursor() {
|
||
window.clearTimeout(featureHoverTimer);
|
||
featureHoverRequestId += 1;
|
||
featureHoverRequest?.abort();
|
||
featureHoverRequest = undefined;
|
||
map.getViewport().style.cursor = "";
|
||
}
|
||
|
||
async function getFeatureAtCoordinate(coordinate, signal) {
|
||
const queryLayers = availableLayers
|
||
.map((layer) => layer.name)
|
||
.filter(
|
||
(name) => selectedLayers.includes(name) && FEATURE_INFO_LAYER_NAMES.has(unqualifiedLayerName(name)),
|
||
);
|
||
|
||
if (!queryLayers.length) {
|
||
return null;
|
||
}
|
||
|
||
const url = wmsSource.getFeatureInfoUrl(coordinate, map.getView().getResolution(), "EPSG:3857", {
|
||
INFO_FORMAT: "application/json",
|
||
QUERY_LAYERS: queryLayers.join(","),
|
||
FEATURE_COUNT: 1,
|
||
});
|
||
|
||
if (!url) {
|
||
return null;
|
||
}
|
||
|
||
const response = await fetch(url, { signal });
|
||
if (!response.ok) {
|
||
throw new Error(`Feature information failed with status ${response.status}.`);
|
||
}
|
||
|
||
return (await response.json()).features?.[0] || null;
|
||
}
|
||
|
||
function openFeatureModal(feature) {
|
||
const layerName = unqualifiedLayerName(feature.id?.split(".")[0] || "");
|
||
const isParcel = layerName === "parcelas" || feature.properties?.cod_parcela != null;
|
||
featureModalRestoreRequestId += 1;
|
||
sharedMapState.feature = featureReference(feature, isParcel);
|
||
const requestId = ++featureModalRequestId;
|
||
highlightFeature(feature);
|
||
els.featureModal.hidden = false;
|
||
renderFeatureModal(feature, isParcel, requestId);
|
||
els.featureModalClose.focus();
|
||
syncSharedMapStateToUrl();
|
||
}
|
||
|
||
function highlightFeature(feature) {
|
||
selectedFeatureSource.clear();
|
||
const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, {
|
||
featureProjection: "EPSG:3857",
|
||
});
|
||
|
||
if (geometry) {
|
||
selectedFeatureSource.addFeature(new ol.Feature(geometry));
|
||
}
|
||
}
|
||
|
||
async function renderFeatureModal(feature, isParcel, requestId) {
|
||
const content = document.createDocumentFragment();
|
||
const properties = feature.properties || {};
|
||
const featureId = isParcel ? properties.cod_parcela : properties.cod_predio;
|
||
|
||
content.append(createFeatureHeading(isParcel ? t("parcel") : t("building"), featureId));
|
||
content.append(createFeatureDetails([[t("area"), formatFeatureArea(feature)]]));
|
||
|
||
if (isParcel) {
|
||
if (isDwellingParcel(feature)) {
|
||
const inhabited = document.createElement("p");
|
||
inhabited.className = "feature-inhabited";
|
||
inhabited.textContent = t("inhabited");
|
||
content.append(inhabited);
|
||
}
|
||
|
||
if (properties.predio_uuid) {
|
||
content.append(createFeatureNavigation(
|
||
t("viewCorrespondingBuilding"),
|
||
BUILDING_LAYER_NAME,
|
||
properties.predio_uuid,
|
||
));
|
||
}
|
||
} else {
|
||
const parcels = await getFeaturesByProperty(PARCEL_LAYER_NAME, "predio_uuid", properties.predio_uuid);
|
||
const section = document.createElement("section");
|
||
section.className = "feature-associations";
|
||
const heading = document.createElement("h3");
|
||
heading.textContent = t("associatedParcels");
|
||
section.append(heading);
|
||
|
||
if (parcels.length) {
|
||
const list = document.createElement("ul");
|
||
parcels.forEach((parcel) => {
|
||
const item = document.createElement("li");
|
||
const link = createFeatureNavigation(
|
||
"",
|
||
PARCEL_LAYER_NAME,
|
||
parcel.properties?.parcela_uuid,
|
||
);
|
||
link.className = "feature-association";
|
||
const parcelNumber = parcel.properties?.cod_parcela || "";
|
||
link.setAttribute("aria-label", t("viewParcel", { parcelNumber }).trim());
|
||
const label = document.createElement("span");
|
||
label.className = "feature-association-label";
|
||
label.textContent = t("parcelWithNumber", {
|
||
parcelNumber: parcelNumber || t("unavailable"),
|
||
});
|
||
const area = document.createElement("span");
|
||
area.className = "feature-association-area";
|
||
area.textContent = formatFeatureArea(parcel);
|
||
link.append(label, area);
|
||
if (isDwellingParcel(parcel)) {
|
||
const inhabited = document.createElement("span");
|
||
inhabited.className = "feature-association-inhabited";
|
||
inhabited.textContent = t("inhabited");
|
||
link.append(inhabited);
|
||
}
|
||
item.append(link);
|
||
list.append(item);
|
||
});
|
||
section.append(list);
|
||
} else {
|
||
const empty = document.createElement("p");
|
||
empty.textContent = t("noAssociatedParcels");
|
||
section.append(empty);
|
||
}
|
||
|
||
content.append(section);
|
||
}
|
||
|
||
content.append(createFeatureModalShareButton());
|
||
|
||
// A linked-feature request may finish after a newer selection was made.
|
||
if (!els.featureModal.hidden && requestId === featureModalRequestId) {
|
||
els.featureModalContent.replaceChildren(content);
|
||
}
|
||
}
|
||
|
||
function createFeatureModalShareButton() {
|
||
const actions = document.createElement("div");
|
||
actions.className = "feature-modal-actions";
|
||
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "feature-modal-share";
|
||
button.title = t("copyMapLink");
|
||
button.setAttribute("aria-label", t("copyMapLink"));
|
||
|
||
const icon = document.createElement("span");
|
||
icon.className = "material-symbols-outlined";
|
||
icon.setAttribute("aria-hidden", "true");
|
||
icon.textContent = "share";
|
||
button.append(icon);
|
||
button.addEventListener("click", copyCurrentViewUrl);
|
||
|
||
actions.append(button);
|
||
return actions;
|
||
}
|
||
|
||
function createFeatureHeading(kind, id) {
|
||
const heading = document.createElement("h2");
|
||
heading.id = "feature-modal-title";
|
||
heading.textContent = id ? `${kind} ${id}` : kind;
|
||
return heading;
|
||
}
|
||
|
||
function createFeatureDetails(entries) {
|
||
const details = document.createElement("dl");
|
||
details.className = "feature-details";
|
||
entries.forEach(([label, value]) => {
|
||
const row = document.createElement("div");
|
||
const term = document.createElement("dt");
|
||
const description = document.createElement("dd");
|
||
term.textContent = label;
|
||
description.textContent = value;
|
||
row.append(term, description);
|
||
details.append(row);
|
||
});
|
||
return details;
|
||
}
|
||
|
||
function createFeatureNavigation(label, layerName, featureUuid) {
|
||
const link = document.createElement("button");
|
||
link.type = "button";
|
||
link.className = "feature-navigation";
|
||
link.textContent = label;
|
||
link.dataset.featureLayer = layerName;
|
||
link.dataset.featureUuid = featureUuid || "";
|
||
return link;
|
||
}
|
||
|
||
async function handleFeatureModalNavigation(event) {
|
||
const link = event.target.closest("[data-feature-layer][data-feature-uuid]");
|
||
if (!link.dataset.featureUuid) {
|
||
return;
|
||
}
|
||
|
||
link.disabled = true;
|
||
try {
|
||
const idProperty = unqualifiedLayerName(link.dataset.featureLayer) === "parcelas"
|
||
? "parcela_uuid"
|
||
: "predio_uuid";
|
||
const [feature] = await getFeaturesByProperty(link.dataset.featureLayer, idProperty, link.dataset.featureUuid);
|
||
if (feature) {
|
||
openFeatureModal(feature);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
link.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function getFeaturesByProperty(layerName, property, value) {
|
||
if (!value) {
|
||
return [];
|
||
}
|
||
|
||
const params = new URLSearchParams({
|
||
service: "WFS",
|
||
version: "2.0.0",
|
||
request: "GetFeature",
|
||
typeNames: layerName,
|
||
outputFormat: "application/json",
|
||
srsName: "EPSG:3857",
|
||
CQL_FILTER: `${property} = '${escapeCqlLiteral(value)}'`,
|
||
});
|
||
const response = await fetch(`${API_WMS}?${params}`);
|
||
if (!response.ok) {
|
||
throw new Error(`Feature request failed with status ${response.status}.`);
|
||
}
|
||
|
||
return (await response.json()).features || [];
|
||
}
|
||
|
||
function isDwellingParcel(feature) {
|
||
const parcelUuid = feature.properties?.parcela_uuid;
|
||
return dwellingParcelSource.getFeatures().some(
|
||
(dwellingParcel) => dwellingParcel.get("parcela_uuid") === parcelUuid,
|
||
);
|
||
}
|
||
|
||
function unqualifiedLayerName(name) {
|
||
return name.split(":").at(-1);
|
||
}
|
||
|
||
function closeFeatureModal() {
|
||
featureModalRequestId += 1;
|
||
featureModalRestoreRequestId += 1;
|
||
sharedMapState.feature = null;
|
||
resetFeatureModalPosition();
|
||
els.featureModal.hidden = true;
|
||
selectedFeatureSource.clear();
|
||
syncSharedMapStateToUrl();
|
||
}
|
||
|
||
function startFeatureModalDrag(event) {
|
||
if (event.button !== 0 || event.target.closest("button, a, input, select, textarea, label")) {
|
||
return;
|
||
}
|
||
|
||
const dialogRect = els.featureModalDialog.getBoundingClientRect();
|
||
els.featureModalDialog.style.position = "fixed";
|
||
els.featureModalDialog.style.left = `${dialogRect.left}px`;
|
||
els.featureModalDialog.style.top = `${dialogRect.top}px`;
|
||
els.featureModalDialog.style.margin = "0";
|
||
els.featureModalDialog.classList.add("is-dragging");
|
||
modalDrag = {
|
||
pointerId: event.pointerId,
|
||
offsetX: event.clientX - dialogRect.left,
|
||
offsetY: event.clientY - dialogRect.top,
|
||
};
|
||
els.featureModalDialog.setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function dragFeatureModal(event) {
|
||
if (!modalDrag || event.pointerId !== modalDrag.pointerId) {
|
||
return;
|
||
}
|
||
|
||
const dialogRect = els.featureModalDialog.getBoundingClientRect();
|
||
const maxLeft = Math.max(MODAL_VIEWPORT_PADDING, window.innerWidth - dialogRect.width - MODAL_VIEWPORT_PADDING);
|
||
const maxTop = Math.max(MODAL_VIEWPORT_PADDING, window.innerHeight - dialogRect.height - MODAL_VIEWPORT_PADDING);
|
||
const left = Math.min(maxLeft, Math.max(MODAL_VIEWPORT_PADDING, event.clientX - modalDrag.offsetX));
|
||
const top = Math.min(maxTop, Math.max(MODAL_VIEWPORT_PADDING, event.clientY - modalDrag.offsetY));
|
||
|
||
els.featureModalDialog.style.left = `${left}px`;
|
||
els.featureModalDialog.style.top = `${top}px`;
|
||
}
|
||
|
||
function stopFeatureModalDrag(event) {
|
||
if (!modalDrag || event.pointerId !== modalDrag.pointerId) {
|
||
return;
|
||
}
|
||
|
||
els.featureModalDialog.releasePointerCapture(event.pointerId);
|
||
els.featureModalDialog.classList.remove("is-dragging");
|
||
modalDrag = undefined;
|
||
}
|
||
|
||
function keepFeatureModalInViewport() {
|
||
if (els.featureModal.hidden || !els.featureModalDialog.style.left) {
|
||
return;
|
||
}
|
||
|
||
const dialogRect = els.featureModalDialog.getBoundingClientRect();
|
||
const maxLeft = Math.max(MODAL_VIEWPORT_PADDING, window.innerWidth - dialogRect.width - MODAL_VIEWPORT_PADDING);
|
||
const maxTop = Math.max(MODAL_VIEWPORT_PADDING, window.innerHeight - dialogRect.height - MODAL_VIEWPORT_PADDING);
|
||
els.featureModalDialog.style.left = `${Math.min(maxLeft, Math.max(MODAL_VIEWPORT_PADDING, dialogRect.left))}px`;
|
||
els.featureModalDialog.style.top = `${Math.min(maxTop, Math.max(MODAL_VIEWPORT_PADDING, dialogRect.top))}px`;
|
||
}
|
||
|
||
function resetFeatureModalPosition() {
|
||
modalDrag = undefined;
|
||
els.featureModalDialog.classList.remove("is-dragging");
|
||
els.featureModalDialog.style.removeProperty("position");
|
||
els.featureModalDialog.style.removeProperty("left");
|
||
els.featureModalDialog.style.removeProperty("top");
|
||
els.featureModalDialog.style.removeProperty("margin");
|
||
}
|
||
|
||
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(i18n.locale, { 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(i18n.locale, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
|
||
: t("areaUnavailable");
|
||
}
|
||
|
||
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(t("selectLayerToZoom"), "error");
|
||
showMessage(t("selectLayerToZoom"), { type: "warning" });
|
||
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");
|
||
syncSharedMapStateToUrl();
|
||
}
|
||
|
||
function readSharedMapState(url = new URL(window.location.href)) {
|
||
const params = url.searchParams;
|
||
const viewParamNames = [
|
||
MAP_URL_PARAMS.latitude,
|
||
MAP_URL_PARAMS.longitude,
|
||
MAP_URL_PARAMS.zoom,
|
||
MAP_URL_PARAMS.rotation,
|
||
];
|
||
const hasViewParams = viewParamNames.some((name) => params.has(name));
|
||
const view = hasViewParams ? readSharedView(params) : null;
|
||
const requestedBackground = params.get(MAP_URL_PARAMS.background);
|
||
|
||
return {
|
||
view,
|
||
background: BACKGROUND_VALUES.has(requestedBackground) ? requestedBackground : null,
|
||
// An empty value deliberately represents no visible published layers.
|
||
layers: params.has(MAP_URL_PARAMS.layers)
|
||
? params
|
||
.get(MAP_URL_PARAMS.layers)
|
||
.split(",")
|
||
.map((name) => name.trim())
|
||
.filter(Boolean)
|
||
: null,
|
||
feature: readSharedFeature(params),
|
||
};
|
||
}
|
||
|
||
function readSharedView(params) {
|
||
const values = [
|
||
params.get(MAP_URL_PARAMS.latitude),
|
||
params.get(MAP_URL_PARAMS.longitude),
|
||
params.get(MAP_URL_PARAMS.zoom),
|
||
params.get(MAP_URL_PARAMS.rotation),
|
||
];
|
||
if (values.some((value) => value === null || value.trim() === "")) {
|
||
return null;
|
||
}
|
||
|
||
const [latitude, longitude, zoom, rotationDegrees] = values.map(Number);
|
||
|
||
if (
|
||
!Number.isFinite(latitude) ||
|
||
!Number.isFinite(longitude) ||
|
||
!Number.isFinite(zoom) ||
|
||
!Number.isFinite(rotationDegrees) ||
|
||
latitude < -90 ||
|
||
latitude > 90 ||
|
||
longitude < -180 ||
|
||
longitude > 180 ||
|
||
zoom < 0 ||
|
||
zoom > 28 ||
|
||
rotationDegrees < -360 ||
|
||
rotationDegrees > 360
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
center: [longitude, latitude],
|
||
zoom,
|
||
rotation: (rotationDegrees * Math.PI) / 180,
|
||
};
|
||
}
|
||
|
||
function selectedLayersFromSharedMapState(state) {
|
||
const visibleLayers = availableLayers.filter((layer) => !isInternalLayer(layer.name));
|
||
const defaultLayers = visibleLayers.map((layer) => layer.name);
|
||
|
||
if (state.layers === null) {
|
||
return defaultLayers;
|
||
}
|
||
|
||
const requestedLayers = new Set(state.layers);
|
||
const matchedLayers = visibleLayers
|
||
.map((layer) => layer.name)
|
||
.filter((name) => requestedLayers.has(name));
|
||
|
||
// An explicit empty list is valid. A non-empty list with no known layers is
|
||
// malformed or from another deployment, so retain the useful default.
|
||
return state.layers.length === 0 || matchedLayers.length ? matchedLayers : defaultLayers;
|
||
}
|
||
|
||
function readSharedFeature(params) {
|
||
const layerName = params.get(MAP_URL_PARAMS.featureLayer);
|
||
const featureId = params.get(MAP_URL_PARAMS.featureId);
|
||
if (!layerName || !featureId) {
|
||
return null;
|
||
}
|
||
|
||
if (![PARCEL_LAYER_NAME, BUILDING_LAYER_NAME].includes(layerName)) {
|
||
return null;
|
||
}
|
||
|
||
return { layerName, featureId };
|
||
}
|
||
|
||
function featureReference(feature, isParcel) {
|
||
const featureId = isParcel ? feature.properties?.parcela_uuid : feature.properties?.predio_uuid;
|
||
if (typeof featureId !== "string" || !featureId.trim()) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
layerName: isParcel ? PARCEL_LAYER_NAME : BUILDING_LAYER_NAME,
|
||
featureId,
|
||
};
|
||
}
|
||
|
||
async function restoreSharedFeatureModal() {
|
||
const feature = sharedMapState.feature;
|
||
const restoreRequestId = ++featureModalRestoreRequestId;
|
||
|
||
if (!feature) {
|
||
if (!els.featureModal.hidden) {
|
||
closeFeatureModal();
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const idProperty = feature.layerName === PARCEL_LAYER_NAME ? "parcela_uuid" : "predio_uuid";
|
||
const [restoredFeature] = await getFeaturesByProperty(feature.layerName, idProperty, feature.featureId);
|
||
if (restoreRequestId === featureModalRestoreRequestId && restoredFeature) {
|
||
openFeatureModal(restoredFeature);
|
||
}
|
||
} catch (error) {
|
||
// Keep the map usable if a linked feature no longer exists or GeoServer is
|
||
// temporarily unavailable; the map view itself still restores normally.
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
function restoreSharedMapState() {
|
||
sharedMapState = readSharedMapState();
|
||
applyingSharedMapState = true;
|
||
|
||
try {
|
||
const viewState = sharedMapState.view;
|
||
const view = map.getView();
|
||
if (viewState) {
|
||
view.setCenter(ol.proj.fromLonLat(viewState.center));
|
||
view.setZoom(viewState.zoom);
|
||
view.setRotation(viewState.rotation);
|
||
} else {
|
||
// A history entry without a valid map state behaves like a fresh visit.
|
||
view.setCenter(ol.proj.fromLonLat(DEFAULT_CENTER));
|
||
view.setZoom(DEFAULT_ZOOM);
|
||
view.setRotation(DEFAULT_ROTATION);
|
||
}
|
||
|
||
els.backgroundSelect.value = sharedMapState.background || "soft";
|
||
syncBackground();
|
||
|
||
if (availableLayers.length) {
|
||
selectedLayers = selectedLayersFromSharedMapState(sharedMapState);
|
||
renderLayerList();
|
||
syncSelectedLayers();
|
||
}
|
||
restoreSharedFeatureModal();
|
||
} finally {
|
||
applyingSharedMapState = false;
|
||
}
|
||
}
|
||
|
||
function syncSharedMapStateToUrl() {
|
||
if (!urlStateReady || applyingSharedMapState) {
|
||
return;
|
||
}
|
||
|
||
const view = map.getView();
|
||
const center = view.getCenter();
|
||
const zoom = view.getZoom();
|
||
const rotation = view.getRotation();
|
||
if (!center || !Number.isFinite(zoom) || !Number.isFinite(rotation)) {
|
||
return;
|
||
}
|
||
|
||
const [longitude, latitude] = ol.proj.toLonLat(center);
|
||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||
return;
|
||
}
|
||
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set(MAP_URL_PARAMS.latitude, formatUrlNumber(latitude, 6));
|
||
url.searchParams.set(MAP_URL_PARAMS.longitude, formatUrlNumber(longitude, 6));
|
||
url.searchParams.set(MAP_URL_PARAMS.zoom, formatUrlNumber(zoom, 3));
|
||
url.searchParams.set(
|
||
MAP_URL_PARAMS.rotation,
|
||
formatUrlNumber(normalizeRotationDegrees((rotation * 180) / Math.PI), 2),
|
||
);
|
||
url.searchParams.set(MAP_URL_PARAMS.background, els.backgroundSelect.value);
|
||
url.searchParams.set(MAP_URL_PARAMS.layers, selectedLayers.join(","));
|
||
if (sharedMapState.feature) {
|
||
url.searchParams.set(MAP_URL_PARAMS.featureLayer, sharedMapState.feature.layerName);
|
||
url.searchParams.set(MAP_URL_PARAMS.featureId, sharedMapState.feature.featureId);
|
||
} else {
|
||
url.searchParams.delete(MAP_URL_PARAMS.featureLayer);
|
||
url.searchParams.delete(MAP_URL_PARAMS.featureId);
|
||
}
|
||
|
||
// Replacing, rather than pushing, avoids one browser-history entry for every
|
||
// pan or zoom while still keeping normal back/forward navigation intact.
|
||
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
||
}
|
||
|
||
function normalizeRotationDegrees(rotation) {
|
||
return ((rotation + 180) % 360 + 360) % 360 - 180;
|
||
}
|
||
|
||
function formatUrlNumber(value, fractionDigits) {
|
||
return Number(value.toFixed(fractionDigits)).toString();
|
||
}
|