@defra/interactive-map 0.0.4-alpha → 0.0.5-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 (202) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  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/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +95 -0
  65. package/plugins/interact/src/utils/spatial.test.js +93 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +3 -0
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +25 -1
  164. package/src/App/store/appActionsMap.test.js +21 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_dimensions.scss +6 -2
  184. package/src/services/eventBus.test.js +24 -1
  185. package/src/test-utils.js +1 -0
  186. package/src/types.js +5 -0
  187. package/src/utils/detectBreakpoint.js +78 -77
  188. package/src/utils/detectBreakpoint.test.js +50 -4
  189. package/src/utils/detectInterfaceType.js +4 -4
  190. package/src/utils/getIsFullscreen.js +3 -1
  191. package/src/utils/getSafeZoneInset.js +23 -7
  192. package/src/utils/getSafeZoneInset.test.js +18 -0
  193. package/src/utils/mapStateSync.js +3 -3
  194. package/src/utils/queryString.js +1 -1
  195. package/src/utils/stringToKebab.js +1 -1
  196. package/src/utils/toggleInertElements.js +37 -12
  197. package/webpack.dev.mjs +3 -4
  198. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  199. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  200. package/plugins/search/src/utils/fetchDataset.js +0 -23
  201. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  202. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -71,7 +71,7 @@
71
71
  height: 66.66%;
72
72
 
73
73
  > *:first-child {
74
- transform: scale(150%);
74
+ transform: scale(1.5);
75
75
  }
76
76
  }
77
77
 
@@ -80,7 +80,7 @@
80
80
  height: 50%;
81
81
 
82
82
  > *:first-child {
83
- transform: scale(200%);
83
+ transform: scale(2);
84
84
  }
85
85
  }
86
86
 
