@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,186 @@
1
+ import turfArea from '@turf/area'
2
+
3
+ /**
4
+ * Engine-agnostic geometry validation.
5
+ *
6
+ * A rule is a pure `(feature, context) => { valid, reason }`. Two classes:
7
+ * - SOFT_RULES gate the Done button (geometryValid) — the change is always
8
+ * kept, so a shape can pass through interim invalid states while being
9
+ * built or reshaped.
10
+ * - HARD_RULES gate vertex placement — a failure rejects the placement and
11
+ * the vertex never appears (used for unrecoverable states, e.g. a vertex
12
+ * that would force the drawn path to cross itself).
13
+ *
14
+ * `context` is `{ phase, vertexIndex, mode }` (phase: import('../adapterEvents.js').GeometryChangePhase)
15
+ * so rules can vary by phase or mode.
16
+ * Add a rule by appending it to SOFT_RULES or HARD_RULES.
17
+ */
18
+
19
+ // Minimum vertices for a finishable shape — the single source for every
20
+ // threshold check (rules, live gating, adapter finish conditions).
21
+ export const MIN_VERTICES = { Polygon: 3, LineString: 2 }
22
+ const MIN_INTERSECT_VERTICES = 4 // need 4+ vertices for two non-adjacent edges to exist
23
+
24
+ const getGeometry = (feature) => feature?.geometry ?? feature
25
+ const getPolygon = (feature) => {
26
+ const geometry = getGeometry(feature)
27
+ return geometry?.type === 'Polygon' ? geometry : null
28
+ }
29
+
30
+ // Distinct outer-ring vertices. Drops consecutive duplicates (zero-length edges —
31
+ // e.g. the rubber band sitting on the just-placed vertex) which would otherwise
32
+ // read as false intersections, then any explicit closing point so open
33
+ // (in-progress) and closed (finished) rings are handled the same way.
34
+ const getRingVertices = (geometry) => {
35
+ const raw = geometry.coordinates?.[0] ?? []
36
+ const ring = raw.filter((v, i) => i === 0 || v[0] !== raw[i - 1][0] || v[1] !== raw[i - 1][1])
37
+ while (ring.length > 1 &&
38
+ ring[0][0] === ring[ring.length - 1][0] &&
39
+ ring[0][1] === ring[ring.length - 1][1]) {
40
+ ring.pop()
41
+ }
42
+ return ring
43
+ }
44
+
45
+ const closeRing = (vertices) => [...vertices, vertices[0]]
46
+
47
+ // Signed area of the triangle (o, a, b); sign gives orientation, zero = collinear.
48
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
49
+
50
+ // True when collinear point q lies within the bounding box of segment p→r.
51
+ const onSegment = (p, q, r) =>
52
+ Math.min(p[0], r[0]) <= q[0] && q[0] <= Math.max(p[0], r[0]) &&
53
+ Math.min(p[1], r[1]) <= q[1] && q[1] <= Math.max(p[1], r[1])
54
+
55
+ // True when d1 and d2 lie on opposite sides (one strictly positive, one strictly negative).
56
+ const straddles = (d1, d2) => (d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)
57
+
58
+ // True when p is collinear with the segment (d === 0) and lies on it.
59
+ const collinearHit = (d, a, p, b) => d === 0 && onSegment(a, p, b)
60
+
61
+ // True when segments (p1,p2) and (p3,p4) intersect (including collinear overlap).
62
+ const segmentsIntersect = (p1, p2, p3, p4) => {
63
+ const d1 = cross(p3, p4, p1)
64
+ const d2 = cross(p3, p4, p2)
65
+ const d3 = cross(p1, p2, p3)
66
+ const d4 = cross(p1, p2, p4)
67
+
68
+ if (straddles(d1, d2) && straddles(d3, d4)) { return true }
69
+
70
+ return collinearHit(d1, p3, p1, p4) || collinearHit(d2, p3, p2, p4) ||
71
+ collinearHit(d3, p1, p3, p2) || collinearHit(d4, p1, p4, p2)
72
+ }
73
+
74
+ // Do any two non-adjacent edges cross? When `closed`, the implicit closing edge
75
+ // (last vertex back to the first) is included; when open, only the drawn edges are
76
+ // tested (used to reject a self-crossing vertex placement while drawing).
77
+ const edgesSelfIntersect = (vertices, closed) => {
78
+ const n = vertices.length
79
+ const edgeCount = closed ? n : n - 1
80
+ for (let i = 0; i < edgeCount; i++) {
81
+ for (let j = i + 1; j < edgeCount; j++) {
82
+ const adjacent = j === i + 1 || (closed && i === 0 && j === edgeCount - 1)
83
+ if (adjacent) { continue }
84
+ if (segmentsIntersect(vertices[i], vertices[(i + 1) % n], vertices[j], vertices[(j + 1) % n])) {
85
+ return true
86
+ }
87
+ }
88
+ }
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * HARD (draw placement): would the open drawn path cross itself? Run against the
94
+ * candidate path (placed vertices + the point about to be placed) — a failure
95
+ * rejects the placement so the vertex never appears and a genuine self-intersection
96
+ * can't be drawn forward.
97
+ */
98
+ const pathSelfIntersects = (feature) => {
99
+ const geometry = getPolygon(feature)
100
+ if (!geometry) { return false }
101
+ const vertices = getRingVertices(geometry)
102
+ if (vertices.length < MIN_INTERSECT_VERTICES) { return false }
103
+ return edgesSelfIntersect(vertices, false)
104
+ }
105
+
106
+ /** Rule-shaped wrapper for pathSelfIntersects (see HARD_RULES). */
107
+ export const noPathSelfIntersection = (feature) =>
108
+ pathSelfIntersects(feature)
109
+ ? { valid: false, reason: 'Shape must not intersect itself' }
110
+ : { valid: true }
111
+
112
+ /**
113
+ * A polygon must not self-intersect. Gates Done while drawing (a would-be-crossing
114
+ * closing edge disables Done) and while editing.
115
+ */
116
+ export const noSelfIntersection = (feature) => {
117
+ const geometry = getPolygon(feature)
118
+ if (!geometry) { return { valid: true } }
119
+ const vertices = getRingVertices(geometry)
120
+ if (vertices.length < MIN_INTERSECT_VERTICES) { return { valid: true } }
121
+ return edgesSelfIntersect(vertices, true)
122
+ ? { valid: false, reason: 'Shape must not intersect itself' }
123
+ : { valid: true }
124
+ }
125
+
126
+ /**
127
+ * A polygon must enclose a non-zero area (rejects collinear / degenerate rings).
128
+ */
129
+ export const nonZeroArea = (feature) => {
130
+ const geometry = getPolygon(feature)
131
+ if (!geometry) { return { valid: true } }
132
+
133
+ const vertices = getRingVertices(geometry)
134
+ if (vertices.length < MIN_VERTICES.Polygon) { return { valid: true } }
135
+
136
+ let area = 0
137
+ try {
138
+ area = turfArea({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [closeRing(vertices)] }, properties: {} })
139
+ } catch {
140
+ return { valid: true }
141
+ }
142
+
143
+ return area > 0 ? { valid: true } : { valid: false, reason: 'Shape must enclose an area' }
144
+ }
145
+
146
+ // minVertices' own reason strings, exported so callers (events.js's hint toast)
147
+ // can recognise "still being built" as distinct from a genuine rule violation —
148
+ // unlike self-intersection/zero-area/a custom onGeometryChange veto, an
149
+ // incomplete shape isn't a mistake worth interrupting the user over.
150
+ export const MIN_VERTICES_REASONS = {
151
+ Polygon: 'Shape needs at least 3 points',
152
+ LineString: 'Line needs at least 2 points'
153
+ }
154
+
155
+ /**
156
+ * A shape needs enough vertices to be finishable (3 for a polygon, 2 for a line).
157
+ */
158
+ export const minVertices = (feature) => {
159
+ const geometry = getGeometry(feature)
160
+ if (geometry?.type === 'Polygon') {
161
+ return getRingVertices(geometry).length >= MIN_VERTICES.Polygon
162
+ ? { valid: true }
163
+ : { valid: false, reason: MIN_VERTICES_REASONS.Polygon }
164
+ }
165
+ if (geometry?.type === 'LineString') {
166
+ return (geometry.coordinates?.length ?? 0) >= MIN_VERTICES.LineString
167
+ ? { valid: true }
168
+ : { valid: false, reason: MIN_VERTICES_REASONS.LineString }
169
+ }
170
+ return { valid: true }
171
+ }
172
+
173
+ // Validation rules. A failure disables the Done button (geometryValid).
174
+ // Order sets which reason surfaces first.
175
+ export const SOFT_RULES = [noSelfIntersection, nonZeroArea, minVertices]
176
+
177
+ // Rules that drive the live invalid stroke while drawing, run against the displayed
178
+ // geometry (placed vertices + cursor) on every rubber-band move. Everything soft
179
+ // EXCEPT minVertices — an incomplete shape is "part-drawn", not invalid.
180
+ // validateDisplayedGeometry applies the minimum-placed-vertex threshold instead.
181
+ export const LIVE_RULES = [noSelfIntersection, nonZeroArea]
182
+
183
+ // Placement rules. Run by validatePlacement against the candidate geometry
184
+ // (placed vertices + the point about to be placed); a failure rejects the
185
+ // placement outright — the vertex never appears.
186
+ export const HARD_RULES = [noPathSelfIntersection]
@@ -0,0 +1,120 @@
1
+ import { noSelfIntersection, nonZeroArea, minVertices, noPathSelfIntersection, SOFT_RULES, HARD_RULES, MIN_VERTICES_REASONS } from './rules.js'
2
+
3
+ const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } })
4
+ const line = (coordinates) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates } })
5
+
6
+ describe('noSelfIntersection', () => {
7
+ test('rejects a closed self-intersecting polygon', () => {
8
+ expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false)
9
+ })
10
+
11
+ test('rejects a shape whose closing edge crosses another edge', () => {
12
+ expect(noSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]])).valid).toBe(false)
13
+ })
14
+
15
+ test('detects a T-touch (collinear) crossing', () => {
16
+ // Edge (1,0)-(1,2) starts on the earlier edge (0,0)-(3,0): a collinear/touch hit.
17
+ expect(noSelfIntersection(poly([[0, 0], [3, 0], [1, 0], [1, 2]])).valid).toBe(false)
18
+ })
19
+
20
+ test('accepts a simple polygon', () => {
21
+ expect(noSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]))).toEqual({ valid: true })
22
+ })
23
+
24
+ test('skips non-polygons and short rings', () => {
25
+ expect(noSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true })
26
+ expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 0]]))).toEqual({ valid: true })
27
+ })
28
+ })
29
+
30
+ describe('nonZeroArea (soft)', () => {
31
+ test('rejects a collinear (zero-area) ring', () => {
32
+ const result = nonZeroArea(poly([[0, 0], [1, 0], [2, 0]]))
33
+ expect(result.valid).toBe(false)
34
+ expect(result.reason).toMatch(/area/i)
35
+ })
36
+
37
+ test('accepts a ring with area', () => {
38
+ expect(nonZeroArea(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true })
39
+ })
40
+
41
+ test('skips non-polygons and rings under three vertices', () => {
42
+ expect(nonZeroArea(line([[0, 0], [1, 1]]))).toEqual({ valid: true })
43
+ expect(nonZeroArea(poly([[0, 0], [1, 0]]))).toEqual({ valid: true })
44
+ })
45
+ // The defensive catch (a turf failure → skip) is covered in rules.areaError.test.js,
46
+ // which needs a file-level @turf/area mock that must not affect the real-area tests here.
47
+ })
48
+
49
+ describe('minVertices (soft)', () => {
50
+ test('requires three points for a polygon', () => {
51
+ expect(minVertices(poly([[0, 0], [1, 0]])).valid).toBe(false)
52
+ expect(minVertices(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true })
53
+ })
54
+
55
+ // events.js's hint toast recognises MIN_VERTICES_REASONS to skip hinting on an
56
+ // incomplete (still-being-drawn) shape — these must stay the actual reasons used.
57
+ test('uses the exported MIN_VERTICES_REASONS text', () => {
58
+ expect(minVertices(poly([[0, 0], [1, 0]])).reason).toBe(MIN_VERTICES_REASONS.Polygon)
59
+ expect(minVertices(line([[0, 0]])).reason).toBe(MIN_VERTICES_REASONS.LineString)
60
+ })
61
+
62
+ test('counts a closed ring by its distinct vertices', () => {
63
+ expect(minVertices(poly([[0, 0], [1, 0], [1, 1], [0, 0]]))).toEqual({ valid: true })
64
+ })
65
+
66
+ test('requires two points for a line', () => {
67
+ expect(minVertices(line([[0, 0]])).valid).toBe(false)
68
+ expect(minVertices(line([[0, 0], [1, 1]]))).toEqual({ valid: true })
69
+ })
70
+
71
+ test('skips geometry that is neither a polygon nor a line', () => {
72
+ expect(minVertices({ geometry: { type: 'Point', coordinates: [0, 0] } })).toEqual({ valid: true })
73
+ })
74
+ })
75
+
76
+ describe('noPathSelfIntersection (hard, draw placement)', () => {
77
+ test('rejects a self-crossing open drawn path with a reason', () => {
78
+ expect(noPathSelfIntersection(poly([[0, 0], [2, 2], [2, 0], [0, 2]])))
79
+ .toEqual({ valid: false, reason: expect.stringMatching(/intersect/i) })
80
+ })
81
+
82
+ test('accepts an open path that only closes into a crossing', () => {
83
+ // A bow-tie only crosses via its closing edge, which the open-path check ignores.
84
+ expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [0, 2], [2, 2]]))).toEqual({ valid: true })
85
+ })
86
+
87
+ test('accepts a simple open path', () => {
88
+ expect(noPathSelfIntersection(poly([[0, 0], [2, 0], [2, 2], [0, 2]]))).toEqual({ valid: true })
89
+ })
90
+
91
+ test('skips non-polygons and paths under four vertices', () => {
92
+ expect(noPathSelfIntersection(line([[0, 0], [1, 1]]))).toEqual({ valid: true })
93
+ expect(noPathSelfIntersection(poly([[0, 0], [1, 0], [1, 1]]))).toEqual({ valid: true })
94
+ })
95
+ })
96
+
97
+ describe('consecutive duplicate vertices (zero-length edges)', () => {
98
+ test('a rubber band sitting on the just-placed vertex is not an intersection', () => {
99
+ // 3 placed + duplicate rubber-band coord + closing point — the in-progress
100
+ // ring layout the instant after a vertex is placed.
101
+ expect(noSelfIntersection(poly([[0, 0], [10, 0], [10, 10], [10, 10], [0, 0]])))
102
+ .toEqual({ valid: true })
103
+ })
104
+
105
+ test('a real crossing is still detected when a duplicate vertex is present', () => {
106
+ expect(noSelfIntersection(poly([[0, 0], [1, 1], [1, 1], [1, 0], [0, 1], [0, 0]])).valid).toBe(false)
107
+ })
108
+ })
109
+
110
+ describe('SOFT_RULES', () => {
111
+ test('are the gating rules in reason-priority order', () => {
112
+ expect(SOFT_RULES).toEqual([noSelfIntersection, nonZeroArea, minVertices])
113
+ })
114
+ })
115
+
116
+ describe('HARD_RULES', () => {
117
+ test('are the placement-veto rules', () => {
118
+ expect(HARD_RULES).toEqual([noPathSelfIntersection])
119
+ })
120
+ })
@@ -0,0 +1,119 @@
1
+ import { SOFT_RULES, HARD_RULES, LIVE_RULES, MIN_VERTICES } from './rules.js'
2
+
3
+ /**
4
+ * Normalise a rule / callback result into `{ valid, reason }`.
5
+ *
6
+ * Supports three return shapes so a user `onGeometryChange` callback can stay
7
+ * terse:
8
+ * - `true` / `undefined` → valid
9
+ * - `false` → invalid (no reason)
10
+ * - `{ valid, reason }` → passed through
11
+ */
12
+ const normaliseResult = (result) => {
13
+ if (result === undefined || result === true) { return { valid: true } }
14
+ if (result === false) { return { valid: false, reason: null } }
15
+ return { valid: result.valid !== false, reason: result.reason }
16
+ }
17
+
18
+ /**
19
+ * Validate an in-progress geometry against the default rules and an optional user
20
+ * callback. Rules run first and short-circuit on the first failure; the callback
21
+ * runs last. Gates the Done button only — never reverts a vertex change.
22
+ *
23
+ * @param {object} feature - current GeoJSON feature
24
+ * @param {object} context - { phase: import('../adapterEvents.js').GeometryChangePhase, vertexIndex, mode }
25
+ * @param {object} [config]
26
+ * @param {Array<Function>} [config.rules] - defaults to DEFAULT_RULES; called as rule(feature, context)
27
+ * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange]
28
+ * - user callback, called as onGeometryChange({ feature, ...context }) — a single
29
+ * event object, unlike the internal rules above, matching the shape of the
30
+ * PLACEMENT_BLOCKED adapter event
31
+ * @returns {{ valid: boolean, reason?: string }}
32
+ */
33
+ export const validateGeometry = (feature, context = {}, config = {}) => {
34
+ const { rules = SOFT_RULES, onGeometryChange } = config
35
+
36
+ for (const rule of rules) {
37
+ const result = normaliseResult(rule(feature, context))
38
+ if (!result.valid) { return result }
39
+ }
40
+
41
+ if (typeof onGeometryChange === 'function') {
42
+ return normaliseResult(onGeometryChange({ feature, ...context }))
43
+ }
44
+
45
+ return { valid: true }
46
+ }
47
+
48
+ /**
49
+ * Validate a candidate vertex placement against the hard rules and the same
50
+ * optional user callback. A failure means the vertex is rejected outright and
51
+ * never appears. The callback receives `context.phase === 'place'`.
52
+ *
53
+ * @param {object} feature - candidate GeoJSON feature (placed vertices + new point)
54
+ * @param {object} context - { vertexIndex, mode }; phase is forced to 'place'
55
+ * @param {object} [config]
56
+ * @param {Array<Function>} [config.rules] - defaults to HARD_RULES
57
+ * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback
58
+ * @returns {{ valid: boolean, reason?: string }}
59
+ */
60
+ export const validatePlacement = (feature, context = {}, config = {}) => {
61
+ const { rules = HARD_RULES, onGeometryChange } = config
62
+ return validateGeometry(feature, { ...context, phase: 'place' }, { rules, onGeometryChange })
63
+ }
64
+
65
+ export const MODE_BY_GEOMETRY = { Polygon: 'draw_polygon', LineString: 'draw_line' }
66
+
67
+ /**
68
+ * Attempt to place a vertex — shared by both adapters. Builds the candidate
69
+ * geometry, validates it, and on a veto returns the PLACEMENT_BLOCKED payload
70
+ * for the caller to emit.
71
+ *
72
+ * @param {object} params
73
+ * @param {Array<Array<number>>} params.placed - committed vertex coordinates
74
+ * @param {Array<number>} params.point - the coordinate about to be placed
75
+ * @param {'Polygon'|'LineString'} params.geometryType
76
+ * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [params.onGeometryChange] - user callback
77
+ * @returns {{ valid: true } | { valid: false, blocked: object }}
78
+ */
79
+ export const attemptPlacement = ({ placed, point, geometryType, onGeometryChange }) => {
80
+ const candidate = [...placed, point]
81
+ const geometry = geometryType === 'Polygon'
82
+ ? { type: 'Polygon', coordinates: [candidate] }
83
+ : { type: 'LineString', coordinates: candidate }
84
+ const feature = { type: 'Feature', geometry, properties: {} }
85
+ const mode = MODE_BY_GEOMETRY[geometryType]
86
+ const { valid, reason } = validatePlacement(feature, { mode, vertexIndex: placed.length }, { onGeometryChange })
87
+ if (valid) { return { valid: true } }
88
+ return { valid: false, blocked: { feature, reason: reason ?? null, phase: 'place', mode, vertexIndex: placed.length } }
89
+ }
90
+
91
+ /**
92
+ * Validate the displayed (in-progress) geometry that drives the live invalid
93
+ * stroke: the placed vertices plus the current cursor point. The built-in live
94
+ * rules (self-intersection/area) are meaningless below the geometry type's
95
+ * minimum vertex count — there isn't a real ring/line yet — so they're skipped
96
+ * entirely until then; a caller's own `onGeometryChange` has no such floor
97
+ * (numVertices defaults to 0 via `context.numVertices ?? 0`, so it still runs).
98
+ *
99
+ * Used directly by edit mode's live-stroke check (there's no Add-point gate to
100
+ * combine with there, so it's a self-contained call). Draw mode does NOT use
101
+ * this — see liveDrawChecks.js, which combines this same LIVE_RULES set with a
102
+ * separate HARD_RULES-based placement check, both driven from a single shared
103
+ * `onGeometryChange` call rather than one call each.
104
+ *
105
+ * @param {object} feature - displayed GeoJSON feature (placed vertices + cursor)
106
+ * @param {object} context - { mode, numVertices, phase }; phase defaults to 'preview'
107
+ * @param {object} [config]
108
+ * @param {Array<Function>} [config.rules] - defaults to LIVE_RULES
109
+ * @param {function(import('../adapterEvents.js').GeometryChangeEvent): (boolean|{valid: boolean, reason?: string}|undefined)} [config.onGeometryChange] - user callback
110
+ * @returns {{ valid: boolean, reason?: string }}
111
+ */
112
+ export const validateDisplayedGeometry = (feature, context = {}, config = {}) => {
113
+ const { rules = LIVE_RULES, onGeometryChange } = config
114
+ const type = feature?.geometry?.type ?? feature?.type
115
+ const min = MIN_VERTICES[type] ?? 0
116
+ const numVertices = context.numVertices ?? 0
117
+ const effectiveRules = numVertices < min ? [] : rules
118
+ return validateGeometry(feature, { ...context, phase: context.phase ?? 'preview' }, { rules: effectiveRules, onGeometryChange })
119
+ }
@@ -0,0 +1,200 @@
1
+ import { validateGeometry, validatePlacement, attemptPlacement, validateDisplayedGeometry } from './validateGeometry.js'
2
+
3
+ const poly = (coordinates) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coordinates] } })
4
+
5
+ const square = poly([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]])
6
+ const bowtie = poly([[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]])
7
+ const collinear = poly([[0, 0], [1, 0], [2, 0], [0, 0]])
8
+ const twoPoints = poly([[0, 0], [1, 0]])
9
+
10
+ describe('validateGeometry (soft gating)', () => {
11
+ test('a valid polygon passes', () => {
12
+ expect(validateGeometry(square)).toEqual({ valid: true })
13
+ })
14
+
15
+ test('a self-intersecting polygon fails', () => {
16
+ const result = validateGeometry(bowtie)
17
+ expect(result.valid).toBe(false)
18
+ expect(result.reason).toMatch(/intersect/i)
19
+ })
20
+
21
+ test('a zero-area polygon fails', () => {
22
+ expect(validateGeometry(collinear).reason).toMatch(/area/i)
23
+ })
24
+
25
+ test('too few vertices fails', () => {
26
+ expect(validateGeometry(twoPoints).reason).toMatch(/points/i)
27
+ })
28
+
29
+ test('short-circuits on the first failing rule', () => {
30
+ const first = jest.fn(() => ({ valid: false, reason: 'first' }))
31
+ const second = jest.fn(() => ({ valid: true }))
32
+ expect(validateGeometry(square, {}, { rules: [first, second] })).toEqual({ valid: false, reason: 'first' })
33
+ expect(second).not.toHaveBeenCalled()
34
+ })
35
+
36
+ test('passes the context to rules as (feature, context), and to the callback as a single flattened event object', () => {
37
+ const rule = jest.fn(() => ({ valid: true }))
38
+ const onGeometryChange = jest.fn(() => ({ valid: true }))
39
+ const context = { phase: 'commit-move', vertexIndex: 2, mode: 'edit_vertex' }
40
+ validateGeometry(square, context, { rules: [rule], onGeometryChange })
41
+ expect(rule).toHaveBeenCalledWith(square, context)
42
+ expect(onGeometryChange).toHaveBeenCalledWith({ feature: square, ...context })
43
+ })
44
+
45
+ test('runs the user callback after the rules pass', () => {
46
+ expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => false }))
47
+ .toEqual({ valid: false, reason: null })
48
+ expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => true }))
49
+ .toEqual({ valid: true })
50
+ expect(validateGeometry(square, {}, { rules: [], onGeometryChange: () => ({ valid: false, reason: 'too big' }) }))
51
+ .toEqual({ valid: false, reason: 'too big' })
52
+ })
53
+
54
+ test('does not run the callback when a rule fails', () => {
55
+ const onGeometryChange = jest.fn()
56
+ validateGeometry(square, {}, { rules: [() => ({ valid: false })], onGeometryChange })
57
+ expect(onGeometryChange).not.toHaveBeenCalled()
58
+ })
59
+
60
+ test('is valid with no rules and no callback', () => {
61
+ expect(validateGeometry(square, {}, { rules: [] })).toEqual({ valid: true })
62
+ })
63
+ })
64
+
65
+ describe('attemptPlacement (shared engine gate)', () => {
66
+ const placedL = [[0, 0], [1, 1], [1, 0]] // adding (0,1) makes the open path cross
67
+
68
+ test('a legal placement passes with no payload', () => {
69
+ expect(attemptPlacement({ placed: [[0, 0], [1, 0], [1, 1]], point: [0, 1], geometryType: 'Polygon' }))
70
+ .toEqual({ valid: true })
71
+ })
72
+
73
+ test('a self-crossing placement is vetoed with the PLACEMENT_BLOCKED payload', () => {
74
+ const result = attemptPlacement({ placed: placedL, point: [0, 1], geometryType: 'Polygon' })
75
+ expect(result.valid).toBe(false)
76
+ expect(result.blocked).toEqual({
77
+ feature: expect.objectContaining({ type: 'Feature' }),
78
+ reason: expect.stringMatching(/intersect/i),
79
+ phase: 'place',
80
+ mode: 'draw_polygon',
81
+ vertexIndex: 3
82
+ })
83
+ })
84
+
85
+ test('the user callback can veto, with mode derived from the geometry type', () => {
86
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' }))
87
+ const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange })
88
+ expect(result.blocked).toEqual(expect.objectContaining({ mode: 'draw_line', reason: 'outside region', vertexIndex: 1 }))
89
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ phase: 'place', mode: 'draw_line', vertexIndex: 1 }))
90
+ })
91
+
92
+ test('a veto with no reason falls back to null in the PLACEMENT_BLOCKED payload', () => {
93
+ const onGeometryChange = jest.fn(() => false) // false → invalid, no reason (see normaliseResult)
94
+ const result = attemptPlacement({ placed: [[0, 0]], point: [1, 1], geometryType: 'LineString', onGeometryChange })
95
+ expect(result.blocked.reason).toBeNull()
96
+ })
97
+
98
+ test('attemptPlacement mode is set correctly for Polygon vs LineString', () => {
99
+ // Both legal and illegal placements should have the correct mode set
100
+ const polygonLegal = attemptPlacement({ placed: [[0, 0], [1, 0]], point: [1, 1], geometryType: 'Polygon' })
101
+ expect(polygonLegal).toEqual({ valid: true })
102
+ // Test a Polygon placement that would cross
103
+ const polygonCrossing = attemptPlacement({ placed: [[0, 0], [2, 2], [2, 0]], point: [0, 2], geometryType: 'Polygon' })
104
+ expect(polygonCrossing.blocked?.mode).toBe('draw_polygon')
105
+ })
106
+ })
107
+
108
+ describe('validateDisplayedGeometry edge cases', () => {
109
+ test('handles unknown geometry types with fallback min vertices', () => {
110
+ const result = validateDisplayedGeometry({ type: 'Feature', geometry: { type: 'Unknown', coordinates: [] } }, { numVertices: 0 })
111
+ expect(result.valid).toBe(true) // unknown types use MIN_VERTICES fallback of 0, so 0 placed = valid
112
+ })
113
+
114
+ test('feature without geometry type defaults to context.phase', () => {
115
+ const result = validateDisplayedGeometry({ type: 'Feature', geometry: { coordinates: [[0, 0]] } }, { numVertices: 2, phase: 'custom' })
116
+ expect(typeof result).toBe('object')
117
+ expect(result).toHaveProperty('valid')
118
+ })
119
+
120
+ test('context without numVertices defaults to 0 for min-vertex check', () => {
121
+ const result = validateDisplayedGeometry(poly([[0, 0]]), {})
122
+ expect(result.valid).toBe(true) // no numVertices = 0, below any min, so valid
123
+ })
124
+
125
+ test('context and config are both optional — called with just a feature', () => {
126
+ expect(validateDisplayedGeometry(poly([[0, 0]]))).toEqual({ valid: true })
127
+ })
128
+
129
+ test('calls the caller\'s own onGeometryChange once at/above the vertex threshold, with a single flattened event object', () => {
130
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'too few for my rule' }))
131
+ // Polygon minimum is 3 placed vertices — this feature/numVertices pair is at it.
132
+ const feature = poly([[0, 0], [1, 0], [1, 1]])
133
+
134
+ const result = validateDisplayedGeometry(feature, { numVertices: 3 }, { onGeometryChange })
135
+
136
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 3, phase: 'preview' }))
137
+ expect(result).toEqual({ valid: false, reason: 'too few for my rule' })
138
+ })
139
+
140
+ test('skips the built-in rules with zero committed vertices, but still runs the caller\'s onGeometryChange — a location-based rule is meaningful against the very first candidate point', () => {
141
+ const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' }))
142
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' }))
143
+ const feature = poly([[0, 0]])
144
+
145
+ const result = validateDisplayedGeometry(feature, { numVertices: 0 }, { rules: [failingRule], onGeometryChange })
146
+
147
+ expect(failingRule).not.toHaveBeenCalled()
148
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 0, phase: 'preview' }))
149
+ expect(result).toEqual({ valid: false, reason: 'outside region' })
150
+ })
151
+
152
+ test('runs the caller\'s onGeometryChange from the first committed vertex, even while the built-in rules are still skipped', () => {
153
+ const failingRule = jest.fn(() => ({ valid: false, reason: 'should not run' }))
154
+ // A single committed vertex — well below the Polygon minimum of 3 — so the
155
+ // built-in self-intersection/area rules stay skipped, but a per-point user
156
+ // rule (e.g. "is this inside a region?") is meaningful and should still run.
157
+ const onGeometryChange = jest.fn(() => ({ valid: false, reason: 'outside region' }))
158
+ const feature = poly([[0, 0], [1, 1]])
159
+
160
+ const result = validateDisplayedGeometry(feature, { numVertices: 1 }, { rules: [failingRule], onGeometryChange })
161
+
162
+ expect(failingRule).not.toHaveBeenCalled()
163
+ expect(onGeometryChange).toHaveBeenCalledWith(expect.objectContaining({ feature, numVertices: 1, phase: 'preview' }))
164
+ expect(result).toEqual({ valid: false, reason: 'outside region' })
165
+ })
166
+ })
167
+
168
+ describe('validatePlacement (hard gating)', () => {
169
+ // The candidate is the open drawn path plus the point about to be placed.
170
+ const crossingPath = poly([[0, 0], [1, 1], [1, 0], [0, 1]])
171
+ const simplePath = poly([[0, 0], [1, 0], [1, 1], [0, 1]])
172
+
173
+ test('rejects a candidate whose open path self-crosses, with a reason', () => {
174
+ const result = validatePlacement(crossingPath)
175
+ expect(result.valid).toBe(false)
176
+ expect(result.reason).toMatch(/intersect/i)
177
+ })
178
+
179
+ test('passes a simple candidate', () => {
180
+ expect(validatePlacement(simplePath)).toEqual({ valid: true })
181
+ })
182
+
183
+ test('forces phase "place" into the rule and callback context', () => {
184
+ const onGeometryChange = jest.fn(() => true)
185
+ validatePlacement(simplePath, { mode: 'draw_polygon', vertexIndex: 4 }, { onGeometryChange })
186
+ expect(onGeometryChange).toHaveBeenCalledWith({ feature: simplePath, phase: 'place', mode: 'draw_polygon', vertexIndex: 4 })
187
+ })
188
+
189
+ test('the user callback can veto a placement with a reason', () => {
190
+ const onGeometryChange = () => ({ valid: false, reason: 'outside region' })
191
+ expect(validatePlacement(simplePath, {}, { onGeometryChange }))
192
+ .toEqual({ valid: false, reason: 'outside region' })
193
+ })
194
+
195
+ test('hard rules run before the user callback', () => {
196
+ const onGeometryChange = jest.fn()
197
+ validatePlacement(crossingPath, {}, { onGeometryChange })
198
+ expect(onGeometryChange).not.toHaveBeenCalled()
199
+ })
200
+ })