gloomberb 0.16.0 → 0.16.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 (691) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -1
  3. package/src/api-client/beneficial-owners.ts +92 -0
  4. package/src/api-client/data.ts +44 -11
  5. package/src/api-client/errors.ts +12 -0
  6. package/src/api-client/geo.ts +363 -0
  7. package/src/api-client/index.ts +5 -0
  8. package/src/api-client/market-discovery.ts +10 -0
  9. package/src/api-client/members.ts +26 -0
  10. package/src/api-client/paths.ts +30 -1
  11. package/src/api-client/perps.ts +41 -0
  12. package/src/api-client/request.ts +5 -2
  13. package/src/api-client/types.ts +9 -1
  14. package/src/app/global-shortcuts.ts +14 -6
  15. package/src/app/keybindings/actions.ts +1 -1
  16. package/src/app/runtime/update.ts +38 -2
  17. package/src/app.tsx +10 -6
  18. package/src/brokers/account-cache.ts +24 -0
  19. package/src/cli/commands/command-utils.ts +8 -0
  20. package/src/cli/commands/crypto-hints.ts +75 -0
  21. package/src/cli/commands/market.ts +414 -106
  22. package/src/cli/commands/overview.ts +77 -32
  23. package/src/cli/commands/ticker.ts +101 -43
  24. package/src/cli/desktop-pane-shot.ts +34 -4
  25. package/src/cli/freshness.ts +61 -0
  26. package/src/cli/help.ts +1 -0
  27. package/src/cli/helpers.ts +4 -8
  28. package/src/cli/index.ts +12 -2
  29. package/src/cli/listing-arg.ts +321 -0
  30. package/src/cli/pane-functions/access-gate.ts +42 -0
  31. package/src/cli/pane-functions/catalog.ts +28 -1
  32. package/src/cli/pane-functions/data.ts +3 -1
  33. package/src/cli/pane-functions/dom.ts +20 -2
  34. package/src/cli/pane-functions/freshness.ts +400 -0
  35. package/src/cli/pane-functions/function-help.ts +36 -22
  36. package/src/cli/pane-functions/headless.ts +39 -11
  37. package/src/cli/pane-functions/index.ts +15 -4
  38. package/src/cli/pane-functions/options.ts +3 -0
  39. package/src/cli/pane-functions/report.ts +4 -0
  40. package/src/cli/pane-functions/resolver.ts +35 -7
  41. package/src/cli/pane-functions/screenshot-evidence.ts +2 -0
  42. package/src/cli/pane-functions/screenshot.ts +7 -3
  43. package/src/cli/result.ts +41 -27
  44. package/src/cli/ticker-resolution.ts +7 -5
  45. package/src/components/chart/composite/composite-chart.tsx +88 -2536
  46. package/src/components/chart/composite/drawing-store.ts +59 -0
  47. package/src/components/chart/composite/legend.tsx +319 -0
  48. package/src/components/chart/composite/panel-bitmap.ts +139 -0
  49. package/src/components/chart/composite/panel-gestures.ts +59 -0
  50. package/src/components/chart/composite/panel-labels.ts +107 -0
  51. package/src/components/chart/composite/panel-surface.tsx +876 -0
  52. package/src/components/chart/composite/tool-catalog.ts +98 -0
  53. package/src/components/chart/composite/toolbar.tsx +186 -0
  54. package/src/components/chart/composite/tools.ts +8 -0
  55. package/src/components/chart/composite/use-composite-chart-keyboard.ts +703 -0
  56. package/src/components/chart/composite/use-composite-viewport.ts +143 -0
  57. package/src/components/command-bar/commands/collection.ts +5 -1
  58. package/src/components/command-bar/list/model.ts +5 -0
  59. package/src/components/command-bar/list/view.tsx +16 -3
  60. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  61. package/src/components/command-bar/routes/root/command-items.ts +9 -2
  62. package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
  63. package/src/components/command-bar/routes/root/provider-search.ts +19 -5
  64. package/src/components/command-bar/routes/root/runtime.ts +14 -5
  65. package/src/components/command-bar/routes/ticker-search/actions.ts +5 -0
  66. package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
  67. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  68. package/src/components/command-bar/routing/actions.ts +2 -0
  69. package/src/components/command-bar/selection-runtime.ts +38 -0
  70. package/src/components/command-bar/surface/index.tsx +7 -2
  71. package/src/components/command-bar/workflow/ops.ts +1 -1
  72. package/src/components/command-bar/workflow/tickers.ts +6 -0
  73. package/src/components/data-table/view.tsx +29 -10
  74. package/src/components/feedback-dialog.tsx +2 -1
  75. package/src/components/form-modal/field-row.tsx +2 -2
  76. package/src/components/index.ts +2 -2
  77. package/src/components/layout/detached-pane-shell.tsx +6 -3
  78. package/src/components/layout/floating-pane.tsx +6 -0
  79. package/src/components/layout/header.tsx +24 -7
  80. package/src/components/layout/missing-pane.tsx +2 -2
  81. package/src/components/layout/pane/content.tsx +23 -10
  82. package/src/components/layout/pane/error-boundary.tsx +103 -0
  83. package/src/components/layout/pane/footer/registration.tsx +68 -23
  84. package/src/components/layout/pane/header.tsx +61 -28
  85. package/src/components/layout/pane/index.tsx +6 -0
  86. package/src/components/layout/pane/title.ts +31 -7
  87. package/src/components/layout/shell/drag/index.ts +6 -5
  88. package/src/components/layout/shell/drag/runtime.ts +3 -0
  89. package/src/components/layout/shell/index.tsx +6 -0
  90. package/src/components/layout/shell/menu.ts +2 -1
  91. package/src/components/layout/shell/pane/actions.ts +2 -1
  92. package/src/components/layout/shell/pane/layers.tsx +29 -10
  93. package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
  94. package/src/components/layout/shell/ticker-link-menu.ts +23 -9
  95. package/src/components/metric-treemap/heat-surface.tsx +745 -0
  96. package/src/components/metric-treemap/heat-tiles.ts +228 -0
  97. package/src/components/metric-treemap/index.tsx +2 -0
  98. package/src/components/metric-treemap/layout.ts +8 -0
  99. package/src/components/metric-treemap/scene.ts +432 -0
  100. package/src/components/onboarding/onboarding-keyboard.ts +311 -0
  101. package/src/components/onboarding/onboarding-wizard.tsx +101 -788
  102. package/src/components/onboarding/use-onboarding-broker-form.ts +263 -0
  103. package/src/components/onboarding/use-onboarding-progress.ts +298 -0
  104. package/src/components/onboarding/use-onboarding-upgrade.ts +83 -0
  105. package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
  106. package/src/components/ticker/list-table-view.tsx +13 -1
  107. package/src/components/toggle-list.tsx +38 -12
  108. package/src/components/ui/country-flag-art.tsx +449 -0
  109. package/src/components/ui/country-flag.tsx +41 -0
  110. package/src/components/ui/data-table/types.ts +13 -0
  111. package/src/components/ui/fields.tsx +8 -0
  112. package/src/components/ui/icon.tsx +2 -0
  113. package/src/components/ui/index.ts +4 -0
  114. package/src/components/ui/multi-select/dialog.tsx +108 -22
  115. package/src/components/ui/range-track.tsx +53 -0
  116. package/src/components/ui/status.tsx +8 -2
  117. package/src/components/ui/tabs.tsx +11 -4
  118. package/src/components/ui/text-prompt-dialog.tsx +4 -2
  119. package/src/core/state/app/types.ts +10 -1
  120. package/src/data/config/store/normalize.ts +17 -1
  121. package/src/i18n/es.ts +24 -4
  122. package/src/i18n/ja.ts +24 -4
  123. package/src/i18n/ko.ts +24 -4
  124. package/src/i18n/zh-cn.ts +24 -4
  125. package/src/i18n/zh-tw.ts +24 -4
  126. package/src/layout/ticker-navigation.ts +28 -2
  127. package/src/market-data/coordinator/chart.ts +1 -1
  128. package/src/market-data/coordinator/quotes.ts +23 -4
  129. package/src/market-data/fx-closes.ts +72 -0
  130. package/src/market-data/market/freshness.ts +78 -6
  131. package/src/market-data/market/session-close-note.ts +57 -0
  132. package/src/market-data/market/status.ts +151 -0
  133. package/src/market-data/published-apac-sessions.ts +66 -0
  134. package/src/market-data/quotes/contributions.ts +8 -0
  135. package/src/market-data/quotes/freshness.ts +36 -0
  136. package/src/market-data/quotes/resolution.ts +24 -8
  137. package/src/market-data/quotes/screener-live-quotes.ts +49 -4
  138. package/src/market-data/quotes/time.ts +16 -0
  139. package/src/plugins/builtin/account-management/pane.tsx +4 -0
  140. package/src/plugins/builtin/alerts/cli.ts +10 -5
  141. package/src/plugins/builtin/alerts/index.tsx +1 -0
  142. package/src/plugins/builtin/analytics/metrics.ts +8 -3
  143. package/src/plugins/builtin/analytics/risk-client.ts +330 -79
  144. package/src/plugins/builtin/analytics/risk-fx.ts +102 -0
  145. package/src/plugins/builtin/analytics/risk-headless.ts +61 -49
  146. package/src/plugins/builtin/analytics/risk-math.ts +33 -13
  147. package/src/plugins/builtin/analytics/risk-model.ts +282 -64
  148. package/src/plugins/builtin/analytics/risk-pane.tsx +62 -30
  149. package/src/plugins/builtin/analytics/risk-test-data.ts +206 -0
  150. package/src/plugins/builtin/attention/headless.ts +6 -4
  151. package/src/plugins/builtin/attention/index.tsx +1 -1
  152. package/src/plugins/builtin/attention/pane.tsx +1 -1
  153. package/src/plugins/builtin/awards/headless.ts +1 -0
  154. package/src/plugins/builtin/awards/pane.tsx +1 -1
  155. package/src/plugins/builtin/backtest/headless.ts +1 -0
  156. package/src/plugins/builtin/bond-calculator/headless.ts +1 -0
  157. package/src/plugins/builtin/buildout/cells.tsx +15 -12
  158. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  159. package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
  160. package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
  161. package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
  162. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
  163. package/src/plugins/builtin/buildout/index.tsx +1 -0
  164. package/src/plugins/builtin/catalysts/headless.ts +3 -1
  165. package/src/plugins/builtin/catalysts/index.tsx +2 -2
  166. package/src/plugins/builtin/catalysts/pane.tsx +3 -1
  167. package/src/plugins/builtin/cds/headless.ts +2 -0
  168. package/src/plugins/builtin/cds/index.tsx +1 -0
  169. package/src/plugins/builtin/central-bank-rates/headless.ts +1 -0
  170. package/src/plugins/builtin/chart-composer/headless.ts +38 -3
  171. package/src/plugins/builtin/chart-composer/index.tsx +3 -1
  172. package/src/plugins/builtin/chart-composer/series-expression.ts +18 -2
  173. package/src/plugins/builtin/chat/channels.ts +19 -3
  174. package/src/plugins/builtin/chat/content/composer-runtime.ts +28 -13
  175. package/src/plugins/builtin/chat/content/footer.tsx +15 -6
  176. package/src/plugins/builtin/chat/content/index.tsx +120 -21
  177. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +261 -113
  178. package/src/plugins/builtin/chat/content/transcript.tsx +20 -2
  179. package/src/plugins/builtin/chat/direct-messages.ts +200 -0
  180. package/src/plugins/builtin/chat/index.tsx +1 -0
  181. package/src/plugins/builtin/chat/message/desktop.tsx +3 -0
  182. package/src/plugins/builtin/chat/message/header.tsx +16 -6
  183. package/src/plugins/builtin/chat/message/inline-tokens.tsx +72 -32
  184. package/src/plugins/builtin/chat/message/profile-popover.tsx +96 -12
  185. package/src/plugins/builtin/chat/message/terminal.tsx +3 -0
  186. package/src/plugins/builtin/chat/message/types.ts +14 -2
  187. package/src/plugins/builtin/chat/profile-popover.ts +18 -13
  188. package/src/plugins/builtin/clinical-trials/index.tsx +1 -0
  189. package/src/plugins/builtin/cloud/askg/executor.ts +2 -1
  190. package/src/plugins/builtin/cloud/plugin.tsx +1 -0
  191. package/src/plugins/builtin/cloud/team/module.tsx +1 -0
  192. package/src/plugins/builtin/cloud/thesis/module.tsx +1 -0
  193. package/src/plugins/builtin/cloud-tweets/registration.ts +1 -0
  194. package/src/plugins/builtin/comment-letters/index.tsx +1 -0
  195. package/src/plugins/builtin/company-attention/headless.ts +5 -1
  196. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  197. package/src/plugins/builtin/company-kpis/headless.ts +1 -0
  198. package/src/plugins/builtin/congress-trades/headless.ts +1 -0
  199. package/src/plugins/builtin/correlation/compute.ts +59 -16
  200. package/src/plugins/builtin/correlation/geo.ts +34 -0
  201. package/src/plugins/builtin/correlation/headless-schema.ts +4 -1
  202. package/src/plugins/builtin/correlation/headless.ts +42 -8
  203. package/src/plugins/builtin/correlation/history.ts +2 -0
  204. package/src/plugins/builtin/correlation/index.tsx +68 -14
  205. package/src/plugins/builtin/correlation/matrix/model.ts +17 -2
  206. package/src/plugins/builtin/correlation/matrix/selection.ts +5 -3
  207. package/src/plugins/builtin/correlation/relationship/model.ts +1 -1
  208. package/src/plugins/builtin/correlation/relationship/pane.tsx +4 -2
  209. package/src/plugins/builtin/correlation/relationship/view-model.ts +16 -1
  210. package/src/plugins/builtin/correlation/settings.ts +1 -1
  211. package/src/plugins/builtin/cot/headless.ts +6 -3
  212. package/src/plugins/builtin/cot/index.tsx +3 -3
  213. package/src/plugins/builtin/cot/model.ts +36 -3
  214. package/src/plugins/builtin/cpi/headless.ts +4 -0
  215. package/src/plugins/builtin/credit-boards/headless.ts +2 -0
  216. package/src/plugins/builtin/credit-conditions/headless.ts +1 -0
  217. package/src/plugins/builtin/credit-documents/gauge.tsx +5 -8
  218. package/src/plugins/builtin/credit-documents/headless.ts +2 -0
  219. package/src/plugins/builtin/crypto-board/headless.ts +3 -0
  220. package/src/plugins/builtin/custom-view/loader.ts +1 -1
  221. package/src/plugins/builtin/debt-maturities/headless.ts +4 -0
  222. package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
  223. package/src/plugins/builtin/dividend-yield/client.ts +4 -2
  224. package/src/plugins/builtin/dividend-yield/headless.ts +1 -0
  225. package/src/plugins/builtin/doe/headless.ts +7 -0
  226. package/src/plugins/builtin/doe/range-bar.tsx +7 -37
  227. package/src/plugins/builtin/earnings/client.ts +2 -2
  228. package/src/plugins/builtin/earnings/headless.ts +5 -0
  229. package/src/plugins/builtin/earnings-calls/headless.ts +1 -0
  230. package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
  231. package/src/plugins/builtin/earnings-ripple/client.ts +91 -18
  232. package/src/plugins/builtin/earnings-ripple/headless.ts +28 -8
  233. package/src/plugins/builtin/earnings-ripple/index.tsx +2 -2
  234. package/src/plugins/builtin/earnings-ripple/model.ts +97 -7
  235. package/src/plugins/builtin/earnings-ripple/pane.tsx +148 -23
  236. package/src/plugins/builtin/econ/index.tsx +1 -0
  237. package/src/plugins/builtin/econ-statistics/headless.ts +2 -0
  238. package/src/plugins/builtin/equity-screener/headless.ts +2 -0
  239. package/src/plugins/builtin/estimate-revisions/headless.ts +1 -0
  240. package/src/plugins/builtin/executives/index.tsx +1 -0
  241. package/src/plugins/builtin/exposure/headless.ts +3 -1
  242. package/src/plugins/builtin/fear-greed/headless.ts +2 -0
  243. package/src/plugins/builtin/filing-events/index.tsx +1 -0
  244. package/src/plugins/builtin/futures-curve/client.ts +12 -3
  245. package/src/plugins/builtin/futures-curve/headless.ts +39 -10
  246. package/src/plugins/builtin/futures-curve/index.tsx +3 -3
  247. package/src/plugins/builtin/futures-curve/model.ts +188 -4
  248. package/src/plugins/builtin/futures-curve/pane.tsx +51 -9
  249. package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
  250. package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
  251. package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
  252. package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
  253. package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
  254. package/src/plugins/builtin/gpu/headless.ts +7 -3
  255. package/src/plugins/builtin/gpu/ladder.tsx +26 -30
  256. package/src/plugins/builtin/gpu/model.ts +27 -1
  257. package/src/plugins/builtin/holders/beneficial-client.ts +209 -0
  258. package/src/plugins/builtin/holders/beneficial-model.ts +279 -0
  259. package/src/plugins/builtin/holders/beneficial-report.ts +126 -0
  260. package/src/plugins/builtin/holders/beneficial-view.tsx +241 -0
  261. package/src/plugins/builtin/holders/headless.ts +139 -5
  262. package/src/plugins/builtin/holders/index.tsx +10 -3
  263. package/src/plugins/builtin/holders/pane.tsx +20 -3
  264. package/src/plugins/builtin/holders/report-header.ts +95 -0
  265. package/src/plugins/builtin/holders/table-model.ts +20 -5
  266. package/src/plugins/builtin/holders/thirteenf-match.ts +29 -1
  267. package/src/plugins/builtin/holders/types.ts +2 -1
  268. package/src/plugins/builtin/insider/headless.ts +2 -0
  269. package/src/plugins/builtin/ipo-calendar/headless.ts +1 -0
  270. package/src/plugins/builtin/iv-history/headless.ts +4 -1
  271. package/src/plugins/builtin/jobs/headless.ts +1 -0
  272. package/src/plugins/builtin/jobs/index.tsx +1 -0
  273. package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
  274. package/src/plugins/builtin/kelly-sizer/index.tsx +1 -0
  275. package/src/plugins/builtin/macro-day/headless.ts +2 -0
  276. package/src/plugins/builtin/market-halts/index.tsx +1 -0
  277. package/src/plugins/builtin/market-heatmap/data.ts +30 -2
  278. package/src/plugins/builtin/market-heatmap/headless.ts +135 -0
  279. package/src/plugins/builtin/market-heatmap/index.tsx +245 -81
  280. package/src/plugins/builtin/market-heatmap/live.ts +88 -0
  281. package/src/plugins/builtin/market-heatmap/model.ts +146 -0
  282. package/src/plugins/builtin/market-heatmap/portfolio.ts +50 -11
  283. package/src/plugins/builtin/market-movers/client.ts +3 -2
  284. package/src/plugins/builtin/market-movers/headless.ts +10 -1
  285. package/src/plugins/builtin/market-movers/screener.ts +18 -5
  286. package/src/plugins/builtin/market-valuation/headless.ts +2 -0
  287. package/src/plugins/builtin/members/client.ts +53 -0
  288. package/src/plugins/builtin/members/headless.ts +35 -0
  289. package/src/plugins/builtin/members/index.tsx +20 -0
  290. package/src/plugins/builtin/members/model.ts +68 -0
  291. package/src/plugins/builtin/members/pane.tsx +175 -0
  292. package/src/plugins/builtin/mna/distress/headless.ts +1 -0
  293. package/src/plugins/builtin/mna/headless.ts +1 -0
  294. package/src/plugins/builtin/mna/index.tsx +2 -1
  295. package/src/plugins/builtin/mna/pane.tsx +5 -2
  296. package/src/plugins/builtin/money-markets/headless.ts +1 -0
  297. package/src/plugins/builtin/news/headless.ts +2 -0
  298. package/src/plugins/builtin/news/wire/index.tsx +8 -1
  299. package/src/plugins/builtin/notes/cli.ts +14 -6
  300. package/src/plugins/builtin/notes/index.tsx +1 -0
  301. package/src/plugins/builtin/openfda/index.tsx +1 -0
  302. package/src/plugins/builtin/options/index.tsx +2 -1
  303. package/src/plugins/builtin/options-calculator/headless.ts +2 -0
  304. package/src/plugins/builtin/options-positioning/headless.ts +3 -0
  305. package/src/plugins/builtin/options-scenario/headless.ts +5 -0
  306. package/src/plugins/builtin/pe-band/client.ts +74 -2
  307. package/src/plugins/builtin/pe-band/headless.ts +26 -8
  308. package/src/plugins/builtin/pe-band/model.ts +102 -22
  309. package/src/plugins/builtin/pe-band/pane.tsx +34 -16
  310. package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
  311. package/src/plugins/builtin/perps/client.ts +66 -5
  312. package/src/plugins/builtin/perps/equity-identity.ts +16 -0
  313. package/src/plugins/builtin/perps/equity-row.tsx +6 -4
  314. package/src/plugins/builtin/perps/headless.ts +144 -16
  315. package/src/plugins/builtin/perps/index.tsx +6 -6
  316. package/src/plugins/builtin/perps/model.ts +189 -12
  317. package/src/plugins/builtin/perps/pane.tsx +248 -47
  318. package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
  319. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
  320. package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
  321. package/src/plugins/builtin/portfolio-list/allocation.ts +245 -0
  322. package/src/plugins/builtin/portfolio-list/cached-account.ts +33 -0
  323. package/src/plugins/builtin/portfolio-list/cell-version.ts +19 -1
  324. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +328 -29
  325. package/src/plugins/builtin/portfolio-list/cli/render.ts +230 -39
  326. package/src/plugins/builtin/portfolio-list/cli/shared.ts +67 -2
  327. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +35 -31
  328. package/src/plugins/builtin/portfolio-list/column-values.ts +124 -47
  329. package/src/plugins/builtin/portfolio-list/headless.ts +98 -16
  330. package/src/plugins/builtin/portfolio-list/index.tsx +14 -3
  331. package/src/plugins/builtin/portfolio-list/live-valuation.ts +7 -5
  332. package/src/plugins/builtin/portfolio-list/mutations.ts +55 -1
  333. package/src/plugins/builtin/portfolio-list/pane/data.ts +6 -0
  334. package/src/plugins/builtin/portfolio-list/pane/index.tsx +47 -13
  335. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
  336. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
  337. package/src/plugins/builtin/portfolio-list/settings.ts +33 -2
  338. package/src/plugins/builtin/portfolio-list/summary/index.tsx +24 -1
  339. package/src/plugins/builtin/portfolio-list/summary/totals.ts +26 -7
  340. package/src/plugins/builtin/power/headless.ts +3 -1
  341. package/src/plugins/builtin/rate-path/headless.ts +2 -0
  342. package/src/plugins/builtin/realized-vol/headless.ts +2 -0
  343. package/src/plugins/builtin/relative-rotation/headless.ts +2 -0
  344. package/src/plugins/builtin/research/analyst-headless.ts +3 -0
  345. package/src/plugins/builtin/research/events-headless.ts +2 -0
  346. package/src/plugins/builtin/research/headless.ts +2 -0
  347. package/src/plugins/builtin/research/index.tsx +1 -0
  348. package/src/plugins/builtin/research/relative-valuation-headless.ts +10 -5
  349. package/src/plugins/builtin/research/relative-valuation-model.ts +11 -3
  350. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  351. package/src/plugins/builtin/research-search/index.tsx +1 -0
  352. package/src/plugins/builtin/revenue-breakdown/headless.ts +2 -0
  353. package/src/plugins/builtin/reverse-dcf/headless.ts +2 -0
  354. package/src/plugins/builtin/risk-factors/headless.ts +2 -0
  355. package/src/plugins/builtin/scanner/flow-history.ts +2 -13
  356. package/src/plugins/builtin/scanner/headless.ts +14 -0
  357. package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
  358. package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
  359. package/src/plugins/builtin/seasonality/headless.ts +2 -0
  360. package/src/plugins/builtin/sec/command-bar-search.ts +14 -4
  361. package/src/plugins/builtin/sec/filing-content.ts +42 -17
  362. package/src/plugins/builtin/sec/forms.ts +12 -0
  363. package/src/plugins/builtin/sec/headless.ts +2 -0
  364. package/src/plugins/builtin/sectors/client.ts +1 -0
  365. package/src/plugins/builtin/sectors/headless.ts +9 -1
  366. package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
  367. package/src/plugins/builtin/sectors/sector-model.ts +2 -0
  368. package/src/plugins/builtin/shared/crypto-pair.ts +14 -0
  369. package/src/plugins/builtin/shared/locked-rows.tsx +1 -1
  370. package/src/plugins/builtin/shared/report-freshness.ts +57 -0
  371. package/src/plugins/builtin/shared/research-cells.tsx +4 -9
  372. package/src/plugins/builtin/short-interest/headless.ts +2 -0
  373. package/src/plugins/builtin/short-interest/watch-headless.ts +1 -0
  374. package/src/plugins/builtin/short-volume/headless.ts +1 -0
  375. package/src/plugins/builtin/social-mentions/headless.ts +1 -0
  376. package/src/plugins/builtin/social-mentions/index.tsx +1 -1
  377. package/src/plugins/builtin/supply-chain/headless.ts +7 -1
  378. package/src/plugins/builtin/supply-chain/index.tsx +10 -1
  379. package/src/plugins/builtin/supply-chain/model.ts +27 -2
  380. package/src/plugins/builtin/supply-chain/pane.tsx +71 -32
  381. package/src/plugins/builtin/themes/headless.ts +3 -0
  382. package/src/plugins/builtin/thirteenf/headless.ts +110 -25
  383. package/src/plugins/builtin/thirteenf/index.tsx +2 -1
  384. package/src/plugins/builtin/thirteenf/report-notes.ts +149 -0
  385. package/src/plugins/builtin/ticker-detail/headless.ts +7 -1
  386. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  387. package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
  388. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
  389. package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
  390. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +26 -26
  391. package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
  392. package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
  393. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
  394. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
  395. package/src/plugins/builtin/time-sales/headless.ts +5 -1
  396. package/src/plugins/builtin/treasury-auctions/headless.ts +1 -0
  397. package/src/plugins/builtin/vol-surface/index.tsx +1 -1
  398. package/src/plugins/builtin/volatility/headless.ts +6 -1
  399. package/src/plugins/builtin/world-indices/headless.ts +2 -0
  400. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +157 -0
  401. package/src/plugins/builtin/world-venue-map/basemap.ts +214 -0
  402. package/src/plugins/builtin/world-venue-map/client.ts +45 -0
  403. package/src/plugins/builtin/world-venue-map/entity-detail.tsx +147 -0
  404. package/src/plugins/builtin/world-venue-map/entity-table.ts +59 -0
  405. package/src/plugins/builtin/world-venue-map/evidence.ts +77 -0
  406. package/src/plugins/builtin/world-venue-map/feed.ts +293 -0
  407. package/src/plugins/builtin/world-venue-map/geo-draw.tsx +248 -0
  408. package/src/plugins/builtin/world-venue-map/geo-series.ts +142 -0
  409. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +447 -0
  410. package/src/plugins/builtin/world-venue-map/headless.ts +146 -0
  411. package/src/plugins/builtin/world-venue-map/ids.ts +1 -0
  412. package/src/plugins/builtin/world-venue-map/index.ts +67 -4
  413. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +552 -0
  414. package/src/plugins/builtin/world-venue-map/layers.ts +348 -0
  415. package/src/plugins/builtin/world-venue-map/map-symbols.ts +220 -0
  416. package/src/plugins/builtin/world-venue-map/map.tsx +381 -45
  417. package/src/plugins/builtin/world-venue-map/model.ts +7 -4
  418. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +199 -0
  419. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n00.chunk.ts +4 -0
  420. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n15.chunk.ts +4 -0
  421. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n30.chunk.ts +4 -0
  422. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n45.chunk.ts +4 -0
  423. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n60.chunk.ts +4 -0
  424. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000n75.chunk.ts +4 -0
  425. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000s15.chunk.ts +4 -0
  426. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000s30.chunk.ts +4 -0
  427. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e000s60.chunk.ts +4 -0
  428. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n00.chunk.ts +4 -0
  429. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n15.chunk.ts +4 -0
  430. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n30.chunk.ts +4 -0
  431. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n45.chunk.ts +4 -0
  432. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n60.chunk.ts +4 -0
  433. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015n75.chunk.ts +4 -0
  434. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015s15.chunk.ts +4 -0
  435. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015s30.chunk.ts +4 -0
  436. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e015s45.chunk.ts +4 -0
  437. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n00.chunk.ts +4 -0
  438. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n15.chunk.ts +4 -0
  439. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n30.chunk.ts +4 -0
  440. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n45.chunk.ts +4 -0
  441. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n60.chunk.ts +4 -0
  442. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030n75.chunk.ts +4 -0
  443. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030s15.chunk.ts +4 -0
  444. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030s30.chunk.ts +4 -0
  445. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030s45.chunk.ts +4 -0
  446. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e030s60.chunk.ts +4 -0
  447. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n00.chunk.ts +4 -0
  448. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n15.chunk.ts +4 -0
  449. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n30.chunk.ts +4 -0
  450. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n45.chunk.ts +4 -0
  451. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n60.chunk.ts +4 -0
  452. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045n75.chunk.ts +4 -0
  453. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045s15.chunk.ts +4 -0
  454. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045s30.chunk.ts +4 -0
  455. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e045s60.chunk.ts +4 -0
  456. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n00.chunk.ts +4 -0
  457. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n15.chunk.ts +4 -0
  458. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n30.chunk.ts +4 -0
  459. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n45.chunk.ts +4 -0
  460. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n60.chunk.ts +4 -0
  461. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060n75.chunk.ts +4 -0
  462. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060s15.chunk.ts +4 -0
  463. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060s30.chunk.ts +4 -0
  464. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e060s60.chunk.ts +4 -0
  465. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n00.chunk.ts +4 -0
  466. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n15.chunk.ts +4 -0
  467. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n30.chunk.ts +4 -0
  468. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n45.chunk.ts +4 -0
  469. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n60.chunk.ts +4 -0
  470. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075n75.chunk.ts +4 -0
  471. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e075s45.chunk.ts +4 -0
  472. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n00.chunk.ts +4 -0
  473. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n15.chunk.ts +4 -0
  474. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n30.chunk.ts +4 -0
  475. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n45.chunk.ts +4 -0
  476. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n60.chunk.ts +4 -0
  477. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090n75.chunk.ts +4 -0
  478. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e090s15.chunk.ts +4 -0
  479. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n00.chunk.ts +4 -0
  480. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n15.chunk.ts +4 -0
  481. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n30.chunk.ts +4 -0
  482. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n45.chunk.ts +4 -0
  483. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n60.chunk.ts +4 -0
  484. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105n75.chunk.ts +4 -0
  485. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105s15.chunk.ts +4 -0
  486. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105s30.chunk.ts +4 -0
  487. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e105s45.chunk.ts +4 -0
  488. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120n00.chunk.ts +4 -0
  489. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120n15.chunk.ts +4 -0
  490. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120n30.chunk.ts +4 -0
  491. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120n45.chunk.ts +4 -0
  492. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120n60.chunk.ts +4 -0
  493. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120s15.chunk.ts +4 -0
  494. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120s30.chunk.ts +4 -0
  495. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e120s45.chunk.ts +4 -0
  496. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n00.chunk.ts +4 -0
  497. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n15.chunk.ts +4 -0
  498. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n30.chunk.ts +4 -0
  499. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n45.chunk.ts +4 -0
  500. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n60.chunk.ts +4 -0
  501. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135n75.chunk.ts +4 -0
  502. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135s15.chunk.ts +4 -0
  503. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135s30.chunk.ts +4 -0
  504. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e135s45.chunk.ts +4 -0
  505. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150n00.chunk.ts +4 -0
  506. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150n15.chunk.ts +4 -0
  507. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150n45.chunk.ts +4 -0
  508. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150n60.chunk.ts +4 -0
  509. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150n75.chunk.ts +4 -0
  510. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150s15.chunk.ts +4 -0
  511. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150s30.chunk.ts +4 -0
  512. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150s45.chunk.ts +4 -0
  513. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e150s60.chunk.ts +4 -0
  514. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165n00.chunk.ts +4 -0
  515. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165n15.chunk.ts +4 -0
  516. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165n45.chunk.ts +4 -0
  517. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165n60.chunk.ts +4 -0
  518. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165s15.chunk.ts +4 -0
  519. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165s30.chunk.ts +4 -0
  520. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165s45.chunk.ts +4 -0
  521. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/e165s60.chunk.ts +4 -0
  522. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n00.chunk.ts +4 -0
  523. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n15.chunk.ts +4 -0
  524. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n30.chunk.ts +4 -0
  525. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n45.chunk.ts +4 -0
  526. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n60.chunk.ts +4 -0
  527. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015n75.chunk.ts +4 -0
  528. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015s15.chunk.ts +4 -0
  529. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015s30.chunk.ts +4 -0
  530. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w015s45.chunk.ts +4 -0
  531. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030n00.chunk.ts +4 -0
  532. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030n15.chunk.ts +4 -0
  533. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030n30.chunk.ts +4 -0
  534. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030n60.chunk.ts +4 -0
  535. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030n75.chunk.ts +4 -0
  536. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030s30.chunk.ts +4 -0
  537. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w030s60.chunk.ts +4 -0
  538. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045n30.chunk.ts +4 -0
  539. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045n45.chunk.ts +4 -0
  540. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045n60.chunk.ts +4 -0
  541. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045n75.chunk.ts +4 -0
  542. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045s15.chunk.ts +4 -0
  543. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045s30.chunk.ts +4 -0
  544. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w045s60.chunk.ts +4 -0
  545. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060n00.chunk.ts +4 -0
  546. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060n30.chunk.ts +4 -0
  547. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060n45.chunk.ts +4 -0
  548. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060n60.chunk.ts +4 -0
  549. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060n75.chunk.ts +4 -0
  550. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060s15.chunk.ts +4 -0
  551. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060s30.chunk.ts +4 -0
  552. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060s45.chunk.ts +4 -0
  553. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w060s60.chunk.ts +4 -0
  554. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n00.chunk.ts +4 -0
  555. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n15.chunk.ts +4 -0
  556. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n30.chunk.ts +4 -0
  557. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n45.chunk.ts +4 -0
  558. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n60.chunk.ts +4 -0
  559. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075n75.chunk.ts +4 -0
  560. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075s15.chunk.ts +4 -0
  561. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075s30.chunk.ts +4 -0
  562. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075s45.chunk.ts +4 -0
  563. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w075s60.chunk.ts +4 -0
  564. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n00.chunk.ts +4 -0
  565. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n15.chunk.ts +4 -0
  566. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n30.chunk.ts +4 -0
  567. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n45.chunk.ts +4 -0
  568. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n60.chunk.ts +4 -0
  569. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090n75.chunk.ts +4 -0
  570. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090s15.chunk.ts +4 -0
  571. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090s30.chunk.ts +4 -0
  572. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090s45.chunk.ts +4 -0
  573. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w090s60.chunk.ts +4 -0
  574. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n00.chunk.ts +4 -0
  575. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n15.chunk.ts +4 -0
  576. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n30.chunk.ts +4 -0
  577. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n45.chunk.ts +4 -0
  578. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n60.chunk.ts +4 -0
  579. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105n75.chunk.ts +4 -0
  580. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w105s15.chunk.ts +4 -0
  581. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n00.chunk.ts +4 -0
  582. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n15.chunk.ts +4 -0
  583. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n30.chunk.ts +4 -0
  584. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n45.chunk.ts +4 -0
  585. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n60.chunk.ts +4 -0
  586. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120n75.chunk.ts +4 -0
  587. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w120s30.chunk.ts +4 -0
  588. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w135n30.chunk.ts +4 -0
  589. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w135n45.chunk.ts +4 -0
  590. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w135n60.chunk.ts +4 -0
  591. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w135n75.chunk.ts +4 -0
  592. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w135s30.chunk.ts +4 -0
  593. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w150n45.chunk.ts +4 -0
  594. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w150n60.chunk.ts +4 -0
  595. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w150s15.chunk.ts +4 -0
  596. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w150s30.chunk.ts +4 -0
  597. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165n00.chunk.ts +4 -0
  598. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165n15.chunk.ts +4 -0
  599. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165n45.chunk.ts +4 -0
  600. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165n60.chunk.ts +4 -0
  601. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165s15.chunk.ts +4 -0
  602. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w165s30.chunk.ts +4 -0
  603. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180n00.chunk.ts +4 -0
  604. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180n15.chunk.ts +4 -0
  605. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180n45.chunk.ts +4 -0
  606. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180n60.chunk.ts +4 -0
  607. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180s15.chunk.ts +4 -0
  608. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180s30.chunk.ts +4 -0
  609. package/src/plugins/builtin/world-venue-map/natural-earth/tiles/w180s45.chunk.ts +4 -0
  610. package/src/plugins/builtin/world-venue-map/natural-earth/world-50m.chunk.ts +4 -0
  611. package/src/plugins/builtin/world-venue-map/pane.tsx +28 -43
  612. package/src/plugins/builtin/world-venue-map/use-geo.ts +55 -0
  613. package/src/plugins/builtin/world-venue-map/venue-header.tsx +32 -0
  614. package/src/plugins/builtin/yield-curve/headless.ts +16 -0
  615. package/src/plugins/catalog-browser.ts +2 -0
  616. package/src/plugins/catalog-manifest.ts +1 -0
  617. package/src/plugins/catalog-ui.ts +3 -1
  618. package/src/plugins/catalog.ts +1 -1
  619. package/src/plugins/host-link.ts +33 -1
  620. package/src/plugins/installer.ts +48 -12
  621. package/src/plugins/loader.ts +30 -3
  622. package/src/plugins/manager-node.ts +15 -5
  623. package/src/plugins/registry/index.ts +1 -1
  624. package/src/plugins/registry/pane-settings.ts +26 -7
  625. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  626. package/src/renderers/dom/build-assets.ts +81 -3
  627. package/src/renderers/dom/data-table/row.tsx +52 -16
  628. package/src/renderers/dom/desktop/controls.tsx +7 -3
  629. package/src/renderers/dom/desktop/icons.tsx +1 -0
  630. package/src/renderers/dom/desktop/list-view.tsx +35 -10
  631. package/src/renderers/dom/desktop/popover.tsx +73 -14
  632. package/src/renderers/dom/host/box.tsx +69 -26
  633. package/src/renderers/dom/host/input.tsx +14 -3
  634. package/src/renderers/dom/host/tabs.tsx +37 -1
  635. package/src/renderers/dom/styles.css +41 -7
  636. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
  637. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  638. package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
  639. package/src/renderers/electrobun/bun/index.ts +73 -6
  640. package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
  641. package/src/renderers/electrobun/shared/protocol.ts +3 -0
  642. package/src/renderers/electrobun/view/backend-rpc.ts +10 -4
  643. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -1
  644. package/src/renderers/electrobun/view/main.tsx +7 -1
  645. package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
  646. package/src/renderers/electrobun/view/rpc-timeout.ts +14 -3
  647. package/src/renderers/electrobun/view/update-host.ts +45 -21
  648. package/src/renderers/opentui/host.tsx +5 -0
  649. package/src/sources/gloomberb-cloud/index.ts +25 -4
  650. package/src/sources/gloomberb-cloud/normalizers.ts +36 -5
  651. package/src/sources/provider-router/cached-routes.ts +4 -1
  652. package/src/sources/provider-router/financials.ts +11 -8
  653. package/src/sources/provider-router/history.ts +15 -10
  654. package/src/sources/sec-registrant.ts +75 -0
  655. package/src/state/app/context/index.tsx +24 -8
  656. package/src/state/hooks/quote-streaming.ts +41 -26
  657. package/src/sync/core-contributors.ts +4 -0
  658. package/src/telemetry/benign-window-error.ts +7 -0
  659. package/src/telemetry/crash-reports-dom.ts +2 -0
  660. package/src/telemetry/crash-reports.ts +4 -1
  661. package/src/test-support/cli-context.ts +10 -2
  662. package/src/test-support/daily-nav.ts +25 -0
  663. package/src/theme/heat-colors.ts +357 -2
  664. package/src/tickers/instrument-kind.ts +34 -7
  665. package/src/tickers/search/asset-classes.ts +24 -9
  666. package/src/tickers/search/index.ts +44 -11
  667. package/src/tickers/search/ranking.ts +23 -3
  668. package/src/time-series/resolve.ts +1 -1
  669. package/src/time-series/use-chart-resolution.ts +1 -1
  670. package/src/types/context-menu.ts +6 -0
  671. package/src/types/data-provider.ts +2 -0
  672. package/src/types/financials.ts +5 -0
  673. package/src/types/headless.ts +79 -2
  674. package/src/types/plugin.ts +38 -6
  675. package/src/types/ticker.ts +15 -0
  676. package/src/ui/context-menu.tsx +1 -0
  677. package/src/ui/host.tsx +16 -0
  678. package/src/updater/index.ts +38 -13
  679. package/src/utils/async-deadline.ts +21 -0
  680. package/src/utils/cli-output.ts +5 -1
  681. package/src/utils/crypto-pair.ts +41 -0
  682. package/src/utils/currency-units.ts +14 -0
  683. package/src/utils/depositary-receipt.ts +26 -0
  684. package/src/utils/exchanges.ts +72 -0
  685. package/src/utils/fire-and-forget.ts +24 -0
  686. package/src/utils/format.ts +13 -0
  687. package/src/utils/price-earnings.ts +70 -3
  688. package/src/utils/price-history.ts +58 -13
  689. package/src/utils/rpc-timeout-error.ts +27 -0
  690. package/src/utils/sec.ts +13 -1
  691. package/src/utils/utc-time.ts +79 -0
