@defra/interactive-map 0.0.22-alpha → 0.0.23-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 (190) 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/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -6,43 +6,51 @@ jest.mock('../utils/buildStylesMap.js', () => ({
6
6
  buildStylesMap: jest.fn(() => ({ layer1: { stroke: 'red', fill: 'blue' } }))
7
7
  }))
8
8
 
9
- describe('useHighlightSync', () => {
10
- let mockDeps
11
- let capturedEventHandler
12
-
13
- const render = (overrides = {}) =>
14
- renderHook(() => useHighlightSync({ ...mockDeps, ...overrides }))
15
-
16
- beforeEach(() => {
17
- jest.clearAllMocks()
18
- capturedEventHandler = null
19
-
20
- mockDeps = {
21
- mapProvider: {
22
- updateHighlightedFeatures: jest.fn(() => ({ sw: [0, 0], ne: [1, 1] }))
23
- },
24
- mapStyle: { id: 'default-style' },
25
- pluginState: {
26
- layers: [{ layerId: 'layer1' }]
27
- },
28
- selectedFeatures: [],
29
- dispatch: jest.fn(),
30
- events: { MAP_DATA_CHANGE: 'map:datachange' },
31
- eventBus: {
32
- on: jest.fn((event, handler) => {
33
- if (event === 'map:datachange') {
34
- capturedEventHandler = handler
35
- }
36
- }),
37
- off: jest.fn()
38
- }
9
+ const STYLE_CHANGE = 'map:stylechange'
10
+ const DATA_CHANGE = 'map:datachange'
11
+
12
+ let mockDeps
13
+ let capturedStyleChangeHandler
14
+ let capturedDataChangeHandler
15
+
16
+ const render = (overrides = {}) =>
17
+ renderHook(() => useHighlightSync({ ...mockDeps, ...overrides }))
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks()
21
+ capturedStyleChangeHandler = null
22
+ capturedDataChangeHandler = null
23
+
24
+ mockDeps = {
25
+ mapProvider: {
26
+ updateHighlightedFeatures: jest.fn(() => ({ sw: [0, 0], ne: [1, 1] }))
27
+ },
28
+ mapStyle: { id: 'default-style' },
29
+ pluginState: {
30
+ layers: [{ layerId: 'layer1' }]
31
+ },
32
+ selectedFeatures: [],
33
+ dispatch: jest.fn(),
34
+ events: { MAP_STYLE_CHANGE: STYLE_CHANGE, MAP_DATA_CHANGE: DATA_CHANGE },
35
+ eventBus: {
36
+ on: jest.fn((event, handler) => {
37
+ if (event === STYLE_CHANGE) {
38
+ capturedStyleChangeHandler = handler
39
+ }
40
+ }),
41
+ once: jest.fn((event, handler) => {
42
+ if (event === DATA_CHANGE) {
43
+ capturedDataChangeHandler = handler
44
+ }
45
+ }),
46
+ off: jest.fn()
39
47
  }
40
- })
48
+ }
49
+ })
41
50
 
42
- /* ------------------------------------------------------------------ */
43
- /* Highlighting */
44
- /* ------------------------------------------------------------------ */
51
+ // ─── useHighlightSync — highlighting ─────────────────────────────────────────
45
52
 
53
+ describe('useHighlightSync — highlighting', () => {
46
54
  it('updates map highlights and dispatches bounds', () => {
47
55
  mockDeps.selectedFeatures = [{ featureId: 'F1', layerId: 'layer1' }]
48
56
 
@@ -50,9 +58,9 @@ describe('useHighlightSync', () => {
50
58
 
51
59
  expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
52
60
  mockDeps.selectedFeatures,
61
+ [],
53
62
  expect.any(Object)
54
63
  )
55
-
56
64
  expect(mockDeps.dispatch).toHaveBeenCalledWith({
57
65
  type: 'UPDATE_SELECTED_BOUNDS',
58
66
  payload: { sw: [0, 0], ne: [1, 1] }
@@ -70,11 +78,11 @@ describe('useHighlightSync', () => {
70
78
  payload: null
71
79
  })
72
80
  })
81
+ })
73
82
 
74
- /* ------------------------------------------------------------------ */
75
- /* Styles memoization */
76
- /* ------------------------------------------------------------------ */
83
+ // ─── useHighlightSync — styles memoization ───────────────────────────────────
77
84
 
85
+ describe('useHighlightSync — styles memoization', () => {
78
86
  it('rebuilds styles when mapStyle changes', () => {
79
87
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
80
88
 
@@ -84,46 +92,37 @@ describe('useHighlightSync', () => {
84
92
  )
85
93
 
86
94
  buildStylesMap.mockClear()
87
-
88
95
  rerender({ mapStyle: { id: 'satellite' } })
89
96
 
90
- expect(buildStylesMap).toHaveBeenCalledWith(
91
- expect.anything(),
92
- { id: 'satellite' }
93
- )
97
+ expect(buildStylesMap).toHaveBeenCalledWith(expect.anything(), { id: 'satellite' })
94
98
  })
95
99
 
96
100
  it('rebuilds styles when layers change', () => {
97
101
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
98
102
 
99
103
  const { rerender } = renderHook(
100
- ({ layers }) =>
101
- useHighlightSync({
102
- ...mockDeps,
103
- pluginState: { layers }
104
- }),
104
+ ({ layers }) => useHighlightSync({ ...mockDeps, pluginState: { layers } }),
105
105
  { initialProps: { layers: [{ layerId: 'layer1' }] } }
106
106
  )
107
107
 
108
108
  buildStylesMap.mockClear()
109
-
110
109
  rerender({ layers: [{ layerId: 'layer1' }, { layerId: 'layer2' }] })
111
110
 
112
111
  expect(buildStylesMap).toHaveBeenCalled()
113
112
  })
113
+ })
114
114
 
115
- /* ------------------------------------------------------------------ */
116
- /* Map data change events */
117
- /* ------------------------------------------------------------------ */
115
+ // ─── useHighlightSync — style-change event ───────────────────────────────────
118
116
 
119
- it('refreshes highlights on MAP_DATA_CHANGE', () => {
117
+ describe('useHighlightSync style-change event', () => {
118
+ it('refreshes highlights after MAP_STYLE_CHANGE then MAP_DATA_CHANGE', () => {
120
119
  mockDeps.selectedFeatures = [{ featureId: 'F1', layerId: 'layer1' }]
121
120
 
122
121
  render()
123
-
124
122
  mockDeps.mapProvider.updateHighlightedFeatures.mockClear()
125
123
 
126
- act(() => capturedEventHandler())
124
+ act(() => capturedStyleChangeHandler())
125
+ act(() => capturedDataChangeHandler())
127
126
 
128
127
  expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalled()
129
128
  })
@@ -132,19 +131,15 @@ describe('useHighlightSync', () => {
132
131
  mockDeps.selectedFeatures = [{ featureId: 'F1', layerId: 'layer1' }]
133
132
 
134
133
  const { unmount } = render()
135
-
136
134
  unmount()
137
135
 
138
- expect(mockDeps.eventBus.off).toHaveBeenCalledWith(
139
- 'map:datachange',
140
- expect.any(Function)
141
- )
136
+ expect(mockDeps.eventBus.off).toHaveBeenCalledWith(STYLE_CHANGE, expect.any(Function))
142
137
  })
138
+ })
143
139
 
144
- /* ------------------------------------------------------------------ */
145
- /* Guards */
146
- /* ------------------------------------------------------------------ */
140
+ // ─── useHighlightSync — guards ───────────────────────────────────────────────
147
141
 
142
+ describe('useHighlightSync — guards', () => {
148
143
  it('does nothing when mapProvider is null', () => {
149
144
  mockDeps.mapProvider = null
150
145
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
@@ -154,14 +149,6 @@ describe('useHighlightSync', () => {
154
149
  expect(mockDeps.dispatch).not.toHaveBeenCalled()
155
150
  })
156
151
 
157
- it('does nothing when selectedFeatures is null', () => {
158
- mockDeps.selectedFeatures = null
159
-
160
- render()
161
-
162
- expect(mockDeps.mapProvider.updateHighlightedFeatures).not.toHaveBeenCalled()
163
- })
164
-
165
152
  it('does nothing when mapStyle is null', () => {
166
153
  mockDeps.mapStyle = null
167
154
  mockDeps.selectedFeatures = [{ featureId: 'F1' }]
@@ -171,41 +158,101 @@ describe('useHighlightSync', () => {
171
158
  expect(mockDeps.mapProvider.updateHighlightedFeatures).not.toHaveBeenCalled()
172
159
  expect(buildStylesMap).not.toHaveBeenCalled()
173
160
  })
161
+ })
174
162
 
175
- /* ------------------------------------------------------------------ */
176
- /* Selection updates */
177
- /* ------------------------------------------------------------------ */
163
+ // ─── useHighlightSync — selection updates ────────────────────────────────────
178
164
 
165
+ describe('useHighlightSync — selection updates', () => {
179
166
  it('updates highlights when selection changes', () => {
180
167
  const { rerender } = renderHook(
181
- ({ selectedFeatures }) =>
182
- useHighlightSync({ ...mockDeps, selectedFeatures }),
168
+ ({ selectedFeatures }) => useHighlightSync({ ...mockDeps, selectedFeatures }),
183
169
  { initialProps: { selectedFeatures: [{ featureId: 'F1' }] } }
184
170
  )
185
171
 
186
172
  mockDeps.mapProvider.updateHighlightedFeatures.mockClear()
187
-
188
173
  rerender({ selectedFeatures: [{ featureId: 'F1' }, { featureId: 'F2' }] })
189
174
 
190
175
  expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
191
176
  [{ featureId: 'F1' }, { featureId: 'F2' }],
177
+ [],
192
178
  expect.anything()
193
179
  )
194
180
  })
195
181
 
196
182
  it('clears highlights when selection becomes empty', () => {
197
183
  const { rerender } = renderHook(
198
- ({ selectedFeatures }) =>
199
- useHighlightSync({ ...mockDeps, selectedFeatures }),
184
+ ({ selectedFeatures }) => useHighlightSync({ ...mockDeps, selectedFeatures }),
200
185
  { initialProps: { selectedFeatures: [{ featureId: 'F1' }] } }
201
186
  )
202
187
 
203
188
  mockDeps.mapProvider.updateHighlightedFeatures.mockClear()
204
-
205
189
  rerender({ selectedFeatures: [] })
206
190
 
207
191
  expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
208
192
  [],
193
+ [],
194
+ expect.anything()
195
+ )
196
+ })
197
+ })
198
+
199
+ // ─── useHighlightSync — listboxActiveItem ────────────────────────────────────
200
+
201
+ describe('useHighlightSync — listboxActiveItem', () => {
202
+ it('passes listboxActiveItem as activeFeatures (second arg)', () => {
203
+ mockDeps.pluginState = {
204
+ layers: [{ layerId: 'layer1' }],
205
+ listboxActiveItem: { featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }
206
+ }
207
+ mockDeps.selectedFeatures = []
208
+
209
+ render()
210
+
211
+ expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
212
+ [],
213
+ [{ featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }],
214
+ expect.anything()
215
+ )
216
+ })
217
+
218
+ it('passes selectedFeatures and listboxActiveItem as separate arguments', () => {
219
+ mockDeps.pluginState = {
220
+ layers: [{ layerId: 'layer1' }],
221
+ listboxActiveItem: { featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }
222
+ }
223
+ mockDeps.selectedFeatures = [{ featureId: 'F1', layerId: 'layer1' }]
224
+
225
+ render()
226
+
227
+ expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
228
+ [{ featureId: 'F1', layerId: 'layer1' }],
229
+ [{ featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }],
230
+ expect.anything()
231
+ )
232
+ })
233
+
234
+ it('re-highlights when listboxActiveItem changes', () => {
235
+ const { rerender } = renderHook(
236
+ ({ pluginState }) => useHighlightSync({ ...mockDeps, pluginState }),
237
+ {
238
+ initialProps: {
239
+ pluginState: { layers: [{ layerId: 'layer1' }], listboxActiveItem: null }
240
+ }
241
+ }
242
+ )
243
+
244
+ mockDeps.mapProvider.updateHighlightedFeatures.mockClear()
245
+
246
+ rerender({
247
+ pluginState: {
248
+ layers: [{ layerId: 'layer1' }],
249
+ listboxActiveItem: { featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }
250
+ }
251
+ })
252
+
253
+ expect(mockDeps.mapProvider.updateHighlightedFeatures).toHaveBeenCalledWith(
254
+ [],
255
+ [{ featureId: 'A1', layerId: 'layer1', idProperty: 'id', geometry: { type: 'Point' } }],
209
256
  expect.anything()
210
257
  )
211
258
  })
@@ -1,28 +1,30 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
- import { isContiguousWithAny, canSplitFeatures, areAllContiguous } from '../utils/spatial.js'
2
+ import { areAllContiguous } from '../utils/spatial.js'
3
3
  import { getFeaturesAtPoint, findMatchingFeature, buildLayerConfigMap } from '../utils/featureQueries.js'
4
4
  import { scaleFactor } from '../../../../src/config/appConfig.js'
5
+ import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
5
6
 
6
7
  /**
7
8
  * Returns the id of the first DOM marker whose visual bounds contain the given point.
8
9
  *
9
10
  * 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).
11
+ * We convert by subtracting the markers container's top-left (.im-c-viewport__markers has
12
+ * inset:0 over the same area as the map canvas, giving the correct offset).
12
13
  *
13
14
  * @param {Object} markers - markers object from mapState (has .items and .markerRefs)
14
15
  * @param {{ x: number, y: number }} point - container-relative pixel coordinates
15
16
  * @param {number} scale - scaleFactor for the current mapSize (e.g. 1.5 for medium)
16
17
  * @returns {string|null}
17
18
  */
19
+
18
20
  const findMarkerAtPoint = (markers, point, scale) => {
19
21
  for (const marker of markers.items) {
20
22
  const el = markers.markerRefs?.get(marker.id)
21
- if (!el) {
23
+ if (!el || isStandaloneLabel(marker)) {
22
24
  continue
23
25
  }
24
- const parent = el.parentElement
25
- const parentRect = parent ? parent.getBoundingClientRect() : { left: 0, top: 0 }
26
+ const container = el.closest('.im-c-viewport__markers') || el.parentElement
27
+ const parentRect = container ? container.getBoundingClientRect() : { left: 0, top: 0 }
26
28
  const { left, top, right, bottom } = el.getBoundingClientRect()
27
29
  const scaledX = point.x * scale
28
30
  const scaledY = point.y * scale
@@ -57,8 +59,7 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
57
59
  selectedFeatures,
58
60
  selectedMarkers,
59
61
  selectionBounds,
60
- canMerge: areAllContiguous(selectedFeatures),
61
- canSplit: canSplitFeatures(selectedFeatures)
62
+ contiguous: areAllContiguous(selectedFeatures)
62
63
  })
63
64
 
64
65
  lastEmittedSelectionChange.current = { features: selectedFeatures, markers: selectedMarkers }
@@ -82,13 +83,12 @@ const useSelectionChangeEmitter = (eventBus, selectedFeatures, selectedMarkers,
82
83
  */
83
84
  export const useInteractionHandlers = ({ mapState, pluginState, services, mapProvider }) => {
84
85
  const { markers, mapSize } = mapState
85
- const { dispatch, layers, interactionModes, multiSelect, contiguous, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
86
+ const { dispatch, layers, interactionModes, multiSelect, marker: markerOptions, tolerance, selectedFeatures, selectedMarkers, selectionBounds, deselectOnClickOutside } = pluginState
86
87
  const { eventBus } = services
87
88
  const layerConfigMap = buildLayerConfigMap(layers)
88
89
  const scale = scaleFactor[mapSize] ?? 1
89
90
  const processFeatureMatch = useCallback(({ feature, config }) => {
90
91
  markers.remove('location')
91
- const isNewContiguous = contiguous && isContiguousWithAny(feature, selectedFeatures)
92
92
  const featureId = feature.properties?.[config.idProperty] ?? feature.id
93
93
  if (featureId == null) {
94
94
  return
@@ -101,11 +101,10 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
101
101
  layerId: config.layerId,
102
102
  idProperty: config.idProperty,
103
103
  properties: feature.properties,
104
- geometry: feature.geometry,
105
- replaceAll: contiguous && !isNewContiguous
104
+ geometry: feature.geometry
106
105
  }
107
106
  })
108
- }, [markers, contiguous, selectedFeatures, dispatch, multiSelect])
107
+ }, [markers, dispatch, multiSelect])
109
108
 
110
109
  const processFallback = useCallback(({ coords }) => {
111
110
  const canPlace = interactionModes.includes('placeMarker')
@@ -120,6 +119,11 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
120
119
  }, [interactionModes, dispatch, markers, markerOptions, eventBus, deselectOnClickOutside])
121
120
 
122
121
  const handleInteraction = useCallback(({ point, coords }) => {
122
+ const debugFeatures = pluginState?.debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null
123
+ if (debugFeatures) {
124
+ console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR
125
+ }
126
+
123
127
  if (interactionModes.includes('selectMarker')) {
124
128
  const markerHit = findMarkerAtPoint(markers, point, scale)
125
129
  if (markerHit) {
@@ -129,10 +133,7 @@ export const useInteractionHandlers = ({ mapState, pluginState, services, mapPro
129
133
  }
130
134
 
131
135
  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 allFeatures = debugFeatures ?? getFeaturesAtPoint(mapProvider, point, { radius: tolerance })
136
137
  const match = findMatchingFeature(allFeatures, layerConfigMap)
137
138
  if (match) {
138
139
  processFeatureMatch(match)
@@ -1,15 +1,11 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
2
  import { useInteractionHandlers } from './useInteractionHandlers.js'
3
3
  import * as featureQueries from '../utils/featureQueries.js'
4
- import { isContiguousWithAny } from '../utils/spatial.js'
5
-
6
4
  /* ------------------------------------------------------------------ */
7
5
  /* Mocks */
8
6
  /* ------------------------------------------------------------------ */
9
7
 
10
8
  jest.mock('../utils/spatial.js', () => ({
11
- isContiguousWithAny: jest.fn(),
12
- canSplitFeatures: jest.fn(() => false),
13
9
  areAllContiguous: jest.fn(() => false)
14
10
  }))
15
11
  jest.mock('../utils/featureQueries.js', () => ({
@@ -32,6 +28,7 @@ const click = result =>
32
28
 
33
29
  const makeMarkerEl = (rect) => ({
34
30
  getBoundingClientRect: () => rect,
31
+ closest: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
35
32
  parentElement: {
36
33
  getBoundingClientRect: () => ({ left: 0, top: 0 })
37
34
  }
@@ -47,7 +44,6 @@ const setup = (pluginOverrides = {}, markerItems = [], markerRefs = new Map()) =
47
44
  layers: [{ layerId: 'parcels', idProperty: 'parcelId' }],
48
45
  interactionModes: ['selectMarker', 'selectFeature'],
49
46
  multiSelect: false,
50
- contiguous: false,
51
47
  marker: { symbol: 'pin', backgroundColor: 'red' },
52
48
  selectedFeatures: [],
53
49
  selectedMarkers: [],
@@ -127,10 +123,11 @@ describe('DOM marker hit detection', () => {
127
123
  )
128
124
  })
129
125
 
130
- it('uses zero offset when marker element has no parentElement', () => {
131
- // parentElement is null — fallback parentRect { left: 0, top: 0 } should be used
126
+ it('uses zero offset when marker element has no features container or parentElement', () => {
127
+ // closest and parentElement both null — fallback parentRect { left: 0, top: 0 } should be used
132
128
  const markerEl = {
133
129
  getBoundingClientRect: () => ({ left: 5, top: 15, right: 15, bottom: 25 }),
130
+ closest: () => null,
134
131
  parentElement: null
135
132
  }
136
133
  const markerRefs = new Map([['marker-1', markerEl]])
@@ -255,58 +252,6 @@ it('passes multiSelect flag through to dispatch', () => {
255
252
  )
256
253
  })
257
254
 
258
- /* ------------------------------------------------------------------ */
259
- /* Contiguous selection (FULL COVERAGE) */
260
- /* ------------------------------------------------------------------ */
261
-
262
- describe('contiguous selection', () => {
263
- it('does NOT replace selection when feature is contiguous', () => {
264
- isContiguousWithAny.mockReturnValue(true) // contiguous
265
-
266
- const { result, deps } = setup({
267
- contiguous: true,
268
- selectedFeatures: [{ geometry: { type: 'Polygon' } }]
269
- })
270
-
271
- click(result)
272
-
273
- expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
274
- expect.objectContaining({
275
- payload: expect.objectContaining({
276
- replaceAll: false
277
- })
278
- })
279
- )
280
- })
281
-
282
- it('replaces selection when feature is NOT contiguous', () => {
283
- isContiguousWithAny.mockReturnValue(false) // disjoint
284
-
285
- const { result, deps } = setup({
286
- contiguous: true,
287
- selectedFeatures: [{ geometry: { type: 'Polygon' } }]
288
- })
289
-
290
- click(result)
291
-
292
- expect(deps.pluginState.dispatch).toHaveBeenCalledWith(
293
- expect.objectContaining({
294
- payload: expect.objectContaining({
295
- replaceAll: true
296
- })
297
- })
298
- )
299
- })
300
-
301
- it('does not compute contiguity when contiguous is false', () => {
302
- const { result } = setup({ contiguous: false })
303
-
304
- click(result)
305
-
306
- expect(isContiguousWithAny).not.toHaveBeenCalled()
307
- })
308
- })
309
-
310
255
  /* ------------------------------------------------------------------ */
311
256
  /* deselectOnClickOutside */
312
257
  /* ------------------------------------------------------------------ */
@@ -382,6 +327,23 @@ it('does not check markers when selectMarker is not in interactionModes', () =>
382
327
  /* Selection change event */
383
328
  /* ------------------------------------------------------------------ */
384
329
 
330
+ it('does not emit selectionchange when features are selected but bounds not yet calculated', () => {
331
+ const deps = {
332
+ mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
333
+ pluginState: {
334
+ selectedFeatures: [{ featureId: 'F1' }],
335
+ selectedMarkers: [],
336
+ selectionBounds: null
337
+ },
338
+ services: { eventBus: { emit: jest.fn() } },
339
+ mapProvider: {}
340
+ }
341
+
342
+ renderHook(() => useInteractionHandlers(deps))
343
+
344
+ expect(deps.services.eventBus.emit).not.toHaveBeenCalled()
345
+ })
346
+
385
347
  it('emits selectionchange once when bounds exist', () => {
386
348
  const deps = {
387
349
  mapState: { markers: { add: jest.fn(), remove: jest.fn(), items: [], markerRefs: new Map() } },
@@ -402,8 +364,7 @@ it('emits selectionchange once when bounds exist', () => {
402
364
  selectedFeatures: deps.pluginState.selectedFeatures,
403
365
  selectedMarkers: [],
404
366
  selectionBounds: deps.pluginState.selectionBounds,
405
- canMerge: false,
406
- canSplit: false
367
+ contiguous: false
407
368
  })
408
369
  )
409
370
  })