@defra/interactive-map 0.0.17-alpha → 0.0.19-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 (185) hide show
  1. package/assets/css/docusaurus.css +58 -34
  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/panel-definition.md +16 -0
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +50 -23
  14. package/docs/assets/basic-map.jpg +0 -0
  15. package/docs/assets/button-first.jpg +0 -0
  16. package/docs/assets/maker-panel.jpg +0 -0
  17. package/docs/examples/add-marker-with-panel.mdx +59 -0
  18. package/docs/examples/basic-map.mdx +24 -0
  19. package/docs/examples/button-map.mdx +24 -0
  20. package/docs/examples/index.mdx +49 -0
  21. package/docs/index.mdx +1 -1
  22. package/docs/plugins/datasets.md +105 -9
  23. package/docs/plugins/interact.md +100 -44
  24. package/docs/plugins/search.md +15 -3
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +9 -1
  27. package/package.json +1 -1
  28. package/plugins/beta/datasets/dist/css/index.css +32 -14
  29. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  31. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  32. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  33. package/plugins/beta/datasets/src/DatasetsInit.jsx +9 -4
  34. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +57 -11
  35. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +14 -8
  36. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +155 -53
  37. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  38. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  39. package/plugins/beta/datasets/src/api/addDataset.js +1 -1
  40. package/plugins/beta/datasets/src/api/setData.js +4 -2
  41. package/plugins/beta/datasets/src/api/setStyle.js +2 -2
  42. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  43. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  44. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  45. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  46. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  47. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  48. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  49. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  50. package/plugins/beta/datasets/src/datasets.js +13 -4
  51. package/plugins/beta/datasets/src/defaults.js +4 -2
  52. package/plugins/beta/datasets/src/index.js +2 -1
  53. package/plugins/beta/datasets/src/manifest.js +1 -1
  54. package/plugins/beta/datasets/src/panels/Key.jsx +11 -89
  55. package/plugins/beta/datasets/src/panels/Key.module.scss +24 -13
  56. package/plugins/beta/datasets/src/panels/Layers.module.scss +13 -7
  57. package/plugins/beta/datasets/src/reducer.js +6 -0
  58. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  59. package/plugins/beta/datasets/src/utils/mergeSublayer.js +8 -0
  60. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  61. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  62. package/plugins/beta/draw-ml/dist/css/index.css +3 -0
  63. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  64. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  65. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  66. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  67. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  68. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  69. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  70. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  71. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  72. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  73. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  74. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  75. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  76. package/plugins/interact/dist/umd/index.js +1 -1
  77. package/plugins/interact/src/InteractInit.jsx +19 -8
  78. package/plugins/interact/src/InteractInit.test.js +26 -6
  79. package/plugins/interact/src/api/clear.js +1 -1
  80. package/plugins/interact/src/api/enable.test.js +7 -7
  81. package/plugins/interact/src/api/selectMarker.js +14 -0
  82. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  83. package/plugins/interact/src/api/unselectMarker.js +14 -0
  84. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  85. package/plugins/interact/src/defaults.js +4 -6
  86. package/plugins/interact/src/events.js +27 -36
  87. package/plugins/interact/src/events.test.js +119 -90
  88. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  89. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  90. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  91. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  92. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  93. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  94. package/plugins/interact/src/manifest.js +10 -2
  95. package/plugins/interact/src/reducer.js +59 -5
  96. package/plugins/interact/src/reducer.test.js +100 -12
  97. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  98. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  99. package/plugins/interact/src/utils/featureQueries.js +11 -6
  100. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  101. package/plugins/interact/src/utils/interactionModes.js +12 -0
  102. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  103. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  104. package/plugins/search/src/Search.jsx +3 -1
  105. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  106. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  107. package/plugins/search/src/events/formHandlers.js +3 -3
  108. package/plugins/search/src/events/formHandlers.test.js +1 -1
  109. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  110. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  111. package/plugins/search/src/utils/updateMap.js +3 -3
  112. package/plugins/search/src/utils/updateMap.test.js +3 -3
  113. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  114. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  115. package/providers/maplibre/dist/umd/index.js +1 -1
  116. package/providers/maplibre/src/appEvents.js +7 -0
  117. package/providers/maplibre/src/appEvents.test.js +18 -4
  118. package/providers/maplibre/src/maplibreProvider.js +52 -0
  119. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  120. package/providers/maplibre/src/utils/highlightFeatures.js +36 -7
  121. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -96
  122. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  123. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  124. package/providers/maplibre/src/utils/patternImages.js +70 -0
  125. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  126. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  127. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  128. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  129. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  130. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  131. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  132. package/src/App/components/Markers/Markers.jsx +122 -27
  133. package/src/App/components/Markers/Markers.module.scss +0 -10
  134. package/src/App/components/Markers/Markers.test.jsx +246 -0
  135. package/src/App/components/Panel/Panel.jsx +6 -6
  136. package/src/App/components/Panel/Panel.test.jsx +37 -0
  137. package/src/App/components/Viewport/Viewport.jsx +5 -15
  138. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  139. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  140. package/src/App/hooks/useInterfaceAPI.js +7 -7
  141. package/src/App/hooks/useInterfaceAPI.test.js +162 -0
  142. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  143. package/src/App/hooks/useMarkersAPI.js +2 -5
  144. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  145. package/src/App/layout/Layout.jsx +3 -3
  146. package/src/App/layout/Layout.test.jsx +4 -2
  147. package/src/App/layout/layout.module.scss +1 -8
  148. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  149. package/src/App/renderer/mapPanels.js +2 -1
  150. package/src/App/store/ServiceProvider.jsx +7 -5
  151. package/src/App/store/appActionsMap.js +4 -4
  152. package/src/App/store/appActionsMap.test.js +10 -0
  153. package/src/App/store/mapActionsMap.js +4 -6
  154. package/src/App/store/mapActionsMap.test.js +3 -2
  155. package/src/App/store/mapReducer.js +2 -1
  156. package/src/InteractiveMap/InteractiveMap.js +59 -11
  157. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  158. package/src/InteractiveMap/domStateManager.js +18 -6
  159. package/src/InteractiveMap/domStateManager.test.js +21 -0
  160. package/src/InteractiveMap/historyManager.js +28 -16
  161. package/src/InteractiveMap/historyManager.test.js +17 -0
  162. package/src/config/appConfig.js +2 -7
  163. package/src/config/appConfig.test.js +4 -15
  164. package/src/config/defaults.js +2 -3
  165. package/src/config/events.js +20 -21
  166. package/src/config/mapTheme.js +56 -0
  167. package/src/config/patternConfig.js +16 -0
  168. package/src/config/symbolConfig.js +80 -0
  169. package/src/scss/settings/_colors.scss +0 -9
  170. package/src/services/closeApp.js +1 -10
  171. package/src/services/closeApp.test.js +3 -43
  172. package/src/services/patternRegistry.js +40 -0
  173. package/src/services/patternRegistry.test.js +48 -0
  174. package/src/services/symbolRegistry.js +113 -0
  175. package/src/services/symbolRegistry.test.js +262 -0
  176. package/src/types.js +99 -12
  177. package/src/utils/mapStateSync.js +48 -10
  178. package/src/utils/mapStateSync.test.js +29 -9
  179. package/src/utils/patternUtils.js +94 -0
  180. package/src/utils/patternUtils.test.js +160 -0
  181. package/src/utils/symbolUtils.js +85 -0
  182. package/src/utils/symbolUtils.test.js +156 -0
  183. package/docs/examples.mdx +0 -70
  184. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +0 -48
  185. package/plugins/beta/datasets/src/styles/patterns.js +0 -157
