@defra/interactive-map 0.0.14-alpha → 0.0.16-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 (199) hide show
  1. package/assets/css/docusaurus.css +104 -0
  2. package/assets/images/favicon.svg +1 -0
  3. package/assets/images/hero.png +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/slot-map.svg +1 -0
  9. package/docs/api/slots.md +89 -6
  10. package/docs/api.md +1 -1
  11. package/docs/architecture.md +3 -1
  12. package/docs/{demo.mdx → examples.mdx} +1 -1
  13. package/docs/getting-started.md +1 -3
  14. package/docs/index.mdx +42 -0
  15. package/docs/plugins/interact.md +176 -55
  16. package/docs/plugins/map-styles.md +64 -7
  17. package/docs/plugins/search.md +207 -63
  18. package/docs/plugins.md +7 -15
  19. package/docusaurus.config.cjs +34 -34
  20. package/jest.setup.js +1 -1
  21. package/package.json +5 -4
  22. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/src/DatasetsInit.jsx +1 -1
  25. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  26. package/plugins/beta/datasets/src/api/hideDataset.js +1 -1
  27. package/plugins/beta/datasets/src/api/hideFeatures.js +1 -1
  28. package/plugins/beta/datasets/src/api/removeDataset.js +1 -1
  29. package/plugins/beta/datasets/src/api/showDataset.js +1 -1
  30. package/plugins/beta/datasets/src/api/showFeatures.js +1 -1
  31. package/plugins/beta/datasets/src/datasets.js +4 -4
  32. package/plugins/beta/datasets/src/defaults.js +1 -1
  33. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +5 -5
  34. package/plugins/beta/datasets/src/handleSetMapStyle.js +1 -1
  35. package/plugins/beta/datasets/src/manifest.js +7 -7
  36. package/plugins/beta/datasets/src/mapLayers.js +2 -3
  37. package/plugins/beta/datasets/src/panels/Key.jsx +31 -29
  38. package/plugins/beta/datasets/src/panels/Layers.jsx +8 -9
  39. package/plugins/beta/datasets/src/utils/bbox.js +4 -4
  40. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  41. package/plugins/beta/draw-es/src/DrawInit.jsx +16 -16
  42. package/plugins/beta/draw-es/src/api/addFeature.js +3 -3
  43. package/plugins/beta/draw-es/src/api/deleteFeature.js +3 -3
  44. package/plugins/beta/draw-es/src/api/editFeature.js +3 -3
  45. package/plugins/beta/draw-es/src/api/newPolygon.js +3 -3
  46. package/plugins/beta/draw-es/src/events.js +52 -20
  47. package/plugins/beta/draw-es/src/events.test.js +301 -0
  48. package/plugins/beta/draw-es/src/graphic.js +1 -1
  49. package/plugins/beta/draw-es/src/manifest.js +4 -4
  50. package/plugins/beta/draw-es/src/reducer.js +1 -1
  51. package/plugins/beta/draw-es/src/sketchViewModel.js +1 -1
  52. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  53. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  54. package/plugins/beta/draw-ml/src/DrawInit.jsx +49 -52
  55. package/plugins/beta/draw-ml/src/api/deleteFeature.js +1 -1
  56. package/plugins/beta/draw-ml/src/api/editFeature.js +8 -5
  57. package/plugins/beta/draw-ml/src/api/newLine.js +0 -1
  58. package/plugins/beta/draw-ml/src/api/newPolygon.js +0 -1
  59. package/plugins/beta/draw-ml/src/api/split.js +4 -4
  60. package/plugins/beta/draw-ml/src/defaults.js +1 -1
  61. package/plugins/beta/draw-ml/src/events.js +8 -6
  62. package/plugins/beta/draw-ml/src/manifest.js +15 -15
  63. package/plugins/beta/draw-ml/src/mapboxDraw.js +1 -1
  64. package/plugins/beta/draw-ml/src/mapboxSnap.js +17 -18
  65. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -31
  66. package/plugins/beta/draw-ml/src/modes/disabledMode.js +1 -1
  67. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +11 -11
  68. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +7 -7
  69. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +8 -8
  70. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +7 -7
  71. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +32 -24
  72. package/plugins/beta/draw-ml/src/reducer.js +1 -1
  73. package/plugins/beta/draw-ml/src/undoStack.js +4 -4
  74. package/plugins/beta/draw-ml/src/utils/snapHelpers.js +12 -12
  75. package/plugins/beta/draw-ml/src/utils/spatial.js +11 -11
  76. package/plugins/beta/frame/src/Frame.jsx +4 -4
  77. package/plugins/beta/frame/src/FrameInit.jsx +4 -4
  78. package/plugins/beta/frame/src/api/addFrame.js +1 -1
  79. package/plugins/beta/frame/src/api/editFeature.js +1 -1
  80. package/plugins/beta/frame/src/config.js +1 -1
  81. package/plugins/beta/frame/src/manifest.js +3 -3
  82. package/plugins/beta/frame/src/reducer.js +1 -1
  83. package/plugins/beta/frame/src/utils.js +1 -1
  84. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  85. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  86. package/plugins/beta/map-styles/src/MapStyles.jsx +18 -18
  87. package/plugins/beta/map-styles/src/manifest.js +2 -2
  88. package/plugins/beta/scale-bar/src/ScaleBar.jsx +5 -5
  89. package/plugins/beta/use-location/src/UseLocation.jsx +1 -1
  90. package/plugins/beta/use-location/src/defaults.js +1 -1
  91. package/plugins/beta/use-location/src/events.js +3 -3
  92. package/plugins/interact/src/InteractInit.jsx +1 -2
  93. package/plugins/interact/src/api/enable.js +8 -5
  94. package/plugins/interact/src/api/enable.test.js +2 -2
  95. package/plugins/interact/src/api/selectFeature.js +4 -4
  96. package/plugins/interact/src/api/unselectFeature.js +5 -5
  97. package/plugins/interact/src/defaults.js +0 -1
  98. package/plugins/interact/src/events.test.js +15 -15
  99. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  100. package/plugins/interact/src/hooks/useInteractionHandlers.js +2 -2
  101. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +5 -5
  102. package/plugins/interact/src/manifest.js +2 -2
  103. package/plugins/interact/src/manifest.test.js +3 -4
  104. package/plugins/interact/src/reducer.js +3 -3
  105. package/plugins/interact/src/reducer.test.js +0 -1
  106. package/plugins/interact/src/utils/spatial.js +10 -10
  107. package/plugins/interact/src/utils/spatial.test.js +14 -14
  108. package/plugins/search/dist/css/index.css +1 -1
  109. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  110. package/plugins/search/dist/esm/index.js +1 -1
  111. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  112. package/plugins/search/dist/umd/index.js +1 -1
  113. package/plugins/search/src/Search.jsx +7 -6
  114. package/plugins/search/src/Search.test.jsx +23 -23
  115. package/plugins/search/src/components/CloseButton/CloseButton.jsx +15 -15
  116. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +2 -2
  117. package/plugins/search/src/components/Form/Form.jsx +14 -14
  118. package/plugins/search/src/components/Form/Form.test.jsx +11 -11
  119. package/plugins/search/src/components/OpenButton/OpenButton.jsx +16 -15
  120. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +6 -2
  121. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +15 -15
  122. package/plugins/search/src/components/Suggestions/Suggestions.jsx +6 -6
  123. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +4 -4
  124. package/plugins/search/src/datasets.js +12 -13
  125. package/plugins/search/src/datasets.test.js +1 -1
  126. package/plugins/search/src/defaults.js +1 -1
  127. package/plugins/search/src/events/fetchSuggestions.js +4 -4
  128. package/plugins/search/src/events/fetchSuggestions.test.js +5 -5
  129. package/plugins/search/src/events/formHandlers.js +3 -3
  130. package/plugins/search/src/events/formHandlers.test.js +1 -1
  131. package/plugins/search/src/events/index.js +2 -2
  132. package/plugins/search/src/events/index.test.js +2 -2
  133. package/plugins/search/src/events/inputHandlers.js +4 -4
  134. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  135. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  136. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  137. package/plugins/search/src/index.js +2 -1
  138. package/plugins/search/src/index.test.js +3 -3
  139. package/plugins/search/src/manifest.js +6 -4
  140. package/plugins/search/src/reducer.js +1 -2
  141. package/plugins/search/src/reducer.test.js +2 -2
  142. package/plugins/search/src/search.scss +18 -6
  143. package/plugins/search/src/utils/parseOsNamesResults.js +1 -2
  144. package/plugins/search/src/utils/parseOsNamesResults.test.js +2 -2
  145. package/plugins/search/src/utils/updateMap.js +1 -1
  146. package/plugins/search/src/utils/updateMap.test.js +5 -5
  147. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  148. package/providers/beta/esri/src/esriProvider.js +5 -5
  149. package/providers/beta/esri/src/utils/coords.js +1 -1
  150. package/providers/beta/esri/src/utils/esriFixes.js +1 -1
  151. package/providers/beta/esri/src/utils/query.js +4 -4
  152. package/providers/beta/esri/src/utils/spatial.js +1 -2
  153. package/providers/beta/esri/src/utils/spatial.test.js +4 -1
  154. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +1 -1
  155. package/providers/maplibre/src/appEvents.test.js +1 -1
  156. package/providers/maplibre/src/index.js +1 -1
  157. package/providers/maplibre/src/index.test.js +3 -5
  158. package/providers/maplibre/src/mapEvents.test.js +15 -5
  159. package/providers/maplibre/src/maplibreProvider.test.js +6 -2
  160. package/providers/maplibre/src/utils/calculateLinearTextSize.js +4 -4
  161. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +3 -3
  162. package/providers/maplibre/src/utils/detectWebgl.test.js +1 -1
  163. package/providers/maplibre/src/utils/highlightFeatures.js +2 -2
  164. package/providers/maplibre/src/utils/highlightFeatures.test.js +12 -6
  165. package/providers/maplibre/src/utils/labels.js +19 -20
  166. package/providers/maplibre/src/utils/labels.test.js +15 -13
  167. package/providers/maplibre/src/utils/maplibreFixes.test.js +1 -1
  168. package/providers/maplibre/src/utils/queryFeatures.js +6 -6
  169. package/providers/maplibre/src/utils/queryFeatures.test.js +13 -13
  170. package/providers/maplibre/src/utils/spatial.js +0 -1
  171. package/providers/maplibre/src/utils/spatial.test.js +26 -27
  172. package/src/App/components/Panel/Panel.module.scss +1 -0
  173. package/src/App/hooks/useLayoutMeasurements.js +1 -10
  174. package/src/App/hooks/useLayoutMeasurements.test.js +2 -5
  175. package/src/App/hooks/useVisibleGeometry.js +7 -13
  176. package/src/App/hooks/useVisibleGeometry.test.js +72 -47
  177. package/src/App/layout/Layout.jsx +0 -3
  178. package/src/App/layout/Layout.test.jsx +0 -1
  179. package/src/App/layout/layout.module.scss +11 -77
  180. package/src/App/registry/pluginRegistry.js +17 -0
  181. package/src/App/registry/pluginRegistry.test.js +33 -0
  182. package/src/App/renderer/HtmlElementHost.jsx +0 -1
  183. package/src/App/renderer/HtmlElementHost.test.jsx +20 -11
  184. package/src/App/renderer/mapButtons.js +3 -2
  185. package/src/App/renderer/mapPanels.test.js +3 -3
  186. package/src/App/renderer/slotHelpers.js +2 -2
  187. package/src/App/renderer/slotHelpers.test.js +3 -3
  188. package/src/App/renderer/slots.js +0 -3
  189. package/src/App/store/AppProvider.jsx +0 -1
  190. package/src/App/store/appDispatchMiddleware.js +33 -1
  191. package/src/App/store/appDispatchMiddleware.test.js +250 -222
  192. package/src/config/appConfig.js +4 -4
  193. package/src/utils/getSafeZoneInset.js +139 -42
  194. package/src/utils/getSafeZoneInset.test.js +298 -122
  195. package/src/utils/logger.js +6 -0
  196. package/src/utils/logger.test.js +32 -0
  197. package/webpack.dev.mjs +22 -18
  198. package/docs/govuk-prototype.md +0 -23
  199. package/docs/index.md +0 -19
