Files

1622 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}