gloomberb 0.10.0 → 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 (353) 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/native-stubs/chart/surface-manager.ts +11 -0
  274. package/src/renderers/electrobun/view/native-stubs/native-stubs.test.ts +47 -0
  275. package/src/renderers/electrobun/view/remote/asset-data-client.ts +6 -44
  276. package/src/renderers/electrobun/view/remote/persistence.ts +6 -2
  277. package/src/renderers/electrobun/view/remote/quote-subscription-registry.test.ts +86 -0
  278. package/src/renderers/electrobun/view/remote/quote-subscription-registry.ts +72 -0
  279. package/src/renderers/electrobun/view/remote/ticker-repository.ts +2 -2
  280. package/src/renderers/electrobun/view/toast-host.tsx +0 -1
  281. package/src/renderers/electrobun/view/ui-host.tsx +1 -2
  282. package/src/renderers/electrobun/view/update-host.ts +1 -1
  283. package/src/renderers/opentui/chart-surface.tsx +2 -1
  284. package/src/renderers/opentui/dialog-host.tsx +20 -4
  285. package/src/renderers/opentui/image/loader.ts +12 -2
  286. package/src/renderers/opentui/input-host.tsx +7 -49
  287. package/src/renderers/opentui/start.tsx +2 -0
  288. package/src/sources/gloomberb-cloud/history-quality.ts +44 -0
  289. package/src/sources/gloomberb-cloud/index.test.ts +173 -0
  290. package/src/sources/gloomberb-cloud/index.ts +42 -20
  291. package/src/sources/gloomberb-cloud/normalizers.ts +9 -3
  292. package/src/sources/provider-router/cache.ts +1 -1
  293. package/src/sources/provider-router/document-routes.ts +10 -5
  294. package/src/sources/provider-router/financials.test.ts +14 -0
  295. package/src/sources/provider-router/financials.ts +6 -1
  296. package/src/sources/provider-router/history.test.ts +163 -1
  297. package/src/sources/provider-router/history.ts +278 -347
  298. package/src/sources/provider-router/index.ts +2 -2
  299. package/src/sources/provider-router/routing.test.ts +39 -0
  300. package/src/sources/provider-router/routing.ts +24 -11
  301. package/src/sources/provider-router/supplemental.ts +37 -17
  302. package/src/sources/sec-edgar/content.ts +2 -12
  303. package/src/sources/sec-edgar.ts +2 -13
  304. package/src/sources/yahoo-finance/history.ts +2 -2
  305. package/src/sources/yahoo-finance/options.ts +5 -0
  306. package/src/sources/yahoo-finance.ts +2 -2
  307. package/src/state/app/bootstrap.ts +2 -2
  308. package/src/state/app/context/index.tsx +2 -2
  309. package/src/state/hooks/quote-streaming.ts +66 -1
  310. package/src/state/hooks/session-snapshot.ts +2 -2
  311. package/src/state/selectors.ts +0 -9
  312. package/src/sync/controller.test.ts +98 -0
  313. package/src/sync/controller.ts +29 -5
  314. package/src/sync/react.ts +2 -2
  315. package/src/sync/types.ts +2 -2
  316. package/src/theme/colors.test.ts +10 -0
  317. package/src/theme/colors.ts +75 -78
  318. package/src/theme/theme-context.test.tsx +67 -0
  319. package/src/theme/theme-context.tsx +9 -5
  320. package/src/tickers/open-target.ts +2 -2
  321. package/src/tickers/search/upsert.ts +2 -2
  322. package/src/time-series/alignment.ts +0 -7
  323. package/src/time-series/chart-data.ts +145 -0
  324. package/src/time-series/date-window.ts +44 -0
  325. package/src/time-series/history-window.ts +21 -0
  326. package/src/time-series/hooks.test.ts +59 -0
  327. package/src/time-series/hooks.ts +33 -12
  328. package/src/time-series/market-resolution.ts +2 -2
  329. package/src/time-series/market.ts +1 -1
  330. package/src/time-series/range.ts +9 -0
  331. package/src/{components/chart/core → time-series}/resolution.ts +3 -102
  332. package/src/time-series/resolve.test.ts +203 -1
  333. package/src/time-series/resolve.ts +96 -18
  334. package/src/time-series/spec-studies.test.ts +15 -0
  335. package/src/time-series/spec.ts +2 -2
  336. package/src/time-series/studies.ts +5 -2
  337. package/src/time-series/types.ts +3 -1
  338. package/src/time-series/use-chart-resolution.test.tsx +358 -0
  339. package/src/time-series/use-chart-resolution.ts +46 -4
  340. package/src/types/broker.ts +2 -2
  341. package/src/types/data-provider.ts +3 -3
  342. package/src/types/financials.ts +12 -0
  343. package/src/types/plugin.ts +2 -2
  344. package/src/ui/context-menu.tsx +3 -2
  345. package/src/ui/dialog.tsx +2 -2
  346. package/src/utils/html-entities.ts +3 -5
  347. package/src/utils/main-thread-monitor.ts +1 -1
  348. package/src/utils/price-history.ts +5 -5
  349. package/src/version.ts +3 -1
  350. package/src/components/chart/core/viewport.test.ts +0 -8
  351. package/src/components/chart/core/viewport.ts +0 -24
  352. package/src/time-series/index.ts +0 -14
  353. /package/src/{components/chart/core → time-series}/resolution.test.ts +0 -0
