@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,49 @@
|
|
|
1
|
+
import VectorSource from 'ol/source/Vector.js'
|
|
2
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
3
|
+
import Feature from 'ol/Feature.js'
|
|
4
|
+
import Point from 'ol/geom/Point.js'
|
|
5
|
+
import { getCoords } from '../utils/geometryHelpers.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Always-visible vertex handle layer for edit mode.
|
|
9
|
+
* OL Modify's built-in vertex handles only appear on hover; this layer
|
|
10
|
+
* keeps circles visible at all times. The selected vertex is rendered by
|
|
11
|
+
* the separate active-selection layer in EditMode (zIndex 103).
|
|
12
|
+
*/
|
|
13
|
+
export const createVertexLayer = (map, vertexStyle) => {
|
|
14
|
+
let currentStyle = vertexStyle
|
|
15
|
+
let selectedIndex = -1
|
|
16
|
+
const source = new VectorSource()
|
|
17
|
+
const layer = new VectorLayer({
|
|
18
|
+
source,
|
|
19
|
+
style: (feature) => feature.get('vertexIndex') === selectedIndex ? null : [currentStyle],
|
|
20
|
+
zIndex: 102
|
|
21
|
+
})
|
|
22
|
+
map.addLayer(layer)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
update (geom) {
|
|
26
|
+
source.clear()
|
|
27
|
+
getCoords(geom).forEach((coord, i) => {
|
|
28
|
+
const f = new Feature({ geometry: new Point(coord) })
|
|
29
|
+
f.set('vertexIndex', i)
|
|
30
|
+
source.addFeature(f)
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
setSelected (index) {
|
|
35
|
+
selectedIndex = index
|
|
36
|
+
source.changed()
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
updateStyle (newVertexStyle) {
|
|
40
|
+
currentStyle = newVertexStyle
|
|
41
|
+
source.changed()
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
remove () {
|
|
45
|
+
source.clear()
|
|
46
|
+
map.removeLayer(layer)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import Style from 'ol/style/Style.js'
|
|
2
|
+
import { createVertexLayer } from './vertexLayer.js'
|
|
3
|
+
import { createFakeMap } from '../__helpers__/harness.js'
|
|
4
|
+
|
|
5
|
+
const GEOM = { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] }
|
|
6
|
+
|
|
7
|
+
const setup = () => {
|
|
8
|
+
const map = createFakeMap()
|
|
9
|
+
const style = new Style({})
|
|
10
|
+
const handles = createVertexLayer(map, style)
|
|
11
|
+
const layer = map.layers[0]
|
|
12
|
+
return { map, style, handles, layer, source: layer.getSource() }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('update renders one handle per editable vertex, indexed', () => {
|
|
16
|
+
const { handles, source } = setup()
|
|
17
|
+
handles.update(GEOM)
|
|
18
|
+
expect(source.getFeatures()).toHaveLength(3)
|
|
19
|
+
expect(source.getFeatures().map(f => f.get('vertexIndex')).sort()).toEqual([0, 1, 2])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('the selected vertex is hidden here (the active layer draws it) and styles hot-swap', () => {
|
|
23
|
+
const { handles, layer, style, source } = setup()
|
|
24
|
+
handles.update(GEOM)
|
|
25
|
+
handles.setSelected(1)
|
|
26
|
+
const styleFn = layer.getStyle()
|
|
27
|
+
const featureAt = (i) => source.getFeatures().find(f => f.get('vertexIndex') === i)
|
|
28
|
+
expect(styleFn(featureAt(1))).toBeNull()
|
|
29
|
+
expect(styleFn(featureAt(0))).toEqual([style])
|
|
30
|
+
|
|
31
|
+
const newStyle = new Style({})
|
|
32
|
+
handles.updateStyle(newStyle)
|
|
33
|
+
expect(styleFn(featureAt(0))).toEqual([newStyle])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('remove clears and detaches the layer', () => {
|
|
37
|
+
const { map, handles, source } = setup()
|
|
38
|
+
handles.update(GEOM)
|
|
39
|
+
handles.remove()
|
|
40
|
+
expect(source.getFeatures()).toHaveLength(0)
|
|
41
|
+
expect(map.layers).toHaveLength(0)
|
|
42
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCoords,
|
|
3
|
+
getRingSegments,
|
|
4
|
+
getSegmentForIndex,
|
|
5
|
+
getModifiableCoords
|
|
6
|
+
} from '../utils/geometryHelpers.js'
|
|
7
|
+
import { MIN_VERTICES } from '../../../validation/rules.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Delete the vertex at `selectedIndex` from the OL feature's geometry.
|
|
11
|
+
* Respects minimum vertex counts (MIN_VERTICES — validation/rules.js).
|
|
12
|
+
*
|
|
13
|
+
* @returns {{ deletedIndex: number, deletedCoord: number[] } | null} undo payload, or null if not deleted
|
|
14
|
+
*/
|
|
15
|
+
export const deleteVertex = (olFeature, selectedIndex) => {
|
|
16
|
+
const geom = olFeature.getGeometry()
|
|
17
|
+
const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
|
|
18
|
+
const coords = getCoords(geojsonGeom)
|
|
19
|
+
const segments = getRingSegments(geojsonGeom)
|
|
20
|
+
const result = getSegmentForIndex(segments, selectedIndex)
|
|
21
|
+
if (!result) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { segment } = result
|
|
26
|
+
const minVertices = segment.closed ? MIN_VERTICES.Polygon : MIN_VERTICES.LineString
|
|
27
|
+
if (segment.length <= minVertices) {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const deletedCoord = [...coords[selectedIndex]]
|
|
32
|
+
const ring = getModifiableCoords(geojsonGeom, segment.path)
|
|
33
|
+
ring.splice(result.localIdx, 1)
|
|
34
|
+
if (segment.closed) {
|
|
35
|
+
ring[ring.length - 1] = [...ring[0]]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
geom.setCoordinates(geojsonGeom.coordinates)
|
|
39
|
+
return { deletedIndex: selectedIndex, deletedCoord }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Insert a vertex after `afterIndex` in the OL feature's geometry.
|
|
44
|
+
* Used when the user activates a midpoint (touch tap or keyboard insert).
|
|
45
|
+
*
|
|
46
|
+
* @param {number[][]} midpoints - current midpoint array (from midpointLayer)
|
|
47
|
+
* @param {number} midpointFlatIndex - flat index (vertexCount + midpointOffset)
|
|
48
|
+
* @param {number} vertexCount - number of actual vertices
|
|
49
|
+
* @param {number[][]} vertices - current vertex array
|
|
50
|
+
* @returns {{ insertedIndex: number } | null}
|
|
51
|
+
*/
|
|
52
|
+
export const insertAtMidpoint = (olFeature, midpoints, midpointFlatIndex, vertexCount) => {
|
|
53
|
+
const midpointLocalIdx = midpointFlatIndex - vertexCount
|
|
54
|
+
const midCoord = midpoints[midpointLocalIdx]
|
|
55
|
+
if (!midCoord) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const geom = olFeature.getGeometry()
|
|
60
|
+
const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
|
|
61
|
+
const segments = getRingSegments(geojsonGeom)
|
|
62
|
+
|
|
63
|
+
// Map midpoint local index to insertion position in the coordinate array
|
|
64
|
+
let midpointCounter = 0
|
|
65
|
+
for (const seg of segments) {
|
|
66
|
+
const segMidpoints = seg.closed ? seg.length : seg.length - 1
|
|
67
|
+
if (midpointLocalIdx < midpointCounter + segMidpoints) {
|
|
68
|
+
const localMidIdx = midpointLocalIdx - midpointCounter
|
|
69
|
+
const insertLocalIdx = localMidIdx + 1
|
|
70
|
+
const insertGlobalIdx = seg.start + insertLocalIdx
|
|
71
|
+
const ring = getModifiableCoords(geojsonGeom, seg.path)
|
|
72
|
+
ring.splice(insertLocalIdx, 0, [...midCoord])
|
|
73
|
+
geom.setCoordinates(geojsonGeom.coordinates)
|
|
74
|
+
return { insertedIndex: insertGlobalIdx }
|
|
75
|
+
}
|
|
76
|
+
midpointCounter += segMidpoints
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Move vertex at `index` to `newCoord` in the OL feature's geometry.
|
|
84
|
+
*/
|
|
85
|
+
export const moveVertex = (olFeature, index, newCoord) => {
|
|
86
|
+
const geom = olFeature.getGeometry()
|
|
87
|
+
const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
|
|
88
|
+
const segments = getRingSegments(geojsonGeom)
|
|
89
|
+
const result = getSegmentForIndex(segments, index)
|
|
90
|
+
if (!result) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ring = getModifiableCoords(geojsonGeom, result.segment.path)
|
|
95
|
+
ring[result.localIdx] = [...newCoord]
|
|
96
|
+
if (result.segment.closed && result.localIdx === 0) {
|
|
97
|
+
ring[ring.length - 1] = [...newCoord]
|
|
98
|
+
}
|
|
99
|
+
geom.setCoordinates(geojsonGeom.coordinates)
|
|
100
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Feature from 'ol/Feature.js'
|
|
2
|
+
import Polygon from 'ol/geom/Polygon.js'
|
|
3
|
+
import { deleteVertex, insertAtMidpoint, moveVertex } from './vertexOps.js'
|
|
4
|
+
import { polygonFeature, lineFeature } from '../__helpers__/harness.js'
|
|
5
|
+
|
|
6
|
+
const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]
|
|
7
|
+
const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]]
|
|
8
|
+
const ring = (feature, i = 0) => feature.getGeometry().getCoordinates()[i]
|
|
9
|
+
|
|
10
|
+
describe('deleteVertex', () => {
|
|
11
|
+
test('removes the vertex and keeps the ring closed', () => {
|
|
12
|
+
const feature = polygonFeature(SQUARE)
|
|
13
|
+
expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] })
|
|
14
|
+
expect(ring(feature)).toEqual([[0, 0], [10, 10], [0, 10], [0, 0]])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('deleting ring vertex 0 syncs the closing coordinate', () => {
|
|
18
|
+
const feature = polygonFeature(SQUARE)
|
|
19
|
+
deleteVertex(feature, 0)
|
|
20
|
+
expect(ring(feature)).toEqual([[10, 0], [10, 10], [0, 10], [10, 0]])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('deleting from an open line needs no closing-coordinate sync', () => {
|
|
24
|
+
const feature = lineFeature([[0, 0], [10, 0], [20, 0]])
|
|
25
|
+
expect(deleteVertex(feature, 1)).toEqual({ deletedIndex: 1, deletedCoord: [10, 0] })
|
|
26
|
+
expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [20, 0]])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('refuses to shrink below the minimum (3 for rings, 2 for lines) or for bad indices', () => {
|
|
30
|
+
expect(deleteVertex(polygonFeature([[0, 0], [10, 0], [10, 10], [0, 0]]), 1)).toBeNull()
|
|
31
|
+
expect(deleteVertex(lineFeature([[0, 0], [10, 0]]), 0)).toBeNull()
|
|
32
|
+
expect(deleteVertex(polygonFeature(SQUARE), 99)).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('insertAtMidpoint', () => {
|
|
37
|
+
test('inserts after the midpoint position in the first ring', () => {
|
|
38
|
+
const feature = polygonFeature(SQUARE) // 4 vertices; midpoint 0 sits between v0 and v1
|
|
39
|
+
expect(insertAtMidpoint(feature, [[5, 0]], 4, 4)).toEqual({ insertedIndex: 1 })
|
|
40
|
+
expect(ring(feature)[1]).toEqual([5, 0])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('maps midpoints in later rings to their global insert position', () => {
|
|
44
|
+
const feature = new Feature(new Polygon([SQUARE, HOLE]))
|
|
45
|
+
// 7 vertices; outer ring owns midpoints 0-3, hole owns 4-6. Hole midpoint 1 = between hole v1 and v2.
|
|
46
|
+
const midpoints = [[5, 0], [10, 5], [5, 10], [0, 5], [3, 2], [4, 3], [3, 3]]
|
|
47
|
+
expect(insertAtMidpoint(feature, midpoints, 7 + 5, 7)).toEqual({ insertedIndex: 6 })
|
|
48
|
+
expect(ring(feature, 1)[2]).toEqual([4, 3])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('unknown midpoints are a no-op', () => {
|
|
52
|
+
expect(insertAtMidpoint(polygonFeature(SQUARE), [[5, 0]], 99, 4)).toBeNull()
|
|
53
|
+
// A midpoint index past every segment's midpoints (stale midpoint array) is also refused
|
|
54
|
+
const extra = [[5, 0], [10, 5], [5, 10], [0, 5], [9, 9]]
|
|
55
|
+
expect(insertAtMidpoint(polygonFeature(SQUARE), extra, 4 + 4, 4)).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('inserts a midpoint into an open line segment', () => {
|
|
59
|
+
const feature = lineFeature([[0, 0], [10, 0]]) // 2 vertices, 1 midpoint at flat index 2
|
|
60
|
+
expect(insertAtMidpoint(feature, [[5, 0]], 2, 2)).toEqual({ insertedIndex: 1 })
|
|
61
|
+
expect(feature.getGeometry().getCoordinates()).toEqual([[0, 0], [5, 0], [10, 0]])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('moveVertex', () => {
|
|
66
|
+
test('moves the vertex, syncing the closing coordinate for ring vertex 0', () => {
|
|
67
|
+
const feature = polygonFeature(SQUARE)
|
|
68
|
+
moveVertex(feature, 0, [1, 1])
|
|
69
|
+
expect(ring(feature)[0]).toEqual([1, 1])
|
|
70
|
+
expect(ring(feature).at(-1)).toEqual([1, 1])
|
|
71
|
+
moveVertex(feature, 2, [20, 20])
|
|
72
|
+
expect(ring(feature)[2]).toEqual([20, 20])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('an invalid index leaves the geometry untouched', () => {
|
|
76
|
+
const feature = polygonFeature(SQUARE)
|
|
77
|
+
moveVertex(feature, 99, [1, 1])
|
|
78
|
+
expect(ring(feature)).toEqual(SQUARE)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { OLDrawManager } from './core/OLDrawManager.js'
|
|
2
|
+
import { MAP_SIZE_SCALES } from './defaults.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the OLDrawManager, attaches it to mapProvider, and wires
|
|
6
|
+
* app-level events (MAP_SET_SIZE for scale-aware touch targets,
|
|
7
|
+
* MAP_SET_STYLE for dynamic color updates).
|
|
8
|
+
*
|
|
9
|
+
* @returns {{ manager: OLDrawManager, remove: () => void }}
|
|
10
|
+
*/
|
|
11
|
+
export const createOLDraw = ({ mapProvider, events, eventBus, pluginConfig = {}, mapStyle = null }) => {
|
|
12
|
+
const { map } = mapProvider
|
|
13
|
+
const manager = new OLDrawManager(map, pluginConfig)
|
|
14
|
+
|
|
15
|
+
if (mapStyle) {
|
|
16
|
+
manager.setMapStyle(mapStyle)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
mapProvider.draw = manager
|
|
20
|
+
|
|
21
|
+
const handleSetMapSize = (size) => {
|
|
22
|
+
mapProvider.drawScale = MAP_SIZE_SCALES[size] ?? 1
|
|
23
|
+
}
|
|
24
|
+
eventBus.on(events.MAP_SET_SIZE, handleSetMapSize)
|
|
25
|
+
|
|
26
|
+
const handleSetMapStyle = (newMapStyle) => {
|
|
27
|
+
manager.setMapStyle(newMapStyle)
|
|
28
|
+
}
|
|
29
|
+
eventBus.on(events.MAP_SET_STYLE, handleSetMapStyle)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
manager,
|
|
33
|
+
remove () {
|
|
34
|
+
eventBus.off(events.MAP_SET_SIZE, handleSetMapSize)
|
|
35
|
+
eventBus.off(events.MAP_SET_STYLE, handleSetMapStyle)
|
|
36
|
+
manager.remove()
|
|
37
|
+
mapProvider.draw = null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createOLDraw } from './olDraw.js'
|
|
2
|
+
import { OLDrawManager } from './core/OLDrawManager.js'
|
|
3
|
+
import { MAP_SIZE_SCALES } from './defaults.js'
|
|
4
|
+
|
|
5
|
+
jest.mock('./core/OLDrawManager.js', () => ({
|
|
6
|
+
OLDrawManager: jest.fn(function () {
|
|
7
|
+
this.setMapStyle = jest.fn()
|
|
8
|
+
this.remove = jest.fn()
|
|
9
|
+
})
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const events = { MAP_SET_SIZE: 'app:size', MAP_SET_STYLE: 'app:style' }
|
|
13
|
+
|
|
14
|
+
const setup = (mapStyle = null) => {
|
|
15
|
+
const listeners = {}
|
|
16
|
+
const eventBus = {
|
|
17
|
+
on: jest.fn((type, handler) => { listeners[type] = handler }),
|
|
18
|
+
off: jest.fn(),
|
|
19
|
+
emit: (type, payload) => listeners[type]?.(payload)
|
|
20
|
+
}
|
|
21
|
+
const mapProvider = { map: { id: 'ol-map' } }
|
|
22
|
+
const olDraw = createOLDraw({ mapProvider, events, eventBus, pluginConfig: { snapRadius: 5 }, mapStyle })
|
|
23
|
+
const manager = OLDrawManager.mock.instances.at(-1)
|
|
24
|
+
return { eventBus, mapProvider, olDraw, manager }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => jest.clearAllMocks())
|
|
28
|
+
|
|
29
|
+
test('creates the manager for the map, exposes it as mapProvider.draw and applies an initial style', () => {
|
|
30
|
+
const { mapProvider, manager } = setup({ id: 'dark' })
|
|
31
|
+
expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, { snapRadius: 5 })
|
|
32
|
+
expect(mapProvider.draw).toBe(manager)
|
|
33
|
+
expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' })
|
|
34
|
+
|
|
35
|
+
expect(setup().manager.setMapStyle).not.toHaveBeenCalled() // no initial style
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('map size changes update the draw UI scale, defaulting to 1 for unknown sizes', () => {
|
|
39
|
+
const { eventBus, mapProvider } = setup()
|
|
40
|
+
eventBus.emit(events.MAP_SET_SIZE, 'large')
|
|
41
|
+
expect(mapProvider.drawScale).toBe(MAP_SIZE_SCALES.large)
|
|
42
|
+
eventBus.emit(events.MAP_SET_SIZE, 'enormous')
|
|
43
|
+
expect(mapProvider.drawScale).toBe(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('pluginConfig and mapStyle are optional, defaulting to {} and no initial style', () => {
|
|
47
|
+
const eventBus = { on: jest.fn(), off: jest.fn() }
|
|
48
|
+
const mapProvider = { map: { id: 'ol-map' } }
|
|
49
|
+
const olDraw = createOLDraw({ mapProvider, events, eventBus }) // no pluginConfig, no mapStyle
|
|
50
|
+
const manager = OLDrawManager.mock.instances.at(-1)
|
|
51
|
+
expect(OLDrawManager).toHaveBeenCalledWith(mapProvider.map, {})
|
|
52
|
+
expect(manager.setMapStyle).not.toHaveBeenCalled()
|
|
53
|
+
expect(mapProvider.draw).toBe(manager)
|
|
54
|
+
olDraw.remove()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('map style changes are forwarded to the manager', () => {
|
|
58
|
+
const { eventBus, manager } = setup()
|
|
59
|
+
eventBus.emit(events.MAP_SET_STYLE, { id: 'dark' })
|
|
60
|
+
expect(manager.setMapStyle).toHaveBeenCalledWith({ id: 'dark' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('remove unsubscribes, destroys the manager and clears mapProvider.draw', () => {
|
|
64
|
+
const { eventBus, mapProvider, olDraw, manager } = setup()
|
|
65
|
+
olDraw.remove()
|
|
66
|
+
expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_SIZE, expect.any(Function))
|
|
67
|
+
expect(eventBus.off).toHaveBeenCalledWith(events.MAP_SET_STYLE, expect.any(Function))
|
|
68
|
+
expect(manager.remove).toHaveBeenCalled()
|
|
69
|
+
expect(mapProvider.draw).toBeNull()
|
|
70
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { transform as projTransform } from 'ol/proj.js'
|
|
2
|
+
import VectorLayer from 'ol/layer/Vector.js'
|
|
3
|
+
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
4
|
+
import { testOLFeature, testRenderFeature, bestOf } from './snapGeometry.js'
|
|
5
|
+
|
|
6
|
+
// VectorTile geometry is clipped to a rectangle (tile extent + buffer), producing fake
|
|
7
|
+
// axis-aligned segments — and fake vertices where the clip cut the ring — that don't
|
|
8
|
+
// exist in the real data and mustn't be snap targets. They are detected by orientation:
|
|
9
|
+
// clip segments are exactly axis-aligned in tile space AND sit near a tile edge, a
|
|
10
|
+
// combination real boundaries essentially never produce. This works without knowing
|
|
11
|
+
// the exact buffer size the tiler used. Standard MVT buffer is 128/4096 tile units.
|
|
12
|
+
const MVT_BUFFER_UNITS = 128
|
|
13
|
+
const MVT_TILE_EXTENT = 4096
|
|
14
|
+
|
|
15
|
+
const buildTileBoundaryState = (layer, map) => {
|
|
16
|
+
const source = layer.getSource()
|
|
17
|
+
const tileGrid = source?.getTileGrid()
|
|
18
|
+
if (!tileGrid) { return null }
|
|
19
|
+
const viewProj = map.getView().getProjection()
|
|
20
|
+
const sourceProj = source.getProjection() ?? viewProj
|
|
21
|
+
const zoom = tileGrid.getZForResolution(map.getView().getResolution(), 0)
|
|
22
|
+
return { tileGrid, viewProj, sourceProj, zoom }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Per-candidate context: the candidate's tile extent plus the two tolerances,
|
|
26
|
+
// computed once so both adjacent segments of a vertex reuse it.
|
|
27
|
+
const getClipContext = (state, coord) => {
|
|
28
|
+
if (!state) { return null }
|
|
29
|
+
const { tileGrid, viewProj, sourceProj, zoom } = state
|
|
30
|
+
const toSource = (c) => c ? projTransform(c, viewProj, sourceProj) : null
|
|
31
|
+
const anchor = toSource(coord)
|
|
32
|
+
const tileCoord = tileGrid.getTileCoordForCoordAndZ(anchor, zoom)
|
|
33
|
+
const extent = tileGrid.getTileCoordExtent(tileCoord)
|
|
34
|
+
const tileWidth = extent[2] - extent[0]
|
|
35
|
+
return {
|
|
36
|
+
toSource,
|
|
37
|
+
anchor,
|
|
38
|
+
extent,
|
|
39
|
+
// Coarse gate: within twice the standard MVT buffer of a tile edge. Generous on
|
|
40
|
+
// purpose (the axis-alignment test does the real discrimination) so tilers with
|
|
41
|
+
// a non-standard buffer size are still covered. Relative to tile width, so it is
|
|
42
|
+
// projection-unit independent.
|
|
43
|
+
band: tileWidth * (2 * MVT_BUFFER_UNITS / MVT_TILE_EXTENT),
|
|
44
|
+
// MVT coordinates are quantized to 1/4096 of the tile extent; two quantization
|
|
45
|
+
// steps of tolerance decides whether a segment is exactly axis-aligned.
|
|
46
|
+
eps: 2 * (tileWidth / MVT_TILE_EXTENT)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isArtefactSegment = (ctx, pa, pb) => {
|
|
51
|
+
if (!pa || !pb) { return false }
|
|
52
|
+
const [minX, minY, maxX, maxY] = ctx.extent
|
|
53
|
+
const nearEdge = (v, lo, hi) => Math.min(Math.abs(v - lo), Math.abs(v - hi)) <= ctx.band
|
|
54
|
+
const vertical = Math.abs(pa[0] - pb[0]) <= ctx.eps && nearEdge(pa[0], minX, maxX)
|
|
55
|
+
const horizontal = Math.abs(pa[1] - pb[1]) <= ctx.eps && nearEdge(pa[1], minY, maxY)
|
|
56
|
+
return vertical || horizontal
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Edges: artefact when the snapped segment itself is an axis-aligned boundary segment.
|
|
60
|
+
// Vertices: fake vertices are the points where clipping cut the ring, i.e. endpoints
|
|
61
|
+
// of artefact segments — test both adjacent segments.
|
|
62
|
+
const isClipArtefact = (state, candidate) => {
|
|
63
|
+
const ctx = getClipContext(state, candidate.coord)
|
|
64
|
+
if (!ctx) { return false }
|
|
65
|
+
if (candidate.type === 'edge') {
|
|
66
|
+
const [a, b] = candidate.seg
|
|
67
|
+
return isArtefactSegment(ctx, ctx.toSource(a), ctx.toSource(b))
|
|
68
|
+
}
|
|
69
|
+
const [prev, next] = candidate.adjacent ?? []
|
|
70
|
+
return isArtefactSegment(ctx, ctx.anchor, ctx.toSource(prev)) ||
|
|
71
|
+
isArtefactSegment(ctx, ctx.anchor, ctx.toSource(next))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Read at call time so it can be toggled from the console at any point: window.DEBUG_SNAP_VISIBILITY = true
|
|
75
|
+
const isDebugEnabled = () => globalThis.DEBUG_SNAP_VISIBILITY === true
|
|
76
|
+
|
|
77
|
+
// Two same-style fill polygons share an invisible boundary — snapping to it is confusing.
|
|
78
|
+
// Detect this by projecting a test point slightly past the snap position (away from the cursor)
|
|
79
|
+
// and checking whether the same fill layer covers that side too.
|
|
80
|
+
// If it does → same-style invisible boundary → skip.
|
|
81
|
+
// If not (empty space, road, different layer) → visible outer edge → include.
|
|
82
|
+
const INVISIBLE_FILL_MIN_DIST_SQ = 1 // (1m)² — below this, cursor is on the edge, skip direction check
|
|
83
|
+
|
|
84
|
+
const isInvisibleFillBoundary = (edgeCoord, cursorCoord, layerId, map, vtLayers, resolution) => {
|
|
85
|
+
const dx = edgeCoord[0] - cursorCoord[0]
|
|
86
|
+
const dy = edgeCoord[1] - cursorCoord[1]
|
|
87
|
+
const distSq = dx * dx + dy * dy
|
|
88
|
+
if (distSq < INVISIBLE_FILL_MIN_DIST_SQ) { return false }
|
|
89
|
+
const dist = Math.sqrt(distSq)
|
|
90
|
+
const step = resolution * 4
|
|
91
|
+
const testCoord = [edgeCoord[0] + (dx / dist) * step, edgeCoord[1] + (dy / dist) * step]
|
|
92
|
+
const testPixel = map.getPixelFromCoordinate(testCoord)
|
|
93
|
+
if (!testPixel) { return false }
|
|
94
|
+
return !!map.forEachFeatureAtPixel(
|
|
95
|
+
testPixel,
|
|
96
|
+
(f) => f.get('mapbox-layer')?.id === layerId,
|
|
97
|
+
{ hitTolerance: 2, layerFilter: (l) => vtLayers.includes(l) }
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const logCandidate = (candidate, mapboxLayer) => {
|
|
102
|
+
if (!isDebugEnabled()) { return }
|
|
103
|
+
console.log('[snap-candidate-found]', {
|
|
104
|
+
type: candidate.type,
|
|
105
|
+
layerId: mapboxLayer?.id,
|
|
106
|
+
layerType: mapboxLayer?.type,
|
|
107
|
+
coord: [candidate.coord[0].toFixed(2), candidate.coord[1].toFixed(2)],
|
|
108
|
+
distSq: candidate.distSq.toFixed(2)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Returns true when the candidate is a rendering artefact rather than a visible
|
|
113
|
+
// snap target: either a tile clip artefact, or an invisible same-fill boundary.
|
|
114
|
+
const shouldFilterCandidate = ({ candidate, cursorCoord, mapboxLayer, boundaryState, map, vtLayers, resolution }) => {
|
|
115
|
+
if (isClipArtefact(boundaryState, candidate)) {
|
|
116
|
+
if (isDebugEnabled()) { console.log('[snap-filtered] tile clip artefact', candidate.type) }
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' &&
|
|
120
|
+
isInvisibleFillBoundary(candidate.coord, cursorCoord, mapboxLayer.id, map, vtLayers, resolution)) {
|
|
121
|
+
if (isDebugEnabled()) { console.log('[snap-filtered] invisible fill boundary') }
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Folds a feature's candidates into the current best, skipping rendering artefacts
|
|
128
|
+
const pickVisibleCandidates = (best, candidates, context) => {
|
|
129
|
+
let result = best
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
logCandidate(candidate, context.mapboxLayer)
|
|
132
|
+
if (!shouldFilterCandidate({ candidate, ...context })) {
|
|
133
|
+
result = bestOf(result, candidate)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Collected on each query — VectorTileLayers are replaced when the map style changes
|
|
140
|
+
const getVTLayers = (map) => {
|
|
141
|
+
const layers = []
|
|
142
|
+
map.getLayers().forEach(l => {
|
|
143
|
+
if (l instanceof VectorTileLayer) { layers.push(l) }
|
|
144
|
+
})
|
|
145
|
+
return layers
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const createSnapEngine = (map, snapLayers = []) => {
|
|
149
|
+
let vtLayerNames = new Set()
|
|
150
|
+
let olLayers = []
|
|
151
|
+
|
|
152
|
+
const setLayers = (layers) => {
|
|
153
|
+
vtLayerNames = new Set()
|
|
154
|
+
olLayers = []
|
|
155
|
+
for (const entry of layers ?? []) {
|
|
156
|
+
if (typeof entry === 'string') {
|
|
157
|
+
vtLayerNames.add(entry)
|
|
158
|
+
} else if (entry instanceof VectorLayer) {
|
|
159
|
+
olLayers.push(entry)
|
|
160
|
+
} else {
|
|
161
|
+
// unsupported layer type — skip
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setLayers(snapLayers)
|
|
167
|
+
|
|
168
|
+
const query = (coord, radiusPx) => {
|
|
169
|
+
if (isDebugEnabled()) {
|
|
170
|
+
console.log('[snap-query]', { coord: [coord[0].toFixed(2), coord[1].toFixed(2)], radiusPx, vtLayerCount: vtLayerNames.size, olLayerCount: olLayers.length })
|
|
171
|
+
}
|
|
172
|
+
const resolution = map.getView().getResolution()
|
|
173
|
+
if (!resolution) { return null }
|
|
174
|
+
const toleranceMapUnits = radiusPx * resolution
|
|
175
|
+
const toleranceSq = toleranceMapUnits * toleranceMapUnits
|
|
176
|
+
const ext = [
|
|
177
|
+
coord[0] - toleranceMapUnits,
|
|
178
|
+
coord[1] - toleranceMapUnits,
|
|
179
|
+
coord[0] + toleranceMapUnits,
|
|
180
|
+
coord[1] + toleranceMapUnits
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
let best = null
|
|
184
|
+
|
|
185
|
+
for (const layer of olLayers) {
|
|
186
|
+
const source = layer.getSource()
|
|
187
|
+
if (!source) { continue }
|
|
188
|
+
for (const feature of source.getFeaturesInExtent(ext)) {
|
|
189
|
+
best = bestOf(best, testOLFeature(feature, coord, toleranceSq))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (vtLayerNames.size > 0) {
|
|
194
|
+
const vtLayers = getVTLayers(map)
|
|
195
|
+
const pixel = vtLayers.length > 0 ? map.getPixelFromCoordinate(coord) : null
|
|
196
|
+
if (pixel) {
|
|
197
|
+
const tileBoundaryStates = new Map()
|
|
198
|
+
map.forEachFeatureAtPixel(
|
|
199
|
+
pixel,
|
|
200
|
+
(feature, layer) => {
|
|
201
|
+
const mapboxLayer = feature.get('mapbox-layer')
|
|
202
|
+
if (!vtLayerNames.has(mapboxLayer?.id)) { return }
|
|
203
|
+
const candidates = testRenderFeature(feature, coord, toleranceSq)
|
|
204
|
+
if (!candidates.length) { return }
|
|
205
|
+
if (!tileBoundaryStates.has(layer)) {
|
|
206
|
+
tileBoundaryStates.set(layer, buildTileBoundaryState(layer, map))
|
|
207
|
+
}
|
|
208
|
+
const boundaryState = tileBoundaryStates.get(layer)
|
|
209
|
+
best = pickVisibleCandidates(best, candidates, { cursorCoord: coord, mapboxLayer, boundaryState, map, vtLayers, resolution })
|
|
210
|
+
},
|
|
211
|
+
{ hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) }
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return best ? { type: best.type, coord: best.coord } : null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { query, setLayers }
|
|
220
|
+
}
|