gloomberb 0.9.6 → 0.9.8

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 (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -7,6 +7,7 @@ import {
7
7
  renderNativeBarChart,
8
8
  resolveNativeBarPixelBounds,
9
9
  resolveBarChartHover,
10
+ resolveNativeBarChartHover,
10
11
  } from "./bar-chart-renderer";
11
12
 
12
13
  const series = [
@@ -122,16 +123,14 @@ describe("bar chart renderer", () => {
122
123
  colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
123
124
  })!;
124
125
 
125
- const lastBar = scene.bars.at(-1)!;
126
- expect(lastBar.x + lastBar.width).toBe(scene.width);
127
- expect(renderBarChart(scene)[scene.zeroRow]).not.toContain("─");
126
+ const zeroRow = renderBarChart(scene)[scene.zeroRow]!;
127
+ expect([...zeroRow].filter((char) => char === "█").length).toBeGreaterThan(0);
128
+ expect([...zeroRow].filter((char) => char === "─").length).toBeGreaterThanOrEqual(scene.categories.length - 1);
128
129
 
129
130
  const bitmapWidth = 470;
130
131
  const pixelBounds = scene.bars.map((bar) => resolveNativeBarPixelBounds(scene, bar, bitmapWidth));
131
132
  const pixelWidths = pixelBounds.map((bounds) => bounds.rightExclusive - bounds.left);
132
133
  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
134
  const pixelGaps: number[] = [];
136
135
  for (let index = 1; index < pixelBounds.length; index++) {
137
136
  pixelGaps.push(pixelBounds[index]!.left - pixelBounds[index - 1]!.rightExclusive);
@@ -139,13 +138,7 @@ describe("bar chart renderer", () => {
139
138
  expect(new Set(pixelGaps)).toEqual(new Set([1]));
140
139
 
141
140
  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);
141
+ expect(bitmap.pixels.some((value) => value > 0)).toBe(true);
149
142
 
150
143
  const axis = renderBarChartAxis(scene, 1);
151
144
  expect(axis).toHaveLength(1);
@@ -155,4 +148,86 @@ describe("bar chart renderer", () => {
155
148
  expect(axis.join("\n")).not.toContain("20 20");
156
149
  expect(axis[0]!.endsWith(" ")).toBe(true);
157
150
  });
151
+
152
+ test("drops null-only categories while keeping fixed ticker slots", () => {
153
+ const sparseSeries = [
154
+ {
155
+ id: "AMD",
156
+ label: "AMD",
157
+ color: "#00ff66",
158
+ points: [
159
+ { category: "2025 Q1", value: 10 },
160
+ { category: "2025 Q2", value: 11 },
161
+ { category: "2025 Q3", value: 12 },
162
+ { category: "2025 Q4", value: null },
163
+ ],
164
+ },
165
+ {
166
+ id: "NVDA",
167
+ label: "NVDA",
168
+ color: "#4dabf7",
169
+ points: [
170
+ { category: "2025 Q1", value: 20 },
171
+ { category: "2025 Q3", value: 30 },
172
+ { category: "2025 Q4", value: null },
173
+ ],
174
+ },
175
+ ];
176
+ const scene = buildBarChartScene(sparseSeries, {
177
+ width: 36,
178
+ height: 8,
179
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
180
+ })!;
181
+
182
+ expect(scene.categories).toEqual(["2025 Q1", "2025 Q2", "2025 Q3"]);
183
+ const middleCategoryBars = scene.bars.filter((bar) => bar.category === "2025 Q2");
184
+ expect(middleCategoryBars).toHaveLength(1);
185
+ expect(middleCategoryBars[0]!.seriesCount).toBe(2);
186
+
187
+ const bitmapWidth = 360;
188
+ const firstAmd = resolveNativeBarPixelBounds(scene, scene.bars.find((bar) => bar.category === "2025 Q1" && bar.seriesId === "AMD")!, bitmapWidth);
189
+ const middleAmd = resolveNativeBarPixelBounds(scene, middleCategoryBars[0]!, bitmapWidth);
190
+ expect(middleAmd.rightExclusive - middleAmd.left).toBe(firstAmd.rightExclusive - firstAmd.left);
191
+ });
192
+
193
+ test("spreads saturated text slots and resolves native hover by pixel slot", () => {
194
+ const denseMultiSeries = Array.from({ length: 3 }, (_unused, seriesIndex) => ({
195
+ id: `S${seriesIndex}`,
196
+ label: `S${seriesIndex}`,
197
+ color: "#00ff66",
198
+ points: Array.from({ length: 80 }, (_point, categoryIndex) => ({
199
+ category: `${2020 + Math.floor(categoryIndex / 4)} Q${categoryIndex % 4 + 1}`,
200
+ value: seriesIndex + categoryIndex + 1,
201
+ })),
202
+ }));
203
+ const scene = buildBarChartScene(denseMultiSeries, {
204
+ width: 80,
205
+ height: 8,
206
+ colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
207
+ })!;
208
+
209
+ const xPositions = scene.bars.map((bar) => bar.x);
210
+ expect(Math.min(...xPositions)).toBe(0);
211
+ expect(Math.max(...xPositions)).toBe(scene.width - 1);
212
+ expect(new Set(xPositions).size).toBe(scene.width);
213
+ expect(xPositions.filter((x) => x === scene.width - 1).length).toBeLessThanOrEqual(denseMultiSeries.length);
214
+
215
+ const target = scene.bars.find((bar, index) => (
216
+ scene.bars.findIndex((candidate) => candidate.x === bar.x) < index
217
+ ))!;
218
+ const bitmapWidth = 1200;
219
+ const bounds = resolveNativeBarPixelBounds(scene, target, bitmapWidth);
220
+ const pixelCenter = Math.floor((bounds.left + bounds.rightExclusive - 1) / 2);
221
+
222
+ expect(scene.bars.filter((bar) => bar.x === target.x).length).toBeGreaterThan(1);
223
+ expect(resolveBarChartHover(scene, target.x)).not.toMatchObject({
224
+ seriesId: target.seriesId,
225
+ category: target.category,
226
+ });
227
+ expect(resolveNativeBarChartHover(scene, pixelCenter, bitmapWidth)).toMatchObject({
228
+ seriesId: target.seriesId,
229
+ category: target.category,
230
+ value: target.value,
231
+ });
232
+ });
158
233
  });
@@ -34,6 +34,9 @@ export interface BarChartBar {
34
34
  category: string;
35
35
  value: number;
36
36
  color: string;
37
+ categoryIndex: number;
38
+ seriesIndex: number;
39
+ seriesCount: number;
37
40
  x: number;
38
41
  width: number;
39
42
  row: number;
@@ -69,17 +72,6 @@ export interface NativeBarChartPixelBounds {
69
72
 
70
73
  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
71
74
 
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
-
83
75
  function collectCategories(series: BarChartSeries[]): string[] {
84
76
  const categories: string[] = [];
85
77
  const seen = new Set<string>();
@@ -90,7 +82,10 @@ function collectCategories(series: BarChartSeries[]): string[] {
90
82
  categories.push(point.category);
91
83
  }
92
84
  }
93
- return categories;
85
+ return categories.filter((category) => series.some((item) => {
86
+ const value = item.points.find((point) => point.category === category)?.value;
87
+ return typeof value === "number" && Number.isFinite(value);
88
+ }));
94
89
  }
95
90
 
96
91
  function normalizeRange(values: number[]): { min: number; max: number } {
@@ -133,32 +128,28 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
133
128
 
134
129
  const { min, max } = normalizeRange(values);
135
130
  const zeroRow = valueToRow(0, min, max, height);
136
- const seriesCount = Math.max(series.length, 1);
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));
131
+ const seriesSlotCount = Math.max(series.length, 1);
141
132
  const bars: BarChartBar[] = [];
142
133
 
143
134
  categories.forEach((category, categoryIndex) => {
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));
148
135
  series.forEach((item, seriesIndex) => {
149
136
  const value = item.points.find((point) => point.category === category)?.value;
150
137
  if (typeof value !== "number" || !Number.isFinite(value)) return;
151
138
  const color = value < 0 && options.colors.negativeColor
152
139
  ? options.colors.negativeColor
153
140
  : item.color;
141
+ const barBounds = chartSlotBounds(width, categories.length * seriesSlotCount, categoryIndex * seriesSlotCount + seriesIndex);
154
142
  bars.push({
155
143
  seriesId: item.id,
156
144
  seriesLabel: item.label,
157
145
  category,
158
146
  value,
159
147
  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)),
148
+ categoryIndex,
149
+ seriesIndex,
150
+ seriesCount: seriesSlotCount,
151
+ x: barBounds.x,
152
+ width: barBounds.width,
162
153
  row: valueToRow(value, min, max, height),
163
154
  });
164
155
  });
