@defra/interactive-map 0.0.31-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 (131) hide show
  1. package/README.md +2 -2
  2. package/assets/images/bright-ofm-thumb.jpg +0 -0
  3. package/assets/images/dark-ofm-thumb.jpg +0 -0
  4. package/assets/images/road-ofm-thumb.jpg +0 -0
  5. package/assets/templates/add-polygons.njk +143 -0
  6. package/assets/templates/add-symbols.njk +119 -0
  7. package/assets/templates/{map.njk → basic-map.njk} +9 -17
  8. package/assets/templates/button-map.njk +56 -0
  9. package/assets/templates/draw-tools.njk +161 -0
  10. package/assets/templates/marker-panel.njk +91 -0
  11. package/assets/templates/place-marker.njk +104 -0
  12. package/assets/templates/search-control.njk +98 -0
  13. package/assets/templates/select-feature.njk +101 -0
  14. package/assets/templates/style-switcher.njk +87 -0
  15. package/assets/templates/toggle-marker-label.njk +74 -0
  16. package/dist/css/index.css +1 -1
  17. package/dist/esm/im-core.js +1 -1
  18. package/dist/esm/im-shell.js +1 -1
  19. package/dist/umd/im-core.js +1 -1
  20. package/dist/umd/index.js +1 -1
  21. package/docs/api/map-style-config.md +27 -3
  22. package/docs/api.md +55 -0
  23. package/docs/assets/css/docusaurus.scss +22 -6
  24. package/docs/assets/images/add-polygons.jpg +0 -0
  25. package/docs/assets/images/add-symbols.jpg +0 -0
  26. package/docs/assets/images/hero.png +0 -0
  27. package/docs/assets/images/place-marker.jpg +0 -0
  28. package/docs/assets/images/screenshot.jpg +0 -0
  29. package/docs/examples/add-marker-with-panel.mdx +1 -1
  30. package/docs/examples/add-polygons.mdx +225 -0
  31. package/docs/examples/add-symbols.mdx +177 -0
  32. package/docs/examples/{draw.mdx → draw-tools.mdx} +2 -2
  33. package/docs/examples/index.mdx +38 -2
  34. package/docs/examples/place-marker.mdx +151 -0
  35. package/docs/plugins/datasets.md +144 -96
  36. package/docs/plugins/interact.md +1 -4
  37. package/docusaurus.config.cjs +5 -2
  38. package/govuk-prototype-kit.config.json +52 -2
  39. package/package.json +1 -1
  40. package/plugins/beta/datasets/dist/css/index.css +1 -2
  41. package/plugins/beta/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  42. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  43. package/plugins/beta/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  44. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  45. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  46. package/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.js +27 -9
  47. package/plugins/beta/datasets/src/adapters/maplibre/registry/mapLibreDataset.test.js +46 -4
  48. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +10 -1
  49. package/plugins/beta/datasets/src/api/setFeatureVisibility.test.js +48 -6
  50. package/plugins/beta/datasets/src/components/LayersMenu/Layers.module.scss +12 -12
  51. package/plugins/beta/datasets/src/reducers/datasetsToMenu.js +11 -6
  52. package/plugins/beta/datasets/src/reducers/datasetsToMenu.test.js +49 -0
  53. package/plugins/beta/datasets/src/reducers/mappedDatasetsReducer.js +8 -1
  54. package/plugins/beta/datasets/src/reducers/pluginState.js +1 -1
  55. package/plugins/beta/datasets/src/registry/dataset.js +7 -3
  56. package/plugins/beta/datasets/src/registry/dataset.test.js +14 -0
  57. package/plugins/beta/datasets/src/registry/dynamicGeoJson.js +1 -1
  58. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  59. package/plugins/beta/draw-es/src/api/addFeature.js +12 -3
  60. package/plugins/beta/draw-es/src/events.js +8 -3
  61. package/plugins/beta/draw-es/src/events.test.js +2 -0
  62. package/plugins/beta/draw-es/src/graphic.js +26 -2
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/src/DrawInit.jsx +19 -0
  66. package/plugins/beta/draw-ml/src/api/newLine.js +1 -0
  67. package/plugins/beta/draw-ml/src/api/newPolygon.js +1 -0
  68. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +52 -37
  69. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +1 -1
  70. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +5 -4
  71. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +53 -21
  72. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -1
  73. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
  74. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  75. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +3 -0
  76. package/plugins/beta/draw-ol/src/draw/DrawMode.js +57 -49
  77. package/plugins/beta/draw-ol/src/draw/drawInput.js +76 -32
  78. package/plugins/beta/draw-ol/src/edit/EditMode.js +4 -4
  79. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +4 -2
  80. package/plugins/beta/draw-ol/src/manifest.js +5 -1
  81. package/plugins/beta/draw-ol/src/reducer.js +1 -1
  82. package/plugins/beta/draw-ol/src/snap/snapEngine.js +75 -31
  83. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +23 -46
  84. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +30 -29
  85. package/plugins/beta/draw-ol/src/snap/snapManager.js +10 -1
  86. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +9 -3
  87. package/plugins/beta/draw-ol/src/utils/olCoords.js +2 -2
  88. package/plugins/beta/draw-ol/src/utils/spatial.js +4 -8
  89. package/plugins/beta/map-styles/src/mapStyles.scss +41 -41
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +1 -3
  94. package/plugins/interact/src/InteractInit.test.js +0 -2
  95. package/plugins/interact/src/events.test.js +1 -1
  96. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -10
  97. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +5 -10
  98. package/plugins/interact/src/hooks/useHighlightSync.js +1 -10
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +2 -21
  100. package/plugins/interact/src/hooks/useInteractionHandlers.js +5 -11
  101. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +5 -25
  102. package/plugins/interact/src/reducer.js +4 -22
  103. package/plugins/interact/src/reducer.test.js +11 -39
  104. package/plugins/search/dist/css/index.css +5 -2
  105. package/plugins/search/src/components/Form/Form.module.scss +5 -2
  106. package/plugins/search/src/search.scss +4 -4
  107. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  109. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  110. package/providers/beta/openlayers/src/utils/tileLayers.js +29 -2
  111. package/providers/beta/openlayers/src/utils/tileLayers.test.js +53 -1
  112. package/src/App/components/CrossHair/CrossHair.module.scss +8 -8
  113. package/src/App/components/MapButton/MapButton.jsx +14 -10
  114. package/src/App/components/MapButton/MapButton.module.scss +1 -1
  115. package/src/App/components/MapButton/MapButton.test.jsx +1 -1
  116. package/src/App/components/PopupMenu/PopupMenu.test.jsx +79 -0
  117. package/src/App/components/PopupMenu/usePopupMenu.js +61 -12
  118. package/src/App/hooks/useLayoutMeasurements.js +10 -2
  119. package/src/App/hooks/useResizeObserver.js +4 -2
  120. package/src/InteractiveMap/InteractiveMap.js +4 -4
  121. package/src/InteractiveMap/InteractiveMap.test.js +3 -3
  122. package/src/InteractiveMap/behaviourController.js +3 -1
  123. package/src/InteractiveMap/behaviourController.test.js +6 -3
  124. package/src/InteractiveMap/domStateManager.js +2 -0
  125. package/src/config/events.js +21 -3
  126. package/src/types.js +12 -3
  127. package/src/utils/findNextTabStop.js +3 -3
  128. package/src/utils/findNextTabStop.test.js +25 -1
  129. package/webpack.dev.mjs +1 -0
  130. package/docs/assets/images/screens-blue.jpg +0 -0
  131. package/docs/assets/images/screens-white.jpg +0 -0