@@ -1,145 +1,35 @@
1
- import { usePaneArrowsClaimed } from "../../layout/pane/footer/registration";
2
- import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
- import {
4
- AsciiText,
5
- Box,
6
- ChartSurface,
7
- ScrollBox,
8
- Text,
9
- useNativeRenderer,
10
- useUiCapabilities,
11
- type BoxRenderable,
12
- type ChartSurfaceProps,
13
- type ScrollBoxRenderable,
14
- } from "../../../ui";
15
- import { useShortcut } from "../../../react/input";
16
- import { usePaneFooter } from "../../layout/pane/footer/registration";
17
- import type { PaneHint } from "../../layout/pane/footer/model";
18
- import type { ContextMenuItem } from "../../../types/context-menu";
19
- import { useOptionalPaneInstanceId, usePaneSettingValue } from "../../../state/app/context";
20
- import { colors as themeColors, hoverBg } from "../../../theme/colors";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { useCompositeChartKeyboard } from "./use-composite-chart-keyboard";
3
+ import { useCompositeViewport } from "./use-composite-viewport";
4
+ import { CompositePanelSurface } from "./panel-surface";
5
+ import { MINIMUM_AXIS_LABEL_WIDTH, compositeAxisLabelWidth } from "./panel-labels";
6
+ import { isVerticalWheelDirection } from "./panel-gestures";
7
+ import { type ChartToolSpan, CHART_DRAWING_COLORS, isDrawingTool, type ChartDrawing } from "./tools";
8
+ import { ChartDrawingStore, NO_DRAWINGS } from "./drawing-store";
9
+ import { CompositeLegend } from "./legend";
10
+ import { ChartToolbar, CHART_TOOLBAR_WIDTH } from "./toolbar";
11
+ import { AsciiText, Box, ChartSurface, Text, useUiCapabilities } from "../../../ui";
12
+ import { useOptionalPaneInstanceId } from "../../../state/app/context";
21
13
  import { useThemeColors } from "../../../theme/theme-context";