@@ -6,12 +6,12 @@ import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
6
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
7
  import { Icon } from '../Icon/Icon'
8
8
 
9
- const computePanelState = (bpConfig, triggeringElement) => {
9
+ const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
10
10
  const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
11
11
  const isDialog = !isAside && bpConfig.dismissible
12
12
  const isModal = bpConfig.modal === true
13
13
  const isDismissible = bpConfig.dismissible !== false
14
- const shouldFocus = Boolean(isModal || triggeringElement)
14
+ const shouldFocus = isModal || (focusOnOpen !== false && (focusOnOpen === true || focus === true || Boolean(triggeringElement)))
15
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
16
  return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
17
17
  }
@@ -58,15 +58,15 @@ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }
58
58
 
59
59
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
60
60
  // sonarjs/disable-next-line function-name
61
- export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
61
+ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
62
62
  const { id } = useConfig()
63
- const { dispatch, breakpoint, layoutRefs } = useApp()
63
+ const { dispatch, breakpoint, layoutRefs, interfaceType } = useApp()
64
64
 
65
65
  const rootEl = document.getElementById(`${id}-im-app`)
66
66
  const bpConfig = panelConfig[breakpoint]
67
67
  const elementId = `${id}-panel-${stringToKebab(panelId)}`
68
68
 