@@ -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
  )
@@ -1,10 +1,3 @@
1
- /**
2
- * Pure geometry helpers for snap candidate testing.
3
- * No OL imports, no side effects — only coordinate math.
4
- *
5
- * All coordinates are [x, y] pairs in map projection units.
6
- */
7
-
8
1
  const dist2 = (a, b) => {
9
2
  const dx = a[0] - b[0]
10
3
  const dy = a[1] - b[1]
@@ -41,11 +34,8 @@ const better = (a, b) => {
41
34
  return b.distSq < a.distSq
42
35
  }
43
36
 
44
- /**
45
- * Test all vertices and edges of a coordinate ring/line for snap candidates.
46
- * coords: [[x,y], ...] — OL ring (first === last for closed rings)
47
- * isClosedRing: if true, OL has duplicated the first coord as last; skip it and wrap edges
48
- */
37
+ // OL Vector rings duplicate the first coord as last; VectorTile rings do not.
38
+ // isClosedRing=true: skip the duplicated last vertex, wrap closing edge back to first.
49
39
  const testCoords = (coords, query, toleranceSq, isClosedRing) => {
50
40
  let best = null
51
41
  const n = isClosedRing && coords.length > 1 ? coords.length - 1 : coords.length
@@ -72,13 +62,6 @@ const testCoords = (coords, query, toleranceSq, isClosedRing) => {
72
62
  return best
73
63
  }
74
64
 
75
- /**
76
- * Test flat coordinate array (stride 2) from a VectorTile RenderFeature.
77
- * flat: number[] — [x0,y0,x1,y1,...]
78
- * start/end: index range within flat
79
- * isClosedRing: VTile polygon rings — first coord is NOT duplicated at end (unlike OL Vector)
80
- * so treat all coords as unique vertices and add a closing edge back to first
81
- */
82
65
  const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
83
66
  let best = null
84
67
  for (let i = 0; i < edgeCount; i++) {
@@ -95,7 +78,7 @@ const getBestEdge = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
95
78
  return best
96
79
  }
97
80
 
98
- const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
81
+ const getBestVertex = (flat, start, numPairs, query, toleranceSq) => {
99
82
  let best = null
100
83
  for (let i = 0; i < numPairs; i++) {
101
84
  const xi = start + i * 2
@@ -105,7 +88,14 @@ const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
105
88
  best = bestOf(best, { type: 'vertex', coord: v, distSq: dSq })
106
89
  }
107
90
  }
108
- return bestOf(best, getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq))
91
+ return best
92
+ }
93
+
94
+ const getBestPair = (flat, start, numPairs, edgeCount, query, toleranceSq) => {
95
+ return bestOf(
96
+ getBestVertex(flat, start, numPairs, query, toleranceSq),
97
+ getBestEdge(flat, start, numPairs, edgeCount, query, toleranceSq)
98
+ )
109
99
  }
