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