@defra/interactive-map 0.0.16-alpha → 0.0.17-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 (130) hide show
  1. package/assets/images/slot-map.svg +264 -0
  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/slots.md +16 -15
  8. package/docs/api.md +3 -3
  9. package/docs/getting-started.md +4 -1
  10. package/docs/plugins/datasets.md +561 -0
  11. package/docs/plugins.md +1 -1
  12. package/package.json +2 -2
  13. package/plugins/beta/datasets/dist/css/index.css +85 -15
  14. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  15. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  16. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  17. package/plugins/beta/datasets/src/DatasetsInit.jsx +23 -8
  18. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  19. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
  20. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
  21. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
  22. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
  23. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  24. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  25. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  26. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  27. package/plugins/beta/datasets/src/api/setData.js +8 -0
  28. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  29. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  30. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  31. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  32. package/plugins/beta/datasets/src/datasets.js +29 -55
  33. package/plugins/beta/datasets/src/defaults.js +42 -8
  34. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  35. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  36. package/plugins/beta/datasets/src/manifest.js +24 -16
  37. package/plugins/beta/datasets/src/panels/Key.jsx +128 -50
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
  39. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  40. package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
  41. package/plugins/beta/datasets/src/reducer.js +128 -9
  42. package/plugins/beta/datasets/src/styles/patterns.js +157 -0
  43. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  44. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  45. package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
  46. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  47. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  48. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  49. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  50. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  51. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  52. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  53. package/plugins/beta/frame/src/Frame.jsx +5 -5
  54. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  55. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/src/manifest.js +1 -1
  57. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  58. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  59. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  60. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  61. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  62. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  63. package/plugins/interact/dist/css/index.css +1 -1
  64. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  65. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  66. package/plugins/interact/src/interact.scss +0 -7
  67. package/plugins/interact/src/manifest.js +14 -18
  68. package/plugins/interact/src/manifest.test.js +3 -1
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  71. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  72. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  73. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  74. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  75. package/providers/maplibre/src/utils/highlightFeatures.js +1 -0
  76. package/providers/maplibre/src/utils/highlightFeatures.test.js +1 -0
  77. package/src/App/components/Actions/Actions.jsx +2 -2
  78. package/src/App/components/Actions/Actions.module.scss +0 -7
  79. package/src/App/components/Actions/Actions.test.jsx +1 -1
  80. package/src/App/components/Icon/Icon.jsx +3 -2
  81. package/src/App/components/Icon/Icon.module.scss +4 -0
  82. package/src/App/components/Icon/Icon.test.jsx +43 -4
  83. package/src/App/components/MapButton/MapButton.jsx +42 -17
  84. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  85. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  86. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  87. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  88. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  89. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  90. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  91. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  92. package/src/App/hooks/useInterfaceAPI.js +6 -0
  93. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  94. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  95. package/src/App/layout/Layout.jsx +12 -7
  96. package/src/App/layout/Layout.test.jsx +2 -2
  97. package/src/App/layout/layout.module.scss +67 -29
  98. package/src/App/registry/pluginRegistry.js +1 -1
  99. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  100. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  101. package/src/App/renderer/mapButtons.js +1 -1
  102. package/src/App/renderer/mapPanels.test.js +2 -2
  103. package/src/App/renderer/slotHelpers.js +2 -2
  104. package/src/App/renderer/slotHelpers.test.js +5 -5
  105. package/src/App/renderer/slots.js +9 -5
  106. package/src/App/store/AppProvider.jsx +3 -1
  107. package/src/App/store/AppProvider.test.jsx +1 -1
  108. package/src/App/store/ServiceProvider.jsx +3 -1
  109. package/src/App/store/appActionsMap.js +16 -0
  110. package/src/App/store/appActionsMap.test.js +27 -0
  111. package/src/App/store/appDispatchMiddleware.js +1 -1
  112. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  113. package/src/App/store/appReducer.js +2 -0
  114. package/src/InteractiveMap/InteractiveMap.js +4 -0
  115. package/src/config/appConfig.js +5 -2
  116. package/src/config/events.js +28 -0
  117. package/src/scss/main.scss +1 -0
  118. package/src/scss/settings/_dimensions.scss +0 -1
  119. package/src/utils/getSafeZoneInset.js +9 -7
  120. package/src/utils/getSafeZoneInset.test.js +10 -10
  121. package/webpack.dev.mjs +1 -1
  122. package/docs/api/slot-map.svg +0 -1
  123. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  124. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  125. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  126. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  127. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  128. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  129. /package/src/{utils → services}/logger.js +0 -0
  130. /package/src/{utils → services}/logger.test.js +0 -0
