@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
@@ -10,7 +10,7 @@ describe('mouse clicks (polygon)', () => {
10
10
  expect(state.polygon.coordinates[0]).toHaveLength(3)
11
11
  expect(state.polygon.coordinates[0][0]).toEqual([0, 0])
12
12
  expect(state.polygon.coordinates[0][1]).toEqual([10, 0])
13
- expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 2 })
13
+ expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertices: 2 })
14
14
  expect(ctx.map._undoStack.length).toBe(2)
15
15
  })
16
16
 
@@ -132,6 +132,16 @@ describe('add-vertex button and doClick', () => {
132
132
  expect(ctx.map._undoStack.length).toBe(1)
133
133
  })
134
134
 
135
+ // Regression: lifecycle.js's shared crosshair.activate wiring calls this._placeAtCrossHair
136
+ // for every create mode — drawPointMode.js defines its own, but polygon/line only had
137
+ // doClick under a different name, so "Click Target" (Voice Control) silently no-opped.
138
+ test('_placeAtCrossHair aliases doClick — Voice Control\'s "Click Target" places a vertex too', () => {
139
+ const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' })
140
+ ctx._placeAtCrossHair(state)
141
+ expect(state.polygon.coordinates[0][0]).toEqual([CENTER.lng, CENTER.lat])
142
+ expect(ctx.map._undoStack.length).toBe(1)
143
+ })
144
+
135
145
  test('does not finish a line via doClick while the geometry is invalid', () => {
136
146
  const { ctx, state } = setup(DrawLineMode)
137
147
  clickAt(ctx, state, 0, 0)
@@ -1,3 +1,5 @@
1
+ import { stopIfGlobalAltKey } from '../../../../../../../src/utils/globalAltShortcuts.js'
2
+
1
3
  /**
2
4
  * Setup / teardown for the shared draw mode: binds the window/container/map event
3
5
  * handlers on entry and removes them on exit. Part of createDrawMode.
@@ -61,6 +63,10 @@ export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromS
61
63
  this._listeners = [
62
64
  [window, 'keydown', this.keydownHandler],
63
65
  [window, 'keyup', this.keyupHandler],
66
+ // Capture phase (unlike keyupHandler above) so it runs before the event ever bubbles to
67
+ // useKeyboardShortcuts.js's app-wide listener — shadows global Alt+<key> shortcuts
68
+ // (src/utils/globalAltShortcuts.js) unconditionally while drawing.
69
+ [window, 'keyup', stopIfGlobalAltKey, { capture: true }],
64
70
  [window, 'click', this.vertexButtonClickHandler],
65
71
  [container, 'blur', this.blurHandler],
66
72
  [container, 'pointermove', this.pointermoveHandler],
@@ -71,14 +77,14 @@ export const createLifecycle = ({ ParentMode, featureProp, excludeFeatureIdFromS
71
77
  [map, 'draw.undo', this.undoHandler],
72
78
  [map, 'draw.interfacetypechange', this.interfaceTypeChangeHandler]
73
79
  ]
74
- this._listeners.forEach(([t, e, h]) => t.addEventListener ? t.addEventListener(e, h) : t.on(e, h))
80
+ this._listeners.forEach(([target, eventName, handler, opts]) => target.addEventListener ? target.addEventListener(eventName, handler, opts) : target.on(eventName, handler))
75
81
 
76
82
  return state
77
83
  },
78
84
 
79
85
  onStop (state) {
80
86
  ParentMode.onStop.call(this, state)
81
- this._listeners.forEach(([t, e, h]) => t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h))
87
+ this._listeners.forEach(([target, eventName, handler, opts]) => target.removeEventListener ? target.removeEventListener(eventName, handler, opts) : target.off(eventName, handler))
82
88
  // Don't leave a stale closure over this mode's state on the shared crossHair object once
83
89
  // it's gone — the next owner (e.g. interact re-enabling) assigns its own before this one
84
90
  // could ever be invoked again, but this avoids relying on that ordering.
@@ -50,6 +50,41 @@ describe('setup and crosshair', () => {
50
50
  })
51
51
  })
52
52
 
53
+ describe('global Alt+<key> shortcut shadowing', () => {
54
+ test('Alt+Enter keyup stops propagation while drawing — never reaches the app-wide "highlight label at center" shortcut', () => {
55
+ setup(DrawPolygonMode)
56
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
57
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
58
+ window.dispatchEvent(event)
59
+ expect(stopSpy).toHaveBeenCalled()
60
+ })
61
+
62
+ test('Alt+Arrow keyup stops propagation too — never reaches the app-wide "highlight next label" shortcut', () => {
63
+ setup(DrawPolygonMode)
64
+ const event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
65
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
66
+ window.dispatchEvent(event)
67
+ expect(stopSpy).toHaveBeenCalled()
68
+ })
69
+
70
+ test('a plain Enter keyup (no Alt) is left alone', () => {
71
+ setup(DrawPolygonMode)
72
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: false, cancelable: true, bubbles: true })
73
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
74
+ window.dispatchEvent(event)
75
+ expect(stopSpy).not.toHaveBeenCalled()
76
+ })
77
+
78
+ test('the shadow listener is removed on onStop — Alt+Enter bubbles normally again', () => {
79
+ const { ctx, state } = setup(DrawPolygonMode)
80
+ ctx.onStop(state)
81
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
82
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
83
+ window.dispatchEvent(event)
84
+ expect(stopSpy).not.toHaveBeenCalled()
85
+ })
86
+ })
87
+
53
88
  describe('onStop', () => {
54
89
  test('mouse session: removes listeners, hides the crosshair and reports the final interface type', () => {
55
90
  const { ctx, state, marker, container } = setup(DrawPolygonMode, { interfaceType: 'mouse' })
@@ -7,17 +7,17 @@ import {
7
7
  * switching, rubber-band movement (with snapping) and blur. Part of createDrawMode.
8
8
  */
9
9
  export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) => ({
10
- onTouchStart (state, e) {
10
+ onTouchStart (state, event) {
11
11
  this._setInterface(state, 'touch')
12
- this.onMove(state, e)
12
+ this.onMove(state, event)
13
13
  },
14
14
 
15
- onTouchEnd (state, e) {
15
+ onTouchEnd (state, event) {
16
16
  this._setInterface(state, 'touch')
17
- this.onMove(state, e)
17
+ this.onMove(state, event)
18
18
  },
19
19
 
20
- // The global interface type (e.g. switching to touch and panning via MoveControls)
20
+ // The global interface type (e.g. switching to touch and panning via MapControls)
21
21
  // can change mid-session without any touch/pointer/key event ever landing on the
22
22
  // map container, so this can't rely on onTouchStart/onPointerdown alone — refresh
23
23
  // the rubber band immediately rather than waiting for the next incidental 'move'.
@@ -25,37 +25,47 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) =>
25
25
  // draw_polygon/draw_line starts), so this must only show the crosshair for
26
26
  // touch/keyboard — same as onSetup/onMove — not unconditionally like touch's own
27
27
  // onTouchStart/onTouchEnd, or a mouse-driven session would flash it on every entry.
28
- onInterfaceTypeChange (state, e) {
29
- this._setInterface(state, e.interfaceType, ['touch', 'keyboard'].includes(e.interfaceType))
28
+ onInterfaceTypeChange (state, event) {
29
+ this._setInterface(state, event.interfaceType, ['touch', 'keyboard'].includes(event.interfaceType))
30
30
  this.onMove(state)
31
31
  },
32
32
 
33
- onBlur (state, e) {
34
- if (e.target !== state.container) {
33
+ onBlur (state, event) {
34
+ if (event.target !== state.container) {
35
35
  this._hideCrossHair(state)
36
36
  }
37
37
  },
38
38
 
39
- onMouseMove (state, e) {
39
+ onMouseMove (state, event) {
40
40
  if (isSnapEnabled(state)) {
41
41
  const snap = getSnapInstance(this.map)
42
- triggerSnapAtPoint(snap, this.map, e.point)
42
+ triggerSnapAtPoint(snap, this.map, event.point)
43
43
 
44
44
  const snappedLngLat = getSnapLngLat(snap)
45
45
  if (snappedLngLat) {
46
- e = { ...e, lngLat: snappedLngLat }
46
+ event = { ...event, lngLat: snappedLngLat }
47
47
  }
48
48
  }
49
49
 
50
- ParentMode.onMouseMove.call(this, state, e)
50
+ ParentMode.onMouseMove.call(this, state, event)
51
51
 
52
52
  // Fired after the parent updates the rubber band so the payload (and the live
53
53
  // invalid-stroke check driven by it) reflects the current cursor position.
54
54
  this.map.fire('draw.geometrychange', state.polygon || state.line)
55
55
  },
56
56
 
57
- onMove (state) {
58
- if (['touch', 'keyboard'].includes(state.interfaceType)) {
57
+ // The crosshair/rubber-band candidate normally only tracks the map centre for
58
+ // touch/keyboard interfaceType, since a real mouse cursor already drives it via
59
+ // onMouseMove. But Voice Control's simulated clicks report pointerType 'mouse' (so
60
+ // interfaceType never leaves 'mouse') and produce no mousemove at all — panning via
61
+ // MapControls while interfaceType is 'mouse' would otherwise leave the candidate
62
+ // stale. `map.on('move', ...)` fires this with the real MapLibre move event, whose
63
+ // `originalEvent` is only set for a live mouse/touch/wheel interaction (dragPan,
64
+ // scrollZoom, ...) — absent for a programmatic move like MapControls' panBy, which
65
+ // is exactly the case that needs this fallback.
66
+ onMove (state, event) {
67
+ const isProgrammaticMapMove = event?.type === 'move' && !event.originalEvent
68
+ if (['touch', 'keyboard'].includes(state.interfaceType) || isProgrammaticMapMove) {
59
69
  if (isSnapEnabled(state)) {
60
70
  triggerSnapAtCenter(getSnapInstance(this.map), this.map)
61
71
  }
@@ -85,14 +95,14 @@ export const createPointerHandlers = ({ ParentMode, getFeature, getCoords }) =>
85
95
  }
86
96
  },
87
97
 
88
- onPointerdown (state, e) {
89
- if (e.pointerType !== 'touch') {
98
+ onPointerdown (state, event) {
99
+ if (event.pointerType !== 'touch') {
90
100
  this._setInterface(state, 'mouse', false)
91
101
  }
92
102
  },
93
103
 
94
- onPointermove (state, e) {
95
- if (e.pointerType !== 'touch') {
104
+ onPointermove (state, event) {
105
+ if (event.pointerType !== 'touch') {
96
106
  this._hideCrossHair(state)
97
107
  }
98
108
  },
@@ -33,7 +33,7 @@ describe('touch and pointer interface', () => {
33
33
  clickAt(ctx, state, 0, 0)
34
34
  ctx.map.fire.mockClear()
35
35
  ctx.pointerupHandler({})
36
- expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertecies: 1 }])
36
+ expect(firedWith(ctx.map, 'draw.vertexchange')).toEqual([{ numVertices: 1 }])
37
37
  })
38
38
 
39
39
  test('blur away from the container hides the crosshair; blur on the container does not', () => {
@@ -44,7 +44,7 @@ describe('touch and pointer interface', () => {
44
44
  expect(marker.style.display).toBe('none')
45
45
  })
46
46
 
47
- test('draw.interfacetypechange (e.g. switching to touch and panning via MoveControls mid-session) updates the interface and refreshes the rubber band immediately', () => {
47
+ test('draw.interfacetypechange (e.g. switching to touch and panning via MapControls mid-session) updates the interface and refreshes the rubber band immediately', () => {
48
48
  const { ctx, state } = setup(DrawPolygonMode)
49
49
  clickAt(ctx, state, 0, 0)
50
50
  ctx.map.fire('draw.interfacetypechange', { interfaceType: 'touch' })
@@ -116,6 +116,21 @@ describe('rubber band and snapping while moving', () => {
116
116
  expect(state.polygon.coordinates[0]).toEqual(before)
117
117
  })
118
118
 
119
+ test('a programmatic map move (e.g. MapControls panBy while Voice Control keeps interfaceType mouse) still tracks the rubber band to center', () => {
120
+ const { ctx, state } = setup(DrawPolygonMode)
121
+ clickAt(ctx, state, 0, 0)
122
+ ctx.map.fire('move', { type: 'move' }) // no originalEvent — matches a programmatic move, unlike a real drag/scroll
123
+ expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat])
124
+ })
125
+
126
+ test('a real mouse-driven map move (dragPan/scrollZoom) still leaves the rubber band alone', () => {
127
+ const { ctx, state } = setup(DrawPolygonMode)
128
+ clickAt(ctx, state, 0, 0)
129
+ const before = [...state.polygon.coordinates[0].map((c) => [...c])]
130
+ ctx.map.fire('move', { type: 'move', originalEvent: new MouseEvent('mousemove') })
131
+ expect(state.polygon.coordinates[0]).toEqual(before)
132
+ })
133
+
119
134
  test('map move in keyboard line mode with snap fires geometrychange with state.line', () => {
120
135
  const { ctx, state } = setup(DrawLineMode, { interfaceType: 'keyboard', getSnapEnabled: () => true })
121
136
  clickAt(ctx, state, 0, 0)
@@ -126,7 +126,7 @@ describe('undo via draw.undo event and reinitialisation', () => {
126
126
  expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id])
127
127
  expect(state.polygon.id).toBe(id)
128
128
  expect(state.currentVertexPosition).toBe(0)
129
- expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 })
129
+ expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertices: 1 })
130
130
  })
131
131
 
132
132
  test('undoing the only line vertex restarts draw_line with the same feature id', () => {
@@ -62,8 +62,8 @@ const pointHandlers = {
62
62
  // after the re-id (not before) so it writes to the feature under its final id — the
63
63
  // resolver itself is injected by the adapter via changeMode's options so this mode has
64
64
  // no direct dependency on symbolRegistry/mapProvider.
65
- onCreate (state, e) {
66
- const feature = e.features[0]
65
+ onCreate (state, event) {
66
+ const feature = event.features[0]
67
67
  this._ctx.api.delete(feature.id)
68
68
  feature.id = state.featureId
69
69
  this._ctx.api.add(feature, { userProperties: true })
@@ -78,52 +78,58 @@ const pointHandlers = {
78
78
  // update (nothing to rubber-band for a single coordinate). Bound to MapLibre's own 'move'
79
79
  // event by createLifecycle, so this fires continuously while panning; also called
80
80
  // explicitly below wherever the interface type switches to touch/keyboard, so the
81
- // indicator appears immediately rather than waiting for the first pan.
82
- onMove (state) {
83
- if (['touch', 'keyboard'].includes(state.interfaceType) && isSnapEnabled(state)) {
81
+ // indicator appears immediately rather than waiting for the first pan. Also runs for a
82
+ // programmatic move with interfaceType still 'mouse' — Voice Control's simulated clicks
83
+ // (e.g. on MapControls) report pointerType 'mouse' and produce no mousemove, so without
84
+ // this the indicator would never refresh while panning that way; a real map 'move' event's
85
+ // `originalEvent` is only set for a live mouse/touch/wheel interaction, not a programmatic
86
+ // one like MapControls' panBy.
87
+ onMove (state, event) {
88
+ const isProgrammaticMapMove = event?.type === 'move' && !event.originalEvent
89
+ if ((['touch', 'keyboard'].includes(state.interfaceType) || isProgrammaticMapMove) && isSnapEnabled(state)) {
84
90
  triggerSnapAtCenter(getSnapInstance(this.map), this.map)
85
91
  }
86
92
  },
87
93
 
88
- onBlur (state, e) {
89
- if (e.target !== state.container) { this._hideCrossHair(state) }
94
+ onBlur (state, event) {
95
+ if (event.target !== state.container) { this._hideCrossHair(state) }
90
96
  },
91
97
 
92
- onPointerdown (state, e) {
93
- if (e.pointerType !== 'touch') { this._setInterface(state, 'mouse', false) }
98
+ onPointerdown (state, event) {
99
+ if (event.pointerType !== 'touch') { this._setInterface(state, 'mouse', false) }
94
100
  },
95
101
 
96
- onPointermove (state, e) {
97
- if (e.pointerType !== 'touch') { this._hideCrossHair(state) }
102
+ onPointermove (state, event) {
103
+ if (event.pointerType !== 'touch') { this._hideCrossHair(state) }
98
104
  },
99
105
 
100
106
  onTouchStart (state) { this._setInterface(state, 'touch'); this.onMove(state) },
101
107
  onTouchEnd (state) { this._setInterface(state, 'touch'); this.onMove(state) },
102
108
 
103
- onInterfaceTypeChange (state, e) {
104
- this._setInterface(state, e.interfaceType, ['touch', 'keyboard'].includes(e.interfaceType))
109
+ onInterfaceTypeChange (state, event) {
110
+ this._setInterface(state, event.interfaceType, ['touch', 'keyboard'].includes(event.interfaceType))
105
111
  this.onMove(state)
106
112
  },
107
113
 
108
114
  // Continuously track a snap candidate under the real cursor (mouse only — touch/keyboard
109
115
  // placement tracks the crosshair instead, triggered once at commit time in _placeAtCrossHair).
110
- onMouseMove (state, e) {
116
+ onMouseMove (state, event) {
111
117
  if (isSnapEnabled(state)) {
112
- triggerSnapAtPoint(getSnapInstance(this.map), this.map, e.point)
118
+ triggerSnapAtPoint(getSnapInstance(this.map), this.map, event.point)
113
119
  }
114
120
  },
115
121
 
116
- onVertexButtonClick (state, e) {
117
- if (state.addVertexButtonId && !this.map._undoInProgress && e.target.closest(`#${state.addVertexButtonId}`)) {
122
+ onVertexButtonClick (state, event) {
123
+ if (state.addVertexButtonId && !this.map._undoInProgress && event.target.closest(`#${state.addVertexButtonId}`)) {
118
124
  this._placeAtCrossHair(state)
119
125
  }
120
126
  },
121
127
 
122
- onKeydown (state, e) {
128
+ onKeydown (state, event) {
123
129
  if (document.activeElement !== state.container) { return }
124
- if (e.key === 'Escape') { e.preventDefault(); return }
125
- if (e.key === 'Enter') { state.isActive = true }
126
- if (!INTERFACE_KEYS.has(e.key)) { return }
130
+ if (event.key === 'Escape') { event.preventDefault(); return }
131
+ if (event.key === 'Enter') { state.isActive = true }
132
+ if (!INTERFACE_KEYS.has(event.key)) { return }
127
133
  this._setInterface(state, 'keyboard')
128
134
  this.onMove(state)
129
135
  },
@@ -132,26 +138,26 @@ const pointHandlers = {
132
138
  // Escape a pending placement without ever focusing the map, and keyboard users placing via
133
139
  // crosshair can commit with Enter. A point has no partial ring to reinitialize the way
134
140
  // line/polygon's Escape does, so Escape always routes to draw.cancel, keyboard-focused or not.
135
- onKeyup (state, e) {
136
- if (e.key === 'Escape') {
141
+ onKeyup (state, event) {
142
+ if (event.key === 'Escape') {
137
143
  this.map.fire('draw.cancel')
138
144
  return
139
145
  }
140
146
  if (document.activeElement !== state.container) { return }
141
- if (!INTERFACE_KEYS.has(e.key)) { return }
147
+ if (!INTERFACE_KEYS.has(event.key)) { return }
142
148
  this._setInterface(state, 'keyboard')
143
149
  this.onMove(state)
144
- if (e.key === 'Enter' && state.isActive) { this._placeAtCrossHair(state) }
150
+ if (event.key === 'Enter' && state.isActive) { this._placeAtCrossHair(state) }
145
151
  },
146
152
 
147
- onClick (state, e) {
148
- if (e.originalEvent.button > 0 || e.originalEvent.target !== this.map.getCanvas()) { return }
153
+ onClick (state, event) {
154
+ if (event.originalEvent.button > 0 || event.originalEvent.target !== this.map.getCanvas()) { return }
149
155
  const snap = getSnapInstance(this.map)
150
156
  const snapped = isSnapEnabled(state) && isSnapActive(snap) ? getSnapLngLat(snap) : null
151
- this._commit(state, snapped || e.lngLat)
157
+ this._commit(state, snapped || event.lngLat)
152
158
  },
153
159
 
154
- onTap (state, e) { this.onClick(state, e) },
160
+ onTap (state, event) { this.onClick(state, event) },
155
161
 
156
162
  // Touch "Add point" button and keyboard Enter both place at the crosshair (map centre),
157
163
  // snapping to a candidate under it if one exists. Deliberately bypasses onClick — its
@@ -16,15 +16,15 @@ const createMap = () => {
16
16
  getCenter: () => ({ ...CENTER }),
17
17
  project: jest.fn(() => ({ x: 50, y: 50 })),
18
18
  unproject: jest.fn(() => ({ ...CENTER })),
19
- fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }),
20
- on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }),
21
- off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) })
19
+ fire: jest.fn(function (type, event) { (listeners[type] ?? []).forEach((handler) => handler(event)) }),
20
+ on: jest.fn((type, handler) => { (listeners[type] ??= []).push(handler) }),
21
+ off: jest.fn((type, handler) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== handler) })
22
22
  }
23
23
  }
24
24
 
25
25
  const contexts = []
26
- const removeListeners = (ctx) => ctx._listeners?.forEach(([t, e, h]) =>
27
- t.removeEventListener ? t.removeEventListener(e, h) : t.off(e, h))
26
+ const removeListeners = (ctx) => ctx._listeners?.forEach(([target, eventName, handler]) =>
27
+ target.removeEventListener ? target.removeEventListener(eventName, handler) : target.off(eventName, handler))
28
28
 
29
29
  const createModeContext = () => {
30
30
  const map = createMap()
@@ -90,7 +90,7 @@ const clickEvent = (ctx, lng, lat, overrides = {}) => ({
90
90
  originalEvent: { button: 0, target: ctx.map.getCanvas(), ...overrides }
91
91
  })
92
92
 
93
- const firedWith = (map, type) => map.fire.mock.calls.filter(([t]) => t === type).map(([, e]) => e)
93
+ const firedWith = (map, type) => map.fire.mock.calls.filter(([eventType]) => eventType === type).map(([, event]) => event)
94
94
 
95
95
  afterEach(() => {
96
96
  contexts.splice(0).forEach(removeListeners)
@@ -436,6 +436,24 @@ describe('DrawPointMode', () => {
436
436
  expect(snap.snapToClosestPoint).not.toHaveBeenCalled()
437
437
  })
438
438
 
439
+ // Voice Control's simulated clicks report pointerType 'mouse' (interfaceType never
440
+ // leaves 'mouse') and produce no mousemove, so panning via MapControls needs its own
441
+ // fallback: a real map 'move' event with no originalEvent (a programmatic move, unlike a
442
+ // live mouse drag/scroll) still refreshes the indicator even with interfaceType 'mouse'.
443
+ it('a programmatic map move still refreshes the indicator while interfaceType is mouse', () => {
444
+ const { ctx, state } = setup()
445
+ const snap = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
446
+ ctx.map._snapInstance = snap
447
+ state.getSnapEnabled = () => true
448
+
449
+ ctx.onMove(state, { type: 'move' })
450
+ expect(snap.snapToClosestPoint).toHaveBeenCalledWith({ point: expect.any(Object), lngLat: CENTER, _explicit: true })
451
+
452
+ snap.snapToClosestPoint.mockClear()
453
+ ctx.onMove(state, { type: 'move', originalEvent: new MouseEvent('mousemove') })
454
+ expect(snap.snapToClosestPoint).not.toHaveBeenCalled()
455
+ })
456
+
439
457
  it('switching to touch or keyboard shows the snap indicator immediately, not just on the first pan', () => {
440
458
  const snap = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
441
459
 
@@ -1,4 +1,5 @@
1
1
  import { ARROW_KEYS, ARROW_OFFSETS, isInteractiveElementFocused, isUndoShortcut, sharedKeyboardHandlers } from '../../utils/keyboardShortcuts.js'
2
+ import { stopIfGlobalAltKey } from '../../../../../../../src/utils/globalAltShortcuts.js'
2
3
 
3
4
  /**
4
5
  * Keyboard interaction for the point-edit mode: arrow-key/Shift+arrow nudge and Cmd/Ctrl+Z
@@ -10,7 +11,7 @@ import { ARROW_KEYS, ARROW_OFFSETS, isInteractiveElementFocused, isUndoShortcut,
10
11
  export const keyboardHandlers = {
11
12
  ...sharedKeyboardHandlers,
12
13
 
13
- onKeydown (state, e) {
14
+ onKeydown (state, event) {
14
15
  if (isInteractiveElementFocused(state)) {
15
16
  return
16
17
  }
@@ -18,30 +19,30 @@ export const keyboardHandlers = {
18
19
  state.interfaceType = 'keyboard'
19
20
  this.hideTouchPointIndicator(state)
20
21
 
21
- if (e.key === ' ') {
22
+ if (event.key === ' ') {
22
23
  // Prevent the page from scrolling; there's nothing to select, the point already is.
23
- e.preventDefault()
24
+ event.preventDefault()
24
25
  return
25
26
  }
26
- if (ARROW_KEYS.has(e.key)) {
27
- this.handleArrowKey(state, e)
27
+ if (ARROW_KEYS.has(event.key)) {
28
+ this.handleArrowKey(state, event)
28
29
  return
29
30
  }
30
- if (isUndoShortcut(e)) {
31
- this.handleUndoShortcut(state, e)
31
+ if (isUndoShortcut(event)) {
32
+ this.handleUndoShortcut(state, event)
32
33
  }
33
34
  },
34
35
 
35
36
  // A plain or Shift+arrow nudges the point (Shift's finer-step-vs-coarse-step distinction
36
37
  // lives in pointOperations.js's getOffset). Alt+arrow is left unhandled — nothing to
37
38
  // navigate to with a single coordinate.
38
- handleArrowKey (state, e) {
39
- e.preventDefault()
40
- e.stopPropagation()
41
- this.movePointByKey(state, e)
39
+ handleArrowKey (state, event) {
40
+ event.preventDefault()
41
+ event.stopPropagation()
42
+ this.movePointByKey(state, event)
42
43
  },
43
44
 
44
- movePointByKey (state, e) {
45
+ movePointByKey (state, event) {
45
46
  const currentCoord = this.getPointCoord(state)
46
47
  if (!currentCoord) {
47
48
  return
@@ -52,25 +53,26 @@ export const keyboardHandlers = {
52
53
  state._keyboardMoveStartPosition = [...currentCoord]
53
54
  }
54
55
 
55
- this.movePoint(state, this._keyboardMoveTarget(state, e, currentCoord))
56
+ this.movePoint(state, this._keyboardMoveTarget(state, event, currentCoord))
56
57
  },
57
58
 
58
59
  // Resolve the destination coordinate for a keyboard nudge, applying or breaking snap —
59
- // delegates to the shared resolver (utils/snapMovement.js) also used by MoveControls'
60
+ // delegates to the shared resolver (utils/snapMovement.js) also used by MapControls'
60
61
  // nudgePointByDelta, so both snap identically.
61
- _keyboardMoveTarget (state, e, currentCoord) {
62
- const [dx, dy] = ARROW_OFFSETS[e.key]
63
- return this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getNewCoord(state, e))
62
+ _keyboardMoveTarget (state, event, currentCoord) {
63
+ const [dx, dy] = ARROW_OFFSETS[event.key]
64
+ return this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getNewCoord(state, event))
64
65
  },
65
66
 
66
- onKeyup (state, e) {
67
+ onKeyup (state, event) {
67
68
  if (isInteractiveElementFocused(state)) {
68
69
  return
69
70
  }
70
71
 
71
72
  state.interfaceType = 'keyboard'
72
- if (ARROW_KEYS.has(e.key)) {
73
- e.stopPropagation()
73
+ stopIfGlobalAltKey(event)
74
+ if (ARROW_KEYS.has(event.key)) {
75
+ event.stopPropagation()
74
76
 
75
77
  // Push undo for the whole held-key move sequence as one step
76
78
  if (state._keyboardMoveStartPosition) {
@@ -39,7 +39,7 @@ export const pointOperations = {
39
39
  },
40
40
 
41
41
  // Moves the point by an explicit (dx, dy) unit direction — the entry point for
42
- // MoveControls' D-pad (see mapProvider.activeMoveTarget in events.js). Each call is one
42
+ // MapControls' D-pad (see mapProvider.activeMoveTarget in events.js). Each call is one
43
43
  // complete, undoable action (no held-key sequencing — a button click has no "held" state
44
44
  // the way arrow keys do), honouring snap via the shared resolveSnapTarget above.
45
45
  nudgePointByDelta (state, dx, dy, isLargeStep) {
@@ -89,10 +89,10 @@ export const EditPointMode = {
89
89
  // every vertex selection change), a point never emits draw.vertexchange — that event
90
90
  // unconditionally releases mapProvider.activeMoveTarget, so it must never fire here.
91
91
  // attachEvents' resetState() (events.js) is the release point, on Done/Cancel/edit-finish.
92
- // numVertecies (misspelled) — MaplibreDrawAdapter.js's vertexselection handler only reads
92
+ // numVertices (misspelled) — MaplibreDrawAdapter.js's vertexselection handler only reads
93
93
  // that exact key when normalising to the adapter contract's numVertices; firing the
94
94
  // correctly-spelled key here would get silently clobbered to undefined instead.
95
- this.map.fire(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, { index: 0, numVertecies: 1 })
95
+ this.map.fire(CUSTOM_DRAW_EVENTS.VERTEX_SELECTION, { index: 0, numVertices: 1 })
96
96
 
97
97
  return state
98
98
  },
@@ -33,9 +33,9 @@ describe('onSetup / onStop lifecycle', () => {
33
33
 
34
34
  test('claims the D-pad exactly once at setup, never followed by draw.vertexchange', () => {
35
35
  const { map } = createHarness()
36
- // numVertecies (misspelled) — MaplibreDrawAdapter.js's vertexselection handler only reads
36
+ // numVertices (misspelled) — MaplibreDrawAdapter.js's vertexselection handler only reads
37
37
  // that exact key when normalising to the adapter contract's numVertices.
38
- expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', { index: 0, numVertecies: 1 })
38
+ expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', { index: 0, numVertices: 1 })
39
39
  expect(map.fire).not.toHaveBeenCalledWith('draw.vertexchange', expect.anything())
40
40
  })
41
41
 
@@ -124,7 +124,7 @@ describe('scale, move and interface-type events', () => {
124
124
  expect(state.touchPointTarget.style.display).toBe('block')
125
125
  })
126
126
 
127
- test('draw.nudgevertex moves the point and repositions the touch target — the inbound bridge for MoveControls.mapProvider.activeMoveTarget', () => {
127
+ test('draw.nudgevertex moves the point and repositions the touch target — the inbound bridge for MapControls.mapProvider.activeMoveTarget', () => {
128
128
  jest.useFakeTimers()
129
129
  const { state, map } = createHarness(undefined, { interfaceType: 'touch' })
130
130
  jest.runAllTimers() // flush onSetup's deferred initial touch-target positioning
@@ -29,6 +29,37 @@ export const getCoords = (feature) => {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Get the midpoint coordinate between every pair of adjacent vertices, one per segment
34
+ * boundary — closed rings (Polygon) also include the last→first midpoint, open lines
35
+ * (LineString) don't wrap around. Pure function of the geometry alone (no live mode state),
36
+ * so both the live mode (vertexQueries.js's getMidpoints) and read-only consumers (e.g. the
37
+ * adapter's getVertexItems) share one implementation.
38
+ *
39
+ * @param {Object} feature - GeoJSON geometry object
40
+ * @returns {Array<[number, number]>} Flat array of midpoint coordinates, same ordering/segment
41
+ * boundaries as getCoords/getRingSegments
42
+ */
43
+ export const getMidpointCoords = (feature) => {
44
+ const coords = getCoords(feature)
45
+ const segments = getRingSegments(feature)
46
+ if (!coords.length || !segments.length) {
47
+ return []
48
+ }
49
+ const midpoints = []
50
+ for (const seg of segments) {
51
+ const count = seg.closed ? seg.length : seg.length - 1
52
+ for (let i = 0; i < count; i++) {
53
+ const idx = seg.start + i
54
+ const nextIdx = seg.start + ((i + 1) % seg.length)
55
+ const [x1, y1] = coords[idx]
56
+ const [x2, y2] = coords[nextIdx]
57
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
58
+ }
59
+ }
60
+ return midpoints
61
+ }
62
+
32
63
  /**
33
64
  * Get segment metadata for multi-ring/multi-part geometries.
34
65
  * Each segment represents a ring (for Polygon) or part (for Multi*).
@@ -1,5 +1,5 @@
1
1
  import {
2
- getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, coordPathToFlatIndex
2
+ getCoords, getMidpointCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, coordPathToFlatIndex
3
3
  } from './geometryHelpers.js'
4
4
 
5
5
  const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] })
@@ -26,6 +26,13 @@ describe('geometryHelpers', () => {
26
26
  expect(getRingSegments(null)).toEqual([])
27
27
  })
28
28
 
29
+ test('getMidpointCoords finds the midpoint between each adjacent pair — no wrap for an open line, wrapping last→first for a closed ring', () => {
30
+ expect(getMidpointCoords(LINE())).toEqual([[5, 0], [10, 5]])
31
+ expect(getMidpointCoords(POLYGON())).toHaveLength(5) // closed ring: wraps, including the duplicate closing point
32
+ expect(getMidpointCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([])
33
+ expect(getMidpointCoords(null)).toEqual([])
34
+ })
35
+
29
36
  test('getSegmentForIndex finds the owning segment or returns null', () => {
30
37
  const segments = getRingSegments(POLYGON())
31
38
  expect(getSegmentForIndex(segments, 2)).toEqual({ segment: segments[0], localIdx: 2 })