feat: support shareable map views
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user