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,1556 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
3
+ import { useKeyboard, useRenderer } from "@opentui/react";
4
+ import { useAppState, usePaneInstanceId, usePaneTicker } from "../../state/app-context";
5
+ import { saveConfig } from "../../data/config-store";
6
+ import { colors, priceColor } from "../../theme/colors";
7
+ import { formatCompact, formatCurrency } from "../../utils/format";
8
+ import { useChartQuery, useResolvedEntryValue } from "../../market-data/hooks";
9
+ import { instrumentFromTicker } from "../../market-data/request-types";
10
+ import { filterByTimeRange, getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
11
+ import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
12
+ import {
13
+ buildChartScene,
14
+ formatDateShort,
15
+ formatAxisValue,
16
+ getActivePointIndex,
17
+ getPointTerminalColumn,
18
+ renderChart,
19
+ resolveChartPalette,
20
+ type StyledContent,
21
+ } from "./chart-renderer";
22
+ import {
23
+ CELL_CURSOR_SNAP_DISTANCE,
24
+ sameCursorPosition,
25
+ stepCursorTowards,
26
+ type ChartCursorMotionKind,
27
+ } from "./cursor-motion";
28
+ import {
29
+ CHART_RENDER_MODES,
30
+ TIME_RANGES,
31
+ type ChartAxisMode,
32
+ type ChartRenderMode,
33
+ type ChartViewState,
34
+ type ResolvedChartRenderer,
35
+ } from "./chart-types";
36
+ import {
37
+ computeBitmapSize,
38
+ intersectCellRects,
39
+ renderNativeChartBase,
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 type { PricePoint } from "../../types/financials";
50
+
51
+ const MODE_CHIPS: Record<ChartRenderMode, string> = {
52
+ area: "A",
53
+ line: "L",
54
+ candles: "C",
55
+ ohlc: "O",
56
+ };
57
+
58
+ const MODE_LABELS: Record<ChartRenderMode, string> = {
59
+ area: "AREA",
60
+ line: "LINE",
61
+ candles: "CANDLES",
62
+ ohlc: "OHLC",
63
+ };
64
+
65
+ interface StockChartProps {
66
+ width: number;
67
+ height: number;
68
+ focused: boolean;
69
+ interactive?: boolean;
70
+ compact?: boolean;
71
+ axisMode?: ChartAxisMode;
72
+ }
73
+
74
+ interface DragState {
75
+ startGlobalX: number;
76
+ startPanOffset: number;
77
+ }
78
+
79
+ interface ChartMouseEvent {
80
+ x: number;
81
+ y: number;
82
+ pixelX?: number;
83
+ pixelY?: number;
84
+ modifiers: {
85
+ shift: boolean;
86
+ alt: boolean;
87
+ ctrl: boolean;
88
+ };
89
+ scroll?: {
90
+ direction: "up" | "down" | "left" | "right";
91
+ delta: number;
92
+ };
93
+ }
94
+
95
+ export interface LocalPlotPointer {
96
+ cellX: number;
97
+ cellY: number;
98
+ pixelX: number | null;
99
+ pixelY: number | null;
100
+ hasPixelPrecision: boolean;
101
+ }
102
+
103
+ interface DisplayCursorState {
104
+ cellX: number | null;
105
+ cellY: number | null;
106
+ pixelX: number | null;
107
+ pixelY: number | null;
108
+ }
109
+
110
+ interface RenderableNode {
111
+ x: number;
112
+ y: number;
113
+ width: number;
114
+ height: number;
115
+ parent: RenderableNode | null;
116
+ }
117
+
118
+ const EMPTY_DISPLAY_CURSOR: DisplayCursorState = {
119
+ cellX: null,
120
+ cellY: null,
121
+ pixelX: null,
122
+ pixelY: null,
123
+ };
124
+
125
+ const PIXEL_CURSOR_SNAP_DISTANCE = 0.5;
126
+ const globalAnimationFrame = globalThis as typeof globalThis & {
127
+ requestAnimationFrame?: (callback: (timestamp: number) => void) => number;
128
+ cancelAnimationFrame?: (handle: number) => void;
129
+ };
130
+
131
+ function clamp(value: number, min: number, max: number): number {
132
+ return Math.max(min, Math.min(max, value));
133
+ }
134
+
135
+ function requestAnimationFrameSafe(callback: (timestamp: number) => void): number {
136
+ if (typeof globalAnimationFrame.requestAnimationFrame === "function") {
137
+ return globalAnimationFrame.requestAnimationFrame(callback);
138
+ }
139
+ return globalThis.setTimeout(() => callback(Date.now()), 16) as unknown as number;
140
+ }
141
+
142
+ function cancelAnimationFrameSafe(handle: number) {
143
+ if (typeof globalAnimationFrame.cancelAnimationFrame === "function") {
144
+ globalAnimationFrame.cancelAnimationFrame(handle);
145
+ return;
146
+ }
147
+ clearTimeout(handle);
148
+ }
149
+
150
+ function coerceChartDate(value: Date | string | number): Date {
151
+ return value instanceof Date ? value : new Date(value);
152
+ }
153
+
154
+ function getRendererCellMetrics(renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">) {
155
+ if (!renderer.resolution) return null;
156
+ const cellWidth = renderer.resolution.width / Math.max(renderer.terminalWidth, 1);
157
+ const cellHeight = renderer.resolution.height / Math.max(renderer.terminalHeight, 1);
158
+ if (!(cellWidth > 0) || !(cellHeight > 0)) return null;
159
+ return { cellWidth, cellHeight };
160
+ }
161
+
162
+ function getRenderablePixelSize(
163
+ renderable: Pick<RenderableNode, "width" | "height"> | null,
164
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
165
+ ) {
166
+ const metrics = getRendererCellMetrics(renderer);
167
+ if (!renderable || !metrics) return null;
168
+ return {
169
+ pixelWidth: Math.max(renderable.width * metrics.cellWidth, 1),
170
+ pixelHeight: Math.max(renderable.height * metrics.cellHeight, 1),
171
+ };
172
+ }
173
+
174
+ export function projectCellCursorToLocalPixels(
175
+ cellX: number,
176
+ cellY: number,
177
+ renderable: Pick<RenderableNode, "width" | "height"> | null,
178
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
179
+ ): { pixelX: number; pixelY: number } | null {
180
+ const pixelSize = getRenderablePixelSize(renderable, renderer);
181
+ if (!renderable || !pixelSize) return null;
182
+
183
+ return {
184
+ pixelX: renderable.width <= 1
185
+ ? 0
186
+ : clamp(
187
+ (cellX / Math.max(renderable.width - 1, 1)) * Math.max(pixelSize.pixelWidth - 1, 0),
188
+ 0,
189
+ Math.max(pixelSize.pixelWidth - 1, 0),
190
+ ),
191
+ pixelY: renderable.height <= 1
192
+ ? 0
193
+ : clamp(
194
+ (cellY / Math.max(renderable.height - 1, 1)) * Math.max(pixelSize.pixelHeight - 1, 0),
195
+ 0,
196
+ Math.max(pixelSize.pixelHeight - 1, 0),
197
+ ),
198
+ };
199
+ }
200
+
201
+ function scaleLocalPixelCoordinate(value: number | null, sourceExtent: number, targetExtent: number): number | null {
202
+ if (value === null) return null;
203
+ if (targetExtent <= 1) return 0;
204
+ if (!(sourceExtent > 1)) {
205
+ return clamp(value, 0, Math.max(targetExtent - 1, 0));
206
+ }
207
+ return clamp(
208
+ (value / Math.max(sourceExtent - 1, 1)) * Math.max(targetExtent - 1, 0),
209
+ 0,
210
+ Math.max(targetExtent - 1, 0),
211
+ );
212
+ }
213
+
214
+ function toCursorPosition(x: number | null, y: number | null) {
215
+ return { x, y };
216
+ }
217
+
218
+ function sameDisplayCursorState(
219
+ left: DisplayCursorState,
220
+ right: DisplayCursorState,
221
+ cellEpsilon = 0,
222
+ pixelEpsilon = 0,
223
+ ): boolean {
224
+ return sameCursorPosition(toCursorPosition(left.cellX, left.cellY), toCursorPosition(right.cellX, right.cellY), cellEpsilon)
225
+ && sameCursorPosition(toCursorPosition(left.pixelX, left.pixelY), toCursorPosition(right.pixelX, right.pixelY), pixelEpsilon);
226
+ }
227
+
228
+ function buildDisplayCursorState(
229
+ cellX: number | null,
230
+ cellY: number | null,
231
+ renderable: BoxRenderable | null,
232
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
233
+ pixelX: number | null = null,
234
+ pixelY: number | null = null,
235
+ ): DisplayCursorState {
236
+ if (cellX === null || cellY === null) return EMPTY_DISPLAY_CURSOR;
237
+ if (pixelX !== null && pixelY !== null) {
238
+ return { cellX, cellY, pixelX, pixelY };
239
+ }
240
+ const derivedPixels = projectCellCursorToLocalPixels(cellX, cellY, renderable, renderer);
241
+ return {
242
+ cellX,
243
+ cellY,
244
+ pixelX: derivedPixels?.pixelX ?? null,
245
+ pixelY: derivedPixels?.pixelY ?? null,
246
+ };
247
+ }
248
+
249
+ export function resolveSelectionDisplayCursorState(
250
+ cursorX: number | null,
251
+ cursorY: number | null,
252
+ fallbackCursorX: number | null,
253
+ fallbackCursorY: number | null,
254
+ renderable: BoxRenderable | null,
255
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
256
+ ): DisplayCursorState {
257
+ const resolvedCursorX = cursorY === null ? (fallbackCursorX ?? cursorX) : cursorX;
258
+ const resolvedCursorY = cursorY ?? fallbackCursorY;
259
+ if (resolvedCursorX === null) return EMPTY_DISPLAY_CURSOR;
260
+ return buildDisplayCursorState(resolvedCursorX, resolvedCursorY, renderable, renderer);
261
+ }
262
+
263
+ export function resolveAdjacentSelectionCursorX(
264
+ cursorX: number | null,
265
+ step: -1 | 1,
266
+ pointCount: number,
267
+ width: number,
268
+ mode: ChartRenderMode,
269
+ ): number | null {
270
+ if (pointCount <= 0 || width <= 0) return null;
271
+ const anchorX = cursorX ?? (step < 0 ? width - 1 : 0);
272
+ const currentIndex = getActivePointIndex(pointCount, width, anchorX, mode);
273
+ const nextIndex = clamp(currentIndex + step, 0, pointCount - 1);
274
+ return getPointTerminalColumn(nextIndex, pointCount, width, mode);
275
+ }
276
+
277
+ export function getLocalPlotPointer(
278
+ event: ChartMouseEvent,
279
+ renderable: BoxRenderable | null,
280
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
281
+ ): LocalPlotPointer | null {
282
+ if (!renderable) return null;
283
+
284
+ const localCellX = event.x - renderable.x;
285
+ const localCellY = event.y - renderable.y;
286
+ if (localCellX < 0 || localCellX >= renderable.width || localCellY < 0 || localCellY >= renderable.height) {
287
+ return null;
288
+ }
289
+
290
+ const metrics = getRendererCellMetrics(renderer);
291
+ if (event.pixelX === undefined || event.pixelY === undefined || !metrics) {
292
+ return {
293
+ cellX: localCellX,
294
+ cellY: localCellY,
295
+ pixelX: null,
296
+ pixelY: null,
297
+ hasPixelPrecision: false,
298
+ };
299
+ }
300
+
301
+ const pixelLeft = renderable.x * metrics.cellWidth;
302
+ const pixelTop = renderable.y * metrics.cellHeight;
303
+ const pixelWidth = renderable.width * metrics.cellWidth;
304
+ const pixelHeight = renderable.height * metrics.cellHeight;
305
+ const localPixelX = event.pixelX - pixelLeft;
306
+ const localPixelY = event.pixelY - pixelTop;
307
+
308
+ if (localPixelX < 0 || localPixelY < 0 || localPixelX > pixelWidth || localPixelY > pixelHeight) {
309
+ return {
310
+ cellX: localCellX,
311
+ cellY: localCellY,
312
+ pixelX: null,
313
+ pixelY: null,
314
+ hasPixelPrecision: false,
315
+ };
316
+ }
317
+
318
+ return {
319
+ cellX: renderable.width <= 1
320
+ ? 0
321
+ : clamp(
322
+ (localPixelX / Math.max(pixelWidth - 1, 1)) * Math.max(renderable.width - 1, 0),
323
+ 0,
324
+ Math.max(renderable.width - 1, 0),
325
+ ),
326
+ cellY: renderable.height <= 1
327
+ ? 0
328
+ : clamp(
329
+ (localPixelY / Math.max(pixelHeight - 1, 1)) * Math.max(renderable.height - 1, 0),
330
+ 0,
331
+ Math.max(renderable.height - 1, 0),
332
+ ),
333
+ pixelX: clamp(localPixelX, 0, Math.max(pixelWidth - 1, 0)),
334
+ pixelY: clamp(localPixelY, 0, Math.max(pixelHeight - 1, 0)),
335
+ hasPixelPrecision: true,
336
+ };
337
+ }
338
+
339
+ function getGlobalMouseX(
340
+ event: ChartMouseEvent,
341
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
342
+ ): number {
343
+ const metrics = getRendererCellMetrics(renderer);
344
+ if (event.pixelX === undefined || !metrics) return event.x;
345
+ return event.pixelX / metrics.cellWidth;
346
+ }
347
+
348
+ function resolveCursorMotionKind(
349
+ event: ChartMouseEvent,
350
+ renderer: Pick<CliRenderer, "resolution" | "terminalWidth" | "terminalHeight">,
351
+ ): ChartCursorMotionKind {
352
+ return event.pixelX !== undefined && event.pixelY !== undefined && getRendererCellMetrics(renderer)
353
+ ? "pixel"
354
+ : "cell";
355
+ }
356
+
357
+ function extractCellRect(renderable: RenderableNode): CellRect {
358
+ return {
359
+ x: renderable.x,
360
+ y: renderable.y,
361
+ width: renderable.width,
362
+ height: renderable.height,
363
+ };
364
+ }
365
+
366
+ function resolveVisibleRect(
367
+ renderable: RenderableNode | null,
368
+ terminalWidth: number,
369
+ terminalHeight: number,
370
+ ): CellRect | null {
371
+ if (!renderable) return null;
372
+
373
+ let visible: CellRect = {
374
+ x: 0,
375
+ y: 0,
376
+ width: terminalWidth,
377
+ height: terminalHeight,
378
+ };
379
+ let current: RenderableNode | null = renderable;
380
+
381
+ while (current) {
382
+ const currentRect = extractCellRect(current);
383
+ const nextVisible = intersectCellRects(visible, currentRect);
384
+ if (!nextVisible) return null;
385
+ visible = nextVisible;
386
+ current = current.parent;
387
+ }
388
+
389
+ return visible;
390
+ }
391
+
392
+ function buildBlankPlotLines(width: number, height: number): string[] {
393
+ return Array.from({ length: height }, () => " ".repeat(width));
394
+ }
395
+
396
+ function formatAxisCell(label: string | null, width: number): string {
397
+ if (!label) return " ".repeat(width);
398
+ return label.length >= width ? label.slice(0, width) : label.padStart(width);
399
+ }
400
+
401
+ function getMaxPanOffset(history: PricePoint[], timeRange: ChartViewState["timeRange"], zoomLevel: number, chartWidth: number): number {
402
+ const filtered = filterByTimeRange(history, timeRange);
403
+ const visibleCount = getVisiblePointCount(filtered.length, zoomLevel);
404
+ return Math.max(filtered.length - visibleCount, 0);
405
+ }
406
+
407
+ function applyZoomAroundAnchor(
408
+ view: ChartViewState,
409
+ nextZoomLevel: number,
410
+ anchorRatio: number,
411
+ history: PricePoint[],
412
+ chartWidth: number,
413
+ ): ChartViewState {
414
+ const filtered = filterByTimeRange(history, view.timeRange);
415
+ if (filtered.length === 0) return view;
416
+
417
+ const clampedZoom = clampChartZoom(filtered.length, nextZoomLevel);
418
+ const currentVisibleCount = getVisiblePointCount(filtered.length, view.zoomLevel);
419
+ const nextVisibleCount = getVisiblePointCount(filtered.length, clampedZoom);
420
+ const currentPanOffset = clamp(view.panOffset, 0, Math.max(filtered.length - currentVisibleCount, 0));
421
+ const ratio = clamp(anchorRatio, 0, 1);
422
+ const anchorIndex = filtered.length - currentPanOffset - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
423
+ const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
424
+ const clampedStart = clamp(nextStart, 0, Math.max(filtered.length - nextVisibleCount, 0));
425
+ const nextPanOffset = filtered.length - nextVisibleCount - clampedStart;
426
+
427
+ return {
428
+ ...view,
429
+ zoomLevel: clampedZoom,
430
+ panOffset: clamp(nextPanOffset, 0, Math.max(filtered.length - nextVisibleCount, 0)),
431
+ };
432
+ }
433
+
434
+ function buildNativeBitmapKey(
435
+ pointCount: number,
436
+ points: PricePoint[],
437
+ pixelWidth: number,
438
+ pixelHeight: number,
439
+ mode: ChartRenderMode,
440
+ showVolume: boolean,
441
+ paletteId: string,
442
+ ): string {
443
+ const fingerprint = points
444
+ .map((point) => {
445
+ const date = point.date instanceof Date ? point.date.getTime() : new Date(point.date).getTime();
446
+ return `${date}:${point.open}:${point.high}:${point.low}:${point.close}:${point.volume ?? 0}`;
447
+ })
448
+ .join("|");
449
+ return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, fingerprint].join("::");
450
+ }
451
+
452
+ function buildNativeCrosshairBitmapKey(
453
+ pixelWidth: number,
454
+ pixelHeight: number,
455
+ chartHeight: number,
456
+ chartRows: number,
457
+ crosshairColor: string,
458
+ pixelX: number | null,
459
+ pixelY: number | null,
460
+ ): string {
461
+ const cursorKey = pixelX === null || pixelY === null
462
+ ? "cursor:none"
463
+ : `cursor:${pixelX.toFixed(3)}:${pixelY.toFixed(3)}`;
464
+ return [pixelWidth, pixelHeight, chartHeight, chartRows, crosshairColor, cursorKey].join("::");
465
+ }
466
+
467
+ function resolveSelectionCursorX(
468
+ cellX: number,
469
+ pointCount: number,
470
+ width: number,
471
+ mode: ChartRenderMode,
472
+ ): number | null {
473
+ if (pointCount <= 0 || width <= 0) return null;
474
+
475
+ let bestIndex = pointCount - 1;
476
+ let bestDistance = Number.POSITIVE_INFINITY;
477
+ for (let index = 0; index < pointCount; index += 1) {
478
+ const pointColumn = getPointTerminalColumn(index, pointCount, width, mode);
479
+ const distance = Math.abs(pointColumn - cellX);
480
+ if (distance <= bestDistance) {
481
+ bestDistance = distance;
482
+ bestIndex = index;
483
+ }
484
+ }
485
+
486
+ return getPointTerminalColumn(bestIndex, pointCount, width, mode);
487
+ }
488
+
489
+ function resolveSelectionCursor(
490
+ pointer: LocalPlotPointer,
491
+ pointCount: number,
492
+ width: number,
493
+ mode: ChartRenderMode,
494
+ ): { cursorX: number | null; cursorY: number | null } {
495
+ if (!pointer.hasPixelPrecision) {
496
+ return {
497
+ cursorX: pointer.cellX,
498
+ cursorY: pointer.cellY,
499
+ };
500
+ }
501
+
502
+ return {
503
+ cursorX: resolveSelectionCursorX(pointer.cellX, pointCount, width, mode),
504
+ cursorY: null,
505
+ };
506
+ }
507
+
508
+ export function StockChart({ width, height, focused, interactive, compact, axisMode = "price" }: StockChartProps) {
509
+ const renderer = useRenderer();
510
+ const { state, dispatch } = useAppState();
511
+ const paneId = usePaneInstanceId();
512
+ const { ticker, financials } = usePaneTicker();
513
+ const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
514
+ const defaultRenderMode = state.config.chartPreferences.defaultRenderMode;
515
+ const preferredRenderer = state.config.chartPreferences.renderer;
516
+ const [viewState, setViewState] = useState<ChartViewState>({
517
+ timeRange: compact ? "1Y" : "5Y",
518
+ panOffset: 0,
519
+ zoomLevel: 1,
520
+ cursorX: null,
521
+ cursorY: null,
522
+ renderMode: defaultRenderMode,
523
+ });
524
+ const [showVolume, setShowVolume] = useState(!compact);
525
+ const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
526
+ const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
527
+ const plotRef = useRef<BoxRenderable | null>(null);
528
+ const nativeBaseSurfaceIdRef = useRef(`chart-surface:${paneId}:${compact ? "compact" : "full"}:base`);
529
+ const nativeCrosshairSurfaceIdRef = useRef(`chart-surface:${paneId}:${compact ? "compact" : "full"}:crosshair`);
530
+ const dragRef = useRef<DragState | null>(null);
531
+ const lastNativeGeometryRef = useRef<{ rect: CellRect; visibleRect: CellRect | null } | null>(null);
532
+ const lastNativeBaseBitmapRef = useRef<{ key: string; bitmap: NativeChartBitmap } | null>(null);
533
+ const lastNativeCrosshairBitmapRef = useRef<{ key: string; bitmap: NativeChartBitmap } | null>(null);
534
+ const displayCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
535
+ const targetCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
536
+ const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
537
+ const animationFrameRef = useRef<number | null>(null);
538
+
539
+ const commitDisplayCursor = (next: DisplayCursorState) => {
540
+ displayCursorRef.current = next;
541
+ setDisplayCursor((current) => (sameDisplayCursorState(current, next) ? current : next));
542
+ };
543
+
544
+ const stopDisplayCursorAnimation = () => {
545
+ if (animationFrameRef.current !== null) {
546
+ cancelAnimationFrameSafe(animationFrameRef.current);
547
+ animationFrameRef.current = null;
548
+ }
549
+ };
550
+
551
+ const startDisplayCursorAnimation = () => {
552
+ if (animationFrameRef.current !== null) return;
553
+
554
+ const tick = () => {
555
+ animationFrameRef.current = null;
556
+ const target = targetCursorRef.current;
557
+
558
+ if (cursorMotionKindRef.current !== "cell") {
559
+ commitDisplayCursor(target);
560
+ return;
561
+ }
562
+
563
+ const currentCell = toCursorPosition(displayCursorRef.current.cellX, displayCursorRef.current.cellY);
564
+ const targetCell = toCursorPosition(target.cellX, target.cellY);
565
+ const currentPixel = toCursorPosition(displayCursorRef.current.pixelX, displayCursorRef.current.pixelY);
566
+ const targetPixel = toCursorPosition(target.pixelX, target.pixelY);
567
+ const cellStep = stepCursorTowards(currentCell, targetCell);
568
+ const pixelStep = stepCursorTowards(currentPixel, targetPixel, undefined, PIXEL_CURSOR_SNAP_DISTANCE);
569
+ const next: DisplayCursorState = {
570
+ cellX: cellStep.next.x,
571
+ cellY: cellStep.next.y,
572
+ pixelX: pixelStep.next.x,
573
+ pixelY: pixelStep.next.y,
574
+ };
575
+ const settled = cellStep.settled && pixelStep.settled;
576
+ commitDisplayCursor(next);
577
+ if (!settled) {
578
+ animationFrameRef.current = requestAnimationFrameSafe(tick);
579
+ }
580
+ };
581
+
582
+ animationFrameRef.current = requestAnimationFrameSafe(tick);
583
+ };
584
+
585
+ const updateDisplayCursorTarget = (next: DisplayCursorState, motionKind: ChartCursorMotionKind) => {
586
+ cursorMotionKindRef.current = motionKind;
587
+ targetCursorRef.current = next;
588
+
589
+ if (motionKind !== "cell" || next.cellX === null || next.cellY === null) {
590
+ stopDisplayCursorAnimation();
591
+ commitDisplayCursor(next);
592
+ return;
593
+ }
594
+
595
+ if (displayCursorRef.current.cellX === null || displayCursorRef.current.cellY === null) {
596
+ commitDisplayCursor(next);
597
+ return;
598
+ }
599
+
600
+ if (sameDisplayCursorState(displayCursorRef.current, next, CELL_CURSOR_SNAP_DISTANCE, PIXEL_CURSOR_SNAP_DISTANCE)) {
601
+ stopDisplayCursorAnimation();
602
+ commitDisplayCursor(next);
603
+ return;
604
+ }
605
+
606
+ startDisplayCursorAnimation();
607
+ };
608
+
609
+ useEffect(() => (
610
+ () => {
611
+ stopDisplayCursorAnimation();
612
+ lastNativeBaseBitmapRef.current = null;
613
+ lastNativeCrosshairBitmapRef.current = null;
614
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
615
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
616
+ }
617
+ ), [nativeSurfaceManager]);
618
+
619
+ const instrumentRef = useMemo(
620
+ () => instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null),
621
+ [ticker],
622
+ );
623
+ const baseChartEntry = useChartQuery(
624
+ !compact && instrumentRef
625
+ ? {
626
+ instrument: instrumentRef,
627
+ range: viewState.timeRange,
628
+ granularity: "daily",
629
+ }
630
+ : null,
631
+ );
632
+ const rangeHistory = useResolvedEntryValue(baseChartEntry);
633
+ const axisWidth = compact
634
+ ? axisMode === "percent" ? 11 : 8
635
+ : axisMode === "percent" ? 11 : 10;
636
+ const axisGap = axisWidth > 0 ? 1 : 0;
637
+ const chartWidth = Math.max(width - axisWidth - axisGap, compact ? 12 : 20);
638
+ const maxCursorX = chartWidth - 1;
639
+ const panStep = Math.max(Math.floor(chartWidth / 10), 1);
640
+ const baseHistory = rangeHistory && rangeHistory.length > 0
641
+ ? rangeHistory
642
+ : (financials?.priceHistory ?? []);
643
+ const detailRequest = useMemo(() => {
644
+ if (compact || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
645
+ const window = getVisibleWindow(baseHistory, viewState, chartWidth);
646
+ if (window.points.length < 2) return null;
647
+
648
+ const startDate = coerceChartDate(window.points[0]!.date as Date | string | number);
649
+ const endDate = coerceChartDate(window.points[window.points.length - 1]!.date as Date | string | number);
650
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null;
651
+ const spanMs = endDate.getTime() - startDate.getTime();
652
+ const barSize = resolveBarSize(spanMs);
653
+ if (!barSize) return null;
654
+
655
+ return {
656
+ instrument: instrumentRef,
657
+ range: viewState.timeRange,
658
+ granularity: "detail" as const,
659
+ startDate,
660
+ endDate,
661
+ barSize,
662
+ };
663
+ }, [baseHistory, chartWidth, compact, instrumentRef, viewState]);
664
+ const detailChartEntry = useChartQuery(detailRequest);
665
+ const detailHistory = useResolvedEntryValue(detailChartEntry);
666
+
667
+ const history = (viewState.zoomLevel > 1 && detailHistory) ? detailHistory : baseHistory;
668
+
669
+ useEffect(() => {
670
+ if (interactive) {
671
+ cursorMotionKindRef.current = "discrete";
672
+ setViewState((current) => (current.cursorX === null ? { ...current, cursorX: chartWidth - 1 } : current));
673
+ } else {
674
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
675
+ setViewState((current) => (current.cursorX !== null || current.cursorY !== null
676
+ ? { ...current, cursorX: null, cursorY: null }
677
+ : current));
678
+ }
679
+ }, [interactive, chartWidth]);
680
+
681
+ useEffect(() => {
682
+ const refreshSupport = () => setKittySupport(getCachedKittySupport(renderer));
683
+ refreshSupport();
684
+ renderer.on("capabilities", refreshSupport);
685
+ return () => {
686
+ renderer.off("capabilities", refreshSupport);
687
+ };
688
+ }, [renderer]);
689
+
690
+ useEffect(() => {
691
+ let cancelled = false;
692
+ const known = getCachedKittySupport(renderer);
693
+ setKittySupport(known);
694
+ if (preferredRenderer === "braille" || known !== null) return;
695
+ ensureKittySupport(renderer).then((supported) => {
696
+ if (!cancelled) setKittySupport(supported);
697
+ }).catch(() => {
698
+ if (!cancelled) setKittySupport(false);
699
+ });
700
+ return () => {
701
+ cancelled = true;
702
+ };
703
+ }, [preferredRenderer, renderer]);
704
+
705
+ const persistDefaultRenderMode = (nextMode: ChartRenderMode) => {
706
+ if (nextMode === defaultRenderMode) return;
707
+ const nextConfig = {
708
+ ...state.config,
709
+ chartPreferences: {
710
+ ...state.config.chartPreferences,
711
+ defaultRenderMode: nextMode,
712
+ },
713
+ };
714
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
715
+ saveConfig(nextConfig).catch(() => {});
716
+ };
717
+
718
+ const setRange = (range: ChartViewState["timeRange"]) => {
719
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
720
+ setViewState((current) => ({
721
+ ...current,
722
+ timeRange: range,
723
+ panOffset: 0,
724
+ zoomLevel: 1,
725
+ cursorX: null,
726
+ cursorY: null,
727
+ }));
728
+ };
729
+
730
+ const setRenderMode = (mode: ChartRenderMode) => {
731
+ persistDefaultRenderMode(mode);
732
+ setViewState((current) => ({ ...current, renderMode: mode }));
733
+ };
734
+
735
+ const headerRows = compact ? 0 : 3;
736
+ const helpRow = compact ? 0 : 1;
737
+ const timeAxisRow = 1;
738
+ const volumeHeight = showVolume && !compact ? 3 : 0;
739
+ const chartHeight = Math.max(height - headerRows - helpRow - timeAxisRow, 4);
740
+ const isDetailView = viewState.zoomLevel > 1 && detailHistory != null && detailHistory.length > 0;
741
+ const historyRenderKey = history.length === 0
742
+ ? "empty"
743
+ : [
744
+ history.length,
745
+ new Date(history[0]!.date).getTime(),
746
+ new Date(history[history.length - 1]!.date).getTime(),
747
+ history[history.length - 1]!.close,
748
+ ].join(":");
749
+
750
+ useEffect(() => {
751
+ queueMicrotask(() => renderer.requestRender());
752
+ }, [chartHeight, chartWidth, compact, historyRenderKey, renderer, ticker?.metadata.ticker, viewState.renderMode]);
753
+
754
+ const chartWindow = useMemo(() => (
755
+ isDetailView
756
+ ? { points: history, startIdx: 0, endIdx: history.length }
757
+ : getVisibleWindow(history, viewState, chartWidth)
758
+ ), [chartWidth, history, isDetailView, viewState.panOffset, viewState.timeRange, viewState.zoomLevel]);
759
+
760
+ const projection = useMemo(() => (
761
+ projectChartData(chartWindow.points, chartWidth, viewState.renderMode, !!compact)
762
+ ), [chartWindow.points, chartWidth, compact, viewState.renderMode]);
763
+
764
+ useKeyboard((event) => {
765
+ if (!focused || compact) return;
766
+
767
+ switch (event.name) {
768
+ case "=":
769
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.5, 0.5, baseHistory, chartWidth));
770
+ return;
771
+ case "-":
772
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.5, 0.5, baseHistory, chartWidth));
773
+ return;
774
+ case "0":
775
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
776
+ setViewState((current) => ({ ...current, panOffset: 0, zoomLevel: 1, cursorX: null, cursorY: null }));
777
+ return;
778
+ case "v":
779
+ setShowVolume((value) => !value);
780
+ return;
781
+ case "a":
782
+ setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
783
+ return;
784
+ case "d":
785
+ setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
786
+ return;
787
+ case "m":
788
+ setViewState((current) => {
789
+ const activeMode = current.renderMode ?? "area";
790
+ const index = CHART_RENDER_MODES.indexOf(activeMode);
791
+ const nextMode = CHART_RENDER_MODES[(index + 1) % CHART_RENDER_MODES.length]!;
792
+ persistDefaultRenderMode(nextMode);
793
+ return { ...current, renderMode: nextMode };
794
+ });
795
+ return;
796
+ }
797
+
798
+ if (event.name >= "1" && event.name <= "7") {
799
+ const index = parseInt(event.name) - 1;
800
+ if (index < TIME_RANGES.length) setRange(TIME_RANGES[index]!);
801
+ return;
802
+ }
803
+
804
+ if (!interactive) return;
805
+
806
+ switch (event.name) {
807
+ case "left":
808
+ if (event.shift) {
809
+ setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
810
+ } else {
811
+ cursorMotionKindRef.current = "discrete";
812
+ setViewState((current) => {
813
+ const pointCount = projection.points.length;
814
+ const currentIndex = pointCount <= 0
815
+ ? -1
816
+ : getActivePointIndex(
817
+ pointCount,
818
+ chartWidth,
819
+ current.cursorX ?? maxCursorX,
820
+ projection.effectiveMode,
821
+ );
822
+ const nextCursor = resolveAdjacentSelectionCursorX(
823
+ current.cursorX,
824
+ -1,
825
+ pointCount,
826
+ chartWidth,
827
+ projection.effectiveMode,
828
+ );
829
+ const maxPan = getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth);
830
+ if (currentIndex <= 0) {
831
+ return {
832
+ ...current,
833
+ cursorX: nextCursor,
834
+ cursorY: null,
835
+ panOffset: clamp(current.panOffset + 1, 0, maxPan),
836
+ };
837
+ }
838
+ return { ...current, cursorX: nextCursor, cursorY: null };
839
+ });
840
+ }
841
+ return;
842
+ case "right":
843
+ if (event.shift) {
844
+ setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
845
+ } else {
846
+ cursorMotionKindRef.current = "discrete";
847
+ setViewState((current) => {
848
+ const pointCount = projection.points.length;
849
+ const currentIndex = pointCount <= 0
850
+ ? -1
851
+ : getActivePointIndex(
852
+ pointCount,
853
+ chartWidth,
854
+ current.cursorX ?? 0,
855
+ projection.effectiveMode,
856
+ );
857
+ const nextCursor = resolveAdjacentSelectionCursorX(
858
+ current.cursorX,
859
+ 1,
860
+ pointCount,
861
+ chartWidth,
862
+ projection.effectiveMode,
863
+ );
864
+ if (currentIndex >= pointCount - 1) {
865
+ return {
866
+ ...current,
867
+ cursorX: nextCursor,
868
+ cursorY: null,
869
+ panOffset: Math.max(current.panOffset - 1, 0),
870
+ };
871
+ }
872
+ return { ...current, cursorX: nextCursor, cursorY: null };
873
+ });
874
+ }
875
+ return;
876
+ }
877
+ });
878
+
879
+ const chartColors = useMemo(() => {
880
+ const rawChange = chartWindow.points.length >= 2
881
+ ? chartWindow.points[chartWindow.points.length - 1]!.close - chartWindow.points[0]!.close
882
+ : 0;
883
+ const trend = rawChange < 0 ? "negative" : rawChange > 0 ? "positive" : "neutral";
884
+ return resolveChartPalette({
885
+ bg: colors.bg,
886
+ border: colors.border,
887
+ borderFocused: colors.borderFocused,
888
+ text: colors.text,
889
+ textDim: colors.textDim,
890
+ positive: colors.positive,
891
+ negative: colors.negative,
892
+ }, trend);
893
+ }, [chartWindow.points]);
894
+ const chartCurrency = financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
895
+
896
+ const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
897
+ const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
898
+ const selectionCursorX = interactive ? cursorX : null;
899
+ const selectionCursorY = interactive ? cursorY : null;
900
+ const displayCursorX = interactive && displayCursor.cellX !== null ? clamp(displayCursor.cellX, 0, chartWidth - 1) : null;
901
+ const displayCursorY = interactive && displayCursor.cellY !== null ? clamp(displayCursor.cellY, 0, chartHeight - 1) : null;
902
+
903
+ const commitSelectionCursor = (next: { cursorX: number | null; cursorY: number | null }) => {
904
+ setViewState((current) => (
905
+ current.cursorX === next.cursorX && current.cursorY === next.cursorY
906
+ ? current
907
+ : { ...current, cursorX: next.cursorX, cursorY: next.cursorY }
908
+ ));
909
+ };
910
+
911
+ const selectionScene = useMemo(() => buildChartScene(projection.points, {
912
+ width: chartWidth,
913
+ height: chartHeight,
914
+ showVolume: showVolume && !compact,
915
+ volumeHeight,
916
+ cursorX: selectionCursorX,
917
+ cursorY: selectionCursorY,
918
+ mode: projection.effectiveMode,
919
+ axisMode,
920
+ colors: chartColors,
921
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, volumeHeight]);
922
+ const snappedSelectionCursorX = selectionScene
923
+ ? getPointTerminalColumn(selectionScene.activeIdx, projection.points.length, chartWidth, projection.effectiveMode)
924
+ : null;
925
+
926
+ useEffect(() => {
927
+ const remapCursor = (cursor: DisplayCursorState) => buildDisplayCursorState(
928
+ cursor.cellX,
929
+ cursor.cellY,
930
+ plotRef.current,
931
+ renderer,
932
+ );
933
+ const nextDisplay = remapCursor(displayCursorRef.current);
934
+ const nextTarget = remapCursor(targetCursorRef.current);
935
+ displayCursorRef.current = nextDisplay;
936
+ targetCursorRef.current = nextTarget;
937
+ setDisplayCursor((current) => (sameDisplayCursorState(current, nextDisplay) ? current : nextDisplay));
938
+ }, [chartHeight, chartWidth, renderer.resolution?.height, renderer.resolution?.width]);
939
+
940
+ useEffect(() => {
941
+ if (cursorMotionKindRef.current === "pixel") return;
942
+ updateDisplayCursorTarget(
943
+ resolveSelectionDisplayCursorState(
944
+ selectionCursorX,
945
+ selectionCursorY,
946
+ cursorMotionKindRef.current === "discrete" ? snappedSelectionCursorX : null,
947
+ selectionScene?.cursorY ?? null,
948
+ plotRef.current,
949
+ renderer,
950
+ ),
951
+ cursorMotionKindRef.current,
952
+ );
953
+ }, [renderer, selectionCursorX, selectionCursorY, selectionScene?.cursorY, snappedSelectionCursorX]);
954
+
955
+ const nativeBaseScene = useMemo(() => buildChartScene(projection.points, {
956
+ width: chartWidth,
957
+ height: chartHeight,
958
+ showVolume: showVolume && !compact,
959
+ volumeHeight,
960
+ cursorX: null,
961
+ cursorY: null,
962
+ mode: projection.effectiveMode,
963
+ axisMode,
964
+ colors: chartColors,
965
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
966
+
967
+ const rendererState = resolveChartRendererState(preferredRenderer, kittySupport, renderer.resolution);
968
+ const effectiveRenderer: ResolvedChartRenderer = rendererState.renderer;
969
+
970
+ const staticResult = useMemo(() => renderChart(projection.points, {
971
+ width: chartWidth,
972
+ height: chartHeight,
973
+ showVolume: showVolume && !compact,
974
+ volumeHeight,
975
+ cursorX: null,
976
+ cursorY: null,
977
+ mode: projection.effectiveMode,
978
+ axisMode,
979
+ currency: chartCurrency,
980
+ colors: chartColors,
981
+ }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
982
+
983
+ const interactiveResult = useMemo(() => (
984
+ effectiveRenderer === "kitty"
985
+ ? null
986
+ : renderChart(projection.points, {
987
+ width: chartWidth,
988
+ height: chartHeight,
989
+ showVolume: showVolume && !compact,
990
+ volumeHeight,
991
+ cursorX: displayCursorX,
992
+ cursorY: displayCursorY,
993
+ mode: projection.effectiveMode,
994
+ axisMode,
995
+ currency: chartCurrency,
996
+ colors: chartColors,
997
+ })
998
+ ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
999
+
1000
+ const result = effectiveRenderer === "kitty" ? staticResult : interactiveResult!;
1001
+
1002
+ const kittyCursorRow = effectiveRenderer === "kitty" && displayCursorY !== null && nativeBaseScene
1003
+ ? Math.round(clamp(displayCursorY, 0, Math.max(nativeBaseScene.chartRows - 1, 0)))
1004
+ : null;
1005
+ const kittyCrosshairPrice = effectiveRenderer === "kitty" && displayCursorY !== null && nativeBaseScene
1006
+ ? nativeBaseScene.max
1007
+ - (clamp(displayCursorY, 0, Math.max(nativeBaseScene.chartRows - 1, 0)) / Math.max(nativeBaseScene.chartRows - 1, 1))
1008
+ * (nativeBaseScene.max - nativeBaseScene.min)
1009
+ : null;
1010
+ const cursorRow = effectiveRenderer === "kitty" ? kittyCursorRow : result.cursorRow;
1011
+ const crosshairPrice = effectiveRenderer === "kitty" ? kittyCrosshairPrice : result.crosshairPrice;
1012
+
1013
+ const nativeCrosshair = useMemo<NativeCrosshairOverlay | null>(() => {
1014
+ if (!interactive || displayCursor.cellX === null || displayCursor.cellY === null) return null;
1015
+ return {
1016
+ width: chartWidth,
1017
+ height: chartHeight,
1018
+ chartRows: chartHeight - (showVolume && !compact ? volumeHeight : 0),
1019
+ pixelX: displayCursor.pixelX,
1020
+ pixelY: displayCursor.pixelY,
1021
+ colors: {
1022
+ crosshairColor: chartColors.crosshairColor,
1023
+ },
1024
+ };
1025
+ }, [
1026
+ chartColors.crosshairColor,
1027
+ chartHeight,
1028
+ chartWidth,
1029
+ compact,
1030
+ displayCursor.cellX,
1031
+ displayCursor.cellY,
1032
+ displayCursor.pixelX,
1033
+ displayCursor.pixelY,
1034
+ interactive,
1035
+ showVolume,
1036
+ volumeHeight,
1037
+ ]);
1038
+ const blankPlotLines = useMemo(() => buildBlankPlotLines(chartWidth, chartHeight), [chartHeight, chartWidth]);
1039
+
1040
+ useEffect(() => {
1041
+ if (effectiveRenderer === "kitty") return;
1042
+ lastNativeBaseBitmapRef.current = null;
1043
+ lastNativeCrosshairBitmapRef.current = null;
1044
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
1045
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
1046
+ lastNativeGeometryRef.current = null;
1047
+ }, [effectiveRenderer, nativeSurfaceManager]);
1048
+
1049
+ useEffect(() => {
1050
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
1051
+ const plot = plotRef.current;
1052
+ let mountTimer: ReturnType<typeof setTimeout> | null = null;
1053
+
1054
+ const syncPlacement = () => {
1055
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
1056
+ const rect = extractCellRect(plotRef.current);
1057
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
1058
+ const previous = lastNativeGeometryRef.current;
1059
+ if (previous
1060
+ && previous.rect.x === rect.x
1061
+ && previous.rect.y === rect.y
1062
+ && previous.rect.width === rect.width
1063
+ && previous.rect.height === rect.height
1064
+ && previous.visibleRect?.x === visibleRect?.x
1065
+ && previous.visibleRect?.y === visibleRect?.y
1066
+ && previous.visibleRect?.width === visibleRect?.width
1067
+ && previous.visibleRect?.height === visibleRect?.height) {
1068
+ return;
1069
+ }
1070
+ lastNativeGeometryRef.current = { rect, visibleRect };
1071
+ const geometry = {
1072
+ paneId,
1073
+ rect,
1074
+ visibleRect,
1075
+ };
1076
+ syncCachedNativeSurface(
1077
+ nativeSurfaceManager,
1078
+ nativeBaseSurfaceIdRef.current,
1079
+ geometry,
1080
+ lastNativeBaseBitmapRef.current,
1081
+ );
1082
+ syncCachedNativeSurface(
1083
+ nativeSurfaceManager,
1084
+ nativeCrosshairSurfaceIdRef.current,
1085
+ geometry,
1086
+ lastNativeCrosshairBitmapRef.current,
1087
+ );
1088
+ };
1089
+
1090
+ plot.onLifecyclePass = syncPlacement;
1091
+ renderer.registerLifecyclePass(plot);
1092
+ syncPlacement();
1093
+ mountTimer = setTimeout(() => {
1094
+ syncPlacement();
1095
+ renderer.requestRender();
1096
+ }, 0);
1097
+ return () => {
1098
+ if (mountTimer) clearTimeout(mountTimer);
1099
+ plot.onLifecyclePass = null;
1100
+ renderer.unregisterLifecyclePass(plot);
1101
+ lastNativeGeometryRef.current = null;
1102
+ };
1103
+ }, [effectiveRenderer, nativeSurfaceManager, paneId, renderer, rendererState.nativeReady]);
1104
+
1105
+ useEffect(() => {
1106
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !nativeBaseScene) {
1107
+ lastNativeBaseBitmapRef.current = null;
1108
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
1109
+ return;
1110
+ }
1111
+
1112
+ const plotRect = extractCellRect(plotRef.current);
1113
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
1114
+ const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
1115
+ const bitmapKey = buildNativeBitmapKey(
1116
+ projection.points.length,
1117
+ projection.points,
1118
+ bitmapSize.pixelWidth,
1119
+ bitmapSize.pixelHeight,
1120
+ projection.effectiveMode,
1121
+ showVolume && !compact,
1122
+ [
1123
+ chartColors.lineColor,
1124
+ chartColors.fillColor,
1125
+ chartColors.gridColor,
1126
+ chartColors.volumeUp,
1127
+ chartColors.volumeDown,
1128
+ chartColors.candleUp,
1129
+ chartColors.candleDown,
1130
+ ].join(","),
1131
+ );
1132
+ const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
1133
+ ? lastNativeBaseBitmapRef.current.bitmap
1134
+ : null;
1135
+ const bitmap = cachedBitmap ?? renderNativeChartBase(nativeBaseScene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
1136
+ if (!cachedBitmap) {
1137
+ lastNativeBaseBitmapRef.current = { key: bitmapKey, bitmap };
1138
+ }
1139
+
1140
+ if (!visibleRect) {
1141
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
1142
+ return;
1143
+ }
1144
+
1145
+ nativeSurfaceManager.upsertSurface({
1146
+ id: nativeBaseSurfaceIdRef.current,
1147
+ paneId,
1148
+ rect: plotRect,
1149
+ visibleRect,
1150
+ bitmap,
1151
+ bitmapKey,
1152
+ });
1153
+ renderer.requestRender();
1154
+ }, [
1155
+ chartColors.candleDown,
1156
+ chartColors.candleUp,
1157
+ chartColors.fillColor,
1158
+ chartColors.gridColor,
1159
+ chartColors.lineColor,
1160
+ chartColors.volumeDown,
1161
+ chartColors.volumeUp,
1162
+ compact,
1163
+ effectiveRenderer,
1164
+ nativeBaseScene,
1165
+ nativeSurfaceManager,
1166
+ paneId,
1167
+ projection.effectiveMode,
1168
+ projection.points,
1169
+ renderer,
1170
+ rendererState.nativeReady,
1171
+ showVolume,
1172
+ ]);
1173
+
1174
+ useEffect(() => {
1175
+ if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !nativeCrosshair) {
1176
+ lastNativeCrosshairBitmapRef.current = null;
1177
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
1178
+ return;
1179
+ }
1180
+
1181
+ const plotRect = extractCellRect(plotRef.current);
1182
+ const visibleRect = resolveVisibleRect(plotRef.current, renderer.terminalWidth, renderer.terminalHeight);
1183
+ const bitmapSize = computeBitmapSize(plotRect, renderer.resolution, renderer.terminalWidth, renderer.terminalHeight);
1184
+ const renderablePixelSize = getRenderablePixelSize(plotRef.current, renderer);
1185
+ const overlayPixelX = scaleLocalPixelCoordinate(
1186
+ nativeCrosshair.pixelX,
1187
+ renderablePixelSize?.pixelWidth ?? bitmapSize.pixelWidth,
1188
+ bitmapSize.pixelWidth,
1189
+ );
1190
+ const overlayPixelY = scaleLocalPixelCoordinate(
1191
+ nativeCrosshair.pixelY,
1192
+ renderablePixelSize?.pixelHeight ?? bitmapSize.pixelHeight,
1193
+ bitmapSize.pixelHeight,
1194
+ );
1195
+
1196
+ if (overlayPixelX === null || overlayPixelY === null) {
1197
+ lastNativeCrosshairBitmapRef.current = null;
1198
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
1199
+ return;
1200
+ }
1201
+
1202
+ const overlay = {
1203
+ ...nativeCrosshair,
1204
+ pixelX: overlayPixelX,
1205
+ pixelY: overlayPixelY,
1206
+ };
1207
+ const bitmapKey = buildNativeCrosshairBitmapKey(
1208
+ bitmapSize.pixelWidth,
1209
+ bitmapSize.pixelHeight,
1210
+ overlay.height,
1211
+ overlay.chartRows,
1212
+ overlay.colors.crosshairColor,
1213
+ overlay.pixelX,
1214
+ overlay.pixelY,
1215
+ );
1216
+ const cachedBitmap = lastNativeCrosshairBitmapRef.current?.key === bitmapKey
1217
+ ? lastNativeCrosshairBitmapRef.current.bitmap
1218
+ : null;
1219
+ const bitmap = cachedBitmap ?? renderNativeCrosshairOverlay(overlay, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
1220
+ if (!cachedBitmap) {
1221
+ lastNativeCrosshairBitmapRef.current = { key: bitmapKey, bitmap };
1222
+ }
1223
+
1224
+ if (!visibleRect) {
1225
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
1226
+ return;
1227
+ }
1228
+
1229
+ nativeSurfaceManager.upsertSurface({
1230
+ id: nativeCrosshairSurfaceIdRef.current,
1231
+ paneId,
1232
+ rect: plotRect,
1233
+ visibleRect,
1234
+ bitmap,
1235
+ bitmapKey,
1236
+ });
1237
+ }, [
1238
+ effectiveRenderer,
1239
+ nativeCrosshair,
1240
+ nativeSurfaceManager,
1241
+ paneId,
1242
+ renderer,
1243
+ rendererState.nativeReady,
1244
+ ]);
1245
+
1246
+ if (history.length === 0) {
1247
+ return <text fg={colors.textDim}>No price history available.</text>;
1248
+ }
1249
+
1250
+ const firstPrice = chartWindow.points[0]?.close ?? 0;
1251
+ const lastPrice = chartWindow.points[chartWindow.points.length - 1]?.close ?? 0;
1252
+ const change = lastPrice - firstPrice;
1253
+ const changePct = firstPrice ? (change / firstPrice) * 100 : 0;
1254
+ const requestedMode = projection.requestedMode;
1255
+ const showOhlcSummary = projection.effectiveMode === "candles" || projection.effectiveMode === "ohlc";
1256
+ const hasSelectionCursor = selectionCursorX !== null;
1257
+ const hasDisplayCursor = displayCursorX !== null && displayCursorY !== null;
1258
+ const displayPrice = hasSelectionCursor ? (selectionScene?.priceAtCursor ?? lastPrice) : lastPrice;
1259
+ const displayChange = hasSelectionCursor ? (selectionScene?.changeAtCursor ?? change) : change;
1260
+ const displayChangePct = hasSelectionCursor ? (selectionScene?.changePctAtCursor ?? changePct) : changePct;
1261
+ const displayDate = hasSelectionCursor || showOhlcSummary
1262
+ ? (selectionScene?.dateAtCursor ? formatDateShort(selectionScene.dateAtCursor) : null)
1263
+ : null;
1264
+ const activePoint = showOhlcSummary ? (selectionScene?.activePoint ?? null) : null;
1265
+ const axisLabels = new Map(staticResult.axisLabels.map((entry) => [entry.row, entry.label]));
1266
+ const cursorAxisLabel = hasDisplayCursor && cursorRow !== null && crosshairPrice !== null
1267
+ ? formatAxisValue(crosshairPrice, axisMode, projection.points[0]?.close ?? 0, chartCurrency)
1268
+ : null;
1269
+
1270
+ const handlePlotMove = (event: ChartMouseEvent) => {
1271
+ if (!interactive || compact) return;
1272
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1273
+ if (!localPointer) return;
1274
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
1275
+ updateDisplayCursorTarget(
1276
+ buildDisplayCursorState(
1277
+ localPointer.cellX,
1278
+ localPointer.cellY,
1279
+ plotRef.current,
1280
+ renderer,
1281
+ localPointer.pixelX,
1282
+ localPointer.pixelY,
1283
+ ),
1284
+ resolveCursorMotionKind(event, renderer),
1285
+ );
1286
+ commitSelectionCursor(selectionCursor);
1287
+ };
1288
+
1289
+ const handlePlotDown = (event: ChartMouseEvent) => {
1290
+ if (!interactive || compact) return;
1291
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1292
+ if (!localPointer) return;
1293
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
1294
+ updateDisplayCursorTarget(
1295
+ buildDisplayCursorState(
1296
+ localPointer.cellX,
1297
+ localPointer.cellY,
1298
+ plotRef.current,
1299
+ renderer,
1300
+ localPointer.pixelX,
1301
+ localPointer.pixelY,
1302
+ ),
1303
+ resolveCursorMotionKind(event, renderer),
1304
+ );
1305
+ dragRef.current = {
1306
+ startGlobalX: getGlobalMouseX(event, renderer),
1307
+ startPanOffset: viewState.panOffset,
1308
+ };
1309
+ commitSelectionCursor(selectionCursor);
1310
+ };
1311
+
1312
+ const handlePlotDrag = (event: ChartMouseEvent) => {
1313
+ if (!interactive || compact) return;
1314
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1315
+ if (localPointer) {
1316
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
1317
+ updateDisplayCursorTarget(
1318
+ buildDisplayCursorState(
1319
+ localPointer.cellX,
1320
+ localPointer.cellY,
1321
+ plotRef.current,
1322
+ renderer,
1323
+ localPointer.pixelX,
1324
+ localPointer.pixelY,
1325
+ ),
1326
+ resolveCursorMotionKind(event, renderer),
1327
+ );
1328
+ commitSelectionCursor(selectionCursor);
1329
+ }
1330
+ if (!dragRef.current) return;
1331
+
1332
+ const filtered = filterByTimeRange(baseHistory, viewState.timeRange);
1333
+ const visibleCount = getVisiblePointCount(filtered.length, viewState.zoomLevel);
1334
+ const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1335
+ const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * visibleCount);
1336
+ const nextPan = clamp(
1337
+ dragRef.current.startPanOffset - pointDelta,
1338
+ 0,
1339
+ Math.max(filtered.length - visibleCount, 0),
1340
+ );
1341
+ setViewState((current) => ({ ...current, panOffset: nextPan }));
1342
+ };
1343
+
1344
+ const handlePlotScroll = (event: ChartMouseEvent) => {
1345
+ if (!interactive || compact) return;
1346
+ const direction = event.scroll?.direction;
1347
+ if (!direction) return;
1348
+
1349
+ const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1350
+ const anchorRatio = localPointer ? localPointer.cellX / Math.max(chartWidth - 1, 1) : 0.5;
1351
+
1352
+ if (localPointer) {
1353
+ const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
1354
+ updateDisplayCursorTarget(
1355
+ buildDisplayCursorState(
1356
+ localPointer.cellX,
1357
+ localPointer.cellY,
1358
+ plotRef.current,
1359
+ renderer,
1360
+ localPointer.pixelX,
1361
+ localPointer.pixelY,
1362
+ ),
1363
+ resolveCursorMotionKind(event, renderer),
1364
+ );
1365
+ commitSelectionCursor(selectionCursor);
1366
+ }
1367
+
1368
+ if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1369
+ const shiftDirection = direction === "up" ? 1 : -1;
1370
+ const nextPan = clamp(
1371
+ viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1),
1372
+ 0,
1373
+ getMaxPanOffset(baseHistory, viewState.timeRange, viewState.zoomLevel, chartWidth),
1374
+ );
1375
+ setViewState((current) => ({ ...current, panOffset: nextPan }));
1376
+ return;
1377
+ }
1378
+
1379
+ switch (direction) {
1380
+ case "up":
1381
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory, chartWidth));
1382
+ return;
1383
+ case "down":
1384
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory, chartWidth));
1385
+ return;
1386
+ case "left":
1387
+ setViewState((current) => ({
1388
+ ...current,
1389
+ panOffset: clamp(
1390
+ current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1391
+ 0,
1392
+ getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1393
+ ),
1394
+ }));
1395
+ return;
1396
+ case "right":
1397
+ setViewState((current) => ({
1398
+ ...current,
1399
+ panOffset: clamp(
1400
+ current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1401
+ 0,
1402
+ getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1403
+ ),
1404
+ }));
1405
+ return;
1406
+ }
1407
+ };
1408
+
1409
+ const plotLines: Array<string | StyledContent> = effectiveRenderer === "kitty"
1410
+ ? blankPlotLines
1411
+ : result.lines;
1412
+ const plotContent = plotLines.map((line, index) => (
1413
+ <text key={index} content={line} />
1414
+ ));
1415
+
1416
+ const plotBox = (
1417
+ <box
1418
+ ref={plotRef}
1419
+ width={chartWidth}
1420
+ height={chartHeight}
1421
+ flexDirection="column"
1422
+ backgroundColor={chartColors.bgColor}
1423
+ onMouseMove={compact ? undefined : handlePlotMove}
1424
+ onMouseDown={compact ? undefined : handlePlotDown}
1425
+ onMouseUp={compact ? undefined : () => { dragRef.current = null; }}
1426
+ onMouseDrag={compact ? undefined : handlePlotDrag}
1427
+ onMouseDragEnd={compact ? undefined : () => { dragRef.current = null; }}
1428
+ onMouseOut={compact ? undefined : () => {
1429
+ dragRef.current = null;
1430
+ }}
1431
+ onMouseScroll={compact ? undefined : handlePlotScroll}
1432
+ >
1433
+ {plotContent}
1434
+ </box>
1435
+ );
1436
+
1437
+ const axisBox = (
1438
+ <box width={axisWidth} height={chartHeight} flexDirection="column">
1439
+ {Array.from({ length: chartHeight }, (_, row) => {
1440
+ const isCursorRow = cursorAxisLabel !== null && cursorRow === row;
1441
+ const label = isCursorRow ? cursorAxisLabel : (axisLabels.get(row) ?? null);
1442
+ return (
1443
+ <text key={row} fg={isCursorRow ? chartColors.crosshairColor : colors.textDim}>
1444
+ {formatAxisCell(label, axisWidth)}
1445
+ </text>
1446
+ );
1447
+ })}
1448
+ </box>
1449
+ );
1450
+
1451
+ if (compact) {
1452
+ return (
1453
+ <box flexDirection="column">
1454
+ <box flexDirection="row" height={chartHeight} gap={axisGap}>
1455
+ {plotBox}
1456
+ {axisBox}
1457
+ </box>
1458
+ <box height={1}>
1459
+ <text fg={colors.textDim}>{selectionScene?.timeLabels ?? staticResult.timeLabels}</text>
1460
+ </box>
1461
+ </box>
1462
+ );
1463
+ }
1464
+
1465
+ return (
1466
+ <box flexDirection="column" flexGrow={1}>
1467
+ <box flexDirection="row" gap={2} height={1}>
1468
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1469
+ {ticker?.metadata.ticker ?? ""} - {viewState.timeRange}
1470
+ </text>
1471
+ <text fg={priceColor(displayChange)}>
1472
+ {formatCurrency(displayPrice, chartCurrency)}
1473
+ </text>
1474
+ <text fg={priceColor(displayChange)}>
1475
+ {displayChange >= 0 ? "+" : ""}{displayChange.toFixed(2)} ({displayChangePct >= 0 ? "+" : ""}{displayChangePct.toFixed(2)}%)
1476
+ </text>
1477
+ {displayDate && <text fg={colors.textDim}>{displayDate}</text>}
1478
+ {showOhlcSummary && activePoint && (
1479
+ <>
1480
+ <text fg={colors.textDim}>O {formatCurrency(activePoint.open, chartCurrency)}</text>
1481
+ <text fg={colors.textDim}>H {formatCurrency(activePoint.high, chartCurrency)}</text>
1482
+ <text fg={colors.textDim}>L {formatCurrency(activePoint.low, chartCurrency)}</text>
1483
+ <text fg={colors.textDim}>C {formatCurrency(activePoint.close, chartCurrency)}</text>
1484
+ <text fg={colors.textDim}>V {formatCompact(activePoint.volume)}</text>
1485
+ </>
1486
+ )}
1487
+ </box>
1488
+
1489
+ <box flexDirection="row" height={1}>
1490
+ <box flexDirection="row" gap={1}>
1491
+ {TIME_RANGES.map((range, index) => (
1492
+ <text
1493
+ key={range}
1494
+ fg={viewState.timeRange === range ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1495
+ attributes={viewState.timeRange === range ? TextAttributes.BOLD : 0}
1496
+ onMouseDown={() => setRange(range)}
1497
+ >
1498
+ {`${index + 1}:${range}`}
1499
+ </text>
1500
+ ))}
1501
+ {viewState.zoomLevel !== 1 && (
1502
+ <text fg={colors.textDim}> zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1503
+ )}
1504
+ </box>
1505
+ <box flexGrow={1} />
1506
+ {chartWidth >= 72 ? (
1507
+ <box flexDirection="row" gap={1}>
1508
+ {CHART_RENDER_MODES.map((mode) => (
1509
+ <text
1510
+ key={mode}
1511
+ fg={requestedMode === mode ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1512
+ attributes={requestedMode === mode ? TextAttributes.BOLD : 0}
1513
+ onMouseDown={() => setRenderMode(mode)}
1514
+ >
1515
+ {MODE_CHIPS[mode]}
1516
+ </text>
1517
+ ))}
1518
+ {projection.fallbackMode && (
1519
+ <text fg={colors.textDim}>auto:{MODE_LABELS[projection.fallbackMode]}</text>
1520
+ )}
1521
+ {rendererState.nativeUnavailable && (
1522
+ <text fg={colors.textDim}>native unavailable</text>
1523
+ )}
1524
+ </box>
1525
+ ) : (
1526
+ <box flexDirection="row" gap={1}>
1527
+ <text fg={colors.textDim}>mode:{MODE_LABELS[requestedMode]}</text>
1528
+ {projection.fallbackMode && (
1529
+ <text fg={colors.textDim}>auto:{MODE_LABELS[projection.fallbackMode]}</text>
1530
+ )}
1531
+ {rendererState.nativeUnavailable && (
1532
+ <text fg={colors.textDim}>native unavailable</text>
1533
+ )}
1534
+ </box>
1535
+ )}
1536
+ </box>
1537
+
1538
+ <box flexDirection="row" height={chartHeight} gap={axisGap}>
1539
+ {plotBox}
1540
+ {axisBox}
1541
+ </box>
1542
+
1543
+ <box height={1}>
1544
+ <text fg={colors.textDim}>{selectionScene?.timeLabels ?? staticResult.timeLabels}</text>
1545
+ </box>
1546
+
1547
+ <box height={1}>
1548
+ <text fg={colors.textMuted}>
1549
+ {interactive
1550
+ ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode 1-7 range v vol Esc exit"
1551
+ : "Enter crosshair click chart to focus a/d pan +/- zoom m mode 1-7 range v volume 0 reset"}
1552
+ </text>
1553
+ </box>
1554
+ </box>
1555
+ );
1556
+ }