@defra/interactive-map 0.0.17-alpha → 0.0.18-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 (140) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/context.md +53 -7
  7. package/docs/api/map-style-config.md +41 -2
  8. package/docs/api/marker-config.md +53 -11
  9. package/docs/api/symbol-config.md +160 -0
  10. package/docs/api/symbol-registry.md +115 -0
  11. package/docs/api.md +22 -19
  12. package/docs/plugins/datasets.md +105 -9
  13. package/docs/plugins/interact.md +68 -43
  14. package/docs/plugins/search.md +15 -3
  15. package/package.json +1 -1
  16. package/plugins/beta/datasets/dist/css/index.css +32 -14
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  19. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  21. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  22. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  23. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  24. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  25. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  26. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  27. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  28. package/plugins/beta/datasets/src/api/setData.js +4 -2
  29. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  30. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  31. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  32. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  33. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  34. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  35. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  36. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  37. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  38. package/plugins/beta/datasets/src/datasets.js +13 -4
  39. package/plugins/beta/datasets/src/defaults.js +4 -2
  40. package/plugins/beta/datasets/src/index.js +2 -1
  41. package/plugins/beta/datasets/src/manifest.js +1 -1
  42. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  43. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  44. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  45. package/plugins/beta/datasets/src/reducer.js +6 -0
  46. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  47. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  48. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  49. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  50. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  51. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  52. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  53. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  54. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  55. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  56. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  57. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  58. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  59. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/dist/umd/index.js +1 -1
  63. package/plugins/interact/src/InteractInit.jsx +14 -5
  64. package/plugins/interact/src/InteractInit.test.js +26 -6
  65. package/plugins/interact/src/api/enable.test.js +7 -7
  66. package/plugins/interact/src/defaults.js +4 -6
  67. package/plugins/interact/src/events.js +9 -6
  68. package/plugins/interact/src/events.test.js +28 -4
  69. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  70. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  71. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  72. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  73. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  74. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  75. package/plugins/interact/src/reducer.js +23 -4
  76. package/plugins/interact/src/reducer.test.js +60 -11
  77. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  78. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  79. package/plugins/interact/src/utils/featureQueries.js +11 -6
  80. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  81. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  82. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  83. package/plugins/search/src/Search.jsx +3 -1
  84. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  85. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  86. package/plugins/search/src/events/formHandlers.js +3 -3
  87. package/plugins/search/src/events/formHandlers.test.js +1 -1
  88. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  89. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  90. package/plugins/search/src/utils/updateMap.js +3 -3
  91. package/plugins/search/src/utils/updateMap.test.js +3 -3
  92. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  93. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  94. package/providers/maplibre/dist/umd/index.js +1 -1
  95. package/providers/maplibre/src/appEvents.js +7 -0
  96. package/providers/maplibre/src/appEvents.test.js +18 -4
  97. package/providers/maplibre/src/maplibreProvider.js +52 -0
  98. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  99. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  100. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  101. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  102. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  103. package/providers/maplibre/src/utils/patternImages.js +70 -0
  104. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  105. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  106. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  107. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  108. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  109. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  110. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  111. package/src/App/components/Markers/Markers.jsx +122 -27
  112. package/src/App/components/Markers/Markers.module.scss +0 -10
  113. package/src/App/components/Markers/Markers.test.jsx +246 -0
  114. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  115. package/src/App/hooks/useMarkersAPI.js +2 -5
  116. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  117. package/src/App/layout/Layout.jsx +2 -2
  118. package/src/App/layout/Layout.test.jsx +4 -2
  119. package/src/App/store/ServiceProvider.jsx +7 -5
  120. package/src/App/store/mapActionsMap.js +4 -6
  121. package/src/App/store/mapActionsMap.test.js +3 -2
  122. package/src/App/store/mapReducer.js +2 -1
  123. package/src/config/appConfig.js +0 -6
  124. package/src/config/appConfig.test.js +1 -2
  125. package/src/config/defaults.js +0 -2
  126. package/src/config/mapTheme.js +56 -0
  127. package/src/config/patternConfig.js +16 -0
  128. package/src/config/symbolConfig.js +80 -0
  129. package/src/scss/settings/_colors.scss +0 -9
  130. package/src/services/patternRegistry.js +40 -0
  131. package/src/services/patternRegistry.test.js +48 -0
  132. package/src/services/symbolRegistry.js +113 -0
  133. package/src/services/symbolRegistry.test.js +262 -0
  134. package/src/types.js +93 -11
  135. package/src/utils/patternUtils.js +94 -0
  136. package/src/utils/patternUtils.test.js +160 -0
  137. package/src/utils/symbolUtils.js +85 -0
  138. package/src/utils/symbolUtils.test.js +156 -0
  139. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  140. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -30,19 +30,27 @@ const click = result =>
