gloomberb 0.4.0 → 0.5.0

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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -1,43 +1,73 @@
1
- import { memo, useEffect, useMemo, useRef, useState } from "react";
1
+ import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
3
3
  import { useKeyboard, useRenderer } from "@opentui/react";
4
- import { useAppDispatch, useAppSelector, usePaneInstanceId, usePaneSettingValue, usePaneTicker } from "../../state/app-context";
4
+ import { useAppDispatch, useAppSelector, usePaneInstance, usePaneInstanceId, usePaneSettingValue, usePaneTicker } from "../../state/app-context";
5
5
  import { saveConfig } from "../../data/config-store";
6
6
  import { getSharedDataProvider } from "../../plugins/registry";
7
- import { colors, priceColor } from "../../theme/colors";
8
- import { formatCompact, formatCurrency } from "../../utils/format";
9
- import { useChartQuery } from "../../market-data/hooks";
10
- import { instrumentFromTicker } from "../../market-data/request-types";
7
+ import { colors } from "../../theme/colors";
8
+ import { formatCompact } from "../../utils/format";
9
+ import { formatMarketPriceWithCurrency } from "../../utils/market-format";
10
+ import { useChartQueries, useChartQuery } from "../../market-data/hooks";
11
+ import { instrumentFromTicker, type ChartRequest } from "../../market-data/request-types";
12
+ import { buildChartKey } from "../../market-data/selectors";
11
13
  import { usePersistChartControlSelection } from "./chart-pane-settings";
