@defra/interactive-map 0.0.17-alpha → 0.0.19-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 (185) hide show
  1. package/assets/css/docusaurus.css +58 -34
  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/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +50 -23
  14. package/docs/assets/basic-map.jpg +0 -0
  15. package/docs/assets/button-first.jpg +0 -0
  16. package/docs/assets/maker-panel.jpg +0 -0
  17. package/docs/examples/add-marker-with-panel.mdx +59 -0
  18. package/docs/examples/basic-map.mdx +24 -0
  19. package/docs/examples/button-map.mdx +24 -0
  20. package/docs/examples/index.mdx +49 -0
  21. package/docs/index.mdx +1 -1
  22. package/docs/plugins/datasets.md +105 -9
  23. package/docs/plugins/interact.md +100 -44
  24. package/docs/plugins/search.md +15 -3
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +9 -1
  27. package/package.json +1 -1
  28. package/plugins/beta/datasets/dist/css/index.css +32 -14
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  32. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  33. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  34. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  35. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  36. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  37. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  38. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  39. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  40. package/plugins/beta/datasets/src/api/setData.js +4 -2
  41. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  42. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  43. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  44. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  45. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  46. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  47. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  48. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  49. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  50. package/plugins/beta/datasets/src/datasets.js +13 -4
  51. package/plugins/beta/datasets/src/defaults.js +4 -2
  52. package/plugins/beta/datasets/src/index.js +2 -1
  53. package/plugins/beta/datasets/src/manifest.js +1 -1
  54. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  55. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  56. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  57. package/plugins/beta/datasets/src/reducer.js +6 -0
  58. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  59. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  60. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  61. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  62. package/plugins/beta/draw-ml/dist/css/index.css +3 -0
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  66. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  67. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  68. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  69. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  70. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  71. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  72. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  73. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  74. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  75. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  76. package/plugins/interact/dist/umd/index.js +1 -1
  77. package/plugins/interact/src/InteractInit.jsx +19 -8
  78. package/plugins/interact/src/InteractInit.test.js +26 -6
  79. package/plugins/interact/src/api/clear.js +1 -1
  80. package/plugins/interact/src/api/enable.test.js +7 -7
  81. package/plugins/interact/src/api/selectMarker.js +14 -0
  82. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  83. package/plugins/interact/src/api/unselectMarker.js +14 -0
  84. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  85. package/plugins/interact/src/defaults.js +4 -6
  86. package/plugins/interact/src/events.js +27 -36
  87. package/plugins/interact/src/events.test.js +119 -90
  88. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  90. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  91. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  92. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  93. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  94. package/plugins/interact/src/manifest.js +10 -2
  95. package/plugins/interact/src/reducer.js +59 -5
  96. package/plugins/interact/src/reducer.test.js +100 -12
  97. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  98. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  99. package/plugins/interact/src/utils/featureQueries.js +11 -6
  100. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  101. package/plugins/interact/src/utils/interactionModes.js +12 -0
  102. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/src/Search.jsx +3 -1
  105. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  106. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  107. package/plugins/search/src/events/formHandlers.js +3 -3
  108. package/plugins/search/src/events/formHandlers.test.js +1 -1
  109. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  110. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  111. package/plugins/search/src/utils/updateMap.js +3 -3
  112. package/plugins/search/src/utils/updateMap.test.js +3 -3
  113. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  114. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/umd/index.js +1 -1
  116. package/providers/maplibre/src/appEvents.js +7 -0
  117. package/providers/maplibre/src/appEvents.test.js +18 -4
  118. package/providers/maplibre/src/maplibreProvider.js +52 -0
  119. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  120. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  121. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  122. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  123. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  124. package/providers/maplibre/src/utils/patternImages.js +70 -0
  125. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  126. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  127. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  128. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  129. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  130. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  131. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  132. package/src/App/components/Markers/Markers.jsx +122 -27
  133. package/src/App/components/Markers/Markers.module.scss +0 -10
  134. package/src/App/components/Markers/Markers.test.jsx +246 -0
  135. package/src/App/components/Panel/Panel.jsx +6 -6
  136. package/src/App/components/Panel/Panel.test.jsx +37 -0
  137. package/src/App/components/Viewport/Viewport.jsx +5 -15
  138. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  139. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  140. package/src/App/hooks/useInterfaceAPI.js +7 -7
  141. package/src/App/hooks/useInterfaceAPI.test.js +162 -0
  142. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  143. package/src/App/hooks/useMarkersAPI.js +2 -5
  144. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  145. package/src/App/layout/Layout.jsx +3 -3
  146. package/src/App/layout/Layout.test.jsx +4 -2
  147. package/src/App/layout/layout.module.scss +1 -8
  148. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  149. package/src/App/renderer/mapPanels.js +2 -1
  150. package/src/App/store/ServiceProvider.jsx +7 -5
  151. package/src/App/store/appActionsMap.js +4 -4
  152. package/src/App/store/appActionsMap.test.js +10 -0
  153. package/src/App/store/mapActionsMap.js +4 -6
  154. package/src/App/store/mapActionsMap.test.js +3 -2
  155. package/src/App/store/mapReducer.js +2 -1
  156. package/src/InteractiveMap/InteractiveMap.js +59 -11
  157. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  158. package/src/InteractiveMap/domStateManager.js +18 -6
  159. package/src/InteractiveMap/domStateManager.test.js +21 -0
  160. package/src/InteractiveMap/historyManager.js +28 -16
  161. package/src/InteractiveMap/historyManager.test.js +17 -0
  162. package/src/config/appConfig.js +2 -7
  163. package/src/config/appConfig.test.js +4 -15
  164. package/src/config/defaults.js +2 -3
  165. package/src/config/events.js +20 -21
  166. package/src/config/mapTheme.js +56 -0
  167. package/src/config/patternConfig.js +16 -0
  168. package/src/config/symbolConfig.js +80 -0
  169. package/src/scss/settings/_colors.scss +0 -9
  170. package/src/services/closeApp.js +1 -10
  171. package/src/services/closeApp.test.js +3 -43
  172. package/src/services/patternRegistry.js +40 -0
  173. package/src/services/patternRegistry.test.js +48 -0
  174. package/src/services/symbolRegistry.js +113 -0
  175. package/src/services/symbolRegistry.test.js +262 -0
  176. package/src/types.js +99 -12
  177. package/src/utils/mapStateSync.js +48 -10
  178. package/src/utils/mapStateSync.test.js +29 -9
  179. package/src/utils/patternUtils.js +94 -0
  180. package/src/utils/patternUtils.test.js +160 -0
  181. package/src/utils/symbolUtils.js +85 -0
  182. package/src/utils/symbolUtils.test.js +156 -0
  183. package/docs/examples.mdx +0 -70
  184. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  185. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -1,40 +1,38 @@
