@defra/interactive-map 0.0.26-alpha → 0.0.27-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/esm/im-core.js +1 -1
  2. package/dist/esm/im-shell.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/assets/css/docusaurus.scss +4 -0
  6. package/package.json +1 -1
  7. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  8. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/src/defaults.js +14 -14
  10. package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
  11. package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
  13. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
  14. package/plugins/beta/draw-ml/src/reducer.js +4 -1
  15. package/plugins/beta/draw-ml/src/styles.js +42 -39
  16. package/plugins/beta/draw-ol/dist/css/index.css +13 -0
  17. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
  18. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
  19. package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
  20. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
  21. package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
  22. package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
  23. package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
  24. package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
  25. package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
  26. package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
  27. package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
  28. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
  29. package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
  30. package/plugins/beta/draw-ol/src/core/styles.js +98 -0
  31. package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
  32. package/plugins/beta/draw-ol/src/defaults.js +16 -0
  33. package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
  34. package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
  35. package/plugins/beta/draw-ol/src/draw.scss +17 -0
  36. package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
  37. package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
  38. package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
  39. package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
  40. package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
  41. package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
  42. package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
  43. package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
  44. package/plugins/beta/draw-ol/src/events.js +114 -0
  45. package/plugins/beta/draw-ol/src/index.js +12 -0
  46. package/plugins/beta/draw-ol/src/manifest.js +121 -0
  47. package/plugins/beta/draw-ol/src/olDraw.js +38 -0
  48. package/plugins/beta/draw-ol/src/reducer.js +41 -0
  49. package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
  50. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
  51. package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
  52. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
  53. package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
  54. package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
  55. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
  56. package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
  57. package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
  58. package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
  59. package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
  60. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  61. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  62. package/plugins/interact/src/defaults.js +1 -0
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
  65. package/plugins/interact/src/reducer.js +7 -0
  66. package/plugins/interact/src/reducer.test.js +16 -0
  67. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  68. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  69. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  70. package/providers/beta/openlayers/src/appEvents.js +9 -13
  71. package/providers/beta/openlayers/src/appEvents.test.js +25 -17
  72. package/providers/beta/openlayers/src/mapEvents.js +32 -16
  73. package/providers/beta/openlayers/src/mapEvents.test.js +43 -10
  74. package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
  75. package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
  76. package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
  77. package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
  78. package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
  79. package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
  80. package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
  81. package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
  82. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
  83. package/providers/beta/openlayers/src/utils/tileLayers.js +19 -1
  84. package/providers/beta/openlayers/src/utils/tileLayers.test.js +74 -3
  85. package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
  86. package/rollup.esm.mjs +6 -0
  87. package/src/App/controls/keyboardActions.js +2 -2
  88. package/src/InteractiveMap/InteractiveMap.js +38 -10
  89. package/src/InteractiveMap/InteractiveMap.test.js +282 -356
  90. package/src/InteractiveMap/historyManager.js +7 -0
  91. package/src/InteractiveMap/historyManager.test.js +11 -0
  92. package/webpack.dev.mjs +3 -1
