@defra/interactive-map 0.0.11-alpha → 0.0.14-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/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/plugins/plugin-descriptor.md +37 -0
- package/package.json +15 -6
- 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/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/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/events/fetchSuggestions.js +9 -6
- package/providers/beta/esri/dist/css/index.css +4 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/esriProvider.js +19 -3
- package/providers/beta/esri/src/esriProvider.scss +5 -0
- package/providers/beta/esri/src/mapEvents.js +34 -3
- package/providers/beta/esri/src/utils/coords.js +1 -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/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/maplibreProvider.js +12 -1
- package/providers/maplibre/src/maplibreProvider.test.js +14 -1
- package/providers/maplibre/src/utils/spatial.js +40 -0
- package/providers/maplibre/src/utils/spatial.test.js +35 -0
- package/src/App/components/MapButton/MapButton.jsx +1 -0
- package/src/App/components/Panel/Panel.jsx +14 -13
- package/src/App/components/Viewport/MapController.jsx +4 -0
- package/src/App/hooks/useLayoutMeasurements.js +37 -20
- package/src/App/hooks/useLayoutMeasurements.test.js +38 -6
- package/src/App/hooks/useMarkersAPI.js +5 -3
- package/src/App/hooks/useModalPanelBehaviour.js +91 -10
- package/src/App/hooks/useModalPanelBehaviour.test.js +185 -53
- package/src/App/hooks/useVisibleGeometry.js +100 -0
- package/src/App/hooks/useVisibleGeometry.test.js +331 -0
- package/src/App/layout/Layout.jsx +13 -5
- package/src/App/layout/layout.module.scss +149 -13
- package/src/App/renderer/HtmlElementHost.jsx +10 -2
- package/src/App/renderer/HtmlElementHost.test.jsx +12 -0
- package/src/App/renderer/SlotRenderer.jsx +1 -1
- package/src/App/renderer/mapPanels.js +1 -2
- package/src/App/renderer/pluginWrapper.js +3 -2
- package/src/App/renderer/slots.js +12 -6
- package/src/App/store/AppProvider.jsx +6 -1
- package/src/App/store/appDispatchMiddleware.js +19 -0
- package/src/App/store/appDispatchMiddleware.test.js +56 -0
- package/src/InteractiveMap/InteractiveMap.js +3 -3
- package/src/types.js +9 -0
- package/src/utils/getSafeZoneInset.js +12 -9
- package/src/utils/getSafeZoneInset.test.js +102 -58
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { useVisibleGeometry, getGeometryType, getPointCoordinates } from './useVisibleGeometry'
|
|
3
|
+
import { useConfig } from '../store/configContext.js'
|
|
4
|
+
import { useApp } from '../store/appContext.js'
|
|
5
|
+
|
|
6
|
+
jest.mock('../store/configContext.js')
|
|
7
|
+
jest.mock('../store/appContext.js')
|
|
8
|
+
|
|
9
|
+
const pointFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: [1, 51] }, properties: {} }
|
|
10
|
+
const multiPointFeature = { type: 'Feature', geometry: { type: 'MultiPoint', coordinates: [[1, 51], [2, 52]] }, properties: {} }
|
|
11
|
+
const polygonFeature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
12
|
+
|
|
13
|
+
const insetPanelRect = { left: 600, top: 0, right: 1000, bottom: 800, width: 400, height: 800 }
|
|
14
|
+
const bottomPanelRect = { left: 0, top: 500, right: 1000, bottom: 800, width: 1000, height: 300 }
|
|
15
|
+
|
|
16
|
+
const setup = (overrides = {}) => {
|
|
17
|
+
const capturedHandlers = {}
|
|
18
|
+
const mapProvider = {
|
|
19
|
+
isGeometryObscured: jest.fn(() => true),
|
|
20
|
+
fitToBounds: jest.fn(),
|
|
21
|
+
setView: jest.fn(),
|
|
22
|
+
...overrides.mapProvider
|
|
23
|
+
}
|
|
24
|
+
const eventBus = {
|
|
25
|
+
on: jest.fn((event, handler) => { capturedHandlers[event] = handler }),
|
|
26
|
+
off: jest.fn(),
|
|
27
|
+
...overrides.eventBus
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const insetEl = document.createElement('div')
|
|
31
|
+
insetEl.getBoundingClientRect = jest.fn(() => insetPanelRect)
|
|
32
|
+
const bottomEl = document.createElement('div')
|
|
33
|
+
bottomEl.getBoundingClientRect = jest.fn(() => bottomPanelRect)
|
|
34
|
+
|
|
35
|
+
const layoutRefs = {
|
|
36
|
+
mainRef: { current: document.createElement('div') },
|
|
37
|
+
insetRef: { current: insetEl },
|
|
38
|
+
bottomRef: { current: bottomEl },
|
|
39
|
+
...overrides.layoutRefs
|
|
40
|
+
}
|
|
41
|
+
const panelConfig = {
|
|
42
|
+
myPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'inset' } },
|
|
43
|
+
emptyPanel: {},
|
|
44
|
+
...overrides.panelConfig
|
|
45
|
+
}
|
|
46
|
+
const panelRegistry = {
|
|
47
|
+
getPanelConfig: jest.fn(() => panelConfig),
|
|
48
|
+
...overrides.panelRegistry
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useConfig.mockReturnValue({ mapProvider, eventBus, ...overrides.config })
|
|
52
|
+
useApp.mockReturnValue({ layoutRefs, panelConfig, panelRegistry, breakpoint: 'desktop', ...overrides.app })
|
|
53
|
+
|
|
54
|
+
return { mapProvider, eventBus, capturedHandlers, layoutRefs, panelConfig, insetEl, bottomEl }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('useVisibleGeometry', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
jest.useFakeTimers()
|
|
61
|
+
})
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
jest.useRealTimers()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('early returns when mapProvider is null', () => {
|
|
67
|
+
setup({ config: { mapProvider: null } })
|
|
68
|
+
const { result } = renderHook(() => useVisibleGeometry())
|
|
69
|
+
expect(result.error).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('early returns when eventBus is null', () => {
|
|
73
|
+
setup({ config: { eventBus: null } })
|
|
74
|
+
const { result } = renderHook(() => useVisibleGeometry())
|
|
75
|
+
expect(result.error).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('subscribes to APP_PANEL_OPENED on eventBus', () => {
|
|
79
|
+
const { eventBus } = setup()
|
|
80
|
+
renderHook(() => useVisibleGeometry())
|
|
81
|
+
expect(eventBus.on).toHaveBeenCalledWith('app:panelopened', expect.any(Function))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('unsubscribes from APP_PANEL_OPENED on unmount', () => {
|
|
85
|
+
const { eventBus } = setup()
|
|
86
|
+
const { unmount } = renderHook(() => useVisibleGeometry())
|
|
87
|
+
unmount()
|
|
88
|
+
expect(eventBus.off).toHaveBeenCalledWith('app:panelopened', expect.any(Function))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('does nothing when panel has no visibleGeometry', () => {
|
|
92
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
93
|
+
renderHook(() => useVisibleGeometry())
|
|
94
|
+
capturedHandlers['app:panelopened']({ panelId: 'emptyPanel' })
|
|
95
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('does nothing when panel does not exist in config', () => {
|
|
99
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
100
|
+
renderHook(() => useVisibleGeometry())
|
|
101
|
+
capturedHandlers['app:panelopened']({ panelId: 'unknownPanel' })
|
|
102
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('does nothing when panel has visibleGeometry but no slot config', () => {
|
|
106
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
107
|
+
panelConfig: { noSlotPanel: { visibleGeometry: polygonFeature } }
|
|
108
|
+
})
|
|
109
|
+
renderHook(() => useVisibleGeometry())
|
|
110
|
+
capturedHandlers['app:panelopened']({ panelId: 'noSlotPanel' })
|
|
111
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('does nothing when mapProvider has no isGeometryObscured method', () => {
|
|
115
|
+
const { capturedHandlers, mapProvider } = setup({ mapProvider: { isGeometryObscured: null, fitToBounds: jest.fn(), setView: jest.fn() } })
|
|
116
|
+
renderHook(() => useVisibleGeometry())
|
|
117
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
118
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('does nothing when slot ref has zero dimensions (panel not visible)', () => {
|
|
122
|
+
const zeroEl = document.createElement('div')
|
|
123
|
+
zeroEl.getBoundingClientRect = jest.fn(() => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }))
|
|
124
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
125
|
+
layoutRefs: {
|
|
126
|
+
mainRef: { current: document.createElement('div') },
|
|
127
|
+
insetRef: { current: zeroEl }
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
renderHook(() => useVisibleGeometry())
|
|
131
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
132
|
+
|
|
133
|
+
// Run the current pending animation frame
|
|
134
|
+
jest.runOnlyPendingTimers()
|
|
135
|
+
|
|
136
|
+
expect(mapProvider.isGeometryObscured).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('does nothing when geometry is not obscured', () => {
|
|
140
|
+
const { mapProvider, capturedHandlers } = setup({ mapProvider: { isGeometryObscured: jest.fn(() => false), fitToBounds: jest.fn(), setView: jest.fn() } })
|
|
141
|
+
renderHook(() => useVisibleGeometry())
|
|
142
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
143
|
+
jest.runAllTimers()
|
|
144
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
145
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('calls fitToBounds with visibleGeometry for non-point geometry when obscured', () => {
|
|
149
|
+
const { mapProvider, capturedHandlers } = setup()
|
|
150
|
+
renderHook(() => useVisibleGeometry())
|
|
151
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
152
|
+
jest.runAllTimers()
|
|
153
|
+
|
|
154
|
+
expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, insetPanelRect)
|
|
155
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
156
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('calls setView with center for Point geometry when obscured', () => {
|
|
160
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
161
|
+
panelConfig: { pointPanel: { visibleGeometry: pointFeature, desktop: { slot: 'inset' } } }
|
|
162
|
+
})
|
|
163
|
+
renderHook(() => useVisibleGeometry())
|
|
164
|
+
capturedHandlers['app:panelopened']({ panelId: 'pointPanel' })
|
|
165
|
+
jest.runAllTimers()
|
|
166
|
+
|
|
167
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
168
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('calls setView with first coordinate for MultiPoint geometry when obscured', () => {
|
|
172
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
173
|
+
panelConfig: { mpPanel: { visibleGeometry: multiPointFeature, desktop: { slot: 'inset' } } }
|
|
174
|
+
})
|
|
175
|
+
renderHook(() => useVisibleGeometry())
|
|
176
|
+
capturedHandlers['app:panelopened']({ panelId: 'mpPanel' })
|
|
177
|
+
jest.runAllTimers()
|
|
178
|
+
|
|
179
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
180
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('calls fitToBounds for a raw non-Feature geometry (e.g. Polygon) when obscured', () => {
|
|
184
|
+
const rawPolygon = { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }
|
|
185
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
186
|
+
panelConfig: { geoPanel: { visibleGeometry: rawPolygon, desktop: { slot: 'inset' } } }
|
|
187
|
+
})
|
|
188
|
+
renderHook(() => useVisibleGeometry())
|
|
189
|
+
capturedHandlers['app:panelopened']({ panelId: 'geoPanel' })
|
|
190
|
+
jest.runAllTimers()
|
|
191
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(rawPolygon)
|
|
192
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('calls setView for a raw Point geometry (not Feature-wrapped) when obscured', () => {
|
|
196
|
+
const rawPoint = { type: 'Point', coordinates: [1, 51] }
|
|
197
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
198
|
+
panelConfig: { rawPointPanel: { visibleGeometry: rawPoint, desktop: { slot: 'inset' } } }
|
|
199
|
+
})
|
|
200
|
+
renderHook(() => useVisibleGeometry())
|
|
201
|
+
capturedHandlers['app:panelopened']({ panelId: 'rawPointPanel' })
|
|
202
|
+
jest.runAllTimers()
|
|
203
|
+
expect(mapProvider.setView).toHaveBeenCalledWith({ center: [1, 51] })
|
|
204
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('does not call setView when Point feature has null coordinates', () => {
|
|
208
|
+
const nullCoordsFeature = { type: 'Feature', geometry: { type: 'Point', coordinates: null }, properties: {} }
|
|
209
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
210
|
+
panelConfig: { nullPanel: { visibleGeometry: nullCoordsFeature, desktop: { slot: 'inset' } } }
|
|
211
|
+
})
|
|
212
|
+
renderHook(() => useVisibleGeometry())
|
|
213
|
+
capturedHandlers['app:panelopened']({ panelId: 'nullPanel' })
|
|
214
|
+
jest.runAllTimers()
|
|
215
|
+
expect(mapProvider.setView).not.toHaveBeenCalled()
|
|
216
|
+
expect(mapProvider.fitToBounds).not.toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('uses bottom slot ref when panel is in bottom slot', () => {
|
|
220
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
221
|
+
panelConfig: { bottomPanel: { visibleGeometry: polygonFeature, desktop: { slot: 'bottom' } } }
|
|
222
|
+
})
|
|
223
|
+
renderHook(() => useVisibleGeometry())
|
|
224
|
+
capturedHandlers['app:panelopened']({ panelId: 'bottomPanel' })
|
|
225
|
+
jest.runAllTimers()
|
|
226
|
+
|
|
227
|
+
expect(mapProvider.isGeometryObscured).toHaveBeenCalledWith(polygonFeature, bottomPanelRect)
|
|
228
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('uses latest panelConfig via ref when it changes between renders', () => {
|
|
232
|
+
const { mapProvider, capturedHandlers, insetEl } = setup()
|
|
233
|
+
const { rerender } = renderHook(() => useVisibleGeometry())
|
|
234
|
+
|
|
235
|
+
const updatedGeometry = { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} }
|
|
236
|
+
const updatedPanelConfig = { myPanel: { visibleGeometry: updatedGeometry, desktop: { slot: 'inset' } } }
|
|
237
|
+
useApp.mockReturnValue({
|
|
238
|
+
layoutRefs: { mainRef: { current: document.createElement('div') }, insetRef: { current: insetEl } },
|
|
239
|
+
panelConfig: updatedPanelConfig,
|
|
240
|
+
panelRegistry: { getPanelConfig: jest.fn(() => updatedPanelConfig) },
|
|
241
|
+
breakpoint: 'desktop'
|
|
242
|
+
})
|
|
243
|
+
rerender()
|
|
244
|
+
|
|
245
|
+
capturedHandlers['app:panelopened']({ panelId: 'myPanel' })
|
|
246
|
+
jest.runAllTimers()
|
|
247
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(updatedGeometry)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('uses slot from event payload when registry config lacks slot info', () => {
|
|
251
|
+
const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
252
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
253
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry } })) }
|
|
254
|
+
})
|
|
255
|
+
renderHook(() => useVisibleGeometry())
|
|
256
|
+
// Event includes slot (as middleware provides for ADD_PANEL); registry config has no slot info
|
|
257
|
+
capturedHandlers['app:panelopened']({ panelId: 'freshPanel', slot: 'inset' })
|
|
258
|
+
jest.runAllTimers()
|
|
259
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('falls back to panelRegistry for panels not yet in stale panelConfig', () => {
|
|
263
|
+
const freshGeometry = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, properties: {} }
|
|
264
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
265
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({ freshPanel: { visibleGeometry: freshGeometry, desktop: { slot: 'inset' } } })) }
|
|
266
|
+
})
|
|
267
|
+
renderHook(() => useVisibleGeometry())
|
|
268
|
+
capturedHandlers['app:panelopened']({ panelId: 'freshPanel' })
|
|
269
|
+
jest.runAllTimers()
|
|
270
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(freshGeometry)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('falls back to config when panel not in panelConfig and registry returns null', () => {
|
|
274
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
275
|
+
panelConfig: {}, // panel not present
|
|
276
|
+
panelRegistry: { getPanelConfig: jest.fn(() => null) } // registry returns null
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
renderHook(() => useVisibleGeometry())
|
|
280
|
+
capturedHandlers['app:panelopened']({ panelId: 'missingPanel', visibleGeometry: polygonFeature, slot: 'inset' })
|
|
281
|
+
jest.runAllTimers()
|
|
282
|
+
// Should still call fitToBounds using visibleGeometry from event payload
|
|
283
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('uses visibleGeometry from event payload directly, bypassing registry (ADD_PANEL first-click case)', () => {
|
|
287
|
+
// Registry is empty — simulates first ADD_PANEL before React has processed the reducer
|
|
288
|
+
const { mapProvider, capturedHandlers } = setup({
|
|
289
|
+
panelRegistry: { getPanelConfig: jest.fn(() => ({})) }
|
|
290
|
+
})
|
|
291
|
+
renderHook(() => useVisibleGeometry())
|
|
292
|
+
capturedHandlers['app:panelopened']({ panelId: 'newPanel', slot: 'inset', visibleGeometry: polygonFeature })
|
|
293
|
+
jest.runAllTimers()
|
|
294
|
+
expect(mapProvider.fitToBounds).toHaveBeenCalledWith(polygonFeature)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('getGeometryType', () => {
|
|
299
|
+
test('returns null for falsy input', () => {
|
|
300
|
+
expect(getGeometryType(null)).toBeNull()
|
|
301
|
+
expect(getGeometryType(undefined)).toBeNull()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('returns geometry type for a Feature', () => {
|
|
305
|
+
expect(getGeometryType({ type: 'Feature', geometry: { type: 'Polygon' } })).toBe('Polygon')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('returns type directly for a raw geometry', () => {
|
|
309
|
+
expect(getGeometryType({ type: 'Point' })).toBe('Point')
|
|
310
|
+
expect(getGeometryType({ type: 'FeatureCollection' })).toBe('FeatureCollection')
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('getPointCoordinates', () => {
|
|
315
|
+
test('returns null for unrecognised geometry type', () => {
|
|
316
|
+
expect(getPointCoordinates({ type: 'Polygon', coordinates: [] })).toBeNull()
|
|
317
|
+
expect(getPointCoordinates({ type: 'LineString', coordinates: [] })).toBeNull()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('returns coordinates for a Point', () => {
|
|
321
|
+
expect(getPointCoordinates(pointFeature.geometry)).toEqual(pointFeature.geometry.coordinates)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('returns first coordinate for a MultiPoint', () => {
|
|
325
|
+
expect(getPointCoordinates(multiPointFeature.geometry)).toEqual(multiPointFeature.geometry.coordinates[0])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('recurses into Feature geometry', () => {
|
|
329
|
+
expect(getPointCoordinates(pointFeature)).toEqual(pointFeature.geometry.coordinates)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -66,17 +66,25 @@ export const Layout = () => {
|
|
|
66
66
|
<div className='im-o-app__inset' ref={layoutRefs.insetRef}>
|
|
67
67
|
<SlotRenderer slot={layoutSlots.INSET} />
|
|
68
68
|
</div>
|
|
69
|
-
<div className='im-o-
|
|
70
|
-
<div className='im-o-
|
|
71
|
-
<SlotRenderer slot={layoutSlots.
|
|
69
|
+
<div className='im-o-app__left' ref={layoutRefs.leftRef}>
|
|
70
|
+
<div className='im-o-app__left-top' ref={layoutRefs.leftTopRef}>
|
|
71
|
+
<SlotRenderer slot={layoutSlots.LEFT_TOP} />
|
|
72
72
|
</div>
|
|
73
|
-
<div className='im-o-
|
|
74
|
-
<SlotRenderer slot={layoutSlots.
|
|
73
|
+
<div className='im-o-app__left-bottom' ref={layoutRefs.leftBottomRef}>
|
|
74
|
+
<SlotRenderer slot={layoutSlots.LEFT_BOTTOM} />
|
|
75
75
|
</div>
|
|
76
76
|
</div>
|
|
77
77
|
<div className='im-o-app__middle' ref={layoutRefs.middleRef}>
|
|
78
78
|
<SlotRenderer slot={layoutSlots.MIDDLE} />
|
|
79
79
|
</div>
|
|
80
|
+
<div className='im-o-app__right' ref={layoutRefs.rightRef}>
|
|
81
|
+
<div className='im-o-app__right-top' ref={layoutRefs.rightTopRef}>
|
|
82
|
+
<SlotRenderer slot={layoutSlots.RIGHT_TOP} />
|
|
83
|
+
</div>
|
|
84
|
+
<div className='im-o-app__right-bottom' ref={layoutRefs.rightBottomRef}>
|
|
85
|
+
<SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
80
88
|
<div className='im-o-app__footer' ref={layoutRefs.footerRef}>
|
|
81
89
|
<div className='im-o-app__footer-col'>
|
|
82
90
|
<Logo />
|
|
@@ -139,12 +139,57 @@
|
|
|
139
139
|
flex-direction: column;
|
|
140
140
|
position: absolute;
|
|
141
141
|
gap: var(--divider-gap);
|
|
142
|
-
top: var(--
|
|
142
|
+
top: var(--left-offset-top);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
// ---------------------------------------------------
|
|
146
|
+
// Left: Buttons and panels
|
|
147
|
+
// ---------------------------------------------------
|
|
148
|
+
|
|
149
|
+
.im-o-app__left {
|
|
150
|
+
position: absolute;
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-direction: column;
|
|
153
|
+
left: var(--primary-gap);
|
|
154
|
+
top: var(--left-offset-top);
|
|
155
|
+
bottom: var(--left-offset-bottom);
|
|
156
|
+
|
|
157
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
158
|
+
transition: bottom 0.15s ease;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.im-o-app__left-top {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-direction: column;
|
|
165
|
+
flex: 0 0 auto;
|
|
166
|
+
align-items: flex-end;
|
|
167
|
+
position: relative;
|
|
168
|
+
gap: var(--divider-gap);
|
|
169
|
+
|
|
170
|
+
.im-c-panel {
|
|
171
|
+
position: absolute;
|
|
172
|
+
top: 0;
|
|
173
|
+
left: 0;
|
|
174
|
+
max-height: var(--left-top-panel-max-height);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.im-o-app__left-bottom {
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
flex: 0 0 auto;
|
|
182
|
+
margin-top: auto;
|
|
183
|
+
align-items: flex-end;
|
|
184
|
+
position: relative;
|
|
185
|
+
gap: var(--divider-gap);
|
|
186
|
+
|
|
187
|
+
.im-c-panel {
|
|
188
|
+
position: absolute;
|
|
189
|
+
bottom: 0;
|
|
190
|
+
left: 0;
|
|
191
|
+
max-height: var(--left-bottom-panel-max-height);
|
|
192
|
+
}
|
|
148
193
|
}
|
|
149
194
|
|
|
150
195
|
// ---------------------------------------------------
|
|
@@ -164,7 +209,7 @@
|
|
|
164
209
|
}
|
|
165
210
|
|
|
166
211
|
// ---------------------------------------------------
|
|
167
|
-
// Right: Buttons and
|
|
212
|
+
// Right: Buttons and panels
|
|
168
213
|
// ---------------------------------------------------
|
|
169
214
|
|
|
170
215
|
.im-o-app__right {
|
|
@@ -174,18 +219,87 @@
|
|
|
174
219
|
right: var(--primary-gap);
|
|
175
220
|
top: var(--right-offset-top);
|
|
176
221
|
bottom: var(--right-offset-bottom);
|
|
222
|
+
|
|
223
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
224
|
+
transition: bottom 0.15s ease;
|
|
225
|
+
}
|
|
177
226
|
}
|
|
178
227
|
|
|
179
228
|
.im-o-app__right-top {
|
|
180
229
|
display: flex;
|
|
181
230
|
flex-direction: column;
|
|
182
|
-
flex
|
|
231
|
+
flex: 0 0 auto;
|
|
183
232
|
align-items: flex-end;
|
|
233
|
+
position: relative;
|
|
184
234
|
gap: var(--divider-gap);
|
|
235
|
+
|
|
236
|
+
.im-c-panel {
|
|
237
|
+
position: absolute;
|
|
238
|
+
top: 0;
|
|
239
|
+
right: 0;
|
|
240
|
+
max-height: var(--right-top-panel-max-height);
|
|
241
|
+
}
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
.im-o-app__right-bottom {
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-direction: column;
|
|
247
|
+
flex: 0 0 auto;
|
|
188
248
|
margin-top: auto;
|
|
249
|
+
align-items: flex-end;
|
|
250
|
+
position: relative;
|
|
251
|
+
gap: var(--divider-gap);
|
|
252
|
+
|
|
253
|
+
.im-c-panel {
|
|
254
|
+
position: absolute;
|
|
255
|
+
bottom: 0;
|
|
256
|
+
right: 0;
|
|
257
|
+
max-height: var(--right-bottom-panel-max-height);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// When both left sub-slots have panels, split the column proportionally
|
|
262
|
+
// so panels don't overlap. flex: 1 1 auto gives each sub-slot a share of the
|
|
263
|
+
// column; max-height: 100% caps the panel at its sub-slot height (not forced).
|
|
264
|
+
.im-o-app__left:has(.im-o-app__left-top > .im-c-panel:not([style*="display: none"])):has(.im-o-app__left-bottom > .im-c-panel:not([style*="display: none"])) {
|
|
265
|
+
gap: var(--divider-gap);
|
|
266
|
+
|
|
267
|
+
.im-o-app__left-top,
|
|
268
|
+
.im-o-app__left-bottom {
|
|
269
|
+
flex: 1 1 auto;
|
|
270
|
+
min-height: 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.im-o-app__left-bottom {
|
|
274
|
+
margin-top: 0;
|
|
275
|
+
justify-content: flex-end;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.im-o-app__left-top .im-c-panel,
|
|
279
|
+
.im-o-app__left-bottom .im-c-panel {
|
|
280
|
+
max-height: 100%;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Same for right column
|
|
285
|
+
.im-o-app__right:has(.im-o-app__right-top > .im-c-panel:not([style*="display: none"])):has(.im-o-app__right-bottom > .im-c-panel:not([style*="display: none"])) {
|
|
286
|
+
gap: var(--divider-gap);
|
|
287
|
+
|
|
288
|
+
.im-o-app__right-top,
|
|
289
|
+
.im-o-app__right-bottom {
|
|
290
|
+
flex: 1 1 auto;
|
|
291
|
+
min-height: 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.im-o-app__right-bottom {
|
|
295
|
+
margin-top: 0;
|
|
296
|
+
justify-content: flex-end;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.im-o-app__right-top .im-c-panel,
|
|
300
|
+
.im-o-app__right-bottom .im-c-panel {
|
|
301
|
+
max-height: 100%;
|
|
302
|
+
}
|
|
189
303
|
}
|
|
190
304
|
|
|
191
305
|
// ---------------------------------------------------
|
|
@@ -301,10 +415,33 @@
|
|
|
301
415
|
}
|
|
302
416
|
|
|
303
417
|
.im-c-panel--inset {
|
|
304
|
-
|
|
305
|
-
left: var(--primary-gap);
|
|
418
|
+
inset: var(--modal-inset);
|
|
306
419
|
max-width: calc(100% - (var(--primary-gap) * 2));
|
|
307
|
-
max-height:
|
|
420
|
+
max-height: var(--modal-max-height);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.im-c-panel--left-top,
|
|
424
|
+
.im-c-panel.im-c-panel--left-top-button {
|
|
425
|
+
inset: var(--left-offset-top) auto auto var(--primary-gap);
|
|
426
|
+
max-height: calc(100% - var(--left-offset-top) - var(--primary-gap));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.im-c-panel--left-bottom,
|
|
430
|
+
.im-c-panel.im-c-panel--left-bottom-button {
|
|
431
|
+
inset: auto auto var(--left-offset-bottom) var(--primary-gap);
|
|
432
|
+
max-height: calc(100% - var(--left-offset-bottom) - var(--primary-gap));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.im-c-panel--right-top,
|
|
436
|
+
.im-c-panel.im-c-panel--right-top-button {
|
|
437
|
+
inset: var(--right-offset-top) var(--primary-gap) auto auto;
|
|
438
|
+
max-height: calc(100% - var(--right-offset-top) - var(--primary-gap));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.im-c-panel--right-bottom,
|
|
442
|
+
.im-c-panel.im-c-panel--right-bottom-button {
|
|
443
|
+
inset: auto var(--primary-gap) var(--right-offset-bottom) auto;
|
|
444
|
+
max-height: calc(100% - var(--right-offset-bottom) - var(--primary-gap));
|
|
308
445
|
}
|
|
309
446
|
|
|
310
447
|
.im-c-panel--middle {
|
|
@@ -322,11 +459,10 @@
|
|
|
322
459
|
}
|
|
323
460
|
|
|
324
461
|
[class*="im-c-panel--"][class*="-button"] { // Adjacent to button
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
bottom: var(--modal-inset);
|
|
328
|
-
left: var(--modal-inset);
|
|
462
|
+
inset: var(--modal-inset);
|
|
463
|
+
max-height: var(--modal-max-height);
|
|
329
464
|
}
|
|
465
|
+
|
|
330
466
|
}
|
|
331
467
|
|
|
332
468
|
// Mobile and tablet
|
|
@@ -4,7 +4,6 @@ import { useApp } from '../store/appContext.js'
|
|
|
4
4
|
import { Panel } from '../components/Panel/Panel.jsx'
|
|
5
5
|
import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
|
|
6
6
|
import { allowedSlots } from './slots.js'
|
|
7
|
-
import { stringToKebab } from '../../utils/stringToKebab.js'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Maps slot names to their corresponding layout refs.
|
|
@@ -16,11 +15,20 @@ export const getSlotRef = (slot, layoutRefs) => {
|
|
|
16
15
|
'top-left': layoutRefs.topLeftColRef,
|
|
17
16
|
'top-right': layoutRefs.topRightColRef,
|
|
18
17
|
inset: layoutRefs.insetRef,
|
|
18
|
+
'left-top': layoutRefs.leftTopRef,
|
|
19
|
+
'left-bottom': layoutRefs.leftBottomRef,
|
|
19
20
|
middle: layoutRefs.middleRef,
|
|
21
|
+
'right-top': layoutRefs.rightTopRef,
|
|
22
|
+
'right-bottom': layoutRefs.rightBottomRef,
|
|
20
23
|
bottom: layoutRefs.bottomRef,
|
|
21
24
|
actions: layoutRefs.actionsRef,
|
|
22
25
|
modal: layoutRefs.modalRef
|
|
23
26
|
}
|
|
27
|
+
if (slot?.endsWith('-button')) {
|
|
28
|
+
const el = document.querySelector(`[data-button-slot="${slot}"]`)
|
|
29
|
+
return el ? { current: el } : null
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
return slotRefMap[slot] || null
|
|
25
33
|
}
|
|
26
34
|
|
|
@@ -78,7 +86,7 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModal
|
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
// 2. Slot Validation
|
|
81
|
-
const isNextToButton =
|
|
89
|
+
const isNextToButton = targetSlot.endsWith('-button')
|
|
82
90
|
const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
|
|
83
91
|
|
|
84
92
|
if (!isSlotAllowed) {
|
|
@@ -292,6 +292,18 @@ describe('HtmlElementHost', () => {
|
|
|
292
292
|
expect(getSlotRef('unknown-slot', {})).toBeNull()
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
test('getSlotRef returns wrapped element for button slot when element exists', () => {
|
|
296
|
+
const el = document.createElement('div')
|
|
297
|
+
el.dataset.buttonSlot = 'my-panel-button'
|
|
298
|
+
document.body.appendChild(el)
|
|
299
|
+
expect(getSlotRef('my-panel-button', {})).toEqual({ current: el })
|
|
300
|
+
el.remove()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('getSlotRef returns null for button slot when no element found', () => {
|
|
304
|
+
expect(getSlotRef('nonexistent-button', {})).toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
295
307
|
it('does not append child if slotRef exists but current is null', () => {
|
|
296
308
|
// 1. Setup refs where the slot exists in the map but the DOM node (current) is null
|
|
297
309
|
const incompleteRefs = {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// src/core/renderers/mapPanels.js
|
|
2
2
|
import React from 'react'
|
|
3
|
-
import { stringToKebab } from '../../utils/stringToKebab.js'
|
|
4
3
|
import { withPluginContexts } from './pluginWrapper.js'
|
|
5
4
|
import { Panel } from '../components/Panel/Panel.jsx'
|
|
6
5
|
import { allowedSlots } from './slots.js'
|
|
@@ -12,7 +11,7 @@ import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.
|
|
|
12
11
|
* and ensures only the topmost modal panel is shown.
|
|
13
12
|
*/
|
|
14
13
|
const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => {
|
|
15
|
-
const isNextToButton =
|
|
14
|
+
const isNextToButton = targetSlot.endsWith('-button')
|
|
16
15
|
if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
|
|
17
16
|
return false
|
|
18
17
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/core/renderers/pluginWrapper.js
|
|
2
|
+
import { useMemo } from 'react'
|
|
2
3
|
import { useConfig } from '../store/configContext.js'
|
|
3
4
|
import { useApp } from '../store/appContext.js'
|
|
4
5
|
import { useMap } from '../store/mapContext.js'
|
|
@@ -59,11 +60,11 @@ export function withPluginContexts (Component, { pluginId, pluginConfig }) {
|
|
|
59
60
|
services={services}
|
|
60
61
|
mapProvider={appConfig.mapProvider}
|
|
61
62
|
iconRegistry={getIconRegistry()}
|
|
62
|
-
buttonConfig={Object.fromEntries(
|
|
63
|
+
buttonConfig={useMemo(() => Object.fromEntries(
|
|
63
64
|
Object.entries(appState.buttonConfig).filter(
|
|
64
65
|
([_, btn]) => btn.pluginId === pluginId
|
|
65
66
|
)
|
|
66
|
-
)}
|
|
67
|
+
), [appState.buttonConfig])}
|
|
67
68
|
/>
|
|
68
69
|
)
|
|
69
70
|
})
|