12
- import { getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
14
+ import { computeSMA, computeEMA } from "./indicators/moving-averages";
15
+ import { computeRSI, computeMACD } from "./indicators/oscillators";
16
+ import { computeBollingerBands } from "./indicators/bands";
17
+ import type { IndicatorConfig, MacdResult, OscillatorPoint, OverlayPoint } from "./indicators/types";
18
+ import type { ChartIndicatorOverlays } from "./chart-types";
13
19
  import {
20
+ applyBufferedPanExpansion,
21
+ consumeScrollPanMovement,
22
+ getDragPanWindowRatio,
23
+ getKeyboardPanCellCount,
24
+ resolveDragPanOffset,
25
+ } from "./chart-scroll";
26
+ import { getVisibleWindow, projectChartData, resolveBarSize, resolveStableOhlcProjectionOptions, type ProjectedChartPoint } from "./chart-data";
27
+ import {
28
+ buildPresetDateWindow,
14
29
  buildVisibleDateWindow,
30
+ buildVisibleDateWindowFromRange,
31
+ clampDateWindowToBounds,
15
32
  clearActivePreset,
16
- formatVisibleSpanLabel,
17
- getCanonicalZoomLevel,
33
+ getDateWindowBounds,
34
+ getMinimumDateStepMs,
35
+ getVisibleWindowForDateRange,
18
36
  getPointDates,
19
- isCanonicalPresetViewport,
37
+ needsCanonicalPresetViewportReset,
20
38
  resolveChartBodyState,
39
+ resolveCanonicalPresetViewport,
21
40
  resolvePresetSelection,
22
- resolvePresetSelectionWithResolution,
41
+ resolvePresetRangeViewport,
23
42
  resolveResolutionSelection,
24
43
  resolveStoredChartSelection,
44
+ resolveVisibleActivePreset,
45
+ sameDateWindow,
46
+ shiftDateWindow,
47
+ type ChartBodyState,
48
+ type DateWindowRange,
25
49
  } from "./chart-controller";
26
50
  import {
27
51
  buildChartResolutionSupportMap,
52
+ CHART_RESOLUTION_STEP_MS,
28
53
  clampTimeRangeToMaxRange,
29
54
  DEFAULT_TICKER_CHART_RANGE_PRESET,
30
55
  DEFAULT_TICKER_CHART_RESOLUTION,
31
56
  DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS,
32
- getBestSupportedResolutionForPreset,
57
+ getBestSupportedResolutionForVisibleWindow,
33
58
  getChartResolutionLabel,
34
- getNextFallbackResolution,
35
59
  getNextBufferRange,
60
+ getNextFallbackResolution,
36
61
  getPresetResolution,
37
62
  getSupportMaxRange,
63
+ getTimeRangeForDateWindow,
64
+ isIntradayResolution,
38
65
  isRangePresetSupported,
66
+ maxTimeRange,
39
67
  normalizeChartResolutionSupport,
40
68
  sortChartResolutions,
69
+ subtractTimeRange,
70
+ TIME_RANGE_ORDER,
41
71
  type ChartResolutionSupport,
42
72
  type ManualChartResolution,
43
73
  } from "./chart-resolution";
@@ -48,12 +78,14 @@ import {
48
78
  } from "./chart-viewport";
49
79
  import {
50
80
  buildChartScene,
81
+ formatAxisCell,
51
82
  formatDateShort,
52
- formatAxisValue,
83
+ formatCursorAxisValue,
53
84
  getActivePointIndex,
54
85
  getPointTerminalColumn,
55
86
  renderChart,
56
87
  resolveChartPalette,
88
+ resolveChartAxisWidth,
57
89
  type StyledContent,
58
90
  } from "./chart-renderer";
59
91
  import {
@@ -64,13 +96,16 @@ import {
64
96
  } from "./cursor-motion";
65
97
  import {
66
98
  CHART_RENDER_MODES,
99
+ RANGE_DAYS,
67
100
  TIME_RANGES,
68
101
  type ChartAxisMode,
69
102
  type ChartRenderMode,
70
103
  type ChartResolution,
104
+ type TimeRange,
71
105
  type ChartViewState,
72
106
  type ResolvedChartRenderer,
73
107
  } from "./chart-types";
108
+ import { ChartControlHint } from "./chart-control-hint";
74
109
  import {
75
110
  computeBitmapSize,
76
111
  intersectCellRects,
@@ -101,6 +136,20 @@ const MODE_LABELS: Record<ChartRenderMode, string> = {
101
136
  ohlc: "OHLC",
102
137
  };
103
138
 
139
+ const AXIS_MEASURE_PALETTE = resolveChartPalette({
140
+ bg: colors.bg,
141
+ border: colors.border,
142
+ borderFocused: colors.borderFocused,
143
+ text: colors.text,
144
+ textDim: colors.textDim,
145
+ positive: colors.positive,
146
+ negative: colors.negative,
147
+ }, "neutral");
148
+
149
+ const EMPTY_INDICATOR_CONFIG: IndicatorConfig = {};
150
+ const INDICATOR_RENDER_DEBOUNCE_MS = 120;
151
+ const CHART_DETAIL_REQUEST_DEBOUNCE_MS = 160;
152
+
104
153
  interface StockChartProps {
105
154
  width: number;
106
155
  height: number;
@@ -110,6 +159,9 @@ interface StockChartProps {
110
159
  axisMode?: ChartAxisMode;
111
160
  historyOverride?: PricePoint[] | null;
112
161
  currencyOverride?: string | null;
162
+ indicatorConfig?: IndicatorConfig;
163
+ showVolume?: boolean;
164
+ footerControls?: ReactNode;
113
165
  }
114
166
 
115
167
  interface ResolvedStockChartProps extends StockChartProps {
@@ -121,18 +173,62 @@ interface ResolvedStockChartProps extends StockChartProps {
121
173
  interface DragState {
122
174
  startGlobalX: number;
123
175
  startPanOffset: number;
176
+ startWindow: DateWindowRange | null;
177
+ }
178
+
179
+ interface ResolvedChartRequestPlan {
180
+ effectiveResolution: ManualChartResolution | null;
181
+ requestedWindow: DateWindowRange | null;
182
+ resolutionRequest: ChartRequest | null;
183
+ detailRequest: ChartRequest | null;
184
+ unsupportedMessage: string | null;
185
+ }
186
+
187
+ interface ResolvedRenderCandidate {
188
+ resolution: ManualChartResolution;
189
+ plan: ResolvedChartRequestPlan;
190
+ resolutionRequestKey: string | null;
191
+ detailRequestKey: string | null;
192
+ }
193
+
194
+ interface AutoRenderedView {
195
+ window: DateWindowRange;
196
+ resolution: ManualChartResolution;
197
+ data: PricePoint[];
198
+ }
199
+
200
+ interface CachedRenderedView {
201
+ window: DateWindowRange | null;
202
+ resolution: ManualChartResolution | null;
203
+ data: PricePoint[];
124
204
  }
125
205
 
206
+ interface AutoDisplayState {
207
+ bodyState: ChartBodyState<PricePoint[]>;
208
+ resolution: ManualChartResolution | null;
209
+ window: DateWindowRange | null;
210
+ }
211
+
212
+ type StockChartViewportState = Omit<ChartViewState, "resolution">;
213
+ type ResolutionAwareViewportState = StockChartViewportState & { resolution: ChartResolution };
214
+
126
215
  type PendingExpansionAction =
127
216
  | { kind: "zoom-out"; targetVisibleCount: number; anchorRatio: number }
128
217
  | { kind: "pan-left"; targetPanOffset: number }
129
218
  | null;
130
219
 
220
+ interface DeferredIndicatorState {
221
+ key: string;
222
+ overlays: ChartIndicatorOverlays;
223
+ }
224
+
131
225
  interface ChartMouseEvent {
132
226
  x: number;
133
227
  y: number;
134
228
  pixelX?: number;
135
229
  pixelY?: number;
230
+ stopPropagation?: () => void;
231
+ preventDefault?: () => void;
136
232
  modifiers: {
137
233
  shift: boolean;
138
234
  alt: boolean;
@@ -144,6 +240,22 @@ interface ChartMouseEvent {
144
240
  };
145
241
  }
146
242
 
243
+ export function resolveChartKeyboardKey(event: { name?: string; sequence?: string }): string {
244
+ const name = event.name ?? "";
245
+ const sequence = event.sequence ?? "";
246
+ const candidates = [name, sequence];
247
+
248
+ if (candidates.some((key) => key === "=" || key === "+" || key === "plus")) {
249
+ return "zoom-in";
250
+ }
251
+ if (candidates.some((key) => key === "-" || key === "_" || key === "minus")) {
252
+ return "zoom-out";
253
+ }
254
+
255
+ const key = name || sequence;
256
+ return key.length === 1 ? key.toLowerCase() : key;
257
+ }
258
+
147
259
  export interface LocalPlotPointer {
148
260
  cellX: number;
149
261
  cellY: number;
@@ -445,22 +557,558 @@ function buildBlankPlotLines(width: number, height: number): string[] {
445
557
  return Array.from({ length: height }, () => " ".repeat(width));
446
558
  }
447
559
 
448
- function formatAxisCell(label: string | null, width: number): string {
449
- if (!label) return " ".repeat(width);
450
- return label.length >= width ? label.slice(0, width) : label.padStart(width);
451
- }
452
-
453
560
  function getMaxPanOffset(history: PricePoint[], zoomLevel: number): number {
454
561
  const visibleCount = getVisiblePointCount(history.length, zoomLevel);
455
562
  return Math.max(history.length - visibleCount, 0);
456
563
  }
457
564
 
565
+ function attachViewportResolution(
566
+ view: StockChartViewportState,
567
+ resolution: ChartResolution,
568
+ ): ResolutionAwareViewportState {
569
+ return {
570
+ ...view,
571
+ resolution,
572
+ };
573
+ }
574
+
575
+ function stripViewportResolution(view: ResolutionAwareViewportState): StockChartViewportState {
576
+ const { resolution: _resolution, ...viewport } = view;
577
+ return viewport;
578
+ }
579
+
580
+ function resolveViewportPresetSelection(
581
+ view: StockChartViewportState,
582
+ presetRange: TimeRange,
583
+ supportMaxRange: TimeRange | null = null,
584
+ ): StockChartViewportState {
585
+ return stripViewportResolution(
586
+ resolvePresetSelection(attachViewportResolution(view, "auto"), presetRange, supportMaxRange),
587
+ );
588
+ }
589
+
590
+ function resolveViewportResolutionSelection(
591
+ view: StockChartViewportState,
592
+ resolution: ChartResolution,
593
+ support: ReadonlyMap<ManualChartResolution, TimeRange>,
594
+ visibleWindow: DateWindowRange | null,
595
+ ): StockChartViewportState | null {
596
+ const nextView = resolveResolutionSelection(
597
+ attachViewportResolution(view, resolution),
598
+ resolution,
599
+ support,
600
+ visibleWindow,
601
+ );
602
+ return nextView ? stripViewportResolution(nextView) : null;
603
+ }
604
+
605
+ function resolveViewportStoredSelection(
606
+ view: StockChartViewportState,
607
+ presetRange: TimeRange,
608
+ resolution: ChartResolution,
609
+ support: ReadonlyMap<ManualChartResolution, TimeRange>,
610
+ ): StockChartViewportState {
611
+ return stripViewportResolution(
612
+ resolveStoredChartSelection(attachViewportResolution(view, resolution), presetRange, resolution, support),
613
+ );
614
+ }
615
+
616
+ function clearAutoViewportState(view: StockChartViewportState): StockChartViewportState {
617
+ const clearedPreset = clearActivePreset(view);
618
+ if (clearedPreset.panOffset === 0 && clearedPreset.zoomLevel === 1) {
619
+ return clearedPreset;
620
+ }
621
+ return {
622
+ ...clearedPreset,
623
+ panOffset: 0,
624
+ zoomLevel: 1,
625
+ };
626
+ }
627
+
628
+ function buildBufferedDetailWindow(
629
+ requestedWindow: DateWindowRange | null | undefined,
630
+ maxRange: TimeRange,
631
+ bounds: DateWindowRange | null | undefined,
632
+ minimumSpanMs: number,
633
+ minimumBufferRange: TimeRange | null = null,
634
+ ): DateWindowRange | null {
635
+ if (!requestedWindow?.end) return null;
636
+
637
+ let bufferRange = maxRange;
638
+ const maxRangeIndex = TIME_RANGE_ORDER.indexOf(maxRange);
639
+ if (maxRangeIndex >= 0) {
640
+ for (const candidate of TIME_RANGE_ORDER.slice(0, maxRangeIndex + 1)) {
641
+ if (requestedWindow.start && subtractTimeRange(requestedWindow.end, candidate).getTime() <= requestedWindow.start.getTime()) {
642
+ bufferRange = candidate;
643
+ break;
644
+ }
645
+ }
646
+ }
647
+ if (minimumBufferRange) {
648
+ bufferRange = maxTimeRange(bufferRange, clampTimeRangeToMaxRange(minimumBufferRange, maxRange));
649
+ }
650
+
651
+ return clampDateWindowToBounds(
652
+ {
653
+ start: subtractTimeRange(requestedWindow.end, bufferRange),
654
+ end: requestedWindow.end,
655
+ },
656
+ bounds,
657
+ minimumSpanMs,
658
+ );
659
+ }
660
+
661
+ function getWindowPoints(points: readonly PricePoint[], requestedWindow: DateWindowRange | null | undefined): PricePoint[] {
662
+ if (!requestedWindow?.start || !requestedWindow.end || points.length === 0) return [...points];
663
+ const startMs = requestedWindow.start.getTime();
664
+ const endMs = requestedWindow.end.getTime();
665
+ return points.filter((point) => {
666
+ const pointMs = coerceChartDate(point.date as Date | string | number).getTime();
667
+ return pointMs >= startMs && pointMs <= endMs;
668
+ });
669
+ }
670
+
671
+ function getMaximumDateGapMs(dates: readonly Date[]): number {
672
+ if (dates.length < 2) return 0;
673
+
674
+ let maximumGapMs = 0;
675
+ for (let index = 1; index < dates.length; index += 1) {
676
+ const previousDate = dates[index - 1];
677
+ const currentDate = dates[index];
678
+ if (!previousDate || !currentDate) continue;
679
+ maximumGapMs = Math.max(maximumGapMs, currentDate.getTime() - previousDate.getTime());
680
+ }
681
+
682
+ return maximumGapMs;
683
+ }
684
+
685
+ function isSeriesCompatibleWithRequest(
686
+ points: readonly PricePoint[] | null | undefined,
687
+ requestedWindow: DateWindowRange | null | undefined,
688
+ resolution: ManualChartResolution | null,
689
+ ): boolean {
690
+ if (!requestedWindow?.start || !requestedWindow.end || !resolution || !points?.length) return false;
691
+
692
+ const windowPoints = getWindowPoints(points, requestedWindow);
693
+ if (windowPoints.length === 0) return false;
694
+
695
+ const expectedStepMs = CHART_RESOLUTION_STEP_MS[resolution];
696
+ if (!expectedStepMs) return false;
697
+
698
+ const windowStartMs = requestedWindow.start.getTime();
699
+ const windowEndMs = requestedWindow.end.getTime();
700
+ const requestedSpanMs = Math.max(windowEndMs - windowStartMs, 0);
701
+ const allDates = getPointDates(points);
702
+ const windowDates = getPointDates(windowPoints);
703
+ const firstPointMs = windowDates[0]!.getTime();
704
+ const lastPointMs = windowDates[windowDates.length - 1]!.getTime();
705
+ const startGapMs = Math.max(firstPointMs - windowStartMs, 0);
706
+ const endGapMs = Math.max(windowEndMs - lastPointMs, 0);
707
+ const sessionGapAllowanceMs = isIntradayResolution(resolution)
708
+ ? Math.max(expectedStepMs * 8, getMaximumDateGapMs(allDates) + expectedStepMs)
709
+ : expectedStepMs * 8;
710
+
711
+ if (windowPoints.length === 1) {
712
+ return requestedSpanMs <= expectedStepMs * 2
713
+ && startGapMs <= sessionGapAllowanceMs
714
+ && endGapMs <= sessionGapAllowanceMs;
715
+ }
716
+
717
+ const actualStepMs = getMinimumDateStepMs(windowDates);
718
+ return actualStepMs <= expectedStepMs * 4
719
+ && startGapMs <= sessionGapAllowanceMs
720
+ && endGapMs <= sessionGapAllowanceMs;
721
+ }
722
+
723
+ function getExpectedPointCountForWindow(
724
+ requestedWindow: DateWindowRange | null | undefined,
725
+ resolution: ManualChartResolution | null,
726
+ ): number {
727
+ if (!requestedWindow?.start || !requestedWindow.end || !resolution) return 0;
728
+ const expectedStepMs = CHART_RESOLUTION_STEP_MS[resolution];
729
+ if (!expectedStepMs) return 0;
730
+
731
+ const spanMs = Math.max(requestedWindow.end.getTime() - requestedWindow.start.getTime(), 0);
732
+ return Math.max(spanMs / expectedStepMs, 1);
733
+ }
734
+
735
+ function getMinimumAutoRenderablePointCount(
736
+ requestedWindow: DateWindowRange | null | undefined,
737
+ targetResolution: ManualChartResolution | null,
738
+ ): number {
739
+ const expectedPointCount = getExpectedPointCountForWindow(requestedWindow, targetResolution);
740
+ if (!Number.isFinite(expectedPointCount) || expectedPointCount <= 0) return 2;
741
+ return clamp(Math.ceil(expectedPointCount / 24), 2, 8);
742
+ }
743
+
744
+ function isSeriesAcceptedForRequest(
745
+ points: readonly PricePoint[] | null | undefined,
746
+ requestedWindow: DateWindowRange | null | undefined,
747
+ resolution: ManualChartResolution | null,
748
+ options?: {
749
+ requireAutoDensity?: boolean;
750
+ targetResolution?: ManualChartResolution | null;
751
+ },
752
+ ): boolean {
753
+ if (!isSeriesCompatibleWithRequest(points, requestedWindow, resolution)) return false;
754
+ if (!options?.requireAutoDensity) return true;
755
+
756
+ const windowPoints = getWindowPoints(points ?? [], requestedWindow);
757
+ if (windowPoints.length === 0) return false;
758
+
759
+ const targetResolution = options.targetResolution ?? resolution;
760
+ const minimumPointCount = getMinimumAutoRenderablePointCount(requestedWindow, targetResolution);
761
+ return windowPoints.length >= minimumPointCount;
762
+ }
763
+
764
+ function canRenderAutoViewForWindow(
765
+ render: AutoRenderedView | null,
766
+ requestedWindow: DateWindowRange | null | undefined,
767
+ ): render is AutoRenderedView {
768
+ if (!render || !requestedWindow?.start || !requestedWindow.end) return false;
769
+ return getWindowPoints(render.data, requestedWindow).length > 0;
770
+ }
771
+
772
+ export function resolveAutoDisplayState(options: {
773
+ shouldUseRenderedAutoView: boolean;
774
+ renderedAutoView: AutoRenderedView | null;
775
+ isRenderedAutoViewUpdating: boolean;
776
+ plannedRenderBodyState: ChartBodyState<PricePoint[]>;
777
+ plannedResolvedManualResolution: ManualChartResolution | null;
778
+ plannedDateWindow: DateWindowRange | null;
779
+ }): AutoDisplayState {
780
+ const {
781
+ shouldUseRenderedAutoView,
782
+ renderedAutoView,
783
+ isRenderedAutoViewUpdating,
784
+ plannedRenderBodyState,
785
+ plannedResolvedManualResolution,
786
+ plannedDateWindow,
787
+ } = options;
788
+
789
+ if (shouldUseRenderedAutoView && renderedAutoView) {
790
+ return {
791
+ bodyState: {
792
+ data: renderedAutoView.data,
793
+ blocking: false,
794
+ updating: isRenderedAutoViewUpdating,
795
+ emptyMessage: null,
796
+ errorMessage: null,
797
+ },
798
+ resolution: renderedAutoView.resolution,
799
+ window: renderedAutoView.window,
800
+ };
801
+ }
802
+
803
+ return {
804
+ bodyState: plannedRenderBodyState,
805
+ resolution: plannedResolvedManualResolution,
806
+ window: plannedDateWindow,
807
+ };
808
+ }
809
+
810
+ export function resolveVisibleChartDateWindow(
811
+ points: readonly Pick<PricePoint, "date">[],
812
+ preferredWindow: DateWindowRange | null | undefined,
813
+ ): DateWindowRange | null {
814
+ return getDateWindowBounds(getPointDates(points)) ?? preferredWindow ?? null;
815
+ }
816
+
817
+ function buildDateWindowFromIndices(dates: readonly Date[], startIdx: number, endIdx: number): DateWindowRange | null {
818
+ if (dates.length === 0 || endIdx <= startIdx) return null;
819
+ return {
820
+ start: dates[startIdx] ?? null,
821
+ end: dates[endIdx - 1] ?? null,
822
+ };
823
+ }
824
+
825
+ function inferAutoZoomStepMs(
826
+ dates: readonly Date[],
827
+ window: Pick<ReturnType<typeof buildVisibleDateWindowFromRange>, "startIdx" | "endIdx">,
828
+ fallbackWindow: DateWindowRange,
829
+ ): number {
830
+ const visibleCount = window.endIdx - window.startIdx;
831
+ if (visibleCount >= 2) {
832
+ const start = dates[window.startIdx]?.getTime() ?? null;
833
+ const end = dates[window.endIdx - 1]?.getTime() ?? null;
834
+ if (start !== null && end !== null && end > start) {
835
+ return Math.max((end - start) / Math.max(visibleCount - 1, 1), 1);
836
+ }
837
+ }
838
+
839
+ if (dates.length >= 2) {
840
+ return Math.max(getMinimumDateStepMs(dates), 1);
841
+ }
842
+
843
+ if (fallbackWindow.start && fallbackWindow.end) {
844
+ return Math.max(fallbackWindow.end.getTime() - fallbackWindow.start.getTime(), 1);
845
+ }
846
+
847
+ return 1;
848
+ }
849
+
850
+ export function resolveAutoZoomWindow(options: {
851
+ historyPoints: readonly PricePoint[];
852
+ boundsDates: readonly Date[];
853
+ currentWindow: DateWindowRange | null | undefined;
854
+ direction: "in" | "out";
855
+ anchorRatio: number;
856
+ zoomFactor?: number;
857
+ }): DateWindowRange | null {
858
+ const {
859
+ historyPoints,
860
+ boundsDates,
861
+ currentWindow,
862
+ direction,
863
+ anchorRatio,
864
+ zoomFactor = 1.5,
865
+ } = options;
866
+
867
+ if (!currentWindow?.start || !currentWindow.end) return currentWindow ?? null;
868
+
869
+ const historyDates = getPointDates(historyPoints);
870
+ if (historyDates.length === 0) return currentWindow;
871
+
872
+ const currentHistoryWindow = buildVisibleDateWindowFromRange(historyDates, currentWindow, 0);
873
+ const canExpandWithinHistory = direction === "out"
874
+ && (
875
+ currentHistoryWindow.startIdx > 0
876
+ || currentHistoryWindow.endIdx < historyDates.length
877
+ );
878
+
879
+ const navigationDates = direction === "out" && !canExpandWithinHistory && boundsDates.length > 0
880
+ ? boundsDates
881
+ : historyDates;
882
+ const currentNavigationWindow = buildVisibleDateWindowFromRange(navigationDates, currentWindow, 0);
883
+ const currentVisibleCount = currentNavigationWindow.endIdx - currentNavigationWindow.startIdx;
884
+
885
+ if (currentVisibleCount <= 0) {
886
+ return currentWindow;
887
+ }
888
+
889
+ const ratio = clamp(anchorRatio, 0, 1);
890
+ let targetVisibleCount: number;
891
+ if (direction === "in") {
892
+ if (currentVisibleCount <= 2) {
893
+ const currentSpanMs = Math.max(currentWindow.end.getTime() - currentWindow.start.getTime(), 1);
894
+ const targetSpanMs = Math.max(currentSpanMs / zoomFactor, 1);
895
+ const anchorMs = currentWindow.start.getTime() + currentSpanMs * ratio;
896
+ const nextStartMs = anchorMs - targetSpanMs * ratio;
897
+ return {
898
+ start: new Date(nextStartMs),
899
+ end: new Date(nextStartMs + targetSpanMs),
900
+ };
901
+ }
902
+ targetVisibleCount = Math.max(2, Math.floor(currentVisibleCount / zoomFactor));
903
+ if (targetVisibleCount >= currentVisibleCount) {
904
+ targetVisibleCount = currentVisibleCount - 1;
905
+ }
906
+ } else {
907
+ targetVisibleCount = Math.ceil(currentVisibleCount * zoomFactor);
908
+ if (targetVisibleCount <= currentVisibleCount) {
909
+ targetVisibleCount = currentVisibleCount + 1;
910
+ }
911
+
912
+ if (targetVisibleCount > navigationDates.length) {
913
+ const stepMs = inferAutoZoomStepMs(navigationDates, currentNavigationWindow, currentWindow);
914
+ const targetSpanMs = Math.max(stepMs * Math.max(targetVisibleCount - 1, 1), stepMs);
915
+ const currentSpanMs = Math.max(currentWindow.end.getTime() - currentWindow.start.getTime(), stepMs);
916
+ const anchorMs = currentWindow.start.getTime() + currentSpanMs * ratio;
917
+ const nextStartMs = anchorMs - targetSpanMs * ratio;
918
+
919
+ return {
920
+ start: new Date(nextStartMs),
921
+ end: new Date(nextStartMs + targetSpanMs),
922
+ };
923
+ }
924
+ }
925
+
926
+ const anchorIndex = currentNavigationWindow.startIdx + ratio * Math.max(currentVisibleCount - 1, 0);
927
+ let nextStartIdx = clamp(
928
+ Math.round(anchorIndex - ratio * Math.max(targetVisibleCount - 1, 0)),
929
+ 0,
930
+ Math.max(navigationDates.length - targetVisibleCount, 0),
931
+ );
932
+ let nextEndIdx = Math.min(nextStartIdx + targetVisibleCount, navigationDates.length);
933
+
934
+ if (nextStartIdx === currentNavigationWindow.startIdx && nextEndIdx === currentNavigationWindow.endIdx) {
935
+ if (direction === "in" && currentVisibleCount > 2) {
936
+ nextStartIdx = Math.min(currentNavigationWindow.startIdx + 1, currentNavigationWindow.endIdx - 2);
937
+ nextEndIdx = currentNavigationWindow.endIdx;
938
+ } else if (direction === "out" && currentNavigationWindow.startIdx > 0) {
939
+ nextStartIdx = currentNavigationWindow.startIdx - 1;
940
+ nextEndIdx = currentNavigationWindow.endIdx;
941
+ } else if (direction === "out" && currentNavigationWindow.endIdx < navigationDates.length) {
942
+ nextStartIdx = currentNavigationWindow.startIdx;
943
+ nextEndIdx = currentNavigationWindow.endIdx + 1;
944
+ }
945
+ }
946
+
947
+ return buildDateWindowFromIndices(navigationDates, nextStartIdx, nextEndIdx) ?? currentWindow;
948
+ }
949
+
950
+ function isDateWindowReachableByAnchoredRange(
951
+ window: DateWindowRange | null | undefined,
952
+ latestDate: Date | null | undefined,
953
+ range: TimeRange,
954
+ ): boolean {
955
+ if (!window?.start || !window.end || !latestDate) return false;
956
+ const threshold = subtractTimeRange(latestDate, range).getTime();
957
+ return window.start.getTime() >= threshold && window.end.getTime() <= latestDate.getTime();
958
+ }
959
+
960
+ function getMinimumAnchoredBufferRange(
961
+ window: DateWindowRange | null | undefined,
962
+ latestDate: Date | null | undefined,
963
+ maxRange: TimeRange,
964
+ ): TimeRange | null {
965
+ for (const candidate of TIME_RANGE_ORDER) {
966
+ if (candidate === "ALL" || TIME_RANGE_ORDER.indexOf(candidate) > TIME_RANGE_ORDER.indexOf(maxRange)) break;
967
+ if (isDateWindowReachableByAnchoredRange(window, latestDate, candidate)) {
968
+ return candidate;
969
+ }
970
+ }
971
+
972
+ return isDateWindowReachableByAnchoredRange(window, latestDate, maxRange)
973
+ ? maxRange
974
+ : null;
975
+ }
976
+
977
+ function buildResolvedChartRequestPlan(options: {
978
+ compact?: boolean;
979
+ historyOverride?: PricePoint[] | null;
980
+ instrumentRef: ReturnType<typeof instrumentFromTicker>;
981
+ requestedWindow: DateWindowRange | null;
982
+ effectiveResolution: ChartResolution;
983
+ effectiveManualResolution: ManualChartResolution | null;
984
+ bounds: DateWindowRange | null;
985
+ bufferRange: TimeRange;
986
+ minimumBufferRange?: TimeRange | null;
987
+ support: ReadonlyMap<ManualChartResolution, TimeRange>;
988
+ hasResolutionHistoryApi: boolean;
989
+ hasDetailedHistoryApi: boolean;
990
+ minimumSpanMs: number;
991
+ }): ResolvedChartRequestPlan {
992
+ const {
993
+ compact,
994
+ historyOverride,
995
+ instrumentRef,
996
+ requestedWindow,
997
+ effectiveResolution,
998
+ effectiveManualResolution,
999
+ bounds,
1000
+ bufferRange,
1001
+ minimumBufferRange = null,
1002
+ support,
1003
+ hasResolutionHistoryApi,
1004
+ hasDetailedHistoryApi,
1005
+ minimumSpanMs,
1006
+ } = options;
1007
+
1008
+ if (historyOverride) {
1009
+ return {
1010
+ effectiveResolution: effectiveManualResolution,
1011
+ requestedWindow,
1012
+ resolutionRequest: null,
1013
+ detailRequest: null,
1014
+ unsupportedMessage: null,
1015
+ };
1016
+ }
1017
+
1018
+ if (compact || !instrumentRef || !requestedWindow?.start || !requestedWindow.end || !effectiveManualResolution) {
1019
+ return {
1020
+ effectiveResolution: effectiveManualResolution,
1021
+ requestedWindow,
1022
+ resolutionRequest: null,
1023
+ detailRequest: null,
1024
+ unsupportedMessage: null,
1025
+ };
1026
+ }
1027
+
1028
+ const maxRange = getSupportMaxRange(support, effectiveManualResolution);
1029
+ const latestDate = bounds?.end ?? null;
1030
+ let resolutionRequest: ChartRequest | null = null;
1031
+ if (hasResolutionHistoryApi && maxRange && latestDate) {
1032
+ const baseAnchoredRange = getMinimumAnchoredBufferRange(requestedWindow, latestDate, maxRange);
1033
+ const minimumAnchoredRange = minimumBufferRange
1034
+ ? clampTimeRangeToMaxRange(minimumBufferRange, maxRange)
1035
+ : null;
1036
+ const anchoredRange = baseAnchoredRange && minimumAnchoredRange
1037
+ ? maxTimeRange(baseAnchoredRange, minimumAnchoredRange)
1038
+ : baseAnchoredRange;
1039
+ if (anchoredRange) {
1040
+ resolutionRequest = {
1041
+ instrument: instrumentRef,
1042
+ bufferRange: anchoredRange,
1043
+ granularity: "resolution",
1044
+ resolution: effectiveManualResolution,
1045
+ };
1046
+ }
1047
+ }
1048
+
1049
+ let detailRequest: ChartRequest | null = null;
1050
+ if (hasDetailedHistoryApi && maxRange) {
1051
+ const bufferedWindow = buildBufferedDetailWindow(requestedWindow, maxRange, bounds, minimumSpanMs, minimumBufferRange);
1052
+ if (bufferedWindow?.start && bufferedWindow.end) {
1053
+ detailRequest = {
1054
+ instrument: instrumentRef,
1055
+ bufferRange,
1056
+ granularity: "detail",
1057
+ startDate: bufferedWindow.start,
1058
+ endDate: bufferedWindow.end,
1059
+ barSize: effectiveManualResolution,
1060
+ };
1061
+ }
1062
+ }
1063
+
1064
+ return {
1065
+ effectiveResolution: effectiveManualResolution,
1066
+ requestedWindow,
1067
+ resolutionRequest,
1068
+ detailRequest,
1069
+ unsupportedMessage: effectiveResolution !== "auto" && !resolutionRequest && !detailRequest
1070
+ ? `No ${getChartResolutionLabel(effectiveManualResolution)} history available for this window.`
1071
+ : null,
1072
+ };
1073
+ }
1074
+
1075
+ function buildResolutionFallbackChain(
1076
+ startResolution: ManualChartResolution | null,
1077
+ range: TimeRange,
1078
+ support: ReadonlyMap<ManualChartResolution, TimeRange>,
1079
+ ): ManualChartResolution[] {
1080
+ if (!startResolution) return [];
1081
+
1082
+ const chain: ManualChartResolution[] = [];
1083
+ let current: ManualChartResolution | null = startResolution;
1084
+ while (current && !chain.includes(current)) {
1085
+ chain.push(current);
1086
+ const nextFallback = getNextFallbackResolution(range, current, support);
1087
+ current = nextFallback && nextFallback !== "auto" ? nextFallback : null;
1088
+ }
1089
+
1090
+ return chain;
1091
+ }
1092
+
1093
+ function dedupeChartRequests(requests: Array<ChartRequest | null | undefined>): ChartRequest[] {
1094
+ const uniqueRequests: ChartRequest[] = [];
1095
+ const seenKeys = new Set<string>();
1096
+ for (const request of requests) {
1097
+ if (!request) continue;
1098
+ const key = buildChartKey(request);
1099
+ if (seenKeys.has(key)) continue;
1100
+ seenKeys.add(key);
1101
+ uniqueRequests.push(request);
1102
+ }
1103
+ return uniqueRequests;
1104
+ }
1105
+
458
1106
  function applyZoomAroundAnchor(
459
- view: ChartViewState,
1107
+ view: StockChartViewportState,
460
1108
  nextZoomLevel: number,
461
1109
  anchorRatio: number,
462
1110
  history: PricePoint[],
463
- ): ChartViewState {
1111
+ ): StockChartViewportState {
464
1112
  if (history.length === 0) return view;
465
1113
 
466
1114
  const nextZoom = resolveAnchoredChartZoom(
@@ -485,6 +1133,7 @@ function buildNativeBitmapKey(
485
1133
  mode: ChartRenderMode,
486
1134
  showVolume: boolean,
487
1135
  paletteId: string,
1136
+ indicatorKey: string,
488
1137
  ): string {
489
1138
  const fingerprint = points
490
1139
  .map((point) => {
@@ -492,7 +1141,7 @@ function buildNativeBitmapKey(
492
1141
  return `${date}:${point.open}:${point.high}:${point.low}:${point.close}:${point.volume ?? 0}`;
493
1142
  })
494
1143
  .join("|");
495
- return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, fingerprint].join("::");
1144
+ return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, indicatorKey, fingerprint].join("::");
496
1145
  }
497
1146
 
498
1147
  function buildNativeCrosshairBitmapKey(
@@ -551,6 +1200,270 @@ function resolveSelectionCursor(
551
1200
  };
552
1201
  }
553
1202
 
1203
+ const INDICATOR_COLORS = [
1204
+ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8",
1205
+ ];
1206
+
1207
+ export function getIndicatorWarmupPeriod(config: IndicatorConfig): number {
1208
+ const periods = [
1209
+ ...(config.sma ?? []),
1210
+ ...(config.ema ?? []),
1211
+ config.bollinger?.period,
1212
+ config.rsi ?? undefined,
1213
+ config.macd ? config.macd.slow + config.macd.signal : undefined,
1214
+ ].filter((period): period is number => typeof period === "number" && Number.isFinite(period) && period > 0);
1215
+
1216
+ return periods.length > 0 ? Math.max(...periods) : 0;
1217
+ }
1218
+
1219
+ export function resolveIndicatorBufferRange(
1220
+ visibleRange: TimeRange,
1221
+ currentBufferRange: TimeRange,
1222
+ config: IndicatorConfig,
1223
+ ): TimeRange {
1224
+ const warmupPeriod = getIndicatorWarmupPeriod(config);
1225
+ if (warmupPeriod <= 0) return currentBufferRange;
1226
+
1227
+ const visibleDays = RANGE_DAYS[visibleRange];
1228
+ if (!Number.isFinite(visibleDays)) return currentBufferRange;
1229
+
1230
+ const targetDays = visibleDays + warmupPeriod;
1231
+ const warmupRange = TIME_RANGE_ORDER.find((range) => RANGE_DAYS[range] >= targetDays) ?? "ALL";
1232
+ return maxTimeRange(currentBufferRange, warmupRange);
1233
+ }
1234
+
1235
+ function getPricePointTime(point: Pick<PricePoint, "date"> | Pick<ProjectedChartPoint, "date">): number {
1236
+ return coerceChartDate(point.date as Date | string | number).getTime();
1237
+ }
1238
+
1239
+ function buildProjectedSourceIndexMap(
1240
+ sourcePoints: readonly PricePoint[],
1241
+ projectedPoints: readonly ProjectedChartPoint[],
1242
+ sourceIndexOffset = 0,
1243
+ ): Map<number, number> {
1244
+ const sourceIndexByTime = new Map<number, number>();
1245
+ sourcePoints.forEach((point, index) => {
1246
+ sourceIndexByTime.set(getPricePointTime(point), sourceIndexOffset + index);
1247
+ });
1248
+
1249
+ const projectedIndexBySourceIndex = new Map<number, number>();
1250
+ projectedPoints.forEach((point, projectedIndex) => {
1251
+ const sourceIndex = sourceIndexByTime.get(getPricePointTime(point));
1252
+ if (sourceIndex !== undefined) {
1253
+ projectedIndexBySourceIndex.set(sourceIndex, projectedIndex);
1254
+ }
1255
+ });
1256
+ return projectedIndexBySourceIndex;
1257
+ }
1258
+
1259
+ function findPointBySourceIndex<TPoint extends { index: number }>(
1260
+ points: readonly TPoint[],
1261
+ sourceIndex: number,
1262
+ ): TPoint | null {
1263
+ let low = 0;
1264
+ let high = points.length - 1;
1265
+
1266
+ while (low <= high) {
1267
+ const mid = Math.floor((low + high) / 2);
1268
+ const point = points[mid]!;
1269
+ if (point.index === sourceIndex) return point;
1270
+ if (point.index < sourceIndex) {
1271
+ low = mid + 1;
1272
+ } else {
1273
+ high = mid - 1;
1274
+ }
1275
+ }
1276
+
1277
+ return null;
1278
+ }
1279
+
1280
+ function reindexOverlayPoints(
1281
+ points: readonly OverlayPoint[],
1282
+ projectedIndexBySourceIndex: ReadonlyMap<number, number>,
1283
+ ): OverlayPoint[] {
1284
+ const reindexed: OverlayPoint[] = [];
1285
+ projectedIndexBySourceIndex.forEach((projectedIndex, sourceIndex) => {
1286
+ const point = findPointBySourceIndex(points, sourceIndex);
1287
+ if (point) reindexed.push({ index: projectedIndex, value: point.value });
1288
+ });
1289
+ return reindexed;
1290
+ }
1291
+
1292
+ function reindexOscillatorPoints(
1293
+ points: readonly OscillatorPoint[],
1294
+ projectedIndexBySourceIndex: ReadonlyMap<number, number>,
1295
+ ): OscillatorPoint[] {
1296
+ const reindexed: OscillatorPoint[] = [];
1297
+ projectedIndexBySourceIndex.forEach((projectedIndex, sourceIndex) => {
1298
+ const point = findPointBySourceIndex(points, sourceIndex);
1299
+ if (point) reindexed.push({ index: projectedIndex, value: point.value });
1300
+ });
1301
+ return reindexed;
1302
+ }
1303
+
1304
+ function reindexMacdResult(
1305
+ result: MacdResult | null,
1306
+ projectedIndexBySourceIndex: ReadonlyMap<number, number>,
1307
+ ): MacdResult | null {
1308
+ if (!result) return null;
1309
+ return {
1310
+ macd: reindexOscillatorPoints(result.macd, projectedIndexBySourceIndex),
1311
+ signal: reindexOscillatorPoints(result.signal, projectedIndexBySourceIndex),
1312
+ histogram: reindexOscillatorPoints(result.histogram, projectedIndexBySourceIndex),
1313
+ };
1314
+ }
1315
+
1316
+ function computeIndicatorOverlays(
1317
+ closes: number[],
1318
+ config: IndicatorConfig,
1319
+ ): ChartIndicatorOverlays {
1320
+ let colorIdx = 0;
1321
+ const nextColor = () => INDICATOR_COLORS[colorIdx++ % INDICATOR_COLORS.length]!;
1322
+
1323
+ const smaLines = (config.sma ?? []).map((period) => ({
1324
+ period,
1325
+ points: computeSMA(closes, period),
1326
+ color: nextColor(),
1327
+ }));
1328
+
1329
+ const emaLines = (config.ema ?? []).map((period) => ({
1330
+ period,
1331
+ points: computeEMA(closes, period),
1332
+ color: nextColor(),
1333
+ }));
1334
+
1335
+ const bollinger = config.bollinger
1336
+ ? { ...computeBollingerBands(closes, config.bollinger.period, config.bollinger.stdDev), color: nextColor() }
1337
+ : null;
1338
+
1339
+ const rsi = config.rsi ? computeRSI(closes, config.rsi) : null;
1340
+ const macd = config.macd
1341
+ ? computeMACD(closes, config.macd.fast, config.macd.slow, config.macd.signal)
1342
+ : null;
1343
+
1344
+ return { smaLines, emaLines, bollinger, rsi, macd };
1345
+ }
1346
+
1347
+ export function computeProjectedIndicatorOverlays(
1348
+ sourcePoints: readonly PricePoint[],
1349
+ projectedPoints: readonly ProjectedChartPoint[],
1350
+ config: IndicatorConfig,
1351
+ ): ChartIndicatorOverlays {
1352
+ const closes = sourcePoints.map((point) => point.close);
1353
+ const overlays = computeIndicatorOverlays(closes, config);
1354
+ return reindexIndicatorOverlaysForProjection(overlays, sourcePoints, projectedPoints);
1355
+ }
1356
+
1357
+ export function reindexIndicatorOverlaysForProjection(
1358
+ overlays: ChartIndicatorOverlays,
1359
+ sourcePoints: readonly PricePoint[],
1360
+ projectedPoints: readonly ProjectedChartPoint[],
1361
+ sourceIndexOffset = 0,
1362
+ ): ChartIndicatorOverlays {
1363
+ const projectedIndexBySourceIndex = buildProjectedSourceIndexMap(
1364
+ sourcePoints,
1365
+ projectedPoints,
1366
+ sourceIndexOffset,
1367
+ );
1368
+
1369
+ return {
1370
+ smaLines: overlays.smaLines.map((line) => ({
1371
+ ...line,
1372
+ points: reindexOverlayPoints(line.points, projectedIndexBySourceIndex),
1373
+ })),
1374
+ emaLines: overlays.emaLines.map((line) => ({
1375
+ ...line,
1376
+ points: reindexOverlayPoints(line.points, projectedIndexBySourceIndex),
1377
+ })),
1378
+ bollinger: overlays.bollinger
1379
+ ? {
1380
+ ...overlays.bollinger,
1381
+ upper: reindexOverlayPoints(overlays.bollinger.upper, projectedIndexBySourceIndex),
1382
+ middle: reindexOverlayPoints(overlays.bollinger.middle, projectedIndexBySourceIndex),
1383
+ lower: reindexOverlayPoints(overlays.bollinger.lower, projectedIndexBySourceIndex),
1384
+ }
1385
+ : null,
1386
+ rsi: overlays.rsi ? reindexOscillatorPoints(overlays.rsi, projectedIndexBySourceIndex) : null,
1387
+ macd: reindexMacdResult(overlays.macd, projectedIndexBySourceIndex),
1388
+ };
1389
+ }
1390
+
1391
+ function buildIndicatorRenderKey(indicators: ChartIndicatorOverlays | null): string {
1392
+ if (!indicators) return "none";
1393
+
1394
+ const pointKey = (points: readonly OverlayPoint[]) => {
1395
+ const first = points[0];
1396
+ const last = points[points.length - 1];
1397
+ return first && last
1398
+ ? `${points.length}:${first.index}:${first.value.toFixed(6)}:${last.index}:${last.value.toFixed(6)}`
1399
+ : "0";
1400
+ };
1401
+
1402
+ return [
1403
+ indicators.smaLines.map((line) => `sma:${line.period}:${line.color}:${pointKey(line.points)}`).join(","),
1404
+ indicators.emaLines.map((line) => `ema:${line.period}:${line.color}:${pointKey(line.points)}`).join(","),
1405
+ indicators.bollinger
1406
+ ? [
1407
+ `bb:${indicators.bollinger.color}`,
1408
+ pointKey(indicators.bollinger.upper),
1409
+ pointKey(indicators.bollinger.middle),
1410
+ pointKey(indicators.bollinger.lower),
1411
+ ].join(":")
1412
+ : "",
1413
+ ].join("|");
1414
+ }
1415
+
1416
+ function buildIndicatorConfigKey(config: IndicatorConfig): string {
1417
+ return [
1418
+ `sma:${(config.sma ?? []).join(",")}`,
1419
+ `ema:${(config.ema ?? []).join(",")}`,
1420
+ config.bollinger ? `bb:${config.bollinger.period}:${config.bollinger.stdDev}` : "bb:",
1421
+ `rsi:${config.rsi ?? ""}`,
1422
+ config.macd ? `macd:${config.macd.fast}:${config.macd.slow}:${config.macd.signal}` : "macd:",
1423
+ ].join("|");
1424
+ }
1425
+
1426
+ function buildIndicatorSourceKey(
1427
+ sourcePoints: readonly PricePoint[],
1428
+ config: IndicatorConfig,
1429
+ ): string {
1430
+ const first = sourcePoints[0];
1431
+ const last = sourcePoints[sourcePoints.length - 1];
1432
+ return [
1433
+ buildIndicatorConfigKey(config),
1434
+ sourcePoints.length,
1435
+ first ? getPricePointTime(first) : "",
1436
+ first?.close ?? "",
1437
+ last ? getPricePointTime(last) : "",
1438
+ last?.close ?? "",
1439
+ ].join(":");
1440
+ }
1441
+
1442
+ function buildIndicatorProjectionKey(options: {
1443
+ sourceKey: string;
1444
+ sourcePoints: readonly PricePoint[];
1445
+ sourceIndexOffset: number;
1446
+ projectedPoints: readonly ProjectedChartPoint[];
1447
+ mode: ChartRenderMode;
1448
+ }): string {
1449
+ const firstSource = options.sourcePoints[0];
1450
+ const lastSource = options.sourcePoints[options.sourcePoints.length - 1];
1451
+ const firstProjected = options.projectedPoints[0];
1452
+ const lastProjected = options.projectedPoints[options.projectedPoints.length - 1];
1453
+
1454
+ return [
1455
+ options.sourceKey,
1456
+ options.sourceIndexOffset,
1457
+ options.sourcePoints.length,
1458
+ firstSource ? getPricePointTime(firstSource) : "",
1459
+ lastSource ? getPricePointTime(lastSource) : "",
1460
+ options.projectedPoints.length,
1461
+ firstProjected ? getPricePointTime(firstProjected) : "",
1462
+ lastProjected ? getPricePointTime(lastProjected) : "",
1463
+ options.mode,
1464
+ ].join(":");
1465
+ }
1466
+
554
1467
  export function StockChart(props: StockChartProps) {
555
1468
  const { symbol, ticker, financials } = usePaneTicker();
556
1469
  return <ResolvedStockChart {...props} symbol={symbol} ticker={ticker} financials={financials} />;
@@ -565,6 +1478,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
565
1478
  axisMode = "price",
566
1479
  historyOverride = null,
567
1480
  currencyOverride = null,
1481
+ indicatorConfig: indicatorConfigOverride,
1482
+ showVolume: showVolumeOverride,
1483
+ footerControls,
568
1484
  symbol,
569
1485
  ticker,
570
1486
  financials,
@@ -573,30 +1489,51 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
573
1489
  const dispatch = useAppDispatch();
574
1490
  const config = useAppSelector((state) => state.config);
575
1491
  const paneId = usePaneInstanceId();
1492
+ const pane = usePaneInstance();
576
1493
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
577
1494
  const defaultRenderMode = config.chartPreferences.defaultRenderMode;
578
1495
  const preferredRenderer = config.chartPreferences.renderer;
579
1496
  const [storedRangePreset] = usePaneSettingValue("chartRangePreset", DEFAULT_TICKER_CHART_RANGE_PRESET);
580
1497
  const [storedResolution] = usePaneSettingValue<ChartResolution>("chartResolution", DEFAULT_TICKER_CHART_RESOLUTION);
581
1498
  const persistChartControls = usePersistChartControlSelection("chartRangePreset");
582
- const [viewState, setViewState] = useState<ChartViewState>({
1499
+ const [viewState, setViewState] = useState<StockChartViewportState>({
583
1500
  presetRange: compact ? "1Y" : storedRangePreset,
584
1501
  bufferRange: compact ? "1Y" : storedRangePreset,
585
1502
  activePreset: compact ? null : storedRangePreset,
586
- resolution: compact ? "auto" : storedResolution,
587
1503
  panOffset: 0,
588
1504
  zoomLevel: 1,
589
1505
  cursorX: null,
590
1506
  cursorY: null,
591
1507
  renderMode: defaultRenderMode,
592
1508
  });
1509
+ // Read indicator config from the chart caller, falling back to legacy pane settings.
1510
+ const indicatorConfig: IndicatorConfig = indicatorConfigOverride ?? ((pane?.settings?.indicators as IndicatorConfig) ?? EMPTY_INDICATOR_CONFIG);
1511
+ const hasIndicators = !!(indicatorConfig.sma?.length || indicatorConfig.ema?.length || indicatorConfig.rsi || indicatorConfig.macd || indicatorConfig.bollinger);
1512
+ const indicatorBufferRange = useMemo(
1513
+ () => resolveIndicatorBufferRange(viewState.presetRange, viewState.bufferRange, indicatorConfig),
1514
+ [indicatorConfig, viewState.bufferRange, viewState.presetRange],
1515
+ );
1516
+ const [requestedResolution, setRequestedResolution] = useState<ChartResolution>(
1517
+ compact ? "auto" : storedResolution,
1518
+ );
593
1519
  const [resolutionSupport, setResolutionSupport] = useState<ChartResolutionSupport[] | null>(null);
594
- const [showVolume, setShowVolume] = useState(!compact);
1520
+ const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
1521
+ const [renderedAutoView, setRenderedAutoView] = useState<AutoRenderedView | null>(null);
1522
+ const [pendingAutoWindowOverride, setPendingAutoWindowOverride] = useState<DateWindowRange | null>(null);
1523
+ const [lastReadyRenderView, setLastReadyRenderView] = useState<CachedRenderedView | null>(null);
1524
+ const showVolume = showVolumeOverride ?? !compact;
595
1525
  const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
596
1526
  const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
597
1527
  const plotRef = useRef<BoxRenderable | null>(null);
598
- const nativeBaseSurfaceIdRef = useRef(`chart-surface:${paneId}:${compact ? "compact" : "full"}:base`);
599
- const nativeCrosshairSurfaceIdRef = useRef(`chart-surface:${paneId}:${compact ? "compact" : "full"}:crosshair`);
1528
+ const nativeSurfaceScope = compact ? "compact" : "full";
1529
+ const nativeBaseSurfaceId = useMemo(
1530
+ () => `chart-surface:${paneId}:${nativeSurfaceScope}:base`,
1531
+ [nativeSurfaceScope, paneId],
1532
+ );
1533
+ const nativeCrosshairSurfaceId = useMemo(
1534
+ () => `chart-surface:${paneId}:${nativeSurfaceScope}:crosshair`,
1535
+ [nativeSurfaceScope, paneId],
1536
+ );
600
1537
  const dragRef = useRef<DragState | null>(null);
601
1538
  const lastNativeGeometryRef = useRef<{ rect: CellRect; visibleRect: CellRect | null } | null>(null);
602
1539
  const lastNativeBaseBitmapRef = useRef<{ key: string; bitmap: NativeChartBitmap } | null>(null);
@@ -608,6 +1545,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
608
1545
  const pendingCanonicalResetRef = useRef(1);
609
1546
  const appliedCanonicalResetRef = useRef(0);
610
1547
  const pendingExpansionRef = useRef<PendingExpansionAction>(null);
1548
+ const pendingAutoWindowRef = useRef<DateWindowRange | null>(null);
1549
+ const scrollPanCellRemainderRef = useRef(0);
1550
+ const lastAppliedStoredSelectionKeyRef = useRef<string | null>(null);
611
1551
 
612
1552
  const commitDisplayCursor = (next: DisplayCursorState) => {
613
1553
  displayCursorRef.current = next;
@@ -684,15 +1624,16 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
684
1624
  stopDisplayCursorAnimation();
685
1625
  lastNativeBaseBitmapRef.current = null;
686
1626
  lastNativeCrosshairBitmapRef.current = null;
687
- nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
688
- nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
1627
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceId);
1628
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceId);
689
1629
  }
690
- ), [nativeSurfaceManager]);
1630
+ ), [nativeBaseSurfaceId, nativeCrosshairSurfaceId, nativeSurfaceManager]);
691
1631
 
692
1632
  const instrumentRef = useMemo(
693
1633
  () => instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null),
694
1634
  [ticker],
695
1635
  );
1636
+ const dataProvider = getSharedDataProvider();
696
1637
  const capabilityKey = instrumentRef ? [
697
1638
  instrumentRef.symbol,
698
1639
  instrumentRef.exchange ?? "",
@@ -701,38 +1642,20 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
701
1642
  instrumentRef.instrument?.conId ?? "",
702
1643
  ].join("|") : null;
703
1644
 
704
- useEffect(() => {
705
- if (compact) return;
706
- pendingCanonicalResetRef.current += 1;
707
- setViewState((current) => {
708
- const nextState = resolvePresetSelection(
709
- {
710
- ...current,
711
- resolution: storedResolution,
712
- },
713
- storedRangePreset,
714
- storedResolution === "auto" ? null : getSupportMaxRange(resolutionSupport ?? [], storedResolution),
715
- );
716
- return nextState;
717
- });
718
- updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
719
- }, [compact, resolutionSupport, storedRangePreset, storedResolution]);
720
-
721
1645
  useEffect(() => {
722
1646
  if (compact || !instrumentRef) {
723
1647
  setResolutionSupport(null);
724
1648
  return;
725
1649
  }
726
- const provider = getSharedDataProvider();
727
- if (!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) {
1650
+ if (!dataProvider?.getChartResolutionSupport && !dataProvider?.getChartResolutionCapabilities) {
728
1651
  setResolutionSupport(null);
729
1652
  return;
730
1653
  }
731
1654
 
732
1655
  let cancelled = false;
733
1656
  setResolutionSupport(null);
734
- Promise.resolve(provider.getChartResolutionSupport
735
- ? provider.getChartResolutionSupport(
1657
+ Promise.resolve(dataProvider.getChartResolutionSupport
1658
+ ? dataProvider.getChartResolutionSupport(
736
1659
  instrumentRef.symbol,
737
1660
  instrumentRef.exchange ?? "",
738
1661
  {
@@ -741,8 +1664,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
741
1664
  instrument: instrumentRef.instrument ?? null,
742
1665
  },
743
1666
  )
744
- : normalizeChartResolutionSupport(
745
- (provider.getChartResolutionCapabilities?.(
1667
+ : Promise.resolve(dataProvider.getChartResolutionCapabilities?.(
746
1668
  instrumentRef.symbol,
747
1669
  instrumentRef.exchange ?? "",
748
1670
  {
@@ -750,8 +1672,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
750
1672
  brokerInstanceId: instrumentRef.brokerInstanceId,
751
1673
  instrument: instrumentRef.instrument ?? null,
752
1674
  },
753
- ) ?? []).map((resolution) => ({ resolution, maxRange: "ALL" })),
754
- )
1675
+ ) ?? []).then((capabilities) => normalizeChartResolutionSupport(
1676
+ capabilities.map((resolution) => ({ resolution, maxRange: "ALL" })),
1677
+ ))
755
1678
  ).then((support) => {
756
1679
  if (!cancelled) {
757
1680
  setResolutionSupport(support);
@@ -762,99 +1685,797 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
762
1685
  }
763
1686
  });
764
1687
 
765
- return () => {
766
- cancelled = true;
1688
+ return () => {
1689
+ cancelled = true;
1690
+ };
1691
+ }, [capabilityKey, compact, dataProvider, instrumentRef]);
1692
+
1693
+ const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
1694
+ resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
1695
+ ), [resolutionSupport]);
1696
+ const selectionSupportMap = useMemo(
1697
+ () => buildChartResolutionSupportMap(effectiveResolutionSupport),
1698
+ [effectiveResolutionSupport],
1699
+ );
1700
+ const availableManualResolutions = resolutionSupport?.map((entry) => entry.resolution) ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS;
1701
+ useEffect(() => {
1702
+ if (compact) return;
1703
+ const storedSelectionKey = `${storedRangePreset}:${storedResolution}`;
1704
+ if (lastAppliedStoredSelectionKeyRef.current === storedSelectionKey) return;
1705
+
1706
+ lastAppliedStoredSelectionKeyRef.current = storedSelectionKey;
1707
+ setRequestedResolution(storedResolution);
1708
+ pendingCanonicalResetRef.current += 1;
1709
+ pendingAutoWindowRef.current = null;
1710
+ setPendingAutoWindowOverride(null);
1711
+ if (storedResolution === "auto") {
1712
+ setRenderedAutoView(null);
1713
+ }
1714
+ setViewState((current) => {
1715
+ return storedResolution === "auto"
1716
+ ? resolveViewportStoredSelection(current, storedRangePreset, storedResolution, selectionSupportMap)
1717
+ : resolveViewportPresetSelection(
1718
+ current,
1719
+ storedRangePreset,
1720
+ getSupportMaxRange(selectionSupportMap, storedResolution),
1721
+ );
1722
+ });
1723
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1724
+ }, [compact, selectionSupportMap, storedRangePreset, storedResolution]);
1725
+ const effectiveResolution: ChartResolution = !compact
1726
+ && requestedResolution !== "auto"
1727
+ && resolutionSupport !== null
1728
+ && !supportMap.has(requestedResolution)
1729
+ ? "auto"
1730
+ : requestedResolution;
1731
+ const resolutionChips = useMemo(
1732
+ () => sortChartResolutions(["auto", ...availableManualResolutions] as ChartResolution[]),
1733
+ [availableManualResolutions],
1734
+ );
1735
+
1736
+ const boundsChartEntry = useChartQuery(
1737
+ !compact && instrumentRef
1738
+ ? {
1739
+ instrument: instrumentRef,
1740
+ bufferRange: indicatorBufferRange,
1741
+ granularity: "range",
1742
+ }
1743
+ : null,
1744
+ );
1745
+ const fallbackPriceHistory = historyOverride ?? financials?.priceHistory ?? [];
1746
+ const rawBoundsBodyState = resolveChartBodyState(
1747
+ boundsChartEntry,
1748
+ (value) => Array.isArray(value) && value.length > 0,
1749
+ "No price history available.",
1750
+ );
1751
+ const boundsBodyState = !compact && !boundsChartEntry && fallbackPriceHistory.length > 0
1752
+ ? {
1753
+ data: fallbackPriceHistory,
1754
+ blocking: false,
1755
+ updating: false,
1756
+ emptyMessage: null,
1757
+ errorMessage: null,
1758
+ }
1759
+ : rawBoundsBodyState;
1760
+ const axisSectionWidthBudget = compact
1761
+ ? axisMode === "percent" ? 11 : 8
1762
+ : axisMode === "percent" ? 11 : 10;
1763
+ const axisRightPadding = 1;
1764
+ const minimumAxisWidth = axisMode === "percent" ? 5 : 4;
1765
+ const axisGap = axisSectionWidthBudget > 0 ? 1 : 0;
1766
+ const minChartWidth = compact ? 12 : 20;
1767
+ const measurementChartWidth = Math.max(width - axisSectionWidthBudget - axisGap, minChartWidth);
1768
+ const headerRows = compact ? 0 : 4;
1769
+ const helpRow = compact ? 0 : 1;
1770
+ const timeAxisRow = 1;
1771
+ const volumeHeight = showVolume && !compact ? 3 : 0;
1772
+ const chartHeight = Math.max(height - headerRows - helpRow - timeAxisRow, 4);
1773
+ const chartCurrency = currencyOverride ?? financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
1774
+ const chartAssetCategory = ticker?.metadata.assetCategory;
1775
+ const boundsHistory = compact
1776
+ ? fallbackPriceHistory
1777
+ : (boundsBodyState.data ?? []);
1778
+ const boundsHistoryDates = useMemo(() => getPointDates(boundsHistory), [boundsHistory]);
1779
+ const baseDateBounds = useMemo(() => getDateWindowBounds(boundsHistoryDates), [boundsHistoryDates]);
1780
+ const manualVisibleDateWindow = useMemo(
1781
+ () => buildVisibleDateWindow(boundsHistoryDates, viewState.panOffset, viewState.zoomLevel),
1782
+ [boundsHistoryDates, viewState.panOffset, viewState.zoomLevel],
1783
+ );
1784
+ const canonicalAutoWindow = useMemo(
1785
+ () => buildPresetDateWindow(boundsHistoryDates, viewState.presetRange),
1786
+ [boundsHistoryDates, viewState.presetRange],
1787
+ );
1788
+ const autoMinimumSpanMs = useMemo(() => {
1789
+ const finestSupportedResolution = effectiveResolutionSupport[0]?.resolution;
1790
+ return finestSupportedResolution
1791
+ ? CHART_RESOLUTION_STEP_MS[finestSupportedResolution]
1792
+ : getMinimumDateStepMs(boundsHistoryDates);
1793
+ }, [boundsHistoryDates, effectiveResolutionSupport]);
1794
+ const renderedAutoWindow = useMemo(() => (
1795
+ effectiveResolution !== "auto"
1796
+ ? null
1797
+ : clampDateWindowToBounds(renderedAutoView?.window ?? canonicalAutoWindow, baseDateBounds, autoMinimumSpanMs)
1798
+ ), [autoMinimumSpanMs, baseDateBounds, canonicalAutoWindow, effectiveResolution, renderedAutoView]);
1799
+ const plannedAutoWindow = useMemo(() => (
1800
+ effectiveResolution !== "auto"
1801
+ ? null
1802
+ : clampDateWindowToBounds(
1803
+ pendingAutoWindowOverride ?? renderedAutoWindow ?? canonicalAutoWindow,
1804
+ baseDateBounds,
1805
+ autoMinimumSpanMs,
1806
+ )
1807
+ ), [
1808
+ autoMinimumSpanMs,
1809
+ baseDateBounds,
1810
+ canonicalAutoWindow,
1811
+ effectiveResolution,
1812
+ pendingAutoWindowOverride,
1813
+ renderedAutoWindow,
1814
+ ]);
1815
+ const hasResolutionSupportApi = !!dataProvider?.getChartResolutionSupport || !!dataProvider?.getChartResolutionCapabilities;
1816
+ const plannedAutoResolution = useMemo<ManualChartResolution | null>(() => {
1817
+ if (
1818
+ compact
1819
+ || effectiveResolution !== "auto"
1820
+ || !plannedAutoWindow?.start
1821
+ || !plannedAutoWindow.end
1822
+ ) {
1823
+ return null;
1824
+ }
1825
+
1826
+ const spanMs = plannedAutoWindow.end.getTime() - plannedAutoWindow.start.getTime();
1827
+ if (!Number.isFinite(spanMs) || spanMs < 0) return null;
1828
+ if (hasResolutionSupportApi && resolutionSupport === null) return null;
1829
+ return hasResolutionSupportApi
1830
+ ? getBestSupportedResolutionForVisibleWindow(plannedAutoWindow, supportMap, measurementChartWidth)
1831
+ : (resolveBarSize(spanMs) as ManualChartResolution | null);
1832
+ }, [compact, plannedAutoWindow, effectiveResolution, hasResolutionSupportApi, measurementChartWidth, resolutionSupport, supportMap]);
1833
+ const plannedDateWindow = effectiveResolution === "auto"
1834
+ ? plannedAutoWindow
1835
+ : manualVisibleDateWindow;
1836
+ const plannedManualResolution = effectiveResolution === "auto"
1837
+ ? plannedAutoResolution
1838
+ : effectiveResolution;
1839
+ const plannedWindowRange = useMemo(
1840
+ () => getTimeRangeForDateWindow(plannedDateWindow),
1841
+ [plannedDateWindow],
1842
+ );
1843
+ const renderCandidates = useMemo<ResolvedRenderCandidate[]>(() => (
1844
+ buildResolutionFallbackChain(plannedManualResolution, plannedWindowRange, supportMap).map((candidateResolution) => {
1845
+ const plan = buildResolvedChartRequestPlan({
1846
+ compact,
1847
+ historyOverride,
1848
+ instrumentRef,
1849
+ requestedWindow: plannedDateWindow,
1850
+ effectiveResolution,
1851
+ effectiveManualResolution: candidateResolution,
1852
+ bounds: baseDateBounds,
1853
+ bufferRange: indicatorBufferRange,
1854
+ minimumBufferRange: hasIndicators ? indicatorBufferRange : null,
1855
+ support: supportMap,
1856
+ hasResolutionHistoryApi: !!dataProvider?.getPriceHistoryForResolution,
1857
+ hasDetailedHistoryApi: !!dataProvider?.getDetailedPriceHistory,
1858
+ minimumSpanMs: autoMinimumSpanMs,
1859
+ });
1860
+ return {
1861
+ resolution: candidateResolution,
1862
+ plan,
1863
+ resolutionRequestKey: plan.resolutionRequest ? buildChartKey(plan.resolutionRequest) : null,
1864
+ detailRequestKey: plan.detailRequest ? buildChartKey(plan.detailRequest) : null,
1865
+ };
1866
+ })
1867
+ ), [
1868
+ autoMinimumSpanMs,
1869
+ baseDateBounds,
1870
+ compact,
1871
+ dataProvider,
1872
+ plannedManualResolution,
1873
+ effectiveResolution,
1874
+ historyOverride,
1875
+ instrumentRef,
1876
+ plannedDateWindow,
1877
+ plannedWindowRange,
1878
+ supportMap,
1879
+ indicatorBufferRange,
1880
+ hasIndicators,
1881
+ ]);
1882
+ const candidateResolutionRequests = useMemo(
1883
+ () => dedupeChartRequests(renderCandidates.map((candidate) => candidate.plan.resolutionRequest)),
1884
+ [renderCandidates],
1885
+ );
1886
+ const candidateResolutionEntries = useChartQueries(candidateResolutionRequests);
1887
+ const candidateDetailRequests = useMemo(() => (
1888
+ dedupeChartRequests(renderCandidates.flatMap((candidate) => {
1889
+ if (!candidate.plan.detailRequest) return [];
1890
+ if (!candidate.plan.resolutionRequest) return [candidate.plan.detailRequest];
1891
+
1892
+ const resolutionBodyState = resolveChartBodyState(
1893
+ candidateResolutionEntries.get(candidate.resolutionRequestKey!),
1894
+ (value) => Array.isArray(value) && value.length > 0,
1895
+ "No price history available.",
1896
+ );
1897
+ const resolutionAccepted = isSeriesAcceptedForRequest(
1898
+ resolutionBodyState.data ?? [],
1899
+ plannedDateWindow,
1900
+ candidate.resolution,
1901
+ {
1902
+ requireAutoDensity: effectiveResolution === "auto",
1903
+ targetResolution: plannedManualResolution,
1904
+ },
1905
+ );
1906
+
1907
+ return !resolutionBodyState.blocking && !resolutionAccepted
1908
+ ? [candidate.plan.detailRequest]
1909
+ : [];
1910
+ }))
1911
+ ), [candidateResolutionEntries, effectiveResolution, plannedDateWindow, plannedManualResolution, renderCandidates]);
1912
+ const candidateDetailEntries = useChartQueries(candidateDetailRequests, {
1913
+ debounceMs: CHART_DETAIL_REQUEST_DEBOUNCE_MS,
1914
+ });
1915
+ const boundsHistoryCompatible = useMemo(() => (
1916
+ isSeriesAcceptedForRequest(boundsHistory, plannedDateWindow, plannedManualResolution, {
1917
+ requireAutoDensity: effectiveResolution === "auto",
1918
+ targetResolution: plannedManualResolution,
1919
+ })
1920
+ ), [boundsHistory, effectiveResolution, plannedManualResolution, plannedDateWindow]);
1921
+ const renderedAutoViewAccepted = useMemo(() => (
1922
+ effectiveResolution === "auto"
1923
+ && !!renderedAutoView
1924
+ && !!plannedDateWindow?.start
1925
+ && !!plannedDateWindow.end
1926
+ && !!plannedManualResolution
1927
+ && CHART_RESOLUTION_STEP_MS[renderedAutoView.resolution] <= CHART_RESOLUTION_STEP_MS[plannedManualResolution]
1928
+ && isSeriesAcceptedForRequest(renderedAutoView.data, plannedDateWindow, renderedAutoView.resolution, {
1929
+ requireAutoDensity: true,
1930
+ targetResolution: plannedManualResolution,
1931
+ })
1932
+ ), [effectiveResolution, plannedDateWindow, plannedManualResolution, renderedAutoView]);
1933
+ const resolvedRender = useMemo(() => {
1934
+ const overrideBodyState = {
1935
+ data: fallbackPriceHistory,
1936
+ blocking: false,
1937
+ updating: false,
1938
+ emptyMessage: null,
1939
+ errorMessage: null,
1940
+ };
1941
+
1942
+ if (historyOverride || compact) {
1943
+ return {
1944
+ bodyState: overrideBodyState,
1945
+ resolvedManualResolution: plannedManualResolution,
1946
+ };
1947
+ }
1948
+
1949
+ if (!plannedDateWindow?.start || !plannedDateWindow.end || !plannedManualResolution) {
1950
+ if (boundsBodyState.errorMessage) {
1951
+ return {
1952
+ bodyState: {
1953
+ data: null,
1954
+ blocking: false,
1955
+ updating: false,
1956
+ emptyMessage: null,
1957
+ errorMessage: boundsBodyState.errorMessage,
1958
+ },
1959
+ resolvedManualResolution: plannedManualResolution,
1960
+ };
1961
+ }
1962
+ if (boundsBodyState.emptyMessage) {
1963
+ return {
1964
+ bodyState: {
1965
+ data: null,
1966
+ blocking: false,
1967
+ updating: false,
1968
+ emptyMessage: boundsBodyState.emptyMessage,
1969
+ errorMessage: null,
1970
+ },
1971
+ resolvedManualResolution: plannedManualResolution,
1972
+ };
1973
+ }
1974
+ return {
1975
+ bodyState: {
1976
+ data: null,
1977
+ blocking: true,
1978
+ updating: false,
1979
+ emptyMessage: null,
1980
+ errorMessage: null,
1981
+ },
1982
+ resolvedManualResolution: plannedManualResolution,
1983
+ };
1984
+ }
1985
+
1986
+ if (renderedAutoViewAccepted && renderedAutoView) {
1987
+ return {
1988
+ bodyState: {
1989
+ data: renderedAutoView.data,
1990
+ blocking: false,
1991
+ updating: false,
1992
+ emptyMessage: null,
1993
+ errorMessage: null,
1994
+ },
1995
+ resolvedManualResolution: renderedAutoView.resolution,
1996
+ };
1997
+ }
1998
+
1999
+ let firstBlockingState: ReturnType<typeof resolveChartBodyState<PricePoint[]>> | null = null;
2000
+ let firstBlockingResolution: ManualChartResolution | null = null;
2001
+ let firstCompatibleState: ReturnType<typeof resolveChartBodyState<PricePoint[]>> | null = null;
2002
+ let firstCompatibleResolution: ManualChartResolution | null = null;
2003
+ let lastFailureState: ReturnType<typeof resolveChartBodyState<PricePoint[]>> | null = null;
2004
+
2005
+ for (const candidate of renderCandidates) {
2006
+ const resolutionBodyState = candidate.plan.resolutionRequest
2007
+ ? resolveChartBodyState(
2008
+ candidateResolutionEntries.get(candidate.resolutionRequestKey!),
2009
+ (value) => Array.isArray(value) && value.length > 0,
2010
+ "No price history available.",
2011
+ )
2012
+ : null;
2013
+ const resolutionCompatible = candidate.plan.resolutionRequest !== null
2014
+ && isSeriesAcceptedForRequest(
2015
+ resolutionBodyState?.data ?? [],
2016
+ plannedDateWindow,
2017
+ candidate.resolution,
2018
+ {
2019
+ requireAutoDensity: effectiveResolution === "auto",
2020
+ targetResolution: plannedManualResolution,
2021
+ },
2022
+ );
2023
+ if (resolutionCompatible) {
2024
+ if (!firstBlockingState) {
2025
+ return {
2026
+ bodyState: resolutionBodyState!,
2027
+ resolvedManualResolution: candidate.resolution,
2028
+ };
2029
+ }
2030
+ firstCompatibleState ??= resolutionBodyState!;
2031
+ firstCompatibleResolution ??= candidate.resolution;
2032
+ continue;
2033
+ }
2034
+
2035
+ const detailBodyState = candidate.plan.detailRequest
2036
+ ? resolveChartBodyState(
2037
+ candidateDetailEntries.get(candidate.detailRequestKey!),
2038
+ (value) => Array.isArray(value) && value.length > 0,
2039
+ "No price history available.",
2040
+ )
2041
+ : null;
2042
+ const detailCompatible = candidate.plan.detailRequest !== null
2043
+ && isSeriesAcceptedForRequest(
2044
+ detailBodyState?.data ?? [],
2045
+ plannedDateWindow,
2046
+ candidate.resolution,
2047
+ {
2048
+ requireAutoDensity: effectiveResolution === "auto",
2049
+ targetResolution: plannedManualResolution,
2050
+ },
2051
+ );
2052
+ if (detailCompatible) {
2053
+ if (!firstBlockingState) {
2054
+ return {
2055
+ bodyState: detailBodyState!,
2056
+ resolvedManualResolution: candidate.resolution,
2057
+ };
2058
+ }
2059
+ firstCompatibleState ??= detailBodyState!;
2060
+ firstCompatibleResolution ??= candidate.resolution;
2061
+ continue;
2062
+ }
2063
+
2064
+ if (!firstBlockingState) {
2065
+ if (resolutionBodyState?.blocking) {
2066
+ firstBlockingState = resolutionBodyState;
2067
+ firstBlockingResolution = candidate.resolution;
2068
+ } else if (detailBodyState?.blocking) {
2069
+ firstBlockingState = detailBodyState;
2070
+ firstBlockingResolution = candidate.resolution;
2071
+ }
2072
+ }
2073
+
2074
+ if (detailBodyState?.errorMessage || detailBodyState?.emptyMessage) {
2075
+ lastFailureState = detailBodyState;
2076
+ } else if (resolutionBodyState?.errorMessage || resolutionBodyState?.emptyMessage) {
2077
+ lastFailureState = resolutionBodyState;
2078
+ }
2079
+ }
2080
+
2081
+ if (effectiveResolution === "auto" && boundsHistoryCompatible) {
2082
+ return {
2083
+ bodyState: {
2084
+ data: boundsHistory,
2085
+ blocking: false,
2086
+ updating: boundsBodyState.updating,
2087
+ emptyMessage: null,
2088
+ errorMessage: null,
2089
+ },
2090
+ resolvedManualResolution: plannedManualResolution,
2091
+ };
2092
+ }
2093
+
2094
+ if (firstCompatibleState) {
2095
+ return {
2096
+ bodyState: firstBlockingState
2097
+ ? {
2098
+ ...firstCompatibleState,
2099
+ blocking: false,
2100
+ updating: true,
2101
+ emptyMessage: null,
2102
+ errorMessage: null,
2103
+ }
2104
+ : firstCompatibleState,
2105
+ resolvedManualResolution: firstCompatibleResolution ?? plannedManualResolution,
2106
+ };
2107
+ }
2108
+
2109
+ if (effectiveResolution === "auto" && firstBlockingState && boundsHistory.length > 0) {
2110
+ return {
2111
+ bodyState: {
2112
+ data: boundsHistory,
2113
+ blocking: false,
2114
+ updating: true,
2115
+ emptyMessage: null,
2116
+ errorMessage: null,
2117
+ },
2118
+ resolvedManualResolution: plannedManualResolution,
2119
+ };
2120
+ }
2121
+
2122
+ if (effectiveResolution !== "auto" && firstBlockingState && boundsHistoryCompatible) {
2123
+ return {
2124
+ bodyState: {
2125
+ data: boundsHistory,
2126
+ blocking: false,
2127
+ updating: true,
2128
+ emptyMessage: null,
2129
+ errorMessage: null,
2130
+ },
2131
+ resolvedManualResolution: plannedManualResolution,
2132
+ };
2133
+ }
2134
+
2135
+ if (firstBlockingState) {
2136
+ return {
2137
+ bodyState: firstBlockingState,
2138
+ resolvedManualResolution: firstBlockingResolution ?? plannedManualResolution,
2139
+ };
2140
+ }
2141
+
2142
+ if (renderCandidates[0]?.plan.unsupportedMessage) {
2143
+ return {
2144
+ bodyState: {
2145
+ data: null,
2146
+ blocking: false,
2147
+ updating: false,
2148
+ emptyMessage: null,
2149
+ errorMessage: renderCandidates[0].plan.unsupportedMessage,
2150
+ },
2151
+ resolvedManualResolution: renderCandidates[0].resolution,
2152
+ };
2153
+ }
2154
+
2155
+ return {
2156
+ bodyState: lastFailureState ?? {
2157
+ data: null,
2158
+ blocking: false,
2159
+ updating: false,
2160
+ emptyMessage: boundsBodyState.emptyMessage ?? "No price history available.",
2161
+ errorMessage: boundsBodyState.errorMessage,
2162
+ },
2163
+ resolvedManualResolution: plannedManualResolution,
2164
+ };
2165
+ }, [
2166
+ boundsBodyState.emptyMessage,
2167
+ boundsBodyState.errorMessage,
2168
+ boundsBodyState.updating,
2169
+ boundsHistory,
2170
+ boundsHistoryCompatible,
2171
+ candidateDetailEntries,
2172
+ candidateResolutionEntries,
2173
+ compact,
2174
+ effectiveResolution,
2175
+ financials?.priceHistory,
2176
+ historyOverride,
2177
+ plannedDateWindow,
2178
+ plannedManualResolution,
2179
+ renderedAutoView,
2180
+ renderedAutoViewAccepted,
2181
+ renderCandidates,
2182
+ ]);
2183
+ const plannedRenderBodyState = resolvedRender.bodyState;
2184
+ const plannedResolvedManualResolution = resolvedRender.resolvedManualResolution;
2185
+ const canCommitPlannedAutoView = effectiveResolution === "auto"
2186
+ && !!plannedDateWindow?.start
2187
+ && !!plannedDateWindow.end
2188
+ && !!plannedResolvedManualResolution
2189
+ && isSeriesAcceptedForRequest(
2190
+ plannedRenderBodyState.data ?? [],
2191
+ plannedDateWindow,
2192
+ plannedResolvedManualResolution,
2193
+ {
2194
+ requireAutoDensity: true,
2195
+ targetResolution: plannedManualResolution,
2196
+ },
2197
+ );
2198
+ useEffect(() => {
2199
+ if (!canCommitPlannedAutoView || !plannedDateWindow?.start || !plannedDateWindow.end || !plannedResolvedManualResolution || !plannedRenderBodyState.data?.length) {
2200
+ return;
2201
+ }
2202
+
2203
+ const nextRenderedAutoView: AutoRenderedView = {
2204
+ window: plannedDateWindow,
2205
+ resolution: plannedResolvedManualResolution,
2206
+ data: plannedRenderBodyState.data,
767
2207
  };
768
- }, [capabilityKey, compact, instrumentRef]);
2208
+ setRenderedAutoView((current) => (
2209
+ current
2210
+ && sameDateWindow(current.window, nextRenderedAutoView.window)
2211
+ && current.resolution === nextRenderedAutoView.resolution
2212
+ && current.data === nextRenderedAutoView.data
2213
+ ? current
2214
+ : nextRenderedAutoView
2215
+ ));
2216
+ setPendingAutoWindowOverride((current) => (
2217
+ sameDateWindow(current, plannedDateWindow) ? null : current
2218
+ ));
2219
+ }, [
2220
+ canCommitPlannedAutoView,
2221
+ plannedDateWindow,
2222
+ plannedRenderBodyState.data,
2223
+ plannedResolvedManualResolution,
2224
+ ]);
2225
+ const shouldRejectPendingAutoView = effectiveResolution === "auto"
2226
+ && pendingAutoWindowOverride !== null
2227
+ && !plannedRenderBodyState.blocking
2228
+ && !plannedRenderBodyState.updating
2229
+ && !canCommitPlannedAutoView;
2230
+ useEffect(() => {
2231
+ if (!shouldRejectPendingAutoView || !plannedDateWindow?.start || !plannedDateWindow.end) {
2232
+ return;
2233
+ }
769
2234
 
770
- const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
771
- const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
772
- resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
773
- ), [resolutionSupport]);
774
- const availableManualResolutions = resolutionSupport?.map((entry) => entry.resolution) ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS;
775
- const effectiveResolution: ChartResolution = !compact
776
- && viewState.resolution !== "auto"
777
- && resolutionSupport !== null
778
- && !supportMap.has(viewState.resolution)
779
- ? "auto"
780
- : viewState.resolution;
781
- const resolutionChips = useMemo(
782
- () => sortChartResolutions(["auto", ...availableManualResolutions] as ChartResolution[]),
783
- [availableManualResolutions],
784
- );
2235
+ setPendingAutoWindowOverride((current) => (
2236
+ sameDateWindow(current, plannedDateWindow) ? null : current
2237
+ ));
2238
+ }, [
2239
+ pendingAutoWindowOverride,
2240
+ plannedDateWindow,
2241
+ plannedRenderBodyState.blocking,
2242
+ plannedRenderBodyState.emptyMessage,
2243
+ plannedRenderBodyState.errorMessage,
2244
+ plannedRenderBodyState.updating,
2245
+ shouldRejectPendingAutoView,
2246
+ ]);
2247
+ useEffect(() => {
2248
+ setPendingAutoWindowOverride(null);
2249
+ setRenderedAutoView(null);
2250
+ setLastReadyRenderView(null);
2251
+ }, [instrumentRef?.exchange, instrumentRef?.symbol]);
2252
+ const hasPendingAutoProposal = effectiveResolution === "auto" && pendingAutoWindowOverride !== null;
2253
+ const shouldUseRenderedAutoView = effectiveResolution === "auto"
2254
+ && !!renderedAutoView
2255
+ && (
2256
+ plannedRenderBodyState.blocking
2257
+ || !plannedRenderBodyState.data?.length
2258
+ || !!plannedRenderBodyState.emptyMessage
2259
+ || !!plannedRenderBodyState.errorMessage
2260
+ );
2261
+ const isRenderedAutoViewUpdating = hasPendingAutoProposal
2262
+ || plannedRenderBodyState.blocking
2263
+ || plannedRenderBodyState.updating;
2264
+ const autoDisplayState = useMemo(() => resolveAutoDisplayState({
2265
+ shouldUseRenderedAutoView,
2266
+ renderedAutoView,
2267
+ isRenderedAutoViewUpdating,
2268
+ plannedRenderBodyState,
2269
+ plannedResolvedManualResolution,
2270
+ plannedDateWindow,
2271
+ }), [
2272
+ isRenderedAutoViewUpdating,
2273
+ plannedDateWindow,
2274
+ plannedRenderBodyState,
2275
+ plannedResolvedManualResolution,
2276
+ renderedAutoView,
2277
+ shouldUseRenderedAutoView,
2278
+ ]);
2279
+ const baseRenderBodyState = effectiveResolution === "auto"
2280
+ ? autoDisplayState.bodyState
2281
+ : plannedRenderBodyState;
2282
+ const baseResolvedManualResolution = effectiveResolution === "auto"
2283
+ ? autoDisplayState.resolution
2284
+ : plannedResolvedManualResolution;
2285
+ const baseDisplayedDateWindow = effectiveResolution === "auto"
2286
+ ? autoDisplayState.window
2287
+ : manualVisibleDateWindow;
2288
+ useEffect(() => {
2289
+ if (compact || baseRenderBodyState.blocking || !baseRenderBodyState.data?.length) return;
785
2290
 
786
- const baseChartEntry = useChartQuery(
787
- !compact && instrumentRef
788
- ? {
789
- instrument: instrumentRef,
790
- bufferRange: viewState.bufferRange,
791
- granularity: effectiveResolution === "auto" ? "range" : "resolution",
792
- resolution: effectiveResolution === "auto" ? undefined : effectiveResolution,
793
- }
794
- : null,
795
- );
796
- const baseBodyState = resolveChartBodyState(baseChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
797
- const axisWidth = compact
798
- ? axisMode === "percent" ? 11 : 8
799
- : axisMode === "percent" ? 11 : 10;
800
- const axisGap = axisWidth > 0 ? 1 : 0;
801
- const chartWidth = Math.max(width - axisWidth - axisGap, compact ? 12 : 20);
802
- const maxCursorX = chartWidth - 1;
803
- const panStep = Math.max(Math.floor(chartWidth / 10), 1);
804
- const baseHistory = compact
805
- ? (historyOverride ?? financials?.priceHistory ?? [])
806
- : (baseBodyState.data ?? []);
807
- const detailRequest = useMemo(() => {
808
- if (compact || effectiveResolution !== "auto" || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
809
- const window = getVisibleWindow(baseHistory, viewState, chartWidth);
810
- if (window.points.length < 2) return null;
811
-
812
- const startDate = coerceChartDate(window.points[0]!.date as Date | string | number);
813
- const endDate = coerceChartDate(window.points[window.points.length - 1]!.date as Date | string | number);
814
- if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null;
815
- const spanMs = endDate.getTime() - startDate.getTime();
816
- const barSize = resolveBarSize(spanMs);
817
- if (!barSize) return null;
2291
+ const nextView: CachedRenderedView = {
2292
+ window: baseDisplayedDateWindow,
2293
+ resolution: baseResolvedManualResolution,
2294
+ data: baseRenderBodyState.data,
2295
+ };
2296
+ setLastReadyRenderView((current) => (
2297
+ current
2298
+ && current.data === nextView.data
2299
+ && current.resolution === nextView.resolution
2300
+ && sameDateWindow(current.window, nextView.window)
2301
+ ? current
2302
+ : nextView
2303
+ ));
2304
+ }, [
2305
+ baseDisplayedDateWindow,
2306
+ baseRenderBodyState.blocking,
2307
+ baseRenderBodyState.data,
2308
+ baseResolvedManualResolution,
2309
+ compact,
2310
+ ]);
2311
+ const shouldUseLastReadyRenderView = !compact && baseRenderBodyState.blocking && !!lastReadyRenderView?.data.length;
2312
+ const renderBodyState = shouldUseLastReadyRenderView
2313
+ ? {
2314
+ data: lastReadyRenderView!.data,
2315
+ blocking: false,
2316
+ updating: true,
2317
+ emptyMessage: null,
2318
+ errorMessage: null,
2319
+ }
2320
+ : baseRenderBodyState;
2321
+ const resolvedManualResolution = shouldUseLastReadyRenderView
2322
+ ? lastReadyRenderView!.resolution
2323
+ : baseResolvedManualResolution;
2324
+ const displayedDateWindow = shouldUseLastReadyRenderView
2325
+ ? lastReadyRenderView!.window
2326
+ : baseDisplayedDateWindow;
2327
+ const renderedResolution: ChartResolution = effectiveResolution === "auto"
2328
+ ? "auto"
2329
+ : (resolvedManualResolution ?? effectiveResolution);
2330
+ const selectedResolution: ChartResolution = requestedResolution === "auto"
2331
+ ? "auto"
2332
+ : (resolutionChips.includes(requestedResolution) ? requestedResolution : effectiveResolution);
2333
+ const fallbackResolutionLabel = selectedResolution !== "auto" && renderedResolution !== selectedResolution
2334
+ ? `showing ${getChartResolutionLabel(renderedResolution)}`
2335
+ : null;
2336
+ const history = compact
2337
+ ? fallbackPriceHistory
2338
+ : (renderBodyState.data ?? []);
2339
+ const visibleDateWindow = displayedDateWindow;
2340
+ const navigableDateWindow = effectiveResolution === "auto"
2341
+ ? (pendingAutoWindowOverride ?? displayedDateWindow ?? plannedDateWindow)
2342
+ : visibleDateWindow;
2343
+ const navigationOhlcPointCount = !compact && effectiveResolution !== "auto"
2344
+ ? manualVisibleDateWindow.dates.length
2345
+ : 0;
2346
+ const resolveOhlcProjectionOptions = useCallback((
2347
+ pointCount: number,
2348
+ sourceIndexOffset: number,
2349
+ ) => (
2350
+ resolveStableOhlcProjectionOptions({
2351
+ pointCount,
2352
+ sourceIndexOffset,
2353
+ bucketWidth: measurementChartWidth,
2354
+ navigationPointCount: navigationOhlcPointCount,
2355
+ })
2356
+ ), [measurementChartWidth, navigationOhlcPointCount]);
2357
+ const axisWidth = useMemo(() => {
2358
+ const measureAxisWidth = (targetWidth: number) => {
2359
+ const measuredWindow = historyOverride || !displayedDateWindow?.start || !displayedDateWindow.end
2360
+ ? { points: history, startIdx: 0, endIdx: history.length }
2361
+ : getVisibleWindowForDateRange(history, displayedDateWindow, 0);
2362
+ const measuredTimeAxisDates = measuredWindow.points.map((point) => point.date);
2363
+ const measuredProjection = projectChartData(
2364
+ measuredWindow.points,
2365
+ targetWidth,
2366
+ viewState.renderMode,
2367
+ !!compact,
2368
+ resolveOhlcProjectionOptions(measuredWindow.points.length, measuredWindow.startIdx),
2369
+ );
2370
+ const measuredResult = renderChart(measuredProjection.points, {
2371
+ width: targetWidth,
2372
+ height: chartHeight,
2373
+ showVolume: showVolume && !compact,
2374
+ volumeHeight,
2375
+ cursorX: null,
2376
+ cursorY: null,
2377
+ mode: measuredProjection.effectiveMode,
2378
+ axisMode,
2379
+ currency: chartCurrency,
2380
+ assetCategory: chartAssetCategory,
2381
+ colors: AXIS_MEASURE_PALETTE,
2382
+ timeAxisDates: measuredTimeAxisDates,
2383
+ });
2384
+ const measuredScene = buildChartScene(measuredProjection.points, {
2385
+ width: targetWidth,
2386
+ height: chartHeight,
2387
+ showVolume: showVolume && !compact,
2388
+ volumeHeight,
2389
+ cursorX: null,
2390
+ cursorY: null,
2391
+ mode: measuredProjection.effectiveMode,
2392
+ axisMode,
2393
+ colors: AXIS_MEASURE_PALETTE,
2394
+ timeAxisDates: measuredTimeAxisDates,
2395
+ });
2396
+ const cursorSamples = measuredScene
2397
+ ? [
2398
+ formatCursorAxisValue(
2399
+ measuredScene.min,
2400
+ axisMode,
2401
+ measuredProjection.points[0]?.close ?? 0,
2402
+ chartCurrency,
2403
+ chartAssetCategory,
2404
+ measuredResult.priceRange ?? undefined,
2405
+ ),
2406
+ formatCursorAxisValue(
2407
+ measuredScene.max,
2408
+ axisMode,
2409
+ measuredProjection.points[0]?.close ?? 0,
2410
+ chartCurrency,
2411
+ chartAssetCategory,
2412
+ measuredResult.priceRange ?? undefined,
2413
+ ),
2414
+ ]
2415
+ : [];
818
2416
 
819
- return {
820
- instrument: instrumentRef,
821
- bufferRange: viewState.bufferRange,
822
- granularity: "detail" as const,
823
- startDate,
824
- endDate,
825
- barSize,
2417
+ return resolveChartAxisWidth(
2418
+ [...measuredResult.axisLabels.map((entry) => entry.label), ...cursorSamples],
2419
+ minimumAxisWidth,
2420
+ Math.max(axisSectionWidthBudget - axisRightPadding, minimumAxisWidth),
2421
+ );
826
2422
  };
827
- }, [baseHistory, chartWidth, compact, effectiveResolution, instrumentRef, viewState]);
828
- const detailChartEntry = useChartQuery(detailRequest);
829
- const detailBodyState = resolveChartBodyState(detailChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
830
- const history = (effectiveResolution === "auto" && viewState.zoomLevel > 1 && detailBodyState.data) ? detailBodyState.data : baseHistory;
831
- const baseHistoryDates = useMemo(() => getPointDates(baseHistory), [baseHistory]);
832
- const visibleDateWindow = useMemo(() => buildVisibleDateWindow(baseHistoryDates, viewState.panOffset, viewState.zoomLevel), [baseHistoryDates, viewState.panOffset, viewState.zoomLevel]);
833
- const activePreset = compact || !isCanonicalPresetViewport(baseHistoryDates, {
834
- activePreset: viewState.activePreset,
835
- panOffset: viewState.panOffset,
836
- zoomLevel: viewState.zoomLevel,
837
- resolution: effectiveResolution,
838
- })
2423
+
2424
+ const firstPassWidth = measureAxisWidth(measurementChartWidth);
2425
+ const refinedChartWidth = Math.max(width - firstPassWidth - axisRightPadding - axisGap, minChartWidth);
2426
+ return refinedChartWidth === measurementChartWidth ? firstPassWidth : measureAxisWidth(refinedChartWidth);
2427
+ }, [
2428
+ axisGap,
2429
+ axisMode,
2430
+ axisRightPadding,
2431
+ axisSectionWidthBudget,
2432
+ chartAssetCategory,
2433
+ chartCurrency,
2434
+ chartHeight,
2435
+ compact,
2436
+ displayedDateWindow,
2437
+ history,
2438
+ historyOverride,
2439
+ minChartWidth,
2440
+ minimumAxisWidth,
2441
+ resolveOhlcProjectionOptions,
2442
+ showVolume,
2443
+ viewState.renderMode,
2444
+ volumeHeight,
2445
+ width,
2446
+ ]);
2447
+ const axisSectionWidth = axisWidth + axisRightPadding;
2448
+ const chartWidth = Math.max(width - axisSectionWidth - axisGap, minChartWidth);
2449
+ const maxCursorX = chartWidth - 1;
2450
+ const panStep = getKeyboardPanCellCount(chartWidth);
2451
+ const activePreset = compact
839
2452
  ? null
840
- : viewState.activePreset;
2453
+ : effectiveResolution === "auto"
2454
+ ? (canonicalAutoWindow && displayedDateWindow && sameDateWindow(displayedDateWindow, canonicalAutoWindow)
2455
+ ? viewState.presetRange
2456
+ : null)
2457
+ : resolveVisibleActivePreset(boundsHistoryDates, {
2458
+ presetRange: viewState.presetRange,
2459
+ activePreset: viewState.activePreset,
2460
+ panOffset: viewState.panOffset,
2461
+ zoomLevel: viewState.zoomLevel,
2462
+ resolution: effectiveResolution,
2463
+ });
841
2464
 
842
2465
  useEffect(() => {
843
- if (compact || baseHistoryDates.length === 0) return;
844
-
845
- if (appliedCanonicalResetRef.current < pendingCanonicalResetRef.current) {
846
- const canonicalZoom = getCanonicalZoomLevel(baseHistoryDates, viewState.presetRange);
847
- appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
2466
+ if (compact || effectiveResolution === "auto" || boundsHistoryDates.length === 0) return;
2467
+
2468
+ const hasPendingCanonicalReset = appliedCanonicalResetRef.current < pendingCanonicalResetRef.current;
2469
+ const shouldReconcileActivePreset = !hasPendingCanonicalReset
2470
+ && needsCanonicalPresetViewportReset(boundsHistoryDates, viewState);
2471
+ if (hasPendingCanonicalReset || shouldReconcileActivePreset) {
2472
+ if (hasPendingCanonicalReset) {
2473
+ appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
2474
+ }
848
2475
  setViewState((current) => (
849
- current.zoomLevel === canonicalZoom && current.panOffset === 0
850
- ? current
851
- : {
852
- ...current,
853
- zoomLevel: canonicalZoom,
854
- panOffset: 0,
855
- cursorX: null,
856
- cursorY: null,
857
- }
2476
+ hasPendingCanonicalReset
2477
+ ? resolvePresetRangeViewport(current, boundsHistoryDates)
2478
+ : resolveCanonicalPresetViewport(current, boundsHistoryDates)
858
2479
  ));
859
2480
  return;
860
2481
  }
@@ -864,24 +2485,62 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
864
2485
  pendingExpansionRef.current = null;
865
2486
  setViewState((current) => {
866
2487
  if (pendingExpansion.kind === "zoom-out") {
867
- const nextVisibleCount = Math.min(baseHistoryDates.length, Math.max(pendingExpansion.targetVisibleCount, 1));
2488
+ const nextVisibleCount = Math.min(boundsHistoryDates.length, Math.max(pendingExpansion.targetVisibleCount, 1));
868
2489
  return {
869
2490
  ...current,
870
2491
  ...resolveAnchoredChartZoom(
871
- baseHistoryDates.length,
2492
+ boundsHistoryDates.length,
872
2493
  1,
873
2494
  0,
874
- baseHistoryDates.length / nextVisibleCount,
2495
+ boundsHistoryDates.length / nextVisibleCount,
875
2496
  pendingExpansion.anchorRatio,
876
2497
  ),
877
2498
  };
878
2499
  }
879
2500
  return {
880
2501
  ...current,
881
- panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxPanOffset(baseHistory, current.zoomLevel)),
2502
+ panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxPanOffset(boundsHistory, current.zoomLevel)),
882
2503
  };
883
2504
  });
884
- }, [baseHistory, baseHistoryDates, compact, viewState.presetRange]);
2505
+ }, [
2506
+ boundsHistory,
2507
+ boundsHistoryDates,
2508
+ compact,
2509
+ effectiveResolution,
2510
+ viewState.activePreset,
2511
+ viewState.panOffset,
2512
+ viewState.presetRange,
2513
+ viewState.zoomLevel,
2514
+ ]);
2515
+
2516
+ useEffect(() => {
2517
+ if (compact || effectiveResolution !== "auto" || !baseDateBounds) return;
2518
+
2519
+ const pendingAutoWindow = pendingAutoWindowRef.current;
2520
+ if (pendingAutoWindow) {
2521
+ const clampedPendingWindow = clampDateWindowToBounds(pendingAutoWindow, baseDateBounds, autoMinimumSpanMs);
2522
+ if (clampedPendingWindow && sameDateWindow(clampedPendingWindow, pendingAutoWindow)) {
2523
+ pendingAutoWindowRef.current = null;
2524
+ const normalizedPendingWindow = canonicalAutoWindow && sameDateWindow(clampedPendingWindow, canonicalAutoWindow)
2525
+ ? null
2526
+ : clampedPendingWindow;
2527
+ setPendingAutoWindowOverride((current) => (
2528
+ sameDateWindow(current, normalizedPendingWindow) ? current : normalizedPendingWindow
2529
+ ));
2530
+ }
2531
+ return;
2532
+ }
2533
+
2534
+ if (!pendingAutoWindowOverride) return;
2535
+ const clampedOverride = clampDateWindowToBounds(pendingAutoWindowOverride, baseDateBounds, autoMinimumSpanMs);
2536
+ if (!clampedOverride) return;
2537
+ const normalizedOverride = canonicalAutoWindow && sameDateWindow(clampedOverride, canonicalAutoWindow)
2538
+ ? null
2539
+ : clampedOverride;
2540
+ if (!sameDateWindow(pendingAutoWindowOverride, normalizedOverride)) {
2541
+ setPendingAutoWindowOverride(normalizedOverride);
2542
+ }
2543
+ }, [autoMinimumSpanMs, baseDateBounds, canonicalAutoWindow, compact, effectiveResolution, pendingAutoWindowOverride]);
885
2544
 
886
2545
  useEffect(() => {
887
2546
  if (interactive) {
@@ -940,31 +2599,91 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
940
2599
  : clampTimeRangeToMaxRange(nextCandidate, supportMap.get(effectiveResolution) ?? viewState.bufferRange);
941
2600
  if (nextBufferRange === viewState.bufferRange) return false;
942
2601
  pendingExpansionRef.current = action;
943
- setViewState((current) => ({
944
- ...clearActivePreset(current),
945
- bufferRange: nextBufferRange,
946
- cursorX: null,
947
- cursorY: null,
948
- }));
2602
+ setViewState((current) => applyBufferedPanExpansion(current, nextBufferRange));
2603
+ return true;
2604
+ };
2605
+
2606
+ const requestAutoWindow = (nextWindow: DateWindowRange | null | undefined): boolean => {
2607
+ if (compact || effectiveResolution !== "auto" || !nextWindow?.start || !nextWindow.end || !baseDateBounds?.start || !baseDateBounds.end) {
2608
+ return false;
2609
+ }
2610
+
2611
+ if (nextWindow.start.getTime() < baseDateBounds.start.getTime()) {
2612
+ const nextBufferRange = getNextBufferRange(viewState.bufferRange);
2613
+ if (nextBufferRange !== viewState.bufferRange) {
2614
+ pendingAutoWindowRef.current = nextWindow;
2615
+ setViewState((current) => {
2616
+ const nextState = clearAutoViewportState(current);
2617
+ return nextState.bufferRange === nextBufferRange
2618
+ ? nextState
2619
+ : { ...nextState, bufferRange: nextBufferRange };
2620
+ });
2621
+ return true;
2622
+ }
2623
+ }
2624
+
2625
+ const clampedWindow = clampDateWindowToBounds(nextWindow, baseDateBounds, autoMinimumSpanMs);
2626
+ if (!clampedWindow) {
2627
+ return false;
2628
+ }
2629
+ const normalizedWindow = canonicalAutoWindow && sameDateWindow(clampedWindow, canonicalAutoWindow)
2630
+ ? null
2631
+ : clampedWindow;
2632
+ if (sameDateWindow(navigableDateWindow, normalizedWindow)) {
2633
+ return false;
2634
+ }
2635
+ pendingAutoWindowRef.current = null;
2636
+ setPendingAutoWindowOverride((current) => (sameDateWindow(current, normalizedWindow) ? current : normalizedWindow));
2637
+ setViewState((current) => clearAutoViewportState(current));
949
2638
  return true;
950
2639
  };
951
2640
 
952
2641
  const setRange = (range: TimeRange) => {
953
2642
  if (!compact && !isRangePresetSupported(range, effectiveResolutionSupport)) return;
954
2643
  const supportMaxRange = getSupportMaxRange(effectiveResolutionSupport, getPresetResolution(range));
2644
+ const nextResolution = getPresetResolution(range);
2645
+ setRequestedResolution(nextResolution);
955
2646
  if (!compact) {
956
- persistChartControls(range, getPresetResolution(range));
2647
+ persistChartControls(range, nextResolution);
957
2648
  }
958
2649
  pendingCanonicalResetRef.current += 1;
2650
+ pendingAutoWindowRef.current = null;
2651
+ setPendingAutoWindowOverride(null);
959
2652
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
960
- setViewState((current) => resolvePresetSelection(current, range, supportMaxRange));
2653
+ setViewState((current) => resolveViewportPresetSelection(current, range, supportMaxRange));
961
2654
  };
962
2655
 
963
2656
  const setResolution = (resolution: ChartResolution) => {
964
2657
  if (compact) return;
965
2658
  if (resolution !== "auto" && !availableManualResolutions.includes(resolution)) return;
966
- const nextState = resolveResolutionSelection(viewState, resolution, supportMap, visibleDateWindow);
2659
+
2660
+ if (resolution === "auto") {
2661
+ const preservedWindow = effectiveResolution === "auto" ? navigableDateWindow : manualVisibleDateWindow;
2662
+ const nextAutoWindow = clampDateWindowToBounds(preservedWindow, baseDateBounds, autoMinimumSpanMs);
2663
+ pendingAutoWindowRef.current = null;
2664
+ setPendingAutoWindowOverride(
2665
+ canonicalAutoWindow && nextAutoWindow && sameDateWindow(nextAutoWindow, canonicalAutoWindow)
2666
+ ? null
2667
+ : nextAutoWindow,
2668
+ );
2669
+ if (effectiveResolution !== "auto") {
2670
+ setRenderedAutoView(null);
2671
+ }
2672
+ setRequestedResolution(resolution);
2673
+ persistChartControls(viewState.presetRange, resolution);
2674
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
2675
+ setViewState((current) => ({
2676
+ ...clearAutoViewportState(current),
2677
+ bufferRange: getNextBufferRange(current.presetRange),
2678
+ }));
2679
+ return;
2680
+ }
2681
+
2682
+ const nextState = resolveViewportResolutionSelection(viewState, resolution, selectionSupportMap, visibleDateWindow);
967
2683
  if (!nextState) return;
2684
+ setRequestedResolution(resolution);
2685
+ pendingAutoWindowRef.current = null;
2686
+ setPendingAutoWindowOverride(null);
968
2687
  pendingCanonicalResetRef.current += 1;
969
2688
  persistChartControls(nextState.presetRange, resolution);
970
2689
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
@@ -976,58 +2695,146 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
976
2695
  setViewState((current) => ({ ...current, renderMode: mode }));
977
2696
  };
978
2697
 
979
- const headerRows = compact ? 0 : 4;
980
- const helpRow = compact ? 0 : 1;
981
- const timeAxisRow = 1;
982
- const volumeHeight = showVolume && !compact ? 3 : 0;
983
- const chartHeight = Math.max(height - headerRows - helpRow - timeAxisRow, 4);
984
- const isDetailView = effectiveResolution === "auto" && viewState.zoomLevel > 1 && !!detailBodyState.data?.length;
985
- const historyRenderKey = history.length === 0
2698
+ const focusPaneForMouseInteraction = (event: { stopPropagation?: () => void; preventDefault?: () => void } | null | undefined) => {
2699
+ event?.stopPropagation?.();
2700
+ event?.preventDefault?.();
2701
+ if (!focused) {
2702
+ dispatch({ type: "FOCUS_PANE", paneId });
2703
+ }
2704
+ };
2705
+
2706
+ const chartWindow = useMemo(() => {
2707
+ if (historyOverride || !displayedDateWindow?.start || !displayedDateWindow.end) {
2708
+ return { points: history, startIdx: 0, endIdx: history.length };
2709
+ }
2710
+ return getVisibleWindowForDateRange(history, displayedDateWindow, 0);
2711
+ }, [displayedDateWindow, history, historyOverride]);
2712
+ const historyRenderKey = chartWindow.points.length === 0
986
2713
  ? "empty"
987
2714
  : [
988
- history.length,
989
- new Date(history[0]!.date).getTime(),
990
- new Date(history[history.length - 1]!.date).getTime(),
991
- history[history.length - 1]!.close,
2715
+ chartWindow.points.length,
2716
+ new Date(chartWindow.points[0]!.date).getTime(),
2717
+ new Date(chartWindow.points[chartWindow.points.length - 1]!.date).getTime(),
2718
+ chartWindow.points[chartWindow.points.length - 1]!.close,
992
2719
  ].join(":");
993
2720
 
994
2721
  useEffect(() => {
995
2722
  queueMicrotask(() => renderer.requestRender());
996
2723
  }, [chartHeight, chartWidth, compact, historyRenderKey, renderer, ticker?.metadata.ticker, viewState.renderMode]);
997
2724
 
998
- const chartWindow = useMemo(() => {
999
- if (historyOverride) {
1000
- return { points: history, startIdx: 0, endIdx: history.length };
1001
- }
1002
- return isDetailView
1003
- ? { points: history, startIdx: 0, endIdx: history.length }
1004
- : getVisibleWindow(history, viewState, chartWidth);
1005
- }, [chartWidth, history, historyOverride, isDetailView, viewState.panOffset, viewState.zoomLevel]);
1006
2725
  const timeAxisDates = useMemo(
1007
2726
  () => chartWindow.points.map((point) => point.date),
1008
2727
  [chartWindow.points],
1009
2728
  );
1010
2729
 
1011
2730
  const projection = useMemo(() => (
1012
- projectChartData(chartWindow.points, chartWidth, viewState.renderMode, !!compact)
1013
- ), [chartWindow.points, chartWidth, compact, viewState.renderMode]);
2731
+ projectChartData(
2732
+ chartWindow.points,
2733
+ chartWidth,
2734
+ viewState.renderMode,
2735
+ !!compact,
2736
+ resolveOhlcProjectionOptions(chartWindow.points.length, chartWindow.startIdx),
2737
+ )
2738
+ ), [
2739
+ chartWindow.points,
2740
+ chartWindow.startIdx,
2741
+ chartWidth,
2742
+ compact,
2743
+ resolveOhlcProjectionOptions,
2744
+ viewState.renderMode,
2745
+ ]);
2746
+
2747
+ const sourceIndicatorOverlays = useMemo(() => {
2748
+ if (!hasIndicators || !history.length) return null;
2749
+ return computeIndicatorOverlays(history.map((point) => point.close), indicatorConfig);
2750
+ }, [history, hasIndicators, indicatorConfig]);
2751
+
2752
+ const indicatorSourceKey = useMemo(() => (
2753
+ sourceIndicatorOverlays ? buildIndicatorSourceKey(history, indicatorConfig) : "none"
2754
+ ), [history, indicatorConfig, sourceIndicatorOverlays]);
2755
+ const indicatorProjectionKey = useMemo(() => {
2756
+ if (!sourceIndicatorOverlays || !chartWindow.points.length || !projection.points.length) return "none";
2757
+ return buildIndicatorProjectionKey({
2758
+ sourceKey: indicatorSourceKey,
2759
+ sourcePoints: chartWindow.points,
2760
+ sourceIndexOffset: chartWindow.startIdx,
2761
+ projectedPoints: projection.points,
2762
+ mode: projection.effectiveMode,
2763
+ });
2764
+ }, [
2765
+ chartWindow.points,
2766
+ chartWindow.startIdx,
2767
+ indicatorSourceKey,
2768
+ projection.effectiveMode,
2769
+ projection.points,
2770
+ sourceIndicatorOverlays,
2771
+ ]);
2772
+ const [deferredIndicators, setDeferredIndicators] = useState<DeferredIndicatorState | null>(null);
2773
+
2774
+ useEffect(() => {
2775
+ if (!sourceIndicatorOverlays || indicatorProjectionKey === "none" || !chartWindow.points.length || !projection.points.length) {
2776
+ setDeferredIndicators((current) => (current === null ? current : null));
2777
+ return;
2778
+ }
2779
+
2780
+ const timeout = setTimeout(() => {
2781
+ setDeferredIndicators({
2782
+ key: indicatorProjectionKey,
2783
+ overlays: reindexIndicatorOverlaysForProjection(
2784
+ sourceIndicatorOverlays,
2785
+ chartWindow.points,
2786
+ projection.points,
2787
+ chartWindow.startIdx,
2788
+ ),
2789
+ });
2790
+ }, INDICATOR_RENDER_DEBOUNCE_MS);
2791
+
2792
+ return () => clearTimeout(timeout);
2793
+ }, [
2794
+ chartWindow.points,
2795
+ chartWindow.startIdx,
2796
+ indicatorProjectionKey,
2797
+ projection.points,
2798
+ sourceIndicatorOverlays,
2799
+ ]);
2800
+
2801
+ const indicators = deferredIndicators?.key === indicatorProjectionKey ? deferredIndicators.overlays : null;
2802
+ const indicatorRenderKey = useMemo(() => {
2803
+ return buildIndicatorRenderKey(indicators);
2804
+ }, [indicators]);
1014
2805
 
1015
2806
  useKeyboard((event) => {
1016
2807
  if (!focused || compact) return;
1017
-
1018
- switch (event.name) {
1019
- case "=":
1020
- setViewState((current) => applyZoomAroundAnchor(
1021
- current,
1022
- current.zoomLevel * 1.5,
1023
- RIGHT_EDGE_ANCHOR_RATIO,
1024
- baseHistory,
1025
- ));
2808
+ const key = resolveChartKeyboardKey(event);
2809
+
2810
+ switch (key) {
2811
+ case "zoom-in":
2812
+ if (effectiveResolution === "auto") {
2813
+ requestAutoWindow(resolveAutoZoomWindow({
2814
+ historyPoints: history,
2815
+ boundsDates: boundsHistoryDates,
2816
+ currentWindow: navigableDateWindow,
2817
+ direction: "in",
2818
+ anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
2819
+ }));
2820
+ return;
2821
+ }
2822
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.5, RIGHT_EDGE_ANCHOR_RATIO, boundsHistory));
1026
2823
  return;
1027
- case "-":
2824
+ case "zoom-out":
2825
+ if (effectiveResolution === "auto") {
2826
+ requestAutoWindow(resolveAutoZoomWindow({
2827
+ historyPoints: history,
2828
+ boundsDates: boundsHistoryDates,
2829
+ currentWindow: navigableDateWindow,
2830
+ direction: "out",
2831
+ anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
2832
+ }));
2833
+ return;
2834
+ }
1028
2835
  if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1029
2836
  kind: "zoom-out",
1030
- targetVisibleCount: Math.round(baseHistory.length * 1.5),
2837
+ targetVisibleCount: Math.round(boundsHistory.length * 1.5),
1031
2838
  anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
1032
2839
  })) {
1033
2840
  return;
@@ -1036,14 +2843,27 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1036
2843
  current,
1037
2844
  current.zoomLevel / 1.5,
1038
2845
  RIGHT_EDGE_ANCHOR_RATIO,
1039
- baseHistory,
2846
+ boundsHistory,
1040
2847
  ));
1041
2848
  return;
1042
2849
  case "0":
2850
+ if (effectiveResolution === "auto") {
2851
+ pendingAutoWindowRef.current = null;
2852
+ setPendingAutoWindowOverride(null);
2853
+ setRenderedAutoView(null);
2854
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
2855
+ setViewState((current) => clearAutoViewportState(current));
2856
+ return;
2857
+ }
1043
2858
  pendingCanonicalResetRef.current += 1;
1044
2859
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1045
2860
  setViewState((current) => {
1046
- const nextState = resolveResolutionSelection(current, effectiveResolution, supportMap, visibleDateWindow) ?? current;
2861
+ const nextState = resolveViewportResolutionSelection(
2862
+ current,
2863
+ effectiveResolution,
2864
+ selectionSupportMap,
2865
+ visibleDateWindow,
2866
+ ) ?? current;
1047
2867
  return {
1048
2868
  ...nextState,
1049
2869
  panOffset: 0,
@@ -1053,20 +2873,32 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1053
2873
  };
1054
2874
  });
1055
2875
  return;
1056
- case "v":
1057
- setShowVolume((value) => !value);
1058
- return;
1059
2876
  case "a":
1060
- if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
2877
+ if (effectiveResolution === "auto") {
2878
+ requestAutoWindow(shiftDateWindow(navigableDateWindow, panStep / Math.max(chartWidth, 1)));
2879
+ return;
2880
+ }
2881
+ if (viewState.panOffset >= getMaxPanOffset(boundsHistory, viewState.zoomLevel) && expandBufferRange({
1061
2882
  kind: "pan-left",
1062
2883
  targetPanOffset: viewState.panOffset + panStep,
1063
2884
  })) {
1064
2885
  return;
1065
2886
  }
1066
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
2887
+ setViewState((current) => {
2888
+ const cleared = clearActivePreset(current);
2889
+ return { ...cleared, panOffset: current.panOffset + panStep };
2890
+ });
1067
2891
  return;
1068
2892
  case "d":
1069
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
2893
+ if (effectiveResolution === "auto") {
2894
+ requestAutoWindow(shiftDateWindow(navigableDateWindow, -panStep / Math.max(chartWidth, 1)));
2895
+ return;
2896
+ }
2897
+ setViewState((current) => {
2898
+ const cleared = clearActivePreset(current);
2899
+ const nextPanOffset = Math.max(current.panOffset - panStep, 0);
2900
+ return cleared.panOffset === nextPanOffset ? cleared : { ...cleared, panOffset: nextPanOffset };
2901
+ });
1070
2902
  return;
1071
2903
  case "m":
1072
2904
  setViewState((current) => {
@@ -1078,51 +2910,66 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1078
2910
  });
1079
2911
  return;
1080
2912
  case "r": {
1081
- const currentIndex = resolutionChips.indexOf(effectiveResolution);
2913
+ const currentIndex = resolutionChips.indexOf(selectedResolution);
1082
2914
  const nextResolution = resolutionChips[(currentIndex + 1) % resolutionChips.length] ?? "auto";
1083
2915
  setResolution(nextResolution);
1084
2916
  return;
1085
2917
  }
1086
2918
  }
1087
2919
 
1088
- if (event.name >= "1" && event.name <= "7") {
1089
- const index = parseInt(event.name) - 1;
2920
+ if (key >= "1" && key <= "7") {
2921
+ const index = parseInt(key) - 1;
1090
2922
  if (index < TIME_RANGES.length) setRange(TIME_RANGES[index]!);
1091
2923
  return;
1092
2924
  }
1093
2925
 
1094
2926
  if (!interactive) return;
1095
2927
 
1096
- switch (event.name) {
2928
+ switch (key) {
1097
2929
  case "left":
1098
2930
  if (event.shift) {
1099
- if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
2931
+ if (effectiveResolution === "auto") {
2932
+ requestAutoWindow(shiftDateWindow(navigableDateWindow, panStep / Math.max(chartWidth, 1)));
2933
+ return;
2934
+ }
2935
+ if (viewState.panOffset >= getMaxPanOffset(boundsHistory, viewState.zoomLevel) && expandBufferRange({
1100
2936
  kind: "pan-left",
1101
2937
  targetPanOffset: viewState.panOffset + panStep,
1102
2938
  })) {
1103
2939
  return;
1104
2940
  }
1105
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
2941
+ setViewState((current) => {
2942
+ const cleared = clearActivePreset(current);
2943
+ return { ...cleared, panOffset: current.panOffset + panStep };
2944
+ });
1106
2945
  } else {
1107
2946
  cursorMotionKindRef.current = "discrete";
1108
- setViewState((current) => {
1109
- const pointCount = projection.points.length;
1110
- const currentIndex = pointCount <= 0
1111
- ? -1
1112
- : getActivePointIndex(
1113
- pointCount,
1114
- chartWidth,
1115
- current.cursorX ?? maxCursorX,
1116
- projection.effectiveMode,
1117
- );
1118
- const nextCursor = resolveAdjacentSelectionCursorX(
1119
- current.cursorX,
1120
- -1,
2947
+ const pointCount = projection.points.length;
2948
+ const currentIndex = pointCount <= 0
2949
+ ? -1
2950
+ : getActivePointIndex(
1121
2951
  pointCount,
1122
2952
  chartWidth,
2953
+ viewState.cursorX ?? maxCursorX,
1123
2954
  projection.effectiveMode,
1124
2955
  );
1125
- const maxPan = getMaxPanOffset(baseHistory, current.zoomLevel);
2956
+ const nextCursor = resolveAdjacentSelectionCursorX(
2957
+ viewState.cursorX,
2958
+ -1,
2959
+ pointCount,
2960
+ chartWidth,
2961
+ projection.effectiveMode,
2962
+ );
2963
+ if (effectiveResolution === "auto" && currentIndex <= 0) {
2964
+ requestAutoWindow(shiftDateWindow(
2965
+ navigableDateWindow,
2966
+ 1 / Math.max(pointCount - 1, 1),
2967
+ ));
2968
+ commitSelectionCursor({ cursorX: nextCursor, cursorY: null });
2969
+ return;
2970
+ }
2971
+ setViewState((current) => {
2972
+ const maxPan = getMaxPanOffset(boundsHistory, current.zoomLevel);
1126
2973
  if (currentIndex <= 0) {
1127
2974
  return {
1128
2975
  ...clearActivePreset(current),
@@ -1137,26 +2984,42 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1137
2984
  return;
1138
2985
  case "right":
1139
2986
  if (event.shift) {
1140
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
2987
+ if (effectiveResolution === "auto") {
2988
+ requestAutoWindow(shiftDateWindow(navigableDateWindow, -panStep / Math.max(chartWidth, 1)));
2989
+ return;
2990
+ }
2991
+ setViewState((current) => {
2992
+ const cleared = clearActivePreset(current);
2993
+ const nextPanOffset = Math.max(current.panOffset - panStep, 0);
2994
+ return cleared.panOffset === nextPanOffset ? cleared : { ...cleared, panOffset: nextPanOffset };
2995
+ });
1141
2996
  } else {
1142
2997
  cursorMotionKindRef.current = "discrete";
1143
- setViewState((current) => {
1144
- const pointCount = projection.points.length;
1145
- const currentIndex = pointCount <= 0
1146
- ? -1
1147
- : getActivePointIndex(
1148
- pointCount,
1149
- chartWidth,
1150
- current.cursorX ?? 0,
1151
- projection.effectiveMode,
1152
- );
1153
- const nextCursor = resolveAdjacentSelectionCursorX(
1154
- current.cursorX,
1155
- 1,
2998
+ const pointCount = projection.points.length;
2999
+ const currentIndex = pointCount <= 0
3000
+ ? -1
3001
+ : getActivePointIndex(
1156
3002
  pointCount,
1157
3003
  chartWidth,
3004
+ viewState.cursorX ?? 0,
1158
3005
  projection.effectiveMode,
1159
3006
  );
3007
+ const nextCursor = resolveAdjacentSelectionCursorX(
3008
+ viewState.cursorX,
3009
+ 1,
3010
+ pointCount,
3011
+ chartWidth,
3012
+ projection.effectiveMode,
3013
+ );
3014
+ if (effectiveResolution === "auto" && currentIndex >= pointCount - 1) {
3015
+ requestAutoWindow(shiftDateWindow(
3016
+ navigableDateWindow,
3017
+ -1 / Math.max(pointCount - 1, 1),
3018
+ ));
3019
+ commitSelectionCursor({ cursorX: nextCursor, cursorY: null });
3020
+ return;
3021
+ }
3022
+ setViewState((current) => {
1160
3023
  if (currentIndex >= pointCount - 1) {
1161
3024
  return {
1162
3025
  ...clearActivePreset(current),
@@ -1187,7 +3050,6 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1187
3050
  negative: colors.negative,
1188
3051
  }, trend);
1189
3052
  }, [chartWindow.points]);
1190
- const chartCurrency = currencyOverride ?? financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
1191
3053
 
1192
3054
  const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
1193
3055
  const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
@@ -1260,7 +3122,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1260
3122
  axisMode,
1261
3123
  colors: chartColors,
1262
3124
  timeAxisDates,
1263
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3125
+ indicators,
3126
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
1264
3127
 
1265
3128
  const rendererState = resolveChartRendererState(preferredRenderer, kittySupport, renderer.resolution);
1266
3129
  const effectiveRenderer: ResolvedChartRenderer = rendererState.renderer;
@@ -1275,9 +3138,11 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1275
3138
  mode: projection.effectiveMode,
1276
3139
  axisMode,
1277
3140
  currency: chartCurrency,
3141
+ assetCategory: chartAssetCategory,
1278
3142
  colors: chartColors,
1279
3143
  timeAxisDates,
1280
- }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3144
+ indicators,
3145
+ }), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
1281
3146
 
1282
3147
  const interactiveResult = useMemo(() => (
1283
3148
  effectiveRenderer === "kitty"
@@ -1292,10 +3157,12 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1292
3157
  mode: projection.effectiveMode,
1293
3158
  axisMode,
1294
3159
  currency: chartCurrency,
3160
+ assetCategory: chartAssetCategory,
1295
3161
  colors: chartColors,
1296
3162
  timeAxisDates,
3163
+ indicators,
1297
3164
  })
1298
- ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3165
+ ), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
1299
3166
 
1300
3167
  const result = effectiveRenderer === "kitty" ? staticResult : interactiveResult!;
1301
3168
 
@@ -1341,10 +3208,10 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1341
3208
  if (effectiveRenderer === "kitty") return;
1342
3209
  lastNativeBaseBitmapRef.current = null;
1343
3210
  lastNativeCrosshairBitmapRef.current = null;
1344
- nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
1345
- nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
3211
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceId);
3212
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceId);
1346
3213
  lastNativeGeometryRef.current = null;
1347
- }, [effectiveRenderer, nativeSurfaceManager]);
3214
+ }, [effectiveRenderer, nativeBaseSurfaceId, nativeCrosshairSurfaceId, nativeSurfaceManager]);
1348
3215
 
1349
3216
  useEffect(() => {
1350
3217
  if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !plotRef.current) return;
@@ -1375,13 +3242,13 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1375
3242
  };
1376
3243
  syncCachedNativeSurface(
1377
3244
  nativeSurfaceManager,
1378
- nativeBaseSurfaceIdRef.current,
3245
+ nativeBaseSurfaceId,
1379
3246
  geometry,
1380
3247
  lastNativeBaseBitmapRef.current,
1381
3248
  );
1382
3249
  syncCachedNativeSurface(
1383
3250
  nativeSurfaceManager,
1384
- nativeCrosshairSurfaceIdRef.current,
3251
+ nativeCrosshairSurfaceId,
1385
3252
  geometry,
1386
3253
  lastNativeCrosshairBitmapRef.current,
1387
3254
  );
@@ -1400,12 +3267,12 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1400
3267
  renderer.unregisterLifecyclePass(plot);
1401
3268
  lastNativeGeometryRef.current = null;
1402
3269
  };
1403
- }, [effectiveRenderer, nativeSurfaceManager, paneId, renderer, rendererState.nativeReady]);
3270
+ }, [effectiveRenderer, nativeBaseSurfaceId, nativeCrosshairSurfaceId, nativeSurfaceManager, paneId, renderer, rendererState.nativeReady]);
1404
3271
 
1405
3272
  useEffect(() => {
1406
3273
  if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !nativeBaseScene) {
1407
3274
  lastNativeBaseBitmapRef.current = null;
1408
- nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
3275
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceId);
1409
3276
  return;
1410
3277
  }
1411
3278
 
@@ -1428,6 +3295,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1428
3295
  chartColors.candleUp,
1429
3296
  chartColors.candleDown,
1430
3297
  ].join(","),
