gloomberb 0.9.7 → 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.
- package/package.json +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- 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
|
|
126
|
-
expect(
|
|
127
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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 =
|
|
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,
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
82
|
-
}, [formatValue, hover,
|
|
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 =
|
|
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 =
|
|
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
|
-
{
|
|
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
|
+
});
|