110
100
 
111
101
  const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => {
@@ -115,32 +105,32 @@ const testFlatCoords = (flat, start, end, query, toleranceSq, isClosedRing) => {
115
105
  }
116
106
 
117
107
  const olGeomHandlers = {
118
- Point (geom, query, toleranceSq) {
108
+ point: (geom, query, toleranceSq) => {
119
109
  const c = geom.getCoordinates()
120
110
  const dSq = dist2(query, c)
121
111
  return dSq <= toleranceSq ? { type: 'vertex', coord: [c[0], c[1]], distSq: dSq } : null
122
112
  },
123
- LineString (geom, query, toleranceSq) {
113
+ lineString: (geom, query, toleranceSq) => {
124
114
  return testCoords(geom.getCoordinates(), query, toleranceSq, false)
125
115
  },
126
- LinearRing (geom, query, toleranceSq) {
116
+ linearRing: (geom, query, toleranceSq) => {
127
117
  return testCoords(geom.getCoordinates(), query, toleranceSq, true)
128
118
  },
129
- Polygon (geom, query, toleranceSq) {
119
+ polygon: (geom, query, toleranceSq) => {
130
120
  let best = null
131
121
  for (const ring of geom.getCoordinates()) {
132
122
  best = bestOf(best, testCoords(ring, query, toleranceSq, true))
133
123
  }
134
124
  return best
135
125
  },
136
- MultiLineString (geom, query, toleranceSq) {
126
+ multiLineString: (geom, query, toleranceSq) => {
137
127
  let best = null
138
128
  for (const line of geom.getCoordinates()) {
139
129
  best = bestOf(best, testCoords(line, query, toleranceSq, false))
140
130
  }
141
131
  return best
142
132
  },
143
- MultiPolygon (geom, query, toleranceSq) {
133
+ multiPolygon: (geom, query, toleranceSq) => {
144
134
  let best = null
145
135
  for (const polygon of geom.getCoordinates()) {
146
136
  for (const ring of polygon) {
@@ -151,27 +141,14 @@ const olGeomHandlers = {
151
141
  }
152
142
  }
153
143
 
154
- /**
155
- * Test an OL Feature (from a VectorSource) against query coord.
156
- * Handles Point, LineString, LinearRing, Polygon, MultiLineString, MultiPolygon.
157
- *
158
- * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null}
159
- */
160
144
  export const testOLFeature = (feature, query, toleranceSq) => {
161
145
  const geom = feature.getGeometry()
162
- if (!geom) {
163
- return null
164
- }
165
- const handler = olGeomHandlers[geom.getType()]
146
+ if (!geom) { return null }
147
+ const rawType = geom.getType()
148
+ const handler = olGeomHandlers[rawType[0].toLowerCase() + rawType.slice(1)]
166
149
  return handler ? handler(geom, query, toleranceSq) : null
167
150
  }
168
151
 
169
- /**
170
- * Test an OL RenderFeature (from a VectorTileSource) against query coord.
171
- * Handles Point, LineString, Polygon, MultiLineString.
172
- *
173
- * @returns {{ type: 'vertex'|'edge', coord: number[], distSq: number } | null}
174
- */
175
152
  export const testRenderFeature = (feature, query, toleranceSq) => {
176
153
  const type = feature.getType()
177
154
  const flat = feature.getFlatCoordinates()
@@ -180,10 +157,10 @@ export const testRenderFeature = (feature, query, toleranceSq) => {
180
157
  if (type === 'Point') {
181
158
  const dSq = dist2(query, flat)
182
159
  if (dSq <= toleranceSq) {
183
- best = bestOf(best, { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq })
160
+ best = { type: 'vertex', coord: [flat[0], flat[1]], distSq: dSq }
184
161
  }
185
162
  } else if (type === 'LineString') {
186
- best = bestOf(best, testFlatCoords(flat, 0, flat.length, query, toleranceSq, false))
163
+ best = testFlatCoords(flat, 0, flat.length, query, toleranceSq, false)
187
164
  } else if (type === 'Polygon' || type === 'MultiLineString') {
188
165
  const ends = feature.getEnds()
189
166
  let start = 0
@@ -193,7 +170,7 @@ export const testRenderFeature = (feature, query, toleranceSq) => {
193
170
  start = end
194
171
  }
195
172
  } else {
196
- // No action
173
+ // MultiPoint / unknown — no snap candidates
197
174
  }
198
175
 
199
176
  return best
@@ -16,38 +16,39 @@ import Interaction from 'ol/interaction/Interaction.js'
16
16
 
17
17
  const SNAP_EVENTS = new Set(['pointermove', 'pointerdrag', 'pointerdown', 'pointerup', 'singleclick', 'click'])
18
18
 
19
- export const createSnapInteraction = (engine, indicator, snapRadius) => {
19
+ const processSnapEvent = (mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive) => {
20
+ const { type } = mapBrowserEvent
21
+
22
+ if (type === 'pointerout' || type === 'pointerleave') {
23
+ indicator.hide()
24
+ return
25
+ }
26
+
27
+ if (!SNAP_EVENTS.has(type)) {
28
+ return
29
+ }
30
+
31
+ const result = engine.query(mapBrowserEvent.coordinate, snapRadius)
32
+ if (result) {
33
+ mapBrowserEvent.coordinate = result.coord.slice()
34
+ }
35
+
36
+ // Only update indicator during free mouse movement — hide during drag, no-op for clicks
37
+ if (type === 'pointermove' && isIndicatorActive()) {
38
+ result ? indicator.show(result.coord, result.type) : indicator.hide()
39
+ } else if (type === 'pointerdrag') {
40
+ indicator.hide()
41
+ } else {
42
+ // no indicator update for click/down/up events
43
+ }
44
+ }
45
+
46
+ export const createSnapInteraction = (engine, indicator, snapRadius, isIndicatorActive) => {
20
47
  const interaction = new Interaction({
21
48
  handleEvent (mapBrowserEvent) {
22
- if (!interaction.getActive()) {
23
- return true
24
- }
25
-
26
- const { type } = mapBrowserEvent
27
-
28
- if (type === 'pointerout' || type === 'pointerleave') {
29
- indicator.hide()
30
- return true
31
- }
32
-
33
- if (!SNAP_EVENTS.has(type)) {
34
- return true
35
- }
36
-
37
- const result = engine.query(mapBrowserEvent.coordinate, snapRadius)
38
- if (result) {
39
- mapBrowserEvent.coordinate = result.coord.slice()
49
+ if (interaction.getActive()) {
50
+ processSnapEvent(mapBrowserEvent, engine, indicator, snapRadius, isIndicatorActive)
40
51
  }
41
-
42
- // Only show indicator during free mouse movement — hide during drag and clicks
43
- if (type === 'pointermove') {
44
- result ? indicator.show(result.coord, result.type) : indicator.hide()
45
- } else if (type === 'pointerdrag') {
46
- indicator.hide()
47
- } else {
48
- // No action
49
- }
50
-
51
52
  return true
52
53
  }
53
54
  })
@@ -23,7 +23,9 @@ export const createSnapManager = (map, snapLayers, colors, snapRadius) => {
23
23
 
24
24
  const engine = createSnapEngine(map, snapLayers)
25
25
  const indicator = createSnapIndicator(map, colors)
26
- const interaction = createSnapInteraction(engine, indicator, snapRadius)
26
+
27
+ let indicatorActive = false
28
+ const interaction = createSnapInteraction(engine, indicator, snapRadius, () => indicatorActive)
27
29
 
28
30
  map.addInteraction(interaction)
29
31
  interaction.setActive(false) // matches reducer initial state: snap: false
@@ -55,6 +57,13 @@ export const createSnapManager = (map, snapLayers, colors, snapRadius) => {
55
57
  indicator.hide()
56
58
  },
57
59
 
60
+ setIndicatorActive (value) {
61
+ indicatorActive = value
62
+ if (!value) {
63
+ indicator.hide()
64
+ }
65
+ },
66
+
58
67
  setActive (value) {
59
68
  active = value
60
69
  interaction.setActive(value)
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  export const getCoords = (geom) => {
8
- if (!geom?.coordinates) return []
8
+ if (!geom?.coordinates) {
9
+ return []
10
+ }
9
11
  switch (geom.type) {
10
12
  case 'LineString': return geom.coordinates
11
13
  case 'Polygon': return geom.coordinates.flatMap(ring => ring.slice(0, -1))
@@ -20,7 +22,9 @@ export const getCoords = (geom) => {
20
22
  * { start, length, path, closed }
21
23
  */
22
24
  export const getRingSegments = (geom) => {
23
- if (!geom?.coordinates) return []
25
+ if (!geom?.coordinates) {
26
+ return []
27
+ }
24
28
  const segments = []
25
29
  let start = 0
26
30
 
@@ -80,7 +84,9 @@ export const getModifiableCoords = (geojsonGeometry, path) => {
80
84
  export const getMidpoints = (geom) => {
81
85
  const coords = getCoords(geom)
82
86
  const segments = getRingSegments(geom)
83
- if (!coords.length || !segments.length) return []
87
+ if (!coords.length || !segments.length) {
88
+ return []
89
+ }
84
90
 
85
91
  const midpoints = []
86
92
  for (const seg of segments) {
@@ -6,7 +6,7 @@
6
6
  /** OL coordinate [e, n] → screen pixel { x, y } */
7
7
  export const coordToPixel = (map, coord) => {
8
8
  const px = map.getPixelFromCoordinate(coord)
9
- if (!px) return null
9
+ if (!px) { return null }
10
10
  return { x: px[0], y: px[1] }
11
11
  }
12
12
 
@@ -30,6 +30,6 @@ export const pixelToArray = ({ x, y }) => [x, y]
30
30
  */
31
31
  export const nudgeCoord = (map, coord, dx, dy) => {
32
32
  const px = map.getPixelFromCoordinate(coord)
33
- if (!px) return coord
33
+ if (!px) { return coord }
34
34
  return map.getCoordinateFromPixel([px[0] + dx, px[1] + dy])
35
35
  }
@@ -12,17 +12,13 @@ export const spatialNavigate = (start, pixels, direction) => {
12
12
  const dx = Math.abs(p[0] - start[0])
13
13
  const dy = Math.abs(p[1] - start[1])
14
14
  let inQuadrant = false
15
- if (direction === 'ArrowUp') inQuadrant = p[1] <= start[1] && dy >= dx
16
- else if (direction === 'ArrowDown') inQuadrant = p[1] > start[1] && dy >= dx
17
- else if (direction === 'ArrowLeft') inQuadrant = p[0] <= start[0] && dy < dx
18
- else if (direction === 'ArrowRight') inQuadrant = p[0] > start[0] && dy < dx
19
- else inQuadrant = true
15
+ if (direction === 'ArrowUp') { inQuadrant = p[1] <= start[1] && dy >= dx } else if (direction === 'ArrowDown') { inQuadrant = p[1] > start[1] && dy >= dx } else if (direction === 'ArrowLeft') { inQuadrant = p[0] <= start[0] && dy < dx } else if (direction === 'ArrowRight') { inQuadrant = p[0] > start[0] && dy < dx } else { inQuadrant = true }
20
16
  return inQuadrant && JSON.stringify(p) !== JSON.stringify(start)
21
17
  })
22
18
 
23
- if (!quadrant.length) quadrant.push(start)
19
+ if (!quadrant.length) { quadrant.push(start) }
24
20
 
25
- const dist = (p) => Math.sqrt((start[0] - p[0]) ** 2 + (start[1] - p[1]) ** 2)
26
- const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best)
21
+ const dist = (p) => Math.hypot(start[0] - p[0], start[1] - p[1])
22
+ const closest = quadrant.reduce((best, p) => dist(p) < dist(best) ? p : best, quadrant[0])
27
23
  return pixels.findIndex(p => JSON.stringify(p) === JSON.stringify(closest))
28
24
  }
@@ -1,84 +1,84 @@
1
1
  .im-c-map-styles {
2
- display: block;
2
+ display: block;
3
3
  }
4
4
 
5
5
  .im-c-map-styles__inner {
6
- display: flex;
7
- flex-wrap: wrap;
6
+ display: flex;
7
+ flex-wrap: wrap;
8
8
  }
9
9
 
10
10
  .im-c-map-styles__heading {
11
- padding: var(--primary-gap) 0;
11
+ padding: var(--primary-gap) 0;
12
12
  }
13
13
 
14
14
  .im-c-map-styles__item {
15
- display: flex;
16
- justify-content: center;
17
- padding: calc(var(--divider-gap) / 2);
18
- flex: 0 0 33.3333%;
19
- box-sizing: border-box;
20
- min-width: 90px;
15
+ display: flex;
16
+ justify-content: center;
17
+ padding: calc(var(--divider-gap) / 2);
18
+ flex: 0 0 33.3333%;
19
+ box-sizing: border-box;
20
+ min-width: 90px;
21
21
  }
22
22
 
23
23
  .im-c-map-styles__button {
24
- display: flex;
25
- flex-direction: column;
26
- align-items: center;
27
- border: 0;
28
- margin: 0;
29
- color: var(--foreground-color);
30
- background-color: transparent;
31
- cursor: pointer;
24
+ display: flex;
25
+ flex-direction: column;
26
+ align-items: center;
27
+ border: 0;
28
+ margin: 0;
29
+ color: var(--foreground-color);
30
+ background-color: transparent;
31
+ cursor: pointer;
32
32
  }
33
33
 
34
34
  .im-c-map-styles__image {
35
- position: relative;
36
- padding: 2px;
37
- border: 3px solid transparent;
35
+ position: relative;
36
+ padding: 2px;
37
+ border: 3px solid transparent;
38
38
  }
39
39
 
40
40
  .im-c-map-styles__image svg {
41
- fill: currentColor;
41
+ fill: currentColor;
42
42
  }
43
43
 
44
44
  .im-c-map-styles__image-bg {
45
- fill: none;
46
- stroke: var(--image-placeholder-color);
45
+ fill: none;
46
+ stroke: var(--image-placeholder-color);
47
47
  }
48
48
 
49
49
  .im-c-map-styles img {
50
- display: block;
51
- // border: 1px solid var(--image-placeholder-color);
50
+ display: block;
51
+ // border: 1px solid var(--image-placeholder-color);
52
52
  }
53
53
 
54
54
  // Text size
55
55
  .im-c-map-styles__group:not(:first-child) {
56
- margin-top: var(--divider-gap);
57
- border-top: 1px solid var(--content-border-color);
56
+ margin-top: var(--divider-gap);
57
+ border-top: 1px solid var(--content-border-color);
58
58
  }
59
59
 
60
60
  // State
61
61
  .im-c-map-styles__button[aria-pressed="true"] .im-c-map-styles__image {
62
- border-color: var(--foreground-color);
62
+ border-color: var(--foreground-color);
63
63
  }
64
64
 
65
65
  .im-c-map-styles__button:focus {
66
- outline: 0;
66
+ outline: 0;
67
67
  }
68
68
 
69
69
  .im-c-map-styles__button[data-focus-visible="true"] {
70
- .im-c-map-styles__image::after {
71
- content: '';
72
- position: absolute;
73
- top: -3px;
74
- right: -3px;
75
- bottom: -3px;
76
- left: -3px;
77
- outline: 3px solid var(--focus-outline-color);
78
- border: 3px solid var(--focus-border-color);
79
- }
70
+ .im-c-map-styles__image::after {
71
+ content: '';
72
+ position: absolute;
73
+ top: -3px;
74
+ right: -3px;
75
+ bottom: -3px;
76
+ left: -3px;
77
+ outline: 3px solid var(--focus-outline-color);
78
+ border: 3px solid var(--focus-border-color);
79
+ }
80
80
  }
81
81
 
82
82
  .im-c-map-styles__button:hover:not([aria-pressed="true"]) .im-c-map-styles__image {
83
- border-color: var(--button-hover-color);
83
+ border-color: var(--button-hover-color);
84
84
  }