30
30
  })
31
31
  )
32
32
 
33
- const setup = (pluginOverrides = {}) => {
33
+ const makeMarkerEl = (rect) => ({
34
+ getBoundingClientRect: () => rect,
35
+ parentElement: {
36
+ getBoundingClientRect: () => ({ left: 0, top: 0 })
37
+ }
38
+ })
39
+
40
+ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) => {
34
41
  const deps = {
35
42
  mapState: {
36
- markers: { add: jest.fn(), remove: jest.fn() }
43
+ markers: { add: jest.fn(), remove: jest.fn(), items: markerItems, markerRefs }
37
44
  },
38
45
  pluginState: {
39
46
  dispatch: jest.fn(),
40
- dataLayers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
41
- interactionMode: 'select',
47
+ layers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
48
+ interactionModes: ['selectMarker', 'selectFeature'],
42
49
  multiSelect: false,
43
50
  contiguous: false,
44
- markerColor: 'red',
51
+ marker: { symbol: 'pin', backgroundColor: 'red' },
45
52
  selectedFeatures: [],
53
+ selectedMarkers: [],
46
54
  selectionBounds: null,
47
55
  ...pluginOverrides
48
56
  },
@@ -73,12 +81,94 @@ beforeEach(() => {
73
81
  })
74
82
 
75
83
  /* ------------------------------------------------------------------ */
76
- /* Marker mode */
84
+ /* DOM marker hit detection */
85
+ /* ------------------------------------------------------------------ */
86
+
87
+ describe('DOM marker hit detection', () => {
88
+ it('dispatches TOGGLE_SELECTED_MARKERS when click is within a marker bounds', () => {
89
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
90
+ const markerRefs = new Map([['marker-1', markerEl]])
91
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
92
+
93
+ const { result, deps } = setup({}, markerItems, markerRefs)
94
+ click(result)
95
+
96
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
97
+ type: 'TOGGLE_SELECTED_MARKERS',
98
+ payload: { markerId: 'marker-1', multiSelect: false }
99
+ })
100
+ })
101
+
102
+ it('markers take precedence over features when both hit', () => {
103
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
104
+ const markerRefs = new Map([['marker-1', markerEl]])
105
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
106
+
107
+ const { result, deps } = setup({}, markerItems, markerRefs)
108
+ click(result)
109
+
110
+ expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
111
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
112
+ )
113
+ expect(featureQueries.getFeaturesAtPoint).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('skips markers with no ref and continues to next', () => {
117
+ // marker in items but not in markerRefs — should not throw, should fall through to features
118
+ const markerItems = [{ id: 'marker-no-ref', coords: [1, 2] }]
119
+ const markerRefs = new Map() // no entry for marker-no-ref
120
+
121
+ const { result, deps } = setup({}, markerItems, markerRefs)
122
+ click(result)
123
+
124
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
125
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
126
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
127
+ )
128
+ })
129
+
130
+ it('uses zero offset when marker element has no parentElement', () => {
131
+ // parentElement is null — fallback parentRect { left: 0, top: 0 } should be used
132
+ const markerEl = {
133
+ getBoundingClientRect: () => ({ left: 5, top: 15, right: 15, bottom: 25 }),
134
+ parentElement: null
135
+ }
136
+ const markerRefs = new Map([['marker-1', markerEl]])
137
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
138
+
139
+ const { result, deps } = setup({}, markerItems, markerRefs)
140
+ click(result)
141
+
142
+ // click point { x: 10, y: 20 } is within [5,15,15,25] with zero parent offset
143
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith({
144
+ type: 'TOGGLE_SELECTED_MARKERS',
145
+ payload: { markerId: 'marker-1', multiSelect: false }
146
+ })
147
+ })
148
+
149
+ it('falls through to feature selection when click misses all markers', () => {
150
+ // marker bounds don't include the click point { x: 10, y: 20 }
151
+ const markerEl = makeMarkerEl({ left: 50, top: 50, right: 80, bottom: 80 })
152
+ const markerRefs = new Map([['marker-1', markerEl]])
153
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
154
+
155
+ const { result, deps } = setup({}, markerItems, markerRefs)
156
+ click(result)
157
+
158
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
159
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
160
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_FEATURES' })
161
+ )
162
+ })
163
+ })
164
+
165
+ /* ------------------------------------------------------------------ */
166
+ /* placeMarker mode */
77
167
  /* ------------------------------------------------------------------ */