@@ -0,0 +1,258 @@
1
+ import { useState, useMemo, useEffect } from 'react'
2
+ import { stringToKebab } from '../../../utils/stringToKebab.js'
3
+
4
+ /**
5
+ * Computes the position and alignment style for the popup menu based on the
6
+ * triggering button's bounding rect. Positions above/below and left/center/right
7
+ * depending on which third of the screen the button centre falls in.
8
+ *
9
+ * @param {DOMRect|null} buttonRect - Bounding rect of the trigger button, or null.
10
+ * @returns {{ style: object, direction: string, halign: string }}
11
+ */
12
+ const getMenuStyle = (buttonRect) => {
13
+ if (!buttonRect) {
14
+ return { style: {}, direction: 'below' }
15
+ }
16
+ const style = {}
17
+ let direction
18
+ if (buttonRect.top >= window.innerHeight / 2) {
19
+ style.bottom = `${window.innerHeight - buttonRect.top}px`
20
+ direction = 'above'
21
+ } else {
22
+ style.top = `${buttonRect.bottom}px`
23
+ direction = 'below'
24
+ }
25
+ const buttonCenterX = (buttonRect.left + buttonRect.right) / 2
26
+ let halign
27
+ if (buttonCenterX > (window.innerWidth * 2) / 3) { // NOSONAR, third of a page width
28
+ style.right = `${window.innerWidth - buttonRect.right}px`
29
+ halign = 'right'
30
+ } else if (buttonCenterX < window.innerWidth / 3) { // NOSONAR, third of a page width
31
+ style.left = `${buttonRect.left}px`
32
+ halign = 'left'
33
+ } else {
34
+ style.left = `${buttonCenterX}px`
35
+ halign = 'center'
36
+ }
37
+ return { style, direction, halign }
38
+ }
39
+
40
+ /**
41
+ * Invokes an item's action via buttonConfig.onClick (if configured) or item.onClick.
42
+ * For keyboard-triggered activations also dispatches a synthetic MouseEvent so that
43
+ * any window-level click listeners (e.g. editVertexMode) fire as expected.
44
+ * The synthetic event is marked _fromKeyboardActivation so handleItemClick can
45
+ * ignore it and avoid double-activation.
46
+ *
47
+ * @param {React.SyntheticEvent} e - The triggering React event.
48
+ * @param {object} item - The item being activated.
49
+ * @param {object} ctx - Dependencies: { buttonConfig, evaluateProp, pluginId, id }.
50
+ */
51
+ const activateItem = (e, item, { buttonConfig, evaluateProp, pluginId, id }) => {
52
+ const menuItemConfig = buttonConfig[item.id]
53
+ if (typeof menuItemConfig?.onClick === 'function') {
54
+ menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
55
+ } else if (typeof item.onClick === 'function') {
56
+ item.onClick(e.nativeEvent)
57
+ } else {
58
+ // No action
59
+ }
60
+ if (e.nativeEvent instanceof KeyboardEvent) {
61
+ const el = document.getElementById(`${id}-${stringToKebab(item.id)}`)
62
+ if (el) {
63
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
64
+ click._fromKeyboardActivation = true
65
+ el.dispatchEvent(click)
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Builds the keydown handler for the menu UL. Handles Escape/Tab (close & focus),
72
+ * ArrowDown/Up (navigate visible items), Home/End (jump to ends),
73
+ * Enter (activate and close), Space (activate; close only for non-checkbox items).
74
+ *
75
+ * @param {object} p
76
+ * @param {Array} p.items - All menu item descriptors.
77
+ * @param {number[]} p.visibleIndices - Indices of non-hidden items.
78
+ * @param {number} p.index - Currently highlighted index.
79
+ * @param {Function} p.setIndex - State setter for highlighted index.
80
+ * @param {Set} p.disabledButtons - IDs of disabled items.
81
+ * @param {object} p.instigator - DOM node of the trigger button.
82
+ * @param {Function} p.setIsOpen - Callback to close the menu.
83
+ * @param {object} p.activateCtx - Context passed through to activateItem.
84
+ * @returns {Function} onKeyDown handler for the menu element.
85
+ */
86
+ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx }) => {
87
+ const closeAndFocus = (e, preventDefault = false) => {
88
+ if (preventDefault && e?.preventDefault) {
89
+ e.preventDefault()
90
+ }
91
+ instigator.focus()
92
+ setIsOpen(false)
93
+ }
94
+
95
+ const navigateVisible = (e) => {
96
+ e.preventDefault()
97
+ const n = visibleIndices.length
98
+ if (n === 0) {
99
+ return
100
+ }
101
+ const pos = visibleIndices.indexOf(index)
102
+ let nextPos
103
+ if (e.key === 'ArrowDown') {
104
+ nextPos = pos === -1 ? 0 : (pos + 1) % n
105
+ } else if (pos === -1) {
106
+ nextPos = n - 1
107
+ } else {
108
+ nextPos = (pos - 1 + n) % n
109
+ }
110
+ setIndex(visibleIndices[nextPos])
111
+ }
112
+
113
+ const handleEnter = (e) => {
114
+ e.preventDefault()
115
+ const item = items[index]
116
+ if (item && !disabledButtons.has(item.id)) {
117
+ activateItem(e, item, activateCtx)
118
+ }
119
+ instigator.focus()
120
+ setIsOpen(false)
121
+ }
122
+
123
+ const handleSpace = (e) => {
124
+ e.preventDefault()
125
+ const item = items[index]
126
+ if (!item || disabledButtons.has(item.id)) {
127
+ return
128
+ }
129
+ activateItem(e, item, activateCtx)
130
+ if (!(item.isPressed !== undefined || item.pressedWhen)) {
131
+ instigator.focus()
132
+ setIsOpen(false)
133
+ }
134
+ }
135
+
136
+ return (e) => {
137
+ if (['Escape', 'Esc'].includes(e.key)) {
138
+ closeAndFocus(e, true)
139
+ return
140
+ }
141
+ if (e.key === 'Tab') {
142
+ closeAndFocus(e)
143
+ return
144
+ }
145
+ if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
146
+ navigateVisible(e)
147
+ return
148
+ }
149
+ if (e.key === 'Home' && visibleIndices.length) {
150
+ setIndex(visibleIndices[0])
151
+ return
152
+ }
153
+ if (e.key === 'End' && visibleIndices.length) {
154
+ setIndex(visibleIndices[visibleIndices.length - 1])
155
+ return
156
+ }
157
+ if (e.key === 'Enter') {
158
+ handleEnter(e)
159
+ }
160
+ if (e.key === ' ') {
161
+ handleSpace(e)
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Custom hook encapsulating all state and event-handler logic for PopupMenu.
168
+ *
169
+ * @param {object} params
170
+ * @param {Array} params.items - Menu item descriptors.
171
+ * @param {Set} params.hiddenButtons - IDs of items that should not be visible.
172
+ * @param {number} [params.startIndex] - Exact index to select on mount; takes precedence over startPos.
173
+ * @param {string} [params.startPos] - 'first' | 'last' — initial selection strategy.
174
+ * @param {object} params.instigator - DOM node of the button that opened the menu.
175
+ * @param {string} params.instigatorKey - Key used to look up instigator in buttonRefs.
176
+ * @param {object} params.buttonRefs - Ref map of all registered button DOM nodes.
177
+ * @param {object} params.buttonConfig - Config map that may override item onClick handlers.
178
+ * @param {Set} params.disabledButtons - IDs of currently disabled items.
179
+ * @param {string} params.pluginId - Plugin context passed to evaluateProp.
180
+ * @param {Function} params.evaluateProp - Context evaluator from useEvaluateProp.
181
+ * @param {string} params.id - App-level ID prefix for DOM element IDs.
182
+ * @param {object} params.menuRef - Ref to the menu UL element.
183
+ * @param {Function} params.setIsOpen - Callback to open/close the menu.
184
+ * @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
185
+ * @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
186
+ * menuStyle: object, menuDirection: string, menuHAlign: string }}
187
+ */
188
+ export const usePopupMenu = ({
189
+ items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
190
+ buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
191
+ }) => {
192
+ const visibleIndices = useMemo(() => {
193
+ const visible = []
194
+ items.forEach((item, idx) => {
195
+ if (!hiddenButtons.has(item.id)) {
196
+ visible.push(idx)
197
+ }
198
+ })
199
+ return visible
200
+ }, [items, hiddenButtons])
201
+
202
+ const [index, setIndex] = useState(() => {
203
+ if (typeof startIndex === 'number') {
204
+ return startIndex
205
+ }
206
+ if (startPos === 'first') {
207
+ return visibleIndices[0] ?? -1
208
+ }
209
+ if (startPos === 'last') {
210
+ return visibleIndices[visibleIndices.length - 1] ?? -1
211
+ }
212
+ return -1
213
+ })
214
+
215
+ const activateCtx = { buttonConfig, evaluateProp, pluginId, id }
216
+
217
+ const handleMenuKeyDown = createMenuKeyDownHandler({
218
+ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx
219
+ })
220
+
221
+ const handleOutside = (e) => {
222
+ if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
223
+ return
224
+ }
225
+ setIsOpen(false)
226
+ }
227
+
228
+ const handleItemClick = (e, item) => {
229
+ if (e.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) {
230
+ return
231
+ }
232
+ setIsOpen(false)
233
+ activateItem(e, item, activateCtx)
234
+ }
235
+
236
+ useEffect(() => {
237
+ menuRef.current?.focus()
238
+ if (startPos === 'first') {
239
+ setIndex(visibleIndices[0] ?? -1)
240
+ } else if (startPos === 'last') {
241
+ setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
242
+ } else {
243
+ // No action
244
+ }
245
+ const handleResize = () => setIsOpen(false)
246
+ document.addEventListener('focusin', handleOutside)
247
+ document.addEventListener('pointerdown', handleOutside)
248
+ window.addEventListener('resize', handleResize)
249
+ return () => {
250
+ document.removeEventListener('focusin', handleOutside)
251
+ document.removeEventListener('pointerdown', handleOutside)
252
+ window.removeEventListener('resize', handleResize)
253
+ }
254
+ }, [])
255
+
256
+ const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
257
+ return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
258
+ }
@@ -1,4 +1,3 @@
1
- // src/core/hooks/useButtonStateEvaluator.js
2
1
  import { useLayoutEffect, useContext } from 'react'