@@ -35,11 +35,12 @@ export const createKeyboardActions = (mapProvider, announce, {
35
35
 
36
36
  zoomOut: (e) => mapProvider.zoomOut(getZoom(e.shiftKey)),
37
37
 
38
- getInfo: async (e) => {
38
+ getInfo: async (_e) => {
39
39
  const coord = mapProvider.getCenter()
40
40
  const place = await reverseGeocode(mapProvider.getZoom(), coord)
41
41
  const area = mapProvider.getAreaDimensions?.()
42
- announce(`${place}.${area ? ' Covering ' + area + '.' : ''}`, 'core')
42
+ const message = area ? `${place}. Covering ${area}.` : `${place}.`
43
+ announce(message, 'core')
43
44
  },
44
45
 
45
46
  highlightNextLabel: (e) => {
@@ -48,13 +48,15 @@ export const coreShortcuts = [
48
48
  group: 'Labels',
49
49
  title: 'Highlight label at centre',
50
50
  command: '<kbd>Alt</kbd> + <kbd>Enter</kbd>',
51
- enabled: false
51
+ enabled: false,
52
+ requiredConfig: ['readMapText']
52
53
  },
53
54
  {
54
55
  id: 'highlightNextLabel',
55
56
  group: 'Labels',
56
57
  title: 'Highlight nearby label',
57
58
  command: '<kbd>Alt</kbd> + <kbd>→</kbd>, <kbd>←</kbd>, <kbd>↑</kbd> or <kbd>↓</kbd>',
58
- enabled: false
59
+ enabled: false,
60
+ requiredConfig: ['readMapText']
59
61
  }
60
62
  ]
@@ -4,6 +4,52 @@ import { useApp } from '../store/appContext.js'
4
4
  import { useConfig } from '../store/configContext.js'
5
5
  import { PluginContext } from '../store/PluginProvider.jsx'
6
6
 
7
+ function evaluateButtonState ({ btn, pluginId, appState, dispatch, evaluateProp }) {
8
+ const checks = [{
9
+ prop: 'enableWhen',
10
+ state: appState.disabledButtons,
11
+ action: 'TOGGLE_BUTTON_DISABLED',
12
+ invert: true,
13
+ key: 'isDisabled'
14
+ },
15
+ {
16
+ prop: 'hiddenWhen',
17
+ state: appState.hiddenButtons,
18
+ action: 'TOGGLE_BUTTON_HIDDEN',
19
+ key: 'isHidden'
20
+ },
21
+ {
22
+ prop: 'pressedWhen',
23
+ state: appState.pressedButtons,
24
+ action: 'TOGGLE_BUTTON_PRESSED',
25
+ key: 'isPressed'
26
+ },
27
+ {
28
+ prop: 'expandedWhen',
29
+ state: appState.expandedButtons,
30
+ action: 'TOGGLE_BUTTON_EXPANDED',
31
+ key: 'isExpanded'
32
+ }]
33
+
34
+ checks.forEach(({ prop, state, action, key, invert }) => {
35
+ if (typeof btn[prop] !== 'function') {
36
+ return
37
+ }
38
+
39
+ try {
40
+ const result = evaluateProp(btn[prop], pluginId)
41
+ const next = invert ? !result : result
42
+ const current = state.has(btn.id)
43
+
44
+ if (current !== next) {
45
+ dispatch({ type: action, payload: { id: btn.id, [key]: next } })
46
+ }
47
+ } catch (err) {
48
+ console.warn(`${prop} error for button ${btn.id}:`, err)
49
+ }
50
+ })
51
+ }
52
+
7
53
  export function useButtonStateEvaluator (evaluateProp) {
8
54
  const appState = useApp()
9
55
  const { pluginRegistry } = useConfig()
@@ -13,54 +59,21 @@ export function useButtonStateEvaluator (evaluateProp) {
13
59
  if (!appState?.dispatch || !pluginContext) {
14
60
  return
15
61
  }
16
- const { dispatch } = appState
17
62
 
18
- pluginRegistry.registeredPlugins.forEach((plugin) => {
19
- const buttons = plugin?.manifest?.buttons || []
20
-
21
- buttons.forEach((btn) => {
22
- const pluginId = plugin.id
23
-
24
- // EnableWhen
25
- if (typeof btn.enableWhen === 'function') {
26
- try {
27
- const shouldBeEnabled = evaluateProp(btn.enableWhen, pluginId)
28
- const currentlyDisabled = appState.disabledButtons.has(btn.id)
29
- const isDisabled = !shouldBeEnabled
30
- if (currentlyDisabled !== isDisabled) {
31
- dispatch({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id: btn.id, isDisabled } })
32
- }
33
- } catch (err) {
34
- console.warn(`enableWhen error for button ${btn.id}:`, err)
35
- }
36
- }
63
+ const { dispatch } = appState
37
64
 
38
- // HiddenWhen
39
- if (typeof btn.hiddenWhen === 'function') {
40
- try {
41
- const shouldBeHidden = evaluateProp(btn.hiddenWhen, pluginId)
42
- const currentlyHidden = appState.hiddenButtons.has(btn.id)
43
- if (currentlyHidden !== shouldBeHidden) {
44
- dispatch({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id: btn.id, isHidden: shouldBeHidden } })
45
- }
46
- } catch (err) {
47
- console.warn(`hiddenWhen error for button ${btn.id}:`, err)
48
- }
49
- }
65
+ pluginRegistry.registeredPlugins.forEach(plugin => {
66
+ const buttons = (plugin?.manifest?.buttons ?? []).flatMap(b => [b, ...(b.menuItems ?? [])])
50
67
 
51
- // PressedWhen
52
- if (typeof btn.pressedWhen === 'function') {
53
- try {
54
- const shouldBePressed = evaluateProp(btn.pressedWhen, pluginId)
55
- const currentlyPressed = appState.pressedButtons.has(btn.id)
56
- if (currentlyPressed !== shouldBePressed) {
57
- dispatch({ type: 'TOGGLE_BUTTON_PRESSED', payload: { id: btn.id, isPressed: shouldBePressed } })
58
- }
59
- } catch (err) {
60
- console.warn(`pressedWhen error for button ${btn.id}:`, err)
61
- }
62
- }
63
- })
68
+ buttons.forEach(btn =>
69
+ evaluateButtonState({
70
+ btn,
71
+ pluginId: plugin.id,
72
+ appState,
73
+ dispatch,
74
+ evaluateProp
75
+ })
76
+ )
64
77
  })
65
78
  }, [appState, pluginContext, evaluateProp])
66
79
  }
@@ -21,6 +21,7 @@ describe('useButtonStateEvaluator', () => {
21
21
  disabledButtons: new Set(),
22
22
  hiddenButtons: new Set(),
23
23
  pressedButtons: new Set(),
24
+ expandedButtons: new Set(),
24
25
  dispatch: mockDispatch
25
26
  }
26
27
  useApp.mockReturnValue(mockAppState)
@@ -89,15 +90,30 @@ describe('useButtonStateEvaluator', () => {
89
90
  })
90
91
  })
91
92
 
93
+ it('toggles button expanded state based on expandedWhen', () => {
94
+ mockPluginRegistry.registeredPlugins = [{
95
+ id: 'p1',
96
+ manifest: { buttons: [{ id: 'btn1', expandedWhen: () => true }] }
97
+ }]
98
+
99
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
100
+ expect(mockDispatch).toHaveBeenCalledWith({
101
+ type: 'TOGGLE_BUTTON_EXPANDED',
102
+ payload: { id: 'btn1', isExpanded: true }
103
+ })
104
+ })
105
+
92
106
  it('does not dispatch if state already matches', () => {
93
107
  mockAppState.disabledButtons.add('btn1')
94
108
  mockAppState.hiddenButtons.add('btn2')
95
109
  mockAppState.pressedButtons.add('btn3')
110
+ mockAppState.expandedButtons.add('btn4')
96
111
 
97
112
  mockPluginRegistry.registeredPlugins = [
98
113
  { id: 'p1', manifest: { buttons: [{ id: 'btn1', enableWhen: () => false }] } },
99
114
  { id: 'p2', manifest: { buttons: [{ id: 'btn2', hiddenWhen: () => true }] } },
100
- { id: 'p3', manifest: { buttons: [{ id: 'btn3', pressedWhen: () => true }] } }
115
+ { id: 'p3', manifest: { buttons: [{ id: 'btn3', pressedWhen: () => true }] } },
116
+ { id: 'p4', manifest: { buttons: [{ id: 'btn4', expandedWhen: () => true }] } }
101
117
  ]
102
118
 
103
119
  renderHook(() => useButtonStateEvaluator((fn) => fn()))
@@ -112,13 +128,14 @@ describe('useButtonStateEvaluator', () => {
112
128
  buttons: [
113
129
  { id: 'btn1', enableWhen: failingFn },
114
130
  { id: 'btn2', hiddenWhen: failingFn },
115
- { id: 'btn3', pressedWhen: failingFn }
131
+ { id: 'btn3', pressedWhen: failingFn },
132
+ { id: 'btn4', expandedWhen: failingFn }
116
133
  ]
117
134
  }
118
135
  }]
119
136
 
120
137
  renderHook(() => useButtonStateEvaluator((fn, pluginId) => fn(pluginId)))
121
- expect(console.warn).toHaveBeenCalledTimes(3)
138
+ expect(console.warn).toHaveBeenCalledTimes(4)
122
139
  expect(mockDispatch).not.toHaveBeenCalled()
123
140
  })