3298
+ indicatorRenderKey,
1431
3299
  );
1432
3300
  const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
1433
3301
  ? lastNativeBaseBitmapRef.current.bitmap
@@ -1438,12 +3306,12 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1438
3306
  }
1439
3307
 
1440
3308
  if (!visibleRect) {
1441
- nativeSurfaceManager.removeSurface(nativeBaseSurfaceIdRef.current);
3309
+ nativeSurfaceManager.removeSurface(nativeBaseSurfaceId);
1442
3310
  return;
1443
3311
  }
1444
3312
 
1445
3313
  nativeSurfaceManager.upsertSurface({
1446
- id: nativeBaseSurfaceIdRef.current,
3314
+ id: nativeBaseSurfaceId,
1447
3315
  paneId,
1448
3316
  rect: plotRect,
1449
3317
  visibleRect,
@@ -1461,7 +3329,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1461
3329
  chartColors.volumeUp,
1462
3330
  compact,
1463
3331
  effectiveRenderer,
3332
+ indicatorRenderKey,
1464
3333
  nativeBaseScene,
3334
+ nativeBaseSurfaceId,
1465
3335
  nativeSurfaceManager,
1466
3336
  paneId,
1467
3337
  projection.effectiveMode,
@@ -1474,7 +3344,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1474
3344
  useEffect(() => {
1475
3345
  if (effectiveRenderer !== "kitty" || !rendererState.nativeReady || !renderer.resolution || !plotRef.current || !nativeCrosshair) {
1476
3346
  lastNativeCrosshairBitmapRef.current = null;
1477
- nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
3347
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceId);
1478
3348
  return;
1479
3349
  }
1480
3350
 
@@ -1495,7 +3365,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1495
3365
 
1496
3366
  if (overlayPixelX === null || overlayPixelY === null) {
1497
3367
  lastNativeCrosshairBitmapRef.current = null;
1498
- nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
3368
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceId);
1499
3369
  return;
1500
3370
  }
1501
3371
 
@@ -1522,12 +3392,12 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1522
3392
  }
1523
3393
 
1524
3394
  if (!visibleRect) {
1525
- nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceIdRef.current);
3395
+ nativeSurfaceManager.removeSurface(nativeCrosshairSurfaceId);
1526
3396
  return;
1527
3397
  }
