@defra/interactive-map 0.0.10-alpha → 0.0.12-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/README.md +1 -1
- 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/button-definition.md +21 -3
- package/docs/api/panel-definition.md +10 -12
- package/docs/api.md +80 -7
- package/docs/demo.mdx +70 -0
- package/docs/index.md +0 -4
- package/docs/plugins/plugin-context.md +3 -3
- package/docs/plugins/plugin-descriptor.md +37 -0
- package/docs/plugins/plugin-manifest.md +1 -1
- package/docusaurus.config.cjs +55 -25
- package/package.json +18 -9
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/src/manifest.js +3 -3
- 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/events.js +4 -14
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +3 -3
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +7 -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/src/InteractInit.jsx +28 -6
- package/plugins/interact/src/InteractInit.test.js +19 -5
- package/plugins/interact/src/events.js +17 -15
- package/plugins/interact/src/events.test.js +25 -16
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +9 -3
- package/plugins/search/src/Search.test.jsx +26 -6
- package/plugins/search/src/components/Form/Form.jsx +35 -7
- package/plugins/search/src/components/Form/Form.module.scss +27 -0
- package/plugins/search/src/components/Form/Form.test.jsx +99 -2
- package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
- package/plugins/search/src/datasets.js +15 -11
- package/plugins/search/src/datasets.test.js +17 -2
- package/plugins/search/src/events/fetchSuggestions.js +1 -1
- package/plugins/search/src/index.js +1 -1
- package/plugins/search/src/index.test.js +4 -4
- package/plugins/search/src/reducer.js +9 -4
- package/plugins/search/src/reducer.test.js +12 -7
- package/plugins/search/src/search.scss +5 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
- package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/appEvents.js +8 -2
- package/providers/beta/esri/src/esriProvider.js +25 -17
- package/providers/beta/esri/src/mapEvents.js +41 -4
- package/providers/beta/esri/src/utils/coords.js +34 -1
- package/providers/beta/esri/src/utils/coords.test.js +126 -0
- package/providers/beta/esri/src/utils/spatial.js +47 -1
- package/providers/beta/esri/src/utils/spatial.test.js +55 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/appEvents.js +10 -1
- package/providers/maplibre/src/appEvents.test.js +13 -4
- package/providers/maplibre/src/index.js +5 -13
- package/providers/maplibre/src/index.test.js +34 -15
- package/providers/maplibre/src/mapEvents.js +9 -1
- package/providers/maplibre/src/maplibreProvider.js +25 -15
- package/providers/maplibre/src/maplibreProvider.test.js +28 -2
- package/providers/maplibre/src/utils/spatial.js +51 -0
- package/providers/maplibre/src/utils/spatial.test.js +47 -0
- package/src/App/components/Actions/Actions.module.scss +5 -4
- package/src/App/components/MapButton/MapButton.jsx +4 -16
- package/src/App/components/MapButton/MapButton.module.scss +12 -12
- package/src/App/components/MapButton/MapButton.test.jsx +0 -9
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +14 -15
- package/src/App/components/Viewport/MapController.jsx +6 -1
- package/src/App/hooks/useLayoutMeasurements.js +1 -1
- package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
- package/src/App/hooks/useMapProviderOverrides.js +21 -1
- package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
- package/src/App/hooks/useMarkersAPI.js +5 -3
- package/src/App/hooks/useModalPanelBehaviour.js +19 -2
- package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
- package/src/App/hooks/useVisibleGeometry.js +100 -0
- package/src/App/hooks/useVisibleGeometry.test.js +331 -0
- package/src/App/layout/Layout.jsx +5 -5
- package/src/App/layout/layout.module.scss +2 -4
- package/src/App/registry/panelRegistry.js +1 -10
- package/src/App/registry/panelRegistry.test.js +6 -11
- package/src/App/renderer/HtmlElementHost.jsx +12 -3
- package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
- package/src/App/renderer/mapButtons.js +128 -28
- package/src/App/renderer/mapButtons.test.js +119 -19
- package/src/App/renderer/pluginWrapper.js +3 -2
- package/src/App/renderer/slots.js +1 -1
- package/src/App/store/AppProvider.jsx +1 -0
- package/src/App/store/MapProvider.jsx +18 -5
- package/src/App/store/MapProvider.test.jsx +56 -1
- package/src/App/store/appActionsMap.js +17 -9
- package/src/App/store/appActionsMap.test.js +33 -7
- package/src/App/store/appDispatchMiddleware.js +19 -0
- package/src/App/store/appDispatchMiddleware.test.js +56 -0
- package/src/App/store/mapActionsMap.js +4 -7
- package/src/InteractiveMap/InteractiveMap.js +18 -0
- package/src/InteractiveMap/InteractiveMap.test.js +12 -0
- package/src/config/appConfig.js +17 -15
- package/src/config/events.js +41 -4
- package/src/config/getInitialOpenPanels.js +2 -2
- package/src/config/getInitialOpenPanels.test.js +7 -7
- package/src/types.js +22 -11
- package/src/utils/getValueForStyle.js +1 -1
|
@@ -9,10 +9,18 @@ describe('createMapLibreProvider', () => {
|
|
|
9
9
|
|
|
10
10
|
beforeEach(() => {
|
|
11
11
|
getWebGL.mockReturnValue({ isEnabled: true, error: null })
|
|
12
|
+
|
|
13
|
+
// Ensure modern support by default
|
|
14
|
+
String.prototype.replaceAll = jest.fn()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
jest.restoreAllMocks()
|
|
12
19
|
})
|
|
13
20
|
|
|
14
|
-
test('checkDeviceCapabilities: WebGL enabled, no IE → isSupported true
|
|
21
|
+
test('checkDeviceCapabilities: WebGL enabled, modern browser, no IE → isSupported true', () => {
|
|
15
22
|
const result = createMapLibreProvider().checkDeviceCapabilities()
|
|
23
|
+
|
|
16
24
|
expect(result.isSupported).toBe(true)
|
|
17
25
|
expect(result.error).toBeFalsy()
|
|
18
26
|
expect(getWebGL).toHaveBeenCalledWith(['webgl2', 'webgl1'])
|
|
@@ -20,41 +28,52 @@ describe('createMapLibreProvider', () => {
|
|
|
20
28
|
|
|
21
29
|
test('checkDeviceCapabilities: WebGL disabled → isSupported false, returns webGL error', () => {
|
|
22
30
|
getWebGL.mockReturnValue({ isEnabled: false, error: 'WebGL not supported' })
|
|
31
|
+
|
|
23
32
|
const result = createMapLibreProvider().checkDeviceCapabilities()
|
|
33
|
+
|
|
24
34
|
expect(result.isSupported).toBe(false)
|
|
25
35
|
expect(result.error).toBe('WebGL not supported')
|
|
26
36
|
})
|
|
27
37
|
|
|
28
38
|
test('checkDeviceCapabilities: IE detected → error is IE message', () => {
|
|
29
|
-
Object.defineProperty(document, 'documentMode', {
|
|
39
|
+
Object.defineProperty(document, 'documentMode', {
|
|
40
|
+
get: () => 11,
|
|
41
|
+
configurable: true
|
|
42
|
+
})
|
|
43
|
+
|
|
30
44
|
try {
|
|
31
45
|
const result = createMapLibreProvider().checkDeviceCapabilities()
|
|
32
46
|
expect(result.error).toBe('Internet Explorer is not supported')
|
|
33
47
|
} finally {
|
|
34
|
-
Object.defineProperty(document, 'documentMode', {
|
|
48
|
+
Object.defineProperty(document, 'documentMode', {
|
|
49
|
+
get: () => undefined,
|
|
50
|
+
configurable: true
|
|
51
|
+
})
|
|
35
52
|
}
|
|
36
53
|
})
|
|
37
54
|
|
|
38
|
-
test('
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} finally {
|
|
45
|
-
global.Function = RealFunction
|
|
46
|
-
}
|
|
55
|
+
test('checkDeviceCapabilities: no replaceAll support → isSupported false', () => {
|
|
56
|
+
delete String.prototype.replaceAll
|
|
57
|
+
|
|
58
|
+
const result = createMapLibreProvider().checkDeviceCapabilities()
|
|
59
|
+
|
|
60
|
+
expect(result.isSupported).toBe(false)
|
|
47
61
|
})
|
|
48
62
|
|
|
49
|
-
test('load returns MapProvider, mapFramework, and mapProviderConfig
|
|
63
|
+
test('load returns MapProvider, mapFramework, and merged mapProviderConfig', async () => {
|
|
50
64
|
const result = await createMapLibreProvider({ tileSize: 512 }).load()
|
|
65
|
+
|
|
51
66
|
expect(result.MapProvider).toBeDefined()
|
|
52
67
|
expect(result.mapFramework).toBeDefined()
|
|
53
|
-
expect(result.mapProviderConfig).toEqual({
|
|
68
|
+
expect(result.mapProviderConfig).toEqual({
|
|
69
|
+
tileSize: 512,
|
|
70
|
+
crs: 'EPSG:4326'
|
|
71
|
+
})
|
|
54
72
|
})
|
|
55
73
|
|
|
56
74
|
test('load uses empty default config', async () => {
|
|
57
75
|
const { mapProviderConfig } = await createMapLibreProvider().load()
|
|
76
|
+
|
|
58
77
|
expect(mapProviderConfig).toEqual({ crs: 'EPSG:4326' })
|
|
59
78
|
})
|
|
60
|
-
})
|
|
79
|
+
})
|
|
@@ -4,7 +4,15 @@ import { throttle } from '../../../src/utils/throttle.js'
|
|
|
4
4
|
const DEBOUNCE_IDLE_TIME = 500
|
|
5
5
|
const MOVE_THROTTLE_TIME = 10
|
|
6
6
|
|
|
7
|
-
export function attachMapEvents ({
|
|
7
|
+
export function attachMapEvents ({
|
|
8
|
+
map,
|
|
9
|
+
events,
|
|
10
|
+
eventBus,
|
|
11
|
+
getCenter,
|
|
12
|
+
getZoom,
|
|
13
|
+
getBounds,
|
|
14
|
+
getResolution
|
|
15
|
+
}) {
|
|
8
16
|
const handlers = []
|
|
9
17
|
const debouncers = []
|
|
10
18
|
|
|
@@ -7,7 +7,7 @@ import { DEFAULTS, supportedShortcuts } from './defaults.js'
|
|
|
7
7
|
import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js'
|
|
8
8
|
import { attachMapEvents } from './mapEvents.js'
|
|
9
9
|
import { attachAppEvents } from './appEvents.js'
|
|
10
|
-
import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
|
|
10
|
+
import { getAreaDimensions, getCardinalMove, getBboxFromGeoJSON, isGeometryObscured, getResolution, getPaddedBounds } from './utils/spatial.js'
|
|
11
11
|
import { createMapLabelNavigator } from './utils/labels.js'
|
|
12
12
|
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
|
|
13
13
|
import { queryFeatures } from './utils/queryFeatures.js'
|
|
@@ -44,7 +44,9 @@ export default class MapLibreProvider {
|
|
|
44
44
|
* @returns {Promise<void>}
|
|
45
45
|
*/
|
|
46
46
|
async initMap (config) {
|
|
47
|
-
const { container, padding, mapStyle, center, zoom, bounds, pixelRatio, ...initConfig } = config
|
|
47
|
+
const { container, padding, mapStyle, mapSize, center, zoom, bounds, pixelRatio, ...initConfig } = config
|
|
48
|
+
this.mapStyleId = mapStyle?.id
|
|
49
|
+
this.mapSize = mapSize
|
|
48
50
|
const { Map: MaplibreMap } = this.maplibreModule
|
|
49
51
|
const { events, eventBus } = this
|
|
50
52
|
|
|
@@ -90,6 +92,7 @@ export default class MapLibreProvider {
|
|
|
90
92
|
})
|
|
91
93
|
|
|
92
94
|
attachAppEvents({
|
|
95
|
+
mapProvider: this,
|
|
93
96
|
map,
|
|
94
97
|
events,
|
|
95
98
|
eventBus
|
|
@@ -100,17 +103,12 @@ export default class MapLibreProvider {
|
|
|
100
103
|
this.labelNavigator = createMapLabelNavigator(map, mapStyle?.mapColorScheme, events, eventBus)
|
|
101
104
|
})
|
|
102
105
|
|
|
103
|
-
this.eventBus.emit(events.MAP_READY,
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Returns the public API exposed via the map:ready event. */
|
|
107
|
-
getMapAPI () {
|
|
108
|
-
return {
|
|
106
|
+
this.eventBus.emit(events.MAP_READY, {
|
|
109
107
|
map: this.map,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
108
|
+
mapStyleId: this.mapStyleId,
|
|
109
|
+
mapSize: this.mapSize,
|
|
110
|
+
crs: this.crs
|
|
111
|
+
})
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
/** Destroy the map and clean up resources. */
|
|
@@ -177,12 +175,13 @@ export default class MapLibreProvider {
|
|
|
177
175
|
}
|
|
178
176
|
|
|
179
177
|
/**
|
|
180
|
-
* Fit map view to the specified bounds
|
|
178
|
+
* Fit map view to the specified bounds or GeoJSON geometry.
|
|
181
179
|
*
|
|
182
|
-
* @param {[number, number, number, number]} bounds - Bounds as [west, south, east, north].
|
|
180
|
+
* @param {[number, number, number, number] | object} bounds - Bounds as [west, south, east, north], or a GeoJSON Feature, FeatureCollection, or geometry. Bbox is computed from GeoJSON using @turf/bbox.
|
|
183
181
|
*/
|
|
184
182
|
fitToBounds (bounds) {
|
|
185
|
-
|
|
183
|
+
const bbox = Array.isArray(bounds) ? bounds : getBboxFromGeoJSON(bounds)
|
|
184
|
+
this.map.fitBounds(bbox, { duration: DEFAULTS.animationDuration })
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
/**
|
|
@@ -338,4 +337,15 @@ export default class MapLibreProvider {
|
|
|
338
337
|
const { lng, lat } = this.map.unproject([point.x, point.y])
|
|
339
338
|
return [lng, lat]
|
|
340
339
|
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry.
|
|
345
|
+
* @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates).
|
|
346
|
+
* @returns {boolean}
|
|
347
|
+
*/
|
|
348
|
+
isGeometryObscured (geojson, panelRect) {
|
|
349
|
+
return isGeometryObscured(geojson, panelRect, this.map)
|
|
350
|
+
}
|
|
341
351
|
}
|
|
@@ -4,7 +4,7 @@ import { attachAppEvents } from './appEvents.js'
|
|
|
4
4
|
import { createMapLabelNavigator } from './utils/labels.js'
|
|
5
5
|
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
|
|
6
6
|
import { queryFeatures } from './utils/queryFeatures.js'
|
|
7
|
-
import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds } from './utils/spatial.js'
|
|
7
|
+
import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
|
|
8
8
|
|
|
9
9
|
jest.mock('./defaults.js', () => ({
|
|
10
10
|
DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 },
|
|
@@ -19,6 +19,8 @@ jest.mock('./appEvents.js', () => ({ attachAppEvents: jest.fn() }))
|
|
|
19
19
|
jest.mock('./utils/spatial.js', () => ({
|
|
20
20
|
getAreaDimensions: jest.fn(() => '400m by 750m'),
|
|
21
21
|
getCardinalMove: jest.fn(() => 'north'),
|
|
22
|
+
getBboxFromGeoJSON: jest.fn(() => [-1, 50, 1, 52]),
|
|
23
|
+
isGeometryObscured: jest.fn(() => true),
|
|
22
24
|
getResolution: jest.fn(() => 10),
|
|
23
25
|
getPaddedBounds: jest.fn(() => [[0, 0], [1, 1]])
|
|
24
26
|
}))
|
|
@@ -84,7 +86,7 @@ describe('MapLibreProvider', () => {
|
|
|
84
86
|
expect(map.setPadding).toHaveBeenCalled()
|
|
85
87
|
expect(attachMapEvents).toHaveBeenCalled()
|
|
86
88
|
expect(attachAppEvents).toHaveBeenCalled()
|
|
87
|
-
expect(eventBus.emit).toHaveBeenCalledWith('map:ready',
|
|
89
|
+
expect(eventBus.emit).toHaveBeenCalledWith('map:ready', { map, mapStyleId: undefined, mapSize: undefined, crs: undefined })
|
|
88
90
|
})
|
|
89
91
|
|
|
90
92
|
test('initMap: fitBounds called when bounds provided; skipped when absent; null mapStyle → style undefined', async () => {
|
|
@@ -157,6 +159,30 @@ describe('MapLibreProvider', () => {
|
|
|
157
159
|
expect(map.setPadding).toHaveBeenCalledWith({ top: 5 })
|
|
158
160
|
})
|
|
159
161
|
|
|
162
|
+
test('fitToBounds accepts GeoJSON: computes bbox via getBboxFromGeoJSON', async () => {
|
|
163
|
+
const { getBboxFromGeoJSON } = require('./utils/spatial.js')
|
|
164
|
+
const p = makeProvider()
|
|
165
|
+
await doInitMap(p)
|
|
166
|
+
const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
|
|
167
|
+
|
|
168
|
+
p.fitToBounds(feature)
|
|
169
|
+
|
|
170
|
+
expect(getBboxFromGeoJSON).toHaveBeenCalledWith(feature)
|
|
171
|
+
expect(map.fitBounds).toHaveBeenCalledWith([-1, 50, 1, 52], { duration: 400 })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('isGeometryObscured delegates to spatial utility with map instance', async () => {
|
|
175
|
+
const p = makeProvider()
|
|
176
|
+
await doInitMap(p)
|
|
177
|
+
const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
|
|
178
|
+
const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
|
|
179
|
+
|
|
180
|
+
const result = p.isGeometryObscured(geojson, panelRect)
|
|
181
|
+
|
|
182
|
+
expect(isGeometryObscured).toHaveBeenCalledWith(geojson, panelRect, map)
|
|
183
|
+
expect(result).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
160
186
|
test('getCenter, getZoom, getBounds return formatted values', async () => {
|
|
161
187
|
const p = makeProvider()
|
|
162
188
|
await doInitMap(p)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/utils/spatial.js
|
|
2
2
|
import LatLon from 'geodesy/latlon-spherical.js'
|
|
3
|
+
import turfBbox from '@turf/bbox'
|
|
3
4
|
|
|
4
5
|
// -----------------------------------------------------------------------------
|
|
5
6
|
// Internal (not exported)
|
|
@@ -186,9 +187,59 @@ const getPaddedBounds = (LngLatBounds, map) => {
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Get a flat bbox [west, south, east, north] from any GeoJSON object
|
|
192
|
+
* (Feature, FeatureCollection, or geometry).
|
|
193
|
+
*
|
|
194
|
+
* @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry
|
|
195
|
+
* @returns {[number, number, number, number]}
|
|
196
|
+
*/
|
|
197
|
+
const getBboxFromGeoJSON = (geojson) => turfBbox(geojson)
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Returns true if the geometry's screen bounding box overlaps the given panel rectangle.
|
|
201
|
+
* Used to decide whether to pan/zoom when a panel opens over a visibleGeometry target.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} geojson - GeoJSON Feature, FeatureCollection, or geometry
|
|
204
|
+
* @param {DOMRect} panelRect - Bounding rect of the panel element (viewport coordinates)
|
|
205
|
+
* @param {object} map - MapLibre map instance
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
const isGeometryObscured = (geojson, panelRect, map) => {
|
|
209
|
+
const containerRect = map.getContainer().getBoundingClientRect()
|
|
210
|
+
const [west, south, east, north] = getBboxFromGeoJSON(geojson)
|
|
211
|
+
|
|
212
|
+
const corners = [
|
|
213
|
+
map.project([west, south]),
|
|
214
|
+
map.project([west, north]),
|
|
215
|
+
map.project([east, south]),
|
|
216
|
+
map.project([east, north])
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
const screenMinX = Math.min(...corners.map(c => c.x))
|
|
220
|
+
const screenMaxX = Math.max(...corners.map(c => c.x))
|
|
221
|
+
const screenMinY = Math.min(...corners.map(c => c.y))
|
|
222
|
+
const screenMaxY = Math.max(...corners.map(c => c.y))
|
|
223
|
+
|
|
224
|
+
// Convert panelRect from viewport coords to map-container-relative coords
|
|
225
|
+
const panelLeft = panelRect.left - containerRect.left
|
|
226
|
+
const panelTop = panelRect.top - containerRect.top
|
|
227
|
+
const panelRight = panelRect.right - containerRect.left
|
|
228
|
+
const panelBottom = panelRect.bottom - containerRect.top
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
screenMinX < panelRight &&
|
|
232
|
+
screenMaxX > panelLeft &&
|
|
233
|
+
screenMinY < panelBottom &&
|
|
234
|
+
screenMaxY > panelTop
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
189
238
|
export {
|
|
190
239
|
getAreaDimensions,
|
|
191
240
|
getCardinalMove,
|
|
241
|
+
getBboxFromGeoJSON,
|
|
242
|
+
isGeometryObscured,
|
|
192
243
|
spatialNavigate,
|
|
193
244
|
getResolution,
|
|
194
245
|
getPaddedBounds,
|
|
@@ -7,6 +7,8 @@ jest.mock('geodesy/latlon-spherical.js', () =>
|
|
|
7
7
|
}))
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
+
jest.mock('@turf/bbox', () => jest.fn(() => [-1, 50, 1, 52]))
|
|
11
|
+
|
|
10
12
|
describe('spatial utils', () => {
|
|
11
13
|
|
|
12
14
|
test('formatDimension hits all branches', () => {
|
|
@@ -93,4 +95,49 @@ describe('spatial utils', () => {
|
|
|
93
95
|
expect(bounds.sw).toBeDefined()
|
|
94
96
|
expect(bounds.ne).toBeDefined()
|
|
95
97
|
})
|
|
98
|
+
|
|
99
|
+
test('getBboxFromGeoJSON delegates to @turf/bbox and returns flat bbox array', () => {
|
|
100
|
+
const turfBbox = require('@turf/bbox')
|
|
101
|
+
const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 52] }, properties: {} }
|
|
102
|
+
|
|
103
|
+
const result = spatial.getBboxFromGeoJSON(feature)
|
|
104
|
+
|
|
105
|
+
expect(turfBbox).toHaveBeenCalledWith(feature)
|
|
106
|
+
expect(result).toEqual([-1, 50, 1, 52])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('isGeometryObscured', () => {
|
|
110
|
+
const geojson = { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 51] }, properties: {} }
|
|
111
|
+
// getBboxFromGeoJSON is mocked to always return [-1, 50, 1, 52]
|
|
112
|
+
|
|
113
|
+
// Container sits at viewport origin so container-relative coords equal viewport coords
|
|
114
|
+
const makeMap = (projectFn) => ({
|
|
115
|
+
getContainer: jest.fn(() => ({
|
|
116
|
+
getBoundingClientRect: jest.fn(() => ({ left: 0, top: 0, right: 1000, bottom: 800 }))
|
|
117
|
+
})),
|
|
118
|
+
project: jest.fn(projectFn)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Panel occupies the right 400px of the viewport
|
|
122
|
+
const panelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
|
|
123
|
+
|
|
124
|
+
test('returns true when geometry screen bbox overlaps the panel rect', () => {
|
|
125
|
+
// Corners project into the panel (x: 650 is between panelLeft 600 and panelRight 1000)
|
|
126
|
+
const map = makeMap(() => ({ x: 650, y: 400 }))
|
|
127
|
+
expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('returns false when geometry screen bbox does not overlap the panel rect', () => {
|
|
131
|
+
// Corners project to x: 300, entirely left of panelLeft (600)
|
|
132
|
+
const map = makeMap(() => ({ x: 300, y: 400 }))
|
|
133
|
+
expect(spatial.isGeometryObscured(geojson, panelRect, map)).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('projects all four bbox corners', () => {
|
|
137
|
+
const map = makeMap(() => ({ x: 300, y: 400 }))
|
|
138
|
+
spatial.isGeometryObscured(geojson, panelRect, map)
|
|
139
|
+
// bbox is [-1, 50, 1, 52]: corners are [-1,50], [-1,52], [1,50], [1,52]
|
|
140
|
+
expect(map.project).toHaveBeenCalledTimes(4)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
96
143
|
})
|
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
padding-top var(--duration) ease, padding-bottom var(--duration) ease;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
&--border-top {
|
|
15
|
+
border-top: 1px solid var(--app-border-color);
|
|
16
|
+
}
|
|
17
|
+
|
|
14
18
|
&--hidden {
|
|
15
19
|
max-height: 0;
|
|
16
20
|
overflow: hidden;
|
|
17
21
|
opacity: 0;
|
|
18
22
|
padding-top: 0;
|
|
19
23
|
padding-bottom: 0;
|
|
24
|
+
border: 0;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
.im-c-button-wrapper--wide {
|
|
@@ -30,10 +35,6 @@
|
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
.im-c-actions--border-top {
|
|
34
|
-
border-top: 1px solid var(--app-border-color);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
38
|
.im-o-app--tablet, .im-o-app--desktop {
|
|
38
39
|
.im-c-actions {
|
|
39
40
|
min-width: 0;
|
|
@@ -26,18 +26,12 @@ const buildButtonClassNames = (buttonId, variant, showLabel) => [
|
|
|
26
26
|
* Builds CSS class names for the wrapper div that contains the button.
|
|
27
27
|
* @param {string} buttonId - Unique identifier for the button
|
|
28
28
|
* @param {boolean} showLabel - Whether the button label is displayed
|
|
29
|
-
* @param {boolean} groupStart - Whether this button is at the start of a button group
|
|
30
|
-
* @param {boolean} groupMiddle - Whether this button is in the middle of a button group
|
|
31
|
-
* @param {boolean} groupEnd - Whether this button is at the end of a button group
|
|
32
29
|
* @returns {string} Space-separated CSS class names for the wrapper
|
|
33
30
|
*/
|
|
34
|
-
const buildWrapperClassNames = (buttonId, showLabel
|
|
31
|
+
const buildWrapperClassNames = (buttonId, showLabel) => [
|
|
35
32
|
'im-c-button-wrapper',
|
|
36
33
|
buttonId && `im-c-button-wrapper--${stringToKebab(buttonId)}`,
|
|
37
|
-
showLabel && 'im-c-button-wrapper--wide'
|
|
38
|
-
groupStart && 'im-c-button-wrapper--group-start',
|
|
39
|
-
groupMiddle && 'im-c-button-wrapper--group-middle',
|
|
40
|
-
groupEnd && 'im-c-button-wrapper--group-end'
|
|
34
|
+
showLabel && 'im-c-button-wrapper--wide'
|
|
41
35
|
].filter(Boolean).join(' ')
|
|
42
36
|
|
|
43
37
|
/**
|
|
@@ -159,9 +153,6 @@ const buildButtonProps = ({
|
|
|
159
153
|
* @param {Array<Object>} [props.menuItems] - Array of items for popup menu
|
|
160
154
|
* @param {string} [props.idPrefix=''] - Prefix for generated panel/popup IDs
|
|
161
155
|
* @param {string} [props.href] - URL for anchor element; if provided, renders as <a> instead of <button>
|
|
162
|
-
* @param {boolean} [props.groupStart=false] - Whether button is at start of button group
|
|
163
|
-
* @param {boolean} [props.groupMiddle=false] - Whether button is in middle of button group
|
|
164
|
-
* @param {boolean} [props.groupEnd=false] - Whether button is at end of button group
|
|
165
156
|
* @returns {JSX.Element} The rendered button component
|
|
166
157
|
*/
|
|
167
158
|
export const MapButton = ({
|
|
@@ -180,10 +171,7 @@ export const MapButton = ({
|
|
|
180
171
|
panelId,
|
|
181
172
|
menuItems,
|
|
182
173
|
idPrefix,
|
|
183
|
-
href
|
|
184
|
-
groupMiddle,
|
|
185
|
-
groupStart,
|
|
186
|
-
groupEnd
|
|
174
|
+
href
|
|
187
175
|
}) => {
|
|
188
176
|
const { id: appId } = useConfig()
|
|
189
177
|
const { buttonRefs } = useApp()
|
|
@@ -255,7 +243,7 @@ export const MapButton = ({
|
|
|
255
243
|
|
|
256
244
|
return (
|
|
257
245
|
<div
|
|
258
|
-
className={buildWrapperClassNames(buttonId, showLabel
|
|
246
|
+
className={buildWrapperClassNames(buttonId, showLabel)}
|
|
259
247
|
style={isHidden ? { display: 'none' } : undefined}
|
|
260
248
|
>
|
|
261
249
|
{showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
|
|
@@ -138,16 +138,16 @@
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
.im-o-app__right {
|
|
141
|
-
.im-c-button-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
.im-c-button-group {
|
|
142
|
+
display: flex;
|
|
143
|
+
flex-direction: column;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
.im-c-button-
|
|
146
|
+
.im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
|
|
147
147
|
@include tools.border-focus-corner-override($corners: 'top');
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
.im-c-button-
|
|
150
|
+
.im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
|
|
151
151
|
@include tools.border-focus-corner-override($corners: 'none');
|
|
152
152
|
|
|
153
153
|
&::before {
|
|
@@ -155,23 +155,23 @@
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
.im-c-button-
|
|
158
|
+
.im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
|
|
159
159
|
margin-top: 0;
|
|
160
160
|
@include tools.border-focus-corner-override($corners: 'bottom');
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
.im-o-app__top {
|
|
165
|
-
.im-c-button-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
.im-c-button-group {
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-direction: row;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
.im-c-button-
|
|
170
|
+
.im-c-button-group .im-c-button-wrapper:first-child .im-c-map-button {
|
|
171
171
|
@include tools.border-focus-corner-override($corners: 'left');
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
.im-c-button-
|
|
174
|
+
.im-c-button-group .im-c-button-wrapper:not(:first-child):not(:last-child) .im-c-map-button {
|
|
175
175
|
@include tools.border-focus-corner-override($corners: 'none');
|
|
176
176
|
|
|
177
177
|
&::before {
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
.im-c-button-
|
|
182
|
+
.im-c-button-group .im-c-button-wrapper:last-child .im-c-map-button {
|
|
183
183
|
@include tools.border-focus-corner-override($corners: 'right');
|
|
184
184
|
}
|
|
185
185
|
}
|
|
@@ -66,15 +66,6 @@ describe('MapButton', () => {
|
|
|
66
66
|
expect(container.firstChild).toHaveStyle('display: none')
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
it.each([
|
|
70
|
-
['groupStart', 'im-c-button-wrapper--group-start'],
|
|
71
|
-
['groupMiddle', 'im-c-button-wrapper--group-middle'],
|
|
72
|
-
['groupEnd', 'im-c-button-wrapper--group-end']
|
|
73
|
-
])('applies wrapper %s class', (prop, className) => {
|
|
74
|
-
const { container } = renderButton({ [prop]: true })
|
|
75
|
-
expect(container.firstChild).toHaveClass(className)
|
|
76
|
-
})
|
|
77
|
-
|
|
78
69
|
it('handles panelId aria attributes', () => {
|
|
79
70
|
renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isPanelOpen: false })
|
|
80
71
|
const button = getButton()
|
|
@@ -7,10 +7,10 @@ import { useIsScrollable } from '../../hooks/useIsScrollable.js'
|
|
|
7
7
|
import { Icon } from '../Icon/Icon'
|
|
8
8
|
|
|
9
9
|
const computePanelState = (bpConfig, triggeringElement) => {
|
|
10
|
-
const isAside = bpConfig.slot === 'side' && bpConfig.
|
|
11
|
-
const isDialog = !isAside && bpConfig.
|
|
10
|
+
const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
|
|
11
|
+
const isDialog = !isAside && bpConfig.dismissible
|
|
12
12
|
const isModal = bpConfig.modal === true
|
|
13
|
-
const isDismissable = bpConfig.
|
|
13
|
+
const isDismissable = bpConfig.dismissible !== false
|
|
14
14
|
const shouldFocus = Boolean(isModal || triggeringElement)
|
|
15
15
|
const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
|
|
16
16
|
return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
|
|
@@ -96,8 +96,8 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
|
|
|
96
96
|
}
|
|
97
97
|
}, [isOpen])
|
|
98
98
|
|
|
99
|
-
const panelClass = buildPanelClassNames(bpConfig.slot,
|
|
100
|
-
const panelBodyClass = buildPanelBodyClassNames(
|
|
99
|
+
const panelClass = buildPanelClassNames(bpConfig.slot, bpConfig.showLabel ?? true)
|
|
100
|
+
const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissable)
|
|
101
101
|
const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
|
|
102
102
|
|
|
103
103
|
const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissable, isModal, width: bpConfig.width, panelClass })
|
|
@@ -110,7 +110,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
|
|
|
110
110
|
>
|
|
111
111
|
<h2
|
|
112
112
|
id={`${elementId}-label`}
|
|
113
|
-
className={
|
|
113
|
+
className={(bpConfig.showLabel ?? true) ? 'im-c-panel__heading im-e-heading-m' : 'im-u-visually-hidden'}
|
|
114
114
|
>
|
|
115
115
|
{label}
|
|
116
116
|
</h2>
|
|
@@ -32,8 +32,7 @@ describe('Panel', () => {
|
|
|
32
32
|
|
|
33
33
|
const renderPanel = (config = {}, props = {}) => {
|
|
34
34
|
const panelConfig = {
|
|
35
|
-
showLabel: true,
|
|
36
|
-
desktop: { slot: 'side', initiallyOpen: true, dismissable: false, modal: false },
|
|
35
|
+
desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true },
|
|
37
36
|
...config
|
|
38
37
|
}
|
|
39
38
|
return render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' {...props} />)
|
|
@@ -49,17 +48,17 @@ describe('Panel', () => {
|
|
|
49
48
|
})
|
|
50
49
|
|
|
51
50
|
it('renders visually hidden label when showLabel=false', () => {
|
|
52
|
-
renderPanel({ showLabel: false })
|
|
51
|
+
renderPanel({ desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: false } })
|
|
53
52
|
expect(screen.getByText('Settings')).toHaveClass('im-u-visually-hidden')
|
|
54
53
|
})
|
|
55
54
|
|
|
56
|
-
it('applies offset class to body when showLabel=false and
|
|
57
|
-
renderPanel({
|
|
55
|
+
it('applies offset class to body when showLabel=false and dismissible', () => {
|
|
56
|
+
renderPanel({ desktop: { slot: 'side', dismissible: true, open: false, showLabel: false } })
|
|
58
57
|
expect(screen.getByRole('dialog').querySelector('.im-c-panel__body')).toHaveClass('im-c-panel__body--offset')
|
|
59
58
|
})
|
|
60
59
|
|
|
61
60
|
it('applies width style if provided', () => {
|
|
62
|
-
renderPanel({ desktop: { slot: 'side',
|
|
61
|
+
renderPanel({ desktop: { slot: 'side', dismissible: true, open: true, width: '300px' } })
|
|
63
62
|
expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' })
|
|
64
63
|
})
|
|
65
64
|
|
|
@@ -82,23 +81,23 @@ describe('Panel', () => {
|
|
|
82
81
|
})
|
|
83
82
|
|
|
84
83
|
describe('role and aria attributes', () => {
|
|
85
|
-
it('renders region role for non-
|
|
84
|
+
it('renders region role for non-dismissible panels', () => {
|
|
86
85
|
renderPanel()
|
|
87
86
|
expect(screen.getByRole('region')).toBeInTheDocument()
|
|
88
87
|
})
|
|
89
88
|
|
|
90
|
-
it('renders dialog role for
|
|
91
|
-
renderPanel({ desktop: { slot: 'side',
|
|
89
|
+
it('renders dialog role for dismissible non-aside panels', () => {
|
|
90
|
+
renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
|
|
92
91
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
93
92
|
})
|
|
94
93
|
|
|
95
|
-
it('renders complementary role for
|
|
96
|
-
renderPanel({ desktop: { slot: 'side',
|
|
94
|
+
it('renders complementary role for dismissible aside panels', () => {
|
|
95
|
+
renderPanel({ desktop: { slot: 'side', open: true, dismissible: true } })
|
|
97
96
|
expect(screen.getByRole('complementary')).toBeInTheDocument()
|
|
98
97
|
})
|
|
99
98
|
|
|
100
99
|
it('sets aria-modal and tabIndex for modal dialogs', () => {
|
|
101
|
-
renderPanel({ desktop: { slot: 'overlay',
|
|
100
|
+
renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } })
|
|
102
101
|
const dialog = screen.getByRole('dialog')
|
|
103
102
|
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
|
104
103
|
expect(dialog).toHaveAttribute('tabIndex', '-1')
|
|
@@ -111,7 +110,7 @@ describe('Panel', () => {
|
|
|
111
110
|
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
|
|
112
111
|
|
|
113
112
|
renderPanel(
|
|
114
|
-
{ desktop: { slot: 'top-button',
|
|
113
|
+
{ desktop: { slot: 'top-button', dismissible: true, open: false } },
|
|
115
114
|
{ props: { triggeringElement } }
|
|
116
115
|
)
|
|
117
116
|
|
|
@@ -125,7 +124,7 @@ describe('Panel', () => {
|
|
|
125
124
|
const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
|
|
126
125
|
|
|
127
126
|
renderPanel(
|
|
128
|
-
{ desktop: { slot: 'overlay',
|
|
127
|
+
{ desktop: { slot: 'overlay', dismissible: true, modal: true } },
|
|
129
128
|
{ props: { triggeringElement } }
|
|
130
129
|
)
|
|
131
130
|
|
|
@@ -134,7 +133,7 @@ describe('Panel', () => {
|
|
|
134
133
|
})
|
|
135
134
|
|
|
136
135
|
it('falls back to viewportRef focus when no triggeringElement', () => {
|
|
137
|
-
renderPanel({ desktop: { slot: 'side',
|
|
136
|
+
renderPanel({ desktop: { slot: 'side', dismissible: true, open: false } })
|
|
138
137
|
|
|
139
138
|
fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
|
|
140
139
|
expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
|