22
14
  import { chartSurfaceBackground, usePaneSurface } from "../../layout/pane/surface";
23
- import { CANONICAL_EXCHANGE_ALIASES } from "../../../utils/exchanges";
24
- import { displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format";
25
- import { isPlainKey } from "../../../utils/keyboard";
26
15
  import { CHART_WATERMARK_ROLE } from "../../../utils/screenshot-watermark";
27
16
  import type { ResolvedSeries } from "../../../time-series/types";
28
17
  import { downsampleCompositeChartScene } from "./downsample";
29
18
  import { reuseResolvedSeriesList } from "./panel-series";
30
- import {
31
- consumeChartMouseEvent,
32
- getGlobalMouseX,
33
- getLocalPlotPointer,
34
- type ChartMouseEvent,
35
- } from "../core/pointer";
36
- import type { NativeChartBitmap } from "../native/chart-rasterizer";
19
+ import { consumeChartMouseEvent, type ChartMouseEvent } from "../core/pointer";
37
20
  import { useShowChartTextFallback } from "../native/use-chart-text-fallback";
38
- import {
39
- useStaticChartBitmapSize,
40
- type StaticChartBitmapSize,
41
- } from "./bitmap";
42
- import {
43
- StaticXAxisLabels,
44
- StaticXMarkerLabels,
45
- StaticXMarkerOverlay,
46
- } from "./axis-overlays";
47
- import { PriceAxisLabels } from "./price-axis-labels";
48
- import {
49
- compositeAxisTicks,
50
- formatCompositeCursorValue,
51
- formatCompositePointDetails,
52
- formatCompositeSeriesValue,
53
- seriesPriceReference,
54
- formatCompositeTimeAxisDate,
55
- type CompositeAxisValueFormatter,
56
- } from "./format";
57
- import {
58
- COMPOSITE_KEYBOARD_PAN_RATIO,
59
- COMPOSITE_ZOOM_STEP_FACTOR,
60
- buildCompositeNavigationFrame,
61
- compositeNavigationDataViewport,
62
- compositeViewportPositions,
63
- fitCompositeViewport,
64
- panCompositeViewport,
65
- resolveCompositeChartInteraction,
66
- resolveCompositeWheelPan,
67
- resolveCompositeWheelZoom,
68
- sameCompositeViewport,
69
- shouldResetCompositeViewport,
70
- zoomCompositeViewport,
71
- type CompositeNavigationFrame,
72
- type CompositeViewportRange,
73
- } from "./interactions";
74
- import { buildCompositeColumnLayout, type CompositeColumnLayout } from "./column-layout";
75
- import { renderCompositePanelBitmap } from "./rasterizer";
76
- import {
77
- fitAxisLabels,
78
- hitTestLevel,
79
- levelGrabRatio,
80
- levelPanel,
81
- levelVectors,
82
- paintLevels,
83
- projectLevels,
84
- roundLevelValue,
85
- writeLevelText,
86
- type CompositeChartLevel,
87
- type CompositeChartLevels,
88
- type ProjectedLevel,
89
- } from "./levels";
90
- import {
91
- buildChartToolVectors,
92
- CHART_DRAWING_COLORS,
93
- CHART_DRAWINGS_SETTING_KEY,
94
- countMeasureBars,
95
- drawChartToolOverlay,
96
- resolveChartToolKind,
97
- resolveMeasureAxisDomain,
98
- resolveMeasureDirection,
99
- resolveMeasureValueAt,
100
- resolveZoomTimeRange,
101
- hitTestDrawings,
102
- isDrawingTool,
103
- nextDrawingColor,
104
- parseChartDrawings,
105
- resolveDrawingFromDrag,
106
- resolveZoomBoxRange,
107
- shiftDrawing,
108
- summarizeMeasure,
109
- summarizeZoomRange,
110
- summarizeZoomSelection,
111
- type ChartDrawing,
112
- type ChartDrawingPoint,
113
- type ChartToolDrag,
114
- type ChartToolKind,
115
- } from "./tools";
116
- import {
117
- COMPOSITE_RIGHT_OFFSET_RATIO,
118
- compositeRightOffsetRatio,
119
- projectCompositeTimestamp,
120
- unprojectCompositeTimestamp,
121
- } from "./time-scale";
21
+ import { StaticXAxisLabels, StaticXMarkerLabels, StaticXMarkerOverlay } from "./axis-overlays";
22
+ import { formatCompositeTimeAxisDate } from "./format";
23
+ import { resolveCompositeWheelPan, resolveCompositeWheelZoom } from "./interactions";
24
+ import { COMPOSITE_RIGHT_OFFSET_RATIO } from "./time-scale";
122
25
  import {
123
26
  allocateCompositePanelHeights,
124
27
  applyCompositeChartCursor,
125
28
  buildCompositeChartScene,
126
- projectCompositeValue,
127
29
  resizeCompositePanel,
128
- resolveAdjacentCompositeCursorDate,
129
- resolveCompositeCursorDate,
130
- unprojectCompositeValue,
131
30
  } from "./scene";
132
- import {
133
- compositeAxisTickLabels,
134
- renderCompositeAxisText,
135
- renderCompositePanelText,
136
- } from "./text-renderer";
137
- import {
138
- buildCompositeTimeAxisLayout,
139
- buildCompositeViewportTimeAxisLayout,
140
- } from "./time-axis";
31
+ import { buildCompositeTimeAxisLayout, buildCompositeViewportTimeAxisLayout } from "./time-axis";
141
32
  import type {
142
- CompositeAxisDomain,
143
33
  CompositeChartColors,
144
34
  CompositeChartProps,
145
35
  CompositeChartScene,
@@ -147,408 +37,6 @@ import type {
147
37
  CompositePanelScene,
148
38
  } from "./types";
149
39
 
150
- // A short resize-only delay coalesces geometry churn without delaying
151
- // live-data paints or depending on a foreground animation frame.
152
- const DESKTOP_BITMAP_RESIZE_DEBOUNCE_MS = 32;
153
- const LEGEND_WHEEL_DELTA_PER_CELL = 8;
154
- /** How far past the first loaded observation a backfilling chart may pan, as a fraction of the view. */
155
- const HISTORICAL_PADDING_RATIO = 0.5;
156
-
157
- function isVerticalWheelDirection(
158
- direction: "up" | "down" | "left" | "right",
159
- ): direction is "up" | "down" {
160
- return direction === "up" || direction === "down";
161
- }
162
-
163
- interface PanGesture {
164
- kind: "pan";
165
- startGlobalX: number;
166
- frame: CompositeNavigationFrame;
167
- startViewport: CompositeViewportRange;
168
- positionsPerCell: number;
169
- }
170
-
171
- /**
172
- * A drag pans in the frame it started in; see `CompositeNavigationFrame`. The
173
- * plot covers the viewport plus its reserved right offset, so a cell is worth
174
- * that much time and the bars keep pace with the pointer.
175
- */
176
- function startPanGesture(
177
- frame: CompositeNavigationFrame,
178
- viewport: CompositeViewportRange,
179
- plotWidth: number,
180
- globalX: number,
181
- plotSpanFactor: number,
182
- ): PanGesture {
183
- const positions = compositeViewportPositions(frame, viewport);
184
- const span = positions ? Math.max(positions.end - positions.start, Number.EPSILON) : 1;
185
- return {
186
- kind: "pan",
187
- startGlobalX: globalX,
188
- frame,
189
- startViewport: viewport,
190
- positionsPerCell: span * plotSpanFactor / Math.max(plotWidth, 1),
191
- };
192
- }
193
-
194
- interface PendingWheel {
195
- /** Fraction of the visible span, positive toward older observations. */
196
- panRatio: number;
197
- zoomLog: number;
198
- anchorRatio: number;
199
- }
200
-
201
- const webFrame = globalThis as typeof globalThis & {
202
- requestAnimationFrame?: (callback: () => void) => number;
203
- cancelAnimationFrame?: (handle: number) => void;
204
- };
205
-
206
- function renderPanelBitmap(
207
- panel: CompositePanelScene,
208
- bitmapSize: StaticChartBitmapSize,
209
- colors: CompositeChartColors,
210
- snapGridToRows = false,
211
- ): NativeChartBitmap {
212
- return renderCompositePanelBitmap(panel, {
213
- pixelWidth: bitmapSize.pixelWidth,
214
- pixelHeight: bitmapSize.pixelHeight,
215
- colors,
216
- snapGridToRows,
217
- });
218
- }
219
-
220
- function useCompositePanelBitmap({
221
- panel,
222
- bitmapSize,
223
- colors,
224
- isDesktopWeb,
225
- }: {
226
- panel: CompositePanelScene;
227
- bitmapSize: StaticChartBitmapSize | null;
228
- colors: CompositeChartColors;
229
- isDesktopWeb: boolean;
230
- }): NativeChartBitmap | null {
231
- const [desktopBitmap, setDesktopBitmap] = useState<NativeChartBitmap | null>(null);
232
- const desktopBitmapRef = useRef<NativeChartBitmap | null>(null);
233
- const desktopRenderInputRef = useRef<{
234
- panel: CompositePanelScene;
235
- pixelWidth: number;
236
- pixelHeight: number;
237
- colors: CompositeChartColors;
238
- } | null>(null);
239
- const desktopRequestedSizeRef = useRef<{ pixelWidth: number; pixelHeight: number } | null>(null);
240
- const desktopRenderedSizeRef = useRef<{ pixelWidth: number; pixelHeight: number } | null>(null);
241
- const desktopRenderTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null);
242
- const desktopActiveRef = useRef(false);
243
- const pixelWidth = bitmapSize?.pixelWidth ?? null;
244
- const pixelHeight = bitmapSize?.pixelHeight ?? null;
245
-
246
- desktopRenderInputRef.current = isDesktopWeb && pixelWidth !== null && pixelHeight !== null
247
- ? { panel, pixelWidth, pixelHeight, colors }
248
- : null;
249
-
250
- // The cursor is drawn as a separate overlay, so the plot raster stays cached
251
- // (and resident in the terminal) while the crosshair moves.
252
- const terminalBitmap = useMemo(() => {
253
- if (isDesktopWeb || !bitmapSize) return null;
254
- return renderPanelBitmap(panel, bitmapSize, colors, true);
255
- }, [bitmapSize, colors, isDesktopWeb, panel]);
256
-
257
- useEffect(() => {
258
- const cancelRender = () => {
259
- if (desktopRenderTimerRef.current === null) return;
260
- clearTimeout(desktopRenderTimerRef.current);
261
- desktopRenderTimerRef.current = null;
262
- };
263
- const scheduleRender = (delay: number) => {
264
- if (desktopRenderTimerRef.current !== null) return;
265
- desktopRenderTimerRef.current = globalThis.setTimeout(() => {
266
- desktopRenderTimerRef.current = null;
267
- if (!desktopActiveRef.current) return;
268
- const input = desktopRenderInputRef.current;
269
- if (!input) return;
270
- const next = renderPanelBitmap(
271
- input.panel,
272
- { pixelWidth: input.pixelWidth, pixelHeight: input.pixelHeight },
273
- input.colors,
274
- );
275
- if (!desktopActiveRef.current) return;
276
- desktopRenderedSizeRef.current = {
277
- pixelWidth: input.pixelWidth,
278
- pixelHeight: input.pixelHeight,
279
- };
280
- desktopBitmapRef.current = next;
281
- setDesktopBitmap(next);
282
- }, delay);
283
- };
284
-
285
- if (!isDesktopWeb) {
286
- desktopActiveRef.current = false;
287
- cancelRender();
288
- desktopRequestedSizeRef.current = null;
289
- desktopRenderedSizeRef.current = null;
290
- return;
291
- }
292
- if (pixelWidth === null || pixelHeight === null) {
293
- desktopActiveRef.current = false;
294
- cancelRender();
295
- desktopRequestedSizeRef.current = null;
296
- desktopRenderedSizeRef.current = null;
297
- desktopBitmapRef.current = null;
298
- setDesktopBitmap((current) => current === null ? current : null);
299
- return;
300
- }
301
-
302
- desktopActiveRef.current = true;
303
- const nextSize = { pixelWidth, pixelHeight };
304
- const requestedSize = desktopRequestedSizeRef.current;
305
- const requestedSizeChanged = !requestedSize
306
- || requestedSize.pixelWidth !== pixelWidth
307
- || requestedSize.pixelHeight !== pixelHeight;
308
- desktopRequestedSizeRef.current = nextSize;
309
- const renderedSize = desktopRenderedSizeRef.current;
310
- const sizeAlreadyRendered = !!renderedSize
311
- && renderedSize.pixelWidth === pixelWidth
312
- && renderedSize.pixelHeight === pixelHeight;
313
-
314
- if (!desktopBitmapRef.current || sizeAlreadyRendered) {
315
- if (requestedSizeChanged) cancelRender();
316
- scheduleRender(0);
317
- return;
318
- }
319
-
320
- if (!requestedSizeChanged || desktopRenderTimerRef.current !== null) return;
321
- scheduleRender(DESKTOP_BITMAP_RESIZE_DEBOUNCE_MS);
322
- }, [colors, isDesktopWeb, panel, pixelHeight, pixelWidth]);
323
-
324
- useEffect(() => () => {
325
- desktopActiveRef.current = false;
326
- if (desktopRenderTimerRef.current !== null) {
327
- clearTimeout(desktopRenderTimerRef.current);
328
- desktopRenderTimerRef.current = null;
329
- }
330
- }, []);
331
-
332
- if (!bitmapSize) return null;
333
- return isDesktopWeb ? desktopBitmap : terminalBitmap;
334
- }
335
-
336
- function resolvePanelCrosshair(
337
- panel: CompositePanelScene,
338
- columnLayout: CompositeColumnLayout,
339
- bitmap: NativeChartBitmap | null,
340
- cursorXRatio: number | null,
341
- cursorYRatio: number | null,
342
- color: string,
343
- ): ChartSurfaceProps["crosshair"] {
344
- if (!bitmap || cursorXRatio === null) return null;
345
- const markers = panel.series.flatMap((series) => {
346
- // Column cohorts are drawn at their group center, not each observation's
347
- // own timestamp, so match the position the bar actually occupies.
348
- const cursorPoint = series.points.find((point) => {
349
- const xRatio = series.source.style === "columns"
350
- ? columnLayout.groupByPoint.get(point)?.xRatio ?? point.xRatio
351
- : point.xRatio;
352
- return Math.abs(xRatio - cursorXRatio) < 1e-9;
353
- });
354
- return cursorPoint
355
- ? [{
356
- pixelY: cursorPoint.yRatio * Math.max(bitmap.height - 1, 0),
357
- color: series.source.color,
358
- }]
359
- : [];
360
- });
361
- return {
362
- pixelX: cursorXRatio * Math.max(bitmap.width - 1, 0),
363
- pixelY: cursorYRatio === null ? null : cursorYRatio * Math.max(bitmap.height - 1, 0),
364
- color,
365
- markers,
366
- };
367
- }
368
-
369
- function axisLabelRows(lines: string[]): ReadonlyMap<number, string> {
370
- return new Map(lines.flatMap((line, row) => {
371
- const label = line.trim();
372
- return label ? [[row, label] as const] : [];
373
- }));
374
- }
375
-
376
- function cursorAxisLabel(
377
- panel: CompositePanelScene,
378
- side: "left" | "right",
379
- cursorYRatio: number | null,
380
- format?: CompositeAxisValueFormatter,
381
- ): string | null {
382
- const domain = panel.axes[side];
383
- if (!domain || cursorYRatio === null) return null;
384
- const value = unprojectCompositeValue(cursorYRatio, domain);
385
- if (value === null) return null;
386
- return format ? format(value, domain) : formatCompositeCursorValue(value, domain);
387
- }
388
-
389
- const MINIMUM_AXIS_LABEL_WIDTH = 3;
390
-
391
- /**
392
- * Reserve cursor precision even when the tick values are round numbers. This
393
- * keeps the plot stable while the pointer moves between integer tick values.
394
- */
395
- function compositeAxisLabelWidth(
396
- domain: CompositeAxisDomain | undefined,
397
- format: CompositeAxisValueFormatter | undefined,
398
- includeCursor: boolean,
399
- ): number {
400
- if (!domain) return 0;
401
- const ticks = compositeAxisTicks(domain, format);
402
- const labels = ticks.map((tick) => tick.label);
403
- if (includeCursor) {
404
- const cursorFormat = format ?? formatCompositeCursorValue;
405
- for (const { value } of ticks) {
406
- labels.push(cursorFormat(value, domain));
407
- // Totals use two decimals at their compact scale; prices can use four
408
- // below one currency unit. Probe those digits without changing the data.
409
- const scale = domain.unitGroup.toLowerCase().split(":")[0] === "currency-total"
410
- ? 10 ** Math.max(0, Math.min(12, Math.floor(Math.log10(Math.abs(value) || 1) / 3) * 3))
411
- : Math.abs(value) > 0 && Math.abs(value) < 0.01
412
- ? 10 ** Math.floor(Math.log10(Math.abs(value))) : 1;
413
- const precisionValue = (value < 0 ? -1 : 1) * (Math.floor(Math.abs(value) / scale) + 0.1234) * scale;
414
- labels.push(cursorFormat(precisionValue, domain));
415
- }
416
- }
417
- return labels.reduce((widest, label) => Math.max(widest, [...label].length), 0);
418
- }
419
-
420
-
421
- const HAND_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M6.4 8V3.5a1.15 1.15 0 0 1 2.3 0V7m0-.6a1.15 1.15 0 0 1 2.3 0V8m0-.5a1.15 1.15 0 0 1 2.3 0v3.1c0 2.1-1.7 3.8-3.8 3.8H8.9c-1.2 0-2.3-.6-3-1.6L3.6 9.4a1.15 1.15 0 0 1 1.8-1.4L6.4 9.2" fill="none" stroke="#000" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
422
- const RULER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1.4" y="4.6" width="13.2" height="6.8" rx="1.4" fill="none" stroke="#000" stroke-width="1.4"/><path d="M5 4.6v2.6M8 4.6v3.6M11 4.6v2.6" stroke="#000" stroke-width="1.3" stroke-linecap="round"/></svg>`;
423
- const PEN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2.4 13.6 4 9.9 10.6 3.3a1.6 1.6 0 0 1 2.3 0l0 0a1.6 1.6 0 0 1 0 2.3L6.2 12 2.4 13.6Z" fill="none" stroke="#000" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
424
- const LINE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3.2 12.8 12.8 3.2" stroke="#000" stroke-width="1.6" stroke-linecap="round"/><circle cx="3.2" cy="12.8" r="2" fill="none" stroke="#000" stroke-width="1.4"/><circle cx="12.8" cy="3.2" r="2" fill="none" stroke="#000" stroke-width="1.4"/></svg>`;
425
- const LEVEL_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M1.6 8h4.2M10.2 8h4.2" stroke="#000" stroke-width="1.6" stroke-linecap="round"/><circle cx="8" cy="8" r="2.2" fill="none" stroke="#000" stroke-width="1.4"/></svg>`;
426
- const MARQUEE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2.2 6V3.4a1.2 1.2 0 0 1 1.2-1.2H6M10 2.2h2.6a1.2 1.2 0 0 1 1.2 1.2V6M13.8 10v2.6a1.2 1.2 0 0 1-1.2 1.2H10M6 13.8H3.4a1.2 1.2 0 0 1-1.2-1.2V10" fill="none" stroke="#000" stroke-width="1.7" stroke-linecap="round"/></svg>`;
427
-
428
- const CHART_TOOLS: ReadonlyArray<{
429
- /** null is the resting state: the pointer pans and nothing is armed. */
430
- kind: ChartToolKind | null;
431
- label: string;
432
- shortcut: string;
433
- hint: string;
434
- glyph: string;
435
- icon: string;
436
- }> = [
437
- {
438
- kind: null,
439
- label: "Pan",
440
- shortcut: "Esc",
441
- hint: "Drag to move through time, the resting state of the pointer",
442
- glyph: "\u2725",
443
- icon: HAND_ICON,
444
- },
445
- {
446
- kind: "measure",
447
- label: "Ruler",
448
- shortcut: "Shift+M",
449
- hint: "Drag to measure change, percent, bars, and elapsed time",
450
- glyph: "\u2194",
451
- icon: RULER_ICON,
452
- },
453
- {
454
- kind: "zoom",
455
- label: "Zoom to range",
456
- shortcut: "Shift+Z",
457
- hint: "Drag to select a time range to zoom into",
458
- glyph: "\u229e",
459
- icon: MARQUEE_ICON,
460
- },
461
- {
462
- kind: "line",
463
- label: "Trend line",
464
- shortcut: "Shift+D",
465
- hint: "Drag a straight line, grab an end to reshape it, Backspace deletes",
466
- glyph: "\u2571",
467
- icon: LINE_ICON,
468
- },
469
- {
470
- kind: "pencil",
471
- label: "Freehand",
472
- shortcut: "Shift+P",
473
- hint: "Draw freehand, drag a shape to move it, Backspace deletes",
474
- glyph: "\u223f",
475
- icon: PEN_ICON,
476
- },
477
- {
478
- kind: "level",
479
- label: "Price level",
480
- shortcut: "Shift+H",
481
- hint: "Click to add a level every chart of this ticker shows, drag one to move it, Backspace deletes",
482
- glyph: "\u2550",
483
- icon: LEVEL_ICON,
484
- },
485
- ];
486
-
487
- interface ChartToolSpan {
488
- startXRatio: number;
489
- endXRatio: number;
490
- /** The anchor's own time, which can sit outside the view once it pans. */
491
- startTime: number;
492
- color: string;
493
- }
494
-
495
- /** Pane menu names for the tools, with the keys that pick them. */
496
- const CHART_TOOL_MENU: ReadonlyArray<{ kind: ChartToolKind; label: string; accelerator: string }> = [
497
- { kind: "measure", label: "Ruler", accelerator: "Shift+M" },
498
- { kind: "zoom", label: "Zoom to Range", accelerator: "Shift+Z" },
499
- { kind: "line", label: "Trend Line", accelerator: "Shift+D" },
500
- { kind: "pencil", label: "Freehand", accelerator: "Shift+P" },
501
- { kind: "level", label: "Price Level", accelerator: "Shift+H" },
502
- ];
503
-
504
- /** What Enter does next with a tool in hand, for the footer and the pane menu. */
505
- const KEYBOARD_TOOL_HINTS: Record<ChartToolKind, {
506
- start: string;
507
- startTitle: string;
508
- finish: string;
509
- finishTitle: string;
510
- }> = {
511
- measure: { start: "measure", startTitle: "Start Measure", finish: "done", finishTitle: "Finish Measure" },
512
- zoom: { start: "select", startTitle: "Select Range", finish: "zoom", finishTitle: "Zoom to Range" },
513
- line: { start: "draw", startTitle: "Draw Line", finish: "place", finishTitle: "Place Line" },
514
- pencil: { start: "draw", startTitle: "Draw Freehand", finish: "place", finishTitle: "Place Drawing" },
515
- level: { start: "add level", startTitle: "Add Level", finish: "add level", finishTitle: "Add Level" },
516
- };
517
-
518
- /**
519
- * A tool placed from the keyboard: Enter anchors it at the cursor, the arrows
520
- * move its end and Enter finishes it. Held in data, so a pan, a zoom or a new
521
- * bar leaves it on the observations it was placed on.
522
- */
523
- interface KeyboardToolPlacement {
524
- kind: ChartToolKind;
525
- panelId: string;
526
- start: ChartDrawingPoint;
527
- end: ChartDrawingPoint;
528
- /** Every step the end took: the freehand tool's trail. */
529
- path: ChartDrawingPoint[];
530
- /** Up or Down set the end's level, so Left and Right stop following the series. */
531
- freeValue: boolean;
532
- }
533
-
534
- /** A keyboard placement projected into the plot of the panel that holds it. */
535
- interface KeyboardToolDrag {
536
- panelId: string;
537
- drag: ChartToolDrag;
538
- start: ChartDrawingPoint;
539
- end: ChartDrawingPoint;
540
- }
541
-
542
- const ARMED_TOOL_BY_INTERACTION = {
543
- "arm-measure": "measure",
544
- "arm-zoom": "zoom",
545
- "arm-line": "line",
546
- "arm-pencil": "pencil",
547
- "arm-level": "level",
548
- } as const satisfies Record<string, ChartToolKind>;
549
-
550
- const COMPOSITE_PANEL_ROLE = "composite-chart-panel";
551
-
552
40
  // Wordmark cell grid at scale 1 (27 columns of 8px, 4 rows of 12px).
553
41
  const WATERMARK_BASE_WIDTH_PX = 216;
554
42
  const WATERMARK_BASE_HEIGHT_PX = 48;
@@ -567,1385 +55,7 @@ function chartWatermarkScale(plotWidthPx: number, plotHeightPx: number): number
567
55
  return scale >= 1 ? Math.round(scale * 4) / 4 : null;
568
56
  }
