gloomberb 0.9.3 → 0.9.5

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 (176) hide show
  1. package/README.md +1 -0
  2. package/package.json +3 -2
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/runtime/desktop-menu.ts +5 -1
  6. package/src/app/runtime/ticker-refresh.ts +4 -1
  7. package/src/app.tsx +10 -1
  8. package/src/brokers/account-cache.ts +1 -1
  9. package/src/brokers/sync-broker-instance.test.ts +107 -0
  10. package/src/brokers/sync-broker-instance.ts +22 -10
  11. package/src/cli/desktop-pane-shot.ts +2 -1
  12. package/src/cli/entry.ts +8 -1
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/cli/pane-functions/screenshot.ts +13 -1
  15. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  16. package/src/components/chart/bar-chart-renderer.ts +226 -27
  17. package/src/components/chart/core/pointer.ts +32 -6
  18. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  19. package/src/components/chart/native/bitmap-support.ts +55 -0
  20. package/src/components/chart/native/kitty/index.test.ts +47 -0
  21. package/src/components/chart/native/surface/visibility.ts +8 -1
  22. package/src/components/chart/price-axis-labels.tsx +3 -1
  23. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  24. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  25. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  26. package/src/components/chart/static/chart/bitmap.ts +16 -9
  27. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  28. package/src/components/chart/static/chart/surface.tsx +167 -24
  29. package/src/components/chart/stock-chart/index.test.ts +25 -0
  30. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  31. package/src/components/command-bar/list/view.tsx +1 -1
  32. package/src/components/command-bar/theme-picker.tsx +1 -1
  33. package/src/components/data-table/view.tsx +0 -12
  34. package/src/components/layout/detached-pane-shell.tsx +6 -3
  35. package/src/components/layout/floating-pane.tsx +6 -5
  36. package/src/components/layout/pane/content.tsx +20 -8
  37. package/src/components/layout/pane/footer/registration.tsx +6 -2
  38. package/src/components/layout/pane/frame.tsx +4 -4
  39. package/src/components/layout/pane/header.tsx +45 -4
  40. package/src/components/layout/pane/index.tsx +6 -4
  41. package/src/components/layout/pane/sizing.test.ts +43 -0
  42. package/src/components/layout/pane/sizing.ts +13 -4
  43. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  44. package/src/components/layout/shell/cursor-occlusion.ts +204 -0
  45. package/src/components/layout/shell/drag/runtime.ts +9 -2
  46. package/src/components/layout/shell/index.test.tsx +92 -2
  47. package/src/components/layout/shell/index.tsx +45 -3
  48. package/src/components/layout/shell/layout-state.test.ts +33 -0
  49. package/src/components/layout/shell/layout-state.ts +4 -9
  50. package/src/components/layout/shell/pane/layers.tsx +10 -4
  51. package/src/components/layout/shell/visible-layout.ts +17 -0
  52. package/src/components/layout/status-bar.test.tsx +6 -1
  53. package/src/components/layout/status-bar.tsx +10 -6
  54. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  55. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  56. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  57. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  58. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  59. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  60. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  61. package/src/components/price-performance.tsx +1 -1
  62. package/src/components/price-sparkline/view.tsx +15 -14
  63. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  64. package/src/components/table-view-shared.tsx +12 -14
  65. package/src/components/ticker/list-table-view.tsx +1 -7
  66. package/src/components/ticker/list-table.test.tsx +0 -4
  67. package/src/components/ui/data-table/opentui/index.tsx +29 -22
  68. package/src/components/ui/data-table/types.ts +10 -4
  69. package/src/components/ui/fields.tsx +10 -3
  70. package/src/components/ui/list-view.tsx +1 -1
  71. package/src/components/ui/page-stack-view.tsx +6 -2
  72. package/src/components/ui/table-layout.test.ts +25 -0
  73. package/src/components/ui/table-layout.ts +22 -6
  74. package/src/components/ui/tabs.tsx +27 -12
  75. package/src/components/ui/ui.test.tsx +87 -9
  76. package/src/core/state/app/layout-reducer.ts +2 -2
  77. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  78. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  79. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  80. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  81. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  82. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  83. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  84. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  85. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  86. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  87. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  88. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  89. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  90. package/src/plugins/builtin/debug/index.tsx +3 -4
  91. package/src/plugins/builtin/help/components.tsx +1 -1
  92. package/src/plugins/builtin/holders/treemap.tsx +1 -1
  93. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  94. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  95. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  96. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  97. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  98. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  99. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  100. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  101. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  102. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  103. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  104. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  105. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  106. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  107. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  108. package/src/plugins/builtin/options/table.ts +5 -5
  109. package/src/plugins/builtin/options/view.test.tsx +141 -4
  110. package/src/plugins/builtin/options/view.tsx +76 -15
  111. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  112. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  113. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  114. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  115. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  116. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  117. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  118. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  119. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  120. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  121. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  122. package/src/plugins/catalog-ui.ts +2 -0
  123. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  124. package/src/plugins/ibkr/config.test.ts +31 -2
  125. package/src/plugins/ibkr/config.ts +9 -2
  126. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  127. package/src/plugins/ibkr/flex/index.ts +30 -0
  128. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  129. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  130. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  131. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  132. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  133. package/src/plugins/pane-manager/gridlock.ts +13 -5
  134. package/src/plugins/pane-manager/layout-state.ts +25 -0
  135. package/src/plugins/pane-manager.test.ts +33 -0
  136. package/src/plugins/pane-manager.ts +6 -1
  137. package/src/plugins/prediction-markets/chart.tsx +10 -4
  138. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  139. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  140. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  141. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  142. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  143. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  144. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  145. package/src/plugins/prediction-markets/pane.tsx +50 -22
  146. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  147. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  148. package/src/renderers/electrobun/view/data-table/index.tsx +19 -12
  149. package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
  150. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  151. package/src/renderers/electrobun/view/host/box.tsx +6 -1
  152. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  153. package/src/renderers/electrobun/view/host/style.ts +1 -0
  154. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  155. package/src/renderers/electrobun/view/styles.css +18 -0
  156. package/src/renderers/opentui/host.tsx +17 -1
  157. package/src/renderers/opentui/native-smoke.ts +1 -0
  158. package/src/renderers/opentui/ui-host.tsx +57 -25
  159. package/src/sources/provider-router/batches.ts +16 -2
  160. package/src/sources/provider-router/financial-routes.ts +5 -0
  161. package/src/sources/provider-router/financials.ts +11 -23
  162. package/src/sources/provider-router/index.test.ts +189 -0
  163. package/src/sources/provider-router/primary.ts +3 -2
  164. package/src/sources/sec-edgar.test.ts +83 -0
  165. package/src/sources/sec-edgar.ts +231 -0
  166. package/src/sources/yahoo-finance.test.ts +51 -0
  167. package/src/sources/yahoo-finance.ts +49 -1
  168. package/src/state/app/context/index.test.tsx +5 -8
  169. package/src/types/broker.ts +1 -0
  170. package/src/types/config.ts +10 -0
  171. package/src/types/trading.ts +2 -0
  172. package/src/ui/host.tsx +16 -0
  173. package/src/ui/index.tsx +2 -0
  174. package/src/utils/financial-statements.ts +23 -0
  175. package/src/version.ts +1 -1
  176. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,7 +1,7 @@
