@defra/interactive-map 0.0.26-alpha → 0.0.27-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 (92) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/esm/im-shell.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/assets/css/docusaurus.scss +4 -0
  6. package/package.json +1 -1
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/defaults.js +14 -14
  10. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  11. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  13. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  14. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  15. package/plugins/beta/draw-ml/src/styles.js +42 -39
  16. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  17. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  18. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  22. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  23. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  24. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  25. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  26. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  27. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  28. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  29. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  30. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  31. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  32. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  33. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  34. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  35. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  36. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  37. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  38. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  39. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  40. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  41. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  44. package/plugins/beta/draw-ol/src/events.js +114 -0
  45. package/plugins/beta/draw-ol/src/index.js +12 -0
  46. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  47. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  48. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  49. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  50. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  51. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  52. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  53. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  54. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  55. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  56. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  57. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  58. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  59. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/src/defaults.js +1 -0
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  65. package/plugins/interact/src/reducer.js +7 -0
  66. package/plugins/interact/src/reducer.test.js +16 -0
  67. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  68. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  69. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  70. package/providers/beta/openlayers/src/appEvents.js +9 -13
  71. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  72. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  73. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  74. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  75. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  76. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  77. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  78. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  79. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  80. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  81. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  82. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  83. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  84. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  85. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  86. package/rollup.esm.mjs +6 -0
  87. package/src/App/controls/keyboardActions.js +2 -2
  88. package/src/InteractiveMap/InteractiveMap.js +38 -10
  89. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  90. package/src/InteractiveMap/historyManager.js +7 -0
  91. package/src/InteractiveMap/historyManager.test.js +11 -0
  92. package/webpack.dev.mjs +3 -1
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Input handling for draw mode: touch, keyboard, and button events.
3
+ *
4
+ * Mouse/pointer drawing is handled entirely by OL's Draw interaction.
5
+ * This module handles the crosshair-based input path (touch + keyboard)
6
+ * and the Done / Add Point / Cancel button wiring.
7
+ */
8
+
9
+ import { coordToPixel, pixelDist } from '../utils/olCoords.js'
10
+
11
+ const SNAP_TOLERANCE = 12 // pixels
12
+ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
13
+
14
+ const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => {
15
+ if (geometryType !== 'Polygon' || sketchCoords.length < 4) {
16
+ return false
17
+ }
18
+ const firstCoord = sketchCoords[0]
19
+ const currentPixel = coordToPixel(map, coord)
20
+ const firstPixel = coordToPixel(map, firstCoord)
21
+ if (!currentPixel || !firstPixel) {
22
+ return false
23
+ }
24
+ return pixelDist(currentPixel, firstPixel) < SNAP_TOLERANCE
25
+ }
26
+
27
+ const applyRubberbanding = (geom, centerCoord) => {
28
+ if (geom.getType() === 'LineString') {
29
+ const updated = [...geom.getCoordinates()]
30
+ updated[updated.length - 1] = centerCoord
31
+ geom.setCoordinates(updated)
32
+ } else if (geom.getType() === 'Polygon') {
33
+ const updated = geom.getCoordinates().map((ring, i) => {
34
+ if (i !== 0) {
35
+ return ring
36
+ }
37
+ const r = [...ring]
38
+ r[r.length - 1] = centerCoord
39
+ return r
40
+ })
41
+ geom.setCoordinates(updated)
42
+ } else {
43
+ // No action
44
+ }
45
+ }
46
+
47
+ const wireInputEvents = ({
48
+ container, addVertexButtonId, olView, onUndo,
49
+ getInterfaceType, setInterfaceType, clearLastCoord,
50
+ updateRubberbanding, placeVertex
51
+ }) => {
52
+ const onCenterChange = () => {
53
+ if (getInterfaceType() !== 'pointer') {
54
+ updateRubberbanding()
55
+ }
56
+ }
57
+ olView?.on('change:center', onCenterChange)
58
+
59
+ const onKeydown = (e) => {
60
+ if (!container.contains(document.activeElement)) {
61
+ return
62
+ }
63
+ if (ARROW_KEYS.has(e.key)) {
64
+ setInterfaceType('keyboard')
65
+ return
66
+ }
67
+ if (e.key === 'Enter') {
68
+ e.preventDefault()
69
+ setInterfaceType('keyboard')
70
+ placeVertex()
71
+ }
72
+ if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
73
+ e.preventDefault()
74
+ onUndo?.()
75
+ }
76
+ }
77
+
78
+ const onButtonClick = (e) => {
79
+ if (addVertexButtonId && e.target.closest(`#${addVertexButtonId}`)) {
80
+ placeVertex()
81
+ }
82
+ }
83
+
84
+ const onPointerdown = (e) => {
85
+ if (e.pointerType !== 'touch') {
86
+ setInterfaceType('pointer')
87
+ clearLastCoord()
88
+ }
89
+ }
90
+
91
+ const onTouchstart = () => {
92
+ setInterfaceType('touch')
93
+ }
94
+
95
+ const onPointerMove = () => {
96
+ if (getInterfaceType() === 'pointer') {
97
+ return
98
+ }
99
+ updateRubberbanding()
100
+ }
101
+
102
+ globalThis.addEventListener('keydown', onKeydown)
103
+ globalThis.addEventListener('click', onButtonClick)
104
+ container.addEventListener('pointerdown', onPointerdown)
105
+ container.addEventListener('touchstart', onTouchstart, { passive: true })
106
+ container.addEventListener('pointermove', onPointerMove)
107
+
108
+ return {
109
+ destroy () {
110
+ olView?.un('change:center', onCenterChange)
111
+ globalThis.removeEventListener('keydown', onKeydown)
112
+ globalThis.removeEventListener('click', onButtonClick)
113
+ container.removeEventListener('pointerdown', onPointerdown)
114
+ container.removeEventListener('touchstart', onTouchstart)
115
+ container.removeEventListener('pointermove', onPointerMove)
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * @param {object} params
122
+ * @param {import('ol/interaction/Draw').default} params.drawInteraction
123
+ * @param {object} params.options - { container, interfaceType, addVertexButtonId, mapProvider, snap }
124
+ * @returns {{ getInterfaceType: () => string, destroy: () => void }}
125
+ */
126
+ export const createDrawInput = ({ drawInteraction, options }) => {
127
+ const { container, addVertexButtonId, mapProvider, snap, onUndo } = options
128
+ let interfaceType = options.interfaceType
129
+ let sketchFeature = null
130
+ let lastPlacedCoord = null
131
+
132
+ drawInteraction.on('drawstart', (e) => {
133
+ sketchFeature = e.feature
134
+ lastPlacedCoord = null
135
+ })
136
+ drawInteraction.on('drawend', () => {
137
+ sketchFeature = null
138
+ lastPlacedCoord = null
139
+ })
140
+ drawInteraction.on('drawabort', () => {
141
+ sketchFeature = null
142
+ lastPlacedCoord = null
143
+ })
144
+
145
+ const updateRubberbanding = () => {
146
+ if (!sketchFeature) {
147
+ return
148
+ }
149
+ const geom = sketchFeature.getGeometry()
150
+ const coords = geom.getCoordinates()
151
+ if (!coords.length) {
152
+ return
153
+ }
154
+ const raw = mapProvider.getCenter()
155
+ const centerCoord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw
156
+ applyRubberbanding(geom, centerCoord)
157
+ }
158
+
159
+ const placeVertex = () => {
160
+ const raw = mapProvider.getCenter()
161
+ const coord = (interfaceType !== 'pointer' && snap) ? snap.apply(raw) : raw
162
+ snap?.hideIndicator()
163
+ if (sketchFeature) {
164
+ const geom = sketchFeature.getGeometry()
165
+ const rawCoords = geom.getCoordinates()
166
+ const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords
167
+ if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) {
168
+ drawInteraction.finishDrawing()
169
+ lastPlacedCoord = null
170
+ return
171
+ }
172
+ if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) {
173
+ drawInteraction.finishDrawing()
174
+ return
175
+ }
176
+ }
177
+ drawInteraction.appendCoordinates([coord])
178
+ lastPlacedCoord = coord
179
+ }
180
+
181
+ const events = wireInputEvents({
182
+ container,
183
+ addVertexButtonId,
184
+ olView: drawInteraction.getMap()?.getView(),
185
+ onUndo,
186
+ getInterfaceType: () => interfaceType,
187
+ setInterfaceType: (t) => { interfaceType = t },
188
+ clearLastCoord: () => { lastPlacedCoord = null },
189
+ updateRubberbanding,
190
+ placeVertex
191
+ })
192
+
193
+ return {
194
+ getInterfaceType: () => interfaceType,
195
+ destroy () {
196
+ events.destroy()
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,17 @@
1
+ // Draw plugin styles for OpenLayers
2
+
3
+ .im-draw-touch-target {
4
+ color: #3b82f6;
5
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
6
+ cursor: grab;
7
+
8
+ &:active {
9
+ cursor: grabbing;
10
+ }
11
+ }
12
+
13
+ // Accessibility: focus visible on buttons
14
+ .im-draw-button:focus-visible {
15
+ outline: 2px solid #3b82f6;
16
+ outline-offset: 2px;
17
+ }
@@ -0,0 +1,424 @@
1
+ import Modify from 'ol/interaction/Modify.js'
2
+ import Collection from 'ol/Collection.js'
3
+ import VectorSource from 'ol/source/Vector.js'
4
+ import VectorLayer from 'ol/layer/Vector.js'
5
+ import Feature from 'ol/Feature.js'
6
+ import Point from 'ol/geom/Point.js'
7
+ import { createMidpointLayer } from './midpointLayer.js'
8
+ import { createVertexLayer } from './vertexLayer.js'
9
+ import { createTouchHandler } from './touchHandler.js'
10
+ import { createKeyboardHandler } from './keyboardHandler.js'
11
+ import { findNearest } from './vertexHitTest.js'
12
+ import { deleteVertex, insertAtMidpoint } from './vertexOps.js'
13
+ import { applyUndo } from './undoOps.js'
14
+ import { getCoords, getMidpoints } from '../utils/geometryHelpers.js'
15
+
16
+ /**
17
+ * Edit vertex mode — handles edit_vertex.
18
+ *
19
+ * OL Modify handles pointer/mouse vertex dragging natively.
20
+ * touchHandler covers touch drag via the SVG offset target.
21
+ * keyboardHandler covers keyboard navigation and nudging.
22
+ *
23
+ * @returns {{ done, cancel, undo, deleteVertex: fn, destroy }}
24
+ */
25
+ export const createEditMode = ({ map, manager, options }) => {
26
+ const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options
27
+ const { store, undoStack } = manager
28
+
29
+ const olFeature = store.getOL(featureId)
30
+ if (!olFeature) {
31
+ return null
32
+ }
33
+
34
+ const originalFeatureStyle = olFeature.getStyle()
35
+ olFeature.setStyle(manager.styles.editFeatureStyle)
36
+
37
+ // Mutable state shared across sub-handlers
38
+ const state = {
39
+ olFeature,
40
+ selectedVertexIndex: -1,
41
+ selectedVertexType: null,
42
+ vertices: [],
43
+ midpoints: [],
44
+ interfaceType: interfaceType ?? 'pointer'
45
+ }
46
+
47
+ const getState = () => state
48
+ let onDeselect = null // set after touchHandler is created; hides offset target on any deselect
49
+ let onUpdate = null // set after touchHandler is created; repositions offset target when vertex coords change
50
+
51
+ const setState = (updates) => {
52
+ Object.assign(state, updates)
53
+ if (updates.selectedVertexIndex !== undefined) {
54
+ vertexLayer.setSelected(state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1)
55
+ midpointLayer.setSelected(
56
+ state.selectedVertexType === 'midpoint' ? state.selectedVertexIndex - state.vertices.length : -1
57
+ )
58
+ if (state.selectedVertexIndex < 0) {
59
+ onDeselect?.()
60
+ }
61
+ updateActiveLayer()
62
+ manager.emit('vertexselection', {
63
+ index: state.selectedVertexType === 'vertex' ? state.selectedVertexIndex : -1,
64
+ numVertices: state.vertices.length
65
+ })
66
+ }
67
+ if (updates.vertices !== undefined) {
68
+ const plainGeom = {
69
+ type: olFeature.getGeometry().getType(),
70
+ coordinates: olFeature.getGeometry().getCoordinates()
71
+ }
72
+ midpointLayer.update(plainGeom)
73
+ vertexLayer.update(plainGeom)
74
+ state.midpoints = midpointLayer.getCoords()
75
+ updateActiveLayer()
76
+ onUpdate?.()
77
+ map.render()
78
+ }
79
+ }
80
+
81
+ // Lightweight per-frame update during drag — updates layers without emitting events
82
+ const updateLayersFromGeom = () => {
83
+ const geom = olFeature.getGeometry()
84
+ const plainGeom = { type: geom.getType(), coordinates: geom.getCoordinates() }
85
+ state.vertices = getCoords(plainGeom)
86
+ state.midpoints = getMidpoints(plainGeom)
87
+ midpointLayer.update(plainGeom)
88
+ vertexLayer.update(plainGeom)
89
+ updateActiveLayer()
90
+ }
91
+
92
+ const syncGeom = () => {
93
+ updateLayersFromGeom()
94
+ manager.emit('vertexchange', { numVertices: state.vertices.length })
95
+ manager.emit('update', store.toGeoJSON(olFeature))
96
+ }
97
+
98
+ // Keep overlay layers in sync on every geometry change (e.g. during pointer drag)
99
+ const onGeometryChange = () => updateLayersFromGeom()
100
+ olFeature.getGeometry().on('change', onGeometryChange)
101
+
102
+ // --- OL Modify (handles pointer vertex drag + midpoint insertion natively) ---
103
+ const collection = new Collection([olFeature])
104
+ const modifyCondition = (mapBrowserEvent) => {
105
+ if (state.interfaceType === 'touch') {
106
+ return false
107
+ }
108
+ const olPixel = map.getEventPixel(mapBrowserEvent.originalEvent)
109
+ return findNearest(map, state.vertices, state.midpoints, { x: olPixel[0], y: olPixel[1] }) !== null
110
+ }
111
+ const modifyInteraction = new Modify({
112
+ features: collection,
113
+ style: () => [], // vertex circles rendered by vertexLayer instead
114
+ pixelTolerance: 12,
115
+ // Only activate when clicking on a vertex or midpoint circle, not anywhere on a segment.
116
+ // Touch drags are handled by touchHandler; returning false here lets them pass through to
117
+ // DragPan (touchHandler uses preventDefault on the offset target to stop unwanted panning).
118
+ condition: modifyCondition
119
+ })
120
+ map.addInteraction(modifyInteraction)
121
+
122
+ // Track move start for undo
123
+ let modifyStartCoords = null
124
+
125
+ modifyInteraction.on('modifystart', () => {
126
+ if (state.interfaceType === 'touch') {
127
+ return
128
+ }
129
+ modifyStartCoords = state.vertices.map(c => [...c])
130
+ })
131
+
132
+ modifyInteraction.on('modifyend', () => {
133
+ if (state.interfaceType === 'touch') {
134
+ return
135
+ }
136
+ const prevCoords = modifyStartCoords
137
+ syncGeom()
138
+ if (!prevCoords) {
139
+ return
140
+ }
141
+
142
+ const newCoords = state.vertices
143
+ if (newCoords.length > prevCoords.length) {
144
+ // Midpoint drag inserted a vertex — find it and select it
145
+ const insertedIdx = newCoords.findIndex((c, i) => !prevCoords[i] || c[0] !== prevCoords[i][0])
146
+ const idx = Math.max(0, insertedIdx)
147
+ undoStack.push({ type: 'insert_vertex', vertexIndex: idx })
148
+ setState({ selectedVertexIndex: idx, selectedVertexType: 'vertex' })
149
+ } else if (newCoords.length === prevCoords.length) {
150
+ const movedIdx = newCoords.findIndex((c, i) => c[0] !== prevCoords[i][0] || c[1] !== prevCoords[i][1])
151
+ if (movedIdx >= 0) {
152
+ undoStack.push({ type: 'move_vertex', vertexIndex: movedIdx, previousCoord: prevCoords[movedIdx] })
153
+ setState({ selectedVertexIndex: movedIdx, selectedVertexType: 'vertex' })
154
+ }
155
+ } else {
156
+ // no change in vertex count (shouldn't happen, but satisfies linter)
157
+ }
158
+ modifyStartCoords = null
159
+ })
160
+
161
+ // --- Vertex + midpoint layers (always-visible handles) ---
162
+ const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle)
163
+ const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle)
164
+
165
+ // --- Active selection overlay — always on top of vertex and midpoint layers ---
166
+ const activeSource = new VectorSource()
167
+ const activeLayer = new VectorLayer({ source: activeSource, zIndex: 103 })
168
+ map.addLayer(activeLayer)
169
+
170
+ const updateActiveLayer = () => {
171
+ activeSource.clear()
172
+ const { selectedVertexIndex, selectedVertexType, vertices, midpoints } = state
173
+ if (selectedVertexIndex < 0) {
174
+ return
175
+ }
176
+ let coord, style
177
+ if (selectedVertexType === 'vertex') {
178
+ coord = vertices[selectedVertexIndex]
179
+ style = manager.styles.selectedVertexStyle
180
+ } else if (selectedVertexType === 'midpoint') {
181
+ coord = midpoints[selectedVertexIndex - vertices.length]
182
+ style = manager.styles.selectedMidpointStyle
183
+ } else {
184
+ return
185
+ }
186
+ if (!coord) {
187
+ return
188
+ }
189
+ const f = new Feature({ geometry: new Point(coord) })
190
+ f.setStyle(style)
191
+ activeSource.addFeature(f)
192
+ }
193
+
194
+ syncGeom() // initial populate
195
+
196
+ // --- Style hot-swap when map style changes ---
197
+ const onStylesChanged = (styles) => {
198
+ olFeature.setStyle(styles.editFeatureStyle)
199
+ vertexLayer.updateStyle(styles.vertexStyle)
200
+ midpointLayer.updateStyle(styles.midpointStyle)
201
+ updateActiveLayer()
202
+ touchHandler.updateColors(manager.colors)
203
+ }
204
+ manager.on('styleschanged', onStylesChanged)
205
+
206
+ // --- Pointer hit detection ---
207
+ const onPointerdown = (e) => {
208
+ if (e.pointerType === 'touch') {
209
+ state.interfaceType = 'touch'
210
+ touchHandler.updateTargetPosition()
211
+ return
212
+ }
213
+ state.interfaceType = 'pointer'
214
+
215
+ const olPixel = map.getEventPixel(e)
216
+ const pixel = { x: olPixel[0], y: olPixel[1] }
217
+ const hit = findNearest(map, state.vertices, state.midpoints, pixel)
218
+ if (hit?.type === 'vertex') {
219
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
220
+ }
221
+ }
222
+
223
+ // click fires after OL Modify finishes, so state.vertices reflects any insertions/moves
224
+ const onContainerClick = (e) => {
225
+ if (state.interfaceType === 'touch') {
226
+ return
227
+ }
228
+ const olPixel = map.getEventPixel(e)
229
+ const pixel = { x: olPixel[0], y: olPixel[1] }
230
+ const hit = findNearest(map, state.vertices, state.midpoints, pixel)
231
+ if (hit?.type === 'vertex') {
232
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
233
+ } else if (hit?.type === 'midpoint') {
234
+ // modifyend already selected the inserted vertex — nothing to do here
235
+ } else {
236
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
237
+ }
238
+ }
239
+
240
+ // Switch to pointer mode and hide the touch target as soon as the mouse moves.
241
+ const onPointerMove = (e) => {
242
+ if (e.pointerType !== 'mouse') {
243
+ return
244
+ }
245
+ if (state.interfaceType === 'pointer') {
246
+ return
247
+ }
248
+ state.interfaceType = 'pointer'
249
+ touchHandler.hide()
250
+ }
251
+
252
+ container.addEventListener('pointerdown', onPointerdown)
253
+ container.addEventListener('pointerenter', onPointerMove)
254
+ container.addEventListener('pointermove', onPointerMove)
255
+ container.addEventListener('click', onContainerClick)
256
+
257
+ // --- Button click (delete vertex) ---
258
+ const onButtonClick = (e) => {
259
+ if (deleteVertexButtonId && e.target.closest(`#${deleteVertexButtonId}`)) {
260
+ doDeleteVertex()
261
+ }
262
+ }
263
+ globalThis.addEventListener('click', onButtonClick)
264
+
265
+ // --- Operations ---
266
+
267
+ const doDeleteVertex = () => {
268
+ if (state.selectedVertexType !== 'vertex' || state.selectedVertexIndex < 0) {
269
+ return
270
+ }
271
+ const result = deleteVertex(olFeature, state.selectedVertexIndex)
272
+ if (!result) {
273
+ return
274
+ }
275
+ undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord })
276
+ syncGeom()
277
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
278
+ }
279
+
280
+ const doUndo = () => {
281
+ const op = undoStack.pop()
282
+ if (!op) {
283
+ return
284
+ }
285
+ const previousIndex = state.selectedVertexIndex
286
+ const restoredIndex = applyUndo(olFeature, op)
287
+ syncGeom()
288
+ // Only re-select if a vertex was already active — undo must not create a new selection
289
+ const newIndex = previousIndex >= 0 ? restoredIndex : -1
290
+ setState({
291
+ selectedVertexIndex: newIndex,
292
+ selectedVertexType: newIndex >= 0 ? 'vertex' : null
293
+ })
294
+ if (previousIndex >= 0 && newIndex >= 0) {
295
+ onUpdate?.()
296
+ }
297
+ }
298
+
299
+ // --- Touch handler ---
300
+ const touchHandler = createTouchHandler({
301
+ map,
302
+ container,
303
+ getState,
304
+ setState,
305
+ colors: manager.colors,
306
+ snap,
307
+ onVertexMoved ({ vertexIndex, previousCoord }) {
308
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
309
+ syncGeom()
310
+ setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' })
311
+ touchHandler.updateTargetPosition()
312
+ },
313
+ onTap (hit) {
314
+ if (!hit) {
315
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
316
+ return
317
+ }
318
+ if (hit.type === 'vertex') {
319
+ setState({ selectedVertexIndex: hit.index, selectedVertexType: 'vertex' })
320
+ touchHandler.updateTargetPosition()
321
+ return
322
+ }
323
+ if (hit.type === 'midpoint') {
324
+ const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length)
325
+ if (!result) {
326
+ return
327
+ }
328
+ undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex })
329
+ syncGeom()
330
+ setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: 'vertex' })
331
+ touchHandler.updateTargetPosition()
332
+ }
333
+ }
334
+ })
335
+ onDeselect = () => touchHandler.hide()
336
+ onUpdate = () => {
337
+ if (state.interfaceType === 'touch') {
338
+ touchHandler.updateTargetPosition()
339
+ }
340
+ }
341
+
342
+ // Reposition the touch target after OL re-renders with the new size.
343
+ // change:size fires before the render, so we wait for postrender to get
344
+ // correct pixel coords from getPixelFromCoordinate.
345
+ const onMapSizeChange = () => {
346
+ if (state.interfaceType !== 'touch' || state.selectedVertexIndex < 0) {
347
+ return
348
+ }
349
+ map.once('postrender', () => touchHandler.updateTargetPosition())
350
+ }
351
+ map.on('change:size', onMapSizeChange)
352
+
353
+ // --- Keyboard handler ---
354
+ const keyboardHandler = createKeyboardHandler({
355
+ map,
356
+ getState,
357
+ setState,
358
+ snap,
359
+ onVertexMoved ({ vertexIndex, previousCoord }) {
360
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
361
+ syncGeom()
362
+ setState({ selectedVertexIndex: vertexIndex, selectedVertexType: 'vertex' })
363
+ },
364
+ onInserted ({ insertedIndex }) {
365
+ undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex })
366
+ syncGeom()
367
+ },
368
+ onDeleted: doDeleteVertex,
369
+ onUndo: doUndo,
370
+ onKeyboardActive () {
371
+ if (state.interfaceType === 'keyboard') {
372
+ return
373
+ }
374
+ state.interfaceType = 'keyboard'
375
+ touchHandler.hide()
376
+ container.focus({ preventScroll: true })
377
+ }
378
+ })
379
+
380
+ return {
381
+ setInterfaceType (type) {
382
+ if (type === state.interfaceType) {
383
+ return
384
+ }
385
+ state.interfaceType = type
386
+ if (type === 'touch') {
387
+ touchHandler.updateTargetPosition()
388
+ } else {
389
+ touchHandler.hide()
390
+ }
391
+ },
392
+
393
+ done () {
394
+ manager.emit('editfinish', store.toGeoJSON(olFeature))
395
+ },
396
+
397
+ cancel () {
398
+ // Restore original feature from store (re-read from initial state)
399
+ // The original was stored as tempFeature in reducer — events.js handles restore
400
+ },
401
+
402
+ undo: doUndo,
403
+ deleteVertex: doDeleteVertex,
404
+
405
+ destroy () {
406
+ olFeature.setStyle(originalFeatureStyle)
407
+ olFeature.getGeometry().un('change', onGeometryChange)
408
+ manager.off('styleschanged', onStylesChanged)
409
+ container.removeEventListener('pointerdown', onPointerdown)
410
+ container.removeEventListener('pointerenter', onPointerMove)
411
+ container.removeEventListener('pointermove', onPointerMove)
412
+ container.removeEventListener('click', onContainerClick)
413
+ globalThis.removeEventListener('click', onButtonClick)
414
+ map.un('change:size', onMapSizeChange)
415
+ map.removeInteraction(modifyInteraction)
416
+ activeSource.clear()
417
+ map.removeLayer(activeLayer)
418
+ midpointLayer.remove()
419
+ vertexLayer.remove()
420
+ touchHandler.destroy()
421
+ keyboardHandler.destroy()
422
+ }
423
+ }
424
+ }