69
- const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement)
69
+ const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement, panelConfig.focus, focusOnOpen) // nosonar
70
70
 
71
71
  // For persistent panels, gate modal behaviour on open state
72
72
  const isModalActive = isModal && isOpen
@@ -81,7 +81,7 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
81
81
  const panelRef = rootRef || internalPanelRef
82
82
 
83
83
  const handleClose = () => {
84
- requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.() })
84
+ requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.({ preventScroll: interfaceType !== 'keyboard' }) })
85
85
  dispatch({ type: 'CLOSE_PANEL', payload: panelId })
86
86
  }
87
87
 
@@ -104,6 +104,43 @@ describe('Panel', () => {
104
104
  })
105
105
  })
106
106
 
107
+ describe('focus behaviour', () => {
108
+ it('focuses panel on mount when focus: true', () => {
109
+ const { container } = renderPanel({ focus: true })
110
+ const panel = container.firstChild
111
+ expect(panel).toHaveAttribute('tabIndex', '-1')
112
+ expect(document.activeElement).toBe(panel)
113
+ })
114
+
115
+ it('does not focus panel on mount when focus: false and no triggering element or modal', () => {
116
+ const { container } = renderPanel({ focus: false })
117
+ const panel = container.firstChild
118
+ expect(panel).not.toHaveAttribute('tabIndex')
119
+ expect(document.activeElement).not.toBe(panel)
120
+ })
121
+
122
+ it('focuses panel on mount when modal even if focus: false', () => {
123
+ const { container } = renderPanel({ focus: false, desktop: { slot: 'overlay', dismissible: true, modal: true } })
124
+ const panel = container.firstChild
125
+ expect(panel).toHaveAttribute('tabIndex', '-1')
126
+ expect(document.activeElement).toBe(panel)
127
+ })
128
+
129
+ it('focuses panel when focusOnOpen is true regardless of panelConfig.focus', () => {
130
+ const { container } = renderPanel({ focus: false }, { focusOnOpen: true })
131
+ const panel = container.firstChild
132
+ expect(panel).toHaveAttribute('tabIndex', '-1')
133
+ expect(document.activeElement).toBe(panel)
134
+ })
135
+
136
+ it('does not focus panel when focusOnOpen is false even if panelConfig.focus is true', () => {
137
+ const { container } = renderPanel({ focus: true }, { focusOnOpen: false })
138
+ const panel = container.firstChild
139
+ expect(panel).not.toHaveAttribute('tabIndex')
140
+ expect(document.activeElement).not.toBe(panel)
141
+ })
142
+ })
143
+
107
144
  describe('close functionality', () => {
108
145
  it('focuses triggeringElement on close for button slots', () => {
109
146
  const focusMock = jest.fn()
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState } from 'react'
2
2
  import { EVENTS as events } from '../../../config/events.js'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { useConfig } from '../../store/configContext.js'
5
+
5
6
  import { useApp } from '../../store/appContext.js'
6
7
  import { useMap } from '../../store/mapContext.js'
7
8
  import { MapController } from './MapController.jsx'
@@ -14,9 +15,10 @@ import { Markers } from '../Markers/Markers'
14
15
 
15
16
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
16
17
  // sonarjs/disable-next-line function-name
17
- export const Viewport = ({ keyboardHintPortalRef }) => {
18
+ export const Viewport = () => {
18
19
  const { id, mapProvider, mapLabel, keyboardHintText } = useConfig()
19
20
  const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp()
21
+ const { mainRef } = layoutRefs
20
22
  const { mapSize } = useMap()
21
23
 
22
24
  const mapContainerRef = useRef(null)
@@ -49,18 +51,6 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
49
51
  }
50
52
  }, [mode])
51
53
 
52
- // Toggle external class based on keyboard hint
53
- useEffect(() => {
54
- const mainEl = layoutRefs.mainRef?.current
55
- if (!mainEl) {
56
- return undefined
57
- }
58
-
59
- mainEl.classList.toggle('im-o-app__main--keyboard-hint-visible', showHint)
60
-
61
- return () => mainEl?.classList.remove('im-o-app__main--keyboard-hint-visible')
62
- }, [showHint])
63
-
64
54
  return (
65
55
  <>
66
56
  <MapController mapContainerRef={mapContainerRef} />
@@ -74,14 +64,14 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
74
64
  onBlur={handleBlur}
75
65
  ref={layoutRefs.viewportRef}
76
66
  >
77
- {showHint && keyboardHintPortalRef?.current && createPortal(
67
+ {showHint && mainRef?.current && createPortal(
78
68
  <div
79
69
  className='im-c-viewport__keyboard-hint'
80
70
  aria-hidden='true'
81
71
  ref={keyboardHintRef}
82
72
  dangerouslySetInnerHTML={{ __html: keyboardHintText }}
83
73
  />,
84
- keyboardHintPortalRef.current
74
+ mainRef.current
85
75
  )}
86
76
  <div className='im-c-viewport__map-container' ref={mapContainerRef} />
87
77
  <div className='im-c-viewport__features' />
@@ -31,8 +31,10 @@
31
31
 
32
32
  .im-c-viewport__keyboard-hint {
33
33
  position: absolute;
34
+ bottom: var(--keyboard-hint-bottom, var(--primary-gap));
34
35
  left: 50%;
35
36
  transform: translateX(-50%);
37
+ z-index: 1001;
36
38
  text-wrap: nowrap;
37
39
 
38
40
  color: var(--tooltip-foreground-color);
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render, fireEvent, cleanup } from '@testing-library/react'
2
+ import { render, cleanup } from '@testing-library/react'
3
3
  import { Viewport } from './Viewport.jsx'
4
4
  import { useConfig } from '../../store/configContext.js'
5
5
  import { useApp } from '../../store/appContext.js'
@@ -23,15 +23,18 @@ jest.mock('../CrossHair/CrossHair', () => ({ CrossHair: jest.fn(() => <div data-
23
23
  jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid='markers' />) }))
24
24
 
25
25
  describe('Viewport', () => {
26
- let keyboardHintPortalRef
26
+ let viewportEl
27
+ let mainEl
27
28
  const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
28
29
 
29
30
  beforeEach(() => {
30
31
  cleanup()
31
32
  jest.clearAllMocks()
32
33
 
33
- keyboardHintPortalRef = { current: document.createElement('div') }
34
- document.body.appendChild(keyboardHintPortalRef.current)
34
+ viewportEl = document.createElement('div')
35
+ mainEl = document.createElement('div')
36
+ document.body.appendChild(viewportEl)
37
+ document.body.appendChild(mainEl)
35
38
 
36
39
  // ---------------------------
37
40
  // Hook mocks
@@ -47,7 +50,7 @@ describe('Viewport', () => {
47
50
  interfaceType: 'desktop',
48
51
  mode: 'default',
49
52
  previousMode: 'default',
50
- layoutRefs: { viewportRef: { current: null }, mainRef: { current: null }, safeZoneRef: { current: null } },
53
+ layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
51
54
  safeZoneInset: {}
52
55
  })
53
56
 
@@ -75,14 +78,17 @@ describe('Viewport', () => {
75
78
  useMapEvents.mockImplementation(() => {})
76
79
  })
77
80
 
78
- afterEach(() => document.body.removeChild(keyboardHintPortalRef.current))
81
+ afterEach(() => {
82
+ viewportEl.remove()
83
+ mainEl.remove()
84
+ })
79
85
 
80
86
  const renderViewport = () => {
81
- const { container, rerender, unmount } = render(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
87
+ const { container, rerender, unmount } = render(<Viewport />)
82
88
  const viewport = container.querySelector('.im-c-viewport')
83
89
  const mapContainer = container.querySelector('.im-c-viewport__map-container')
84
90
  const safeZone = container.querySelector('.im-c-viewport__safezone')
85
- const keyboardHint = keyboardHintPortalRef.current.querySelector('.im-c-viewport__keyboard-hint')
91
+ const keyboardHint = mainEl.querySelector('.im-c-viewport__keyboard-hint')
86
92
  const crossHair = container.querySelector('[data-testid="cross-hair"]')
87
93
  const markers = container.querySelector('[data-testid="markers"]')
88
94
  return { viewport, mapContainer, safeZone, keyboardHint, crossHair, markers, rerender, unmount }
@@ -109,14 +115,6 @@ describe('Viewport', () => {
109
115
  expect(keyboardHint.innerHTML).toBe('Press arrow keys')
110
116
  })
111
117
 
112
- it('handles focus and blur events updating keyboard hint visibility', () => {
113
- const { viewport, keyboardHint } = renderViewport()
114
- fireEvent.focus(viewport)
115
- fireEvent.blur(viewport)
116
- expect(keyboardHint).toBeInTheDocument()
117
- expect(keyboardHint.innerHTML).toBe('Press arrow keys')
118
- })
119
-
120
118
  it('attaches keyboard shortcuts', () => {
121
119
  renderViewport()
122
120
  expect(useKeyboardShortcuts).toHaveBeenCalled()
@@ -137,25 +135,10 @@ describe('Viewport', () => {
137
135
  interfaceType: 'desktop',
138
136
  mode: 'edit',
139
137
  previousMode: 'default',
140
- layoutRefs: { viewportRef: { current: viewport }, mainRef: { current: null }, safeZoneRef: { current: null } },
138
+ layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewport }, safeZoneRef: { current: null } },
141
139
  safeZoneInset: {}
142
140
  })
143
- rerender(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
141
+ rerender(<Viewport />)
144
142
  expect(focusMock).toHaveBeenCalled()
145
143
  })
146
-
147
- it('toggles main element class for keyboard hint and cleans up on unmount', () => {
148
- const mainEl = document.createElement('div')
149
- useApp.mockReturnValueOnce({
150
- interfaceType: 'desktop',
151
- mode: 'default',
152
- previousMode: 'default',
153
- layoutRefs: { viewportRef: { current: null }, mainRef: { current: mainEl }, safeZoneRef: { current: null } },
154
- safeZoneInset: {}
155
- })
156
- const { unmount } = renderViewport()
157
- expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(true)
158
- unmount()
159
- expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(false)
160
- })
161
144
  })
@@ -54,16 +54,16 @@ export const useInterfaceAPI = () => {
54
54
  }
55
55
  }
56
56
 
57
- const handleAppVisible = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
- const handleAppHidden = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
57
+ const handleAppOpened = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
+ const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
59
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
60
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
61
- const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } })
61
+ const handleShowPanel = ({ id, focus = true }) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus } })
62
62
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
63
63
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
64
64
 
65
- eventBus.on(events.APP_VISIBLE, handleAppVisible)
66
- eventBus.on(events.APP_HIDDEN, handleAppHidden)
65
+ eventBus.on(events.APP_OPENED, handleAppOpened)
66
+ eventBus.on(events.APP_CLOSED, handleAppClosed)
67
67
  eventBus.on(events.APP_ADD_BUTTON, handleAddButton)
68
68
  eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
69
69
  eventBus.on(events.APP_ADD_PANEL, handleAddPanel)
@@ -73,8 +73,8 @@ export const useInterfaceAPI = () => {
73
73
  eventBus.on(events.APP_ADD_CONTROL, handleAddControl)
74
74
 
75
75
  return () => {
76
- eventBus.off(events.APP_VISIBLE, handleAppVisible)
77
- eventBus.off(events.APP_HIDDEN, handleAppHidden)
76
+ eventBus.off(events.APP_OPENED, handleAppOpened)
77
+ eventBus.off(events.APP_CLOSED, handleAppClosed)
78
78
  eventBus.off(events.APP_ADD_BUTTON, handleAddButton)
79
79
  eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
80
80
  eventBus.off(events.APP_ADD_PANEL, handleAddPanel)
@@ -0,0 +1,162 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useInterfaceAPI } from './useInterfaceAPI.js'
3
+ import { useApp } from '../store/appContext.js'
4
+ import { useService } from '../store/serviceContext.js'
5
+
6
+ jest.mock('../store/appContext.js')
7
+ jest.mock('../store/serviceContext.js')
8
+
9
+ const makeEventBus = () => {
10
+ const handlers = {}
11
+ return {
12
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
13
+ off: jest.fn(),
14
+ emit: (event, payload) => handlers[event]?.(payload),
15
+ _handlers: handlers
16
+ }
17
+ }
18
+
19
+ describe('useInterfaceAPI', () => {
20
+ let mockDispatch, mockEventBus, mockState
21
+
22
+ beforeEach(() => {
23
+ mockDispatch = jest.fn()
24
+ mockEventBus = makeEventBus()
25
+ mockState = {
26
+ hiddenButtons: new Set(),
27
+ disabledButtons: new Set(),
28
+ pressedButtons: new Set(),
29
+ expandedButtons: new Set()
30
+ }
31
+
32
+ useApp.mockReturnValue({ dispatch: mockDispatch, ...mockState })
33
+ useService.mockReturnValue({ eventBus: mockEventBus })
34
+ })
35
+
36
+ it('dispatches ADD_BUTTON on app:addbutton', () => {
37
+ renderHook(() => useInterfaceAPI())
38
+ act(() => mockEventBus.emit('app:addbutton', { id: 'btn1', config: { label: 'Test' } }))
39
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: { label: 'Test' } } })
40
+ })
41
+
42
+ it('also dispatches ADD_BUTTON for each menuItem', () => {
43
+ renderHook(() => useInterfaceAPI())
44
+ act(() => mockEventBus.emit('app:addbutton', {
45
+ id: 'btn1',
46
+ config: {
47
+ label: 'Parent',
48
+ menuItems: [
49
+ { id: 'item1', label: 'Item 1' },
50
+ { id: 'item2', label: 'Item 2' }
51
+ ]
52
+ }
53
+ }))
54
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: expect.objectContaining({ label: 'Parent' }) } })
55
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item1', config: { id: 'item1', label: 'Item 1', isMenuItem: true } } })
56
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item2', config: { id: 'item2', label: 'Item 2', isMenuItem: true } } })
57
+ })
58
+
59
+ it('dispatches TOGGLE_APP_VISIBLE true on app:opened', () => {
60
+ renderHook(() => useInterfaceAPI())
61
+ act(() => mockEventBus.emit('app:opened'))
62
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: true })
63
+ })
64
+
65
+ it('dispatches TOGGLE_APP_VISIBLE false on app:closed', () => {
66
+ renderHook(() => useInterfaceAPI())
67
+ act(() => mockEventBus.emit('app:closed'))
68
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: false })
69
+ })
70
+
71
+ it('dispatches ADD_PANEL on app:addpanel', () => {
72
+ renderHook(() => useInterfaceAPI())
73
+ act(() => mockEventBus.emit('app:addpanel', { id: 'panel1', config: { title: 'My Panel' } }))
74
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_PANEL', payload: { id: 'panel1', config: { title: 'My Panel' } } })
75
+ })
76
+
77
+ it('dispatches REMOVE_PANEL on app:removepanel', () => {
78
+ renderHook(() => useInterfaceAPI())
79
+ act(() => mockEventBus.emit('app:removepanel', 'panel1'))
80
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_PANEL', payload: 'panel1' })
81
+ })
82
+
83
+ it('dispatches OPEN_PANEL with focusOnOpen:true on app:showpanel by default', () => {
84
+ renderHook(() => useInterfaceAPI())
85
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1' }))
86
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true } })
87
+ })
88
+
89
+ it('dispatches OPEN_PANEL with focusOnOpen:false when focus:false', () => {
90
+ renderHook(() => useInterfaceAPI())
91
+ act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', focus: false }))
92
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
93
+ })
94
+
95
+ it('dispatches CLOSE_PANEL on app:hidepanel', () => {
96
+ renderHook(() => useInterfaceAPI())
97
+ act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
98
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'panel1' })
99
+ })
100
+
101
+ it('dispatches ADD_CONTROL on app:addcontrol', () => {
102
+ renderHook(() => useInterfaceAPI())
103
+ act(() => mockEventBus.emit('app:addcontrol', { id: 'ctrl1', config: { position: 'top-left' } }))
104
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_CONTROL', payload: { id: 'ctrl1', config: { position: 'top-left' } } })
105
+ })
106
+
107
+ describe('handleToggleButtonState', () => {
108
+ it.each([
109
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden'],
110
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled'],
111
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed'],
112
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded']
113
+ ])('sets %s to explicit boolean value when provided', (prop, actionType, payloadKey) => {
114
+ renderHook(() => useInterfaceAPI())
115
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: true }))
116
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
117
+
118
+ mockDispatch.mockClear()
119
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: false }))
120
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
121
+ })
122
+
123
+ it.each([
124
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden', 'hiddenButtons'],
125
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled', 'disabledButtons'],
126
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed', 'pressedButtons'],
127
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded', 'expandedButtons']
128
+ ])('toggles %s when no boolean value provided', (prop, actionType, payloadKey, stateKey) => {
129
+ renderHook(() => useInterfaceAPI())
130
+
131
+ // Not in set → toggles to true
132
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
133
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
134
+
135
+ // Already in set → toggles to false
136
+ mockDispatch.mockClear()
137
+ mockState[stateKey].add('btn1')
138
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
139
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
140
+ })
141
+
142
+ it('does nothing for unknown prop', () => {
143
+ renderHook(() => useInterfaceAPI())
144
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop: 'unknown', value: true }))
145
+ expect(mockDispatch).not.toHaveBeenCalled()
146
+ })
147
+ })
148
+
149
+ it('removes all event listeners on unmount', () => {
150
+ const { unmount } = renderHook(() => useInterfaceAPI())
151
+ unmount()
152
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:opened', expect.any(Function))
153
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:closed', expect.any(Function))
154
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addbutton', expect.any(Function))
155
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:togglebuttonstate', expect.any(Function))
156
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addpanel', expect.any(Function))
157
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:removepanel', expect.any(Function))
158
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:showpanel', expect.any(Function))
159
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:hidepanel', expect.any(Function))
160
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addcontrol', expect.any(Function))
161
+ })
162
+ })
@@ -51,80 +51,72 @@ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
51
51
  * It does not dispatch the safe zone — safe zone dispatch is owned entirely by