1528
3398
 
1529
3399
  nativeSurfaceManager.upsertSurface({
1530
- id: nativeCrosshairSurfaceIdRef.current,
3400
+ id: nativeCrosshairSurfaceId,
1531
3401
  paneId,
1532
3402
  rect: plotRect,
1533
3403
  visibleRect,
@@ -1537,53 +3407,52 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1537
3407
  }, [
1538
3408
  effectiveRenderer,
1539
3409
  nativeCrosshair,
3410
+ nativeCrosshairSurfaceId,
1540
3411
  nativeSurfaceManager,
1541
3412
  paneId,
1542
3413
  renderer,
1543
3414
  rendererState.nativeReady,
1544
3415
  ]);
1545
3416
 
1546
- const hasHistory = history.length > 0;
1547
- const firstPrice = chartWindow.points[0]?.close ?? 0;
1548
- const lastPrice = chartWindow.points[chartWindow.points.length - 1]?.close ?? 0;
1549
- const change = lastPrice - firstPrice;
1550
- const changePct = firstPrice ? (change / firstPrice) * 100 : 0;
3417
+ const hasHistory = chartWindow.points.length > 0;
1551
3418
  const requestedMode = projection.requestedMode;
1552
3419
  const showOhlcSummary = projection.effectiveMode === "candles" || projection.effectiveMode === "ohlc";
