@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,72 @@
1
+ import { createSnapIndicator } from './snapIndicator.js'
2
+ import { createFakeMap } from '../__helpers__/harness.js'
3
+
4
+ const colors = { snapVertex: '#sv', snapEdge: '#se' }
5
+
6
+ const setup = () => {
7
+ const map = createFakeMap()
8
+ const indicator = createSnapIndicator(map, colors)
9
+ const layer = map.layers[0]
10
+ return { map, indicator, layer, source: layer.getSource() }
11
+ }
12
+
13
+ const renderedColor = (layer, feature) => {
14
+ const ctx = { beginPath: jest.fn(), arc: jest.fn(), fill: jest.fn(), fillStyle: null }
15
+ layer.getStyle()(feature).getRenderer()([5, 6], { context: ctx, pixelRatio: 2 })
16
+ return { color: ctx.fillStyle, arc: ctx.arc.mock.calls[0] }
17
+ }
18
+
19
+ test('show places a single reused feature; repeated shows move it rather than duplicate', () => {
20
+ const { indicator, source } = setup()
21
+ indicator.show([10, 10], 'vertex')
22
+ indicator.show([20, 20], 'edge')
23
+ expect(source.getFeatures()).toHaveLength(1)
24
+ expect(source.getFeatures()[0].getGeometry().getCoordinates()).toEqual([20, 20])
25
+ expect(source.getFeatures()[0].get('snapType')).toBe('edge')
26
+ })
27
+
28
+ test('vertex and edge snaps render circles in their own colours, scaled by pixelRatio', () => {
29
+ const { indicator, layer, source } = setup()
30
+ indicator.show([10, 10], 'vertex')
31
+ const feature = source.getFeatures()[0]
32
+ expect(renderedColor(layer, feature)).toEqual({ color: colors.snapVertex, arc: [5, 6, 20, 0, Math.PI * 2] })
33
+
34
+ feature.set('snapType', 'edge', true)
35
+ expect(renderedColor(layer, feature).color).toBe(colors.snapEdge)
36
+
37
+ feature.set('snapType', 'mystery', true)
38
+ expect(layer.getStyle()(feature)).toBeNull()
39
+ })
40
+
41
+ test('hide clears the circle and is safe to call when nothing is showing', () => {
42
+ const { indicator, source } = setup()
43
+ indicator.hide() // nothing showing — no-op
44
+ indicator.show([10, 10], 'vertex')
45
+ indicator.hide()
46
+ expect(source.getFeatures()).toHaveLength(0)
47
+ indicator.show([1, 1], 'vertex') // can show again after hiding
48
+ expect(source.getFeatures()).toHaveLength(1)
49
+ })
50
+
51
+ test('colour updates apply to subsequent renders, refreshing a visible circle', () => {
52
+ const { indicator, layer, source } = setup()
53
+ indicator.show([10, 10], 'vertex')
54
+ indicator.updateColors({ snapVertex: '#new', snapEdge: '#se' })
55
+ expect(renderedColor(layer, source.getFeatures()[0]).color).toBe('#new')
56
+ indicator.updateColors(colors) // also fine while hidden
57
+ })
58
+
59
+ test('colour updates while hidden skip the redraw', () => {
60
+ const { indicator, source } = setup()
61
+ const changed = jest.spyOn(source, 'changed')
62
+ indicator.updateColors({ snapVertex: '#x', snapEdge: '#y' }) // nothing showing yet
63
+ expect(changed).not.toHaveBeenCalled()
64
+ })
65
+
66
+ test('remove clears the source and detaches the layer', () => {
67
+ const { map, indicator, source } = setup()
68
+ indicator.show([10, 10], 'vertex')
69
+ indicator.remove()
70
+ expect(source.getFeatures()).toHaveLength(0)
71
+ expect(map.removeLayer).toHaveBeenCalled()
72
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Custom OL Interaction that intercepts pointer events and rewrites
3
+ * mapBrowserEvent.coordinate to the nearest snap candidate before any draw
4
+ * or modify interaction sees it.
5
+ *
6
+ * Coordinate snapping applies to all pointer events (pointermove, pointerdown,
7
+ * pointerup, singleclick) so that both rubberbanding and vertex placement are snapped.
8
+ * The visual indicator is only updated on pointermove.
9
+ *
10
+ * Must be added to the map AFTER the Draw/Modify interaction so it is processed
11
+ * first (OL iterates interactions in reverse-add order).
12
+ * snapManager.reattach() handles this after each mode change.
13
+ */
14
+
15
+ import Interaction from 'ol/interaction/Interaction.js'
16
+
17
+ const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click'])
18
+
19
+ const processSnapEvent = (mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) => {
20
+ const { type } = mapBrowserEvent
21
+
22
+ if (type === 'pointerout' || type === 'pointerleave') {
23
+ indicator.hide()
24
+ return
25
+ }
26
+
27
+ if (!SNAP_EVENTS.has(type)) {
28
+ return
29
+ }
30
+
31
+ const result = engine.query(mapBrowserEvent.coordinate, snapRadius)
32
+ if (result) {
33
+ mapBrowserEvent.coordinate = result.coord.slice()
34
+ }
35
+
36
+ // Only update indicator during free mouse movement — hide during drag, no-op for clicks
37
+ if (type === 'pointermove' && isIndicatorActive()) {
38
+ result ? indicator.show(result.coord, result.type) : indicator.hide()
39
+ } else if (type === 'pointerdrag') {
40
+ indicator.hide()
41
+ } else {
42
+ // no indicator update for click/down/up events
43
+ }
44
+ }
45
+
46
+ export const createSnapInteraction = (engine, indicator, snapRadius, isIndicatorActive) => {
47
+ const interaction = new Interaction({
48
+ handleEvent (mapBrowserEvent) {
49
+ if (interaction.getActive()) {
50
+ processSnapEvent(mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive)
51
+ }
52
+ return true
53
+ }
54
+ })
55
+
56
+ return interaction
57
+ }
@@ -0,0 +1,68 @@
1
+ import { createSnapInteraction } from './snapInteraction.js'
2
+
3
+ const setup = ({ indicatorActive = true } = {}) => {
4
+ const engine = { query: jest.fn(() => ({ type: 'vertex', coord: [9, 9] })) }
5
+ const indicator = { show: jest.fn(), hide: jest.fn() }
6
+ const interaction = createSnapInteraction(engine, indicator, 12, () => indicatorActive)
7
+ const fire = (type, coordinate = [1, 1]) => {
8
+ const event = { type, coordinate }
9
+ const result = interaction.handleEvent(event)
10
+ return { event, result }
11
+ }
12
+ return { engine, indicator, interaction, fire }
13
+ }
14
+
15
+ test('always lets the event continue to other interactions', () => {
16
+ const { fire } = setup()
17
+ expect(fire('pointermove').result).toBe(true)
18
+ expect(fire('wheel').result).toBe(true)
19
+ })
20
+
21
+ test('does nothing while inactive', () => {
22
+ const { engine, interaction, fire } = setup()
23
+ interaction.setActive(false)
24
+ const { event } = fire('pointermove')
25
+ expect(engine.query).not.toHaveBeenCalled()
26
+ expect(event.coordinate).toEqual([1, 1])
27
+ })
28
+
29
+ test('rewrites the event coordinate to a copy of the snap candidate for all pointer gestures', () => {
30
+ const { engine, fire } = setup()
31
+ for (const type of ['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click']) {
32
+ const { event } = fire(type)
33
+ expect(event.coordinate).toEqual([9, 9])
34
+ expect(event.coordinate).not.toBe(engine.query.mock.results.at(-1).value.coord) // copy, not shared
35
+ }
36
+ })
37
+
38
+ test('the indicator follows free mouse movement only', () => {
39
+ const { engine, indicator, fire } = setup()
40
+ fire('pointermove')
41
+ expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex')
42
+
43
+ engine.query.mockReturnValue(null)
44
+ fire('pointermove')
45
+ expect(indicator.hide).toHaveBeenCalledTimes(1)
46
+
47
+ engine.query.mockReturnValue({ type: 'edge', coord: [5, 5] })
48
+ fire('pointerdrag')
49
+ expect(indicator.hide).toHaveBeenCalledTimes(2) // hidden during drags
50
+ fire('singleclick')
51
+ expect(indicator.show).toHaveBeenCalledTimes(1) // clicks never touch the indicator
52
+ })
53
+
54
+ test('the indicator stays untouched during pointermove when the indicator is gated off', () => {
55
+ const { indicator, fire } = setup({ indicatorActive: false })
56
+ const { event } = fire('pointermove')
57
+ expect(event.coordinate).toEqual([9, 9]) // snapping still applies
58
+ expect(indicator.show).not.toHaveBeenCalled()
59
+ expect(indicator.hide).not.toHaveBeenCalled()
60
+ })
61
+
62
+ test('leaving the map hides the indicator without querying; unrelated events are ignored', () => {
63
+ const { engine, indicator, fire } = setup()
64
+ fire('pointerout')
65
+ expect(indicator.hide).toHaveBeenCalled()
66
+ fire('wheel')
67
+ expect(engine.query).not.toHaveBeenCalled()
68
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Snap manager — orchestrates the snap engine, indicator, and OL interaction.
3
+ *
4
+ * Returned as manager.snap; null when no snapLayers are configured.
5
+ *
6
+ * Interface used by draw and edit modes:
7
+ * snap.apply(coord) — query + show/hide indicator; returns snapped coord or original
8
+ * snap.hideIndicator() — explicit hide (e.g. on touch/keyboard commit)
9
+ * snap.setActive(bool) — enable / disable (wired to the UI snap toggle)
10
+ * snap.reattach() — re-add the OL interaction after a mode change so it stays
11
+ * last-added (= first to process pointermove events)
12
+ * snap.destroy() — full cleanup
13
+ */
14
+
15
+ import { createSnapEngine } from './snapEngine.js'
16
+ import { createSnapIndicator } from './snapIndicator.js'
17
+ import { createSnapInteraction } from './snapInteraction.js'
18
+
19
+ export const createSnapManager = (map, snapLayers, colors, snapRadius) => {
20
+ if (!snapLayers?.length) {
21
+ return null
22
+ }
23
+
24
+ const engine = createSnapEngine(map, snapLayers)
25
+ const indicator = createSnapIndicator(map, colors)
26
+
27
+ let indicatorActive = false
28
+ const interaction = createSnapInteraction(engine, indicator, snapRadius, () => indicatorActive)
29
+
30
+ map.addInteraction(interaction)
31
+ interaction.setActive(false) // matches reducer initial state: snap: false
32
+
33
+ let active = false
34
+
35
+ return {
36
+ /**
37
+ * Apply snap at coord. Updates the indicator and returns the snapped
38
+ * coordinate, or the original coordinate when no snap candidate is found.
39
+ * Returns original coord unchanged when snap is disabled.
40
+ */
41
+ snapRadius,
42
+
43
+ apply (coord) {
44
+ if (!active) {
45
+ return coord
46
+ }
47
+ const result = engine.query(coord, snapRadius)
48
+ if (result) {
49
+ indicator.show(result.coord, result.type)
50
+ return result.coord
51
+ }
52
+ indicator.hide()
53
+ return coord
54
+ },
55
+
56
+ hideIndicator () {
57
+ indicator.hide()
58
+ },
59
+
60
+ setIndicatorActive (value) {
61
+ indicatorActive = value
62
+ if (!value) {
63
+ indicator.hide()
64
+ }
65
+ },
66
+
67
+ setActive (value) {
68
+ active = value
69
+ interaction.setActive(value)
70
+ if (!value) {
71
+ indicator.hide()
72
+ }
73
+ },
74
+
75
+ /**
76
+ * Remove and re-add the OL interaction so it sits at the top of the
77
+ * interaction stack (last-added = first to handle pointermove).
78
+ * Call after each changeMode() so the interaction always runs before
79
+ * the newly added Draw or Modify interaction.
80
+ */
81
+ setSnapLayers (layers) {
82
+ engine.setLayers(layers === null || layers === undefined ? snapLayers : layers)
83
+ },
84
+
85
+ reattach () {
86
+ map.removeInteraction(interaction)
87
+ map.addInteraction(interaction)
88
+ },
89
+
90
+ updateColors (newColors) {
91
+ indicator.updateColors(newColors)
92
+ },
93
+
94
+ destroy () {
95
+ map.removeInteraction(interaction)
96
+ indicator.remove()
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,101 @@
1
+ import { createSnapManager } from './snapManager.js'
2
+ import { createSnapEngine } from './snapEngine.js'
3
+ import { createSnapIndicator } from './snapIndicator.js'
4
+ import { createSnapInteraction } from './snapInteraction.js'
5
+ import { createFakeMap } from '../__helpers__/harness.js'
6
+
7
+ jest.mock('./snapEngine.js', () => ({
8
+ createSnapEngine: jest.fn(() => ({ query: jest.fn(() => null), setLayers: jest.fn() }))
9
+ }))
10
+ jest.mock('./snapIndicator.js', () => ({
11
+ createSnapIndicator: jest.fn(() => ({ show: jest.fn(), hide: jest.fn(), updateColors: jest.fn(), remove: jest.fn() }))
12
+ }))
13
+ jest.mock('./snapInteraction.js', () => ({
14
+ createSnapInteraction: jest.fn(() => ({ setActive: jest.fn() }))
15
+ }))
16
+
17
+ const setup = () => {
18
+ const map = createFakeMap()
19
+ const snap = createSnapManager(map, ['boundaries'], { snapVertex: '#sv' }, 12)
20
+ const engine = createSnapEngine.mock.results.at(-1).value
21
+ const indicator = createSnapIndicator.mock.results.at(-1).value
22
+ const interaction = createSnapInteraction.mock.results.at(-1).value
23
+ return { map, snap, engine, indicator, interaction }
24
+ }
25
+
26
+ afterEach(() => jest.clearAllMocks())
27
+
28
+ test('no snap layers configured means no snap manager at all', () => {
29
+ expect(createSnapManager(createFakeMap(), null, {}, 12)).toBeNull()
30
+ expect(createSnapManager(createFakeMap(), [], {}, 12)).toBeNull()
31
+ })
32
+
33
+ test('starts attached to the map but inactive, matching the initial snap-off UI state', () => {
34
+ const { map, snap, interaction } = setup()
35
+ expect(map.interactions).toContain(interaction)
36
+ expect(interaction.setActive).toHaveBeenCalledWith(false)
37
+ expect(snap.snapRadius).toBe(12)
38
+ })
39
+
40
+ describe('apply', () => {
41
+ test('returns the coordinate untouched while snap is off', () => {
42
+ const { snap, engine } = setup()
43
+ expect(snap.apply([1, 2])).toEqual([1, 2])
44
+ expect(engine.query).not.toHaveBeenCalled()
45
+ })
46
+
47
+ test('snaps to a candidate and shows the indicator, or hides it on a miss', () => {
48
+ const { snap, engine, indicator } = setup()
49
+ snap.setActive(true)
50
+ engine.query.mockReturnValue({ type: 'vertex', coord: [9, 9] })
51
+ expect(snap.apply([8, 8])).toEqual([9, 9])
52
+ expect(indicator.show).toHaveBeenCalledWith([9, 9], 'vertex')
53
+
54
+ engine.query.mockReturnValue(null)
55
+ expect(snap.apply([1, 2])).toEqual([1, 2])
56
+ expect(indicator.hide).toHaveBeenCalled()
57
+ })
58
+ })
59
+
60
+ test('deactivating snap or the indicator hides the circle; the interaction reads indicator state live', () => {
61
+ const { snap, indicator, interaction } = setup()
62
+ const isIndicatorActive = createSnapInteraction.mock.calls.at(-1)[3]
63
+ snap.setIndicatorActive(true)
64
+ expect(isIndicatorActive()).toBe(true)
65
+ snap.setIndicatorActive(false)
66
+ expect(isIndicatorActive()).toBe(false)
67
+ expect(indicator.hide).toHaveBeenCalledTimes(1)
68
+
69
+ snap.setActive(false)
70
+ expect(interaction.setActive).toHaveBeenLastCalledWith(false)
71
+ expect(indicator.hide).toHaveBeenCalledTimes(2)
72
+ })
73
+
74
+ test('setSnapLayers forwards new layers, falling back to the configured set when cleared', () => {
75
+ const { snap, engine } = setup()
76
+ snap.setSnapLayers(['other'])
77
+ expect(engine.setLayers).toHaveBeenCalledWith(['other'])
78
+ snap.setSnapLayers(null)
79
+ expect(engine.setLayers).toHaveBeenCalledWith(['boundaries'])
80
+ snap.setSnapLayers(undefined)
81
+ expect(engine.setLayers).toHaveBeenLastCalledWith(['boundaries'])
82
+ })
83
+
84
+ test('reattach re-adds the interaction so it processes pointer events first', () => {
85
+ const { map, snap, interaction } = setup()
86
+ map.interactions.push({ id: 'draw' }) // a mode was just added
87
+ snap.reattach()
88
+ expect(map.removeInteraction).toHaveBeenCalledWith(interaction)
89
+ expect(map.interactions.at(-1)).toBe(interaction)
90
+ })
91
+
92
+ test('colour updates and explicit hides reach the indicator; destroy removes interaction and indicator', () => {
93
+ const { map, snap, indicator, interaction } = setup()
94
+ snap.hideIndicator()
95
+ expect(indicator.hide).toHaveBeenCalled()
96
+ snap.updateColors({ snapVertex: '#new' })
97
+ expect(indicator.updateColors).toHaveBeenCalledWith({ snapVertex: '#new' })
98
+ snap.destroy()
99
+ expect(map.removeInteraction).toHaveBeenCalledWith(interaction)
100
+ expect(indicator.remove).toHaveBeenCalled()
101
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure geometry helpers for multi-ring/multi-part geometry support.
3
+ * Handles coordinate transformations between flat arrays and hierarchical GeoJSON.
4
+ * Supports Polygon, MultiPolygon, LineString, MultiLineString.
5
+ */
6
+
7
+ export const getCoords = (geom) => {
8
+ if (!geom?.coordinates) {
9
+ return []
10
+ }
11
+ switch (geom.type) {
12
+ case 'LineString': return geom.coordinates
13
+ case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1))
14
+ case 'MultiLineString': return geom.coordinates.flat(1)
15
+ case 'MultiPolygon': return geom.coordinates.flatMap(poly => poly.flatMap(ring => ring.slice(0, -1)))
16
+ default: return []
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Segment metadata for each ring or part.
22
+ * { start, length, path, closed }
23
+ */
24
+ export const getRingSegments = (geom) => {
25
+ if (!geom?.coordinates) {
26
+ return []
27
+ }
28
+ const segments = []
29
+ let start = 0
30
+
31
+ switch (geom.type) {
32
+ case 'LineString':
33
+ segments.push({ start: 0, length: geom.coordinates.length, path: [], closed: false })
34
+ break
35
+ case 'Polygon':
36
+ geom.coordinates.forEach((ring, i) => {
37
+ const len = ring.length - 1
38
+ segments.push({ start, length: len, path: [i], closed: true })
39
+ start += len
40
+ })
41
+ break
42
+ case 'MultiLineString':
43
+ geom.coordinates.forEach((line, i) => {
44
+ segments.push({ start, length: line.length, path: [i], closed: false })
45
+ start += line.length
46
+ })
47
+ break
48
+ case 'MultiPolygon':
49
+ geom.coordinates.forEach((polygon, pi) => {
50
+ polygon.forEach((ring, ri) => {
51
+ const len = ring.length - 1
52
+ segments.push({ start, length: len, path: [pi, ri], closed: true })
53
+ start += len
54
+ })
55
+ })
56
+ break
57
+ default:
58
+ break
59
+ }
60
+
61
+ return segments
62
+ }
63
+
64
+ /** Find which segment a flat vertex index belongs to. */
65
+ export const getSegmentForIndex = (segments, flatIdx) => {
66
+ for (const seg of segments) {
67
+ if (flatIdx >= seg.start && flatIdx < seg.start + seg.length) {
68
+ return { segment: seg, localIdx: flatIdx - seg.start }
69
+ }
70
+ }
71
+ return null
72
+ }
73
+
74
+ /** Return a reference to the coordinate array at a hierarchical path. */
75
+ export const getModifiableCoords = (geojsonGeometry, path) => {
76
+ let coords = geojsonGeometry.coordinates
77
+ for (const idx of path) {
78
+ coords = coords[idx]
79
+ }
80
+ return coords
81
+ }
82
+
83
+ /** Compute midpoints for all segments, respecting open/closed ring boundaries. */
84
+ export const getMidpoints = (geom) => {
85
+ const coords = getCoords(geom)
86
+ const segments = getRingSegments(geom)
87
+ if (!coords.length || !segments.length) {
88
+ return []
89
+ }
90
+
91
+ const midpoints = []
92
+ for (const seg of segments) {
93
+ const count = seg.closed ? seg.length : seg.length - 1
94
+ for (let i = 0; i < count; i++) {
95
+ const idx = seg.start + i
96
+ const nextIdx = seg.start + ((i + 1) % seg.length)
97
+ const [x1, y1] = coords[idx]
98
+ const [x2, y2] = coords[nextIdx]
99
+ midpoints.push([(x1 + x2) / 2, (y1 + y2) / 2])
100
+ }
101
+ }
102
+ return midpoints
103
+ }
@@ -0,0 +1,76 @@
1
+ import { getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, getMidpoints } from './geometryHelpers.js'
2
+
3
+ const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]
4
+ const HOLE = [[2, 2], [4, 2], [4, 4], [2, 2]]
5
+ const LINE = [[0, 0], [10, 0], [20, 0]]
6
+
7
+ describe('getCoords flattens editable vertices (closing coords stripped)', () => {
8
+ test.each([
9
+ ['LineString', { type: 'LineString', coordinates: LINE }, 3],
10
+ ['Polygon with hole', { type: 'Polygon', coordinates: [SQUARE, HOLE] }, 7],
11
+ ['MultiLineString', { type: 'MultiLineString', coordinates: [LINE, LINE] }, 6],
12
+ ['MultiPolygon', { type: 'MultiPolygon', coordinates: [[SQUARE], [SQUARE, HOLE]] }, 11]
13
+ ])('%s', (_, geom, expected) => {
14
+ expect(getCoords(geom)).toHaveLength(expected)
15
+ })
16
+
17
+ test('missing or unsupported geometry yields an empty array', () => {
18
+ expect(getCoords(null)).toEqual([])
19
+ expect(getCoords({ type: 'Point' })).toEqual([])
20
+ expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([])
21
+ })
22
+ })
23
+
24
+ describe('getRingSegments maps flat indices back to parts', () => {
25
+ test('describes each ring/part with its offset, length, path and closure', () => {
26
+ expect(getRingSegments({ type: 'LineString', coordinates: LINE }))
27
+ .toEqual([{ start: 0, length: 3, path: [], closed: false }])
28
+ expect(getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] }))
29
+ .toEqual([
30
+ { start: 0, length: 4, path: [0], closed: true },
31
+ { start: 4, length: 3, path: [1], closed: true }
32
+ ])
33
+ expect(getRingSegments({ type: 'MultiLineString', coordinates: [LINE, LINE] })[1])
34
+ .toEqual({ start: 3, length: 3, path: [1], closed: false })
35
+ expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] })[1])
36
+ .toEqual({ start: 4, length: 3, path: [1, 0], closed: true })
37
+ })
38
+
39
+ test('missing or unsupported geometry yields no segments', () => {
40
+ expect(getRingSegments(null)).toEqual([])
41
+ expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([])
42
+ })
43
+ })
44
+
45
+ describe('getSegmentForIndex', () => {
46
+ const segments = getRingSegments({ type: 'Polygon', coordinates: [SQUARE, HOLE] })
47
+
48
+ test('finds the owning segment and the local index within it', () => {
49
+ expect(getSegmentForIndex(segments, 5)).toEqual({ segment: segments[1], localIdx: 1 })
50
+ })
51
+
52
+ test('returns null beyond the last segment', () => {
53
+ expect(getSegmentForIndex(segments, 7)).toBeNull()
54
+ })
55
+ })
56
+
57
+ test('getModifiableCoords returns a live reference at a hierarchical path', () => {
58
+ const geom = { type: 'MultiPolygon', coordinates: [[SQUARE], [HOLE]] }
59
+ const ring = getModifiableCoords(geom, [1, 0])
60
+ ring[0] = [3, 3]
61
+ expect(geom.coordinates[1][0][0]).toEqual([3, 3])
62
+ expect(getModifiableCoords(geom, [])).toBe(geom.coordinates)
63
+ })
64
+
65
+ describe('getMidpoints', () => {
66
+ test('closed rings wrap (one midpoint per vertex); open lines do not', () => {
67
+ expect(getMidpoints({ type: 'Polygon', coordinates: [SQUARE] }))
68
+ .toEqual([[5, 0], [10, 5], [5, 10], [0, 5]])
69
+ expect(getMidpoints({ type: 'LineString', coordinates: LINE }))
70
+ .toEqual([[5, 0], [15, 0]])
71
+ })
72
+
73
+ test('empty geometry yields no midpoints', () => {
74
+ expect(getMidpoints({ type: 'LineString', coordinates: [] })).toEqual([])
75
+ })
76
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Thin helpers bridging OL's array-based pixel/coordinate API
3
+ * and the {x, y} object convention used in touch/keyboard handlers.
4
+ */
5
+
6
+ /** OL coordinate [e, n] → screen pixel { x, y } */
7
+ export const coordToPixel = (map, coord) => {
8
+ const px = map.getPixelFromCoordinate(coord)
9
+ if (!px) { return null }
10
+ return { x: px[0], y: px[1] }
11
+ }
12
+
13
+ /** Screen pixel { x, y } → OL coordinate [e, n] */
14
+ export const pixelToCoord = (map, pixel) => {
15
+ return map.getCoordinateFromPixel([pixel.x, pixel.y])
16
+ }
17
+
18
+ /** Pixel distance between two { x, y } points */
19
+ export const pixelDist = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
20
+
21
+ /** OL pixel array [x, y] → { x, y } */
22
+ export const arrayToPixel = ([x, y]) => ({ x, y })
23
+
24
+ /** { x, y } → OL pixel array [x, y] */
25
+ export const pixelToArray = ({ x, y }) => [x, y]
26
+
27
+ /**
28
+ * Nudge a coordinate by (dx, dy) screen pixels.
29
+ * Converts pixel offset to map coordinate delta using the current resolution.
30
+ */
31
+ export const nudgeCoord = (map, coord, dx, dy) => {
32
+ const px = map.getPixelFromCoordinate(coord)
33
+ if (!px) { return coord }
34
+ return map.getCoordinateFromPixel([px[0] + dx, px[1] + dy])
35
+ }
@@ -0,0 +1,22 @@
1
+ import { coordToPixel, pixelToCoord, pixelDist, arrayToPixel, pixelToArray, nudgeCoord } from './olCoords.js'
2
+ import { createFakeMap } from '../__helpers__/harness.js'
3
+
4
+ const map = createFakeMap()
5
+
6
+ test('converts between OL arrays and {x, y} pixel objects', () => {
7
+ expect(coordToPixel(map, [10, 20])).toEqual({ x: 10, y: 20 })
8
+ expect(pixelToCoord(map, { x: 10, y: 20 })).toEqual([10, 20])
9
+ expect(arrayToPixel([1, 2])).toEqual({ x: 1, y: 2 })
10
+ expect(pixelToArray({ x: 1, y: 2 })).toEqual([1, 2])
11
+ expect(pixelDist({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5)
12
+ })
13
+
14
+ test('nudgeCoord offsets a coordinate by screen pixels', () => {
15
+ expect(nudgeCoord(map, [10, 20], 5, -3)).toEqual([15, 17])
16
+ })
17
+
18
+ test('unprojectable coordinates return null / stay put', () => {
19
+ const blindMap = { getPixelFromCoordinate: () => null }
20
+ expect(coordToPixel(blindMap, [1, 1])).toBeNull()
21
+ expect(nudgeCoord(blindMap, [1, 1], 5, 5)).toEqual([1, 1])
22
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Coordinate-layout helpers for ol/interaction/Draw sketch geometries.
3
+ *
4
+ * During drawing OL keeps a trailing rubber-band coordinate, and for Polygons
5
+ * additionally appends a copy of the first coordinate to close the ring:
6
+ * LineString: [...placed, rubber_band]
7
+ * Polygon ring: [...placed, rubber_band, v0_closing]
8
+ *
9
+ * This is the single place that layout is encoded — if an OL upgrade changes
10
+ * the Draw interaction's sketch bookkeeping, update it here.
11
+ */
12
+ const TRAILING_COORDS = { Polygon: 2, LineString: 1 }
13
+
14
+ /** Placed vertex coordinates of an in-progress sketch geometry. */
15
+ export const getPlacedSketchCoords = (geom) => {
16
+ const type = geom.getType()
17
+ const coords = type === 'Polygon' ? (geom.getCoordinates()[0] ?? []) : geom.getCoordinates()
18
+ return coords.slice(0, -TRAILING_COORDS[type])
19
+ }
20
+
21
+ /** Last vertex committed by OL's Draw interaction, or null if none placed yet. */
22
+ export const getLastPlacedSketchCoord = (geom) => getPlacedSketchCoords(geom).at(-1) ?? null
@@ -0,0 +1,18 @@
1
+ import Polygon from 'ol/geom/Polygon.js'
2
+ import LineString from 'ol/geom/LineString.js'
3
+ import { getPlacedSketchCoords, getLastPlacedSketchCoord } from './sketchHelpers.js'
4
+
5
+ test('strips the rubber band (and the closing coord for polygons) to expose placed vertices', () => {
6
+ const poly = new Polygon([[[0, 0], [10, 0], [5, 5], [0, 0]]]) // 2 placed + rubber + closing
7
+ expect(getPlacedSketchCoords(poly)).toEqual([[0, 0], [10, 0]])
8
+ expect(getLastPlacedSketchCoord(poly)).toEqual([10, 0])
9
+
10
+ const line = new LineString([[0, 0], [10, 0], [5, 5]]) // 2 placed + rubber
11
+ expect(getPlacedSketchCoords(line)).toEqual([[0, 0], [10, 0]])
12
+ expect(getLastPlacedSketchCoord(line)).toEqual([10, 0])
13
+ })
14
+
15
+ test('empty or rubber-band-only sketches have no placed vertices', () => {
16
+ expect(getPlacedSketchCoords(new Polygon([]))).toEqual([])
17
+ expect(getLastPlacedSketchCoord(new LineString([]))).toBeNull()
18
+ })