@defra/interactive-map 0.0.26-alpha → 0.0.28-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 (105) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/assets/css/docusaurus.scss +4 -0
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/src/defaults.js +14 -14
  11. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  12. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  13. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  14. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  15. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  16. package/plugins/beta/draw-ml/src/styles.js +42 -39
  17. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  18. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  22. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  23. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  24. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  25. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  26. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  27. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  28. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  29. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  30. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  31. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  32. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  33. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  34. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  35. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  36. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  37. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  38. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  39. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  40. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  41. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  44. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  45. package/plugins/beta/draw-ol/src/events.js +114 -0
  46. package/plugins/beta/draw-ol/src/index.js +12 -0
  47. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  48. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  49. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  50. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  51. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  52. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  53. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  54. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  55. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  56. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  57. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  58. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  59. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  60. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  61. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  62. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  63. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  64. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  65. package/plugins/interact/src/defaults.js +1 -0
  66. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  67. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  68. package/plugins/interact/src/reducer.js +7 -0
  69. package/plugins/interact/src/reducer.test.js +16 -0
  70. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  71. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  72. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  73. package/providers/beta/openlayers/src/appEvents.js +9 -13
  74. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  75. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  76. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  77. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  78. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  79. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  80. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  81. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  82. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  83. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  84. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  85. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  86. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  87. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  88. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  89. package/rollup.esm.mjs +6 -0
  90. package/src/App/components/Hints/Hints.test.jsx +2 -2
  91. package/src/App/components/PopupMenu/PopupMenu.module.scss +6 -0
  92. package/src/App/controls/keyboardActions.js +2 -2
  93. package/src/App/controls/keyboardMappings.js +1 -2
  94. package/src/App/controls/keyboardShortcuts.js +5 -3
  95. package/src/App/controls/keyboardShortcuts.test.js +24 -0
  96. package/src/InteractiveMap/InteractiveMap.js +38 -10
  97. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  98. package/src/InteractiveMap/deviceChecker.js +3 -2
  99. package/src/InteractiveMap/deviceChecker.test.js +7 -10
  100. package/src/InteractiveMap/historyManager.js +7 -0
  101. package/src/InteractiveMap/historyManager.test.js +11 -0
  102. package/src/config/defaults.js +1 -1
  103. package/src/utils/htmlToPlainText.js +11 -0
  104. package/src/utils/htmlToPlainText.test.js +5 -0
  105. package/webpack.dev.mjs +3 -1
