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
+8
View File
@@ -62,3 +62,11 @@ ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms,https://examp
- The proxy only allows configured WMS endpoints. By default it allows the XPro GeoServer endpoint.
- It fetches WMS capabilities with backend HTTP Basic Auth and loads all discovered layers on top of OpenStreetMap.
- The currently published layers discovered during setup were `parcelas` and `predios`.
## Sharing a map view
The URL updates as the map is moved, zoomed, rotated, or its visible layers or
base map are changed. An open parcel or building information dialog is included
too. Copy the URL to share that exact state; opening it restores the same map
view and selected feature. Invalid or incomplete map parameters fall back to
the normal default view.
+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();
}