@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
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../../src/config/events.js'
2
3
  import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
3
4
  import { useHighlightSync } from './hooks/useHighlightSync.js'
5
+ import { useHoverCursor } from './hooks/useHoverCursor.js'
4
6
  import { attachEvents } from './events.js'
5
7
 
6
8
  export const InteractInit = ({
@@ -12,8 +14,8 @@ export const InteractInit = ({
12
14
  pluginState
13
15
  }) => {
14
16
  const { interfaceType } = appState
15
- const { dispatch, enabled, selectedFeatures, selectionBounds } = pluginState
16
- const { events, eventBus, closeApp } = services
17
+ const { dispatch, enabled, selectedFeatures, selectionBounds, interactionModes, layers } = pluginState
18
+ const { eventBus, closeApp } = services
17
19
  const { crossHair, mapStyle } = mapState
18
20
 
19
21
  const isTouchOrKeyboard = ['touch', 'keyboard'].includes(interfaceType)
@@ -56,10 +58,17 @@ export const InteractInit = ({
56
58
  selectedFeatures,
57
59
  selectionBounds,
58
60
  dispatch,
59
- events,
61
+ events: EVENTS,
60
62
  eventBus
61
63
  })
62
64
 
65
+ // Notify other components (e.g. Markers) whether interact is active
66
+ useEffect(() => {
67
+ eventBus.emit('interact:active', { active: enabled, interactionModes })
68
+ }, [enabled, interactionModes])
69
+
70
+ useHoverCursor(mapProvider, enabled, interactionModes, layers)
71
+
63
72
  // Toggle target marker visibility
64
73
  useEffect(() => {
65
74
  if (enabled && isTouchOrKeyboard) {
@@ -79,7 +88,7 @@ export const InteractInit = ({
79
88
  mapState,
80
89
  getPluginState: () => pluginStateRef.current,
81
90
  buttonConfig,
82
- events,
91
+ events: EVENTS,
83
92
  eventBus,
84
93
  handleInteraction: (e) => handleInteractionRef.current(e),
85
94
  clickReadyRef,
@@ -87,7 +96,7 @@ export const InteractInit = ({
87
96
  })
88
97
 
89
98
  return cleanupEvents
90
- }, [pluginState.enabled, buttonConfig, events, eventBus, closeApp])
99
+ }, [pluginState.enabled, buttonConfig, eventBus, closeApp])
91
100
 
92
101
  return null
93
102
  }
@@ -1,11 +1,14 @@
1
1
  import { act, render } from '@testing-library/react'
2
+ import { EVENTS } from '../../../src/config/events.js'
2
3
  import { InteractInit } from './InteractInit.jsx'
3
4
  import { useInteractionHandlers } from './hooks/useInteractionHandlers.js'
4
5
  import { useHighlightSync } from './hooks/useHighlightSync.js'
6
+ import { useHoverCursor } from './hooks/useHoverCursor.js'
5
7
  import { attachEvents } from './events.js'
6
8
 
7
9
  jest.mock('./hooks/useInteractionHandlers.js')
8
10
  jest.mock('./hooks/useHighlightSync.js')
11
+ jest.mock('./hooks/useHoverCursor.js')
9
12
  jest.mock('./events.js')
10
13
 
11
14
  describe('InteractInit', () => {
@@ -19,15 +22,24 @@ describe('InteractInit', () => {
19
22
 
20
23
  useInteractionHandlers.mockReturnValue({ handleInteraction: handleInteractionMock })
21
24
  useHighlightSync.mockReturnValue(undefined)
25
+ useHoverCursor.mockReturnValue(undefined)
22
26
  attachEvents.mockReturnValue(cleanupMock)
23
27
 
24
28
  props = {
25
- appState: { interfaceType: 'mouse' },
29
+ appState: { interfaceType: 'mouse', layoutRefs: { viewportRef: { current: null } } },
26
30
  mapState: { crossHair: { fixAtCenter: jest.fn(), hide: jest.fn() }, mapStyle: {} },
27
- services: { events: {}, eventBus: {}, closeApp: jest.fn() },
31
+ services: { eventBus: { emit: jest.fn() }, closeApp: jest.fn() },
28
32
  buttonConfig: {},
29
- mapProvider: {},
30
- pluginState: { dispatch: jest.fn(), enabled: true, selectedFeatures: [], selectionBounds: {} }
33
+ mapProvider: { setHoverCursor: jest.fn() },
34
+ pluginState: {
35
+ dispatch: jest.fn(),
36
+ enabled: true,
37
+ selectedFeatures: [],
38
+ selectedMarkers: [],
39
+ selectionBounds: {},
40
+ interactionModes: ['selectFeature'],
41
+ layers: []
42
+ }
31
43
  }
32
44
  })
33
45
 
@@ -50,7 +62,7 @@ describe('InteractInit', () => {
50
62
  pluginState: props.pluginState,
51
63
  selectedFeatures: props.pluginState.selectedFeatures,
52
64
  dispatch: props.pluginState.dispatch,
53
- events: props.services.events,
65
+ events: EVENTS,
54
66
  eventBus: props.services.eventBus
55
67
  }))
56
68
  })
@@ -75,7 +87,7 @@ describe('InteractInit', () => {
75
87
  handleInteraction: expect.any(Function),
76
88
  mapState: props.mapState,
77
89
  buttonConfig: props.buttonConfig,
78
- events: props.services.events,
90
+ events: EVENTS,
79
91
  eventBus: props.services.eventBus,
80
92
  closeApp: props.services.closeApp
81
93
  }))
@@ -92,6 +104,14 @@ describe('InteractInit', () => {
92
104
  expect(cleanupMock).toHaveBeenCalled()
93
105
  })
94
106
 
107
+ it('emits interact:active with active state and interactionModes on enable', () => {
108
+ render(<InteractInit {...props} />)
109
+ expect(props.services.eventBus.emit).toHaveBeenCalledWith('interact:active', {
110
+ active: true,
111
+ interactionModes: props.pluginState.interactionModes
112
+ })
113
+ })
114
+
95
115
  it('enables click handling after a macrotask', () => {
96
116
  jest.useFakeTimers()
97
117
  render(<InteractInit {...props} />)
@@ -9,8 +9,8 @@ describe('enable', () => {
9
9
  })
10
10
 
11
11
  it('dispatches ENABLE with merged payload correctly', () => {
12
- const pluginConfig = { markerColor: 'blue' }
13
- const options = { interactionMode: 'select', markerColor: 'green', dataLayers: [{ layerId: 'test' }] }
12
+ const pluginConfig = { marker: { symbol: 'pin', backgroundColor: 'blue' } }
13
+ const options = { interactionModes: ['selectFeature'], marker: { symbol: 'circle', backgroundColor: 'green' }, layers: [{ layerId: 'test' }] }
14
14
 
15
15
  enable({ pluginState: { dispatch }, pluginConfig }, options)
16
16
 
@@ -18,16 +18,16 @@ describe('enable', () => {
18
18
  expect(dispatch).toHaveBeenCalledWith({
19
19
  type: 'ENABLE',
20
20
  payload: expect.objectContaining({
21
- interactionMode: 'select', // options override DEFAULTS
22
- multiSelect: DEFAULTS.multiSelect, // default preserved
23
- markerColor: 'green', // options override pluginConfig
24
- dataLayers: [{ layerId: 'test' }] // options passed
21
+ interactionModes: ['selectFeature'],
22
+ multiSelect: DEFAULTS.multiSelect,
23
+ marker: { symbol: 'circle', backgroundColor: 'green' },
24
+ layers: [{ layerId: 'test' }]
25
25
  })
26
26
  })
27
27
  })
28
28
 
29
29
  it('handles empty or undefined options', () => {
30
- const pluginConfig = { markerColor: 'blue' }
30
+ const pluginConfig = { marker: { symbol: 'pin', backgroundColor: 'blue' } }
31
31
 
32
32
  enable({ pluginState: { dispatch }, pluginConfig }, {})
33
33
  expect(dispatch).toHaveBeenCalledTimes(1)
@@ -1,11 +1,9 @@
1
1
  export const DEFAULTS = {
2
- tolerance: 10, // Used for cross hair and click events
3
- interactionMode: 'marker',
2
+ tolerance: 10,
3
+ interactionModes: ['selectMarker'],
4
4
  multiSelect: false,
5
5
  contiguous: false,
6
6
  deselectOnClickOutside: false,
7
- markerColor: 'rgba(212,53,28,1)',
8
- selectedStroke: 'rgba(212,53,28,1)',
9
- selectedFill: 'rgba(255, 0, 0, 0.1)',
10
- selectedStrokeWidth: 2
7
+ marker: {},
8
+ selectedStrokeWidth: 3
11
9
  }
@@ -1,3 +1,10 @@
1
+ const buildDonePayload = (coords, selectedFeatures, selectedMarkers, selectionBounds) => ({
2
+ ...(coords && { coords }),
3
+ ...(!coords && selectedFeatures && { selectedFeatures }),
4
+ ...(!coords && selectedMarkers?.length && { selectedMarkers }),
5
+ ...(!coords && selectionBounds && { selectionBounds })
6
+ })
7
+
1
8
  // Helper for feature toggling logic
2
9
  const createFeatureHandler = (mapState, getPluginState) => (args, addToExisting) => {
3
10
  const pluginState = getPluginState()
@@ -48,17 +55,13 @@ export function attachEvents ({
48
55
  const pluginState = getPluginState()
49
56
  const marker = mapState.markers.getMarker('location')
50
57
  const { coords } = marker || {}
51
- const { selectionBounds, selectedFeatures } = pluginState
58
+ const { selectionBounds, selectedFeatures, selectedMarkers } = pluginState
52
59
 
53
60
  if (getAppState().disabledButtons.has('selectDone')) {
54
61
  return
55
62
  }
56
63
 
57
- eventBus.emit('interact:done', {
58
- ...(coords && { coords }),
59
- ...(!coords && selectedFeatures && { selectedFeatures }),
60
- ...(!coords && selectionBounds && { selectionBounds })
61
- })
64
+ eventBus.emit('interact:done', buildDonePayload(coords, selectedFeatures, selectedMarkers, selectionBounds))
62
65
 
63
66
  if (pluginState.closeOnAction ?? true) {
64
67
  closeApp()
@@ -172,14 +172,11 @@ describe('attachEvents', () => {
172
172
  Object.values(params.buttonConfig).forEach(btn => expect(btn.onClick).toBeNull())
173
173
  })
174
174
 
175
- it('selectDone handles emission when no marker/coords exist', () => {
175
+ it('selectDone emits selectedFeatures and selectionBounds when no marker/coords', () => {
176
176
  const params = createParams()
177
177
  cleanup = attachEvents(params)
178
178
 
179
- // Ensure marker returns null (no coords)
180
179
  params.mapState.markers.getMarker.mockReturnValue(null)
181
-
182
- // Set up features and bounds
183
180
  params.pluginState.selectedFeatures = [{ id: 'f1' }]
184
181
  params.pluginState.selectionBounds = { sw: [0, 0], ne: [1, 1] }
185
182
 
@@ -191,6 +188,33 @@ describe('attachEvents', () => {
191
188
  })
192
189
  })
193
190
 
191
+ it('selectDone includes selectedMarkers in payload when present', () => {
192
+ const params = createParams()
193
+ cleanup = attachEvents(params)
194
+
195
+ params.mapState.markers.getMarker.mockReturnValue(null)
196
+ params.pluginState.selectedMarkers = ['m1', 'm2']
197
+
198
+ params.buttonConfig.selectDone.onClick()
199
+
200
+ expect(params.eventBus.emit).toHaveBeenCalledWith('interact:done',
201
+ expect.objectContaining({ selectedMarkers: ['m1', 'm2'] })
202
+ )
203
+ })
204
+
205
+ it('selectDone omits selectedMarkers from payload when empty', () => {
206
+ const params = createParams()
207
+ cleanup = attachEvents(params)
208
+
209
+ params.mapState.markers.getMarker.mockReturnValue(null)
210
+ params.pluginState.selectedMarkers = []
211
+
212
+ params.buttonConfig.selectDone.onClick()
213
+
214
+ const payload = params.eventBus.emit.mock.calls.find(c => c[0] === 'interact:done')[1]
215
+ expect(payload).not.toHaveProperty('selectedMarkers')
216
+ })
217
+
194
218
  it('respects default closeOnAction when value is undefined (fallback to true)', () => {
195
219
  const params = createParams()
196
220
  // Explicitly set to undefined to trigger the ?? fallback
@@ -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
  }