@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,31 @@
1
+ /**
2
+ * Undo stack for draw operations.
3
+ * Calls onChange(length) whenever the stack changes so the UI can update.
4
+ *
5
+ * @param {(length: number) => void} onChange
6
+ */
7
+ export const createUndoStack = (onChange) => {
8
+ const stack = []
9
+
10
+ return {
11
+ push (operation) {
12
+ stack.push(operation)
13
+ onChange(stack.length)
14
+ },
15
+
16
+ pop () {
17
+ const op = stack.pop()
18
+ onChange(stack.length)
19
+ return op
20
+ },
21
+
22
+ clear () {
23
+ stack.length = 0
24
+ onChange(stack.length)
25
+ },
26
+
27
+ get length () {
28
+ return stack.length
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,49 @@
1
+ import { createUndoStack } from './undoStack.js'
2
+
3
+ describe('createUndoStack', () => {
4
+ test('push adds an operation and reports the new length', () => {
5
+ const onChange = jest.fn()
6
+ const stack = createUndoStack(onChange)
7
+
8
+ stack.push({ id: 1 })
9
+ stack.push({ id: 2 })
10
+
11
+ expect(stack.length).toBe(2)
12
+ expect(onChange).toHaveBeenNthCalledWith(1, 1)
13
+ expect(onChange).toHaveBeenNthCalledWith(2, 2)
14
+ })
15
+
16
+ test('pop returns and removes the last operation, reporting the length', () => {
17
+ const onChange = jest.fn()
18
+ const stack = createUndoStack(onChange)
19
+ stack.push({ id: 1 })
20
+ onChange.mockClear()
21
+
22
+ const op = stack.pop()
23
+
24
+ expect(op).toEqual({ id: 1 })
25
+ expect(stack.length).toBe(0)
26
+ expect(onChange).toHaveBeenCalledWith(0)
27
+ })
28
+
29
+ test('pop on an empty stack returns undefined and reports zero', () => {
30
+ const onChange = jest.fn()
31
+ const stack = createUndoStack(onChange)
32
+
33
+ expect(stack.pop()).toBeUndefined()
34
+ expect(onChange).toHaveBeenCalledWith(0)
35
+ })
36
+
37
+ test('clear empties the stack and reports zero', () => {
38
+ const onChange = jest.fn()
39
+ const stack = createUndoStack(onChange)
40
+ stack.push({ id: 1 })
41
+ stack.push({ id: 2 })
42
+ onChange.mockClear()
43
+
44
+ stack.clear()
45
+
46
+ expect(stack.length).toBe(0)
47
+ expect(onChange).toHaveBeenCalledWith(0)
48
+ })
49
+ })
@@ -0,0 +1,120 @@
1
+ import { validateGeometry } from './validateGeometry.js'
2
+ import { LIVE_RULES, HARD_RULES, MIN_VERTICES } from './rules.js'
3
+ import { requestFrame, cancelFrame } from './liveStroke.js'
4
+
5
+ /**
6
+ * Live validation driver for DRAW mode (draw_polygon/draw_line) only — edit mode
7
+ * has no Add-point gate and keeps using the plain createLiveStroke (liveStroke.js).
8
+ *
9
+ * On every rubber-band move, a user `onGeometryChange` callback is called ONCE,
10
+ * throttled to one call per animation frame (phase 'preview', with NO
11
+ * vertex-count floor — a location-based rule is meaningful even against the
12
+ * very first candidate point, before any vertex is committed). Its verdict is
13
+ * combined with two INDEPENDENTLY-floored built-in rule checks, each flip-guarded
14
+ * and reported through its own callback — `onChange(invalid, reason)` fires only
15
+ * when THAT gate's own state flips, same contract as createLiveStroke:
16
+ * - LIVE_RULES (closed-ring self-intersect/area), floored at the geometry
17
+ * type's minimum vertex count — self-intersection/area are meaningless
18
+ * below that — reported via onStrokeChange (dashed stroke / Done gate).
19
+ * - HARD_RULES (open-path self-intersect), never floored, so it still
20
+ * protects placing an invalid first vertex — reported via onPlaceChange
21
+ * (Add-point gate).
22
+ * Previously each gate ran its own independently-throttled call to the same
23
+ * user callback (one forcing phase 'preview', the other forcing phase 'place'),
24
+ * firing it twice per rubber-band move for what was really one live moment.
25
+ * This unifies that into a single call, whose verdict both gates share.
26
+ *
27
+ * Being flip-guarded itself (unlike an earlier version of this module), a
28
+ * caller only strictly needs createLiveStroke for onStrokeChange — where
29
+ * MaplibreDrawAdapter.js/OL DrawMode.js feed it into their existing shared
30
+ * `_liveStroke.set()` sink, since that instance is also driven independently
31
+ * by edit mode and must stay the single source of truth for the rendered
32
+ * stroke. onPlaceChange has no such cross-mode instance to stay in sync with —
33
+ * draw mode is its only caller — so it can emit CAN_PLACE_CHANGE directly.
34
+ *
35
+ * @param {object} params
36
+ * @param {(invalid: boolean, reason: string|null) => void} params.onStrokeChange
37
+ * @param {(vetoed: boolean, reason: string|null) => void} params.onPlaceChange
38
+ * @returns {{ update: Function, reset: Function, destroy: Function }}
39
+ */
40
+ export const createLiveDrawChecks = ({ onStrokeChange, onPlaceChange }) => {
41
+ // A small flip-guarded gate — same shape as liveStroke.js's own `flip`, one
42
+ // per output so the stroke and placement verdicts track independently.
43
+ const makeGate = (onChange) => {
44
+ let invalid = false
45
+ return (next, reason) => {
46
+ if (next !== invalid) {
47
+ invalid = next
48
+ onChange(next, reason ?? null)
49
+ }
50
+ }
51
+ }
52
+ const flipStroke = makeGate(onStrokeChange)
53
+ const flipPlace = makeGate(onPlaceChange)
54
+
55
+ let customInvalid = false
56
+ let customReason = null
57
+ let frame = null
58
+ let pending = null
59
+
60
+ const cancelPending = () => {
61
+ if (frame != null) { cancelFrame(frame); frame = null }
62
+ pending = null
63
+ }
64
+
65
+ // One built-in rule set, its own floor, and the gate it drives — the stroke
66
+ // and placement checks are otherwise identical shape.
67
+ const evaluateGate = (feature, context, rules, floor, flip) => {
68
+ const numVertices = context.numVertices ?? 0
69
+ const result = numVertices < floor
70
+ ? { valid: true }
71
+ : validateGeometry(feature, { ...context, phase: 'preview' }, { rules })
72
+ flip(!result.valid || customInvalid, result.valid ? customReason : result.reason)
73
+ }
74
+
75
+ const recompute = () => {
76
+ if (!pending) { return }
77
+ const { feature, context } = pending
78
+ const min = MIN_VERTICES[feature?.geometry?.type] ?? 0
79
+ evaluateGate(feature, context, LIVE_RULES, min, flipStroke)
80
+ evaluateGate(feature, context, HARD_RULES, 0, flipPlace)
81
+ }
82
+
83
+ const runUserRule = () => {
84
+ frame = null
85
+ if (!pending) { return }
86
+ const { feature, context, onGeometryChange } = pending
87
+ const result = validateGeometry(feature, { ...context, phase: 'preview' }, { rules: [], onGeometryChange })
88
+ customInvalid = !result.valid
89
+ customReason = result.reason
90
+ recompute()
91
+ }
92
+
93
+ return {
94
+ // Re-evaluate the displayed geometry after a rubber-band move.
95
+ update ({ feature, context = {}, numVertices, onGeometryChange }) {
96
+ pending = { feature, context: { ...context, numVertices }, onGeometryChange }
97
+ // Built-in rules first, synchronously — immediate feedback either gate.
98
+ recompute()
99
+ // A user rule (if any) decides last, throttled to one frame; its cached
100
+ // verdict (customInvalid) feeds the next recompute() once it lands.
101
+ if (typeof onGeometryChange === 'function' && frame == null) {
102
+ frame = requestFrame(runUserRule)
103
+ }
104
+ },
105
+ // Authoritative reset for starting a fresh draw session: clears cached
106
+ // custom validity and drops any pending frame so a previous session's
107
+ // verdict can't leak, and re-asserts both gates back to valid — through
108
+ // the same flip guards, so it only fires what actually changed.
109
+ reset () {
110
+ cancelPending()
111
+ customInvalid = false
112
+ customReason = null
113
+ flipStroke(false, null)
114
+ flipPlace(false, null)
115
+ },
116
+ destroy () {
117
+ cancelPending()
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,159 @@
1
+ import { createLiveDrawChecks } from './liveDrawChecks.js'
2
+
3
+ const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } })
4
+
5
+ // Closed-ring self-intersect only (LIVE_RULES fails, HARD_RULES/open-path passes) —
6
+ // only the implicit closing edge crosses.
7
+ const closingEdgeCrosses = poly([[0, 0], [2, 0], [0, 2], [2, 2]])
8
+ // Open-path self-intersect (HARD_RULES fails) — also fails LIVE_RULES, since a
9
+ // crossing open path implies a crossing closed one too.
10
+ const openPathCrosses = poly([[0, 0], [2, 2], [2, 0], [0, 2]])
11
+ const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]])
12
+
13
+ const setup = () => {
14
+ const onStrokeChange = jest.fn()
15
+ const onPlaceChange = jest.fn()
16
+ const checks = createLiveDrawChecks({ onStrokeChange, onPlaceChange })
17
+ return { onStrokeChange, onPlaceChange, checks }
18
+ }
19
+
20
+ beforeEach(() => jest.useFakeTimers())
21
+ afterEach(() => jest.useRealTimers())
22
+
23
+ describe('built-in rules (synchronous, independently floored, flip-guarded)', () => {
24
+ test('LIVE_RULES (closed ring) gates the stroke; HARD_RULES (open path) gates placement — independently', () => {
25
+ const { onStrokeChange, onPlaceChange, checks } = setup()
26
+ checks.update({ feature: closingEdgeCrosses, numVertices: 3 })
27
+ expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String)) // dashed
28
+ expect(onPlaceChange).not.toHaveBeenCalled() // still placeable — never flipped from its valid default
29
+ })
30
+
31
+ test('an open-path crossing vetoes placement too, since it also crosses closed', () => {
32
+ const { onStrokeChange, onPlaceChange, checks } = setup()
33
+ checks.update({ feature: openPathCrosses, numVertices: 3 })
34
+ expect(onStrokeChange).toHaveBeenCalledWith(true, expect.any(String))
35
+ expect(onPlaceChange).toHaveBeenCalledWith(true, expect.any(String))
36
+ })
37
+
38
+ test('a valid shape leaves both gates open, and flips them back once they were invalid', () => {
39
+ const { onStrokeChange, onPlaceChange, checks } = setup()
40
+ checks.update({ feature: openPathCrosses, numVertices: 3 }) // both invalid first
41
+ checks.update({ feature: square, numVertices: 3 }) // valid again — should flip back
42
+ expect(onStrokeChange).toHaveBeenLastCalledWith(false, null)
43
+ expect(onPlaceChange).toHaveBeenLastCalledWith(false, null)
44
+ })
45
+
46
+ test('LIVE_RULES stays floored below the minimum vertex count — never flips at all', () => {
47
+ const { onStrokeChange, checks } = setup()
48
+ checks.update({ feature: closingEdgeCrosses, numVertices: 1 })
49
+ expect(onStrokeChange).not.toHaveBeenCalled()
50
+ })
51
+
52
+ test('numVertices defaults to 0 when omitted from context', () => {
53
+ const { onStrokeChange, checks } = setup()
54
+ checks.update({ feature: closingEdgeCrosses })
55
+ expect(onStrokeChange).not.toHaveBeenCalled() // treated as 0 — below the floor
56
+ })
57
+
58
+ test('HARD_RULES is never floored — it must protect placing an invalid first vertex', () => {
59
+ const { onPlaceChange, checks } = setup()
60
+ // A single point can never self-intersect, so this stays placeable — the
61
+ // point is that placement is EVALUATED (not skipped) even at numVertices 0,
62
+ // proven by the user-callback tests below where a custom veto DOES apply here.
63
+ checks.update({ feature: poly([[0, 0]]), numVertices: 0 })
64
+ expect(onPlaceChange).not.toHaveBeenCalled() // stayed valid — no flip to report
65
+ })
66
+ })
67
+
68
+ describe('user callback (throttled, single call drives both gates)', () => {
69
+ test('runs ONCE per frame with phase preview, not once per gate', () => {
70
+ const { checks } = setup()
71
+ const onGeometryChange = jest.fn(() => true)
72
+ checks.update({ feature: square, numVertices: 3, onGeometryChange })
73
+ expect(onGeometryChange).not.toHaveBeenCalled() // deferred to the frame
74
+ jest.runAllTimers()
75
+ expect(onGeometryChange).toHaveBeenCalledTimes(1)
76
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: square, numVertices: 3, phase: 'preview' }))
77
+ })
78
+
79
+ test('a veto flips BOTH the stroke and placement gates from the one call', () => {
80
+ const { onStrokeChange, onPlaceChange, checks } = setup()
81
+ const onGeometryChange = () => ({ valid: false, reason: 'outside region' })
82
+ checks.update({ feature: square, numVertices: 3, onGeometryChange })
83
+ jest.runAllTimers()
84
+ expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region')
85
+ expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region')
86
+ })
87
+
88
+ test('runs even with zero committed vertices — a location-based rule is meaningful against the first candidate point', () => {
89
+ const { onPlaceChange, checks } = setup()
90
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' }))
91
+ checks.update({ feature: poly([[0, 0]]), numVertices: 0, onGeometryChange })
92
+ jest.runAllTimers()
93
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ numVertices: 0, phase: 'preview' }))
94
+ expect(onPlaceChange).toHaveBeenLastCalledWith(true, 'outside region')
95
+ })
96
+
97
+ test('a built-in HARD_RULES failure still applies even when the user callback passes', () => {
98
+ const { onPlaceChange, checks } = setup()
99
+ const onGeometryChange = jest.fn(() => true)
100
+ checks.update({ feature: openPathCrosses, numVertices: 3, onGeometryChange })
101
+ jest.runAllTimers()
102
+ expect(onPlaceChange).toHaveBeenLastCalledWith(true, expect.any(String))
103
+ })
104
+
105
+ test('runs once per frame using the latest geometry (trailing edge)', () => {
106
+ const { checks } = setup()
107
+ const onGeometryChange = jest.fn(() => true)
108
+ checks.update({ feature: square, numVertices: 3, onGeometryChange })
109
+ checks.update({ feature: square, numVertices: 4, onGeometryChange })
110
+ const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]])
111
+ checks.update({ feature: latest, numVertices: 5, onGeometryChange })
112
+ jest.runAllTimers()
113
+ expect(onGeometryChange).toHaveBeenCalledTimes(1)
114
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 }))
115
+ })
116
+ })
117
+
118
+ describe('flip guard', () => {
119
+ test('each gate fires only when its own state flips, not on every update', () => {
120
+ const { onStrokeChange, onPlaceChange, checks } = setup()
121
+ checks.update({ feature: openPathCrosses, numVertices: 3 })
122
+ checks.update({ feature: openPathCrosses, numVertices: 3 })
123
+ checks.update({ feature: openPathCrosses, numVertices: 3 })
124
+ expect(onStrokeChange).toHaveBeenCalledTimes(1)
125
+ expect(onPlaceChange).toHaveBeenCalledTimes(1)
126
+ })
127
+
128
+ test('reset() is a no-op for a gate that was never invalid', () => {
129
+ const { onStrokeChange, onPlaceChange, checks } = setup()
130
+ checks.update({ feature: square, numVertices: 3 }) // valid — neither gate ever flips
131
+ checks.reset()
132
+ expect(onStrokeChange).not.toHaveBeenCalled()
133
+ expect(onPlaceChange).not.toHaveBeenCalled()
134
+ })
135
+ })
136
+
137
+ describe('reset / destroy', () => {
138
+ test('reset() clears the cached custom verdict and drops a pending frame', () => {
139
+ const { onStrokeChange, checks } = setup()
140
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' }))
141
+ checks.update({ feature: square, numVertices: 3, onGeometryChange })
142
+ jest.runAllTimers()
143
+ expect(onStrokeChange).toHaveBeenLastCalledWith(true, 'outside region')
144
+
145
+ checks.reset()
146
+ onGeometryChange.mockClear()
147
+ checks.update({ feature: square, numVertices: 3 }) // fresh session, no callback this time
148
+ expect(onStrokeChange).toHaveBeenLastCalledWith(false, null)
149
+ })
150
+
151
+ test('destroy() cancels a pending user-rule frame', () => {
152
+ const { checks } = setup()
153
+ const onGeometryChange = jest.fn(() => true)
154
+ checks.update({ feature: square, numVertices: 3, onGeometryChange })
155
+ checks.destroy()
156
+ jest.runAllTimers()
157
+ expect(onGeometryChange).not.toHaveBeenCalled()
158
+ })
159
+ })
@@ -0,0 +1,85 @@
1
+ import { validateDisplayedGeometry } from './validateGeometry.js'
2
+
3
+ // Shared with liveDrawChecks.js — the same one-call-per-frame throttle for a
4
+ // user rule of unknown cost, used both for the invalid stroke and (there) the
5
+ // combined draw-mode live checks.
6
+ export const requestFrame = (cb) =>
7
+ (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(cb) : setTimeout(cb, 16))
8
+ export const cancelFrame = (id) =>
9
+ (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame(id) : clearTimeout(id))
10
+
11
+ /**
12
+ * Engine-agnostic driver for a live validity signal while drawing/editing —
13
+ * the invalid stroke by default, or (via `validate`) any other continuously
14
+ * re-evaluated verdict such as the Add-point placement gate.
15
+ *
16
+ * On every rubber-band/drag move the caller passes the displayed geometry (placed
17
+ * vertices + cursor). The default rules run synchronously for immediate feedback;
18
+ * a user `onGeometryChange` callback — whose cost is unknown — is throttled to one
19
+ * call per animation frame using the latest geometry (trailing edge), so an
20
+ * arbitrary user rule can't jank the drag. The default-fail path short-circuits
21
+ * (no user call) and cancels any pending frame so a stale evaluation can't flip
22
+ * the state back. `onChange(invalid, reason)` fires only when the state flips.
23
+ *
24
+ * @param {object} params
25
+ * @param {(invalid: boolean, reason: string|null) => void} params.onChange
26
+ * @param {Function} [params.validate] - (feature, context, config) => { valid, reason };
27
+ * defaults to validateDisplayedGeometry (the invalid-stroke rules)
28
+ * @returns {{ update: Function, set: Function, reset: Function, destroy: Function }}
29
+ */
30
+ export const createLiveStroke = ({ onChange, validate = validateDisplayedGeometry }) => {
31
+ let invalid = false
32
+ let frame = null
33
+ let pending = null
34
+
35
+ const cancelPending = () => {
36
+ if (frame != null) { cancelFrame(frame); frame = null }
37
+ pending = null
38
+ }
39
+
40
+ const flip = (next, reason) => {
41
+ if (next !== invalid) {
42
+ invalid = next
43
+ onChange(next, reason ?? null)
44
+ }
45
+ }
46
+
47
+ const runUserRule = () => {
48
+ frame = null
49
+ if (!pending) { return }
50
+ const { feature, context, onGeometryChange } = pending
51
+ const { valid, reason } = validate(feature, context, { onGeometryChange })
52
+ flip(!valid, reason)
53
+ }
54
+
55
+ return {
56
+ // Re-evaluate the displayed geometry after a rubber-band move.
57
+ update ({ feature, context = {}, numVertices, onGeometryChange }) {
58
+ const ctx = { ...context, numVertices }
59
+ // Default rules first, synchronously — immediate feedback on self-intersection / area.
60
+ const base = validate(feature, ctx)
61
+ if (!base.valid) { cancelPending(); flip(true, base.reason); return }
62
+ // Default rules pass — a user rule (if any) decides, throttled to one frame.
63
+ if (typeof onGeometryChange !== 'function') { cancelPending(); flip(false, null); return }
64
+ pending = { feature, context: ctx, onGeometryChange }
65
+ if (frame == null) { frame = requestFrame(runUserRule) }
66
+ },
67
+ // Authoritative external write — a committed verdict (events.js) or a mode
68
+ // reset. Applies through the same flip guard so the cached state always
69
+ // mirrors what is rendered, and drops any pending user-rule frame so a stale
70
+ // live evaluation can't overwrite it a frame later.
71
+ set (next, reason) {
72
+ cancelPending()
73
+ flip(next, reason ?? null)
74
+ },
75
+ // Re-assert the cached state through onChange unconditionally — for callers
76
+ // whose rendered output was reset behind the controller's back (e.g. a map
77
+ // style reload re-adding layers with their spec-default visibility).
78
+ refresh () {
79
+ onChange(invalid, null)
80
+ },
81
+ destroy () {
82
+ cancelPending()
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,150 @@
1
+ import { createLiveStroke } from './liveStroke.js'
2
+
3
+ const poly = (ring) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } })
4
+
5
+ const square = poly([[0, 0], [10, 0], [10, 10], [0, 10]])
6
+ const bowtie = poly([[0, 0], [10, 10], [10, 0], [0, 10]])
7
+
8
+ const setup = () => {
9
+ const onChange = jest.fn()
10
+ const stroke = createLiveStroke({ onChange })
11
+ return { onChange, stroke }
12
+ }
13
+
14
+ beforeEach(() => jest.useFakeTimers())
15
+ afterEach(() => jest.useRealTimers())
16
+
17
+ describe('default rules (synchronous)', () => {
18
+ test('a failing default rule flips dashed immediately with the reason', () => {
19
+ const { onChange, stroke } = setup()
20
+ stroke.update({ feature: bowtie, numVertices: 3 })
21
+ expect(onChange).toHaveBeenCalledWith(true, expect.stringMatching(/intersect/i))
22
+ })
23
+
24
+ test('going valid again flips back solid', () => {
25
+ const { onChange, stroke } = setup()
26
+ stroke.update({ feature: bowtie, numVertices: 3 })
27
+ stroke.update({ feature: square, numVertices: 3 })
28
+ expect(onChange).toHaveBeenLastCalledWith(false, null)
29
+ })
30
+
31
+ test('onChange fires only when the state flips, not on every update', () => {
32
+ const { onChange, stroke } = setup()
33
+ stroke.update({ feature: bowtie, numVertices: 3 })
34
+ stroke.update({ feature: bowtie, numVertices: 3 })
35
+ stroke.update({ feature: bowtie, numVertices: 3 })
36
+ expect(onChange).toHaveBeenCalledTimes(1)
37
+ })
38
+
39
+ test('below the minimum vertex count the shape is part-drawn — never dashed', () => {
40
+ const { onChange, stroke } = setup()
41
+ stroke.update({ feature: bowtie, numVertices: 2 })
42
+ expect(onChange).not.toHaveBeenCalled()
43
+ })
44
+
45
+ test('a default-rule failure never invokes the user callback', () => {
46
+ const { stroke } = setup()
47
+ const onGeometryChange = jest.fn()
48
+ stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange })
49
+ jest.runAllTimers()
50
+ expect(onGeometryChange).not.toHaveBeenCalled()
51
+ })
52
+ })
53
+
54
+ describe('user callback (throttled)', () => {
55
+ test('runs once per frame with the latest geometry (trailing edge)', () => {
56
+ const { stroke } = setup()
57
+ const onGeometryChange = jest.fn(() => true)
58
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange })
59
+ stroke.update({ feature: square, numVertices: 4, onGeometryChange })
60
+ const latest = poly([[0, 0], [20, 0], [20, 20], [0, 20]])
61
+ stroke.update({ feature: latest, numVertices: 5, onGeometryChange })
62
+ expect(onGeometryChange).not.toHaveBeenCalled() // nothing synchronous
63
+ jest.runAllTimers()
64
+ expect(onGeometryChange).toHaveBeenCalledTimes(1)
65
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature: latest, numVertices: 5 }))
66
+ })
67
+
68
+ test('a user-callback veto flips dashed with its reason', () => {
69
+ const { onChange, stroke } = setup()
70
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange: () => ({ valid: false, reason: 'outside region' }) })
71
+ jest.runAllTimers()
72
+ expect(onChange).toHaveBeenCalledWith(true, 'outside region')
73
+ })
74
+
75
+ test('a synchronous default failure cancels a pending user-rule frame', () => {
76
+ const { onChange, stroke } = setup()
77
+ const onGeometryChange = jest.fn(() => true)
78
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange })
79
+ stroke.update({ feature: bowtie, numVertices: 3, onGeometryChange }) // sync dashed
80
+ jest.runAllTimers()
81
+ expect(onGeometryChange).not.toHaveBeenCalled() // stale frame dropped
82
+ expect(onChange).toHaveBeenLastCalledWith(true, expect.any(String))
83
+ })
84
+
85
+ test('without a user callback a valid update settles solid immediately', () => {
86
+ const { onChange, stroke } = setup()
87
+ stroke.update({ feature: bowtie, numVertices: 3 })
88
+ stroke.update({ feature: square, numVertices: 3 })
89
+ expect(onChange).toHaveBeenLastCalledWith(false, null)
90
+ expect(jest.getTimerCount()).toBe(0)
91
+ })
92
+ })
93
+
94
+ describe('custom validate function', () => {
95
+ test('drives both the synchronous default pass and the throttled user pass', () => {
96
+ const onChange = jest.fn()
97
+ const validate = jest.fn((feature, context, config) =>
98
+ config?.onGeometryChange ? config.onGeometryChange(feature, context) : { valid: true })
99
+ const stroke = createLiveStroke({ onChange, validate })
100
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'vetoed' }))
101
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange })
102
+ expect(validate).toHaveBeenCalledWith(square, expect.objectContaining({ numVertices: 3 }))
103
+ jest.runAllTimers()
104
+ expect(onChange).toHaveBeenCalledWith(true, 'vetoed')
105
+ })
106
+ })
107
+
108
+ describe('set / reset / destroy', () => {
109
+ test('set() applies through the flip guard and drops any pending frame', () => {
110
+ const { onChange, stroke } = setup()
111
+ const onGeometryChange = jest.fn(() => true)
112
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange })
113
+ stroke.set(true, 'committed invalid')
114
+ expect(onChange).toHaveBeenCalledWith(true, 'committed invalid')
115
+ jest.runAllTimers()
116
+ expect(onGeometryChange).not.toHaveBeenCalled() // stale live frame dropped
117
+ onChange.mockClear()
118
+ stroke.set(true) // same state — guarded no-op
119
+ expect(onChange).not.toHaveBeenCalled()
120
+ })
121
+
122
+ test('set() keeps the cache in sync so the next live update flips correctly', () => {
123
+ const { onChange, stroke } = setup()
124
+ stroke.set(true)
125
+ onChange.mockClear()
126
+ stroke.update({ feature: square, numVertices: 3 }) // valid → back solid
127
+ expect(onChange).toHaveBeenCalledWith(false, null)
128
+ })
129
+
130
+ test('refresh() re-asserts the cached state unconditionally (style-reload resync)', () => {
131
+ const { onChange, stroke } = setup()
132
+ stroke.update({ feature: bowtie, numVertices: 3 })
133
+ onChange.mockClear()
134
+ stroke.refresh() // rendered output was reset externally — re-apply dashed
135
+ expect(onChange).toHaveBeenCalledWith(true, null)
136
+ stroke.update({ feature: square, numVertices: 3 })
137
+ onChange.mockClear()
138
+ stroke.refresh()
139
+ expect(onChange).toHaveBeenCalledWith(false, null)
140
+ })
141
+
142
+ test('destroy() cancels a pending user-rule frame', () => {
143
+ const { stroke } = setup()
144
+ const onGeometryChange = jest.fn(() => true)
145
+ stroke.update({ feature: square, numVertices: 3, onGeometryChange })
146
+ stroke.destroy()
147
+ jest.runAllTimers()
148
+ expect(onGeometryChange).not.toHaveBeenCalled()
149
+ })
150
+ })
@@ -0,0 +1,12 @@
1
+ import { nonZeroArea } from './rules.js'
2
+
3
+ // A separate file is required: this file-level @turf/area mock is hoisted above the
4
+ // import and applies to the whole file, so it can't live in rules.test.js without
5
+ // breaking the tests there that rely on the real area calculation. It covers the
6
+ // defensive catch in nonZeroArea (a turf failure is treated as "skip / valid").
7
+ jest.mock('@turf/area', () => ({ __esModule: true, default: jest.fn(() => { throw new Error('turf boom') }) }))
8
+
9
+ test('nonZeroArea treats a turf area failure as a skip (valid)', () => {
10
+ const feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1]]] } }
11
+ expect(nonZeroArea(feature)).toEqual({ valid: true })
12
+ })