@@ -1,59 +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.leftRef - The left-hand button column.
14
- * @param {React.RefObject} refs.rightRef - The right-hand button column.
15
- * @param {React.RefObject} refs.actionsRef - The bottom action bar.
16
- * @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.
17
35
  * @returns {{ top: number, right: number, left: number, bottom: number } | undefined}
18
- * 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.
19
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
+
20
109
  export const getSafeZoneInset = ({
21
- mainRef,
22
- insetRef,
23
- leftRef,
24
- rightRef,
25
- actionsRef,
26
- footerRef
110
+ mainRef, leftRef, rightRef, actionsRef, footerRef,
111
+ leftTopRef, leftBottomRef, rightTopRef, rightBottomRef
27
112
  }) => {
28
- const refs = [mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef]
29
-
30
- if (refs.some(ref => !ref.current)) {
113
+ if ([mainRef, leftRef, rightRef, actionsRef, footerRef].some(ref => !ref?.current)) {
31
114
  return undefined
32
115
  }
33
116
 
34
- const [main, inset, left, 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)
35
124
 
36
- const root = document.documentElement
37
- 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)
38
132
 
39
- // === Safe area logic ===
40
- const availableHeight = actions.offsetTop - inset.offsetTop - dividerGap
41
- const leftOffset = left.offsetLeft + left.offsetWidth + dividerGap
42
- const rightOffset = left.offsetLeft + right.offsetWidth + dividerGap
43
- const availableWidth = main.offsetWidth - (leftOffset + rightOffset)
44
- const insetOverlapWidth = inset.offsetWidth - leftOffset + left.offsetLeft
45
- const isLandscape = availableWidth - insetOverlapWidth > availableHeight - inset.offsetHeight
46
- const topOffset = left.offsetTop + (!isLandscape && inset.offsetHeight > 0 ? inset.offsetHeight + dividerGap : 0)
47
- const combinedLeftOffset = isLandscape ? Math.max(inset.offsetWidth, left.offsetWidth) + left.offsetLeft + dividerGap : rightOffset
48
- const actionsOffset = main.offsetHeight - actions.offsetTop
49
- const footerOffset = main.offsetHeight - footer.offsetTop
133
+ const availableH = main.offsetHeight - baseTop - baseBottom
134
+ const availableW = main.offsetWidth - (baseLeft - main.offsetLeft) - baseRight
50
135
 
51
- const RATIO = 2 // At what point is there enough room to leave the inset above
52
- 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)
53
138
 
