@defra/interactive-map 0.0.32-alpha → 0.0.33-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/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/map-style-config.md +27 -3
- package/docs/assets/images/hero.png +0 -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/DrawInit.jsx +11 -0
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -25
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +5 -4
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +49 -21
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +3 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +57 -49
- package/plugins/beta/draw-ol/src/draw/drawInput.js +76 -32
- package/plugins/beta/draw-ol/src/edit/EditMode.js +4 -4
- package/plugins/beta/draw-ol/src/manifest.js +5 -1
- package/plugins/beta/draw-ol/src/reducer.js +1 -1
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +75 -31
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +23 -46
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +30 -29
- package/plugins/beta/draw-ol/src/snap/snapManager.js +10 -1
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +9 -3
- package/plugins/beta/draw-ol/src/utils/olCoords.js +2 -2
- package/plugins/beta/draw-ol/src/utils/spatial.js +4 -8
- 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/utils/tileLayers.js +29 -2
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +53 -1
- package/src/App/components/MapButton/MapButton.jsx +14 -10
- package/src/App/components/MapButton/MapButton.module.scss +1 -1
- package/src/App/components/MapButton/MapButton.test.jsx +1 -1
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +79 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +61 -12
- package/src/types.js +12 -3
- package/src/utils/findNextTabStop.js +3 -3
- package/src/utils/findNextTabStop.test.js +25 -1
- package/webpack.dev.mjs +1 -0
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
import Draw from 'ol/interaction/Draw.js'
|
|
2
|
+
import { noModifierKeys } from 'ol/events/condition.js'
|
|
2
3
|
import { createDrawInput } from './drawInput.js'
|
|
3
4
|
import { getCoords } from '../utils/geometryHelpers.js'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
const SNAP_TOLERANCE_PX = 12
|
|
7
|
+
const MIN_VERTICES = { Polygon: 3, LineString: 2 }
|
|
8
|
+
|
|
9
|
+
const canFinish = (geometryType, sketchFeature) => {
|
|
10
|
+
if (!sketchFeature) { return false }
|
|
11
|
+
const geom = sketchFeature.getGeometry()
|
|
12
|
+
const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() })
|
|
13
|
+
// OL keeps a trailing rubber-band coordinate; subtract 1 to get real vertex count
|
|
14
|
+
return coords.length - 1 >= MIN_VERTICES[geometryType]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// OL closes Polygon rings by appending v1: [...placed, rubber_band, v1_closing]; last placed is 3 from end.
|
|
18
|
+
const POLY_LAST_PLACED_OFFSET = 3
|
|
19
|
+
|
|
20
|
+
const getLastPlacedCoord = (geom) => {
|
|
21
|
+
if (geom.getType() === 'Polygon') {
|
|
22
|
+
const ring = geom.getCoordinates()[0] || []
|
|
23
|
+
return ring.length >= POLY_LAST_PLACED_OFFSET ? ring[ring.length - POLY_LAST_PLACED_OFFSET] : null
|
|
24
|
+
}
|
|
25
|
+
const coords = geom.getCoordinates()
|
|
26
|
+
return coords.length >= 2 ? coords[coords.length - 2] : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DUPLICATE_TOLERANCE_PX = 2
|
|
30
|
+
|
|
31
|
+
const buildCondition = (map, geometryType, getSketchFeature) => (e) => {
|
|
32
|
+
if (!noModifierKeys(e)) { return false }
|
|
33
|
+
const sf = getSketchFeature()
|
|
34
|
+
if (!sf || canFinish(geometryType, sf)) { return true }
|
|
35
|
+
const prev = getLastPlacedCoord(sf.getGeometry())
|
|
36
|
+
if (!prev) { return true }
|
|
37
|
+
const pp = map.getPixelFromCoordinate(prev)
|
|
38
|
+
if (!pp) { return true }
|
|
39
|
+
const dx = e.pixel[0] - pp[0]; const dy = e.pixel[1] - pp[1]
|
|
40
|
+
return dx * dx + dy * dy > DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
export const createDrawMode = ({ map, manager, options }) => {
|
|
14
44
|
const {
|
|
15
|
-
geometryType,
|
|
45
|
+
geometryType,
|
|
16
46
|
featureId,
|
|
17
47
|
properties = {},
|
|
18
48
|
container,
|
|
@@ -22,28 +52,28 @@ export const createDrawMode = ({ map, manager, options }) => {
|
|
|
22
52
|
snap
|
|
23
53
|
} = options
|
|
24
54
|
|
|
55
|
+
let sketchFeature = null
|
|
56
|
+
|
|
25
57
|
const drawInteraction = new Draw({
|
|
26
58
|
type: geometryType,
|
|
27
59
|
style: manager.styles.createSketchStyle(),
|
|
28
60
|
stopClick: true,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
snapTolerance: 12
|
|
61
|
+
snapTolerance: SNAP_TOLERANCE_PX,
|
|
62
|
+
condition: buildCondition(map, geometryType, () => sketchFeature)
|
|
32
63
|
})
|
|
33
64
|
map.addInteraction(drawInteraction)
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
65
|
+
// OL internal: overlay_ is the private VectorLayer used for the sketch geometry.
|
|
66
|
+
// updateWhileAnimating_ forces per-frame redraws during view animations (keyboard pan).
|
|
67
|
+
// Without this, geom.setCoordinates() calls are ignored while the ANIMATING hint is set.
|
|
68
|
+
// Check ol/interaction/Draw.js and ol/layer/BaseVector.js if this breaks after an OL upgrade.
|
|
69
|
+
drawInteraction.overlay_.updateWhileAnimating_ = true
|
|
37
70
|
|
|
38
71
|
const updateVertexCount = () => {
|
|
39
|
-
if (!sketchFeature) {
|
|
40
|
-
return
|
|
41
|
-
}
|
|
72
|
+
if (!sketchFeature) { return }
|
|
42
73
|
const geom = sketchFeature.getGeometry()
|
|
43
74
|
const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() })
|
|
44
75
|
// OL always keeps a trailing rubber-band coordinate; subtract 1
|
|
45
|
-
|
|
46
|
-
manager.emit('vertexchange', { numVertices })
|
|
76
|
+
manager.emit('vertexchange', { numVertices: Math.max(0, coords.length - 1) })
|
|
47
77
|
}
|
|
48
78
|
|
|
49
79
|
drawInteraction.on('drawstart', (e) => {
|
|
@@ -56,14 +86,11 @@ export const createDrawMode = ({ map, manager, options }) => {
|
|
|
56
86
|
olFeature.setId(String(featureId))
|
|
57
87
|
olFeature.setProperties(properties)
|
|
58
88
|
manager.store.source.addFeature(olFeature)
|
|
59
|
-
|
|
60
|
-
manager.emit('create', geojson)
|
|
89
|
+
manager.emit('create', manager.store.toGeoJSON(olFeature))
|
|
61
90
|
// Mode switches to disabled in events.js after receiving 'create'
|
|
62
91
|
})
|
|
63
92
|
|
|
64
|
-
drawInteraction.on('drawabort', () => {
|
|
65
|
-
manager.emit('cancel')
|
|
66
|
-
})
|
|
93
|
+
drawInteraction.on('drawabort', () => { manager.emit('cancel') })
|
|
67
94
|
|
|
68
95
|
const input = createDrawInput({
|
|
69
96
|
drawInteraction,
|
|
@@ -74,36 +101,17 @@ export const createDrawMode = ({ map, manager, options }) => {
|
|
|
74
101
|
addVertexButtonId,
|
|
75
102
|
mapProvider,
|
|
76
103
|
snap,
|
|
77
|
-
onUndo: () => {
|
|
78
|
-
|
|
79
|
-
updateVertexCount()
|
|
80
|
-
}
|
|
104
|
+
onUndo: () => { drawInteraction.removeLastPoint(); updateVertexCount() },
|
|
105
|
+
canFinish: () => canFinish(geometryType, sketchFeature)
|
|
81
106
|
}
|
|
82
107
|
})
|
|
83
108
|
|
|
84
109
|
return {
|
|
85
110
|
done () {
|
|
86
|
-
|
|
87
|
-
if (sketchFeature) {
|
|
88
|
-
const geom = sketchFeature.getGeometry()
|
|
89
|
-
const coords = getCoords({ type: geometryType, coordinates: geom.getCoordinates() })
|
|
90
|
-
const min = geometryType === 'Polygon' ? 4 : 3 // +1 for rubber band
|
|
91
|
-
if (coords.length < min) {
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
drawInteraction.finishDrawing()
|
|
111
|
+
if (canFinish(geometryType, sketchFeature)) { drawInteraction.finishDrawing() }
|
|
96
112
|
},
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
drawInteraction.abortDrawing()
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
undo () {
|
|
103
|
-
drawInteraction.removeLastPoint()
|
|
104
|
-
updateVertexCount()
|
|
105
|
-
},
|
|
106
|
-
|
|
113
|
+
cancel () { drawInteraction.abortDrawing() },
|
|
114
|
+
undo () { drawInteraction.removeLastPoint(); updateVertexCount() },
|
|
107
115
|
destroy () {
|
|
108
116
|
input.destroy()
|
|
109
117
|
map.removeInteraction(drawInteraction)
|
|
@@ -1,18 +1,15 @@
|
|
|
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
1
|
import { coordToPixel, pixelDist } from '../utils/olCoords.js'
|
|
10
2
|
|
|
11
3
|
const SNAP_TOLERANCE = 12 // pixels
|
|
4
|
+
// Minimum ring length to allow snap-to-close (placed vertices + rubber-band)
|
|
5
|
+
const MIN_SKETCH_COORDS = { Polygon: 4, LineString: 3 }
|
|
6
|
+
const DUPLICATE_TOLERANCE_PX = 2
|
|
7
|
+
// OL Polygon ring layout after addToDrawing_: [...committed, rubber_band, closing_v1]
|
|
8
|
+
const POLY_COMMITTED_OFFSET = 3
|
|
12
9
|
const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
|
|
13
10
|
|
|
14
11
|
const isCloseToFirstVertex = (map, coord, sketchCoords, geometryType) => {
|
|
15
|
-
if (geometryType !== 'Polygon' || sketchCoords.length <
|
|
12
|
+
if (geometryType !== 'Polygon' || sketchCoords.length < MIN_SKETCH_COORDS.Polygon) {
|
|
16
13
|
return false
|
|
17
14
|
}
|
|
18
15
|
const firstCoord = sketchCoords[0]
|
|
@@ -44,13 +41,24 @@ const applyRubberbanding = (geom, centerCoord) => {
|
|
|
44
41
|
}
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
// Returns the last vertex committed by OL's Draw interaction (not the rubber-band or
|
|
45
|
+
// the closing copy that OL appends to Polygon rings).
|
|
46
|
+
const getLastCommittedVertex = (geom) => {
|
|
47
|
+
if (geom.getType() === 'Polygon') {
|
|
48
|
+
const ring = geom.getCoordinates()[0] || []
|
|
49
|
+
return ring.length >= POLY_COMMITTED_OFFSET ? ring[ring.length - POLY_COMMITTED_OFFSET] : null
|
|
50
|
+
}
|
|
51
|
+
const coords = geom.getCoordinates()
|
|
52
|
+
return coords.length >= 2 ? coords[coords.length - 2] : null
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
const wireInputEvents = ({
|
|
48
56
|
container, addVertexButtonId, olView, onUndo,
|
|
49
57
|
getInterfaceType, setInterfaceType, clearLastCoord,
|
|
50
58
|
updateRubberbanding, placeVertex
|
|
51
59
|
}) => {
|
|
52
60
|
const onCenterChange = () => {
|
|
53
|
-
if (getInterfaceType() !== '
|
|
61
|
+
if (getInterfaceType() !== 'mouse') {
|
|
54
62
|
updateRubberbanding()
|
|
55
63
|
}
|
|
56
64
|
}
|
|
@@ -83,7 +91,7 @@ const wireInputEvents = ({
|
|
|
83
91
|
|
|
84
92
|
const onPointerdown = (e) => {
|
|
85
93
|
if (e.pointerType !== 'touch') {
|
|
86
|
-
setInterfaceType('
|
|
94
|
+
setInterfaceType('mouse')
|
|
87
95
|
clearLastCoord()
|
|
88
96
|
}
|
|
89
97
|
}
|
|
@@ -93,7 +101,7 @@ const wireInputEvents = ({
|
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
const onPointerMove = () => {
|
|
96
|
-
if (getInterfaceType() === '
|
|
104
|
+
if (getInterfaceType() === 'mouse') {
|
|
97
105
|
return
|
|
98
106
|
}
|
|
99
107
|
updateRubberbanding()
|
|
@@ -117,15 +125,9 @@ const wireInputEvents = ({
|
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
|
|
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
128
|
export const createDrawInput = ({ drawInteraction, options }) => {
|
|
127
|
-
const { container, addVertexButtonId, mapProvider, snap, onUndo } = options
|
|
128
|
-
let interfaceType = options.interfaceType
|
|
129
|
+
const { container, addVertexButtonId, mapProvider, snap, onUndo, canFinish } = options
|
|
130
|
+
let interfaceType = options.interfaceType ?? 'mouse'
|
|
129
131
|
let sketchFeature = null
|
|
130
132
|
let lastPlacedCoord = null
|
|
131
133
|
|
|
@@ -144,6 +146,12 @@ export const createDrawInput = ({ drawInteraction, options }) => {
|
|
|
144
146
|
|
|
145
147
|
const updateRubberbanding = () => {
|
|
146
148
|
if (!sketchFeature) {
|
|
149
|
+
// No sketch yet — update snap indicator at crosshair position so targets are
|
|
150
|
+
// visible before the first vertex is placed (touch/keyboard only; mouse uses
|
|
151
|
+
// the OL snap interaction's pointermove handler instead).
|
|
152
|
+
if (interfaceType !== 'mouse' && snap) {
|
|
153
|
+
snap.apply(mapProvider.getCenter())
|
|
154
|
+
}
|
|
147
155
|
return
|
|
148
156
|
}
|
|
149
157
|
const geom = sketchFeature.getGeometry()
|
|
@@ -152,36 +160,63 @@ export const createDrawInput = ({ drawInteraction, options }) => {
|
|
|
152
160
|
return
|
|
153
161
|
}
|
|
154
162
|
const raw = mapProvider.getCenter()
|
|
155
|
-
const centerCoord = (interfaceType !== '
|
|
163
|
+
const centerCoord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw
|
|
156
164
|
applyRubberbanding(geom, centerCoord)
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
// Returns true if the vertex was handled as a close/finish attempt (caller should not append).
|
|
168
|
+
const tryClose = (geom, sketchCoords, coord) => {
|
|
169
|
+
if (lastPlacedCoord && lastPlacedCoord[0] === coord[0] && lastPlacedCoord[1] === coord[1]) {
|
|
170
|
+
// Same position as last placed: don't duplicate. Close only if enough real vertices exist.
|
|
171
|
+
if (canFinish?.()) { drawInteraction.finishDrawing() }
|
|
172
|
+
lastPlacedCoord = null
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) {
|
|
176
|
+
drawInteraction.finishDrawing()
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
// When the add-vertex button overlays the map (touch UI), OL's native pointer handler
|
|
180
|
+
// and this button click handler both fire for the same tap. Detect that OL already
|
|
181
|
+
// committed a vertex at coord's position and skip the duplicate appendCoordinates,
|
|
182
|
+
// but register coord as lastPlacedCoord so a second tap at the same position can close.
|
|
183
|
+
const map = drawInteraction.getMap()
|
|
184
|
+
const lastCommitted = getLastCommittedVertex(geom)
|
|
185
|
+
if (lastCommitted) {
|
|
186
|
+
const p1 = map.getPixelFromCoordinate(lastCommitted)
|
|
187
|
+
const p2 = map.getPixelFromCoordinate(coord)
|
|
188
|
+
if (p1 && p2) {
|
|
189
|
+
const dx = p1[0] - p2[0]; const dy = p1[1] - p2[1]
|
|
190
|
+
if (dx * dx + dy * dy < DUPLICATE_TOLERANCE_PX * DUPLICATE_TOLERANCE_PX) {
|
|
191
|
+
lastPlacedCoord = coord
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
|
|
159
199
|
const placeVertex = () => {
|
|
160
200
|
const raw = mapProvider.getCenter()
|
|
161
|
-
const coord = (interfaceType !== '
|
|
201
|
+
const coord = (interfaceType !== 'mouse' && snap) ? snap.apply(raw) : raw
|
|
162
202
|
snap?.hideIndicator()
|
|
163
203
|
if (sketchFeature) {
|
|
164
204
|
const geom = sketchFeature.getGeometry()
|
|
165
205
|
const rawCoords = geom.getCoordinates()
|
|
166
206
|
const sketchCoords = geom.getType() === 'Polygon' ? (rawCoords[0] || []) : rawCoords
|
|
167
|
-
if (
|
|
168
|
-
drawInteraction.finishDrawing()
|
|
169
|
-
lastPlacedCoord = null
|
|
170
|
-
return
|
|
171
|
-
}
|
|
172
|
-
if (isCloseToFirstVertex(drawInteraction.getMap(), coord, sketchCoords, geom.getType())) {
|
|
173
|
-
drawInteraction.finishDrawing()
|
|
174
|
-
return
|
|
175
|
-
}
|
|
207
|
+
if (tryClose(geom, sketchCoords, coord)) { return }
|
|
176
208
|
}
|
|
177
209
|
drawInteraction.appendCoordinates([coord])
|
|
178
210
|
lastPlacedCoord = coord
|
|
179
211
|
}
|
|
180
212
|
|
|
213
|
+
const map = drawInteraction.getMap()
|
|
214
|
+
const olView = map?.getView()
|
|
215
|
+
|
|
181
216
|
const events = wireInputEvents({
|
|
182
217
|
container,
|
|
183
218
|
addVertexButtonId,
|
|
184
|
-
olView
|
|
219
|
+
olView,
|
|
185
220
|
onUndo,
|
|
186
221
|
getInterfaceType: () => interfaceType,
|
|
187
222
|
setInterfaceType: (t) => { interfaceType = t },
|
|
@@ -190,10 +225,19 @@ export const createDrawInput = ({ drawInteraction, options }) => {
|
|
|
190
225
|
placeVertex
|
|
191
226
|
})
|
|
192
227
|
|
|
228
|
+
// change:center fires once when a keyboard pan animation starts; postrender tracks each frame.
|
|
229
|
+
const onMapRender = () => {
|
|
230
|
+
if (interfaceType !== 'mouse' && olView?.getAnimating()) {
|
|
231
|
+
updateRubberbanding()
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
map?.on('postrender', onMapRender)
|
|
235
|
+
|
|
193
236
|
return {
|
|
194
237
|
getInterfaceType: () => interfaceType,
|
|
195
238
|
destroy () {
|
|
196
239
|
events.destroy()
|
|
240
|
+
map?.un('postrender', onMapRender)
|
|
197
241
|
}
|
|
198
242
|
}
|
|
199
243
|
}
|
|
@@ -41,7 +41,7 @@ export const createEditMode = ({ map, manager, options }) => {
|
|
|
41
41
|
selectedVertexType: null,
|
|
42
42
|
vertices: [],
|
|
43
43
|
midpoints: [],
|
|
44
|
-
interfaceType: interfaceType ?? '
|
|
44
|
+
interfaceType: interfaceType ?? 'mouse'
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const getState = () => state
|
|
@@ -210,7 +210,7 @@ export const createEditMode = ({ map, manager, options }) => {
|
|
|
210
210
|
touchHandler.updateTargetPosition()
|
|
211
211
|
return
|
|
212
212
|
}
|
|
213
|
-
state.interfaceType = '
|
|
213
|
+
state.interfaceType = 'mouse'
|
|
214
214
|
|
|
215
215
|
const olPixel = map.getEventPixel(e)
|
|
216
216
|
const pixel = { x: olPixel[0], y: olPixel[1] }
|
|
@@ -242,10 +242,10 @@ export const createEditMode = ({ map, manager, options }) => {
|
|
|
242
242
|
if (e.pointerType !== 'mouse') {
|
|
243
243
|
return
|
|
244
244
|
}
|
|
245
|
-
if (state.interfaceType === '
|
|
245
|
+
if (state.interfaceType === 'mouse') {
|
|
246
246
|
return
|
|
247
247
|
}
|
|
248
|
-
state.interfaceType = '
|
|
248
|
+
state.interfaceType = 'mouse'
|
|
249
249
|
touchHandler.hide()
|
|
250
250
|
}
|
|
251
251
|
|
|
@@ -81,7 +81,11 @@ export const manifest = {
|
|
|
81
81
|
id: 'drawDeletePoint',
|
|
82
82
|
label: 'Delete point',
|
|
83
83
|
iconId: 'trash',
|
|
84
|
-
enableWhen: ({ pluginState }) =>
|
|
84
|
+
enableWhen: ({ pluginState }) => {
|
|
85
|
+
if (pluginState.selectedVertexIndex < 0) { return false }
|
|
86
|
+
const isPolygon = pluginState.feature?.geometry?.type === 'Polygon'
|
|
87
|
+
return isPolygon ? pluginState.numVertices > 3 : pluginState.numVertices > 2 // NOSONAR
|
|
88
|
+
},
|
|
85
89
|
hiddenWhen: ({ pluginState }) => pluginState.mode !== 'edit_vertex'
|
|
86
90
|
}],
|
|
87
91
|
mobile: { slot: 'bottom-right' },
|
|
@@ -9,7 +9,7 @@ const initialState = {
|
|
|
9
9
|
hasSnapLayers: false
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const setMode = (state, payload) => ({ ...state, mode: payload })
|
|
12
|
+
const setMode = (state, payload) => ({ ...state, mode: payload, numVertices: null })
|
|
13
13
|
|
|
14
14
|
const setFeature = (state, payload) => ({
|
|
15
15
|
...state,
|
|
@@ -1,21 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
* Snap candidate engine.
|
|
3
|
-
*
|
|
4
|
-
* Accepts two kinds of entry in snapLayers:
|
|
5
|
-
* - string → a VectorTile style-layer name (matched via feature.get('layer'))
|
|
6
|
-
* All VectorTileLayers on the map are searched; only features whose
|
|
7
|
-
* style-layer name is in the set are tested.
|
|
8
|
-
* - OL VectorLayer instance → all features in that layer's source are tested.
|
|
9
|
-
*
|
|
10
|
-
* query() is synchronous and uses:
|
|
11
|
-
* - VectorSource.getFeaturesInExtent() for OL vector layers (internal rBush, fast)
|
|
12
|
-
* - map.forEachFeatureAtPixel() for VectorTile layers (rendered tile data)
|
|
13
|
-
*/
|
|
14
|
-
|
|
1
|
+
import { transform as projTransform } from 'ol/proj.js'
|
|
15
2
|
import VectorLayer from 'ol/layer/Vector.js'
|
|
16
3
|
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
17
4
|
import { testOLFeature, testRenderFeature } from './snapGeometry.js'
|
|
18
5
|
|
|
6
|
+
// VectorTile features are clipped to tile extents, producing artificial straight edges at tile
|
|
7
|
+
// boundaries. MVT tiles also carry a buffer region (geometry from adjacent tiles extending
|
|
8
|
+
// past the tile boundary). Both the exact boundary and the buffer zone must be filtered.
|
|
9
|
+
// Standard MVT buffer is 128 out of 4096 tile coordinate units.
|
|
10
|
+
const TILE_BOUNDARY_EPS = 1 // source-projection units of margin beyond the buffer
|
|
11
|
+
const MVT_BUFFER_UNITS = 128
|
|
12
|
+
const MVT_TILE_EXTENT = 4096
|
|
13
|
+
|
|
14
|
+
const buildTileBoundaryState = (layer, map) => {
|
|
15
|
+
const source = layer.getSource()
|
|
16
|
+
const tileGrid = source?.getTileGrid()
|
|
17
|
+
if (!tileGrid) { return null }
|
|
18
|
+
const viewProj = map.getView().getProjection()
|
|
19
|
+
const sourceProj = source.getProjection() ?? viewProj
|
|
20
|
+
const zoom = tileGrid.getZForResolution(map.getView().getResolution(), 0)
|
|
21
|
+
return { tileGrid, viewProj, sourceProj, zoom }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isOnTileBoundary = (state, coord) => {
|
|
25
|
+
if (!state) { return false }
|
|
26
|
+
const { tileGrid, viewProj, sourceProj, zoom } = state
|
|
27
|
+
const c = projTransform(coord, viewProj, sourceProj)
|
|
28
|
+
const tileCoord = tileGrid.getTileCoordForCoordAndZ(c, zoom)
|
|
29
|
+
const [minX, minY, maxX, maxY] = tileGrid.getTileCoordExtent(tileCoord)
|
|
30
|
+
// Buffer zone in source projection: edges within this distance of a tile boundary are
|
|
31
|
+
// MVT buffer clip artefacts (geometry from the adjacent tile included for rendering overlap).
|
|
32
|
+
const tileBuffer = (maxX - minX) * (MVT_BUFFER_UNITS / MVT_TILE_EXTENT)
|
|
33
|
+
const eps = tileBuffer + TILE_BOUNDARY_EPS
|
|
34
|
+
return (
|
|
35
|
+
Math.abs(c[0] - minX) < eps ||
|
|
36
|
+
Math.abs(c[0] - maxX) < eps ||
|
|
37
|
+
Math.abs(c[1] - minY) < eps ||
|
|
38
|
+
Math.abs(c[1] - maxY) < eps
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Two same-style fill polygons share an invisible boundary — snapping to it is confusing.
|
|
43
|
+
// Detect this by projecting a test point slightly past the snap position (away from the cursor)
|
|
44
|
+
// and checking whether the same fill layer covers that side too.
|
|
45
|
+
// If it does → same-style invisible boundary → skip.
|
|
46
|
+
// If not (empty space, road, different layer) → visible outer edge → include.
|
|
47
|
+
const INVISIBLE_FILL_MIN_DIST_SQ = 1 // (1m)² — below this, cursor is on the edge, skip direction check
|
|
48
|
+
|
|
49
|
+
const isInvisibleFillBoundary = (edgeCoord, cursorCoord, layerId, map, vtLayers, resolution) => {
|
|
50
|
+
const dx = edgeCoord[0] - cursorCoord[0]
|
|
51
|
+
const dy = edgeCoord[1] - cursorCoord[1]
|
|
52
|
+
const distSq = dx * dx + dy * dy
|
|
53
|
+
if (distSq < INVISIBLE_FILL_MIN_DIST_SQ) { return false }
|
|
54
|
+
const dist = Math.sqrt(distSq)
|
|
55
|
+
const step = resolution * 4
|
|
56
|
+
const testCoord = [edgeCoord[0] + (dx / dist) * step, edgeCoord[1] + (dy / dist) * step]
|
|
57
|
+
const testPixel = map.getPixelFromCoordinate(testCoord)
|
|
58
|
+
if (!testPixel) { return false }
|
|
59
|
+
return !!map.forEachFeatureAtPixel(
|
|
60
|
+
testPixel,
|
|
61
|
+
(f) => f.get('mapbox-layer')?.id === layerId,
|
|
62
|
+
{ hitTolerance: 2, layerFilter: (l) => vtLayers.includes(l) }
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
const pickBest = (a, b) => {
|
|
20
67
|
if (!b) { return a }
|
|
21
68
|
if (!a) { return b }
|
|
@@ -53,17 +100,9 @@ export const createSnapEngine = (map, snapLayers = []) => {
|
|
|
53
100
|
|
|
54
101
|
setLayers(snapLayers)
|
|
55
102
|
|
|
56
|
-
/**
|
|
57
|
-
* Find the nearest snap candidate to coord within radiusPx screen pixels.
|
|
58
|
-
* @param {number[]} coord - map coordinate [x, y]
|
|
59
|
-
* @param {number} radiusPx - tolerance in screen pixels
|
|
60
|
-
* @returns {{ type: 'vertex'|'edge', coord: number[] } | null}
|
|
61
|
-
*/
|
|
62
103
|
const query = (coord, radiusPx) => {
|
|
63
104
|
const resolution = map.getView().getResolution()
|
|
64
|
-
if (!resolution) {
|
|
65
|
-
return null
|
|
66
|
-
}
|
|
105
|
+
if (!resolution) { return null }
|
|
67
106
|
const toleranceMapUnits = radiusPx * resolution
|
|
68
107
|
const toleranceSq = toleranceMapUnits * toleranceMapUnits
|
|
69
108
|
const ext = [
|
|
@@ -75,27 +114,32 @@ export const createSnapEngine = (map, snapLayers = []) => {
|
|
|
75
114
|
|
|
76
115
|
let best = null
|
|
77
116
|
|
|
78
|
-
// --- OL VectorLayer sources ---
|
|
79
117
|
for (const layer of olLayers) {
|
|
80
118
|
const source = layer.getSource()
|
|
81
|
-
if (!source) {
|
|
82
|
-
continue
|
|
83
|
-
}
|
|
119
|
+
if (!source) { continue }
|
|
84
120
|
for (const feature of source.getFeaturesInExtent(ext)) {
|
|
85
121
|
best = pickBest(best, testOLFeature(feature, coord, toleranceSq))
|
|
86
122
|
}
|
|
87
123
|
}
|
|
88
124
|
|
|
89
|
-
// --- VectorTile layers ---
|
|
90
125
|
if (vtLayerNames.size > 0) {
|
|
91
126
|
const vtLayers = getVTLayers(map)
|
|
92
127
|
const pixel = vtLayers.length > 0 ? map.getPixelFromCoordinate(coord) : null
|
|
93
128
|
if (pixel) {
|
|
129
|
+
const tileBoundaryStates = new Map()
|
|
94
130
|
map.forEachFeatureAtPixel(
|
|
95
131
|
pixel,
|
|
96
|
-
(feature,
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
(feature, layer) => {
|
|
133
|
+
const mapboxLayer = feature.get('mapbox-layer')
|
|
134
|
+
if (!vtLayerNames.has(mapboxLayer?.id)) { return }
|
|
135
|
+
const candidate = testRenderFeature(feature, coord, toleranceSq)
|
|
136
|
+
if (!candidate) { return }
|
|
137
|
+
if (!tileBoundaryStates.has(layer)) {
|
|
138
|
+
tileBoundaryStates.set(layer, buildTileBoundaryState(layer, map))
|
|
139
|
+
}
|
|
140
|
+
if (isOnTileBoundary(tileBoundaryStates.get(layer), candidate.coord)) { return }
|
|
141
|
+
if (candidate.type === 'edge' && mapboxLayer?.type === 'fill' && isInvisibleFillBoundary(candidate.coord, coord, mapboxLayer.id, map, vtLayers, resolution)) { return }
|
|
142
|
+
best = pickBest(best, candidate)
|
|
99
143
|
},
|
|
100
144
|
{ hitTolerance: radiusPx, layerFilter: (l) => vtLayers.includes(l) }
|
|
101
145
|
)
|