@@ -177,6 +168,47 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
177
168
  };
178
169
  }
179
170
 
171
+ function chartSlotBounds(
172
+ width: number,
173
+ slotCount: number,
174
+ slotIndex: number,
175
+ ): { x: number; width: number } {
176
+ const slots = Math.max(1, slotCount);
177
+ if (slots > width) {
178
+ const x = clamp(Math.floor(slotIndex * width / slots), 0, width - 1);
179
+ const right = clamp(Math.floor((slotIndex + 1) * width / slots), x + 1, width);
180
+ return {
181
+ x,
182
+ width: Math.max(1, right - x),
183
+ };
184
+ }
185
+ const gap = slots > 1 && width >= slots * 2 - 1 ? 1 : 0;
186
+ const usableWidth = Math.max(slots, width - gap * (slots - 1));
187
+ const slotWidth = Math.max(1, Math.floor(usableWidth / slots));
188
+ const usedWidth = slotWidth * slots + gap * (slots - 1);
189
+ const offset = Math.max(0, Math.floor((width - usedWidth) / 2));
190
+ const x = clamp(offset + slotIndex * (slotWidth + gap), 0, width - 1);
191
+ return {
192
+ x,
193
+ width: Math.max(1, Math.min(slotWidth, width - x)),
194
+ };
195
+ }
196
+
197
+ function categoryCellBounds(
198
+ width: number,
199
+ categoryCount: number,
200
+ seriesCount: number,
201
+ categoryIndex: number,
202
+ ): { left: number; width: number } {
203
+ const first = chartSlotBounds(width, categoryCount * seriesCount, categoryIndex * seriesCount);
204
+ const last = chartSlotBounds(width, categoryCount * seriesCount, categoryIndex * seriesCount + seriesCount - 1);
205
+ const right = last.x + last.width;
206
+ return {
207
+ left: first.x,
208
+ width: Math.max(1, right - first.x),
209
+ };
210
+ }
211
+
180
212
  function labelAtRow(labels: Array<{ row: number; value: number }>, row: number): number | null {
181
213
  const match = labels.find((label) => label.row === row);
182
214
  return match ? match.value : null;
@@ -227,6 +259,10 @@ export function resolveBarChartHover(scene: BarChartScene, cellX: number): BarCh
227
259
  return Math.abs(currentCenter - x) < Math.abs(closestCenter - x) ? current : closest;
228
260
  }, scene.bars[0]!);
