@defra/interactive-map 0.0.37-alpha → 0.0.39-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 (247) hide show
  1. package/assets/templates/draw-tools.njk +4 -3
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api.md +32 -0
  8. package/docs/examples/draw-tools.mdx +0 -4
  9. package/docs/plugins/draw.md +482 -0
  10. package/docs/plugins.md +2 -6
  11. package/govuk-prototype-kit.config.json +3 -2
  12. package/package.json +2 -4
  13. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  14. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  15. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  17. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  18. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/datasets/src/registry/dataset.js +3 -1
  21. package/plugins/datasets/src/registry/datasetRegistry.js +30 -24
  22. package/plugins/datasets/src/registry/datasetRegistry.test.js +25 -18
  23. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  24. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  26. package/plugins/draw/dist/esm/index.js +1 -0
  27. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  30. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  32. package/plugins/draw/dist/umd/index.js +2 -0
  33. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  34. package/plugins/draw/src/DrawInit.jsx +89 -0
  35. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  36. package/plugins/draw/src/adapterEvents.js +127 -0
  37. package/plugins/draw/src/adapterEvents.test.js +20 -0
  38. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  39. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  41. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  43. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  44. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  45. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  49. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  89. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  98. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  100. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  104. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  106. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  107. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  109. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  111. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  112. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  114. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  115. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  121. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  147. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  149. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  159. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  166. package/plugins/draw/src/api/addFeature.js +22 -0
  167. package/plugins/draw/src/api/addFeature.test.js +41 -0
  168. package/plugins/draw/src/api/deleteFeature.js +11 -0
  169. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  170. package/plugins/draw/src/api/editFeature.js +54 -0
  171. package/plugins/draw/src/api/editFeature.test.js +134 -0
  172. package/plugins/draw/src/api/merge.js +30 -0
  173. package/plugins/draw/src/api/merge.test.js +57 -0
  174. package/plugins/draw/src/api/newLine.js +41 -0
  175. package/plugins/draw/src/api/newLine.test.js +93 -0
  176. package/plugins/draw/src/api/newPolygon.js +41 -0
  177. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  178. package/plugins/draw/src/api/split.js +117 -0
  179. package/plugins/draw/src/api/split.test.js +204 -0
  180. package/plugins/draw/src/defaults.js +52 -0
  181. package/plugins/draw/src/defaults.test.js +18 -0
  182. package/plugins/draw/src/draw.scss +43 -0
  183. package/plugins/draw/src/events.js +237 -0
  184. package/plugins/draw/src/events.test.js +369 -0
  185. package/plugins/draw/src/index.js +10 -0
  186. package/plugins/draw/src/index.test.js +24 -0
  187. package/plugins/draw/src/manifest.js +170 -0
  188. package/plugins/draw/src/manifest.test.js +147 -0
  189. package/plugins/draw/src/reducer.js +69 -0
  190. package/plugins/draw/src/reducer.test.js +85 -0
  191. package/plugins/draw/src/utils/debounce.js +16 -0
  192. package/plugins/draw/src/utils/debounce.test.js +40 -0
  193. package/plugins/draw/src/utils/eventBus.js +36 -0
  194. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  195. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  197. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  199. package/plugins/draw/src/utils/resolveColors.js +39 -0
  200. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  201. package/plugins/draw/src/utils/spatial.js +243 -0
  202. package/plugins/draw/src/utils/spatial.test.js +243 -0
  203. package/plugins/draw/src/utils/touchTarget.js +95 -0
  204. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  205. package/plugins/draw/src/utils/undoStack.js +31 -0
  206. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  207. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  209. package/plugins/draw/src/validation/liveStroke.js +85 -0
  210. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  211. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  212. package/plugins/draw/src/validation/rules.js +186 -0
  213. package/plugins/draw/src/validation/rules.test.js +120 -0
  214. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  215. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  216. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  217. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  218. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  220. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  221. package/plugins/search/dist/css/index.css +1 -317
  222. package/rollup.esm.mjs +35 -28
  223. package/sonar-project.properties +2 -2
  224. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  225. package/src/App/components/Hints/Hints.module.scss +5 -4
  226. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  228. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  229. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  230. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  231. package/src/App/hooks/useHintsAPI.js +67 -0
  232. package/src/App/hooks/useHintsAPI.test.js +145 -0
  233. package/src/App/renderer/PluginInits.jsx +4 -0
  234. package/src/App/renderer/PluginInits.test.jsx +7 -1
  235. package/src/InteractiveMap/InteractiveMap.js +20 -0
  236. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  237. package/src/config/events.js +4 -0
  238. package/src/scss/settings/_dimensions.scss +3 -0
  239. package/src/services/announcer.js +38 -3
  240. package/src/services/announcer.test.js +133 -1
  241. package/src/services/hints.js +3 -0
  242. package/src/services/hints.test.js +19 -0
  243. package/src/types.js +12 -0
  244. package/src/utils/detectInterfaceType.js +5 -1
  245. package/src/utils/isMac.js +15 -0
  246. package/src/utils/isMac.test.js +37 -0
  247. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,210 @@
