@defra/interactive-map 0.0.42-fmp-4 → 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/docs/api/keyboard-shortcut-definition.md +102 -0
- package/docs/api.md +2 -0
- package/docs/building-a-plugin.md +1 -0
- package/docs/plugins/draw.md +1 -1
- package/docs/plugins/plugin-manifest.md +9 -0
- package/package.json +1 -1
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +28 -2
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +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/draw/src/manifest.js +4 -1
- package/plugins/draw/src/manifest.test.js +19 -7
- 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/events.js +6 -2
- package/plugins/interact/src/events.test.js +32 -0
- 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/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/manifest.js +5 -9
- package/plugins/search/src/manifest.test.js +5 -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/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/providers/maplibre/src/defaults.test.js +26 -0
- 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/KeyboardHelp/KeyboardHelp.jsx +4 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
- package/src/App/components/MapButton/MapButton.jsx +19 -7
- package/src/App/components/MapButton/MapButton.test.jsx +42 -4
- 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 +19 -20
- package/src/App/components/Panel/Panel.module.scss +5 -0
- package/src/App/components/Panel/Panel.test.jsx +69 -4
- package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
- 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/controls/keyboardMappings.js +7 -2
- package/src/App/controls/keyboardMappings.test.js +21 -0
- package/src/App/controls/keyboardShortcuts.js +16 -2
- package/src/App/controls/keyboardShortcuts.test.js +21 -0
- 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/App/hooks/useKeyboardShortcuts.js +66 -35
- package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
- package/src/App/hooks/useModalPanelBehaviour.js +48 -34
- package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
- package/src/App/layout/Layout.jsx +4 -2
- package/src/App/layout/Layout.test.jsx +21 -0
- package/src/App/layout/layout.module.scss +7 -1
- package/src/App/registry/keyboardShortcutRegistry.js +2 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
- package/src/App/renderer/HtmlElementHost.jsx +12 -32
- package/src/App/renderer/mapButtons.js +4 -0
- package/src/App/renderer/mapButtons.test.js +20 -0
- package/src/App/renderer/mapPanels.js +33 -53
- package/src/App/renderer/mapPanels.test.js +29 -4
- package/src/App/renderer/slotHelpers.js +33 -0
- package/src/App/renderer/slotHelpers.test.js +79 -2
- package/src/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +2 -2
- package/src/types.js +40 -0
- package/src/utils/getPanelElementId.js +4 -0
- package/src/utils/getPanelElementId.test.js +11 -0
- package/src/utils/getPanelRole.js +22 -0
- package/src/utils/getPanelRole.test.js +56 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { DEFAULTS, supportedShortcuts } from './defaults.js'
|
|
2
|
+
|
|
3
|
+
describe('DEFAULTS', () => {
|
|
4
|
+
it('keeps animationDuration below the 500ms core debounce time', () => {
|
|
5
|
+
expect(DEFAULTS.animationDuration).toBeLessThan(500)
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('exposes coordinatePrecision', () => {
|
|
9
|
+
expect(DEFAULTS.coordinatePrecision).toBe(7)
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('supportedShortcuts', () => {
|
|
14
|
+
it('lists the keyboard shortcuts this provider supports', () => {
|
|
15
|
+
expect(supportedShortcuts).toEqual([
|
|
16
|
+
'showKeyboardHelp',
|
|
17
|
+
'selectControl',
|
|
18
|
+
'moveLarge',
|
|
19
|
+
'nudgeMap',
|
|
20
|
+
'zoomLarge',
|
|
21
|
+
'nudgeZoom',
|
|
22
|
+
'highlightLabelAtCenter',
|
|
23
|
+
'highlightNextLabel'
|
|
24
|
+
])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -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
|
})
|
|
@@ -10,7 +10,10 @@ const DEFAULT_GROUP = 'Navigate'
|
|
|
10
10
|
const ShortcutList = ({ items }) => (
|
|
11
11
|
<dl className='im-c-keyboard-help__list'>
|
|
12
12
|
{items.map((item) => (
|
|
13
|
-
<div
|
|
13
|
+
<div
|
|
14
|
+
key={item.id}
|
|
15
|
+
className={`im-c-keyboard-help__item${item.visuallyHidden ? ' im-u-visually-hidden' : ''}`}
|
|
16
|
+
>
|
|
14
17
|
<dt className='im-c-keyboard-help__title'>{item.title}</dt>
|
|
15
18
|
<dd className='im-c-keyboard-help__description' dangerouslySetInnerHTML={{ __html: item.command }} />
|
|
16
19
|
</div>
|
|
@@ -68,6 +68,23 @@ describe('KeyboardHelp — flat list', () => {
|
|
|
68
68
|
render(<KeyboardHelp />)
|
|
69
69
|
expect(screen.getByText('↑')).toBeInTheDocument()
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
it('visually hides a shortcut flagged visuallyHidden but keeps it in the DOM for assistive tech', () => {
|
|
73
|
+
getKeyboardShortcuts.mockReturnValue([
|
|
74
|
+
...VIEWPORT_SHORTCUTS,
|
|
75
|
+
{ id: 'sr', context: 'viewport', title: 'Get info', command: '<kbd>Alt</kbd> + <kbd>I</kbd>', visuallyHidden: true }
|
|
76
|
+
])
|
|
77
|
+
render(<KeyboardHelp />)
|
|
78
|
+
const item = screen.getByText('Get info').closest('.im-c-keyboard-help__item')
|
|
79
|
+
expect(item).toHaveClass('im-u-visually-hidden')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not visually hide a regular shortcut', () => {
|
|
83
|
+
getKeyboardShortcuts.mockReturnValue(VIEWPORT_SHORTCUTS)
|
|
84
|
+
render(<KeyboardHelp />)
|
|
85
|
+
const item = screen.getByText('Move').closest('.im-c-keyboard-help__item')
|
|
86
|
+
expect(item).not.toHaveClass('im-u-visually-hidden')
|
|
87
|
+
})
|
|
71
88
|
})
|
|
72
89
|
|
|
73
90
|
// ─── tab UI (multiple groups) ─────────────────────────────────────────────────
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// components/MapButton.jsx
|
|
2
2
|
import React, { useState, useRef, useCallback } from 'react'
|
|
3
3
|
import { stringToKebab } from '../../../utils/stringToKebab'
|
|
4
|
+
import { getPanelElementId } from '../../../utils/getPanelElementId.js'
|
|
4
5
|
import { Tooltip } from '../Tooltip/Tooltip'
|
|
5
6
|
import { Icon } from '../Icon/Icon'
|
|
6
7
|
import { SlotRenderer } from '../../renderer/SlotRenderer'
|
|
@@ -88,6 +89,10 @@ const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, s
|
|
|
88
89
|
const getButtonSlot = (panelId, buttonId) =>
|
|
89
90
|
panelId ? `${stringToKebab(buttonId)}-button` : undefined
|
|
90
91
|
|
|
92
|
+
// aria-haspopup only accepts these role-shaped tokens (plus 'true', unused here) — any other
|
|
93
|
+
// controlled-element role (e.g. a panel's 'complementary'/'region') means no aria-haspopup.
|
|
94
|
+
const HASPOPUP_ROLES = new Set(['menu', 'listbox', 'tree', 'grid', 'dialog'])
|
|
95
|
+
|
|
91
96
|
/**
|
|
92
97
|
* Determines the controlled element (panel or popup menu) for ARIA attributes.
|
|
93
98
|
* @param {Object} options - Configuration options
|
|
@@ -95,14 +100,16 @@ const getButtonSlot = (panelId, buttonId) =>
|
|
|
95
100
|
* @param {string} options.panelId - ID of the controlled panel (if applicable)
|
|
96
101
|
* @param {string} options.buttonId - Unique button identifier
|
|
97
102
|
* @param {boolean} options.hasMenu - Whether the button has a popup menu
|
|
98
|
-
* @
|
|
103
|
+
* @param {string} [options.panelRole] - The controlled panel's own ARIA role (see getPanelRole.js),
|
|
104
|
+
* used for aria-haspopup on the button — undefined when no panel is controlled.
|
|
105
|
+
* @returns {Object|null} Object with id, type ('panel' or 'popup') and role, or null if no controlled element
|
|
99
106
|
*/
|
|
100
|
-
const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
|
|
107
|
+
const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu, panelRole }) => {
|
|
101
108
|
if (panelId) {
|
|
102
|
-
return { id:
|
|
109
|
+
return { id: getPanelElementId(idPrefix, panelId), type: 'panel', role: panelRole }
|
|
103
110
|
}
|
|
104
111
|
if (hasMenu) {
|
|
105
|
-
return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup' }
|
|
112
|
+
return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup', role: 'menu' }
|
|
106
113
|
}
|
|
107
114
|
return null
|
|
108
115
|
}
|
|
@@ -168,7 +175,7 @@ const buildButtonProps = ({
|
|
|
168
175
|
'aria-expanded': ariaExpanded,
|
|
169
176
|
'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
|
|
170
177
|
'aria-controls': controlledElement?.id ?? ariaControls,
|
|
171
|
-
'aria-haspopup': controlledElement?.
|
|
178
|
+
'aria-haspopup': HASPOPUP_ROLES.has(controlledElement?.role) ? controlledElement.role : undefined,
|
|
172
179
|
...(href
|
|
173
180
|
? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
|
|
174
181
|
: { type: 'button' })
|
|
@@ -191,6 +198,7 @@ const buildButtonProps = ({
|
|
|
191
198
|
* @param {boolean} [props.isExpanded] - Whether content controlled by the button is expanded
|
|
192
199
|
* @param {boolean} [props.isHidden=false] - Whether to hide the button (CSS display: none)
|
|
193
200
|
* @param {boolean} [props.isPanelOpen=false] - Whether the controlled panel is open
|
|
201
|
+
* @param {string} [props.panelRole] - The controlled panel's own ARIA role, used for aria-haspopup
|
|
194
202
|
* @param {string} [props.variant] - CSS variant class for styling (e.g., 'primary')
|
|
195
203
|
* @param {Function} [props.onClick] - Custom click handler
|
|
196
204
|
* @param {string} [props.panelId] - ID of the panel controlled by this button
|
|
@@ -212,6 +220,7 @@ export const MapButton = ({
|
|
|
212
220
|
isExpanded,
|
|
213
221
|
isHidden,
|
|
214
222
|
isPanelOpen,
|
|
223
|
+
panelRole,
|
|
215
224
|
variant,
|
|
216
225
|
onClick,
|
|
217
226
|
panelId,
|
|
@@ -239,7 +248,7 @@ export const MapButton = ({
|
|
|
239
248
|
const hasMenu = menuItems?.length >= 1
|
|
240
249
|
const showIcon = iconId || iconSvgContent || hasMenu
|
|
241
250
|
const buttonSlot = getButtonSlot(panelId, buttonId)
|
|
242
|
-
const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
|
|
251
|
+
const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu, panelRole })
|
|
243
252
|
|
|
244
253
|
/**
|
|
245
254
|
* Handles button click events.
|
|
@@ -301,7 +310,10 @@ export const MapButton = ({
|
|
|
301
310
|
>
|
|
302
311
|
{showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
|
|
303
312
|
{buttonSlot && <SlotRenderer slot={buttonSlot} />}
|
|
304
|
-
{
|
|
313
|
+
{/* Mounted permanently, not just while open, so its aria-controls id stays a stable node. */}
|
|
314
|
+
{controlledElement?.type === 'popup' && (
|
|
315
|
+
<PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} isOpen={isPopupOpen} />
|
|
316
|
+
)}
|
|
305
317
|
</div>
|
|
306
318
|
)
|
|
307
319
|
}
|