@@ -1,4 +1,9 @@
1
1
  import type { ResolvedSeries } from "../../../time-series/types";
2
+ import {
3
+ buildCompositeTimeScale,
4
+ projectCompositeTimestamp,
5
+ unprojectCompositeTimestamp,
6
+ } from "./time-scale";
2
7
 
3
8
  export interface CompositeViewportRange {
4
9
  start: Date;
@@ -44,10 +49,18 @@ function normalizeViewport(
44
49
  };
45
50
  }
46
51
 
52
+ function hasRenderableValue(point: ResolvedSeries["points"][number]): boolean {
53
+ return (
54
+ (typeof point.value === "number" && Number.isFinite(point.value))
55
+ || (typeof point.close === "number" && Number.isFinite(point.close))
56
+ );
57
+ }
58
+
47
59
  function pointTimestamps(series: ResolvedSeries[]): number[] {
48
60
  const timestamps = new Set<number>();
49
61
  for (const entry of series) {
50
62
  for (const point of entry.points) {
63
+ if (!hasRenderableValue(point)) continue;
51
64
  const timestamp = point.date instanceof Date
52
65
  ? point.date.getTime()
53
66
  : new Date(point.date).getTime();
@@ -57,6 +70,33 @@ function pointTimestamps(series: ResolvedSeries[]): number[] {
57
70
  return [...timestamps].sort((left, right) => left - right);
58
71
  }
59
72
 
73
+ function viewportHasTimestamps(
74
+ timestamps: readonly number[],
75
+ viewport: CompositeViewportRange,
76
+ minimumCount: number,
77
+ ): boolean {
78
+ const start = finiteTime(viewport.start);
79
+ const end = finiteTime(viewport.end);
80
+ if (start === null || end === null || start > end) return false;
81
+ const required = Math.max(1, Math.floor(minimumCount));
82
+ let count = 0;
83
+ for (const timestamp of timestamps) {
84
+ if (timestamp < start) continue;
85
+ if (timestamp > end) break;
86
+ count += 1;
87
+ if (count >= required) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ export function compositeViewportHasObservations(
93
+ series: ResolvedSeries[],
94
+ viewport: CompositeViewportRange,
95
+ minimumCount = 2,
96
+ ): boolean {
97
+ return viewportHasTimestamps(pointTimestamps(series), viewport, minimumCount);
98
+ }
99
+
60
100
  export function resolveCompositeNavigationBounds(
61
101
  series: ResolvedSeries[],
62
102
  requestedViewport?: CompositeViewportRange | null,
@@ -70,10 +110,12 @@ export function resolveCompositeNavigationBounds(
70
110
  });
71
111
  if (!requested) return data;
72
112
  if (!data) return requested;
73
- return normalizeViewport({
74
- start: new Date(Math.min(requested.start.getTime(), data.start.getTime())),
75
- end: new Date(Math.max(requested.end.getTime(), data.end.getTime())),
76
- });
113
+ const overlapsData = requested.end.getTime() >= data.start.getTime()
114
+ && requested.start.getTime() <= data.end.getTime();
115
+ // Loaded series can include a buffer outside the authored viewport. Use the
116
+ // complete real-data extent when the two ranges overlap, but do not clamp a
117
+ // newly authored, disjoint request to stale observations.
118
+ return overlapsData ? data : requested;
77
119
  }
78
120
 
79
121
  export function resolveCompositeMinimumSpanMs(
@@ -83,14 +125,18 @@ export function resolveCompositeMinimumSpanMs(
83
125
  const start = bounds.start.getTime();
84
126
  const end = bounds.end.getTime();
85
127
  const timestamps = pointTimestamps(series).filter((timestamp) => timestamp >= start && timestamp <= end);
86
- let minimumStep = Number.POSITIVE_INFINITY;
128
+ const steps: number[] = [];
87
129
  for (let index = 1; index < timestamps.length; index += 1) {
88
130
  const step = timestamps[index]! - timestamps[index - 1]!;
89
- if (step > 0) minimumStep = Math.min(minimumStep, step);
131
+ if (step > 0) steps.push(step);
90
132
  }
91
133
  const boundsSpan = Math.max(end - start, 1);
92
- return Number.isFinite(minimumStep)
93
- ? Math.min(Math.max(minimumStep, 1), boundsSpan)
134
+ steps.sort((left, right) => left - right);
135
+ // Use an observed upper median rather than the absolute minimum. This keeps
136
+ // one near-duplicate live observation from lowering a daily chart's floor.
137
+ const representativeStep = steps[Math.floor(steps.length / 2)];
138
+ return representativeStep !== undefined
139
+ ? Math.min(Math.max(representativeStep, 1), boundsSpan)
94
140
  : Math.max(Math.min(boundsSpan, FALLBACK_SINGLE_POINT_SPAN_MS), 1);
95
141
  }
96
142
 
@@ -98,6 +144,47 @@ function clamp(value: number, minimum: number, maximum: number): number {
98
144
  return Math.max(minimum, Math.min(maximum, value));
99
145
  }
100
146
 
147
+ function marketViewportProjection(
148
+ viewport: CompositeViewportRange,
149
+ bounds: CompositeViewportRange,
150
+ series: ResolvedSeries[] | undefined,
151
+ ): {
152
+ scale: Extract<ReturnType<typeof buildCompositeTimeScale>, { kind: "market" }>;
153
+ startRatio: number;
154
+ endRatio: number;
155
+ } | null {
156
+ if (!series?.some((entry) => entry.timeBasis?.kind === "market")) return null;
157
+ const scale = buildCompositeTimeScale(
158
+ series,
159
+ bounds.start.getTime(),
160
+ bounds.end.getTime(),
161
+ );
162
+ if (scale.kind !== "market") return null;
163
+ const startRatio = projectCompositeTimestamp(scale, viewport.start.getTime())?.ratio;
164
+ const endRatio = projectCompositeTimestamp(scale, viewport.end.getTime())?.ratio;
165
+ if (
166
+ typeof startRatio !== "number"
167
+ || !Number.isFinite(startRatio)
168
+ || typeof endRatio !== "number"
169
+ || !Number.isFinite(endRatio)
170
+ || startRatio >= endRatio
171
+ ) {
172
+ return null;
173
+ }
174
+ return { scale, startRatio, endRatio };
175
+ }
176
+
177
+ function viewportFromMarketRatios(
178
+ scale: Extract<ReturnType<typeof buildCompositeTimeScale>, { kind: "market" }>,
179
+ startRatio: number,
180
+ endRatio: number,
181
+ ): CompositeViewportRange {
182
+ return {
183
+ start: new Date(unprojectCompositeTimestamp(scale, startRatio)),
184
+ end: new Date(unprojectCompositeTimestamp(scale, endRatio)),
185
+ };
186
+ }
187
+
101
188
  export function clampCompositeViewport(
102
189
  viewport: CompositeViewportRange,
103
190
  bounds: CompositeViewportRange,
@@ -136,6 +223,7 @@ export function zoomCompositeViewport(
136
223
  zoomFactor: number,
137
224
  anchorRatio: number,
138
225
  minimumSpanMs: number,
226
+ series?: ResolvedSeries[],
139
227
  ): CompositeViewportRange {
140
228
  const current = clampCompositeViewport(viewport, bounds);
141
229
  if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return current;
@@ -144,17 +232,68 @@ export function zoomCompositeViewport(
144
232
  const end = current.end.getTime();
145
233
  const boundsSpan = Math.max(bounds.end.getTime() - bounds.start.getTime(), 1);
146
234
  const currentSpan = Math.max(end - start, 1);
147
- const nextSpan = clamp(
148
- currentSpan / zoomFactor,
149
- Math.min(Math.max(minimumSpanMs, 1), boundsSpan),
150
- boundsSpan,
151
- );
152
235
  const ratio = clamp(anchorRatio, 0, 1);
153
- const anchor = start + currentSpan * ratio;
154
- return clampCompositeViewport({
155
- start: new Date(anchor - nextSpan * ratio),
156
- end: new Date(anchor + nextSpan * (1 - ratio)),
157
- }, bounds);
236
+ const marketProjection = marketViewportProjection(current, bounds, series);
237
+ const candidate = marketProjection
238
+ ? (() => {
239
+ const {
240
+ scale,
241
+ startRatio,
242
+ endRatio,
243
+ } = marketProjection;
244
+ const currentMarketSpan = endRatio - startRatio;
245
+ const totalMarketPositions = Math.max(
246
+ scale.endPosition - scale.startPosition,
247
+ Number.EPSILON,
248
+ );
249
+ const minimumMarketSpan = clamp(
250
+ Math.max(minimumSpanMs, 1) / scale.cadenceMs / totalMarketPositions,
251
+ Number.EPSILON,
252
+ 1,
253
+ );
254
+ const nextMarketSpan = clamp(
255
+ currentMarketSpan / zoomFactor,
256
+ minimumMarketSpan,
257
+ 1,
258
+ );
259
+ const anchor = startRatio + currentMarketSpan * ratio;
260
+ const candidateStart = clamp(
261
+ anchor - nextMarketSpan * ratio,
262
+ 0,
263
+ 1 - nextMarketSpan,
264
+ );
265
+ return viewportFromMarketRatios(
266
+ scale,
267
+ candidateStart,
268
+ candidateStart + nextMarketSpan,
269
+ );
270
+ })()
271
+ : (() => {
272
+ const nextSpan = clamp(
273
+ currentSpan / zoomFactor,
274
+ Math.min(Math.max(minimumSpanMs, 1), boundsSpan),
275
+ boundsSpan,
276
+ );
277
+ const anchor = start + currentSpan * ratio;
278
+ return clampCompositeViewport({
279
+ start: new Date(anchor - nextSpan * ratio),
280
+ end: new Date(anchor + nextSpan * (1 - ratio)),
281
+ }, bounds);
282
+ })();
283
+ if (!series) return candidate;
284
+
285
+ const timestamps = pointTimestamps(series)
286
+ .filter((timestamp) => timestamp >= bounds.start.getTime() && timestamp <= bounds.end.getTime());
287
+ if (timestamps.length < 2 || viewportHasTimestamps(timestamps, candidate, 2)) {
288
+ return candidate;
289
+ }
290
+
291
+ const currentHasObservations = viewportHasTimestamps(timestamps, current, 2);
292
+ const candidateSpan = candidate.end.getTime() - candidate.start.getTime();
293
+ // An adaptive response can invalidate an existing viewport. Let zoom-out
294
+ // keep expanding from that state until observations re-enter the window.
295
+ if (!currentHasObservations && candidateSpan > currentSpan) return candidate;
296
+ return current;
158
297
  }
159
298
 
160
299
  /**
@@ -165,15 +304,41 @@ export function panCompositeViewport(
165
304
  viewport: CompositeViewportRange,
166
305
  bounds: CompositeViewportRange,
167
306
  shiftRatio: number,
307
+ series?: ResolvedSeries[],
168
308
  ): CompositeViewportRange {
169
309
  const current = clampCompositeViewport(viewport, bounds);
170
310
  if (!Number.isFinite(shiftRatio) || shiftRatio === 0) return current;
171
- const span = Math.max(current.end.getTime() - current.start.getTime(), 1);
172
- const shift = span * shiftRatio;
173
- return clampCompositeViewport({
174
- start: new Date(current.start.getTime() - shift),
175
- end: new Date(current.end.getTime() - shift),
176
- }, bounds);
311
+ const marketProjection = marketViewportProjection(current, bounds, series);
312
+ const candidate = marketProjection
313
+ ? (() => {
314
+ const {
315
+ scale,
316
+ startRatio,
317
+ endRatio,
318
+ } = marketProjection;
319
+ const span = endRatio - startRatio;
320
+ const nextStart = clamp(
321
+ startRatio - span * shiftRatio,
322
+ 0,
323
+ 1 - span,
324
+ );
325
+ return viewportFromMarketRatios(scale, nextStart, nextStart + span);
326
+ })()
327
+ : (() => {
328
+ const span = Math.max(current.end.getTime() - current.start.getTime(), 1);
329
+ const shift = span * shiftRatio;
330
+ return clampCompositeViewport({
331
+ start: new Date(current.start.getTime() - shift),
332
+ end: new Date(current.end.getTime() - shift),
333
+ }, bounds);
334
+ })();
335
+ if (!series) return candidate;
336
+
337
+ const timestamps = pointTimestamps(series)
338
+ .filter((timestamp) => timestamp >= bounds.start.getTime() && timestamp <= bounds.end.getTime());
339
+ return timestamps.length < 2 || viewportHasTimestamps(timestamps, candidate, 2)
340
+ ? candidate
341
+ : current;
177
342
  }
178
343
 
179
344
  export function resolveCompositeWheelPanRatio(
@@ -74,5 +74,3 @@ export function pricePointsToResolvedSeries(
74
74
  warning: options.warning,
75
75
  };
76
76
  }
77
-
78
- export const resolvedPriceSeries = pricePointsToResolvedSeries;
@@ -97,10 +97,6 @@ function median(values: number[]): number | null {
97
97
  : sorted[middle]!;
98
98
  }
99
99
 
100
- function observationGaps(points: CompositeProjectedPoint[], pixelWidth: number): number[] {
101
- return ratioGaps(points.map((point) => point.xRatio), pixelWidth);
102
- }
103
-
104
100
  function ratioGaps(ratios: readonly number[], pixelWidth: number): number[] {
105
101
  const xs = ratios.map((ratio) => ratio * Math.max(pixelWidth - 1, 0)).sort((left, right) => left - right);
106
102
  const positiveGaps: number[] = [];
@@ -111,9 +107,14 @@ function ratioGaps(ratios: readonly number[], pixelWidth: number): number[] {
111
107
  return positiveGaps;
112
108
  }
113
109
 
114
- function observationWidth(points: CompositeProjectedPoint[], pixelWidth: number, maximum: number): number {
115
- const minimumGap = Math.min(...observationGaps(points, pixelWidth));
116
- return clamp(Number.isFinite(minimumGap) ? minimumGap * 0.58 : maximum, 2, maximum);
110
+ export function resolveCompositeObservationWidth(
111
+ points: CompositeProjectedPoint[],
112
+ extent: number,
113
+ minimum: number,
114
+ maximum: number,
115
+ ): number {
116
+ const typicalGap = median(ratioGaps(points.map((point) => point.xRatio), extent));
117
+ return clamp(typicalGap === null ? maximum : typicalGap * 0.58, minimum, maximum);
117
118
  }
118
119
 
119
120
  function columnObservationWidth(
@@ -171,7 +172,8 @@ export function resolveCompositeOhlcWidth(
171
172
  points: CompositeProjectedPoint[],
172
173
  pixelWidth: number,
173
174
  ): number {
174
- return observationWidth(points, pixelWidth, 9);
175
+ const maximum = clamp(pixelWidth * 0.03, 12, 36);
176
+ return resolveCompositeObservationWidth(points, pixelWidth, 2, maximum);
175
177
  }
176
178
 
177
179
  function columnPixelGeometry(
@@ -270,7 +272,6 @@ function drawOhlc(
270
272
  x + candleWidth / 2,
271
273
  Math.max(openY, closeY) + 1,
272
274
  candleColor,
273
- 0.88,
274
275
  );
275
276
  continue;
276
277
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
3
+ import type { CompositePanelScene } from "./types";
3
4
  import {
4
5
  renderCompositePanelBitmap,
5
6
  resolveCompositeColumnWidth,
@@ -7,7 +8,10 @@ import {
7
8
  } from "./rasterizer";
8
9
  import { buildCompositeColumnLayout } from "./column-layout";
9
10
  import { buildCompositeChartScene } from "./scene";
10
- import { renderCompositePanelText } from "./text-renderer";
11
+ import {
12
+ renderCompositePanelText,
13
+ resolveCompositeTextOhlcWidth,
14
+ } from "./text-renderer";
11
15
 
12
16
  function point(date: string, value: number): TimeSeriesPoint {
13
17
  const observedAt = new Date(`${date}T00:00:00.000Z`);
@@ -501,6 +505,64 @@ describe("composite chart renderers", () => {
501
505
  expect(new Set(bitmap.pixels)).not.toEqual(new Set([16, 255]));
502
506
  });
503
507
 
508
+ test("keeps grid lines from showing through candle bodies", () => {
509
+ const source = series("price", "candles", [], "left", "#00ff00");
510
+ const observedAt = new Date("2025-01-01T00:00:00.000Z");
511
+ const candle = {
512
+ date: observedAt,
513
+ observedAt,
514
+ value: 75,
515
+ open: 25,
516
+ high: 90,
517
+ low: 10,
518
+ close: 75,
519
+ };
520
+ source.points = [candle];
521
+ const panel: CompositePanelScene = {
522
+ id: "main",
523
+ height: 9,
524
+ scale: "linear",
525
+ axes: {
526
+ left: {
527
+ side: "left",
528
+ min: 0,
529
+ max: 100,
530
+ scale: "linear",
531
+ unit: "USD",
532
+ unitGroup: "currency",
533
+ seriesIds: [source.id],
534
+ },
535
+ },
536
+ series: [{
537
+ source,
538
+ points: [{
539
+ point: candle,
540
+ timestamp: observedAt.getTime(),
541
+ value: candle.close,
542
+ xRatio: 0.5,
543
+ yRatio: 0.25,
544
+ breakBefore: true,
545
+ }],
546
+ }],
547
+ };
548
+ const bitmap = renderCompositePanelBitmap(panel, {
549
+ pixelWidth: 101,
550
+ pixelHeight: 101,
551
+ colors: {
552
+ background: "#000000",
553
+ grid: "#ff00ff",
554
+ crosshair: "#ffffff",
555
+ text: "#eeeeee",
556
+ textDim: "#999999",
557
+ negative: "#ff0000",
558
+ },
559
+ });
560
+ const bodyPixelOffset = (50 * bitmap.width + 47) * 4;
561
+
562
+ expect([...bitmap.pixels.slice(bodyPixelOffset, bodyPixelOffset + 4)])
563
+ .toEqual([0, 255, 0, 255]);
564
+ });
565
+
504
566
  test("measures column spacing once per series instead of once per observation", () => {
505
567
  const denseColumns = {
506
568
  ...series("volume", "columns", [], "left"),
@@ -569,7 +631,51 @@ describe("composite chart renderers", () => {
569
631
  expect(resolveCompositeOhlcWidth(
570
632
  scene.panels[0]!.series[0]!.points,
571
633
  2_000,
572
- )).toBeLessThan(4);
634
+ )).toBe(36);
635
+ });
636
+
637
+ test("widens candle bodies as the visible observation spacing grows", () => {
638
+ const candles = series("price", "candles", [], "left");
639
+ candles.points = Array.from({ length: 100 }, (_, index) => {
640
+ const observedAt = new Date(Date.UTC(2025, 0, index + 1));
641
+ return {
642
+ date: observedAt,
643
+ observedAt,
644
+ value: 10,
645
+ open: 8,
646
+ high: 11,
647
+ low: 7,
648
+ close: 10,
649
+ };
650
+ });
651
+ const fullScene = buildCompositeChartScene(
652
+ [candles],
653
+ [{ id: "main" }],
654
+ { width: 81, height: 9 },
655
+ )!;
656
+ const zoomedScene = buildCompositeChartScene(
657
+ [candles],
658
+ [{ id: "main" }],
659
+ {
660
+ width: 81,
661
+ height: 9,
662
+ viewport: {
663
+ start: new Date(Date.UTC(2025, 0, 98)),
664
+ end: new Date(Date.UTC(2025, 0, 100)),
665
+ },
666
+ },
667
+ )!;
668
+ const fullPoints = fullScene.panels[0]!.series[0]!.points;
669
+ const zoomedPoints = zoomedScene.panels[0]!.series[0]!.points;
670
+
671
+ expect(resolveCompositeOhlcWidth(fullPoints, 800)).toBeLessThan(
672
+ resolveCompositeOhlcWidth(zoomedPoints, 800),
673
+ );
674
+ expect(resolveCompositeTextOhlcWidth(fullPoints, 81)).toBe(1);
675
+ expect(resolveCompositeTextOhlcWidth(zoomedPoints, 81)).toBe(5);
676
+
677
+ const zoomedText = renderCompositePanelText(zoomedScene.panels[0]!, 81, null, null).join("\n");
678
+ expect(zoomedText).toContain("█████");
573
679
  });
574
680
 
575
681
  test("marks a standalone line observation without extending it through time", () => {
@@ -397,6 +397,42 @@ describe("composite chart scene", () => {
397
397
  expect(layout.groupByPoint.get(second)).toMatchObject({ index: 1, count: 2 });
398
398
  });
399
399
 
400
+ test("uses plotted market data when the legend timeline is empty", () => {
401
+ const timestamps = [
402
+ "2025-01-03T15:00:00.000Z",
403
+ "2025-01-03T16:00:00.000Z",
404
+ "2025-01-06T14:00:00.000Z",
405
+ ];
406
+ const price = series({
407
+ id: "price",
408
+ timeBasis: {
409
+ kind: "market",
410
+ timeZone: "America/New_York",
411
+ cadenceMs: 60 * 60 * 1_000,
412
+ },
413
+ points: timestamps.map((timestamp, index) => {
414
+ const date = new Date(timestamp);
415
+ return { date, observedAt: date, value: 100 + index };
416
+ }),
417
+ });
418
+ const scene = buildCompositeChartScene(
419
+ [price],
420
+ [{ id: "main" }],
421
+ {
422
+ width: 80,
423
+ height: 10,
424
+ viewport: {
425
+ start: new Date(timestamps[0]!),
426
+ end: new Date(timestamps.at(-1)!),
427
+ },
428
+ timelineSeries: [],
429
+ },
430
+ );
431
+
432
+ expect(scene?.timeScale).toMatchObject({ kind: "market", anchorSeriesId: "price" });
433
+ expect(scene?.panels[0]?.series[0]?.points.map(({ xRatio }) => xRatio)).toEqual([0, 0.5, 1]);
434
+ });
435
+
400
436
  test("retains the authored primary market scale when its plotted series is hidden", () => {
401
437
  const anchor = series({
402
438
  id: "primary-price",
@@ -27,7 +27,7 @@ function finiteNumber(value: unknown): value is number {
27
27
  return typeof value === "number" && Number.isFinite(value);
28
28
  }
29
29
 
30
- export function resolveTimeSeriesPointValue(point: TimeSeriesPoint): number | null {
30
+ function resolveTimeSeriesPointValue(point: TimeSeriesPoint): number | null {
31
31
  if (finiteNumber(point.value)) return point.value;
32
32
  if (finiteNumber(point.close)) return point.close;
33
33
  return null;
@@ -423,8 +423,11 @@ export function buildCompositeChartScene(
423
423
  const viewport = explicitViewport(options);
424
424
  const startTime = viewport?.startTime ?? (firstTime === lastTime ? firstTime - DAY_MS / 2 : firstTime);
425
425
  const endTime = viewport?.endTime ?? (firstTime === lastTime ? lastTime + DAY_MS / 2 : lastTime);
426
+ const timelineSeries = options.timelineSeries?.some((entry) => (
427
+ entry.timeBasis?.kind === "market" && entry.points.length > 0
428
+ )) ? options.timelineSeries : dataSeries;
426
429
  const timeScale = buildCompositeTimeScale(
427
- options.timelineSeries ?? dataSeries,
430
+ timelineSeries,
428
431
  startTime,
429
432
  endTime,
430
433
  );
@@ -1,7 +1,12 @@
1
- import { compositeAxisTicks, formatCompositeTimeAxisDate } from "./format";
2
- import { resolveCompositeTimeAxisDate } from "./scene";
1
+ import { compositeAxisTicks } from "./format";
2
+ import type { CompositeViewportRange } from "./interactions";
3
+ import { resolveCompositeObservationWidth } from "./rasterizer";
3
4
  import { buildCompositeColumnLayout, type CompositeColumnLayout } from "./column-layout";
4
5
  import { projectCompositeValue } from "./scene";
6
+ import {
7
+ buildCompositeTimeAxisLayout,
8
+ buildCompositeViewportTimeAxisLayout,
9
+ } from "./time-axis";
5
10
  import type {
6
11
  CompositeAxisDomain,
7
12
  CompositeChartScene,
@@ -159,6 +164,8 @@ function renderOhlc(
159
164
  width: number,
160
165
  height: number,
161
166
  ): void {
167
+ const candleWidth = resolveCompositeTextOhlcWidth(series.points, width);
168
+ const halfCandleWidth = Math.floor(candleWidth / 2);
162
169
  for (const projected of series.points) {
163
170
  const source = projected.point;
164
171
  const x = cellPoint(projected, width, height).x;
@@ -173,14 +180,33 @@ function renderOhlc(
173
180
  if (closeRow === null || highRow === null || lowRow === null) continue;
174
181
  drawLine(rows, x, highRow, x, lowRow, "│");
175
182
  if (series.source.style === "candles" && openRow !== null) {
176
- drawLine(rows, x, openRow, x, closeRow, "█");
183
+ for (let row = Math.min(openRow, closeRow); row <= Math.max(openRow, closeRow); row += 1) {
184
+ for (let offset = -halfCandleWidth; offset <= halfCandleWidth; offset += 1) {
185
+ setCell(rows, x + offset, row, "█");
186
+ }
187
+ }
177
188
  continue;
178
189
  }
179
- if (series.source.style === "ohlc" && openRow !== null) setCell(rows, Math.max(0, x - 1), openRow, "─");
180
- setCell(rows, Math.min(width - 1, x + 1), closeRow, "─");
190
+ const tickWidth = Math.max(1, halfCandleWidth);
191
+ if (series.source.style === "ohlc" && openRow !== null) {
192
+ drawLine(rows, Math.max(0, x - tickWidth), openRow, x, openRow, "─");
193
+ }
194
+ drawLine(rows, x, closeRow, Math.min(width - 1, x + tickWidth), closeRow, "─");
181
195
  }
182
196
  }
183
197
 
198
+ export function resolveCompositeTextOhlcWidth(
199
+ points: CompositeProjectedPoint[],
200
+ width: number,
201
+ ): number {
202
+ const maximum = clamp(Math.floor(width / 16), 3, 7);
203
+ const resolved = Math.max(
204
+ 1,
205
+ Math.floor(resolveCompositeObservationWidth(points, width, 1, maximum)),
206
+ );
207
+ return resolved % 2 === 0 ? Math.max(1, resolved - 1) : resolved;
208
+ }
209
+
184
210
  export function renderCompositePanelText(
185
211
  panel: CompositePanelScene,
186
212
  width: number,
@@ -259,27 +285,13 @@ export function renderCompositeAxisText(
259
285
  return rows;
260
286
  }
261
287
 
262
- function placeLabel(chars: string[], label: string, center: number): void {
263
- const start = clamp(Math.round(center - label.length / 2), 0, Math.max(chars.length - label.length, 0));
264
- for (let index = 0; index < label.length && start + index < chars.length; index += 1) {
265
- chars[start + index] = label[index]!;
266
- }
288
+ export function renderCompositeViewportTimeAxis(
289
+ viewport: CompositeViewportRange,
290
+ width: number,
291
+ ): string {
292
+ return buildCompositeViewportTimeAxisLayout(viewport, width).text;
267
293
  }
268
294
 
269
295
  export function renderCompositeTimeAxis(scene: CompositeChartScene, width: number): string {
270
- const chars = Array(Math.max(1, width)).fill(" ");
271
- const formatDate = (ratio: number) => formatCompositeTimeAxisDate(
272
- resolveCompositeTimeAxisDate(scene, ratio),
273
- scene.startTime,
274
- scene.endTime,
275
- );
276
- const startLabel = formatDate(0);
277
- const midpointLabel = formatDate(0.5);
278
- const endLabel = formatDate(1);
279
- placeLabel(chars, startLabel, 0);
280
- if (chars.length >= startLabel.length + midpointLabel.length + endLabel.length + 4) {
281
- placeLabel(chars, midpointLabel, (chars.length - 1) / 2);
282
- }
283
- placeLabel(chars, endLabel, chars.length - 1);
284
- return chars.join("");
296
+ return buildCompositeTimeAxisLayout(scene, width).text;
285
297
  }