54
- const top = hasRoom ? inset.offsetTop : topOffset
55
- const combinedLeft = main.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset)
56
- 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)
57
143
 
58
- return { top, right: rightOffset, left: combinedLeft, 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
+ }
59
156
  }
@@ -1,133 +1,309 @@
1
1
  import { getSafeZoneInset } from './getSafeZoneInset'
2
2
 
3
- describe('getSafeZoneInset', () => {
4
- let mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef
5
- let originalGetComputedStyle
3
+ const MAIN_WIDTH = 900
4
+ const MAIN_HEIGHT = 600
5
+ const LEFT_LEFT = 10
6
+ const LEFT_WIDTH = 40
7
+ const LEFT_TOP = 60
8
+ const RIGHT_WIDTH = 40
9
+ const ACTIONS_TOP = 540
10
+ const FOOTER_TOP = 560
11
+ const EARLY_ACTIONS_TOP = 500
12
+ const GAP = 8
6
13
 
7
- beforeAll(() => { originalGetComputedStyle = window.getComputedStyle })
8
- afterAll(() => { window.getComputedStyle = originalGetComputedStyle })
14
+ // Base insets: main.offsetLeft=0 in tests
15
+ const BASE_LEFT = LEFT_LEFT + LEFT_WIDTH + GAP // 58
16
+ const BASE_RIGHT = LEFT_LEFT + RIGHT_WIDTH + GAP // 58
17
+ const BASE_TOP = LEFT_TOP // 60
18
+ const BASE_BOTTOM = (MAIN_HEIGHT - ACTIONS_TOP) + GAP // 68
9
19
 
10
- beforeEach(() => {
11
- mainRef = { current: { offsetWidth: 800, offsetHeight: 600, offsetLeft: 0 } }
12
- insetRef = { current: { offsetWidth: 100, offsetHeight: 50, offsetTop: 50, offsetLeft: 20 } }
13
- leftRef = { current: { offsetWidth: 50, offsetLeft: 20, offsetTop: 10 } }
14
- rightRef = { current: { offsetWidth: 50, offsetLeft: 730 } }
15
- actionsRef = { current: { offsetTop: 520 } }
16
- footerRef = { current: { offsetTop: 550 } }
20
+ // Height threshold: availableHeight / RATIO = (600-60-68)/2 = 236
21
+ const ABOVE_THRESHOLD = 240
22
+ const BELOW_THRESHOLD = 230
23
+ const COMBINED_ABOVE = 120 // two × 120 + gap = 248 > 236
24
+ const COMBINED_BELOW = 100 // two × 100 + gap = 208 < 236
17
25
 
18
- // CSS var mock
19
- window.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => '10' })
26
+ // Width threshold: availableWidth / RATIO = (900-58-58)/2 = 392
27
+ const ABOVE_W_THRESHOLD = 400
28
+ const COMBINED_ABOVE_W = 200 // two × 200 = 400 > 392 → triggers combined
29
+ const COMBINED_BELOW_W = 180 // two × 180 = 360 < 392 → does not trigger
30
+ const PANEL_H_TALL = 150
31
+ const PANEL_H_SHORT = 100
32
+
33
+ const ABOVE_CAP_TOP = 330 // 60+330+8=398 > CAP_HEIGHT ≈ 389.3 → capped
34
+ const FOOTER_INSET = MAIN_HEIGHT - FOOTER_TOP + GAP // 48
35
+ const ABOVE_CAP_BOTTOM = 342 // 48+342+8=398 > CAP_HEIGHT → capped
36
+
37
+ const PANEL_W_STANDARD = 200
38
+ const PANEL_W_WIDE = 250
39
+ const PANEL_W_NARROW = 100
40
+ const PANEL_W_XLARGE = 600 // 10+600+8=618 > CAP_WIDTH ≈ 589.3 → capped
41
+
42
+ const leftInset = w => LEFT_LEFT + w + GAP
43
+ const rightInset = w => LEFT_LEFT + w + GAP
44
+ const topInset = h => BASE_TOP + h + GAP
45
+
46
+ const MAX_RATIO = 3
47
+ const CAP_WIDTH = (MAIN_WIDTH - 2 * GAP) * (MAX_RATIO - 1) / MAX_RATIO
48
+ const CAP_HEIGHT = (MAIN_HEIGHT - 2 * GAP) * (MAX_RATIO - 1) / MAX_RATIO
49
+
50
+ // ─── Setup ──────────────────────────────────────────────────────────────────
51
+
52
+ let mainRef, leftRef, rightRef, actionsRef, footerRef
53
+
54
+ beforeAll(() => {
55
+ globalThis.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => String(GAP) })
56
+ })
57
+
58
+ const colRef = (offsetWidth, offsetLeft, offsetTop) => {
59
+ const buttonGroup = { offsetWidth }
60
+ return {
61
+ current: {
62
+ offsetWidth,
63
+ offsetLeft,
64
+ offsetTop,
65
+ querySelector: (sel) => sel === '.im-c-button-group' ? buttonGroup : null
66
+ }
67
+ }
68
+ }
69
+
70
+ beforeEach(() => {
71
+ mainRef = { current: { offsetWidth: MAIN_WIDTH, offsetHeight: MAIN_HEIGHT, offsetLeft: 0 } }
72
+ leftRef = colRef(LEFT_WIDTH, LEFT_LEFT, LEFT_TOP)
73
+ rightRef = colRef(RIGHT_WIDTH)
74
+ actionsRef = { current: { offsetTop: ACTIONS_TOP } }
75
+ footerRef = { current: { offsetTop: FOOTER_TOP } }
76
+ })
77
+
78
+ const base = () => ({ mainRef, leftRef, rightRef, actionsRef, footerRef })
79
+
80
+ // Slot container where an .im-c-panel is the first element child.
81
+ const panel = (offsetWidth, offsetHeight) => {
82
+ const panelEl = { offsetWidth, offsetHeight, classList: { contains: (c) => c === 'im-c-panel' } }
83
+ return { current: { firstElementChild: panelEl } }
84
+ }
85
+
86
+ // Slot container where a button precedes the panel (panel should be ignored).
87
+ const panelAfterButton = () => ({ current: { firstElementChild: { classList: { contains: () => false } } } })
88
+
89
+ // ─── Missing refs ────────────────────────────────────────────────────────────
90
+
91
+ describe('getSafeZoneInset — missing refs', () => {
92
+ it('returns undefined when mainRef.current is null', () => {
93
+ expect(getSafeZoneInset({ ...base(), mainRef: { current: null } })).toBeUndefined()
94
+ })
95
+ it('returns undefined when leftRef.current is null', () => {
96
+ expect(getSafeZoneInset({ ...base(), leftRef: { current: null } })).toBeUndefined()
97
+ })
98
+ it('returns undefined when actionsRef is undefined', () => {
99
+ expect(getSafeZoneInset({ mainRef, leftRef, rightRef, footerRef })).toBeUndefined()
20
100
  })
101
+ })
102
+
103
+ // ─── Base structural insets ──────────────────────────────────────────────────
104
+
105
+ describe('getSafeZoneInset — base structural insets', () => {
106
+ it('returns base insets when no panel refs are provided', () => {
107
+ expect(getSafeZoneInset(base())).toEqual({
108
+ left: BASE_LEFT, right: BASE_RIGHT, top: BASE_TOP, bottom: BASE_BOTTOM
109
+ })
110
+ })
111
+ it('ignores a panel that is not the first element in its slot (buttons precede it)', () => {
112
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panelAfterButton() }).left).toBe(BASE_LEFT)
113
+ })
114
+ it('ignores a slot container with no children', () => {
115
+ expect(getSafeZoneInset({ ...base(), leftTopRef: { current: { firstElementChild: null } } }).left).toBe(BASE_LEFT)
116
+ })
117
+ it('ignores a panel with zero width', () => {
118
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panel(0, ABOVE_THRESHOLD) }).left).toBe(BASE_LEFT)
119
+ })
120
+ it('uses zero button width when column ref has no button group', () => {
121
+ const noGroupLeft = { current: { offsetWidth: 0, offsetLeft: LEFT_LEFT, offsetTop: LEFT_TOP, querySelector: () => null } }
122
+ const noGroupRight = { current: { offsetWidth: 0, offsetLeft: 0, offsetTop: 0, querySelector: () => null } }
123
+ expect(getSafeZoneInset({ mainRef, leftRef: noGroupLeft, rightRef: noGroupRight, actionsRef, footerRef })).toEqual({
124
+ left: LEFT_LEFT + GAP, right: LEFT_LEFT + GAP, top: LEFT_TOP, bottom: BASE_BOTTOM
125
+ })
126
+ })
127
+ it('returns base insets when all panel slots are empty (height 0)', () => {
128
+ expect(getSafeZoneInset({
129
+ ...base(),
130
+ leftTopRef: panel(PANEL_W_STANDARD, 0),
131
+ leftBottomRef: panel(PANEL_W_STANDARD, 0),
132
+ rightTopRef: panel(PANEL_W_STANDARD, 0),
133
+ rightBottomRef: panel(PANEL_W_STANDARD, 0)
134
+ })).toEqual({ left: BASE_LEFT, right: BASE_RIGHT, top: BASE_TOP, bottom: BASE_BOTTOM })
135
+ })
136
+ it('uses max of actions and footer for base bottom', () => {
137
+ actionsRef.current.offsetTop = EARLY_ACTIONS_TOP
138
+ expect(getSafeZoneInset(base()).bottom).toBe(MAIN_HEIGHT - EARLY_ACTIONS_TOP + GAP)
139
+ })
140
+ })
141
+
142
+ // ─── Left edge ───────────────────────────────────────────────────────────────
21
143
 