124
141
 
@@ -130,4 +147,20 @@ describe('useButtonStateEvaluator', () => {
130
147
  renderHook(() => useButtonStateEvaluator((fn) => fn({ pluginState: {} })))
131
148
  expect(enableWhen).toHaveBeenCalled()
132
149
  })
150
+
151
+ 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
+ mockPluginRegistry.registeredPlugins = [
155
+ { id: 'p1' },
156
+ { id: 'p2', manifest: {} },
157
+ { id: 'p3', manifest: { buttons: null } }
158
+ ]
159
+
160
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
161
+
162
+ // If the fallback (|| []) works, the code continues to the next plugin
163
+ // without throwing a "cannot read property forEach of undefined" error.
164
+ expect(mockDispatch).not.toHaveBeenCalled()
165
+ })
133
166
  })
@@ -6,6 +6,53 @@ import { useService } from '../store/serviceContext.js'
6
6
  import { scaleFactor } from '../../config/appConfig.js'
7
7
  import { EVENTS as events } from '../../config/events.js'
8
8
 
9
+ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition) => {
10
+ crossHair.pinToMap = (coords, state) => {
11
+ const { x, y } = mapProvider.mapToScreen(coords)
12
+ crossHair.coords = coords
13
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: true, isVisible: true, coords, state } })
14
+ updatePosition(el, x, y)
15
+ }
16
+
17
+ crossHair.fixAtCenter = () => {
18
+ el.style.left = '50%'
19
+ el.style.top = '50%'
20
+ el.style.transform = 'translate(0,0)'
21
+ el.style.display = 'block'
22
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: true } })
23
+ }
24
+
25
+ crossHair.remove = () => {
26
+ el.style.display = 'none'
27
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: false } })
28
+ }
29
+
30
+ crossHair.show = () => {
31
+ el.style.display = 'block'
32
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } })
33
+ }
34
+
35
+ crossHair.hide = () => {
36
+ el.style.display = 'none'
37
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
38
+ }
39
+
40
+ crossHair.setStyle = (state) => {
41
+ dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { state } })
42
+ }
43
+
44
+ crossHair.getDetail = () => {
45
+ const coords = crossHair.isPinnedToMap ? crossHair.coords : mapProvider.getCenter()
46
+
47
+ return {
48
+ state: crossHair.state,
49
+ point: mapProvider.mapToScreen(coords),
50
+ zoom: mapProvider.getZoom(),
51
+ coords
52
+ }
53
+ }
54
+ }
55
+
9
56
  export const useCrossHair = () => {
10
57
  const { mapProvider } = useConfig()
11
58
  const { safeZoneInset } = useApp()
@@ -25,55 +72,10 @@ export const useCrossHair = () => {
25
72
 
26
73
  const crossHairRef = useCallback(el => {
27
74
  if (!el) {
28
- return
29
- }
30
-
31
- // --- API ---
32
-
33
- crossHair.pinToMap = (coords, state) => {
34
- const { x, y } = mapProvider.mapToScreen(coords)
35
- crossHair.coords = coords
36
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: true, isVisible: true, coords, state } })
37
- updatePosition(el, x, y)
38
- }
39
-
40
- crossHair.fixAtCenter = () => {
41
- el.style.left = '50%'
42
- el.style.top = '50%'
43
- el.style.transform = 'translate(0,0)'
44
- el.style.display = 'block'
45
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: true } })
46
- }
47
-
48
- crossHair.remove = () => {
49
- el.style.display = 'none'
50
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: false } })
75
+ return undefined
51
76
  }