3
2
  import { useApp } from '../store/appContext.js'
4
3
  import { useConfig } from '../store/configContext.js'
@@ -61,6 +60,12 @@ export function useButtonStateEvaluator (evaluateProp) {
61
60
  }
62
61
 
63
62
  const { dispatch } = appState
63
+ let dispatchCount = 0
64
+
65
+ const trackingDispatch = (action) => {
66
+ dispatchCount++
67
+ dispatch(action)
68
+ }
64
69
 
65
70
  pluginRegistry.registeredPlugins.forEach(plugin => {
66
71
  const buttons = (plugin?.manifest?.buttons ?? []).flatMap(b => [b, ...(b.menuItems ?? [])])
@@ -70,10 +75,15 @@ export function useButtonStateEvaluator (evaluateProp) {
70
75
  btn,
71
76
  pluginId: plugin.id,
72
77
  appState,
73
- dispatch,
78
+ dispatch: trackingDispatch,
74
79
  evaluateProp
75
80
  })
76
81
  )
77
82
  })
83
+
84
+ if (dispatchCount === 0 && !appState.arePluginsEvaluated) {
85
+ // No changes and flag not yet set — all button states have settled.
86
+ dispatch({ type: 'PLUGINS_EVALUATED' })
87
+ }
78
88
  }, [appState, pluginContext, evaluateProp])
