@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.
- package/dist/css/index.css +1 -1
- 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/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- 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/components/Hints/Hints.test.jsx +2 -2
- package/src/App/components/PopupMenu/PopupMenu.module.scss +6 -0
- package/src/App/controls/keyboardActions.js +2 -2
- package/src/App/controls/keyboardMappings.js +1 -2
- package/src/App/controls/keyboardShortcuts.js +5 -3
- package/src/App/controls/keyboardShortcuts.test.js +24 -0
- package/src/InteractiveMap/InteractiveMap.js +38 -10
- package/src/InteractiveMap/InteractiveMap.test.js +282 -356
- package/src/InteractiveMap/deviceChecker.js +3 -2
- package/src/InteractiveMap/deviceChecker.test.js +7 -10
- package/src/InteractiveMap/historyManager.js +7 -0
- package/src/InteractiveMap/historyManager.test.js +11 -0
- package/src/config/defaults.js +1 -1
- package/src/utils/htmlToPlainText.js +11 -0
- package/src/utils/htmlToPlainText.test.js +5 -0
- 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
|
+
}
|