@defra/interactive-map 0.0.12-alpha → 0.0.15-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.
Files changed (43) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/package.json +9 -4
  5. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  6. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  7. package/plugins/beta/datasets/src/manifest.js +4 -4
  8. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  9. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  10. package/plugins/beta/map-styles/src/manifest.js +2 -2
  11. package/plugins/search/dist/css/index.css +1 -1
  12. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  13. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  14. package/plugins/search/src/events/fetchSuggestions.js +10 -7
  15. package/plugins/search/src/events/fetchSuggestions.test.js +4 -4
  16. package/plugins/search/src/search.scss +8 -3
  17. package/providers/beta/esri/dist/css/index.css +4 -0
  18. package/providers/beta/esri/src/esriProvider.scss +5 -0
  19. package/src/App/components/MapButton/MapButton.jsx +1 -0
  20. package/src/App/components/Panel/Panel.jsx +14 -13
  21. package/src/App/components/Panel/Panel.module.scss +1 -0
  22. package/src/App/hooks/useLayoutMeasurements.js +31 -23
  23. package/src/App/hooks/useLayoutMeasurements.test.js +39 -10
  24. package/src/App/hooks/useModalPanelBehaviour.js +85 -21
  25. package/src/App/hooks/useModalPanelBehaviour.test.js +126 -18
  26. package/src/App/hooks/useVisibleGeometry.js +7 -13
  27. package/src/App/hooks/useVisibleGeometry.test.js +72 -47
  28. package/src/App/layout/Layout.jsx +11 -6
  29. package/src/App/layout/Layout.test.jsx +0 -1
  30. package/src/App/layout/layout.module.scss +83 -10
  31. package/src/App/renderer/HtmlElementHost.jsx +10 -4
  32. package/src/App/renderer/HtmlElementHost.test.jsx +32 -11
  33. package/src/App/renderer/SlotRenderer.jsx +1 -1
  34. package/src/App/renderer/mapPanels.js +1 -2
  35. package/src/App/renderer/mapPanels.test.js +3 -3
  36. package/src/App/renderer/slotHelpers.js +2 -2
  37. package/src/App/renderer/slotHelpers.test.js +3 -3
  38. package/src/App/renderer/slots.js +11 -8
  39. package/src/App/store/AppProvider.jsx +5 -2
  40. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  41. package/src/config/appConfig.js +4 -4
  42. package/src/utils/getSafeZoneInset.js +139 -39
  43. package/src/utils/getSafeZoneInset.test.js +301 -81
@@ -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 = `${stringToKebab(panelId)}-button` === targetSlot
14
+ const isNextToButton = targetSlot.endsWith('-button')
16
15
  if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
17
16
  return false
18
17
  }
@@ -7,7 +7,7 @@ jest.mock('../registry/panelRegistry.js')
7
7
  jest.mock('../registry/pluginRegistry.js', () => ({ registeredPlugins: [] }))
8
8
  jest.mock('./pluginWrapper.js', () => ({ withPluginContexts: jest.fn((c) => c) }))
9
9
  jest.mock('../components/Panel/Panel.jsx', () => ({ Panel: (props) => <div data-testid='panel' {...props} /> }))
10
- jest.mock('./slots.js', () => ({ allowedSlots: { panel: ['header', 'modal', 'inset'] } }))
10
+ jest.mock('./slots.js', () => ({ allowedSlots: { panel: ['header', 'modal', 'left-top'] } }))
11
11
 