1553
3420
  const hasSelectionCursor = selectionCursorX !== null;
1554
3421
  const hasDisplayCursor = displayCursorX !== null && displayCursorY !== null;
1555
- const displayPrice = !hasHistory
1556
- ? null
1557
- : hasSelectionCursor
1558
- ? (selectionScene?.priceAtCursor ?? lastPrice)
1559
- : lastPrice;
1560
- const displayChange = !hasHistory
1561
- ? null
1562
- : hasSelectionCursor
1563
- ? (selectionScene?.changeAtCursor ?? change)
1564
- : change;
1565
- const displayChangePct = !hasHistory
1566
- ? null
1567
- : hasSelectionCursor
1568
- ? (selectionScene?.changePctAtCursor ?? changePct)
1569
- : changePct;
1570
3422
  const displayDate = hasSelectionCursor || showOhlcSummary
1571
3423
  ? (selectionScene?.dateAtCursor ? formatDateShort(selectionScene.dateAtCursor) : null)
1572
3424
  : null;
1573
3425
  const activePoint = showOhlcSummary ? (selectionScene?.activePoint ?? null) : null;
3426
+ const visiblePriceRange = selectionScene
3427
+ ? Math.max(selectionScene.max - selectionScene.min, 0)
3428
+ : (staticResult.priceRange ?? undefined);
1574
3429
  const axisLabels = new Map(staticResult.axisLabels.map((entry) => [entry.row, entry.label]));
