feat: support shareable map views

This commit is contained in:
2026-07-24 09:43:03 +01:00
parent ae95447dc8
commit 8d4f003b43
2 changed files with 274 additions and 6 deletions
+266 -6
View File
@@ -1,4 +1,6 @@
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";
@@ -9,6 +11,17 @@ const DEVICE_ORIENTATION_EVENTS = ["deviceorientationabsolute", "deviceorientati
const COMPASS_HEADING_DEADBAND = 2;
const SMALL_SCREEN_MEDIA_QUERY = "(max-width: 920px)";
const MODAL_VIEWPORT_PADDING = 24;
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"),
@@ -28,6 +41,13 @@ const els = {
featureModalContent: document.getElementById("feature-modal-content"),
};
// 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",
@@ -128,6 +148,7 @@ let featureHoverRequest;
let featureHoverRequestId = 0;
let featureHoverTimer;
let featureModalRequestId = 0;
let featureModalRestoreRequestId = 0;
const map = new ol.Map({
target: "map",
@@ -149,8 +170,9 @@ const map = new ol.Map({
selectedFeatureLayer,
],
view: new ol.View({
center: ol.proj.fromLonLat(DEFAULT_CENTER),
zoom: 7,
center: ol.proj.fromLonLat(sharedMapState.view?.center || DEFAULT_CENTER),
zoom: sharedMapState.view?.zoom ?? DEFAULT_ZOOM,
rotation: sharedMapState.view?.rotation ?? DEFAULT_ROTATION,
}),
});
map.addInteraction(areaZoomInteraction);
@@ -191,8 +213,11 @@ document.addEventListener("keydown", (event) => {
}
});
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", () => {
@@ -206,6 +231,11 @@ areaZoomInteraction.on("boxcancel", () => {
});
});
if (sharedMapState.background) {
els.backgroundSelect.value = sharedMapState.background;
syncBackground();
}
loadLayers();
function setSidebarCollapsed(collapsed, { focus = true } = {}) {
@@ -565,14 +595,17 @@ async function loadLayers() {
try {
const capabilities = await loadCapabilities();
availableLayers = extractLayers(capabilities);
selectedLayers = availableLayers
.filter((layer) => !isInternalLayer(layer.name))
.map((layer) => layer.name);
selectedLayers = selectedLayersFromSharedMapState(sharedMapState);
renderLayerList();
syncSelectedLayers();
await loadDwellingParcels();
fitToLayerExtent(availableLayers[0]);
// 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(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success");
} catch (error) {
@@ -584,6 +617,12 @@ async function loadLayers() {
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();
}
}
@@ -678,6 +717,7 @@ function syncSelectedLayers() {
}),
);
clearFeatureCursor();
syncSharedMapStateToUrl();
}
function isInternalLayer(name) {
@@ -803,11 +843,14 @@ async function getFeatureAtCoordinate(coordinate, signal) {
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) {
@@ -984,9 +1027,12 @@ function unqualifiedLayerName(name) {
function closeFeatureModal() {
featureModalRequestId += 1;
featureModalRestoreRequestId += 1;
sharedMapState.feature = null;
resetFeatureModalPosition();
els.featureModal.hidden = true;
selectedFeatureSource.clear();
syncSharedMapStateToUrl();
}
function startFeatureModalDrag(event) {
@@ -1153,4 +1199,218 @@ function syncBackground() {
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();
}