1
+ import {
2
+ getCoords,
3
+ getRingSegments,
4
+ getSegmentForIndex,
5
+ getModifiableCoords
6
+ } from './geometryHelpers.js'
7
+ import { KEYBOARD } from '../../defaults.js'
8
+ import {
9
+ getSnapInstance, isSnapActive, isSnapEnabled, getSnapLngLat,
10
+ getSnapRadius, triggerSnapAtPoint, clearSnapIndicator
11
+ } from '../../utils/snapHelpers.js'
12
+ import { MIN_VERTICES } from '../../../../validation/rules.js'
13
+
14
+ const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
15
+
16
+ export const vertexOperations = {
17
+ updateMidpoint (coordinates) {
18
+ setTimeout(() => {
19
+ this.map.getSource('mapbox-gl-draw-hot').setData({
20
+ type: 'Feature',
21
+ properties: { meta: 'midpoint', active: 'true', id: 'active-midpoint' },
22
+ geometry: { type: 'Point', coordinates }
23
+ })
24
+ }, 0)
25
+ },
26
+
27
+ updateVertex (state, direction) {
28
+ const [idx, type] = this.getVertexOrMidpoint(state, direction)
29
+ if (idx < 0 || !type) {
30
+ return
31
+ }
32
+ this.changeMode(state, { selectedVertexIndex: idx, selectedVertexType: type, ...(type === 'vertex' && { coordPath: this.getCoordPath(state, idx) }) })
33
+ },
34
+
35
+ getOffset (coord, e) {
36
+ const pt = this.map.project(coord)
37
+ const offset = e?.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount
38
+ const [dx, dy] = e ? ARROW_OFFSETS[e.key].map(v => v * offset) : [0, 0]
39
+ return this.map.unproject({ x: pt.x + dx, y: pt.y + dy })
40
+ },
41
+
42
+ getNewCoord (state, e) {
43
+ return this.getOffset(getCoords(this.getFeature(state.featureId))[state.selectedVertexIndex], e)
44
+ },
45
+
46
+ // Explicit-delta counterpart to getOffset, driven by a unit direction vector and
47
+ // MoveControl's own Precision toggle rather than a KeyboardEvent — used by
48
+ // nudgeVertexByDelta below.
49
+ getOffsetByDelta (coord, dx, dy, isLargeStep) {
50
+ const pt = this.map.project(coord)
51
+ const amount = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount
52
+ return this.map.unproject({ x: pt.x + dx * amount, y: pt.y + dy * amount })
53
+ },
54
+
55
+ // Resolves the destination coordinate for a vertex nudge by (dx, dy) unit
56
+ // direction, applying snap or breaking out of an already-active one — shared by
57
+ // the keyboard arrow-key path (_keyboardMoveTarget) and MoveControl's
58
+ // explicit-delta path (nudgeVertexByDelta) so both snap identically. dx/dy are
59
+ // only needed for the snap-escape offset; getCandidate computes the raw,
60
+ // un-snapped destination the caller would otherwise have used.
61
+ resolveSnapTarget (state, dx, dy, currentCoord, getCandidate) {
62
+ const snap = getSnapInstance(this.map)
63
+
64
+ // Break out of an active snap by moving beyond the snap radius
65
+ if (isSnapEnabled(state) && state._isSnapped && snap) {
66
+ const offset = getSnapRadius(snap) + 1
67
+ const pt = this.map.project(currentCoord)
68
+ state._isSnapped = false
69
+ clearSnapIndicator(snap, this.map)
70
+ return this.map.unproject({ x: pt.x + dx * offset, y: pt.y + dy * offset })
71
+ }
72
+
73
+ const newCoord = getCandidate()
74
+ if (isSnapEnabled(state) && snap) {
75
+ triggerSnapAtPoint(snap, this.map, this.map.project(newCoord))
76
+ if (isSnapActive(snap)) {
77
+ state._isSnapped = true
78
+ return getSnapLngLat(snap)
79
+ }
80
+ }
81
+ state._isSnapped = false
82
+ return newCoord
83
+ },
84
+
85
+ // Moves the selected vertex by an explicit (dx, dy) unit direction — the entry
86
+ // point for MoveControl's D-pad (see mapProvider.activeMoveTarget in events.js),
87
+ // as opposed to moveVertexByKey's KeyboardEvent-driven path. Each call is treated
88
+ // as one complete, undoable action (no held-key sequencing, since a button click
89
+ // has no "held" state to batch the way arrow keys do) — but still honours snap
90
+ // via the shared resolveSnapTarget, same as keyboard nudging.
91
+ nudgeVertexByDelta (state, dx, dy, isLargeStep) {
92
+ if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) {
93
+ return
94
+ }
95
+ const feature = this.getFeature(state.featureId)
96
+ const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex]
97
+ if (!currentCoord) {
98
+ return
99
+ }
100
+ const previousPosition = [...currentCoord]
101
+ const vertexIndex = state.selectedVertexIndex
102
+ const target = this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getOffsetByDelta(currentCoord, dx, dy, isLargeStep))
103
+ this.moveVertex(state, target)
104
+ this.pushUndo({ type: 'move_vertex', featureId: state.featureId, vertexIndex, previousPosition })
105
+ },
106
+
107
+ insertVertex (state, e) {
108
+ const midIdx = state.selectedVertexIndex - state.vertecies.length
109
+ const newCoord = this.getOffset(state.midpoints[midIdx], e)
110
+ const feature = this.getFeature(state.featureId)
111
+ const geojson = feature.toGeoJSON()
112
+
113
+ // Find which segment this midpoint belongs to and calculate insertion position
114
+ const segments = getRingSegments(feature)
115
+ let globalInsertIdx = midIdx + 1
116
+ let insertSegment = null
117
+ let localInsertIdx = 0
118
+
119
+ // Map midpoint index to segment and local position
120
+ let midpointCounter = 0
121
+ for (const seg of segments) {
122
+ // Must match getMidpoints calculation
123
+ const segMidpoints = seg.closed ? seg.length : seg.length - 1
124
+ if (midIdx < midpointCounter + segMidpoints) {
125
+ insertSegment = seg
126
+ localInsertIdx = (midIdx - midpointCounter) + 1
127
+ globalInsertIdx = seg.start + localInsertIdx
128
+ break
129
+ }
130
+ midpointCounter += segMidpoints
131
+ }
132
+
133
+ if (!insertSegment) { return }
134
+
135
+ const coords = getModifiableCoords(geojson, insertSegment.path)
136
+ coords.splice(localInsertIdx, 0, [newCoord.lng, newCoord.lat])
137
+ this._ctx.api.add(geojson)
138
+
139
+ this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: globalInsertIdx })
140
+ this.changeMode(state, { selectedVertexIndex: globalInsertIdx, selectedVertexType: 'vertex', coordPath: this.getCoordPath(state, globalInsertIdx) })
141
+ },
142
+
143
+ moveVertex (state, coord, options = {}) {
144
+ if (options.checkSnap && state.enableSnap !== false) {
145
+ const snap = this.map._snapInstance
146
+ if (snap?.snapStatus && snap.snapCoords?.length >= 2) {
147
+ coord = { lng: snap.snapCoords[0], lat: snap.snapCoords[1] }
148
+ }
149
+ }
150
+
151
+ const feature = this.getFeature(state.featureId)
152
+ const geojson = feature.toGeoJSON()
153
+ const segments = getRingSegments(feature)
154
+ const result = getSegmentForIndex(segments, state.selectedVertexIndex)
155
+ if (!result) { return }
156
+
157
+ const coords = getModifiableCoords(geojson, result.segment.path)
158
+ coords[result.localIdx] = [coord.lng, coord.lat]
159
+ this._ctx.api.add(geojson)
160
+ state.vertecies = this.getVerticies(state.featureId)
161
+
162
+ this.map.fire('draw.geometrychange', state.feature)
163
+ },
164
+
165
+ deleteVertex (state) {
166
+ const feature = this.getFeature(state.featureId)
167
+ if (!feature) {
168
+ return
169
+ }
170
+
171
+ const segments = getRingSegments(feature)
172
+ const result = getSegmentForIndex(segments, state.selectedVertexIndex)
173
+ if (!result) {
174
+ return
175
+ }
176
+
177
+ const { segment } = result
178
+ // Minimum vertices per segment (mapbox-gl-draw's internal representation is
179
+ // a closed ring for polygons, an open path for lines) — MIN_VERTICES is the
180
+ // single source for this threshold (validation/rules.js).
181
+ const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString
182
+ if (segment.length <= minVertices) {
183
+ return
184
+ }
185
+
186
+ // Save position for undo before deletion
187
+ const deletedPosition = [...state.vertecies[state.selectedVertexIndex]]
188
+ const deletedIndex = state.selectedVertexIndex
189
+
190
+ // Remove the coordinate directly rather than via this._ctx.api.trash(), which routes through
191
+ // DirectSelect.trash() and calls onSetup({ featureId }) with incomplete options, crashing event registration.
192
+ const coordPath = [...result.segment.path, result.localIdx].join('.')
193
+ feature.removeCoordinate(coordPath)
194
+ this.fireUpdate()
195
+ this.clearSelectedCoordinates()
196
+ feature.changed()
197
+ this._ctx.store.render()
198
+
199
+ // Push undo operation
200
+ this.pushUndo({
201
+ type: 'delete_vertex',
202
+ featureId: state.featureId,
203
+ vertexIndex: deletedIndex,
204
+ position: deletedPosition
205
+ })
206
+
207
+ // Clear selection after delete
208
+ this.changeMode(state, { selectedVertexIndex: -1, selectedVertexType: null })
209
+ }
210
+ }
@@ -0,0 +1,167 @@
1
+ import { createHarness, LINE } from './__helpers__/harness.js'
2
+
3
+ describe('vertexOperations', () => {
4
+ test('updateMidpoint pushes point data to the hot source', () => {
5
+ jest.useFakeTimers()
6
+ const { ctx, map } = createHarness()
7
+ ctx.updateMidpoint([1, 2])
8
+ jest.runAllTimers()
9
+ expect(map.getSource).toHaveBeenCalledWith('mapbox-gl-draw-hot')
10
+ })
11
+
12
+ test('updateVertex changes mode for a valid target and no-ops otherwise', () => {
13
+ const { ctx, state, api } = createHarness()
14
+ state.selectedVertexIndex = 0
15
+ ctx.updateVertex(state, 'ArrowRight')
16
+ expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({
17
+ selectedVertexIndex: expect.any(Number)
18
+ }))
19
+
20
+ api.changeMode.mockClear()
21
+ ctx.updateVertex({ featureId: 'missing', vertecies: [], selectedVertexIndex: -1 }, 'ArrowRight')
22
+ expect(api.changeMode).not.toHaveBeenCalled()
23
+
24
+ // A vertex target carries a coordPath; a midpoint target does not
25
+ jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([1, 'vertex'])
26
+ ctx.updateVertex(state, 'ArrowRight')
27
+ expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeDefined()
28
+ jest.spyOn(ctx, 'getVertexOrMidpoint').mockReturnValueOnce([5, 'midpoint'])
29
+ ctx.updateVertex(state, 'ArrowRight')
30
+ expect(api.changeMode.mock.calls.at(-1)[1].coordPath).toBeUndefined()
31
+ })
32
+
33
+ test('getOffset applies step/nudge amounts and returns the coord unchanged without an event', () => {
34
+ const { ctx } = createHarness()
35
+ const stepped = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: false })
36
+ const nudged = ctx.getOffset([5, 5], { key: 'ArrowRight', shiftKey: true })
37
+ expect(stepped.lng).not.toBe(5)
38
+ expect(Math.abs(nudged.lng - 5)).toBeLessThan(Math.abs(stepped.lng - 5))
39
+ expect(ctx.getOffset([5, 5], null)).toEqual({ lng: 5, lat: 5 })
40
+ })
41
+
42
+ test('getNewCoord offsets the currently selected vertex', () => {
43
+ const { ctx, state } = createHarness()
44
+ state.selectedVertexIndex = 1
45
+ expect(ctx.getNewCoord(state, { key: 'ArrowRight', shiftKey: false })).toHaveProperty('lng')
46
+ })
47
+
48
+ test('getOffsetByDelta applies step/nudge amounts along an explicit direction, mirroring getOffset\'s shiftKey polarity', () => {
49
+ const { ctx } = createHarness()
50
+ const large = ctx.getOffsetByDelta([5, 5], 1, 0, true)
51
+ const small = ctx.getOffsetByDelta([5, 5], 1, 0, false)
52
+ expect(large.lng).not.toBe(5)
53
+ expect(Math.abs(small.lng - 5)).toBeLessThan(Math.abs(large.lng - 5))
54
+ expect(ctx.getOffsetByDelta([5, 5], 0, 0, true)).toEqual({ lng: 5, lat: 5 })
55
+ })
56
+
57
+ test('nudgeVertexByDelta moves the selected vertex and pushes a single undo entry, or no-ops for a midpoint/no selection', () => {
58
+ const { ctx, state, map } = createHarness()
59
+ state.selectedVertexIndex = 0
60
+ state.selectedVertexType = 'vertex'
61
+ const before = [...state.vertecies[0]]
62
+ ctx.nudgeVertexByDelta(state, 1, 0, true)
63
+ expect(state.vertecies[0]).not.toEqual(before)
64
+ expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 0, previousPosition: before })
65
+
66
+ map._undoStack.clear()
67
+ ctx.nudgeVertexByDelta({ ...state, selectedVertexType: 'midpoint' }, 1, 0, true)
68
+ ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: -1 }, 1, 0, true)
69
+ // Passes the type/index guard but resolves to no coordinate — missing feature,
70
+ // and a valid feature with an out-of-range index.
71
+ ctx.nudgeVertexByDelta({ ...state, featureId: 'missing' }, 1, 0, true)
72
+ ctx.nudgeVertexByDelta({ ...state, selectedVertexIndex: 99 }, 1, 0, true)
73
+ expect(map._undoStack).toHaveLength(0)
74
+ })
75
+
76
+ test('nudgeVertexByDelta snaps to a nearby target, breaks out of an active snap, and falls back to the raw coord when snap is inactive — same as keyboard nudging (regression: MoveControl bypassed snap entirely before resolveSnapTarget was shared)', () => {
77
+ const { ctx, state, map } = createHarness()
78
+ state.selectedVertexIndex = 1
79
+ state.selectedVertexType = 'vertex'
80
+ state.getSnapEnabled = () => true
81
+
82
+ map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() }
83
+ ctx.nudgeVertexByDelta(state, 0, -1, true)
84
+ expect(state._isSnapped).toBe(true)
85
+ expect(state.vertecies[1]).toEqual([7, 8])
86
+
87
+ ctx.nudgeVertexByDelta(state, -1, 0, true) // already snapped → breaks out of the snap radius
88
+ expect(state._isSnapped).toBe(false)
89
+
90
+ map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
91
+ ctx.nudgeVertexByDelta(state, 1, 0, true) // snap enabled but inactive → raw offset coord
92
+ expect(state._isSnapped).toBe(false)
93
+ })
94
+
95
+ test('resolveSnapTarget is a no-op passthrough to the candidate coordinate when snap is disabled', () => {
96
+ const { ctx, state } = createHarness()
97
+ const candidate = { lng: 3, lat: 4 }
98
+ expect(ctx.resolveSnapTarget(state, 1, 0, [0, 0], () => candidate)).toEqual(candidate)
99
+ })
100
+
101
+ test('insertVertex splits a midpoint into a new vertex, records undo and selects it', () => {
102
+ const { ctx, state, api } = createHarness()
103
+ ctx.insertVertex({ ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false })
104
+ expect(api.add).toHaveBeenCalled()
105
+ expect(state.map ?? ctx.map._undoStack.length).toBeGreaterThan(0)
106
+ expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({
107
+ selectedVertexType: 'vertex'
108
+ }))
109
+
110
+ // Also works for an open line segment
111
+ const line = createHarness(LINE())
112
+ line.ctx.insertVertex({ ...line.state, selectedVertexIndex: line.state.vertecies.length, selectedVertexType: 'midpoint' }, { key: 'ArrowRight', shiftKey: false })
113
+ expect(line.api.add).toHaveBeenCalled()
114
+ })
115
+
116
+ test('insertVertex bails out when the midpoint maps to no segment', () => {
117
+ const { ctx, state, api } = createHarness()
118
+ // midIdx beyond the segment's midpoint count, but with a defined midpoint coord
119
+ const badState = { ...state, selectedVertexIndex: state.vertecies.length + 4, midpoints: [...state.midpoints, [5, 5]] }
120
+ api.add.mockClear()
121
+ ctx.insertVertex(badState, { key: 'ArrowRight', shiftKey: false })
122
+ expect(api.add).not.toHaveBeenCalled()
123
+ })
124
+
125
+ test('moveVertex repositions a vertex, applies snap and guards an out-of-range index', () => {
126
+ const { ctx, state, map } = createHarness()
127
+ state.selectedVertexIndex = 0
128
+ ctx.moveVertex(state, { lng: 1, lat: 2 })
129
+ expect(state.vertecies[0]).toEqual([1, 2])
130
+
131
+ map._snapInstance = { snapStatus: true, snapCoords: [7, 8] }
132
+ ctx.moveVertex(state, { lng: 1, lat: 2 }, { checkSnap: true })
133
+ expect(state.vertecies[0]).toEqual([7, 8])
134
+
135
+ const before = [...state.vertecies]
136
+ ctx.moveVertex({ ...state, selectedVertexIndex: 99 }, { lng: 3, lat: 3 })
137
+ expect(ctx.getVerticies('feat-1')).toEqual(before)
138
+
139
+ // checkSnap requested but no active snap → coordinate used as-is
140
+ const h = createHarness()
141
+ h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 0 }, { lng: 1, lat: 2 }, { checkSnap: true })
142
+ expect(h.ctx.getVerticies('feat-1')[0]).toEqual([1, 2])
143
+ })
144
+
145
+ test('deleteVertex removes a vertex, or no-ops for missing feature / bad index / minimum size', () => {
146
+ const { ctx, state, api } = createHarness()
147
+ ctx.deleteVertex({ ...state, featureId: 'missing' })
148
+ ctx.deleteVertex({ ...state, selectedVertexIndex: 99 })
149
+ expect(api.changeMode).not.toHaveBeenCalled()
150
+
151
+ const triangle = createHarness({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [5, 10], [0, 0]]] })
152
+ triangle.state.selectedVertexIndex = 0
153
+ triangle.ctx.deleteVertex(triangle.state) // 3 vertices → at minimum, rejected
154
+ expect(triangle.api.changeMode).not.toHaveBeenCalled()
155
+
156
+ state.selectedVertexIndex = 1
157
+ ctx.deleteVertex(state)
158
+ expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'delete_vertex', vertexIndex: 1 })
159
+ expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 }))
160
+
161
+ // Also works on an open line segment
162
+ const line = createHarness(LINE())
163
+ line.state.selectedVertexIndex = 1
164
+ line.ctx.deleteVertex(line.state)
165
+ expect(line.ctx.getVerticies('feat-1')).toHaveLength(2)
166
+ })
167
+ })
@@ -0,0 +1,121 @@
1
+ import {
2
+ getCoords,
3
+ getRingSegments,
4
+ getSegmentForIndex
5
+ } from './geometryHelpers.js'
6
+ import { spatialNavigate } from '../../../../utils/spatial.js'
7
+
8
+ export const vertexQueries = {
9
+ findVertexIndex (coords, targetCoord, currentIdx) {
10
+ // Search for vertex, preferring matches near currentIdx to handle duplicate coords (e.g., closing vertices)
11
+ const matches = []
12
+ coords.forEach((c, i) => {
13
+ if (c[0] === targetCoord[0] && c[1] === targetCoord[1]) {
14
+ matches.push(i)
15
+ }
16
+ })
17
+
18
+ if (matches.length === 0) { return -1 }
19
+ if (matches.length === 1) { return matches[0] }
20
+
21
+ // Multiple matches - pick closest to current selection
22
+ if (currentIdx >= 0) {
23
+ return matches.reduce((best, idx) =>
24
+ Math.abs(idx - currentIdx) < Math.abs(best - currentIdx) ? idx : best
25
+ , matches[0])
26
+ }
27
+ return matches[0]
28
+ },
29
+
30
+ getCoordPath (state, idx) {
31
+ const feature = this.getFeature(state.featureId)
32
+ if (!feature) { return '0' }
33
+
34
+ const segments = getRingSegments(feature)
35
+ const result = getSegmentForIndex(segments, idx)
36
+ if (!result) { return '0' }
37
+
38
+ const { segment, localIdx } = result
39
+ return [...segment.path, localIdx].join('.')
40
+ },
41
+
42
+ syncVertices (state) {
43
+ state.vertecies = this.getVerticies(state.featureId)
44
+ state.midpoints = this.getMidpoints(state.featureId)
45
+ },
46
+
47
+ getVerticies (featureId) {
48
+ return getCoords(this.getFeature(featureId))
49
+ },
50
+
51
+ getMidpoints (featureId) {
52
+ const feature = this.getFeature(featureId)
53
+ const coords = getCoords(feature)
54
+ const segments = getRingSegments(feature)
55
+ if (!coords?.length || !segments.length) {
56
+ return []
57
+ }
58
+
59
+ const midpoints = []
60
+ // Create midpoints within each segment, respecting boundaries
61
+ for (const seg of segments) {
62
+ // For closed rings, create midpoint between every vertex including last→first
63
+ // For open lines, create midpoints only between consecutive vertices (no wrap-around)
64
+ const count = seg.closed ? seg.length : seg.length - 1
65
+ for (let i = 0; i < count; i++) {
66
+ const idx = seg.start + i
67
+ const nextIdx = seg.start + ((i + 1) % seg.length)
68
+ const [x1, y1] = coords[idx]
69
+ const [x2, y2] = coords[nextIdx]
70
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
71
+ }
72
+ }
73
+ return midpoints
74
+ },
75
+
76
+ getVertexOrMidpoint (state, direction) {
77
+ // Ensure vertices and midpoints are populated
78
+ if (!state.vertecies?.length) {
79
+ state.vertecies = this.getVerticies(state.featureId)
80
+ state.midpoints = this.getMidpoints(state.featureId)
81
+ }
82
+ if (!state.vertecies?.length) {
83
+ return [-1, null]
84
+ }
85
+ const project = (p) => p ? Object.values(this.map.project(p)) : null
86
+ const pixels = [...state.vertecies.map(project), ...state.midpoints.map(project)].filter(Boolean)
87
+ if (!pixels.length) {
88
+ return [-1, null]
89
+ }
90
+ const start = pixels[state.selectedVertexIndex] || Object.values(this.map.project(this.map.getCenter()))
91
+ const idx = spatialNavigate(start, pixels, direction)
92
+ return [idx, idx < state.vertecies.length ? 'vertex' : 'midpoint']
93
+ },
94
+
95
+ getVertexIndexFromMidpoint (state, coordPath) {
96
+ const feature = this.getFeature(state.featureId)
97
+ const segments = getRingSegments(feature)
98
+ const parts = coordPath.split('.').map(Number)
99
+
100
+ // Find which segment this coord_path belongs to
101
+ let midpointOffset = 0
102
+ for (const seg of segments) {
103
+ const pathMatches = seg.path.every((val, idx) => val === parts[idx])
104
+ if (pathMatches && parts.length === seg.path.length + 1) {
105
+ // In DirectSelect, midpoint coord_path represents the insertion index
106
+ // The midpoint between vertex N and N+1 has coord_path ending in N+1
107
+ // So our flat midpoint index is one less than the coord_path index
108
+ const insertionIdx = parts[parts.length - 1]
109
+ const localMidpointIdx = insertionIdx > 0 ? insertionIdx - 1 : seg.length - 2
110
+ // Midpoints are indexed after all vertices
111
+ return state.vertecies.length + midpointOffset + localMidpointIdx
112
+ }
113
+ // Count midpoints in this segment (must match getMidpoints calculation)
114
+ const segMidpoints = seg.closed ? seg.length : seg.length - 1
115
+ midpointOffset += segMidpoints
116
+ }
117
+
118
+ // Fallback
119
+ return state.vertecies.length
120
+ }
121
+ }
@@ -0,0 +1,73 @@
1
+ import { createHarness, LINE } from './__helpers__/harness.js'
2
+
3
+ describe('vertexQueries', () => {
4
+ test('findVertexIndex handles no, single and duplicate matches', () => {
5
+ const { ctx } = createHarness()
6
+ expect(ctx.findVertexIndex([[0, 0], [1, 1]], [9, 9], -1)).toBe(-1)
7
+ expect(ctx.findVertexIndex([[0, 0], [1, 1]], [1, 1], -1)).toBe(1)
8
+ expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 2)).toBe(2)
9
+ expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], -1)).toBe(0)
10
+ expect(ctx.findVertexIndex([[0, 0], [1, 1], [0, 0]], [0, 0], 0)).toBe(0)
11
+ })
12
+
13
+ test('getCoordPath resolves an index, or returns "0" without a feature or segment', () => {
14
+ const { ctx, state } = createHarness()
15
+ expect(ctx.getCoordPath(state, 2)).toBe('0.2')
16
+ expect(ctx.getCoordPath(state, 99)).toBe('0')
17
+ expect(ctx.getCoordPath({ featureId: 'missing' }, 0)).toBe('0')
18
+ })
19
+
20
+ test('getVerticies / getMidpoints for closed rings, open lines and missing features', () => {
21
+ const { ctx } = createHarness()
22
+ expect(ctx.getVerticies('feat-1')).toHaveLength(4)
23
+ expect(ctx.getMidpoints('feat-1')).toHaveLength(4)
24
+ expect(ctx.getMidpoints('missing')).toEqual([])
25
+ const line = createHarness(LINE())
26
+ expect(line.ctx.getMidpoints('feat-1')).toHaveLength(2)
27
+ })
28
+
29
+ test('syncVertices refreshes the cached vertex and midpoint lists', () => {
30
+ const { ctx, state } = createHarness()
31
+ state.vertecies = []
32
+ state.midpoints = []
33
+ ctx.syncVertices(state)
34
+ expect(state.vertecies).toHaveLength(4)
35
+ expect(state.midpoints).toHaveLength(4)
36
+ })
37
+
38
+ test('getVertexOrMidpoint repopulates empty lists, navigates, and guards empty features', () => {
39
+ const { ctx, state } = createHarness()
40
+ state.vertecies = []
41
+ state.selectedVertexIndex = 0
42
+ const [idx, type] = ctx.getVertexOrMidpoint(state, 'ArrowRight')
43
+ expect(idx).toBeGreaterThanOrEqual(0)
44
+ expect(['vertex', 'midpoint']).toContain(type)
45
+
46
+ expect(ctx.getVertexOrMidpoint({ featureId: 'missing', vertecies: [] }, 'ArrowRight')).toEqual([-1, null])
47
+ expect(ctx.getVertexOrMidpoint({ ...state, vertecies: [null], midpoints: [], selectedVertexIndex: 0 }, 'ArrowRight')).toEqual([-1, null])
48
+
49
+ // Falls back to the map centre when the current index has no pixel, and can resolve a vertex target
50
+ const fresh = createHarness()
51
+ expect(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: -1 }, 'ArrowRight')[0]).toBeGreaterThanOrEqual(0)
52
+ const types = new Set()
53
+ for (let s = 0; s < fresh.state.vertecies.length + fresh.state.midpoints.length; s++) {
54
+ for (const d of ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']) {
55
+ types.add(fresh.ctx.getVertexOrMidpoint({ ...fresh.state, selectedVertexIndex: s }, d)[1])
56
+ }
57
+ }
58
+ expect(types.has('vertex')).toBe(true)
59
+ })
60
+
61
+ test('getVertexIndexFromMidpoint maps coord paths (insertion index, wrap-around and fallback)', () => {
62
+ const { ctx, state, features } = createHarness()
63
+ expect(ctx.getVertexIndexFromMidpoint(state, '0.2')).toBe(5)
64
+ expect(ctx.getVertexIndexFromMidpoint(state, '0.0')).toBe(6)
65
+ expect(ctx.getVertexIndexFromMidpoint(state, '5.0')).toBe(4)
66
+
67
+ // Counts earlier segments for multi-part geometry (closed and open)
68
+ features.set('multipoly', { type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 0], [1, 1], [0, 0]]], [[[5, 5], [6, 5], [6, 6], [5, 5]]]] })
69
+ expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multipoly', vertecies: new Array(8).fill([0, 0]) }, '1.0.1')).toBeGreaterThan(8)
70
+ features.set('multiline', { type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[5, 5], [6, 6]]] })
71
+ expect(ctx.getVertexIndexFromMidpoint({ ...state, featureId: 'multiline', vertecies: new Array(4).fill([0, 0]) }, '1.1')).toBeGreaterThan(4)
72
+ })
73
+ })