@defra/interactive-map 0.0.18-alpha → 0.0.20-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 (91) hide show
  1. package/README.md +1 -1
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +22 -5
  8. package/docs/api/map-style-config.md +2 -11
  9. package/docs/api/marker-config.md +19 -5
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api.md +101 -48
  12. package/docs/architecture.md +0 -2
  13. package/docs/assets/css/docusaurus.css +205 -0
  14. package/docs/assets/images/basic-map.jpg +0 -0
  15. package/docs/assets/images/button-first.jpg +0 -0
  16. package/{assets → docs/assets}/images/hero.png +0 -0
  17. package/docs/assets/images/marker-panel.jpg +0 -0
  18. package/docs/examples/add-marker-with-panel.mdx +123 -0
  19. package/docs/examples/basic-map.mdx +53 -0
  20. package/docs/examples/button-map.mdx +53 -0
  21. package/docs/examples/index.mdx +49 -0
  22. package/docs/getting-started.md +80 -53
  23. package/docs/index.mdx +1 -1
  24. package/docs/plugins/interact.md +32 -1
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +14 -5
  27. package/package.json +3 -3
  28. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  34. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  35. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  36. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/index.js +1 -1
  38. package/plugins/interact/src/InteractInit.jsx +5 -3
  39. package/plugins/interact/src/api/clear.js +1 -1
  40. package/plugins/interact/src/api/selectMarker.js +14 -0
  41. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  42. package/plugins/interact/src/api/unselectMarker.js +14 -0
  43. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  44. package/plugins/interact/src/events.js +18 -30
  45. package/plugins/interact/src/events.test.js +113 -108
  46. package/plugins/interact/src/manifest.js +10 -2
  47. package/plugins/interact/src/reducer.js +36 -1
  48. package/plugins/interact/src/reducer.test.js +40 -1
  49. package/plugins/interact/src/utils/interactionModes.js +12 -0
  50. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  51. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  52. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  53. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  54. package/providers/maplibre/src/maplibreProvider.js +3 -2
  55. package/providers/maplibre/src/maplibreProvider.test.js +22 -0
  56. package/src/App/components/Panel/Panel.jsx +6 -6
  57. package/src/App/components/Panel/Panel.test.jsx +37 -0
  58. package/src/App/components/Viewport/Viewport.jsx +5 -15
  59. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  60. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  61. package/src/App/hooks/useInterfaceAPI.js +7 -7
  62. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  63. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  64. package/src/App/layout/Layout.jsx +1 -1
  65. package/src/App/layout/layout.module.scss +1 -8
  66. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  67. package/src/App/renderer/mapPanels.js +2 -1
  68. package/src/App/store/appActionsMap.js +4 -4
  69. package/src/App/store/appActionsMap.test.js +10 -0
  70. package/src/InteractiveMap/InteractiveMap.js +61 -13
  71. package/src/InteractiveMap/InteractiveMap.test.js +130 -8
  72. package/src/InteractiveMap/domStateManager.js +18 -6
  73. package/src/InteractiveMap/domStateManager.test.js +21 -0
  74. package/src/InteractiveMap/historyManager.js +28 -16
  75. package/src/InteractiveMap/historyManager.test.js +17 -0
  76. package/src/config/appConfig.js +2 -1
  77. package/src/config/appConfig.test.js +3 -13
  78. package/src/config/defaults.js +2 -1
  79. package/src/config/events.js +20 -21
  80. package/src/services/closeApp.js +1 -10
  81. package/src/services/closeApp.test.js +3 -43
  82. package/src/services/eventBus.js +61 -0
  83. package/src/services/eventBus.test.js +70 -0
  84. package/src/types.js +6 -1
  85. package/src/utils/mapStateSync.js +48 -10
  86. package/src/utils/mapStateSync.test.js +29 -9
  87. package/assets/css/docusaurus.css +0 -104
  88. package/docs/examples.mdx +0 -70
  89. /package/docs/assets/{screens-blue.jpg → images/screens-blue.jpg} +0 -0
  90. /package/docs/assets/{screens-white.jpg → images/screens-white.jpg} +0 -0
  91. /package/{assets → docs/assets}/images/slot-map.svg +0 -0
@@ -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,135 +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 emits selectedFeatures and selectionBounds when no marker/coords', () => {
179
+ it('selectCancel emits cancel and respects closeOnAction', () => {
176
180
  const params = createParams()
177
181
  cleanup = attachEvents(params)
178
182
 
179
- params.mapState.markers.getMarker.mockReturnValue(null)
180
- params.pluginState.selectedFeatures = [{ id: 'f1' }]
181
- params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
182
-
183
- params.buttonConfig.selectDone.onClick()
183
+ params.buttonConfig.selectCancel.onClick()
184
+ expect(params.closeApp).toHaveBeenCalled()
184
185
 
185
- expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done', {
186
- selectedFeatures: [{ id: 'f1' }],
187
- selectionBounds: { sw: [0, 0], ne: [1, 1] }
188
- })
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()
189
192
  })
