@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.
Files changed (248) hide show
  1. package/assets/templates/draw-tools.njk +4 -3
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api.md +32 -0
  8. package/docs/examples/draw-tools.mdx +0 -4
  9. package/docs/plugins/draw.md +482 -0
  10. package/docs/plugins.md +2 -6
  11. package/govuk-prototype-kit.config.json +3 -2
  12. package/package.json +2 -4
  13. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  14. package/plugins/beta/draw-es/src/events.js +1 -1
  15. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  17. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  18. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  19. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  20. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  21. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  22. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +13 -0
  24. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  26. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  27. package/plugins/draw/dist/esm/index.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  30. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  32. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  33. package/plugins/draw/dist/umd/index.js +2 -0
  34. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  35. package/plugins/draw/src/DrawInit.jsx +89 -0
  36. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  37. package/plugins/draw/src/adapterEvents.js +127 -0
  38. package/plugins/draw/src/adapterEvents.test.js +20 -0
  39. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  41. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  43. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  44. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  45. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  49. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  98. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  100. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  104. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  106. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  107. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  109. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  111. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  112. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  114. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  115. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  121. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  147. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  149. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  159. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  166. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  167. package/plugins/draw/src/api/addFeature.js +22 -0
  168. package/plugins/draw/src/api/addFeature.test.js +41 -0
  169. package/plugins/draw/src/api/deleteFeature.js +11 -0
  170. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  171. package/plugins/draw/src/api/editFeature.js +54 -0
  172. package/plugins/draw/src/api/editFeature.test.js +134 -0
  173. package/plugins/draw/src/api/merge.js +30 -0
  174. package/plugins/draw/src/api/merge.test.js +57 -0
  175. package/plugins/draw/src/api/newLine.js +41 -0
  176. package/plugins/draw/src/api/newLine.test.js +93 -0
  177. package/plugins/draw/src/api/newPolygon.js +41 -0
  178. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  179. package/plugins/draw/src/api/split.js +117 -0
  180. package/plugins/draw/src/api/split.test.js +204 -0
  181. package/plugins/draw/src/defaults.js +52 -0
  182. package/plugins/draw/src/defaults.test.js +18 -0
  183. package/plugins/draw/src/draw.scss +43 -0
  184. package/plugins/draw/src/events.js +237 -0
  185. package/plugins/draw/src/events.test.js +369 -0
  186. package/plugins/draw/src/index.js +10 -0
  187. package/plugins/draw/src/index.test.js +24 -0
  188. package/plugins/draw/src/manifest.js +170 -0
  189. package/plugins/draw/src/manifest.test.js +147 -0
  190. package/plugins/draw/src/reducer.js +69 -0
  191. package/plugins/draw/src/reducer.test.js +85 -0
  192. package/plugins/draw/src/utils/debounce.js +16 -0
  193. package/plugins/draw/src/utils/debounce.test.js +40 -0
  194. package/plugins/draw/src/utils/eventBus.js +36 -0
  195. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  197. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  199. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  200. package/plugins/draw/src/utils/resolveColors.js +39 -0
  201. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  202. package/plugins/draw/src/utils/spatial.js +243 -0
  203. package/plugins/draw/src/utils/spatial.test.js +243 -0
  204. package/plugins/draw/src/utils/touchTarget.js +95 -0
  205. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  206. package/plugins/draw/src/utils/undoStack.js +31 -0
  207. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  209. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  210. package/plugins/draw/src/validation/liveStroke.js +85 -0
  211. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  212. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  213. package/plugins/draw/src/validation/rules.js +186 -0
  214. package/plugins/draw/src/validation/rules.test.js +120 -0
  215. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  216. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  217. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  218. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  220. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  221. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  222. package/plugins/search/dist/css/index.css +1 -317
  223. package/rollup.esm.mjs +35 -28
  224. package/sonar-project.properties +2 -2
  225. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  226. package/src/App/components/Hints/Hints.module.scss +5 -4
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  228. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  229. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  230. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  231. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  232. package/src/App/hooks/useHintsAPI.js +67 -0
  233. package/src/App/hooks/useHintsAPI.test.js +145 -0
  234. package/src/App/renderer/PluginInits.jsx +4 -0
  235. package/src/App/renderer/PluginInits.test.jsx +7 -1
  236. package/src/InteractiveMap/InteractiveMap.js +20 -0
  237. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  238. package/src/config/events.js +4 -0
  239. package/src/scss/settings/_dimensions.scss +3 -0
  240. package/src/services/announcer.js +38 -3
  241. package/src/services/announcer.test.js +133 -1
  242. package/src/services/hints.js +3 -0
  243. package/src/services/hints.test.js +19 -0
  244. package/src/types.js +12 -0
  245. package/src/utils/detectInterfaceType.js +5 -1
  246. package/src/utils/isMac.js +15 -0
  247. package/src/utils/isMac.test.js +37 -0
  248. 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
+ }