gloomberb 0.1.0 → 0.3.1

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 (311) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +68 -121
  3. package/bin/gloomberb +3 -0
  4. package/package.json +13 -4
  5. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  6. package/src/app.tsx +1330 -0
  7. package/src/brokers/sync-broker-instance.test.ts +100 -0
  8. package/src/brokers/sync-broker-instance.ts +341 -0
  9. package/src/cli/commands/plugins.ts +173 -0
  10. package/src/cli/commands/portfolio.ts +210 -0
  11. package/src/cli/commands/search.ts +120 -0
  12. package/src/cli/commands/ticker.ts +375 -0
  13. package/src/cli/commands/watchlist.ts +210 -0
  14. package/src/cli/context.ts +62 -0
  15. package/src/cli/errors.ts +13 -0
  16. package/src/cli/helpers.ts +85 -0
  17. package/src/cli/index.ts +143 -0
  18. package/src/cli/ticker-resolution.ts +39 -0
  19. package/src/cli/types.ts +15 -0
  20. package/src/cli.test.ts +361 -0
  21. package/src/components/chart/chart-data.ts +239 -0
  22. package/src/components/chart/chart-renderer.ts +1199 -0
  23. package/src/components/chart/chart-types.ts +82 -0
  24. package/src/components/chart/chart-viewport.ts +21 -0
  25. package/src/components/chart/chart.test.ts +439 -0
  26. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  27. package/src/components/chart/comparison-chart-data.ts +316 -0
  28. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  29. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  30. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  31. package/src/components/chart/cursor-motion.ts +48 -0
  32. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  33. package/src/components/chart/native/kitty-adapter.ts +12 -0
  34. package/src/components/chart/native/kitty-manager.ts +142 -0
  35. package/src/components/chart/native/kitty-protocol.ts +110 -0
  36. package/src/components/chart/native/kitty-support.ts +71 -0
  37. package/src/components/chart/native/kitty.test.ts +620 -0
  38. package/src/components/chart/native/renderer-selection.ts +24 -0
  39. package/src/components/chart/native/surface-manager.ts +225 -0
  40. package/src/components/chart/native/surface-sync.ts +34 -0
  41. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  42. package/src/components/chart/stock-chart.test.ts +107 -0
  43. package/src/components/chart/stock-chart.tsx +1556 -0
  44. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  45. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  46. package/src/components/command-bar/command-bar.tsx +4823 -0
  47. package/src/components/command-bar/command-registry.ts +261 -0
  48. package/src/components/command-bar/helpers.test.ts +224 -0
  49. package/src/components/command-bar/helpers.ts +371 -0
  50. package/src/components/command-bar/root-shortcuts.ts +158 -0
  51. package/src/components/command-bar/view-model.test.ts +152 -0
  52. package/src/components/command-bar/view-model.ts +185 -0
  53. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  54. package/src/components/command-bar/workflow-ops.ts +433 -0
  55. package/src/components/command-bar/workflow-types.ts +127 -0
  56. package/src/components/detail-feed-view.test.tsx +105 -0
  57. package/src/components/detail-feed-view.tsx +295 -0
  58. package/src/components/index.ts +32 -0
  59. package/src/components/layout/floating-pane.tsx +49 -0
  60. package/src/components/layout/header.tsx +129 -0
  61. package/src/components/layout/pane-header.tsx +44 -0
  62. package/src/components/layout/pane.tsx +49 -0
  63. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  64. package/src/components/layout/pixel-mouse.test.ts +40 -0
  65. package/src/components/layout/shell.test.tsx +625 -0
  66. package/src/components/layout/shell.tsx +1200 -0
  67. package/src/components/layout/status-bar.test.tsx +173 -0
  68. package/src/components/layout/status-bar.tsx +138 -0
  69. package/src/components/markdown-editor.tsx +134 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +310 -0
  97. package/src/data/config-store.ts +469 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +71 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +132 -0
  259. package/src/state/app-context.tsx +969 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +434 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +162 -0
  285. package/src/updater.ts +247 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -69
