@defra/interactive-map 0.0.25-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.
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/map-style-config.md +30 -0
- package/docs/assets/css/docusaurus.scss +4 -0
- package/package.json +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/defaults.js +14 -14
- package/plugins/beta/draw-ml/src/mapboxDraw.js +5 -0
- package/plugins/beta/draw-ml/src/mapboxSnap.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +10 -2
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +2 -0
- package/plugins/beta/draw-ml/src/reducer.js +4 -1
- package/plugins/beta/draw-ml/src/styles.js +42 -39
- package/plugins/beta/draw-ol/dist/css/index.css +13 -0
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/geometryHelpers.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -0
- package/plugins/beta/draw-ol/dist/esm/index.js +1 -0
- package/plugins/beta/draw-ol/src/DrawInit.jsx +67 -0
- package/plugins/beta/draw-ol/src/api/addFeature.js +29 -0
- package/plugins/beta/draw-ol/src/api/deleteFeature.js +11 -0
- package/plugins/beta/draw-ol/src/api/editFeature.js +50 -0
- package/plugins/beta/draw-ol/src/api/newLine.js +44 -0
- package/plugins/beta/draw-ol/src/api/newPolygon.js +44 -0
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +150 -0
- package/plugins/beta/draw-ol/src/core/featureStore.js +65 -0
- package/plugins/beta/draw-ol/src/core/styles.js +98 -0
- package/plugins/beta/draw-ol/src/core/undoStack.js +31 -0
- package/plugins/beta/draw-ol/src/defaults.js +16 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +113 -0
- package/plugins/beta/draw-ol/src/draw/drawInput.js +199 -0
- package/plugins/beta/draw-ol/src/draw.scss +17 -0
- package/plugins/beta/draw-ol/src/edit/EditMode.js +424 -0
- package/plugins/beta/draw-ol/src/edit/keyboardHandler.js +227 -0
- package/plugins/beta/draw-ol/src/edit/midpointLayer.js +57 -0
- package/plugins/beta/draw-ol/src/edit/touchHandler.js +171 -0
- package/plugins/beta/draw-ol/src/edit/undoOps.js +95 -0
- package/plugins/beta/draw-ol/src/edit/vertexHitTest.js +72 -0
- package/plugins/beta/draw-ol/src/edit/vertexLayer.js +49 -0
- package/plugins/beta/draw-ol/src/edit/vertexOps.js +99 -0
- package/plugins/beta/draw-ol/src/events.js +114 -0
- package/plugins/beta/draw-ol/src/index.js +12 -0
- package/plugins/beta/draw-ol/src/manifest.js +121 -0
- package/plugins/beta/draw-ol/src/olDraw.js +38 -0
- package/plugins/beta/draw-ol/src/reducer.js +41 -0
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +109 -0
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +200 -0
- package/plugins/beta/draw-ol/src/snap/snapIndicator.js +81 -0
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +56 -0
- package/plugins/beta/draw-ol/src/snap/snapManager.js +90 -0
- package/plugins/beta/draw-ol/src/utils/flattenStyleProperties.js +47 -0
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +97 -0
- package/plugins/beta/draw-ol/src/utils/olCoords.js +35 -0
- package/plugins/beta/draw-ol/src/utils/resolveColors.js +39 -0
- package/plugins/beta/draw-ol/src/utils/spatial.js +28 -0
- package/plugins/beta/draw-ol/src/utils/touchTarget.js +61 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +150 -43
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +240 -8
- package/plugins/interact/src/reducer.js +7 -0
- package/plugins/interact/src/reducer.test.js +16 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/appEvents.js +9 -13
- package/providers/beta/openlayers/src/appEvents.test.js +27 -18
- package/providers/beta/openlayers/src/mapEvents.js +34 -20
- package/providers/beta/openlayers/src/mapEvents.test.js +65 -6
- package/providers/beta/openlayers/src/openlayersProvider.js +45 -26
- package/providers/beta/openlayers/src/openlayersProvider.test.js +12 -34
- package/providers/beta/openlayers/src/utils/highlightFeatures.js +245 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.js +65 -0
- package/providers/beta/openlayers/src/utils/hoverCursor.test.js +235 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.js +11 -0
- package/providers/beta/openlayers/src/utils/openLayersFixes.test.js +53 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.js +77 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +181 -0
- package/providers/beta/openlayers/src/utils/tileLayers.js +23 -5
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +81 -5
- package/providers/beta/openlayers/src/utils/vtTileFragments.js +86 -0
- package/rollup.esm.mjs +6 -0
- package/src/App/controls/keyboardActions.js +2 -2
- package/src/InteractiveMap/InteractiveMap.js +38 -10
- package/src/InteractiveMap/InteractiveMap.test.js +282 -356
- package/src/InteractiveMap/historyManager.js +7 -0
- package/src/InteractiveMap/historyManager.test.js +11 -0
- 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
|
+
}
|