190
193
 
191
- it('selectDone includes selectedMarkers in payload when present', () => {
194
+ it('respects default closeOnAction when value is nullish', () => {
192
195
  const params = createParams()
196
+ params.pluginState.closeOnAction = null
193
197
  cleanup = attachEvents(params)
194
198
 
195
- params.mapState.markers.getMarker.mockReturnValue(null)
196
- params.pluginState.selectedMarkers = ['m1', 'm2']
197
-
198
199
  params.buttonConfig.selectDone.onClick()
200
+ expect(params.closeApp).toHaveBeenCalledTimes(1)
199
201
 
200
- expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done',
201
- expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
202
- )
202
+ params.closeApp.mockClear()
203
+ params.buttonConfig.selectCancel.onClick()
204
+ expect(params.closeApp).toHaveBeenCalledTimes(1)
203
205
  })
206
+ })
204
207
 
205
- it('selectDone omits selectedMarkers from payload when empty', () => {
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', () => {
206
215
  const params = createParams()
207
216
  cleanup = attachEvents(params)
208
217
 
209
- params.mapState.markers.getMarker.mockReturnValue(null)
210
- params.pluginState.selectedMarkers = []
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]
211
220
 
212
- params.buttonConfig.selectDone.onClick()
221
+ selectHandler({ featureId: 'F1' })
222
+ unselectHandler({ featureId: 'F2' })
213
223
 
214
- const payload = params.eventBus.emit.mock.calls.find(c => c[0] === 'interact:done')[1]
215
- expect(payload).not.toHaveProperty('selectedMarkers')
224
+ expect(params.pluginState.dispatch).toHaveBeenCalledTimes(2)
225
+ expect(params.mapState.markers.remove).toHaveBeenCalledTimes(2)
216
226
  })
227
+ })
217
228
 
