@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,193 @@
1
+ /**
2
+ * Undo handling for the shared draw mode: pushing draw_vertex operations, undoing the
3
+ * last placed vertex, reinitialising a feature, and the rubber-band update that follows.
4
+ * Part of createDrawMode.
5
+ */
6
+
7
+ // Undo-stack and event wiring: pushing operations and reacting to undo triggers.
8
+ const createUndoStackHandlers = ({ geometryType, getFeature }) => ({
9
+ /**
10
+ * Push an undo operation for the last added vertex
11
+ */
12
+ pushDrawUndo (state) {
13
+ const undoStack = this.map._undoStack
14
+ // Don't push during undo operations
15
+ if (!undoStack || this.map._undoInProgress) {
16
+ return
17
+ }
18
+ undoStack.push({
19
+ type: 'draw_vertex',
20
+ geometryType,
21
+ featureId: getFeature(state).id
22
+ })
23
+ },
24
+
25
+ /**
26
+ * Handle the draw.undo event (fired by the undo button via the adapter). Pops the
27
+ * undo stack itself so it works regardless of whether the caller passes an operation.
28
+ */
29
+ onUndo (state) {
30
+ const undoStack = this.map._undoStack
31
+ if (!undoStack || undoStack.length === 0) {
32
+ return
33
+ }
34
+ const operation = undoStack.pop()
35
+ if (operation?.type === 'draw_vertex') {
36
+ // Prevent click interference during undo
37
+ this.map._undoInProgress = true
38
+ setTimeout(() => { this.map._undoInProgress = false }, 100)
39
+ this.undoVertex(state)
40
+ // An undo commits a vertex removal, so it must re-validate like any other
41
+ // commit — otherwise the Done gate goes stale.
42
+ this.emitDrawValidation(state, 'commit-delete')
43
+ }
44
+ },
45
+
46
+ _handleUndoKeydown (state, e) {
47
+ const tag = document.activeElement?.tagName
48
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
49
+ return
50
+ }
51
+ e.preventDefault()
52
+ e.stopPropagation()
53
+ this.onUndo(state)
54
+ }
55
+ })
56
+
57
+ // Vertex-level undo: removing the last vertex and keeping the rubber band in sync.
58
+ const createVertexUndoHandlers = ({ ParentMode, geometryType, getCoords, getFeature, RUBBER_BAND_OFFSET }) => ({
59
+ /**
60
+ * Undo the last added vertex during drawing
61
+ */
62
+ undoVertex (state) {
63
+ const feature = getFeature(state)
64
+ const coords = getCoords(feature)
65
+
66
+ if (coords.length < 2) {
67
+ return false
68
+ }
69
+
70
+ // Undoing last vertex requires reinitializing the feature
71
+ if (coords.length === 2) {
72
+ return this._reinitializeFeature(state, feature)
73
+ }
74
+
75
+ this._removeLastVertex(state, feature, coords)
76
+ return true
77
+ },
78
+
79
+ /**
80
+ * Remove the last committed vertex and update rubber band
81
+ */
82
+ _removeLastVertex (state, feature, coords) {
83
+ // Structure during drawing: [v1, v2, ..., vN, rubber_band]
84
+ const ring = geometryType === 'Polygon' ? feature.coordinates[0] : coords
85
+ ring.splice(-RUBBER_BAND_OFFSET, 1)
86
+
87
+ // Snap rubber band to new last vertex position. undoVertex only calls here with
88
+ // 3+ coords, so after the splice the ring always has a preceding vertex.
89
+ ring[ring.length - 1] = [...ring[ring.length - 2]]
90
+
91
+ // Keep parent mode's vertex counter in sync (min 1 for rubber band)
92
+ state.currentVertexPosition = Math.max(1, state.currentVertexPosition - 1)
93
+
94
+ this._ctx.store.render()
95
+ this._updateRubberBand(state, getCoords(feature))
96
+ },
97
+
98
+ /**
99
+ * Update rubber band position based on interface type
100
+ */
101
+ _updateRubberBand (state, coords) {
102
+ if (['touch', 'keyboard'].includes(state.interfaceType)) {
103
+ // Touch/keyboard: move to map center for add point to work
104
+ this._simulateMouse('mousemove', ParentMode.onMouseMove, state)
105
+ this._ctx.store.render()
106
+ } else {
107
+ // Mouse: keep rubber band at current position. After a vertex removal the
108
+ // rubber band index always resolves to a real coordinate.
109
+ const rubberBandIndex = geometryType === 'Polygon' ? coords.length - 2 : coords.length - 1
110
+ const rubberBandPos = coords[rubberBandIndex]
111
+ const lngLat = { lng: rubberBandPos[0], lat: rubberBandPos[1] }
112
+ const point = this.map.project(lngLat)
113
+ ParentMode.onMouseMove.call(this, state, {
114
+ lngLat,
115
+ point,
116
+ originalEvent: new MouseEvent('mousemove', { clientX: point.x, clientY: point.y })
117
+ })
118
+ this._ctx.store.render()
119
+ // Parity with _simulateMouse: an undo can add/remove a crossing, so the live
120
+ // invalid-stroke check must see the new displayed geometry immediately.
121
+ this.map.fire('draw.geometrychange', state.polygon || state.line)
122
+ }
123
+ this.dispatchVertexChange(coords)
124
+ }
125
+ })
126
+
127
+ // Reinitialising back to zero vertices: reinitialize Polygon in place, restart LineString.
128
+ const createFeatureReinitHandlers = ({ ParentMode, featureProp, geometryType }) => ({
129
+ /**
130
+ * Reinitialize feature when undoing to 0 vertices
131
+ */
132
+ _reinitializeFeature (state, feature) {
133
+ const featureId = feature.id
134
+ this._ctx.store.delete([featureId])
135
+
136
+ // LineString: restart the draw mode with fresh state but same feature ID
137
+ if (geometryType === 'LineString') {
138
+ return this._restartLineStringDraw(state, featureId)
139
+ }
140
+
141
+ // Polygon: reinitialize in place
142
+ const center = this.map.getCenter()
143
+ const initialCoords = [[center.lng, center.lat], [center.lng, center.lat]]
144
+ const newFeature = this.newFeature({
145
+ type: 'Feature',
146
+ properties: state.properties || {},
147
+ geometry: {
148
+ type: geometryType,
149
+ coordinates: [initialCoords]
150
+ }
151
+ })
152
+ newFeature.id = featureId
153
+ this._ctx.store.add(newFeature)
154
+
155
+ state[featureProp] = newFeature
156
+ state.currentVertexPosition = 0
157
+
158
+ this._ctx.store.render()
159
+ this._simulateMouse('mousemove', ParentMode.onMouseMove, state)
160
+ this._ctx.store.render()
161
+
162
+ this.dispatchVertexChange(initialCoords)
163
+ return true
164
+ },
165
+
166
+ /**
167
+ * Restart the LineString draw mode with fresh state but the same feature ID
168
+ */
169
+ _restartLineStringDraw (state, featureId) {
170
+ const undoStack = this.map._undoStack
171
+ if (undoStack) {
172
+ undoStack.clear()
173
+ }
174
+ // Restart draw with same options (excludeFeatureIdFromSetup prevents "continue" mode)
175
+ this._ctx.api.changeMode('draw_line', {
176
+ featureId,
177
+ container: state.container,
178
+ interfaceType: state.interfaceType,
179
+ crossHair: state.crossHair,
180
+ vertexMarkerId: state.vertexMarkerId,
181
+ addVertexButtonId: state.addVertexButtonId,
182
+ getSnapEnabled: state.getSnapEnabled,
183
+ properties: state.properties
184
+ })
185
+ return true
186
+ }
187
+ })
188
+
189
+ export const createUndoHandlers = (deps) => ({
190
+ ...createUndoStackHandlers(deps),
191
+ ...createVertexUndoHandlers(deps),
192
+ ...createFeatureReinitHandlers(deps)
193
+ })
@@ -0,0 +1,195 @@
1
+ import { setup, clickAt, firedWith, DrawPolygonMode, DrawLineMode, CENTER } from './__helpers__/harness.js'
2
+
3
+ describe('cmd/ctrl+z undo', () => {
4
+ const undoKey = (overrides = {}) => {
5
+ const e = new KeyboardEvent('keydown', { key: 'z', metaKey: true, ...overrides })
6
+ e.preventDefault = jest.fn()
7
+ e.stopPropagation = jest.fn()
8
+ return e
9
+ }
10
+
11
+ test('removes the last placed vertex without switching to keyboard interface', () => {
12
+ jest.useFakeTimers()
13
+ const { ctx, state, marker } = setup(DrawPolygonMode)
14
+ clickAt(ctx, state, 0, 0)
15
+ clickAt(ctx, state, 10, 0)
16
+ clickAt(ctx, state, 10, 10)
17
+
18
+ ctx.keydownHandler(undoKey())
19
+ expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10])
20
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
21
+ expect(state.interfaceType).toBe('mouse')
22
+ expect(marker.style.display).toBe('none')
23
+ expect(ctx.map._undoInProgress).toBe(true)
24
+ jest.advanceTimersByTime(100)
25
+ expect(ctx.map._undoInProgress).toBe(false)
26
+ })
27
+
28
+ test('mouse interface keeps the rubber band on the remaining last vertex', () => {
29
+ const { ctx, state } = setup(DrawPolygonMode)
30
+ clickAt(ctx, state, 0, 0)
31
+ clickAt(ctx, state, 10, 0)
32
+ clickAt(ctx, state, 10, 10)
33
+ ctx.keydownHandler(undoKey())
34
+ const ring = state.polygon.coordinates[0]
35
+ expect(ring[ring.length - 1]).toEqual(ring[ring.length - 2])
36
+ })
37
+
38
+ test('is ignored when typing in an input, when the stack is empty, or for other operations', () => {
39
+ const { ctx, state } = setup(DrawPolygonMode)
40
+ clickAt(ctx, state, 0, 0)
41
+ clickAt(ctx, state, 10, 0)
42
+
43
+ const input = document.createElement('input')
44
+ document.body.appendChild(input)
45
+ input.focus()
46
+ ctx.keydownHandler(undoKey())
47
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
48
+ input.blur()
49
+
50
+ ctx.map._undoStack.clear()
51
+ ctx.keydownHandler(undoKey())
52
+ ctx.map._undoStack.push({ type: 'edit_vertex' })
53
+ ctx.keydownHandler(undoKey())
54
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
55
+ })
56
+
57
+ test('cmd+shift+z is not treated as undo', () => {
58
+ const { ctx, state } = setup(DrawPolygonMode)
59
+ clickAt(ctx, state, 0, 0)
60
+ clickAt(ctx, state, 10, 0)
61
+ ctx.keydownHandler(undoKey({ shiftKey: true }))
62
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
63
+ })
64
+
65
+ test('ctrl+z (Windows/Linux) also undoes the last vertex', () => {
66
+ const { ctx, state } = setup(DrawPolygonMode)
67
+ clickAt(ctx, state, 0, 0)
68
+ clickAt(ctx, state, 10, 0)
69
+ clickAt(ctx, state, 10, 10)
70
+ ctx.keydownHandler(undoKey({ metaKey: false, ctrlKey: true }))
71
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
72
+ })
73
+ })
74
+
75
+ describe('undo via draw.undo event and reinitialisation', () => {
76
+ test('draw.undo pops the stack and removes the last vertex; non-draw ops are ignored', () => {
77
+ const { ctx, state } = setup(DrawPolygonMode)
78
+ clickAt(ctx, state, 0, 0)
79
+ clickAt(ctx, state, 10, 0)
80
+ clickAt(ctx, state, 10, 10)
81
+ // A non-draw op on top of the stack is popped but not undone.
82
+ ctx.map._undoStack.push({ type: 'edit_vertex' })
83
+ ctx.map.fire('draw.undo')
84
+ expect(state.polygon.coordinates[0]).toHaveLength(4)
85
+ // The next op is a draw_vertex → the last vertex is removed.
86
+ ctx.map.fire('draw.undo')
87
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
88
+ })
89
+
90
+ test('undo re-validates the committed shape (deferred, phase commit-delete)', () => {
91
+ jest.useFakeTimers()
92
+ const { ctx, state } = setup(DrawPolygonMode)
93
+ clickAt(ctx, state, 0, 0)
94
+ clickAt(ctx, state, 10, 0)
95
+ clickAt(ctx, state, 10, 10)
96
+ jest.runAllTimers()
97
+ ctx.map.fire.mockClear()
98
+ ctx.onUndo(state)
99
+ jest.runAllTimers()
100
+ expect(firedWith(ctx.map, 'draw.geometrychange').pop()).toEqual(expect.objectContaining({
101
+ phase: 'commit-delete',
102
+ feature: expect.any(Object)
103
+ }))
104
+ jest.useRealTimers()
105
+ })
106
+
107
+ test('keyboard interface moves the rubber band to center after undo', () => {
108
+ const { ctx, state } = setup(DrawPolygonMode, { interfaceType: 'keyboard' })
109
+ clickAt(ctx, state, 0, 0)
110
+ clickAt(ctx, state, 10, 0)
111
+ clickAt(ctx, state, 10, 10)
112
+ ctx.undoVertex(state)
113
+ expect(state.polygon.coordinates[0].at(-1)).toEqual([CENTER.lng, CENTER.lat])
114
+ })
115
+
116
+ test('undoVertex returns false with nothing placed', () => {
117
+ const { ctx, state } = setup(DrawPolygonMode)
118
+ expect(ctx.undoVertex(state)).toBe(false)
119
+ })
120
+
121
+ test('undoing the only polygon vertex reinitialises the feature in place with the same id', () => {
122
+ const { ctx, state } = setup(DrawPolygonMode)
123
+ clickAt(ctx, state, 0, 0)
124
+ const id = state.polygon.id
125
+ expect(ctx.undoVertex(state)).toBe(true)
126
+ expect(ctx._ctx.store.delete).toHaveBeenCalledWith([id])
127
+ expect(state.polygon.id).toBe(id)
128
+ expect(state.currentVertexPosition).toBe(0)
129
+ expect(firedWith(ctx.map, 'draw.vertexchange').pop()).toEqual({ numVertecies: 1 })
130
+ })
131
+
132
+ test('undoing the only line vertex restarts draw_line with the same feature id', () => {
133
+ const { ctx, state } = setup(DrawLineMode)
134
+ ctx.doClick(state)
135
+ ctx.map._undoStack.push({ type: 'draw_vertex' })
136
+ expect(ctx.undoVertex(state)).toBe(true)
137
+ expect(ctx.map._undoStack.length).toBe(0)
138
+ expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({
139
+ featureId: state.line.id,
140
+ container: state.container,
141
+ properties: state.properties
142
+ }))
143
+ })
144
+
145
+ test('Escape (container keyUp) during keyboard drawing clears the undo stack and reinitialises', () => {
146
+ const { ctx, state, container } = setup(DrawPolygonMode, { interfaceType: 'keyboard' })
147
+ clickAt(ctx, state, 0, 0)
148
+ container.focus()
149
+ const id = state.polygon.id
150
+ ctx.onKeyUp(state, { key: 'Escape' })
151
+ expect(ctx.map._undoStack.length).toBe(0)
152
+ expect(state.polygon.id).toBe(id)
153
+ })
154
+
155
+ test('container keyUp defers to the parent mode when focus is elsewhere, and ignores focus inside the container', () => {
156
+ const { ctx, state, button, container } = setup(DrawPolygonMode)
157
+ ctx.onKeyUp(state, { key: 'Enter' }) // focus not on container → parent finishes on Enter
158
+ expect(ctx.changeMode).toHaveBeenCalledWith('simple_select', expect.anything())
159
+
160
+ button.focus()
161
+ ctx.changeMode.mockClear()
162
+ ctx.onKeyUp(state, { key: 'Enter' }) // focus on a child element → ignored
163
+ expect(ctx.changeMode).not.toHaveBeenCalled()
164
+
165
+ container.focus()
166
+ ctx.onKeyUp(state, { key: 'Escape' }) // non-keyboard Escape → handled by window keyup instead
167
+ expect(ctx._ctx.store.delete).not.toHaveBeenCalled()
168
+ })
169
+ })
170
+
171
+ describe('undo-stack and reinitialisation edge cases', () => {
172
+ test('placing a vertex with no undo stack does not throw and still places the vertex', () => {
173
+ const { ctx, state } = setup(DrawPolygonMode)
174
+ ctx.map._undoStack = null
175
+ clickAt(ctx, state, 0, 0)
176
+ expect(state.polygon.coordinates[0][0]).toEqual([0, 0])
177
+ })
178
+
179
+ test('reinitialising a polygon without properties uses an empty properties object', () => {
180
+ const { ctx, state } = setup(DrawPolygonMode, { properties: undefined })
181
+ clickAt(ctx, state, 0, 0)
182
+ expect(ctx.undoVertex(state)).toBe(true)
183
+ expect(state.polygon.properties).toEqual({})
184
+ })
185
+
186
+ test('restarting a line with no undo stack still changes mode', () => {
187
+ const { ctx, state } = setup(DrawLineMode)
188
+ ctx.doClick(state)
189
+ ctx.map._undoStack = null
190
+ expect(ctx.undoVertex(state)).toBe(true)
191
+ expect(ctx._ctx.api.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({
192
+ featureId: state.line.id
193
+ }))
194
+ })
195
+ })
@@ -0,0 +1,16 @@
1
+ import MapboxDraw from '@mapbox/mapbox-gl-draw'
2
+ import { isValidClick } from '../../../utils/spatial.js'
3
+ import { createDrawMode } from './createDrawMode.js'
4
+
5
+ // During drawing the ring is [v0...vN, rubber_band, v0_closing]; the last two are not placed vertices
6
+ const RUBBER_BAND_AND_CLOSING = 2
7
+
8
+ // Extend the built-in mode via the package's public API (MapboxDraw.modes) rather than a deep internal import
9
+ export const DrawPolygonMode = createDrawMode(MapboxDraw.modes.draw_polygon, {
10
+ featureProp: 'polygon',
11
+ geometryType: 'Polygon',
12
+ getCoords: (feature) => feature.coordinates[0],
13
+ validateClick: (feature) => isValidClick(feature.coordinates),
14
+ // Display ring during drawing: [v0...vN, rubber_band, v0_closing]
15
+ getPlacedCoords: (geojson) => geojson.geometry.coordinates[0].slice(0, -RUBBER_BAND_AND_CLOSING)
16
+ })
@@ -0,0 +1,27 @@
1
+ import { setup, clickAt, DrawPolygonMode } from './drawMode/__helpers__/harness.js'
2
+
3
+ const drawVertexMarkers = (ctx, state, geometry) => {
4
+ const display = jest.fn()
5
+ ctx.toDisplayFeatures(state, { type: 'Feature', properties: { id: state.polygon.id }, geometry }, display)
6
+ return display.mock.calls.map(([f]) => f)
7
+ .filter((f) => f.properties.meta === 'draw-vertex')
8
+ .map((m) => m.geometry.coordinates)
9
+ }
10
+
11
+ describe('DrawPolygonMode config', () => {
12
+ test('getCoords reads the polygon ring; validateClick (isValidClick) rejects a duplicate click', () => {
13
+ const { ctx, state } = setup(DrawPolygonMode)
14
+ clickAt(ctx, state, 0, 0)
15
+ clickAt(ctx, state, 10, 0)
16
+ expect(state.polygon.coordinates[0].map(([x]) => x)).toEqual([0, 10, 10]) // v0, v1, rubber band
17
+ clickAt(ctx, state, 10, 0) // same spot → rejected by isValidClick
18
+ expect(state.polygon.coordinates[0]).toHaveLength(3)
19
+ })
20
+
21
+ test('getPlacedCoords marks every placed vertex, excluding the rubber band and closing point', () => {
22
+ const { ctx, state } = setup(DrawPolygonMode)
23
+ // Display ring: [v0..vN, rubber_band, v0_closing] → placed = slice(0, -2)
24
+ const ring = [[0, 0], [10, 0], [10, 10], [5, 5], [0, 0]]
25
+ expect(drawVertexMarkers(ctx, state, { type: 'Polygon', coordinates: [ring] })).toEqual([[0, 0], [10, 0], [10, 10]])
26
+ })
27
+ })
@@ -0,0 +1,107 @@
1
+ import { EditVertexMode } from '../../editVertexMode.js'
2
+ import { createUndoStack } from '../../../../../utils/undoStack.js'
3
+ import PolygonFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/polygon.js'
4
+ import LineStringFeature from '../../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/feature_types/line_string.js'
5
+
6
+ /**
7
+ * Shared test harness for the vertex-edit mode and its handler modules. Builds the real
8
+ * EditVertexMode (with the real DirectSelect parent and real mapbox-gl-draw feature
9
+ * classes) over test doubles for the map, store, api and DOM. Excluded from coverage.
10
+ */
11
+
12
+ // Deterministic projection: lngLat <-> pixel is a x10 scale.
13
+ const project = (p) => {
14
+ const [lng, lat] = Array.isArray(p) ? p : [p.lng, p.lat]
15
+ return { x: lng * 10, y: lat * 10 }
16
+ }
17
+ const unproject = ({ x, y }) => ({ lng: x / 10, lat: y / 10 })
18
+
19
+ export const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] })
20
+ export const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] })
21
+ export const dragEvt = (lng, lat) => ({ originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng, lat }, point: { x: lng * 10, y: lat * 10 } })
22
+ export const svgTarget = (state) => ({ parentNode: state.touchVertexTarget })
23
+
24
+ const contexts = []
25
+
26
+ export const createHarness = (geometry = POLYGON(), setupOptions = {}) => {
27
+ const features = new Map()
28
+ const store = {
29
+ featureChanged: jest.fn(),
30
+ render: jest.fn(),
31
+ get: (id) => features.get(id)
32
+ }
33
+ const api = {
34
+ add: jest.fn((geojson) => {
35
+ const FeatureClass = geojson.geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature
36
+ // Mirror Draw.add: re-create the feature so Polygon closing coords are normalised
37
+ features.set(geojson.id, new FeatureClass(_ctx, { ...geojson, id: geojson.id }))
38
+ return [geojson.id]
39
+ }),
40
+ changeMode: jest.fn(),
41
+ delete: jest.fn(),
42
+ trash: jest.fn()
43
+ }
44
+ const _ctx = { store, api, options: {} }
45
+
46
+ const FeatureClass = geometry.type === 'Polygon' ? PolygonFeature : LineStringFeature
47
+ const feature = new FeatureClass(_ctx, { type: 'Feature', id: 'feat-1', properties: {}, geometry })
48
+ features.set('feat-1', feature)
49
+
50
+ const listeners = {}
51
+ const map = {
52
+ _undoStack: createUndoStack(() => {}),
53
+ _snapInstance: null,
54
+ _lastEditFeatureId: null,
55
+ _drawEditContainer: null,
56
+ _editingFeatureId: null,
57
+ _drawCurrentMapStyle: { mapColorScheme: 'light' },
58
+ dragPan: { enable: jest.fn(), disable: jest.fn(), isEnabled: () => true },
59
+ doubleClickZoom: { enable: jest.fn(), disable: jest.fn() },
60
+ getSource: jest.fn(() => ({ setData: jest.fn() })),
61
+ getLayer: jest.fn(() => null),
62
+ setLayoutProperty: jest.fn(),
63
+ getCenter: () => ({ lng: 5, lat: 5 }),
64
+ project: jest.fn(project),
65
+ unproject: jest.fn(unproject),
66
+ fire: jest.fn(function (type, e) { (listeners[type] ?? []).forEach((h) => h(e)) }),
67
+ on: jest.fn((type, h) => { (listeners[type] ??= []).push(h) }),
68
+ off: jest.fn((type, h) => { listeners[type] = (listeners[type] ?? []).filter((x) => x !== h) })
69
+ }
70
+
71
+ const container = document.createElement('div')
72
+ container.tabIndex = 0
73
+ document.body.appendChild(container)
74
+
75
+ const ctx = {
76
+ map,
77
+ _ctx,
78
+ getFeature: (id) => features.get(id),
79
+ getSelected: jest.fn(() => [feature]),
80
+ setSelected: jest.fn(),
81
+ setSelectedCoordinates: jest.fn(),
82
+ clearSelectedCoordinates: jest.fn(),
83
+ setActionableState: jest.fn(),
84
+ updateUIClasses: jest.fn(),
85
+ fire: jest.fn()
86
+ }
87
+ Object.assign(ctx, EditVertexMode)
88
+
89
+ const options = {
90
+ container,
91
+ featureId: 'feat-1',
92
+ interfaceType: 'mouse',
93
+ deleteVertexButtonId: 'delete-vertex',
94
+ undoButtonId: 'undo-vertex',
95
+ isPanEnabled: true,
96
+ ...setupOptions
97
+ }
98
+ const state = ctx.onSetup(options)
99
+ contexts.push({ ctx, state })
100
+ return { ctx, state, feature, features, map, store, api, container, options }
101
+ }
102
+
103
+ afterEach(() => {
104
+ contexts.splice(0).forEach(({ ctx, state }) => { try { ctx.onStop(state) } catch { /* already stopped */ } })
105
+ document.body.innerHTML = ''
106
+ jest.useRealTimers()
107
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pure geometry helper functions for multi-ring/multi-part geometry support.
3
+ * These functions handle coordinate transformations between flat arrays and
4
+ * hierarchical GeoJSON structures for Polygon, MultiPolygon, LineString, and MultiLineString.
5
+ */
6
+
7
+ /**
8
+ * Get flat coordinates array from feature for all geometry types.
9
+ * Flattens all rings/parts into a single array for unified vertex navigation.
10
+ *
11
+ * @param {Object} feature - GeoJSON geometry object
12
+ * @returns {Array<[number, number]>} Flat array of all coordinates
13
+ */
14
+ export const getCoords = (feature) => {
15
+ if (!feature?.coordinates) {
16
+ return []
17
+ }
18
+ switch (feature.type) {
19
+ case 'LineString':
20
+ return feature.coordinates
21
+ case 'Polygon':
22
+ return feature.coordinates.flat(1)
23
+ case 'MultiLineString':
24
+ return feature.coordinates.flat(1)
25
+ case 'MultiPolygon':
26
+ return feature.coordinates.flat(2)
27
+ default:
28
+ return []
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Get segment metadata for multi-ring/multi-part geometries.
34
+ * Each segment represents a ring (for Polygon) or part (for Multi*).
35
+ *
36
+ * @param {Object} feature - GeoJSON geometry object
37
+ * @returns {Array<{start: number, length: number, path: number[], closed: boolean}>}
38
+ * Array of segment metadata objects where:
39
+ * - start: Starting index in flat coordinates array
40
+ * - length: Number of coordinates in this segment
41
+ * - path: Hierarchical path indices to reach this segment in GeoJSON
42
+ * - closed: Whether this segment is closed (true for Polygon rings)
43
+ */
44
+ export const getRingSegments = (feature) => {
45
+ if (!feature?.coordinates) {
46
+ return []
47
+ }
48
+ const segments = []
49
+ let start = 0
50
+
51
+ switch (feature.type) {
52
+ case 'LineString':
53
+ segments.push({ start: 0, length: feature.coordinates.length, path: [], closed: false })
54
+ break
55
+ case 'Polygon':
56
+ feature.coordinates.forEach((ring, ringIdx) => {
57
+ segments.push({ start, length: ring.length, path: [ringIdx], closed: true })
58
+ start += ring.length
59
+ })
60
+ break
61
+ case 'MultiLineString':
62
+ feature.coordinates.forEach((line, lineIdx) => {
63
+ segments.push({ start, length: line.length, path: [lineIdx], closed: false })
64
+ start += line.length
65
+ })
66
+ break
67
+ case 'MultiPolygon':
68
+ feature.coordinates.forEach((polygon, polyIdx) => {
69
+ polygon.forEach((ring, ringIdx) => {
70
+ segments.push({ start, length: ring.length, path: [polyIdx, ringIdx], closed: true })
71
+ start += ring.length
72
+ })
73
+ })
74
+ break
75
+ default:
76
+ break
77
+ }
78
+
79
+ return segments
80
+ }
81
+
82
+ /**
83
+ * Find which segment a flat vertex index belongs to.
84
+ *
85
+ * @param {Array} segments - Array of segment metadata from getRingSegments
86
+ * @param {number} flatIdx - Flat vertex index
87
+ * @returns {{segment: Object, localIdx: number}|null}
88
+ * Object with segment metadata and local index within that segment, or null if not found
89
+ */
90
+ export const getSegmentForIndex = (segments, flatIdx) => {
91
+ for (const seg of segments) {
92
+ if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) {
93
+ return { segment: seg, localIdx: flatIdx - seg.start }
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ /**
100
+ * Get modifiable coordinate array at a specific hierarchical path.
101
+ * Returns a reference to the actual coordinate array in the GeoJSON structure.
102
+ *
103
+ * @param {Object} geojson - Full GeoJSON feature object
104
+ * @param {number[]} path - Hierarchical path indices (e.g., [0] for first ring, [1, 0] for second polygon's first ring)
105
+ * @returns {Array<[number, number]>} Reference to coordinate array at path
106
+ */
107
+ export const getModifiableCoords = (geojson, path) => {
108
+ let coords = geojson.geometry.coordinates
109
+ for (const idx of path) {
110
+ coords = coords[idx]
111
+ }
112
+ return coords
113
+ }
114
+
115
+ /**
116
+ * Convert mapbox-gl-draw coord_path string to flat vertex index.
117
+ * coord_path format: "ringIdx.vertexIdx" for Polygon, "polyIdx.ringIdx.vertexIdx" for MultiPolygon, etc.
118
+ *
119
+ * @param {Object} feature - GeoJSON geometry object
120
+ * @param {string} coordPath - coord_path string from mapbox-gl-draw
121
+ * @returns {number} Flat vertex index in the unified coordinate array
122
+ */
123
+ export const coordPathToFlatIndex = (feature, coordPath) => {
124
+ const parts = coordPath.split('.').map(Number)
125
+ const segments = getRingSegments(feature)
126
+
127
+ // Match coord_path to segment
128
+ for (const seg of segments) {
129
+ // Check if path matches (compare all but last element which is the local vertex index)
130
+ const pathMatches = seg.path.every((val, idx) => val === parts[idx])
131
+ if (pathMatches && parts.length === seg.path.length + 1) {
132
+ const localIdx = parts[parts.length - 1] // NOSONAR, .length greater borwser support
133
+ return seg.start + localIdx
134
+ }
135
+ }
136
+
137
+ // Fallback: just use the last number (works for simple geometries)
138
+ return parts[parts.length - 1] // NOSONAR, .length greater borwser support
139
+ }