gloomberb 0.10.1 → 0.10.2

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 (351) hide show
  1. package/package.json +8 -1
  2. package/src/api-client/auth.ts +1 -0
  3. package/src/api-client/index.test.ts +359 -108
  4. package/src/api-client/index.ts +24 -13
  5. package/src/api-client/socket.ts +70 -35
  6. package/src/api-client/types.ts +1 -1
  7. package/src/app/language-observer.test.tsx +82 -0
  8. package/src/app/language-observer.tsx +13 -0
  9. package/src/app/pane-runtime/index.tsx +2 -2
  10. package/src/app/pane-runtime/pane-settings-runtime.tsx +2 -2
  11. package/src/app/pane-runtime/pane-template-runtime.ts +2 -2
  12. package/src/app/pane-runtime/plugin-bindings.ts +2 -2
  13. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -2
  14. package/src/app/runtime/broker-import.ts +2 -2
  15. package/src/app/runtime/plugin-bindings.ts +2 -2
  16. package/src/app/runtime/startup.ts +2 -2
  17. package/src/app.tsx +16 -7
  18. package/src/brokers/account-cache.ts +6 -6
  19. package/src/brokers/broker-ticker-sync.ts +3 -3
  20. package/src/brokers/remote-broker-adapter.ts +2 -2
  21. package/src/brokers/sync-broker-instance.ts +5 -6
  22. package/src/cli/commands/api.ts +3 -6
  23. package/src/cli/commands/automation.ts +7 -15
  24. package/src/cli/commands/market.ts +26 -61
  25. package/src/cli/commands/overview.ts +34 -45
  26. package/src/cli/commands/search.ts +25 -27
  27. package/src/cli/commands/system.ts +27 -57
  28. package/src/cli/commands/ticker.ts +62 -63
  29. package/src/cli/context.test.ts +63 -0
  30. package/src/cli/context.ts +15 -1
  31. package/src/cli/index.ts +1 -1
  32. package/src/cli/pane-functions/data.ts +2 -19
  33. package/src/cli/pane-functions/index.ts +26 -25
  34. package/src/cli/pane-functions/report.ts +1 -1
  35. package/src/cli/pane-functions/screenshot.ts +3 -3
  36. package/src/cli/scoped-context.ts +41 -0
  37. package/src/components/chart/composite/composite-chart.test.tsx +615 -18
  38. package/src/components/chart/composite/composite-chart.tsx +179 -79
  39. package/src/components/chart/composite/format.test.ts +20 -7
  40. package/src/components/chart/composite/index.ts +0 -21
  41. package/src/components/chart/composite/interactions.test.ts +90 -3
  42. package/src/components/chart/composite/interactions.ts +189 -24
  43. package/src/components/chart/composite/price-series.ts +0 -2
  44. package/src/components/chart/composite/rasterizer.ts +10 -9
  45. package/src/components/chart/composite/renderers.test.ts +108 -2
  46. package/src/components/chart/composite/scene.test.ts +36 -0
  47. package/src/components/chart/composite/scene.ts +5 -2
  48. package/src/components/chart/composite/text-renderer.ts +37 -25
  49. package/src/components/chart/composite/time-axis.test.ts +243 -0
  50. package/src/components/chart/composite/time-axis.ts +608 -0
  51. package/src/components/chart/composite/types.ts +12 -1
  52. package/src/components/chart/core/data.ts +2 -159
  53. package/src/components/chart/core/date-window.ts +6 -225
  54. package/src/components/chart/core/palette.ts +1 -12
  55. package/src/components/chart/core/pointer.ts +0 -142
  56. package/src/components/chart/core/types.ts +1 -58
  57. package/src/components/chart/cursor-motion.ts +2 -4
  58. package/src/components/chart/indicators/types.ts +0 -9
  59. package/src/components/chart/market-session.ts +0 -11
  60. package/src/components/chart/native/kitty/protocol.ts +1 -1
  61. package/src/components/chart/native/raster/layout.ts +3 -10
  62. package/src/components/chart/static/chart/axis-overlays.test.tsx +64 -0
  63. package/src/components/chart/static/chart/axis-overlays.tsx +59 -14
  64. package/src/components/chart/static/chart/surface.tsx +1 -2
  65. package/src/components/command-bar/action-runtime.ts +2 -2
  66. package/src/components/command-bar/commands/direct/index.ts +1 -1
  67. package/src/components/command-bar/panel/body.tsx +2 -2
  68. package/src/components/command-bar/panel/index.tsx +11 -9
  69. package/src/components/command-bar/routes/ticker-search/actions.ts +2 -2
  70. package/src/components/command-bar/surface/index.tsx +2 -2
  71. package/src/components/command-bar/workflow/body.tsx +3 -3
  72. package/src/components/command-bar/workflow/collection-actions.ts +2 -2
  73. package/src/components/command-bar/workflow/coordinator.ts +2 -2
  74. package/src/components/command-bar/workflow/field-row.tsx +25 -11
  75. package/src/components/command-bar/workflow/tickers.ts +2 -2
  76. package/src/components/feed-data-table/stack-view.test.tsx +28 -36
  77. package/src/components/feed-data-table/stack-view.tsx +3 -1
  78. package/src/components/layout/detached-pane-shell.tsx +6 -6
  79. package/src/components/layout/header.tsx +13 -9
  80. package/src/components/layout/pane/content.test.tsx +42 -3
  81. package/src/components/layout/pane/content.tsx +3 -1
  82. package/src/components/layout/pane/footer/index.test.tsx +45 -2
  83. package/src/components/layout/pane/footer/registration.tsx +3 -1
  84. package/src/components/layout/shell/index.tsx +1 -2
  85. package/src/components/layout/shell/native/surfaces.ts +1 -1
  86. package/src/components/layout/shell/native/window-state.ts +1 -1
  87. package/src/components/layout/status-bar.tsx +12 -8
  88. package/src/components/layout/window-controls.tsx +8 -6
  89. package/src/components/onboarding/onboarding-wizard.tsx +3 -1
  90. package/src/components/pane-settings-dialog/desktop.tsx +0 -2
  91. package/src/components/pane-settings-dialog.tsx +0 -2
  92. package/src/components/pane-template-wizard.tsx +10 -9
  93. package/src/components/price-performance.tsx +2 -113
  94. package/src/components/price-sparkline/model.ts +15 -17
  95. package/src/components/price-sparkline/view.tsx +8 -7
  96. package/src/components/speedometer-gauge/desktop.tsx +2 -1
  97. package/src/components/speedometer-gauge/index.tsx +0 -2
  98. package/src/components/speedometer-gauge/terminal.tsx +24 -5
  99. package/src/components/ticker/badge/index.tsx +1 -18
  100. package/src/components/ui/data-table/opentui/index.tsx +3 -3
  101. package/src/components/ui/frame.tsx +1 -2
  102. package/src/components/ui/page-stack-view.test.tsx +126 -0
  103. package/src/components/ui/page-stack-view.tsx +24 -12
  104. package/src/components/ui/tabs.tsx +7 -3
  105. package/src/components/ui/ui.test.tsx +23 -2
  106. package/src/components/use-external-link-footer.ts +4 -2
  107. package/src/core/app-service-ports.ts +46 -0
  108. package/src/core/app-services.test.ts +66 -0
  109. package/src/core/app-services.ts +17 -15
  110. package/src/data/config/chart-settings.ts +3 -10
  111. package/src/data/config/store/index.test.ts +99 -2
  112. package/src/data/config/store/migrations.ts +278 -0
  113. package/src/data/config/store/normalize.ts +44 -186
  114. package/src/data/resource-store.ts +73 -12
  115. package/src/data/sqlite/cache.test.ts +104 -15
  116. package/src/i18n/index.test.ts +17 -0
  117. package/src/i18n/index.ts +23 -6
  118. package/src/i18n/ja.ts +13 -0
  119. package/src/i18n/ko.ts +13 -0
  120. package/src/i18n/react.ts +6 -0
  121. package/src/i18n/zh-cn.ts +13 -0
  122. package/src/i18n/zh-tw.ts +13 -0
  123. package/src/market-data/coordinator/auxiliary.ts +7 -3
  124. package/src/market-data/coordinator/chart.ts +1 -1
  125. package/src/market-data/coordinator/index.test.ts +26 -0
  126. package/src/market-data/coordinator/index.ts +11 -5
  127. package/src/market-data/coordinator/quotes.ts +4 -40
  128. package/src/market-data/coordinator/subscriptions.test.ts +89 -1
  129. package/src/market-data/hooks.tsx +47 -1
  130. package/src/market-data/market/status.ts +6 -6
  131. package/src/market-data/performance.ts +1 -1
  132. package/src/market-data/quote-subscription-target.test.ts +37 -0
  133. package/src/market-data/quote-subscription-target.ts +60 -0
  134. package/src/market-data/quotes/freshness.ts +1 -0
  135. package/src/market-data/quotes/resolution.ts +4 -0
  136. package/src/market-data/request-types.ts +3 -3
  137. package/src/news/aggregator.test.ts +97 -1
  138. package/src/news/aggregator.ts +100 -57
  139. package/src/news/hooks.test.tsx +64 -0
  140. package/src/news/hooks.ts +18 -25
  141. package/src/plugins/builtin/account-management/footer.ts +4 -2
  142. package/src/plugins/builtin/account-management/pane.tsx +9 -4
  143. package/src/plugins/builtin/ai/agent-history.ts +11 -7
  144. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +0 -5
  145. package/src/plugins/builtin/ai/ask-ai.test.tsx +3 -4
  146. package/src/plugins/builtin/ai/index.test.ts +2 -2
  147. package/src/plugins/builtin/ai/index.tsx +10 -11
  148. package/src/plugins/builtin/ai/pane-settings.ts +1 -1
  149. package/src/plugins/builtin/ai/pi/runtime.ts +6 -5
  150. package/src/plugins/builtin/ai/providers.ts +1 -9
  151. package/src/plugins/builtin/ai/runner.ts +0 -6
  152. package/src/plugins/builtin/ai/screener/footer.ts +4 -1
  153. package/src/plugins/builtin/ai/screener/pane.test.tsx +9 -9
  154. package/src/plugins/builtin/ai/workspace/model.ts +2 -27
  155. package/src/plugins/builtin/ai/workspace/pane.tsx +2 -2
  156. package/src/plugins/builtin/analytics/metrics.ts +1 -7
  157. package/src/plugins/builtin/broker-manager/detail.tsx +3 -2
  158. package/src/plugins/builtin/broker-manager/index.tsx +4 -2
  159. package/src/plugins/builtin/chart-composer/editor/controller.ts +643 -0
  160. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +134 -0
  161. package/src/plugins/builtin/chart-composer/editor/model.test.ts +85 -0
  162. package/src/plugins/builtin/chart-composer/editor/model.ts +292 -0
  163. package/src/plugins/builtin/chart-composer/editor/opentui-view.tsx +108 -0
  164. package/src/plugins/builtin/chart-composer/editor.tsx +65 -947
  165. package/src/plugins/builtin/chart-composer/index.tsx +0 -6
  166. package/src/plugins/builtin/chart-composer/pane.tsx +69 -36
  167. package/src/plugins/builtin/chart-composer/presets.ts +1 -19
  168. package/src/plugins/builtin/chart-composer/settings.ts +1 -1
  169. package/src/plugins/builtin/chat/content/composer-runtime.ts +1 -1
  170. package/src/plugins/builtin/chat/content/shortcuts.ts +0 -1
  171. package/src/plugins/builtin/chat/controller/fetch.ts +8 -2
  172. package/src/plugins/builtin/chat/controller/persistence.ts +2 -0
  173. package/src/plugins/builtin/chat/controller/session.test.ts +25 -0
  174. package/src/plugins/builtin/chat/controller/session.ts +2 -0
  175. package/src/plugins/builtin/chat/controller/state.ts +7 -0
  176. package/src/plugins/builtin/chat/controller.test.ts +73 -20
  177. package/src/plugins/builtin/chat/message/desktop.tsx +2 -0
  178. package/src/plugins/builtin/cloud-tweets/footer.ts +9 -28
  179. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -5
  180. package/src/plugins/builtin/cloud-tweets/table.tsx +12 -17
  181. package/src/plugins/builtin/correlation/compute.ts +4 -10
  182. package/src/plugins/builtin/econ/index.tsx +17 -19
  183. package/src/plugins/builtin/holders/types.ts +0 -1
  184. package/src/plugins/builtin/insider/index.tsx +6 -31
  185. package/src/plugins/builtin/news/index.tsx +5 -7
  186. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +6 -8
  187. package/src/plugins/builtin/news/wire/news/footer.ts +10 -11
  188. package/src/plugins/builtin/news/wire/read-state.test.ts +7 -0
  189. package/src/plugins/builtin/news/wire/read-state.ts +18 -28
  190. package/src/plugins/builtin/news/wire/rss/parser.ts +1 -11
  191. package/src/plugins/builtin/options/footer.test.ts +115 -0
  192. package/src/plugins/builtin/options/footer.ts +110 -0
  193. package/src/plugins/builtin/options/live-quotes.test.ts +238 -0
  194. package/src/plugins/builtin/options/live-quotes.ts +147 -0
  195. package/src/plugins/builtin/options/view.test.tsx +71 -1
  196. package/src/plugins/builtin/options/view.tsx +39 -24
  197. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +103 -117
  198. package/src/plugins/builtin/portfolio-list/cli/render.ts +11 -5
  199. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +120 -135
  200. package/src/plugins/builtin/portfolio-list/header.tsx +9 -4
  201. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +6 -1
  202. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +4 -2
  203. package/src/plugins/builtin/research/corporate-actions-pane.tsx +24 -52
  204. package/src/plugins/builtin/sec/filing-content.test.ts +41 -0
  205. package/src/plugins/builtin/sec/filing-content.ts +94 -0
  206. package/src/plugins/builtin/sec/index.tsx +21 -104
  207. package/src/plugins/builtin/sectors/sector-model.ts +1 -7
  208. package/src/plugins/builtin/shared/cloud-upgrade.ts +1 -0
  209. package/src/plugins/builtin/shared/pane-footer.ts +82 -0
  210. package/src/plugins/builtin/shared/read-state.ts +64 -3
  211. package/src/plugins/builtin/substack/read-state.ts +16 -41
  212. package/src/plugins/builtin/thirteenf/pane.tsx +24 -73
  213. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +10 -10
  214. package/src/plugins/builtin/yield-curve/index.tsx +11 -9
  215. package/src/plugins/ibkr/dialogs.tsx +3 -3
  216. package/src/plugins/ibkr/gateway/fundamentals.ts +1 -1
  217. package/src/plugins/ibkr/gateway/history-requests.ts +2 -2
  218. package/src/plugins/ibkr/gateway/history.ts +2 -2
  219. package/src/plugins/ibkr/gateway/selection.ts +2 -2
  220. package/src/plugins/ibkr/gateway/service/index.ts +2 -2
  221. package/src/plugins/ibkr/gateway/service/native.ts +2 -2
  222. package/src/plugins/plugin-persistence.ts +3 -4
  223. package/src/plugins/prediction-markets/controller/keyboard.ts +1 -1
  224. package/src/plugins/prediction-markets/controller/live-updates.ts +6 -5
  225. package/src/plugins/prediction-markets/detail/book.tsx +2 -2
  226. package/src/plugins/prediction-markets/detail/outcomes.tsx +3 -1
  227. package/src/plugins/prediction-markets/detail/pane.tsx +3 -1
  228. package/src/plugins/prediction-markets/detail/trades.tsx +3 -3
  229. package/src/plugins/registry/context.ts +3 -4
  230. package/src/plugins/registry/index.ts +5 -6
  231. package/src/react/input.test.ts +126 -3
  232. package/src/react/input.ts +120 -1
  233. package/src/remote/app-host.tsx +2 -13
  234. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +44 -60
  235. package/src/renderers/electrobun/bun/desktop/capability-bridge.ts +15 -13
  236. package/src/renderers/electrobun/bun/desktop/host-requests.ts +26 -22
  237. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -1
  238. package/src/renderers/electrobun/bun/desktop/initialization.ts +11 -6
  239. package/src/renderers/electrobun/bun/desktop/media.ts +5 -3
  240. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +21 -22
  241. package/src/renderers/electrobun/bun/desktop/window-controls.ts +3 -1
  242. package/src/renderers/electrobun/bun/desktop/workspace/requests.ts +18 -18
  243. package/src/renderers/electrobun/bun/index.ts +123 -83
  244. package/src/renderers/electrobun/shared/protocol.ts +146 -7
  245. package/src/renderers/electrobun/view/app-services.ts +5 -8
  246. package/src/renderers/electrobun/view/backend-rpc.ts +18 -8
  247. package/src/renderers/electrobun/view/broker-remote-client.ts +2 -2
  248. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +5 -22
  249. package/src/renderers/electrobun/view/config-host.ts +1 -1
  250. package/src/renderers/electrobun/view/context-menu.ts +1 -17
  251. package/src/renderers/electrobun/view/data-table/index.tsx +0 -1
  252. package/src/renderers/electrobun/view/data-table/row.tsx +0 -4
  253. package/src/renderers/electrobun/view/desktop/control-styles.ts +37 -34
  254. package/src/renderers/electrobun/view/desktop/controls.tsx +36 -33
  255. package/src/renderers/electrobun/view/desktop/list-view.tsx +15 -12
  256. package/src/renderers/electrobun/view/desktop/popover.tsx +2 -3
  257. package/src/renderers/electrobun/view/dialog-host.tsx +2 -3
  258. package/src/renderers/electrobun/view/fatal-screen.tsx +0 -1
  259. package/src/renderers/electrobun/view/host/box.tsx +0 -1
  260. package/src/renderers/electrobun/view/host/chart-surface.tsx +0 -1
  261. package/src/renderers/electrobun/view/host/input.tsx +0 -1
  262. package/src/renderers/electrobun/view/host/media-surface.tsx +0 -1
  263. package/src/renderers/electrobun/view/host/native.ts +0 -1
  264. package/src/renderers/electrobun/view/host/scroll-box.tsx +0 -1
  265. package/src/renderers/electrobun/view/host/tabs.tsx +0 -1
  266. package/src/renderers/electrobun/view/host/text.tsx +0 -1
  267. package/src/renderers/electrobun/view/http-fetch.ts +3 -11
  268. package/src/renderers/electrobun/view/input-host.tsx +10 -53
  269. package/src/renderers/electrobun/view/key-event.ts +0 -1
  270. package/src/renderers/electrobun/view/main.tsx +3 -3
  271. package/src/renderers/electrobun/view/native-renderer.ts +0 -1
  272. package/src/renderers/electrobun/view/native-stubs/backend-rpc.ts +13 -1
  273. package/src/renderers/electrobun/view/remote/asset-data-client.ts +6 -44
  274. package/src/renderers/electrobun/view/remote/persistence.ts +6 -2
  275. package/src/renderers/electrobun/view/remote/quote-subscription-registry.test.ts +86 -0
  276. package/src/renderers/electrobun/view/remote/quote-subscription-registry.ts +72 -0
  277. package/src/renderers/electrobun/view/remote/ticker-repository.ts +2 -2
  278. package/src/renderers/electrobun/view/toast-host.tsx +0 -1
  279. package/src/renderers/electrobun/view/ui-host.tsx +1 -2
  280. package/src/renderers/electrobun/view/update-host.ts +1 -1
  281. package/src/renderers/opentui/chart-surface.tsx +2 -1
  282. package/src/renderers/opentui/dialog-host.tsx +20 -4
  283. package/src/renderers/opentui/image/loader.ts +12 -2
  284. package/src/renderers/opentui/input-host.tsx +7 -49
  285. package/src/renderers/opentui/start.tsx +2 -0
  286. package/src/sources/gloomberb-cloud/history-quality.ts +44 -0
  287. package/src/sources/gloomberb-cloud/index.test.ts +173 -0
  288. package/src/sources/gloomberb-cloud/index.ts +42 -20
  289. package/src/sources/gloomberb-cloud/normalizers.ts +9 -3
  290. package/src/sources/provider-router/cache.ts +1 -1
  291. package/src/sources/provider-router/document-routes.ts +10 -5
  292. package/src/sources/provider-router/financials.test.ts +14 -0
  293. package/src/sources/provider-router/financials.ts +6 -1
  294. package/src/sources/provider-router/history.test.ts +163 -1
  295. package/src/sources/provider-router/history.ts +278 -347
  296. package/src/sources/provider-router/index.ts +2 -2
  297. package/src/sources/provider-router/routing.test.ts +39 -0
  298. package/src/sources/provider-router/routing.ts +24 -11
  299. package/src/sources/provider-router/supplemental.ts +37 -17
  300. package/src/sources/sec-edgar/content.ts +2 -12
  301. package/src/sources/sec-edgar.ts +2 -13
  302. package/src/sources/yahoo-finance/history.ts +2 -2
  303. package/src/sources/yahoo-finance/options.ts +5 -0
  304. package/src/sources/yahoo-finance.ts +2 -2
  305. package/src/state/app/bootstrap.ts +2 -2
  306. package/src/state/app/context/index.tsx +2 -2
  307. package/src/state/hooks/quote-streaming.ts +66 -1
  308. package/src/state/hooks/session-snapshot.ts +2 -2
  309. package/src/state/selectors.ts +0 -9
  310. package/src/sync/controller.test.ts +98 -0
  311. package/src/sync/controller.ts +29 -5
  312. package/src/sync/react.ts +2 -2
  313. package/src/sync/types.ts +2 -2
  314. package/src/theme/colors.test.ts +10 -0
  315. package/src/theme/colors.ts +75 -78
  316. package/src/theme/theme-context.test.tsx +67 -0
  317. package/src/theme/theme-context.tsx +9 -5
  318. package/src/tickers/open-target.ts +2 -2
  319. package/src/tickers/search/upsert.ts +2 -2
  320. package/src/time-series/alignment.ts +0 -7
  321. package/src/time-series/chart-data.ts +145 -0
  322. package/src/time-series/date-window.ts +44 -0
  323. package/src/time-series/history-window.ts +21 -0
  324. package/src/time-series/hooks.test.ts +59 -0
  325. package/src/time-series/hooks.ts +33 -12
  326. package/src/time-series/market-resolution.ts +2 -2
  327. package/src/time-series/market.ts +1 -1
  328. package/src/time-series/range.ts +9 -0
  329. package/src/{components/chart/core → time-series}/resolution.ts +3 -102
  330. package/src/time-series/resolve.test.ts +203 -1
  331. package/src/time-series/resolve.ts +96 -18
  332. package/src/time-series/spec-studies.test.ts +15 -0
  333. package/src/time-series/spec.ts +2 -2
  334. package/src/time-series/studies.ts +5 -2
  335. package/src/time-series/types.ts +3 -1
  336. package/src/time-series/use-chart-resolution.test.tsx +358 -0
  337. package/src/time-series/use-chart-resolution.ts +46 -4
  338. package/src/types/broker.ts +2 -2
  339. package/src/types/data-provider.ts +3 -3
  340. package/src/types/financials.ts +12 -0
  341. package/src/types/plugin.ts +2 -2
  342. package/src/ui/context-menu.tsx +3 -2
  343. package/src/ui/dialog.tsx +2 -2
  344. package/src/utils/html-entities.ts +3 -5
  345. package/src/utils/main-thread-monitor.ts +1 -1
  346. package/src/utils/price-history.ts +5 -5
  347. package/src/version.ts +3 -1
  348. package/src/components/chart/core/viewport.test.ts +0 -8
  349. package/src/components/chart/core/viewport.ts +0 -24
  350. package/src/time-series/index.ts +0 -14
  351. /package/src/{components/chart/core → time-series}/resolution.test.ts +0 -0
