@defra/interactive-map 0.0.37-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 (244) 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/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  21. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  22. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  23. package/plugins/draw/dist/esm/index.js +1 -0
  24. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  25. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  28. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  29. package/plugins/draw/dist/umd/index.js +2 -0
  30. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  31. package/plugins/draw/src/DrawInit.jsx +89 -0
  32. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  33. package/plugins/draw/src/adapterEvents.js +127 -0
  34. package/plugins/draw/src/adapterEvents.test.js +20 -0
  35. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  36. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  37. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  38. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  39. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  40. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  41. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  42. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  43. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  44. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  45. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  46. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  47. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  48. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  49. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  86. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  87. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  88. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  89. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  95. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  96. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  97. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  98. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  99. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  100. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  101. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  102. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  103. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  104. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  105. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  106. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  107. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  108. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  109. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  110. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  111. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  112. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  113. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  114. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  115. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  118. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  119. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  120. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  121. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  144. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  145. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  146. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  147. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  148. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  149. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  156. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  157. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  158. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  159. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  163. package/plugins/draw/src/api/addFeature.js +22 -0
  164. package/plugins/draw/src/api/addFeature.test.js +41 -0
  165. package/plugins/draw/src/api/deleteFeature.js +11 -0
  166. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  167. package/plugins/draw/src/api/editFeature.js +54 -0
  168. package/plugins/draw/src/api/editFeature.test.js +134 -0
  169. package/plugins/draw/src/api/merge.js +30 -0
  170. package/plugins/draw/src/api/merge.test.js +57 -0
  171. package/plugins/draw/src/api/newLine.js +41 -0
  172. package/plugins/draw/src/api/newLine.test.js +93 -0
  173. package/plugins/draw/src/api/newPolygon.js +41 -0
  174. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  175. package/plugins/draw/src/api/split.js +117 -0
  176. package/plugins/draw/src/api/split.test.js +204 -0
  177. package/plugins/draw/src/defaults.js +52 -0
  178. package/plugins/draw/src/defaults.test.js +18 -0
  179. package/plugins/draw/src/draw.scss +43 -0
  180. package/plugins/draw/src/events.js +237 -0
  181. package/plugins/draw/src/events.test.js +369 -0
  182. package/plugins/draw/src/index.js +10 -0
  183. package/plugins/draw/src/index.test.js +24 -0
  184. package/plugins/draw/src/manifest.js +170 -0
  185. package/plugins/draw/src/manifest.test.js +147 -0
  186. package/plugins/draw/src/reducer.js +69 -0
  187. package/plugins/draw/src/reducer.test.js +85 -0
  188. package/plugins/draw/src/utils/debounce.js +16 -0
  189. package/plugins/draw/src/utils/debounce.test.js +40 -0
  190. package/plugins/draw/src/utils/eventBus.js +36 -0
  191. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  192. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  193. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  194. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  195. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  196. package/plugins/draw/src/utils/resolveColors.js +39 -0
  197. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  198. package/plugins/draw/src/utils/spatial.js +243 -0
  199. package/plugins/draw/src/utils/spatial.test.js +243 -0
  200. package/plugins/draw/src/utils/touchTarget.js +95 -0
  201. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  202. package/plugins/draw/src/utils/undoStack.js +31 -0
  203. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  204. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  205. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  206. package/plugins/draw/src/validation/liveStroke.js +85 -0
  207. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  208. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  209. package/plugins/draw/src/validation/rules.js +186 -0
  210. package/plugins/draw/src/validation/rules.test.js +120 -0
  211. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  212. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  213. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  214. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  215. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  216. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  217. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  218. package/plugins/search/dist/css/index.css +1 -317
  219. package/rollup.esm.mjs +35 -28
  220. package/sonar-project.properties +2 -2
  221. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  222. package/src/App/components/Hints/Hints.module.scss +5 -4
  223. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  224. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  225. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  226. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  227. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  228. package/src/App/hooks/useHintsAPI.js +67 -0
  229. package/src/App/hooks/useHintsAPI.test.js +145 -0
  230. package/src/App/renderer/PluginInits.jsx +4 -0
  231. package/src/App/renderer/PluginInits.test.jsx +7 -1
  232. package/src/InteractiveMap/InteractiveMap.js +20 -0
  233. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  234. package/src/config/events.js +4 -0
  235. package/src/scss/settings/_dimensions.scss +3 -0
  236. package/src/services/announcer.js +38 -3
  237. package/src/services/announcer.test.js +133 -1
  238. package/src/services/hints.js +3 -0
  239. package/src/services/hints.test.js +19 -0
  240. package/src/types.js +12 -0
  241. package/src/utils/detectInterfaceType.js +5 -1
  242. package/src/utils/isMac.js +15 -0
  243. package/src/utils/isMac.test.js +37 -0
  244. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,80 @@