1
1
  import { attachEvents } from './events.js'
2
2
 
3
- describe('attachEvents', () => {
4
- let createParams, cleanup
5
-
6
- beforeEach(() => {
7
- jest.useFakeTimers()
8
- // factory function to create fresh params for each test
9
- createParams = () => {
10
- const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() }
11
- const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], closeOnAction: true, multiSelect: false }
12
- const clickReadyRef = { current: false }
13
- return {
14
- appState,
15
- pluginState,
16
- clickReadyRef,
17
- getAppState: () => appState,
18
- getPluginState: () => pluginState,
19
- mapState: {
20
- markers: { remove: jest.fn(), getMarker: jest.fn(() => null) },
21
- crossHair: { getDetail: jest.fn(() => ({ point: { x: 0, y: 0 }, coords: [0, 0] })) }
22
- },
23
- buttonConfig: { selectDone: {}, selectAtTarget: {}, selectCancel: {} },
24
- events: { MAP_CLICK: 'map:click' },
25
- eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() },
26
- handleInteraction: jest.fn(),
27
- closeApp: jest.fn()
28
- }
29
- }
30
- })
31
-
32
- afterEach(() => {
33
- cleanup?.()
34
- jest.useRealTimers()
35
- })
36
-
37
- it('keyboard Enter triggers only on viewport', () => {
3
+ const MOCK_POINT = { x: 1, y: 2 }
4
+ const MOCK_COORDS = [1, 2]
5
+ const INTERACT_DONE = 'interact:done'
6
+
7
+ const createParams = () => {
8
+ const appState = { layoutRefs: { viewportRef: { current: document.body } }, disabledButtons: new Set() }
9
+ const pluginState = { dispatch: jest.fn(), selectionBounds: null, selectedFeatures: [], selectedMarkers: [], closeOnAction: true, multiSelect: false }
10
+ const clickReadyRef = { current: false }
11
+ return {
12
+ appState,
13
+ pluginState,
14
+ clickReadyRef,
15
+ getAppState: () => appState,
16
+ getPluginState: () => pluginState,
17
+ mapState: {
18
+ markers: { remove: jest.fn(), getMarker: jest.fn(() => null) },
19
+ crossHair: { getDetail: jest.fn(() => ({ point: { x: 0, y: 0 }, coords: [0, 0] })) }
20
+ },
21
+ buttonConfig: { selectDone: {}, selectAtTarget: {}, selectCancel: {} },
22
+ events: { MAP_CLICK: 'map:click' },
23
+ eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() },
24
+ handleInteraction: jest.fn(),
25
+ closeApp: jest.fn()
26
+ }
27
+ }
28
+
29
+ describe('attachEvents — keyboard', () => {
30
+ let cleanup = null
31
+
32
+ beforeEach(() => { jest.useFakeTimers() })
33
+ afterEach(() => { cleanup?.(); jest.useRealTimers() })
34
+
35
+ it('Enter on viewport triggers interaction', () => {
38
36
  const params = createParams()
39
37
  cleanup = attachEvents(params)
40
38
 
@@ -54,7 +52,6 @@ describe('attachEvents', () => {
54
52
  cleanup = attachEvents(params)
55
53
  const input = document.createElement('input')
56
54
 
57
- // Enter outside viewport
58
55
  let kd = new KeyboardEvent('keydown', { key: 'Enter' })
59
56
  Object.defineProperty(kd, 'target', { value: input })
60
57
  document.dispatchEvent(kd)
@@ -62,7 +59,6 @@ describe('attachEvents', () => {
62
59
  Object.defineProperty(ku, 'target', { value: input })
63
60
  document.dispatchEvent(ku)
64
61
 
65
- // other key
66
62
  kd = new KeyboardEvent('keydown', { key: 'Space' })
67
63
  Object.defineProperty(kd, 'target', { value: document.body })
68
64
  document.dispatchEvent(kd)
@@ -72,6 +68,13 @@ describe('attachEvents', () => {
72
68
 
73
69
  expect(params.handleInteraction).not.toHaveBeenCalled()
74
70
  })
71
+ })
72
+
73
+ describe('attachEvents — click handling', () => {
74
+ let cleanup = null
75
+
76
+ beforeEach(() => { jest.useFakeTimers() })
77
+ afterEach(() => { cleanup?.(); jest.useRealTimers() })
75
78
 
76
79
  it('map click triggers interaction when clickReadyRef is true', () => {
77
80
  const params = createParams()
@@ -79,18 +82,17 @@ describe('attachEvents', () => {
79
82
  cleanup = attachEvents(params)
80
83
 
81
84
  const handler = params.eventBus.on.mock.calls.find(c => c[0] === 'map:click')[1]
82
- handler({ point: { x: 1, y: 2 }, coords: [3, 4] })
85
+ handler({ point: MOCK_POINT, coords: MOCK_COORDS })
83
86
 
84
- expect(params.handleInteraction).toHaveBeenCalledWith({ point: { x: 1, y: 2 }, coords: [3, 4] })
87
+ expect(params.handleInteraction).toHaveBeenCalledWith({ point: MOCK_POINT, coords: MOCK_COORDS })
85
88
  })
86
89
 
87
90
  it('map click is suppressed when clickReadyRef is false', () => {
88
91
  const params = createParams()
89
- params.clickReadyRef.current = false
90
92
  cleanup = attachEvents(params)
91
93
 
92
94
  const handler = params.eventBus.on.mock.calls.find(c => c[0] === 'map:click')[1]
93
- handler({ point: { x: 1, y: 2 }, coords: [3, 4] })
95
+ handler({ point: MOCK_POINT, coords: MOCK_COORDS })
94
96
 
95
97
  expect(params.handleInteraction).not.toHaveBeenCalled()
96
98
  })
@@ -99,111 +101,138 @@ describe('attachEvents', () => {
99
101
  const params = createParams()
100
102
  cleanup = attachEvents(params)
101
103
 
102
- const crossDetail = { point: { x: 1, y: 2 }, coords: [3, 4] }
104
+ const crossDetail = { point: MOCK_POINT, coords: MOCK_COORDS }
103
105
  params.mapState.crossHair.getDetail.mockReturnValue(crossDetail)
104
106
 
105
107
  params.buttonConfig.selectAtTarget.onClick()
106
108
  expect(params.handleInteraction).toHaveBeenCalledWith(crossDetail)
107
109
  })
110
+ })
111
+
112
+ describe('attachEvents — button actions', () => {
113
+ let cleanup = null
114
+
115
+ beforeEach(() => { jest.useFakeTimers() })
116
+ afterEach(() => { cleanup?.(); jest.useRealTimers() })
108
117
 
109
118
  it('selectDone emits correct payload and respects closeOnAction', () => {
110
119
  const params = createParams()
111
120
  cleanup = attachEvents(params)
112
121
 
113
- // closeOnAction = true (already covered)
114
- params.mapState.markers.getMarker.mockReturnValue({ coords: [1, 2] })
122
+ params.mapState.markers.getMarker.mockReturnValue({ coords: MOCK_COORDS })
115
123
  params.buttonConfig.selectDone.onClick()
116
124
  expect(params.closeApp).toHaveBeenCalled()
117
125
 
118
- // cover closeOnAction = false
119
126
  params.closeApp.mockClear()
120
127
  params.pluginState.closeOnAction = false
121
- params.mapState.markers.getMarker.mockReturnValue({ coords: [3, 4] })
122
128
  params.buttonConfig.selectDone.onClick()
123
129
  expect(params.closeApp).not.toHaveBeenCalled()
124
130
  })
125
131
 
126
- it('selectCancel emits cancel and respects closeOnAction', () => {
132
+ it('selectDone emits selectedFeatures and selectionBounds when no marker', () => {
127
133
  const params = createParams()
128
134
  cleanup = attachEvents(params)
129
135
 
130
- // closeOnAction = true
131
- params.buttonConfig.selectCancel.onClick()
132
- expect(params.closeApp).toHaveBeenCalled()
136
+ params.pluginState.selectedFeatures = [{ id: 'f1' }]
137
+ params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
138
+ params.buttonConfig.selectDone.onClick()
133
139
 
134
- // cover closeOnAction = false
135
- cleanup()
136
- const params2 = createParams()
137
- cleanup = attachEvents(params2)
138
- params2.pluginState.closeOnAction = false
139
- params2.buttonConfig.selectCancel.onClick()
140
- expect(params2.closeApp).not.toHaveBeenCalled()
140
+ expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE, {
141
+ selectedFeatures: [{ id: 'f1' }],
142
+ selectionBounds: { sw: [0, 0], ne: [1, 1] }
143
+ })
141
144
  })
142
145
 
143
- it('does not emit or closeApp if selectDone button is disabled', () => {
146
+ it('selectDone includes selectedMarkers in payload when present', () => {
144
147
  const params = createParams()
145
148
  cleanup = attachEvents(params)
146
149
 
147
- params.appState.disabledButtons.add('selectDone')
150
+ params.pluginState.selectedMarkers = ['m1', 'm2']
148
151
  params.buttonConfig.selectDone.onClick()
149
152
 
150
- expect(params.eventBus.emit).not.toHaveBeenCalled()
151
- expect(params.closeApp).not.toHaveBeenCalled()
153
+ expect(params.eventBus.emit).toHaveBeenCalledWith(INTERACT_DONE,
154
+ expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
155
+ )
152
156
  })
153
157
 
154
- it('programmatic select/unselect dispatches and removes location', () => {
158
+ it('selectDone omits selectedMarkers from payload when empty', () => {
155
159
  const params = createParams()
156
160
  cleanup = attachEvents(params)
157
161
 
158
- const selectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:selectFeature')[1]
159
- const unselectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:unselectFeature')[1]
160
-
161
- selectHandler({ featureId: 'F1' })
162
- unselectHandler({ featureId: 'F2' })
162
+ params.buttonConfig.selectDone.onClick()
163
163
 
164
- expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
165
- expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
164
+ const payload = params.eventBus.emit.mock.calls.find(c => c[0] === INTERACT_DONE)[1]
165
+ expect(payload).not.toHaveProperty('selectedMarkers')
166
166
  })
167
167
 
168
- it('cleanup removes all handlers', () => {
168
+ it('does not emit or closeApp if selectDone button is disabled', () => {
169
169
  const params = createParams()
170
170
  cleanup = attachEvents(params)
171
- cleanup()
172
- Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
171
+
172
+ params.appState.disabledButtons.add('selectDone')
173
+ params.buttonConfig.selectDone.onClick()
174
+
175
+ expect(params.eventBus.emit).not.toHaveBeenCalled()
176
+ expect(params.closeApp).not.toHaveBeenCalled()
173
177
  })
174
178
 
175
- it('selectDone handles emission when no marker/coords exist', () => {
179
+ it('selectCancel emits cancel and respects closeOnAction', () => {
176
180
  const params = createParams()
177
181
  cleanup = attachEvents(params)
178
182
 
179
- // Ensure marker returns null (no coords)
180
- params.mapState.markers.getMarker.mockReturnValue(null)
181
-
182
- // Set up features and bounds
183
- params.pluginState.selectedFeatures = [{ id: 'f1' }]
184
- params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
185
-
186
- params.buttonConfig.selectDone.onClick()
183
+ params.buttonConfig.selectCancel.onClick()
184
+ expect(params.closeApp).toHaveBeenCalled()
187
185
 
188
- expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done', {
189
- selectedFeatures: [{ id: 'f1' }],
190
- selectionBounds: { sw: [0, 0], ne: [1, 1] }
191
- })
186
+ cleanup()
187
+ const params2 = createParams()
188
+ cleanup = attachEvents(params2)
189
+ params2.pluginState.closeOnAction = false
190
+ params2.buttonConfig.selectCancel.onClick()
191
+ expect(params2.closeApp).not.toHaveBeenCalled()
192
192
  })
193
193
 
194
- it('respects default closeOnAction when value is undefined (fallback to true)', () => {
194
+ it('respects default closeOnAction when value is nullish', () => {
195
195
  const params = createParams()
196
- // Explicitly set to undefined to trigger the ?? fallback
197
- params.pluginState.closeOnAction = undefined
196
+ params.pluginState.closeOnAction = null
198
197
  cleanup = attachEvents(params)
199
198
 
200
- // Test for selectDone
201
199
  params.buttonConfig.selectDone.onClick()
202
200
  expect(params.closeApp).toHaveBeenCalledTimes(1)
203
201
 
204
- // Test for selectCancel
205
202
  params.closeApp.mockClear()
206
203
  params.buttonConfig.selectCancel.onClick()
207
204
  expect(params.closeApp).toHaveBeenCalledTimes(1)
208
205
  })
209
206
  })
207
+
208
+ describe('attachEvents — programmatic selection', () => {
209
+ let cleanup = null
210
+
211
+ beforeEach(() => { jest.useFakeTimers() })
212
+ afterEach(() => { cleanup?.(); jest.useRealTimers() })
213
+
214
+ it('selectFeature and unselectFeature dispatch and remove location marker', () => {
215
+ const params = createParams()
216
+ cleanup = attachEvents(params)
217
+
218
+ const selectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:selectFeature')[1]
219
+ const unselectHandler = params.eventBus.on.mock.calls.find(c => c[0] === 'interact:unselectFeature')[1]
220
+
221
+ selectHandler({ featureId: 'F1' })
222
+ unselectHandler({ featureId: 'F2' })
223
+
224
+ expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
225
+ expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
226
+ })
227
+ })
228
+
229
+ describe('attachEvents — cleanup', () => {
230
+ it('removes all handlers and nulls button onClick callbacks', () => {
231
+ jest.useFakeTimers()
232
+ const params = createParams()
233
+ const cleanup = attachEvents(params)
234
+ cleanup()
235
+ Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
236
+ jest.useRealTimers()
237
+ })
238
+ })
@@ -10,15 +10,15 @@ export const useHighlightSync = ({
10
10
  events,
11
11
  eventBus
12
12
  }) => {
13
- const { dataLayers } = pluginState
13
+ const { layers } = pluginState
14
14
 
15
15
  // Memoize stylesMap so it only recalculates when style or layers change
16
16
  const stylesMap = useMemo(() => {
17
17
  if (!mapStyle) {
18
18
  return null
19
19
  }
20
- return buildStylesMap(dataLayers, mapStyle)
21
- }, [dataLayers, mapStyle])
20
+ return buildStylesMap(layers, mapStyle)
21
+ }, [layers, mapStyle])
22
22
 
23
23
  // Force re-application of all selected features
24
24
  const updateHighlightedFeatures = () => {
@@ -23,7 +23,7 @@ describe('useHighlightSync', () => {
23
23
  },
24
24
  mapStyle: { id: 'default-style' },
25
25
  pluginState: {
26
- dataLayers: [{ layerId: 'layer1' }]
26
+ layers: [{ layerId: 'layer1' }]
27
27
  },
28
28
  selectedFeatures: [],
29
29
  dispatch: jest.fn(),
@@ -93,21 +93,21 @@ describe('useHighlightSync', () => {
93
93
  )
94
94
  })
95
95
 
96
- it('rebuilds styles when dataLayers change', () => {
96
+ it('rebuilds styles when layers change', () => {
97
97
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
98
98
 
99
99
  const { rerender } = renderHook(
100
- ({ dataLayers }) =>
100
+ ({ layers }) =>
101
101
  useHighlightSync({
102
102
  ...mockDeps,
103
- pluginState: { dataLayers }
103
+ pluginState: { layers }
104
104
  }),
105
- { initialProps: { dataLayers: [{ layerId: 'layer1' }] } }
105
+ { initialProps: { layers: [{ layerId: 'layer1' }] } }
106
106
  )
107
107
 
108
108
  buildStylesMap.mockClear()
109
109
 
110
- rerender({ dataLayers: [{ layerId: 'layer1' }, { layerId: 'layer2' }] })
110
+ rerender({ layers: [{ layerId: 'layer1' }, { layerId: 'layer2' }] })
111
111
 
112
112
  expect(buildStylesMap).toHaveBeenCalled()
113
113
  })
@@ -0,0 +1,10 @@
1
+ import { useEffect } from 'react'
2
+
3
+ export const useHoverCursor = (mapProvider, enabled, interactionModes, layers) => {
4
+ useEffect(() => {
5
+ const canSelect = enabled && interactionModes?.includes('selectFeature')
6
+ const layerIds = canSelect ? layers.map(l => l.layerId) : []
7
+ mapProvider.setHoverCursor?.(layerIds)
8
+ return () => mapProvider.setHoverCursor?.([])
9
+ }, [enabled, interactionModes, layers])
10
+ }
@@ -0,0 +1,44 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { useHoverCursor } from './useHoverCursor.js'
3
+
4
+ const makeProvider = () => ({ setHoverCursor: jest.fn() })
5
+
6
+ describe('useHoverCursor', () => {
7
+ const dataLayers = [{ layerId: 'layer-a' }, { layerId: 'layer-b' }]
8
+
9
+ it('calls setHoverCursor with layer IDs when enabled with selectFeature mode', () => {
10
+ const mapProvider = makeProvider()
11
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectFeature'], dataLayers))
12
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith(['layer-a', 'layer-b'])
13
+ })
14
+
15
+ it('calls setHoverCursor with layer IDs when selectFeature is combined with other interactionModes', () => {
16
+ const mapProvider = makeProvider()
17
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectMarker', 'selectFeature'], dataLayers))
18
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith(['layer-a', 'layer-b'])
19
+ })
20
+
21
+ it('calls setHoverCursor with empty array when disabled', () => {
22
+ const mapProvider = makeProvider()
23
+ renderHook(() => useHoverCursor(mapProvider, false, ['selectFeature'], dataLayers))
24
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
25
+ })
26
+
27
+ it('calls setHoverCursor with empty array when selectFeature is not in interactionModes', () => {
28
+ const mapProvider = makeProvider()
29
+ renderHook(() => useHoverCursor(mapProvider, true, ['selectMarker', 'placeMarker'], dataLayers))
30
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
31
+ })
32
+
33
+ it('clears cursor on unmount', () => {
34
+ const mapProvider = makeProvider()
35
+ const { unmount } = renderHook(() => useHoverCursor(mapProvider, true, ['selectFeature'], dataLayers))
36
+ mapProvider.setHoverCursor.mockClear()
37
+ unmount()
38
+ expect(mapProvider.setHoverCursor).toHaveBeenCalledWith([])
39
+ })
40
+
41
+ it('does not throw when setHoverCursor is absent', () => {
42
+ expect(() => renderHook(() => useHoverCursor({}, true, ['selectFeature'], dataLayers))).not.toThrow()
43
+ })
44
+ })
@@ -1,8 +1,42 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
2
  import { isContiguousWithAny, canSplitFeatures, areAllContiguous } from '../utils/spatial.js'
3
3
  import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
4
+ import { scaleFactor } from '../../../../src/config/appConfig.js'
5
+
6
+ /**
7
+ * Returns the id of the first DOM marker whose visual bounds contain the given point.
8
+ *
9
+ * MAP_CLICK point is container-relative; getBoundingClientRect is viewport-relative.
10
+ * We convert by subtracting the parent element's top-left (markers share a parent with
11
+ * the map container, so parentElement.getBoundingClientRect() gives the offset).
12
+ *
13
+ * @param {Object} markers - markers object from mapState (has .items and .markerRefs)
14
+ * @param {{ x: number, y: number }} point - container-relative pixel coordinates
15
+ * @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium)
16
+ * @returns {string|null}
17
+ */
18
+ const findMarkerAtPoint = (markers, point, scale) => {
19
+ for (const marker of markers.items) {
20
+ const el = markers.markerRefs?.get(marker.id)
21
+ if (!el) {
22
+ continue
23
+ }
24
+ const parent = el.parentElement
25
+ const parentRect = parent ? parent.getBoundingClientRect() : { left: 0, top: 0 }
26
+ const { left, top, right, bottom } = el.getBoundingClientRect()
27
+ const scaledX = point.x * scale
28
+ const scaledY = point.y * scale
29
+ if (
30
+ scaledX >= left - parentRect.left && scaledX <= right - parentRect.left &&
31
+ scaledY >= top - parentRect.top && scaledY <= bottom - parentRect.top
32
+ ) {
33
+ return marker.id
34
+ }
35
+ }
36
+ return null
37
+ }
4
38
 
5
- const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectionBounds) => {
39
+ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers, selectionBounds) => {
6
40
  const lastEmittedSelectionChange = useRef(null)
7
41
 
8
42
  useEffect(() => {
@@ -14,105 +48,113 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectionBounds)
14
48
 
15
49
  // Skip if selection was already empty and remains empty
16
50
  const prev = lastEmittedSelectionChange.current
17
- const wasEmpty = prev === null || prev.length === 0
18
- if (wasEmpty && selectedFeatures.length === 0) {
51
+ const wasEmpty = prev === null || (prev.features.length === 0 && prev.markers.length === 0)
52
+ if (wasEmpty && selectedFeatures.length === 0 && selectedMarkers.length === 0) {
19
53
  return
20
54
  }
21
55
 
22
56
  eventBus.emit('interact:selectionchange', {
23
57
  selectedFeatures,
58
+ selectedMarkers,
24
59
  selectionBounds,
25
60
  canMerge: areAllContiguous(selectedFeatures),
26
61
  canSplit: canSplitFeatures(selectedFeatures)
27
62
  })
28
63
 
29
- lastEmittedSelectionChange.current = selectedFeatures
30
- }, [selectedFeatures, selectionBounds])
64
+ lastEmittedSelectionChange.current = { features: selectedFeatures, markers: selectedMarkers }
65
+ }, [selectedFeatures, selectedMarkers, selectionBounds])
31
66
  }
32
67
 
33
- export const useInteractionHandlers = ({
34
- mapState,
35
- pluginState,
36
- services,
37
- mapProvider
38
- }) => {
39
- const { markers } = mapState
40
- const { dispatch, dataLayers, interactionMode, multiSelect, contiguous, markerColor, tolerance, selectedFeatures, selectionBounds, deselectOnClickOutside } = pluginState
68
+ /**
69
+ * Core interaction hook. Processes map clicks in fixed priority order:
70
+ * selectMarker → selectFeature → placeMarker (fallback).
71
+ *
72
+ * Which steps are active is controlled by `pluginState.interactionModes`. Steps not
73
+ * present in the array are skipped entirely — e.g. omitting `'selectMarker'` means
74
+ * marker hit-testing is never performed.
75
+ *
76
+ * @param {Object} deps
77
+ * @param {Object} deps.mapState - Map state including markers and mapSize
78
+ * @param {Object} deps.pluginState - Plugin state including interactionModes, layers, etc.
79
+ * @param {Object} deps.services - Services including eventBus
80
+ * @param {Object} deps.mapProvider - Map provider instance for feature queries
81
+ * @returns {{ handleInteraction: Function }}
82
+ */
83
+ export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
84
+ const { markers, mapSize } = mapState
85
+ const { dispatch, layers, interactionModes, multiSelect, contiguous, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
41
86
  const { eventBus } = services
42
- const layerConfigMap = buildLayerConfigMap(dataLayers)
43
-
44
- const handleInteraction = useCallback(({ point, coords }) => {
45
- const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
46
- const hasDataLayers = dataLayers.length > 0
47
-
48
- if (pluginState?.debug) {
49
- console.log(`--- Features at ${coords} ---`, allFeatures)
87
+ const layerConfigMap = buildLayerConfigMap(layers)
88
+ const scale = scaleFactor[mapSize] ?? 1
89
+ const processFeatureMatch = useCallback(({ feature, config }) => {
90
+ markers.remove('location')
91
+ const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
92
+ const featureId = feature.properties?.[config.idProperty] ?? feature.id
93
+ if (featureId == null) {
94
+ return
50
95
  }
96
+ dispatch({
97
+ type: 'TOGGLE_SELECTED_FEATURES',
98
+ payload: {
99
+ featureId,
100
+ multiSelect,
101
+ layerId: config.layerId,
102
+ idProperty: config.idProperty,
103
+ properties: feature.properties,
104
+ geometry: feature.geometry,
105
+ replaceAll: contiguous && !isNewContiguous
106
+ }
107
+ })
108
+ }, [markers, contiguous, selectedFeatures, dispatch, multiSelect])
51
109
 
52
- const canMatch = hasDataLayers && (interactionMode === 'select' || interactionMode === 'auto')
53
- const match = canMatch ? findMatchingFeature(allFeatures, layerConfigMap) : null
54
-
55
- // 1. Handle Feature Match
56
- if (match) {
57
- processFeatureMatch(match)
110
+ const processFallback = useCallback(({ coords }) => {
111
+ const canPlace = interactionModes.includes('placeMarker')
112
+ if (!canPlace && !deselectOnClickOutside) {
58
113
  return
59
114
  }
60
-
61
- // 2. Handle Marker Mode (Fallback)
62
- const isMarkerMode = interactionMode === 'marker' || (interactionMode === 'auto' && hasDataLayers)
63
- if (isMarkerMode) {
64
- dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
65
- markers.add('location', coords, { color: markerColor })
115
+ dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
116
+ if (canPlace) {
117
+ markers.add('location', coords, markerOptions)
66
118
  eventBus.emit('interact:markerchange', { coords })
67
- } else if (deselectOnClickOutside) {
68
- dispatch({ type: 'CLEAR_SELECTED_FEATURES' })
69
- } else {
70
- // No action
71
119
  }
120
+ }, [interactionModes, dispatch, markers, markerOptions, eventBus, deselectOnClickOutside])
72
121
 
73
- // Internal helper to keep complexity low
74
- function processFeatureMatch ({ feature, config }) {
75
- markers.remove('location')
76
- const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
77
- const featureId = feature.properties?.[config.idProperty] ?? feature.id
78
-
79
- if (!featureId) {
122
+ const handleInteraction = useCallback(({ point, coords }) => {
123
+ if (interactionModes.includes('selectMarker')) {
124
+ const markerHit = findMarkerAtPoint(markers, point, scale)
125
+ if (markerHit) {
126
+ dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: markerHit, multiSelect } })
80
127
  return
81
128
  }
129
+ }
82
130
 
83
- dispatch({
84
- type: 'TOGGLE_SELECTED_FEATURES',
85
- payload: {
86
- featureId,
87
- multiSelect,
88
- layerId: config.layerId,
89
- idProperty: config.idProperty,
90
- properties: feature.properties,
91
- geometry: feature.geometry,
92
- replaceAll: contiguous && !isNewContiguous
93
- }
94
- })
131
+ if (interactionModes.includes('selectFeature') && layers.length > 0) {
132
+ const allFeatures = getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
133
+ if (pluginState?.debug) {
134
+ console.log(`--- Features at ${coords} ---`, allFeatures)
135
+ }
136
+ const match = findMatchingFeature(allFeatures, layerConfigMap)
137
+ if (match) {
138
+ processFeatureMatch(match)
139
+ return
140
+ }
95
141
  }
142
+
143
+ processFallback({ coords })
96
144
  }, [
97
145
  mapProvider,
98
- dataLayers,
99
- interactionMode,
146
+ layers,
147
+ interactionModes,
100
148
  multiSelect,
101
- eventBus,
102
149
  dispatch,
103
150
  markers,
104
- contiguous,
105
- selectedFeatures,
106
151
  layerConfigMap,
107
152
  pluginState?.debug,
108
153
  tolerance,
109
- markerColor,
110
- deselectOnClickOutside
154
+ processFeatureMatch,
155
+ processFallback,
156
+ scale
111
157
  ])
112
-
113
- useSelectionChangeEmitter(eventBus, selectedFeatures, selectionBounds)
114
-
115
- return {
116
- handleInteraction
117
- }
158
+ useSelectionChangeEmitter(eventBus, selectedFeatures, selectedMarkers, selectionBounds)
159
+ return { handleInteraction }
118
160
  }