12
12
  describe('mapPanels', () => {
13
13
  const baseConfig = {
@@ -145,7 +145,7 @@ describe('mapPanels', () => {
145
145
  expect(map()).toHaveLength(1)
146
146
  })
147
147
 
148
- it('replaces bottom slot with inset on non-mobile breakpoints', () => {
148
+ it('replaces bottom slot with left-top on non-mobile breakpoints', () => {
149
149
  defaultAppState.panelConfig = ({
150
150
  p1: {
151
151
  desktop: { slot: 'bottom' },
@@ -153,7 +153,7 @@ describe('mapPanels', () => {
153
153
  }
154
154
  })
155
155
 
156
- const result = map(defaultAppState, 'inset')
156
+ const result = map(defaultAppState, 'left-top')
157
157
  expect(result).toHaveLength(1)
158
158
  expect(result[0].id).toBe('p1')
159
159
  })
@@ -4,14 +4,14 @@ import { allowedSlots } from './slots.js'
4
4
  /**
5
5
  * Resolves the target slot for a panel based on its breakpoint config.
6
6
  * Modal panels always render in the 'modal' slot, and the bottom slot
7
- * is only available on mobile — tablet and desktop fall back to 'inset'.
7
+ * is only available on mobile — tablet and desktop fall back to 'left-top'.
8
8
  */
9
9
  export const resolveTargetSlot = (bpConfig, breakpoint) => {
10
10
  if (bpConfig.modal) {
11
11
  return 'modal'
12
12
  }
13
13
  if (bpConfig.slot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) {
14
- return 'inset'
14
+ return 'left-top'
15
15
  }
16
16
  return bpConfig.slot
17
17
  }
@@ -7,9 +7,9 @@ describe('resolveTargetSlot', () => {
7
7
  expect(resolveTargetSlot({ modal: true, slot: 'side' }, 'desktop')).toBe('modal')
8
8
  })
9
9
 
10
- it('replaces bottom with inset on tablet and desktop', () => {
11
- expect(resolveTargetSlot({ slot: 'bottom' }, 'tablet')).toBe('inset')
12
- expect(resolveTargetSlot({ slot: 'bottom' }, 'desktop')).toBe('inset')
10
+ it('replaces bottom with left-top on tablet and desktop', () => {
11
+ expect(resolveTargetSlot({ slot: 'bottom' }, 'tablet')).toBe('left-top')
12
+ expect(resolveTargetSlot({ slot: 'bottom' }, 'desktop')).toBe('left-top')
13
13
  })
14
14
 
15
15
  it('keeps bottom on mobile', () => {
@@ -5,10 +5,11 @@ export const layoutSlots = Object.freeze({
5
5
  TOP_LEFT: 'top-left',
6
6
  TOP_MIDDLE: 'top-middle',
7
7
  TOP_RIGHT: 'top-right',
8
- INSET: 'inset',
8
+ LEFT_TOP: 'left-top',
9
+ LEFT_BOTTOM: 'left-bottom',
10
+ MIDDLE: 'middle',
9
11
  RIGHT_TOP: 'right-top',
10
12
  RIGHT_BOTTOM: 'right-bottom',
11
- MIDDLE: 'middle',
12
13
  FOOTER_RIGHT: 'footer-right',
13
14
  BOTTOM: 'bottom',
14
15
  ACTIONS: 'actions',
@@ -20,7 +21,6 @@ export const allowedSlots = Object.freeze({
20
21
  layoutSlots.BANNER,
21
22
  layoutSlots.TOP_LEFT,
22
23
  layoutSlots.TOP_RIGHT,
23
- layoutSlots.INSET,
24
24
  layoutSlots.MIDDLE,
25
25
  layoutSlots.FOOTER_RIGHT,
26
26
  layoutSlots.BOTTOM,
@@ -29,17 +29,20 @@ export const allowedSlots = Object.freeze({
29
29
  panel: [
30
30
  layoutSlots.SIDE,
31
31
  layoutSlots.BANNER,
32
- layoutSlots.INSET,
33
- layoutSlots.RIGHT_BOTTOM,
32
+ layoutSlots.LEFT_TOP,
33
+ layoutSlots.LEFT_BOTTOM,
34
34
  layoutSlots.MIDDLE,
35
- layoutSlots.BOTTOM,
36
- layoutSlots.ACTIONS,
37
- layoutSlots.MODAL
35
+ layoutSlots.RIGHT_TOP,
36
+ layoutSlots.RIGHT_BOTTOM,
37
+ layoutSlots.BOTTOM, // Typicaly on mobile
38
+ layoutSlots.MODAL // Internal only
38
39
  ],
39
40
  button: [
40
41
  layoutSlots.TOP_LEFT,
41
42
  layoutSlots.TOP_MIDDLE,
42
43
  layoutSlots.TOP_RIGHT,
44
+ layoutSlots.LEFT_TOP,
45
+ layoutSlots.LEFT_BOTTOM,
43
46
  layoutSlots.RIGHT_TOP,
44
47
  layoutSlots.RIGHT_BOTTOM,
45
48
  layoutSlots.ACTIONS
@@ -19,10 +19,13 @@ export const AppProvider = ({ options, children }) => {
19
19
  topRef: useRef(null),
20
20
  topLeftColRef: useRef(null),
21
21
  topRightColRef: useRef(null),
22
- insetRef: useRef(null),
22
+ leftRef: useRef(null),
23
+ leftTopRef: useRef(null),
24
+ leftBottomRef: useRef(null),
25
+ middleRef: useRef(null),
23
26
  rightRef: useRef(null),
27
+ rightTopRef: useRef(null),
24
28
  rightBottomRef: useRef(null),
25
- middleRef: useRef(null),
26
29
  bottomRef: useRef(null),
27
30
  footerRef: useRef(null),
28
31
  actionsRef: useRef(null),
@@ -189,7 +189,7 @@ describe('appDispatchMiddleware', () => {
189
189
 
190
190
  expect(eventBus.emit).toHaveBeenCalledWith(
191
191
  events.APP_PANEL_OPENED,
192
- { panelId: 'newPanel', slot: 'inset' }
192
+ { panelId: 'newPanel', slot: 'left-top' }
193
193
  )
194
194
  })
195
195
 
@@ -204,7 +204,7 @@ describe('appDispatchMiddleware', () => {
204
204
 
205
205
  expect(eventBus.emit).toHaveBeenCalledWith(
206
206
  events.APP_PANEL_OPENED,
207
- { panelId: 'geoPanel', slot: 'inset', visibleGeometry }
207
+ { panelId: 'geoPanel', slot: 'left-top', visibleGeometry }
208
208
  )
209
209
  })
210
210
 
@@ -120,14 +120,14 @@ export const defaultPanelConfig = {
120
120
  showLabel: true
121
121
  },
122
122
  tablet: {
123
- slot: 'inset',
123
+ slot: 'left-top',
124
124
  open: true,
125
125
  dismissible: true,
126
126
  modal: false,
127
127
  showLabel: true
128
128
  },
129
129
  desktop: {
130
- slot: 'inset',
130
+ slot: 'left-top',
131
131
  open: true,
132
132
  dismissible: true,
133
133
  modal: false,
@@ -144,10 +144,10 @@ export const defaultControlConfig = {
144
144
  slot: 'bottom'
145
145
  },
146
146
  tablet: {
147
- slot: 'inset'
147
+ slot: 'left-top'
148
148
  },
149
149
  desktop: {
150
- slot: 'inset'
150
+ slot: 'left-top'
151
151
  }
152
152
  }
153
153
 
@@ -1,56 +1,156 @@
1
1
  /**
2
2
  * Calculates the safe zone inset — the unobscured region of the map viewport
3
- * not hidden behind overlay panels, action bars or the footer. Used as padding
4
- * for map operations like setCenter or fitBounds so the full extent is visible.
3
+ * not hidden behind overlay panels or structural UI (button columns, footer,
4
+ * action bar). Used as padding for map operations like fitBounds or setView
5
+ * so the target location or extent is fully visible.
5
6
  *
6
- * The algorithm measures the available space around the inset panel and decides
7
- * whether to push the safe area below or beside it, depending on whether the
8
- * layout is landscape or portrait oriented.
7
+ * Each edge inset is driven by:
8
+ * - A structural baseline from the button columns, footer, and action bar.
9
+ * - A panel contribution when panels on that edge cover more than 1/RATIO
10
+ * of the available map dimension along that edge.
11
+ * - A cap so no single inset exceeds 1/MAX_RATIO of the map dimension,
12
+ * preventing fitBounds from zooming out to a corner when panels are large.
9
13
  *
10
- * @param {Object} refs - React refs for the key layout elements.
11
- * @param {React.RefObject} refs.mainRef - The main content area.
12
- * @param {React.RefObject} refs.insetRef - The inset panel (e.g. search results).
13
- * @param {React.RefObject} refs.rightRef - The right-hand button column.
14
- * @param {React.RefObject} refs.actionsRef - The bottom action bar.
15
- * @param {React.RefObject} refs.footerRef - The footer (logo, copyright etc).
14
+ * Trigger logic per edge:
15
+ * - left / right : combined HEIGHT of panels in that column vs available height
16
+ * - top / bottom : WIDTH of panels in that row vs available width
17
+ *
18
+ * When a panel individually exceeds BOTH the height and width thresholds it
19
+ * would otherwise trigger both adjacent edges, pushing the safe zone into the
20
+ * opposite corner. To prevent this, such panels are classified as either
21
+ * "column-primary" (h/availableH ≥ w/availableW) or "row-primary" and only
22
+ * contribute to their dominant edge. Panels that exceed at most one threshold
23
+ * are never in conflict and contribute to both axes freely.
24
+ *
25
+ * @param {Object} refs - React refs from layoutRefs.
26
+ * @param {React.RefObject} refs.mainRef - Main map container.
27
+ * @param {React.RefObject} refs.leftRef - Left button column.
28
+ * @param {React.RefObject} refs.rightRef - Right button column.
29
+ * @param {React.RefObject} refs.actionsRef - Bottom action bar.
30
+ * @param {React.RefObject} refs.footerRef - Footer (logo, copyright, etc).
31
+ * @param {React.RefObject} [refs.leftTopRef] - Top-left panel slot.
32
+ * @param {React.RefObject} [refs.leftBottomRef] - Bottom-left panel slot.
33
+ * @param {React.RefObject} [refs.rightTopRef] - Top-right panel slot.
34
+ * @param {React.RefObject} [refs.rightBottomRef] - Bottom-right panel slot.
16
35
  * @returns {{ top: number, right: number, left: number, bottom: number } | undefined}
17
- * Pixel insets from each edge of the main area, or undefined if any ref is missing.
36
+ * Pixel insets from each edge of the main area, or undefined if any required ref is missing.
18
37
  */
38
+
39
+ const RATIO = 2 // panels covering more than 1/RATIO of an edge trigger that edge's inset
40
+ const MAX_RATIO = 3 // each inset is capped at 1/MAX_RATIO of its map dimension
41
+
42
+ // Query the panel element directly within its slot container.
43
+ // Only the first panel matters, and only when it is also the first element in the slot
44
+ // (i.e. no buttons or other elements precede it — a panel pushed down by buttons is
45
+ // already within the button-column structural inset and should not add extra padding).
46
+ const getPanelDimensions = (slotRef) => {
47
+ if (!slotRef?.current) {
48
+ return { offsetWidth: 0, offsetHeight: 0 }
49
+ }
50
+ const first = slotRef.current.firstElementChild
51
+ if (!first?.classList.contains('im-c-panel') || first.offsetWidth === 0 || first.offsetHeight === 0) {
52
+ return { offsetWidth: 0, offsetHeight: 0 }
53
+ }
54
+ return { offsetWidth: first.offsetWidth, offsetHeight: first.offsetHeight }
55
+ }
56
+
57
+ // Panels are in normal flow inside slot containers, expanding left/right offsetWidth.
58
+ // Query button groups directly to recover the button-only structural column width.
59
+ const getButtonColWidth = (colRef) => {
60
+ const group = colRef?.current?.querySelector('.im-c-button-group')
61
+ return group ? group.offsetWidth : 0
62
+ }
63
+
64
+ // Returns the panel's contribution to column (left/right) and row (top/bottom) edges.
65
+ //
66
+ // A panel can only trigger BOTH adjacent edges simultaneously when it individually
67
+ // exceeds both the height threshold (h > availableH/RATIO) and the width threshold
68
+ // (w > availableW/RATIO). In that case, only the dominant direction is used, preventing
69
+ // a corner panel from pushing the safe zone into the opposite corner.
70
+ // When only one (or neither) threshold is individually exceeded, the panel contributes
71
+ // its dimensions to both axes freely (combined thresholds still apply later).
72
+ const panelContrib = (w, h, availableW, availableH) => {
73
+ const hFrac = h / availableH
74
+ const wFrac = w / availableW
75
+ if (hFrac >= 1 / RATIO && wFrac >= 1 / RATIO) {
76
+ return hFrac >= wFrac
77
+ ? { colH: h, colW: w, rowW: 0, rowH: 0 } // column-primary (left/right)
78
+ : { colH: 0, colW: 0, rowW: w, rowH: h } // row-primary (top/bottom)
79
+ }
80
+ return { colH: h, colW: w, rowW: w, rowH: h }
81
+ }
82
+
83
+ // Determines the column (left/right) inset and the row-axis contributions for both panels.
84
+ //
85
+ // Column coverage uses raw heights so that two panels whose combined height exceeds the
86
+ // threshold always trigger the column inset, even if one is individually row-primary.
87
+ // Exception: a single row-primary panel (no sibling, colH=0) is suppressed so it
88
+ // contributes to top/bottom instead — giving a larger safe zone in the horizontal direction.
89
+ // When triggered, row contributions for both panels are zeroed to prevent double-padding.
90
+ const computeColumn = (panelA, panelB, availableW, availableH, hThreshold, columnLeft, gap) => {
91
+ const a = panelContrib(panelA.offsetWidth, panelA.offsetHeight, availableW, availableH)
92
+ const b = panelContrib(panelB.offsetWidth, panelB.offsetHeight, availableW, availableH)
93
+ const aSingleRowPrimary = panelB.offsetHeight === 0 && a.colH === 0
94
+ const bSingleRowPrimary = panelA.offsetHeight === 0 && b.colH === 0
95
+ const coverage = panelA.offsetHeight + panelB.offsetHeight + (panelA.offsetHeight > 0 && panelB.offsetHeight > 0 ? gap : 0)
96
+ const triggered = coverage > hThreshold && !aSingleRowPrimary && !bSingleRowPrimary
97
+ return {
98
+ panelInset: triggered ? columnLeft + Math.max(panelA.offsetWidth, panelB.offsetWidth) + gap : 0,
99
+ rowA: triggered ? { w: 0, h: 0 } : { w: a.rowW, h: a.rowH },
100
+ rowB: triggered ? { w: 0, h: 0 } : { w: b.rowW, h: b.rowH }
101
+ }
102
+ }
103
+
104
+ // Determines the row-axis (top/bottom) panel inset driven by combined panel width.
105
+ // The combined width test subsumes the individual checks (dimensions are non-negative).
106
+ const computeRow = (leftW, rightW, leftH, rightH, wThreshold, baseInset, gap) =>
107
+ leftW + rightW > wThreshold ? baseInset + Math.max(leftH, rightH) + gap : 0
108
+
19
109
  export const getSafeZoneInset = ({
20
- mainRef,
21
- insetRef,
22
- rightRef,
23
- actionsRef,
24
- footerRef
110
+ mainRef, leftRef, rightRef, actionsRef, footerRef,
111
+ leftTopRef, leftBottomRef, rightTopRef, rightBottomRef
25
112
  }) => {
26
- const refs = [mainRef, insetRef, rightRef, actionsRef, footerRef]
27
-
28
- if (refs.some(ref => !ref.current)) {
113
+ if ([mainRef, leftRef, rightRef, actionsRef, footerRef].some(ref => !ref?.current)) {
29
114
  return undefined
30
115
  }
31
116
 
32
- const [main, inset, right, actions, footer] = refs.map(ref => ref.current)
117
+ const main = mainRef.current; const left = leftRef.current
118
+ const actions = actionsRef.current; const footer = footerRef.current
119
+
120
+ const gap = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--divider-gap'), 10)
121
+
122
+ const rawTL = getPanelDimensions(leftTopRef); const rawBL = getPanelDimensions(leftBottomRef)
123
+ const rawTR = getPanelDimensions(rightTopRef); const rawBR = getPanelDimensions(rightBottomRef)
33
124
 
34
- const root = document.documentElement
35
- const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
125
+ // Structural base insets — always present, never capped.
126
+ const colWidth = Math.max(getButtonColWidth(leftRef), getButtonColWidth(rightRef))
127
+ const baseLeft = main.offsetLeft + left.offsetLeft + colWidth + gap
128
+ const baseRight = left.offsetLeft + colWidth + gap
129
+ const baseTop = left.offsetTop
130
+ const footerInset = main.offsetHeight - footer.offsetTop + gap // mirrors --left/right-offset-bottom CSS var
131
+ const baseBottom = Math.max(main.offsetHeight - actions.offsetTop + gap, footerInset)
36
132
 
37
- // === Safe area logic ===
38
- const availableHeight = actions.offsetTop - inset.offsetTop - dividerGap
39
- const rightOffset = inset.offsetLeft + right.offsetWidth + dividerGap
40
- const availableWidth = main.offsetWidth - rightOffset * 2
41
- const insetOverlapWidth = inset.offsetWidth - rightOffset + inset.offsetLeft
42
- const isLandscape = availableWidth - insetOverlapWidth > availableHeight - inset.offsetHeight
43
- const topOffset = inset.offsetTop + (!isLandscape && inset.offsetHeight > 0 ? inset.offsetHeight + dividerGap : 0)
44
- const leftOffset = isLandscape ? inset.offsetWidth + inset.offsetLeft + dividerGap : rightOffset
45
- const actionsOffset = main.offsetHeight - actions.offsetTop
46
- const footerOffset = main.offsetHeight - footer.offsetTop
133
+ const availableH = main.offsetHeight - baseTop - baseBottom
134
+ const availableW = main.offsetWidth - (baseLeft - main.offsetLeft) - baseRight
47
135
 
48
- const RATIO = 2 // At what point is there enough room to leave the inset above
49
- const hasRoom = insetOverlapWidth < availableWidth / RATIO && inset.offsetHeight < availableHeight / RATIO
136
+ const leftCol = computeColumn(rawTL, rawBL, availableW, availableH, availableH / RATIO, main.offsetLeft + left.offsetLeft, gap)
137
+ const rightCol = computeColumn(rawTR, rawBR, availableW, availableH, availableH / RATIO, left.offsetLeft, gap)
50
138
 
51
- const top = hasRoom ? inset.offsetTop : topOffset
52
- const left = main.offsetLeft + (hasRoom ? rightOffset : Math.max(leftOffset, rightOffset))
53
- const bottom = Math.max(actionsOffset, footerOffset) + dividerGap
139
+ const leftPanelInset = leftCol.panelInset
140
+ const rightPanelInset = rightCol.panelInset
141
+ const topPanelInset = computeRow(leftCol.rowA.w, rightCol.rowA.w, leftCol.rowA.h, rightCol.rowA.h, availableW / RATIO, baseTop, gap)
142
+ const bottomPanelInset = computeRow(leftCol.rowB.w, rightCol.rowB.w, leftCol.rowB.h, rightCol.rowB.h, availableW / RATIO, footerInset, gap)
54
143
 
55
- return { top, right: rightOffset, left, bottom }
144
+ const usableW = main.offsetWidth - 2 * gap
145
+ const usableH = main.offsetHeight - 2 * gap
146
+ const maxL = main.offsetLeft + usableW * (MAX_RATIO - 1) / MAX_RATIO
147
+ const maxR = usableW * (MAX_RATIO - 1) / MAX_RATIO
148
+ const maxV = usableH * (MAX_RATIO - 1) / MAX_RATIO
149
+
150
+ return {
151
+ left: Math.max(baseLeft, Math.min(leftPanelInset, maxL)),
152
+ right: Math.max(baseRight, Math.min(rightPanelInset, maxR)),
153
+ top: Math.max(baseTop, Math.min(topPanelInset, maxV)),
154
+ bottom: Math.max(baseBottom, Math.min(bottomPanelInset, maxV))
155
+ }
56
156
  }