@@ -64,8 +64,11 @@ import {
64
64
  import {
65
65
  renderCompositeAxisText,
66
66
  renderCompositePanelText,
67
- renderCompositeTimeAxis,
68
67
  } from "./text-renderer";
68
+ import {
69
+ buildCompositeTimeAxisLayout,
70
+ buildCompositeViewportTimeAxisLayout,
71
+ } from "./time-axis";
69
72
  import type {
70
73
  CompositeChartColors,
71
74
  CompositeChartProps,
@@ -78,6 +81,12 @@ import type {
78
81
  const DESKTOP_BITMAP_RESIZE_DEBOUNCE_MS = 32;
79
82
  const LEGEND_WHEEL_DELTA_PER_CELL = 8;
80
83
 
84
+ function isVerticalWheelDirection(
85
+ direction: "up" | "down" | "left" | "right",
86
+ ): direction is "up" | "down" {
87
+ return direction === "up" || direction === "down";
88
+ }
89
+
81
90
  function renderPanelBitmap(
82
91
  panel: CompositePanelScene,
83
92
  bitmapSize: StaticChartBitmapSize,
@@ -400,21 +409,13 @@ function CompositePanelSurface({
400
409
  consumeChartMouseEvent(event);
401
410
  const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
402
411
  const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
403
- if (event.modifiers.ctrl && pointer) {
404
- const zoomIn = direction === "up" || direction === "left";
412
+ if (event.modifiers.ctrl && pointer && isVerticalWheelDirection(direction)) {
413
+ const zoomIn = direction === "up";
405
414
  const magnitude = Math.min(Math.max(Math.abs(event.scroll?.delta ?? 1), 1), 8);
406
415
  const pointerRatio = pointer.cellX / Math.max(plotWidth - 1, 1);
407
- const anchorDate = resolveCompositeCursorDate(scene, pointer.cellX);
408
- const viewportSpan = Math.max(viewport.end.getTime() - viewport.start.getTime(), 1);
409
- const viewportAnchorRatio = anchorDate
410
- ? Math.max(0, Math.min(
411
- 1,
412
- (anchorDate.getTime() - viewport.start.getTime()) / viewportSpan,
413
- ))
414
- : pointerRatio;
415
416
  onZoomViewport(
416
417
  zoomIn ? 1 + magnitude * 0.04 : 1 / (1 + magnitude * 0.04),
417
- viewportAnchorRatio,
418
+ pointerRatio,
418
419
  );
419
420
  updateCursor(event);
420
421
  return;
@@ -734,12 +735,14 @@ function CompositeLegend({
734
735
  export function CompositeChart({
735
736
  series,
736
737
  legendSeries,
738
+ timelineSeries,
737
739
  panels,
738
740
  width,
739
741
  height,
740
742
  focused = false,
741
743
  cursorDate,
742
744
  viewport,
745
+ viewportResetKey,
743
746
  colors,
744
747
  interactive = true,
745
748
  axisWidth = 9,
@@ -762,6 +765,12 @@ export function CompositeChart({
762
765
  const totalWidth = Math.max(1, Math.floor(width));
763
766
  const totalHeight = Math.max(1, Math.floor(height));
764
767
  const visibleSeries = useMemo(() => series.filter((entry) => entry.points.length > 0), [series]);
768
+ const marketTimelineSeries = useMemo(() => {
769
+ const supplied = timelineSeries?.filter((entry) => entry.points.length > 0) ?? [];
770
+ return supplied.some((entry) => entry.timeBasis?.kind === "market")
771
+ ? supplied
772
+ : visibleSeries;
773
+ }, [timelineSeries, visibleSeries]);
765
774
  const visibleLegendSeries = useMemo(
766
775
  () => (legendSeries ?? visibleSeries).filter((entry) => entry.points.length > 0),
767
776
  [legendSeries, visibleSeries],
@@ -777,9 +786,23 @@ export function CompositeChart({
777
786
  : Math.min(current, visibleLegendSeries.length - 1)
778
787
  ));
779
788
  }, [visibleLegendSeries.length]);
789
+ const previousAuthoredViewportRef = useRef<CompositeViewportRange | null>(viewport ?? null);
790
+ const previousViewportResetKeyRef = useRef(viewportResetKey);
791
+ const [interactionViewport, setInteractionViewport] = useState<CompositeViewportRange | null>(null);
792
+ const hasViewportResetKey = viewportResetKey !== undefined
793
+ || previousViewportResetKeyRef.current !== undefined;
794
+ const authoredViewportChanged = hasViewportResetKey
795
+ ? previousViewportResetKeyRef.current !== viewportResetKey
796
+ : shouldResetCompositeViewport(
797
+ previousAuthoredViewportRef.current,
798
+ viewport ?? null,
799
+ );
800
+ const navigationAnchorViewport = authoredViewportChanged
801
+ ? viewport
802
+ : interactionViewport ?? viewport;
780
803
  const navigationBounds = useMemo(
781
- () => resolveCompositeNavigationBounds(visibleSeries, viewport),
782
- [viewport, visibleSeries],
804
+ () => resolveCompositeNavigationBounds(visibleSeries, navigationAnchorViewport),
805
+ [navigationAnchorViewport, visibleSeries],
783
806
  );
784
807
  const initialViewport = useMemo(() => (
785
808
  navigationBounds
@@ -788,82 +811,91 @@ export function CompositeChart({
788
811
  : navigationBounds
789
812
  : null
790
813
  ), [navigationBounds, viewport]);
791
- const previousNavigationBoundsRef = useRef<CompositeViewportRange | null>(navigationBounds);
792
- const previousInitialViewportRef = useRef<CompositeViewportRange | null>(initialViewport);
793
- const [interactionViewport, setInteractionViewport] = useState<CompositeViewportRange | null>(null);
794
- const navigationBoundsChanged = shouldResetCompositeViewport(
795
- previousNavigationBoundsRef.current,
796
- navigationBounds,
797
- );
798
- const initialViewportChanged = shouldResetCompositeViewport(
799
- previousInitialViewportRef.current,
800
- initialViewport,
801
- );
802
- const interactionViewportStillValid = !!interactionViewport
803
- && !!navigationBounds
804
- && sameCompositeViewport(
805
- clampCompositeViewport(interactionViewport, navigationBounds),
806
- interactionViewport,
807
- );
808
- const shouldResetInteractionViewport = initialViewportChanged
809
- || (navigationBoundsChanged && !interactionViewportStillValid);
810
- const currentInteractionViewport = shouldResetInteractionViewport ? null : interactionViewport;
814
+ const viewportSeriesKey = visibleLegendSeries
815
+ .map((entry) => `${entry.id}:${entry.label}`)
816
+ .join("|");
817
+ const clampedInteractionViewport = interactionViewport && navigationBounds
818
+ ? clampCompositeViewport(interactionViewport, navigationBounds)
819
+ : interactionViewport;
820
+ const interactionViewportNeedsSync = !!interactionViewport
821
+ && !!clampedInteractionViewport
822
+ && !sameCompositeViewport(interactionViewport, clampedInteractionViewport);
823
+ const currentInteractionViewport = authoredViewportChanged
824
+ ? null
825
+ : clampedInteractionViewport;
811
826
  const effectiveViewport = navigationBounds
812
827
  ? currentInteractionViewport
813
828
  ? clampCompositeViewport(currentInteractionViewport, navigationBounds)
814
829
  : initialViewport
815
830
  : null;
816
- const effectiveViewportStart = effectiveViewport?.start.getTime() ?? null;
817
- const effectiveViewportEnd = effectiveViewport?.end.getTime() ?? null;
818
- const viewportSeriesKey = visibleLegendSeries
819
- .map((entry) => `${entry.id}:${entry.label}`)
820
- .join("|");
831
+ const interactionViewportStart = currentInteractionViewport?.start.getTime() ?? null;
832
+ const interactionViewportEnd = currentInteractionViewport?.end.getTime() ?? null;
821
833
  const lastReportedViewportRef = useRef<string | null>(null);
834
+ const viewportInteractionRef = useRef<"pan" | "reset" | "sync" | "zoom">("reset");
822
835
  useEffect(() => {
823
836
  if (!onViewportChange) return;
824
- const viewportKey = effectiveViewportStart === null || effectiveViewportEnd === null
837
+ if (interactionViewportNeedsSync) return;
838
+ const interactionKey = interactionViewportStart === null || interactionViewportEnd === null
825
839
  ? "none"
826
- : `${effectiveViewportStart}:${effectiveViewportEnd}`;
827
- const key = `${viewportSeriesKey}|${viewportKey}`;
840
+ : `${interactionViewportStart}:${interactionViewportEnd}`;
841
+ const key = `${viewportSeriesKey}|${interactionKey}`;
842
+ // The callback drives adaptive data loading. Seed it from the authored
843
+ // viewport without echoing that controlled value back into the loader.
844
+ if (lastReportedViewportRef.current === null) {
845
+ lastReportedViewportRef.current = key;
846
+ return;
847
+ }
828
848
  if (lastReportedViewportRef.current === key) return;
829
849
  lastReportedViewportRef.current = key;
830
850
  onViewportChange(
831
- effectiveViewportStart === null || effectiveViewportEnd === null
851
+ interactionViewportStart === null || interactionViewportEnd === null
832
852
  ? null
833
853
  : {
834
- start: new Date(effectiveViewportStart),
835
- end: new Date(effectiveViewportEnd),
854
+ start: new Date(interactionViewportStart),
855
+ end: new Date(interactionViewportEnd),
836
856
  },
857
+ viewportInteractionRef.current,
837
858
  );
838
- }, [effectiveViewportEnd, effectiveViewportStart, onViewportChange, viewportSeriesKey]);
859
+ }, [
860
+ interactionViewportEnd,
861
+ interactionViewportNeedsSync,
862
+ interactionViewportStart,
863
+ onViewportChange,
864
+ viewportSeriesKey,
865
+ ]);
839
866
  const minimumViewportSpanMs = useMemo(
840
867
  () => navigationBounds ? resolveCompositeMinimumSpanMs(visibleSeries, navigationBounds) : 1,
841
868
  [navigationBounds, visibleSeries],
842
869
  );
843
870
 
844
871
  useEffect(() => {
845
- const previousBounds = previousNavigationBoundsRef.current;
846
- const previousInitial = previousInitialViewportRef.current;
847
- previousNavigationBoundsRef.current = navigationBounds;
848
- previousInitialViewportRef.current = initialViewport;
872
+ previousAuthoredViewportRef.current = viewport ?? null;
873
+ previousViewportResetKeyRef.current = viewportResetKey;
849
874
  if (
850
- shouldResetCompositeViewport(previousInitial, initialViewport)
851
- || (
852
- shouldResetCompositeViewport(previousBounds, navigationBounds)
853
- && interactionViewport
854
- && navigationBounds
855
- && !sameCompositeViewport(
856
- clampCompositeViewport(interactionViewport, navigationBounds),
857
- interactionViewport,
858
- )
859
- )
875
+ authoredViewportChanged
876
+ || (interactionViewport && !navigationBounds)
860
877
  ) {
878
+ viewportInteractionRef.current = "reset";
861
879
  setInteractionViewport(null);
880
+ return;
862
881
  }
863
- }, [initialViewport, interactionViewport, navigationBounds]);
882
+ if (interactionViewportNeedsSync && clampedInteractionViewport) {
883
+ viewportInteractionRef.current = "sync";
884
+ setInteractionViewport(clampedInteractionViewport);
885
+ }
886
+ }, [
887
+ authoredViewportChanged,
888
+ clampedInteractionViewport,
889
+ interactionViewport,
890
+ interactionViewportNeedsSync,
891
+ navigationBounds,
892
+ viewport,
893
+ viewportResetKey,
894
+ ]);
864
895
 
865
896
  const zoomViewport = useCallback((zoomFactor: number, anchorRatio = 1) => {
866
897
  if (!navigationBounds || !initialViewport) return;
898
+ viewportInteractionRef.current = "zoom";
867
899
  setInteractionViewport((current) => {
868
900
  const base = current ?? initialViewport;
869
901
  const next = zoomCompositeViewport(
@@ -872,22 +904,30 @@ export function CompositeChart({
872
904
  zoomFactor,
873
905
  anchorRatio,
874
906
  minimumViewportSpanMs,
907
+ marketTimelineSeries,
875
908
  );
909
+ if (sameCompositeViewport(next, base)) return current;
876
910
  return sameCompositeViewport(next, initialViewport) ? null : next;
877
911
  });
878
- }, [initialViewport, minimumViewportSpanMs, navigationBounds]);
912
+ }, [initialViewport, marketTimelineSeries, minimumViewportSpanMs, navigationBounds]);
879
913
  const panViewport = useCallback((
880
914
  shiftRatio: number,
881
915
  fromViewport?: CompositeViewportRange,
882
916
  ) => {
883
917
  if (!navigationBounds || !initialViewport) return;
918
+ viewportInteractionRef.current = "pan";
884
919
  setInteractionViewport((current) => {
885
920
  const base = fromViewport ?? current ?? initialViewport;
886
- const next = panCompositeViewport(base, navigationBounds, shiftRatio);
921
+ const next = panCompositeViewport(base, navigationBounds, shiftRatio, marketTimelineSeries);
922
+ if (sameCompositeViewport(next, base)) {
923
+ if (!fromViewport) return current;
924
+ return sameCompositeViewport(base, initialViewport) ? null : base;
925
+ }
887
926
  return sameCompositeViewport(next, initialViewport) ? null : next;
888
927
  });
889
- }, [initialViewport, navigationBounds]);
928
+ }, [initialViewport, marketTimelineSeries, navigationBounds]);
890
929
  const resetViewport = useCallback(() => {
930
+ viewportInteractionRef.current = "reset";
891
931
  setInteractionViewport(null);
892
932
  }, []);
893
933
  const hasLeftAxis = visibleSeries.some((entry) => entry.axis === "left");
@@ -917,8 +957,8 @@ export function CompositeChart({
917
957
  width: 1,
918
958
  height: Math.max(panelCount, 1),
919
959
  viewport: effectiveViewport ?? undefined,
920
- timelineSeries: visibleLegendSeries,
921
- }), [effectiveViewport, panelCount, panels, visibleLegendSeries, visibleSeries]);
960
+ timelineSeries: marketTimelineSeries,
961
+ }), [effectiveViewport, marketTimelineSeries, panelCount, panels, visibleSeries]);
922
962
  const layoutPanels = useMemo<CompositePanelScene[] | null>(() => {
923
963
  if (!projectedScene) return null;
924
964
  const panelSpecById = new Map(panels.map((panel) => [panel.id, panel] as const));
@@ -955,6 +995,30 @@ export function CompositeChart({
955
995
  )
956
996
  : null
957
997
  ), [baseScene, normalizedCursorTimestamp]);
998
+ const handleEmptyMouseScroll = useCallback((event: ChartMouseEvent) => {
999
+ const direction = event.scroll?.direction;
1000
+ if (!interactive || !navigationBounds || !direction) return;
1001
+ onActivate?.();
1002
+ consumeChartMouseEvent(event);
1003
+ if (event.modifiers.ctrl && isVerticalWheelDirection(direction)) {
1004
+ const zoomIn = direction === "up";
1005
+ if (!zoomIn) {
1006
+ resetViewport();
1007
+ return;
1008
+ }
1009
+ const magnitude = Math.min(Math.max(Math.abs(event.scroll?.delta ?? 1), 1), 8);
1010
+ zoomViewport(1 + magnitude * 0.04, 0.5);
1011
+ return;
1012
+ }
1013
+ panViewport(resolveCompositeWheelPanRatio(direction, event.scroll?.delta));
1014
+ }, [
1015
+ interactive,
1016
+ navigationBounds,
1017
+ onActivate,
1018
+ panViewport,
1019
+ resetViewport,
1020
+ zoomViewport,
1021
+ ]);
958
1022
  const keyboardCursorDateRef = useRef<Date | null>(scene?.cursorDate ?? null);
959
1023
  keyboardCursorDateRef.current = scene?.cursorDate ?? null;
960
1024
  const lastCursorTimestampRef = useRef<number | null>(normalizedCursorTimestamp);
@@ -1049,9 +1113,25 @@ export function CompositeChart({
1049
1113
  }
1050
1114
  }, { enabled: focused && interactive });
1051
1115
 
1116
+ const leftPadding = leftAxisWidth + (leftAxisWidth ? axisGap : 0);
1117
+ const rightPadding = rightAxisWidth + (rightAxisWidth ? axisGap : 0);
1118
+ const timeAxisLayout = scene && showTimeAxis
1119
+ ? buildCompositeTimeAxisLayout(scene, plotWidth)
1120
+ : null;
1121
+ const emptyTimeAxisLayout = !scene && showTimeAxis && effectiveViewport
1122
+ ? buildCompositeViewportTimeAxisLayout(effectiveViewport, plotWidth)
1123
+ : null;
1124
+
1052
1125
  if (!scene) {
1126
+ const emptyPlotHeight = Math.max(0, totalHeight - legendRows - timeAxisRows);
1053
1127
  return (
1054
- <Box flexDirection="column" width={totalWidth} height={totalHeight} overflow="hidden">
1128
+ <Box
1129
+ flexDirection="column"
1130
+ width={totalWidth}
1131
+ height={totalHeight}
1132
+ overflow="hidden"
1133
+ data-gloom-role="composite-chart"
1134
+ >
1055
1135
  {legendRows > 0 && legendAccessory ? (
1056
1136
  <CompositeLegend
1057
1137
  scene={null}
@@ -1067,22 +1147,41 @@ export function CompositeChart({
1067
1147
  keyboardIndex={legendKeyboardIndex}
1068
1148
  />
1069
1149
  ) : null}
1070
- {totalHeight > legendRows ? (
1071
- <Box
1072
- width={totalWidth}
1073
- height={totalHeight - legendRows}
1074
- alignItems="center"
1075
- justifyContent="center"
1076
- >
1077
- <Text fg={resolvedColors.textDim}>{emptyMessage}</Text>
1150
+ {emptyPlotHeight > 0 ? (
1151
+ <Box flexDirection="row" width={totalWidth} height={emptyPlotHeight}>
1152
+ {leftPadding > 0 ? <Box width={leftPadding} /> : null}
1153
+ <ChartSurface
1154
+ width={plotWidth}
1155
+ height={emptyPlotHeight}
1156
+ alignItems="center"
1157
+ justifyContent="center"
1158
+ onMouseScroll={interactive && navigationBounds ? handleEmptyMouseScroll : undefined}
1159
+ cursor={interactive && navigationBounds ? "grab" : undefined}
1160
+ data-gloom-interactive={interactive && navigationBounds ? "true" : undefined}
1161
+ data-gloom-role="composite-chart-empty"
1162
+ data-gloom-label={emptyMessage}
1163
+ >
1164
+ <Text fg={resolvedColors.textDim}>{emptyMessage}</Text>
1165
+ </ChartSurface>
1166
+ {rightPadding > 0 ? <Box width={rightPadding} /> : null}
1167
+ </Box>
1168
+ ) : null}
1169
+ {emptyTimeAxisLayout ? (
1170
+ <Box flexDirection="row" width={totalWidth} height={1}>
1171
+ {leftPadding > 0 ? <Box width={leftPadding} /> : null}
1172
+ <StaticXAxisLabels
1173
+ labels={[emptyTimeAxisLayout.text]}
1174
+ positionedLabels={emptyTimeAxisLayout.ticks}
1175
+ width={plotWidth}
1176
+ color={resolvedColors.textDim}
1177
+ />
1178
+ {rightPadding > 0 ? <Box width={rightPadding} /> : null}
1078
1179
  </Box>
1079
1180
  ) : null}
1080
1181
  </Box>
1081
1182
  );
1082
1183
  }
1083
1184
 
1084
- const leftPadding = leftAxisWidth + (leftAxisWidth ? axisGap : 0);
1085
- const rightPadding = rightAxisWidth + (rightAxisWidth ? axisGap : 0);
1086
1185
  const timeAxisCursorColumn = scene.cursorXRatio === null
1087
1186
  ? null
1088
1187
  : scene.cursorXRatio * Math.max(plotWidth - 1, 0);
@@ -1127,11 +1226,12 @@ export function CompositeChart({
1127
1226
  onZoomViewport={zoomViewport}
1128
1227
  />
1129
1228
  ))}
1130
- {showTimeAxis ? (
1229
+ {timeAxisLayout ? (
1131
1230
  <Box flexDirection="row" width={totalWidth} height={1}>
1132
1231
  {leftPadding > 0 ? <Box width={leftPadding} /> : null}
1133
1232
  <StaticXAxisLabels
1134
- labels={[renderCompositeTimeAxis(scene, plotWidth)]}
1233
+ labels={[timeAxisLayout.text]}
1234
+ positionedLabels={timeAxisLayout.ticks}
1135
1235
  width={plotWidth}
1136
1236
  color={resolvedColors.textDim}
1137
1237
  cursorColumn={timeAxisCursorColumn}
@@ -8,7 +8,10 @@ import {
8
8
  formatCompositeTimeAxisDate,
9
9
  } from "./format";
10
10
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
11
- import { renderCompositeTimeAxis } from "./text-renderer";
11
+ import {
12
+ renderCompositeTimeAxis,
13
+ renderCompositeViewportTimeAxis,
14
+ } from "./text-renderer";
12
15
 
13
16
  function scene(start: string, end: string): CompositeChartScene {
14
17
  const startTime = Date.parse(start);
@@ -38,7 +41,7 @@ describe("composite chart timestamp formatting", () => {
38
41
  expect(formatCompositeTimeAxisDate(cursor, intraday.startTime, intraday.endTime))
39
42
  .toBe("12:05 UTC");
40
43
  expect(renderCompositeTimeAxis(intraday, 60)).toContain("09:30 UTC");
41
- expect(renderCompositeTimeAxis(intraday, 60)).toContain("12:45 UTC");
44
+ expect(renderCompositeTimeAxis(intraday, 60)).toContain("12:00");
42
45
  expect(renderCompositeTimeAxis(intraday, 60)).toContain("16:00 UTC");
43
46
  });
44
47
 
@@ -51,9 +54,9 @@ describe("composite chart timestamp formatting", () => {
51
54
  overnight.endTime,
52
55
  )).toBe("01-02 00:15 UTC");
53
56
  const axis = renderCompositeTimeAxis(overnight, 80);
54
- expect(axis).toContain("01-01 23:30 UTC");
55
- expect(axis).toContain("01-02 00:00 UTC");
56
- expect(axis).toContain("01-02 00:30 UTC");
57
+ expect(axis).toContain("Jan 1 23:30 UTC");
58
+ expect(axis).toContain("Jan 2 00:00");
59
+ expect(axis).toContain("Jan 2 00:30 UTC");
57
60
  });
58
61
 
59
62
  test("keeps longer chart spans as concise UTC calendar dates", () => {
@@ -62,8 +65,18 @@ describe("composite chart timestamp formatting", () => {
62
65
 
63
66
  expect(formatCompositeCursorDate(cursor, weekly.startTime, weekly.endTime)).toBe("2025-01-04");
64
67
  expect(formatCompositeTimeAxisDate(cursor, weekly.startTime, weekly.endTime)).toBe("2025-01-04");
65
- expect(renderCompositeTimeAxis(weekly, 60)).toContain("2025-01-01");
66
- expect(renderCompositeTimeAxis(weekly, 60)).toContain("2025-01-08");
68
+ expect(renderCompositeTimeAxis(weekly, 60)).toContain("Jan 1");
69
+ expect(renderCompositeTimeAxis(weekly, 60)).toContain("Jan 8");
70
+ });
71
+
72
+ test("renders recovery-shell dates directly from a viewport", () => {
73
+ const axis = renderCompositeViewportTimeAxis({
74
+ start: new Date("2025-01-01T00:00:00.000Z"),
75
+ end: new Date("2025-01-08T00:00:00.000Z"),
76
+ }, 60);
77
+
78
+ expect(axis).toContain("Jan 1");
79
+ expect(axis).toContain("Jan 8");
67
80
  });
68
81
  });
69
82
 
@@ -1,29 +1,8 @@
1
1
  export { CompositeChart } from "./composite-chart";
2
- export {
3
- allocateCompositePanelHeights,
4
- buildCompositeChartScene,
5
- projectCompositeValue,
6
- resolveCompositeCursorDate,
7
- resolveTimeSeriesPointValue,
8
- } from "./scene";
9
- export { renderCompositePanelBitmap } from "./rasterizer";
10
2
  export {
11
3
  pricePointsToResolvedSeries,
12
- resolvedPriceSeries,
13
4
  type PricePointsToResolvedSeriesOptions,
14
5
  } from "./price-series";
15
- export {
16
- renderCompositeAxisText,
17
- renderCompositePanelText,
18
- renderCompositeTimeAxis,
19
- } from "./text-renderer";
20
- export {
21
- compositeAxisTicks,
22
- formatCompositeAxisValue,
23
- formatCompositeCursorDate,
24
- formatCompositeSeriesValue,
25
- formatCompositeTimeAxisDate,
26
- } from "./format";
27
6
  export type {
28
7
  BuildCompositeChartSceneOptions,
29
8
  CompositeAxisDomain,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
3
3
  import {
4
+ compositeViewportHasObservations,
4
5
  panCompositeViewport,
5
6
  resolveCompositeChartInteraction,
6
7
  resolveCompositeMinimumSpanMs,
@@ -10,6 +11,10 @@ import {
10
11
  zoomCompositeViewport,
11
12
  type CompositeViewportRange,
12
13
  } from "./interactions";
14
+ import {
15
+ buildCompositeTimeScale,
16
+ projectCompositeTimestamp,
17
+ } from "./time-scale";
13
18
 
14
19
  const DAY_MS = 24 * 60 * 60 * 1000;
15
20
 
@@ -43,6 +48,27 @@ function viewport(startDay: number, endDay: number): CompositeViewportRange {
43
48
  };
44
49
  }
45
50
 
51
+ function intradayMarketSeries(): ResolvedSeries {
52
+ const points: TimeSeriesPoint[] = [];
53
+ for (const day of [2, 3]) {
54
+ const sessionStart = Date.UTC(2025, 0, day, 13, 30);
55
+ for (let index = 0; index < 78; index += 1) {
56
+ const date = new Date(sessionStart + index * 5 * 60_000);
57
+ points.push({ date, observedAt: date, value: 100 + points.length });
58
+ }
59
+ }
60
+ return {
61
+ ...series([]),
62
+ nativeFrequency: "intraday",
63
+ timeBasis: {
64
+ kind: "market",
65
+ timeZone: "America/New_York",
66
+ cadenceMs: 5 * 60_000,
67
+ },
68
+ points,
69
+ };
70
+ }
71
+
46
72
  describe("composite chart interactions", () => {
47
73
  test("zooms around the right edge and clamps zoom-out to loaded bounds", () => {
48
74
  const bounds = viewport(1, 11);
@@ -65,11 +91,34 @@ describe("composite chart interactions", () => {
65
91
  expect(clampedNewer).toEqual(viewport(7, 11));
66
92
  });
67
93
 
68
- test("uses the finest real observation step as the zoom floor", () => {
69
- const data = series([1, 2, 5, 11]);
94
+ test("keeps the same market-slot span while panning across a closed session", () => {
95
+ const data = intradayMarketSeries();
96
+ const bounds = resolveCompositeNavigationBounds([data])!;
97
+ const visible = {
98
+ start: new Date("2025-01-03T13:30:00.000Z"),
99
+ end: new Date("2025-01-03T17:00:00.000Z"),
100
+ };
101
+ const panned = panCompositeViewport(visible, bounds, 0.5, [data]);
102
+ const scale = buildCompositeTimeScale(
103
+ [data],
104
+ bounds.start.getTime(),
105
+ bounds.end.getTime(),
106
+ );
107
+ const projectedSpan = (window: CompositeViewportRange) => (
108
+ projectCompositeTimestamp(scale, window.end.getTime())!.ratio
109
+ - projectCompositeTimestamp(scale, window.start.getTime())!.ratio
110
+ );
111
+
112
+ expect(panned.end.getTime() - panned.start.getTime())
113
+ .not.toBe(visible.end.getTime() - visible.start.getTime());
114
+ expect(Math.abs(projectedSpan(panned) - projectedSpan(visible))).toBeLessThan(1e-12);
115
+ });
116
+
117
+ test("uses representative cadence instead of a stray fine gap as the zoom floor", () => {
118
+ const data = series([1, 2, 5, 8, 11]);
70
119
  const bounds = resolveCompositeNavigationBounds([data]);
71
120
  expect(bounds).toEqual(viewport(1, 11));
72
- expect(resolveCompositeMinimumSpanMs([data], bounds!)).toBe(DAY_MS);
121
+ expect(resolveCompositeMinimumSpanMs([data], bounds!)).toBe(3 * DAY_MS);
73
122
  });
74
123
 
75
124
  test("keeps the selected window separate from wider loaded navigation bounds", () => {
@@ -77,6 +126,44 @@ describe("composite chart interactions", () => {
77
126
  expect(resolveCompositeNavigationBounds([data], viewport(5, 9))).toEqual(viewport(1, 9));
78
127
  });
79
128
 
129
+ test("clamps overlapping requested bounds to real data but preserves disjoint requests", () => {
130
+ const data = series([1, 2, 3, 4, 5, 6, 7, 8, 9]);
131
+
132
+ expect(resolveCompositeNavigationBounds([data], viewport(5, 12))).toEqual(viewport(1, 9));
133
+ expect(resolveCompositeNavigationBounds([data], viewport(20, 30))).toEqual(viewport(20, 30));
134
+ });
135
+
136
+ test("requires distinct renderable observations inside a viewport", () => {
137
+ const data = series([1, 2, 3]);
138
+ const emptyPoint = point(4);
139
+ emptyPoint.value = null;
140
+ data.points.push(emptyPoint);
141
+
142
+ expect(compositeViewportHasObservations([data], viewport(1, 2))).toBe(true);
143
+ expect(compositeViewportHasObservations([data], viewport(2, 2))).toBe(false);
144
+ expect(compositeViewportHasObservations([data], viewport(3, 4))).toBe(false);
145
+ });
146
+
147
+ test("does not zoom or pan a populated viewport into fewer than two observations", () => {
148
+ const data = series([1, 5, 10, 11]);
149
+ const bounds = viewport(1, 11);
150
+ const populated = viewport(1, 5);
151
+
152
+ expect(zoomCompositeViewport(populated, bounds, 2, 0.5, 1, [data])).toEqual(populated);
153
+ expect(panCompositeViewport(populated, bounds, -1, [data])).toEqual(populated);
154
+ expect(panCompositeViewport(populated, bounds, -1.5, [data])).toEqual(viewport(7, 11));
155
+ });
156
+
157
+ test("allows zoom-out to recover when refreshed data has already emptied the viewport", () => {
158
+ const data = series([1, 2, 10, 11]);
159
+ const bounds = viewport(1, 11);
160
+ const empty = viewport(5, 6);
161
+ const expanded = zoomCompositeViewport(empty, bounds, 0.5, 0.5, 1, [data]);
162
+
163
+ expect(expanded.end.getTime() - expanded.start.getTime()).toBe(2 * DAY_MS);
164
+ expect(compositeViewportHasObservations([data], expanded)).toBe(false);
165
+ });
166
+
80
167
  test("keeps live viewport drift but resets deliberate range/window changes", () => {
81
168
  const original = viewport(1, 11);
82
169
  const oneMinuteLater = {