diff --git a/README.md b/README.md index e43f545..a524b92 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.js b/app.js index d92b389..ba8fbf6 100644 --- a/app.js +++ b/app.js @@ -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(); }