@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.
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/assets/css/docusaurus.scss +4 -0
- package/package.json +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/defaults.js +14 -14
- package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
- package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
- package/plugins/beta/draw-ml/src/reducer.js +4 -1
- package/plugins/beta/draw-ml/src/styles.js +42 -39
- package/plugins/beta/draw-ol/dist/css/index.css +13 -0
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
- package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
- package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
- package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
- package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
- package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
- package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
- package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
- package/plugins/beta/draw-ol/src/core/styles.js +98 -0
- package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
- package/plugins/beta/draw-ol/src/defaults.js +16 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
- package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
- package/plugins/beta/draw-ol/src/draw.scss +17 -0
- package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
- package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
- package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
- package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
- package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
- package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
- package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
- package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
- package/plugins/beta/draw-ol/src/events.js +114 -0
- package/plugins/beta/draw-ol/src/index.js +12 -0
- package/plugins/beta/draw-ol/src/manifest.js +121 -0
- package/plugins/beta/draw-ol/src/olDraw.js +38 -0
- package/plugins/beta/draw-ol/src/reducer.js +41 -0
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
- package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
- package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
- package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
- package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
- package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
- package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
- package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
- package/plugins/interact/src/reducer.js +7 -0
- package/plugins/interact/src/reducer.test.js +16 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/appEvents.js +9 -13
- package/providers/beta/openlayers/src/appEvents.test.js +25 -17
- package/providers/beta/openlayers/src/mapEvents.js +32 -16
- package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
- package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
- package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
- package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
- package/rollup.esm.mjs +6 -0
- package/src/App/controls/keyboardActions.js +2 -2
- package/src/InteractiveMap/InteractiveMap.js +38 -10
- package/src/InteractiveMap/InteractiveMap.test.js +282 -356
- package/src/InteractiveMap/historyManager.js +7 -0
- package/src/InteractiveMap/historyManager.test.js +11 -0
- 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
|
+
}
|