79
89
  }
@@ -22,6 +22,7 @@ describe('useButtonStateEvaluator', () => {
22
22
  hiddenButtons: new Set(),
23
23
  pressedButtons: new Set(),
24
24
  expandedButtons: new Set(),
25
+ arePluginsEvaluated: true, // stable by default; override in settlement tests
25
26
  dispatch: mockDispatch
26
27
  }
27
28
  useApp.mockReturnValue(mockAppState)
@@ -149,8 +150,6 @@ describe('useButtonStateEvaluator', () => {
149
150
  })
150
151
 
151
152
  it('covers fallback to empty array when manifest or buttons is missing', () => {
152
- // Branch 1: Plugin exists but manifest is missing
153
- // Branch 2: Manifest exists but buttons is missing
154
153
  mockPluginRegistry.registeredPlugins = [
155
154
  { id: 'p1' },
156
155
  { id: 'p2', manifest: {} },
@@ -158,9 +157,44 @@ describe('useButtonStateEvaluator', () => {
158
157
  ]
159
158
 
160
159
  renderHook(() => useButtonStateEvaluator((fn) => fn()))
160
+ expect(mockDispatch).not.toHaveBeenCalled()
161
+ })
162
+
163
+ // --- Plugin evaluation settlement ---
164
+
165
+ it('dispatches PLUGINS_EVALUATED when no button states changed and arePluginsEvaluated is false', () => {
166
+ mockAppState.arePluginsEvaluated = false
167
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
168
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' })
169
+ })
161
170
 
162
- // If the fallback (|| []) works, the code continues to the next plugin
163
- // without throwing a "cannot read property forEach of undefined" error.
171
+ it('does not dispatch PLUGINS_EVALUATED when arePluginsEvaluated is already true', () => {
172
+ mockAppState.arePluginsEvaluated = true
173
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
164
174
  expect(mockDispatch).not.toHaveBeenCalled()
165
175
  })
176
+
177
+ it('does not dispatch CLEAR_PLUGINS_EVALUATED or PLUGINS_EVALUATED when button states change', () => {
178
+ mockAppState.arePluginsEvaluated = false
179
+ mockPluginRegistry.registeredPlugins = [{
180
+ id: 'p1',
181
+ manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] }
182
+ }]
183
+
184
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
185
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id: 'btn1', isHidden: true } })
186
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
187
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' })
188
+ })
189
+
190
+ it('does not dispatch CLEAR_PLUGINS_EVALUATED when button states change and already evaluated', () => {
191
+ mockAppState.arePluginsEvaluated = true
192
+ mockPluginRegistry.registeredPlugins = [{
193
+ id: 'p1',
194
+ manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] }
195
+ }]
196
+
197
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
198
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
199
+ })
166
200
  })
