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