1575
3430
  const cursorAxisLabel = hasDisplayCursor && cursorRow !== null && crosshairPrice !== null
1576
- ? formatAxisValue(crosshairPrice, axisMode, projection.points[0]?.close ?? 0, chartCurrency)
3431
+ ? formatCursorAxisValue(
3432
+ crosshairPrice,
3433
+ axisMode,
3434
+ projection.points[0]?.close ?? 0,
3435
+ chartCurrency,
3436
+ chartAssetCategory,
3437
+ visiblePriceRange,
3438
+ )
1577
3439
  : null;
1578
- const visibleLabel = formatVisibleSpanLabel({
1579
- start: chartWindow.points[0]?.date ? coerceChartDate(chartWindow.points[0].date as Date | string | number) : null,
1580
- end: chartWindow.points[chartWindow.points.length - 1]?.date
1581
- ? coerceChartDate(chartWindow.points[chartWindow.points.length - 1]!.date as Date | string | number)
1582
- : null,
1583
- });
1584
- const isBlockingBody = !compact && baseBodyState.blocking;
1585
- const bodyMessage = !compact ? (baseBodyState.errorMessage ?? baseBodyState.emptyMessage) : null;
1586
- const isUpdating = !compact && (baseBodyState.updating || (detailChartEntry?.phase === "refreshing" && !!detailBodyState.data?.length));
3440
+ const isBlockingBody = !compact && renderBodyState.blocking;
3441
+ const bodyMessage = !compact
3442
+ ? (renderBodyState.errorMessage ?? renderBodyState.emptyMessage)
3443
+ : null;
3444
+ const isUpdating = !compact && (renderBodyState.updating || boundsBodyState.updating);
3445
+
3446
+ useEffect(() => {
3447
+ queueMicrotask(() => renderer.requestRender());
3448
+ }, [
3449
+ activePreset,
3450
+ bodyMessage,
3451
+ fallbackResolutionLabel,
3452
+ isUpdating,
3453
+ renderer,
3454
+ selectedResolution,
3455
+ ]);
1587
3456
 
