@defra/interactive-map 0.0.37-alpha → 0.0.38-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 (244) 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/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  21. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  22. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  23. package/plugins/draw/dist/esm/index.js +1 -0
  24. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  25. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  28. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  29. package/plugins/draw/dist/umd/index.js +2 -0
  30. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  31. package/plugins/draw/src/DrawInit.jsx +89 -0
  32. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  33. package/plugins/draw/src/adapterEvents.js +127 -0
  34. package/plugins/draw/src/adapterEvents.test.js +20 -0
  35. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  36. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  37. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  38. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  39. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  40. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  41. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  42. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  43. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  44. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  45. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  46. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  47. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  48. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  49. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  86. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  87. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  88. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  89. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  95. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  96. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  97. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  98. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  99. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  100. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  101. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  102. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  103. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  104. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  105. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  106. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  107. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  108. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  109. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  110. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  111. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  112. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  113. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  114. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  115. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  118. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  119. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  120. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  121. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  144. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  145. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  146. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  147. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  148. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  149. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  156. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  157. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  158. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  159. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  163. package/plugins/draw/src/api/addFeature.js +22 -0
  164. package/plugins/draw/src/api/addFeature.test.js +41 -0
  165. package/plugins/draw/src/api/deleteFeature.js +11 -0
  166. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  167. package/plugins/draw/src/api/editFeature.js +54 -0
  168. package/plugins/draw/src/api/editFeature.test.js +134 -0
  169. package/plugins/draw/src/api/merge.js +30 -0
  170. package/plugins/draw/src/api/merge.test.js +57 -0
  171. package/plugins/draw/src/api/newLine.js +41 -0
  172. package/plugins/draw/src/api/newLine.test.js +93 -0
  173. package/plugins/draw/src/api/newPolygon.js +41 -0
  174. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  175. package/plugins/draw/src/api/split.js +117 -0
  176. package/plugins/draw/src/api/split.test.js +204 -0
  177. package/plugins/draw/src/defaults.js +52 -0
  178. package/plugins/draw/src/defaults.test.js +18 -0
  179. package/plugins/draw/src/draw.scss +43 -0
  180. package/plugins/draw/src/events.js +237 -0
  181. package/plugins/draw/src/events.test.js +369 -0
  182. package/plugins/draw/src/index.js +10 -0
  183. package/plugins/draw/src/index.test.js +24 -0
  184. package/plugins/draw/src/manifest.js +170 -0
  185. package/plugins/draw/src/manifest.test.js +147 -0
  186. package/plugins/draw/src/reducer.js +69 -0
  187. package/plugins/draw/src/reducer.test.js +85 -0
  188. package/plugins/draw/src/utils/debounce.js +16 -0
  189. package/plugins/draw/src/utils/debounce.test.js +40 -0
  190. package/plugins/draw/src/utils/eventBus.js +36 -0
  191. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  192. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  193. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  194. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  195. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  196. package/plugins/draw/src/utils/resolveColors.js +39 -0
  197. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  198. package/plugins/draw/src/utils/spatial.js +243 -0
  199. package/plugins/draw/src/utils/spatial.test.js +243 -0
  200. package/plugins/draw/src/utils/touchTarget.js +95 -0
  201. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  202. package/plugins/draw/src/utils/undoStack.js +31 -0
  203. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  204. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  205. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  206. package/plugins/draw/src/validation/liveStroke.js +85 -0
  207. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  208. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  209. package/plugins/draw/src/validation/rules.js +186 -0
  210. package/plugins/draw/src/validation/rules.test.js +120 -0
  211. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  212. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  213. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  214. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  215. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  216. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  217. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  218. package/plugins/search/dist/css/index.css +1 -317
  219. package/rollup.esm.mjs +35 -28
  220. package/sonar-project.properties +2 -2
  221. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  222. package/src/App/components/Hints/Hints.module.scss +5 -4
  223. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  224. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  225. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  226. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  227. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  228. package/src/App/hooks/useHintsAPI.js +67 -0
  229. package/src/App/hooks/useHintsAPI.test.js +145 -0
  230. package/src/App/renderer/PluginInits.jsx +4 -0
  231. package/src/App/renderer/PluginInits.test.jsx +7 -1
  232. package/src/InteractiveMap/InteractiveMap.js +20 -0
  233. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  234. package/src/config/events.js +4 -0
  235. package/src/scss/settings/_dimensions.scss +3 -0
  236. package/src/services/announcer.js +38 -3
  237. package/src/services/announcer.test.js +133 -1
  238. package/src/services/hints.js +3 -0
  239. package/src/services/hints.test.js +19 -0
  240. package/src/types.js +12 -0
  241. package/src/utils/detectInterfaceType.js +5 -1
  242. package/src/utils/isMac.js +15 -0
  243. package/src/utils/isMac.test.js +37 -0
  244. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,117 @@
