From 5ad132194840e8370053226da0dd18b24089e13a Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 4 Mar 2026 13:19:07 +0000 Subject: [PATCH 1/2] Visible geometry option added to addPanel options --- demo/js/index.js | 68 +--- demo/js/planning.js | 9 +- plugins/interact/src/events.js | 6 +- providers/beta/esri/src/esriProvider.js | 20 +- providers/beta/esri/src/utils/coords.js | 1 + providers/beta/esri/src/utils/spatial.js | 48 ++- providers/beta/esri/src/utils/spatial.test.js | 55 +++ providers/maplibre/src/maplibreProvider.js | 13 +- .../maplibre/src/maplibreProvider.test.js | 15 +- providers/maplibre/src/utils/spatial.js | 40 +++ providers/maplibre/src/utils/spatial.test.js | 35 ++ src/App/components/Viewport/MapController.jsx | 4 + src/App/hooks/useVisibleGeometry.js | 100 ++++++ src/App/hooks/useVisibleGeometry.test.js | 331 ++++++++++++++++++ src/App/store/appDispatchMiddleware.js | 19 + src/App/store/appDispatchMiddleware.test.js | 56 +++ src/InteractiveMap/InteractiveMap.js | 6 +- src/types.js | 9 + 18 files changed, 757 insertions(+), 78 deletions(-) create mode 100644 providers/beta/esri/src/utils/spatial.test.js create mode 100644 src/App/hooks/useVisibleGeometry.js create mode 100644 src/App/hooks/useVisibleGeometry.test.js diff --git a/demo/js/index.js b/demo/js/index.js index 07dfcb8f..22431cf1 100755 --- a/demo/js/index.js +++ b/demo/js/index.js @@ -126,7 +126,7 @@ const interactiveMap = new InteractiveMap('map', { transformRequest: transformTileRequest, enableZoomControls: true, readMapText: true, - // enableFullscreen: true, + enableFullscreen: true, // hasExitButton: true, // markers: [{ // id: 'location', @@ -245,72 +245,6 @@ interactiveMap.on('datasets:ready', function () { let selectedFeatureIds = [] interactiveMap.on('draw:ready', function () { - // interactiveMap.addButton('drawPolygon', { - // label: 'Draw polygon', - // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, - // iconSvgContent: '', - // isPressed: false, - // mobile: { slot: 'right-top' }, - // tablet: { slot: 'right-top' }, - // desktop: { slot: 'right-top' }, - // onClick: function (e) { - // e.target.setAttribute('aria-pressed', true) - // drawPlugin.newPolygon(crypto.randomUUID(), { - // stroke: '#e6c700', - // fill: 'rgba(255, 221, 0, 0.1)' - // }) - // } - // }) - // interactiveMap.addButton('drawLine', { - // label: 'Draw line', - // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, - // iconSvgContent: '', - // isPressed: false, - // mobile: { slot: 'right-top' }, - // tablet: { slot: 'right-top' }, - // desktop: { slot: 'right-top' }, - // onClick: function (e) { - // e.target.setAttribute('aria-pressed', true) - // drawPlugin.newLine(crypto.randomUUID(), { - // stroke: { outdoor: '#99704a', dark: '#ffffff' } - // }) - // } - // }) - // interactiveMap.addButton('editFeature', { - // label: 'Edit feature', - // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, - // iconSvgContent: '', - // isDisabled: true, - // mobile: { slot: 'right-top' }, - // tablet: { slot: 'right-top' }, - // desktop: { slot: 'right-top' }, - // onClick: function (e) { - // if (e.target.getAttribute('aria-disabled') === 'true') { - // return - // } - // interactPlugin.disable() - // drawPlugin.editFeature(selectedFeatureId) - // } - // }) - // interactiveMap.addButton('deleteFeature', { - // label: 'Delete feature', - // group: { name: 'drawing-tools', label: 'Drawing tools', order: 2 }, - // iconSvgContent: '', - // isDisabled: true, - // mobile: { slot: 'right-top' }, - // tablet: { slot: 'right-top' }, - // desktop: { slot: 'right-top' }, - // onClick: function (e) { - // if (e.target.getAttribute('aria-disabled') === 'true') { - // return - // } - // drawPlugin.deleteFeature(selectedFeatureId) - // interactiveMap.toggleButtonState('drawPolygon', 'disabled', false) - // interactiveMap.toggleButtonState('drawLine', 'disabled', false) - // interactiveMap.toggleButtonState('editFeature', 'disabled', true) - // interactiveMap.toggleButtonState('deleteFeature', 'disabled', true) - // } - // }) drawPlugin.addFeature({ id: 'test1234', type: 'Feature', diff --git a/demo/js/planning.js b/demo/js/planning.js index a65f29ba..b58c3373 100755 --- a/demo/js/planning.js +++ b/demo/js/planning.js @@ -153,13 +153,20 @@ interactiveMap.on('map:stylechange', function (e) { updateKeyColours(e.mapStyleId) }) +interactiveMap.on('app:panelopened', (e) => { + // console.log('app:panelopened', e) +}) interactiveMap.on('map:exit', function (e) { drawOptions = ['shape', 'square'] }) interactiveMap.on('interact:markerchange', function (e) { - console.log(e) + interactiveMap.addPanel('info', { + label: 'Info', + html: '

Some info

', + visibleGeometry: {type: 'Feature', geometry: {type: 'Point', coordinates: e.coords}} + }) }) interactiveMap.on('draw:ready', function () { diff --git a/plugins/interact/src/events.js b/plugins/interact/src/events.js index 36c3f49c..599d18c1 100755 --- a/plugins/interact/src/events.js +++ b/plugins/interact/src/events.js @@ -39,7 +39,11 @@ export function attachEvents ({ // (e.g. finishing a draw gesture) fires before this handler is live. let clickReady = false const clickReadyTimer = setTimeout(() => { clickReady = true }, 0) - const handleMapClick = (e) => { if (clickReady) { handleInteraction(e) } } + const handleMapClick = (e) => { + if (clickReady) { + handleInteraction(e) + } + } const handleSelectAtTarget = () => handleInteraction(mapState.crossHair.getDetail()) const handleSelectDone = () => { diff --git a/providers/beta/esri/src/esriProvider.js b/providers/beta/esri/src/esriProvider.js index 2a54289b..2e6a8803 100644 --- a/providers/beta/esri/src/esriProvider.js +++ b/providers/beta/esri/src/esriProvider.js @@ -4,10 +4,11 @@ import esriConfig from '@arcgis/core/config.js' import EsriMap from '@arcgis/core/Map.js' import MapView from '@arcgis/core/views/MapView.js' import VectorTileLayer from '@arcgis/core/layers/VectorTileLayer.js' +import Point from '@arcgis/core/geometry/Point.js' import { defaults, supportedShortcuts } from './defaults.js' import { attachAppEvents } from './appEvents.js' import { attachMapEvents } from './mapEvents.js' -import { getAreaDimensions, getCardinalMove, getPaddedExtent } from './utils/spatial.js' +import { getAreaDimensions, getCardinalMove, getPaddedExtent, isGeometryObscured } from './utils/spatial.js' import { queryVectorTileFeatures } from './utils/query.js' import { getExtentFromFlatCoords, getPointFromFlatCoords, getBboxFromGeoJSON } from './utils/coords.js' import { cleanDOM } from './utils/esriFixes.js' @@ -118,9 +119,11 @@ export default class EsriProvider { // Side-effects // ========================== - setView ({ center, zoom }) { + setView({ center, zoom }) { this.view.animation?.destroy() - this.view.goTo({ center, zoom, duration: defaults.animationDuration }) + const point = center ? new Point({ x: center[0], y: center[1], spatialReference: { wkid: 27700 }}) : this.view.center + const target = { center: point, zoom: zoom ?? this.view.zoom } + this.view.goTo({ ...target, duration: defaults.animationDuration }) } zoomIn (zoomDelta) { @@ -203,4 +206,15 @@ export default class EsriProvider { const mapPoint = this.view.toMap(point) return [mapPoint.x, mapPoint.y] } + + /** + * Returns true if the geometry's screen bounding box overlaps the given panel rectangle. + * + * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry. + * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates). + * @returns {boolean} + */ + isGeometryObscured (geojson, panelRect) { + return isGeometryObscured(geojson, panelRect, this.view) + } } diff --git a/providers/beta/esri/src/utils/coords.js b/providers/beta/esri/src/utils/coords.js index b77c613e..772539bb 100644 --- a/providers/beta/esri/src/utils/coords.js +++ b/providers/beta/esri/src/utils/coords.js @@ -33,6 +33,7 @@ const collectCoords = (obj, acc) => { obj.geometries.forEach(g => collectCoords(g, acc)) } else { const flatten = (coords) => { + if (!Array.isArray(coords)) { return } if (typeof coords[0] === 'number') acc.push(coords) else coords.forEach(flatten) } diff --git a/providers/beta/esri/src/utils/spatial.js b/providers/beta/esri/src/utils/spatial.js index 2dd531e1..c2155476 100644 --- a/providers/beta/esri/src/utils/spatial.js +++ b/providers/beta/esri/src/utils/spatial.js @@ -1,4 +1,6 @@ import Extent from '@arcgis/core/geometry/Extent.js' +import Point from '@arcgis/core/geometry/Point.js' +import { getBboxFromGeoJSON } from './coords.js' // ----------------------------------------------------------------------------- // Internal (not exported) @@ -124,8 +126,52 @@ const getPaddedExtent = (view, padding = DEFAULT_PADDING) => { } +/** + * Returns true if the geometry's screen bounding box overlaps the given panel rectangle. + * Used to decide whether to pan/zoom when a panel opens over a visibleGeometry target. + * + * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry + * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates) + * @param {import('@arcgis/core/views/MapView.js').default} view - ESRI MapView instance + * @returns {boolean} + */ +const isGeometryObscured = (geojson, panelRect, view) => { + if (!view?.container) { + return false + } + + const containerRect = view.container.getBoundingClientRect() + const extent = getBboxFromGeoJSON(geojson) + + const corners = [ + view.toScreen(new Point({ x: extent.xmin, y: extent.ymin, spatialReference: extent.spatialReference })), + view.toScreen(new Point({ x: extent.xmin, y: extent.ymax, spatialReference: extent.spatialReference })), + view.toScreen(new Point({ x: extent.xmax, y: extent.ymin, spatialReference: extent.spatialReference })), + view.toScreen(new Point({ x: extent.xmax, y: extent.ymax, spatialReference: extent.spatialReference })) + ] + + const screenMinX = Math.min(...corners.map(c => c.x)) + const screenMaxX = Math.max(...corners.map(c => c.x)) + const screenMinY = Math.min(...corners.map(c => c.y)) + const screenMaxY = Math.max(...corners.map(c => c.y)) + + // Convert panelRect from viewport coords to view-container-relative coords + const panelLeft = panelRect.left - containerRect.left + const panelTop = panelRect.top - containerRect.top + const panelRight = panelRect.right - containerRect.left + const panelBottom = panelRect.bottom - containerRect.top + + return ( + screenMinX < panelRight && + screenMaxX > panelLeft && + screenMinY < panelBottom && + screenMaxY > panelTop + ) +} + export { getAreaDimensions, getCardinalMove, - getPaddedExtent + getPaddedExtent, + isGeometryObscured } diff --git a/providers/beta/esri/src/utils/spatial.test.js b/providers/beta/esri/src/utils/spatial.test.js new file mode 100644 index 00000000..8adcd3c5 --- /dev/null +++ b/providers/beta/esri/src/utils/spatial.test.js @@ -0,0 +1,55 @@ +import { isGeometryObscured } from './spatial.js' + +jest.mock('@arcgis/core/geometry/Extent.js', () => + jest.fn().mockImplementation((opts) => ({ ...opts, type: 'extent' })) +) + +jest.mock('@arcgis/core/geometry/Point.js', () => + jest.fn().mockImplementation((opts) => ({ ...opts, type: 'point' })) +) + +jest.mock('./coords.js', () => ({ + getBboxFromGeoJSON: jest.fn(() => ({ + xmin: 100, ymin: 200, xmax: 500, ymax: 600, + spatialReference: { wkid: 27700 }, + type: 'extent' + })) +})) + +describe('isGeometryObscured', () => { + const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [300, 400] }, properties: {} } + + // Container sits at viewport origin so container-relative coords equal viewport coords + const makeView = (toScreenFn) => ({ + container: { + getBoundingClientRect: jest.fn(() => ({ left: 0, top: 0, right: 1000, bottom: 800 })) + }, + toScreen: jest.fn(toScreenFn) + }) + + // Panel occupies the right 400px of the viewport + const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 } + + test('returns false when view has no container', () => { + expect(isGeometryObscured(geojson, panelRect, null)).toBe(false) + expect(isGeometryObscured(geojson, panelRect, {})).toBe(false) + }) + + test('returns true when geometry screen bbox overlaps the panel rect', () => { + // Corners project into the panel (x: 650 is between panelLeft 600 and panelRight 1000) + const view = makeView(() => ({ x: 650, y: 400 })) + expect(isGeometryObscured(geojson, panelRect, view)).toBe(true) + }) + + test('returns false when geometry screen bbox does not overlap the panel rect', () => { + // Corners project to x: 300, entirely left of panelLeft (600) + const view = makeView(() => ({ x: 300, y: 400 })) + expect(isGeometryObscured(geojson, panelRect, view)).toBe(false) + }) + + test('projects all four bbox corners', () => { + const view = makeView(() => ({ x: 300, y: 400 })) + isGeometryObscured(geojson, panelRect, view) + expect(view.toScreen).toHaveBeenCalledTimes(4) + }) +}) diff --git a/providers/maplibre/src/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js index 8eda891a..3861ba26 100755 --- a/providers/maplibre/src/maplibreProvider.js +++ b/providers/maplibre/src/maplibreProvider.js @@ -7,7 +7,7 @@ import { DEFAULTS, supportedShortcuts } from './defaults.js' import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js' import { attachMapEvents } from './mapEvents.js' import { attachAppEvents } from './appEvents.js' -import { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, getResolution, getPaddedBounds } from './utils/spatial.js' +import { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, isGeometryObscured, getResolution, getPaddedBounds } from './utils/spatial.js' import { createMapLabelNavigator } from './utils/labels.js' import { updateHighlightedFeatures } from './utils/highlightFeatures.js' import { queryFeatures } from './utils/queryFeatures.js' @@ -337,4 +337,15 @@ export default class MapLibreProvider { const { lng, lat } = this.map.unproject([point.x, point.y]) return [lng, lat] } + + /** + * Returns true if the geometry's screen bounding box overlaps the given panel rectangle. + * + * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry. + * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates). + * @returns {boolean} + */ + isGeometryObscured (geojson, panelRect) { + return isGeometryObscured(geojson, panelRect, this.map) + } } diff --git a/providers/maplibre/src/maplibreProvider.test.js b/providers/maplibre/src/maplibreProvider.test.js index 7b6bbf2b..04e9e32b 100644 --- a/providers/maplibre/src/maplibreProvider.test.js +++ b/providers/maplibre/src/maplibreProvider.test.js @@ -4,7 +4,7 @@ import { attachAppEvents } from './appEvents.js' import { createMapLabelNavigator } from './utils/labels.js' import { updateHighlightedFeatures } from './utils/highlightFeatures.js' import { queryFeatures } from './utils/queryFeatures.js' -import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js' +import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js' jest.mock('./defaults.js', () => ({ DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 }, @@ -20,6 +20,7 @@ jest.mock('./utils/spatial.js', () => ({ getAreaDimensions: jest.fn(() => '400m by 750m'), getCardinalMove: jest.fn(() => 'north'), getBboxFromGeoJSON: jest.fn(() => [-1, 50, 1, 52]), + isGeometryObscured: jest.fn(() => true), getResolution: jest.fn(() => 10), getPaddedBounds: jest.fn(() => [[0, 0], [1, 1]]) })) @@ -170,6 +171,18 @@ describe('MapLibreProvider', () => { expect(map.fitBounds).toHaveBeenCalledWith([-1, 50, 1, 52], { duration: 400 }) }) + test('isGeometryObscured delegates to spatial utility with map instance', async () => { + const p = makeProvider() + await doInitMap(p) + const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} } + const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 } + + const result = p.isGeometryObscured(geojson, panelRect) + + expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, map) + expect(result).toBe(true) + }) + test('getCenter, getZoom, getBounds return formatted values', async () => { const p = makeProvider() await doInitMap(p) diff --git a/providers/maplibre/src/utils/spatial.js b/providers/maplibre/src/utils/spatial.js index 01efd737..83fa01fb 100755 --- a/providers/maplibre/src/utils/spatial.js +++ b/providers/maplibre/src/utils/spatial.js @@ -196,10 +196,50 @@ const getPaddedBounds = (LngLatBounds, map) => { */ const getBboxFromGeoJSON = (geojson) => turfBbox(geojson) +/** + * Returns true if the geometry's screen bounding box overlaps the given panel rectangle. + * Used to decide whether to pan/zoom when a panel opens over a visibleGeometry target. + * + * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry + * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates) + * @param {object} map - MapLibre map instance + * @returns {boolean} + */ +const isGeometryObscured = (geojson, panelRect, map) => { + const containerRect = map.getContainer().getBoundingClientRect() + const [west, south, east, north] = getBboxFromGeoJSON(geojson) + + const corners = [ + map.project([west, south]), + map.project([west, north]), + map.project([east, south]), + map.project([east, north]) + ] + + const screenMinX = Math.min(...corners.map(c => c.x)) + const screenMaxX = Math.max(...corners.map(c => c.x)) + const screenMinY = Math.min(...corners.map(c => c.y)) + const screenMaxY = Math.max(...corners.map(c => c.y)) + + // Convert panelRect from viewport coords to map-container-relative coords + const panelLeft = panelRect.left - containerRect.left + const panelTop = panelRect.top - containerRect.top + const panelRight = panelRect.right - containerRect.left + const panelBottom = panelRect.bottom - containerRect.top + + return ( + screenMinX < panelRight && + screenMaxX > panelLeft && + screenMinY < panelBottom && + screenMaxY > panelTop + ) +} + export { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, + isGeometryObscured, spatialNavigate, getResolution, getPaddedBounds, diff --git a/providers/maplibre/src/utils/spatial.test.js b/providers/maplibre/src/utils/spatial.test.js index c7d14be9..02461d1a 100644 --- a/providers/maplibre/src/utils/spatial.test.js +++ b/providers/maplibre/src/utils/spatial.test.js @@ -105,4 +105,39 @@ describe('spatial utils', () => { expect(turfBbox).toHaveBeenCalledWith(feature) expect(result).toEqual([-1, 50, 1, 52]) }) + + describe('isGeometryObscured', () => { + const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] }, properties: {} } + // getBboxFromGeoJSON is mocked to always return [-1, 50, 1, 52] + + // Container sits at viewport origin so container-relative coords equal viewport coords + const makeMap = (projectFn) => ({ + getContainer: jest.fn(() => ({ + getBoundingClientRect: jest.fn(() => ({ left: 0, top: 0, right: 1000, bottom: 800 })) + })), + project: jest.fn(projectFn) + }) + + // Panel occupies the right 400px of the viewport + const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 } + + test('returns true when geometry screen bbox overlaps the panel rect', () => { + // Corners project into the panel (x: 650 is between panelLeft 600 and panelRight 1000) + const map = makeMap(() => ({ x: 650, y: 400 })) + expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(true) + }) + + test('returns false when geometry screen bbox does not overlap the panel rect', () => { + // Corners project to x: 300, entirely left of panelLeft (600) + const map = makeMap(() => ({ x: 300, y: 400 })) + expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(false) + }) + + test('projects all four bbox corners', () => { + const map = makeMap(() => ({ x: 300, y: 400 })) + spatial.isGeometryObscured(geojson, panelRect, map) + // bbox is [-1, 50, 1, 52]: corners are [-1,50], [-1,52], [1,50], [1,52] + expect(map.project).toHaveBeenCalledTimes(4) + }) + }) }) \ No newline at end of file diff --git a/src/App/components/Viewport/MapController.jsx b/src/App/components/Viewport/MapController.jsx index 92b4e596..a0c74b05 100755 --- a/src/App/components/Viewport/MapController.jsx +++ b/src/App/components/Viewport/MapController.jsx @@ -6,6 +6,7 @@ import { useMapStateSync } from '../../hooks/useMapStateSync' import { useMapURLSync } from '../../hooks/useMapURLSync' import { useMapAnnouncements } from '../../hooks/useMapAnnouncements' import { useMapProviderOverrides } from '../../hooks/useMapProviderOverrides' +import { useVisibleGeometry } from '../../hooks/useVisibleGeometry' import { getInitialMapState } from '../../../utils/mapStateSync' import { scaleFactor } from '../../../config/appConfig' import { scalePoints } from '../../../utils/scalePoints.js' @@ -58,6 +59,9 @@ export const MapController = ({ mapContainerRef }) => { // Override mapProvider functions useMapProviderOverrides() + // Pan/zoom to keep visibleGeometry visible when panels open + useVisibleGeometry() + // Update padding when breakpoint or mapSize change useEffect(() => { if (!isMapReady || !syncMapPadding) { diff --git a/src/App/hooks/useVisibleGeometry.js b/src/App/hooks/useVisibleGeometry.js new file mode 100644 index 00000000..75551d67 --- /dev/null +++ b/src/App/hooks/useVisibleGeometry.js @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react' +import { useConfig } from '../store/configContext.js' +import { useApp } from '../store/appContext.js' +import { EVENTS as events } from '../../config/events.js' + +export const getGeometryType = (geojson) => { + if (!geojson) { + return null + } + if (geojson.type === 'Feature') { + return geojson.geometry?.type + } + return geojson.type +} + +const isPointGeometry = (geojson) => { + const type = getGeometryType(geojson) + return type === 'Point' || type === 'MultiPoint' +} + +export const getPointCoordinates = (geojson) => { + if (geojson.type === 'Feature') { + return getPointCoordinates(geojson.geometry) + } + if (geojson.type === 'Point') { + return geojson.coordinates + } + if (geojson.type === 'MultiPoint') { + return geojson.coordinates[0] + } + return null +} + +const SLOT_REFS = { + inset: 'insetRef', + bottom: 'bottomRef', + side: 'sideRef' +} + +export const useVisibleGeometry = () => { + const { mapProvider, eventBus } = useConfig() + const { layoutRefs, panelConfig, panelRegistry, breakpoint } = useApp() + + const latestRef = useRef({ layoutRefs, panelConfig, panelRegistry, breakpoint }) + latestRef.current = { layoutRefs, panelConfig, panelRegistry, breakpoint } + + useEffect(() => { + if (!mapProvider || !eventBus) { + return undefined + } + + const handlePanelOpened = ({ panelId, slot: eventSlot, visibleGeometry: eventVisibleGeometry }) => { + const { panelConfig: config, panelRegistry: registry, layoutRefs: refs, breakpoint: bp } = latestRef.current + const resolvedConfig = config?.[panelId] ? config : (registry?.getPanelConfig() ?? config) + const visibleGeometry = eventVisibleGeometry ?? resolvedConfig?.[panelId]?.visibleGeometry + const slot = eventSlot ?? resolvedConfig?.[panelId]?.[bp]?.slot + const slotRef = refs[SLOT_REFS[slot]] + + if (!visibleGeometry || !slotRef) { + return + } + if (typeof mapProvider.isGeometryObscured !== 'function') { + return + } + + const waitForPanel = () => { + const panelRect = slotRef.current?.getBoundingClientRect() + + if (!panelRect || panelRect.width === 0 || panelRect.height === 0) { + // Not ready yet, check on the next animation frame + requestAnimationFrame(waitForPanel) + return + } + + // Panel now exists and has size, safe to measure + if (!mapProvider.isGeometryObscured(visibleGeometry, panelRect)) { + return + } + + if (isPointGeometry(visibleGeometry)) { + const center = getPointCoordinates(visibleGeometry) + if (center) { + mapProvider.setView({ center }) + } + } else { + mapProvider.fitToBounds(visibleGeometry) + } + } + + // Start waiting for panel to exist with a measurable size + requestAnimationFrame(waitForPanel) + } + + eventBus.on(events.APP_PANEL_OPENED, handlePanelOpened) + + return () => { + eventBus.off(events.APP_PANEL_OPENED, handlePanelOpened) + } + }, [mapProvider, eventBus]) +} diff --git a/src/App/hooks/useVisibleGeometry.test.js b/src/App/hooks/useVisibleGeometry.test.js new file mode 100644 index 00000000..dff555ff --- /dev/null +++ b/src/App/hooks/useVisibleGeometry.test.js @@ -0,0 +1,331 @@ +import { renderHook } from '@testing-library/react' +import { useVisibleGeometry, getGeometryType, getPointCoordinates } from './useVisibleGeometry' +import { useConfig } from '../store/configContext.js' +import { useApp } from '../store/appContext.js' + +jest.mock('../store/configContext.js') +jest.mock('../store/appContext.js') + +const pointFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 51] }, properties: {} } +const multiPointFeature = { type: 'Feature', geometry: { type: 'MultiPoint', coordinates: [[1, 51], [2, 52]] }, properties: {} } +const polygonFeature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} } + +const insetPanelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 } +const bottomPanelRect = { left: 0, top: 500, right: 1000, bottom: 800, width: 1000, height: 300 } + +const setup = (overrides = {}) => { + const capturedHandlers = {} + const mapProvider = { + isGeometryObscured: jest.fn(() => true), + fitToBounds: jest.fn(), + setView: jest.fn(), + ...overrides.mapProvider + } + const eventBus = { + on: jest.fn((event, handler) => { capturedHandlers[event] = handler }), + off: jest.fn(), + ...overrides.eventBus + } + + const insetEl = document.createElement('div') + insetEl.getBoundingClientRect = jest.fn(() => insetPanelRect) + const bottomEl = document.createElement('div') + bottomEl.getBoundingClientRect = jest.fn(() => bottomPanelRect) + + const layoutRefs = { + mainRef: { current: document.createElement('div') }, + insetRef: { current: insetEl }, + bottomRef: { current: bottomEl }, + ...overrides.layoutRefs + } + const panelConfig = { + myPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'inset' } }, + emptyPanel: {}, + ...overrides.panelConfig + } + const panelRegistry = { + getPanelConfig: jest.fn(() => panelConfig), + ...overrides.panelRegistry + } + + useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config }) + useApp.mockReturnValue({ layoutRefs, panelConfig, panelRegistry, breakpoint: 'desktop', ...overrides.app }) + + return { mapProvider, eventBus, capturedHandlers, layoutRefs, panelConfig, insetEl, bottomEl } +} + +describe('useVisibleGeometry', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + }) + + test('early returns when mapProvider is null', () => { + setup({ config: { mapProvider: null } }) + const { result } = renderHook(() => useVisibleGeometry()) + expect(result.error).toBeUndefined() + }) + + test('early returns when eventBus is null', () => { + setup({ config: { eventBus: null } }) + const { result } = renderHook(() => useVisibleGeometry()) + expect(result.error).toBeUndefined() + }) + + test('subscribes to APP_PANEL_OPENED on eventBus', () => { + const { eventBus } = setup() + renderHook(() => useVisibleGeometry()) + expect(eventBus.on).toHaveBeenCalledWith('app:panelopened', expect.any(Function)) + }) + + test('unsubscribes from APP_PANEL_OPENED on unmount', () => { + const { eventBus } = setup() + const { unmount } = renderHook(() => useVisibleGeometry()) + unmount() + expect(eventBus.off).toHaveBeenCalledWith('app:panelopened', expect.any(Function)) + }) + + test('does nothing when panel has no visibleGeometry', () => { + const { mapProvider, capturedHandlers } = setup() + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'emptyPanel' }) + expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled() + }) + + test('does nothing when panel does not exist in config', () => { + const { mapProvider, capturedHandlers } = setup() + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'unknownPanel' }) + expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled() + }) + + test('does nothing when panel has visibleGeometry but no slot config', () => { + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { noSlotPanel: { visibleGeometry: polygonFeature } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'noSlotPanel' }) + expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled() + }) + + test('does nothing when mapProvider has no isGeometryObscured method', () => { + const { capturedHandlers, mapProvider } = setup({ mapProvider: { isGeometryObscured: null, fitToBounds: jest.fn(), setView: jest.fn() } }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + }) + + test('does nothing when slot ref has zero dimensions (panel not visible)', () => { + const zeroEl = document.createElement('div') + zeroEl.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 })) + const { mapProvider, capturedHandlers } = setup({ + layoutRefs: { + mainRef: { current: document.createElement('div') }, + insetRef: { current: zeroEl } + } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) + + // Run the current pending animation frame + jest.runOnlyPendingTimers() + + expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled() + }) + + test('does nothing when geometry is not obscured', () => { + const { mapProvider, capturedHandlers } = setup({ mapProvider: { isGeometryObscured: jest.fn(() => false), fitToBounds: jest.fn(), setView: jest.fn() } }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + expect(mapProvider.setView).not.toHaveBeenCalled() + }) + + test('calls fitToBounds with visibleGeometry for non-point geometry when obscured', () => { + const { mapProvider, capturedHandlers } = setup() + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) + jest.runAllTimers() + + expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, insetPanelRect) + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature) + expect(mapProvider.setView).not.toHaveBeenCalled() + }) + + test('calls setView with center for Point geometry when obscured', () => { + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { pointPanel: { visibleGeometry: pointFeature, desktop: { slot: 'inset' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'pointPanel' }) + jest.runAllTimers() + + expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] }) + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + }) + + test('calls setView with first coordinate for MultiPoint geometry when obscured', () => { + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { mpPanel: { visibleGeometry: multiPointFeature, desktop: { slot: 'inset' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'mpPanel' }) + jest.runAllTimers() + + expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] }) + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + }) + + test('calls fitToBounds for a raw non-Feature geometry (e.g. Polygon) when obscured', () => { + const rawPolygon = { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] } + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { geoPanel: { visibleGeometry: rawPolygon, desktop: { slot: 'inset' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'geoPanel' }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(rawPolygon) + expect(mapProvider.setView).not.toHaveBeenCalled() + }) + + test('calls setView for a raw Point geometry (not Feature-wrapped) when obscured', () => { + const rawPoint = { type: 'Point', coordinates: [1, 51] } + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { rawPointPanel: { visibleGeometry: rawPoint, desktop: { slot: 'inset' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'rawPointPanel' }) + jest.runAllTimers() + expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] }) + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + }) + + test('does not call setView when Point feature has null coordinates', () => { + const nullCoordsFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: null }, properties: {} } + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { nullPanel: { visibleGeometry: nullCoordsFeature, desktop: { slot: 'inset' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'nullPanel' }) + jest.runAllTimers() + expect(mapProvider.setView).not.toHaveBeenCalled() + expect(mapProvider.fitToBounds).not.toHaveBeenCalled() + }) + + test('uses bottom slot ref when panel is in bottom slot', () => { + const { mapProvider, capturedHandlers } = setup({ + panelConfig: { bottomPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'bottom' } } } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'bottomPanel' }) + jest.runAllTimers() + + expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, bottomPanelRect) + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature) + }) + + test('uses latest panelConfig via ref when it changes between renders', () => { + const { mapProvider, capturedHandlers, insetEl } = setup() + const { rerender } = renderHook(() => useVisibleGeometry()) + + const updatedGeometry = { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} } + const updatedPanelConfig = { myPanel: { visibleGeometry: updatedGeometry, desktop: { slot: 'inset' } } } + useApp.mockReturnValue({ + layoutRefs: { mainRef: { current: document.createElement('div') }, insetRef: { current: insetEl } }, + panelConfig: updatedPanelConfig, + panelRegistry: { getPanelConfig: jest.fn(() => updatedPanelConfig) }, + breakpoint: 'desktop' + }) + rerender() + + capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(updatedGeometry) + }) + + test('uses slot from event payload when registry config lacks slot info', () => { + const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} } + const { mapProvider, capturedHandlers } = setup({ + panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry } })) } + }) + renderHook(() => useVisibleGeometry()) + // Event includes slot (as middleware provides for ADD_PANEL); registry config has no slot info + capturedHandlers['app:panelopened']({ panelId: 'freshPanel', slot: 'inset' }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry) + }) + + test('falls back to panelRegistry for panels not yet in stale panelConfig', () => { + const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} } + const { mapProvider, capturedHandlers } = setup({ + panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry, desktop: { slot: 'inset' } } })) } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'freshPanel' }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry) + }) + + test('falls back to config when panel not in panelConfig and registry returns null', () => { + const { mapProvider, capturedHandlers } = setup({ + panelConfig: {}, // panel not present + panelRegistry: { getPanelConfig: jest.fn(() => null) } // registry returns null + }) + + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'missingPanel', visibleGeometry: polygonFeature, slot: 'inset' }) + jest.runAllTimers() + // Should still call fitToBounds using visibleGeometry from event payload + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature) + }) + + test('uses visibleGeometry from event payload directly, bypassing registry (ADD_PANEL first-click case)', () => { + // Registry is empty — simulates first ADD_PANEL before React has processed the reducer + const { mapProvider, capturedHandlers } = setup({ + panelRegistry: { getPanelConfig: jest.fn(() => ({})) } + }) + renderHook(() => useVisibleGeometry()) + capturedHandlers['app:panelopened']({ panelId: 'newPanel', slot: 'inset', visibleGeometry: polygonFeature }) + jest.runAllTimers() + expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature) + }) +}) + +describe('getGeometryType', () => { + test('returns null for falsy input', () => { + expect(getGeometryType(null)).toBeNull() + expect(getGeometryType(undefined)).toBeNull() + }) + + test('returns geometry type for a Feature', () => { + expect(getGeometryType({ type: 'Feature', geometry: { type: 'Polygon' } })).toBe('Polygon') + }) + + test('returns type directly for a raw geometry', () => { + expect(getGeometryType({ type: 'Point' })).toBe('Point') + expect(getGeometryType({ type: 'FeatureCollection' })).toBe('FeatureCollection') + }) +}) + +describe('getPointCoordinates', () => { + test('returns null for unrecognised geometry type', () => { + expect(getPointCoordinates({ type: 'Polygon', coordinates: [] })).toBeNull() + expect(getPointCoordinates({ type: 'LineString', coordinates: [] })).toBeNull() + }) + + test('returns coordinates for a Point', () => { + expect(getPointCoordinates(pointFeature.geometry)).toEqual(pointFeature.geometry.coordinates) + }) + + test('returns first coordinate for a MultiPoint', () => { + expect(getPointCoordinates(multiPointFeature.geometry)).toEqual(multiPointFeature.geometry.coordinates[0]) + }) + + test('recurses into Feature geometry', () => { + expect(getPointCoordinates(pointFeature)).toEqual(pointFeature.geometry.coordinates) + }) +}) diff --git a/src/App/store/appDispatchMiddleware.js b/src/App/store/appDispatchMiddleware.js index ddde61a7..bacaa9af 100644 --- a/src/App/store/appDispatchMiddleware.js +++ b/src/App/store/appDispatchMiddleware.js @@ -1,5 +1,7 @@ // src/App/store/dispatchMiddleware.js import { EVENTS as events } from '../../config/events.js' +import { defaultPanelConfig } from '../../config/appConfig.js' +import { deepMerge } from '../../utils/deepMerge.js' /** * Determines which panels were implicitly closed when opening a new panel @@ -78,4 +80,21 @@ export function handleActionSideEffects (action, previousState, panelConfig, eve eventBus.emit(events.APP_PANEL_OPENED, { panelId, props }) }) } + + if (type === 'ADD_PANEL') { + const { id, config } = payload + const mergedConfig = deepMerge(defaultPanelConfig, config) + const bpConfig = mergedConfig[previousState.breakpoint] + if (bpConfig?.open) { + queueMicrotask(() => { + const slot = bpConfig.slot + const { visibleGeometry } = mergedConfig + const eventPayload = { panelId: id, slot } + if (visibleGeometry) { + eventPayload.visibleGeometry = visibleGeometry + } + eventBus.emit(events.APP_PANEL_OPENED, eventPayload) + }) + } + } } diff --git a/src/App/store/appDispatchMiddleware.test.js b/src/App/store/appDispatchMiddleware.test.js index 6d44fef5..36825ad8 100644 --- a/src/App/store/appDispatchMiddleware.test.js +++ b/src/App/store/appDispatchMiddleware.test.js @@ -177,4 +177,60 @@ describe('appDispatchMiddleware', () => { ) }) }) + + describe('ADD_PANEL', () => { + it('emits APP_PANEL_OPENED with slot when panel opens by default', async () => { + run( + { type: 'ADD_PANEL', payload: { id: 'newPanel', config: {} } }, + { breakpoint: 'desktop' } + ) + + await flushMicrotasks() + + expect(eventBus.emit).toHaveBeenCalledWith( + events.APP_PANEL_OPENED, + { panelId: 'newPanel', slot: 'inset' } + ) + }) + + it('emits APP_PANEL_OPENED with visibleGeometry when provided in config', async () => { + const visibleGeometry = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: {} } + run( + { type: 'ADD_PANEL', payload: { id: 'geoPanel', config: { visibleGeometry } } }, + { breakpoint: 'desktop' } + ) + + await flushMicrotasks() + + expect(eventBus.emit).toHaveBeenCalledWith( + events.APP_PANEL_OPENED, + { panelId: 'geoPanel', slot: 'inset', visibleGeometry } + ) + }) + + it('does not emit APP_PANEL_OPENED when breakpoint config sets open: false', async () => { + run( + { type: 'ADD_PANEL', payload: { id: 'hiddenPanel', config: { desktop: { open: false } } } }, + { breakpoint: 'desktop' } + ) + + await flushMicrotasks() + + expect(eventBus.emit).not.toHaveBeenCalled() + }) + + it('emits APP_PANEL_OPENED with slot for mobile breakpoint', async () => { + run( + { type: 'ADD_PANEL', payload: { id: 'mobilePanel', config: {} } }, + { breakpoint: 'mobile' } + ) + + await flushMicrotasks() + + expect(eventBus.emit).toHaveBeenCalledWith( + events.APP_PANEL_OPENED, + { panelId: 'mobilePanel', slot: 'bottom' } + ) + }) + }) }) diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index a97f55d7..fd7a1fb6 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -408,10 +408,10 @@ export default class InteractiveMap { /** * Fit the map view to a bounding box or GeoJSON geometry, respecting the safe zone padding. * - * @param {[number, number, number, number] | object} bbox - Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on the crs, or a GeoJSON Feature, FeatureCollection, or geometry. + * @param {[number, number, number, number] | object} target - Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on the crs, or a GeoJSON Feature, FeatureCollection, or geometry. */ - fitToBounds (bbox) { - this.eventBus.emit(events.MAP_FIT_TO_BOUNDS, bbox) + fitToBounds (target) { + this.eventBus.emit(events.MAP_FIT_TO_BOUNDS, target) } /** diff --git a/src/types.js b/src/types.js index e97ae153..bf2333b9 100644 --- a/src/types.js +++ b/src/types.js @@ -247,6 +247,10 @@ * * @property {() => void} [clearHighlightedLabel] * @experimental Clear any highlighted label. + * + * @property {(geojson: object, panelRect: DOMRect) => boolean} [isGeometryObscured] + * Returns true if the geometry's screen bounding box overlaps the given panel element rectangle. + * Used internally by useVisibleGeometry to decide whether to pan/zoom when a panel opens. */ /** @@ -373,6 +377,11 @@ * * @property {PanelBreakpointConfig} tablet * Tablet breakpoint configuration. + * + * @property {object} [visibleGeometry] + * GeoJSON Feature, FeatureCollection, or geometry to keep visible when this panel opens. + * If any part of the geometry's bounding box is obscured by the safe zone after the panel opens, + * the map automatically adjusts: Point or MultiPoint geometry routes to setView(), all other types to fitToBounds(). */ /** From 95c5f744e0a7a775f73605e68b976c74b4660e3a Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 4 Mar 2026 14:17:44 +0000 Subject: [PATCH 2/2] Lint fix --- src/App/hooks/useVisibleGeometry.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App/hooks/useVisibleGeometry.test.js b/src/App/hooks/useVisibleGeometry.test.js index dff555ff..71415b72 100644 --- a/src/App/hooks/useVisibleGeometry.test.js +++ b/src/App/hooks/useVisibleGeometry.test.js @@ -131,8 +131,8 @@ describe('useVisibleGeometry', () => { capturedHandlers['app:panelopened']({ panelId: 'myPanel' }) // Run the current pending animation frame - jest.runOnlyPendingTimers() - + jest.runOnlyPendingTimers() + expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled() })