@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.
- package/assets/templates/draw-tools.njk +4 -3
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +32 -0
- package/docs/examples/draw-tools.mdx +0 -4
- package/docs/plugins/draw.md +482 -0
- package/docs/plugins.md +2 -6
- package/govuk-prototype-kit.config.json +3 -2
- package/package.json +2 -4
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/css/index.css +1 -97
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
- package/plugins/draw/dist/esm/index.js +1 -0
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
- package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
- package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
- package/plugins/draw/dist/umd/index.js +2 -0
- package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
- package/plugins/draw/src/DrawInit.jsx +89 -0
- package/plugins/draw/src/DrawInit.test.jsx +184 -0
- package/plugins/draw/src/adapterEvents.js +127 -0
- package/plugins/draw/src/adapterEvents.test.js +20 -0
- package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
- package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
- package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
- package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
- package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
- package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
- package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
- package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
- package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
- package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
- package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
- package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
- package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
- package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
- package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
- package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
- package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
- package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
- package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
- package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
- package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
- package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
- package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
- package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
- package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
- package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
- package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
- package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
- package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
- package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
- package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
- package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
- package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
- package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
- package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
- package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
- package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
- package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
- package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
- package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
- package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
- package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
- package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
- package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
- package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
- package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
- package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
- package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
- package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
- package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
- package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
- package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
- package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
- package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
- package/plugins/draw/src/api/addFeature.js +22 -0
- package/plugins/draw/src/api/addFeature.test.js +41 -0
- package/plugins/draw/src/api/deleteFeature.js +11 -0
- package/plugins/draw/src/api/deleteFeature.test.js +24 -0
- package/plugins/draw/src/api/editFeature.js +54 -0
- package/plugins/draw/src/api/editFeature.test.js +134 -0
- package/plugins/draw/src/api/merge.js +30 -0
- package/plugins/draw/src/api/merge.test.js +57 -0
- package/plugins/draw/src/api/newLine.js +41 -0
- package/plugins/draw/src/api/newLine.test.js +93 -0
- package/plugins/draw/src/api/newPolygon.js +41 -0
- package/plugins/draw/src/api/newPolygon.test.js +93 -0
- package/plugins/draw/src/api/split.js +117 -0
- package/plugins/draw/src/api/split.test.js +204 -0
- package/plugins/draw/src/defaults.js +52 -0
- package/plugins/draw/src/defaults.test.js +18 -0
- package/plugins/draw/src/draw.scss +43 -0
- package/plugins/draw/src/events.js +237 -0
- package/plugins/draw/src/events.test.js +369 -0
- package/plugins/draw/src/index.js +10 -0
- package/plugins/draw/src/index.test.js +24 -0
- package/plugins/draw/src/manifest.js +170 -0
- package/plugins/draw/src/manifest.test.js +147 -0
- package/plugins/draw/src/reducer.js +69 -0
- package/plugins/draw/src/reducer.test.js +85 -0
- package/plugins/draw/src/utils/debounce.js +16 -0
- package/plugins/draw/src/utils/debounce.test.js +40 -0
- package/plugins/draw/src/utils/eventBus.js +36 -0
- package/plugins/draw/src/utils/eventBus.test.js +47 -0
- package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
- package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
- package/plugins/draw/src/utils/getValueForStyle.js +33 -0
- package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
- package/plugins/draw/src/utils/resolveColors.js +39 -0
- package/plugins/draw/src/utils/resolveColors.test.js +34 -0
- package/plugins/draw/src/utils/spatial.js +243 -0
- package/plugins/draw/src/utils/spatial.test.js +243 -0
- package/plugins/draw/src/utils/touchTarget.js +95 -0
- package/plugins/draw/src/utils/touchTarget.test.js +107 -0
- package/plugins/draw/src/utils/undoStack.js +31 -0
- package/plugins/draw/src/utils/undoStack.test.js +49 -0
- package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
- package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
- package/plugins/draw/src/validation/liveStroke.js +85 -0
- package/plugins/draw/src/validation/liveStroke.test.js +150 -0
- package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
- package/plugins/draw/src/validation/rules.js +186 -0
- package/plugins/draw/src/validation/rules.test.js +120 -0
- package/plugins/draw/src/validation/validateGeometry.js +119 -0
- package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
- package/plugins/interact/src/utils/buildStylesMap.js +1 -0
- package/plugins/search/dist/css/index.css +1 -317
- package/rollup.esm.mjs +35 -28
- package/sonar-project.properties +2 -2
- package/src/App/components/Attributions/Attributions.module.scss +1 -0
- package/src/App/components/Hints/Hints.module.scss +5 -4
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
- package/src/App/components/MoveControl/MoveControl.jsx +73 -9
- package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
- package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
- package/src/App/hooks/useHintsAPI.js +67 -0
- package/src/App/hooks/useHintsAPI.test.js +145 -0
- package/src/App/renderer/PluginInits.jsx +4 -0
- package/src/App/renderer/PluginInits.test.jsx +7 -1
- package/src/InteractiveMap/InteractiveMap.js +20 -0
- package/src/InteractiveMap/InteractiveMap.test.js +7 -0
- package/src/config/events.js +4 -0
- package/src/scss/settings/_dimensions.scss +3 -0
- package/src/services/announcer.js +38 -3
- package/src/services/announcer.test.js +133 -1
- package/src/services/hints.js +3 -0
- package/src/services/hints.test.js +19 -0
- package/src/types.js +12 -0
- package/src/utils/detectInterfaceType.js +5 -1
- package/src/utils/isMac.js +15 -0
- package/src/utils/isMac.test.js +37 -0
- 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
|
+
})
|