@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.
Files changed (44) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/api/map-style-config.md +27 -3
  6. package/docs/assets/images/hero.png +0 -0
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/src/DrawInit.jsx +11 -0
  11. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -25
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +1 -1
  13. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +5 -4
  14. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +49 -21
  15. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -1
  16. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
  17. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  18. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +3 -0
  19. package/plugins/beta/draw-ol/src/draw/DrawMode.js +57 -49
  20. package/plugins/beta/draw-ol/src/draw/drawInput.js +76 -32
  21. package/plugins/beta/draw-ol/src/edit/EditMode.js +4 -4
  22. package/plugins/beta/draw-ol/src/manifest.js +5 -1
  23. package/plugins/beta/draw-ol/src/reducer.js +1 -1
  24. package/plugins/beta/draw-ol/src/snap/snapEngine.js +75 -31
  25. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +23 -46
  26. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +30 -29
  27. package/plugins/beta/draw-ol/src/snap/snapManager.js +10 -1
  28. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +9 -3
  29. package/plugins/beta/draw-ol/src/utils/olCoords.js +2 -2
  30. package/plugins/beta/draw-ol/src/utils/spatial.js +4 -8
  31. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  32. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  33. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  34. package/providers/beta/openlayers/src/utils/tileLayers.js +29 -2
  35. package/providers/beta/openlayers/src/utils/tileLayers.test.js +53 -1
  36. package/src/App/components/MapButton/MapButton.jsx +14 -10
  37. package/src/App/components/MapButton/MapButton.module.scss +1 -1
  38. package/src/App/components/MapButton/MapButton.test.jsx +1 -1
  39. package/src/App/components/PopupMenu/PopupMenu.test.jsx +79 -0
  40. package/src/App/components/PopupMenu/usePopupMenu.js +61 -12
  41. package/src/types.js +12 -3
  42. package/src/utils/findNextTabStop.js +3 -3
  43. package/src/utils/findNextTabStop.test.js +25 -1
  44. 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
- * Draw mode handles draw_polygon and draw_line.
7
- *
8
- * OL's Draw interaction handles all pointer/mouse behaviour natively.
9
- * drawInput.js handles touch/keyboard/button input.
10
- *
11
- * @returns {{ done, cancel, undo, destroy }}
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, // 'Polygon' | 'LineString'
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
- // minPoints defaults: 3 for Polygon, 2 for LineString — OL handles this
30
- // snapTolerance: how close to first point to auto-close polygon
31
- snapTolerance: 12
61
+ snapTolerance: SNAP_TOLERANCE_PX,
62
+ condition: buildCondition(map, geometryType, () => sketchFeature)
32
63
  })
33
64
  map.addInteraction(drawInteraction)
34
-
35
- // Track vertex count for the Done button enabled state
36
- let sketchFeature = null
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
- const numVertices = Math.max(0, coords.length - 1)
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
- const geojson = manager.store.toGeoJSON(olFeature)
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
- drawInteraction.removeLastPoint()
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
- // Validate minimum points before finishing
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
- cancel () {
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 < 4) {
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() !== 'pointer') {
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('pointer')
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() === 'pointer') {
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 !== 'pointer' && snap) ? snap.apply(raw) : raw
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 !== 'pointer' && snap) ? snap.apply(raw) : raw
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 (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
- }
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: drawInteraction.getMap()?.getView(),
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 ?? 'pointer'
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 = 'pointer'
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 === 'pointer') {
245
+ if (state.interfaceType === 'mouse') {
246
246
  return
247
247
  }
248
- state.interfaceType = 'pointer'
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 }) => pluginState.selectedVertexIndex >= 0 && pluginState.numVertices > 2,
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, _layer) => {
97
- if (!vtLayerNames.has(feature.get('mapbox-layer')?.id)) { return }
98
- best = pickBest(best, testRenderFeature(feature, coord, toleranceSq))
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
  )