569
57
 
570
- let nextDrawingSequence = 1;
571
-
572
- function nextDrawingId(): string {
573
- return `drawing:${nextDrawingSequence++}`;
574
- }
575
-
576
- /** Levels are stored with the account, so their ids must not repeat across sessions. */
577
- function nextLevelId(): string {
578
- return `${Date.now().toString(36)}-${(nextDrawingSequence++).toString(36)}`;
579
- }
580
-
581
- const NO_LEVELS: readonly ProjectedLevel[] = [];
582
-
583
- /** Shift and a letter, whether or not the terminal reports the shift itself. */
584
- function isShiftedLetter(event: { name?: string; shift?: boolean; ctrl?: boolean; meta?: boolean; alt?: boolean }, letter: string): boolean {
585
- if (event.ctrl || event.meta || event.alt) return false;
586
- return event.name === letter.toUpperCase() || (event.shift === true && event.name === letter);
587
- }
588
-
589
- /** Icon cells plus the gap between chips. */
590
- const CHART_TOOLBAR_WIDTH = CHART_TOOLS.length * 3 + (CHART_TOOLS.length - 1);
591
-
592
- function ChartToolChip({
593
- tool,
594
- active,
595
- isDesktopWeb,
596
- onPress,
597
- }: {
598
- tool: (typeof CHART_TOOLS)[number];
599
- active: boolean;
600
- isDesktopWeb: boolean;
601
- onPress: () => void;
602
- }) {
603
- const label = `${tool.label} (${tool.shortcut}). ${tool.hint}`;
604
- const color = active ? themeColors.text : themeColors.textDim;
605
- return (
606
- <Box
607
- flexDirection="row"
608
- alignItems="center"
609
- justifyContent="center"
610
- width={3}
611
- height={1}
612
- flexShrink={0}
613
- backgroundColor={active ? themeColors.selected : undefined}
614
- hoverBackgroundColor={hoverBg()}
615
- onMouseDown={(event: ChartMouseEvent) => {
616
- consumeChartMouseEvent(event);
617
- onPress();
618
- }}
619
- cursor="pointer"
620
- data-gloom-interactive="true"
621
- data-gloom-role="composite-chart-tool"
622
- data-gloom-label={label}
623
- data-active={active ? "true" : "false"}
624
- title={isDesktopWeb ? label : undefined}
625
- // Cells size the terminal strip; the desktop chip sizes to its icon.
626
- style={isDesktopWeb ? { width: "auto", paddingInline: 4, borderRadius: 4 } : undefined}
627
- >
628
- {isDesktopWeb ? (
629
- <Box
630
- flexShrink={0}
631
- style={{
632
- width: 13,
633
- height: 13,
634
- backgroundColor: color,
635
- maskImage: svgMaskUrl(tool.icon),
636
- WebkitMaskImage: svgMaskUrl(tool.icon),
637
- maskSize: "contain",
638
- WebkitMaskSize: "contain",
639
- maskRepeat: "no-repeat",
640
- WebkitMaskRepeat: "no-repeat",
641
- maskPosition: "center",
642
- WebkitMaskPosition: "center",
643
- }}
644
- />
645
- ) : (
646
- <Text fg={color}>{tool.glyph}</Text>
647
- )}
648
- </Box>
649
- );
650
- }
651
-
652
-
653
- /** Blurs a focused text field, the focus change a consumed mousedown prevents. */
654
- function releaseEditableFocus(event: ChartMouseEvent): void {
655
- if (!event.target?.closest?.(`[data-gloom-role="${COMPOSITE_PANEL_ROLE}"]`)) return;
656
- const active = (globalThis as {
657
- document?: { activeElement?: { tagName?: string; blur?: () => void } };
658
- }).document?.activeElement;
659
- const tag = active?.tagName?.toUpperCase();
660
- if (tag !== "INPUT" && tag !== "TEXTAREA") return;
661
- active?.blur?.();
662
- }
663
-
664
- function svgMaskUrl(svg: string): string {
665
- return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
666
- }
667
-
668
- function ChartColorSwatch({
669
- color,
670
- active,
671
- isDesktopWeb,
672
- onPress,
673
- }: {
674
- color: string;
675
- active: boolean;
676
- isDesktopWeb: boolean;
677
- onPress: () => void;
678
- }) {
679
- return (
680
- <Box
681
- flexDirection="row"
682
- alignItems="center"
683
- justifyContent="center"
684
- width={2}
685
- height={1}
686
- flexShrink={0}
687
- backgroundColor={active ? themeColors.selected : undefined}
688
- hoverBackgroundColor={hoverBg()}
689
- onMouseDown={(event: ChartMouseEvent) => {
690
- consumeChartMouseEvent(event);
691
- onPress();
692
- }}
693
- cursor="pointer"
694
- data-gloom-interactive="true"
695
- data-gloom-role="composite-chart-color"
696
- data-gloom-label={`Drawing colour ${color}`}
697
- data-active={active ? "true" : "false"}
698
- style={isDesktopWeb
699
- ? {
700
- width: 14,
701
- height: 14,
702
- borderRadius: 999,
703
- backgroundColor: color,
704
- border: `1.5px solid ${active ? themeColors.text : "transparent"}`,
705
- }
706
- : undefined}
707
- >
708
- {isDesktopWeb ? null : <Text fg={color}>{active ? "\u25c9" : "\u25cf"}</Text>}
709
- </Box>
710
- );
711
- }
712
-
713
- function ChartToolbar({
714
- armedTool,
715
- isDesktopWeb,
716
- left,
717
- top,
718
- drawColor,
719
- showColors,
720
- levelTool,
721
- onArmTool,
722
- onPickColor,
723
- }: {
724
- armedTool: ChartToolKind | null;
725
- isDesktopWeb: boolean;
726
- left: number;
727
- top: number;
728
- drawColor: string;
729
- showColors: boolean;
730
- /** The chart's levels can be edited here. */
731
- levelTool: boolean;
732
- onArmTool: (tool: ChartToolKind | null) => void;
733
- onPickColor: (color: string) => void;
734
- }) {
735
- return (
736
- <Box
737
- position="absolute"
738
- left={left}
739
- top={top}
740
- height={1}
741
- flexDirection="row"
742
- gap={1}
743
- zIndex={30}
744
- backgroundColor={themeColors.bg}
745
- style={isDesktopWeb
746
- ? {
747
- gap: 3,
748
- padding: 3,
749
- borderRadius: 7,
750
- backgroundColor: `color-mix(in srgb, ${themeColors.bg} 78%, transparent)`,
751
- backdropFilter: "blur(6px)",
752
- width: "auto",
753
- height: "auto",
754
- }
755
- : undefined}
756
- data-gloom-role="composite-chart-toolbar"
757
- >
758
- {CHART_TOOLS.filter((tool) => levelTool || tool.kind !== "level").map((tool) => (
759
- <ChartToolChip
760
- key={tool.kind ?? "pan"}
761
- tool={tool}
762
- active={armedTool === tool.kind}
763
- isDesktopWeb={isDesktopWeb}
764
- onPress={() => onArmTool(tool.kind)}
765
- />
766
- ))}
767
- {/* Colours only take space while something can use them. */}
768
- {showColors ? CHART_DRAWING_COLORS.map((color) => (
769
- <ChartColorSwatch
770
- key={color}
771
- color={color}
772
- active={color === drawColor}
773
- isDesktopWeb={isDesktopWeb}
774
- onPress={() => onPickColor(color)}
775
- />
776
- )) : null}
777
- </Box>
778
- );
779
- }
780
-
781
- function resolveSeriesCursorYRatio(
782
- panel: CompositePanelScene,
783
- scene: CompositeChartScene,
784
- ): number | null {
785
- for (const series of panel.series) {
786
- const value = scene.cursorValues.find(
787
- (entry) => entry.seriesId === series.source.id,
788
- )?.value ?? null;
789
- const domain = panel.axes[series.source.axis];
790
- if (value === null || !domain) continue;
791
- const yRatio = projectCompositeValue(value, domain);
792
- if (yRatio !== null) return yRatio;
793
- }
794
- return null;
795
- }
796
-
797
- interface CompositePanelSurfaceProps {
798
- panel: CompositePanelScene;
799
- scene: CompositeChartScene;
800
- plotWidth: number;
801
- leftAxisWidth: number;
802
- rightAxisWidth: number;
803
- axisGap: number;
804
- colors: CompositeChartColors;
805
- interactive: boolean;
806
- /** False keeps the hover cursor but removes pan, zoom, and the tools. */
807
- navigable: boolean;
808
- formatAxisValue?: CompositeAxisValueFormatter;
809
- remoteKind?: string;
810
- viewport: CompositeViewportRange;
811
- frame: CompositeNavigationFrame;
812
- armedTool: ChartToolKind | null;
813
- drawings: readonly ChartDrawing[];
814
- selectedDrawingId: string | null;
815
- drawColor: string;
816
- onDraw: (drawing: ChartDrawing) => void;
817
- onEditDrawing: (id: string, update: (drawing: ChartDrawing) => ChartDrawing) => void;
818
- onSelectDrawing: (id: string | null) => void;
819
- onActivate?: () => void;
820
- onCursorDateChange: (date: Date | null) => void;
821
- /** Positive positions move toward older observations. A gesture supplies the frame and origin it started from. */
822
- onPanViewport: (
823
- deltaPositions: number,
824
- gesture?: { frame: CompositeNavigationFrame; from: CompositeViewportRange },
825
- ) => void;
826
- onZoomViewport: (zoomFactor: number, anchorRatio: number) => void;
827
- onSetViewport: (range: CompositeViewportRange) => void;
828
- onToolSpanChange: (span: ChartToolSpan | null) => void;
829
- /** The tool being placed from the keyboard, when it lives in this panel. */
830
- keyboardToolDrag: KeyboardToolDrag | null;
831
- /** A press on the plot: the pointer takes over from a keyboard placement. */
832
- onPointerPress: () => void;
833
- showTextFallback: boolean;
834
- /** Set while the owner is picking a bar: a press picks instead of panning. */
835
- onPickTime?: (date: Date) => void;
836
- /** The chart's levels when they draw on this panel. */
837
- levels: CompositeChartLevels | null;
838
- selectedLevelId: string | null;
839
- onSelectLevel: (id: string | null) => void;
840
- }
841
-
842
- function CompositePanelSurface({
843
- panel,
844
- scene,
845
- plotWidth,
846
- leftAxisWidth,
847
- rightAxisWidth,
848
- axisGap,
849
- colors,
850
- interactive,
851
- navigable,
852
- formatAxisValue,
853
- remoteKind,
854
- viewport,
855
- frame,
856
- armedTool,
857
- drawings,
858
- selectedDrawingId,
859
- drawColor,
860
- onDraw,
861
- onEditDrawing,
862
- onSelectDrawing,
863
- onActivate,
864
- onCursorDateChange,
865
- onPanViewport,
866
- onZoomViewport,
867
- onSetViewport,
868
- onToolSpanChange,
869
- keyboardToolDrag,
870
- onPointerPress,
871
- showTextFallback,
872
- onPickTime,
873
- levels,
874
- selectedLevelId,
875
- onSelectLevel,
876
- }: CompositePanelSurfaceProps) {
877
- const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
878
- const { cellHeightPx = 18, cellWidthPx = 8, fractionalViewport = false } = useUiCapabilities();
879
- const renderer = useNativeRenderer();
880
- const plotRef = useRef<BoxRenderable | null>(null);
881
- const [cursorYRatio, setCursorYRatio] = useState<number | null>(null);
882
- const [toolDrag, setToolDrag] = useState<ChartToolDrag | null>(null);
883
- // A pointer drag owns the plot while it lasts; otherwise a tool placed from
884
- // the keyboard draws and reads out exactly like one.
885
- const keyboardDrag = toolDrag ? null : keyboardToolDrag;
886
- const activeDrag = toolDrag ?? keyboardDrag?.drag ?? null;
887
- // A keyboard placement's end sits at the level the user put it, so it
888
- // draws a level line where otherwise only the pointer would.
889
- const heldCursorYRatio = keyboardDrag ? keyboardDrag.drag.endYRatio : cursorYRatio;
890
- const seriesCursorYRatio = useMemo(
891
- () => resolveSeriesCursorYRatio(panel, scene),
892
- [panel, scene],
893
- );
894
- const activeCursorYRatio = scene.cursorXRatio === null
895
- ? null
896
- : heldCursorYRatio ?? seriesCursorYRatio;
897
- const dragRef = useRef<
898
- | PanGesture
899
- | {
900
- kind: "edit";
901
- drawingId: string;
902
- pointIndex: number | null;
903
- lastXRatio: number;
904
- lastYRatio: number;
905
- }
906
- | { kind: "level-move"; id: string }
907
- | ChartToolDrag
908
- | null
909
- >(null);
910
- const plotAspect = (plotWidth * cellWidthPx) / Math.max(panel.height * cellHeightPx, 1);
911
- // The plot is wider than the viewport by the reserved right offset. Gestures
912
- // read the pointer in plot space, so they convert through this.
913
- const plotSpanFactor = 1 + compositeRightOffsetRatio(scene.timeScale);
914
- const bitmapSize = useStaticChartBitmapSize(plotWidth, panel.height);
915
- const bitmap = useCompositePanelBitmap({ panel, bitmapSize, colors, isDesktopWeb });
916
- const columnLayout = useMemo(() => buildCompositeColumnLayout(panel), [panel]);
917
- // The level line follows the pointer only. A keyboard or shared cursor knows
918
- // its column, and the series markers and axis readout already say the value.
919
- // A tool placed from the keyboard is the exception: its end is a level.
920
- const pointerCursorYRatio = scene.cursorXRatio === null ? null : heldCursorYRatio;
921
- const crosshair = useMemo(
922
- () => resolvePanelCrosshair(panel, columnLayout, bitmap, scene.cursorXRatio, pointerCursorYRatio, colors.crosshair),
923
- [bitmap, colors.crosshair, columnLayout, panel, pointerCursorYRatio, scene.cursorXRatio],
924
- );
925
- const measureDomain = useMemo(() => resolveMeasureAxisDomain(panel), [panel]);
926
- const toolReadout = useMemo(() => {
927
- if (!activeDrag) return null;
928
- // A keyboard placement knows its data points exactly, even off screen; a
929
- // pointer drag reads them back from the plot.
930
- const startTime = keyboardDrag?.start.time
931
- ?? unprojectCompositeTimestamp(scene.timeScale, activeDrag.startXRatio);
932
- const endTime = keyboardDrag?.end.time
933
- ?? unprojectCompositeTimestamp(scene.timeScale, activeDrag.endXRatio);
934
- if (activeDrag.kind === "zoom") {
935
- return {
936
- direction: "up" as const,
937
- summary: keyboardDrag
938
- ? summarizeZoomRange(scene, startTime, endTime)
939
- : summarizeZoomSelection(scene, activeDrag),
940
- startTime,
941
- startValueLabel: null,
942
- endValueLabel: null,
943
- };
944
- }
945
- const startValue = keyboardDrag
946
- ? keyboardDrag.start.value
947
- : measureDomain ? unprojectCompositeValue(activeDrag.startYRatio, measureDomain) : null;
948
- const endValue = keyboardDrag
949
- ? keyboardDrag.end.value
950
- : measureDomain ? unprojectCompositeValue(activeDrag.endYRatio, measureDomain) : null;
951
- return {
952
- direction: resolveMeasureDirection(startValue, endValue),
953
- summary: summarizeMeasure({
954
- startValue,
955
- endValue,
956
- startTime,
957
- endTime,
958
- // Cross-market cursor stops include every listing's observations;
959
- // measured bars still follow the primary market's session scale.
960
- bars: countMeasureBars(scene.timeScale.kind === "market"
961
- ? scene.timeScale.anchors.map(({ timestamp }) => new Date(timestamp))
962
- : scene.dates, startTime, endTime),
963
- domain: measureDomain,
964
- }),
965
- startTime,
966
- startValueLabel: measureDomain && startValue !== null
967
- ? formatCompositeCursorValue(startValue, measureDomain)
968
- : null,
969
- endValueLabel: measureDomain && endValue !== null
970
- ? formatCompositeCursorValue(endValue, measureDomain)
971
- : null,
972
- };
973
- }, [activeDrag, keyboardDrag, measureDomain, scene]);
974
- const toolSummary = toolReadout?.summary ?? null;
975
- const toolStartTime = toolReadout?.startTime ?? null;
976
- const toolSpan = useMemo(() => {
977
- if (!activeDrag || !toolSummary || toolStartTime === null) return null;
978
- return {
979
- startXRatio: activeDrag.startXRatio,
980
- endXRatio: activeDrag.endXRatio,
981
- startTime: toolStartTime,
982
- color: activeDrag.kind === "zoom"
983
- ? colors.crosshair
984
- : toolReadout?.direction === "down" ? colors.negative : themeColors.positive,
985
- };
986
- }, [activeDrag, colors.crosshair, colors.negative, toolReadout?.direction, toolStartTime, toolSummary]);
987
- useEffect(() => {
988
- onToolSpanChange(toolSpan);
989
- }, [onToolSpanChange, toolSpan]);
990
- const panelDrawings = useMemo(
991
- () => drawings.filter((drawing) => drawing.panelId === panel.id),
992
- [drawings, panel.id],
993
- );
994
- // A dragged level moves here and is saved once, when the pointer lets go.
995
- const [levelDraft, setLevelDraft] = useState<{ id: string; value: number } | null>(null);
996
- const levelDraftRef = useRef(levelDraft);
997
- levelDraftRef.current = levelDraft;
998
- const levelDomain = useMemo(() => {
999
- const series = levels ? panel.series.find((entry) => entry.source.id === levels.seriesId) : undefined;
1000
- return series ? panel.axes[series.source.axis] ?? null : null;
1001
- }, [levels, panel]);
1002
- const projectedLevels = useMemo(
1003
- () => levels && levelDomain ? projectLevels(levels.items, levelDomain, levelDraft) : NO_LEVELS,
1004
- [levelDomain, levelDraft, levels],
1005
- );
1006
- const bitmapLayers = useMemo(() => {
1007
- if (!bitmap) return null;
1008
- // The desktop composites overlays as vectors, so the plot raster stays put
1009
- // while a tool drags. Copying and reblending it per frame is what made the
1010
- // ruler feel heavy.
1011
- if (isDesktopWeb) return [bitmap];
1012
- const base = !activeDrag && panelDrawings.length === 0 ? bitmap : drawChartToolOverlay(
1013
- bitmap,
1014
- activeDrag,
1015
- {
1016
- positive: themeColors.positive,
1017
- negative: colors.negative,
1018
- zoom: colors.crosshair,
1019
- draw: drawColor,
1020
- },
1021
- toolReadout?.direction ?? "up",
1022
- { scene, panel, items: panelDrawings, selectedId: selectedDrawingId },
1023
- );
1024
- return [projectedLevels.length > 0 ? paintLevels(base, projectedLevels, selectedLevelId) : base];
1025
- }, [
1026
- activeDrag,
1027
- bitmap,
1028
- colors.crosshair,
1029
- colors.negative,
1030
- drawColor,
1031
- isDesktopWeb,
1032
- panel,
1033
- panelDrawings,
1034
- projectedLevels,
1035
- scene,
1036
- selectedDrawingId,
1037
- selectedLevelId,
1038
- toolReadout?.direction,
1039
- ]);
1040
- const vectors = useMemo<ChartSurfaceProps["vectors"]>(() => {
1041
- if (!isDesktopWeb) return null;
1042
- const shapes = [...levelVectors(projectedLevels, selectedLevelId), ...buildChartToolVectors({
1043
- scene,
1044
- panel,
1045
- drawings: panelDrawings,
1046
- selectedId: selectedDrawingId,
1047
- drag: activeDrag,
1048
- colors: {
1049
- positive: themeColors.positive,
1050
- negative: colors.negative,
1051
- zoom: colors.crosshair,
1052
- draw: drawColor,
1053
- },
1054
- direction: toolReadout?.direction ?? "up",
1055
- })];
1056
- return shapes.length > 0 ? shapes : null;
1057
- }, [
1058
- activeDrag,
1059
- colors.crosshair,
1060
- colors.negative,
1061
- drawColor,
1062
- isDesktopWeb,
1063
- panel,
1064
- panelDrawings,
1065
- projectedLevels,
1066
- scene,
1067
- selectedDrawingId,
1068
- selectedLevelId,
1069
- toolReadout?.direction,
1070
- ]);
1071
- const textLines = useMemo(
1072
- () => isDesktopWeb || !showTextFallback
1073
- ? []
1074
- : writeLevelText(
1075
- renderCompositePanelText(panel, plotWidth, scene.cursorXRatio, pointerCursorYRatio),
1076
- projectedLevels,
1077
- selectedLevelId,
1078
- ),
1079
- [isDesktopWeb, panel, plotWidth, pointerCursorYRatio, projectedLevels, scene.cursorXRatio, selectedLevelId, showTextFallback],
1080
- );
1081
- const leftAxisLabels = useMemo(
1082
- () => axisLabelRows(
1083
- renderCompositeAxisText(panel.axes.left, panel.height, leftAxisWidth, "left", formatAxisValue),
1084
- ),
1085
- [formatAxisValue, leftAxisWidth, panel],
1086
- );
1087
- const rightAxisLabels = useMemo(
1088
- () => axisLabelRows(
1089
- renderCompositeAxisText(panel.axes.right, panel.height, rightAxisWidth, "right", formatAxisValue),
1090
- ),
1091
- [formatAxisValue, panel, rightAxisWidth],
1092
- );
1093
- const leftAxisTicks = useMemo(
1094
- () => compositeAxisTickLabels(panel.axes.left, leftAxisWidth, formatAxisValue),
1095
- [formatAxisValue, leftAxisWidth, panel],
1096
- );
1097
- const rightAxisTicks = useMemo(
1098
- () => compositeAxisTickLabels(panel.axes.right, rightAxisWidth, formatAxisValue),
1099
- [formatAxisValue, panel, rightAxisWidth],
1100
- );
1101
- const cursorRow = activeCursorYRatio === null
1102
- ? null
1103
- : Math.round(activeCursorYRatio * Math.max(panel.height - 1, 0));
1104
- const cursorPixelY = activeCursorYRatio === null
1105
- ? null
1106
- : activeCursorYRatio * Math.max(panel.height * cellHeightPx - 1, 0);
1107
- const leftCursorLabel = cursorAxisLabel(panel, "left", activeCursorYRatio, formatAxisValue);
1108
- const rightCursorLabel = cursorAxisLabel(panel, "right", activeCursorYRatio, formatAxisValue);
1109
- const measureReadout = useMemo(() => {
1110
- if (!activeDrag || !toolReadout?.summary) return null;
1111
- const text = toolReadout.summary;
1112
- const centreX = (activeDrag.startXRatio + activeDrag.endXRatio) / 2;
1113
- const centreY = activeDrag.kind === "zoom"
1114
- ? 0.5
1115
- : (activeDrag.startYRatio + activeDrag.endYRatio) / 2;
1116
- const width = Math.min([...text].length, plotWidth);
1117
- return {
1118
- text,
1119
- width,
1120
- color: activeDrag.kind === "zoom"
1121
- ? colors.text
1122
- : toolReadout.direction === "down" ? colors.negative : themeColors.positive,
1123
- left: leftAxisWidth + (leftAxisWidth ? axisGap : 0)
1124
- + Math.max(0, Math.min(plotWidth - width, Math.round(centreX * plotWidth - width / 2))),
1125
- top: Math.max(0, Math.min(panel.height - 1, Math.round(centreY * (panel.height - 1)))),
1126
- };
1127
- }, [
1128
- activeDrag,
1129
- axisGap,
1130
- colors.negative,
1131
- colors.text,
1132
- leftAxisWidth,
1133
- panel.height,
1134
- plotWidth,
1135
- toolReadout,
1136
- ]);
1137
- const axisMarkers = useMemo(() => {
1138
- // The crosshair already marks the moving end, so the axes only need the
1139
- // anchor the drag started from.
1140
- if (!activeDrag || activeDrag.kind === "zoom" || !toolReadout?.startValueLabel) return null;
1141
- return {
1142
- side: null,
1143
- // A keyboard anchor can leave the value range once the view moves.
1144
- yRatio: Math.max(0, Math.min(1, activeDrag.startYRatio)),
1145
- label: toolReadout.startValueLabel,
1146
- color: toolReadout.direction === "down" ? colors.negative : themeColors.positive,
1147
- };
1148
- }, [activeDrag, colors.negative, toolReadout]);
1149
- const lastPriceMarker = useMemo(() => {
1150
- const marker = panel.lastPrice;
1151
- const domain = marker ? panel.axes[marker.axis] : undefined;
1152
- if (!marker || !domain) return null;
1153
- return {
1154
- side: marker.axis,
1155
- yRatio: marker.yRatio,
1156
- label: formatAxisValue
1157
- ? formatAxisValue(marker.value, domain)
1158
- : formatCompositeCursorValue(marker.value, domain),
1159
- color: marker.color,
1160
- };
1161
- }, [formatAxisValue, panel]);
1162
- const priorCloseMarker = useMemo(() => {
1163
- const marker = panel.priorClose;
1164
- const domain = marker ? panel.axes[marker.axis] : undefined;
1165
- if (!marker || !domain) return null;
1166
- return {
1167
- side: marker.axis,
1168
- yRatio: marker.yRatio,
1169
- label: formatAxisValue ? formatAxisValue(marker.value, domain) : formatCompositeCursorValue(marker.value, domain),
1170
- color: colors.textDim,
1171
- };
1172
- }, [colors.textDim, formatAxisValue, panel]);
1173
- const levelMarkers = useMemo(() => levelDomain ? [...projectedLevels]
1174
- // The picked level's label first, then alerts', when they compete for room.
1175
- .sort((left, right) => Number(right.level.id === selectedLevelId) - Number(left.level.id === selectedLevelId)
1176
- || Number(left.level.editable) - Number(right.level.editable))
1177
- .map(({ level, yRatio }) => ({
1178
- side: levelDomain.side,
1179
- yRatio,
1180
- label: formatAxisValue ? formatAxisValue(level.value, levelDomain) : formatCompositeCursorValue(level.value, levelDomain),
1181
- color: level.color,
1182
- })) : [], [formatAxisValue, levelDomain, projectedLevels, selectedLevelId]);
1183
- const buildAxisMarkers = (side: "left" | "right") => {
1184
- if (!panel.axes[side]) return undefined;
1185
- const toAxisMarker = (marker: { yRatio: number; label: string; color: string }) => ({
1186
- row: Math.round(marker.yRatio * Math.max(panel.height - 1, 0)),
1187
- pixelY: marker.yRatio * Math.max(panel.height * cellHeightPx - 1, 0),
1188
- label: marker.label,
1189
- color: marker.color,
1190
- });
1191
- const onSide = <T extends { side: string | null }>(marker: T | null): marker is T => (
1192
- !!marker && (marker.side === null || marker.side === side)
1193
- );
1194
- const fixed = [lastPriceMarker, axisMarkers].filter(onSide).map(toAxisMarker);
1195
- // The prior close's label yields to every level's.
1196
- const levelLabels = [...levelMarkers, priorCloseMarker].filter(onSide).map(toAxisMarker);
1197
- // A label is a row tall, so close levels would stack into an unreadable
1198
- // pile and hide the last price: each keeps its line, and only the labels
1199
- // with a row to themselves show. Desktop badges sit between rows.
1200
- const markers = [
1201
- ...fitAxisLabels(levelLabels, fixed, (marker) => fractionalViewport ? marker.pixelY / cellHeightPx : marker.row),
1202
- ...fixed,
1203
- ];
1204
- return markers.length > 0 ? markers : undefined;
1205
- };
1206
- const leftAxisMarkers = buildAxisMarkers("left");
1207
- const rightAxisMarkers = buildAxisMarkers("right");
1208
-
1209
- const pointerRatios = useCallback((event: ChartMouseEvent) => {
1210
- const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
1211
- const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
1212
- if (!pointer) return null;
1213
- return {
1214
- xRatio: plotWidth <= 1 ? 0 : Math.max(0, Math.min(1, pointer.cellX / (plotWidth - 1))),
1215
- yRatio: panel.height <= 1 ? 0.5 : Math.max(0, Math.min(1, pointer.cellY / (panel.height - 1))),
1216
- };
1217
- }, [panel.height, plotWidth, renderer]);
1218
- const updateCursor = useCallback((event: ChartMouseEvent): boolean => {
1219
- const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
1220
- const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
1221
- if (!pointer) return false;
1222
- const nextDate = resolveCompositeCursorDate(scene, pointer.cellX);
1223
- if (!nextDate) return false;
1224
- const nextYRatio = panel.height <= 1
1225
- ? 0.5
1226
- : Math.max(0, Math.min(1, pointer.cellY / (panel.height - 1)));
1227
- setCursorYRatio((current) => current === nextYRatio ? current : nextYRatio);
1228
- onCursorDateChange(nextDate);
1229
- consumeChartMouseEvent(event);
1230
- return true;
1231
- }, [onCursorDateChange, panel.height, renderer, scene]);
1232
- const clearCursor = useCallback(() => {
1233
- setCursorYRatio(null);
1234
- onCursorDateChange(null);
1235
- }, [onCursorDateChange]);
1236
- const startDrag = useCallback((event: ChartMouseEvent) => {
1237
- onActivate?.();
1238
- onPointerPress();
1239
- // The plot consumes the press, so the browser never moves focus off a text
1240
- // field for us. Hand it back, but only for a press that truly landed here:
1241
- // a dialog over the plot must keep the focus it just took.
1242
- if (isDesktopWeb) releaseEditableFocus(event);
1243
- consumeChartMouseEvent(event);
1244
- if (onPickTime) {
1245
- const pointer = getLocalPlotPointer(event, plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1], renderer);
1246
- const date = pointer ? resolveCompositeCursorDate(scene, pointer.cellX) : null;
1247
- if (date) onPickTime(date);
1248
- return;
1249
- }
1250
- // A keyboard-armed tool covers terminals that never forward modifier drags.
1251
- const tool = resolveChartToolKind(event.modifiers) ?? armedTool;
1252
- if (tool === "level" && levels?.onEdit && levelDomain) {
1253
- const ratios = pointerRatios(event);
1254
- if (!ratios) return;
1255
- const hit = hitTestLevel(projectedLevels, ratios.yRatio, levelGrabRatio(panel.height));
1256
- if (hit) {
1257
- onSelectLevel(hit.id);
1258
- if (hit.editable) dragRef.current = { kind: "level-move", id: hit.id };
1259
- } else {
1260
- const value = unprojectCompositeValue(ratios.yRatio, levelDomain);
1261
- if (value !== null) {
1262
- const id = nextLevelId();
1263
- levels.onEdit({ kind: "add", id, value: roundLevelValue(value, levelDomain) });
1264
- onSelectLevel(id);
1265
- }
1266
- }
1267
- updateCursor(event);
1268
- return;
1269
- }
1270
- if (tool) {
1271
- const ratios = pointerRatios(event);
1272
- if (!ratios) return;
1273
- // With a drawing tool in hand, grabbing an existing shape edits it
1274
- // instead of starting a new one on top of it.
1275
- const hit = isDrawingTool(tool)
1276
- ? hitTestDrawings(drawings, scene, panel, ratios, plotAspect)
1277
- : null;
1278
- if (hit) {
1279
- onSelectDrawing(hit.drawing.id);
1280
- dragRef.current = {
1281
- kind: "edit",
1282
- drawingId: hit.drawing.id,
1283
- pointIndex: hit.pointIndex,
1284
- lastXRatio: ratios.xRatio,
1285
- lastYRatio: ratios.yRatio,
1286
- };
1287
- updateCursor(event);
1288
- return;
1289
- }
1290
- const started: ChartToolDrag = {
1291
- kind: tool,
1292
- startXRatio: ratios.xRatio,
1293
- startYRatio: ratios.yRatio,
1294
- endXRatio: ratios.xRatio,
1295
- endYRatio: ratios.yRatio,
1296
- path: [{ xRatio: ratios.xRatio, yRatio: ratios.yRatio }],
1297
- };
1298
- if (isDrawingTool(tool)) onSelectDrawing(null);
1299
- dragRef.current = { ...started, path: [...started.path] };
1300
- setToolDrag(started);
1301
- updateCursor(event);
1302
- return;
1303
- }
1304
- if (!updateCursor(event)) return;
1305
- dragRef.current = startPanGesture(
1306
- frame,
1307
- viewport,
1308
- plotWidth,
1309
- getGlobalMouseX(event, renderer),
1310
- plotSpanFactor,
1311
- );
1312
- }, [
1313
- armedTool,
1314
- drawings,
1315
- frame,
1316
- levelDomain,
1317
- levels,
1318
- onActivate,
1319
- onPickTime,
1320
- onPointerPress,
1321
- onSelectDrawing,
1322
- onSelectLevel,
1323
- panel,
1324
- plotAspect,
1325
- projectedLevels,
1326
- plotSpanFactor,
1327
- plotWidth,
1328
- pointerRatios,
1329
- renderer,
1330
- scene,
1331
- updateCursor,
1332
- viewport,
1333
- ]);
1334
- const pressCursor = useCallback((event: ChartMouseEvent) => {
1335
- onActivate?.();
1336
- updateCursor(event);
1337
- }, [onActivate, updateCursor]);
1338
- const dragViewport = useCallback((event: ChartMouseEvent) => {
1339
- const drag = dragRef.current;
1340
- if (!drag) return;
1341
- consumeChartMouseEvent(event);
1342
- updateCursor(event);
1343
- if (drag.kind === "level-move") {
1344
- const ratios = pointerRatios(event);
1345
- const value = ratios && levelDomain ? unprojectCompositeValue(ratios.yRatio, levelDomain) : null;
1346
- if (value !== null && levelDomain) setLevelDraft({ id: drag.id, value: roundLevelValue(value, levelDomain) });
1347
- return;
1348
- }
1349
- if (drag.kind === "edit") {
1350
- const ratios = pointerRatios(event);
1351
- if (!ratios) return;
1352
- // Read the deltas before advancing the ref: the state updater runs later,
1353
- // and a mutable capture would hand it a zero move every time.
1354
- const deltaXRatio = ratios.xRatio - drag.lastXRatio;
1355
- const deltaYRatio = ratios.yRatio - drag.lastYRatio;
1356
- const { drawingId, pointIndex } = drag;
1357
- drag.lastXRatio = ratios.xRatio;
1358
- drag.lastYRatio = ratios.yRatio;
1359
- onEditDrawing(
1360
- drawingId,
1361
- (drawing) => shiftDrawing(drawing, scene, panel, deltaXRatio, deltaYRatio, pointIndex),
1362
- );
1363
- return;
1364
- }
1365
- if (drag.kind !== "pan") {
1366
- const ratios = pointerRatios(event);
1367
- if (!ratios) return;
1368
- drag.endXRatio = ratios.xRatio;
1369
- drag.endYRatio = ratios.yRatio;
1370
- if (drag.kind === "pencil") drag.path.push({ xRatio: ratios.xRatio, yRatio: ratios.yRatio });
1371
- setToolDrag({ ...drag, path: [...drag.path] });
1372
- return;
1373
- }
1374
- const globalX = getGlobalMouseX(event, renderer);
1375
- if (drag.frame !== frame) {
1376
- // Data refreshed mid-drag. The new frame maps positions differently, so
1377
- // continue from where the plot is now instead of replaying the drag.
1378
- Object.assign(drag, startPanGesture(frame, viewport, plotWidth, globalX, plotSpanFactor));
1379
- }
1380
- onPanViewport(
1381
- (globalX - drag.startGlobalX) * drag.positionsPerCell,
1382
- { frame: drag.frame, from: drag.startViewport },
1383
- );
1384
- }, [
1385
- frame,
1386
- levelDomain,
1387
- onEditDrawing,
1388
- onPanViewport,
1389
- panel,
1390
- plotSpanFactor,
1391
- plotWidth,
1392
- pointerRatios,
1393
- renderer,
1394
- scene,
1395
- updateCursor,
1396
- viewport,
1397
- ]);
1398
- const finishToolDrag = useCallback(() => {
1399
- const drag = dragRef.current;
1400
- if (!drag || drag.kind === "pan") return;
1401
- dragRef.current = null;
1402
- if (drag.kind === "edit") return;
1403
- if (drag.kind === "level-move") {
1404
- const draft = levelDraftRef.current;
1405
- setLevelDraft(null);
1406
- if (draft) levels?.onEdit?.({ kind: "move", id: draft.id, value: draft.value });
1407
- return;
1408
- }
1409
- setToolDrag(null);
1410
- if (isDrawingTool(drag.kind)) {
1411
- const drawing = resolveDrawingFromDrag(scene, panel, drag, drawColor, nextDrawingId());
1412
- if (drawing) onDraw(drawing);
1413
- return;
1414
- }
1415
- if (drag.kind !== "zoom") return;
1416
- const range = resolveZoomBoxRange(scene, drag, frame.minimumSpanMs);
1417
- if (range) onSetViewport(range);
1418
- }, [drawColor, frame.minimumSpanMs, levels, onDraw, onSetViewport, panel, scene]);
1419
- const resetDrag = useCallback(() => {
1420
- if (dragRef.current?.kind === "pan") {
1421
- dragRef.current = null;
1422
- return;
1423
- }
1424
- finishToolDrag();
1425
- }, [finishToolDrag]);
1426
- const handleMouseMove = useCallback((event: ChartMouseEvent) => {
1427
- // A release over a neighbouring pane never reaches this surface, so treat a
1428
- // plain move, which only fires with no button held, as the missing release.
1429
- finishToolDrag();
1430
- updateCursor(event);
1431
- }, [finishToolDrag, updateCursor]);
1432
- // Trackpads fire wheel events faster than the plot can paint, so one frame
1433
- // absorbs every event that arrived since the last one.
1434
- const pendingWheelRef = useRef<PendingWheel | null>(null);
1435
- const wheelFrameRef = useRef<number | null>(null);
1436
- const latestWheelStateRef = useRef({ frame, viewport, onPanViewport, onZoomViewport, plotSpanFactor });
1437
- latestWheelStateRef.current = { frame, viewport, onPanViewport, onZoomViewport, plotSpanFactor };
1438
- const flushWheel = useCallback(() => {
1439
- wheelFrameRef.current = null;
1440
- const pending = pendingWheelRef.current;
1441
- pendingWheelRef.current = null;
1442
- if (!pending) return;
1443
- const latest = latestWheelStateRef.current;
1444
- if (pending.panRatio !== 0) {
1445
- const positions = compositeViewportPositions(latest.frame, latest.viewport);
1446
- const span = positions ? Math.max(positions.end - positions.start, Number.EPSILON) : 0;
1447
- latest.onPanViewport(pending.panRatio * span * latest.plotSpanFactor);
1448
- }
1449
- if (pending.zoomLog !== 0) {
1450
- latest.onZoomViewport(Math.exp(pending.zoomLog), pending.anchorRatio);
1451
- }
1452
- }, []);
1453
- useEffect(() => () => {
1454
- pendingWheelRef.current = null;
1455
- if (wheelFrameRef.current !== null) {
1456
- webFrame.cancelAnimationFrame?.(wheelFrameRef.current);
1457
- wheelFrameRef.current = null;
1458
- }
1459
- }, []);
1460
- const panFromWheel = useCallback((event: ChartMouseEvent) => {
1461
- const scroll = event.scroll;
1462
- if (!scroll) return;
1463
- onActivate?.();
1464
- consumeChartMouseEvent(event);
1465
- const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
1466
- const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
1467
- updateCursor(event);
1468
- const pending = pendingWheelRef.current ?? { panRatio: 0, zoomLog: 0, anchorRatio: 0.5 };
1469
- if (event.modifiers.ctrl && pointer && isVerticalWheelDirection(scroll.direction)) {
1470
- pending.zoomLog += Math.log(resolveCompositeWheelZoom(scroll));
1471
- // The anchor is a fraction of the viewport, so a pointer over the empty
1472
- // right offset anchors on the newest observation instead of past it.
1473
- pending.anchorRatio = Math.min(
1474
- 1,
1475
- pointer.cellX / Math.max(plotWidth - 1, 1) * plotSpanFactor,
1476
- );
1477
- } else {
1478
- pending.panRatio += resolveCompositeWheelPan(scroll, plotWidth * cellWidthPx);
1479
- }
1480
- pendingWheelRef.current = pending;
1481
- if (!isDesktopWeb || typeof webFrame.requestAnimationFrame !== "function") {
1482
- flushWheel();
1483
- return;
1484
- }
1485
- if (wheelFrameRef.current === null) {
1486
- wheelFrameRef.current = webFrame.requestAnimationFrame(flushWheel);
1487
- }
1488
- }, [
1489
- cellWidthPx,
1490
- flushWheel,
1491
- isDesktopWeb,
1492
- onActivate,
1493
- plotSpanFactor,
1494
- plotWidth,
1495
- renderer,
1496
- updateCursor,
1497
- ]);
1498
-
1499
- return (
1500
- <Box
1501
- flexDirection="row"
1502
- height={panel.height}
1503
- width={plotWidth + leftAxisWidth + rightAxisWidth + axisGap * ((leftAxisWidth ? 1 : 0) + (rightAxisWidth ? 1 : 0))}
1504
- position="relative"
1505
- >
1506
- {measureReadout ? (
1507
- <Box
1508
- position="absolute"
1509
- left={measureReadout.left}
1510
- top={measureReadout.top}
1511
- width={measureReadout.width}
1512
- height={1}
1513
- zIndex={25}
1514
- backgroundColor={colors.background}
1515
- style={isDesktopWeb
1516
- ? {
1517
- width: "auto",
1518
- paddingInline: 4,
1519
- borderRadius: 4,
1520
- backgroundColor: `color-mix(in srgb, ${colors.background} 82%, transparent)`,
1521
- }
1522
- : undefined}
1523
- data-gloom-role="composite-chart-measure"
1524
- >
1525
- <Text fg={measureReadout.color}>{measureReadout.text}</Text>
1526
- </Box>
1527
- ) : null}
1528
- {leftAxisWidth > 0 ? (
1529
- <>
1530
- <PriceAxisLabels
1531
- axisLabels={leftAxisLabels}
1532
- axisTicks={leftAxisTicks}
1533
- axisWidth={leftAxisWidth}
1534
- axisSectionWidth={leftAxisWidth}
1535
- side="left"
1536
- height={panel.height}
1537
- cursorRow={cursorRow}
1538
- cursorPixelY={cursorPixelY}
1539
- cursorLabel={leftCursorLabel}
1540
- cursorColor={colors.crosshair}
1541
- cursorBackgroundColor={colors.background}
1542
- axisColor={colors.textDim}
1543
- extraMarkers={leftAxisMarkers}
1544
- />
1545
- <Box width={axisGap} />
1546
- </>
1547
- ) : null}
1548
- <ChartSurface
1549
- ref={plotRef}
1550
- width={plotWidth}
1551
- height={panel.height}
1552
- flexDirection="column"
1553
- bitmaps={bitmapLayers}
1554
- crosshair={crosshair}
1555
- vectors={vectors}
1556
- onMouseMove={interactive ? handleMouseMove : undefined}
1557
- onMouseDown={interactive ? navigable ? startDrag : pressCursor : undefined}
1558
- onMouseDrag={interactive && navigable ? dragViewport : undefined}
1559
- onMouseUp={interactive && navigable ? resetDrag : undefined}
1560
- onMouseDragEnd={interactive && navigable ? resetDrag : undefined}
1561
- onMouseScroll={interactive && navigable ? panFromWheel : undefined}
1562
- onMouseOut={interactive ? clearCursor : undefined}
1563
- cursor={interactive ? toolDrag || !navigable || onPickTime ? "crosshair" : "grab" : undefined}
1564
- data-gloom-interactive={interactive ? "true" : undefined}
1565
- data-gloom-role={COMPOSITE_PANEL_ROLE}
1566
- data-gloom-remote-kind={remoteKind}
1567
- data-gloom-label={panel.label ?? panel.id}
1568
- >
1569
- {textLines.map((line, index) => <Text key={index} fg={colors.text}>{line}</Text>)}
1570
- </ChartSurface>
1571
- {rightAxisWidth > 0 ? (
1572
- <>
1573
- <Box width={axisGap} />
1574
- <PriceAxisLabels
1575
- axisLabels={rightAxisLabels}
1576
- axisTicks={rightAxisTicks}
1577
- axisWidth={rightAxisWidth}
1578
- axisSectionWidth={rightAxisWidth}
1579
- side="right"
1580
- height={panel.height}
1581
- cursorRow={cursorRow}
1582
- cursorPixelY={cursorPixelY}
1583
- cursorLabel={rightCursorLabel}
1584
- cursorColor={colors.crosshair}
1585
- cursorBackgroundColor={colors.background}
1586
- axisColor={colors.textDim}
1587
- extraMarkers={rightAxisMarkers}
1588
- />
1589
- </>
1590
- ) : null}
1591
- </Box>
1592
- );
1593
- }
1594
-
1595
- function legendValue(
1596
- series: ResolvedSeries,
1597
- value: number | null,
1598
- formatValue: CompositeChartProps["formatValue"],
1599
- scene: CompositeChartScene | null,
1600
- ): string {
1601
- if (value === null) return "—";
1602
- return formatValue ? formatValue(value, series) : formatCompositeSeriesValue(value, series, axisPriceReference(scene, series));
1603
- }
1604
-
1605
- /** The reference the axis holding a series keeps for its asset, so an average
1606
- * of the price shows the price's decimals rather than its own float tail. A
1607
- * series far from that price (another coin on a shared axis) keeps its own. */
1608
- function axisPriceReference(scene: CompositeChartScene | null, series: ResolvedSeries): number | undefined {
1609
- const own = seriesPriceReference(series);
1610
- for (const panel of scene?.panels ?? []) {
1611
- for (const domain of [panel.axes.left, panel.axes.right]) {
1612
- if (!domain?.seriesIds.includes(series.id)) continue;
1613
- const shared = domain.priceReferences?.[series.priceAssetCategory ?? ""];
1614
- if (shared === undefined || own === undefined) return own ?? shared;
1615
- const ratio = Math.abs(own / shared);
1616
- return ratio > 0.1 && ratio < 10 ? shared : own;
1617
- }
1618
- }
1619
- return own;
1620
- }
1621
-
1622
- const LISTED_TICKER_PATTERN = /(^|[\s(])([A-Z0-9^][A-Z0-9.^=/-]*):([A-Z0-9]{2,})(?=$|[\s),])/g;
1623
-
1624
- /** `Volume AAPL:XNAS Price` → `Volume AAPL Price`, for a legend name that must shorten. */
1625
- function withoutListingExchange(label: string): string {
1626
- return label.replace(LISTED_TICKER_PATTERN, (match, lead: string, symbol: string, exchange: string) => (
1627
- CANONICAL_EXCHANGE_ALIASES[exchange] ? `${lead}${symbol}` : match
1628
- ));
1629
- }
1630
-
1631
- function CompositeLegend({
1632
- scene,
1633
- series,
1634
- visibleSeriesIds,
1635
- width,
1636
- accessory,
1637
- accessoryWidth,
1638
- formatValue,
1639
- showLatestChangePercent,
1640
- onActivate,
1641
- onToggleSeries,
1642
- isSeriesToggleable,
1643
- keyboardIndex,
1644
- }: {
1645
- scene: CompositeChartScene | null;
1646
- series: ResolvedSeries[];
1647
- visibleSeriesIds: ReadonlySet<string>;
1648
- width: number;
1649
- accessory: CompositeChartProps["legendAccessory"];
1650
- accessoryWidth: CompositeChartProps["legendAccessoryWidth"];
1651
- formatValue: CompositeChartProps["formatValue"];
1652
- showLatestChangePercent: CompositeChartProps["showLatestChangePercent"];
1653
- onActivate: CompositeChartProps["onActivate"];
1654
- onToggleSeries: CompositeChartProps["onToggleSeries"];
1655
- isSeriesToggleable: CompositeChartProps["isSeriesToggleable"];
1656
- keyboardIndex?: number | null;
1657
- }) {
1658
- const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
1659
- const scrollRef = useRef<ScrollBoxRenderable | null>(null);
1660
- const cursorValueById = new Map(
1661
- scene?.cursorValues.map((entry) => [entry.seriesId, entry] as const) ?? [],
1662
- );
1663
- const entries = series.map((entry) => {
1664
- const toggleable = !!onToggleSeries && (isSeriesToggleable?.(entry) ?? true);
1665
- const cursorValue = cursorValueById.get(entry.id);
1666
- const changeText = showLatestChangePercent
1667
- && !scene?.cursorDate
1668
- && typeof entry.latestChangePercent === "number"
1669
- && Number.isFinite(entry.latestChangePercent)
1670
- ? ` ${formatPercentRaw(entry.latestChangePercent)}`
1671
- : "";
1672
- const valueText = entry.points.length === 0
1673
- ? entry.hidden ? "" : "no data"
1674
- : `${legendValue(
1675
- entry,
1676
- cursorValue?.value ?? null,
1677
- formatValue,
1678
- scene,
1679
- )}${changeText}`;
1680
- const fullText = [entry.label, valueText].filter(Boolean).join(" ");
1681
- const details = formatCompositePointDetails(cursorValue?.point);
1682
- const exactTotal = entry.unitGroup.split(":")[0] === "currency-total"
1683
- && cursorValue?.value != null && Number.isFinite(cursorValue.value)
1684
- ? `Value ${cursorValue.value} ${entry.unit}` : "";
1685
- const tooltip = [fullText, exactTotal, details].filter(Boolean).join(" · ");
1686
- const label = entry.label;
1687
- const compactLabel = withoutListingExchange(label);
1688
- const labelWidth = displayWidth(label);
1689
- const text = [label, valueText].filter(Boolean).join(" ");
1690
- return {
1691
- entry,
1692
- label,
1693
- compactLabel,
1694
- text,
1695
- width: Math.max(1, displayWidth(text)) + 2,
1696
- labelWidth,
1697
- valueText,
1698
- toggleable,
1699
- tooltip,
1700
- };
1701
- });
1702
- type LegendEntry = (typeof entries)[number];
1703
- const setEntryLabel = (target: LegendEntry, label: string, labelWidth: number) => {
1704
- target.label = label;
1705
- target.labelWidth = labelWidth;
1706
- target.text = [truncateToDisplayWidth(label, labelWidth), target.valueText].filter(Boolean).join(" ");
1707
- target.width = Math.max(1, displayWidth(target.text)) + 2;
1708
- };
1709
- const measureSeriesWidth = () => entries.reduce(
1710
- (total, entry, index) => total + entry.width + (index > 0 ? 1 : 0),
1711
- 0,
1712
- );
1713
- // One cell in from the pane border, so the first marker and the legend text
1714
- // line up with the query bar and tables above and below the chart.
1715
- const inset = width > 1 ? 1 : 0;
1716
- const innerWidth = Math.max(0, width - inset);
1717
- const resolvedAccessoryWidth = accessory
1718
- ? Math.max(1, Math.min(innerWidth, Math.floor(accessoryWidth ?? 14)))
1719
- : 0;
1720
- const reservedAccessoryGap = accessory && innerWidth > resolvedAccessoryWidth ? 1 : 0;
1721
- // The cursor date lives on the time axis, where the crosshair points at it.
1722
- const widthBeforeAccessory = Math.max(0, innerWidth - resolvedAccessoryWidth - reservedAccessoryGap);
1723
- // Names get the room the row has. When it overflows, the widest name
1724
- // shortens first: its listing exchange goes before any of the name is cut.
1725
- // The value (including its sign/unit) always stays intact. If the row cannot
1726
- // fit even with every name at a readable fragment, it keeps the old 30-cell
1727
- // entries and scrolls.
1728
- const minimumSeriesWidth = entries.reduce((total, entry, index) => {
1729
- const text = [truncateToDisplayWidth(entry.compactLabel, Math.min(8, displayWidth(entry.compactLabel))), entry.valueText]
1730
- .filter(Boolean).join(" ");
1731
- return total + Math.max(1, displayWidth(text)) + 2 + (index > 0 ? 1 : 0);
1732
- }, 0);
1733
- if (minimumSeriesWidth <= widthBeforeAccessory) {
1734
- while (measureSeriesWidth() > widthBeforeAccessory) {
1735
- const shrinkable = entries
1736
- .filter((entry) => entry.label !== entry.compactLabel || entry.labelWidth > 8)
1737
- .sort((left, right) => right.labelWidth - left.labelWidth)[0];
1738
- if (!shrinkable) break;
1739
- if (shrinkable.label !== shrinkable.compactLabel) {
1740
- setEntryLabel(shrinkable, shrinkable.compactLabel, Math.min(shrinkable.labelWidth, displayWidth(shrinkable.compactLabel)));
1741
- } else {
1742
- setEntryLabel(shrinkable, shrinkable.label, shrinkable.labelWidth - 1);
1743
- }
1744
- }
1745
- } else {
1746
- for (const entry of entries) {
1747
- const budget = Math.max(0, 30 - (entry.valueText ? displayWidth(entry.valueText) + 1 : 0));
1748
- if (entry.labelWidth > budget) setEntryLabel(entry, entry.compactLabel, budget);
1749
- }
1750
- }
1751
- const desiredSeriesWidth = measureSeriesWidth();
1752
- const seriesWidth = Math.min(desiredSeriesWidth, widthBeforeAccessory);
1753
- const accessorySpacerWidth = accessory
1754
- ? Math.max(reservedAccessoryGap, innerWidth - seriesWidth - resolvedAccessoryWidth)
1755
- : 0;
1756
- const keyboardEntryStart = keyboardIndex === null || keyboardIndex === undefined
1757
- ? null
1758
- : entries.slice(0, keyboardIndex).reduce(
1759
- (total, entry, index) => total + entry.width + (index > 0 ? 1 : 0),
1760
- 0,
1761
- ) + (keyboardIndex > 0 ? 1 : 0);
1762
- const keyboardEntryEnd = keyboardEntryStart === null
1763
- ? null
1764
- : keyboardEntryStart + (entries[keyboardIndex!]?.width ?? 0);
1765
-
1766
- useEffect(() => {
1767
- if (keyboardEntryStart === null || keyboardEntryEnd === null) return;
1768
- const scrollBox = scrollRef.current;
1769
- const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || seriesWidth;
1770
- if (!scrollBox || viewportWidth <= 0) return;
1771
- const currentLeft = scrollBox.scrollLeft ?? 0;
1772
- const nextLeft = keyboardEntryStart < currentLeft
1773
- ? keyboardEntryStart
1774
- : keyboardEntryEnd > currentLeft + viewportWidth
1775
- ? keyboardEntryEnd - viewportWidth
1776
- : currentLeft;
1777
- if (nextLeft === currentLeft) return;
1778
- scrollBox.scrollLeft = nextLeft;
1779
- scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
1780
- }, [keyboardEntryEnd, keyboardEntryStart, seriesWidth]);
1781
- const handleMouseScroll = (event?: {
1782
- preventDefault?: () => void;
1783
- stopPropagation?: () => void;
1784
- scroll?: { direction?: string; delta?: number };
1785
- }) => {
1786
- const direction = event?.scroll?.direction;
1787
- const scrollBox = scrollRef.current;
1788
- const viewportWidth = scrollBox?.viewport?.width || scrollBox?.width || 0;
1789
- const contentWidth = Math.max(desiredSeriesWidth, scrollBox?.scrollWidth ?? 0);
1790
- if (!direction || !scrollBox || viewportWidth <= 0 || contentWidth <= viewportWidth) return;
1791
-
1792
- event.preventDefault?.();
1793
- event.stopPropagation?.();
1794
- const rawDelta = Math.abs(event.scroll?.delta ?? 1);
1795
- const deltaCells = Math.max(1, Math.round(rawDelta / LEGEND_WHEEL_DELTA_PER_CELL));
1796
- const directionSign = direction === "right" || direction === "down" ? 1 : -1;
1797
- const nextLeft = Math.max(
1798
- 0,
1799
- Math.min(
1800
- contentWidth - viewportWidth,
1801
- (scrollBox.scrollLeft ?? 0) + directionSign * deltaCells,
1802
- ),
1803
- );
1804
- scrollBox.scrollLeft = nextLeft;
1805
- scrollBox.scrollTo({ x: nextLeft, y: scrollBox.scrollTop });
1806
- };
1807
- return (
1808
- <Box
1809
- flexDirection="row"
1810
- alignItems="flex-end"
1811
- width={width}
1812
- height={1}
1813
- paddingLeft={inset}
1814
- overflow="visible"
1815
- // Accessory dropdowns must escape above the sibling drawing toolbar.
1816
- zIndex={accessory ? 40 : 20}
1817
- data-gloom-role="composite-chart-legend"
1818
- >
1819
- {seriesWidth > 0 ? (
1820
- <ScrollBox
1821
- ref={scrollRef}
1822
- width={seriesWidth}
1823
- height={1}
1824
- flexShrink={0}
1825
- scrollX
1826
- focusable={false}
1827
- horizontalScrollbarOptions={{ visible: false }}
1828
- onMouseScroll={handleMouseScroll}
1829
- data-gloom-role="composite-chart-legend-scroll"
1830
- >
1831
- <Box flexDirection="row" width={desiredSeriesWidth} height={1} gap={1}>
1832
- {entries.map(({ entry, text, toggleable, tooltip, width: entryWidth }, index) => {
1833
- const entryVisible = visibleSeriesIds.has(entry.id);
1834
- return (
1835
- <Box
1836
- key={entry.id}
1837
- flexDirection="row"
1838
- alignItems="center"
1839
- width={entryWidth}
1840
- height={1}
1841
- flexShrink={0}
1842
- overflow="hidden"
1843
- backgroundColor={keyboardIndex === index ? themeColors.selected : undefined}
1844
- hoverBackgroundColor={toggleable ? hoverBg() : undefined}
1845
- onMouseDown={toggleable ? (event: ChartMouseEvent) => {
1846
- onActivate?.();
1847
- consumeChartMouseEvent(event);
1848
- onToggleSeries?.(entry.id);
1849
- } : undefined}
1850
- cursor={toggleable ? "pointer" : undefined}
1851
- data-gloom-interactive={toggleable ? "true" : undefined}
1852
- data-gloom-role="composite-chart-legend-series"
1853
- data-gloom-label={`${toggleable
1854
- ? `${entryVisible ? "Hide" : "Show"} `
1855
- : ""}${tooltip}`}
1856
- data-visible={entryVisible ? "true" : "false"}
1857
- title={isDesktopWeb ? tooltip : undefined}
1858
- >
1859
- {isDesktopWeb ? (
1860
- <Box
1861
- flexShrink={0}
1862
- style={{
1863
- width: 8,
1864
- height: 8,
1865
- marginInlineEnd: 6,
1866
- borderRadius: 999,
1867
- border: `1px solid ${entry.color}`,
1868
- backgroundColor: entryVisible ? entry.color : "transparent",
1869
- }}
1870
- data-gloom-role="composite-chart-legend-marker"
1871
- />
1872
- ) : (
1873
- <Text fg={entryVisible ? entry.color : themeColors.textMuted}>● </Text>
1874
- )}
1875
- {/* The filled/hollow marker already says whether a series is
1876
- shown; the word only repeated it in every legend slot. */}
1877
- <Text fg={entryVisible ? themeColors.text : themeColors.textDim}>{text}</Text>
1878
- </Box>
1879
- );
1880
- })}
1881
- </Box>
1882
- </ScrollBox>
1883
- ) : null}
1884
- {accessorySpacerWidth > 0 ? (
1885
- <Box width={accessorySpacerWidth} flexShrink={0} />
1886
- ) : null}
1887
- {accessory ? (
1888
- <Box
1889
- position="relative"
1890
- width={resolvedAccessoryWidth}
1891
- flexShrink={0}
1892
- height={1}
1893
- overflow="visible"
1894
- zIndex={21}
1895
- >
1896
- {accessory}
1897
- </Box>
1898
- ) : null}
1899
- </Box>
1900
- );
1901
- }
1902
-
1903
- const NO_DRAWINGS: readonly ChartDrawing[] = [];
1904
58
  const NO_X_MARKERS: readonly CompositeChartXMarker[] = [];