52
77
 
53
- crossHair.show = () => {
54
- el.style.display = 'block'
55
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } })
56
- }
57
-
58
- crossHair.hide = () => {
59
- el.style.display = 'none'
60
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
61
- }
62
-
63
- crossHair.setStyle = (state) => {
64
- dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { state } })
65
- }
66
-
67
- crossHair.getDetail = () => {
68
- const coords = crossHair.isPinnedToMap ? crossHair.coords : mapProvider.getCenter()
69
-
70
- return {
71
- state: crossHair.state,
72
- point: mapProvider.mapToScreen(coords),
73
- zoom: mapProvider.getZoom(),
74
- coords
75
- }
76
- }
78
+ assignCrossHairAPI(crossHair, el, mapProvider, dispatch, updatePosition)
77
79
 
78
80
  const handleRender = () => {
79
81
  if (crossHair.coords && crossHair.isPinnedToMap) {
@@ -11,7 +11,7 @@ export function useFocusVisible () {
11
11
  useEffect(() => {
12
12
  const scope = layoutRefs.appContainerRef.current
13
13
  if (!scope) {
14
- return
14
+ return undefined
15
15
  }
16
16
 
17
17
  function handleFocusIn (e) {
@@ -22,7 +22,7 @@ export function useFocusVisible () {
22
22
  delete e.target.dataset.focusVisible
23
23
  }
24
24
 
25
- function handlePointerdown (e) {
25
+ function handlePointerdown () {
26
26
  delete document.activeElement.dataset.focusVisible
27
27
  }
28
28
 
@@ -5,12 +5,19 @@ import { useApp } from '../store/appContext.js'
5
5
  import { useService } from '../store/serviceContext.js'
6
6
 
7
7
  export const useInterfaceAPI = () => {
8
- const { dispatch, hiddenButtons, pressedButtons, disabledButtons } = useApp()
8
+ const { dispatch, hiddenButtons, disabledButtons, pressedButtons, expandedButtons } = useApp()
9
9
  const { eventBus } = useService()
10
10
 
11
11
  useEffect(() => {
12
12
  const handleAddButton = ({ id, config }) => {
13
+ // Add the button
13
14
  dispatch({ type: 'ADD_BUTTON', payload: { id, config } })
15
+ // Add all optional menu items as individual buttons
16
+ if (Array.isArray(config.menuItems)) {
17
+ config.menuItems.forEach(item => {
18
+ dispatch({ type: 'ADD_BUTTON', payload: { id: item.id, config: item } })
19
+ })
20
+ }
14
21
  }
15
22
 
16
23
  const handleToggleButtonState = ({ id, prop, value }) => {
@@ -20,14 +27,19 @@ export const useInterfaceAPI = () => {
20
27
  dispatch({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id, isHidden } })
21
28
  break
22
29
  }
30
+ case 'disabled': {
31
+ const isDisabled = typeof value === 'boolean' ? value : !disabledButtons.has(id)
32
+ dispatch({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id, isDisabled } })
33
+ break
34
+ }
23
35
  case 'pressed': {
24
36
  const isPressed = typeof value === 'boolean' ? value : !pressedButtons.has(id)
25
37
  dispatch({ type: 'TOGGLE_BUTTON_PRESSED', payload: { id, isPressed } })
26
38
  break
27
39
  }
28
- case 'disabled': {
29
- const isDisabled = typeof value === 'boolean' ? value : !disabledButtons.has(id)
30
- dispatch({ type: 'TOGGLE_BUTTON_DISABLED', payload: { id, isDisabled } })
40
+ case 'expanded': {
41
+ const isExpanded = typeof value === 'boolean' ? value : !expandedButtons.has(id)
42
+ dispatch({ type: 'TOGGLE_BUTTON_EXPANDED', payload: { id, isExpanded } })
31
43
  break
32
44
  }
33
45
  default:
@@ -11,7 +11,7 @@ export function useKeyboardHint ({
11
11
 
12
12
  useEffect(() => {
13
13
  if (!showHint || !containerRef.current) {
14
- return
14
+ return undefined
15
15
  }
16
16
 
17
17
  const containerEl = containerRef.current
@@ -13,7 +13,7 @@ export function useKeyboardShortcuts (containerRef) {
13
13
  useEffect(() => {
14
14
  const el = containerRef.current
15
15
  if (!el || interfaceType !== 'keyboard') {
16
- return
16
+ return undefined
17
17
  }
18
18
 
19
19
  const actions = createKeyboardActions(mapProvider, announce, {
@@ -31,7 +31,7 @@ export function useKeyboardShortcuts (containerRef) {
31
31
 
32
32
  // Use e.code for letters to avoid 'dead' keys with Alt/AltGr
33
33
  if (/^Key[A-Z]$/.test(e.code)) {
34
- key = e.code.slice(3) // "KeyI" -> "I"
34
+ key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
35
35
  } else {
36
36
  key = e.key // works for arrows, numpad, punctuation
37
37
  }
@@ -41,6 +41,8 @@ export function useKeyboardShortcuts (containerRef) {
41
41
  key = '+'
42
42
  } else if (key === 'Subtract' || key === 'NumpadSubtract') {
43
43
  key = '-'
44
+ } else {
45
+ // No action
44
46
  }
45
47
 
46
48
  return e.altKey ? `Alt+${key}` : key
@@ -5,6 +5,39 @@ import { useService } from '../store/serviceContext.js'
5
5
  import { getMapStatusMessage } from '../../utils/getMapStatusMessage.js'
6
6
  import { EVENTS as events } from '../../config/events.js'
7
7
 
8
+ const resolveMessage = (previous, current, mapProvider) => {
9
+ const zoomChanged = previous.zoom !== current.zoom
10
+ const centerChanged =
11
+ previous.center[0] !== current.center[0] ||
12
+ previous.center[1] !== current.center[1]
13
+
14
+ const areaDimensions = mapProvider.getAreaDimensions()
15
+
16
+ // Panned only
17
+ if (centerChanged && !zoomChanged) {
18
+ const direction = mapProvider.getCardinalMove(previous.center, current.center)
19
+ return getMapStatusMessage.moved({ direction, areaDimensions })
20
+ }
21
+
22
+ // Zoomed only
23
+ if (!centerChanged && zoomChanged) {
24
+ return getMapStatusMessage.zoomed({
25
+ ...current,
26
+ from: previous.zoom,
27
+ to: current.zoom,
28
+ areaDimensions
29
+ })
30
+ }
31
+
32
+ // No change
33
+ if (!centerChanged && !zoomChanged) {
34
+ return getMapStatusMessage.noChange({ ...current })
35
+ }
36
+
37
+ // Panned and zoomed
38
+ return getMapStatusMessage.newArea({ ...current, areaDimensions })
39
+ }
40
+
8
41
  export function useMapAnnouncements () {
9
42
  const { mapProvider } = useConfig()
10
43
  const { eventBus, announce } = useService()
@@ -15,38 +48,7 @@ export function useMapAnnouncements () {
15
48
  return
16
49
  }
17
50
 
18
- const zoomChanged = previous.zoom !== current.zoom
19
- const centerChanged =
20
- previous.center[0] !== current.center[0] ||
21
- previous.center[1] !== current.center[1]
22
-
23
- const areaDimensions = mapProvider.getAreaDimensions()
24
- let message
25
-
26
- if (centerChanged && !zoomChanged) {
27
- const direction = mapProvider.getCardinalMove(previous.center, current.center)
28
- message = getMapStatusMessage.moved({
29
- direction,
30
- areaDimensions
31
- })
32
- } else if (!centerChanged && zoomChanged) {
33
- message = getMapStatusMessage.zoomed({
34
- ...current,
35
- from: previous.zoom,
36
- to: current.zoom,
37
- areaDimensions
38
- })
39
- } else if (!centerChanged && !zoomChanged) {
40
- message = getMapStatusMessage.noChange({
41
- ...current
42
- })
43
- } else {
44
- message = getMapStatusMessage.newArea({
45
- ...current,
46
- areaDimensions
47
- })
48
- }
49
-
51
+ const message = resolveMessage(previous, current, mapProvider)
50
52
  if (message) {
51
53
  announce(message, 'core')
52
54
  }
@@ -2,13 +2,24 @@ import { useEffect } from 'react'
2
2
  import { useConfig } from '../store/configContext.js'
3
3
  import { useService } from '../store/serviceContext.js'
4
4
 
5
+ /**
6
+ * Subscribes to map events via the shared event bus for the lifetime of
7
+ * the consuming component. Handlers are automatically unsubscribed on
8
+ * unmount or when the eventMap reference changes.
9
+ *
10
+ * Supported events include clicks, pans, zooms, style changes and more
11
+ * — see `EVENTS` in `config/events.js` for the full list.
12
+ *
13
+ * @param {Object<string, Function>} [eventMap={}] - A map of event names to
14
+ * callback functions.
15
+ */
5
16
  export function useMapEvents (eventMap = {}) {
6
17
  const { mapProvider } = useConfig()
7
18
  const { eventBus } = useService()
8
19
 
9
20
  useEffect(() => {
10
21
  if (!mapProvider) {
11
- return
22
+ return undefined
12
23
  }
13
24
 
14
25
  const handlers = {}
@@ -32,14 +32,14 @@ export const useMapProviderOverrides = () => {
32
32
 
33
33
  useEffect(() => {
34
34
  if (!mapProvider) {
35
- return
35
+ return undefined
36
36
  }
37
37
 
38
38
  const originalFitToBounds = mapProvider.fitToBounds
39
39
 
40
40
  mapProvider.fitToBounds = (bounds, skipPaddingCalc = false) => {
41
41
  if (!bounds) {
42
- return
42
+ return undefined
43
43
  }
44
44
 
45
45
  // Calculate and set safe zone padding unless explicitly skipped
@@ -54,7 +54,7 @@ export const useMapProviderOverrides = () => {
54
54
 
55
55
  mapProvider.setView = ({ center, zoom }) => {
56
56
  if (!center) {
57
- return
57
+ return undefined
58
58
  }
59
59
 
60
60
  updatePadding()
@@ -4,6 +4,12 @@ import { useMap } from '../store/mapContext.js'
4
4
  import { useService } from '../store/serviceContext.js'
5
5
  import { EVENTS as events } from '../../config/events.js'
6
6
 
7
+ /**
8
+ * Keeps React state in sync with the map provider by listening to move,
9
+ * move-end and first-idle events. On each move-end it also emits a
10
+ * MAP_STATE_UPDATED event carrying both the previous and current state,
11
+ * which other hooks (e.g. useMapAnnouncements) use to determine what changed.
12
+ */
7
13
  export function useMapStateSync () {
8
14
  const { mapProvider } = useConfig()
9
15
  const { dispatch } = useMap()
@@ -14,7 +20,7 @@ export function useMapStateSync () {
14
20
 
15
21
  useEffect(() => {
16
22
  if (!mapProvider) {
17
- return
23
+ return undefined
18
24
  }
19
25
 
20
26
  // Handle map move
@@ -4,13 +4,18 @@ import { useConfig } from '../store/configContext.js'
4
4
  import { useService } from '../store/serviceContext.js'
5
5
  import { EVENTS as events } from '../../config/events.js'
6
6
 
7
+ /**
8
+ * Persists the current map center and zoom into the page URL whenever the
9
+ * map state changes, allowing the view to be restored on page reload or
10
+ * shared via a link.
11
+ */
7
12
  export function useMapURLSync () {
8
13
  const { id } = useConfig()
9
14
  const { eventBus } = useService()
10
15
 
11
16
  useEffect(() => {
12
17
  if (!id) {
13
- return
18
+ return undefined
14
19
  }
15
20
 
16
21
  const handleStateUpdate = ({ current }) => {