gloomberb 0.10.2 → 0.10.4
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/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { formatPercentRaw } from "../../../utils/format";
|
|
2
|
+
import type { ChartVectorShape } from "../../../ui/host";
|
|
3
|
+
import type { NativeChartBitmap } from "../native/chart-rasterizer";
|
|
4
|
+
import { clamp, drawCircle, drawLine, fillRect, parseHex } from "../native/raster/primitives";
|
|
5
|
+
import { formatCompositeAxisValue, formatCompositeCursorDate } from "./format";
|
|
6
|
+
import { projectCompositeValue, unprojectCompositeValue } from "./scene";
|
|
7
|
+
import { projectCompositeTimestamp, unprojectCompositeTimestamp } from "./time-scale";
|
|
8
|
+
import type {
|
|
9
|
+
CompositeAxisDomain,
|
|
10
|
+
CompositeChartScene,
|
|
11
|
+
CompositePanelScene,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
/** Pointer tools that take the drag away from panning while one is picked. */
|
|
15
|
+
export type ChartToolKind = "measure" | "zoom" | "line" | "pencil";
|
|
16
|
+
|
|
17
|
+
export interface ChartDrawingPoint {
|
|
18
|
+
time: number;
|
|
19
|
+
value: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Anchored to data, so pan and zoom keep a drawing on its own points. */
|
|
23
|
+
export interface ChartDrawing {
|
|
24
|
+
id: string;
|
|
25
|
+
panelId: string;
|
|
26
|
+
points: ChartDrawingPoint[];
|
|
27
|
+
color: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChartDrawingHit {
|
|
31
|
+
drawing: ChartDrawing;
|
|
32
|
+
/** Grabbed endpoint, or null when the whole shape was grabbed. */
|
|
33
|
+
pointIndex: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const CHART_DRAWING_COLORS = [
|
|
37
|
+
"#f5a524",
|
|
38
|
+
"#4c9aff",
|
|
39
|
+
"#33c481",
|
|
40
|
+
"#ff5c7a",
|
|
41
|
+
"#c084fc",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export interface ChartToolDrag {
|
|
45
|
+
kind: ChartToolKind;
|
|
46
|
+
startXRatio: number;
|
|
47
|
+
startYRatio: number;
|
|
48
|
+
endXRatio: number;
|
|
49
|
+
endYRatio: number;
|
|
50
|
+
/** Sampled pointer trail, used by the freehand tool. */
|
|
51
|
+
path: Array<{ xRatio: number; yRatio: number }>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ChartToolColors {
|
|
55
|
+
positive: string;
|
|
56
|
+
negative: string;
|
|
57
|
+
zoom: string;
|
|
58
|
+
draw: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A drag shorter than this is a mis-click, not a range selection. */
|
|
62
|
+
const MINIMUM_TOOL_DRAG_RATIO = 0.004;
|
|
63
|
+
|
|
64
|
+
/** Pointer slack for grabbing a drawing, as a fraction of the plot's height. */
|
|
65
|
+
const DRAWING_GRAB_RATIO = 0.03;
|
|
66
|
+
|
|
67
|
+
const MINUTE_MS = 60_000;
|
|
68
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
69
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
70
|
+
|
|
71
|
+
function drawingPointFrom(
|
|
72
|
+
scene: CompositeChartScene,
|
|
73
|
+
domain: CompositeAxisDomain,
|
|
74
|
+
xRatio: number,
|
|
75
|
+
yRatio: number,
|
|
76
|
+
): ChartDrawingPoint | null {
|
|
77
|
+
const value = unprojectCompositeValue(yRatio, domain);
|
|
78
|
+
return value === null
|
|
79
|
+
? null
|
|
80
|
+
: { time: unprojectCompositeTimestamp(scene.timeScale, xRatio), value };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Data anchors for a finished drag, so a drawing survives pan and zoom. */
|
|
84
|
+
export function resolveDrawingFromDrag(
|
|
85
|
+
scene: CompositeChartScene,
|
|
86
|
+
panel: CompositePanelScene,
|
|
87
|
+
drag: ChartToolDrag,
|
|
88
|
+
color: string,
|
|
89
|
+
id: string,
|
|
90
|
+
): ChartDrawing | null {
|
|
91
|
+
const domain = resolveMeasureAxisDomain(panel);
|
|
92
|
+
if (!domain || !isMeaningfulToolDrag(drag)) return null;
|
|
93
|
+
const source = drag.kind === "pencil" && drag.path.length > 1
|
|
94
|
+
? drag.path
|
|
95
|
+
: [
|
|
96
|
+
{ xRatio: drag.startXRatio, yRatio: drag.startYRatio },
|
|
97
|
+
{ xRatio: drag.endXRatio, yRatio: drag.endYRatio },
|
|
98
|
+
];
|
|
99
|
+
const points = source
|
|
100
|
+
.map((point) => drawingPointFrom(scene, domain, point.xRatio, point.yRatio))
|
|
101
|
+
.filter((point): point is ChartDrawingPoint => point !== null);
|
|
102
|
+
return points.length < 2 ? null : { id, panelId: panel.id, points, color };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function projectDrawing(
|
|
106
|
+
scene: CompositeChartScene,
|
|
107
|
+
panel: CompositePanelScene,
|
|
108
|
+
drawing: ChartDrawing,
|
|
109
|
+
): Array<{ xRatio: number; yRatio: number }> | null {
|
|
110
|
+
const domain = resolveMeasureAxisDomain(panel);
|
|
111
|
+
if (!domain) return null;
|
|
112
|
+
const projected: Array<{ xRatio: number; yRatio: number }> = [];
|
|
113
|
+
for (const point of drawing.points) {
|
|
114
|
+
const xRatio = projectCompositeTimestamp(scene.timeScale, point.time)?.ratio;
|
|
115
|
+
const yRatio = projectCompositeValue(point.value, domain);
|
|
116
|
+
if (typeof xRatio !== "number" || yRatio === null) return null;
|
|
117
|
+
projected.push({ xRatio, yRatio });
|
|
118
|
+
}
|
|
119
|
+
return projected;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function distanceToSegment(
|
|
123
|
+
point: { x: number; y: number },
|
|
124
|
+
a: { x: number; y: number },
|
|
125
|
+
b: { x: number; y: number },
|
|
126
|
+
): number {
|
|
127
|
+
const dx = b.x - a.x;
|
|
128
|
+
const dy = b.y - a.y;
|
|
129
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
130
|
+
const t = lengthSquared === 0
|
|
131
|
+
? 0
|
|
132
|
+
: clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared, 0, 1);
|
|
133
|
+
return Math.hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Nearest drawing under the pointer. Endpoints win over the body so a line can
|
|
138
|
+
* be reshaped without first dragging the whole thing out of the way.
|
|
139
|
+
*/
|
|
140
|
+
export function hitTestDrawings(
|
|
141
|
+
drawings: readonly ChartDrawing[],
|
|
142
|
+
scene: CompositeChartScene,
|
|
143
|
+
panel: CompositePanelScene,
|
|
144
|
+
pointer: { xRatio: number; yRatio: number },
|
|
145
|
+
/** Plot width over height in pixels, so slack is even in both directions. */
|
|
146
|
+
aspect = 1,
|
|
147
|
+
): ChartDrawingHit | null {
|
|
148
|
+
let best: { hit: ChartDrawingHit; distance: number; endpoint: boolean } | null = null;
|
|
149
|
+
const scale = Math.max(aspect, Number.EPSILON);
|
|
150
|
+
const target = { x: pointer.xRatio * scale, y: pointer.yRatio };
|
|
151
|
+
for (const drawing of drawings) {
|
|
152
|
+
if (drawing.panelId !== panel.id) continue;
|
|
153
|
+
const projected = projectDrawing(scene, panel, drawing);
|
|
154
|
+
if (!projected) continue;
|
|
155
|
+
const shape = projected.map((point) => ({ x: point.xRatio * scale, y: point.yRatio }));
|
|
156
|
+
if (drawing.points.length === 2) {
|
|
157
|
+
for (const [index, point] of shape.entries()) {
|
|
158
|
+
const distance = Math.hypot(target.x - point.x, target.y - point.y);
|
|
159
|
+
if (distance > DRAWING_GRAB_RATIO) continue;
|
|
160
|
+
if (!best?.endpoint || best.distance > distance) {
|
|
161
|
+
best = { hit: { drawing, pointIndex: index }, distance, endpoint: true };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (let index = 1; index < shape.length; index += 1) {
|
|
166
|
+
const distance = distanceToSegment(target, shape[index - 1]!, shape[index]!);
|
|
167
|
+
if (distance > DRAWING_GRAB_RATIO || best?.endpoint) continue;
|
|
168
|
+
if (!best || best.distance > distance) {
|
|
169
|
+
best = { hit: { drawing, pointIndex: null }, distance, endpoint: false };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return best?.hit ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Re-anchors a drawing after a move, through the projection it is drawn with. */
|
|
177
|
+
export function shiftDrawing(
|
|
178
|
+
drawing: ChartDrawing,
|
|
179
|
+
scene: CompositeChartScene,
|
|
180
|
+
panel: CompositePanelScene,
|
|
181
|
+
deltaXRatio: number,
|
|
182
|
+
deltaYRatio: number,
|
|
183
|
+
pointIndex: number | null,
|
|
184
|
+
): ChartDrawing {
|
|
185
|
+
const domain = resolveMeasureAxisDomain(panel);
|
|
186
|
+
const projected = projectDrawing(scene, panel, drawing);
|
|
187
|
+
if (!domain || !projected) return drawing;
|
|
188
|
+
return {
|
|
189
|
+
...drawing,
|
|
190
|
+
points: drawing.points.map((point, index) => {
|
|
191
|
+
if (pointIndex !== null && index !== pointIndex) return point;
|
|
192
|
+
const source = projected[index]!;
|
|
193
|
+
return drawingPointFrom(
|
|
194
|
+
scene,
|
|
195
|
+
domain,
|
|
196
|
+
clamp(source.xRatio + deltaXRatio, 0, 1),
|
|
197
|
+
clamp(source.yRatio + deltaYRatio, 0, 1),
|
|
198
|
+
) ?? point;
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function nextDrawingColor(color: string): string {
|
|
204
|
+
const index = CHART_DRAWING_COLORS.indexOf(color as typeof CHART_DRAWING_COLORS[number]);
|
|
205
|
+
return CHART_DRAWING_COLORS[(index + 1) % CHART_DRAWING_COLORS.length]!;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function resolveChartToolKind(modifiers: {
|
|
209
|
+
shift: boolean;
|
|
210
|
+
alt: boolean;
|
|
211
|
+
ctrl: boolean;
|
|
212
|
+
}): ChartToolKind | null {
|
|
213
|
+
// Ctrl already means zoom-at-pointer on the wheel; leave it alone.
|
|
214
|
+
if (modifiers.ctrl) return null;
|
|
215
|
+
if (modifiers.shift) return "zoom";
|
|
216
|
+
if (modifiers.alt) return "measure";
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function isDrawingTool(tool: ChartToolKind | null): boolean {
|
|
221
|
+
return tool === "line" || tool === "pencil";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isMeaningfulToolDrag(drag: ChartToolDrag): boolean {
|
|
225
|
+
return Math.abs(drag.endXRatio - drag.startXRatio) >= MINIMUM_TOOL_DRAG_RATIO;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Panel axis the measure readout reports against: the first plotted series wins. */
|
|
229
|
+
export function resolveMeasureAxisDomain(
|
|
230
|
+
panel: CompositePanelScene,
|
|
231
|
+
): CompositeAxisDomain | null {
|
|
232
|
+
for (const series of panel.series) {
|
|
233
|
+
const domain = panel.axes[series.source.axis];
|
|
234
|
+
if (domain) return domain;
|
|
235
|
+
}
|
|
236
|
+
return panel.axes.left ?? panel.axes.right ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatMeasureSpan(spanMs: number): string {
|
|
240
|
+
const span = Math.max(0, Math.round(spanMs));
|
|
241
|
+
if (span < MINUTE_MS) return `${Math.round(span / 1000)}s`;
|
|
242
|
+
if (span < HOUR_MS) return `${Math.round(span / MINUTE_MS)}m`;
|
|
243
|
+
if (span < DAY_MS) {
|
|
244
|
+
const hours = Math.floor(span / HOUR_MS);
|
|
245
|
+
const minutes = Math.round((span - hours * HOUR_MS) / MINUTE_MS);
|
|
246
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
247
|
+
}
|
|
248
|
+
const days = Math.floor(span / DAY_MS);
|
|
249
|
+
const hours = Math.round((span - days * DAY_MS) / HOUR_MS);
|
|
250
|
+
return days < 7 && hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function countMeasureBars(
|
|
254
|
+
dates: readonly Date[],
|
|
255
|
+
startTime: number,
|
|
256
|
+
endTime: number,
|
|
257
|
+
): number {
|
|
258
|
+
const low = Math.min(startTime, endTime);
|
|
259
|
+
const high = Math.max(startTime, endTime);
|
|
260
|
+
let count = 0;
|
|
261
|
+
for (const date of dates) {
|
|
262
|
+
const timestamp = date.getTime();
|
|
263
|
+
if (timestamp >= low && timestamp <= high) count += 1;
|
|
264
|
+
}
|
|
265
|
+
return count;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function summarizeMeasure(input: {
|
|
269
|
+
startValue: number | null;
|
|
270
|
+
endValue: number | null;
|
|
271
|
+
startTime: number;
|
|
272
|
+
endTime: number;
|
|
273
|
+
bars: number;
|
|
274
|
+
domain: CompositeAxisDomain | null;
|
|
275
|
+
}): string | null {
|
|
276
|
+
const { startValue, endValue, domain } = input;
|
|
277
|
+
const spanText = formatMeasureSpan(Math.abs(input.endTime - input.startTime));
|
|
278
|
+
const barsText = input.bars > 0 ? ` · ${input.bars} bar${input.bars === 1 ? "" : "s"}` : "";
|
|
279
|
+
if (
|
|
280
|
+
startValue === null
|
|
281
|
+
|| endValue === null
|
|
282
|
+
|| !Number.isFinite(startValue)
|
|
283
|
+
|| !Number.isFinite(endValue)
|
|
284
|
+
) {
|
|
285
|
+
return `Δ —${barsText} · ${spanText}`;
|
|
286
|
+
}
|
|
287
|
+
const delta = endValue - startValue;
|
|
288
|
+
// Sign leads the unit: the axis formatter would render "$-11.0".
|
|
289
|
+
const magnitude = Math.abs(delta);
|
|
290
|
+
const magnitudeText = domain
|
|
291
|
+
? formatCompositeAxisValue(magnitude, domain)
|
|
292
|
+
: magnitude.toFixed(2);
|
|
293
|
+
const signedDeltaText = `${delta > 0 ? "+" : delta < 0 ? "-" : ""}${magnitudeText}`;
|
|
294
|
+
// A zero or sign-flipping baseline makes the percentage meaningless, not huge.
|
|
295
|
+
const percentText = startValue !== 0 && startValue * endValue > 0
|
|
296
|
+
? ` (${formatPercentRaw((delta / Math.abs(startValue)) * 100)})`
|
|
297
|
+
: "";
|
|
298
|
+
return `Δ ${signedDeltaText}${percentText}${barsText} · ${spanText}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Range preview for a live zoom drag. The band drawn on the raster is invisible
|
|
303
|
+
* on the braille renderer, so the selection is also reported as text.
|
|
304
|
+
*/
|
|
305
|
+
export function summarizeZoomSelection(
|
|
306
|
+
scene: CompositeChartScene,
|
|
307
|
+
drag: ChartToolDrag,
|
|
308
|
+
): string | null {
|
|
309
|
+
if (!isMeaningfulToolDrag(drag)) return null;
|
|
310
|
+
const first = unprojectCompositeTimestamp(scene.timeScale, drag.startXRatio);
|
|
311
|
+
const second = unprojectCompositeTimestamp(scene.timeScale, drag.endXRatio);
|
|
312
|
+
if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
|
|
313
|
+
const start = Math.min(first, second);
|
|
314
|
+
const end = Math.max(first, second);
|
|
315
|
+
const label = (timestamp: number) => formatCompositeCursorDate(
|
|
316
|
+
new Date(timestamp),
|
|
317
|
+
scene.startTime,
|
|
318
|
+
scene.endTime,
|
|
319
|
+
);
|
|
320
|
+
return `${label(start)} → ${label(end)} · ${formatMeasureSpan(end - start)}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function resolveMeasureDirection(
|
|
324
|
+
startValue: number | null,
|
|
325
|
+
endValue: number | null,
|
|
326
|
+
): "up" | "down" {
|
|
327
|
+
if (startValue === null || endValue === null) return "up";
|
|
328
|
+
return endValue < startValue ? "down" : "up";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Time range a zoom-box drag selects. Returns null when the drag is too short
|
|
333
|
+
* to be intentional, so a modifier-held click never zooms to a single instant.
|
|
334
|
+
*/
|
|
335
|
+
export function resolveZoomBoxRange(
|
|
336
|
+
scene: CompositeChartScene,
|
|
337
|
+
drag: ChartToolDrag,
|
|
338
|
+
minimumSpanMs: number,
|
|
339
|
+
): { start: Date; end: Date } | null {
|
|
340
|
+
if (!isMeaningfulToolDrag(drag)) return null;
|
|
341
|
+
const first = unprojectCompositeTimestamp(scene.timeScale, drag.startXRatio);
|
|
342
|
+
const second = unprojectCompositeTimestamp(scene.timeScale, drag.endXRatio);
|
|
343
|
+
if (!Number.isFinite(first) || !Number.isFinite(second)) return null;
|
|
344
|
+
const start = Math.min(first, second);
|
|
345
|
+
const end = Math.max(first, second);
|
|
346
|
+
const minimumSpan = Math.max(1, minimumSpanMs);
|
|
347
|
+
if (end - start >= minimumSpan) return { start: new Date(start), end: new Date(end) };
|
|
348
|
+
const center = (start + end) / 2;
|
|
349
|
+
return {
|
|
350
|
+
start: new Date(center - minimumSpan / 2),
|
|
351
|
+
end: new Date(center + minimumSpan / 2),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Overlay geometry in plot ratios, for renderers that composite vectors. */
|
|
356
|
+
export function buildChartToolVectors(input: {
|
|
357
|
+
scene: CompositeChartScene;
|
|
358
|
+
panel: CompositePanelScene;
|
|
359
|
+
drawings: readonly ChartDrawing[];
|
|
360
|
+
selectedId: string | null;
|
|
361
|
+
drag: ChartToolDrag | null;
|
|
362
|
+
colors: ChartToolColors;
|
|
363
|
+
direction: "up" | "down";
|
|
364
|
+
}): ChartVectorShape[] {
|
|
365
|
+
const shapes: ChartVectorShape[] = [];
|
|
366
|
+
for (const drawing of input.drawings) {
|
|
367
|
+
const projected = projectDrawing(input.scene, input.panel, drawing);
|
|
368
|
+
if (!projected) continue;
|
|
369
|
+
const selected = input.selectedId === drawing.id;
|
|
370
|
+
shapes.push({
|
|
371
|
+
id: drawing.id,
|
|
372
|
+
points: projected.map((point) => ({ x: point.xRatio, y: point.yRatio })),
|
|
373
|
+
color: drawing.color,
|
|
374
|
+
strokeWidth: selected ? 2.4 : 1.6,
|
|
375
|
+
handles: selected && drawing.points.length === 2,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const drag = input.drag;
|
|
380
|
+
if (!drag) return shapes;
|
|
381
|
+
const start = { x: drag.startXRatio, y: drag.startYRatio };
|
|
382
|
+
const end = { x: drag.endXRatio, y: drag.endYRatio };
|
|
383
|
+
if (drag.kind === "zoom") {
|
|
384
|
+
shapes.push({
|
|
385
|
+
id: "tool:zoom",
|
|
386
|
+
points: [{ x: drag.startXRatio, y: 0 }, { x: drag.endXRatio, y: 1 }],
|
|
387
|
+
color: input.colors.zoom,
|
|
388
|
+
box: true,
|
|
389
|
+
fillOpacity: 0.16,
|
|
390
|
+
strokeWidth: 1.2,
|
|
391
|
+
});
|
|
392
|
+
return shapes;
|
|
393
|
+
}
|
|
394
|
+
if (drag.kind === "measure") {
|
|
395
|
+
const color = input.direction === "down" ? input.colors.negative : input.colors.positive;
|
|
396
|
+
shapes.push({
|
|
397
|
+
id: "tool:measure",
|
|
398
|
+
points: [start, end],
|
|
399
|
+
color,
|
|
400
|
+
box: true,
|
|
401
|
+
fillOpacity: 0.18,
|
|
402
|
+
strokeWidth: 1.2,
|
|
403
|
+
});
|
|
404
|
+
shapes.push({ id: "tool:measure-line", points: [start, end], color, strokeWidth: 1.4 });
|
|
405
|
+
return shapes;
|
|
406
|
+
}
|
|
407
|
+
const trail = drag.kind === "pencil" && drag.path.length > 1
|
|
408
|
+
? drag.path.map((point) => ({ x: point.xRatio, y: point.yRatio }))
|
|
409
|
+
: [start, end];
|
|
410
|
+
shapes.push({ id: "tool:draw", points: trail, color: input.colors.draw, strokeWidth: 1.6 });
|
|
411
|
+
return shapes;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Paints drawings and the active tool on a copy of the panel raster. Copying
|
|
416
|
+
* keeps the cached plot bitmap intact and keeps a single bitmap layer, which is
|
|
417
|
+
* all the terminal surface uploads.
|
|
418
|
+
*/
|
|
419
|
+
export function drawChartToolOverlay(
|
|
420
|
+
base: NativeChartBitmap,
|
|
421
|
+
drag: ChartToolDrag | null,
|
|
422
|
+
colors: ChartToolColors,
|
|
423
|
+
direction: "up" | "down" = "up",
|
|
424
|
+
drawings?: {
|
|
425
|
+
scene: CompositeChartScene;
|
|
426
|
+
panel: CompositePanelScene;
|
|
427
|
+
items: readonly ChartDrawing[];
|
|
428
|
+
selectedId?: string | null;
|
|
429
|
+
},
|
|
430
|
+
): NativeChartBitmap {
|
|
431
|
+
const width = base.width;
|
|
432
|
+
const height = base.height;
|
|
433
|
+
const data = new Uint8Array(base.pixels);
|
|
434
|
+
const maxX = Math.max(width - 1, 0);
|
|
435
|
+
const maxY = Math.max(height - 1, 0);
|
|
436
|
+
|
|
437
|
+
for (const drawing of drawings?.items ?? []) {
|
|
438
|
+
const shape = projectDrawing(drawings!.scene, drawings!.panel, drawing);
|
|
439
|
+
if (!shape) continue;
|
|
440
|
+
const selected = drawings!.selectedId === drawing.id;
|
|
441
|
+
const color = parseHex(drawing.color);
|
|
442
|
+
for (let index = 1; index < shape.length; index += 1) {
|
|
443
|
+
drawLine(
|
|
444
|
+
data,
|
|
445
|
+
width,
|
|
446
|
+
height,
|
|
447
|
+
shape[index - 1]!.xRatio * maxX,
|
|
448
|
+
shape[index - 1]!.yRatio * maxY,
|
|
449
|
+
shape[index]!.xRatio * maxX,
|
|
450
|
+
shape[index]!.yRatio * maxY,
|
|
451
|
+
color,
|
|
452
|
+
selected ? 2.2 : 1.4,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (!selected) continue;
|
|
456
|
+
for (const handle of shape.length === 2 ? shape : [shape[0]!, shape.at(-1)!]) {
|
|
457
|
+
drawCircle(data, width, height, handle.xRatio * maxX, handle.yRatio * maxY, 3.2, color);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!drag) return { width, height, pixels: data };
|
|
461
|
+
|
|
462
|
+
const x0 = drag.startXRatio * maxX;
|
|
463
|
+
const x1 = drag.endXRatio * maxX;
|
|
464
|
+
const y0 = drag.startYRatio * maxY;
|
|
465
|
+
const y1 = drag.endYRatio * maxY;
|
|
466
|
+
const left = Math.min(x0, x1);
|
|
467
|
+
const right = Math.max(x0, x1);
|
|
468
|
+
|
|
469
|
+
if (drag.kind === "line" || drag.kind === "pencil") {
|
|
470
|
+
const color = parseHex(colors.draw);
|
|
471
|
+
const trail = drag.kind === "pencil" && drag.path.length > 1
|
|
472
|
+
? drag.path
|
|
473
|
+
: [{ xRatio: drag.startXRatio, yRatio: drag.startYRatio }, { xRatio: drag.endXRatio, yRatio: drag.endYRatio }];
|
|
474
|
+
for (let index = 1; index < trail.length; index += 1) {
|
|
475
|
+
drawLine(
|
|
476
|
+
data,
|
|
477
|
+
width,
|
|
478
|
+
height,
|
|
479
|
+
trail[index - 1]!.xRatio * maxX,
|
|
480
|
+
trail[index - 1]!.yRatio * maxY,
|
|
481
|
+
trail[index]!.xRatio * maxX,
|
|
482
|
+
trail[index]!.yRatio * maxY,
|
|
483
|
+
color,
|
|
484
|
+
1.4,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
return { width, height, pixels: data };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (drag.kind === "zoom") {
|
|
491
|
+
const tint = parseHex(colors.zoom);
|
|
492
|
+
fillRect(data, width, height, left, 0, right, maxY, tint, 0.16);
|
|
493
|
+
drawLine(data, width, height, x0, 0, x0, maxY, tint, 1.2);
|
|
494
|
+
drawLine(data, width, height, x1, 0, x1, maxY, tint, 1.2);
|
|
495
|
+
return { width, height, pixels: data };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const top = Math.min(y0, y1);
|
|
499
|
+
const bottom = Math.max(y0, y1);
|
|
500
|
+
const tint = parseHex(direction === "down" ? colors.negative : colors.positive);
|
|
501
|
+
fillRect(data, width, height, left, top, right, bottom, tint, 0.18);
|
|
502
|
+
drawLine(data, width, height, left, top, right, top, tint, 1.2);
|
|
503
|
+
drawLine(data, width, height, left, bottom, right, bottom, tint, 1.2);
|
|
504
|
+
drawLine(data, width, height, left, top, left, bottom, tint, 1.2);
|
|
505
|
+
drawLine(data, width, height, right, top, right, bottom, tint, 1.2);
|
|
506
|
+
drawLine(data, width, height, x0, y0, x1, y1, tint, 1.4);
|
|
507
|
+
return { width, height, pixels: data };
|
|
508
|
+
}
|
|
@@ -122,6 +122,8 @@ export interface CompositeChartProps {
|
|
|
122
122
|
height: number;
|
|
123
123
|
focused?: boolean;
|
|
124
124
|
interactive?: boolean;
|
|
125
|
+
/** Permit panning into an older unloaded window so the owner can backfill it. */
|
|
126
|
+
allowHistoricalBackfill?: boolean;
|
|
125
127
|
cursorDate?: Date | null;
|
|
126
128
|
viewport?: {
|
|
127
129
|
start: Date;
|
|
@@ -135,6 +137,8 @@ export interface CompositeChartProps {
|
|
|
135
137
|
colors?: Partial<CompositeChartColors>;
|
|
136
138
|
axisWidth?: number;
|
|
137
139
|
showLegend?: boolean;
|
|
140
|
+
/** Show the regular-session percentage beside latest eligible legend values. */
|
|
141
|
+
showLatestChangePercent?: boolean;
|
|
138
142
|
legendAccessory?: ReactNode;
|
|
139
143
|
legendAccessoryWidth?: number;
|
|
140
144
|
showTimeAxis?: boolean;
|
|
@@ -18,6 +18,8 @@ interface PlotRenderableLike extends RenderableSizeLike {
|
|
|
18
18
|
export interface ChartMouseEvent {
|
|
19
19
|
x: number;
|
|
20
20
|
y: number;
|
|
21
|
+
/** DOM target on the desktop host; absent in the terminal. */
|
|
22
|
+
target?: { closest?: (selector: string) => unknown } | null;
|
|
21
23
|
preciseX?: number;
|
|
22
24
|
preciseY?: number;
|
|
23
25
|
pixelX?: number;
|
|
@@ -87,6 +87,24 @@ export function drawLine(
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Opaque whole-buffer fill. Doubling copyWithin runs at memcpy speed, where the
|
|
92
|
+
* per-pixel blend path costs most of a chart raster on its own.
|
|
93
|
+
*/
|
|
94
|
+
export function fillOpaque(data: Uint8Array, color: RgbaColor) {
|
|
95
|
+
if (data.length < 4) return;
|
|
96
|
+
data[0] = color.r;
|
|
97
|
+
data[1] = color.g;
|
|
98
|
+
data[2] = color.b;
|
|
99
|
+
data[3] = 255;
|
|
100
|
+
let filled = 4;
|
|
101
|
+
while (filled < data.length) {
|
|
102
|
+
const chunk = Math.min(filled, data.length - filled);
|
|
103
|
+
data.copyWithin(filled, 0, chunk);
|
|
104
|
+
filled += chunk;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
export function fillRect(
|
|
91
109
|
data: Uint8Array,
|
|
92
110
|
width: number,
|
|
@@ -3,6 +3,13 @@ import { Box, Text, useUiCapabilities } from "../../ui";
|
|
|
3
3
|
import { colors } from "../../theme/colors";
|
|
4
4
|
import { formatAxisCell } from "./core/renderer";
|
|
5
5
|
|
|
6
|
+
export interface PriceAxisMarker {
|
|
7
|
+
row: number;
|
|
8
|
+
pixelY: number;
|
|
9
|
+
label: string;
|
|
10
|
+
color: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
interface PriceAxisLabelsProps {
|
|
7
14
|
axisLabels: ReadonlyMap<number, string>;
|
|
8
15
|
axisWidth: number;
|
|
@@ -15,6 +22,8 @@ interface PriceAxisLabelsProps {
|
|
|
15
22
|
cursorColor: string;
|
|
16
23
|
cursorBackgroundColor?: string;
|
|
17
24
|
axisColor?: string;
|
|
25
|
+
/** Anchors that stay put while the cursor moves, such as a measure start. */
|
|
26
|
+
extraMarkers?: readonly PriceAxisMarker[];
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
interface CursorPriceAxisOverlay {
|
|
@@ -67,6 +76,7 @@ export function PriceAxisLabels({
|
|
|
67
76
|
cursorColor,
|
|
68
77
|
cursorBackgroundColor = colors.bg,
|
|
69
78
|
axisColor = colors.textDim,
|
|
79
|
+
extraMarkers,
|
|
70
80
|
}: PriceAxisLabelsProps) {
|
|
71
81
|
const { cellHeightPx = 18, fractionalViewport = false } = useUiCapabilities();
|
|
72
82
|
const overlay = useMemo(() => buildCursorPriceAxisOverlay({
|
|
@@ -113,13 +123,43 @@ export function PriceAxisLabels({
|
|
|
113
123
|
>
|
|
114
124
|
{Array.from({ length: height }, (_, row) => {
|
|
115
125
|
const isCursorRow = !usePixelOverlay && cursorLabel !== null && cursorRow === row;
|
|
116
|
-
const
|
|
126
|
+
const marker = usePixelOverlay
|
|
127
|
+
? undefined
|
|
128
|
+
: extraMarkers?.find((entry) => entry.row === row);
|
|
129
|
+
const label = isCursorRow
|
|
130
|
+
? cursorLabel
|
|
131
|
+
: marker?.label ?? (axisLabels.get(row) ?? null);
|
|
117
132
|
return (
|
|
118
133
|
<Box key={row} height={1}>
|
|
119
|
-
{renderAxisLabel(
|
|
134
|
+
{renderAxisLabel(
|
|
135
|
+
label,
|
|
136
|
+
isCursorRow ? cursorColor : marker ? marker.color : axisColor,
|
|
137
|
+
)}
|
|
120
138
|
</Box>
|
|
121
139
|
);
|
|
122
140
|
})}
|
|
141
|
+
{usePixelOverlay ? extraMarkers?.map((marker) => (
|
|
142
|
+
<Box
|
|
143
|
+
key={`${marker.label}:${marker.pixelY}`}
|
|
144
|
+
width={axisSectionWidth}
|
|
145
|
+
bg={cursorBackgroundColor}
|
|
146
|
+
flexDirection="row"
|
|
147
|
+
style={{
|
|
148
|
+
position: "absolute",
|
|
149
|
+
left: 0,
|
|
150
|
+
top: `${(marker.pixelY / Math.max(height * cellHeightPx - 1, 1)) * 100}%`,
|
|
151
|
+
transform: "translateY(-50%)",
|
|
152
|
+
whiteSpace: "pre",
|
|
153
|
+
pointerEvents: "none",
|
|
154
|
+
zIndex: 1,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
|
|
158
|
+
<Text fg={marker.color} selectable={false}>{formatAxisCell(marker.label, axisWidth).trimStart()}</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
{axisPaddingWidth > 0 ? <Box width={axisPaddingWidth} /> : null}
|
|
161
|
+
</Box>
|
|
162
|
+
)) : null}
|
|
123
163
|
{usePixelOverlay ? (
|
|
124
164
|
<Box
|
|
125
165
|
width={axisSectionWidth}
|
|
@@ -49,6 +49,8 @@ describe("StaticXAxisLabels", () => {
|
|
|
49
49
|
test("positions desktop labels by chart ratio instead of font-space advances", () => {
|
|
50
50
|
const html = renderAxis("desktop-web");
|
|
51
51
|
|
|
52
|
+
expect(html).toContain('data-gloom-role="chart-time-axis"');
|
|
53
|
+
expect(html).toContain('data-gloom-label="Nov 3 2025 Mar Jul 29 2026"');
|
|
52
54
|
expect(html).toContain("left:50%");
|
|
53
55
|
expect(html).toContain("transform:translateX(-50%)");
|
|
54
56
|
expect(html).toContain("right:0");
|