1
1
  import { dirname, resolve } from "path";
2
2
  import { mkdir } from "fs/promises";
3
3
  import type { PaneRuntimeState } from "../../core/state/app/state";
4
- import type { TickerFinancials } from "../../types/financials";
4
+ import type { OptionsChain, TickerFinancials } from "../../types/financials";
5
5
  import type { TickerRecord } from "../../types/ticker";
6
6
  import { slugifyName } from "../../utils/slugify";
7
7
  import { renderDesktopPaneScreenshot, type DesktopPaneShotPayload } from "../desktop-pane-shot";
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const DESKTOP_CELL_WIDTH_PX = 8;
20
20
  const DESKTOP_CELL_HEIGHT_PX = 18;
21
+ const OPTIONS_PANE_ID = "options";
21
22
 
22
23
  export function defaultScreenshotPath(resolved: ResolvedPaneFunction, rawArg: string): string {
23
24
  const suffix = slugifyName([resolved.token, rawArg].filter(Boolean).join("-"), "pane");
@@ -70,11 +71,21 @@ async function buildDesktopShotPayload(
70
71
 
71
72
  const tickers: TickerRecord[] = [];
72
73
  const financials: Array<[string, TickerFinancials]> = [];
74
+ const optionsChains: Array<[string, OptionsChain]> = [];
75
+ const includeOptionsChains = resolved.pane.id === OPTIONS_PANE_ID || resolved.template?.paneId === OPTIONS_PANE_ID;
73
76
  for (const symbol of collectShotSymbols(resolved, rawArg)) {
74
77
  const entry = await fetchTickerFinancials(context, symbol);
75
78
  const data = await withShotPriceHistory(context, symbol, entry.tickerFile, entry.financials);
76
79
  tickers.push(entry.tickerFile ?? createFallbackTicker(symbol, data, context));
77
80
  financials.push([symbol, data]);
81
+ if (includeOptionsChains && context.dataProvider.getOptionsChain) {
82
+ const exchange = entry.tickerFile?.metadata.exchange
83
+ ?? data.quote?.listingExchangeName
84
+ ?? data.quote?.exchangeName
85
+ ?? "";
86
+ const chain = await context.dataProvider.getOptionsChain(symbol, exchange);
87
+ optionsChains.push([symbol, chain]);
88
+ }
78
89
  }
79
90
 
80
91
  return {
@@ -86,6 +97,7 @@ async function buildDesktopShotPayload(
86
97
  heightPx,
87
98
  tickers,
88
99
  financials,
100
+ optionsChains,
89
101
  paneState,
90
102
  };
91
103
  }
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildBarChartScene, renderBarChart, renderNativeBarChart } from "./bar-chart-renderer";
2
+ import {
3
+ buildBarChartScene,
4
+ renderBarChart,
5
+ renderBarChartAxis,
6
+ renderBarChartYAxis,
7
+ renderNativeBarChart,
8
+ resolveNativeBarPixelBounds,
9
+ resolveBarChartHover,
10
+ } from "./bar-chart-renderer";
3
11
 
4
12
  const series = [
5
13
  {
@@ -27,27 +35,124 @@ describe("bar chart renderer", () => {
27
35
  const scene = buildBarChartScene(series, {
28
36
  width: 24,
29
37
  height: 8,
30
- colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
38
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
31
39
  });
32
40
 
33
41
  expect(scene).not.toBeNull();
34
42
  expect(scene!.categories).toEqual(["2024", "2025"]);
35
43
  expect(scene!.bars).toHaveLength(4);
36
44
  expect(scene!.zeroRow).toBeGreaterThanOrEqual(0);
45
+ expect(scene!.bars.find((bar) => bar.value < 0)?.color).toBe("#ff5555");
37
46
  });
38
47
 
39
48
  test("renders text fallback and native bitmap output", () => {
40
49
  const scene = buildBarChartScene(series, {
41
50
  width: 24,
42
51
  height: 8,
43
- colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
52
+ colors: {
53
+ bgColor: "#000000",
54
+ gridColor: "#333333",
55
+ axisColor: "#666666",
56
+ negativeColor: "#ff5555",
57
+ hoverColor: "#ffffff",
58
+ },
44
59
  })!;
45
60
 
46
61
  expect(renderBarChart(scene).join("\n")).toContain("█");
47
62
 
48
63
  const bitmap = renderNativeBarChart(scene, 96, 48);
64
+ const negativePixel = Array.from({ length: bitmap.pixels.length / 4 }).some((_, pixelIndex) => {
65
+ const index = pixelIndex * 4;
66
+ return bitmap.pixels[index]! > 200 && bitmap.pixels[index + 1]! < 100 && bitmap.pixels[index + 2]! < 100;
67
+ });
49
68
  expect(bitmap.width).toBe(96);
50
69
  expect(bitmap.height).toBe(48);
51
70
  expect(bitmap.pixels.some((value) => value > 0)).toBe(true);
71
+ expect(negativePixel).toBe(true);
72
+
73
+ const hover = resolveBarChartHover(scene, scene.bars[0]!.x);
74
+ expect(renderBarChart(scene, hover).join("\n")).toContain("▓");
75
+ const hoverBitmap = renderNativeBarChart(scene, 96, 48, hover);
76
+ const hoverPixel = Array.from({ length: hoverBitmap.pixels.length / 4 }).some((_, pixelIndex) => {
77
+ const index = pixelIndex * 4;
78
+ return hoverBitmap.pixels[index]! > 240 && hoverBitmap.pixels[index + 1]! > 240 && hoverBitmap.pixels[index + 2]! > 240;
79
+ });
80
+ expect(hoverBitmap.pixels).not.toEqual(bitmap.pixels);
81
+ expect(hoverPixel).toBe(true);
82
+ });
83
+
84
+ test("renders y-axis labels and resolves hover targets", () => {
85
+ const scene = buildBarChartScene(series, {
86
+ width: 24,
87
+ height: 8,
88
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
89
+ })!;
90
+
91
+ const yAxis = renderBarChartYAxis(scene, 8, (value) => String(Math.round(value)));
92
+ const labelCount = yAxis.filter((line) => line.trim().length > 0).length;
93
+ expect(labelCount).toBeGreaterThanOrEqual(5);
94
+ expect(yAxis.join("\n")).toContain("22");
95
+ expect(yAxis.join("\n")).toContain("0");
96
+
97
+ const hover = resolveBarChartHover(scene, scene.bars[1]!.x);
98
+ expect(hover).toMatchObject({
99
+ seriesId: "NVDA",
100
+ seriesLabel: "NVDA",
101
+ category: "2024",
102
+ value: 20,
103
+ x: scene.bars[1]!.x,
104
+ width: scene.bars[1]!.width,
105
+ row: scene.bars[1]!.row,
106
+ });
107
+ });
108
+
109
+ test("renders dense single-series bars with consistent separators and readable x-axis labels", () => {
110
+ const denseSeries = [{
111
+ id: "value",
112
+ label: "Value",
113
+ color: "#00ff66",
114
+ points: Array.from({ length: 14 }, (_unused, index) => ({
115
+ category: `${2015 + index} Q1`,
116
+ value: index + 1,
117
+ })),
118
+ }];
119
+ const scene = buildBarChartScene(denseSeries, {
120
+ width: 47,
121
+ height: 8,
122
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
123
+ })!;
124
+
125
+ const lastBar = scene.bars.at(-1)!;
126
+ expect(lastBar.x + lastBar.width).toBe(scene.width);
127
+ expect(renderBarChart(scene)[scene.zeroRow]).not.toContain("─");
128
+
129
+ const bitmapWidth = 470;
130
+ const pixelBounds = scene.bars.map((bar) => resolveNativeBarPixelBounds(scene, bar, bitmapWidth));
131
+ const pixelWidths = pixelBounds.map((bounds) => bounds.rightExclusive - bounds.left);
132
+ expect(Math.max(...pixelWidths) - Math.min(...pixelWidths)).toBeLessThanOrEqual(1);
133
+ expect(pixelBounds[0]!.left).toBe(0);
134
+ expect(pixelBounds.at(-1)!.rightExclusive).toBe(bitmapWidth);
135
+ const pixelGaps: number[] = [];
136
+ for (let index = 1; index < pixelBounds.length; index++) {
137
+ pixelGaps.push(pixelBounds[index]!.left - pixelBounds[index - 1]!.rightExclusive);
138
+ }
139
+ expect(new Set(pixelGaps)).toEqual(new Set([1]));
140
+
141
+ const bitmap = renderNativeBarChart(scene, bitmapWidth, 80);
142
+ const zeroY = Math.round((scene.zeroRow / Math.max(scene.height - 1, 1)) * (bitmap.height - 1));
143
+ let separatorColumns = 0;
144
+ for (let x = 0; x < bitmap.width; x++) {
145
+ const index = (zeroY * bitmap.width + x) * 4;
146
+ if (bitmap.pixels[index + 1]! <= 120) separatorColumns++;
147
+ }
148
+ expect(separatorColumns).toBe(scene.categories.length - 1);
149
+
150
+ const axis = renderBarChartAxis(scene, 1);
151
+ expect(axis).toHaveLength(1);
152
+ expect(axis.every((line) => line.length === scene.width)).toBe(true);
153
+ expect(axis.join("\n")).toContain("2015");
154
+ expect(axis.join("\n")).not.toContain("Q1");
155
+ expect(axis.join("\n")).not.toContain("20 20");
156
+ expect(axis[0]!.endsWith(" ")).toBe(true);
52
157
  });
53
158
  });
@@ -1,5 +1,6 @@
1
1
  import type { NativeChartBitmap } from "./native/chart-rasterizer";
2
2
  import { fillRect, parseHex } from "./native/raster/primitives";
3
+ import { formatCompact } from "../../utils/format";
3
4
 
4
5
  interface BarChartPoint {
5
6
  category: string;
@@ -17,6 +18,8 @@ export interface BarChartColors {
17
18
  bgColor: string;
18
19
  gridColor: string;
19
20
  axisColor: string;
21
+ negativeColor?: string;
22
+ hoverColor?: string;
20
23
  }
21
24
 
22
25
  export interface BarChartSceneOptions {
@@ -25,8 +28,9 @@ export interface BarChartSceneOptions {
25
28
  colors: BarChartColors;
26
29
  }
27
30
 
28
- interface BarChartBar {
31
+ export interface BarChartBar {
29
32
  seriesId: string;
33
+ seriesLabel: string;
30
34
  category: string;
31
35
  value: number;
32
36
  color: string;
@@ -47,8 +51,35 @@ export interface BarChartScene {
47
51
  colors: BarChartColors;
48
52
  }
49
53
 
54
+ export interface BarChartHover {
55
+ seriesId: string;
56
+ seriesLabel: string;
57
+ category: string;
58
+ value: number;
59
+ color: string;
60
+ x: number;
61
+ width: number;
62
+ row: number;
63
+ }
64
+
65
+ export interface NativeBarChartPixelBounds {
66
+ left: number;
67
+ rightExclusive: number;
68
+ }
69
+
50
70
  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
51
71
 
72
+ function categorySlotBounds(width: number, categoryCount: number, index: number): { left: number; width: number } {
73
+ const left = Math.floor((index * width) / categoryCount);
74
+ const right = index === categoryCount - 1
75
+ ? width
76
+ : Math.floor(((index + 1) * width) / categoryCount);
77
+ return {
78
+ left,
79
+ width: Math.max(1, right - left),
80
+ };
81
+ }
82
+
52
83
  function collectCategories(series: BarChartSeries[]): string[] {
53
84
  const categories: string[] = [];
54
85
  const seen = new Set<string>();
@@ -64,14 +95,19 @@ function collectCategories(series: BarChartSeries[]): string[] {
64
95
 
65
96
  function normalizeRange(values: number[]): { min: number; max: number } {
66
97
  if (values.length === 0) return { min: 0, max: 1 };
67
- const rawMin = Math.min(0, ...values);
68
- const rawMax = Math.max(0, ...values);
69
- if (rawMin === rawMax) {
70
- const delta = Math.max(Math.abs(rawMin) * 0.1, 1);
71
- return { min: rawMin - delta, max: rawMax + delta };
98
+ const dataMin = Math.min(...values);
99
+ const dataMax = Math.max(...values);
100
+ if (dataMin === dataMax) {
101
+ if (dataMin === 0) return { min: -1, max: 1 };
102
+ const delta = Math.max(Math.abs(dataMin) * 0.1, 1);
103
+ return dataMin > 0
104
+ ? { min: 0, max: dataMax + delta }
105
+ : { min: dataMin - delta, max: 0 };
72
106
  }
73
- const padding = (rawMax - rawMin) * 0.08;
74
- return { min: rawMin - padding, max: rawMax + padding };
107
+ const padding = (dataMax - dataMin) * 0.08;
108
+ if (dataMin >= 0) return { min: 0, max: dataMax + padding };
109
+ if (dataMax <= 0) return { min: dataMin - padding, max: 0 };
110
+ return { min: dataMin - padding, max: dataMax + padding };
75
111
  }
76
112
 
77
113
  function valueToRow(value: number, min: number, max: number, height: number): number {
@@ -79,6 +115,12 @@ function valueToRow(value: number, min: number, max: number, height: number): nu
79
115
  return clamp(Math.round((1 - t) * (height - 1)), 0, height - 1);
80
116
  }
81
117
 
118
+ function rowToValue(row: number, scene: Pick<BarChartScene, "height" | "min" | "max">): number {
119
+ if (scene.height <= 1) return scene.max;
120
+ const t = 1 - row / Math.max(scene.height - 1, 1);
121
+ return scene.min + t * (scene.max - scene.min);
122
+ }
123
+
82
124
  export function buildBarChartScene(series: BarChartSeries[], options: BarChartSceneOptions): BarChartScene | null {
83
125
  const width = Math.max(1, Math.floor(options.width));
84
126
  const height = Math.max(1, Math.floor(options.height));
@@ -91,23 +133,32 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
91
133
 
92
134
  const { min, max } = normalizeRange(values);
93
135
  const zeroRow = valueToRow(0, min, max, height);
94
- const groupWidth = Math.max(1, Math.floor(width / categories.length));
95
136
  const seriesCount = Math.max(series.length, 1);
96
- const barWidth = Math.max(1, Math.floor(Math.max(groupWidth - 1, 1) / seriesCount));
137
+ const categorySlots = categories.map((_, index) => categorySlotBounds(width, categories.length, index));
138
+ const minSlotWidth = Math.max(1, Math.min(...categorySlots.map((slot) => slot.width)));
139
+ const groupedInnerWidth = Math.max(seriesCount, minSlotWidth - (minSlotWidth > seriesCount * 2 ? 1 : 0));
140
+ const groupedBarWidth = Math.max(1, Math.floor(groupedInnerWidth / seriesCount));
97
141
  const bars: BarChartBar[] = [];
98
142
 
99
143
  categories.forEach((category, categoryIndex) => {
100
- const groupLeft = categoryIndex * groupWidth;
144
+ const group = categorySlots[categoryIndex]!;
145
+ const barWidth = seriesCount === 1 ? group.width : groupedBarWidth;
146
+ const barGroupWidth = barWidth * seriesCount;
147
+ const groupOffset = Math.max(0, Math.floor((group.width - barGroupWidth) / 2));
101
148
  series.forEach((item, seriesIndex) => {
102
149
  const value = item.points.find((point) => point.category === category)?.value;
103
150
  if (typeof value !== "number" || !Number.isFinite(value)) return;
151
+ const color = value < 0 && options.colors.negativeColor
152
+ ? options.colors.negativeColor
153
+ : item.color;
104
154
  bars.push({
105
155
  seriesId: item.id,
156
+ seriesLabel: item.label,
106
157
  category,
107
158
  value,
108
- color: item.color,
109
- x: clamp(groupLeft + seriesIndex * barWidth, 0, width - 1),
110
- width: Math.min(barWidth, Math.max(width - groupLeft, 1)),
159
+ color,
160
+ x: clamp(group.left + groupOffset + seriesIndex * barWidth, 0, width - 1),
161
+ width: Math.min(barWidth, Math.max(width - (group.left + groupOffset + seriesIndex * barWidth), 1)),
111
162
  row: valueToRow(value, min, max, height),
112
163
  });
113
164
  });
@@ -126,18 +177,81 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
126
177
  };
127
178
  }
128
179
 
129
- export function renderBarChart(scene: BarChartScene): string[] {
180
+ function labelAtRow(labels: Array<{ row: number; value: number }>, row: number): number | null {
181
+ const match = labels.find((label) => label.row === row);
182
+ return match ? match.value : null;
183
+ }
184
+
185
+ export function barChartYAxisTicks(scene: BarChartScene): Array<{ row: number; value: number }> {
186
+ const ticksByRow = new Map<number, number>();
187
+ const addTick = (row: number, value: number) => {
188
+ ticksByRow.set(clamp(row, 0, scene.height - 1), value);
189
+ };
190
+
191
+ addTick(0, scene.max);
192
+ for (let i = 1; i <= 3; i++) {
193
+ const row = Math.round((scene.height - 1) * (i / 4));
194
+ addTick(row, rowToValue(row, scene));
195
+ }
196
+ addTick(scene.zeroRow, 0);
197
+ addTick(scene.height - 1, scene.min);
198
+
199
+ return [...ticksByRow.entries()]
200
+ .map(([row, value]) => ({ row, value }))
201
+ .sort((left, right) => left.row - right.row);
202
+ }
203
+
204
+ export function renderBarChartYAxis(
205
+ scene: BarChartScene,
206
+ width: number,
207
+ formatValue: (value: number) => string = formatCompact,
208
+ ): string[] {
209
+ const axisWidth = Math.max(0, Math.floor(width));
210
+ if (axisWidth === 0) return Array.from({ length: scene.height }, () => "");
211
+
212
+ const ticks = barChartYAxisTicks(scene);
213
+ return Array.from({ length: scene.height }, (_unused, row) => {
214
+ const value = labelAtRow(ticks, row);
215
+ const label = value === null ? "" : formatValue(value);
216
+ return `${label.slice(0, Math.max(axisWidth - 1, 0)).padStart(Math.max(axisWidth - 1, 0))} `;
217
+ });
218
+ }
219
+
220
+ export function resolveBarChartHover(scene: BarChartScene, cellX: number): BarChartHover | null {
221
+ if (!Number.isFinite(cellX) || scene.bars.length === 0) return null;
222
+ const x = clamp(Math.floor(cellX), 0, scene.width - 1);
223
+ const direct = scene.bars.find((bar) => x >= bar.x && x < bar.x + bar.width);
224
+ const bar = direct ?? scene.bars.reduce((closest, current) => {
225
+ const closestCenter = closest.x + closest.width / 2;
226
+ const currentCenter = current.x + current.width / 2;
227
+ return Math.abs(currentCenter - x) < Math.abs(closestCenter - x) ? current : closest;
228
+ }, scene.bars[0]!);
229
+
230
+ return {
231
+ seriesId: bar.seriesId,
232
+ seriesLabel: bar.seriesLabel,
233
+ category: bar.category,
234
+ value: bar.value,
235
+ color: bar.color,
236
+ x: bar.x,
237
+ width: bar.width,
238
+ row: bar.row,
239
+ };
240
+ }
241
+
242
+ export function renderBarChart(scene: BarChartScene, hover: BarChartHover | null = null): string[] {
130
243
  const rows = Array.from({ length: scene.height }, () => Array(scene.width).fill(" "));
131
244
  for (let x = 0; x < scene.width; x++) {
132
245
  rows[scene.zeroRow]![x] = "─";
133
246
  }
134
247
 
135
248
  for (const bar of scene.bars) {
249
+ const fill = hover && hover.seriesId === bar.seriesId && hover.category === bar.category ? "▓" : "█";
136
250
  const top = Math.min(bar.row, scene.zeroRow);
137
251
  const bottom = Math.max(bar.row, scene.zeroRow);
138
252
  for (let y = top; y <= bottom; y++) {
139
253
  for (let x = bar.x; x < Math.min(bar.x + bar.width, scene.width); x++) {
140
- rows[y]![x] = "█";
254
+ rows[y]![x] = fill;
141
255
  }
142
256
  }
143
257
  }
@@ -145,26 +259,101 @@ export function renderBarChart(scene: BarChartScene): string[] {
145
259
  return rows.map((row) => row.join(""));
146
260
  }
147
261
 
148
- export function renderBarChartAxis(scene: BarChartScene): string {
149
- const axis = Array(scene.width).fill(" ");
150
- const groupWidth = Math.max(1, Math.floor(scene.width / scene.categories.length));
262
+ function centeredLabelStart(center: number, label: string, width: number, rightInset = 0): number {
263
+ return clamp(Math.round(center - label.length / 2), 0, Math.max(0, width - label.length - rightInset));
264
+ }
265
+
266
+ function compactCategoryLabel(category: string, previous: string | undefined, categoryCount: number): string {
267
+ const quarterMatch = category.match(/^(\d{4}) Q([1-4])$/);
268
+ if (!quarterMatch) return category;
269
+ const year = quarterMatch[1]!;
270
+ const previousYear = previous?.match(/^(\d{4}) Q[1-4]$/)?.[1];
271
+ if (categoryCount > 10) return previousYear === year ? "" : year;
272
+ return category;
273
+ }
274
+
275
+ function canPlaceLabel(row: string[], start: number, label: string, minGap: number): boolean {
276
+ const left = Math.max(0, start - minGap);
277
+ const right = Math.min(row.length - 1, start + label.length + minGap - 1);
278
+ for (let index = left; index <= right; index++) {
279
+ if (row[index] !== " ") return false;
280
+ }
281
+ return true;
282
+ }
283
+
284
+ export function renderBarChartAxis(scene: BarChartScene, rowCount = 1): string[] {
285
+ const rows = Array.from({ length: Math.max(1, Math.floor(rowCount)) }, () => Array(scene.width).fill(" "));
286
+ const minGap = scene.categories.length > 10 ? 3 : 1;
287
+ const rightInset = scene.categories.length > 10 ? 2 : 0;
151
288
  scene.categories.forEach((category, index) => {
152
- const label = category.slice(0, Math.max(1, groupWidth - 1));
153
- const start = index * groupWidth;
289
+ const label = compactCategoryLabel(category, scene.categories[index - 1], scene.categories.length);
290
+ if (!label) return;
291
+ const group = categorySlotBounds(scene.width, scene.categories.length, index);
292
+ const start = centeredLabelStart(group.left + group.width / 2, label, scene.width, rightInset);
293
+ const row = rows.find((candidate) => canPlaceLabel(candidate, start, label, minGap));
294
+ if (!row) return;
154
295
  for (let i = 0; i < label.length && start + i < scene.width; i++) {
155
- axis[start + i] = label[i]!;
296
+ row[start + i] = label[i]!;
156
297
  }
157
298
  });
158
- return axis.join("");
299
+ return rows.map((row) => row.join(""));
159
300
  }
160
301
 
161
- export function renderNativeBarChart(scene: BarChartScene, pixelWidth: number, pixelHeight: number): NativeChartBitmap {
302
+ export function resolveNativeBarPixelBounds(
303
+ scene: BarChartScene,
304
+ bar: BarChartBar,
305
+ pixelWidth: number,
306
+ ): NativeBarChartPixelBounds {
307
+ const width = Math.max(1, Math.floor(pixelWidth));
308
+
309
+ if (scene.series.length === 1) {
310
+ const categoryIndex = scene.categories.indexOf(bar.category);
311
+ if (categoryIndex >= 0) {
312
+ const separatorWidth = scene.categories.length > 1 && width >= scene.categories.length * 3 ? 1 : 0;
313
+ const usableWidth = Math.max(
314
+ scene.categories.length,
315
+ width - separatorWidth * (scene.categories.length - 1),
316
+ );
317
+ const left = Math.floor((categoryIndex * usableWidth) / scene.categories.length)
318
+ + categoryIndex * separatorWidth;
319
+ const rightExclusive = categoryIndex === scene.categories.length - 1
320
+ ? width
321
+ : Math.floor(((categoryIndex + 1) * usableWidth) / scene.categories.length)
322
+ + categoryIndex * separatorWidth;
323
+ return {
324
+ left,
325
+ rightExclusive: Math.max(left + 1, rightExclusive),
326
+ };
327
+ }
328
+ }
329
+
330
+ const horizontalInset = 1;
331
+ const left = clamp(
332
+ Math.floor((bar.x / scene.width) * width + horizontalInset),
333
+ 0,
334
+ width - 1,
335
+ );
336
+ const rightExclusive = clamp(
337
+ Math.ceil(((bar.x + bar.width) / scene.width) * width - horizontalInset),
338
+ left + 1,
339
+ width,
340
+ );
341
+ return { left, rightExclusive };
342
+ }
343
+
344
+ export function renderNativeBarChart(
345
+ scene: BarChartScene,
346
+ pixelWidth: number,
347
+ pixelHeight: number,
348
+ hover: BarChartHover | null = null,
349
+ ): NativeChartBitmap {
162
350
  const width = Math.max(1, Math.floor(pixelWidth));
163
351
  const height = Math.max(1, Math.floor(pixelHeight));
164
352
  const data = new Uint8Array(width * height * 4);
165
353
  const bg = parseHex(scene.colors.bgColor, 1);
166
354
  const grid = parseHex(scene.colors.gridColor, 0.45);
167
355
  const axis = parseHex(scene.colors.axisColor, 0.75);
356
+ const hoverColor = parseHex(scene.colors.hoverColor ?? scene.colors.axisColor, 1);
168
357
 
169
358
  fillRect(data, width, height, 0, 0, width - 1, height - 1, bg, 1);
170
359
 
@@ -177,13 +366,23 @@ export function renderNativeBarChart(scene: BarChartScene, pixelWidth: number, p
177
366
  fillRect(data, width, height, 0, zeroY, width - 1, zeroY + 1.1, axis, 0.8);
178
367
 
179
368
  for (const bar of scene.bars) {
369
+ const isHovered = !!hover
370
+ && hover.seriesId === bar.seriesId
371
+ && hover.category === bar.category;
180
372
  const color = parseHex(bar.color, 0.96);
181
- const left = (bar.x / scene.width) * width + 1;
182
- const right = ((bar.x + bar.width) / scene.width) * width - 1;
373
+ const bounds = resolveNativeBarPixelBounds(scene, bar, width);
374
+ const left = bounds.left;
375
+ const right = bounds.rightExclusive - 1;
183
376
  const valueY = clamp((bar.row / Math.max(scene.height - 1, 1)) * (height - 1), 0, height - 1);
184
377
  const top = Math.min(valueY, zeroY);
185
378
  const bottom = Math.max(valueY, zeroY);
186
- fillRect(data, width, height, left, top, Math.max(left, right), Math.max(top + 1, bottom), color, 1);
379
+ fillRect(data, width, height, left, top, right, Math.max(top + 1, bottom), color, 1);
380
+ if (isHovered) {
381
+ fillRect(data, width, height, left, top, right, top + 1.3, hoverColor, 1);
382
+ fillRect(data, width, height, left, bottom - 1.3, right, bottom, hoverColor, 1);
383
+ fillRect(data, width, height, left, top, left + 1.3, bottom, hoverColor, 1);
384
+ fillRect(data, width, height, right - 1.3, top, right, bottom, hoverColor, 1);
385
+ }
187
386
  }
188
387
 
189
388
  return { width, height, pixels: data };
@@ -11,11 +11,16 @@ interface RenderableSizeLike {
11
11
  interface PlotRenderableLike extends RenderableSizeLike {
12
12
  x?: number;
13
13
  y?: number;
14
+ absoluteX?: number;
15
+ absoluteY?: number;
16
+ absoluteBounds?: { x: number; y: number; width: number; height: number };
14
17
  }
15
18
 
16
19
  export interface ChartMouseEvent {
17
20
  x: number;
18
21
  y: number;
22
+ preciseX?: number;
23
+ preciseY?: number;
19
24
  pixelX?: number;
20
25
  pixelY?: number;
21
26
  stopPropagation?: () => void;
@@ -95,14 +100,33 @@ function getRendererCellMetrics(renderer: RendererMetricsHost) {
95
100
  }
96
101
 
97
102
  function getRenderableSize(renderable: RenderableSizeLike | null): { width: number; height: number } | null {
98
- if (!renderable || typeof renderable.width !== "number" || typeof renderable.height !== "number") return null;
99
- return { width: renderable.width, height: renderable.height };
103
+ if (!renderable) return null;
104
+ if (typeof renderable.width === "number" && typeof renderable.height === "number") {
105
+ return { width: renderable.width, height: renderable.height };
106
+ }
107
+ const absoluteBounds = (renderable as PlotRenderableLike).absoluteBounds;
108
+ if (absoluteBounds && typeof absoluteBounds.width === "number" && typeof absoluteBounds.height === "number") {
109
+ return { width: absoluteBounds.width, height: absoluteBounds.height };
110
+ }
111
+ return null;
100
112
  }
101
113
 
102
114
  function getPlotBounds(renderable: PlotRenderableLike | null): { x: number; y: number; width: number; height: number } | null {
103
- if (!renderable || typeof renderable.x !== "number" || typeof renderable.y !== "number") return null;
115
+ if (!renderable) return null;
104
116
  const size = getRenderableSize(renderable);
105
- return size ? { x: renderable.x, y: renderable.y, width: size.width, height: size.height } : null;
117
+ if (!size) return null;
118
+ const x = typeof renderable.absoluteX === "number"
119
+ ? renderable.absoluteX
120
+ : typeof renderable.absoluteBounds?.x === "number"
121
+ ? renderable.absoluteBounds.x
122
+ : renderable.x;
123
+ const y = typeof renderable.absoluteY === "number"
124
+ ? renderable.absoluteY
125
+ : typeof renderable.absoluteBounds?.y === "number"
126
+ ? renderable.absoluteBounds.y
127
+ : renderable.y;
128
+ if (typeof x !== "number" || typeof y !== "number") return null;
129
+ return { x, y, width: size.width, height: size.height };
106
130
  }
107
131
 
108
132
  export function getRenderablePixelSize(
@@ -216,8 +240,10 @@ export function getLocalPlotPointer(
216
240
  const bounds = getPlotBounds(renderable);
217
241
  if (!bounds) return null;
218
242
 
219
- const localCellX = event.x - bounds.x;
220
- const localCellY = event.y - bounds.y;
243
+ const rawCellX = typeof event.preciseX === "number" ? event.preciseX : event.x;
244
+ const rawCellY = typeof event.preciseY === "number" ? event.preciseY : event.y;
245
+ const localCellX = rawCellX - bounds.x;
246
+ const localCellY = rawCellY - bounds.y;
221
247
  if (localCellX < 0 || localCellX >= bounds.width || localCellY < 0 || localCellY >= bounds.height) {
222
248
  return null;
223
249
  }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { NativeRendererHost } from "../../../ui";
3
+ import { shouldRenderNativeBitmap } from "./bitmap-support";
4
+
5
+ function rendererWithCapabilities(capabilities: unknown): NativeRendererHost {
6
+ return {
7
+ terminalWidth: 80,
8
+ terminalHeight: 24,
9
+ resolution: null,
10
+ capabilities,
11
+ on() {},
12
+ off() {},
13
+ requestRender() {},
14
+ registerLifecyclePass() {},
15
+ unregisterLifecyclePass() {},
16
+ };
17
+ }
18
+
19
+ describe("shouldRenderNativeBitmap", () => {
20
+ test("skips native bitmaps only when kitty graphics are known unsupported", () => {
21
+ expect(shouldRenderNativeBitmap(rendererWithCapabilities({ kitty_graphics: false }), true)).toBe(false);
22
+ expect(shouldRenderNativeBitmap(rendererWithCapabilities({ kitty_graphics: true }), true)).toBe(true);
23
+ expect(shouldRenderNativeBitmap(rendererWithCapabilities(null), true)).toBe(true);
24
+ expect(shouldRenderNativeBitmap(rendererWithCapabilities({}), true)).toBe(true);
25
+ expect(shouldRenderNativeBitmap(rendererWithCapabilities({ kitty_graphics: true }), false)).toBe(false);
26
+ });
27
+ });