@defra/interactive-map 0.0.10-alpha → 0.0.12-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +21 -3
  8. package/docs/api/panel-definition.md +10 -12
  9. package/docs/api.md +80 -7
  10. package/docs/demo.mdx +70 -0
  11. package/docs/index.md +0 -4
  12. package/docs/plugins/plugin-context.md +3 -3
  13. package/docs/plugins/plugin-descriptor.md +37 -0
  14. package/docs/plugins/plugin-manifest.md +1 -1
  15. package/docusaurus.config.cjs +55 -25
  16. package/package.json +18 -9
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/beta/datasets/src/manifest.js +3 -3
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/src/events.js +4 -14
  23. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  24. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  25. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  26. package/plugins/beta/map-styles/src/manifest.js +3 -3
  27. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  28. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/src/manifest.js +7 -7
  30. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +28 -6
  33. package/plugins/interact/src/InteractInit.test.js +19 -5
  34. package/plugins/interact/src/events.js +17 -15
  35. package/plugins/interact/src/events.test.js +25 -16
  36. package/plugins/search/dist/css/index.css +1 -1
  37. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  38. package/plugins/search/dist/esm/index.js +1 -1
  39. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/umd/index.js +1 -1
  41. package/plugins/search/src/Search.jsx +9 -3
  42. package/plugins/search/src/Search.test.jsx +26 -6
  43. package/plugins/search/src/components/Form/Form.jsx +35 -7
  44. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  45. package/plugins/search/src/components/Form/Form.test.jsx +99 -2
  46. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  47. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  48. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  49. package/plugins/search/src/datasets.js +15 -11
  50. package/plugins/search/src/datasets.test.js +17 -2
  51. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  52. package/plugins/search/src/index.js +1 -1
  53. package/plugins/search/src/index.test.js +4 -4
  54. package/plugins/search/src/reducer.js +9 -4
  55. package/plugins/search/src/reducer.test.js +12 -7
  56. package/plugins/search/src/search.scss +5 -1
  57. package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
  58. package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
  59. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  60. package/providers/beta/esri/src/appEvents.js +8 -2
  61. package/providers/beta/esri/src/esriProvider.js +25 -17
  62. package/providers/beta/esri/src/mapEvents.js +41 -4
  63. package/providers/beta/esri/src/utils/coords.js +34 -1
  64. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  65. package/providers/beta/esri/src/utils/spatial.js +47 -1
  66. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/esm/index.js +1 -1
  69. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  70. package/providers/maplibre/dist/umd/index.js +1 -1
  71. package/providers/maplibre/src/appEvents.js +10 -1
  72. package/providers/maplibre/src/appEvents.test.js +13 -4
  73. package/providers/maplibre/src/index.js +5 -13
  74. package/providers/maplibre/src/index.test.js +34 -15
  75. package/providers/maplibre/src/mapEvents.js +9 -1
  76. package/providers/maplibre/src/maplibreProvider.js +25 -15
  77. package/providers/maplibre/src/maplibreProvider.test.js +28 -2
  78. package/providers/maplibre/src/utils/spatial.js +51 -0
  79. package/providers/maplibre/src/utils/spatial.test.js +47 -0
  80. package/src/App/components/Actions/Actions.module.scss +5 -4
  81. package/src/App/components/MapButton/MapButton.jsx +4 -16
  82. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  83. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  84. package/src/App/components/Panel/Panel.jsx +6 -6
  85. package/src/App/components/Panel/Panel.test.jsx +14 -15
  86. package/src/App/components/Viewport/MapController.jsx +6 -1
  87. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  88. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  89. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  90. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  91. package/src/App/hooks/useMarkersAPI.js +5 -3
  92. package/src/App/hooks/useModalPanelBehaviour.js +19 -2
  93. package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
  94. package/src/App/hooks/useVisibleGeometry.js +100 -0
  95. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  96. package/src/App/layout/Layout.jsx +5 -5
  97. package/src/App/layout/layout.module.scss +2 -4
  98. package/src/App/registry/panelRegistry.js +1 -10
  99. package/src/App/registry/panelRegistry.test.js +6 -11
  100. package/src/App/renderer/HtmlElementHost.jsx +12 -3
  101. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  102. package/src/App/renderer/mapButtons.js +128 -28
  103. package/src/App/renderer/mapButtons.test.js +119 -19
  104. package/src/App/renderer/pluginWrapper.js +3 -2
  105. package/src/App/renderer/slots.js +1 -1
  106. package/src/App/store/AppProvider.jsx +1 -0
  107. package/src/App/store/MapProvider.jsx +18 -5
  108. package/src/App/store/MapProvider.test.jsx +56 -1
  109. package/src/App/store/appActionsMap.js +17 -9
  110. package/src/App/store/appActionsMap.test.js +33 -7
  111. package/src/App/store/appDispatchMiddleware.js +19 -0
  112. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  113. package/src/App/store/mapActionsMap.js +4 -7
  114. package/src/InteractiveMap/InteractiveMap.js +18 -0
  115. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  116. package/src/config/appConfig.js +17 -15
  117. package/src/config/events.js +41 -4
  118. package/src/config/getInitialOpenPanels.js +2 -2
  119. package/src/config/getInitialOpenPanels.test.js +7 -7
  120. package/src/types.js +22 -11
  121. package/src/utils/getValueForStyle.js +1 -1