52
52
  * Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
53
53
  */
54
- export function useLayoutMeasurements () {
55
- const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
56
- const { mapSize, isMapReady } = useMap()
57
-
54
+ function calculateLayout (layoutRefs) {
58
55
  const {
59
- appContainerRef,
60
- mainRef,
61
- bannerRef,
62
- topRef,
63
- topLeftColRef,
64
- topRightColRef,
65
- leftTopRef,
66
- leftBottomRef,
67
- rightTopRef,
68
- rightBottomRef,
69
- bottomRef,
70
- bottomRightRef,
71
- attributionsRef,
72
- drawerRef,
73
- actionsRef
56
+ appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
57
+ bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
58
+ rightTopRef, rightBottomRef
74
59
  } = layoutRefs
75
60
 
76
- // --------------------------------
77
- // 1. Calculate layout CSS vars (pure side effect, no dispatch)
78
- // --------------------------------
79
- const calculateLayout = () => {
80
- const appContainer = appContainerRef.current
81
- const main = mainRef.current
82
- const top = topRef.current
83
- const topLeftCol = topLeftColRef.current
84
- const topRightCol = topRightColRef.current
85
- const bottom = bottomRef.current
86
- const attributions = attributionsRef.current
87
-
88
- if ([main, top, bottom].some(r => !r)) {
89
- return
90
- }
61
+ const appContainer = appContainerRef.current
62
+ const main = mainRef.current
63
+ const top = topRef.current
64
+ const topLeftCol = topLeftColRef.current
65
+ const topRightCol = topRightColRef.current
66
+ const bottom = bottomRef.current
67
+ const attributions = attributionsRef.current
91
68
 
92
- const root = document.documentElement
93
- const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
94
-
95
- // === Top column width ===
96
- appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
97
-
98
- // === Left container offsets ===
99
- const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
100
- const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
101
- appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
102
- appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
103
- appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
104
-
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
111
- const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
112
- const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
113
- const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
114
- const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
115
- appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
116
- appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
117
- appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
118
-
119
- // === Sub-slot panel max-heights ===
120
- appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
121
- appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
122
- appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
123
- appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
69
+ if ([main, top, bottom].some(r => !r)) {
70
+ return
124
71
  }
125
72
 
73
+ const root = document.documentElement
74
+ const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)
75
+
76
+ // === Top column width ===
77
+ appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)
78
+
79
+ // === Left container offsets ===
80
+ const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
81
+ const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
82
+ appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
83
+ appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
84
+ appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)
85
+
86
+ // === Right container offsets ===
87
+ // Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
88
+ // bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
89
+ // the padding between the bottom of the bottom container and the bottom of main.
90
+ const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
91
+ const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
92
+ const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
93
+ const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
94
+ const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
95
+ const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
96
+ appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
97
+ appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
98
+ appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)
99
+
100
+ // === Keyboard hint bottom offset ===
101
+ // Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main.
102
+ // Used to position the hint above the bottom bar (and above drawers on mobile).
103
+ appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`)
104
+
105
+ // === Sub-slot panel max-heights ===
106
+ appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
107
+ appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
108
+ appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
109
+ appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
110
+ }
111
+
112
+ export function useLayoutMeasurements () {
113
+ const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
114
+ const { mapSize, isMapReady } = useMap()
115
+
116
+ const { bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef } = layoutRefs
117
+
126
118
  // --------------------------------
127
- // 2. Clear the evaluated flag when structural inputs change so the safe zone
119
+ // 1. Clear the evaluated flag when structural inputs change so the safe zone
128
120
  // is not dispatched until useButtonStateEvaluator has completed a full
129
121
  // pass with the new app/map state and set PLUGINS_EVALUATED.
130
122
  // --------------------------------
@@ -133,7 +125,7 @@ export function useLayoutMeasurements () {
133
125
  }, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])
134
126
 
135
127
  // --------------------------------
136
- // 3. Once all plugin button props have been evaluated (arePluginsEvaluated),
128
+ // 2. Once all plugin button props have been evaluated (arePluginsEvaluated),
137
129
  // recalculate layout and dispatch the safe zone inset.
138
130
  // RAF required to ensure browser layout is committed before measuring.
139
131
  // --------------------------------
@@ -142,7 +134,7 @@ export function useLayoutMeasurements () {
142
134
  return
143
135
  }
144
136
  requestAnimationFrame(() => {
145
- calculateLayout()
137
+ calculateLayout(layoutRefs)
146
138
  const safeZoneInset = getSafeZoneInset(layoutRefs)
147
139
  if (safeZoneInset) {
148
140
  dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
@@ -151,13 +143,13 @@ export function useLayoutMeasurements () {
151
143
  }, [arePluginsEvaluated])
152
144
 
153
145
  // --------------------------------
154
- // 4. Recalculate CSS vars whenever observed elements resize (panels, banner,
146
+ // 3. Recalculate CSS vars whenever observed elements resize (panels, banner,
155
147
  // actions buttons, etc.). Safe zone is intentionally not dispatched here —
156
- // that is Effect 3's responsibility.
148
+ // that is Effect 2's responsibility.
157
149
  // --------------------------------
158
150
  useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
159
151
  requestAnimationFrame(() => {
160
- calculateLayout()
152
+ calculateLayout(layoutRefs)
161
153
  })
162
154
  })
163
155
  }
@@ -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