@@ -0,0 +1,245 @@
1
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
2
+ import VectorLayer from 'ol/layer/Vector.js'
3
+ import VectorSource from 'ol/source/Vector.js'
4
+ import Feature from 'ol/Feature.js'
5
+ import GeoJSON from 'ol/format/GeoJSON.js'
6
+ import Style from 'ol/style/Style.js'
7
+ import Stroke from 'ol/style/Stroke.js'
8
+ import Fill from 'ol/style/Fill.js'
9
+ import { collectTileFragments } from './vtTileFragments.js'
10
+
11
+ const CRS = 'EPSG:27700'
12
+ const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS })
13
+
14
+ const HIGHLIGHT_MARKER = '_highlight'
15
+ const HIGHLIGHT_Z = 999
16
+
17
+ const buildHighlightStyles = (styleEntry, isActive) => {
18
+ if (!styleEntry) {
19
+ return []
20
+ }
21
+ const { stroke, selectionStroke, fill, strokeWidth, activeStrokeWidth } = styleEntry
22
+
23
+ if (isActive) {
24
+ return [
25
+ new Style({ stroke: new Stroke({ color: stroke, width: activeStrokeWidth }), zIndex: HIGHLIGHT_Z }),
26
+ new Style({ stroke: new Stroke({ color: selectionStroke, width: strokeWidth }), zIndex: HIGHLIGHT_Z + 1 })
27
+ ]
28
+ }
29
+
30
+ const styles = [new Style({ stroke: new Stroke({ color: selectionStroke, width: strokeWidth }), zIndex: HIGHLIGHT_Z })]
31
+ if (fill && fill !== 'transparent') {
32
+ styles.push(new Style({ fill: new Fill({ color: fill }), zIndex: HIGHLIGHT_Z }))
33
+ }
34
+ return styles
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // VectorTileLayer: style-function wrap
39
+ // Evaluated per feature per tile at render time — automatically covers all
40
+ // tiles including those that load after selection, matching MapLibre's
41
+ // filter-based behaviour.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const buildFeatureKeyIndex = (features) => {
45
+ const keys = new Set()
46
+ const idProps = {} // styleLayerId → idProperty
47
+
48
+ for (const { layerId, featureId, idProperty } of features ?? []) {
49
+ keys.add(`${layerId}:${featureId}`)
50
+ if (idProperty) {
51
+ idProps[layerId] = idProperty
52
+ }
53
+ }
54
+
55
+ return { keys, idProps }
56
+ }
57
+
58
+ const wrapVtLayers = (map, selectedKeys, activeKeys, idPropsMap, stylesMap) => {
59
+ map.getLayers().forEach(layer => {
60
+ if (!(layer instanceof VectorTileLayer)) {
61
+ return
62
+ }
63
+
64
+ const hasSelection = selectedKeys.size > 0 || activeKeys.size > 0
65
+
66
+ if (!hasSelection) {
67
+ if (layer._highlightOriginalStyle) {
68
+ layer.setStyle(layer._highlightOriginalStyle)
69
+ delete layer._highlightOriginalStyle
70
+ }
71
+ return
72
+ }
73
+
74
+ if (!layer._highlightOriginalStyle) {
75
+ layer._highlightOriginalStyle = layer.getStyleFunction()
76
+ }
77
+ const orig = layer._highlightOriginalStyle
78
+
79
+ layer.setStyle((feature, resolution) => {
80
+ const base = orig(feature, resolution)
81
+ const styleLayerId = feature.get('mapbox-layer')?.id
82
+ if (!styleLayerId) {
83
+ return base
84
+ }
85
+
86
+ const idProp = idPropsMap[styleLayerId]
87
+ const fid = idProp ? feature.get(idProp) : feature.getId()
88
+ const key = `${styleLayerId}:${fid}`
89
+
90
+ const isActive = activeKeys.has(key)
91
+ const isSelected = !isActive && selectedKeys.has(key)
92
+ if (!isActive && !isSelected) {
93
+ return base
94
+ }
95
+
96
+ const highlightStyles = buildHighlightStyles(stylesMap?.[styleLayerId], isActive)
97
+ if (!highlightStyles.length) {
98
+ return base
99
+ }
100
+
101
+ const baseArr = base ? (Array.isArray(base) ? base : [base]) : []
102
+ return [...baseArr, ...highlightStyles]
103
+ })
104
+ // setStyle() calls layer.changed() internally — no source.changed() needed
105
+ // (source.changed() works but causes a visible flicker on selection)
106
+ })
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // VectorLayer: overlay approach
111
+ // Non-tiled vector layers (e.g. the draw layer) have no tile-boundary issue,
112
+ // so a simple overlay Feature works correctly and needs no style-wrap.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const getOrCreateHighlightLayer = (map) => {
116
+ let layer = null
117
+ map.getLayers().forEach(l => {
118
+ if (l.get(HIGHLIGHT_MARKER)) {
119
+ layer = l
120
+ }
121
+ })
122
+ if (!layer) {
123
+ layer = new VectorLayer({ source: new VectorSource(), zIndex: HIGHLIGHT_Z + 2 })
124
+ layer.set(HIGHLIGHT_MARKER, true)
125
+ map.addLayer(layer)
126
+ }
127
+ return layer
128
+ }
129
+
130
+ const addVectorHighlights = (source, features, isActive, stylesMap) => {
131
+ for (const { layerId, geometry } of features ?? []) {
132
+ if (!geometry) {
133
+ continue
134
+ }
135
+ const styles = buildHighlightStyles(stylesMap?.[layerId], isActive)
136
+ if (!styles.length) {
137
+ continue
138
+ }
139
+ const olFeature = new Feature({ geometry: geoJsonFormat.readGeometry(geometry) })
140
+ olFeature.setStyle(styles)
141
+ source.addFeature(olFeature)
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Bounds
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const expandBoundsFromGeometry = (geometry, cb) => {
150
+ const { type, coordinates } = geometry
151
+ const visitCoord = ([x, y]) => cb(x, y)
152
+ const visitRing = (ring) => ring.forEach(visitCoord)
153
+
154
+ if (type === 'Point') {
155
+ visitCoord(coordinates)
156
+ } else if (type === 'MultiPoint' || type === 'LineString') {
157
+ coordinates.forEach(visitCoord)
158
+ } else if (type === 'MultiLineString' || type === 'Polygon') {
159
+ coordinates.forEach(visitRing)
160
+ } else if (type === 'MultiPolygon') {
161
+ coordinates.forEach(poly => poly.forEach(visitRing))
162
+ }
163
+ }
164
+
165
+ const resolveGeometries = (map, { layerId, featureId, idProperty, geometry }) => {
166
+ if (featureId != null) {
167
+ const fragments = collectTileFragments(map, layerId, featureId, idProperty)
168
+ if (fragments.length > 0) {
169
+ return fragments
170
+ }
171
+ }
172
+ return geometry ? [geometry] : []
173
+ }
174
+
175
+ const computeBounds = (geometries) => {
176
+ if (!geometries.length) {
177
+ return null
178
+ }
179
+
180
+ let minX = Infinity
181
+ let minY = Infinity
182
+ let maxX = -Infinity
183
+ let maxY = -Infinity
184
+
185
+ for (const geometry of geometries) {
186
+ expandBoundsFromGeometry(geometry, (x, y) => {
187
+ if (x < minX) { minX = x }
188
+ if (y < minY) { minY = y }
189
+ if (x > maxX) { maxX = x }
190
+ if (y > maxY) { maxY = y }
191
+ })
192
+ }
193
+
194
+ return minX === Infinity ? null : [minX, minY, maxX, maxY]
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Public API
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Highlight selected/active features.
203
+ * - VectorTileLayers: style-function wrap so all tiles (including those that
204
+ * load after selection) render the highlight automatically.
205
+ * - VectorLayers (draw etc.): overlay Feature at zIndex 1001.
206
+ * Returns EPSG:27700 bounds [minX, minY, maxX, maxY] for selected features, or null.
207
+ */
208
+ export const updateHighlightedFeatures = (map, selectedFeatures, activeFeatures, stylesMap) => {
209
+ if (!map) {
210
+ return null
211
+ }
212
+
213
+ // Determine which layerIds belong to plain VectorLayers vs VT layers
214
+ const vectorLayerIds = new Set()
215
+ map.getLayers().forEach(l => {
216
+ if (l instanceof VectorLayer && !l.get(HIGHLIGHT_MARKER)) {
217
+ const id = l.get('layerId')
218
+ if (id) {
219
+ vectorLayerIds.add(id)
220
+ }
221
+ }
222
+ })
223
+
224
+ // VT layers — style-wrap
225
+ const { keys: selectedKeys, idProps: selectedIdProps } = buildFeatureKeyIndex(selectedFeatures)
226
+ const { keys: activeKeys, idProps: activeIdProps } = buildFeatureKeyIndex(activeFeatures)
227
+ const idPropsMap = { ...selectedIdProps, ...activeIdProps }
228
+ wrapVtLayers(map, selectedKeys, activeKeys, idPropsMap, stylesMap)
229
+
230
+ // VectorLayers — overlay
231
+ const hlLayer = getOrCreateHighlightLayer(map)
232
+ const hlSource = hlLayer.getSource()
233
+ hlSource.clear()
234
+
235
+ const vecSelected = (selectedFeatures ?? []).filter(f => vectorLayerIds.has(f.layerId))
236
+ const vecActive = (activeFeatures ?? []).filter(f => vectorLayerIds.has(f.layerId))
237
+
238
+ // VT features are handled by style-wrap; only add vector features to overlay
239
+ addVectorHighlights(hlSource, vecActive, true, stylesMap)
240
+ addVectorHighlights(hlSource, vecSelected, false, stylesMap)
241
+
242
+ // Bounds from all tile fragments of selected features
243
+ const allGeoms = (selectedFeatures ?? []).flatMap(feat => resolveGeometries(map, feat))
244
+ return computeBounds(allGeoms)
245
+ }
@@ -0,0 +1,65 @@
1
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
2
+ import VectorLayer from 'ol/layer/Vector.js'
3
+
4
+ const HIGHLIGHT_MARKER = '_highlight'
5
+ const HIT_TOLERANCE = 8
6
+
7
+ const isInteractiveFeature = (feature, layer, layerSet) => {
8
+ if (layer instanceof VectorTileLayer) {
9
+ const styleLayerId = feature.get('mapbox-layer')?.id
10
+ return Boolean(styleLayerId && layerSet.has(styleLayerId))
11
+ }
12
+ if (layer instanceof VectorLayer && !layer.get(HIGHLIGHT_MARKER)) {
13
+ const layerId = layer.get('layerId')
14
+ return Boolean(layerId && layerSet.has(layerId))
15
+ }
16
+ return false
17
+ }
18
+
19
+ /**
20
+ * Attaches a pointermove listener that changes the map cursor to a pointer when
21
+ * hovering over any of the specified layers. Only fires for mouse pointer events
22
+ * so touch interactions are unaffected.
23
+ *
24
+ * @param {import('ol/Map').default} map - OL map instance
25
+ * @param {string[]} layerIds - Layer IDs to watch
26
+ * @param {Function|null} prevHandler - Previous pointermove handler to remove
27
+ * @returns {Function|null} The new handler, or null if layerIds is empty
28
+ */
29
+ export const setupHoverCursor = (map, layerIds, prevHandler) => {
30
+ const viewport = map.getViewport()
31
+
32
+ if (prevHandler) {
33
+ map.un('pointermove', prevHandler)
34
+ }
35
+
36
+ if (!layerIds?.length) {
37
+ viewport.style.cursor = ''
38
+ return null
39
+ }
40
+
41
+ const layerSet = new Set(layerIds)
42
+ let rafId = null
43
+
44
+ const handler = (e) => {
45
+ if (e.originalEvent?.pointerType !== 'mouse') {
46
+ return
47
+ }
48
+ const pixel = e.pixel
49
+ if (rafId !== null) {
50
+ cancelAnimationFrame(rafId)
51
+ }
52
+ rafId = requestAnimationFrame(() => {
53
+ rafId = null
54
+ const hit = map.forEachFeatureAtPixel(
55
+ pixel,
56
+ (feature, layer) => isInteractiveFeature(feature, layer, layerSet),
57
+ { hitTolerance: HIT_TOLERANCE }
58
+ )
59
+ viewport.style.cursor = hit ? 'pointer' : ''
60
+ })
61
+ }
62
+
63
+ map.on('pointermove', handler)
64
+ return handler
65
+ }
@@ -0,0 +1,235 @@
1
+ import { setupHoverCursor } from './hoverCursor.js'
2
+
3
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
4
+ import VectorLayer from 'ol/layer/Vector.js'
5
+
6
+ jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} }))
7
+ jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} }))
8
+
9
+ const makeViewport = () => ({ style: { cursor: '' } })
10
+
11
+ const makeMap = (forEachResult = undefined) => {
12
+ const viewport = makeViewport()
13
+ return {
14
+ getViewport: () => viewport,
15
+ forEachFeatureAtPixel: jest.fn(() => forEachResult),
16
+ on: jest.fn(),
17
+ un: jest.fn()
18
+ }
19
+ }
20
+
21
+ const makeVTFeature = (styleLayerId) => ({
22
+ get: (key) => key === 'mapbox-layer' ? { id: styleLayerId } : undefined
23
+ })
24
+
25
+ const makeVectorFeature = () => ({ get: () => undefined })
26
+
27
+ const makeVTLayer = () => {
28
+ const layer = new VectorTileLayer()
29
+ layer.get = () => undefined
30
+ return layer
31
+ }
32
+
33
+ const makeVectorLayer = (layerId, isHighlight = false) => {
34
+ const layer = new VectorLayer()
35
+ layer.get = (key) => {
36
+ if (key === '_highlight') return isHighlight ? true : undefined
37
+ if (key === 'layerId') return layerId
38
+ return undefined
39
+ }
40
+ return layer
41
+ }
42
+
43
+ const move = (handler, pointerType = 'mouse') =>
44
+ handler({ pixel: [10, 10], originalEvent: { pointerType } })
45
+
46
+ describe('setupHoverCursor', () => {
47
+ beforeEach(() => {
48
+ jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
49
+ jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
50
+ })
51
+
52
+ afterEach(() => {
53
+ jest.restoreAllMocks()
54
+ })
55
+
56
+ /* ------------------------------------------------------------------ */
57
+ /* Setup / teardown */
58
+ /* ------------------------------------------------------------------ */
59
+
60
+ it('returns null and clears cursor when layerIds is empty', () => {
61
+ const map = makeMap()
62
+ const result = setupHoverCursor(map, [], null)
63
+ expect(result).toBeNull()
64
+ expect(map.getViewport().style.cursor).toBe('')
65
+ expect(map.on).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('returns null and clears cursor when layerIds is null', () => {
69
+ const map = makeMap()
70
+ const result = setupHoverCursor(map, null, null)
71
+ expect(result).toBeNull()
72
+ expect(map.getViewport().style.cursor).toBe('')
73
+ })
74
+
75
+ it('removes previous handler before attaching a new one', () => {
76
+ const map = makeMap()
77
+ const prev = jest.fn()
78
+ setupHoverCursor(map, ['layer-a'], prev)
79
+ expect(map.un).toHaveBeenCalledWith('pointermove', prev)
80
+ })
81
+
82
+ it('removes previous handler when clearing layers', () => {
83
+ const map = makeMap()
84
+ const prev = jest.fn()
85
+ setupHoverCursor(map, [], prev)
86
+ expect(map.un).toHaveBeenCalledWith('pointermove', prev)
87
+ })
88
+
89
+ it('attaches a pointermove listener and returns the handler', () => {
90
+ const map = makeMap()
91
+ const handler = setupHoverCursor(map, ['layer-a'], null)
92
+ expect(typeof handler).toBe('function')
93
+ expect(map.on).toHaveBeenCalledWith('pointermove', handler)
94
+ })
95
+
96
+ /* ------------------------------------------------------------------ */
97
+ /* Pointermove — cursor state */
98
+ /* ------------------------------------------------------------------ */
99
+
100
+ it('sets pointer cursor when an interactive VT feature is hit', () => {
101
+ const map = makeMap(true)
102
+ const handler = setupHoverCursor(map, ['fill-layer'], null)
103
+ move(handler)
104
+ expect(map.getViewport().style.cursor).toBe('pointer')
105
+ })
106
+
107
+ it('clears cursor when no interactive feature is hit', () => {
108
+ const viewport = makeViewport()
109
+ viewport.style.cursor = 'pointer'
110
+ const map = { ...makeMap(undefined), getViewport: () => viewport }
111
+ const handler = setupHoverCursor(map, ['fill-layer'], null)
112
+ move(handler)
113
+ expect(viewport.style.cursor).toBe('')
114
+ })
115
+
116
+ it('ignores touch pointer events', () => {
117
+ const map = makeMap(true)
118
+ const handler = setupHoverCursor(map, ['layer-a'], null)
119
+ move(handler, 'touch')
120
+ expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('ignores pen pointer events', () => {
124
+ const map = makeMap(true)
125
+ const handler = setupHoverCursor(map, ['layer-a'], null)
126
+ move(handler, 'pen')
127
+ expect(map.forEachFeatureAtPixel).not.toHaveBeenCalled()
128
+ })
129
+
130
+ /* ------------------------------------------------------------------ */
131
+ /* Feature matching — VectorTile layer */
132
+ /* ------------------------------------------------------------------ */
133
+
134
+ it('matches a VT feature whose mapbox-layer id is in the watched set', () => {
135
+ const map = makeMap()
136
+ const feature = makeVTFeature('roads')
137
+ const layer = makeVTLayer()
138
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
139
+
140
+ const handler = setupHoverCursor(map, ['roads'], null)
141
+ move(handler)
142
+ expect(map.getViewport().style.cursor).toBe('pointer')
143
+ })
144
+
145
+ it('does not match a VT feature whose mapbox-layer id is not in the watched set', () => {
146
+ const map = makeMap()
147
+ const feature = makeVTFeature('other-layer')
148
+ const layer = makeVTLayer()
149
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
150
+
151
+ const handler = setupHoverCursor(map, ['roads'], null)
152
+ move(handler)
153
+ expect(map.getViewport().style.cursor).toBe('')
154
+ })
155
+
156
+ /* ------------------------------------------------------------------ */
157
+ /* Feature matching — Vector layer */
158
+ /* ------------------------------------------------------------------ */
159
+
160
+ it('matches a VectorLayer feature whose layerId is in the watched set', () => {
161
+ const map = makeMap()
162
+ const feature = makeVectorFeature()
163
+ const layer = makeVectorLayer('draw')
164
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
165
+
166
+ const handler = setupHoverCursor(map, ['draw'], null)
167
+ move(handler)
168
+ expect(map.getViewport().style.cursor).toBe('pointer')
169
+ })
170
+
171
+ it('does not match a VectorLayer feature whose layerId is not in the watched set', () => {
172
+ const map = makeMap()
173
+ const feature = makeVectorFeature()
174
+ const layer = makeVectorLayer('other')
175
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
176
+
177
+ const handler = setupHoverCursor(map, ['draw'], null)
178
+ move(handler)
179
+ expect(map.getViewport().style.cursor).toBe('')
180
+ })
181
+
182
+ it('skips highlight overlay VectorLayers', () => {
183
+ const map = makeMap()
184
+ const feature = makeVectorFeature()
185
+ const layer = makeVectorLayer('draw', true)
186
+ map.forEachFeatureAtPixel.mockImplementation((pixel, cb) => cb(feature, layer))
187
+
188
+ const handler = setupHoverCursor(map, ['draw'], null)
189
+ move(handler)
190
+ expect(map.getViewport().style.cursor).toBe('')
191
+ })
192
+
193
+ /* ------------------------------------------------------------------ */
194
+ /* forEachFeatureAtPixel options */
195
+ /* ------------------------------------------------------------------ */
196
+
197
+ it('passes hitTolerance to forEachFeatureAtPixel', () => {
198
+ const map = makeMap()
199
+ const handler = setupHoverCursor(map, ['layer-a'], null)
200
+ move(handler)
201
+ expect(map.forEachFeatureAtPixel).toHaveBeenCalledWith(
202
+ [10, 10],
203
+ expect.any(Function),
204
+ { hitTolerance: 8 }
205
+ )
206
+ })
207
+
208
+ /* ------------------------------------------------------------------ */
209
+ /* RAF throttle */
210
+ /* ------------------------------------------------------------------ */
211
+
212
+ it('cancels a pending RAF when a second move fires before the frame', () => {
213
+ global.requestAnimationFrame.mockImplementation(() => 42) // don't execute immediately
214
+ const map = makeMap(true)
215
+ const handler = setupHoverCursor(map, ['layer-a'], null)
216
+ move(handler)
217
+ move(handler)
218
+ expect(global.cancelAnimationFrame).toHaveBeenCalledWith(42)
219
+ })
220
+
221
+ it('calls forEachFeatureAtPixel only once per RAF even with multiple moves', () => {
222
+ let pendingCb = null
223
+ global.requestAnimationFrame.mockImplementation(cb => { pendingCb = cb; return 1 })
224
+ global.cancelAnimationFrame.mockImplementation(() => { pendingCb = null })
225
+
226
+ const map = makeMap(true)
227
+ const handler = setupHoverCursor(map, ['layer-a'], null)
228
+ move(handler)
229
+ move(handler)
230
+ move(handler)
231
+
232
+ pendingCb?.()
233
+ expect(map.forEachFeatureAtPixel).toHaveBeenCalledTimes(1)
234
+ })
235
+ })
@@ -0,0 +1,11 @@
1
+ // OL creates hit-detection canvases without willReadFrequently, causing browser warnings.
2
+ // Patching getContext ensures any 2D canvas created after this point gets the flag.
3
+ export const applyOpenLayersFixes = () => {
4
+ if (typeof HTMLCanvasElement === 'undefined') {
5
+ return
6
+ }
7
+ const _getContext = HTMLCanvasElement.prototype.getContext
8
+ HTMLCanvasElement.prototype.getContext = function (type, attrs, ...rest) { // NOSONAR
9
+ return _getContext.call(this, type, type === '2d' ? { ...attrs, willReadFrequently: true } : attrs, ...rest)
10
+ }
11
+ }
@@ -0,0 +1,53 @@
1
+ import { applyOpenLayersFixes } from './openLayersFixes.js'
2
+
3
+ let origGetContext
4
+
5
+ beforeEach(() => {
6
+ origGetContext = HTMLCanvasElement.prototype.getContext
7
+ })
8
+
9
+ afterEach(() => {
10
+ HTMLCanvasElement.prototype.getContext = origGetContext
11
+ })
12
+
13
+ const applyFix = (spy) => {
14
+ HTMLCanvasElement.prototype.getContext = spy
15
+ applyOpenLayersFixes()
16
+ }
17
+
18
+ describe('applyOpenLayersFixes', () => {
19
+ it('adds willReadFrequently: true to 2D context requests', () => {
20
+ const spy = jest.fn()
21
+ applyFix(spy)
22
+ document.createElement('canvas').getContext('2d')
23
+ expect(spy).toHaveBeenCalledWith('2d', { willReadFrequently: true })
24
+ })
25
+
26
+ it('willReadFrequently: true wins over an explicit false', () => {
27
+ const spy = jest.fn()
28
+ applyFix(spy)
29
+ document.createElement('canvas').getContext('2d', { willReadFrequently: false })
30
+ expect(spy).toHaveBeenCalledWith('2d', { willReadFrequently: true })
31
+ })
32
+
33
+ it('preserves other 2D context attributes', () => {
34
+ const spy = jest.fn()
35
+ applyFix(spy)
36
+ document.createElement('canvas').getContext('2d', { alpha: false })
37
+ expect(spy).toHaveBeenCalledWith('2d', { alpha: false, willReadFrequently: true })
38
+ })
39
+
40
+ it('does not modify non-2D context requests', () => {
41
+ const spy = jest.fn()
42
+ applyFix(spy)
43
+ document.createElement('canvas').getContext('webgl', { antialias: true })
44
+ expect(spy).toHaveBeenCalledWith('webgl', { antialias: true })
45
+ })
46
+
47
+ it('does nothing in environments without HTMLCanvasElement', () => {
48
+ const orig = global.HTMLCanvasElement
49
+ delete global.HTMLCanvasElement
50
+ expect(() => applyOpenLayersFixes()).not.toThrow()
51
+ global.HTMLCanvasElement = orig
52
+ })
53
+ })
@@ -0,0 +1,77 @@
1
+ import VectorTileLayer from 'ol/layer/VectorTile.js'
2
+ import VectorLayer from 'ol/layer/Vector.js'
3
+ import GeoJSON from 'ol/format/GeoJSON.js'
4
+ import { renderFeatureToGeoJSON } from './vtTileFragments.js'
5
+
6
+ const CRS = 'EPSG:27700'
7
+
8
+ const geoJsonFormat = new GeoJSON({ dataProjection: CRS, featureProjection: CRS })
9
+
10
+ // Mirror MapLibre's fallback: use property hash when feature has no explicit MVT ID.
11
+ // This deduplicates tile-split fragments that share the same properties.
12
+ const getVtFeatureId = (feature) => {
13
+ const id = feature.getId()
14
+ if (id !== null && id !== undefined) {
15
+ return id
16
+ }
17
+ const props = { ...feature.getProperties() }
18
+ delete props['mapbox-layer']
19
+ return JSON.stringify(props)
20
+ }
21
+
22
+ export const queryFeatures = (map, point, options = {}) => {
23
+ if (!point) {
24
+ return []
25
+ }
26
+ const { radius = 10 } = options
27
+ const pixel = [point.x, point.y]
28
+ const results = []
29
+ const seenKeys = new Set()
30
+
31
+ map.forEachFeatureAtPixel(
32
+ pixel,
33
+ (feature, layer) => {
34
+ if (layer instanceof VectorTileLayer) {
35
+ const mapboxLayer = feature.get('mapbox-layer')
36
+ const styleLayerId = mapboxLayer?.id
37
+ // background-type layers have no features in MapLibre — skip to match behaviour
38
+ if (!styleLayerId || mapboxLayer?.type === 'background') {
39
+ return
40
+ }
41
+ const key = `${styleLayerId}:${getVtFeatureId(feature)}`
42
+ if (seenKeys.has(key)) {
43
+ return
44
+ }
45
+ seenKeys.add(key)
46
+ results.push({
47
+ id: feature.getId(),
48
+ layer: { id: styleLayerId },
49
+ geometry: renderFeatureToGeoJSON(feature),
50
+ properties: feature.getProperties()
51
+ })
52
+ } else if (layer instanceof VectorLayer) {
53
+ const layerId = layer.get('layerId')
54
+ if (!layerId || layer.get('_highlight')) {
55
+ return
56
+ }
57
+ const featureId = feature.getId()
58
+ const key = `${layerId}:${featureId}`
59
+ if (seenKeys.has(key)) {
60
+ return
61
+ }
62
+ seenKeys.add(key)
63
+ results.push({
64
+ id: featureId,
65
+ layer: { id: layerId },
66
+ geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
67
+ properties: feature.getProperties()
68
+ })
69
+ } else {
70
+ // other layer types (e.g. TileLayer) — skip
71
+ }
72
+ },
73
+ { hitTolerance: radius }
74
+ )
75
+
76
+ return results
77
+ }