@@ -0,0 +1,1486 @@
1
+ import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
2
+ import { useKeyboard, useRenderer } from "@opentui/react";
3
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useAppState } from "../../state/app-context";
5
+ import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
6
+ import type { BrokerContractRef } from "../../types/instrument";
7
+ import type { PricePoint } from "../../types/financials";
8
+ import { formatCurrency, formatPercentRaw } from "../../utils/format";
9
+ import { getSharedDataProvider } from "../../plugins/registry";
10
+ import {
11
+ applyComparisonZoomAroundAnchor,
12
+ getMaxComparisonPanOffset,
13
+ getVisibleComparisonWindow,
14
+ projectComparisonChartData,
15
+ } from "./comparison-chart-data";
16
+ import {
17
+ buildComparisonChartScene,
18
+ formatComparisonAxisValue,
19
+ renderComparisonChart,
20
+ } from "./comparison-chart-renderer";
21
+ import {
22
+ CELL_CURSOR_SNAP_DISTANCE,
23
+ sameCursorPosition,
24
+ stepCursorTowards,
25
+ type ChartCursorMotionKind,
26
+ } from "./cursor-motion";
27
+ import {
28
+ COMPARISON_RENDER_MODES,
29
+ TIME_RANGES,
30
+ type ChartAxisMode,
31
+ type ChartRendererPreference,
32
+ type ComparisonChartRenderMode,
33
+ type ComparisonChartViewState,
34
+ type ResolvedChartRenderer,
35
+ } from "./chart-types";
36
+ import {
37
+ computeBitmapSize,
38
+ intersectCellRects,
39
+ renderNativeComparisonChartBase,
40
+ renderNativeCrosshairOverlay,
41
+ type CellRect,
42
+ type NativeChartBitmap,
43
+ type NativeCrosshairOverlay,
44
+ } from "./native/chart-rasterizer";
45
+ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-support";
46
+ import { resolveChartRendererState } from "./native/renderer-selection";
47
+ import { getNativeSurfaceManager } from "./native/surface-manager";
48
+ import { syncCachedNativeSurface } from "./native/surface-sync";
49
+ import { formatDateShort, type StyledContent } from "./chart-renderer";
50
+
51
+ const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
52
+ area: "A",
53
+ line: "L",
54
+ };
55
+
56
+ interface ComparisonStockChartProps {
57
+ paneId: string;
58
+ width: number;
59
+ height: number;
60
+ focused: boolean;
61
+ symbols: string[];
62
+ axisMode: ChartAxisMode;
63
+ onOpenSymbol: (symbol: string) => void;
64
+ }
65
+
66
+ interface ComparisonChartSymbolSource {
67
+ symbol: string;
68
+ currency: string | undefined;
69
+ exchange: string;
70
+ brokerId: string | undefined;
71
+ brokerInstanceId: string | undefined;
72
+ instrument: BrokerContractRef | null;
73
+ priceHistory: PricePoint[];
74
+ }
75
+
76
+ interface ComparisonStockChartViewProps extends ComparisonStockChartProps {
77
+ defaultRenderMode: string | undefined;
78
+ preferredRenderer: ChartRendererPreference;
79
+ symbolSources: ComparisonChartSymbolSource[];
80
+ }
81
+
82
+ interface DragState {
83
+ startGlobalX: number;
84
+ startPanOffset: number;
85
+ }
86
+
87
+ interface ChartMouseEvent {
88
+ x: number;
89
+ y: number;
90
+ pixelX?: number;
91
+ pixelY?: number;
92
+ modifiers: {
93
+ shift: boolean;
94
+ alt: boolean;
95
+ ctrl: boolean;
96
+ };
97
+ scroll?: {
98
+ direction: "up" | "down" | "left" | "right";
99
+ delta: number;
100
+ };
101
+ }
102
+
103
+ interface LocalPlotPointer {
104
+ cellX: number;
105
+ cellY: number;
106
+ pixelX: number | null;
107
+ pixelY: number | null;
108
+ hasPixelPrecision: boolean;
109
+ }
110
+
111
+ interface DisplayCursorState {
112
+ cellX: number | null;
113
+ cellY: number | null;
114
+ pixelX: number | null;
115
+ pixelY: number | null;
116
+ }
117
+
118
+ interface RenderableNode {
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ parent: RenderableNode | null;
124
+ }
125
+
126
+ const EMPTY_DISPLAY_CURSOR: DisplayCursorState = {
127
+ cellX: null,
128
+ cellY: null,
129
+ pixelX: null,
130
+ pixelY: null,
131
+ };
132
+
133
+ const PIXEL_CURSOR_SNAP_DISTANCE = 0.5;
134
+ const globalAnimationFrame = globalThis as typeof globalThis & {
135
+ requestAnimationFrame?: (callback: (timestamp: number) => void) => number;
136
+ cancelAnimationFrame?: (handle: number) => void;
137
+ };
138
+
139
+ function clamp(value: number, min: number, max: number): number {
140
+ return Math.max(min, Math.min(max, value));
141
+ }
142
+
143
+ function requestAnimationFrameSafe(callback: (timestamp: number) => void): number {
144
+ if (typeof globalAnimationFrame.requestAnimationFrame === "function") {
145
+ return globalAnimationFrame.requestAnimationFrame(callback);
146
+ }
147
+ return globalThis.setTimeout(() => callback(Date.now()), 16) as unknown as number;
148
+ }
149
+
150
+ function cancelAnimationFrameSafe(handle: number) {
151
+ if (typeof globalAnimationFrame.cancelAnimationFrame === "function") {
152
+ globalAnimationFrame.cancelAnimationFrame(handle);
153
+ return;
154
+ }
155
+ clearTimeout(handle);
156
+ }
157
+
158
+ function getRendererCellMetrics(renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">) {
159
+ if (!renderer.resolution) return null;
160
+ const cellWidth = renderer.resolution.width / Math.max(renderer.terminalWidth, 1);
161
+ const cellHeight = renderer.resolution.height / Math.max(renderer.terminalHeight, 1);
162
+ if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
163
+ return { cellWidth, cellHeight };
164
+ }
165
+
166
+ function getRenderablePixelSize(
167
+ renderable: Pick<RenderableNode, "width" | "height"> | null,
168
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
169
+ ) {
170
+ const metrics = getRendererCellMetrics(renderer);
171
+ if (!renderable || !metrics) return null;
172
+ return {
173
+ pixelWidth: Math.max(renderable.width * metrics.cellWidth, 1),
174
+ pixelHeight: Math.max(renderable.height * metrics.cellHeight, 1),
175
+ };
176
+ }
177
+
178
+ function projectCellCursorToLocalPixels(
179
+ cellX: number,
180
+ cellY: number,
181
+ renderable: Pick<RenderableNode, "width" | "height"> | null,
182
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
183
+ ): { pixelX: number; pixelY: number } | null {
184
+ const pixelSize = getRenderablePixelSize(renderable, renderer);
185
+ if (!renderable || !pixelSize) return null;
186
+
187
+ return {
188
+ pixelX: renderable.width <= 1
189
+ ? 0
190
+ : clamp(
191
+ (cellX / Math.max(renderable.width - 1, 1)) * Math.max(pixelSize.pixelWidth - 1, 0),
192
+ 0,
193
+ Math.max(pixelSize.pixelWidth - 1, 0),
194
+ ),
195
+ pixelY: renderable.height <= 1
196
+ ? 0
197
+ : clamp(
198
+ (cellY / Math.max(renderable.height - 1, 1)) * Math.max(pixelSize.pixelHeight - 1, 0),
199
+ 0,
200
+ Math.max(pixelSize.pixelHeight - 1, 0),
201
+ ),
202
+ };
203
+ }
204
+
205
+ function scaleLocalPixelCoordinate(value: number | null, sourceExtent: number, targetExtent: number): number | null {
206
+ if (value === null) return null;
207
+ if (targetExtent <= 1) return 0;
208
+ if (!(sourceExtent > 1)) {
209
+ return clamp(value, 0, Math.max(targetExtent - 1, 0));
210
+ }
211
+ return clamp(
212
+ (value / Math.max(sourceExtent - 1, 1)) * Math.max(targetExtent - 1, 0),
213
+ 0,
214
+ Math.max(targetExtent - 1, 0),
215
+ );
216
+ }
217
+
218
+ function toCursorPosition(x: number | null, y: number | null) {
219
+ return { x, y };
220
+ }
221
+
222
+ function sameDisplayCursorState(
223
+ left: DisplayCursorState,
224
+ right: DisplayCursorState,
225
+ cellEpsilon = 0,
226
+ pixelEpsilon = 0,
227
+ ): boolean {
228
+ return sameCursorPosition(toCursorPosition(left.cellX, left.cellY), toCursorPosition(right.cellX, right.cellY), cellEpsilon)
229
+ && sameCursorPosition(toCursorPosition(left.pixelX, left.pixelY), toCursorPosition(right.pixelX, right.pixelY), pixelEpsilon);
230
+ }
231
+
232
+ function buildDisplayCursorState(
233
+ cellX: number | null,
234
+ cellY: number | null,
235
+ renderable: BoxRenderable | null,
236
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
237
+ pixelX: number | null = null,
238
+ pixelY: number | null = null,
239
+ ): DisplayCursorState {
240
+ if (cellX === null || cellY === null) return EMPTY_DISPLAY_CURSOR;
241
+ if (pixelX !== null && pixelY !== null) {
242
+ return { cellX, cellY, pixelX, pixelY };
243
+ }
244
+ const derivedPixels = projectCellCursorToLocalPixels(cellX, cellY, renderable, renderer);
245
+ return {
246
+ cellX,
247
+ cellY,
248
+ pixelX: derivedPixels?.pixelX ?? null,
249
+ pixelY: derivedPixels?.pixelY ?? null,
250
+ };
251
+ }
252
+
253
+ function getLocalPlotPointer(
254
+ event: ChartMouseEvent,
255
+ renderable: BoxRenderable | null,
256
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
257
+ ): LocalPlotPointer | null {
258
+ if (!renderable) return null;
259
+
260
+ const localCellX = event.x - renderable.x;
261
+ const localCellY = event.y - renderable.y;
262
+ if (localCellX < 0 || localCellX >= renderable.width || localCellY < 0 || localCellY >= renderable.height) {
263
+ return null;
264
+ }
265
+
266
+ const metrics = getRendererCellMetrics(renderer);
267
+ if (event.pixelX === undefined || event.pixelY === undefined || !metrics) {
268
+ return {
269
+ cellX: localCellX,
270
+ cellY: localCellY,
271
+ pixelX: null,
272
+ pixelY: null,
273
+ hasPixelPrecision: false,
274
+ };
275
+ }
276
+
277
+ const pixelLeft = renderable.x * metrics.cellWidth;
278
+ const pixelTop = renderable.y * metrics.cellHeight;
279
+ const pixelWidth = renderable.width * metrics.cellWidth;
280
+ const pixelHeight = renderable.height * metrics.cellHeight;
281
+ const localPixelX = event.pixelX - pixelLeft;
282
+ const localPixelY = event.pixelY - pixelTop;
283
+
284
+ if (localPixelX < 0 || localPixelY < 0 || localPixelX > pixelWidth || localPixelY > pixelHeight) {
285
+ return {
286
+ cellX: localCellX,
287
+ cellY: localCellY,
288
+ pixelX: null,
289
+ pixelY: null,
290
+ hasPixelPrecision: false,
291
+ };
292
+ }
293
+
294
+ return {
295
+ cellX: renderable.width <= 1
296
+ ? 0
297
+ : clamp(
298
+ (localPixelX / Math.max(pixelWidth - 1, 1)) * Math.max(renderable.width - 1, 0),
299
+ 0,
300
+ Math.max(renderable.width - 1, 0),
301
+ ),
302
+ cellY: renderable.height <= 1
303
+ ? 0
304
+ : clamp(
305
+ (localPixelY / Math.max(pixelHeight - 1, 1)) * Math.max(renderable.height - 1, 0),
306
+ 0,
307
+ Math.max(renderable.height - 1, 0),
308
+ ),
309
+ pixelX: clamp(localPixelX, 0, Math.max(pixelWidth - 1, 0)),
310
+ pixelY: clamp(localPixelY, 0, Math.max(pixelHeight - 1, 0)),
311
+ hasPixelPrecision: true,
312
+ };
313
+ }
314
+
315
+ function getGlobalMouseX(
316
+ event: ChartMouseEvent,
317
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
318
+ ): number {
319
+ const metrics = getRendererCellMetrics(renderer);
320
+ if (event.pixelX === undefined || !metrics) return event.x;
321
+ return event.pixelX / metrics.cellWidth;
322
+ }
323
+
324
+ function resolveCursorMotionKind(
325
+ event: ChartMouseEvent,
326
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
327
+ ): ChartCursorMotionKind {
328
+ return event.pixelX !== undefined && event.pixelY !== undefined && getRendererCellMetrics(renderer)
329
+ ? "pixel"
330
+ : "cell";
331
+ }
332
+
333
+ function extractCellRect(renderable: RenderableNode): CellRect {
334
+ return {
335
+ x: renderable.x,
336
+ y: renderable.y,
337
+ width: renderable.width,
338
+ height: renderable.height,
339
+ };
340
+ }
341
+
342
+ function resolveVisibleRect(
343
+ renderable: RenderableNode | null,
344
+ terminalWidth: number,
345
+ terminalHeight: number,
346
+ ): CellRect | null {
347
+ if (!renderable) return null;
348
+
349
+ let visible: CellRect = {
350
+ x: 0,
351
+ y: 0,
352
+ width: terminalWidth,
353
+ height: terminalHeight,
354
+ };
355
+ let current: RenderableNode | null = renderable;
356
+
357
+ while (current) {
358
+ const currentRect = extractCellRect(current);
359
+ const nextVisible = intersectCellRects(visible, currentRect);
360
+ if (!nextVisible) return null;
361
+ visible = nextVisible;
362
+ current = current.parent;
363
+ }
364
+
365
+ return visible;
366
+ }
367
+
368
+ function buildBlankPlotLines(width: number, height: number): string[] {
369
+ return Array.from({ length: height }, () => " ".repeat(width));
370
+ }
371
+
372
+ function formatAxisCell(label: string | null, width: number): string {
373
+ if (!label) return " ".repeat(width);
374
+ return label.length >= width ? label.slice(0, width) : label.padStart(width);
375
+ }
376
+
377
+ function buildComparisonNativeBitmapKey(
378
+ symbolCount: number,
379
+ projection: ReturnType<typeof projectComparisonChartData>,
380
+ selectedSymbol: string | null,
381
+ pixelWidth: number,
382
+ pixelHeight: number,
383
+ paletteKey: string,
384
+ ): string {
385
+ const fingerprint = projection.series
386
+ .map((series) => [
387
+ series.symbol,
388
+ series.color,
389
+ series.fillColor,
390
+ ...series.points.map((point) => {
391
+ const timestamp = point.date.getTime();
392
+ return `${timestamp}:${point.value ?? "null"}:${point.rawValue ?? "null"}`;
393
+ }),
394
+ ].join("|"))
395
+ .join("::");
396
+ return [
397
+ symbolCount,
398
+ projection.effectiveMode,
399
+ projection.effectiveAxisMode,
400
+ selectedSymbol ?? "",
401
+ pixelWidth,
402
+ pixelHeight,
403
+ paletteKey,
404
+ fingerprint,
405
+ ].join("::");
406
+ }
407
+
408
+ function buildNativeCrosshairBitmapKey(
409
+ pixelWidth: number,
410
+ pixelHeight: number,
411
+ chartHeight: number,
412
+ chartRows: number,
413
+ crosshairColor: string,
414
+ pixelX: number | null,
415
+ pixelY: number | null,
416
+ ): string {
417
+ const cursorKey = pixelX === null || pixelY === null
418
+ ? "cursor:none"
419
+ : `cursor:${pixelX.toFixed(3)}:${pixelY.toFixed(3)}`;
420
+ return [pixelWidth, pixelHeight, chartHeight, chartRows, crosshairColor, cursorKey].join("::");
421
+ }
422
+
423
+ function getLegendColumns(width: number): number {
424
+ if (width >= 110) return 3;
425
+ if (width >= 72) return 2;
426
+ return 1;
427
+ }
428
+
429
+ function clipText(text: string, width: number): string {
430
+ if (width <= 0) return "";
431
+ if (text.length <= width) return text.padEnd(width);
432
+ if (width <= 3) return text.slice(0, width);
433
+ return `${text.slice(0, width - 3)}...`;
434
+ }
435
+
436
+ function getInitialComparisonMode(mode: string | undefined): ComparisonChartRenderMode {
437
+ return mode === "line" ? "line" : "area";
438
+ }
439
+
440
+ function getComparisonPlotColumn(index: number, pointCount: number, width: number): number {
441
+ if (pointCount <= 1 || width <= 1) return 0;
442
+ return Math.round((index / (pointCount - 1)) * Math.max(width - 1, 0));
443
+ }
444
+
445
+ function resolveSelectionCursorX(cellX: number, pointCount: number, width: number): number | null {
446
+ if (pointCount <= 0 || width <= 0) return null;
447
+
448
+ let bestIndex = pointCount - 1;
449
+ let bestDistance = Number.POSITIVE_INFINITY;
450
+ for (let index = 0; index < pointCount; index += 1) {
451
+ const pointColumn = getComparisonPlotColumn(index, pointCount, width);
452
+ const distance = Math.abs(pointColumn - cellX);
453
+ if (distance <= bestDistance) {
454
+ bestDistance = distance;
455
+ bestIndex = index;
456
+ }
457
+ }
458
+
459
+ return getComparisonPlotColumn(bestIndex, pointCount, width);
460
+ }
461
+
462
+ function resolveSelectionCursor(
463
+ pointer: LocalPlotPointer,
464
+ pointCount: number,
465
+ width: number,
466
+ ): { cursorX: number | null; cursorY: number | null } {
467
+ if (!pointer.hasPixelPrecision) {
468
+ return {
469
+ cursorX: pointer.cellX,
470
+ cursorY: pointer.cellY,
471
+ };
472
+ }
473
+
474
+ return {
475
+ cursorX: resolveSelectionCursorX(pointer.cellX, pointCount, width),
476
+ cursorY: null,
477
+ };
478
+ }
479
+
480
+ function ComparisonStockChartView({
481
+ paneId,
482
+ width,
483
+ height,
484
+ focused,
485
+ symbols,
486
+ axisMode,
487
+ defaultRenderMode,
488
+ preferredRenderer,
489
+ symbolSources,
490
+ onOpenSymbol,
491
+ }: ComparisonStockChartViewProps) {
492
+ const renderer = useRenderer();
493
+ const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
494
+ const [viewState, setViewState] = useState<ComparisonChartViewState>({
495
+ timeRange: "1Y",
496
+ panOffset: 0,
497
+ zoomLevel: 1,
498
+ cursorX: null,
499
+ cursorY: null,
500
+ renderMode: getInitialComparisonMode(defaultRenderMode),
501
+ selectedSymbol: symbols[0] ?? null,
502
+ });
503
+ const [remoteHistory, setRemoteHistory] = useState<Record<string, PricePoint[] | null>>({});
504
+ const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
505
+ const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
506
+ const fetchIdRef = useRef(0);
507
+ const plotRef = useRef<BoxRenderable | null>(null);
508
+ const nativeBaseSurfaceIdRef = useRef(`comparison-chart-surface:${paneId}:base`);
509
+ const nativeCrosshairSurfaceIdRef = useRef(`comparison-chart-surface:${paneId}:crosshair`);
510
+ const dragRef = useRef<DragState | null>(null);
511
+ const lastNativeGeometryRef = useRef<{ rect: CellRect; visibleRect: CellRect | null } | null>(null);
512
+ const lastNativeBaseBitmapRef = useRef<{ key: string; bitmap: NativeChartBitmap } | null>(null);
513
+ const lastNativeCrosshairBitmapRef = useRef<{ key: string; bitmap: NativeChartBitmap } | null>(null);
514
+ const displayCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
515
+ const targetCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
516
+ const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
517
+ const animationFrameRef = useRef<number | null>(null);
518
+
519
+ const axisWidth = 11;
520
+ const axisGap = 1;
521
+ const headerRows = 1;
522
+ const controlRows = 1;
523
+ const timeAxisRows = 1;
524
+ const helpRows = 1;
525
+ const legendColumns = getLegendColumns(width);
526
+ const legendNeededRows = symbols.length > 0 ? Math.ceil(symbols.length / legendColumns) : 0;
527
+ const legendRows = legendNeededRows > 0
528
+ ? Math.min(4, Math.max(Math.min(height - (headerRows + controlRows + timeAxisRows + helpRows + 4), legendNeededRows), 1))
529
+ : 0;
530
+ const chartWidth = Math.max(width - axisWidth - axisGap, 20);
531
+ const chartHeight = Math.max(height - headerRows - controlRows - timeAxisRows - helpRows - legendRows, 4);
532
+ const maxCursorX = chartWidth - 1;
533
+ const legendItemWidth = Math.max(Math.floor((width - Math.max(legendColumns - 1, 0)) / legendColumns), 20);
534
+ const chartColors = useMemo(() => ({
535
+ bgColor: colors.bg,
536
+ gridColor: blendHex(colors.bg, colors.border, 0.55),
537
+ crosshairColor: colors.borderFocused,
538
+ }), [colors.bg, colors.border, colors.borderFocused]);
539
+
540
+ const setSelectedSymbol = (symbol: string) => {
541
+ setViewState((current) => (
542
+ current.selectedSymbol === symbol
543
+ ? current
544
+ : { ...current, selectedSymbol: symbol }
545
+ ));
546
+ };
547
+
548
+ const symbolMetaKey = useMemo(() => symbolSources.map((source) => {
549
+ return [
550
+ source.symbol,
551
+ source.exchange,
552
+ source.brokerId ?? "",
553
+ source.brokerInstanceId ?? "",
554
+ ].join(":");
555
+ }).join("|"), [symbolSources]);
556
+
557
+ useEffect(() => {
558
+ if (symbols.includes(viewState.selectedSymbol ?? "")) return;
559
+ setViewState((current) => ({
560
+ ...current,
561
+ selectedSymbol: symbols[0] ?? null,
562
+ }));
563
+ }, [symbols, viewState.selectedSymbol]);
564
+
565
+ const commitDisplayCursor = (next: DisplayCursorState) => {
566
+ displayCursorRef.current = next;
567
+ setDisplayCursor((current) => (sameDisplayCursorState(current, next) ? current : next));
568
+ };
569
+
570
+ const stopDisplayCursorAnimation = () => {
571
+ if (animationFrameRef.current !== null) {
572
+ cancelAnimationFrameSafe(animationFrameRef.current);
573
+ animationFrameRef.current = null;
574
+ }
575
+ };
576
+
577
+ const startDisplayCursorAnimation = () => {
578
+ if (animationFrameRef.current !== null) return;
579
+
580
+ const tick = () => {
581
+ animationFrameRef.current = null;
582
+ const target = targetCursorRef.current;
583
+
584
+ if (cursorMotionKindRef.current !== "cell") {
585
+ commitDisplayCursor(target);
586
+ return;
587
+ }
588
+
589
+ const currentCell = toCursorPosition(displayCursorRef.current.cellX, displayCursorRef.current.cellY);
590
+ const targetCell = toCursorPosition(target.cellX, target.cellY);
591
+ const currentPixel = toCursorPosition(displayCursorRef.current.pixelX, displayCursorRef.current.pixelY);
592
+ const targetPixel = toCursorPosition(target.pixelX, target.pixelY);
593
+ const cellStep = stepCursorTowards(currentCell, targetCell);
594
+ const pixelStep = stepCursorTowards(currentPixel, targetPixel, undefined, PIXEL_CURSOR_SNAP_DISTANCE);
595
+ const next: DisplayCursorState = {
596
+ cellX: cellStep.next.x,
597
+ cellY: cellStep.next.y,
598
+ pixelX: pixelStep.next.x,
599
+ pixelY: pixelStep.next.y,
600
+ };
601
+ const settled = cellStep.settled && pixelStep.settled;
602
+ commitDisplayCursor(next);
603
+ if (!settled) {
604
+ animationFrameRef.current = requestAnimationFrameSafe(tick);
605
+ }
606
+ };
607
+
608
+ animationFrameRef.current = requestAnimationFrameSafe(tick);
609
+ };
610
+
611
+ const updateDisplayCursorTarget = (next: DisplayCursorState, motionKind: ChartCursorMotionKind) => {
612
+ cursorMotionKindRef.current = motionKind;
613
+ targetCursorRef.current = next;
614
+
615
+ if (motionKind !== "cell" || next.cellX === null || next.cellY === null) {
616
+ stopDisplayCursorAnimation();
617
+ commitDisplayCursor(next);
618
+ return;
619
+ }
620
+
621
+ if (displayCursorRef.current.cellX === null || displayCursorRef.current.cellY === null) {
622
+ commitDisplayCursor(next);
623
+ return;
624
+ }
625
+
626
+ if (sameDisplayCursorState(displayCursorRef.current, next, CELL_CURSOR_SNAP_DISTANCE, PIXEL_CURSOR_SNAP_DISTANCE)) {
627
+ stopDisplayCursorAnimation();
628
+ commitDisplayCursor(next);
629
+ return;
630
+ }
631
+
632
+ startDisplayCursorAnimation();
633
+ };
634
+
635
+ useEffect(() => (
636
+ () => {
637
+ stopDisplayCursorAnimation();
638
+ lastNativeBaseBitmapRef.current = null;
639
+ lastNativeCrosshairBitmapRef.current = null;
640
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
641
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
642
+ }
643
+ ), [nativeSurfaceManager]);
644
+
645
+ useEffect(() => {
646
+ const provider = getSharedDataProvider();
647
+ if (!provider || symbols.length === 0) {
648
+ setRemoteHistory({});
649
+ return;
650
+ }
651
+
652
+ const id = ++fetchIdRef.current;
653
+ setRemoteHistory({});
654
+
655
+ Promise.all(symbolSources.map(async (source) => {
656
+ try {
657
+ const points = await provider.getPriceHistory(source.symbol, source.exchange, viewState.timeRange, {
658
+ brokerId: source.brokerId,
659
+ brokerInstanceId: source.brokerInstanceId,
660
+ instrument: source.instrument,
661
+ });
662
+ return [source.symbol, points] as const;
663
+ } catch {
664
+ return [source.symbol, null] as const;
665
+ }
666
+ })).then((entries) => {
667
+ if (fetchIdRef.current !== id) return;
668
+ setRemoteHistory(Object.fromEntries(entries));
669
+ }).catch(() => {
670
+ if (fetchIdRef.current !== id) return;
671
+ setRemoteHistory({});
672
+ });
673
+ }, [symbolMetaKey, symbolSources, symbols.length, viewState.timeRange]);
674
+
675
+ useEffect(() => {
676
+ const refreshSupport = () => setKittySupport(getCachedKittySupport(renderer));
677
+ refreshSupport();
678
+ renderer.on("capabilities", refreshSupport);
679
+ return () => {
680
+ renderer.off("capabilities", refreshSupport);
681
+ };
682
+ }, [renderer]);
683
+
684
+ useEffect(() => {
685
+ let cancelled = false;
686
+ const known = getCachedKittySupport(renderer);
687
+ setKittySupport(known);
688
+ if (preferredRenderer === "braille" || known !== null) return;
689
+ ensureKittySupport(renderer).then((supported) => {
690
+ if (!cancelled) setKittySupport(supported);
691
+ }).catch(() => {
692
+ if (!cancelled) setKittySupport(false);
693
+ });
694
+ return () => {
695
+ cancelled = true;
696
+ };
697
+ }, [preferredRenderer, renderer]);
698
+
699
+ const series = useMemo(() => symbolSources.map((source, index) => {
700
+ const history = remoteHistory[source.symbol] && remoteHistory[source.symbol]!.length > 0
701
+ ? remoteHistory[source.symbol]!
702
+ : source.priceHistory;
703
+ const color = getComparisonSeriesColor(index);
704
+ return {
705
+ symbol: source.symbol,
706
+ color,
707
+ fillColor: blendHex(colors.bg, color, 0.22),
708
+ currency: source.currency,
709
+ points: history,
710
+ };
711
+ }), [colors.bg, remoteHistory, symbolSources]);
712
+
713
+ const visibleWindow = useMemo(() => (
714
+ getVisibleComparisonWindow(series, viewState, chartWidth)
715
+ ), [chartWidth, series, viewState]);
716
+
717
+ const projection = useMemo(() => (
718
+ projectComparisonChartData(series, chartWidth, viewState, axisMode)
719
+ ), [axisMode, chartWidth, series, viewState]);
720
+
721
+ const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
722
+ const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
723
+ const displayCursorX = displayCursor.cellX !== null ? clamp(displayCursor.cellX, 0, chartWidth - 1) : null;
724
+ const displayCursorY = displayCursor.cellY !== null ? clamp(displayCursor.cellY, 0, chartHeight - 1) : null;
725
+
726
+ const commitSelectionCursor = (next: { cursorX: number | null; cursorY: number | null }) => {
727
+ setViewState((current) => (
728
+ current.cursorX === next.cursorX && current.cursorY === next.cursorY
729
+ ? current
730
+ : { ...current, cursorX: next.cursorX, cursorY: next.cursorY }
731
+ ));
732
+ };
733
+
734
+ useEffect(() => {
735
+ const remapCursor = (cursor: DisplayCursorState) => buildDisplayCursorState(
736
+ cursor.cellX,
737
+ cursor.cellY,
738
+ plotRef.current,
739
+ renderer,
740
+ );
741
+ const nextDisplay = remapCursor(displayCursorRef.current);
742
+ const nextTarget = remapCursor(targetCursorRef.current);
743
+ displayCursorRef.current = nextDisplay;
744
+ targetCursorRef.current = nextTarget;
745
+ setDisplayCursor((current) => (sameDisplayCursorState(current, nextDisplay) ? current : nextDisplay));
746
+ }, [chartHeight, chartWidth, renderer.resolution?.height, renderer.resolution?.width]);
747
+
748
+ useEffect(() => {
749
+ if (cursorMotionKindRef.current === "pixel") return;
750
+ updateDisplayCursorTarget(
751
+ buildDisplayCursorState(cursorX, cursorY, plotRef.current, renderer),
752
+ cursorMotionKindRef.current,
753
+ );
754
+ }, [cursorX, cursorY, renderer]);
755
+
756
+ const rendererState = resolveChartRendererState(preferredRenderer, kittySupport, renderer.resolution);
757
+ const effectiveRenderer: ResolvedChartRenderer = rendererState.renderer;
758
+
759
+ const staticScene = useMemo(() => buildComparisonChartScene(projection, {
760
+ width: chartWidth,
761
+ height: chartHeight,
762
+ cursorX: null,
763
+ cursorY: null,
764
+ selectedSymbol: viewState.selectedSymbol,
765
+ colors: chartColors,
766
+ }), [chartColors, chartHeight, chartWidth, projection, viewState.selectedSymbol]);
767
+
768
+ const staticResult = useMemo(() => renderComparisonChart(projection, {
769
+ width: chartWidth,
770
+ height: chartHeight,
771
+ cursorX: null,
772
+ cursorY: null,
773
+ selectedSymbol: viewState.selectedSymbol,
774
+ colors: chartColors,
775
+ }), [chartColors, chartHeight, chartWidth, projection, viewState.selectedSymbol]);
776
+
777
+ const interactiveResult = useMemo(() => (
778
+ effectiveRenderer === "kitty"
779
+ ? null
780
+ : renderComparisonChart(projection, {
781
+ width: chartWidth,
782
+ height: chartHeight,
783
+ cursorX: displayCursorX,
784
+ cursorY: displayCursorY,
785
+ selectedSymbol: viewState.selectedSymbol,
786
+ colors: chartColors,
787
+ })
788
+ ), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, effectiveRenderer, projection, viewState.selectedSymbol]);
789
+
790
+ const result = effectiveRenderer === "kitty" ? staticResult : interactiveResult!;
791
+ const liveScene = useMemo(() => (
792
+ effectiveRenderer === "kitty"
793
+ ? staticScene
794
+ : buildComparisonChartScene(projection, {
795
+ width: chartWidth,
796
+ height: chartHeight,
797
+ cursorX: displayCursorX,
798
+ cursorY: displayCursorY,
799
+ selectedSymbol: viewState.selectedSymbol,
800
+ colors: chartColors,
801
+ })
802
+ ), [chartColors, chartHeight, chartWidth, displayCursorX, displayCursorY, effectiveRenderer, projection, staticScene, viewState.selectedSymbol]);
803
+
804
+ const nativeCrosshair = useMemo<NativeCrosshairOverlay | null>(() => {
805
+ if (displayCursor.cellX === null || displayCursor.cellY === null) return null;
806
+ return {
807
+ width: chartWidth,
808
+ height: chartHeight,
809
+ chartRows: chartHeight,
810
+ pixelX: displayCursor.pixelX,
811
+ pixelY: displayCursor.pixelY,
812
+ colors: {
813
+ crosshairColor: chartColors.crosshairColor,
814
+ },
815
+ };
816
+ }, [chartColors.crosshairColor, chartHeight, chartWidth, displayCursor]);
817
+ const blankPlotLines = useMemo(() => buildBlankPlotLines(chartWidth, chartHeight), [chartHeight, chartWidth]);
818
+
819
+ useEffect(() => {
820
+ if (effectiveRenderer === "kitty") return;
821
+ lastNativeBaseBitmapRef.current = null;
822
+ lastNativeCrosshairBitmapRef.current = null;
823
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
824
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
825
+ lastNativeGeometryRef.current = null;
826
+ }, [effectiveRenderer, nativeSurfaceManager]);
827
+
828
+ useEffect(() => {
829
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
830
+ const plot = plotRef.current;
831
+ let mountTimer: ReturnType<typeof setTimeout> | null = null;
832
+
833
+ const syncPlacement = () => {
834
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
835
+ const rect = extractCellRect(plotRef.current);
836
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
837
+ const previous = lastNativeGeometryRef.current;
838
+ if (previous
839
+ && previous.rect.x === rect.x
840
+ && previous.rect.y === rect.y
841
+ && previous.rect.width === rect.width
842
+ && previous.rect.height === rect.height
843
+ && previous.visibleRect?.x === visibleRect?.x
844
+ && previous.visibleRect?.y === visibleRect?.y
845
+ && previous.visibleRect?.width === visibleRect?.width
846
+ && previous.visibleRect?.height === visibleRect?.height) {
847
+ return;
848
+ }
849
+ lastNativeGeometryRef.current = { rect, visibleRect };
850
+ const geometry = {
851
+ paneId,
852
+ rect,
853
+ visibleRect,
854
+ };
855
+ syncCachedNativeSurface(
856
+ nativeSurfaceManager,
857
+ nativeBaseSurfaceIdRef.current,
858
+ geometry,
859
+ lastNativeBaseBitmapRef.current,
860
+ );
861
+ syncCachedNativeSurface(
862
+ nativeSurfaceManager,
863
+ nativeCrosshairSurfaceIdRef.current,
864
+ geometry,
865
+ lastNativeCrosshairBitmapRef.current,
866
+ );
867
+ };
868
+
869
+ plot.onLifecyclePass = syncPlacement;
870
+ renderer.registerLifecyclePass(plot);
871
+ syncPlacement();
872
+ mountTimer = setTimeout(() => {
873
+ syncPlacement();
874
+ renderer.requestRender();
875
+ }, 0);
876
+ return () => {
877
+ if (mountTimer) clearTimeout(mountTimer);
878
+ plot.onLifecyclePass = null;
879
+ renderer.unregisterLifecyclePass(plot);
880
+ lastNativeGeometryRef.current = null;
881
+ };
882
+ }, [effectiveRenderer, nativeSurfaceManager, paneId, renderer, rendererState.nativeReady]);
883
+
884
+ useEffect(() => {
885
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !staticScene) {
886
+ lastNativeBaseBitmapRef.current = null;
887
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
888
+ return;
889
+ }
890
+
891
+ const plotRect = extractCellRect(plotRef.current);
892
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
893
+ const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
894
+ const bitmapKey = buildComparisonNativeBitmapKey(
895
+ symbols.length,
896
+ projection,
897
+ viewState.selectedSymbol,
898
+ bitmapSize.pixelWidth,
899
+ bitmapSize.pixelHeight,
900
+ [chartColors.bgColor, chartColors.gridColor, chartColors.crosshairColor].join(","),
901
+ );
902
+ const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
903
+ ? lastNativeBaseBitmapRef.current.bitmap
904
+ : null;
905
+ const bitmap = cachedBitmap ?? renderNativeComparisonChartBase(staticScene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
906
+ if (!cachedBitmap) {
907
+ lastNativeBaseBitmapRef.current = { key: bitmapKey, bitmap };
908
+ }
909
+
910
+ if (!visibleRect) {
911
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
912
+ return;
913
+ }
914
+
915
+ nativeSurfaceManager.upsertSurface({
916
+ id: nativeBaseSurfaceIdRef.current,
917
+ paneId,
918
+ rect: plotRect,
919
+ visibleRect,
920
+ bitmap,
921
+ bitmapKey,
922
+ });
923
+ if (!cachedBitmap) {
924
+ renderer.requestRender();
925
+ }
926
+ }, [
927
+ chartColors.bgColor,
928
+ chartColors.crosshairColor,
929
+ chartColors.gridColor,
930
+ effectiveRenderer,
931
+ nativeSurfaceManager,
932
+ paneId,
933
+ projection,
934
+ renderer,
935
+ rendererState.nativeReady,
936
+ staticScene,
937
+ symbols.length,
938
+ viewState.selectedSymbol,
939
+ ]);
940
+
941
+ useEffect(() => {
942
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !nativeCrosshair) {
943
+ lastNativeCrosshairBitmapRef.current = null;
944
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
945
+ return;
946
+ }
947
+
948
+ const plotRect = extractCellRect(plotRef.current);
949
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
950
+ const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
951
+ const renderablePixelSize = getRenderablePixelSize(plotRef.current, renderer);
952
+ const overlayPixelX = scaleLocalPixelCoordinate(
953
+ nativeCrosshair.pixelX,
954
+ renderablePixelSize?.pixelWidth ?? bitmapSize.pixelWidth,
955
+ bitmapSize.pixelWidth,
956
+ );
957
+ const overlayPixelY = scaleLocalPixelCoordinate(
958
+ nativeCrosshair.pixelY,
959
+ renderablePixelSize?.pixelHeight ?? bitmapSize.pixelHeight,
960
+ bitmapSize.pixelHeight,
961
+ );
962
+
963
+ if (overlayPixelX === null || overlayPixelY === null) {
964
+ lastNativeCrosshairBitmapRef.current = null;
965
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
966
+ return;
967
+ }
968
+
969
+ const overlay = {
970
+ ...nativeCrosshair,
971
+ pixelX: overlayPixelX,
972
+ pixelY: overlayPixelY,
973
+ };
974
+ const bitmapKey = buildNativeCrosshairBitmapKey(
975
+ bitmapSize.pixelWidth,
976
+ bitmapSize.pixelHeight,
977
+ overlay.height,
978
+ overlay.chartRows,
979
+ overlay.colors.crosshairColor,
980
+ overlay.pixelX,
981
+ overlay.pixelY,
982
+ );
983
+ const cachedBitmap = lastNativeCrosshairBitmapRef.current?.key === bitmapKey
984
+ ? lastNativeCrosshairBitmapRef.current.bitmap
985
+ : null;
986
+ const bitmap = cachedBitmap ?? renderNativeCrosshairOverlay(overlay, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
987
+ if (!cachedBitmap) {
988
+ lastNativeCrosshairBitmapRef.current = { key: bitmapKey, bitmap };
989
+ }
990
+
991
+ if (!visibleRect) {
992
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
993
+ return;
994
+ }
995
+
996
+ nativeSurfaceManager.upsertSurface({
997
+ id: nativeCrosshairSurfaceIdRef.current,
998
+ paneId,
999
+ rect: plotRect,
1000
+ visibleRect,
1001
+ bitmap,
1002
+ bitmapKey,
1003
+ });
1004
+ }, [
1005
+ effectiveRenderer,
1006
+ nativeCrosshair,
1007
+ nativeSurfaceManager,
1008
+ paneId,
1009
+ renderer,
1010
+ rendererState.nativeReady,
1011
+ ]);
1012
+
1013
+ useKeyboard((event) => {
1014
+ if (!focused || symbols.length === 0) return;
1015
+
1016
+ switch (event.name) {
1017
+ case "=":
1018
+ setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel * 1.5, 0.5, series, chartWidth));
1019
+ return;
1020
+ case "-":
1021
+ setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel / 1.5, 0.5, series, chartWidth));
1022
+ return;
1023
+ case "0":
1024
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1025
+ setViewState((current) => ({
1026
+ ...current,
1027
+ panOffset: 0,
1028
+ zoomLevel: 1,
1029
+ cursorX: null,
1030
+ cursorY: null,
1031
+ }));
1032
+ return;
1033
+ case "a":
1034
+ setViewState((current) => ({
1035
+ ...current,
1036
+ panOffset: clamp(current.panOffset + Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth)),
1037
+ }));
1038
+ return;
1039
+ case "d":
1040
+ setViewState((current) => ({
1041
+ ...current,
1042
+ panOffset: clamp(current.panOffset - Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth)),
1043
+ }));
1044
+ return;
1045
+ case "m":
1046
+ setViewState((current) => ({
1047
+ ...current,
1048
+ renderMode: current.renderMode === "line" ? "area" : "line",
1049
+ }));
1050
+ return;
1051
+ case "h":
1052
+ cursorMotionKindRef.current = "discrete";
1053
+ setViewState((current) => ({
1054
+ ...current,
1055
+ cursorX: current.cursorX === null ? maxCursorX : Math.max(current.cursorX - 1, 0),
1056
+ }));
1057
+ return;
1058
+ case "l":
1059
+ cursorMotionKindRef.current = "discrete";
1060
+ setViewState((current) => ({
1061
+ ...current,
1062
+ cursorX: current.cursorX === null ? 0 : Math.min(current.cursorX + 1, maxCursorX),
1063
+ }));
1064
+ return;
1065
+ case "escape":
1066
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1067
+ setViewState((current) => ({ ...current, cursorX: null, cursorY: null }));
1068
+ return;
1069
+ case "enter":
1070
+ case "return":
1071
+ if (viewState.selectedSymbol) onOpenSymbol(viewState.selectedSymbol);
1072
+ return;
1073
+ case "left":
1074
+ setViewState((current) => {
1075
+ const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1076
+ return {
1077
+ ...current,
1078
+ selectedSymbol: symbols[Math.max(currentIndex - 1, 0)] ?? current.selectedSymbol,
1079
+ };
1080
+ });
1081
+ return;
1082
+ case "right":
1083
+ setViewState((current) => {
1084
+ const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1085
+ return {
1086
+ ...current,
1087
+ selectedSymbol: symbols[Math.min(currentIndex + 1, symbols.length - 1)] ?? current.selectedSymbol,
1088
+ };
1089
+ });
1090
+ return;
1091
+ case "up":
1092
+ case "k":
1093
+ setViewState((current) => {
1094
+ const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1095
+ return {
1096
+ ...current,
1097
+ selectedSymbol: symbols[Math.max(currentIndex - legendColumns, 0)] ?? current.selectedSymbol,
1098
+ };
1099
+ });
1100
+ return;
1101
+ case "down":
1102
+ case "j":
1103
+ setViewState((current) => {
1104
+ const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1105
+ return {
1106
+ ...current,
1107
+ selectedSymbol: symbols[Math.min(currentIndex + legendColumns, symbols.length - 1)] ?? current.selectedSymbol,
1108
+ };
1109
+ });
1110
+ return;
1111
+ }
1112
+
1113
+ if (event.name >= "1" && event.name <= "7") {
1114
+ const index = parseInt(event.name, 10) - 1;
1115
+ if (index < TIME_RANGES.length) {
1116
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1117
+ setViewState((current) => ({
1118
+ ...current,
1119
+ timeRange: TIME_RANGES[index]!,
1120
+ panOffset: 0,
1121
+ zoomLevel: 1,
1122
+ cursorX: null,
1123
+ cursorY: null,
1124
+ }));
1125
+ }
1126
+ }
1127
+ });
1128
+
1129
+ const handlePlotMove = (event: ChartMouseEvent) => {
1130
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1131
+ if (!localPointer) return;
1132
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.dates.length, chartWidth);
1133
+ updateDisplayCursorTarget(
1134
+ buildDisplayCursorState(
1135
+ localPointer.cellX,
1136
+ localPointer.cellY,
1137
+ plotRef.current,
1138
+ renderer,
1139
+ localPointer.pixelX,
1140
+ localPointer.pixelY,
1141
+ ),
1142
+ resolveCursorMotionKind(event, renderer),
1143
+ );
1144
+ commitSelectionCursor(selectionCursor);
1145
+ };
1146
+
1147
+ const handlePlotDown = (event: ChartMouseEvent) => {
1148
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1149
+ if (!localPointer) return;
1150
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.dates.length, chartWidth);
1151
+ updateDisplayCursorTarget(
1152
+ buildDisplayCursorState(
1153
+ localPointer.cellX,
1154
+ localPointer.cellY,
1155
+ plotRef.current,
1156
+ renderer,
1157
+ localPointer.pixelX,
1158
+ localPointer.pixelY,
1159
+ ),
1160
+ resolveCursorMotionKind(event, renderer),
1161
+ );
1162
+ dragRef.current = {
1163
+ startGlobalX: getGlobalMouseX(event, renderer),
1164
+ startPanOffset: viewState.panOffset,
1165
+ };
1166
+ commitSelectionCursor(selectionCursor);
1167
+ };
1168
+
1169
+ const handlePlotDrag = (event: ChartMouseEvent) => {
1170
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1171
+ if (localPointer) {
1172
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.dates.length, chartWidth);
1173
+ updateDisplayCursorTarget(
1174
+ buildDisplayCursorState(
1175
+ localPointer.cellX,
1176
+ localPointer.cellY,
1177
+ plotRef.current,
1178
+ renderer,
1179
+ localPointer.pixelX,
1180
+ localPointer.pixelY,
1181
+ ),
1182
+ resolveCursorMotionKind(event, renderer),
1183
+ );
1184
+ commitSelectionCursor(selectionCursor);
1185
+ }
1186
+ if (!dragRef.current) return;
1187
+
1188
+ const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1189
+ const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * Math.max(visibleWindow.dates.length, 1));
1190
+ const nextPan = clamp(
1191
+ dragRef.current.startPanOffset - pointDelta,
1192
+ 0,
1193
+ Math.max(visibleWindow.totalDates - visibleWindow.dates.length, 0),
1194
+ );
1195
+ setViewState((current) => ({ ...current, panOffset: nextPan }));
1196
+ };
1197
+
1198
+ const handlePlotScroll = (event: ChartMouseEvent) => {
1199
+ const direction = event.scroll?.direction;
1200
+ if (!direction) return;
1201
+
1202
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1203
+ const anchorRatio = localPointer ? localPointer.cellX / Math.max(chartWidth - 1, 1) : 0.5;
1204
+
1205
+ if (localPointer) {
1206
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.dates.length, chartWidth);
1207
+ updateDisplayCursorTarget(
1208
+ buildDisplayCursorState(
1209
+ localPointer.cellX,
1210
+ localPointer.cellY,
1211
+ plotRef.current,
1212
+ renderer,
1213
+ localPointer.pixelX,
1214
+ localPointer.pixelY,
1215
+ ),
1216
+ resolveCursorMotionKind(event, renderer),
1217
+ );
1218
+ commitSelectionCursor(selectionCursor);
1219
+ }
1220
+
1221
+ if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1222
+ const shiftDirection = direction === "up" ? 1 : -1;
1223
+ const nextPan = clamp(
1224
+ viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1),
1225
+ 0,
1226
+ getMaxComparisonPanOffset(series, viewState.timeRange, viewState.zoomLevel, chartWidth),
1227
+ );
1228
+ setViewState((current) => ({ ...current, panOffset: nextPan }));
1229
+ return;
1230
+ }
1231
+
1232
+ switch (direction) {
1233
+ case "up":
1234
+ setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, series, chartWidth));
1235
+ return;
1236
+ case "down":
1237
+ setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, series, chartWidth));
1238
+ return;
1239
+ case "left":
1240
+ setViewState((current) => ({
1241
+ ...current,
1242
+ panOffset: clamp(
1243
+ current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1244
+ 0,
1245
+ getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth),
1246
+ ),
1247
+ }));
1248
+ return;
1249
+ case "right":
1250
+ setViewState((current) => ({
1251
+ ...current,
1252
+ panOffset: clamp(
1253
+ current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1254
+ 0,
1255
+ getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth),
1256
+ ),
1257
+ }));
1258
+ return;
1259
+ }
1260
+ };
1261
+
1262
+ if (symbols.length === 0) {
1263
+ return <text fg={colors.textDim}>No comparison tickers configured.</text>;
1264
+ }
1265
+
1266
+ if (series.every((entry) => entry.points.length === 0)) {
1267
+ return <text fg={colors.textDim}>No chart data yet.</text>;
1268
+ }
1269
+
1270
+ const selectedSeries = result.selectedSeries ?? staticResult.selectedSeries;
1271
+ const selectedPoint = result.selectedPoint ?? staticResult.selectedPoint;
1272
+ const selectedRawValue = selectedPoint?.rawValue ?? selectedSeries?.latestRawValue ?? null;
1273
+ const selectedBaseValue = selectedSeries?.baseValue ?? null;
1274
+ const selectedChange = selectedRawValue !== null && selectedBaseValue !== null
1275
+ ? selectedRawValue - selectedBaseValue
1276
+ : null;
1277
+ const selectedChangePct = selectedChange !== null && selectedBaseValue
1278
+ ? (selectedChange / selectedBaseValue) * 100
1279
+ : null;
1280
+ const selectedCurrency = selectedSeries?.currency ?? "USD";
1281
+ const displayDate = result.activeDate ?? staticResult.activeDate;
1282
+ const axisLabels = new Map(staticResult.axisLabels.map((entry) => [entry.row, entry.label]));
1283
+ const cursorAxisLabel = result.cursorRow !== null && result.crosshairValue !== null
1284
+ ? formatComparisonAxisValue(result.crosshairValue, projection.effectiveAxisMode)
1285
+ : null;
1286
+
1287
+ const plotLines: Array<string | StyledContent> = effectiveRenderer === "kitty"
1288
+ ? blankPlotLines
1289
+ : result.lines;
1290
+ const plotContent = plotLines.map((line, index) => (
1291
+ <text key={index} content={line} />
1292
+ ));
1293
+
1294
+ const plotBox = (
1295
+ <box
1296
+ ref={plotRef}
1297
+ width={chartWidth}
1298
+ height={chartHeight}
1299
+ flexDirection="column"
1300
+ backgroundColor={chartColors.bgColor}
1301
+ onMouseMove={handlePlotMove}
1302
+ onMouseDown={handlePlotDown}
1303
+ onMouseUp={() => { dragRef.current = null; }}
1304
+ onMouseDrag={handlePlotDrag}
1305
+ onMouseDragEnd={() => { dragRef.current = null; }}
1306
+ onMouseOut={() => {
1307
+ dragRef.current = null;
1308
+ }}
1309
+ onMouseScroll={handlePlotScroll}
1310
+ >
1311
+ {plotContent}
1312
+ </box>
1313
+ );
1314
+
1315
+ const axisBox = (
1316
+ <box width={axisWidth} height={chartHeight} flexDirection="column">
1317
+ {Array.from({ length: chartHeight }, (_, row) => {
1318
+ const isCursorRow = cursorAxisLabel !== null && result.cursorRow === row;
1319
+ const label = isCursorRow ? cursorAxisLabel : (axisLabels.get(row) ?? null);
1320
+ return (
1321
+ <text key={row} fg={isCursorRow ? chartColors.crosshairColor : colors.textDim}>
1322
+ {formatAxisCell(label, axisWidth)}
1323
+ </text>
1324
+ );
1325
+ })}
1326
+ </box>
1327
+ );
1328
+
1329
+ const legendRowsData = Array.from({ length: Math.ceil(symbols.length / legendColumns) }, (_, rowIndex) => (
1330
+ symbols.slice(rowIndex * legendColumns, rowIndex * legendColumns + legendColumns)
1331
+ ));
1332
+
1333
+ return (
1334
+ <box flexDirection="column" flexGrow={1}>
1335
+ <box flexDirection="row" gap={2} height={1}>
1336
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1337
+ {viewState.selectedSymbol ?? "Compare"} - {viewState.timeRange}
1338
+ </text>
1339
+ <text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1340
+ {selectedRawValue !== null ? formatCurrency(selectedRawValue, selectedCurrency) : "—"}
1341
+ </text>
1342
+ <text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1343
+ {selectedChange !== null && selectedChangePct !== null
1344
+ ? `${selectedChange >= 0 ? "+" : ""}${selectedChange.toFixed(2)} (${formatPercentRaw(selectedChangePct)})`
1345
+ : "Waiting for data"}
1346
+ </text>
1347
+ {displayDate && <text fg={colors.textDim}>{formatDateShort(displayDate)}</text>}
1348
+ </box>
1349
+
1350
+ <box flexDirection="row" height={1}>
1351
+ <box flexDirection="row" gap={1}>
1352
+ {TIME_RANGES.map((range, index) => (
1353
+ <text
1354
+ key={range}
1355
+ fg={viewState.timeRange === range ? colors.textBright : colors.textDim}
1356
+ attributes={viewState.timeRange === range ? TextAttributes.BOLD : 0}
1357
+ onMouseDown={() => {
1358
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1359
+ setViewState((current) => ({
1360
+ ...current,
1361
+ timeRange: range,
1362
+ panOffset: 0,
1363
+ zoomLevel: 1,
1364
+ cursorX: null,
1365
+ cursorY: null,
1366
+ }));
1367
+ }}
1368
+ >
1369
+ {`${index + 1}:${range}`}
1370
+ </text>
1371
+ ))}
1372
+ {viewState.zoomLevel !== 1 && (
1373
+ <text fg={colors.textDim}> zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1374
+ )}
1375
+ </box>
1376
+ <box flexGrow={1} />
1377
+ <box flexDirection="row" gap={1}>
1378
+ {COMPARISON_RENDER_MODES.map((mode) => (
1379
+ <text
1380
+ key={mode}
1381
+ fg={viewState.renderMode === mode ? colors.textBright : colors.textDim}
1382
+ attributes={viewState.renderMode === mode ? TextAttributes.BOLD : 0}
1383
+ onMouseDown={() => setViewState((current) => ({ ...current, renderMode: mode }))}
1384
+ >
1385
+ {MODE_CHIPS[mode]}
1386
+ </text>
1387
+ ))}
1388
+ {projection.warning && (
1389
+ <text fg={colors.textDim}>{projection.warning}</text>
1390
+ )}
1391
+ {rendererState.nativeUnavailable && (
1392
+ <text fg={colors.textDim}>native unavailable</text>
1393
+ )}
1394
+ </box>
1395
+ </box>
1396
+
1397
+ <box flexDirection="row" height={chartHeight} gap={axisGap}>
1398
+ {plotBox}
1399
+ {axisBox}
1400
+ </box>
1401
+
1402
+ <box height={1}>
1403
+ <text fg={colors.textDim}>{result.timeLabels || staticResult.timeLabels}</text>
1404
+ </box>
1405
+
1406
+ {legendRows > 0 && (
1407
+ <scrollbox height={legendRows} scrollY>
1408
+ <box flexDirection="column">
1409
+ {legendRowsData.map((legendRow, rowIndex) => (
1410
+ <box key={`legend-row:${rowIndex}`} flexDirection="row" gap={1}>
1411
+ {legendRow.map((symbol) => {
1412
+ const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
1413
+ const isSelected = viewState.selectedSymbol === symbol;
1414
+ const latestRaw = item?.latestRawValue ?? null;
1415
+ const latestChange = latestRaw !== null && item?.baseValue != null ? latestRaw - item.baseValue : null;
1416
+ const latestChangePct = latestChange !== null && item?.baseValue
1417
+ ? (latestChange / item.baseValue) * 100
1418
+ : null;
1419
+ const currency = item?.currency ?? "USD";
1420
+ const summary = latestRaw !== null && latestChangePct !== null
1421
+ ? `${symbol} ${formatCurrency(latestRaw, currency)} ${formatPercentRaw(latestChangePct)}`
1422
+ : `${symbol} waiting`;
1423
+
1424
+ return (
1425
+ <box
1426
+ key={symbol}
1427
+ width={legendItemWidth}
1428
+ backgroundColor={isSelected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
1429
+ onMouseMove={() => setSelectedSymbol(symbol)}
1430
+ onMouseDown={() => {
1431
+ setSelectedSymbol(symbol);
1432
+ onOpenSymbol(symbol);
1433
+ }}
1434
+ >
1435
+ <text fg={item?.color ?? colors.textDim} attributes={isSelected ? TextAttributes.BOLD : 0}>
1436
+ {clipText(`${isSelected ? ">" : " "} ${summary}`, legendItemWidth)}
1437
+ </text>
1438
+ </box>
1439
+ );
1440
+ })}
1441
+ </box>
1442
+ ))}
1443
+ </box>
1444
+ </scrollbox>
1445
+ )}
1446
+
1447
+ <box height={1}>
1448
+ <text fg={colors.textMuted}>
1449
+ mouse hover inspect drag pan wheel zoom ⇧wheel pan h/l cursor arrows legend Enter open 1-7 range m mode 0 reset
1450
+ </text>
1451
+ </box>
1452
+ </box>
1453
+ );
1454
+ }
1455
+
1456
+ const MemoizedComparisonStockChartView = memo(ComparisonStockChartView);
1457
+
1458
+ export function ComparisonStockChart(props: ComparisonStockChartProps) {
1459
+ const { state } = useAppState();
1460
+ const symbolsKey = props.symbols.join("|");
1461
+ const stableSymbols = useMemo(() => props.symbols, [symbolsKey]);
1462
+ const symbolSources = useMemo<ComparisonChartSymbolSource[]>(() => stableSymbols.map((symbol) => {
1463
+ const ticker = state.tickers.get(symbol) ?? null;
1464
+ const financials = state.financials.get(symbol) ?? null;
1465
+ const instrument = ticker?.metadata.broker_contracts?.[0] ?? null;
1466
+ return {
1467
+ symbol,
1468
+ currency: financials?.quote?.currency ?? ticker?.metadata.currency,
1469
+ exchange: ticker?.metadata.exchange ?? "",
1470
+ brokerId: instrument?.brokerId,
1471
+ brokerInstanceId: instrument?.brokerInstanceId,
1472
+ instrument,
1473
+ priceHistory: financials?.priceHistory ?? [],
1474
+ };
1475
+ }), [stableSymbols, state.financials, state.tickers]);
1476
+
1477
+ return (
1478
+ <MemoizedComparisonStockChartView
1479
+ {...props}
1480
+ symbols={stableSymbols}
1481
+ defaultRenderMode={state.config.chartPreferences.defaultRenderMode}
1482
+ preferredRenderer={state.config.chartPreferences.renderer}
1483
+ symbolSources={symbolSources}
1484
+ />
1485
+ );
1486
+ }