@defra/interactive-map 0.0.16-alpha → 0.0.18-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 (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -12,8 +12,47 @@ const topColWidth = (left, right) =>
12
12
  const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
13
13
  columnHeight - (siblingButtons ? siblingButtons + gap : 0)
14
14
 
15
+ /**
16
+ * Manages all layout measurements for the map overlay and dispatches the safe
17
+ * zone inset used by the map to pad `fitBounds` / `setView` operations.
18
+ *
19
+ * ## Lifecycle
20
+ *
21
+ * The safe zone must only be dispatched once every plugin button's reactive
22
+ * props (`hiddenWhen`, `enableWhen`, `pressedWhen`, `expandedWhen`) have been
23
+ * evaluated for the current app/map state. Dispatching too early — before
24
+ * buttons that affect layout (e.g. the actions bar) have their correct
25
+ * visibility — produces a stale inset that causes the map to jump when the UI
26
+ * then settles into its real state.
27
+ *
28
+ * ### Trigger events
29
+ * The following state changes can alter which buttons are visible and therefore
30
+ * how much space the UI occupies:
31
+ * - `breakpoint` — responsive layout changes (desktop ↔ mobile / tablet)
32
+ * - `mapSize` — map container size variant changes
33
+ * - `isMapReady` — plugins are enabled on `map:ready`, changing button visibility
34
+ * - `isFullscreen` — fullscreen entry/exit changes which buttons are visible
35
+ * - `appVisible` — app shown/hidden by parent HTML outside React (hybrid mode)
36
+ *
37
+ * When any of these change, `CLEAR_PLUGINS_EVALUATED` is dispatched (Effect 2),
38
+ * which prevents the safe zone from being re-dispatched until
39
+ * `useButtonStateEvaluator` has completed a full pass with no button state
40
+ * changes and sets `PLUGINS_EVALUATED` again.
41
+ *
42
+ * ### Safe zone dispatch
43
+ * Effect 3 fires whenever `arePluginsEvaluated` transitions to `true`, at which
44
+ * point DOM dimensions are stable and `getSafeZoneInset` can be read reliably.
45
+ * A `requestAnimationFrame` is used to ensure the browser has committed all
46
+ * layout changes before measuring.
47
+ *
48
+ * ### Resize observer
49
+ * Effect 4 keeps CSS custom properties up to date whenever any observed element
50
+ * resizes (e.g. panels opening, banner appearing, actions buttons toggling).
51
+ * It does not dispatch the safe zone — safe zone dispatch is owned entirely by
52
+ * Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
53
+ */
15
54
  export function useLayoutMeasurements () {
16
- const { dispatch, breakpoint, layoutRefs } = useApp()
55
+ const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
17
56
  const { mapSize, isMapReady } = useMap()
18
57
 
19
58
  const {
@@ -23,24 +62,28 @@ export function useLayoutMeasurements () {
23
62
  topRef,
24
63
  topLeftColRef,
25
64
  topRightColRef,
26
- footerRef,
27
- actionsRef,
28
65
  leftTopRef,
29
66
  leftBottomRef,
30
67
  rightTopRef,
31
- rightBottomRef
68
+ rightBottomRef,
69
+ bottomRef,
70
+ bottomRightRef,
71
+ attributionsRef,
72
+ drawerRef,
73
+ actionsRef
32
74
  } = layoutRefs
33
75
 
34
- // -----------------------------
35
- // 1. Calculate layout CSS vars (side effect)
36
- // -----------------------------
76
+ // --------------------------------
77
+ // 1. Calculate layout CSS vars (pure side effect, no dispatch)
78
+ // --------------------------------
37
79
  const calculateLayout = () => {
38
80
  const appContainer = appContainerRef.current
39
81
  const main = mainRef.current
40
82
  const top = topRef.current
41
83
  const topLeftCol = topLeftColRef.current
42
84
  const topRightCol = topRightColRef.current
43
- const bottom = footerRef.current
85
+ const bottom = bottomRef.current
86
+ const attributions = attributionsRef.current
44
87
 
45
88
  if ([main, top, bottom].some(r => !r)) {
46
89
  return
@@ -60,10 +103,17 @@ export function useLayoutMeasurements () {
60
103
  appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
61
104
 
62
105
  // === Right container offsets ===
106
+ // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
107
+ // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
108
+ // the padding between the bottom of the bottom container and the bottom of main.
109
+ const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
110
+ const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
63
111
  const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
64
- const rightColumnHeight = bottom.offsetTop - rightOffsetTop - dividerGap
112
+ const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
113
+ const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
114
+ const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
65
115
  appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
66
- appContainer.style.setProperty('--right-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
116
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
67
117
  appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
68
118
 
69
119
  // === Sub-slot panel max-heights ===
@@ -74,22 +124,38 @@ export function useLayoutMeasurements () {
74
124
  }
75
125
 
76
126
  // --------------------------------
77
- // 2. Run when breakpoint and mapSize change
127
+ // 2. Clear the evaluated flag when structural inputs change so the safe zone
128
+ // is not dispatched until useButtonStateEvaluator has completed a full
129
+ // pass with the new app/map state and set PLUGINS_EVALUATED.
78
130
  // --------------------------------
79
131
  useLayoutEffect(() => {
80
- requestAnimationFrame(() => { // Required for Preact
81
- calculateLayout()
132
+ dispatch({ type: 'CLEAR_PLUGINS_EVALUATED' })
133
+ }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
82
134
 
83
- // === Set safe zone inset ===
135
+ // --------------------------------
136
+ // 3. Once all plugin button props have been evaluated (arePluginsEvaluated),
137
+ // recalculate layout and dispatch the safe zone inset.
138
+ // RAF required to ensure browser layout is committed before measuring.
139
+ // --------------------------------
140
+ useLayoutEffect(() => {
141
+ if (!arePluginsEvaluated) {
142
+ return
143
+ }
144
+ requestAnimationFrame(() => {
145
+ calculateLayout()
84
146
  const safeZoneInset = getSafeZoneInset(layoutRefs)
85
- dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
147
+ if (safeZoneInset) {
148
+ dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
149
+ }
86
150
  })
87
- }, [breakpoint, mapSize, isMapReady])
151
+ }, [arePluginsEvaluated])
88
152
 
89
153
  // --------------------------------
90
- // 3. Recaluclate CSS vars when elements resize
154
+ // 4. Recalculate CSS vars whenever observed elements resize (panels, banner,
155
+ // actions buttons, etc.). Safe zone is intentionally not dispatched here —
156
+ // that is Effect 3's responsibility.
91
157
  // --------------------------------
92
- useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, footerRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef], () => {
158
+ useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
93
159
  requestAnimationFrame(() => {
94
160
  calculateLayout()
95
161
  })
@@ -24,18 +24,21 @@ const refs = (o = {}) => ({
24
24
  topRef: { current: o.top === null ? null : el({ offsetTop: 10, ...o.top }) },
25
25
  topLeftColRef: { current: el({ offsetHeight: 50, offsetWidth: 200, ...o.topLeftCol }) },
26
26
  topRightColRef: { current: el({ offsetHeight: 40, offsetWidth: 180, ...o.topRightCol }) },
27
- footerRef: { current: o.footer === null ? null : el({ offsetTop: 400, ...o.footer }) },
28
- actionsRef: { current: el({ offsetTop: 450, ...o.actions }) },
27
+ bottomRef: { current: o.bottom === null ? null : el({ offsetTop: 400, ...o.bottom }) },
28
+ bottomRightRef: { current: el({ offsetTop: 400, ...o.bottomRight }) },
29
29
  leftTopRef: { current: el({ offsetHeight: 0, ...o.leftTop }) },
30
30
  leftBottomRef: { current: el({ offsetHeight: 0, ...o.leftBottom }) },
31
31
  rightTopRef: { current: el({ offsetHeight: 0, ...o.rightTop }) },
32
- rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) }
32
+ rightBottomRef: { current: el({ offsetHeight: 0, ...o.rightBottom }) },
33
+ attributionsRef: { current: el({ offsetHeight: 16, ...o.attributions }) },
34
+ drawerRef: { current: el(o.drawer) },
35
+ actionsRef: { current: el({ offsetTop: 450, ...o.actions }) }
33
36
  })
34
37
 
35
38
  const setup = (o = {}) => {
36
39
  const dispatch = jest.fn()
37
40
  const layoutRefs = refs(o.refs)
38
- useApp.mockReturnValue({ dispatch, breakpoint: 'desktop', layoutRefs, ...o.app })
41
+ useApp.mockReturnValue({ dispatch, breakpoint: 'desktop', layoutRefs, arePluginsEvaluated: true, ...o.app })
39
42
  useMap.mockReturnValue({ mapSize: { width: 800, height: 600 }, isMapReady: true, ...o.map })
40
43
  getSafeZoneInset.mockReturnValue({ top: 0, right: 0, bottom: 0, left: 0 })
41
44
  return { dispatch, layoutRefs }
@@ -56,7 +59,7 @@ describe('useLayoutMeasurements', () => {
56
59
  })
57
60
 
58
61
  test('early return when required refs are null', () => {
59
- const { layoutRefs } = setup({ refs: { main: null, top: null, footer: null } })
62
+ const { layoutRefs } = setup({ refs: { main: null, top: null, bottom: null } })
60
63
  renderHook(() => useLayoutMeasurements())
61
64
  expect(layoutRefs.appContainerRef.current.style.setProperty).not.toHaveBeenCalled()
62
65
  })
@@ -71,7 +74,7 @@ describe('useLayoutMeasurements', () => {
71
74
 
72
75
  test.each([
73
76
  ['right-offset-top', { topRightCol: { offsetHeight: 80 }, top: { offsetTop: 15 } }, '95px'],
74
- ['right-offset-bottom', { main: { offsetHeight: 600 }, footer: { offsetTop: 500 } }, '108px'],
77
+ ['right-offset-bottom', { main: { offsetHeight: 600 }, bottom: { offsetTop: 500 } }, '116px'],
75
78
  // leftColumnHeight = 400 - (50+10) - 8 = 332; rightColumnHeight = 400 - (40+10) - 8 = 342
76
79
  ['left-top-max-height', {}, '332px'],
77
80
  ['right-top-max-height', {}, '342px']
@@ -107,6 +110,26 @@ describe('useLayoutMeasurements', () => {
107
110
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--top-col-width', expected)
108
111
  })
109
112
 
113
+ test('uses 0 when bottomRightRef current is null', () => {
114
+ const { layoutRefs } = setup()
115
+ layoutRefs.bottomRightRef.current = null
116
+ renderHook(() => useLayoutMeasurements())
117
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-offset-bottom', '116px')
118
+ })
119
+
120
+ test('uses bottomRight height when bottomRightHeight > 0', () => {
121
+ const { layoutRefs } = setup({
122
+ refs: {
123
+ bottomRight: { offsetHeight: 20 } // 👈 triggers TRUE branch
124
+ }
125
+ })
126
+ renderHook(() => useLayoutMeasurements())
127
+ // bottomContainerPad = 500 - 400 - 0 = 100
128
+ // expected = 100 + (20 + 8) = 128
129
+ expect(layoutRefs.appContainerRef.current.style.setProperty)
130
+ .toHaveBeenCalledWith('--right-offset-bottom', '128px')
131
+ })
132
+
110
133
  test('uses 0 when sub-slot refs have null current', () => {
111
134
  const { layoutRefs } = setup()
112
135
  layoutRefs.leftTopRef.current = null
@@ -119,31 +142,100 @@ describe('useLayoutMeasurements', () => {
119
142
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalledWith('--right-bottom-panel-max-height', '342px')
120
143
  })
121
144
 
122
- test('dispatches safe zone inset', () => {
123
- const { dispatch, layoutRefs } = setup()
145
+ test('dispatches safe zone inset on desktop (post-batch RAF read only)', () => {
146
+ const { dispatch, layoutRefs } = setup({ app: { breakpoint: 'desktop' } })
124
147
  getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 15, left: 5 })
125
148
  renderHook(() => useLayoutMeasurements())
126
149
  expect(getSafeZoneInset).toHaveBeenCalledWith(layoutRefs)
127
150
  expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset: { top: 10, right: 5, bottom: 15, left: 5 } } })
128
151
  })
129
152
 
130
- test('recalculates on dependency changes', () => {
153
+ test('dispatches safe zone inset on mobile', () => {
154
+ const { dispatch } = setup({ app: { breakpoint: 'mobile' } })
155
+ getSafeZoneInset.mockReturnValue({ top: 10, right: 5, bottom: 40, left: 5 })
156
+ renderHook(() => useLayoutMeasurements())
157
+ expect(dispatch).toHaveBeenCalledWith({
158
+ type: 'SET_SAFE_ZONE_INSET',
159
+ payload: { safeZoneInset: { top: 10, right: 5, bottom: 40, left: 5 } }
160
+ })
161
+ })
162
+
163
+ test('does not dispatch SET_SAFE_ZONE_INSET when getSafeZoneInset returns undefined', () => {
164
+ const { dispatch } = setup()
165
+ getSafeZoneInset.mockReturnValue(undefined)
166
+ renderHook(() => useLayoutMeasurements())
167
+ expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' }))
168
+ })
169
+
170
+ test('does not dispatch safe zone when arePluginsEvaluated is false', () => {
171
+ const { dispatch } = setup({ app: { arePluginsEvaluated: false } })
172
+ renderHook(() => useLayoutMeasurements())
173
+ expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' }))
174
+ expect(layoutRefs => layoutRefs).toBeDefined() // no layout calculation
175
+ })
176
+
177
+ test('re-dispatches safe zone when arePluginsEvaluated becomes true', () => {
178
+ setup({ app: { arePluginsEvaluated: false } })
179
+ const { rerender } = renderHook(() => useLayoutMeasurements())
180
+ const { dispatch } = setup({ app: { arePluginsEvaluated: true } })
181
+ getSafeZoneInset.mockReturnValue({ top: 5, right: 5, bottom: 60, left: 5 })
182
+ rerender()
183
+ expect(dispatch).toHaveBeenCalledWith({
184
+ type: 'SET_SAFE_ZONE_INSET',
185
+ payload: { safeZoneInset: { top: 5, right: 5, bottom: 60, left: 5 } }
186
+ })
187
+ })
188
+
189
+ test('dispatches CLEAR_PLUGINS_EVALUATED when breakpoint changes', () => {
190
+ setup()
191
+ const { rerender } = renderHook(() => useLayoutMeasurements())
192
+ const { dispatch } = setup({ app: { breakpoint: 'mobile' } })
193
+ dispatch.mockClear()
194
+ rerender()
195
+ expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
196
+ })
197
+
198
+ test('dispatches CLEAR_PLUGINS_EVALUATED when isMapReady changes', () => {
131
199
  setup()
132
200
  const { rerender } = renderHook(() => useLayoutMeasurements())
133
- ;[{ app: { breakpoint: 'mobile' } }, { map: { mapSize: { width: 1000, height: 800 } } }, { map: { isMapReady: false } }]
134
- .forEach(change => {
135
- const { layoutRefs } = setup(change)
136
- layoutRefs.appContainerRef.current.style.setProperty.mockClear()
137
- rerender()
138
- expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled()
139
- })
201
+ const { dispatch } = setup({ map: { isMapReady: false } })
202
+ dispatch.mockClear()
203
+ rerender()
204
+ expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
205
+ })
206
+
207
+ test('dispatches CLEAR_PLUGINS_EVALUATED when isFullscreen changes', () => {
208
+ setup({ app: { isFullscreen: false } })
209
+ const { rerender } = renderHook(() => useLayoutMeasurements())
210
+ const { dispatch } = setup({ app: { isFullscreen: true } })
211
+ dispatch.mockClear()
212
+ rerender()
213
+ expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
214
+ })
215
+
216
+ test('dispatches CLEAR_PLUGINS_EVALUATED when appVisible changes', () => {
217
+ setup({ app: { appVisible: false } })
218
+ const { rerender } = renderHook(() => useLayoutMeasurements())
219
+ const { dispatch } = setup({ app: { appVisible: true } })
220
+ dispatch.mockClear()
221
+ rerender()
222
+ expect(dispatch).toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
223
+ })
224
+
225
+ test('recalculates layout when arePluginsEvaluated becomes true', () => {
226
+ setup({ app: { arePluginsEvaluated: false } })
227
+ const { rerender } = renderHook(() => useLayoutMeasurements())
228
+ const { layoutRefs } = setup({ app: { arePluginsEvaluated: true } })
229
+ layoutRefs.appContainerRef.current.style.setProperty.mockClear()
230
+ rerender()
231
+ expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled()
140
232
  })
141
233
 
142
234
  test('sets up resize observer', () => {
143
235
  const { layoutRefs } = setup()
144
236
  renderHook(() => useLayoutMeasurements())
145
237
  expect(useResizeObserver).toHaveBeenCalledWith(
146
- [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.footerRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef],
238
+ [layoutRefs.bannerRef, layoutRefs.mainRef, layoutRefs.topRef, layoutRefs.topLeftColRef, layoutRefs.topRightColRef, layoutRefs.actionsRef, layoutRefs.bottomRef, layoutRefs.bottomRightRef, layoutRefs.leftTopRef, layoutRefs.leftBottomRef, layoutRefs.rightTopRef, layoutRefs.rightBottomRef, layoutRefs.drawerRef],
147
239
  expect.any(Function)
148
240
  )
149
241
  layoutRefs.appContainerRef.current.style.setProperty.mockClear()
@@ -151,4 +243,19 @@ describe('useLayoutMeasurements', () => {
151
243
  expect(rafSpy).toHaveBeenCalled()
152
244
  expect(layoutRefs.appContainerRef.current.style.setProperty).toHaveBeenCalled()
153
245
  })
246
+
247
+ test('resize observer does not dispatch safe zone (safe zone is Effect 3 only)', () => {
248
+ const { dispatch } = setup()
249
+ renderHook(() => useLayoutMeasurements())
250
+ dispatch.mockClear()
251
+ useResizeObserver.mock.calls[0][1]()
252
+ expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_SAFE_ZONE_INSET' }))
253
+ })
254
+
255
+ test('resize observer handles null mainRef without throwing', () => {
256
+ const { layoutRefs } = setup()
257
+ renderHook(() => useLayoutMeasurements())
258
+ layoutRefs.mainRef.current = null
259
+ expect(() => useResizeObserver.mock.calls[0][1]()).not.toThrow()
260
+ })
154
261
  })
@@ -5,12 +5,9 @@ import { useService } from '../store/serviceContext.js'
5
5
  import { scaleFactor } from '../../config/appConfig.js'
6
6
  import { EVENTS as events } from '../../config/events.js'
7
7
 
8
- // Vertical offset to align the marker tip with the coordinate point
9
- const MARKER_ANCHOR_OFFSET_Y = 19
10
-
11
8
  /**
12
9
  * Projects geographic coordinates to screen pixel position, scaled for the
13
- * current map size and offset so the marker tip aligns with the point.
10
+ * current map size. Anchor alignment is handled in CSS by the Markers component.
14
11
  *
15
12
  * @param {Array<number>} coords - [lng, lat] geographic coordinates
16
13
  * @param {Object} mapProvider - Map provider instance with `mapToScreen` method
@@ -25,7 +22,7 @@ export const projectCoords = (coords, mapProvider, mapSize, isMapReady) => {
25
22
  const { x, y } = mapProvider.mapToScreen(coords)
26
23
  return {
27
24
  x: x * scaleFactor[mapSize],
28
- y: y * scaleFactor[mapSize] - MARKER_ANCHOR_OFFSET_Y
25
+ y: y * scaleFactor[mapSize]
29
26
  }
30
27
  }
31
28
 
@@ -19,7 +19,7 @@ describe('projectCoords', () => {
19
19
  const mockProvider = { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) }
20
20
 
21
21
  it('returns scaled coordinates when ready', () => {
22
- expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 381 })
22
+ expect(projectCoords({ lat: 1, lng: 1 }, mockProvider, 'medium', true)).toEqual({ x: 200, y: 400 })
23
23
  })
24
24
 
25
25
  it('returns zero when not ready or no provider', () => {
@@ -102,7 +102,7 @@ describe('useMarkers', () => {
102
102
 
103
103
  const renderCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
104
104
  act(() => renderCallback())
105
- expect(mockElement.style.transform).toBe('translate(200px, 381px)')
105
+ expect(mockElement.style.transform).toBe('translate(200px, 400px)')
106
106
  })
107
107
 
108
108
  it('skips map:render when not ready or no provider (line 60)', () => {
@@ -126,7 +126,7 @@ describe('useMarkers', () => {
126
126
  isMapReady: true
127
127
  })
128
128
  rerender()
129
- expect(mockElement.style.transform).toBe('translate(300px, 581px)')
129
+ expect(mockElement.style.transform).toBe('translate(300px, 600px)')
130
130
  })
131
131
 
132
132
  it('handles app:addmarker safely', () => {
@@ -137,7 +137,7 @@ describe('useMarkers', () => {
137
137
  act(() => handleAddMarker(addPayload))
138
138
  expect(mockDispatch).toHaveBeenCalledWith({
139
139
  type: 'UPSERT_LOCATION_MARKER',
140
- payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 381, isVisible: true }
140
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 400, isVisible: true }
141
141
  })
142
142
  })
143
143
 
@@ -10,6 +10,7 @@ import { Attributions } from '../components/Attributions/Attributions'
10
10
  import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
+ import { getMapThemeVars } from '../../config/mapTheme.js'
13
14
 
14
15
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
15
16
  // sonarjs/disable-next-line function-name
@@ -30,10 +31,9 @@ export const Layout = () => {
30
31
  `im-o-app--${interfaceType}`,
31
32
  `im-o-app--${isFullscreen ? 'fullscreen' : 'inline'}`,
32
33
  `im-o-app--${mapStyle?.appColorScheme || preferredColorScheme}-app`,
33
- `im-o-app--${mapStyle?.mapColorScheme || 'light'}-map`,
34
34
  hasExclusiveControl && 'im-o-app--exclusive-control'
35
35
  ].filter(Boolean).join(' ')}
36
- style={{ backgroundColor: mapStyle?.backgroundColor || undefined }}
36
+ style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
37
37
  ref={layoutRefs.appContainerRef}
38
38
  >
39
39
  <Viewport keyboardHintPortalRef={layoutRefs.topRef} />
@@ -82,19 +82,24 @@ export const Layout = () => {
82
82
  <SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
83
83
  </div>
84
84
  </div>
85
- <div className='im-o-app__footer' ref={layoutRefs.footerRef}>
86
- <div className='im-o-app__footer-col'>
85
+ <div className='im-o-app__bottom' ref={layoutRefs.bottomRef}>
86
+ <div className='im-o-app__bottom-col'>
87
87
  <Logo />
88
+ <div className='im-o-app__bottom-left'>
89
+ <SlotRenderer slot={layoutSlots.BOTTOM_LEFT} />
90
+ </div>
88
91
  </div>
89
- <div className='im-o-app__footer-col'>
90
- <SlotRenderer slot={layoutSlots.FOOTER_RIGHT} />
91
- <div className='im-o-app__attributions'>
92
+ <div className='im-o-app__bottom-col'>
93
+ <div className='im-o-app__bottom-right' ref={layoutRefs.bottomRightRef}>
94
+ <SlotRenderer slot={layoutSlots.BOTTOM_RIGHT} />
95
+ </div>
96
+ <div className='im-o-app__attributions' ref={layoutRefs.attributionsRef}>
92
97
  <Attributions />
93
98
  </div>
94
99
  </div>
95
100
  </div>
96
- <div className='im-o-app__bottom' ref={layoutRefs.bottomRef}>
97
- <SlotRenderer slot={layoutSlots.BOTTOM} />
101
+ <div className='im-o-app__drawer' ref={layoutRefs.drawerRef}>
102
+ <SlotRenderer slot={layoutSlots.DRAWER} />
98
103
  </div>
99
104
  <div className='im-o-app__actions' ref={layoutRefs.actionsRef}>
100
105
  <SlotRenderer slot={layoutSlots.ACTIONS} />
@@ -38,7 +38,7 @@ describe('Layout', () => {
38
38
  topLeftColRef: React.createRef(),
39
39
  topRightColRef: React.createRef(),
40
40
  rightRef: React.createRef(),
41
- footerRef: React.createRef(),
41
+ bottomRef: React.createRef(),
42
42
  actionsRef: React.createRef()
43
43
  }
44
44
 
@@ -72,9 +72,11 @@ describe('Layout', () => {
72
72
  expect(root.className).toContain('im-o-app--map')
73
73
  expect(root.className).toContain('im-o-app--inline')
74
74
  expect(root.className).toContain('im-o-app--light-app')
75
- expect(root.className).toContain('im-o-app--dark-map')
76
75
  expect(root.className).toContain('im-o-app--exclusive-control')
77
76
  expect(root.style.backgroundColor).toBe('pink')
77
+ expect(root.style.getPropertyValue('--map-overlay-halo-color')).toBe('#0b0c0c')
78
+ expect(root.style.getPropertyValue('--map-overlay-selected-color')).toBe('#ffffff')
79
+ expect(root.style.getPropertyValue('--map-overlay-foreground-color')).toBe('#ffffff')
78
80
 
79
81
  const overlay = root.querySelector('.im-o-app__overlay')
80
82
  expect(overlay.className).not.toContain('not-ready')
@@ -86,7 +88,7 @@ describe('Layout', () => {
86
88
  expect(screen.getByTestId('slot-side')).toBeInTheDocument()
87
89
  expect(screen.getByTestId('slot-banner')).toBeInTheDocument()
88
90
  expect(screen.getByTestId('slot-top-left')).toBeInTheDocument()
89
- expect(screen.getByTestId('slot-footer-right')).toBeInTheDocument()
91
+ expect(screen.getByTestId('slot-bottom-right')).toBeInTheDocument()
90
92
  expect(screen.getByTestId('slot-modal')).toBeInTheDocument()
91
93
  })
92
94
 
@@ -123,7 +125,7 @@ describe('Layout', () => {
123
125
 
124
126
  const root = document.getElementById('myApp-im-app')
125
127
  expect(root.className).toContain('im-o-app--dark-app')
126
- expect(root.className).toContain('im-o-app--light-map') // fallback
128
+ expect(root.className).not.toContain('im-o-app--light-map')
127
129
  expect(root.style.backgroundColor).toBe('')
128
130
  expect(root.className).not.toContain('exclusive-control')
129
131
  })