1588
3457
  const handlePlotMove = (event: ChartMouseEvent) => {
1589
3458
  if (!interactive || compact) return;
@@ -1606,6 +3475,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1606
3475
 
1607
3476
  const handlePlotDown = (event: ChartMouseEvent) => {
1608
3477
  if (!interactive || compact) return;
3478
+ focusPaneForMouseInteraction(event);
1609
3479
  const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1610
3480
  if (!localPointer) return;
1611
3481
  const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
@@ -1623,6 +3493,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1623
3493
  dragRef.current = {
1624
3494
  startGlobalX: getGlobalMouseX(event, renderer),
1625
3495
  startPanOffset: viewState.panOffset,
3496
+ startWindow: navigableDateWindow,
1626
3497
  };
1627
3498
  commitSelectionCursor(selectionCursor);
1628
3499
  };
@@ -1647,24 +3518,48 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1647
3518
  }
1648
3519
  if (!dragRef.current) return;
1649
3520
 
1650
- const visibleCount = getVisiblePointCount(baseHistory.length, viewState.zoomLevel);
3521
+ if (effectiveResolution === "auto" && dragRef.current.startWindow) {
3522
+ const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
3523
+ requestAutoWindow(shiftDateWindow(
3524
+ dragRef.current.startWindow,
3525
+ -getDragPanWindowRatio(deltaCells, chartWidth),
3526
+ ));
3527
+ return;
3528
+ }
3529
+
3530
+ const visibleCount = getVisiblePointCount(boundsHistory.length, viewState.zoomLevel);
1651
3531
  const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1652