1
+ import { deriveModifyOp, buildModifyCondition, createModifyInteraction } from './modifyInteraction.js'
2
+ import { createFakeMap, polygonFeature } from '../__helpers__/harness.js'
3
+
4
+ const RING = [[0, 0], [10, 0], [10, 10], [0, 0]]
5
+
6
+ describe('deriveModifyOp', () => {
7
+ const prev = [[0, 0], [10, 0], [10, 10]]
8
+
9
+ test('a moved vertex becomes a move_vertex op with its previous coordinate', () => {
10
+ expect(deriveModifyOp(prev, [[0, 0], [12, 2], [10, 10]]))
11
+ .toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [10, 0] })
12
+ })
13
+
14
+ test('a grown array becomes an insert_vertex op at the first differing index', () => {
15
+ expect(deriveModifyOp(prev, [[0, 0], [5, 5], [10, 0], [10, 10]]))
16
+ .toEqual({ type: 'insert_vertex', vertexIndex: 1 })
17
+ expect(deriveModifyOp(prev, [...prev, [9, 9]]))
18
+ .toEqual({ type: 'insert_vertex', vertexIndex: 3 })
19
+ })
20
+
21
+ test('no coordinate change or a shrunk array yields no op', () => {
22
+ expect(deriveModifyOp(prev, prev.map(c => [...c]))).toBeNull()
23
+ expect(deriveModifyOp(prev, prev.slice(0, 2))).toBeNull()
24
+ })
25
+ })
26
+
27
+ describe('buildModifyCondition', () => {
28
+ const event = (x, y) => ({ originalEvent: { clientX: x, clientY: y } })
29
+ const conditionFor = (interfaceType) => buildModifyCondition({
30
+ map: createFakeMap(),
31
+ getState: () => ({ interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] })
32
+ })
33
+
34
+ test('activates on a vertex or midpoint handle but not on empty map', () => {
35
+ const condition = conditionFor('mouse')
36
+ expect(condition(event(11, 11))).toBe(true)
37
+ expect(condition(event(51, 49))).toBe(true)
38
+ expect(condition(event(200, 200))).toBe(false)
39
+ })
40
+
41
+ test('never activates for the touch interface', () => {
42
+ expect(conditionFor('touch')(event(11, 11))).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('createModifyInteraction', () => {
47
+ const setup = (interfaceType = 'mouse') => {
48
+ const map = createFakeMap()
49
+ const state = { interfaceType, vertices: [[0, 0], [10, 0]], midpoints: [] }
50
+ const onModifyEnd = jest.fn()
51
+ const modify = createModifyInteraction({ map, olFeature: polygonFeature(RING), getState: () => state, onModifyEnd })
52
+ return { map, state, onModifyEnd, modify, interaction: map.interactions[0] }
53
+ }
54
+
55
+ test('reports a completed drag with the vertices snapshotted at drag start', () => {
56
+ const { state, onModifyEnd, interaction } = setup()
57
+ interaction.dispatchEvent({ type: 'modifystart' })
58
+ state.vertices = [[5, 5], [10, 0]] // drag mutates state via geometry change
59
+ interaction.dispatchEvent({ type: 'modifyend' })
60
+ expect(onModifyEnd).toHaveBeenCalledWith([[0, 0], [10, 0]])
61
+ })
62
+
63
+ test('touch drags are ignored entirely', () => {
64
+ const { onModifyEnd, interaction } = setup('touch')
65
+ interaction.dispatchEvent({ type: 'modifystart' })
66
+ interaction.dispatchEvent({ type: 'modifyend' })
67
+ expect(onModifyEnd).not.toHaveBeenCalled()
68
+ })
69
+
70
+ test('vertex handles are rendered by the vertex layer, not by Modify itself', () => {
71
+ const { interaction } = setup()
72
+ expect(interaction.getOverlay().getStyleFunction()()).toEqual([])
73
+ })
74
+
75
+ test('destroy removes the interaction from the map', () => {
76
+ const { map, modify, interaction } = setup()
77
+ modify.destroy()
78
+ expect(map.removeInteraction).toHaveBeenCalledWith(interaction)
79
+ })
80
+ })
@@ -0,0 +1,118 @@
1
+ import { nudgeCoord } from '../utils/olCoords.js'
2
+ import { moveVertex, insertAtMidpoint } from './vertexOps.js'
3
+ import { KEYBOARD } from '../defaults.js'
4
+
5
+ const SNAP_PROGRESS_RATIO = 0.5
6
+
7
+ /**
8
+ * Returns snappedCoord, but escapes snap if it prevents sufficient progress in the
9
+ * nudge direction. Covers vertex-stuck (snap holds position) and edge-hugging
10
+ * (vertex slides along edge).
11
+ */
12
+ export const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => {
13
+ if (!snap) {
14
+ return snappedCoord
15
+ }
16
+ const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]]
17
+ const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]]
18
+ const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2
19
+ const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1]
20
+ if (nudgeLenSq > 0 && dot / nudgeLenSq < SNAP_PROGRESS_RATIO) {
21
+ const escapePx = snap.snapRadius + 1
22
+ return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx)
23
+ }
24
+ return snappedCoord
25
+ }
26
+
27
+ /**
28
+ * Arrow-key vertex nudging for edit mode.
29
+ *
30
+ * `keyMove` tracks the coordinate at the start of a nudge sequence so a single
31
+ * move_vertex undo op can be pushed on keyup (see keyboardHandler).
32
+ *
33
+ * @returns {{ nudge: (e: KeyboardEvent) => void, keyMove: { start, index }, nudgeByDelta: (dx: number, dy: number, isLargeStep: boolean) => void }}
34
+ */
35
+ export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexMoved }) => {
36
+ const keyMove = { start: null, index: null }
37
+
38
+ // Shared core: moves the selected vertex by an already pixel-scaled delta,
39
+ // applying snap. Returns the previous coordinate (for undo) and vertex index,
40
+ // or null if there's no valid vertex selection to move.
41
+ const moveSelectedVertexBy = (dx, dy) => {
42
+ const { selectedVertexIndex, vertices, olFeature } = getState()
43
+ if (!olFeature || selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) {
44
+ return null
45
+ }
46
+ const current = vertices[selectedVertexIndex]
47
+ const nudgedCoord = nudgeCoord(map, current, dx, dy)
48
+ const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
49
+ snap?.hideIndicator()
50
+ const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy)
51
+ moveVertex(olFeature, selectedVertexIndex, newCoord)
52
+ setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) })
53
+ return { previousCoord: current, vertexIndex: selectedVertexIndex }
54
+ }
55
+
56
+ // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved.
57
+ const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => {
58
+ const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length)
59
+ if (!result) {
60
+ return
61
+ }
62
+ onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom
63
+ const updatedVertices = getState().vertices
64
+ const insertedCoord = updatedVertices[result.insertedIndex]
65
+ if (!insertedCoord) {
66
+ return
67
+ }
68
+ keyMove.start = [...insertedCoord]
69
+ keyMove.index = result.insertedIndex
70
+ const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy)
71
+ const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
72
+ moveVertex(olFeature, result.insertedIndex, movedCoord)
73
+ setState({
74
+ selectedVertexIndex: result.insertedIndex,
75
+ selectedVertexType: 'vertex',
76
+ vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c)
77
+ })
78
+ }
79
+
80
+ const nudge = (e) => {
81
+ const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState()
82
+ if (!olFeature) {
83
+ return
84
+ }
85
+ const step = e.shiftKey ? KEYBOARD.nudgeAmount : KEYBOARD.stepAmount
86
+ const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] }
87
+ const [dx, dy] = offsets[e.key]
88
+ if (selectedVertexType === 'midpoint') {
89
+ nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy)
90
+ return
91
+ }
92
+ if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) {
93
+ return
94
+ }
95
+ if (!keyMove.start) {
96
+ keyMove.start = [...vertices[selectedVertexIndex]]
97
+ keyMove.index = selectedVertexIndex
98
+ }
99
+ moveSelectedVertexBy(dx, dy)
100
+ }
101
+
102
+ // Explicit-delta counterpart to nudge(e) — the entry point for MoveControl's
103
+ // D-pad (see mapProvider.activeMoveTarget in events.js). Unlike nudge(e)'s
104
+ // held-key sequencing (undo pushed on keyup via keyMove, so repeated keydowns
105
+ // while held batch into one undo step), each call here is one complete,
106
+ // undoable action — a button click has no "held" state to batch. Midpoints
107
+ // aren't handled here: MoveControl only claims the D-pad once a real vertex is
108
+ // selected (see buildVertexMoveTarget in events.js).
109
+ const nudgeByDelta = (dx, dy, isLargeStep) => {
110
+ const step = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount
111
+ const result = moveSelectedVertexBy(dx * step, dy * step)
112
+ if (result) {
113
+ onVertexMoved({ vertexIndex: result.vertexIndex, previousCoord: result.previousCoord })
114
+ }
115
+ }
116
+
117
+ return { nudge, keyMove, nudgeByDelta }
118
+ }
@@ -0,0 +1,165 @@
1
+ import { wireNudge, resolveSnappedCoord } from './nudge.js'
2
+ import { getCoords } from '../utils/geometryHelpers.js'
3
+ import { createFakeMap, polygonFeature } from '../__helpers__/harness.js'
4
+
5
+ const RING = [[0, 0], [10, 0], [10, 10], [0, 0]]
6
+
7
+ const setup = ({ snap = null } = {}) => {
8
+ const map = createFakeMap()
9
+ const olFeature = polygonFeature(RING)
10
+ const state = {
11
+ olFeature,
12
+ selectedVertexIndex: -1,
13
+ selectedVertexType: null,
14
+ vertices: [[0, 0], [10, 0], [10, 10]],
15
+ midpoints: [[5, 0], [10, 5], [5, 5]]
16
+ }
17
+ const setState = jest.fn((updates) => Object.assign(state, updates))
18
+ const onInserted = jest.fn(() => {
19
+ // EditMode's onInserted runs syncGeom, refreshing vertices from the geometry
20
+ state.vertices = getCoords({ type: 'Polygon', coordinates: olFeature.getGeometry().getCoordinates() })
21
+ })
22
+ const onVertexMoved = jest.fn()
23
+ const { nudge, keyMove, nudgeByDelta } = wireNudge({ map, snap, getState: () => state, setState, onInserted, onVertexMoved })
24
+ return { olFeature, state, setState, onInserted, onVertexMoved, nudge, keyMove, nudgeByDelta }
25
+ }
26
+
27
+ const ring = (olFeature) => olFeature.getGeometry().getCoordinates()[0]
28
+
29
+ describe('nudging a vertex', () => {
30
+ test('a plain arrow moves the selected vertex by the step amount, shift by the fine amount', () => {
31
+ const { state, nudge, olFeature } = setup()
32
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
33
+ nudge({ key: 'ArrowRight', shiftKey: false })
34
+ expect(ring(olFeature)[1]).toEqual([15, 0])
35
+ nudge({ key: 'ArrowDown', shiftKey: true })
36
+ expect(ring(olFeature)[1]).toEqual([15, 1])
37
+ })
38
+
39
+ test('the start coordinate is captured once for the whole nudge sequence (single undo op)', () => {
40
+ const { state, nudge, keyMove } = setup()
41
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
42
+ nudge({ key: 'ArrowRight' })
43
+ nudge({ key: 'ArrowRight' })
44
+ expect(keyMove).toEqual({ start: [10, 0], index: 1 })
45
+ })
46
+
47
+ test('does nothing without a feature or a valid selection', () => {
48
+ const { state, nudge, setState } = setup()
49
+ nudge({ key: 'ArrowRight' }) // nothing selected
50
+ state.olFeature = null
51
+ nudge({ key: 'ArrowRight' })
52
+ expect(setState).not.toHaveBeenCalled()
53
+ })
54
+ })
55
+
56
+ describe('nudgeByDelta (MoveControl D-pad)', () => {
57
+ test('moves the selected vertex by an explicit direction, scaled by isLargeStep, and reports the move for undo', () => {
58
+ const { state, nudgeByDelta, olFeature, onVertexMoved } = setup()
59
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
60
+ nudgeByDelta(1, 0, true)
61
+ expect(ring(olFeature)[1]).toEqual([15, 0])
62
+ expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [10, 0] })
63
+
64
+ onVertexMoved.mockClear()
65
+ nudgeByDelta(0, 1, false)
66
+ expect(ring(olFeature)[1]).toEqual([15, 1])
67
+ expect(onVertexMoved).toHaveBeenCalledWith({ vertexIndex: 1, previousCoord: [15, 0] })
68
+ })
69
+
70
+ test('each call is its own undo-able action, unlike nudge(e)\'s held-key batching', () => {
71
+ const { state, nudgeByDelta, onVertexMoved } = setup()
72
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
73
+ nudgeByDelta(1, 0, true)
74
+ nudgeByDelta(1, 0, true)
75
+ expect(onVertexMoved).toHaveBeenCalledTimes(2)
76
+ })
77
+
78
+ test('does nothing without a feature or a valid vertex selection, and never touches midpoints', () => {
79
+ const { state, nudgeByDelta, onVertexMoved } = setup()
80
+ nudgeByDelta(1, 0, true) // nothing selected
81
+ Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' })
82
+ nudgeByDelta(1, 0, true) // midpoint selected — MoveControl only claims a real vertex
83
+ state.olFeature = null
84
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
85
+ nudgeByDelta(1, 0, true)
86
+ expect(onVertexMoved).not.toHaveBeenCalled()
87
+ })
88
+ })
89
+
90
+ describe('nudging a midpoint', () => {
91
+ test('inserts the midpoint as a vertex, then moves and selects it', () => {
92
+ const { state, nudge, onInserted, olFeature, setState } = setup()
93
+ Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5]
94
+ nudge({ key: 'ArrowRight', shiftKey: false })
95
+ expect(onInserted).toHaveBeenCalledWith({ insertedIndex: 2 })
96
+ expect(ring(olFeature)[2]).toEqual([15, 5])
97
+ expect(setState).toHaveBeenLastCalledWith(expect.objectContaining({
98
+ selectedVertexIndex: 2, selectedVertexType: 'vertex'
99
+ }))
100
+ })
101
+
102
+ test('an invalid midpoint index is a no-op', () => {
103
+ const { state, nudge, onInserted } = setup()
104
+ Object.assign(state, { selectedVertexIndex: 99, selectedVertexType: 'midpoint' })
105
+ nudge({ key: 'ArrowRight' })
106
+ expect(onInserted).not.toHaveBeenCalled()
107
+ })
108
+
109
+ test('snaps the inserted-then-moved coordinate when a snap manager is active', () => {
110
+ const snap = { apply: jest.fn((c) => [c[0] + 2, c[1]]), hideIndicator: jest.fn(), snapRadius: 12 }
111
+ const { state, nudge, olFeature } = setup({ snap })
112
+ Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' }) // midpoint [10, 5]
113
+ nudge({ key: 'ArrowRight', shiftKey: false })
114
+ expect(snap.apply).toHaveBeenCalled()
115
+ expect(ring(olFeature)[2]).toEqual([17, 5]) // inserted [10,5] nudged +5 -> [15,5], snapped +2 -> [17,5]
116
+ })
117
+
118
+ test('stops safely if the inserted vertex is not reflected in state', () => {
119
+ const { state, nudge, onInserted, setState } = setup()
120
+ onInserted.mockImplementation(() => {}) // consumer failed to sync vertices
121
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'midpoint', vertices: [] })
122
+ nudge({ key: 'ArrowRight' }) // insert succeeds but the inserted coord is missing from state
123
+ expect(onInserted).toHaveBeenCalled()
124
+ expect(setState).not.toHaveBeenCalled()
125
+ })
126
+ })
127
+
128
+ describe('snapping while nudging', () => {
129
+ const snapReturning = (impl) => ({ apply: jest.fn(impl), hideIndicator: jest.fn(), snapRadius: 12 })
130
+
131
+ test('the snapped coordinate is used when it makes progress in the nudge direction', () => {
132
+ const snap = snapReturning((c) => [c[0] + 1, c[1]])
133
+ const { state, nudge, olFeature } = setup({ snap })
134
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
135
+ nudge({ key: 'ArrowRight' })
136
+ expect(ring(olFeature)[1]).toEqual([16, 0])
137
+ expect(snap.hideIndicator).toHaveBeenCalled()
138
+ })
139
+
140
+ test('escapes the snap radius when snapping blocks the nudge', () => {
141
+ const snap = snapReturning(() => [10, 0]) // snap holds the vertex in place
142
+ const { state, nudge, olFeature } = setup({ snap })
143
+ Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
144
+ nudge({ key: 'ArrowRight' })
145
+ expect(ring(olFeature)[1]).toEqual([23, 0]) // snapRadius + 1 past the original position
146
+ })
147
+ })
148
+
149
+ describe('resolveSnappedCoord', () => {
150
+ test('returns the snapped coordinate without a snap manager or when progress is sufficient', () => {
151
+ const map = createFakeMap()
152
+ expect(resolveSnappedCoord(null, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0])
153
+ expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 0], [4, 0], 5, 0)).toEqual([4, 0])
154
+ })
155
+
156
+ test('escapes along both axes of a diagonal nudge when blocked', () => {
157
+ const map = createFakeMap()
158
+ expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [5, 5], [0, 0], 5, 5)).toEqual([13, 13])
159
+ })
160
+
161
+ test('escapes only the vertical axis when the horizontal delta is zero', () => {
162
+ const map = createFakeMap()
163
+ expect(resolveSnappedCoord({ snapRadius: 12 }, map, [0, 0], [0, 5], [0, 0], 0, 5)).toEqual([0, 13])
164
+ })
165
+ })
@@ -0,0 +1,79 @@
1
+ import { findNearest } from './vertexHitTest.js'
2
+
3
+ /**
4
+ * Pointer/mouse selection handlers for edit mode, plus the global
5
+ * delete-vertex button listener.
6
+ *
7
+ * Interface-type flips here mutate state directly (not via setState) because
8
+ * switching device must not touch the current selection.
9
+ *
10
+ * @returns {{ destroy: () => void }}
11
+ */
12
+ export const createPointerHandlers = ({ map, container, getState, setState, touchHandler, deleteVertexButtonId, onDeleteVertex }) => {
13
+ const hitAt = (e) => {
14
+ const { vertices, midpoints } = getState()
15
+ const olPixel = map.getEventPixel(e)
16
+ return findNearest(map, vertices, midpoints, { x: olPixel[0], y: olPixel[1] })
17
+ }
18
+
19
+ const onPointerdown = (e) => {
20
+ const state = getState()
21
+ if (e.pointerType === 'touch') {
22
+ state.interfaceType = 'touch'
23
+ touchHandler.updateTargetPosition()
24
+ return
25
+ }
26
+ state.interfaceType = 'mouse'
27
+ const hit = hitAt(e)
28
+ if (hit?.type === 'vertex') {
29
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
30
+ }
31
+ }
32
+
33
+ // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves
34
+ const onContainerClick = (e) => {
35
+ if (getState().interfaceType === 'touch') {
36
+ return
37
+ }
38
+ const hit = hitAt(e)
39
+ if (hit?.type === 'vertex') {
40
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
41
+ } else if (hit?.type === 'midpoint') {
42
+ // modifyend already selected the inserted vertex — nothing to do here
43
+ } else {
44
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
45
+ }
46
+ }
47
+
48
+ // Switch to pointer mode and hide the touch target as soon as the mouse moves
49
+ const onPointerMove = (e) => {
50
+ const state = getState()
51
+ if (e.pointerType !== 'mouse' || state.interfaceType === 'mouse') {
52
+ return
53
+ }
54
+ state.interfaceType = 'mouse'
55
+ touchHandler.hide()
56
+ }
57
+
58
+ const onButtonClick = (e) => {
59
+ if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) {
60
+ onDeleteVertex()
61
+ }
62
+ }
63
+
64
+ container.addEventListener('pointerdown', onPointerdown)
65
+ container.addEventListener('pointerenter', onPointerMove)
66
+ container.addEventListener('pointermove', onPointerMove)
67
+ container.addEventListener('click', onContainerClick)
68
+ globalThis.addEventListener('click', onButtonClick)
69
+
70
+ return {
71
+ destroy () {
72
+ container.removeEventListener('pointerdown', onPointerdown)
73
+ container.removeEventListener('pointerenter', onPointerMove)
74
+ container.removeEventListener('pointermove', onPointerMove)
75
+ container.removeEventListener('click', onContainerClick)
76
+ globalThis.removeEventListener('click', onButtonClick)
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,78 @@
1
+ import { createPointerHandlers } from './pointerHandlers.js'
2
+ import { createFakeMap, createContainer, domEvent } from '../__helpers__/harness.js'
3
+
4
+ const setup = (interfaceType = 'mouse') => {
5
+ const map = createFakeMap()
6
+ const container = createContainer()
7
+ const button = document.createElement('button')
8
+ button.id = 'delete-vertex'
9
+ document.body.appendChild(button)
10
+ const state = { interfaceType, vertices: [[10, 10]], midpoints: [[50, 50]] }
11
+ const setState = jest.fn((updates) => Object.assign(state, updates))
12
+ const touchHandler = { updateTargetPosition: jest.fn(), hide: jest.fn() }
13
+ const onDeleteVertex = jest.fn()
14
+ const handlers = createPointerHandlers({
15
+ map, container, getState: () => state, setState, touchHandler, deleteVertexButtonId: 'delete-vertex', onDeleteVertex
16
+ })
17
+ return { container, button, state, setState, touchHandler, onDeleteVertex, handlers }
18
+ }
19
+
20
+ afterEach(() => { document.body.innerHTML = '' })
21
+
22
+ const pointer = (type, pointerType, x = 0, y = 0) => domEvent(type, { pointerType, clientX: x, clientY: y })
23
+
24
+ test('touch pointerdown switches to touch interface and repositions the touch target', () => {
25
+ const { container, state, touchHandler, setState } = setup()
26
+ container.dispatchEvent(pointer('pointerdown', 'touch'))
27
+ expect(state.interfaceType).toBe('touch')
28
+ expect(touchHandler.updateTargetPosition).toHaveBeenCalled()
29
+ expect(setState).not.toHaveBeenCalled() // device switch must not touch the selection
30
+ })
31
+
32
+ test('mouse pointerdown selects a vertex under the pointer, but nothing elsewhere', () => {
33
+ const { container, state, setState } = setup('touch')
34
+ container.dispatchEvent(pointer('pointerdown', 'mouse', 11, 11))
35
+ expect(state.interfaceType).toBe('mouse')
36
+ expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' })
37
+ setState.mockClear()
38
+ container.dispatchEvent(pointer('pointerdown', 'mouse', 200, 200))
39
+ expect(setState).not.toHaveBeenCalled()
40
+ })
41
+
42
+ test('click selects a vertex, deselects on empty map, leaves midpoints to Modify, and ignores touch', () => {
43
+ const { container, state, setState } = setup()
44
+ container.dispatchEvent(pointer('click', 'mouse', 11, 11))
45
+ expect(setState).toHaveBeenCalledWith({ selectedVertexIndex: 0, selectedVertexType: 'vertex' })
46
+ container.dispatchEvent(pointer('click', 'mouse', 51, 49))
47
+ expect(setState).toHaveBeenCalledTimes(1) // midpoint click: modifyend already handled selection
48
+ container.dispatchEvent(pointer('click', 'mouse', 200, 200))
49
+ expect(setState).toHaveBeenLastCalledWith({ selectedVertexIndex: -1, selectedVertexType: null })
50
+
51
+ state.interfaceType = 'touch'
52
+ setState.mockClear()
53
+ container.dispatchEvent(pointer('click', 'mouse', 11, 11))
54
+ expect(setState).not.toHaveBeenCalled()
55
+ })
56
+
57
+ test('mouse movement switches the interface back to mouse and hides the touch target once', () => {
58
+ const { container, state, touchHandler } = setup('touch')
59
+ container.dispatchEvent(pointer('pointermove', 'pen'))
60
+ expect(state.interfaceType).toBe('touch')
61
+ container.dispatchEvent(pointer('pointermove', 'mouse'))
62
+ expect(state.interfaceType).toBe('mouse')
63
+ container.dispatchEvent(pointer('pointermove', 'mouse'))
64
+ expect(touchHandler.hide).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ test('the delete-vertex button triggers deletion; other clicks and destroy() do not', () => {
68
+ const { button, container, onDeleteVertex, handlers } = setup()
69
+ button.dispatchEvent(domEvent('click', {}))
70
+ expect(onDeleteVertex).toHaveBeenCalledTimes(1)
71
+ document.body.dispatchEvent(domEvent('click', {}))
72
+ expect(onDeleteVertex).toHaveBeenCalledTimes(1)
73
+
74
+ handlers.destroy()
75
+ button.dispatchEvent(domEvent('click', {}))
76
+ container.dispatchEvent(pointer('pointerdown', 'touch'))
77
+ expect(onDeleteVertex).toHaveBeenCalledTimes(1)
78
+ })
@@ -0,0 +1,116 @@
1
+ import { getCoords, getMidpoints } from '../utils/geometryHelpers.js'
2
+ import { ADAPTER_EVENTS } from '../../../adapterEvents.js'
3
+
4
+ // Deferred commit-level geometrychange emitter (feature + change phase + vertex index)
5
+ // consumed by the validation layer. Deferred a tick so that a rejection's revert runs
6
+ // after the current mutation's undo bookkeeping has settled.
7
+ const createGeometryValidationEmitter = (manager, store, olFeature) => (phase, vertexIndex) => {
8
+ if (!phase) { return }
9
+ setTimeout(() => {
10
+ manager.emit(ADAPTER_EVENTS.GEOMETRY_CHANGE, { feature: store.toGeoJSON(olFeature), phase, vertexIndex })
11
+ }, 0)
12
+ }
13
+
14
+ // Reflect the current selection onto the handle layers and emit VERTEX_SELECTION.
15
+ const applySelectionChange = (state, { vertexLayer, midpointLayer, activeLayer, manager, hooks }) => {
16
+ vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1)
17
+ midpointLayer.setSelected(
18
+ state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1
19
+ )
20
+ if (state.selectedVertexIndex < 0) {
21
+ hooks.onDeselect?.()
22
+ }
23
+ activeLayer.update(state)
24
+ manager.emit(ADAPTER_EVENTS.VERTEX_SELECTION, {
25
+ index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1,
26
+ numVertices: state.vertices.length
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Mutable edit-mode state shared by the Modify interaction, pointer, touch and
32
+ * keyboard handlers, plus the setState/sync helpers that keep the handle
33
+ * layers, the active-selection overlay and the adapter events in step with it.
34
+ *
35
+ * Hooks (`onDeselect`, `onUpdate`) are bound late via `setHooks` because the
36
+ * touch handler both consumes this state and needs to react to its changes.
37
+ *
38
+ * @returns {{ state, getState, setState, syncGeom, updateLayersFromGeom, setHooks, destroy }}
39
+ */
40
+ export const createSelectionState = ({ map, manager, store, olFeature, interfaceType, layers }) => {
41
+ const { vertexLayer, midpointLayer, activeLayer } = layers
42
+
43
+ const state = {
44
+ olFeature,
45
+ selectedVertexIndex: -1,
46
+ selectedVertexType: null,
47
+ vertices: [],
48
+ midpoints: [],
49
+ interfaceType: interfaceType ?? 'mouse'
50
+ }
51
+
52
+ const hooks = { onDeselect: null, onUpdate: null }
53
+ const setHooks = ({ onDeselect, onUpdate }) => Object.assign(hooks, { onDeselect, onUpdate })
54
+
55
+ const plainGeom = () => {
56
+ const geom = olFeature.getGeometry()
57
+ return { type: geom.getType(), coordinates: geom.getCoordinates() }
58
+ }
59
+
60
+ const applySelectionChangeLocal = () => applySelectionChange(state, { vertexLayer, midpointLayer, activeLayer, manager, hooks })
61
+
62
+ const applyVertexChange = () => {
63
+ const geom = plainGeom()
64
+ midpointLayer.update(geom)
65
+ vertexLayer.update(geom)
66
+ state.midpoints = midpointLayer.getCoords()
67
+ activeLayer.update(state)
68
+ hooks.onUpdate?.()
69
+ map.render()
70
+ }
71
+
72
+ const setState = (updates) => {
73
+ Object.assign(state, updates)
74
+ if (updates.selectedVertexIndex !== undefined) {
75
+ applySelectionChangeLocal()
76
+ }
77
+ if (updates.vertices !== undefined) {
78
+ applyVertexChange()
79
+ }
80
+ }
81
+
82
+ // Lightweight per-frame update during drag — refreshes layers without emitting events
83
+ const updateLayersFromGeom = () => {
84
+ const geom = plainGeom()
85
+ state.vertices = getCoords(geom)
86
+ state.midpoints = getMidpoints(geom)
87
+ midpointLayer.update(geom)
88
+ vertexLayer.update(geom)
89
+ activeLayer.update(state)
90
+ }
91
+
92
+ const syncGeom = () => {
93
+ updateLayersFromGeom()
94
+ manager.emit(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: state.vertices.length })
95
+ manager.emit(ADAPTER_EVENTS.UPDATE, store.toGeoJSON(olFeature))
96
+ }
97
+
98
+ const emitGeometryValidation = createGeometryValidationEmitter(manager, store, olFeature)
99
+
100
+ // Keep overlay layers in sync on every geometry change (e.g. during pointer drag)
101
+ const onGeometryChange = () => updateLayersFromGeom()
102
+ olFeature.getGeometry().on('change', onGeometryChange)
103
+
104
+ return {
105
+ state,
106
+ getState: () => state,
107
+ setState,
108
+ syncGeom,
109
+ emitGeometryValidation,
110
+ updateLayersFromGeom,
111
+ setHooks,
112
+ destroy () {
113
+ olFeature.getGeometry().un('change', onGeometryChange)
114
+ }
115
+ }
116
+ }