78
168
 
79
- describe('marker mode', () => {
169
+ describe('placeMarker mode', () => {
80
170
  it('places marker, clears selection, and emits event', () => {
81
- const { result, deps } = setup({ interactionMode: 'marker' })
171
+ const { result, deps } = setup({ interactionModes: ['placeMarker'] })
82
172
 
83
173
  click(result)
84
174
 
@@ -88,7 +178,7 @@ describe('marker mode', () => {
88
178
  expect(deps.mapState.markers.add).toHaveBeenCalledWith(
89
179
  'location',
90
180
  [1, 2],
91
- { color: 'red' }
181
+ { symbol: 'pin', backgroundColor: 'red' }
92
182
  )
93
183
  expect(deps.services.eventBus.emit).toHaveBeenCalledWith(
94
184
  'interact:markerchange',
@@ -98,32 +188,30 @@ describe('marker mode', () => {
98
188
  })
99
189
 
100
190
  /* ------------------------------------------------------------------ */
101
- /* Select & Auto modes */
191
+ /* selectFeature mode */
102
192
  /* ------------------------------------------------------------------ */
103
193
 
104
- describe.each(['select', 'auto'])('%s mode feature selection', mode => {
105
- it('dispatches selection for matching feature', () => {
106
- const { result, deps } = setup({ interactionMode: mode })
194
+ it('selectFeature mode dispatches selection for matching feature', () => {
195
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] })
107
196
 
108
- click(result)
197
+ click(result)
109
198
 
110
- expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
111
- expect.objectContaining({
112
- type: 'TOGGLE_SELECTED_FEATURES',
113
- payload: expect.objectContaining({
114
- featureId: 'P1',
115
- layerId: 'parcels'
116
- })
199
+ expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ type: 'TOGGLE_SELECTED_FEATURES',
202
+ payload: expect.objectContaining({
203
+ featureId: 'P1',
204
+ layerId: 'parcels'
117
205
  })
118
- )
119
- })
206
+ })
207
+ )
120
208
  })
121
209
 
122
- it('falls back to marker in auto mode when no feature found', () => {
210
+ it('falls back to placeMarker when selectFeature finds no match', () => {
123
211
  featureQueries.getFeaturesAtPoint.mockReturnValue([])
124
212
  featureQueries.findMatchingFeature.mockReturnValue(null)
125
213
 
126
- const { result, deps } = setup({ interactionMode: 'auto' })
214
+ const { result, deps } = setup({ interactionModes: ['selectFeature', 'placeMarker'] })
127
215
 
128
216
  click(result)
129
217
 
@@ -248,32 +336,58 @@ describe('deselectOnClickOutside', () => {
248
336
  })
249
337
 
250
338
  /* ------------------------------------------------------------------ */
251
- /* Marker condition guard (FULL COVERAGE) */
339
+ /* placeMarker / selectFeature guards */
252
340
  /* ------------------------------------------------------------------ */
253
341
 
254
- it('does NOT place marker in auto mode when no dataLayers exist', () => {
342
+ it('places marker with placeMarker mode even when no layers exist', () => {
255
343
  featureQueries.getFeaturesAtPoint.mockReturnValue([])
256
344
  featureQueries.findMatchingFeature.mockReturnValue(null)
257
345
 
258
346
  const { result, deps } = setup({
259
- interactionMode: 'auto',
260
- dataLayers: []
347
+ interactionModes: ['selectFeature', 'placeMarker'],
348
+ layers: []
261
349
  })
262
350
 
263
351
  click(result)
264
352
 
353
+ expect(deps.mapState.markers.add).toHaveBeenCalled()
354
+ })
355
+
356
+ it('does not place marker when placeMarker is not in interactionModes', () => {
357
+ featureQueries.getFeaturesAtPoint.mockReturnValue([])
358
+ featureQueries.findMatchingFeature.mockReturnValue(null)
359
+
360
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] })
361
+
362
+ click(result)
363
+
265
364
  expect(deps.mapState.markers.add).not.toHaveBeenCalled()
266
365
  })
267
366
 
367
+ it('does not check markers when selectMarker is not in interactionModes', () => {
368
+ const markerEl = makeMarkerEl({ left: 5, top: 15, right: 15, bottom: 25 })
369
+ const markerRefs = new Map([['marker-1', markerEl]])
370
+ const markerItems = [{ id: 'marker-1', coords: [1, 2] }]
371
+
372
+ const { result, deps } = setup({ interactionModes: ['selectFeature'] }, markerItems, markerRefs)
373
+ click(result)
374
+
375
+ expect(deps.pluginState.dispatch).not.toHaveBeenCalledWith(
376
+ expect.objectContaining({ type: 'TOGGLE_SELECTED_MARKERS' })
377
+ )
378
+ expect(featureQueries.getFeaturesAtPoint).toHaveBeenCalled()
379
+ })
380
+
268
381
  /* ------------------------------------------------------------------ */
269
382
  /* Selection change event */
270
383
  /* ------------------------------------------------------------------ */
271
384
 
272
385
  it('emits selectionchange once when bounds exist', () => {
273
386
  const deps = {
274
- mapState: { markers: { add: jest.fn(), remove: jest.fn() } },
387
+ mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
275
388
  pluginState: {
276
389
  selectedFeatures: [{ featureId: 'F1' }],
390
+ selectedMarkers: [],
277
391
  selectionBounds: { sw: [0, 0], ne: [1, 1] }
278
392
  },
279
393
  services: { eventBus: { emit: jest.fn() } },
@@ -286,6 +400,7 @@ it('emits selectionchange once when bounds exist', () => {
286
400
  'interact:selectionchange',
287
401
  expect.objectContaining({
288
402
  selectedFeatures: deps.pluginState.selectedFeatures,
403
+ selectedMarkers: [],
289
404
  selectionBounds: deps.pluginState.selectionBounds,
290
405
  canMerge: false,
291
406
  canSplit: false
@@ -299,8 +414,8 @@ it('skips emission when selection remains empty after being cleared', () => {
299
414
  // 1. First render with a feature (prev is null, emission happens)
300
415
  const { rerender } = renderHook(
301
416
  ({ features }) => useInteractionHandlers({
302
- mapState: { markers: {} },
303
- pluginState: { selectedFeatures: features, selectionBounds: { b: 1 } },
417
+ mapState: { markers: { items: [], markerRefs: new Map() } },
418
+ pluginState: { selectedFeatures: features, selectedMarkers: [], selectionBounds: { b: 1 } },
304
419
  services: { eventBus },
305
420
  mapProvider: {}
306
421
  }),
@@ -1,12 +1,13 @@
1
1
  const initialState = {
2
2
  enabled: false,
3
- dataLayers: [],
4
- markerColor: null,
5
- interactionMode: null,
3
+ layers: [],
4
+ marker: null,
5
+ interactionModes: null,
6
6
  multiSelect: false,
7
7
  contiguous: false,
8
8
  deselectOnClickOutside: false,
9
9
  selectedFeatures: [],
10
+ selectedMarkers: [],
10
11
  selectionBounds: null,
11
12
  closeOnAction: true // Done or Cancel
12
13
  }
@@ -24,6 +25,7 @@ const disable = (state) => {
24
25
  ...state,
25
26
  enabled: false,
26
27
  selectedFeatures: [],
28
+ selectedMarkers: [],
27
29
  selectionBounds: null
28
30
  }
29
31
  }
@@ -67,7 +69,7 @@ const toggleSelectedFeatures = (state, payload) => {
67
69
  nextSelected = isSameSingle ? [] : [featureObj]
68
70
  }
69
71
 
70
- return { ...state, selectedFeatures: nextSelected, selectionBounds: null }
72
+ return { ...state, selectedFeatures: nextSelected, selectedMarkers: multiSelect && !replaceAll ? state.selectedMarkers : [], selectionBounds: null }
71
73
  }
72
74
 
73
75
  // Update bounds (called from useEffect after map provider calculates them)
@@ -81,10 +83,26 @@ const updateSelectedBounds = (state, payload) => {
81
83
  }
82
84
  }
83
85
 
86
+ const toggleSelectedMarkers = (state, { markerId, multiSelect }) => {
87
+ const current = state.selectedMarkers
88
+ const exists = current.includes(markerId)
89
+ if (multiSelect) {
90
+ const next = exists ? current.filter(id => id !== markerId) : [...current, markerId]
91
+ return { ...state, selectedMarkers: next }
92
+ }
93
+ return {
94
+ ...state,
95
+ selectedFeatures: [],
96
+ selectionBounds: null,
97
+ selectedMarkers: exists && current.length === 1 ? [] : [markerId]
98
+ }
99
+ }
100
+
84
101
  const clearSelectedFeatures = (state) => {
85
102
  return {
86
103
  ...state,
87
104
  selectedFeatures: [],
105
+ selectedMarkers: [],
88
106
  selectionBounds: null
89
107
  }
90
108
  }
@@ -93,6 +111,7 @@ const actions = {
93
111
  ENABLE: enable,
94
112
  DISABLE: disable,
95
113
  TOGGLE_SELECTED_FEATURES: toggleSelectedFeatures,
114
+ TOGGLE_SELECTED_MARKERS: toggleSelectedMarkers,
96
115
  UPDATE_SELECTED_BOUNDS: updateSelectedBounds,
97
116
  CLEAR_SELECTED_FEATURES: clearSelectedFeatures
98
117
  }
@@ -4,13 +4,14 @@ describe('initialState', () => {
4
4
  it('has correct defaults', () => {
5
5
  expect(initialState).toEqual({
6
6
  enabled: false,
7
- dataLayers: [],
8
- markerColor: null,
9
- interactionMode: null,
7
+ layers: [],
8
+ marker: null,
9
+ interactionModes: null,
10
10
  multiSelect: false,
11
11
  contiguous: false,
12
12
  deselectOnClickOutside: false,
13
13
  selectedFeatures: [],
14
+ selectedMarkers: [],
14
15
  selectionBounds: null,
15
16
  closeOnAction: true
16
17
  })
@@ -20,23 +21,26 @@ describe('initialState', () => {
20
21
  describe('ENABLE/DISABLE actions', () => {
21
22
  it('ENABLE sets enabled and merges payload', () => {
22
23
  const state = { ...initialState, enabled: false }
23
- const payload = { dataLayers: [1], markerColor: 'red' }
24
+ const marker = { symbol: 'pin', backgroundColor: 'red' }
25
+ const payload = { layers: [1], marker }
24
26
  const result = actions.ENABLE(state, payload)
25
27
 
26
28
  expect(result.enabled).toBe(true)
27
- expect(result.dataLayers).toEqual([1])
28
- expect(result.markerColor).toBe('red')
29
+ expect(result.layers).toEqual([1])
30
+ expect(result.marker).toEqual(marker)
29
31
  expect(result).not.toBe(state)
30
32
  })
31
33
 
32
- it('DISABLE sets enabled to false, clears selection, and preserves other state', () => {
33
- const state = { ...initialState, enabled: true, dataLayers: [1], markerColor: 'red', selectedFeatures: [{ featureId: 'f1' }], selectionBounds: [0, 0, 1, 1] }
34
+ it('DISABLE sets enabled to false, clears selection and markers, and preserves other state', () => {
35
+ const marker = { symbol: 'pin', backgroundColor: 'red' }
36
+ const state = { ...initialState, enabled: true, layers: [1], marker, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: ['m1'], selectionBounds: [0, 0, 1, 1] }
34
37
  const result = actions.DISABLE(state)
35
38
 
36
39
  expect(result.enabled).toBe(false)
37
- expect(result.dataLayers).toEqual([1])
38
- expect(result.markerColor).toBe('red')
40
+ expect(result.layers).toEqual([1])
41
+ expect(result.marker).toEqual(marker)
39
42
  expect(result.selectedFeatures).toEqual([])
43
+ expect(result.selectedMarkers).toEqual([])
40
44
  expect(result.selectionBounds).toBeNull()
41
45
  expect(result).not.toBe(state)
42
46
  })
@@ -96,6 +100,19 @@ describe('TOGGLE_SELECTED_FEATURES action', () => {
96
100
  expect(state.selectedFeatures[0].featureId).toBe('f3')
97
101
  })
98
102
 
103
+ it('clears selectedMarkers in single-select; preserves them in multi-select', () => {
104
+ const state = { ...initialState, selectedMarkers: ['m1'] }
105
+ const feature = createFeature('f1')
106
+
107
+ // single-select clears markers
108
+ const single = actions.TOGGLE_SELECTED_FEATURES(state, feature)
109
+ expect(single.selectedMarkers).toEqual([])
110
+
111
+ // multi-select preserves markers
112
+ const multi = actions.TOGGLE_SELECTED_FEATURES(state, { ...feature, multiSelect: true })
113
+ expect(multi.selectedMarkers).toEqual(['m1'])
114
+ })
115
+
99
116
  it('handles null or empty selectedFeatures gracefully', () => {
100
117
  let state = { ...initialState, selectedFeatures: null }
101
118
  state = actions.TOGGLE_SELECTED_FEATURES(state, createFeature('f1'))
@@ -127,15 +144,46 @@ describe('UPDATE_SELECTED_BOUNDS action', () => {
127
144
  })
128
145
  })
129
146
 
147
+ describe('TOGGLE_SELECTED_MARKERS action', () => {
148
+ it('selects a marker in single-select mode and clears features', () => {
149
+ const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectionBounds: { sw: [0, 0], ne: [1, 1] } }
150
+ const result = actions.TOGGLE_SELECTED_MARKERS(state, { markerId: 'm1', multiSelect: false })
151
+ expect(result.selectedMarkers).toEqual(['m1'])
152
+ expect(result.selectedFeatures).toEqual([])
153
+ expect(result.selectionBounds).toBeNull()
154
+ })
155
+
156
+ it('toggles off the only selected marker in single-select mode', () => {
157
+ const state = { ...initialState, selectedMarkers: ['m1'] }
158
+ const result = actions.TOGGLE_SELECTED_MARKERS(state, { markerId: 'm1', multiSelect: false })
159
+ expect(result.selectedMarkers).toEqual([])
160
+ })
161
+
162
+ it('adds a marker in multi-select mode without clearing features', () => {
163
+ const state = { ...initialState, selectedFeatures: [{ featureId: 'f1' }], selectedMarkers: ['m1'] }
164
+ const result = actions.TOGGLE_SELECTED_MARKERS(state, { markerId: 'm2', multiSelect: true })
165
+ expect(result.selectedMarkers).toEqual(['m1', 'm2'])
166
+ expect(result.selectedFeatures).toEqual([{ featureId: 'f1' }])
167
+ })
168
+
169
+ it('removes a marker in multi-select mode', () => {
170
+ const state = { ...initialState, selectedMarkers: ['m1', 'm2'] }
171
+ const result = actions.TOGGLE_SELECTED_MARKERS(state, { markerId: 'm1', multiSelect: true })
172
+ expect(result.selectedMarkers).toEqual(['m2'])
173
+ })
174
+ })
175
+
130
176
  describe('CLEAR_SELECTED_FEATURES action', () => {
131
- it('resets selection and bounds', () => {
177
+ it('resets features, markers and bounds', () => {
132
178
  const state = {
133
179
  ...initialState,
134
180
  selectedFeatures: [1],
181
+ selectedMarkers: ['m1'],
135
182
  selectionBounds: { sw: [0, 0], ne: [1, 1] }
136
183
  }
137
184
  const result = actions.CLEAR_SELECTED_FEATURES(state)
138
185
  expect(result.selectedFeatures).toEqual([])
186
+ expect(result.selectedMarkers).toEqual([])
139
187
  expect(result.selectionBounds).toBeNull()
140
188
  expect(result).not.toBe(state)
141
189
  })
@@ -147,6 +195,7 @@ describe('actions object', () => {
147
195
  'ENABLE',
148
196
  'DISABLE',
149
197
  'TOGGLE_SELECTED_FEATURES',
198
+ 'TOGGLE_SELECTED_MARKERS',
150
199
  'UPDATE_SELECTED_BOUNDS',
151
200
  'CLEAR_SELECTED_FEATURES'
152
201
  ])
@@ -1,6 +1,17 @@
1
- import { DEFAULTS } from '../defaults.js'
2
1
  import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
3
2
 
3
+ const DEFAULT_STROKE_WIDTH = 3
4
+
5
+ /**
6
+ * Builds a map of layerId → resolved highlight style for the given data layers.
7
+ *
8
+ * Stroke colour resolution order:
9
+ * layer.selectedStroke → mapStyle.selectedColor → mapColorScheme scheme default
10
+ *
11
+ * @param {Object[]} dataLayers
12
+ * @param {Object} mapStyle - Current map style config
13
+ * @returns {Object} layerId → { stroke, fill, strokeWidth }
14
+ */
4
15
  export const buildStylesMap = (dataLayers, mapStyle) => {
5
16
  const stylesMap = {}
6
17
 
@@ -8,10 +19,12 @@ export const buildStylesMap = (dataLayers, mapStyle) => {
8
19
  return stylesMap
9
20
  }
10
21
 
22
+ const schemeSelectedColor = mapStyle.mapColorScheme === 'dark' ? '#ffffff' : '#0b0c0c'
23
+
11
24
  dataLayers.forEach(layer => {
12
- const stroke = layer.selectedStroke || DEFAULTS.selectedStroke
13
- const fill = layer.selectedFill || DEFAULTS.selectedFill
14
- const strokeWidth = layer.selectedStrokeWidth || DEFAULTS.selectedStrokeWidth
25
+ const stroke = layer.selectedStroke || mapStyle.selectedColor || schemeSelectedColor
26
+ const fill = layer.selectedFill || 'transparent'
27
+ const strokeWidth = layer.selectedStrokeWidth || DEFAULT_STROKE_WIDTH
15
28
 
16
29
  stylesMap[layer.layerId] = {
17
30
  stroke: getValueForStyle(stroke, mapStyle.id),
@@ -34,11 +34,25 @@ describe('buildStylesMap', () => {
34
34
  })
35
35
 
36
36
  // Default fallback values
37
- expect(result.custom2.stroke).toBe(DEFAULTS.selectedStroke)
38
- expect(result.custom2.fill).toBe(DEFAULTS.selectedFill)
37
+ expect(result.custom2.stroke).toBe('#0b0c0c')
38
+ expect(result.custom2.fill).toBe('transparent')
39
39
  expect(result.custom2.strokeWidth).toBe(DEFAULTS.selectedStrokeWidth)
40
40
  })
41
41
 
42
+ it('uses mapStyle.selectedColor as default stroke when no layer override', () => {
43
+ const dataLayers = [{ layerId: 'layer1' }]
44
+ const mapStyle = { id: 'test', selectedColor: '#336699' }
45
+ const result = buildStylesMap(dataLayers, mapStyle)
46
+ expect(result.layer1.stroke).toBe('#336699')
47
+ expect(result.layer1.fill).toBe('transparent')
48
+ })
49
+
50
+ it('uses scheme default when mapStyle has no selectedColor', () => {
51
+ const dataLayers = [{ layerId: 'layer1' }]
52
+ expect(buildStylesMap(dataLayers, { id: 'light' }).layer1.stroke).toBe('#0b0c0c')
53
+ expect(buildStylesMap(dataLayers, { id: 'dark', mapColorScheme: 'dark' }).layer1.stroke).toBe('#ffffff')
54
+ })
55
+
42
56
  it('calls getValueForStyle for stroke and fill with mapStyle.id', () => {
43
57
  const dataLayers = [
44
58
  { layerId: 'layer1', selectedStroke: 'strokeVal', selectedFill: 'fillVal' }
@@ -15,12 +15,17 @@ export const getFeaturesAtPoint = (mapProvider, point, options) => {
15
15
  }
16
16
  }
17
17
 
18
+ const isPointGeometry = (feature) => {
19
+ const type = feature.geometry?.type
20
+ return type === 'Point' || type === 'MultiPoint'
21
+ }
22
+
18
23
  export const findMatchingFeature = (features, layerConfigMap) => {
19
- for (const feature of features) {
20
- const layerId = feature.layer?.id
21
- if (layerConfigMap[layerId]) {
22
- return { feature, config: layerConfigMap[layerId] }
23
- }
24
+ const matched = features.filter(f => layerConfigMap[f.layer?.id])
25
+ const pointMatch = matched.find(isPointGeometry)
26
+ if (pointMatch) {
27
+ return { feature: pointMatch, config: layerConfigMap[pointMatch.layer.id] }
24
28
  }
25
- return null
29
+ const first = matched[0]
30
+ return first ? { feature: first, config: layerConfigMap[first.layer.id] } : null
26
31
  }
@@ -38,7 +38,7 @@ describe('getFeaturesAtPoint', () => {
38
38
  })
39
39
 
40
40
  describe('findMatchingFeature', () => {
41
- const layerConfigMap = { layer1: { layerId: 'layer1' } }
41
+ const layerConfigMap = { layer1: { layerId: 'layer1' }, layer2: { layerId: 'layer2' } }
42
42
 
43
43
  it('returns first feature matching config, null otherwise', () => {
44
44
  const features = [
@@ -53,4 +53,11 @@ describe('findMatchingFeature', () => {
53
53
  expect(findMatchingFeature([{ id: 'f4' }], layerConfigMap)).toBeNull()
54
54
  expect(findMatchingFeature([{ id: 'f5', layer: { id: 'other' } }], {})).toBeNull()
55
55
  })
56
+
57
+ it('prioritises point geometry over non-point when both match', () => {
58
+ const polygon = { id: 'p1', layer: { id: 'layer1' }, geometry: { type: 'Polygon' } }
59
+ const point = { id: 'p2', layer: { id: 'layer2' }, geometry: { type: 'Point' } }
60
+ const result = findMatchingFeature([polygon, point], layerConfigMap)
61
+ expect(result).toEqual({ feature: point, config: layerConfigMap.layer2 })
62
+ })
56
63
  })