- const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * visibleCount);
1653
- const nextPan = clamp(
1654
- dragRef.current.startPanOffset - pointDelta,
1655
- 0,
1656
- Math.max(baseHistory.length - visibleCount, 0),
3532
+ const nextPan = resolveDragPanOffset(
3533
+ dragRef.current.startPanOffset,
3534
+ deltaCells,
3535
+ chartWidth,
3536
+ visibleCount,
3537
+ boundsHistory.length - visibleCount,
1657
3538
  );
1658
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
3539
+ setViewState((current) => {
3540
+ const cleared = clearActivePreset(current);
3541
+ return cleared.panOffset === nextPan ? cleared : { ...cleared, panOffset: nextPan };
3542
+ });
1659
3543
  };
1660
3544
 
1661
3545
  const handlePlotScroll = (event: ChartMouseEvent) => {
1662
- if (!interactive || compact) return;
3546
+ if (compact) return;
3547
+ event.stopPropagation?.();
3548
+ event.preventDefault?.();
3549
+ focusPaneForMouseInteraction(event);
1663
3550
  const direction = event.scroll?.direction;
1664
3551
  if (!direction) return;
3552
+ const scrollPan = consumeScrollPanMovement(
3553
+ chartWidth,
3554
+ event.scroll?.delta,
3555
+ direction,
3556
+ scrollPanCellRemainderRef.current,
3557
+ );
3558
+ scrollPanCellRemainderRef.current = scrollPan.remainder;
3559
+ const scrollPanCells = scrollPan.cells;
3560
+ const scrollPanRatio = scrollPan.ratio;
1665
3561
 
1666
3562
  const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1667
- const anchorRatio = localPointer ? localPointer.cellX / Math.max(chartWidth - 1, 1) : 0.5;
1668
3563
 
1669
3564
  if (localPointer) {
1670
3565
  const selectionCursor = resolveSelectionCursor(localPointer, projection.points.length, chartWidth, projection.effectiveMode);
@@ -1682,72 +3577,35 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1682
3577
  commitSelectionCursor(selectionCursor);
1683
3578
  }
1684
3579
 
1685
- if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1686
- const shiftDirection = direction === "up" ? 1 : -1;
1687
- const targetPanOffset = viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1);
1688
- if (shiftDirection > 0 && viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1689
- kind: "pan-left",
1690
- targetPanOffset,
1691
- })) {
1692
- return;
1693
- }
1694
- const nextPan = clamp(
1695
- targetPanOffset,
1696
- 0,
1697
- getMaxPanOffset(baseHistory, viewState.zoomLevel),
1698
- );
1699
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
3580
+ if (scrollPanCells === 0) return;
3581
+
3582
+ if (effectiveResolution === "auto") {
3583
+ requestAutoWindow(shiftDateWindow(navigableDateWindow, scrollPanRatio));
1700
3584
  return;
1701
3585
  }
1702
3586
 
1703
- switch (direction) {
1704
- case "up":
1705
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory));
1706
- return;
1707
- case "down":
1708
- if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1709
- kind: "zoom-out",
1710
- targetVisibleCount: Math.round(baseHistory.length * 1.18),
1711
- anchorRatio,
1712
- })) {
1713
- return;
1714
- }
1715
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory));
1716
- return;
1717
- case "left":
1718
- if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1719
- kind: "pan-left",
1720
- targetPanOffset: viewState.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1721
- })) {
1722
- return;
1723
- }
1724
- setViewState((current) => ({
1725
- ...clearActivePreset(current),
1726
- panOffset: clamp(
1727
- current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1728
- 0,
1729
- getMaxPanOffset(baseHistory, current.zoomLevel),
1730
- ),
1731
- }));
1732
- return;
1733
- case "right":
1734
- setViewState((current) => ({
1735
- ...clearActivePreset(current),
1736
- panOffset: clamp(
1737
- current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1738
- 0,
1739
- getMaxPanOffset(baseHistory, current.zoomLevel),
1740
- ),
1741
- }));
1742
- return;
3587
+ const targetPanOffset = viewState.panOffset + scrollPanCells;
3588
+ if (scrollPanCells > 0 && viewState.panOffset >= getMaxPanOffset(boundsHistory, viewState.zoomLevel) && expandBufferRange({
3589
+ kind: "pan-left",
3590
+ targetPanOffset,
3591
+ })) {
3592
+ return;
1743
3593
  }
3594
+ setViewState((current) => ({
3595
+ ...clearActivePreset(current),
3596
+ panOffset: clamp(
3597
+ current.panOffset + scrollPanCells,
3598
+ 0,
3599
+ getMaxPanOffset(boundsHistory, current.zoomLevel),
3600
+ ),
3601
+ }));
1744
3602
  };
1745
3603
 
1746
3604
  const plotLines: Array<string | StyledContent> = effectiveRenderer === "kitty"
1747
3605
  ? blankPlotLines
1748
3606
  : result.lines;
1749
3607
  const plotContent = plotLines.map((line, index) => (
1750
- <text key={index} content={line} />
3608
+ <text key={index} content={line as unknown as string} />
1751
3609
  ));
1752
3610
 
1753
3611
  const plotBox = (
@@ -1765,7 +3623,6 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1765
3623
  onMouseOut={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => {
1766
3624
  dragRef.current = null;
1767
3625
  }}
1768
- onMouseScroll={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotScroll}
1769
3626
  >
1770
3627
  {isBlockingBody
1771
3628
  ? (
@@ -1784,13 +3641,13 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1784
3641
  );
1785
3642
 
1786
3643
  const axisBox = (
1787
- <box width={axisWidth} height={chartHeight} flexDirection="column">
3644
+ <box width={axisSectionWidth} height={chartHeight} flexDirection="column">
1788
3645
  {Array.from({ length: chartHeight }, (_, row) => {
1789
3646
  const isCursorRow = cursorAxisLabel !== null && cursorRow === row;
1790
3647
  const label = isCursorRow ? cursorAxisLabel : (axisLabels.get(row) ?? null);
1791
3648
  return (
1792
3649
  <text key={row} fg={isCursorRow ? chartColors.crosshairColor : colors.textDim}>
1793
- {formatAxisCell(label, axisWidth)}
3650
+ {formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}
1794
3651
  </text>
1795
3652
  );
1796
3653
  })}
@@ -1812,27 +3669,45 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1812
3669
  }
1813
3670
 
1814
3671
  return (
1815
- <box flexDirection="column" flexGrow={1}>
3672
+ <box
3673
+ flexDirection="column"
3674
+ flexGrow={1}
3675
+ onMouseScroll={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotScroll}
3676
+ >
1816
3677
  <box flexDirection="row" gap={2} height={1}>
1817
3678
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1818
- {ticker?.metadata.ticker ?? ""} - {getChartResolutionLabel(effectiveResolution)}
1819
- </text>
1820
- <text fg={colors.textDim}>{visibleLabel}</text>
1821
- <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1822
- {displayPrice !== null ? formatCurrency(displayPrice, chartCurrency) : "—"}
1823
- </text>
1824
- <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1825
- {displayChange !== null && displayChangePct !== null
1826
- ? `${displayChange >= 0 ? "+" : ""}${displayChange.toFixed(2)} (${displayChangePct >= 0 ? "+" : ""}${displayChangePct.toFixed(2)}%)`
1827
- : "No data"}
3679
+ {ticker?.metadata.ticker ?? ""} - {getChartResolutionLabel(selectedResolution)}
1828
3680
  </text>
3681
+ {fallbackResolutionLabel && (
3682
+ <text fg={colors.textDim}>{fallbackResolutionLabel}</text>
3683
+ )}
1829
3684
  {displayDate && <text fg={colors.textDim}>{displayDate}</text>}
1830
3685
  {showOhlcSummary && activePoint && (
1831
3686
  <>
1832
- <text fg={colors.textDim}>O {formatCurrency(activePoint.open, chartCurrency)}</text>
1833
- <text fg={colors.textDim}>H {formatCurrency(activePoint.high, chartCurrency)}</text>
1834
- <text fg={colors.textDim}>L {formatCurrency(activePoint.low, chartCurrency)}</text>
1835
- <text fg={colors.textDim}>C {formatCurrency(activePoint.close, chartCurrency)}</text>
3687
+ <text fg={colors.textDim}>O {formatMarketPriceWithCurrency(activePoint.open, chartCurrency, {
3688
+ assetCategory: chartAssetCategory,
3689
+ minimumFractionDigits: 2,
3690
+ precisionOffset: 1,
3691
+ priceRange: visiblePriceRange,
3692
+ })}</text>
3693
+ <text fg={colors.textDim}>H {formatMarketPriceWithCurrency(activePoint.high, chartCurrency, {
3694
+ assetCategory: chartAssetCategory,
3695
+ minimumFractionDigits: 2,
3696
+ precisionOffset: 1,
3697
+ priceRange: visiblePriceRange,
3698
+ })}</text>
3699
+ <text fg={colors.textDim}>L {formatMarketPriceWithCurrency(activePoint.low, chartCurrency, {
3700
+ assetCategory: chartAssetCategory,
3701
+ minimumFractionDigits: 2,
3702
+ precisionOffset: 1,
3703
+ priceRange: visiblePriceRange,
3704
+ })}</text>
3705
+ <text fg={colors.textDim}>C {formatMarketPriceWithCurrency(activePoint.close, chartCurrency, {
3706
+ assetCategory: chartAssetCategory,
3707
+ minimumFractionDigits: 2,
3708
+ precisionOffset: 1,
3709
+ priceRange: visiblePriceRange,
3710
+ })}</text>
1836
3711
  <text fg={colors.textDim}>V {formatCompact(activePoint.volume)}</text>
1837
3712
  </>
1838
3713
  )}
@@ -1845,7 +3720,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1845
3720
  key={range}
1846
3721
  fg={activePreset === range ? chartColors.activeRangeColor : (isRangePresetSupported(range, availableManualResolutions) ? chartColors.inactiveRangeColor : colors.textMuted)}
1847
3722
  attributes={activePreset === range ? TextAttributes.BOLD : 0}
1848
- onMouseDown={() => {
3723
+ onMouseDown={(event: any) => {
3724
+ focusPaneForMouseInteraction(event);
1849
3725
  if (isRangePresetSupported(range, availableManualResolutions)) setRange(range);
1850
3726
  }}
1851
3727
  >
@@ -1860,16 +3736,16 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1860
3736
  {resolutionChips.map((resolution) => (
1861
3737
  <text
1862
3738
  key={resolution}
1863
- fg={effectiveResolution === resolution ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1864
- attributes={effectiveResolution === resolution ? TextAttributes.BOLD : 0}
1865
- onMouseDown={() => setResolution(resolution)}
3739
+ fg={selectedResolution === resolution ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
3740
+ attributes={selectedResolution === resolution ? TextAttributes.BOLD : 0}
3741
+ onMouseDown={(event: any) => {
3742
+ focusPaneForMouseInteraction(event);
3743
+ setResolution(resolution);
3744
+ }}
1866
3745
  >
1867
3746
  {getChartResolutionLabel(resolution)}
1868
3747
  </text>
1869
3748
  ))}
1870
- {viewState.zoomLevel !== 1 && (
1871
- <text fg={colors.textDim}>zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1872
- )}
1873
3749
  {isUpdating && (
1874
3750
  <text fg={colors.textDim}>updating</text>
1875
3751
  )}
@@ -1882,7 +3758,10 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1882
3758
  key={mode}
1883
3759
  fg={requestedMode === mode ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1884
3760
  attributes={requestedMode === mode ? TextAttributes.BOLD : 0}
1885
- onMouseDown={() => setRenderMode(mode)}
3761
+ onMouseDown={(event: any) => {
3762
+ focusPaneForMouseInteraction(event);
3763
+ setRenderMode(mode);
3764
+ }}
1886
3765
  >
1887
3766
  {MODE_CHIPS[mode]}
1888
3767
  </text>
@@ -1907,7 +3786,12 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1907
3786
  )}
1908
3787
  </box>
1909
3788
 
1910
- <box flexDirection="row" height={chartHeight} gap={axisGap}>
3789
+ <box
3790
+ flexDirection="row"
3791
+ height={chartHeight}
3792
+ gap={axisGap}
3793
+ onMouseScroll={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotScroll}
3794
+ >
1911
3795
  {plotBox}
1912
3796
  {axisBox}
1913
3797
  </box>
@@ -1916,12 +3800,13 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1916
3800
  <text fg={colors.textDim}>{selectionScene?.timeLabels ?? staticResult.timeLabels}</text>
1917
3801
  </box>
1918
3802
 
1919
- <box height={1}>
1920
- <text fg={colors.textMuted}>
1921
- {interactive
1922
- ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode r res 1-7 presets v vol 0 reset Esc exit"
1923
- : "Enter crosshair click chart to focus a/d pan +/- zoom m mode r res 1-7 presets v volume 0 reset"}
1924
- </text>
3803
+ <box height={1} flexDirection="row" gap={1}>
3804
+ <ChartControlHint hotkey="m" label="ode" />
3805
+ {footerControls}
3806
+ <ChartControlHint hotkey="r" label="es" />
3807
+ <ChartControlHint hotkey="+/-" label="zoom" />
3808
+ <ChartControlHint hotkey="0" label="reset" />
3809
+ {width >= 72 && <ChartControlHint hotkey="1-7" label="range" />}
1925
3810
  </box>
1926
3811
  </box>
1927
3812
  );