gloomberb 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -1,6 +1,5 @@
1
1
  import type { PricePoint } from "../../types/financials";
2
- import type { ChartRenderMode, ChartViewState, VisibleWindow } from "./chart-types";
3
- import { getVisiblePointCount } from "./chart-viewport";
2
+ import type { ChartRenderMode } from "./chart-types";
4
3
 
5
4
  export interface ProjectedChartPoint {
6
5
  date: Date;
@@ -76,34 +75,6 @@ export function resolveRenderMode(
76
75
  };
77
76
  }
78
77
 
79
- /**
80
- * Apply zoom and pan to get the visible data window.
81
- */
82
- export function getVisibleWindow(
83
- history: PricePoint[],
84
- viewState: ChartViewState,
85
- chartWidth: number,
86
- ): VisibleWindow {
87
- if (history.length === 0) {
88
- return { points: [], startIdx: 0, endIdx: 0 };
89
- }
90
-
91
- const visibleCount = getVisiblePointCount(history.length, viewState.zoomLevel);
92
-
93
- // Clamp pan offset
94
- const maxPan = Math.max(history.length - visibleCount, 0);
95
- const pan = Math.min(Math.max(viewState.panOffset, 0), maxPan);
96
-
97
- const endIdx = history.length - pan;
98
- const startIdx = Math.max(endIdx - visibleCount, 0);
99
-
100
- return {
101
- points: history.slice(startIdx, endIdx),
102
- startIdx,
103
- endIdx,
104
- };
105
- }
106
-
107
78
  function normalizePoint(point: PricePoint): ProjectedChartPoint {
108
79
  const open = point.open ?? point.close;
109
80
  const high = point.high ?? point.close;
@@ -334,7 +305,6 @@ export function bucketOhlcSeries(
334
305
  return result;
335
306
  }
336
307
 
337
- const MS_HOUR = 3600_000;
338
308
  const MS_DAY = 86400_000;
339
309
 
340
310
  /**
@@ -157,10 +157,14 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
157
157
  }
158
158
 
159
159
  function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
160
- if (x >= 0 && x < buf.width && y >= 0 && y < buf.height) {
161
- const existing = buf.pixels[y]![x];
160
+ const px = Math.round(x);
161
+ const py = Math.round(y);
162
+ if (px >= 0 && px < buf.width && py >= 0 && py < buf.height) {
163
+ const row = buf.pixels[py];
164
+ if (!row) return;
165
+ const existing = row[px];
162
166
  if (!existing || layer >= existing.layer) {
163
- buf.pixels[y]![x] = { color, layer };
167
+ row[px] = { color, layer };
164
168
  }
165
169
  }
166
170
  }
@@ -178,17 +182,22 @@ export function drawLine(
178
182
  color: string,
179
183
  layer: number = LAYER_DATA,
180
184
  ) {
181
- let dx = Math.abs(x1 - x0);
182
- let dy = Math.abs(y1 - y0);
183
- const sx = x0 < x1 ? 1 : -1;
184
- const sy = y0 < y1 ? 1 : -1;
185
+ const startX = Math.round(x0);
186
+ const startY = Math.round(y0);
187
+ const endX = Math.round(x1);
188
+ const endY = Math.round(y1);
189
+ if (![startX, startY, endX, endY].every(Number.isFinite)) return;
190
+ let dx = Math.abs(endX - startX);
191
+ let dy = Math.abs(endY - startY);
192
+ const sx = startX < endX ? 1 : -1;
193
+ const sy = startY < endY ? 1 : -1;
185
194
  let err = dx - dy;
186
- let x = x0;
187
- let y = y0;
195
+ let x = startX;
196
+ let y = startY;
188
197
 
189
198
  while (true) {
190
199
  setPixel(buf, x, y, color, layer);
191
- if (x === x1 && y === y1) break;
200
+ if (x === endX && y === endY) break;
192
201
  const e2 = 2 * err;
193
202
  if (e2 > -dy) {
194
203
  err -= dy;
@@ -492,11 +501,14 @@ export function drawGridLines(
492
501
  yPositions: number[],
493
502
  color: string,
494
503
  ) {
495
- for (const y of yPositions) {
504
+ for (const rawY of yPositions) {
505
+ const y = Math.round(rawY);
496
506
  if (y < 0 || y >= buf.height) continue;
497
507
  // Dotted horizontal line — dot every 6th column (≈ every 3 terminal columns)
508
+ const row = buf.pixels[y];
509
+ if (!row) continue;
498
510
  for (let x = 0; x < buf.width; x++) {
499
- if (x % 6 === 0 && !buf.pixels[y]![x]) {
511
+ if (x % 6 === 0 && !row[x]) {
500
512
  setPixel(buf, x, y, color, LAYER_GRID);
501
513
  }
502
514
  }
@@ -918,6 +930,76 @@ function writeAxisLabel(axis: string[], start: number, label: string) {
918
930
  }
919
931
  }
920
932
 
933
+ export interface AxisLabelSegment {
934
+ text: string;
935
+ highlighted: boolean;
936
+ }
937
+
938
+ function formatCursorTimeAxisValue(
939
+ value: Date | string | number,
940
+ dates: Array<Date | string | number>,
941
+ width: number,
942
+ ): string {
943
+ const date = value instanceof Date ? value : new Date(value);
944
+ if (isNaN(date.getTime())) return "—";
945
+
946
+ const normalizedDates = dates.map((entry) => (entry instanceof Date ? entry : new Date(entry)));
947
+ const validDates = normalizedDates.filter((entry) => !isNaN(entry.getTime()));
948
+ if (validDates.length === 0) return formatDateShort(date);
949
+
950
+ const first = validDates[0]!;
951
+ const last = validDates[validDates.length - 1]!;
952
+ const spanMs = Math.max(last.getTime() - first.getTime(), 1);
953
+ const roughLabelCount = Math.max(Math.floor(normalizeCount(width, 0) / 10), 2);
954
+ const minGapMs = getMinPositiveGapMs(validDates);
955
+ const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
956
+ const unit = resolveAxisLabelUnit(effectiveStepMs);
957
+ const counterpart = isSameCalendarDay(first, last)
958
+ ? first
959
+ : isSameCalendarDay(date, first)
960
+ ? last
961
+ : first;
962
+
963
+ return formatTimeAxisBoundaryLabel(date, unit, counterpart);
964
+ }
965
+
966
+ export function buildCursorTimeAxisSegments({
967
+ timeLabels,
968
+ width,
969
+ cursorColumn,
970
+ cursorDate,
971
+ dates,
972
+ }: {
973
+ timeLabels: string;
974
+ width: number;
975
+ cursorColumn: number | null;
976
+ cursorDate: Date | string | number | null;
977
+ dates: Array<Date | string | number>;
978
+ }): AxisLabelSegment[] {
979
+ const axisWidth = normalizeCount(width, 0);
980
+ if (axisWidth <= 0) return [];
981
+
982
+ const base = timeLabels.padEnd(axisWidth).slice(0, axisWidth);
983
+ if (cursorColumn === null || cursorDate === null) {
984
+ return [{ text: base, highlighted: false }];
985
+ }
986
+
987
+ const label = formatCursorTimeAxisValue(cursorDate, dates, axisWidth);
988
+ if (!label) return [{ text: base, highlighted: false }];
989
+
990
+ const axis = base.split("");
991
+ const column = Math.max(Math.min(Math.round(cursorColumn), axisWidth - 1), 0);
992
+ const start = resolveAxisLabelStart(column, label, axisWidth);
993
+ const end = Math.min(start + label.length, axisWidth);
994
+ writeAxisLabel(axis, start, label);
995
+
996
+ return [
997
+ { text: axis.slice(0, start).join(""), highlighted: false },
998
+ { text: axis.slice(start, end).join(""), highlighted: true },
999
+ { text: axis.slice(end).join(""), highlighted: false },
1000
+ ].filter((segment) => segment.text.length > 0);
1001
+ }
1002
+
921
1003
  export function buildTimeAxis(dates: Array<Date | string | number>, width: number): string {
922
1004
  const axisWidth = normalizeCount(width, 0);
923
1005
  if (dates.length === 0 || axisWidth <= 0) return "";
@@ -3,10 +3,8 @@ import {
3
3
  CHART_RESOLUTION_STEP_MS,
4
4
  clampTimeRangeForResolution,
5
5
  getActiveRangePreset,
6
- getBestSupportedResolutionForDateWindow,
7
6
  getBestSupportedResolutionForVisibleWindow,
8
7
  getPresetResolution,
9
- intersectChartResolutions,
10
8
  isRangePresetSupported,
11
9
  normalizeChartResolution,
12
10
  sortChartResolutions,
@@ -30,14 +28,6 @@ describe("chart-resolution", () => {
30
28
  expect(getActiveRangePreset("1Y", "1d", 1, 2)).toBeNull();
31
29
  });
32
30
 
33
- test("intersects and sorts provider capabilities", () => {
34
- expect(intersectChartResolutions([
35
- ["1d", "5m", "1wk", "nope"],
36
- ["1wk", "1d", "1mo"],
37
- ["1d", "1wk"],
38
- ])).toEqual(["1d", "1wk"]);
39
- });
40
-
41
31
  test("checks whether a range preset is supported by the visible capability set", () => {
42
32
  expect(isRangePresetSupported("1Y", ["1d", "1wk"])).toBe(true);
43
33
  expect(isRangePresetSupported("ALL", ["1d", "1wk"])).toBe(false);
@@ -57,32 +47,6 @@ describe("chart-resolution", () => {
57
47
  expect(sortChartResolutions(["1wk", "auto", "15m", "1d"])).toEqual(["auto", "15m", "1d", "1wk"]);
58
48
  });
59
49
 
60
- test("picks the best supported resolution for the current visible date window", () => {
61
- const support = [
62
- { resolution: "1m", maxRange: "1D" },
63
- { resolution: "5m", maxRange: "1W" },
64
- { resolution: "15m", maxRange: "1M" },
65
- { resolution: "1h", maxRange: "3M" },
66
- { resolution: "1d", maxRange: "5Y" },
67
- { resolution: "1wk", maxRange: "ALL" },
68
- ] as const;
69
-
70
- expect(getBestSupportedResolutionForDateWindow({
71
- start: new Date("2026-01-08T10:00:00Z"),
72
- end: new Date("2026-01-08T16:00:00Z"),
73
- }, support)).toBe("1m");
74
-
75
- expect(getBestSupportedResolutionForDateWindow({
76
- start: new Date("2026-01-01T00:00:00Z"),
77
- end: new Date("2026-01-08T00:00:00Z"),
78
- }, support)).toBe("5m");
79
-
80
- expect(getBestSupportedResolutionForDateWindow({
81
- start: new Date("2021-01-01T00:00:00Z"),
82
- end: new Date("2026-01-01T00:00:00Z"),
83
- }, support)).toBe("1wk");
84
- });
85
-
86
50
  test("picks a denser supported resolution as the visible window narrows", () => {
87
51
  const support = [
88
52
  { resolution: "1m", maxRange: "1D" },
@@ -151,24 +151,6 @@ export function sortChartResolutions<T extends ChartResolution>(resolutions: rea
151
151
  ));
152
152
  }
153
153
 
154
- function normalizeManualChartResolutions(resolutions: readonly string[]): ManualChartResolution[] {
155
- const seen = new Set<ManualChartResolution>();
156
- for (const resolution of resolutions) {
157
- if (isManualChartResolution(resolution)) {
158
- seen.add(resolution);
159
- }
160
- }
161
- return sortChartResolutions([...seen]);
162
- }
163
-
164
- export function intersectChartResolutions(resolutionSets: Array<readonly string[]>): ManualChartResolution[] {
165
- if (resolutionSets.length === 0) return [];
166
- const [first = new Set<ManualChartResolution>(), ...rest] = resolutionSets.map((set) => new Set(normalizeManualChartResolutions(set)));
167
- return sortChartResolutions(
168
- [...first].filter((resolution) => rest.every((set) => set.has(resolution))),
169
- );
170
- }
171
-
172
154
  function sortChartResolutionSupport(support: readonly ChartResolutionSupport[]): ChartResolutionSupport[] {
173
155
  return [...support].sort((left, right) => (
174
156
  CHART_RESOLUTION_ORDER.indexOf(left.resolution) - CHART_RESOLUTION_ORDER.indexOf(right.resolution)
@@ -324,15 +306,6 @@ export function getBestSupportedResolutionForPreset(
324
306
  return supportedResolutions[supportedResolutions.length - 1] ?? null;
325
307
  }
326
308
 
327
- export function getBestSupportedResolutionForDateWindow(
328
- window: { start: Date | null; end: Date | null } | null,
329
- support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
330
- ): ManualChartResolution | null {
331
- if (!window?.start || !window.end) return null;
332
- const range = getTimeRangeForDateWindow(window);
333
- return getBestSupportedResolutionForPreset(range, support);
334
- }
335
-
336
309
  export function getTimeRangeForDateWindow(
337
310
  window: { start: Date | null; end: Date | null } | null,
338
311
  ): TimeRange {
@@ -1,15 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  bucketOhlcSeries,
4
- getVisibleWindow,
5
4
  projectChartData,
6
5
  resolveRenderMode,
7
6
  resolveStableOhlcProjectionOptions,
8
7
  } from "./chart-data";
9
8
  import { stepCursorTowards } from "./cursor-motion";
10
- import { buildChartScene, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
9
+ import { buildChartScene, buildCursorTimeAxisSegments, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
10
+ import { buildCursorTimeAxisOverlay } from "./time-axis-label";
11
+ import { buildCursorPriceAxisOverlay } from "./price-axis-labels";
11
12
  import type { PricePoint } from "../../types/financials";
12
- import type { ChartRenderMode, ChartViewState } from "./chart-types";
13
+ import type { ChartRenderMode } from "./chart-types";
13
14
 
14
15
  const aggregationFixture: PricePoint[] = [
15
16
  { date: new Date("2024-01-02"), close: 10, volume: 100 },
@@ -225,48 +226,6 @@ describe("resolveRenderMode", () => {
225
226
  });
226
227
  });
227
228
 
228
- describe("getVisibleWindow", () => {
229
- test("shows the full selected range at default zoom and lets the renderer downsample it", () => {
230
- const history = buildDenseHistory(252);
231
- const viewState: ChartViewState = {
232
- presetRange: "1Y",
233
- bufferRange: "1Y",
234
- activePreset: "1Y",
235
- resolution: "auto",
236
- panOffset: 0,
237
- zoomLevel: 1,
238
- cursorX: null,
239
- cursorY: null,
240
- };
241
-
242
- const window = getVisibleWindow(history, viewState, 80);
243
-
244
- expect(window.points).toHaveLength(history.length);
245
- expect(window.points[0]?.date.toISOString()).toBe(history[0]!.date.toISOString());
246
- expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)!.date.toISOString());
247
- });
248
-
249
- test("zooms into the selected range instead of pinning the default view to chart width", () => {
250
- const history = buildDenseHistory(252);
251
- const viewState: ChartViewState = {
252
- presetRange: "1Y",
253
- bufferRange: "1Y",
254
- activePreset: "1Y",
255
- resolution: "auto",
256
- panOffset: 0,
257
- zoomLevel: 2,
258
- cursorX: null,
259
- cursorY: null,
260
- };
261
-
262
- const window = getVisibleWindow(history, viewState, 80);
263
-
264
- expect(window.points).toHaveLength(126);
265
- expect(window.points[0]?.date.toISOString()).toBe(history[126]!.date.toISOString());
266
- expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)!.date.toISOString());
267
- });
268
- });
269
-
270
229
  describe("renderChart", () => {
271
230
  test("sizes the y-axis to the visible label width instead of a fixed gutter", () => {
272
231
  expect(resolveChartAxisWidth(["$18", "$17", "$16", "$14"], 4, 10)).toBe(4);
@@ -457,6 +416,62 @@ describe("renderChart", () => {
457
416
  );
458
417
  });
459
418
 
419
+ test("overlays the cursor date on the x-axis without changing its width", () => {
420
+ const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
421
+ const width = 72;
422
+ const segments = buildCursorTimeAxisSegments({
423
+ timeLabels: buildTimeAxis(dates, width),
424
+ width,
425
+ cursorColumn: 36,
426
+ cursorDate: dates[6]!,
427
+ dates,
428
+ });
429
+
430
+ expect(segments.map((segment) => segment.text).join("")).toHaveLength(width);
431
+ expect(segments.find((segment) => segment.highlighted)?.text).toBe("12:30");
432
+ });
433
+
434
+ test("positions desktop cursor x-axis overlay from exact pixels", () => {
435
+ const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
436
+ const width = 72;
437
+ const cursorPixelX = 291.5;
438
+ const cellWidthPx = 8;
439
+ const segments = buildCursorTimeAxisSegments({
440
+ timeLabels: buildTimeAxis(dates, width),
441
+ width,
442
+ cursorColumn: 36,
443
+ cursorDate: dates[6]!,
444
+ dates,
445
+ });
446
+ const overlay = buildCursorTimeAxisOverlay({
447
+ segments,
448
+ width,
449
+ cursorPixelX,
450
+ cellWidthPx,
451
+ });
452
+
453
+ expect(overlay.label).toBe("12:30");
454
+ expect(overlay.baseText).toHaveLength(width);
455
+ expect(overlay.leftPercent).toBeCloseTo((cursorPixelX / (width * cellWidthPx - 1)) * 100, 5);
456
+ });
457
+
458
+ test("positions desktop cursor y-axis overlay from exact pixels", () => {
459
+ const height = 8;
460
+ const cellHeightPx = 18;
461
+ const cursorPixelY = 45.25;
462
+ const overlay = buildCursorPriceAxisOverlay({
463
+ axisWidth: 7,
464
+ axisSectionWidth: 8,
465
+ height,
466
+ cursorPixelY,
467
+ cursorLabel: "$214.03",
468
+ cellHeightPx,
469
+ });
470
+
471
+ expect(overlay.labelText).toBe("$214.03 ");
472
+ expect(overlay.topPercent).toBeCloseTo((cursorPixelY / (height * cellHeightPx - 1)) * 100, 5);
473
+ });
474
+
460
475
  test("shows second precision when the visible window reaches second-level data", () => {
461
476
  const dates = Array.from({ length: 6 }, (_, index) => new Date(2026, 0, 5, 9, 30, index * 10));
462
477
 
@@ -25,10 +25,9 @@ describe("projectComparisonChartData", () => {
25
25
  const window = getVisibleComparisonWindow([
26
26
  makeSeries("AAPL", "USD", closes),
27
27
  ], {
28
- timeRange: "ALL",
29
28
  panOffset: 0,
30
29
  zoomLevel: 1,
31
- }, 40);
30
+ });
32
31
 
33
32
  expect(window.dates).toHaveLength(120);
34
33
  expect(window.dates[0]?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
@@ -48,7 +47,6 @@ describe("projectComparisonChartData", () => {
48
47
  ["2024-01-05", 22],
49
48
  ]),
50
49
  ], 8, {
51
- timeRange: "ALL",
52
50
  panOffset: 0,
53
51
  zoomLevel: 1,
54
52
  renderMode: "line",
@@ -72,7 +70,6 @@ describe("projectComparisonChartData", () => {
72
70
  ["2024-01-04", 60],
73
71
  ]),
74
72
  ], 8, {
75
- timeRange: "ALL",
76
73
  panOffset: 0,
77
74
  zoomLevel: 1,
78
75
  renderMode: "line",
@@ -94,7 +91,6 @@ describe("projectComparisonChartData", () => {
94
91
  ["2024-01-03", 2100],
95
92
  ]),
96
93
  ], 8, {
97
- timeRange: "ALL",
98
94
  panOffset: 0,
99
95
  zoomLevel: 1,
100
96
  renderMode: "line",
@@ -83,9 +83,7 @@ function getUniqueSortedDates(series: ComparisonChartSeries[]): Date[] {
83
83
 
84
84
  export function getMaxComparisonPanOffset(
85
85
  series: ComparisonChartSeries[],
86
- _timeRange: TimeRange,
87
86
  zoomLevel: number,
88
- chartWidth: number,
89
87
  ): number {
90
88
  const dates = getUniqueSortedDates(series);
91
89
  const visibleCount = getVisiblePointCount(dates.length, zoomLevel);
@@ -95,7 +93,6 @@ export function getMaxComparisonPanOffset(
95
93
  export function getVisibleComparisonWindow(
96
94
  series: ComparisonChartSeries[],
97
95
  viewState: Pick<ComparisonChartViewState, "panOffset" | "zoomLevel">,
98
- chartWidth: number,
99
96
  ): ComparisonVisibleWindow {
100
97
  const dates = getUniqueSortedDates(series);
101
98
 
@@ -214,7 +211,7 @@ export function projectComparisonChartData(
214
211
  requestedAxisMode: ChartAxisMode,
215
212
  ): ComparisonChartProjection {
216
213
  const normalizedSeries = series.map((entry) => ({ ...entry, points: normalizeSeriesPoints(entry.points) }));
217
- const window = getVisibleComparisonWindow(normalizedSeries, viewState, chartWidth);
214
+ const window = getVisibleComparisonWindow(normalizedSeries, viewState);
218
215
  const axisResolution = resolveComparisonAxisMode(requestedAxisMode, normalizedSeries, window.dates);
219
216
  const requestedMode = normalizeComparisonMode(viewState.renderMode);
220
217
  if (window.dates.length === 0) {
@@ -119,6 +119,32 @@ describe("renderComparisonChart", () => {
119
119
  expect(result.activeDate?.toISOString()).toBe("2024-01-03T00:00:00.000Z");
120
120
  });
121
121
 
122
+ test("renders when precise desktop layout passes a fractional chart height", () => {
123
+ const projection = projectComparisonChartData([
124
+ makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
125
+ ], 12, {
126
+ panOffset: 0,
127
+ zoomLevel: 1,
128
+ renderMode: "area",
129
+ }, "percent");
130
+
131
+ const result = renderComparisonChart(projection, {
132
+ width: 12,
133
+ height: 6.5,
134
+ cursorX: null,
135
+ cursorY: null,
136
+ selectedSymbol: "AAPL",
137
+ colors: {
138
+ bgColor: "#000000",
139
+ gridColor: "#333333",
140
+ crosshairColor: "#ffffff",
141
+ },
142
+ });
143
+
144
+ expect(result.lines.length).toBeGreaterThan(0);
145
+ expect(textLines(result).some((line) => /[^\s]/.test(line))).toBe(true);
146
+ });
147
+
122
148
  test("keeps terminal comparison chart cells transparent", () => {
123
149
  const projection = projectComparisonChartData([
124
150
  makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
@@ -80,6 +80,11 @@ function clamp(value: number, min: number, max: number): number {
80
80
  return Math.max(min, Math.min(max, value));
81
81
  }
82
82
 
83
+ function normalizeCount(value: number, min: number): number {
84
+ if (!Number.isFinite(value)) return min;
85
+ return Math.max(Math.floor(value), min);
86
+ }
87
+
83
88
  function getComparisonDotX(index: number, count: number, width: number): number {
84
89
  if (count <= 1) return Math.round(Math.max(width - 1, 0) / 2);
85
90
  return Math.round((index / (count - 1)) * Math.max(width - 1, 0));
@@ -260,15 +265,17 @@ export function buildComparisonChartScene(
260
265
 
261
266
  const min = Math.min(...values);
262
267
  const max = Math.max(...values);
268
+ const width = normalizeCount(opts.width, 1);
269
+ const height = normalizeCount(opts.height, 1);
263
270
  const selectedSeries = getSelectedSeries(projection.series, opts.selectedSymbol);
264
- const activeIdx = getActiveIndex(projection.dates.length, opts.width, opts.cursorX);
271
+ const activeIdx = getActiveIndex(projection.dates.length, width, opts.cursorX);
265
272
  const activeDate = projection.dates[activeIdx] ?? projection.dates[projection.dates.length - 1]!;
266
273
  const selectedPoint = selectedSeries?.points[activeIdx] ?? null;
267
- const chartRows = opts.height;
274
+ const chartRows = height;
268
275
  const range = max - min || 1;
269
276
  const cursorX = opts.cursorX === null
270
277
  ? null
271
- : clamp(opts.cursorX, 0, Math.max(opts.width - 1, 0));
278
+ : clamp(opts.cursorX, 0, Math.max(width - 1, 0));
272
279
  const fallbackValue = selectedPoint?.value ?? selectedSeries?.latestValue ?? min;
273
280
  const cursorY = cursorX === null
274
281
  ? null
@@ -282,7 +289,7 @@ export function buildComparisonChartScene(
282
289
  const cursorColumn = cursorX === null ? null : Math.round(cursorX);
283
290
  const cursorDotX = cursorX === null
284
291
  ? null
285
- : Math.round((cursorX / Math.max(opts.width - 1, 1)) * Math.max(opts.width * 2 - 1, 0));
292
+ : Math.round((cursorX / Math.max(width - 1, 1)) * Math.max(width * 2 - 1, 0));
286
293
  const cursorRow = cursorY === null ? null : Math.round(cursorY);
287
294
  const crosshairValue = cursorY === null
288
295
  ? null
@@ -291,8 +298,8 @@ export function buildComparisonChartScene(
291
298
  return {
292
299
  dates: projection.dates,
293
300
  series: projection.series,
294
- width: opts.width,
295
- height: opts.height,
301
+ width,
302
+ height,
296
303
  chartRows,
297
304
  mode: projection.effectiveMode,
298
305
  axisMode: projection.effectiveAxisMode,