@@ -0,0 +1,227 @@
1
+ import { coordToPixel, nudgeCoord } from '../utils/olCoords.js'
2
+ import { spatialNavigate } from '../utils/spatial.js'
3
+ import { moveVertex, insertAtMidpoint } from './vertexOps.js'
4
+
5
+ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
6
+ const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A'])
7
+ const NUDGE_PX = 1
8
+ const STEP_PX = 5
9
+
10
+ const selectNearest = (map, getState, setState) => {
11
+ const { vertices, midpoints } = getState()
12
+ if (!vertices.length) {
13
+ return
14
+ }
15
+ const centerCoord = map.getView().getCenter()
16
+ const centerPx = coordToPixel(map, centerCoord)
17
+ if (!centerPx) {
18
+ return
19
+ }
20
+ const allPixels = [
21
+ ...vertices.map(c => coordToPixel(map, c)),
22
+ ...midpoints.map(c => coordToPixel(map, c))
23
+ ].filter(Boolean).map(p => [p.x, p.y])
24
+ const idx = spatialNavigate([centerPx.x, centerPx.y], allPixels, undefined)
25
+ setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' })
26
+ }
27
+
28
+ const navigateTo = (direction, map, getState, setState) => {
29
+ const { selectedVertexIndex, vertices, midpoints } = getState()
30
+ if (!vertices.length) {
31
+ return
32
+ }
33
+ const allCoords = [...vertices, ...midpoints]
34
+ const allPixels = allCoords.map(c => coordToPixel(map, c)).filter(Boolean).map(p => [p.x, p.y])
35
+ const startPx = selectedVertexIndex >= 0
36
+ ? allPixels[selectedVertexIndex]
37
+ : (() => {
38
+ const c = coordToPixel(map, map.getView().getCenter())
39
+ return c ? [c.x, c.y] : null
40
+ })()
41
+ if (!startPx) {
42
+ return
43
+ }
44
+ const idx = spatialNavigate(startPx, allPixels, direction)
45
+ setState({ selectedVertexIndex: idx, selectedVertexType: idx < vertices.length ? 'vertex' : 'midpoint' })
46
+ }
47
+
48
+ // Returns snappedCoord, but escapes snap if it prevents sufficient progress in the nudge direction.
49
+ // Covers vertex-stuck (snap holds position) and edge-hugging (vertex slides along edge).
50
+ const resolveSnappedCoord = (snap, map, current, nudgedCoord, snappedCoord, dx, dy) => {
51
+ if (!snap) {
52
+ return snappedCoord
53
+ }
54
+ const nudgeVec = [nudgedCoord[0] - current[0], nudgedCoord[1] - current[1]]
55
+ const actualVec = [snappedCoord[0] - current[0], snappedCoord[1] - current[1]]
56
+ const nudgeLenSq = nudgeVec[0] ** 2 + nudgeVec[1] ** 2
57
+ const dot = actualVec[0] * nudgeVec[0] + actualVec[1] * nudgeVec[1]
58
+ if (nudgeLenSq > 0 && dot / nudgeLenSq < 0.5) {
59
+ const escapePx = snap.snapRadius + 1
60
+ return nudgeCoord(map, current, dx === 0 ? 0 : Math.sign(dx) * escapePx, dy === 0 ? 0 : Math.sign(dy) * escapePx)
61
+ }
62
+ return snappedCoord
63
+ }
64
+
65
+ const wireNudge = ({ map, snap, getState, setState, onInserted }) => {
66
+ const keyMove = { start: null, index: null }
67
+
68
+ // Insert the midpoint as a vertex then move it — midpoints stay as midpoints until actually moved.
69
+ const nudgeMidpoint = (olFeature, midpoints, selectedVertexIndex, vertices, dx, dy) => {
70
+ const result = insertAtMidpoint(olFeature, midpoints, selectedVertexIndex, vertices.length)
71
+ if (!result) {
72
+ return
73
+ }
74
+ onInserted({ insertedIndex: result.insertedIndex }) // pushes insert_vertex undo + syncGeom
75
+ const updatedVertices = getState().vertices
76
+ const insertedCoord = updatedVertices[result.insertedIndex]
77
+ if (!insertedCoord) {
78
+ return
79
+ }
80
+ keyMove.start = [...insertedCoord]
81
+ keyMove.index = result.insertedIndex
82
+ const nudgedCoord = nudgeCoord(map, insertedCoord, dx, dy)
83
+ const movedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
84
+ moveVertex(olFeature, result.insertedIndex, movedCoord)
85
+ setState({
86
+ selectedVertexIndex: result.insertedIndex,
87
+ selectedVertexType: 'vertex',
88
+ vertices: updatedVertices.map((c, i) => i === result.insertedIndex ? movedCoord : c)
89
+ })
90
+ }
91
+
92
+ const nudge = (e) => {
93
+ const { selectedVertexIndex, selectedVertexType, vertices, midpoints, olFeature } = getState()
94
+ if (!olFeature) {
95
+ return
96
+ }
97
+ const step = e.shiftKey ? NUDGE_PX : STEP_PX
98
+ const offsets = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] }
99
+ const [dx, dy] = offsets[e.key]
100
+ if (selectedVertexType === 'midpoint') {
101
+ nudgeMidpoint(olFeature, midpoints, selectedVertexIndex, vertices, dx, dy)
102
+ return
103
+ }
104
+ if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex]) {
105
+ return
106
+ }
107
+ const current = vertices[selectedVertexIndex]
108
+ if (!keyMove.start) {
109
+ keyMove.start = [...current]
110
+ keyMove.index = selectedVertexIndex
111
+ }
112
+ const nudgedCoord = nudgeCoord(map, current, dx, dy)
113
+ const snappedCoord = snap ? snap.apply(nudgedCoord) : nudgedCoord
114
+ snap?.hideIndicator()
115
+ const newCoord = resolveSnappedCoord(snap, map, current, nudgedCoord, snappedCoord, dx, dy)
116
+ moveVertex(olFeature, selectedVertexIndex, newCoord)
117
+ setState({ vertices: vertices.map((c, i) => i === selectedVertexIndex ? newCoord : c) })
118
+ }
119
+
120
+ return { nudge, keyMove }
121
+ }
122
+
123
+ const isInteractiveElementFocused = (appViewport) => {
124
+ const el = document.activeElement
125
+ if (!el || el === document.body) {
126
+ return false
127
+ }
128
+ if (appViewport.contains(el)) {
129
+ return false
130
+ }
131
+ const tag = el.tagName
132
+ return INTERACTIVE_TAGS.has(tag) || el.isContentEditable || el.hasAttribute('tabindex')
133
+ }
134
+
135
+ const wireKeyboardEvents = ({ map, snap, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo, onKeyboardActive }) => {
136
+ const { nudge, keyMove } = wireNudge({ map, snap, getState, setState, onInserted })
137
+ const appViewport = map.getViewport().closest('[role="application"]') ?? map.getViewport()
138
+ const isFocused = () => isInteractiveElementFocused(appViewport)
139
+
140
+ const handleArrowKey = (e) => {
141
+ if (e.altKey) {
142
+ e.preventDefault()
143
+ e.stopPropagation()
144
+ navigateTo(e.key, map, getState, setState)
145
+ } else if (getState().selectedVertexIndex >= 0) {
146
+ e.preventDefault()
147
+ e.stopPropagation()
148
+ nudge(e)
149
+ } else {
150
+ // No action: arrow with no selection and no alt modifier
151
+ }
152
+ }
153
+
154
+ const handleKey = (e) => {
155
+ onKeyboardActive?.()
156
+ if (e.key === ' ' && getState().selectedVertexIndex < 0) {
157
+ e.preventDefault()
158
+ selectNearest(map, getState, setState)
159
+ } else if (ARROW_KEYS.has(e.key)) {
160
+ handleArrowKey(e)
161
+ } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
162
+ const tag = document.activeElement?.tagName
163
+ if (!INTERACTIVE_TAGS.has(tag)) {
164
+ e.preventDefault()
165
+ e.stopPropagation()
166
+ onUndo()
167
+ }
168
+ } else {
169
+ // No action
170
+ }
171
+ }
172
+
173
+ const onKeydown = (e) => {
174
+ if (!isFocused()) {
175
+ if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) {
176
+ e.preventDefault()
177
+ keyMove.start = null
178
+ keyMove.index = null
179
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
180
+ } else {
181
+ handleKey(e)
182
+ }
183
+ }
184
+ }
185
+
186
+ const onKeyup = (e) => {
187
+ if (!isFocused()) {
188
+ if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) {
189
+ snap?.hideIndicator()
190
+ onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start })
191
+ keyMove.start = null
192
+ keyMove.index = null
193
+ }
194
+ if (e.key === 'Delete') {
195
+ onDeleted()
196
+ }
197
+ }
198
+ }
199
+
200
+ window.addEventListener('keydown', onKeydown, { capture: true })
201
+ window.addEventListener('keyup', onKeyup, { capture: true })
202
+
203
+ return {
204
+ destroy () {
205
+ window.removeEventListener('keydown', onKeydown, { capture: true })
206
+ window.removeEventListener('keyup', onKeyup, { capture: true })
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Keyboard handler for edit mode.
213
+ *
214
+ * Space — select nearest vertex or midpoint to crosshair (only when nothing selected)
215
+ * Alt+Arrow — navigate to next vertex/midpoint in that direction (requires selection)
216
+ * Arrow — move selected vertex; if midpoint selected, inserts it as a vertex and moves it
217
+ * Shift+Arrow — same but fine nudge (1px vs 5px)
218
+ * Delete — delete selected vertex (no-op on midpoints)
219
+ * Ctrl/Cmd+Z — undo
220
+ *
221
+ * Midpoints remain midpoints until moved — navigating to a midpoint (Space/Alt+Arrow) does not
222
+ * convert it. Only pressing a plain/Shift arrow converts it.
223
+ *
224
+ * @param {{ map, container, getState, setState, onVertexMoved, onInserted, onDeleted, onUndo }}
225
+ * @returns {{ destroy }}
226
+ */
227
+ export const createKeyboardHandler = (options) => wireKeyboardEvents(options)
@@ -0,0 +1,57 @@
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 { getMidpoints } from '../utils/geometryHelpers.js'
6
+
7
+ /**
8
+ * Manages a dedicated overlay layer for midpoint handles in edit mode.
9
+ * Midpoints are always visible (unlike OL Modify's native midpoints which
10
+ * only appear when the pointer is near a segment). The selected midpoint is
11
+ * rendered by the separate active-selection layer in EditMode (zIndex 103).
12
+ */
13
+ export const createMidpointLayer = (map, midpointStyle) => {
14
+ let currentStyle = midpointStyle
15
+ let selectedIndex = -1
16
+ const source = new VectorSource()
17
+ const layer = new VectorLayer({
18
+ source,
19
+ style: (feature) => feature.get('midpointIndex') === selectedIndex ? null : [currentStyle],
20
+ zIndex: 101
21
+ })
22
+ map.addLayer(layer)
23
+
24
+ return {
25
+ update (geom) {
26
+ source.clear()
27
+ const midpoints = getMidpoints(geom)
28
+ const features = midpoints.map((coord, i) => {
29
+ const f = new Feature({ geometry: new Point(coord) })
30
+ f.set('midpointIndex', i)
31
+ return f
32
+ })
33
+ source.addFeatures(features)
34
+ },
35
+
36
+ setSelected (index) {
37
+ selectedIndex = index
38
+ source.changed()
39
+ },
40
+
41
+ updateStyle (newMidpointStyle) {
42
+ currentStyle = newMidpointStyle
43
+ source.changed()
44
+ },
45
+
46
+ getCoords () {
47
+ return source.getFeatures()
48
+ .sort((a, b) => a.get('midpointIndex') - b.get('midpointIndex'))
49
+ .map(f => f.getGeometry().getCoordinates())
50
+ },
51
+
52
+ remove () {
53
+ source.clear()
54
+ map.removeLayer(layer)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,171 @@
1
+ import { coordToPixel, pixelToCoord } from '../utils/olCoords.js'
2
+ import { createTouchTarget, applyTouchTargetColors, showTouchTarget, hideTouchTarget, isOnTouchTarget } from '../utils/touchTarget.js'
3
+ import { moveVertex } from './vertexOps.js'
4
+ import { findNearest } from './vertexHitTest.js'
5
+
6
+ const TAP_MOVE_THRESHOLD = 10
7
+ const TAP_TIME_THRESHOLD = 400
8
+ const TOUCH_TOLERANCE = 24
9
+
10
+ const wireTouchEvents = ({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap }) => {
11
+ let dragStartCoord = null
12
+ let dragStartIndex = null
13
+ let vertexTouchDelta = null
14
+ let targetTouchDelta = null
15
+ let tapStart = null
16
+
17
+ const onTouchstart = (e) => {
18
+ const touch = e.touches[0]
19
+ const onTarget = isOnTouchTarget(e.target)
20
+ tapStart = { x: touch.clientX, y: touch.clientY, time: Date.now(), onTarget }
21
+ if (!onTarget) {
22
+ return
23
+ }
24
+ const { selectedVertexIndex, vertices } = getState()
25
+ const vertex = vertices[selectedVertexIndex]
26
+ if (!vertex) {
27
+ return
28
+ }
29
+ const tOl = map.getEventPixel({ clientX: touch.clientX, clientY: touch.clientY })
30
+ const vertexPx = coordToPixel(map, vertex)
31
+ const style = getComputedStyle(targetEl)
32
+ const svgOlPx = cssToOl({ x: Number.parseFloat(style.left), y: Number.parseFloat(style.top) })
33
+ dragStartCoord = [...vertex]
34
+ dragStartIndex = selectedVertexIndex
35
+ vertexTouchDelta = { x: tOl[0] - vertexPx.x, y: tOl[1] - vertexPx.y }
36
+ targetTouchDelta = { x: tOl[0] - svgOlPx.x, y: tOl[1] - svgOlPx.y }
37
+ e.preventDefault()
38
+ }
39
+
40
+ const onTouchmove = (e) => {
41
+ if (!isOnTouchTarget(e.target) || dragStartIndex == null) {
42
+ return
43
+ }
44
+ e.preventDefault()
45
+ const tOl = map.getEventPixel({ clientX: e.touches[0].clientX, clientY: e.touches[0].clientY })
46
+ const rawCoord = pixelToCoord(map, { x: tOl[0] - vertexTouchDelta.x, y: tOl[1] - vertexTouchDelta.y })
47
+ const newCoord = snap ? snap.apply(rawCoord) : rawCoord
48
+ snap?.hideIndicator()
49
+ const { olFeature, vertices } = getState()
50
+ if (!olFeature) {
51
+ return
52
+ }
53
+ moveVertex(olFeature, dragStartIndex, newCoord)
54
+ setState({ vertices: vertices.map((c, i) => i === dragStartIndex ? newCoord : c) })
55
+ showTouchTarget(targetEl, olToCSS({ x: tOl[0] - targetTouchDelta.x, y: tOl[1] - targetTouchDelta.y }))
56
+ }
57
+
58
+ const onTouchend = (e) => {
59
+ const wasDragging = dragStartIndex != null
60
+ if (!wasDragging) {
61
+ if (tapStart && !tapStart.onTarget && e.changedTouches.length > 0) {
62
+ const t = e.changedTouches[0]
63
+ const dt = Date.now() - tapStart.time
64
+ if (Math.hypot(t.clientX - tapStart.x, t.clientY - tapStart.y) < TAP_MOVE_THRESHOLD && dt < TAP_TIME_THRESHOLD) {
65
+ const tOl = map.getEventPixel({ clientX: t.clientX, clientY: t.clientY })
66
+ const tapState = getState()
67
+ onTap?.(findNearest(map, tapState.vertices, tapState.midpoints, { x: tOl[0], y: tOl[1] }, TOUCH_TOLERANCE))
68
+ e.preventDefault()
69
+ }
70
+ }
71
+ tapStart = null
72
+ return
73
+ }
74
+ tapStart = null
75
+ const { vertices } = getState()
76
+ if (vertices[dragStartIndex] && dragStartCoord) {
77
+ onVertexMoved({ vertexIndex: dragStartIndex, previousCoord: dragStartCoord })
78
+ }
79
+ snap?.hideIndicator()
80
+ dragStartCoord = null; dragStartIndex = null; vertexTouchDelta = null; targetTouchDelta = null
81
+ e.preventDefault()
82
+ }
83
+
84
+ container.addEventListener('touchstart', onTouchstart, { passive: false })
85
+ container.addEventListener('touchmove', onTouchmove, { passive: false })
86
+ container.addEventListener('touchend', onTouchend, { passive: false })
87
+
88
+ return {
89
+ isDragging: () => dragStartIndex != null,
90
+ destroy () {
91
+ container.removeEventListener('touchstart', onTouchstart)
92
+ container.removeEventListener('touchmove', onTouchmove)
93
+ container.removeEventListener('touchend', onTouchend)
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Touch vertex drag handler for edit mode.
100
+ * Shows an SVG offset target below the finger so the vertex can be repositioned
101
+ * without finger occlusion. Tap on a vertex or midpoint selects it via onTap.
102
+ *
103
+ * @param {{ map, container, getState, setState, onVertexMoved, onTap, colors }} options
104
+ * @returns {{ updateTargetPosition, updateColors, hide, destroy }}
105
+ */
106
+ export const createTouchHandler = ({ map, container, getState, setState, onVertexMoved, onTap, colors, snap }) => {
107
+ const targetEl = createTouchTarget(container)
108
+ applyTouchTargetColors(targetEl, colors)
109
+
110
+ // OL pixel space is relative to ol-viewport at its pre-scale CSS size.
111
+ // Container CSS space is larger when a CSS transform scales up ol-viewport
112
+ // (e.g. scale(1.5) at medium map size makes OL pixels 1.5× smaller than CSS pixels).
113
+ const cssTx = { scale: 1, ox: 0, oy: 0 }
114
+ const olToCSS = (p) => ({ x: p.x * cssTx.scale + cssTx.ox, y: p.y * cssTx.scale + cssTx.oy })
115
+ const cssToOl = (p) => ({ x: (p.x - cssTx.ox) / cssTx.scale, y: (p.y - cssTx.oy) / cssTx.scale })
116
+
117
+ const syncCssTx = () => {
118
+ const vpEl = map.getViewport()
119
+ const vpRect = vpEl.getBoundingClientRect()
120
+ const cRect = container.getBoundingClientRect()
121
+ const vpScale = vpEl.offsetWidth > 0 ? vpRect.width / vpEl.offsetWidth : 1
122
+ const cScale = container.offsetWidth > 0 ? cRect.width / container.offsetWidth : 1
123
+ Object.assign(cssTx, {
124
+ scale: vpScale / cScale,
125
+ ox: (vpRect.left - cRect.left) / cScale,
126
+ oy: (vpRect.top - cRect.top) / cScale
127
+ })
128
+ }
129
+
130
+ const touchEvents = wireTouchEvents({ container, map, targetEl, olToCSS, cssToOl, getState, setState, onVertexMoved, onTap, snap })
131
+
132
+ const updateTargetPosition = () => {
133
+ const { selectedVertexIndex, vertices, interfaceType } = getState()
134
+ if (selectedVertexIndex < 0 || !vertices[selectedVertexIndex] || interfaceType !== 'touch') {
135
+ hideTouchTarget(targetEl)
136
+ return
137
+ }
138
+ const px = coordToPixel(map, vertices[selectedVertexIndex])
139
+ if (!px) {
140
+ hideTouchTarget(targetEl)
141
+ return
142
+ }
143
+ showTouchTarget(targetEl, olToCSS(px))
144
+ }
145
+
146
+ // Reposition on every render — keeps target anchored during pinch-zoom and pan.
147
+ // Skipped during drag since touchmove handles position directly.
148
+ const onPostrender = () => {
149
+ const { selectedVertexIndex, interfaceType } = getState()
150
+ if (selectedVertexIndex >= 0 && !touchEvents.isDragging() && interfaceType === 'touch') {
151
+ updateTargetPosition()
152
+ }
153
+ }
154
+ map.on('postrender', onPostrender)
155
+
156
+ const onSizeChange = () => { syncCssTx(); map.once('postrender', updateTargetPosition) }
157
+ map.on('change:size', onSizeChange)
158
+ syncCssTx()
159
+
160
+ return {
161
+ updateTargetPosition,
162
+ updateColors (newColors) { applyTouchTargetColors(targetEl, newColors) },
163
+ hide () { hideTouchTarget(targetEl) },
164
+ destroy () {
165
+ map.un('change:size', onSizeChange)
166
+ map.un('postrender', onPostrender)
167
+ touchEvents.destroy()
168
+ hideTouchTarget(targetEl)
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ getRingSegments,
3
+ getSegmentForIndex,
4
+ getModifiableCoords
5
+ } from '../utils/geometryHelpers.js'
6
+
7
+ /**
8
+ * Undo handlers for edit mode.
9
+ * Each operation receives the OL feature and the saved op payload,
10
+ * mutates the geometry, and returns the vertex index to re-select (or -1).
11
+ */
12
+
13
+ export const undoMoveVertex = (olFeature, op) => {
14
+ const { vertexIndex, previousCoord } = op
15
+ const geom = olFeature.getGeometry()
16
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
17
+ const segments = getRingSegments(geojsonGeom)
18
+ const result = getSegmentForIndex(segments, vertexIndex)
19
+ if (!result) {
20
+ return -1
21
+ }
22
+
23
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
24
+ ring[result.localIdx] = [...previousCoord]
25
+ if (result.segment.closed && result.localIdx === 0) {
26
+ ring[ring.length - 1] = [...previousCoord]
27
+ }
28
+ geom.setCoordinates(geojsonGeom.coordinates)
29
+ return vertexIndex
30
+ }
31
+
32
+ export const undoInsertVertex = (olFeature, op) => {
33
+ const { vertexIndex } = op
34
+ const geom = olFeature.getGeometry()
35
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
36
+ const segments = getRingSegments(geojsonGeom)
37
+ const result = getSegmentForIndex(segments, vertexIndex)
38
+ if (!result) {
39
+ return -1
40
+ }
41
+
42
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
43
+ ring.splice(result.localIdx, 1)
44
+ if (result.segment.closed) {
45
+ ring[ring.length - 1] = [...ring[0]]
46
+ }
47
+ geom.setCoordinates(geojsonGeom.coordinates)
48
+ return -1
49
+ }
50
+
51
+ export const undoDeleteVertex = (olFeature, op) => {
52
+ const { vertexIndex, deletedCoord } = op
53
+ const geom = olFeature.getGeometry()
54
+ const geojsonGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
55
+ const segments = getRingSegments(geojsonGeom)
56
+
57
+ let result = getSegmentForIndex(segments, vertexIndex)
58
+ // Vertex might be at a segment boundary after deletion shifted indices
59
+ if (!result) {
60
+ for (const seg of segments) {
61
+ if (vertexIndex === seg.start + seg.length) {
62
+ result = { segment: seg, localIdx: seg.length }
63
+ break
64
+ }
65
+ }
66
+ }
67
+ if (!result) {
68
+ return -1
69
+ }
70
+
71
+ const ring = getModifiableCoords(geojsonGeom, result.segment.path)
72
+ ring.splice(result.localIdx, 0, [...deletedCoord])
73
+ if (result.segment.closed) {
74
+ ring[ring.length - 1] = [...ring[0]]
75
+ }
76
+ geom.setCoordinates(geojsonGeom.coordinates)
77
+ return vertexIndex
78
+ }
79
+
80
+ /**
81
+ * Dispatch the correct undo handler based on operation type.
82
+ * @returns {number} vertex index to re-select after undo, or -1 for none
83
+ */
84
+ export const applyUndo = (olFeature, op) => {
85
+ switch (op.type) {
86
+ case 'move_vertex':
87
+ return undoMoveVertex(olFeature, op)
88
+ case 'insert_vertex':
89
+ return undoInsertVertex(olFeature, op)
90
+ case 'delete_vertex':
91
+ return undoDeleteVertex(olFeature, op)
92
+ default:
93
+ return -1
94
+ }
95
+ }
@@ -0,0 +1,72 @@
1
+ import { coordToPixel, pixelDist } from '../utils/olCoords.js'
2
+
3
+ const PIXEL_TOLERANCE = 12
4
+
5
+ /**
6
+ * Find the nearest vertex to a screen pixel within tolerance.
7
+ *
8
+ * @param {import('ol/Map').default} map
9
+ * @param {number[][]} vertices - flat coordinate array [[e,n], ...]
10
+ * @param {{ x: number, y: number }} pixel
11
+ * @param {number} [tolerance]
12
+ * @returns {{ index: number, type: 'vertex' } | null}
13
+ */
14
+ export const findNearestVertex = (map, vertices, pixel, tolerance = PIXEL_TOLERANCE) => {
15
+ let bestIdx = -1
16
+ let bestDist = tolerance
17
+
18
+ vertices.forEach((coord, i) => {
19
+ const px = coordToPixel(map, coord)
20
+ if (!px) {
21
+ return
22
+ }
23
+ const d = pixelDist(px, pixel)
24
+ if (d < bestDist) {
25
+ bestDist = d
26
+ bestIdx = i
27
+ }
28
+ })
29
+
30
+ return bestIdx >= 0 ? { index: bestIdx, type: 'vertex' } : null
31
+ }
32
+
33
+ /**
34
+ * Find the nearest midpoint to a screen pixel within tolerance.
35
+ *
36
+ * @param {import('ol/Map').default} map
37
+ * @param {number[][]} midpoints - midpoint coordinate array
38
+ * @param {{ x: number, y: number }} pixel
39
+ * @param {number} vertexCount - number of actual vertices (midpoint index offset)
40
+ * @param {number} [tolerance]
41
+ * @returns {{ index: number, type: 'midpoint' } | null}
42
+ */
43
+ export const findNearestMidpoint = (map, midpoints, pixel, vertexCount, tolerance = PIXEL_TOLERANCE) => {
44
+ let bestIdx = -1
45
+ let bestDist = tolerance
46
+
47
+ midpoints.forEach((coord, i) => {
48
+ const px = coordToPixel(map, coord)
49
+ if (!px) {
50
+ return
51
+ }
52
+ const d = pixelDist(px, pixel)
53
+ if (d < bestDist) {
54
+ bestDist = d
55
+ bestIdx = i
56
+ }
57
+ })
58
+
59
+ return bestIdx >= 0 ? { index: vertexCount + bestIdx, type: 'midpoint' } : null
60
+ }
61
+
62
+ /**
63
+ * Find the nearest vertex or midpoint to a pixel.
64
+ * Vertices take priority when equidistant.
65
+ *
66
+ * @param {number} [tolerance]
67
+ * @returns {{ index: number, type: 'vertex'|'midpoint' } | null}
68
+ */
69
+ export const findNearest = (map, vertices, midpoints, pixel, tolerance = PIXEL_TOLERANCE) => {
70
+ return findNearestVertex(map, vertices, pixel, tolerance) ??
71
+ findNearestMidpoint(map, midpoints, pixel, vertices.length, tolerance)
72
+ }
@@ -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
+ }