218
- it('respects default closeOnAction when value is undefined (fallback to true)', () => {
229
+ describe('attachEvents cleanup', () => {
230
+ it('removes all handlers and nulls button onClick callbacks', () => {
231
+ jest.useFakeTimers()
219
232
  const params = createParams()
220
- // Explicitly set to undefined to trigger the ?? fallback
221
- params.pluginState.closeOnAction = undefined
222
- cleanup = attachEvents(params)
223
-
224
- // Test for selectDone
225
- params.buttonConfig.selectDone.onClick()
226
- expect(params.closeApp).toHaveBeenCalledTimes(1)
227
-
228
- // Test for selectCancel
229
- params.closeApp.mockClear()
230
- params.buttonConfig.selectCancel.onClick()
231
- expect(params.closeApp).toHaveBeenCalledTimes(1)
233
+ const cleanup = attachEvents(params)
234
+ cleanup()
235
+ Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
236
+ jest.useRealTimers()
232
237
  })
233
238
  })
@@ -1,11 +1,14 @@
1
1
  // /plugins/interact/manifest.js
2
2
  import { InteractInit } from './InteractInit.jsx'
3
+ import { isSelectMarkerOnly } from './utils/interactionModes.js'
3
4
  import { initialState, actions } from './reducer.js'
4
5
  import { enable } from './api/enable.js'
5
6
  import { disable } from './api/disable.js'
6
7
  import { clear } from './api/clear.js'
7
8
  import { selectFeature } from './api/selectFeature.js'
8
9
  import { unselectFeature } from './api/unselectFeature.js'
10
+ import { selectMarker } from './api/selectMarker.js'
11
+ import { unselectMarker } from './api/unselectMarker.js'
9
12
 
10
13
  export const manifest = {
11
14
  InitComponent: InteractInit,
@@ -36,7 +39,10 @@ export const manifest = {
36
39
  id: 'selectAtTarget',
37
40
  label: 'Select',
38
41
  variant: 'primary',
39
- hiddenWhen: ({ appState, pluginState }) => !pluginState.enabled || appState.interfaceType !== 'touch',
42
+ // Hidden for touch when selectMarker is the only mode — markers have a sufficient tap target
43
+ // and the Select button is only needed alongside the crosshair. Mirrors the crosshair logic in InteractInit.
44
+ hiddenWhen: ({ appState, pluginState }) =>
45
+ !pluginState.enabled || appState.interfaceType !== 'touch' || isSelectMarkerOnly(pluginState.interactionModes),
40
46
  mobile: {
41
47
  slot: 'actions'
42
48
  },
@@ -83,6 +89,8 @@ export const manifest = {
83
89
  disable,
84
90
  clear,
85
91
  selectFeature,
86
- unselectFeature
92
+ unselectFeature,
93
+ selectMarker,
94
+ unselectMarker
87
95
  }
88
96
  }
@@ -107,13 +107,48 @@ const clearSelectedFeatures = (state) => {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Explicitly select a marker. Has no effect if already selected.
112
+ * In single-select mode, clears selectedFeatures and replaces the selection.
113
+ * In multi-select mode, adds to the existing selection.
114
+ */
115
+ const selectMarker = (state, { markerId, multiSelect }) => {
116
+ const current = state.selectedMarkers
117
+ if (current.includes(markerId)) {
118
+ return state
119
+ }
120
+ const nextMarkers = multiSelect ? [...current, markerId] : [markerId]
121
+ return {
122
+ ...state,
123
+ selectedFeatures: multiSelect ? state.selectedFeatures : [],
124
+ selectionBounds: null,
125
+ selectedMarkers: nextMarkers
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Explicitly unselect a marker. Has no effect if not selected.
131
+ */
132
+ const unselectMarker = (state, { markerId }) => {
133
+ const current = state.selectedMarkers
134
+ if (!current.includes(markerId)) {
135
+ return state
136
+ }
137
+ return {
138
+ ...state,
139
+ selectedMarkers: current.filter(id => id !== markerId)
140
+ }
141
+ }
142
+
110
143
  const actions = {
111
144
  ENABLE: enable,
112
145
  DISABLE: disable,
113
146
  TOGGLE_SELECTED_FEATURES: toggleSelectedFeatures,
114
147
  TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers,
115
148
  UPDATE_SELECTED_BOUNDS: updateSelectedBounds,
116
- CLEAR_SELECTED_FEATURES: clearSelectedFeatures
149
+ CLEAR_SELECTED_FEATURES: clearSelectedFeatures,
150
+ SELECT_MARKER: selectMarker,
151
+ UNSELECT_MARKER: unselectMarker
117
152
  }
118
153
 
119
154
  export {
@@ -189,6 +189,43 @@ describe('CLEAR_SELECTED_FEATURES action', () => {
189
189
  })
190
190
  })
191
191
 
192
+ describe('SELECT_MARKER action', () => {
193
+ it('adds a marker in single-select mode, clearing selectedFeatures', () => {
194
+ const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: [] }
195
+ const result = actions.SELECT_MARKER(state, { markerId: 'm1', multiSelect: false })
196
+ expect(result.selectedMarkers).toEqual(['m1'])
197
+ expect(result.selectedFeatures).toEqual([])
198
+ expect(result.selectionBounds).toBeNull()
199
+ })
200
+
201
+ it('adds a marker in multi-select mode without clearing selectedFeatures', () => {
202
+ const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: ['m1'] }
203
+ const result = actions.SELECT_MARKER(state, { markerId: 'm2', multiSelect: true })
204
+ expect(result.selectedMarkers).toEqual(['m1', 'm2'])
205
+ expect(result.selectedFeatures).toEqual([{ featureId: 'f1' }])
206
+ })
207
+
208
+ it('is idempotent — does not change state if marker already selected', () => {
209
+ const state = { ...initialState, selectedMarkers: ['m1'] }
210
+ const result = actions.SELECT_MARKER(state, { markerId: 'm1', multiSelect: false })
211
+ expect(result).toBe(state)
212
+ })
213
+ })
214
+
215
+ describe('UNSELECT_MARKER action', () => {
216
+ it('removes a selected marker', () => {
217
+ const state = { ...initialState, selectedMarkers: ['m1', 'm2'] }
218
+ const result = actions.UNSELECT_MARKER(state, { markerId: 'm1' })
219
+ expect(result.selectedMarkers).toEqual(['m2'])
220
+ })
221
+
222
+ it('is idempotent — does not change state if marker not selected', () => {
223
+ const state = { ...initialState, selectedMarkers: ['m2'] }
224
+ const result = actions.UNSELECT_MARKER(state, { markerId: 'm1' })
225
+ expect(result).toBe(state)
226
+ })
227
+ })
228
+
192
229
  describe('actions object', () => {
193
230
  it('exports all action handlers as functions', () => {
194
231
  expect(Object.keys(actions)).toEqual([
@@ -197,7 +234,9 @@ describe('actions object', () => {
197
234
  'TOGGLE_SELECTED_FEATURES',
198
235
  'TOGGLE_SELECTED_MARKERS',
199
236
  'UPDATE_SELECTED_BOUNDS',
200
- 'CLEAR_SELECTED_FEATURES'
237
+ 'CLEAR_SELECTED_FEATURES',
238
+ 'SELECT_MARKER',
239
+ 'UNSELECT_MARKER'
201
240
  ])
202
241
  Object.values(actions).forEach(fn => expect(typeof fn).toBe('function'))
203
242
  })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Returns true when selectMarker is the only active interaction mode.
3
+ *
4
+ * Used to suppress the crosshair and Select action button for touch users —
5
+ * markers have a sufficient tap target so neither is needed. Any other mode
6
+ * combination (e.g. placeMarker, selectFeature) requires the crosshair for touch.
7
+ *
8
+ * @param {string[] | null} interactionModes
9
+ * @returns {boolean}
10
+ */
11
+ export const isSelectMarkerOnly = (interactionModes) =>
12
+ interactionModes?.length === 1 && interactionModes.includes('selectMarker')