1905
- /** Coalesces a drag into one write instead of one per pointer move. */
1906
- const DRAWING_PERSIST_DELAY_MS = 400;
1907
-
1908
- /**
1909
- * Drawings are anchored to data, so they outlive the mounted chart: they ride
1910
- * along with the pane settings that already carry the chart spec. Only mounted
1911
- * inside a pane, so a standalone chart still renders without app state.
1912
- */
1913
- function ChartDrawingStore({
1914
- paneInstanceId,
1915
- drawings,
1916
- onRestore,
1917
- }: {
1918
- paneInstanceId: string;
1919
- drawings: readonly ChartDrawing[];
1920
- onRestore: (drawings: readonly ChartDrawing[]) => void;
1921
- }) {
1922
- const [stored, setStored] = usePaneSettingValue<readonly ChartDrawing[]>(
1923
- CHART_DRAWINGS_SETTING_KEY,
1924
- NO_DRAWINGS,
1925
- paneInstanceId,
1926
- );
1927
- const restoredRef = useRef<readonly ChartDrawing[] | null>(null);
1928
- if (restoredRef.current === null) {
1929
- restoredRef.current = parseChartDrawings(stored);
1930
- }
1931
-
1932
- useEffect(() => {
1933
- const restored = restoredRef.current;
1934
- if (restored && restored.length > 0) onRestore(restored);
1935
- // Restoring once on mount: later writes must not scroll back in time.
1936
- }, []);
1937
-
1938
- useEffect(() => {
1939
- const restored = restoredRef.current ?? NO_DRAWINGS;
1940
- if (drawings === restored) return;
1941
- if (drawings.length === 0 && restored.length === 0) return;
1942
- const timer = setTimeout(() => setStored(drawings), DRAWING_PERSIST_DELAY_MS);
1943
- return () => clearTimeout(timer);
1944
- }, [drawings, setStored]);
1945
-
1946
- return null;
1947
- }
1948
-
1949
59
  export function CompositeChart({
1950
60
  series,
1951
61
  legendSeries,
@@ -1986,16 +96,11 @@ export function CompositeChart({
1986
96
  const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
1987
97
  const showTextFallback = useShowChartTextFallback();
1988
98
  const [internalCursorDate, setInternalCursorDate] = useState<Date | null>(null);
1989
- const [legendKeyboardIndex, setLegendKeyboardIndex] = useState<number | null>(null);
1990
99
  const [toolSpan, setToolSpan] = useState<ChartToolSpan | null>(null);
1991
- const [armedTool, setArmedTool] = useState<ChartToolKind | null>(null);
1992
- const [keyboardPlacement, setKeyboardPlacement] = useState<KeyboardToolPlacement | null>(null);
1993
- const keyboardId = `composite-chart:${useId()}`;
1994
100
  const paneInstanceId = useOptionalPaneInstanceId();
1995
101
  const [drawings, setDrawings] = useState<readonly ChartDrawing[]>(NO_DRAWINGS);
1996
102
  const [selectedDrawingId, setSelectedDrawingId] = useState<string | null>(null);
1997
103
  const [drawColor, setDrawColor] = useState<string>(CHART_DRAWING_COLORS[0]);
1998
- const [selectedLevelId, setSelectedLevelId] = useState<string | null>(null);
1999
104
  const addDrawing = useCallback((drawing: ChartDrawing) => {
2000
105
  setDrawings((current) => [...current, drawing]);
2001
106
  setSelectedDrawingId(drawing.id);
@@ -2050,126 +155,13 @@ export function CompositeChart({
2050
155
  () => new Set(visibleSeries.map((entry) => entry.id)),
2051
156
  [visibleSeries],
2052
157
  );
2053
- useEffect(() => {
2054
- setLegendKeyboardIndex((current) => (
2055
- current === null || visibleLegendSeries.length === 0
2056
- ? null
2057
- : Math.min(current, visibleLegendSeries.length - 1)
2058
- ));
2059
- }, [visibleLegendSeries.length]);
2060
- const previousAuthoredViewportRef = useRef<CompositeViewportRange | null>(viewport ?? null);
2061
- const previousViewportResetKeyRef = useRef(viewportResetKey);
2062
- // The user owns this once they navigate. Data refreshes never rewrite it;
2063
- // only the authored viewport changing or an explicit reset clears it.
2064
- const [userViewport, setUserViewport] = useState<CompositeViewportRange | null>(null);
2065
- const hasViewportResetKey = viewportResetKey !== undefined
2066
- || previousViewportResetKeyRef.current !== undefined;
2067
- const authoredViewportChanged = hasViewportResetKey
2068
- ? previousViewportResetKeyRef.current !== viewportResetKey
2069
- : shouldResetCompositeViewport(
2070
- previousAuthoredViewportRef.current,
2071
- viewport ?? null,
2072
- );
2073
- const navigationFrame = useMemo(
2074
- () => buildCompositeNavigationFrame(visibleSeries, marketTimelineSeries, {
2075
- historicalPaddingRatio: allowHistoricalBackfill ? HISTORICAL_PADDING_RATIO : 0,
2076
- }),
2077
- [allowHistoricalBackfill, marketTimelineSeries, visibleSeries],
2078
- );
2079
- const authoredViewport = useMemo(() => {
2080
- // The requested dates own the axis, including gaps in a cached or partial
2081
- // response. Fitting them to available observations silently changes the
2082
- // research period. User pan/zoom gestures retain their navigation limits.
2083
- if (viewport && Number.isFinite(viewport.start.getTime())
2084
- && Number.isFinite(viewport.end.getTime()) && viewport.start <= viewport.end) return viewport;
2085
- return navigationFrame ? compositeNavigationDataViewport(navigationFrame) : null;
2086
- }, [navigationFrame, viewport]);
2087
- const activeUserViewport = authoredViewportChanged ? null : userViewport;
2088
- const effectiveViewport = activeUserViewport ?? authoredViewport;
2089
- const userViewportStart = activeUserViewport?.start.getTime() ?? null;
2090
- const userViewportEnd = activeUserViewport?.end.getTime() ?? null;
2091
- const lastReportedViewportRef = useRef<string | null>(null);
2092
- const viewportInteractionRef = useRef<"pan" | "reset" | "zoom">("reset");
2093
- useEffect(() => {
2094
- if (!onViewportChange) return;
2095
- const key = userViewportStart === null || userViewportEnd === null
2096
- ? "none"
2097
- : `${userViewportStart}:${userViewportEnd}`;
2098
- // The callback drives adaptive data loading. Seed it from the authored
2099
- // viewport without echoing that controlled value back into the loader.
2100
- if (lastReportedViewportRef.current === null) {
2101
- lastReportedViewportRef.current = key;
2102
- return;
2103
- }
2104
- if (lastReportedViewportRef.current === key) return;
2105
- lastReportedViewportRef.current = key;
2106
- onViewportChange(
2107
- userViewportStart === null || userViewportEnd === null
2108
- ? null
2109
- : { start: new Date(userViewportStart), end: new Date(userViewportEnd) },
2110
- viewportInteractionRef.current,
2111
- );
2112
- }, [onViewportChange, userViewportEnd, userViewportStart]);
2113
-
2114
- useEffect(() => {
2115
- previousAuthoredViewportRef.current = viewport ?? null;
2116
- previousViewportResetKeyRef.current = viewportResetKey;
2117
- if (authoredViewportChanged && userViewport) {
2118
- viewportInteractionRef.current = "reset";
2119
- setUserViewport(null);
2120
- }
2121
- }, [authoredViewportChanged, userViewport, viewport, viewportResetKey]);
2122
-
2123
- const navigate = useCallback((
2124
- kind: "pan" | "zoom",
2125
- compute: (base: CompositeViewportRange, frame: CompositeNavigationFrame) => CompositeViewportRange,
2126
- gesture?: { frame: CompositeNavigationFrame; from: CompositeViewportRange },
2127
- ) => {
2128
- const frame = gesture?.frame ?? navigationFrame;
2129
- if (!frame) return;
2130
- viewportInteractionRef.current = kind;
2131
- setUserViewport((current) => {
2132
- const base = gesture?.from ?? current ?? authoredViewport;
2133
- if (!base) return current;
2134
- const next = compute(base, frame);
2135
- // Once navigated, the window is the user's until they reset it, even if
2136
- // a gesture happens to land back on the authored range: the owner may
2137
- // echo a navigated range back as the authored one, and dropping to null
2138
- // there would reload the original range under the pointer.
2139
- return sameCompositeViewport(next, current ?? base) ? current : next;
2140
- });
2141
- }, [authoredViewport, navigationFrame]);
2142
- const panViewport = useCallback((
2143
- deltaPositions: number,
2144
- gesture?: { frame: CompositeNavigationFrame; from: CompositeViewportRange },
2145
- ) => {
2146
- navigate("pan", (base, frame) => panCompositeViewport(frame, base, deltaPositions), gesture);
2147
- }, [navigate]);
2148
- const panViewportByRatio = useCallback((shiftRatio: number) => {
2149
- if (!navigationFrame || !effectiveViewport) return;
2150
- const positions = compositeViewportPositions(navigationFrame, effectiveViewport);
2151
- if (!positions) return;
2152
- panViewport(shiftRatio * Math.max(positions.end - positions.start, Number.EPSILON));
2153
- }, [effectiveViewport, navigationFrame, panViewport]);
2154
- const zoomViewport = useCallback((zoomFactor: number, anchorRatio = 1) => {
2155
- navigate("zoom", (base, frame) => zoomCompositeViewport(frame, base, zoomFactor, anchorRatio));
2156
- }, [navigate]);
2157
- const setViewportRange = useCallback((range: CompositeViewportRange) => {
2158
- navigate("zoom", (_base, frame) => fitCompositeViewport(frame, range));
2159
- }, [navigate]);
2160
- const resetViewport = useCallback(() => {
2161
- viewportInteractionRef.current = "reset";
2162
- setUserViewport(null);
2163
- }, []);
2164
- // Sticky while armed: the toolbar chip shows which tool owns the drag, and a
2165
- // one-shot tool would blink off before the user could see it.
2166
- const armTool = useCallback((tool: ChartToolKind | null) => {
2167
- setArmedTool((current) => current === tool ? null : tool);
2168
- setKeyboardPlacement(null);
2169
- if (tool === null) setSelectedDrawingId(null);
2170
- if (tool !== "level") setSelectedLevelId(null);
2171
- }, []);
2172
- const cancelKeyboardPlacement = useCallback(() => setKeyboardPlacement(null), []);
158
+ const {
159
+ navigationFrame, activeUserViewport, effectiveViewport,
160
+ panViewport, panViewportByRatio, zoomViewport, setViewportRange, resetViewport,
161
+ } = useCompositeViewport({
162
+ viewport, viewportResetKey, visibleSeries, marketTimelineSeries,
163
+ allowHistoricalBackfill, onViewportChange,
164
+ });
2173
165
  const legendRows = showLegend && (visibleSeries.length > 0 || legendAccessory)
2174
166
  ? 1
2175
167
  : 0;
@@ -2296,10 +288,66 @@ export function CompositeChart({
2296
288
  )
2297
289
  : null
2298
290
  ), [baseScene, normalizedCursorTimestamp]);
291
+ const lastCursorTimestampRef = useRef<number | null>(normalizedCursorTimestamp);
292
+ const renderedCursorTimestampRef = useRef<number | null>(normalizedCursorTimestamp);
293
+ if (renderedCursorTimestampRef.current !== normalizedCursorTimestamp) {
294
+ renderedCursorTimestampRef.current = normalizedCursorTimestamp;
295
+ lastCursorTimestampRef.current = normalizedCursorTimestamp;
296
+ }
297
+
298
+ const updateCursor = useCallback((date: Date | null) => {
299
+ const timestamp = date?.getTime() ?? null;
300
+ const nextTimestamp = timestamp !== null && Number.isFinite(timestamp) ? timestamp : null;
301
+ if (lastCursorTimestampRef.current === nextTimestamp) return;
302
+ lastCursorTimestampRef.current = nextTimestamp;
303
+ if (cursorDate === undefined) setInternalCursorDate(date);
304
+ onCursorDateChange?.(date);
305
+ }, [cursorDate, onCursorDateChange]);
306
+
307
+ const {
308
+ armedTool,
309
+ armTool,
310
+ cancelKeyboardPlacement,
311
+ activateFromPointer,
312
+ legendKeyboardIndex,
313
+ levelHost,
314
+ levelsEditable,
315
+ selectedLevelId,
316
+ setSelectedLevelId,
317
+ keyboardToolDrag,
318
+ } = useCompositeChartKeyboard({
319
+ focused,
320
+ interactive,
321
+ navigable,
322
+ showLegend,
323
+ scene,
324
+ cursorDate,
325
+ updateCursor,
326
+ onActivate,
327
+ timePick,
328
+ levels,
329
+ visibleLegendSeries,
330
+ visibleSeriesIds,
331
+ onToggleSeries,
332
+ isSeriesToggleable,
333
+ navigationFrame,
334
+ activeUserViewport,
335
+ panViewportByRatio,
336
+ zoomViewport,
337
+ setViewportRange,
338
+ resetViewport,
339
+ drawings,
340
+ selectedDrawingId,
341
+ setSelectedDrawingId,
342
+ drawColor,
343
+ addDrawing,
344
+ removeDrawing,
345
+ pickDrawColor,
346
+ });
2299
347
  const handleEmptyMouseScroll = useCallback((event: ChartMouseEvent) => {
2300
348
  const scroll = event.scroll;
2301
349
  if (!interactive || !navigationFrame || !scroll) return;
2302
- onActivate?.();
350
+ activateFromPointer();
2303
351
  consumeChartMouseEvent(event);
2304
352
  if (event.modifiers.ctrl && isVerticalWheelDirection(scroll.direction)) {
2305
353
  const factor = resolveCompositeWheelZoom(scroll);
@@ -2312,511 +360,15 @@ export function CompositeChart({
2312
360
  }
2313
361
  panViewportByRatio(resolveCompositeWheelPan(scroll, plotWidth * cellWidthPx));
2314
362
  }, [
363
+ activateFromPointer,
2315
364
  cellWidthPx,
2316
365
  interactive,
2317
366
  navigationFrame,
2318
- onActivate,
2319
367
  panViewportByRatio,
2320
368
  plotWidth,
2321
369
  resetViewport,
2322
370
  zoomViewport,
2323
371
  ]);
2324
- const levelHost = useMemo(
2325
- () => scene && levels ? levelPanel(scene, levels.seriesId) : null,
2326
- [levels, scene],
2327
- );
2328
- const levelItems = levels?.items ?? [];
2329
- const levelsEditable = !!levels?.onEdit && !!levelHost;
2330
- const selectedLevel = levelItems.find((level) => level.id === selectedLevelId) ?? null;
2331
- useEffect(() => {
2332
- if (selectedLevelId && !selectedLevel) setSelectedLevelId(null);
2333
- }, [selectedLevel, selectedLevelId]);
2334
- const keyboardCursorDateRef = useRef<Date | null>(scene?.cursorDate ?? null);
2335
- keyboardCursorDateRef.current = scene?.cursorDate ?? null;
2336
- const lastCursorTimestampRef = useRef<number | null>(normalizedCursorTimestamp);
2337
- const renderedCursorTimestampRef = useRef<number | null>(normalizedCursorTimestamp);
2338
- if (renderedCursorTimestampRef.current !== normalizedCursorTimestamp) {
2339
- renderedCursorTimestampRef.current = normalizedCursorTimestamp;
2340
- lastCursorTimestampRef.current = normalizedCursorTimestamp;
2341
- }
2342
-
2343
- const updateCursor = useCallback((date: Date | null) => {
2344
- const timestamp = date?.getTime() ?? null;
2345
- const nextTimestamp = timestamp !== null && Number.isFinite(timestamp) ? timestamp : null;
2346
- if (lastCursorTimestampRef.current === nextTimestamp) return;
2347
- lastCursorTimestampRef.current = nextTimestamp;
2348
- if (cursorDate === undefined) setInternalCursorDate(date);
2349
- onCursorDateChange?.(date);
2350
- }, [cursorDate, onCursorDateChange]);
2351
-
2352
- // The cursor keys belong to every focused chart; pan, zoom, the tools and
2353
- // their keys only to one that navigates.
2354
- const arrowsClaimed = usePaneArrowsClaimed();
2355
- const keyboardActive = focused && interactive;
2356
- const toolsActive = keyboardActive && navigable;
2357
- useEffect(() => {
2358
- if (!toolsActive) setKeyboardPlacement(null);
2359
- }, [toolsActive]);
2360
- const legendKeysActive = keyboardActive && showLegend && visibleLegendSeries.length > 0;
2361
- const legendEntry = legendKeyboardIndex === null ? undefined : visibleLegendSeries[legendKeyboardIndex];
2362
- const legendEntryToggleable = !!legendEntry && !!onToggleSeries && (isSeriesToggleable?.(legendEntry) ?? true);
2363
- const anyLegendToggleable = legendKeysActive && !!onToggleSeries
2364
- && visibleLegendSeries.some((entry) => isSeriesToggleable?.(entry) ?? true);
2365
- // With a drawing tool in hand, [ and ] step through the drawings instead of
2366
- // the legend, the way a click with that tool picks one.
2367
- const drawingKeysActive = toolsActive && isDrawingTool(armedTool) && drawings.length > 0 && !keyboardPlacement;
2368
- // Backspace only deletes what the user is working on; otherwise it is the
2369
- // pane's back key.
2370
- const canDeleteDrawing = toolsActive && drawings.length > 0 && (!!selectedDrawingId || isDrawingTool(armedTool));
2371
- // An owner that holds the cursor decides when it goes, so Esc stays the pane's.
2372
- const clearableCursor = !!scene?.cursorDate && cursorDate === undefined;
2373
-
2374
- const placementPanel = (panelId: string) => scene?.panels.find((panel) => panel.id === panelId) ?? null;
2375
- const startKeyboardPlacement = () => {
2376
- if (!scene || !armedTool) return;
2377
- const panel = scene.panels.find((entry) => resolveMeasureAxisDomain(entry) !== null);
2378
- const domain = panel ? resolveMeasureAxisDomain(panel) : null;
2379
- const date = keyboardCursorDateRef.current ?? resolveAdjacentCompositeCursorDate(scene, null, -1);
2380
- if (!panel || !domain || !date) return;
2381
- const time = date.getTime();
2382
- const value = resolveMeasureValueAt(panel, time) ?? unprojectCompositeValue(0.5, domain);
2383
- if (value === null) return;
2384
- const anchor = { time, value };
2385
- onActivate?.();
2386
- if (isDrawingTool(armedTool)) setSelectedDrawingId(null);
2387
- setKeyboardPlacement({ kind: armedTool, panelId: panel.id, start: anchor, end: anchor, path: [anchor], freeValue: false });
2388
- keyboardCursorDateRef.current = date;
2389
- updateCursor(date);
2390
- };
2391
- /** Moves the end to a new bar, following the series until Up or Down set a level. */
2392
- const moveKeyboardPlacement = (
2393
- placement: KeyboardToolPlacement,
2394
- move: { time: number } | { level: 1 | -1 },
2395
- ): KeyboardToolPlacement | null => {
2396
- const panel = placementPanel(placement.panelId);
2397
- const domain = panel ? resolveMeasureAxisDomain(panel) : null;
2398
- if (!panel || !domain) return null;
2399
- let { time, value } = placement.end;
2400
- let freeValue = placement.freeValue;
2401
- if ("level" in move) {
2402
- // Half a row per press: fine enough to reach a wick, quick enough to cross the plot.
2403
- const step = 1 / (2 * Math.max(panel.height - 1, 1));
2404
- const ratio = projectCompositeValue(value, domain) ?? 0.5;
2405
- value = unprojectCompositeValue(Math.max(0, Math.min(1, ratio - move.level * step)), domain) ?? value;
2406
- freeValue = true;
2407
- } else {
2408
- time = move.time;
2409
- if (!freeValue) value = resolveMeasureValueAt(panel, time) ?? value;
2410
- }
2411
- const end = { time, value };
2412
- return { ...placement, end, path: [...placement.path, end], freeValue };
2413
- };
2414
- const finishKeyboardPlacement = () => {
2415
- const placement = keyboardPlacement;
2416
- setKeyboardPlacement(null);
2417
- // A placement that never left its anchor spans nothing, like a click.
2418
- if (!placement || placement.start.time === placement.end.time) return;
2419
- if (placement.kind === "zoom") {
2420
- if (!navigationFrame) return;
2421
- const range = resolveZoomTimeRange(placement.start.time, placement.end.time, navigationFrame.minimumSpanMs);
2422
- if (range) setViewportRange(range);
2423
- return;
2424
- }
2425
- if (!isDrawingTool(placement.kind)) return;
2426
- const points = placement.kind === "pencil"
2427
- ? placement.path.filter((point, index, path) => (
2428
- index === 0 || point.time !== path[index - 1]!.time || point.value !== path[index - 1]!.value
2429
- ))
2430
- : [placement.start, placement.end];
2431
- if (points.length < 2) return;
2432
- addDrawing({ id: nextDrawingId(), panelId: placement.panelId, points, color: drawColor });
2433
- };
2434
- const stepLegend = (direction: -1 | 1) => {
2435
- onActivate?.();
2436
- setLegendKeyboardIndex((current) => (
2437
- current === null
2438
- ? direction > 0 ? 0 : visibleLegendSeries.length - 1
2439
- : (current + direction + visibleLegendSeries.length) % visibleLegendSeries.length
2440
- ));
2441
- };
2442
- const toggleLegendEntry = () => {
2443
- if (!legendEntry || !legendEntryToggleable) return;
2444
- onActivate?.();
2445
- onToggleSeries?.(legendEntry.id);
2446
- };
2447
- /** A level at the keyboard cursor's price, or the last price without a cursor. */
2448
- const addLevelAtCursor = () => {
2449
- if (!levels?.onEdit || !levelHost || !scene) return;
2450
- const { panel, domain } = levelHost;
2451
- const date = keyboardCursorDateRef.current ?? scene.dates.at(-1) ?? null;
2452
- // The levels' own series, not whichever one the panel lists first.
2453
- const pricePanel = { ...panel, series: panel.series.filter((entry) => entry.source.id === levels.seriesId) };
2454
- const value = (date ? resolveMeasureValueAt(pricePanel, date.getTime()) : null)
2455
- ?? (panel.lastPrice?.seriesId === levels.seriesId ? panel.lastPrice.value : null)
2456
- ?? unprojectCompositeValue(0.5, domain);
2457
- if (value === null) return;
2458
- const rounded = roundLevelValue(value, domain);
2459
- onActivate?.();
2460
- // Enter again at the same cursor picks the level already there.
2461
- const existing = levelItems.find((level) => level.editable && level.value === rounded);
2462
- if (existing) {
2463
- setSelectedLevelId(existing.id);
2464
- return;
2465
- }
2466
- const id = nextLevelId();
2467
- levels.onEdit({ kind: "add", id, value: rounded });
2468
- setSelectedLevelId(id);
2469
- };
2470
- /** Half a row per press, like a keyboard-placed tool's end. */
2471
- const nudgeLevel = (level: CompositeChartLevel, direction: 1 | -1) => {
2472
- if (!levels?.onEdit || !levelHost) return;
2473
- const { panel, domain } = levelHost;
2474
- const ratio = projectCompositeValue(level.value, domain);
2475
- if (ratio === null) return;
2476
- const step = 1 / (2 * Math.max(panel.height - 1, 1));
2477
- const value = unprojectCompositeValue(Math.max(0, Math.min(1, ratio - direction * step)), domain);
2478
- if (value !== null) levels.onEdit({ kind: "move", id: level.id, value: roundLevelValue(value, domain) });
2479
- };
2480
- const stepLevel = (direction: -1 | 1) => {
2481
- // Top to bottom, the order they read in on the plot.
2482
- const ordered = [...levelItems].sort((left, right) => right.value - left.value);
2483
- if (ordered.length === 0) return;
2484
- const index = ordered.findIndex((level) => level.id === selectedLevelId);
2485
- const next = index < 0
2486
- ? direction > 0 ? 0 : ordered.length - 1
2487
- : (index + direction + ordered.length) % ordered.length;
2488
- setSelectedLevelId(ordered[next]!.id);
2489
- };
2490
- const removeSelectedLevel = () => {
2491
- if (!selectedLevel?.editable) return;
2492
- levels?.onEdit?.({ kind: "remove", id: selectedLevel.id });
2493
- setSelectedLevelId(null);
2494
- };
2495
- const runLevelAction = () => {
2496
- if (selectedLevel?.actionable) levels?.action?.run(selectedLevel);
2497
- };
2498
- const stepDrawing = (direction: -1 | 1) => {
2499
- if (drawings.length === 0) return;
2500
- const index = drawings.findIndex((drawing) => drawing.id === selectedDrawingId);
2501
- const next = index < 0
2502
- ? direction > 0 ? 0 : drawings.length - 1
2503
- : (index + direction + drawings.length) % drawings.length;
2504
- setSelectedDrawingId(drawings[next]!.id);
2505
- };
2506
- const keyboardToolDrag = useMemo<KeyboardToolDrag | null>(() => {
2507
- if (!keyboardPlacement || !scene) return null;
2508
- const panel = scene.panels.find((entry) => entry.id === keyboardPlacement.panelId);
2509
- const domain = panel ? resolveMeasureAxisDomain(panel) : null;
2510
- if (!panel || !domain) return null;
2511
- const project = (point: ChartDrawingPoint) => {
2512
- const xRatio = projectCompositeTimestamp(scene.timeScale, point.time)?.ratio;
2513
- const yRatio = projectCompositeValue(point.value, domain);
2514
- return typeof xRatio === "number" && yRatio !== null ? { xRatio, yRatio } : null;
2515
- };
2516
- const start = project(keyboardPlacement.start);
2517
- const end = project(keyboardPlacement.end);
2518
- if (!start || !end) return null;
2519
- return {
2520
- panelId: panel.id,
2521
- start: keyboardPlacement.start,
2522
- end: keyboardPlacement.end,
2523
- drag: {
2524
- kind: keyboardPlacement.kind,
2525
- startXRatio: start.xRatio,
2526
- startYRatio: start.yRatio,
2527
- endXRatio: end.xRatio,
2528
- endYRatio: end.yRatio,
2529
- path: keyboardPlacement.path.flatMap((point) => {
2530
- const projected = project(point);
2531
- return projected ? [projected] : [];
2532
- }),
2533
- },
2534
- };
2535
- }, [keyboardPlacement, scene]);
2536
-
2537
- // Scoped, so the chart sees its keys before an older pane or stack handler
2538
- // (Backspace back, Esc close) whichever mounted first; a dialog opened over
2539
- // it is a newer scope and still comes first.
2540
- useShortcut((event) => {
2541
- if (!keyboardActive) return;
2542
- const consume = () => {
2543
- event.preventDefault();
2544
- event.stopPropagation();
2545
- };
2546
- if (timePick && scene && isPlainKey(event, "return", "enter", "escape")) {
2547
- consume();
2548
- if (event.name === "escape") timePick.onCancel();
2549
- else chartActionsRef.current.pick();
2550
- return;
2551
- }
2552
- const levelKeys = toolsActive && armedTool === "level" && levelsEditable && !keyboardPlacement;
2553
- if (levelKeys && isPlainKey(event, "return", "enter")) {
2554
- consume();
2555
- addLevelAtCursor();
2556
- return;
2557
- }
2558
- if (levelKeys && selectedLevel?.editable && isPlainKey(event, "up", "down")) {
2559
- consume();
2560
- nudgeLevel(selectedLevel, event.name === "up" ? 1 : -1);
2561
- return;
2562
- }
2563
- if (levelKeys && levelItems.length > 0 && isPlainKey(event, "[", "]")) {
2564
- consume();
2565
- stepLevel(event.name === "[" ? -1 : 1);
2566
- return;
2567
- }
2568
- if (toolsActive && selectedLevel?.editable && levelsEditable && isPlainKey(event, "backspace")) {
2569
- consume();
2570
- removeSelectedLevel();
2571
- return;
2572
- }
2573
- // Taken even once the level has its alert, which then says so, so a quick
2574
- // second press never falls through to the plain `a` pan.
2575
- if (toolsActive && selectedLevel && levels?.action && isShiftedLetter(event, "a")) {
2576
- consume();
2577
- levels.action.run(selectedLevel);
2578
- return;
2579
- }
2580
- if (toolsActive && armedTool && isPlainKey(event, "return", "enter") && scene) {
2581
- consume();
2582
- if (keyboardPlacement) finishKeyboardPlacement();
2583
- else startKeyboardPlacement();
2584
- return;
2585
- }
2586
- if (keyboardPlacement && (isPlainKey(event, "escape") || isPlainKey(event, "backspace"))) {
2587
- consume();
2588
- setKeyboardPlacement(null);
2589
- return;
2590
- }
2591
- if (keyboardPlacement && keyboardPlacement.kind !== "zoom" && isPlainKey(event, "up", "down")) {
2592
- consume();
2593
- const level = event.name === "up" ? 1 : -1;
2594
- setKeyboardPlacement((current) => current && moveKeyboardPlacement(current, { level }));
2595
- return;
2596
- }
2597
- if (isPlainKey(event, "[", "]") && (drawingKeysActive || legendKeysActive)) {
2598
- consume();
2599
- const direction = event.name === "[" ? -1 : 1;
2600
- if (drawingKeysActive) stepDrawing(direction);
2601
- else stepLegend(direction);
2602
- return;
2603
- }
2604
- if (isPlainKey(event, "space") && legendKeysActive && legendEntryToggleable) {
2605
- consume();
2606
- toggleLegendEntry();
2607
- return;
2608
- }
2609
- if (isPlainKey(event, "escape") && toolsActive && (armedTool || selectedDrawingId || selectedLevelId)) {
2610
- consume();
2611
- setArmedTool(null);
2612
- setSelectedDrawingId(null);
2613
- setSelectedLevelId(null);
2614
- return;
2615
- }
2616
- if (isPlainKey(event, "escape") && legendKeysActive && legendKeyboardIndex !== null && !clearableCursor) {
2617
- consume();
2618
- setLegendKeyboardIndex(null);
2619
- return;
2620
- }
2621
- const interaction = resolveCompositeChartInteraction(event);
2622
- if (!interaction) return;
2623
- const cursorInteraction = interaction === "cursor-left"
2624
- || interaction === "cursor-right"
2625
- || interaction === "clear-cursor";
2626
- if (!cursorInteraction && !toolsActive) return;
2627
- if (
2628
- (interaction === "clear-cursor" && !clearableCursor)
2629
- || ((interaction === "arm-measure"
2630
- || interaction === "arm-zoom"
2631
- || interaction === "arm-line"
2632
- || interaction === "arm-pencil") && !scene)
2633
- || (interaction === "arm-level" && !levelsEditable)
2634
- || (interaction === "delete-drawing" && !canDeleteDrawing)
2635
- || (interaction === "cycle-colour" && !isDrawingTool(armedTool) && !selectedDrawingId)
2636
- // A read-only chart leaves the arrows to a focused tab strip in its pane;
2637
- // a chart you pan and draw on keeps them, with h/l for the tabs.
2638
- || ((interaction === "cursor-left" || interaction === "cursor-right") && (!scene || (arrowsClaimed && !navigable)))
2639
- || ((interaction === "zoom-in"
2640
- || interaction === "zoom-out"
2641
- || interaction === "pan-left"
2642
- || interaction === "pan-right") && !navigationFrame)
2643
- ) {
2644
- return;
2645
- }
2646
- consume();
2647
- switch (interaction) {
2648
- case "arm-measure":
2649
- case "arm-zoom":
2650
- case "arm-line":
2651
- case "arm-pencil":
2652
- case "arm-level":
2653
- onActivate?.();
2654
- armTool(ARMED_TOOL_BY_INTERACTION[interaction]);
2655
- return;
2656
- case "delete-drawing":
2657
- removeDrawing();
2658
- return;
2659
- case "cycle-colour":
2660
- pickDrawColor(nextDrawingColor(drawColor));
2661
- return;
2662
- case "clear-cursor":
2663
- keyboardCursorDateRef.current = null;
2664
- updateCursor(null);
2665
- return;
2666
- case "cursor-left":
2667
- case "cursor-right": {
2668
- const nextDate = resolveAdjacentCompositeCursorDate(
2669
- scene!,
2670
- keyboardCursorDateRef.current,
2671
- interaction === "cursor-left" ? -1 : 1,
2672
- );
2673
- keyboardCursorDateRef.current = nextDate;
2674
- updateCursor(nextDate);
2675
- if (nextDate && keyboardPlacement) {
2676
- const time = nextDate.getTime();
2677
- setKeyboardPlacement((current) => current && moveKeyboardPlacement(current, { time }));
2678
- }
2679
- return;
2680
- }
2681
- case "pan-left":
2682
- panViewportByRatio(COMPOSITE_KEYBOARD_PAN_RATIO);
2683
- return;
2684
- case "pan-right":
2685
- panViewportByRatio(-COMPOSITE_KEYBOARD_PAN_RATIO);
2686
- return;
2687
- case "reset":
2688
- resetViewport();
2689
- return;
2690
- case "zoom-in":
2691
- zoomViewport(COMPOSITE_ZOOM_STEP_FACTOR);
2692
- return;
2693
- case "zoom-out":
2694
- zoomViewport(1 / COMPOSITE_ZOOM_STEP_FACTOR);
2695
- }
2696
- }, { enabled: keyboardActive, scope: keyboardId });
2697
-
2698
- // The keys a mode adds show where they act: Enter with a tool in hand, Space
2699
- // on a legend entry. Everything else the chart answers is in the pane menu.
2700
- // Footer and menu entries outlive the render that registered them, so they
2701
- // call through to this render's actions.
2702
- const chartActions = {
2703
- pick: () => {
2704
- const date = keyboardCursorDateRef.current ?? scene?.dates.at(-1) ?? null;
2705
- if (date) timePick?.onPick(date);
2706
- },
2707
- start: armedTool === "level" ? addLevelAtCursor : startKeyboardPlacement,
2708
- finish: finishKeyboardPlacement,
2709
- stepLevel,
2710
- removeLevel: removeSelectedLevel,
2711
- levelAction: runLevelAction,
2712
- toggleLegend: toggleLegendEntry,
2713
- stepLegend,
2714
- stepDrawing,
2715
- removeDrawing,
2716
- cycleColour: () => pickDrawColor(nextDrawingColor(drawColor)),
2717
- reset: resetViewport,
2718
- arm: (tool: ChartToolKind) => {
2719
- onActivate?.();
2720
- armTool(tool);
2721
- },
2722
- };
2723
- const chartActionsRef = useRef(chartActions);
2724
- chartActionsRef.current = chartActions;
2725
- const armedHints = armedTool ? KEYBOARD_TOOL_HINTS[armedTool] : null;
2726
- const placing = !!keyboardPlacement;
2727
- const legendEntryVisible = !!legendEntry && visibleSeriesIds.has(legendEntry.id);
2728
- const resettable = !!activeUserViewport;
2729
- const timePickLabel = timePick?.label ?? null;
2730
- const levelActionHint = selectedLevel?.actionable && levels?.action ? levels.action : null;
2731
- const levelToolKeys = toolsActive && armedTool === "level" && levelsEditable;
2732
- const canDeleteLevel = toolsActive && levelsEditable && !!selectedLevel?.editable;
2733
- usePaneFooter(keyboardId, () => {
2734
- if (!keyboardActive || !scene) return null;
2735
- const hints: PaneHint[] = [];
2736
- if (timePickLabel) {
2737
- hints.push({ id: "chart-pick", key: "Enter", label: timePickLabel, onPress: () => chartActionsRef.current.pick() });
2738
- } else if (toolsActive && armedHints) {
2739
- hints.push(placing
2740
- ? { id: "chart-tool", key: "Enter", label: armedHints.finish, title: armedHints.finishTitle, onPress: () => chartActionsRef.current.finish() }
2741
- : { id: "chart-tool", key: "Enter", label: armedHints.start, title: armedHints.startTitle, onPress: () => chartActionsRef.current.start() });
2742
- }
2743
- if (toolsActive && levelActionHint) {
2744
- hints.push({
2745
- id: "chart-level-action",
2746
- key: "Shift+A",
2747
- label: levelActionHint.label,
2748
- title: levelActionHint.title,
2749
- onPress: () => chartActionsRef.current.levelAction(),
2750
- });
2751
- }
2752
- if (legendKeysActive && legendEntryToggleable) {
2753
- hints.push({
2754
- id: "chart-series",
2755
- key: "Space",
2756
- label: legendEntryVisible ? "hide" : "show",
2757
- title: legendEntryVisible ? "Hide Series" : "Show Series",
2758
- onPress: () => chartActionsRef.current.toggleLegend(),
2759
- });
2760
- }
2761
- const menu: ContextMenuItem[] = [];
2762
- if (toolsActive) {
2763
- for (const tool of CHART_TOOL_MENU.filter((entry) => entry.kind !== "level" || levelsEditable)) {
2764
- menu.push({
2765
- id: `chart-tool-${tool.kind}`,
2766
- label: tool.label,
2767
- accelerator: tool.accelerator,
2768
- checked: armedTool === tool.kind,
2769
- onSelect: () => chartActionsRef.current.arm(tool.kind),
2770
- });
2771
- }
2772
- if (drawingKeysActive) {
2773
- menu.push({ id: "chart-drawing-next", label: "Next Drawing", accelerator: "]", onSelect: () => chartActionsRef.current.stepDrawing(1) });
2774
- }
2775
- if (canDeleteDrawing) {
2776
- menu.push({ id: "chart-drawing-delete", label: "Delete Drawing", accelerator: "Backspace", onSelect: () => chartActionsRef.current.removeDrawing() });
2777
- }
2778
- if (levelToolKeys && levelItems.length > 0) {
2779
- menu.push({ id: "chart-level-next", label: "Next Level", accelerator: "]", onSelect: () => chartActionsRef.current.stepLevel(1) });
2780
- }
2781
- if (canDeleteLevel) {
2782
- menu.push({ id: "chart-level-delete", label: "Delete Level", accelerator: "Backspace", onSelect: () => chartActionsRef.current.removeLevel() });
2783
- }
2784
- if (isDrawingTool(armedTool) || selectedDrawingId) {
2785
- menu.push({
2786
- id: "chart-drawing-colour",
2787
- label: "Drawing Colour",
2788
- accelerator: "c",
2789
- onSelect: () => chartActionsRef.current.cycleColour(),
2790
- });
2791
- }
2792
- if (resettable) menu.push({ id: "chart-reset", label: "Reset Zoom", accelerator: "0", onSelect: () => chartActionsRef.current.reset() });
2793
- }
2794
- if (legendKeysActive && anyLegendToggleable && !drawingKeysActive) {
2795
- menu.push({ id: "chart-series-next", label: "Next Series", accelerator: "]", onSelect: () => chartActionsRef.current.stepLegend(1) });
2796
- }
2797
- return { order: 10, hints, menu };
2798
- }, [
2799
- armedHints,
2800
- anyLegendToggleable,
2801
- armedTool,
2802
- canDeleteDrawing,
2803
- drawingKeysActive,
2804
- keyboardActive,
2805
- legendEntryToggleable,
2806
- legendEntryVisible,
2807
- legendKeysActive,
2808
- placing,
2809
- resettable,
2810
- !!scene,
2811
- selectedDrawingId,
2812
- timePickLabel,
2813
- levelActionHint,
2814
- levelToolKeys,
2815
- levelItems.length,
2816
- canDeleteLevel,
2817
- levelsEditable,
2818
- toolsActive,
2819
- ]);
2820
372
 
2821
373
  const leftPadding = leftAxisWidth + (leftAxisWidth ? axisGap : 0);
2822
374
  const rightPadding = rightAxisWidth + (rightAxisWidth ? axisGap : 0);
@@ -2847,7 +399,7 @@ export function CompositeChart({
2847
399
  accessoryWidth={legendAccessoryWidth}
2848
400
  formatValue={formatValue}
2849
401
  showLatestChangePercent={showLatestChangePercent}
2850
- onActivate={onActivate}
402
+ onActivate={activateFromPointer}
2851
403
  onToggleSeries={onToggleSeries}
2852
404
  isSeriesToggleable={isSeriesToggleable}
2853
405
  keyboardIndex={legendKeyboardIndex}
@@ -2932,7 +484,7 @@ export function CompositeChart({
2932
484
  accessoryWidth={legendAccessoryWidth}
2933
485
  formatValue={formatValue}
2934
486
  showLatestChangePercent={showLatestChangePercent}
2935
- onActivate={onActivate}
487
+ onActivate={activateFromPointer}
2936
488
  onToggleSeries={onToggleSeries}
2937
489
  isSeriesToggleable={isSeriesToggleable}
2938
490
  keyboardIndex={legendKeyboardIndex}
@@ -2948,11 +500,11 @@ export function CompositeChart({
2948
500
  showColors={isDrawingTool(armedTool) || !!selectedDrawingId}
2949
501
  levelTool={levelsEditable}
2950
502
  onArmTool={(tool) => {
2951
- onActivate?.();
503
+ activateFromPointer();
2952
504
  armTool(tool);
2953
505
  }}
2954
506
  onPickColor={(color) => {
2955
- onActivate?.();
507
+ activateFromPointer();
2956
508
  pickDrawColor(color);
2957
509
  }}
2958
510
  />
@@ -2987,7 +539,7 @@ export function CompositeChart({
2987
539
  onDraw={addDrawing}
2988
540
  onEditDrawing={editDrawing}
2989
541
  onSelectDrawing={setSelectedDrawingId}
2990
- onActivate={onActivate}
542
+ onActivate={activateFromPointer}
2991
543
  onCursorDateChange={updateCursor}
2992
544
  onPanViewport={panViewport}
2993
545
  onZoomViewport={zoomViewport}