@defra/interactive-map 0.0.42-fmp-5 → 0.0.43-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/package.json +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/registry/dataset.js +8 -12
- package/plugins/datasets/src/registry/dataset.test.js +17 -0
- package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +23 -13
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -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/dist/umd/index.js +1 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
- package/plugins/interact/src/hooks/useMapItemList.js +41 -12
- package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
- package/plugins/interact/src/utils/spatial.js +24 -1
- package/plugins/interact/src/utils/spatial.test.js +26 -1
- package/plugins/map-key/dist/css/index.css +1 -1
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/index.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +3 -4
- package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
- package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
- package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
- package/plugins/map-key/src/manifest.js +1 -1
- package/plugins/map-key/src/utils/groupStyles.js +7 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
- package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/index.js +1 -1
- package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
- package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
- package/plugins/menu/src/initialise/createMenu.js +3 -1
- package/plugins/menu/src/initialise/createMenu.test.js +20 -5
- package/plugins/menu/src/initialise/updateUrl.js +44 -0
- package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
- package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
- package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
- package/plugins/menu/src/reducers/pluginState.js +1 -6
- package/plugins/menu/src/reducers/pluginState.test.js +0 -25
- 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/src/openlayersProvider.js +3 -3
- package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
- package/src/App/components/CrossHair/CrossHair.jsx +19 -11
- package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
- package/src/App/components/Markers/SymbolMarker.jsx +2 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
- package/src/App/components/Panel/Panel.jsx +9 -1
- package/src/App/components/Panel/Panel.test.jsx +26 -4
- package/src/App/components/Viewport/Features.jsx +18 -4
- package/src/App/components/Viewport/Features.module.scss +4 -4
- package/src/App/components/Viewport/Features.test.jsx +155 -11
- package/src/App/components/Viewport/Viewport.jsx +3 -3
- package/src/App/hooks/useCrossHairAPI.js +29 -1
- package/src/App/hooks/useCrossHairAPI.test.js +17 -1
- package/src/App/hooks/useFeatureFocus.js +86 -28
- package/src/App/hooks/useFeatureFocus.test.js +167 -0
- package/src/App/hooks/useFeatureItems.js +1 -1
- package/src/App/hooks/useInterfaceAPI.js +2 -1
- package/src/App/hooks/useInterfaceAPI.test.js +9 -0
- package/src/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +2 -2
|
@@ -11,7 +11,7 @@ import { attachMapEvents } from './mapEvents.js'
|
|
|
11
11
|
import { attachAppEvents, createMapStyleLayer } from './appEvents.js'
|
|
12
12
|
import { getAreaDimensions, getCardinalMove, getExtentFromGeoJSON, getPaddedExtent, isGeometryObscured } from './utils/spatial.js'
|
|
13
13
|
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
|
|
14
|
-
import { queryFeatures } from './utils/queryFeatures.js'
|
|
14
|
+
import { queryFeatures, getVisibleFeatures } from './utils/queryFeatures.js'
|
|
15
15
|
import { collectTileFragments } from './utils/vtTileFragments.js'
|
|
16
16
|
import { setupHoverCursor } from './utils/hoverCursor.js'
|
|
17
17
|
import { applyOpenLayersFixes } from './utils/openLayersFixes.js'
|
|
@@ -223,8 +223,8 @@ export default class OpenLayersProvider extends MapProvider {
|
|
|
223
223
|
this._onHoverMove = setupHoverCursor(this.map, layerIds, this._onHoverMove)
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
getVisibleFeatures (
|
|
227
|
-
return
|
|
226
|
+
getVisibleFeatures (layerIds) {
|
|
227
|
+
return getVisibleFeatures(this.map, layerIds)
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
updateHighlightedFeatures (selectedFeatures, activeFeatures, stylesMap) {
|
|
@@ -26,13 +26,15 @@ const mockMapGetSize = jest.fn(() => [800, 600])
|
|
|
26
26
|
const mockMapSetTarget = jest.fn()
|
|
27
27
|
const mockMapGetPixel = jest.fn(() => [100, 200])
|
|
28
28
|
const mockMapGetCoord = jest.fn(() => [400000, 300000])
|
|
29
|
+
const mockMapGetLayers = jest.fn(() => ({ forEach: jest.fn() }))
|
|
29
30
|
const mockMapInstance = {
|
|
30
31
|
once: mockMapOnce,
|
|
31
32
|
getSize: mockMapGetSize,
|
|
32
33
|
setTarget: mockMapSetTarget,
|
|
33
34
|
getPixelFromCoordinate: mockMapGetPixel,
|
|
34
35
|
getCoordinateFromPixel: mockMapGetCoord,
|
|
35
|
-
getView: jest.fn(() => mockViewInstance)
|
|
36
|
+
getView: jest.fn(() => mockViewInstance),
|
|
37
|
+
getLayers: mockMapGetLayers
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const mockSource = {}
|
|
@@ -329,8 +331,9 @@ describe('OpenLayersProvider', () => {
|
|
|
329
331
|
expect(provider.getBounds()).toEqual([1.13, 2.46, 3.99, 4])
|
|
330
332
|
})
|
|
331
333
|
|
|
332
|
-
it('getVisibleFeatures
|
|
333
|
-
expect(provider.getVisibleFeatures()).toEqual([])
|
|
334
|
+
it('getVisibleFeatures delegates to the map (no layers registered here — see queryFeatures.test.js)', () => {
|
|
335
|
+
expect(provider.getVisibleFeatures(['draw'])).toEqual([])
|
|
336
|
+
expect(mockMapGetLayers).toHaveBeenCalled()
|
|
334
337
|
})
|
|
335
338
|
|
|
336
339
|
it('getResolution delegates to view.getResolution', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
2
2
|
import VectorLayer from 'ol/layer/Vector.js'
|
|
3
3
|
import GeoJSON from 'ol/format/GeoJSON.js'
|
|
4
|
+
import TileState from 'ol/TileState.js'
|
|
4
5
|
import { renderFeatureToGeoJSON } from './vtTileFragments.js'
|
|
5
6
|
|
|
6
7
|
const CRS = 'EPSG:27700'
|
|
@@ -75,3 +76,74 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
75
76
|
|
|
76
77
|
return results
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns every currently visible feature for the given layer ids, in the same
|
|
82
|
+
* { id, layer: { id }, geometry, properties } shape queryFeatures() above uses — mirrors
|
|
83
|
+
* MapLibre's map.queryRenderedFeatures(undefined, { layers }). Used by the interact plugin's
|
|
84
|
+
* Features list (collectVisibleFeatures in useMapItemList.js) to find selectable/labelled
|
|
85
|
+
* features without requiring a click.
|
|
86
|
+
*
|
|
87
|
+
* VectorTileLayer features come from currently-loaded tiles, the same source
|
|
88
|
+
* collectTileFragments() reads (see vtTileFragments.js) — OL only loads tiles for the current
|
|
89
|
+
* viewport (plus a small buffer), so "loaded" is a reasonable proxy for "visible" without a
|
|
90
|
+
* separate extent check. A single logical feature can be split across tile boundaries, so
|
|
91
|
+
* fragments are deduplicated the same way queryFeatures() does above.
|
|
92
|
+
* VectorLayer features come directly from the source's current-viewport extent.
|
|
93
|
+
*/
|
|
94
|
+
export const getVisibleFeatures = (map, layerIds) => {
|
|
95
|
+
const wanted = new Set(layerIds)
|
|
96
|
+
const results = []
|
|
97
|
+
const seenKeys = new Set()
|
|
98
|
+
const extent = map.getView().calculateExtent(map.getSize())
|
|
99
|
+
|
|
100
|
+
map.getLayers().forEach(mapLayer => {
|
|
101
|
+
if (mapLayer instanceof VectorTileLayer) {
|
|
102
|
+
const sourceTiles = mapLayer.getSource()?.sourceTiles_
|
|
103
|
+
if (!sourceTiles) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
Object.values(sourceTiles).forEach(tile => {
|
|
107
|
+
if (tile.getState() !== TileState.LOADED) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
tile.getFeatures().forEach(feature => {
|
|
111
|
+
const mapboxLayer = feature.get('mapbox-layer')
|
|
112
|
+
const styleLayerId = mapboxLayer?.id
|
|
113
|
+
// background-type layers have no features in MapLibre — skip to match behaviour
|
|
114
|
+
if (!styleLayerId || !wanted.has(styleLayerId) || mapboxLayer?.type === 'background') {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const key = `${styleLayerId}:${getVtFeatureId(feature)}`
|
|
118
|
+
if (seenKeys.has(key)) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
seenKeys.add(key)
|
|
122
|
+
results.push({
|
|
123
|
+
id: feature.getId(),
|
|
124
|
+
layer: { id: styleLayerId },
|
|
125
|
+
geometry: renderFeatureToGeoJSON(feature),
|
|
126
|
+
properties: feature.getProperties()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
} else if (mapLayer instanceof VectorLayer) {
|
|
131
|
+
const layerId = mapLayer.get('layerId')
|
|
132
|
+
if (!layerId || !wanted.has(layerId) || mapLayer.get('_highlight')) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
mapLayer.getSource()?.getFeaturesInExtent(extent).forEach(feature => {
|
|
136
|
+
results.push({
|
|
137
|
+
id: feature.getId(),
|
|
138
|
+
layer: { id: layerId },
|
|
139
|
+
geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
|
|
140
|
+
properties: feature.getProperties()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
} else {
|
|
144
|
+
// other layer types (e.g. TileLayer) — skip
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return results
|
|
149
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { queryFeatures } from './queryFeatures.js'
|
|
1
|
+
import { queryFeatures, getVisibleFeatures } from './queryFeatures.js'
|
|
2
2
|
|
|
3
3
|
import VectorTileLayer from 'ol/layer/VectorTile.js'
|
|
4
4
|
import VectorLayer from 'ol/layer/Vector.js'
|
|
5
|
+
import TileState from 'ol/TileState.js'
|
|
5
6
|
import { renderFeatureToGeoJSON } from './vtTileFragments.js'
|
|
6
7
|
|
|
7
8
|
jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} }))
|
|
8
9
|
jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} }))
|
|
10
|
+
jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { LOADED: 'loaded', LOADING: 'loading' } }))
|
|
9
11
|
jest.mock('ol/format/GeoJSON.js', () => ({
|
|
10
12
|
__esModule: true,
|
|
11
13
|
default: jest.fn(() => ({
|
|
@@ -179,3 +181,122 @@ describe('queryFeatures', () => {
|
|
|
179
181
|
expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(2)
|
|
180
182
|
})
|
|
181
183
|
})
|
|
184
|
+
|
|
185
|
+
describe('getVisibleFeatures', () => {
|
|
186
|
+
beforeEach(() => jest.clearAllMocks())
|
|
187
|
+
|
|
188
|
+
const EXTENT = [0, 0, 100, 100]
|
|
189
|
+
|
|
190
|
+
const makeExtentMap = (layers = []) => ({
|
|
191
|
+
getView: () => ({ calculateExtent: () => EXTENT }),
|
|
192
|
+
getSize: () => [800, 600],
|
|
193
|
+
getLayers: () => ({ forEach: cb => layers.forEach(cb) })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const makeTile = (features, state = TileState.LOADED) => ({
|
|
197
|
+
getState: () => state,
|
|
198
|
+
getFeatures: () => features
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const makeVTLayerWithTiles = (tiles) => Object.assign(new VectorTileLayer(), {
|
|
202
|
+
getSource: () => ({ sourceTiles_: tiles })
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const makeVectorLayerWithFeatures = (layerId, features, isHighlight = false) => {
|
|
206
|
+
const source = { getFeaturesInExtent: jest.fn(() => features) }
|
|
207
|
+
return Object.assign(new VectorLayer(), {
|
|
208
|
+
get: (key) => {
|
|
209
|
+
if (key === 'layerId') return layerId
|
|
210
|
+
if (key === '_highlight') return isHighlight || undefined
|
|
211
|
+
return undefined
|
|
212
|
+
},
|
|
213
|
+
getSource: () => source
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ------------------------------------------------------------------ */
|
|
218
|
+
/* VectorTile layer features */
|
|
219
|
+
/* ------------------------------------------------------------------ */
|
|
220
|
+
|
|
221
|
+
it('returns a loaded VT feature matching a requested layer id', () => {
|
|
222
|
+
const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
|
|
223
|
+
const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
|
|
224
|
+
const results = getVisibleFeatures(makeExtentMap([layer]), ['roads'])
|
|
225
|
+
expect(results).toHaveLength(1)
|
|
226
|
+
expect(results[0]).toMatchObject({ id: 1, layer: { id: 'roads' } })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('skips VT features whose style layer id is not requested', () => {
|
|
230
|
+
const feature = makeVTFeature({ id: 1, styleLayerId: 'buildings' })
|
|
231
|
+
const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
|
|
232
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('skips tiles that are not loaded', () => {
|
|
236
|
+
const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
|
|
237
|
+
const layer = makeVTLayerWithTiles({ a: makeTile([feature], TileState.LOADING) })
|
|
238
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('skips background-type VT features', () => {
|
|
242
|
+
const feature = makeVTFeature({ styleLayerId: 'roads', type: 'background' })
|
|
243
|
+
const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
|
|
244
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('deduplicates the same feature split across multiple loaded tiles', () => {
|
|
248
|
+
const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
|
|
249
|
+
const layer = makeVTLayerWithTiles({ a: makeTile([feature]), b: makeTile([feature]) })
|
|
250
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toHaveLength(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('does nothing when the VectorTileLayer has no loaded tiles yet', () => {
|
|
254
|
+
const layer = makeVTLayerWithTiles(undefined)
|
|
255
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/* ------------------------------------------------------------------ */
|
|
259
|
+
/* Vector layer features */
|
|
260
|
+
/* ------------------------------------------------------------------ */
|
|
261
|
+
|
|
262
|
+
it('returns Vector layer features in the current extent for a requested layer id', () => {
|
|
263
|
+
const feature = makeVectorFeature('f1', { type: 'Point' })
|
|
264
|
+
const layer = makeVectorLayerWithFeatures('draw', [feature])
|
|
265
|
+
const results = getVisibleFeatures(makeExtentMap([layer]), ['draw'])
|
|
266
|
+
expect(results).toHaveLength(1)
|
|
267
|
+
expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'draw' } })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('queries the source with the current view extent', () => {
|
|
271
|
+
const feature = makeVectorFeature('f1')
|
|
272
|
+
const layer = makeVectorLayerWithFeatures('draw', [feature])
|
|
273
|
+
const source = layer.getSource()
|
|
274
|
+
getVisibleFeatures(makeExtentMap([layer]), ['draw'])
|
|
275
|
+
expect(source.getFeaturesInExtent).toHaveBeenCalledWith(EXTENT)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('skips Vector layers whose layerId is not requested', () => {
|
|
279
|
+
const layer = makeVectorLayerWithFeatures('other', [makeVectorFeature('f1')])
|
|
280
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toEqual([])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('skips highlight overlay Vector layers', () => {
|
|
284
|
+
const layer = makeVectorLayerWithFeatures('draw', [makeVectorFeature('f1')], true)
|
|
285
|
+
expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toEqual([])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/* ------------------------------------------------------------------ */
|
|
289
|
+
/* Mixed / other layers */
|
|
290
|
+
/* ------------------------------------------------------------------ */
|
|
291
|
+
|
|
292
|
+
it('skips layers that are neither VectorTileLayer nor VectorLayer', () => {
|
|
293
|
+
expect(getVisibleFeatures(makeExtentMap([{}]), ['draw'])).toEqual([])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('returns results from both VT and Vector layers in one call', () => {
|
|
297
|
+
const vtLayer = makeVTLayerWithTiles({ a: makeTile([makeVTFeature({ id: 1, styleLayerId: 'roads' })]) })
|
|
298
|
+
const vecLayer = makeVectorLayerWithFeatures('draw', [makeVectorFeature('f1')])
|
|
299
|
+
const results = getVisibleFeatures(makeExtentMap([vtLayer, vecLayer]), ['roads', 'draw'])
|
|
300
|
+
expect(results).toHaveLength(2)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
@@ -16,24 +16,32 @@ export const CrossHair = () => {
|
|
|
16
16
|
const { isVisible, isPinnedToMap, state } = crossHair
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
19
|
+
<button
|
|
20
|
+
tabIndex='-1'
|
|
21
21
|
ref={crossHairRef}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
viewBox='0 0 38 38'
|
|
26
|
-
fillRule='evenodd'
|
|
27
|
-
fill='currentColor'
|
|
22
|
+
id={`${id}-cross-hair`}
|
|
23
|
+
className='im-c-cross-hair-button'
|
|
24
|
+
onClick={() => crossHair.activate?.()}
|
|
28
25
|
style={{
|
|
29
26
|
position: 'absolute',
|
|
30
27
|
left: isPinnedToMap ? 0 : '50%',
|
|
31
28
|
top: isPinnedToMap ? 0 : '50%',
|
|
32
|
-
pointerEvents: 'none',
|
|
33
29
|
display: isVisible ? 'block' : 'none'
|
|
34
30
|
}}
|
|
35
31
|
>
|
|
36
|
-
<
|
|
37
|
-
|
|
32
|
+
<svg
|
|
33
|
+
width='38'
|
|
34
|
+
height='38'
|
|
35
|
+
viewBox='0 0 38 38'
|
|
36
|
+
fillRule='evenodd'
|
|
37
|
+
fill='currentColor'
|
|
38
|
+
>
|
|
39
|
+
<path d={paths[state || 'active']} />
|
|
40
|
+
</svg>
|
|
41
|
+
{/* "Target" — shorter and easier for Voice Control users to say than "Cross hair"
|
|
42
|
+
("Click Target"); a general-purpose map is unlikely to already have a feature/marker
|
|
43
|
+
named "Target" that this would collide with. */}
|
|
44
|
+
<span className='im-u-visually-hidden'>Target</span>
|
|
45
|
+
</button>
|
|
38
46
|
)
|
|
39
47
|
}
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// The crosshair is wrapped in a real <button> (tabIndex="-1") so it's a genuine, accessibility-
|
|
2
|
+
// focusable target for coordinate-based AT (e.g. macOS Voice Control's Show Numbers/Names) —
|
|
3
|
+
// see CrossHair.jsx — without being a Tab stop.
|
|
4
|
+
.im-c-cross-hair-button {
|
|
5
|
+
// Reset native chrome to zero: default padding/border/background otherwise shift and
|
|
6
|
+
// decorate the crosshair icon, since position/margin below assumes the content-box starts
|
|
7
|
+
// exactly at the border-box edge.
|
|
8
|
+
appearance: none;
|
|
9
|
+
border: 0;
|
|
10
|
+
background: none;
|
|
11
|
+
padding: 0;
|
|
12
|
+
font: inherit;
|
|
13
|
+
cursor: inherit;
|
|
14
|
+
|
|
15
|
+
// Explicit size, matching the SVG's own width/height, plus the negative margin that shifts
|
|
16
|
+
// the box by half its own size — so the button's *own* geometry, not just its child SVG's, is
|
|
17
|
+
// centred on wherever CrossHair.jsx's inline top/left positions it. This is what a coordinate-
|
|
18
|
+
// based AT overlay (e.g. Voice Control) reads to place its badge — without it, the button's
|
|
19
|
+
// content-box collapses to 0×0 (its only child is position:absolute, contributing nothing to
|
|
20
|
+
// normal-flow sizing), so the badge would land at the anchor point's corner, not on the icon.
|
|
21
|
+
width: 38px;
|
|
22
|
+
height: 38px;
|
|
6
23
|
margin: -19px 0 0 -19px;
|
|
7
|
-
}
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
svg {
|
|
26
|
+
display: block;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
path {
|
|
30
|
+
stroke-width: 3;
|
|
31
|
+
stroke: var(--map-overlay-halo-color);
|
|
32
|
+
fill: var(--map-overlay-foreground-color);
|
|
33
|
+
paint-order: stroke fill;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { render } from '@testing-library/react'
|
|
2
|
+
import { render, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { CrossHair } from './CrossHair.jsx'
|
|
4
4
|
import { useCrossHair } from '../../hooks/useCrossHairAPI.js'
|
|
5
5
|
|
|
@@ -9,6 +9,9 @@ jest.mock('../../store/configContext', () => ({ useConfig: jest.fn(() => ({ id:
|
|
|
9
9
|
describe('CrossHair', () => {
|
|
10
10
|
const crossHairRef = React.createRef()
|
|
11
11
|
|
|
12
|
+
// Positioning/visibility/sizing all live on the outer <button> (tabIndex="-1" — a real,
|
|
13
|
+
// accessibility-focusable-but-not-Tab-stop element, so coordinate-based AT like Voice Control
|
|
14
|
+
// can find it, at the right geometry); the SVG itself is a plain, unstyled child.
|
|
12
15
|
const renderWith = (overrides = {}) => {
|
|
13
16
|
useCrossHair.mockReturnValue({
|
|
14
17
|
crossHairRef,
|
|
@@ -19,43 +22,64 @@ describe('CrossHair', () => {
|
|
|
19
22
|
...overrides
|
|
20
23
|
}
|
|
21
24
|
})
|
|
22
|
-
|
|
25
|
+
const { container } = render(<CrossHair />)
|
|
26
|
+
return { button: container.querySelector('button'), svg: container.querySelector('svg') }
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
it('renders visible active marker when pinned', () => {
|
|
26
|
-
const svg = renderWith()
|
|
30
|
+
const { button, svg } = renderWith()
|
|
27
31
|
const path = svg.querySelector('path')
|
|
28
32
|
|
|
29
|
-
expect(
|
|
30
|
-
expect(
|
|
33
|
+
expect(button).toHaveClass('im-c-cross-hair-button')
|
|
34
|
+
expect(button).toHaveStyle({
|
|
31
35
|
position: 'absolute',
|
|
32
36
|
left: 0,
|
|
33
37
|
top: 0,
|
|
34
|
-
pointerEvents: 'none',
|
|
35
38
|
display: 'block'
|
|
36
39
|
})
|
|
37
40
|
expect(path.getAttribute('d')).toContain('M5.035 20H1v-2h4.035C5.525') // active path
|
|
38
41
|
})
|
|
39
42
|
|
|
40
43
|
it('positions marker at center when not pinned', () => {
|
|
41
|
-
const
|
|
44
|
+
const { button } = renderWith({ isPinnedToMap: false })
|
|
42
45
|
|
|
43
|
-
expect(
|
|
46
|
+
expect(button).toHaveStyle({
|
|
44
47
|
left: '50%',
|
|
45
48
|
top: '50%'
|
|
46
49
|
})
|
|
47
50
|
})
|
|
48
51
|
|
|
52
|
+
// Fully removed from the layout/accessibility tree when hidden, not just faded — so Voice
|
|
53
|
+
// Control's Show Names never puts a "Target" label over nothing. Its real route back is
|
|
54
|
+
// opening MoveControls (a normal, always-visible button), not re-discovering this element.
|
|
49
55
|
it('hides marker when not visible', () => {
|
|
50
|
-
const
|
|
51
|
-
expect(
|
|
56
|
+
const { button } = renderWith({ isVisible: false })
|
|
57
|
+
expect(button).toHaveStyle({ display: 'none' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('invokes crossHair.activate() on click, for Voice Control / mouse / touch activation', () => {
|
|
61
|
+
const activate = jest.fn()
|
|
62
|
+
const { button } = renderWith({ activate })
|
|
63
|
+
fireEvent.click(button)
|
|
64
|
+
expect(activate).toHaveBeenCalledTimes(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not throw when clicked with no activate() assigned', () => {
|
|
68
|
+
const { button } = renderWith()
|
|
69
|
+
expect(() => fireEvent.click(button)).not.toThrow()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is not a Tab stop, but is a real button for coordinate-based AT (e.g. Voice Control) to find', () => {
|
|
73
|
+
const { button } = renderWith()
|
|
74
|
+
expect(button.tabIndex).toBe(-1)
|
|
75
|
+
expect(button.tagName).toBe('BUTTON')
|
|
52
76
|
})
|
|
53
77
|
|
|
54
78
|
it.each([
|
|
55
79
|
['inactive', 'M5.035 20H1v-2h4.035a13.98'],
|
|
56
80
|
[undefined, 'M5.035 20H1v-2h4.035C5.525'] // fallback to active
|
|
57
81
|
])('renders correct path for state=%s', (state, expected) => {
|
|
58
|
-
const svg = renderWith({ state })
|
|
82
|
+
const { svg } = renderWith({ state })
|
|
59
83
|
expect(svg.querySelector('path').getAttribute('d')).toContain(expected)
|
|
60
84
|
})
|
|
61
85
|
})
|
|
@@ -3,7 +3,7 @@ import { stringToKebab } from '../../../utils/stringToKebab.js'
|
|
|
3
3
|
const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) => { // NOSONAR: project does not use PropTypes
|
|
4
4
|
const { resolvedSvg, viewBox, anchor, shapeId, scaledWidth, scaledHeight } = symbolProps
|
|
5
5
|
return (
|
|
6
|
-
<svg
|
|
6
|
+
<svg
|
|
7
7
|
ref={markerRef(marker.id)}
|
|
8
8
|
id={`${mapId}-marker-${marker.id}`}
|
|
9
9
|
className={[
|
|
@@ -15,8 +15,7 @@ const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) =>
|
|
|
15
15
|
height={scaledHeight}
|
|
16
16
|
viewBox={viewBox}
|
|
17
17
|
overflow='visible'
|
|
18
|
-
|
|
19
|
-
aria-label={marker.label || 'Map marker'}
|
|
18
|
+
aria-hidden='true'
|
|
20
19
|
style={{
|
|
21
20
|
display: marker.isVisible ? 'block' : 'none',
|
|
22
21
|
marginLeft: `${-anchor[0] * scaledWidth}px`,
|
|
@@ -19,19 +19,18 @@ describe('SymbolMarker', () => {
|
|
|
19
19
|
expect(container.querySelector('svg').getAttribute('id')).toBe('my-map-marker-marker-1')
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Decorative: the ancestor markers layer is aria-hidden, and Features (the accessible
|
|
23
|
+
// features listbox) is the sole accessible surface for a marker's label — see Features.jsx.
|
|
24
|
+
it('is aria-hidden', () => {
|
|
23
25
|
const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
|
|
24
|
-
expect(container.querySelector('svg').getAttribute('
|
|
26
|
+
expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true')
|
|
25
27
|
})
|
|
26
28
|
|
|
27
|
-
it('aria-label
|
|
29
|
+
it('has no role or aria-label, regardless of marker.label', () => {
|
|
28
30
|
const { container } = render(<SymbolMarker marker={makeMarker({ label: 'My location' })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
it('aria-label falls back to Map marker when no label', () => {
|
|
33
|
-
const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
|
|
34
|
-
expect(container.querySelector('svg').getAttribute('aria-label')).toBe('Map marker')
|
|
31
|
+
const svg = container.querySelector('svg')
|
|
32
|
+
expect(svg.getAttribute('role')).toBeNull()
|
|
33
|
+
expect(svg.getAttribute('aria-label')).toBeNull()
|
|
35
34
|
})
|
|
36
35
|
|
|
37
36
|
it('applies shapeId class', () => {
|
|
@@ -120,7 +120,15 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
|
|
|
120
120
|
const panelRef = rootRef || internalPanelRef
|
|
121
121
|
|
|
122
122
|
const handleClose = () => {
|
|
123
|
-
requestAnimationFrame(() => {
|
|
123
|
+
requestAnimationFrame(() => {
|
|
124
|
+
// Re-checked here, not at handleClose-call time — the triggering element (a button, a
|
|
125
|
+
// Features-list item, ...) may have been removed from the DOM while the panel was open
|
|
126
|
+
// (e.g. the map panned and the feature it came from dropped out of the current list).
|
|
127
|
+
// document.contains() on a detached node is false, not a throw, so this is a plain check.
|
|
128
|
+
const triggeringElement = props?.triggeringElement
|
|
129
|
+
const focusTarget = triggeringElement && document.contains(triggeringElement) ? triggeringElement : layoutRefs.viewportRef.current
|
|
130
|
+
focusTarget?.focus?.({ preventScroll: interfaceType !== 'keyboard' })
|
|
131
|
+
})
|
|
124
132
|
dispatch({ type: 'CLOSE_PANEL', payload: panelId })
|
|
125
133
|
}
|
|
126
134
|
|
|
@@ -156,9 +156,18 @@ describe('Panel', () => {
|
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
describe('close functionality', () => {
|
|
159
|
+
// document.contains() needs a real Node — a plain { focus: jest.fn() } mock object throws
|
|
160
|
+
// ("parameter 1 is not of type 'Node'"), so triggeringElement here is a real element, spied
|
|
161
|
+
// on and attached to the document, matching what production always passes
|
|
162
|
+
// (e.currentTarget / document.activeElement).
|
|
163
|
+
const makeAttachedTriggeringElement = () => {
|
|
164
|
+
const el = document.createElement('button')
|
|
165
|
+
document.body.appendChild(el)
|
|
166
|
+
return { el, focusMock: jest.spyOn(el, 'focus') }
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
it('focuses triggeringElement on close for button slots', () => {
|
|
160
|
-
const focusMock =
|
|
161
|
-
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
|
|
170
|
+
const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
|
|
162
171
|
|
|
163
172
|
renderPanel(
|
|
164
173
|
{ desktop: { slot: 'top-button', dismissible: true, open: false } },
|
|
@@ -171,8 +180,7 @@ describe('Panel', () => {
|
|
|
171
180
|
})
|
|
172
181
|
|
|
173
182
|
it('handles close for non-button slots', () => {
|
|
174
|
-
const focusMock =
|
|
175
|
-
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
|
|
183
|
+
const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
|
|
176
184
|
|
|
177
185
|
renderPanel(
|
|
178
186
|
{ desktop: { slot: 'overlay', dismissible: true, modal: true } },
|
|
@@ -190,6 +198,20 @@ describe('Panel', () => {
|
|
|
190
198
|
expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
|
|
191
199
|
expect(dispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'Settings' })
|
|
192
200
|
})
|
|
201
|
+
|
|
202
|
+
it('falls back to viewportRef focus when triggeringElement has been removed from the DOM', () => {
|
|
203
|
+
const triggeringElement = document.createElement('button') // never attached
|
|
204
|
+
const focusMock = jest.spyOn(triggeringElement, 'focus')
|
|
205
|
+
|
|
206
|
+
renderPanel(
|
|
207
|
+
{ desktop: { slot: 'side', dismissible: true, open: false } },
|
|
208
|
+
{ props: { triggeringElement } }
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
|
|
212
|
+
expect(focusMock).not.toHaveBeenCalled()
|
|
213
|
+
expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
|
|
214
|
+
})
|
|
193
215
|
})
|
|
194
216
|
|
|
195
217
|
describe('content rendering', () => {
|
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
import React, { forwardRef } from 'react'
|
|
2
2
|
import { useConfig } from '../../store/configContext.js'
|
|
3
3
|
|
|
4
|
-
export const Features = forwardRef(({ activeFeatureId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur }, ref) => {
|
|
4
|
+
export const Features = forwardRef(({ activeFeatureId, tabbableId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur, onSelectItem }, ref) => {
|
|
5
5
|
const { id } = useConfig()
|
|
6
6
|
const hasItems = items.length > 0
|
|
7
|
+
// Roving tabindex: exactly one option is a Tab stop at a time (the focused item while the
|
|
8
|
+
// list has real focus, otherwise the resting entry position) — see useFeatureFocus.js.
|
|
9
|
+
const currentId = activeFeatureId ?? tabbableId
|
|
7
10
|
return (
|
|
8
11
|
<ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
|
|
9
12
|
id={`${id}-features`}
|
|
10
13
|
ref={ref}
|
|
11
14
|
role='listbox' // NOSONAR
|
|
12
|
-
tabIndex={hasItems ? '0' : '-1'}
|
|
13
15
|
aria-hidden={hasItems ? undefined : true}
|
|
14
16
|
aria-label='Map features'
|
|
15
17
|
aria-describedby={`${id}-keyboard-desc`}
|
|
16
18
|
aria-multiselectable={multiselectable || undefined}
|
|
17
|
-
aria-activedescendant={activeFeatureId ? `${id}-feature-${activeFeatureId}` : undefined}
|
|
18
19
|
className='im-c-features'
|
|
19
20
|
onFocus={onFocus}
|
|
20
21
|
onBlur={onBlur}
|
|
21
22
|
>
|
|
22
23
|
{items.map(item => (
|
|
23
|
-
<li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern
|
|
24
|
+
<li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
|
|
25
|
+
// tabIndex/onClick make each option a real, independently focusable and activatable
|
|
26
|
+
// control — required so touch screen readers and voice control can reach and operate
|
|
27
|
+
// it directly, not just app-managed keyboard-arrow navigation.
|
|
24
28
|
key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
|
|
25
29
|
data-id={item.id}
|
|
30
|
+
tabIndex={item.id === currentId ? 0 : -1}
|
|
26
31
|
aria-selected={selectedIds.includes(item.id)}
|
|
32
|
+
// Positions the (visually clipped) option over its on-map feature — item.x/item.y are
|
|
33
|
+
// screen coordinates from the interact plugin (see useMapItemList.js). Without this,
|
|
34
|
+
// every option collapses to the CSS static-position default (~top-left of the
|
|
35
|
+
// viewport), which is invisible to sighted users but misaligns coordinate-based AT
|
|
36
|
+
// overlays, e.g. macOS Voice Control's "Show Numbers", which numbers items at their
|
|
37
|
+
// real position on screen. Items without a resolved position (not yet supported for
|
|
38
|
+
// dataset/polygon features) fall back to that same default.
|
|
39
|
+
style={item.x != null ? { left: item.x, top: item.y } : undefined}
|
|
40
|
+
onClick={() => onSelectItem?.(item.id)}
|
|
27
41
|
>
|
|
28
42
|
{item.label}
|
|
29
43
|
</li>
|
|
@@ -16,10 +16,6 @@
|
|
|
16
16
|
$is-inset: true
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
-
&[data-focus-visible="true"][aria-activedescendant]::after {
|
|
20
|
-
opacity: 0;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
[role="option"] {
|
|
24
20
|
position: absolute;
|
|
25
21
|
width: 1px;
|
|
@@ -27,5 +23,9 @@
|
|
|
27
23
|
overflow: hidden;
|
|
28
24
|
clip-path: inset(50%);
|
|
29
25
|
white-space: nowrap;
|
|
26
|
+
// Roving tabindex moves real focus (and click activation) onto individual options, so they
|
|
27
|
+
// need pointer events re-enabled — overridden off .im-c-features's pointer-events: none,
|
|
28
|
+
// which otherwise lets mouse interaction pass through to the map everywhere else.
|
|
29
|
+
pointer-events: auto;
|
|
30
30
|
}
|
|
31
31
|
}
|