1
+ import { splitPolygon } from '../utils/spatial.js'
2
+ import { ADAPTER_EVENTS } from '../adapterEvents.js'
3
+
4
+ const INVALID_REASON = 'Line does not split the shape into two parts'
5
+ const MIN_LINE_VERTICES = 2
6
+ const SPLITTER_ID = '_splitter'
7
+
8
+ const computeIsValid = (polygonFeature, feature) => {
9
+ const coordinates = feature.geometry?.coordinates
10
+ if (!coordinates || coordinates.length < MIN_LINE_VERTICES) {
11
+ return false
12
+ }
13
+ return !!splitPolygon(polygonFeature, { id: SPLITTER_ID, geometry: feature.geometry })
14
+ }
15
+
16
+ // Colours the splitter line preview. Uses setDrawingPreviewProperty rather than
17
+ // setFeatureProperty since the line has no stable id until it's created.
18
+ const applySplitPreview = ({ draw, polygonFeature, feature }) => {
19
+ const isValid = computeIsValid(polygonFeature, feature)
20
+ draw.setDrawingPreviewProperty('splitter', isValid ? 'valid' : 'invalid')
21
+ return isValid
22
+ }
23
+
24
+ // Colours the preview and gates Done/the finish gesture for a committed vertex.
25
+ const applySplitCommit = ({ draw, dispatch, polygonFeature, feature }) => {
26
+ const isValid = applySplitPreview({ draw, polygonFeature, feature })
27
+ dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid } })
28
+ dispatch({ type: 'SET_GEOMETRY_VALID', payload: isValid })
29
+ draw.setGeometryValid(isValid)
30
+ return isValid
31
+ }
32
+
33
+ // Installed as draw._geometryValidator. 'place' and 'create' are no-ops — a split
34
+ // isn't complete until its last vertex, so checking those would block placement
35
+ // and hijack an early finish into edit mode.
36
+ const createSplitValidator = ({ draw, dispatch, polygonFeature }) => ({ feature, phase }) => {
37
+ let isValid
38
+ if (phase === 'preview') {
39
+ isValid = applySplitPreview({ draw, polygonFeature, feature })
40
+ } else if (phase?.startsWith('commit-')) {
41
+ isValid = applySplitCommit({ draw, dispatch, polygonFeature, feature })
42
+ } else {
43
+ return { valid: true }
44
+ }
45
+ return isValid ? { valid: true } : { valid: false, reason: INVALID_REASON }
46
+ }
47
+
48
+ /**
49
+ * Start drawing a split line for a polygon.
50
+ *
51
+ * @param {object} context - plugin context
52
+ * @param {string} featureId - ID of the polygon to split
53
+ * @param {object} options - Options including snapLayers.
54
+ */
55
+ export const split = ({ appState, appConfig, pluginState, mapState, mapProvider, services }, featureId, options = {}) => {
56
+ const { dispatch } = pluginState
57
+ const { draw } = mapProvider
58
+ const { eventBus } = services
59
+
60
+ if (!draw) {
61
+ return
62
+ }
63
+
64
+ const polygonFeature = draw.get(featureId)
65
+
66
+ // Swap in split's own rule; restored in stopListening.
67
+ const previousValidator = draw._geometryValidator
68
+ draw._geometryValidator = createSplitValidator({ draw, dispatch, polygonFeature })
69
+
70
+ // Always include the draw outline layer so the split line snaps to it
71
+ const snapLayers = ['stroke-inactive.cold', ...(options.snapLayers || [])]
72
+ draw.setSnapLayers(snapLayers)
73
+ dispatch({ type: 'SET_HAS_SNAP_LAYERS', payload: true })
74
+
75
+ draw.changeMode('draw_line', {
76
+ container: appState.layoutRefs.viewportRef.current,
77
+ addVertexButtonId: `${appConfig.id}-draw-add-point`,
78
+ interfaceType: appState.interfaceType,
79
+ crossHair: mapState?.crossHair,
80
+ getSnapEnabled: () => draw.isSnapEnabled(),
81
+ featureId: SPLITTER_ID,
82
+ properties: { splitter: 'invalid' }
83
+ })
84
+
85
+ // Unregister everything scoped to this session.
86
+ const stopListening = () => {
87
+ draw._geometryValidator = previousValidator
88
+ draw.off(ADAPTER_EVENTS.CREATE, onSplitCreate)
89
+ draw.off(ADAPTER_EVENTS.CANCEL, onSplitCancel)
90
+ }
91
+
92
+ // Compute split result once the line is finalised. The splitter line only ever
93
+ // exists to compute this — it must never linger in the draw store afterwards.
94
+ const onSplitCreate = (geojsonFeature) => {
95
+ stopListening()
96
+ draw.delete(SPLITTER_ID)
97
+ const featureCollection = splitPolygon(polygonFeature, geojsonFeature)
98
+
99
+ dispatch({ type: 'SET_ACTION', payload: { name: 'split', isValid: !!featureCollection } })
100
+
101
+ if (featureCollection) {
102
+ eventBus.emit('draw:split', {
103
+ originalFeatureId: featureId,
104
+ featureCollection
105
+ })
106
+ }
107
+ }
108
+
109
+ // Abandoned (e.g. Escape) — just stop listening.
110
+ const onSplitCancel = () => { stopListening() }
111
+
112
+ draw.on(ADAPTER_EVENTS.CREATE, onSplitCreate)
113
+ draw.on(ADAPTER_EVENTS.CANCEL, onSplitCancel)
114
+
115
+ dispatch({ type: 'SET_MODE', payload: 'draw_line' })
116
+ dispatch({ type: 'SET_ACTION', payload: { name: 'split' } })
117
+ }
@@ -0,0 +1,204 @@
1
+ import { split } from './split.js'
2
+ import { splitPolygon } from '../utils/spatial.js'
3
+
4
+ jest.mock('../utils/spatial.js', () => ({ splitPolygon: jest.fn() }))
5
+
6
+ const makeContext = (overrides = {}) => {
7
+ const dispatch = jest.fn()
8
+ const eventBus = { emit: jest.fn() }
9
+ const draw = {
10
+ get: jest.fn(() => ({ id: 'poly' })),
11
+ setSnapLayers: jest.fn(),
12
+ changeMode: jest.fn(),
13
+ on: jest.fn(),
14
+ off: jest.fn(),
15
+ isSnapEnabled: jest.fn(() => true),
16
+ setGeometryValid: jest.fn(),
17
+ setDrawingPreviewProperty: jest.fn(),
18
+ delete: jest.fn()
19
+ }
20
+ const context = {
21
+ appState: { layoutRefs: { viewportRef: { current: 'viewport' } }, interfaceType: 'mouse' },
22
+ appConfig: { id: 'app' },
23
+ pluginState: { dispatch },
24
+ mapState: { crossHair: true },
25
+ mapProvider: { draw },
26
+ services: { eventBus },
27
+ ...overrides
28
+ }
29
+ return { context, dispatch, draw, eventBus }
30
+ }
31
+
32
+ const handlerFor = (draw, event) => draw.on.mock.calls.find(([name]) => name === event)?.[1]
33
+
34
+ const lineFeature = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } })
35
+
36
+ beforeEach(() => jest.clearAllMocks())
37
+
38
+ describe('split', () => {
39
+ test('does nothing when there is no draw instance', () => {
40
+ const { context, dispatch } = makeContext({ mapProvider: { draw: null } })
41
+ expect(() => split(context, 'poly')).not.toThrow()
42
+ expect(dispatch).not.toHaveBeenCalled()
43
+ })
44
+
45
+ test('sets up the splitter line drawing, installs a validator, and registers listeners', () => {
46
+ const { context, dispatch, draw } = makeContext()
47
+
48
+ split(context, 'poly')
49
+
50
+ expect(typeof draw._geometryValidator).toBe('function')
51
+ expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold'])
52
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_HAS_SNAP_LAYERS', payload: true })
53
+ expect(draw.changeMode).toHaveBeenCalledWith('draw_line', expect.objectContaining({
54
+ container: 'viewport',
55
+ addVertexButtonId: 'app-draw-add-point',
56
+ interfaceType: 'mouse',
57
+ crossHair: true,
58
+ featureId: '_splitter',
59
+ properties: { splitter: 'invalid' }
60
+ }))
61
+ expect(draw.on).toHaveBeenCalledWith('create', expect.any(Function))
62
+ expect(draw.on).toHaveBeenCalledWith('cancel', expect.any(Function))
63
+ expect(draw.on).not.toHaveBeenCalledWith('geometrychange', expect.anything())
64
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_MODE', payload: 'draw_line' })
65
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split' } })
66
+ expect(draw.changeMode.mock.calls[0][1].getSnapEnabled()).toBe(true)
67
+ })
68
+
69
+ test('merges option snapLayers with the outline layer', () => {
70
+ const { context, draw } = makeContext()
71
+ split(context, 'poly', { snapLayers: ['extra'] })
72
+ expect(draw.setSnapLayers).toHaveBeenCalledWith(['stroke-inactive.cold', 'extra'])
73
+ })
74
+
75
+ test('finalising the line computes a valid split, emits the result, and restores the previous validator', () => {
76
+ const { context, dispatch, draw, eventBus } = makeContext()
77
+ const polygonFeature = { id: 'poly' }
78
+ const featureCollection = { type: 'FeatureCollection' }
79
+ const previousValidator = jest.fn()
80
+ draw._geometryValidator = previousValidator
81
+ draw.get.mockReturnValue(polygonFeature)
82
+ splitPolygon.mockReturnValue(featureCollection)
83
+
84
+ split(context, 'poly')
85
+ const onCreate = handlerFor(draw, 'create')
86
+ const geojson = { id: 'line' }
87
+ onCreate(geojson)
88
+
89
+ expect(draw.off).toHaveBeenCalledWith('create', onCreate)
90
+ expect(draw.off).toHaveBeenCalledWith('cancel', expect.any(Function))
91
+ expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, geojson)
92
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: true } })
93
+ expect(eventBus.emit).toHaveBeenCalledWith('draw:split', { originalFeatureId: 'poly', featureCollection })
94
+ expect(draw._geometryValidator).toBe(previousValidator)
95
+ // The splitter line only ever existed to compute the split — it must not
96
+ // linger in the draw store afterwards.
97
+ expect(draw.delete).toHaveBeenCalledWith('_splitter')
98
+ })
99
+
100
+ test('cancelling the splitter line stops listening and restores the previous validator', () => {
101
+ const { context, draw } = makeContext()
102
+ const previousValidator = jest.fn()
103
+ draw._geometryValidator = previousValidator
104
+
105
+ split(context, 'poly')
106
+ const onCancel = handlerFor(draw, 'cancel')
107
+ onCancel()
108
+
109
+ expect(draw.off).toHaveBeenCalledWith('create', expect.any(Function))
110
+ expect(draw.off).toHaveBeenCalledWith('cancel', onCancel)
111
+ expect(splitPolygon).not.toHaveBeenCalled()
112
+ expect(draw._geometryValidator).toBe(previousValidator)
113
+ })
114
+
115
+ test('finalising the line computes an invalid split and does not emit', () => {
116
+ const { context, dispatch, draw, eventBus } = makeContext()
117
+ splitPolygon.mockReturnValue(null)
118
+
119
+ split(context, 'poly')
120
+ handlerFor(draw, 'create')({ id: 'line' })
121
+
122
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } })
123
+ expect(eventBus.emit).not.toHaveBeenCalled()
124
+ expect(draw.delete).toHaveBeenCalledWith('_splitter')
125
+ })
126
+
127
+ describe('the installed draw._geometryValidator', () => {
128
+ test('never blocks a hard placement veto (phase: place)', () => {
129
+ const { context, dispatch, draw } = makeContext()
130
+ split(context, 'poly')
131
+ dispatch.mockClear()
132
+
133
+ const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'place', mode: 'draw_line', vertexIndex: 1 })
134
+
135
+ expect(result).toEqual({ valid: true })
136
+ expect(splitPolygon).not.toHaveBeenCalled()
137
+ expect(draw.setDrawingPreviewProperty).not.toHaveBeenCalled()
138
+ expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' }))
139
+ })
140
+
141
+ test('never blocks the whole-feature finish check (phase: create)', () => {
142
+ const { context, dispatch, draw } = makeContext()
143
+ split(context, 'poly')
144
+ dispatch.mockClear()
145
+
146
+ const result = draw._geometryValidator({ feature: lineFeature([[0, 0], [1, 1]]), phase: 'create', mode: 'draw_line' })
147
+
148
+ expect(result).toEqual({ valid: true })
149
+ expect(splitPolygon).not.toHaveBeenCalled()
150
+ expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_ACTION' }))
151
+ })
152
+
153
+ test('the live preview (phase: preview) only updates the visual colour, never Done-gating — even with just 1 placed vertex', () => {
154
+ const { context, dispatch, draw } = makeContext()
155
+ const polygonFeature = { id: 'poly' }
156
+ draw.get.mockReturnValue(polygonFeature)
157
+ splitPolygon.mockReturnValue({ type: 'FeatureCollection' })
158
+ split(context, 'poly')
159
+ dispatch.mockClear()
160
+
161
+ // 1 placed vertex + the rubber-band cursor. validateDisplayedGeometry only
162
+ // gates the built-in rules by numVertices, not a caller's own rule, so this
163
+ // must still reach the validator (numVertices: 1, below MIN_VERTICES.LineString).
164
+ const feature = lineFeature([[0, 0], [1, 1]])
165
+ const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 1 })
166
+
167
+ expect(splitPolygon).toHaveBeenCalledWith(polygonFeature, { id: '_splitter', geometry: feature.geometry })
168
+ expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'valid')
169
+ // Committed Done-gating (pluginState.geometryValid / map._drawGeometryValid) must only
170
+ // change on an actual commit — otherwise a live "what if you added a point here" check
171
+ // could flicker a just-committed valid split back to disabled.
172
+ expect(dispatch).not.toHaveBeenCalled()
173
+ expect(draw.setGeometryValid).not.toHaveBeenCalled()
174
+ expect(result).toEqual({ valid: true })
175
+ })
176
+
177
+ test('a committed vertex (phase: commit-add) updates both the visual colour and Done-gating', () => {
178
+ const { context, dispatch, draw } = makeContext()
179
+ splitPolygon.mockReturnValue(null)
180
+ split(context, 'poly')
181
+
182
+ const feature = lineFeature([[0, 0], [1, 1]])
183
+ const result = draw._geometryValidator({ feature, phase: 'commit-add', mode: 'draw_line', vertexIndex: 1 })
184
+
185
+ expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid')
186
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_ACTION', payload: { name: 'split', isValid: false } })
187
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_GEOMETRY_VALID', payload: false })
188
+ expect(draw.setGeometryValid).toHaveBeenCalledWith(false)
189
+ expect(result).toEqual({ valid: false, reason: expect.any(String) })
190
+ })
191
+
192
+ test('a line with fewer than two coordinates is treated as invalid without calling splitPolygon', () => {
193
+ const { context, draw } = makeContext()
194
+ split(context, 'poly')
195
+
196
+ const feature = lineFeature([[0, 0]])
197
+ const result = draw._geometryValidator({ feature, phase: 'preview', mode: 'draw_line', numVertices: 0 })
198
+
199
+ expect(splitPolygon).not.toHaveBeenCalled()
200
+ expect(draw.setDrawingPreviewProperty).toHaveBeenCalledWith('splitter', 'invalid')
201
+ expect(result).toEqual({ valid: false, reason: expect.any(String) })
202
+ })
203
+ })
204
+ })
@@ -0,0 +1,52 @@
1
+ const BLUE = 'rgba(29,112,184,1)'
2
+ const WHITE = '#ffffff'
3
+ const BLACK = 'rgba(11,12,12,1)'
4
+ const LIGHT_BLUE = 'rgba(29,112,184,0.1)'
5
+ const LIGHT_WHITE = 'rgba(255,255,255,0.1)'
6
+ const RED = 'rgba(212,53,28,1)'
7
+ const MID_ORANGE = 'rgba(212,53,28,0.5)'
8
+ const MID_BLUE = 'rgba(29,112,184,0.5)'
9
+ const GREEN = 'rgba(40,161,151,1)'
10
+
11
+ export const COLORS = {
12
+ editStroke: { light: BLUE, dark: WHITE },
13
+ editFill: { light: LIGHT_BLUE, dark: LIGHT_WHITE },
14
+ editVertex: { light: BLUE, dark: WHITE },
15
+ editMidpoint: { light: BLUE, dark: WHITE },
16
+ editHalo: { light: WHITE, dark: BLACK },
17
+ editActive: { light: BLACK, dark: WHITE },
18
+ splitInvalid: { light: BLUE, dark: WHITE },
19
+ splitValid: { light: BLUE, dark: WHITE },
20
+ invalidStroke: { light: BLUE, dark: WHITE },
21
+ shapeStroke: RED,
22
+ shapeFill: MID_ORANGE,
23
+ snapVertex: MID_ORANGE,
24
+ snapMidpoint: GREEN,
25
+ snapEdge: MID_BLUE
26
+ }
27
+
28
+ export const SIZES = {
29
+ strokeWidth: 2,
30
+ vertexRadius: 6,
31
+ midpointRadius: 4,
32
+ vertexHaloRadius: 8,
33
+ midpointHaloRadius: 6,
34
+ touchTargetSize: 48,
35
+ touchIndicatorRadius: 30
36
+ }
37
+
38
+ export const TOLERANCES = {
39
+ snapRadius: 12
40
+ }
41
+
42
+ export const KEYBOARD = {
43
+ nudgeAmount: 1,
44
+ stepAmount: 5
45
+ }
46
+
47
+ // Scale factor applied to draw UI (touch targets, vertex handles) per app map size
48
+ export const MAP_SIZE_SCALES = {
49
+ small: 1,
50
+ medium: 1.5,
51
+ large: 2
52
+ }
@@ -0,0 +1,18 @@
1
+ import { COLORS, SIZES, TOLERANCES, KEYBOARD, MAP_SIZE_SCALES } from './defaults.js'
2
+
3
+ describe('draw defaults', () => {
4
+ test('exposes colour variants for the edit palette', () => {
5
+ expect(COLORS.editStroke).toEqual(expect.objectContaining({ light: expect.any(String), dark: expect.any(String) }))
6
+ expect(COLORS.shapeStroke).toEqual(expect.any(String))
7
+ })
8
+
9
+ test('exposes numeric sizes and tolerances', () => {
10
+ expect(SIZES.touchTargetSize).toBe(48)
11
+ expect(TOLERANCES.snapRadius).toBe(12)
12
+ expect(KEYBOARD).toEqual({ nudgeAmount: 1, stepAmount: 5 })
13
+ })
14
+
15
+ test('maps app map sizes to scale factors', () => {
16
+ expect(MAP_SIZE_SCALES).toEqual({ small: 1, medium: 1.5, large: 2 })
17
+ })
18
+ })
@@ -0,0 +1,43 @@
1
+ // Touch vertex target (MapLibre)
2
+ .touch-vertex-target {
3
+ circle {
4
+ fill: var(--map-overlay-foreground-color);
5
+ }
6
+ path {
7
+ fill: var(--map-overlay-halo-color);
8
+ }
9
+ }
10
+
11
+ // Touch vertex target (OpenLayers)
12
+ .im-draw-touch-target {
13
+ color: #3b82f6;
14
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
15
+ cursor: grab;
16
+
17
+ &:active {
18
+ cursor: grabbing;
19
+ }
20
+ }
21
+
22
+ // Accessibility: focus visible on buttons
23
+ .im-draw-button:focus-visible {
24
+ outline: 2px solid #3b82f6;
25
+ outline-offset: 2px;
26
+ }
27
+
28
+ // Ensure action buttons take up same width
29
+ .im-c-actions {
30
+ .im-c-button-wrapper--draw-done,
31
+ .im-c-button-wrapper--draw-menu,
32
+ .im-c-button-wrapper--draw-cancel {
33
+ width: 33.33%;
34
+ }
35
+ }
36
+ .im-o-app--tablet .im-c-actions,
37
+ .im-o-app--desktop .im-c-actions {
38
+ .im-c-button-wrapper--draw-done,
39
+ .im-c-button-wrapper--draw-menu,
40
+ .im-c-button-wrapper--draw-cancel {
41
+ width: 100px;
42
+ }
43
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Button and map-event wiring for the draw plugin.
3
+ *
4
+ * Uses the normalised adapter interface (draw.on/off, draw.done(), draw.cancel(),
5
+ * draw.setSnapEnabled(), etc.) so this file is map-framework-agnostic.
6
+ * All MapLibre / OL specifics live in the adapter.
7
+ */
8
+ import { ADAPTER_EVENTS } from './adapterEvents.js'
9
+ import { validateGeometry } from './validation/validateGeometry.js'
10
+ import { MIN_VERTICES_REASONS } from './validation/rules.js'
11
+ import { MAP_SIZE_SCALES } from './defaults.js'
12
+
13
+ const EDIT_VERTEX_MODE = 'edit_vertex'
14
+ const GEOMETRY_INVALID_EVENT = 'draw:geometryinvalid'
15
+
16
+ // A shape that simply hasn't reached its minimum vertex count yet isn't a mistake —
17
+ // it's the normal, expected state of "still being drawn" — so it's excluded below
18
+ // even though it's a genuine SOFT_RULES failure like any other.
19
+ const isIncompleteShape = (reason) => Object.values(MIN_VERTICES_REASONS).includes(reason)
20
+
21
+ // A discrete, one-shot invalid result (a rejected placement, a finished-but-invalid
22
+ // shape, or a completed commit that failed validation — never a continuous live-drag
23
+ // flip, which goes via onValidityChange/CAN_PLACE_CHANGE instead) gets a hint toast
24
+ // alongside the existing public event, so the reason is visible without every
25
+ // consumer having to wire up their own listener for it. The public event still
26
+ // fires either way — only the hint is skipped for an incomplete shape.
27
+ const createGeometryInvalidEmitter = (eventBus, hints) => (payload) => {
28
+ eventBus.emit(GEOMETRY_INVALID_EVENT, payload)
29
+ if (payload.reason && !isIncompleteShape(payload.reason)) { hints.show(payload.reason) }
30
+ }
31
+
32
+ // Claims MoveControl's D-pad for the currently selected vertex — see the generic
33
+ // mapProvider.activeMoveTarget contract (MoveControl.jsx). Any plugin could use
34
+ // this slot; the draw plugin is just its first consumer.
35
+ const buildVertexMoveTarget = (draw) => ({
36
+ move: (dx, dy, isLargeStep) => draw.nudgeSelectedVertex(dx, dy, isLargeStep),
37
+ label: 'vertex'
38
+ })
39
+
40
+ // Vertex-selection handlers: sync pluginState.selectedVertexIndex, and claim/release
41
+ // MoveControl's D-pad via buildVertexMoveTarget above.
42
+ const createVertexSelectionHandlers = ({ draw, pluginState, mapProvider, eventBus }) => ({
43
+ onVertexSelection: (e) => {
44
+ pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e })
45
+ mapProvider.activeMoveTarget = e.index >= 0 ? buildVertexMoveTarget(draw) : null
46
+ eventBus.emit('draw:vertexselection', e)
47
+ },
48
+ onVertexChange: (e) => {
49
+ pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: -1, numVertices: e.numVertices } })
50
+ mapProvider.activeMoveTarget = null
51
+ }
52
+ })
53
+
54
+ // Re-open a feature in vertex-edit mode (used when a drawn shape finishes invalid).
55
+ // Mirrors the options built by api/editFeature.js. Edit-mode Done is gated by the
56
+ // same validity, so the shape can't be finished until it's fixed.
57
+ const enterEditVertexMode = ({ draw, appState, appConfig, mapState, dispatch }, featureId) => {
58
+ draw.changeMode(EDIT_VERTEX_MODE, {
59
+ container: appState?.layoutRefs?.viewportRef?.current,
60
+ deleteVertexButtonId: `${appConfig?.id}-draw-delete-point`,
61
+ undoButtonId: `${appConfig?.id}-draw-undo`,
62
+ isPanEnabled: appState?.interfaceType !== 'keyboard',
63
+ interfaceType: appState?.interfaceType,
64
+ scale: MAP_SIZE_SCALES[mapState?.mapSize],
65
+ featureId,
66
+ getSnapEnabled: () => draw.isSnapEnabled()
67
+ })
68
+ const editing = draw.get(featureId)
69
+ dispatch({ type: 'SET_FEATURE', payload: { feature: editing, tempFeature: editing } })
70
+ dispatch({ type: 'SET_MODE', payload: EDIT_VERTEX_MODE })
71
+ dispatch({ type: 'SET_GEOMETRY_VALID', payload: false })
72
+ draw.setGeometryValid?.(false)
73
+ draw.setInvalid?.(true)
74
+ }
75
+
76
+ function createHandlers ({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState }) {
77
+ const { draw } = mapProvider
78
+ const emitGeometryInvalid = createGeometryInvalidEmitter(eventBus, hints)
79
+ const { feature, tempFeature } = pluginState
80
+ const { dispatch } = pluginState
81
+ // A shape that finished invalid and was re-opened in edit mode: its eventual
82
+ // edit-finish must report as a creation, not an edit.
83
+ let pendingCreateId = null
84
+
85
+ return {
86
+ handleDone: () => { draw.done() },
87
+ handleCancel: () => {
88
+ const mode = draw.getMode()
89
+ if (mode === EDIT_VERTEX_MODE && tempFeature?.id) { draw.add(feature) }
90
+ pendingCreateId = null
91
+ draw.cancel(); resetState()
92
+ eventBus.emit('draw:cancelled', feature)
93
+ },
94
+ handleUndo: () => draw.undo(),
95
+ handleDeleteVertex: () => draw.deleteVertex(),
96
+ handleSnap: () => {
97
+ pluginState.dispatch({ type: 'TOGGLE_SNAP' })
98
+ draw.setSnapEnabled(!pluginState.snap)
99
+ },
100
+ onCreate: (f) => {
101
+ // A shape can be finished by the Done button OR a map gesture (double-click,
102
+ // clicking the first vertex, Enter). Only the button is gated, so re-validate
103
+ // here to catch the gesture paths — an invalid shape must never be finalised.
104
+ const { valid, reason } = validateGeometry(f, { phase: 'create', mode: draw.getMode() }, { onGeometryChange: draw._geometryValidator })
105
+ if (!valid) {
106
+ pendingCreateId = f.id
107
+ emitGeometryInvalid({ feature: f, reason, phase: 'create', mode: EDIT_VERTEX_MODE })
108
+ setTimeout(() => enterEditVertexMode({ draw, appState, appConfig, mapState, dispatch }, f.id), 0)
109
+ return
110
+ }
111
+ resetState(); setTimeout(() => draw.changeMode('disabled'), 0); eventBus.emit('draw:created', f)
112
+ },
113
+ onEditFinish: (f) => {
114
+ resetState()
115
+ setTimeout(() => draw.changeMode('disabled'), 0)
116
+ // A shape that was drawn-then-fixed reports as a creation, not an edit.
117
+ if (pendingCreateId && f.id === pendingCreateId) {
118
+ pendingCreateId = null
119
+ eventBus.emit('draw:created', f)
120
+ } else {
121
+ eventBus.emit('draw:edited', f)
122
+ }
123
+ },
124
+ onCancel: () => {},
125
+ ...createVertexSelectionHandlers({ draw, pluginState, mapProvider, eventBus }),
126
+ onUndoChange: (l) => { pluginState.dispatch({ type: 'SET_UNDO_STACK_LENGTH', payload: l }) },
127
+ onUpdate: (f) => { eventBus.emit('draw:updated', f) },
128
+ onGeometryChange: (e) => {
129
+ // Only commit-level changes (commit-add/move/insert/delete) carry a `phase`.
130
+ // Preview events (e.g. split's live preview) have none and are ignored.
131
+ if (!e?.phase) { return }
132
+
133
+ const mode = draw.getMode()
134
+ const context = { phase: e.phase, vertexIndex: e.vertexIndex, mode }
135
+ const { valid, reason } = validateGeometry(e.feature, context, { onGeometryChange: draw._geometryValidator })
136
+
137
+ // Rules only gate the Done button — never revert. A shape can pass through
138
+ // interim invalid states while being built or reshaped.
139
+ pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: valid })
140
+ draw.setGeometryValid?.(valid)
141
+ // The invalid stroke is committed-validity-driven in edit mode only; in draw
142
+ // mode the adapters drive it live from the displayed geometry (placed
143
+ // vertices + cursor) on every rubber-band move.
144
+ if (mode === EDIT_VERTEX_MODE) {
145
+ draw.setInvalid?.(!valid)
146
+ }
147
+ if (!valid) {
148
+ emitGeometryInvalid({ reason, ...context, feature: e.feature })
149
+ }
150
+ },
151
+ // A vertex placement was rejected (hard rule or user callback veto). Surface it
152
+ // on the public bus with phase 'place', and as a hint toast, so the reason is visible.
153
+ onPlacementBlocked: (e) => {
154
+ emitGeometryInvalid(e)
155
+ },
156
+ // Live (mid-drag) validity flip while editing — the displayed shape is exactly
157
+ // what Done finishes there, so it gates the Done button in real time. Emitted
158
+ // by the adapters' edit wiring only, on flips only.
159
+ onValidityChange: (e) => {
160
+ pluginState.dispatch({ type: 'SET_GEOMETRY_VALID', payload: e.valid })
161
+ draw.setGeometryValid?.(e.valid)
162
+ },
163
+ // Live placement-veto flip while drawing — gates the Add point button so it
164
+ // never looks active when a tap would be rejected.
165
+ onCanPlaceChange: (e) => {
166
+ pluginState.dispatch({ type: 'SET_CAN_ADD_POINT', payload: e.canPlace })
167
+ },
168
+ // The mode's interface type changed (device switch mid-draw, or the final
169
+ // value on mode exit) — relay so the app can sync appState.interfaceType.
170
+ onInterfaceTypeChange: (e) => {
171
+ eventBus.emit('draw:interfacetypechange', { interfaceType: e.interfaceType })
172
+ }
173
+ }
174
+ }
175
+
176
+ function attachButtonHandlers (buttonConfig, handlers) {
177
+ const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig
178
+ drawDone.onClick = handlers.handleDone
179
+ drawCancel.onClick = handlers.handleCancel
180
+ drawUndo.onClick = handlers.handleUndo
181
+ if (drawDeletePoint) { drawDeletePoint.onClick = handlers.handleDeleteVertex }
182
+ if (drawSnap) { drawSnap.onClick = handlers.handleSnap }
183
+ }
184
+
185
+ function attachDrawEvents (draw, handlers) {
186
+ draw.on(ADAPTER_EVENTS.CREATE, handlers.onCreate)
187
+ draw.on(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish)
188
+ draw.on(ADAPTER_EVENTS.CANCEL, handlers.onCancel)
189
+ draw.on(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection)
190
+ draw.on(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange)
191
+ draw.on(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange)
192
+ draw.on(ADAPTER_EVENTS.UPDATE, handlers.onUpdate)
193
+ draw.on(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange)
194
+ draw.on(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked)
195
+ draw.on(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange)
196
+ draw.on(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange)
197
+ draw.on(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange)
198
+ }
199
+
200
+ function detachButtonHandlers (buttonConfig) {
201
+ const { drawDone, drawCancel, drawUndo, drawDeletePoint, drawSnap } = buttonConfig
202
+ drawDone.onClick = null
203
+ drawCancel.onClick = null
204
+ drawUndo.onClick = null
205
+ if (drawDeletePoint) { drawDeletePoint.onClick = null }
206
+ if (drawSnap) { drawSnap.onClick = null }
207
+ }
208
+
209
+ function detachDrawEvents (draw, handlers) {
210
+ draw.off(ADAPTER_EVENTS.CREATE, handlers.onCreate)
211
+ draw.off(ADAPTER_EVENTS.EDIT_FINISH, handlers.onEditFinish)
212
+ draw.off(ADAPTER_EVENTS.CANCEL, handlers.onCancel)
213
+ draw.off(ADAPTER_EVENTS.VERTEX_SELECTION, handlers.onVertexSelection)
214
+ draw.off(ADAPTER_EVENTS.VERTEX_CHANGE, handlers.onVertexChange)
215
+ draw.off(ADAPTER_EVENTS.UNDO_CHANGE, handlers.onUndoChange)
216
+ draw.off(ADAPTER_EVENTS.UPDATE, handlers.onUpdate)
217
+ draw.off(ADAPTER_EVENTS.GEOMETRY_CHANGE, handlers.onGeometryChange)
218
+ draw.off(ADAPTER_EVENTS.PLACEMENT_BLOCKED, handlers.onPlacementBlocked)
219
+ draw.off(ADAPTER_EVENTS.VALIDITY_CHANGE, handlers.onValidityChange)
220
+ draw.off(ADAPTER_EVENTS.CAN_PLACE_CHANGE, handlers.onCanPlaceChange)
221
+ draw.off(ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE, handlers.onInterfaceTypeChange)
222
+ }
223
+
224
+ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapProvider, buttonConfig, eventBus, hints }) {
225
+ const { draw } = mapProvider
226
+ const resetState = () => {
227
+ pluginState.dispatch({ type: 'SET_MODE', payload: null })
228
+ pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } })
229
+ // Release MoveControl's D-pad back to panning the map whenever a draw/edit
230
+ // session ends — a stale claim here would silently hijack it for good.
231
+ mapProvider.activeMoveTarget = null
232
+ }
233
+ const handlers = createHandlers({ appState, appConfig, mapState, pluginState, mapProvider, eventBus, hints, resetState })
234
+ attachButtonHandlers(buttonConfig, handlers)
235
+ attachDrawEvents(draw, handlers)
236
+ return () => { detachButtonHandlers(buttonConfig); detachDrawEvents(draw, handlers) }
237
+ }