22
- it('returns undefined if any ref.current is null', () => {
144
+ describe('getSafeZoneInset left edge', () => {
145
+ it('does not trigger when single panel height is below threshold', () => {
146
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panel(PANEL_W_STANDARD, BELOW_THRESHOLD) }).left).toBe(BASE_LEFT)
147
+ })
148
+ it('triggers when single panel height exceeds threshold', () => {
149
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) }).left)
150
+ .toBe(leftInset(PANEL_W_STANDARD))
151
+ })
152
+ it('triggers when combined height of two panels exceeds threshold', () => {
153
+ expect(getSafeZoneInset({
154
+ ...base(),
155
+ leftTopRef: panel(PANEL_W_STANDARD, COMBINED_ABOVE),
156
+ leftBottomRef: panel(PANEL_W_NARROW, COMBINED_ABOVE)
157
+ }).left).toBe(leftInset(PANEL_W_STANDARD))
158
+ })
159
+ it('does not trigger when combined height is below threshold', () => {
160
+ expect(getSafeZoneInset({
161
+ ...base(),
162
+ leftTopRef: panel(PANEL_W_STANDARD, COMBINED_BELOW),
163
+ leftBottomRef: panel(PANEL_W_STANDARD, COMBINED_BELOW)
164
+ }).left).toBe(BASE_LEFT)
165
+ })
166
+ it('uses the wider panel for the inset amount', () => {
167
+ expect(getSafeZoneInset({
168
+ ...base(),
169
+ leftTopRef: panel(PANEL_W_WIDE, COMBINED_ABOVE),
170
+ leftBottomRef: panel(PANEL_W_NARROW, COMBINED_ABOVE)
171
+ }).left).toBe(leftInset(PANEL_W_WIDE))
172
+ })
173
+ })
174
+
175
+ // ─── Right edge ──────────────────────────────────────────────────────────────
176
+
177
+ describe('getSafeZoneInset — right edge', () => {
178
+ it('triggers when combined height of right-column panels exceeds threshold', () => {
179
+ expect(getSafeZoneInset({
180
+ ...base(),
181
+ rightTopRef: panel(PANEL_W_STANDARD, COMBINED_ABOVE),
182
+ rightBottomRef: panel(PANEL_W_NARROW, COMBINED_ABOVE)
183
+ }).right).toBe(rightInset(PANEL_W_STANDARD))
184
+ })
185
+ it('does not trigger when combined height is below threshold', () => {
186
+ expect(getSafeZoneInset({
187
+ ...base(),
188
+ rightTopRef: panel(PANEL_W_STANDARD, COMBINED_BELOW),
189
+ rightBottomRef: panel(PANEL_W_STANDARD, COMBINED_BELOW)
190
+ }).right).toBe(BASE_RIGHT)
191
+ })
192
+ })
193
+
194
+ // ─── Top edge ────────────────────────────────────────────────────────────────
195
+ // Trigger is WIDTH-based. A narrow panel must not add top padding even if tall.
196
+
197
+ describe('getSafeZoneInset — top edge', () => {
198
+ it('does not trigger when top panel is narrow, even if tall', () => {
199
+ // PANEL_W_STANDARD (200) < width threshold (392)
200
+ expect(getSafeZoneInset({ ...base(), rightTopRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) }).top).toBe(BASE_TOP)
201
+ })
202
+ it('triggers when a top panel width exceeds threshold', () => {
203
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) }).top)
204
+ .toBe(topInset(ABOVE_THRESHOLD))
205
+ })
206
+ it('column-primary wide-and-tall top panel triggers left inset, not top', () => {
207
+ // panel(400,330): h/availableH≈0.699 > w/availableW≈0.510 → column-primary
208
+ const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_TOP) })
209
+ expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
210
+ expect(result.top).toBe(BASE_TOP)
211
+ })
212
+ it('when top panels have mixed primaries, each contributes to its own edge', () => {
213
+ // tl(400,100): row-primary → top; tr(400,330): column-primary → right
214
+ const result = getSafeZoneInset({
215
+ ...base(),
216
+ leftTopRef: panel(ABOVE_W_THRESHOLD, COMBINED_BELOW),
217
+ rightTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_TOP)
218
+ })
219
+ expect(result.top).toBe(topInset(COMBINED_BELOW))
220
+ expect(result.right).toBe(rightInset(ABOVE_W_THRESHOLD))
221
+ })
222
+ it('triggers when combined width of two top panels exceeds threshold; uses max height', () => {
223
+ // each COMBINED_ABOVE_W (200) < threshold (392), but 200+200=400 > 392
224
+ expect(getSafeZoneInset({
225
+ ...base(),
226
+ leftTopRef: panel(COMBINED_ABOVE_W, PANEL_H_TALL),
227
+ rightTopRef: panel(COMBINED_ABOVE_W, PANEL_H_SHORT)
228
+ }).top).toBe(topInset(PANEL_H_TALL))
229
+ })
230
+ it('does not trigger when both top panels are below combined width threshold', () => {
231
+ expect(getSafeZoneInset({
232
+ ...base(),
233
+ leftTopRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD),
234
+ rightTopRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD)
235
+ }).top).toBe(BASE_TOP)
236
+ })
237
+ })
238
+
239
+ // ─── Bottom edge ─────────────────────────────────────────────────────────────
240
+
241
+ describe('getSafeZoneInset — bottom edge', () => {
242
+ it('does not trigger when bottom panel is narrow, even if tall', () => {
243
+ expect(getSafeZoneInset({ ...base(), rightBottomRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) }).bottom).toBe(BASE_BOTTOM)
244
+ })
245
+ it('triggers when a bottom panel width exceeds threshold', () => {
246
+ expect(getSafeZoneInset({ ...base(), leftBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) }).bottom)
247
+ .toBe(Math.min(FOOTER_INSET + ABOVE_THRESHOLD + GAP, CAP_HEIGHT))
248
+ })
249
+ it('column-primary wide-and-tall bottom panel triggers left inset, not bottom', () => {
250
+ // panel(400,342): h/availableH≈0.724 > w/availableW≈0.510 → column-primary
251
+ const result = getSafeZoneInset({ ...base(), leftBottomRef: panel(ABOVE_W_THRESHOLD, ABOVE_CAP_BOTTOM) })
252
+ expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
253
+ expect(result.bottom).toBe(BASE_BOTTOM)
254
+ })
255
+ it('triggers when combined width of two bottom panels exceeds threshold; uses max height', () => {
256
+ expect(getSafeZoneInset({
257
+ ...base(),
258
+ leftBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_TALL),
259
+ rightBottomRef: panel(COMBINED_ABOVE_W, PANEL_H_SHORT)
260
+ }).bottom).toBe(Math.min(FOOTER_INSET + PANEL_H_TALL + GAP, CAP_HEIGHT))
261
+ })
262
+ it('does not trigger when both bottom panels are below combined width threshold', () => {
263
+ expect(getSafeZoneInset({
264
+ ...base(),
265
+ leftBottomRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD),
266
+ rightBottomRef: panel(COMBINED_BELOW_W, ABOVE_THRESHOLD)
267
+ }).bottom).toBe(BASE_BOTTOM)
268
+ })
269
+ })
270
+
271
+ // ─── MAX_RATIO cap ────────────────────────────────────────────────────────────
272
+
273
+ describe('getSafeZoneInset — MAX_RATIO cap', () => {
274
+ it('caps left inset at (MAX_RATIO-1)/MAX_RATIO of usable width', () => {
275
+ expect(getSafeZoneInset({ ...base(), leftTopRef: panel(PANEL_W_XLARGE, PANEL_W_XLARGE) }).left).toBe(CAP_WIDTH)
276
+ })
277
+ it('caps right inset at (MAX_RATIO-1)/MAX_RATIO of usable width', () => {
278
+ expect(getSafeZoneInset({ ...base(), rightTopRef: panel(PANEL_W_XLARGE, PANEL_W_XLARGE) }).right).toBe(CAP_WIDTH)
279
+ })
280
+ })
281
+
282
+ // ─── Corner panel independence ────────────────────────────────────────────────
283
+
284
+ describe('getSafeZoneInset — corner panel independence', () => {
285
+ it('narrow-but-tall corner panel triggers side inset only (not top)', () => {
286
+ // PANEL_W_STANDARD (200): tall enough for right, too narrow for top (< 392)
287
+ const result = getSafeZoneInset({ ...base(), rightTopRef: panel(PANEL_W_STANDARD, ABOVE_THRESHOLD) })
288
+ expect(result.right).toBe(rightInset(PANEL_W_STANDARD))
289
+ expect(result.top).toBe(BASE_TOP)
290
+ })
291
+ it('wide-and-tall corner panel triggers only its primary (row) edge inset', () => {
292
+ // panel(400, 240): w/availableW≈0.510, h/availableH≈0.508 → row-primary → top only
293
+ const result = getSafeZoneInset({ ...base(), leftTopRef: panel(ABOVE_W_THRESHOLD, ABOVE_THRESHOLD) })
294
+ expect(result.left).toBe(BASE_LEFT)
295
+ expect(result.top).toBe(topInset(ABOVE_THRESHOLD))
296
+ })
297
+ it('two wide panels in the same column collectively trigger left but not top or bottom', () => {
298
+ // Each h=COMBINED_ABOVE (120) < hThreshold individually, combined 248 > 236
299
+ // Each w=ABOVE_W_THRESHOLD (400) > wThreshold → left column triggers, excluding from top/bottom
23
300
  const result = getSafeZoneInset({
24
- mainRef: { current: null },
25
- insetRef,
26
- leftRef,
27
- rightRef,
28
- actionsRef,
29
- footerRef
301
+ ...base(),
302
+ leftTopRef: panel(ABOVE_W_THRESHOLD, COMBINED_ABOVE),
303
+ leftBottomRef: panel(ABOVE_W_THRESHOLD, COMBINED_ABOVE)
30
304
  })
31
- expect(result).toBeUndefined()
32
- })
33
-
34
- it('portrait mode shifts inset below itself when it does NOT have enough vertical room', () => {
35
- // Mock layout
36
- mainRef.current.offsetWidth = 200
37
- mainRef.current.offsetHeight = 200
38
- insetRef.current.offsetWidth = 100
39
- insetRef.current.offsetHeight = 50
40
- insetRef.current.offsetTop = 50
41
- insetRef.current.offsetLeft = 20
42
-
43
- leftRef.current.offsetWidth = 50
44
- leftRef.current.offsetLeft = 10
45
- leftRef.current.offsetTop = 10
46
-
47
- rightRef.current.offsetWidth = 50
48
- rightRef.current.offsetLeft = 140
49
-
50
- actionsRef.current.offsetTop = 150
51
- footerRef.current.offsetTop = 180
52
-
53
- const dividerGap = 10
54
-
55
- const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
56
-
57
- // Compute expected values exactly as function would
58
- const availableHeight = actionsRef.current.offsetTop - insetRef.current.offsetTop - dividerGap // 150 - 50 - 10 = 90
59
- const leftOffset = leftRef.current.offsetLeft + leftRef.current.offsetWidth + dividerGap // 10 + 50 + 10 = 70
60
- const rightOffset = leftRef.current.offsetLeft + rightRef.current.offsetWidth + dividerGap // 10 + 50 + 10 = 70
61
- const availableWidth = mainRef.current.offsetWidth - (leftOffset + rightOffset) // 200 - (70+70) = 60
62
- const insetOverlapWidth = insetRef.current.offsetWidth - leftOffset + leftRef.current.offsetLeft // 100 - 70 + 10 = 40
63
- const isLandscape = availableWidth - insetOverlapWidth > availableHeight - insetRef.current.offsetHeight // 60-40 > 90-50 => 20>40 false
64
-
65
- const topOffset = leftRef.current.offsetTop + (!isLandscape && insetRef.current.offsetHeight > 0 ? insetRef.current.offsetHeight + dividerGap : 0) // 10 + 50 +10 = 70
66
- const combinedLeftOffset = isLandscape ? Math.max(insetRef.current.offsetWidth, leftRef.current.offsetWidth) + leftRef.current.offsetLeft + dividerGap : rightOffset // isLandscape=false -> 70
67
- const actionsOffset = mainRef.current.offsetHeight - actionsRef.current.offsetTop // 200-150=50
68
- const footerOffset = mainRef.current.offsetHeight - footerRef.current.offsetTop // 200-180=20
69
- const hasRoom = insetOverlapWidth < availableWidth / 2 && insetRef.current.offsetHeight < availableHeight / 2 // 40 < 60/2? 40<30 false
70
-
71
- const expectedTop = hasRoom ? insetRef.current.offsetTop : topOffset // false -> topOffset = 70
72
- const expectedLeft = mainRef.current.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset) // 0+70=70
73
- const expectedRight = rightOffset // 70
74
- const expectedBottom = Math.max(actionsOffset, footerOffset) + dividerGap // max(50,20)+10 = 60
75
-
76
- expect(result.top).toBe(expectedTop)
77
- expect(result.left).toBe(expectedLeft)
78
- expect(result.right).toBe(expectedRight)
79
- expect(result.bottom).toBe(expectedBottom)
80
- })
81
-
82
- it('landscape mode places inset beside panel when enough room', () => {
83
- mainRef.current.offsetWidth = 1000
84
- insetRef.current.offsetWidth = 200
85
- insetRef.current.offsetHeight = 50
86
-
87
- const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
88
-
89
- const dividerGap = 10
90
- const leftOffset = leftRef.current.offsetLeft + leftRef.current.offsetWidth + dividerGap
91
- const rightOffset = leftRef.current.offsetLeft + rightRef.current.offsetWidth + dividerGap
92
- const availableWidth = mainRef.current.offsetWidth - (leftOffset + rightOffset)
93
- const insetOverlapWidth = insetRef.current.offsetWidth - leftOffset + leftRef.current.offsetLeft
94
- const availableHeight = actionsRef.current.offsetTop - insetRef.current.offsetTop - dividerGap
95
- const isLandscape = availableWidth - insetOverlapWidth > availableHeight - insetRef.current.offsetHeight
96
-
97
- const topOffset = leftRef.current.offsetTop + (!isLandscape && insetRef.current.offsetHeight > 0 ? insetRef.current.offsetHeight + dividerGap : 0)
98
- const combinedLeftOffset = isLandscape ? Math.max(insetRef.current.offsetWidth, leftRef.current.offsetWidth) + leftRef.current.offsetLeft + dividerGap : rightOffset
99
- const actionsOffset = mainRef.current.offsetHeight - actionsRef.current.offsetTop
100
- const footerOffset = mainRef.current.offsetHeight - footerRef.current.offsetTop
101
- const hasRoom = insetOverlapWidth < availableWidth / 2 && insetRef.current.offsetHeight < availableHeight / 2
102
- const top = hasRoom ? insetRef.current.offsetTop : topOffset
103
- const combinedLeft = mainRef.current.offsetLeft + (hasRoom ? rightOffset : combinedLeftOffset)
104
- const bottom = Math.max(actionsOffset, footerOffset) + dividerGap
105
-
106
- expect(result.top).toBe(top)
107
- expect(result.left).toBe(combinedLeft)
108
- expect(result.right).toBe(rightOffset)
109
- expect(result.bottom).toBe(bottom)
110
- })
111
-
112
- it('portrait mode with zero inset height leaves top unchanged', () => {
113
- insetRef.current.offsetHeight = 0
114
- mainRef.current.offsetWidth = 500
115
- insetRef.current.offsetWidth = 100
116
-
117
- const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
118
- expect(result.top).toBe(insetRef.current.offsetTop)
119
- })
120
-
121
- it('calculates correct bottom using max of actions and footer offsets', () => {
122
- mainRef.current.offsetHeight = 600
123
- actionsRef.current.offsetTop = 500
124
- footerRef.current.offsetTop = 550
125
-
126
- const result = getSafeZoneInset({ mainRef, insetRef, leftRef, rightRef, actionsRef, footerRef })
127
-
128
- const dividerGap = 10
129
- const expectedBottom = Math.max(mainRef.current.offsetHeight - actionsRef.current.offsetTop,
130
- mainRef.current.offsetHeight - footerRef.current.offsetTop) + dividerGap
131
- expect(result.bottom).toBe(expectedBottom)
305
+ expect(result.left).toBe(leftInset(ABOVE_W_THRESHOLD))
306
+ expect(result.top).toBe(BASE_TOP)
307
+ expect(result.bottom).toBe(BASE_BOTTOM)
132
308
  })
133
309
  })
@@ -0,0 +1,6 @@
1
+ const PREFIX = '[interactive-map]'
2
+
3
+ export const logger = {
4
+ warn: (...args) => console.warn(PREFIX, ...args),
5
+ error: (...args) => console.error(PREFIX, ...args)
6
+ }