@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,232 @@
|
|
|
1
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
2
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
3
|
+
import VectorSource from 'ol/source/Vector.js'
|
|
4
|
+
import { createSnapEngine } from './snapEngine.js'
|
|
5
|
+
import { polygonFeature } from '../__helpers__/harness.js'
|
|
6
|
+
|
|
7
|
+
// One tile covering [0,0]–[4096,4096]: band for clip artefacts = 256 units from an
|
|
8
|
+
// edge, axis-alignment tolerance = 2 units. Resolution 1 → 12px radius = 12 units.
|
|
9
|
+
const tileGrid = {
|
|
10
|
+
getZForResolution: jest.fn(() => 10),
|
|
11
|
+
getTileCoordForCoordAndZ: jest.fn(() => [10, 0, 0]),
|
|
12
|
+
getTileCoordExtent: jest.fn(() => [0, 0, 4096, 4096])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const createEngineMap = () => {
|
|
16
|
+
const layers = []
|
|
17
|
+
return {
|
|
18
|
+
layers,
|
|
19
|
+
getLayers: () => ({ forEach: (cb) => layers.forEach(cb) }),
|
|
20
|
+
getView: () => ({ getResolution: () => 1, getProjection: () => 'EPSG:3857' }),
|
|
21
|
+
getPixelFromCoordinate: jest.fn((c) => [c[0], c[1]]),
|
|
22
|
+
forEachFeatureAtPixel: jest.fn()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const renderFeature = (type, flat, ends = null, mapboxLayer = { id: 'boundaries', type: 'line' }) => ({
|
|
27
|
+
getType: () => type,
|
|
28
|
+
getFlatCoordinates: () => flat,
|
|
29
|
+
getEnds: () => ends,
|
|
30
|
+
get: (key) => (key === 'mapbox-layer' ? mapboxLayer : undefined)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// map.forEachFeatureAtPixel serves two callers: candidate iteration (hitTolerance =
|
|
34
|
+
// snap radius) and the invisible-fill probe (hitTolerance 2, returns the callback result)
|
|
35
|
+
const setupVT = ({ features, withTileGrid = true, fillNeighbour = false }) => {
|
|
36
|
+
const map = createEngineMap()
|
|
37
|
+
const vtLayer = new VectorTileLayer({})
|
|
38
|
+
if (withTileGrid) {
|
|
39
|
+
jest.spyOn(vtLayer, 'getSource').mockReturnValue({ getTileGrid: () => tileGrid, getProjection: () => null })
|
|
40
|
+
}
|
|
41
|
+
map.layers.push(vtLayer)
|
|
42
|
+
map.forEachFeatureAtPixel.mockImplementation((pixel, visit, opts) => {
|
|
43
|
+
// Honour the layerFilter the way OL does — only tile layers are consulted
|
|
44
|
+
if (!opts.layerFilter(vtLayer)) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
if (opts.hitTolerance === 2) {
|
|
48
|
+
return fillNeighbour ? visit({ get: () => ({ id: 'fills' }) }) : undefined
|
|
49
|
+
}
|
|
50
|
+
features.forEach((f) => visit(f, vtLayer))
|
|
51
|
+
return undefined
|
|
52
|
+
})
|
|
53
|
+
return { map, engine: createSnapEngine(map, ['boundaries', 'fills']) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => jest.clearAllMocks())
|
|
57
|
+
|
|
58
|
+
describe('plain vector layers', () => {
|
|
59
|
+
test('snaps to features near the query, skipping layers without a source', () => {
|
|
60
|
+
const map = createEngineMap()
|
|
61
|
+
const source = new VectorSource()
|
|
62
|
+
source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]]))
|
|
63
|
+
const engine = createSnapEngine(map, [new VectorLayer({ source }), new VectorLayer({})])
|
|
64
|
+
expect(engine.query([98, 2], 12)).toEqual({ type: 'vertex', coord: [100, 0] })
|
|
65
|
+
expect(engine.query([500, 500], 12)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('setLayers swaps targets at runtime and ignores unsupported entries', () => {
|
|
69
|
+
const map = createEngineMap()
|
|
70
|
+
const source = new VectorSource()
|
|
71
|
+
source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]]))
|
|
72
|
+
const engine = createSnapEngine(map, [new VectorLayer({ source }), 42])
|
|
73
|
+
expect(engine.query([98, 2], 12)).not.toBeNull()
|
|
74
|
+
engine.setLayers([])
|
|
75
|
+
expect(engine.query([98, 2], 12)).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('no resolution (map not ready) means no snapping', () => {
|
|
79
|
+
const map = createEngineMap()
|
|
80
|
+
map.getView = () => ({ getResolution: () => undefined })
|
|
81
|
+
expect(createSnapEngine(map, ['boundaries']).query([0, 0], 12)).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('defaults to an empty snap-layer set when none are configured', () => {
|
|
85
|
+
const map = createEngineMap()
|
|
86
|
+
expect(createSnapEngine(map).query([0, 0], 12)).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('setLayers tolerates a null layer list', () => {
|
|
90
|
+
const map = createEngineMap()
|
|
91
|
+
const source = new VectorSource()
|
|
92
|
+
source.addFeature(polygonFeature([[0, 0], [100, 0], [100, 100], [0, 0]]))
|
|
93
|
+
const engine = createSnapEngine(map, [new VectorLayer({ source })])
|
|
94
|
+
expect(engine.query([98, 2], 12)).not.toBeNull()
|
|
95
|
+
engine.setLayers(null)
|
|
96
|
+
expect(engine.query([98, 2], 12)).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('vector-tile layers', () => {
|
|
101
|
+
test('snaps to configured tile layers only, mid-tile axis alignment is fine', () => {
|
|
102
|
+
// Horizontal segment mid-tile: axis-aligned but nowhere near a tile edge → real geometry
|
|
103
|
+
const { engine } = setupVT({
|
|
104
|
+
features: [
|
|
105
|
+
renderFeature('LineString', [500, 2000, 1500, 2000]),
|
|
106
|
+
renderFeature('LineString', [590, 2000, 610, 2000], null, { id: 'not-configured', type: 'line' })
|
|
107
|
+
]
|
|
108
|
+
})
|
|
109
|
+
expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('without any tile layer on the map, tile snapping is skipped entirely', () => {
|
|
113
|
+
const map = createEngineMap()
|
|
114
|
+
const engine = createSnapEngine(map, ['boundaries'])
|
|
115
|
+
expect(engine.query([600, 2005], 12)).toBeNull()
|
|
116
|
+
expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('clip artefacts are rejected: axis-aligned segments hugging a tile edge', () => {
|
|
120
|
+
// Vertical segment at x=1, well inside the 256-unit band of the tile's left edge
|
|
121
|
+
const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] })
|
|
122
|
+
expect(engine.query([5, 1000], 12)).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('clip-cut vertices are rejected while their real edges survive', () => {
|
|
126
|
+
// The vertex at [1,1000] ends a clip segment (vertical along x=1); the horizontal
|
|
127
|
+
// edge leaving it at y=1000 is real geometry. The vertex would normally beat the
|
|
128
|
+
// edge — filtering it lets the edge win.
|
|
129
|
+
const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1000, 800, 1000])] })
|
|
130
|
+
expect(engine.query([6, 1004], 12)).toMatchObject({ type: 'edge' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('without a tile grid the clip filter degrades gracefully and keeps candidates', () => {
|
|
134
|
+
const { engine } = setupVT({
|
|
135
|
+
features: [renderFeature('LineString', [1, 500, 1, 1500])],
|
|
136
|
+
withTileGrid: false
|
|
137
|
+
})
|
|
138
|
+
expect(engine.query([5, 1000], 12)).toEqual({ type: 'edge', coord: [1, 1000] })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('the tile boundary state is computed once per layer per query', () => {
|
|
142
|
+
const { engine } = setupVT({
|
|
143
|
+
features: [
|
|
144
|
+
renderFeature('LineString', [500, 2000, 1500, 2000]),
|
|
145
|
+
renderFeature('LineString', [500, 2020, 1500, 2020])
|
|
146
|
+
]
|
|
147
|
+
})
|
|
148
|
+
engine.query([600, 2005], 12)
|
|
149
|
+
expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('the boundary state is reused across multiple candidates from the same tile layer', () => {
|
|
153
|
+
const { engine } = setupVT({
|
|
154
|
+
features: [
|
|
155
|
+
renderFeature('LineString', [500, 2000, 1500, 2000]),
|
|
156
|
+
renderFeature('LineString', [500, 2005, 1500, 2005])
|
|
157
|
+
]
|
|
158
|
+
})
|
|
159
|
+
expect(engine.query([600, 2002], 12)).not.toBeNull() // both features yield a candidate
|
|
160
|
+
expect(tileGrid.getZForResolution).toHaveBeenCalledTimes(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('open-line endpoints test only the neighbour that exists', () => {
|
|
164
|
+
// Endpoint [2000,2000] has a null previous neighbour; the clip filter must skip it
|
|
165
|
+
const { engine } = setupVT({ features: [renderFeature('LineString', [2000, 2000, 3000, 2000])] })
|
|
166
|
+
expect(engine.query([2002, 2001], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('a tile point candidate without adjacency data snaps as a vertex', () => {
|
|
170
|
+
const { engine } = setupVT({ features: [renderFeature('Point', [2000, 2000])] })
|
|
171
|
+
expect(engine.query([2001, 2000], 12)).toEqual({ type: 'vertex', coord: [2000, 2000] })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('non-tile layers present on the map are ignored when gathering tile layers', () => {
|
|
175
|
+
const { engine, map } = setupVT({ features: [renderFeature('LineString', [500, 2000, 1500, 2000])] })
|
|
176
|
+
map.layers.unshift(new VectorLayer({})) // a non-tile layer sits in the stack
|
|
177
|
+
expect(engine.query([600, 2005], 12)).toEqual({ type: 'edge', coord: [600, 2000] })
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('invisible shared fill boundaries', () => {
|
|
182
|
+
const fillSquare = renderFeature(
|
|
183
|
+
'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' })
|
|
184
|
+
|
|
185
|
+
test('an edge with the same fill on both sides is skipped; a visible outer edge snaps', () => {
|
|
186
|
+
const invisible = setupVT({ features: [fillSquare], fillNeighbour: true })
|
|
187
|
+
expect(invisible.engine.query([2000, 995], 12)).toBeNull()
|
|
188
|
+
|
|
189
|
+
const visible = setupVT({ features: [fillSquare], fillNeighbour: false })
|
|
190
|
+
expect(visible.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('a cursor already on the edge, or an unprojectable probe, skips the direction check', () => {
|
|
194
|
+
const onEdge = setupVT({ features: [fillSquare], fillNeighbour: true })
|
|
195
|
+
expect(onEdge.engine.query([2000, 1000], 12)).toEqual({ type: 'edge', coord: [2000, 1000] })
|
|
196
|
+
|
|
197
|
+
const blindProbe = setupVT({ features: [fillSquare], fillNeighbour: true })
|
|
198
|
+
blindProbe.map.getPixelFromCoordinate.mockReturnValueOnce([2000, 995]).mockReturnValueOnce(null)
|
|
199
|
+
expect(blindProbe.engine.query([2000, 995], 12)).toEqual({ type: 'edge', coord: [2000, 1000] })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('debug logging traces queries, candidates and filtering when enabled', () => {
|
|
204
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
205
|
+
globalThis.DEBUG_SNAP_VISIBILITY = true
|
|
206
|
+
try {
|
|
207
|
+
const { engine } = setupVT({ features: [renderFeature('LineString', [1, 500, 1, 1500])] })
|
|
208
|
+
engine.query([5, 1000], 12)
|
|
209
|
+
const topics = log.mock.calls.map(([topic]) => topic)
|
|
210
|
+
expect(topics).toContain('[snap-query]')
|
|
211
|
+
expect(topics).toContain('[snap-candidate-found]')
|
|
212
|
+
expect(topics).toContain('[snap-filtered] tile clip artefact')
|
|
213
|
+
} finally {
|
|
214
|
+
delete globalThis.DEBUG_SNAP_VISIBILITY
|
|
215
|
+
log.mockRestore()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('debug logging traces invisible-fill filtering when enabled', () => {
|
|
220
|
+
const log = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
221
|
+
globalThis.DEBUG_SNAP_VISIBILITY = true
|
|
222
|
+
try {
|
|
223
|
+
const fillSquare = renderFeature(
|
|
224
|
+
'Polygon', [1000, 1000, 3000, 1000, 3000, 3000, 1000, 3000], [8], { id: 'fills', type: 'fill' })
|
|
225
|
+
const { engine } = setupVT({ features: [fillSquare], fillNeighbour: true })
|
|
226
|
+
engine.query([2000, 995], 12)
|
|
227
|
+
expect(log.mock.calls.map(([topic]) => topic)).toContain('[snap-filtered] invisible fill boundary')
|
|
228
|
+
} finally {
|
|
229
|
+
delete globalThis.DEBUG_SNAP_VISIBILITY
|
|
230
|
+
log.mockRestore()
|
|
231
|
+
}
|
|
232
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const dist2 = (a, b) => {
|
|
2
|
+
const dx = a[0] - b[0]
|
|
3
|
+
const dy = a[1] - b[1]
|
|
4
|
+
return dx * dx + dy * dy
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const closestPointOnSegment = (p, a, b) => {
|
|
8
|
+
const dx = b[0] - a[0]
|
|
9
|
+
const dy = b[1] - a[1]
|
|
10
|
+
const lenSq = dx * dx + dy * dy
|
|
11
|
+
if (lenSq === 0) {
|
|
12
|
+
return [a[0], a[1]]
|
|
13
|
+
}
|
|
14
|
+
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq))
|
|
15
|
+
return [a[0] + t * dx, a[1] + t * dy]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Exported for the snap engine to merge candidates across features/layers
|
|
19
|
+
export const bestOf = (current, candidate) => better(current, candidate) ? candidate : current
|
|
20
|
+
|
|
21
|
+
const better = (a, b) => {
|
|
22
|
+
if (!a) {
|
|
23
|
+
return !!b
|
|
24
|
+
}
|
|
25
|
+
if (!b) {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
// Vertex always beats edge — only compare distance within the same type
|
|
29
|
+
if (a.type === 'edge' && b.type === 'vertex') {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
if (a.type === 'vertex' && b.type === 'edge') {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
return b.distSq < a.distSq
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// OL Vector rings duplicate the first coord as last; VectorTile rings do not.
|
|
39
|
+
// isClosedRing=true: skip the duplicated last vertex, wrap closing edge back to first.
|
|
40
|
+
const testCoords = (coords, query, toleranceSq, isClosedRing) => {
|
|
41
|
+
let best = null
|
|
42
|
+
const n = isClosedRing && coords.length > 1 ? coords.length - 1 : coords.length
|
|
43
|
+
const edgeCount = isClosedRing ? n : n - 1
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < n; i++) {
|
|
46
|
+
const v = coords[i]
|
|
47
|
+
const dSq = dist2(query, v)
|
|
48
|
+
if (dSq <= toleranceSq) {
|
|
49
|
+
best = bestOf(best, { type: 'vertex', coord: [v[0], v[1]], distSq: dSq })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
54
|
+
const a = coords[i]
|
|
55
|
+
const b = coords[(i + 1) % n]
|
|
56
|
+
const pt = closestPointOnSegment(query, a, b)
|
|
57
|
+
const dSq = dist2(query, pt)
|
|
58
|
+
if (dSq <= toleranceSq) {
|
|
59
|
+
best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return best
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Edge candidates carry their segment endpoints (seg) and vertex candidates their
|
|
67
|
+
// neighbouring vertices (adjacent) so the snap engine can test segment orientation —
|
|
68
|
+
// used to recognise tile clip artefacts, which are always axis-aligned in tile space.
|
|
69
|
+
const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
|
|
70
|
+
let best = null
|
|
71
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
72
|
+
const ai = start + i * 2
|
|
73
|
+
const bi = start + ((i + 1) % numPairs) * 2
|
|
74
|
+
const a = [flat[ai], flat[ai + 1]]
|
|
75
|
+
const b = [flat[bi], flat[bi + 1]]
|
|
76
|
+
const pt = closestPointOnSegment(query, a, b)
|
|
77
|
+
const dSq = dist2(query, pt)
|
|
78
|
+
if (dSq <= toleranceSq) {
|
|
79
|
+
best = bestOf(best, { type: 'edge', coord: pt, distSq: dSq, seg: [a, b] })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return best
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Neighbouring vertex indices; closed rings (edgeCount === numPairs) wrap around the ring
|
|
86
|
+
const adjacentIndices = (i, numPairs, wraps) => {
|
|
87
|
+
const prev = i > 0 ? i - 1 : numPairs - 1
|
|
88
|
+
const next = i < numPairs - 1 ? i + 1 : 0
|
|
89
|
+
return [
|
|
90
|
+
(i > 0 || wraps) ? prev : null,
|
|
91
|
+
(i < numPairs - 1 || wraps) ? next : null
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const getBestVertex = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
|
|
96
|
+
const wraps = edgeCount === numPairs
|
|
97
|
+
const coordAt = (i) => i === null ? null : [flat[start + i * 2], flat[start + i * 2 + 1]]
|
|
98
|
+
let best = null
|
|
99
|
+
for (let i = 0; i < numPairs; i++) {
|
|
100
|
+
const v = coordAt(i)
|
|
101
|
+
const dSq = dist2(query, v)
|
|
102
|
+
if (dSq <= toleranceSq) {
|
|
103
|
+
const [prevIdx, nextIdx] = adjacentIndices(i, numPairs, wraps)
|
|
104
|
+
const adjacent = [coordAt(prevIdx), coordAt(nextIdx)]
|
|
105
|
+
best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq, adjacent })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return best
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Returns the best vertex and best edge separately so the caller can filter one
|
|
112
|
+
// (e.g. a clip-artefact vertex) while still snapping to the other.
|
|
113
|
+
const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => {
|
|
114
|
+
const numPairs = (end - start) / 2
|
|
115
|
+
const edgeCount = isClosedRing ? numPairs : numPairs - 1
|
|
116
|
+
return {
|
|
117
|
+
vertex: getBestVertex(flat, start, numPairs, edgeCount, query, toleranceSq),
|
|
118
|
+
edge: getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const olGeomHandlers = {
|
|
123
|
+
point: (geom, query, toleranceSq) => {
|
|
124
|
+
const c = geom.getCoordinates()
|
|
125
|
+
const dSq = dist2(query, c)
|
|
126
|
+
return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null
|
|
127
|
+
},
|
|
128
|
+
lineString: (geom, query, toleranceSq) => {
|
|
129
|
+
return testCoords(geom.getCoordinates(), query, toleranceSq, false)
|
|
130
|
+
},
|
|
131
|
+
linearRing: (geom, query, toleranceSq) => {
|
|
132
|
+
return testCoords(geom.getCoordinates(), query, toleranceSq, true)
|
|
133
|
+
},
|
|
134
|
+
polygon: (geom, query, toleranceSq) => {
|
|
135
|
+
let best = null
|
|
136
|
+
for (const ring of geom.getCoordinates()) {
|
|
137
|
+
best = bestOf(best, testCoords(ring, query, toleranceSq, true))
|
|
138
|
+
}
|
|
139
|
+
return best
|
|
140
|
+
},
|
|
141
|
+
multiLineString: (geom, query, toleranceSq) => {
|
|
142
|
+
let best = null
|
|
143
|
+
for (const line of geom.getCoordinates()) {
|
|
144
|
+
best = bestOf(best, testCoords(line, query, toleranceSq, false))
|
|
145
|
+
}
|
|
146
|
+
return best
|
|
147
|
+
},
|
|
148
|
+
multiPolygon: (geom, query, toleranceSq) => {
|
|
149
|
+
let best = null
|
|
150
|
+
for (const polygon of geom.getCoordinates()) {
|
|
151
|
+
for (const ring of polygon) {
|
|
152
|
+
best = bestOf(best, testCoords(ring, query, toleranceSq, true))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return best
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const testOLFeature = (feature, query, toleranceSq) => {
|
|
160
|
+
const geom = feature.getGeometry()
|
|
161
|
+
if (!geom) { return null }
|
|
162
|
+
const rawType = geom.getType()
|
|
163
|
+
const handler = olGeomHandlers[rawType[0].toLowerCase() + rawType.slice(1)]
|
|
164
|
+
return handler ? handler(geom, query, toleranceSq) : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Returns an array of candidates (best vertex and best edge, when within tolerance)
|
|
168
|
+
// rather than a single winner, so the snap engine can filter clip artefacts per
|
|
169
|
+
// candidate and still fall back to the other.
|
|
170
|
+
export const testRenderFeature = (feature, query, toleranceSq) => {
|
|
171
|
+
const type = feature.getType()
|
|
172
|
+
const flat = feature.getFlatCoordinates()
|
|
173
|
+
let bestVertex = null
|
|
174
|
+
let bestEdge = null
|
|
175
|
+
|
|
176
|
+
if (type === 'Point') {
|
|
177
|
+
const dSq = dist2(query, flat)
|
|
178
|
+
if (dSq <= toleranceSq) {
|
|
179
|
+
bestVertex = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq }
|
|
180
|
+
}
|
|
181
|
+
} else if (type === 'LineString') {
|
|
182
|
+
({ vertex: bestVertex, edge: bestEdge } = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false))
|
|
183
|
+
} else if (type === 'Polygon' || type === 'MultiLineString') {
|
|
184
|
+
const ends = feature.getEnds()
|
|
185
|
+
let start = 0
|
|
186
|
+
const isClosedRing = type === 'Polygon'
|
|
187
|
+
for (const end of ends) {
|
|
188
|
+
const pair = testFlatCoords(flat, start, end, query, toleranceSq, isClosedRing)
|
|
189
|
+
bestVertex = bestOf(bestVertex, pair.vertex)
|
|
190
|
+
bestEdge = bestOf(bestEdge, pair.edge)
|
|
191
|
+
start = end
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// MultiPoint / unknown — no snap candidates
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return [bestVertex, bestEdge].filter(Boolean)
|
|
198
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import Feature from 'ol/Feature.js'
|
|
2
|
+
import Point from 'ol/geom/Point.js'
|
|
3
|
+
import LineString from 'ol/geom/LineString.js'
|
|
4
|
+
import LinearRing from 'ol/geom/LinearRing.js'
|
|
5
|
+
import Polygon from 'ol/geom/Polygon.js'
|
|
6
|
+
import MultiLineString from 'ol/geom/MultiLineString.js'
|
|
7
|
+
import MultiPolygon from 'ol/geom/MultiPolygon.js'
|
|
8
|
+
import MultiPoint from 'ol/geom/MultiPoint.js'
|
|
9
|
+
import { bestOf, testOLFeature, testRenderFeature } from './snapGeometry.js'
|
|
10
|
+
|
|
11
|
+
const TOL = 100 // 10px tolerance, squared
|
|
12
|
+
|
|
13
|
+
describe('bestOf candidate ranking', () => {
|
|
14
|
+
const vertex = (distSq) => ({ type: 'vertex', distSq })
|
|
15
|
+
const edge = (distSq) => ({ type: 'edge', distSq })
|
|
16
|
+
|
|
17
|
+
test('a vertex always beats an edge, regardless of distance', () => {
|
|
18
|
+
expect(bestOf(edge(1), vertex(99))).toEqual(vertex(99))
|
|
19
|
+
expect(bestOf(vertex(99), edge(1))).toEqual(vertex(99))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('within the same type the closer candidate wins', () => {
|
|
23
|
+
expect(bestOf(vertex(50), vertex(10))).toEqual(vertex(10))
|
|
24
|
+
expect(bestOf(edge(10), edge(50))).toEqual(edge(10))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('null candidates never win', () => {
|
|
28
|
+
expect(bestOf(null, vertex(50))).toEqual(vertex(50))
|
|
29
|
+
expect(bestOf(vertex(50), null)).toEqual(vertex(50))
|
|
30
|
+
expect(bestOf(null, null)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('testOLFeature', () => {
|
|
35
|
+
test('points snap as vertices within tolerance only', () => {
|
|
36
|
+
const feature = new Feature(new Point([100, 100]))
|
|
37
|
+
expect(testOLFeature(feature, [103, 104], TOL)).toEqual({ type: 'vertex', coord: [100, 100], distSq: 25 })
|
|
38
|
+
expect(testOLFeature(feature, [200, 200], TOL)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('lines snap to the nearest vertex, or to the closest point on an edge', () => {
|
|
42
|
+
const feature = new Feature(new LineString([[0, 0], [100, 0]]))
|
|
43
|
+
expect(testOLFeature(feature, [2, 2], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0] })
|
|
44
|
+
expect(testOLFeature(feature, [50, 5], TOL)).toMatchObject({ type: 'edge', coord: [50, 0] })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a query beyond the segment end clamps to the endpoint', () => {
|
|
48
|
+
const feature = new Feature(new LineString([[0, 0], [100, 0]]))
|
|
49
|
+
expect(testOLFeature(feature, [106, 0], TOL)).toMatchObject({ coord: [100, 0] })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('polygons snap on the closing edge and skip the duplicated closing vertex', () => {
|
|
53
|
+
const feature = new Feature(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]))
|
|
54
|
+
// Closing edge [0,100] → [0,0]
|
|
55
|
+
expect(testOLFeature(feature, [5, 50], TOL)).toMatchObject({ type: 'edge', coord: [0, 50] })
|
|
56
|
+
// Exactly on the first/closing vertex — one vertex candidate, not two
|
|
57
|
+
expect(testOLFeature(feature, [0, 0], TOL)).toMatchObject({ type: 'vertex', coord: [0, 0], distSq: 0 })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('multi-part geometries test every part and ring', () => {
|
|
61
|
+
const multiLine = new Feature(new MultiLineString([[[0, 0], [10, 0]], [[200, 200], [300, 200]]]))
|
|
62
|
+
expect(testOLFeature(multiLine, [201, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] })
|
|
63
|
+
|
|
64
|
+
const ring = new Feature(new LinearRing([[0, 0], [50, 0], [50, 50], [0, 0]]))
|
|
65
|
+
expect(testOLFeature(ring, [25, 25], TOL)).toMatchObject({ type: 'edge', coord: [25, 25] })
|
|
66
|
+
|
|
67
|
+
const multiPolygon = new Feature(new MultiPolygon([
|
|
68
|
+
[[[0, 0], [10, 0], [10, 10], [0, 0]]],
|
|
69
|
+
[[[200, 200], [300, 200], [300, 300], [200, 200]]]
|
|
70
|
+
]))
|
|
71
|
+
expect(testOLFeature(multiPolygon, [202, 201], TOL)).toMatchObject({ type: 'vertex', coord: [200, 200] })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a zero-length segment degrades to its start point', () => {
|
|
75
|
+
const feature = new Feature(new LineString([[10, 10], [10, 10]]))
|
|
76
|
+
expect(testOLFeature(feature, [12, 10], TOL)).toMatchObject({ type: 'vertex', coord: [10, 10] })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('unsupported or missing geometries yield no candidate', () => {
|
|
80
|
+
expect(testOLFeature(new Feature(new MultiPoint([[0, 0]])), [0, 0], TOL)).toBeNull()
|
|
81
|
+
expect(testOLFeature(new Feature(), [0, 0], TOL)).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('testRenderFeature (vector-tile render features)', () => {
|
|
86
|
+
const renderFeature = (type, flat, ends = null) => ({
|
|
87
|
+
getType: () => type,
|
|
88
|
+
getFlatCoordinates: () => flat,
|
|
89
|
+
getEnds: () => ends
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('points return a single vertex candidate within tolerance', () => {
|
|
93
|
+
expect(testRenderFeature(renderFeature('Point', [10, 10]), [12, 10], TOL))
|
|
94
|
+
.toEqual([{ type: 'vertex', coord: [10, 10], distSq: 4 }])
|
|
95
|
+
expect(testRenderFeature(renderFeature('Point', [10, 10]), [100, 100], TOL)).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('lines return best vertex AND best edge, with adjacency/segment metadata', () => {
|
|
99
|
+
const line = renderFeature('LineString', [0, 0, 100, 0, 100, 100])
|
|
100
|
+
const [vertex, edge] = testRenderFeature(line, [98, 4], TOL)
|
|
101
|
+
expect(vertex).toMatchObject({ type: 'vertex', coord: [100, 0], adjacent: [[0, 0], [100, 100]] })
|
|
102
|
+
expect(edge).toMatchObject({ type: 'edge', seg: [[100, 0], [100, 100]] })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('open line ends have null adjacency on the open side', () => {
|
|
106
|
+
const line = renderFeature('LineString', [0, 0, 100, 0])
|
|
107
|
+
const [vertex] = testRenderFeature(line, [1, 1], TOL)
|
|
108
|
+
expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [null, [100, 0]] })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('the far end of an open line also reports a null neighbour on its open side', () => {
|
|
112
|
+
const line = renderFeature('LineString', [0, 0, 100, 0])
|
|
113
|
+
const [vertex] = testRenderFeature(line, [99, 1], TOL)
|
|
114
|
+
expect(vertex).toMatchObject({ coord: [100, 0], adjacent: [[0, 0], null] })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('polygon rings (no duplicated closing coord in tiles) wrap edges and adjacency', () => {
|
|
118
|
+
const square = renderFeature('Polygon', [0, 0, 100, 0, 100, 100, 0, 100], [8])
|
|
119
|
+
const candidates = testRenderFeature(square, [2, 52], TOL)
|
|
120
|
+
expect(candidates).toEqual([expect.objectContaining({ type: 'edge', coord: [0, 52], seg: [[0, 100], [0, 0]] })])
|
|
121
|
+
|
|
122
|
+
const [vertex] = testRenderFeature(square, [1, 1], TOL)
|
|
123
|
+
expect(vertex).toMatchObject({ coord: [0, 0], adjacent: [[0, 100], [100, 0]] }) // wraps to last vertex
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('multi-line render features honour their part boundaries', () => {
|
|
127
|
+
const twoParts = renderFeature('MultiLineString', [0, 0, 10, 0, 200, 200, 300, 200], [4, 8])
|
|
128
|
+
// No phantom edge between [10,0] and [200,200] — a query midway finds nothing
|
|
129
|
+
expect(testRenderFeature(twoParts, [105, 100], TOL)).toEqual([])
|
|
130
|
+
expect(testRenderFeature(twoParts, [201, 200], TOL)[0]).toMatchObject({ coord: [200, 200] })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('unsupported render feature types return no candidates', () => {
|
|
134
|
+
expect(testRenderFeature(renderFeature('MultiPoint', [0, 0]), [0, 0], TOL)).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snap indicator — a single OL VectorLayer that shows a circle at the active
|
|
3
|
+
* snap candidate position.
|
|
4
|
+
*
|
|
5
|
+
* Vertex snap → orange semi-transparent circle (snapVertex color)
|
|
6
|
+
* Edge snap → blue semi-transparent circle (snapEdge color)
|
|
7
|
+
*
|
|
8
|
+
* Uses Style.renderer (single canvas call) so the circle renders correctly
|
|
9
|
+
* at fractional CSS scale factors.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
13
|
+
import VectorSource from 'ol/source/Vector.js'
|
|
14
|
+
import Feature from 'ol/Feature.js'
|
|
15
|
+
import Point from 'ol/geom/Point.js'
|
|
16
|
+
import { Style } from 'ol/style.js'
|
|
17
|
+
|
|
18
|
+
const RADIUS_PX = 10
|
|
19
|
+
|
|
20
|
+
const makeRenderer = (color) => (coords, state) => {
|
|
21
|
+
const ctx = state.context
|
|
22
|
+
const [cx, cy] = coords
|
|
23
|
+
ctx.beginPath()
|
|
24
|
+
ctx.arc(cx, cy, RADIUS_PX * state.pixelRatio, 0, Math.PI * 2)
|
|
25
|
+
ctx.fillStyle = color
|
|
26
|
+
ctx.fill()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const makeStyles = (colors) => ({
|
|
30
|
+
vertex: new Style({ renderer: makeRenderer(colors.snapVertex) }),
|
|
31
|
+
edge: new Style({ renderer: makeRenderer(colors.snapEdge) })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const createSnapIndicator = (map, colors) => {
|
|
35
|
+
let styles = makeStyles(colors)
|
|
36
|
+
const source = new VectorSource()
|
|
37
|
+
const layer = new VectorLayer({
|
|
38
|
+
source,
|
|
39
|
+
style: (f) => styles[f.get('snapType')] ?? null,
|
|
40
|
+
zIndex: 200,
|
|
41
|
+
updateWhileAnimating: true,
|
|
42
|
+
updateWhileInteracting: true
|
|
43
|
+
})
|
|
44
|
+
map.addLayer(layer)
|
|
45
|
+
|
|
46
|
+
const feature = new Feature()
|
|
47
|
+
let showing = false
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
show (coord, type) {
|
|
51
|
+
feature.setGeometry(new Point(coord))
|
|
52
|
+
feature.set('snapType', type, true)
|
|
53
|
+
if (showing) {
|
|
54
|
+
source.changed()
|
|
55
|
+
} else {
|
|
56
|
+
source.addFeature(feature)
|
|
57
|
+
showing = true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
hide () {
|
|
62
|
+
if (!showing) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
source.clear()
|
|
66
|
+
showing = false
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
updateColors (newColors) {
|
|
70
|
+
styles = makeStyles(newColors)
|
|
71
|
+
if (showing) {
|
|
72
|
+
source.changed()
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
remove () {
|
|
77
|
+
source.clear()
|
|
78
|
+
map.removeLayer(layer)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|