@@ -54,12 +54,16 @@ export const useInterfaceAPI = () => {
54
54
  }
55
55
  }
56
56
 
57
+ const handleAppVisible = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
+ const handleAppHidden = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
57
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
58
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
59
61
  const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } })
60
62
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
61
63
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
62
64
 
65
+ eventBus.on(events.APP_VISIBLE, handleAppVisible)
66
+ eventBus.on(events.APP_HIDDEN, handleAppHidden)
63
67
  eventBus.on(events.APP_ADD_BUTTON, handleAddButton)
64
68
  eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
65
69
  eventBus.on(events.APP_ADD_PANEL, handleAddPanel)
@@ -69,6 +73,8 @@ export const useInterfaceAPI = () => {
69
73
  eventBus.on(events.APP_ADD_CONTROL, handleAddControl)
70
74
 
71
75
  return () => {
76
+ eventBus.off(events.APP_VISIBLE, handleAppVisible)
77
+ eventBus.off(events.APP_HIDDEN, handleAppHidden)
72
78
  eventBus.off(events.APP_ADD_BUTTON, handleAddButton)
73
79
  eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
74
80
  eventBus.off(events.APP_ADD_PANEL, handleAddPanel)
@@ -12,8 +12,47 @@ const topColWidth = (left, right) =>
12
12
  const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
13
13
  columnHeight - (siblingButtons ? siblingButtons + gap : 0)
14
14
 
15
+ /**
16
+ * Manages all layout measurements for the map overlay and dispatches the safe
17
+ * zone inset used by the map to pad `fitBounds` / `setView` operations.
18
+ *
19
+ * ## Lifecycle
20
+ *
21
+ * The safe zone must only be dispatched once every plugin button's reactive
22
+ * props (`hiddenWhen`, `enableWhen`, `pressedWhen`, `expandedWhen`) have been
23
+ * evaluated for the current app/map state. Dispatching too early — before
24
+ * buttons that affect layout (e.g. the actions bar) have their correct
25
+ * visibility — produces a stale inset that causes the map to jump when the UI
26
+ * then settles into its real state.
27
+ *
28
+ * ### Trigger events
29
+ * The following state changes can alter which buttons are visible and therefore
30
+ * how much space the UI occupies:
31
+ * - `breakpoint` — responsive layout changes (desktop ↔ mobile / tablet)
32
+ * - `mapSize` — map container size variant changes
33
+ * - `isMapReady` — plugins are enabled on `map:ready`, changing button visibility
34
+ * - `isFullscreen` — fullscreen entry/exit changes which buttons are visible
35
+ * - `appVisible` — app shown/hidden by parent HTML outside React (hybrid mode)
36
+ *
37
+ * When any of these change, `CLEAR_PLUGINS_EVALUATED` is dispatched (Effect 2),
38
+ * which prevents the safe zone from being re-dispatched until
39
+ * `useButtonStateEvaluator` has completed a full pass with no button state
40
+ * changes and sets `PLUGINS_EVALUATED` again.
41
+ *
42
+ * ### Safe zone dispatch
43
+ * Effect 3 fires whenever `arePluginsEvaluated` transitions to `true`, at which
44
+ * point DOM dimensions are stable and `getSafeZoneInset` can be read reliably.
45
+ * A `requestAnimationFrame` is used to ensure the browser has committed all
46
+ * layout changes before measuring.
47
+ *
48
+ * ### Resize observer
49
+ * Effect 4 keeps CSS custom properties up to date whenever any observed element
50
+ * resizes (e.g. panels opening, banner appearing, actions buttons toggling).
51
+ * It does not dispatch the safe zone — safe zone dispatch is owned entirely by
52
+ * Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
53
+ */
15
54
  export function useLayoutMeasurements () {
16
- const { dispatch, breakpoint, layoutRefs } = useApp()
55
+ const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
17
56
  const { mapSize, isMapReady } = useMap()
18
57
 
19
58
  const {
@@ -23,24 +62,28 @@ export function useLayoutMeasurements () {
23
62
  topRef,
24
63
  topLeftColRef,
25
64
  topRightColRef,
26
- footerRef,
27
- actionsRef,
28
65
  leftTopRef,
29
66
  leftBottomRef,
30
67
  rightTopRef,
31
- rightBottomRef
68
+ rightBottomRef,
69
+ bottomRef,
70
+ bottomRightRef,
71
+ attributionsRef,
72
+ drawerRef,
73
+ actionsRef
32
74
  } = layoutRefs
33
75
 
34
- // -----------------------------
35
- // 1. Calculate layout CSS vars (side effect)
36
- // -----------------------------
76
+ // --------------------------------
77
+ // 1. Calculate layout CSS vars (pure side effect, no dispatch)
78
+ // --------------------------------
37
79
  const calculateLayout = () => {
38
80
  const appContainer = appContainerRef.current
39
81
  const main = mainRef.current
40
82
  const top = topRef.current
41
83
  const topLeftCol = topLeftColRef.current
42
84
  const topRightCol = topRightColRef.current
43
- const bottom = footerRef.current
85
+ const bottom = bottomRef.current
86
+ const attributions = attributionsRef.current
44
87
 
45
88
  if ([main, top, bottom].some(r => !r)) {
46
89
  return
@@ -60,10 +103,17 @@ export function useLayoutMeasurements () {
60
103
  appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
61
104
 
62
105
  // === Right container offsets ===
106
+ // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
107
+ // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
108
+ // the padding between the bottom of the bottom container and the bottom of main.
109
+ const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
110
+ const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
63
111
  const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
64
- const rightColumnHeight = bottom.offsetTop - rightOffsetTop - dividerGap
112
+ const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
113
+ const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
114
+ const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
65
115
  appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
66
- appContainer.style.setProperty('--right-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
116
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
67
117
  appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
68
118
 
69
119
  // === Sub-slot panel max-heights ===
@@ -74,22 +124,38 @@ export function useLayoutMeasurements () {
74
124
  }
75
125
 
76
126
  // --------------------------------
77
- // 2. Run when breakpoint and mapSize change
127
+ // 2. Clear the evaluated flag when structural inputs change so the safe zone
128
+ // is not dispatched until useButtonStateEvaluator has completed a full
129
+ // pass with the new app/map state and set PLUGINS_EVALUATED.
78
130
  // --------------------------------
79
131
  useLayoutEffect(() => {
80
- requestAnimationFrame(() => { // Required for Preact
81
- calculateLayout()
132
+ dispatch({ type: 'CLEAR_PLUGINS_EVALUATED' })
133
+ }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
82
134
 
83
- // === Set safe zone inset ===
135
+ // --------------------------------
136
+ // 3. Once all plugin button props have been evaluated (arePluginsEvaluated),
137
+ // recalculate layout and dispatch the safe zone inset.
138
+ // RAF required to ensure browser layout is committed before measuring.
139
+ // --------------------------------
140
+ useLayoutEffect(() => {
141
+ if (!arePluginsEvaluated) {
142
+ return
143
+ }
144
+ requestAnimationFrame(() => {
145
+ calculateLayout()
84
146
  const safeZoneInset = getSafeZoneInset(layoutRefs)
85
- dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
147
+ if (safeZoneInset) {
148
+ dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
149
+ }
86
150
  })
87
- }, [breakpoint, mapSize, isMapReady])
151
+ }, [arePluginsEvaluated])
88
152
 
89
153
  // --------------------------------
90
- // 3. Recaluclate CSS vars when elements resize
154
+ // 4. Recalculate CSS vars whenever observed elements resize (panels, banner,
155
+ // actions buttons, etc.). Safe zone is intentionally not dispatched here —
156
+ // that is Effect 3's responsibility.
91
157
  // --------------------------------
92
- useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef], () => {
158
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
93
159
  requestAnimationFrame(() => {
94
160
  calculateLayout()
95
161
  })