@defra/interactive-map 0.0.43-alpha → 0.0.44-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 (159) 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.md +16 -8
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  11. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  12. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  13. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  14. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  15. package/plugins/datasets/dist/css/index.css +1 -1
  16. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  19. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  20. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  21. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  22. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  24. package/plugins/draw/dist/esm/index.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/DrawInit.jsx +31 -26
  30. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  32. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  33. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  36. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  38. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
  39. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
  40. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  41. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  42. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  43. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  44. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  45. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  46. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  47. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  48. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  49. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  51. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  52. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  53. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  54. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  55. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  56. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  57. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  58. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  59. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  60. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  61. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  62. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  63. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  64. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  65. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  66. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  67. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  68. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  69. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  70. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
  71. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  72. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  73. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  74. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  75. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  76. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  77. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  78. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  79. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  80. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
  81. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
  82. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  83. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  84. package/plugins/draw/src/events.js +4 -4
  85. package/plugins/draw/src/events.test.js +1 -1
  86. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  87. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  88. package/plugins/draw/src/utils/spatial.js +1 -31
  89. package/plugins/draw/src/utils/spatial.test.js +1 -24
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +2 -2
  94. package/plugins/interact/src/InteractInit.test.js +3 -3
  95. package/plugins/interact/src/events.js +5 -0
  96. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  97. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  98. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
  99. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  100. package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
  101. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
  102. package/plugins/interact/src/manifest.js +12 -1
  103. package/plugins/interact/src/manifest.test.js +20 -0
  104. package/plugins/interact/src/utils/spatial.js +1 -1
  105. package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
  106. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  107. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  108. package/providers/maplibre/dist/umd/index.js +1 -1
  109. package/providers/maplibre/src/utils/labels.js +2 -2
  110. package/providers/maplibre/src/utils/labels.test.js +5 -5
  111. package/providers/maplibre/src/utils/spatial.js +0 -57
  112. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  113. package/src/App/components/CrossHair/CrossHair.jsx +10 -4
  114. package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
  115. package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
  116. package/src/App/components/Hints/Hints.jsx +3 -3
  117. package/src/App/components/Hints/Hints.test.jsx +3 -3
  118. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  119. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  120. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  121. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  122. package/src/App/components/Markers/Markers.jsx +2 -2
  123. package/src/App/components/Markers/Markers.test.jsx +3 -3
  124. package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
  125. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  126. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  127. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
  128. package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
  129. package/src/App/components/Viewport/Viewport.jsx +20 -10
  130. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  131. package/src/App/hooks/useCrossHairAPI.js +5 -5
  132. package/src/App/hooks/useCrossHairAPI.test.js +5 -5
  133. package/src/App/hooks/useHintsAPI.js +5 -5
  134. package/src/App/hooks/useSpatialListFocus.js +296 -0
  135. package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
  136. package/src/App/hooks/useSpatialListItems.js +51 -0
  137. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  138. package/src/App/initialiseApp.js +6 -3
  139. package/src/App/initialiseApp.test.js +11 -0
  140. package/src/App/registry/spatialListRegistry.js +104 -0
  141. package/src/App/registry/spatialListRegistry.test.js +218 -0
  142. package/src/App/store/AppProvider.jsx +3 -2
  143. package/src/config/appConfig.js +14 -14
  144. package/src/config/appConfig.test.js +19 -19
  145. package/src/config/defaults.js +2 -2
  146. package/src/config/events.js +8 -8
  147. package/src/scss/main.scss +2 -2
  148. package/src/test-utils.js +8 -0
  149. package/src/types.js +11 -9
  150. package/src/utils/findNearestItemInDirection.js +29 -0
  151. package/src/utils/findNearestItemInDirection.test.js +67 -0
  152. package/src/utils/globalAltShortcuts.js +12 -0
  153. package/src/utils/globalAltShortcuts.test.js +27 -0
  154. package/src/utils/spatialNavigate.js +49 -0
  155. package/src/utils/spatialNavigate.test.js +42 -0
  156. package/src/App/components/Viewport/Features.jsx +0 -49
  157. package/src/App/hooks/useFeatureFocus.js +0 -257
  158. package/src/App/hooks/useFeatureItems.js +0 -39
  159. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -56,7 +56,7 @@ describe('createDrawPointMode', () => {
56
56
  const { interaction, manager, emitted } = setup()
57
57
  interaction.dispatchEvent({ type: 'drawend', feature: pointFeature([1, 2], 'ol-generated-id') })
58
58
 
59
- const created = emitted().filter((e) => e.type === ADAPTER_EVENTS.CREATE)
59
+ const created = emitted().filter((event) => event.type === ADAPTER_EVENTS.CREATE)
60
60
  expect(created).toHaveLength(1)
61
61
  expect(created[0].payload.id).toBe('point-1') // re-id'd, not the OL-generated one
62
62
  expect(created[0].payload.geometry.coordinates).toEqual([1, 2])
@@ -82,7 +82,7 @@ describe('createDrawPointMode', () => {
82
82
  test('defaults properties to {} when none are supplied', () => {
83
83
  const { interaction, manager, emitted } = setup({ properties: undefined })
84
84
  interaction.dispatchEvent({ type: 'drawend', feature: pointFeature([1, 2], 'ol-generated-id') })
85
- const created = emitted().filter((e) => e.type === ADAPTER_EVENTS.CREATE)
85
+ const created = emitted().filter((event) => event.type === ADAPTER_EVENTS.CREATE)
86
86
  // OL's GeoJSON writer reports no properties as null (not {}) — the meaningful
87
87
  // assertion is what setProperties() was actually called with, on the stored feature.
88
88
  expect(created[0].payload.properties).toBeNull()
@@ -96,7 +96,7 @@ describe('createDrawPointMode', () => {
96
96
  const condition = interaction.condition_
97
97
  const allowed = condition({ coordinate: [1, 2], originalEvent: {} })
98
98
  expect(allowed).toBe(false)
99
- const blocked = emitted().filter((e) => e.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)
99
+ const blocked = emitted().filter((event) => event.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)
100
100
  expect(blocked).toHaveLength(1)
101
101
  expect(blocked[0].payload).toMatchObject({ reason: 'not allowed here', phase: 'place', mode: 'draw_point' })
102
102
  })
@@ -116,7 +116,7 @@ describe('createDrawPointMode', () => {
116
116
  test('drawabort emits CANCEL', () => {
117
117
  const { interaction, emitted } = setup()
118
118
  interaction.dispatchEvent({ type: 'drawabort' })
119
- expect(emitted().some((e) => e.type === ADAPTER_EVENTS.CANCEL)).toBe(true)
119
+ expect(emitted().some((event) => event.type === ADAPTER_EVENTS.CANCEL)).toBe(true)
120
120
  })
121
121
 
122
122
  describe('touch/keyboard placement (via the wired placeVertex callback)', () => {
@@ -142,13 +142,13 @@ describe('createDrawPointMode', () => {
142
142
  expect(finish).toHaveBeenCalled()
143
143
  })
144
144
 
145
- test('does not snap on mouse interface', () => {
146
- const snap = { apply: jest.fn(), hideIndicator: jest.fn() }
145
+ test('still snaps with interfaceType mouse — placeVertex is only ever reached via the crosshair path (Enter, the add-vertex button, or the crosshair\'s own click), never a real mouse click', () => {
146
+ const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() }
147
147
  const { wireArgs, interaction } = setup({ snap, interfaceType: 'mouse' })
148
148
  const { append } = spyOnCommit(interaction)
149
149
  wireArgs.placeVertex()
150
- expect(snap.apply).not.toHaveBeenCalled()
151
- expect(append).toHaveBeenCalledWith([[5, 5]])
150
+ expect(snap.apply).toHaveBeenCalledWith([5, 5])
151
+ expect(append).toHaveBeenCalledWith([[9, 9]])
152
152
  })
153
153
 
154
154
  test('a vetoed crosshair placement never commits', () => {
@@ -158,7 +158,7 @@ describe('createDrawPointMode', () => {
158
158
  wireArgs.placeVertex()
159
159
  expect(append).not.toHaveBeenCalled()
160
160
  expect(finish).not.toHaveBeenCalled()
161
- expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)).toHaveLength(1)
161
+ expect(emitted().filter((event) => event.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)).toHaveLength(1)
162
162
  })
163
163
 
164
164
  test('updateRubberbanding shows the snap indicator at the crosshair for touch/keyboard, not mouse', () => {
@@ -169,11 +169,12 @@ describe('createDrawPointMode', () => {
169
169
  expect(snap.apply).toHaveBeenCalledWith([7, 7])
170
170
  })
171
171
 
172
- test('updateRubberbanding does nothing on mouse interface', () => {
172
+ test('updateRubberbanding still refreshes the indicator regardless of interfaceType — the caller decides when it\'s worth calling this at all', () => {
173
173
  const snap = { apply: jest.fn(), hideIndicator: jest.fn() }
174
- const { wireArgs } = setup({ snap, interfaceType: 'mouse' })
174
+ const mapProvider = { getCenter: () => [7, 7] }
175
+ const { wireArgs } = setup({ snap, mapProvider, interfaceType: 'mouse' })
175
176
  wireArgs.updateRubberbanding()
176
- expect(snap.apply).not.toHaveBeenCalled()
177
+ expect(snap.apply).toHaveBeenCalledWith([7, 7])
177
178
  })
178
179
 
179
180
  // Regression: without this, switching to touch/keyboard left the indicator hidden until
@@ -239,7 +240,7 @@ describe('createDrawPointMode', () => {
239
240
  const { mode, map, interaction, emitted } = setup()
240
241
  const removeSpy = jest.spyOn(map, 'removeInteraction')
241
242
  mode.destroy()
242
- expect(emitted().some((e) => e.type === ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE)).toBe(true)
243
+ expect(emitted().some((event) => event.type === ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE)).toBe(true)
243
244
  expect(removeSpy).toHaveBeenCalledWith(interaction)
244
245
  })
245
246
 
@@ -250,7 +250,7 @@ export const createEditPointMode = ({ map, manager, options }) => {
250
250
  // Matches MaplibreDrawAdapter.deleteVertex's existing no-op precedent.
251
251
  deleteVertex () {},
252
252
 
253
- // MoveControls' D-pad, routed here via mapProvider.activeMoveTarget (events.js) — claimed
253
+ // MapControls' D-pad, routed here via mapProvider.activeMoveTarget (events.js) — claimed
254
254
  // once at construction (pointSelectionState's VERTEX_SELECTION) and held for the session.
255
255
  nudgeSelectedVertex: keyboardHandler.nudgeByDelta,
256
256
 
@@ -208,7 +208,7 @@ describe('mouse hit-testing respects the icon\'s own rendered pixels, not a fixe
208
208
  })
209
209
  })
210
210
 
211
- test('nudgeSelectedVertex (MoveControls D-pad) moves the point and is undoable', () => {
211
+ test('nudgeSelectedVertex (MapControls D-pad) moves the point and is undoable', () => {
212
212
  const { manager, mode, coord } = setup()
213
213
  mode.nudgeSelectedVertex(1, 0, true)
214
214
  expect(coord()).not.toEqual([5, 5])
@@ -249,6 +249,22 @@ test('keyboard: Delete is a no-op — there is no delete-vertex action for a poi
249
249
  expect(coord()).toEqual([5, 5])
250
250
  })
251
251
 
252
+ // A point is "always selected" (pointSelectionState.js's selectedVertexIndex never leaves 0),
253
+ // so the shared keyboardHandler.js's Alt+<key> shadow guard (gated on selectedVertexIndex >= 0)
254
+ // stays unconditionally true here — unlike edit_vertex, where it only applies once a vertex is
255
+ // actually selected.
256
+ test('keyboard: Alt+Enter/Alt+Arrow keyup are shadowed from the app-wide label shortcuts for the whole session', () => {
257
+ setup()
258
+ const enterEvent = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
259
+ const arrowEvent = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
260
+ const enterSpy = jest.spyOn(enterEvent, 'stopPropagation')
261
+ const arrowSpy = jest.spyOn(arrowEvent, 'stopPropagation')
262
+ window.dispatchEvent(enterEvent)
263
+ window.dispatchEvent(arrowEvent)
264
+ expect(enterSpy).toHaveBeenCalled()
265
+ expect(arrowSpy).toHaveBeenCalled()
266
+ })
267
+
252
268
  test('keyboard: Cmd/Ctrl+Z undoes via the same op-popping logic as mode.undo()', () => {
253
269
  const { manager, coord } = setup()
254
270
  key('keydown', { key: 'ArrowRight' })
@@ -36,8 +36,8 @@ const createGeometryInvalidEmitter = (eventBus, hints) => (payload) => {
36
36
  if (payload.reason && !isIncompleteShape(payload.reason)) { hints.show(payload.reason) }
37
37
  }
38
38
 
39
- // Claims MoveControls' D-pad for the currently selected vertex — see the generic
40
- // mapProvider.activeMoveTarget contract (MoveControls.jsx). Any plugin could use
39
+ // Claims MapControls' D-pad for the currently selected vertex — see the generic
40
+ // mapProvider.activeMoveTarget contract (MapControls.jsx). Any plugin could use
41
41
  // this slot; the draw plugin is just its first consumer.
42
42
  const buildVertexMoveTarget = (draw) => ({
43
43
  move: (dx, dy, isLargeStep) => draw.nudgeSelectedVertex(dx, dy, isLargeStep),
@@ -45,7 +45,7 @@ const buildVertexMoveTarget = (draw) => ({
45
45
  })
46
46
 
47
47
  // Vertex-selection handlers: sync pluginState.selectedVertexIndex, and claim/release
48
- // MoveControls' D-pad via buildVertexMoveTarget above.
48
+ // MapControls' D-pad via buildVertexMoveTarget above.
49
49
  const createVertexSelectionHandlers = ({ draw, pluginState, mapProvider, eventBus }) => ({
50
50
  onVertexSelection: (e) => {
51
51
  pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e })
@@ -233,7 +233,7 @@ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapP
233
233
  const resetState = () => {
234
234
  pluginState.dispatch({ type: 'SET_MODE', payload: null })
235
235
  pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } })
236
- // Release MoveControls' D-pad back to panning the map whenever a draw/edit
236
+ // Release MapControls' D-pad back to panning the map whenever a draw/edit
237
237
  // session ends — a stale claim here would silently hijack it for good.
238
238
  mapProvider.activeMoveTarget = null
239
239
  }
@@ -179,7 +179,7 @@ describe('draw event handlers', () => {
179
179
  expect(() => drawHandler(draw, 'cancel')()).not.toThrow()
180
180
  })
181
181
 
182
- test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MoveControls', () => {
182
+ test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MapControls', () => {
183
183
  const { draw, dispatch, eventBus, mapProvider } = setup()
184
184
  drawHandler(draw, 'vertexselection')({ index: 2 })
185
185
  expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: 2 } })
@@ -0,0 +1,180 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../../../src/config/events.js'
3
+ import { scaleFactor } from '../../../../src/config/appConfig.js'
4
+ import { ADAPTER_EVENTS } from '../adapterEvents.js'
5
+
6
+ const EDIT_VERTEX_MODE = 'edit_vertex'
7
+ const DRAW_PROVIDER_ID = 'draw'
8
+
9
+ // Mirrors interact's own projectToScreen (plugins/interact/src/hooks/useSpatialList.js) —
10
+ // mapProvider.mapToScreen is the provider-agnostic projection every mapProvider implements,
11
+ // not any adapter-internal pixel conversion.
12
+ const projectToScreen = (mapProvider, mapSize, coord) => {
13
+ const { x, y } = mapProvider.mapToScreen(coord)
14
+ return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] }
15
+ }
16
+
17
+ // ids stay flat vertices-then-midpoints indices — matches the mode's own selectedVertexIndex
18
+ // convention on both adapters (see the adapters' getVertexItems() doc comments) and is what
19
+ // selectVertex/insertVertexAtMidpoint expect, so it's unrelated to display order. The array
20
+ // order below is interleaved for display only: Point 1, Insert 1-2, Point 2, ... The "between
21
+ // N and M" label (and this interleave) only reads correctly for a single ring/part (the
22
+ // common case); a shape with holes or multiple parts would need ring-aware numbering to stay
23
+ // accurate, not done here.
24
+ const buildVertexItems = (mapProvider, mapSize) => {
25
+ const { vertices, midpoints } = mapProvider.draw?.getVertexItems?.() ?? { vertices: [], midpoints: [] }
26
+ const toVertexItem = (coord, i) => ({
27
+ id: String(i),
28
+ label: `Point ${i + 1}`,
29
+ isMidpoint: false,
30
+ ...projectToScreen(mapProvider, mapSize, coord)
31
+ })
32
+ const toMidpointItem = (coord, i) => ({
33
+ id: String(vertices.length + i),
34
+ // Kept as short as possible — a voice user hears/reads this in full each time, and
35
+ // "Insert point between N and M" was long enough to lose.
36
+ label: `Insert ${i + 1}-${((i + 1) % vertices.length) + 1}`,
37
+ isMidpoint: true,
38
+ ...projectToScreen(mapProvider, mapSize, coord)
39
+ })
40
+ const items = []
41
+ vertices.forEach((coord, i) => {
42
+ items.push(toVertexItem(coord, i))
43
+ if (i < midpoints.length) {
44
+ items.push(toMidpointItem(midpoints[i], i))
45
+ }
46
+ })
47
+ return items
48
+ }
49
+
50
+ /**
51
+ * Rebuilds the vertex/midpoint item list whenever edit_vertex is entered/left, on every
52
+ * geometry change, and on every map move (pan/zoom moves each item's screen position even
53
+ * though the underlying geographic coordinate hasn't changed — same reason interact's own
54
+ * hook rebuilds on MAP_MOVE_END). Keeps the result in itemsRef for useConfirmHandler to
55
+ * resolve against later, and registers as an exclusive spatialListRegistry provider —
56
+ * claiming ownership while edit_vertex is active (interact's own items are excluded
57
+ * meanwhile, restored automatically once the claim is released) rather than contributing
58
+ * additively like interact does.
59
+ */
60
+ function useVertexItemSync ({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus }) {
61
+ useEffect(() => {
62
+ const rebuild = () => {
63
+ itemsRef.current = pluginState.mode === EDIT_VERTEX_MODE ? buildVertexItems(mapProvider, mapSize) : []
64
+ spatialListRegistry.notifyItemsChanged(DRAW_PROVIDER_ID)
65
+ }
66
+ rebuild()
67
+ eventBus.on(EVENTS.MAP_MOVE_END, rebuild)
68
+ const { draw } = mapProvider
69
+ if (!draw) {
70
+ return () => eventBus.off(EVENTS.MAP_MOVE_END, rebuild)
71
+ }
72
+ draw.on(ADAPTER_EVENTS.UPDATE, rebuild)
73
+ return () => {
74
+ eventBus.off(EVENTS.MAP_MOVE_END, rebuild)
75
+ draw.off(ADAPTER_EVENTS.UPDATE, rebuild)
76
+ }
77
+ }, [pluginState.mode, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus])
78
+
79
+ // Registers once, for the lifetime of the hook — safe outside edit_vertex too, since an
80
+ // exclusive-capable provider only ever contributes while it holds the claim (see
81
+ // spatialListRegistry.js). Declared after the effect above for the same initial-recompute
82
+ // ordering reason interact's own hook documents.
83
+ useEffect(() => {
84
+ spatialListRegistry.registerItemProvider(DRAW_PROVIDER_ID, {
85
+ // focusable: false — Tab-only keyboard users already have a full path via the map's own
86
+ // arrow-key nudge/Alt+Arrow navigate, so this list exists purely for Voice Control and a
87
+ // screen reader's own virtual-cursor discovery + activation. See SpatialList.jsx's own
88
+ // comment on why forcing every item's tabIndex to -1 is deliberate here.
89
+ getItems: () => ({ items: itemsRef.current, multiselectable: false, label: 'Shape points', focusable: false }),
90
+ exclusive: true
91
+ })
92
+ return () => { spatialListRegistry.unregisterItemProvider(DRAW_PROVIDER_ID) }
93
+ }, [spatialListRegistry, itemsRef])
94
+
95
+ useEffect(() => {
96
+ if (pluginState.mode !== EDIT_VERTEX_MODE) {
97
+ return undefined
98
+ }
99
+ spatialListRegistry.claimExclusive(DRAW_PROVIDER_ID)
100
+ return () => spatialListRegistry.releaseExclusive(DRAW_PROVIDER_ID)
101
+ }, [pluginState.mode, spatialListRegistry])
102
+ }
103
+
104
+ /**
105
+ * Listens for MAP_SET_ACTIVE_ITEM and previews (non-destructively) whichever vertex or
106
+ * midpoint the listbox cursor is now on — every arrow-key move through the list, mirroring
107
+ * how mouse/touch/keyboard selection already behaves live elsewhere in edit_vertex. Unlike
108
+ * interact's real map features (which need an explicit Enter/Space confirm before anything
109
+ * happens), there's no separate "preview" step for a real vertex anywhere else in this mode —
110
+ * arrowing onto one already is the live selection. A midpoint is also just previewed here;
111
+ * confirming the actual insert is useConfirmHandler's job below.
112
+ */
113
+ function useActiveVertexHandler ({ eventBus, mapProvider, pluginState, activeIdRef }) {
114
+ useEffect(() => {
115
+ const handle = ({ id }) => {
116
+ activeIdRef.current = id
117
+ if (id === null || pluginState.mode !== EDIT_VERTEX_MODE) {
118
+ return
119
+ }
120
+ mapProvider.draw?.selectVertex?.(Number(id))
121
+ }
122
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handle)
123
+ return () => { eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handle) }
124
+ }, [eventBus, mapProvider, pluginState.mode, activeIdRef])
125
+ }
126
+
127
+ /**
128
+ * Handles MAP_SELECT_ITEM (Enter/Space, or a click/Voice-Control click — and, since none of
129
+ * these items are ever a real Tab stop, a screen reader's own virtual-cursor activation gesture
130
+ * too, which likewise fires as a click — all three arrive here identically). For a midpoint,
131
+ * this is the destructive step: commits a new vertex there (a real vertex is already the live
132
+ * selection the moment it's previewed, so confirming one doesn't change anything on the map).
133
+ * Either way, confirming announces what happened and hands keyboard focus back to the viewport
134
+ * — landing the user exactly where the pre-existing, untouched arrow-key nudge/Alt+Arrow flow
135
+ * already works, same as if they'd selected that point with a mouse click. This is deliberately
136
+ * unconditional (not just for midpoints) so every input path ends up in the same place.
137
+ */
138
+ function useConfirmHandler ({ eventBus, mapProvider, pluginState, activeIdRef, itemsRef, viewportRef, announce }) {
139
+ useEffect(() => {
140
+ const handleConfirm = () => {
141
+ if (pluginState.mode !== EDIT_VERTEX_MODE || activeIdRef.current === null) {
142
+ return
143
+ }
144
+ const item = itemsRef.current.find(i => i.id === activeIdRef.current)
145
+ if (!item) {
146
+ return
147
+ }
148
+ if (item.isMidpoint) {
149
+ mapProvider.draw?.insertVertexAtMidpoint?.(Number(activeIdRef.current))
150
+ announce('New point inserted', 'action')
151
+ } else {
152
+ announce(`${item.label} selected`, 'action')
153
+ }
154
+ viewportRef.current?.focus()
155
+ }
156
+ eventBus.on(EVENTS.MAP_SELECT_ITEM, handleConfirm)
157
+ return () => { eventBus.off(EVENTS.MAP_SELECT_ITEM, handleConfirm) }
158
+ }, [eventBus, mapProvider, pluginState.mode, activeIdRef, itemsRef, viewportRef, announce])
159
+ }
160
+
161
+ /**
162
+ * Orchestrates the accessible (Voice Control / screen-reader-discoverable) vertex/midpoint
163
+ * targets for draw's edit_vertex mode. Mirrors plugins/interact/src/hooks/useSpatialList.js's
164
+ * structure (item-sync + active-item concerns), but claims the registry's exclusive slot
165
+ * instead of contributing additively, declares focusable: false (no real Tab stop — see
166
+ * useVertexItemSync's own comment on why), and splits "preview" (any listbox move, non-
167
+ * destructive) from "confirm" (inserts at a midpoint, then always hands focus back to the
168
+ * viewport) rather than interact's own "preview, then explicit confirm for everything" split.
169
+ *
170
+ * @param {{ mapState: object, pluginState: object, services: object, mapProvider: object, spatialListRegistry: object, viewportRef: React.RefObject }} params
171
+ */
172
+ export function useSpatialList ({ mapState, pluginState, services, mapProvider, spatialListRegistry, viewportRef }) {
173
+ const { mapSize } = mapState
174
+ const { eventBus, announce } = services
175
+ const itemsRef = useRef([])
176
+ const activeIdRef = useRef(null)
177
+ useVertexItemSync({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus })
178
+ useActiveVertexHandler({ eventBus, mapProvider, pluginState, activeIdRef })
179
+ useConfirmHandler({ eventBus, mapProvider, pluginState, activeIdRef, itemsRef, viewportRef, announce })
180
+ }