229
261
 
262
+ return barToHover(bar);
263
+ }
264
+
265
+ function barToHover(bar: BarChartBar): BarChartHover {
230
266
  return {
231
267
  seriesId: bar.seriesId,
232
268
  seriesLabel: bar.seriesLabel,
@@ -239,6 +275,29 @@ export function resolveBarChartHover(scene: BarChartScene, cellX: number): BarCh
239
275
  };
240
276
  }
241
277
 
278
+ export function resolveNativeBarChartHover(
279
+ scene: BarChartScene,
280
+ pixelX: number,
281
+ pixelWidth: number,
282
+ ): BarChartHover | null {
283
+ if (!Number.isFinite(pixelX) || scene.bars.length === 0) return null;
284
+ const width = Math.max(1, Math.floor(pixelWidth));
285
+ const x = clamp(Math.floor(pixelX), 0, width - 1);
286
+ const direct = scene.bars.find((bar) => {
287
+ const bounds = resolveNativeBarPixelBounds(scene, bar, width);
288
+ return x >= bounds.left && x < bounds.rightExclusive;
289
+ });
290
+ const bar = direct ?? scene.bars.reduce((closest, current) => {
291
+ const closestBounds = resolveNativeBarPixelBounds(scene, closest, width);
292
+ const currentBounds = resolveNativeBarPixelBounds(scene, current, width);
293
+ const closestCenter = (closestBounds.left + closestBounds.rightExclusive - 1) / 2;
294
+ const currentCenter = (currentBounds.left + currentBounds.rightExclusive - 1) / 2;
295
+ return Math.abs(currentCenter - x) < Math.abs(closestCenter - x) ? current : closest;
296
+ }, scene.bars[0]!);
297
+
298
+ return barToHover(bar);
299
+ }
300
+
242
301
  export function renderBarChart(scene: BarChartScene, hover: BarChartHover | null = null): string[] {
243
302
  const rows = Array.from({ length: scene.height }, () => Array(scene.width).fill(" "));
244
303
  for (let x = 0; x < scene.width; x++) {
@@ -288,7 +347,7 @@ export function renderBarChartAxis(scene: BarChartScene, rowCount = 1): string[]
288
347
  scene.categories.forEach((category, index) => {
289
348
  const label = compactCategoryLabel(category, scene.categories[index - 1], scene.categories.length);
290
349
  if (!label) return;
291
- const group = categorySlotBounds(scene.width, scene.categories.length, index);
350
+ const group = categoryCellBounds(scene.width, scene.categories.length, Math.max(scene.series.length, 1), index);
292
351
  const start = centeredLabelStart(group.left + group.width / 2, label, scene.width, rightInset);
293
352
  const row = rows.find((candidate) => canPlaceLabel(candidate, start, label, minGap));
294
353
  if (!row) return;
@@ -305,42 +364,39 @@ export function resolveNativeBarPixelBounds(
305
364
  pixelWidth: number,
306
365
  ): NativeBarChartPixelBounds {
307
366
  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,
367
+ const bounds = nativeSlotBounds(
339
368
  width,
369
+ scene.categories.length * bar.seriesCount,
370
+ bar.categoryIndex * bar.seriesCount + bar.seriesIndex,
340
371
  );
372
+ const left = clamp(bounds.left, 0, width - 1);
373
+ const rightExclusive = clamp(bounds.rightExclusive, left + 1, width);
341
374
  return { left, rightExclusive };
342
375
  }
343
376
 
377
+ function nativeSlotBounds(
378
+ width: number,
379
+ slotCount: number,
380
+ slotIndex: number,
381
+ ): NativeBarChartPixelBounds {
382
+ const slots = Math.max(1, slotCount);
383
+ if (slots > width) {
384
+ const left = clamp(Math.floor(slotIndex * width / slots), 0, width - 1);
385
+ const rightExclusive = clamp(Math.floor((slotIndex + 1) * width / slots), left + 1, width);
386
+ return { left, rightExclusive };
387
+ }
388
+ const gap = slots > 1 && width >= slots * 2 - 1 ? 1 : 0;
389
+ const usableWidth = Math.max(slots, width - gap * (slots - 1));
390
+ const slotWidth = Math.max(1, Math.floor(usableWidth / slots));
391
+ const usedWidth = slotWidth * slots + gap * (slots - 1);
392
+ const offset = Math.max(0, Math.floor((width - usedWidth) / 2));
393
+ const left = offset + slotIndex * (slotWidth + gap);
394
+ return {
395
+ left,
396
+ rightExclusive: Math.max(left + 1, left + slotWidth),
397
+ };
398
+ }
399
+
344
400
  export function renderNativeBarChart(
345
401
  scene: BarChartScene,
346
402
  pixelWidth: number,
@@ -1,7 +1,14 @@
1
- import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2
- import { Box, ChartSurface, Span, Text, useNativeRenderer, type BoxRenderable } from "../../../ui";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { Box, ChartSurface, Text, TextAttributes, useNativeRenderer, type BoxRenderable } from "../../../ui";
3
3
  import { colors } from "../../../theme/colors";
4
- import { consumeChartMouseEvent, getLocalPlotPointer, type ChartMouseEvent } from "../core/pointer";
4
+ import {
5
+ consumeChartMouseEvent,
6
+ getLocalPlotPointer,
7
+ getRenderablePixelSize,
8
+ scaleLocalPixelCoordinate,
9
+ type ChartMouseEvent,
10
+ type LocalPlotPointer,
11
+ } from "../core/pointer";
5
12
  import type { NativeChartBitmap } from "../native/chart-rasterizer";
6
13
  import { truncateWithEllipsis } from "../../../utils/text-wrap";
7
14
  import { useStaticChartBitmapSize } from "./chart/bitmap";
@@ -12,11 +19,36 @@ import {
12
19
  renderBarChartYAxis,
13
20
  renderNativeBarChart,
14
21
  resolveBarChartHover,
22
+ resolveNativeBarChartHover,
15
23
  type BarChartColors,
16
24
  type BarChartHover,
25
+ type BarChartScene,
17
26
  type BarChartSeries,
18
27
  } from "../bar-chart-renderer";
19
28
 
29
+ export interface BarChartSurfaceHoverGeometry {
30
+ bitmapPixelWidth: number;
31
+ renderablePixelWidth?: number | null;
32
+ }
33
+
34
+ export function resolveBarChartSurfaceHover(
35
+ scene: BarChartScene,
36
+ pointer: Pick<LocalPlotPointer, "cellX" | "pixelX">,
37
+ geometry?: BarChartSurfaceHoverGeometry | null,
38
+ ): BarChartHover | null {
39
+ if (geometry && pointer.pixelX !== null) {
40
+ const pixelX = scaleLocalPixelCoordinate(
41
+ pointer.pixelX,
42
+ geometry.renderablePixelWidth ?? geometry.bitmapPixelWidth,
43
+ geometry.bitmapPixelWidth,
44
+ );
45
+ if (pixelX !== null) {
46
+ return resolveNativeBarChartHover(scene, pixelX, geometry.bitmapPixelWidth);
47
+ }
48
+ }
49
+ return resolveBarChartHover(scene, pointer.cellX);
50
+ }
51
+
20
52
  export interface StaticBarChartSurfaceProps {
21
53
  series: BarChartSeries[];
22
54
  width: number;
@@ -25,6 +57,8 @@ export interface StaticBarChartSurfaceProps {
25
57
  title?: string;
26
58
  header?: ReactNode;
27
59
  formatValue?: (value: number) => string;
60
+ hiddenSeriesIds?: readonly string[] | ReadonlySet<string>;
61
+ onToggleSeries?: (seriesId: string) => void;
28
62
  onHoverChange?: (hover: BarChartHover | null) => void;
29
63
  onMouseDown?: (event: ChartMouseEvent) => void;
30
64
  }
@@ -36,6 +70,8 @@ export function StaticBarChartSurface({
36
70
  title,
37
71
  header,
38
72
  formatValue,
73
+ hiddenSeriesIds,
74
+ onToggleSeries,
39
75
  onHoverChange,
40
76
  onMouseDown,
41
77
  colors: chartColors,
@@ -59,11 +95,18 @@ export function StaticBarChartSurface({
59
95
  negativeColor: chartColors?.negativeColor ?? colors.negative,
60
96
  hoverColor: chartColors?.hoverColor ?? colors.textBright,
61
97
  }), [chartColors]);
62
- const scene = useMemo(() => buildBarChartScene(series, {
98
+ const hiddenSeriesIdSet = useMemo(() => (
99
+ hiddenSeriesIds instanceof Set ? hiddenSeriesIds : new Set(hiddenSeriesIds ?? [])
100
+ ), [hiddenSeriesIds]);
101
+ const visibleSeries = useMemo(
102
+ () => series.filter((item) => !hiddenSeriesIdSet.has(item.id)),
103
+ [hiddenSeriesIdSet, series],
104
+ );
105
+ const scene = useMemo(() => buildBarChartScene(visibleSeries, {
63
106
  width: plotWidth,
64
107
  height: plotHeight,
65
108
  colors: resolvedChartColors,
66
- }), [plotHeight, plotWidth, resolvedChartColors, series]);
109
+ }), [plotHeight, plotWidth, resolvedChartColors, visibleSeries]);
67
110
 
68
111
  const bitmapSize = useStaticChartBitmapSize(plotWidth, plotHeight);
69
112
 
@@ -78,8 +121,8 @@ export function StaticBarChartSurface({
78
121
  const hoverLabel = useMemo(() => {
79
122
  if (!hover) return null;
80
123
  const value = formatValue ? formatValue(hover.value) : String(hover.value);
81
- return series.length > 1 ? `${hover.category} ${hover.seriesLabel}: ${value}` : `${hover.category}: ${value}`;
82
- }, [formatValue, hover, series.length]);
124
+ return visibleSeries.length > 1 ? `${hover.category} ${hover.seriesLabel}: ${value}` : `${hover.category}: ${value}`;
125
+ }, [formatValue, hover, visibleSeries.length]);
83
126
  const readout = useMemo(() => {
84
127
  if (!hoverLabel) return title ?? "";
85
128
  return title ? `${title} ${hoverLabel}` : hoverLabel;
@@ -98,6 +141,17 @@ export function StaticBarChartSurface({
98
141
  return { labelLeft, labelTop, labelWidth, text };
99
142
  }, [hover, hoverLabel, plotHeight, plotWidth]);
100
143
 
144
+ const resolvePointerHover = useCallback((pointer: LocalPlotPointer): BarChartHover | null => {
145
+ if (!scene) return null;
146
+ const renderablePixelSize = bitmapSize ? getRenderablePixelSize(plotRef.current, renderer) : null;
147
+ return resolveBarChartSurfaceHover(scene, pointer, bitmapSize
148
+ ? {
149
+ bitmapPixelWidth: bitmapSize.pixelWidth,
150
+ renderablePixelWidth: renderablePixelSize?.pixelWidth ?? bitmapSize.pixelWidth,
151
+ }
152
+ : null);
153
+ }, [bitmapSize, renderer, scene]);
154
+
101
155
  const handleMouseMove = useCallback((event: ChartMouseEvent) => {
102
156
  if (!scene) return;
103
157
  const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
@@ -106,11 +160,11 @@ export function StaticBarChartSurface({
106
160
  onHoverChange?.(null);
107
161
  return;
108
162
  }
109
- const nextHover = resolveBarChartHover(scene, pointer.cellX);
163
+ const nextHover = resolvePointerHover(pointer);
110
164
  setHover(nextHover);
111
165
  onHoverChange?.(nextHover);
112
166
  consumeChartMouseEvent(event);
113
- }, [onHoverChange, renderer, scene]);
167
+ }, [onHoverChange, renderer, resolvePointerHover, scene]);
114
168
 
115
169
  const handleMouseOut = useCallback(() => {
116
170
  setHover(null);
@@ -122,16 +176,54 @@ export function StaticBarChartSurface({
122
176
  if (!scene) return;
123
177
  const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
124
178
  if (!pointer) return;
125
- const nextHover = resolveBarChartHover(scene, pointer.cellX);
179
+ const nextHover = resolvePointerHover(pointer);
126
180
  setHover(nextHover);
127
181
  onHoverChange?.(nextHover);
128
182
  consumeChartMouseEvent(event);
129
- }, [onHoverChange, onMouseDown, renderer, scene]);
183
+ }, [onHoverChange, onMouseDown, renderer, resolvePointerHover, scene]);
184
+
185
+ useEffect(() => {
186
+ if (hover && hiddenSeriesIdSet.has(hover.seriesId)) {
187
+ setHover(null);
188
+ onHoverChange?.(null);
189
+ }
190
+ }, [hiddenSeriesIdSet, hover, onHoverChange]);
191
+
192
+ const legend = legendRows ? (
193
+ <Box flexDirection="row" height={1}>
194
+ {series.map((item, index) => {
195
+ const hidden = hiddenSeriesIdSet.has(item.id);
196
+ const legendColor = hidden ? colors.textDim : item.color;
197
+ const legendAttributes = hidden ? TextAttributes.STRIKETHROUGH | TextAttributes.DIM : TextAttributes.NONE;
198
+ const handleLegendMouseDown = onToggleSeries
199
+ ? (event: any) => {
200
+ event.stopPropagation?.();
201
+ onToggleSeries(item.id);
202
+ }
203
+ : undefined;
204
+ return (
205
+ <Box
206
+ key={item.id}
207
+ flexDirection="row"
208
+ height={1}
209
+ onMouseDown={handleLegendMouseDown}
210
+ cursor={onToggleSeries ? "pointer" : undefined}
211
+ data-gloom-interactive={onToggleSeries ? "true" : undefined}
212
+ >
213
+ <Text>{index > 0 ? " " : " "}</Text>
214
+ <Text fg={legendColor} bg={legendColor} attributes={legendAttributes}> </Text>
215
+ <Text fg={legendColor} attributes={legendAttributes}>{` ${item.label}`}</Text>
216
+ </Box>
217
+ );
218
+ })}
219
+ </Box>
220
+ ) : null;
130
221
 
131
222
  if (!scene) {
132
223
  return (
133
224
  <Box flexDirection="column" width={totalWidth} height={totalHeight} onMouseDown={onMouseDown}>
134
225
  {header ?? (title ? <Text fg={colors.textBright} bold>{truncateWithEllipsis(title, totalWidth)}</Text> : null)}
226
+ {legend}
135
227
  <Text fg={colors.textDim}>No chart data</Text>
136
228
  </Box>
137
229
  );
@@ -146,15 +238,7 @@ export function StaticBarChartSurface({
146
238
  </Text>
147
239
  )
148
240
  ) : null}
149
- {legendRows ? (
150
- <Text>
151
- {series.map((item, index) => (
152
- <Span key={item.id} fg={item.color}>
153
- {index > 0 ? " " : ""}{"■ "}{item.label}
154
- </Span>
155
- ))}
156
- </Text>
157
- ) : null}
241
+ {legend}
158
242
  <Box flexDirection="row" width={totalWidth} height={plotHeight}>
159
243
  {yAxisWidth ? (
160
244
  <Box flexDirection="column" width={yAxisWidth} height={plotHeight}>
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ProjectedChartPoint } from "../core/data";
3
+ import { resolveStockChartFooterOhlcReadout } from "./footer";
4
+
5
+ function point(
6
+ close: number,
7
+ overrides: Partial<ProjectedChartPoint> = {},
8
+ ): ProjectedChartPoint {
9
+ return {
10
+ date: new Date("2026-07-03T10:00:00Z"),
11
+ open: close,
12
+ high: close,
13
+ low: close,
14
+ close,
15
+ volume: 100,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("stock chart footer OHLC readout", () => {
21
+ test("summarizes the visible OHLC window for idle chart readouts", () => {
22
+ const readout = resolveStockChartFooterOhlcReadout({
23
+ activePoint: null,
24
+ hasDisplayCursor: false,
25
+ points: [
26
+ point(100, { open: 99, high: 101, low: 98, volume: 10 }),
27
+ point(104, { open: 100, high: 108, low: 99, volume: 20 }),
28
+ point(106, { open: 104, high: 107, low: 103, volume: 30 }),
29
+ ],
30
+ });
31
+
32
+ expect(readout).toMatchObject({
33
+ open: 99,
34
+ high: 108,
35
+ low: 98,
36
+ close: 106,
37
+ volume: 60,
38
+ });
39
+ expect(readout?.changePercent).toBeCloseTo(((106 - 99) / 99) * 100);
40
+ });
41
+
42
+ test("uses the summary only when there is no chart cursor", () => {
43
+ const activePoint = point(104, { open: 103, high: 105, low: 102 });
44
+ const points = [
45
+ point(100, { open: 99, high: 101, low: 98 }),
46
+ point(106, { open: 104, high: 107, low: 103 }),
47
+ ];
48
+
49
+ const idleReadout = resolveStockChartFooterOhlcReadout({
50
+ activePoint,
51
+ hasDisplayCursor: false,
52
+ points,
53
+ });
54
+ const cursorReadout = resolveStockChartFooterOhlcReadout({
55
+ activePoint,
56
+ hasDisplayCursor: true,
57
+ points,
58
+ });
59
+
60
+ expect(idleReadout?.close).toBe(106);
61
+ expect(idleReadout?.changePercent).not.toBeNull();
62
+ expect(cursorReadout).toMatchObject(activePoint);
63
+ expect(cursorReadout?.changePercent).toBeNull();
64
+ });
65
+ });