@@ -9,10 +9,18 @@ describe('createMapLibreProvider', () => {
9
9
 
10
10
  beforeEach(() => {
11
11
  getWebGL.mockReturnValue({ isEnabled: true, error: null })
12
+
13
+ // Ensure modern support by default
14
+ String.prototype.replaceAll = jest.fn()
15
+ })
16
+
17
+ afterEach(() => {
18
+ jest.restoreAllMocks()
12
19
  })
13
20
 
14
- test('checkDeviceCapabilities: WebGL enabled, no IE → isSupported true, no error', () => {
21
+ test('checkDeviceCapabilities: WebGL enabled, modern browser, no IE → isSupported true', () => {
15
22
  const result = createMapLibreProvider().checkDeviceCapabilities()
23
+
16
24
  expect(result.isSupported).toBe(true)
17
25
  expect(result.error).toBeFalsy()
18
26
  expect(getWebGL).toHaveBeenCalledWith(['webgl2', 'webgl1'])
@@ -20,41 +28,52 @@ describe('createMapLibreProvider', () => {
20
28
 
21
29
  test('checkDeviceCapabilities: WebGL disabled → isSupported false, returns webGL error', () => {
22
30
  getWebGL.mockReturnValue({ isEnabled: false, error: 'WebGL not supported' })
31
+
23
32
  const result = createMapLibreProvider().checkDeviceCapabilities()
33
+
24
34
  expect(result.isSupported).toBe(false)
25
35
  expect(result.error).toBe('WebGL not supported')
26
36
  })
27
37
 
28
38
  test('checkDeviceCapabilities: IE detected → error is IE message', () => {
29
- Object.defineProperty(document, 'documentMode', { get: () => 11, configurable: true })
39
+ Object.defineProperty(document, 'documentMode', {
40
+ get: () => 11,
41
+ configurable: true
42
+ })
43
+
30
44
  try {
31
45
  const result = createMapLibreProvider().checkDeviceCapabilities()
32
46
  expect(result.error).toBe('Internet Explorer is not supported')
33
47
  } finally {
34
- Object.defineProperty(document, 'documentMode', { get: () => undefined, configurable: true })
48
+ Object.defineProperty(document, 'documentMode', {
49
+ get: () => undefined,
50
+ configurable: true
51
+ })
35
52
  }
36
53
  })
37
54
 
38
- test('supportsModernMaplibre returns false when Function constructor throws → isSupported false', () => {
39
- const RealFunction = global.Function
40
- global.Function = function () { throw new SyntaxError('unsupported') }
41
- try {
42
- const result = createMapLibreProvider().checkDeviceCapabilities()
43
- expect(result.isSupported).toBe(false)
44
- } finally {
45
- global.Function = RealFunction
46
- }
55
+ test('checkDeviceCapabilities: no replaceAll support → isSupported false', () => {
56
+ delete String.prototype.replaceAll
57
+
58
+ const result = createMapLibreProvider().checkDeviceCapabilities()
59
+
60
+ expect(result.isSupported).toBe(false)
47
61
  })
48
62
 
49
- test('load returns MapProvider, mapFramework, and mapProviderConfig with config spread', async () => {
63
+ test('load returns MapProvider, mapFramework, and merged mapProviderConfig', async () => {
50
64
  const result = await createMapLibreProvider({ tileSize: 512 }).load()
65
+
51
66
  expect(result.MapProvider).toBeDefined()
52
67
  expect(result.mapFramework).toBeDefined()
53
- expect(result.mapProviderConfig).toEqual({ tileSize: 512, crs: 'EPSG:4326' })
68
+ expect(result.mapProviderConfig).toEqual({
69
+ tileSize: 512,
70
+ crs: 'EPSG:4326'
71
+ })
54
72
  })
55
73
 
56
74
  test('load uses empty default config', async () => {
57
75
  const { mapProviderConfig } = await createMapLibreProvider().load()
76
+
58
77
  expect(mapProviderConfig).toEqual({ crs: 'EPSG:4326' })
59
78
  })
60
- })
79
+ })
@@ -4,7 +4,15 @@ import { throttle } from '../../../src/utils/throttle.js'
4
4
  const DEBOUNCE_IDLE_TIME = 500
5
5
  const MOVE_THROTTLE_TIME = 10
6
6
 
7
- export function attachMapEvents ({ map, events, eventBus, getCenter, getZoom, getBounds, getResolution }) {
7
+ export function attachMapEvents ({
8
+ map,
9
+ events,
10
+ eventBus,
11
+ getCenter,
12
+ getZoom,
13
+ getBounds,
14
+ getResolution
15
+ }) {
8
16
  const handlers = []
9
17
  const debouncers = []
10
18
 
@@ -7,7 +7,7 @@ import { DEFAULTS, supportedShortcuts } from './defaults.js'
7
7
  import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js'
8
8
  import { attachMapEvents } from './mapEvents.js'
9
9
  import { attachAppEvents } from './appEvents.js'
10
- import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
10
+ import { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, isGeometryObscured, getResolution, getPaddedBounds } from './utils/spatial.js'
11
11
  import { createMapLabelNavigator } from './utils/labels.js'
12
12
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
13
13
  import { queryFeatures } from './utils/queryFeatures.js'
@@ -44,7 +44,9 @@ export default class MapLibreProvider {
44
44
  * @returns {Promise<void>}
45
45
  */
46
46
  async initMap (config) {
47
- const { container, padding, mapStyle, center, zoom, bounds, pixelRatio, ...initConfig } = config
47
+ const { container, padding, mapStyle, mapSize, center, zoom, bounds, pixelRatio, ...initConfig } = config
48
+ this.mapStyleId = mapStyle?.id
49
+ this.mapSize = mapSize
48
50
  const { Map: MaplibreMap } = this.maplibreModule
49
51
  const { events, eventBus } = this
50
52
 
@@ -90,6 +92,7 @@ export default class MapLibreProvider {
90
92
  })
91
93
 
92
94
  attachAppEvents({
95
+ mapProvider: this,
93
96
  map,
94
97
  events,
95
98
  eventBus
@@ -100,17 +103,12 @@ export default class MapLibreProvider {
100
103
  this.labelNavigator = createMapLabelNavigator(map, mapStyle?.mapColorScheme, events, eventBus)
101
104
  })
102
105
 
103
- this.eventBus.emit(events.MAP_READY, this.getMapAPI())
104
- }
105
-
106
- /** Returns the public API exposed via the map:ready event. */
107
- getMapAPI () {
108
- return {
106
+ this.eventBus.emit(events.MAP_READY, {
109
107
  map: this.map,
110
- crs: this.crs,
111
- fitToBounds: this.fitToBounds.bind(this),
112
- setView: this.setView.bind(this)
113
- }
108
+ mapStyleId: this.mapStyleId,
109
+ mapSize: this.mapSize,
110
+ crs: this.crs
111
+ })
114
112
  }
115
113
 
116
114
  /** Destroy the map and clean up resources. */
@@ -177,12 +175,13 @@ export default class MapLibreProvider {
177
175
  }
178
176
 
179
177
  /**
180
- * Fit map view to the specified bounds [west, south, east, north].
178
+ * Fit map view to the specified bounds or GeoJSON geometry.
181
179
  *
182
- * @param {[number, number, number, number]} bounds - Bounds as [west, south, east, north].
180
+ * @param {[number, number, number, number] | object} bounds - Bounds as [west, south, east, north], or a GeoJSON Feature, FeatureCollection, or geometry. Bbox is computed from GeoJSON using @turf/bbox.
183
181
  */
184
182
  fitToBounds (bounds) {
185
- this.map.fitBounds(bounds, { duration: DEFAULTS.animationDuration })
183
+ const bbox = Array.isArray(bounds) ? bounds : getBboxFromGeoJSON(bounds)
184
+ this.map.fitBounds(bbox, { duration: DEFAULTS.animationDuration })
186
185
  }
187
186
 
188
187
  /**
@@ -338,4 +337,15 @@ export default class MapLibreProvider {
338
337
  const { lng, lat } = this.map.unproject([point.x, point.y])
339
338
  return [lng, lat]
340
339
  }
340
+
341
+ /**
342
+ * Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
343
+ *
344
+ * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry.
345
+ * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates).
346
+ * @returns {boolean}
347
+ */
348
+ isGeometryObscured (geojson, panelRect) {
349
+ return isGeometryObscured(geojson, panelRect, this.map)
350
+ }
341
351
  }
@@ -4,7 +4,7 @@ import { attachAppEvents } from './appEvents.js'
4
4
  import { createMapLabelNavigator } from './utils/labels.js'
5
5
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
6
6
  import { queryFeatures } from './utils/queryFeatures.js'
7
- import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
7
+ import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
8
8
 
9
9
  jest.mock('./defaults.js', () => ({
10
10
  DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 },
@@ -19,6 +19,8 @@ jest.mock('./appEvents.js', () => ({ attachAppEvents: jest.fn() }))
19
19
  jest.mock('./utils/spatial.js', () => ({
20
20
  getAreaDimensions: jest.fn(() => '400m by 750m'),
21
21
  getCardinalMove: jest.fn(() => 'north'),
22
+ getBboxFromGeoJSON: jest.fn(() => [-1, 50, 1, 52]),
23
+ isGeometryObscured: jest.fn(() => true),
22
24
  getResolution: jest.fn(() => 10),
23
25
  getPaddedBounds: jest.fn(() => [[0, 0], [1, 1]])
24
26
  }))
@@ -84,7 +86,7 @@ describe('MapLibreProvider', () => {
84
86
  expect(map.setPadding).toHaveBeenCalled()
85
87
  expect(attachMapEvents).toHaveBeenCalled()
86
88
  expect(attachAppEvents).toHaveBeenCalled()
87
- expect(eventBus.emit).toHaveBeenCalledWith('map:ready', expect.any(Object))
89
+ expect(eventBus.emit).toHaveBeenCalledWith('map:ready', { map, mapStyleId: undefined, mapSize: undefined, crs: undefined })
88
90
  })
89
91
 
90
92
  test('initMap: fitBounds called when bounds provided; skipped when absent; null mapStyle → style undefined', async () => {
@@ -157,6 +159,30 @@ describe('MapLibreProvider', () => {
157
159
  expect(map.setPadding).toHaveBeenCalledWith({ top: 5 })
158
160
  })
159
161
 
162
+ test('fitToBounds accepts GeoJSON: computes bbox via getBboxFromGeoJSON', async () => {
163
+ const { getBboxFromGeoJSON } = require('./utils/spatial.js')
164
+ const p = makeProvider()
165
+ await doInitMap(p)
166
+ const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
167
+
168
+ p.fitToBounds(feature)
169
+
170
+ expect(getBboxFromGeoJSON).toHaveBeenCalledWith(feature)
171
+ expect(map.fitBounds).toHaveBeenCalledWith([-1, 50, 1, 52], { duration: 400 })
172
+ })
173
+
174
+ test('isGeometryObscured delegates to spatial utility with map instance', async () => {
175
+ const p = makeProvider()
176
+ await doInitMap(p)
177
+ const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
178
+ const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
179
+
180
+ const result = p.isGeometryObscured(geojson, panelRect)
181
+
182
+ expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, map)
183
+ expect(result).toBe(true)
184
+ })
185
+
160
186
  test('getCenter, getZoom, getBounds return formatted values', async () => {
161
187
  const p = makeProvider()
162
188
  await doInitMap(p)
@@ -1,5 +1,6 @@
1
1
  // src/utils/spatial.js
2
2
  import LatLon from 'geodesy/latlon-spherical.js'
3
+ import turfBbox from '@turf/bbox'
3
4
 
4
5
  // -----------------------------------------------------------------------------
5
6
  // Internal (not exported)
@@ -186,9 +187,59 @@ const getPaddedBounds = (LngLatBounds, map) => {
186
187
  }
187
188
 
188
189
 
190
+ /**
191
+ * Get a flat bbox [west, south, east, north] from any GeoJSON object
192
+ * (Feature, FeatureCollection, or geometry).
193
+ *
194
+ * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry
195
+ * @returns {[number, number, number, number]}
196
+ */
197
+ const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
198
+
199
+ /**
200
+ * Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
201
+ * Used to decide whether to pan/zoom when a panel opens over a visibleGeometry target.
202
+ *
203
+ * @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry
204
+ * @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates)
205
+ * @param {object} map - MapLibre map instance
206
+ * @returns {boolean}
207
+ */
208
+ const isGeometryObscured = (geojson, panelRect, map) => {
209
+ const containerRect = map.getContainer().getBoundingClientRect()
210
+ const [west, south, east, north] = getBboxFromGeoJSON(geojson)
211
+
212
+ const corners = [
213
+ map.project([west, south]),
214
+ map.project([west, north]),
215
+ map.project([east, south]),
216
+ map.project([east, north])
217
+ ]
218
+
219
+ const screenMinX = Math.min(...corners.map(c => c.x))
220
+ const screenMaxX = Math.max(...corners.map(c => c.x))
221
+ const screenMinY = Math.min(...corners.map(c => c.y))
222
+ const screenMaxY = Math.max(...corners.map(c => c.y))
223
+
224
+ // Convert panelRect from viewport coords to map-container-relative coords
225
+ const panelLeft = panelRect.left - containerRect.left
226
+ const panelTop = panelRect.top - containerRect.top
227
+ const panelRight = panelRect.right - containerRect.left
228
+ const panelBottom = panelRect.bottom - containerRect.top
229
+
230
+ return (
231
+ screenMinX < panelRight &&
232
+ screenMaxX > panelLeft &&
233
+ screenMinY < panelBottom &&
234
+ screenMaxY > panelTop
235
+ )
236
+ }
237
+
189
238
  export {
190
239
  getAreaDimensions,
191
240
  getCardinalMove,
241
+ getBboxFromGeoJSON,
242
+ isGeometryObscured,
192
243
  spatialNavigate,
193
244
  getResolution,
194
245
  getPaddedBounds,
@@ -7,6 +7,8 @@ jest.mock('geodesy/latlon-spherical.js', () =>
7
7
  }))
8
8
  )
9
9
 
10
+ jest.mock('@turf/bbox', () => jest.fn(() => [-1, 50, 1, 52]))
11
+
10
12
  describe('spatial utils', () => {
11
13
 
12
14
  test('formatDimension hits all branches', () => {
@@ -93,4 +95,49 @@ describe('spatial utils', () => {
93
95
  expect(bounds.sw).toBeDefined()
94
96
  expect(bounds.ne).toBeDefined()
95
97
  })
98
+
99
+ test('getBboxFromGeoJSON delegates to @turf/bbox and returns flat bbox array', () => {
100
+ const turfBbox = require('@turf/bbox')
101
+ const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
102
+
103
+ const result = spatial.getBboxFromGeoJSON(feature)
104
+
105
+ expect(turfBbox).toHaveBeenCalledWith(feature)
106
+ expect(result).toEqual([-1, 50, 1, 52])
107
+ })
108
+
109
+ describe('isGeometryObscured', () => {
110
+ const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] }, properties: {} }
111
+ // getBboxFromGeoJSON is mocked to always return [-1, 50, 1, 52]
112
+
113
+ // Container sits at viewport origin so container-relative coords equal viewport coords
114
+ const makeMap = (projectFn) => ({
115
+ getContainer: jest.fn(() => ({
116
+ getBoundingClientRect: jest.fn(() => ({ left: 0, top: 0, right: 1000, bottom: 800 }))
117
+ })),
118
+ project: jest.fn(projectFn)
119
+ })
120
+
121
+ // Panel occupies the right 400px of the viewport
122
+ const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
123
+
124
+ test('returns true when geometry screen bbox overlaps the panel rect', () => {
125
+ // Corners project into the panel (x: 650 is between panelLeft 600 and panelRight 1000)
126
+ const map = makeMap(() => ({ x: 650, y: 400 }))
127
+ expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(true)
128
+ })
129
+
130
+ test('returns false when geometry screen bbox does not overlap the panel rect', () => {
131
+ // Corners project to x: 300, entirely left of panelLeft (600)
132
+ const map = makeMap(() => ({ x: 300, y: 400 }))
133
+ expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(false)
134
+ })
135
+
136
+ test('projects all four bbox corners', () => {
137
+ const map = makeMap(() => ({ x: 300, y: 400 }))
138
+ spatial.isGeometryObscured(geojson, panelRect, map)
139
+ // bbox is [-1, 50, 1, 52]: corners are [-1,50], [-1,52], [1,50], [1,52]
140
+ expect(map.project).toHaveBeenCalledTimes(4)
141
+ })
142
+ })
96
143
  })
@@ -11,12 +11,17 @@
11
11
  padding-top var(--duration) ease, padding-bottom var(--duration) ease;
12
12
  }
13
13
 
14
+ &--border-top {
15
+ border-top: 1px solid var(--app-border-color);
16
+ }
17
+
14
18
  &--hidden {
15
19
  max-height: 0;
16
20
  overflow: hidden;
17
21
  opacity: 0;
18
22
  padding-top: 0;
19
23
  padding-bottom: 0;
24
+ border: 0;
20
25
  }
21
26
 
22
27
  .im-c-button-wrapper--wide {
@@ -30,10 +35,6 @@
30
35
  }
31
36
  }
32
37
 
33
- .im-c-actions--border-top {
34
- border-top: 1px solid var(--app-border-color);
35
- }
36
-
37
38
  .im-o-app--tablet, .im-o-app--desktop {
38
39
  .im-c-actions {
39
40
  min-width: 0;
@@ -26,18 +26,12 @@ const buildButtonClassNames = (buttonId, variant, showLabel) => [
26
26
  * Builds CSS class names for the wrapper div that contains the button.
27
27
  * @param {string} buttonId - Unique identifier for the button
28
28
  * @param {boolean} showLabel - Whether the button label is displayed
29
- * @param {boolean} groupStart - Whether this button is at the start of a button group
30
- * @param {boolean} groupMiddle - Whether this button is in the middle of a button group
31
- * @param {boolean} groupEnd - Whether this button is at the end of a button group
32
29
  * @returns {string} Space-separated CSS class names for the wrapper
33
30
  */
34
- const buildWrapperClassNames = (buttonId, showLabel, groupStart, groupMiddle, groupEnd) => [
31
+ const buildWrapperClassNames = (buttonId, showLabel) => [
35
32
  'im-c-button-wrapper',
36
33
  buttonId && `im-c-button-wrapper--${stringToKebab(buttonId)}`,
37
- showLabel && 'im-c-button-wrapper--wide',
38
- groupStart && 'im-c-button-wrapper--group-start',
39
- groupMiddle && 'im-c-button-wrapper--group-middle',
40
- groupEnd && 'im-c-button-wrapper--group-end'
34
+ showLabel && 'im-c-button-wrapper--wide'
41
35
  ].filter(Boolean).join(' ')
42
36
 
43
37
  /**
@@ -159,9 +153,6 @@ const buildButtonProps = ({
159
153
  * @param {Array<Object>} [props.menuItems] - Array of items for popup menu
160
154
  * @param {string} [props.idPrefix=''] - Prefix for generated panel/popup IDs
161
155
  * @param {string} [props.href] - URL for anchor element; if provided, renders as <a> instead of <button>
162
- * @param {boolean} [props.groupStart=false] - Whether button is at start of button group
163
- * @param {boolean} [props.groupMiddle=false] - Whether button is in middle of button group
164
- * @param {boolean} [props.groupEnd=false] - Whether button is at end of button group
165
156
  * @returns {JSX.Element} The rendered button component
166
157
  */
167
158
  export const MapButton = ({
@@ -180,10 +171,7 @@ export const MapButton = ({
180
171
  panelId,
181
172
  menuItems,
182
173
  idPrefix,
183
- href,
184
- groupMiddle,
185
- groupStart,
186
- groupEnd
174
+ href
187
175
  }) => {
188
176
  const { id: appId } = useConfig()
189
177
  const { buttonRefs } = useApp()
@@ -255,7 +243,7 @@ export const MapButton = ({
255
243
 
256
244
  return (
257
245
  <div
258
- className={buildWrapperClassNames(buttonId, showLabel, groupStart, groupMiddle, groupEnd)}
246
+ className={buildWrapperClassNames(buttonId, showLabel)}
259
247
  style={isHidden ? { display: 'none' } : undefined}
260
248
  >
261
249
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
@@ -138,16 +138,16 @@
138
138
  }
139
139
 
140
140
  .im-o-app__right {
141
- .im-c-button-wrapper--group-start,
142
- .im-c-button-wrapper--group-middle {
143
- margin-bottom: calc(-1 * var(--divider-gap));
141
+ .im-c-button-group {
142
+ display: flex;
143
+ flex-direction: column;
144
144
  }
145
145
 
146
- .im-c-button-wrapper--group-start .im-c-map-button {
146
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
147
147
  @include tools.border-focus-corner-override($corners: 'top');
148
148
  }
149
149
 
150
- .im-c-button-wrapper--group-middle .im-c-map-button {
150
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
151
151
  @include tools.border-focus-corner-override($corners: 'none');
152
152
 
153
153
  &::before {
@@ -155,23 +155,23 @@
155
155
  }
156
156
  }
157
157
 
158
- .im-c-button-wrapper--group-end .im-c-map-button {
158
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
159
159
  margin-top: 0;
160
160
  @include tools.border-focus-corner-override($corners: 'bottom');
161
161
  }
162
162
  }
163
163
 
164
164
  .im-o-app__top {
165
- .im-c-button-wrapper--group-start,
166
- .im-c-button-wrapper--group-middle {
167
- margin-right: calc(-1 * var(--divider-gap));
165
+ .im-c-button-group {
166
+ display: flex;
167
+ flex-direction: row;
168
168
  }
169
169
 
170
- .im-c-button-wrapper--group-start .im-c-map-button {
170
+ .im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
171
171
  @include tools.border-focus-corner-override($corners: 'left');
172
172
  }
173
173
 
174
- .im-c-button-wrapper--group-middle .im-c-map-button {
174
+ .im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
175
175
  @include tools.border-focus-corner-override($corners: 'none');
176
176
 
177
177
  &::before {
@@ -179,7 +179,7 @@
179
179
  }
180
180
  }
181
181
 
182
- .im-c-button-wrapper--group-end .im-c-map-button {
182
+ .im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
183
183
  @include tools.border-focus-corner-override($corners: 'right');
184
184
  }
185
185
  }
@@ -66,15 +66,6 @@ describe('MapButton', () => {
66
66
  expect(container.firstChild).toHaveStyle('display: none')
67
67
  })
68
68
 
69
- it.each([
70
- ['groupStart', 'im-c-button-wrapper--group-start'],
71
- ['groupMiddle', 'im-c-button-wrapper--group-middle'],
72
- ['groupEnd', 'im-c-button-wrapper--group-end']
73
- ])('applies wrapper %s class', (prop, className) => {
74
- const { container } = renderButton({ [prop]: true })
75
- expect(container.firstChild).toHaveClass(className)
76
- })
77
-
78
69
  it('handles panelId aria attributes', () => {
79
70
  renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isPanelOpen: false })
80
71
  const button = getButton()
@@ -7,10 +7,10 @@ import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
7
  import { Icon } from '../Icon/Icon'
8
8
 
9
9
  const computePanelState = (bpConfig, triggeringElement) => {
10
- const isAside = bpConfig.slot === 'side' && bpConfig.initiallyOpen && !bpConfig.modal
11
- const isDialog = !isAside && bpConfig.dismissable
10
+ const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
11
+ const isDialog = !isAside && bpConfig.dismissible
12
12
  const isModal = bpConfig.modal === true
13
- const isDismissable = bpConfig.dismissable !== false
13
+ const isDismissable = bpConfig.dismissible !== false
14
14
  const shouldFocus = Boolean(isModal || triggeringElement)
15
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
16
  return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
@@ -96,8 +96,8 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
96
96
  }
97
97
  }, [isOpen])
98
98
 
99
- const panelClass = buildPanelClassNames(bpConfig.slot, panelConfig.showLabel)
100
- const panelBodyClass = buildPanelBodyClassNames(panelConfig.showLabel, isDismissable)
99
+ const panelClass = buildPanelClassNames(bpConfig.slot, bpConfig.showLabel ?? true)
100
+ const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissable)
101
101
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
102
102
 
103
103
  const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissable, isModal, width: bpConfig.width, panelClass })
@@ -110,7 +110,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
110
110
  >
111
111
  <h2
112
112
  id={`${elementId}-label`}
113
- className={panelConfig.showLabel ? 'im-c-panel__heading im-e-heading-m' : 'im-u-visually-hidden'}
113
+ className={(bpConfig.showLabel ?? true) ? 'im-c-panel__heading im-e-heading-m' : 'im-u-visually-hidden'}
114
114
  >
115
115
  {label}
116
116
  </h2>
@@ -32,8 +32,7 @@ describe('Panel', () => {
32
32
 
33
33
  const renderPanel = (config = {}, props = {}) => {
34
34
  const panelConfig = {
35
- showLabel: true,
36
- desktop: { slot: 'side', initiallyOpen: true, dismissable: false, modal: false },
35
+ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true },
37
36
  ...config
38
37
  }
39
38
  return render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' {...props} />)
@@ -49,17 +48,17 @@ describe('Panel', () => {
49
48
  })
50
49
 
51
50
  it('renders visually hidden label when showLabel=false', () => {
52
- renderPanel({ showLabel: false })
51
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: false } })
53
52
  expect(screen.getByText('Settings')).toHaveClass('im-u-visually-hidden')
54
53
  })
55
54
 
56
- it('applies offset class to body when showLabel=false and dismissable', () => {
57
- renderPanel({ showLabel: false, desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
55
+ it('applies offset class to body when showLabel=false and dismissible', () => {
56
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false, showLabel: false } })
58
57
  expect(screen.getByRole('dialog').querySelector('.im-c-panel__body')).toHaveClass('im-c-panel__body--offset')
59
58
  })
60
59
 
61
60
  it('applies width style if provided', () => {
62
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: true, width: '300px' } })
61
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: true, width: '300px' } })
63
62
  expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' })
64
63
  })
65
64
 
@@ -82,23 +81,23 @@ describe('Panel', () => {
82
81
  })
83
82
 
84
83
  describe('role and aria attributes', () => {
85
- it('renders region role for non-dismissable panels', () => {
84
+ it('renders region role for non-dismissible panels', () => {
86
85
  renderPanel()
87
86
  expect(screen.getByRole('region')).toBeInTheDocument()
88
87
  })
89
88
 
90
- it('renders dialog role for dismissable non-aside panels', () => {
91
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
89
+ it('renders dialog role for dismissible non-aside panels', () => {
90
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
92
91
  expect(screen.getByRole('dialog')).toBeInTheDocument()
93
92
  })
94
93
 
95
- it('renders complementary role for dismissable aside panels', () => {
96
- renderPanel({ desktop: { slot: 'side', initiallyOpen: true, dismissable: true } })
94
+ it('renders complementary role for dismissible aside panels', () => {
95
+ renderPanel({ desktop: { slot: 'side', open: true, dismissible: true } })
97
96
  expect(screen.getByRole('complementary')).toBeInTheDocument()
98
97
  })
99
98
 
100
99
  it('sets aria-modal and tabIndex for modal dialogs', () => {
101
- renderPanel({ desktop: { slot: 'overlay', dismissable: true, modal: true } })
100
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } })
102
101
  const dialog = screen.getByRole('dialog')
103
102
  expect(dialog).toHaveAttribute('aria-modal', 'true')
104
103
  expect(dialog).toHaveAttribute('tabIndex', '-1')
@@ -111,7 +110,7 @@ describe('Panel', () => {
111
110
  const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
112
111
 
113
112
  renderPanel(
114
- { desktop: { slot: 'top-button', dismissable: true, initiallyOpen: false } },
113
+ { desktop: { slot: 'top-button', dismissible: true, open: false } },
115
114
  { props: { triggeringElement } }
116
115
  )
117
116
 
@@ -125,7 +124,7 @@ describe('Panel', () => {
125
124
  const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
126
125
 
127
126
  renderPanel(
128
- { desktop: { slot: 'overlay', dismissable: true, modal: true } },
127
+ { desktop: { slot: 'overlay', dismissible: true, modal: true } },
129
128
  { props: { triggeringElement } }
130
129
  )
131
130
 
@@ -134,7 +133,7 @@ describe('Panel', () => {
134
133
  })
135
134
 
136
135
  it('falls back to viewportRef focus when no triggeringElement', () => {
137
- renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: false } })
136
+ renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
138
137
 
139
138
  fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
140
139
  expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()