gloomberb 0.10.3 → 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.
Files changed (152) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +2 -2
  3. package/package.json +2 -1
  4. package/src/api-client/auth.ts +8 -0
  5. package/src/api-client/index.ts +70 -0
  6. package/src/api-client/types.ts +49 -0
  7. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  8. package/src/app/runtime/desktop-deeplink.ts +14 -1
  9. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  10. package/src/components/chart/composite/composite-chart.tsx +859 -68
  11. package/src/components/chart/composite/interactions.test.ts +78 -22
  12. package/src/components/chart/composite/interactions.ts +45 -17
  13. package/src/components/chart/composite/rasterizer.ts +8 -2
  14. package/src/components/chart/composite/renderers.test.ts +61 -1
  15. package/src/components/chart/composite/scene.ts +14 -4
  16. package/src/components/chart/composite/tools.test.ts +371 -0
  17. package/src/components/chart/composite/tools.ts +508 -0
  18. package/src/components/chart/composite/types.ts +4 -0
  19. package/src/components/chart/core/pointer.ts +2 -0
  20. package/src/components/chart/native/raster/primitives.ts +18 -0
  21. package/src/components/chart/price-axis-labels.tsx +42 -2
  22. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  23. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  24. package/src/components/command-bar/action-runtime.ts +3 -0
  25. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  26. package/src/components/command-bar/assist/inventory.ts +92 -0
  27. package/src/components/command-bar/assist/model.test.ts +104 -0
  28. package/src/components/command-bar/assist/model.ts +169 -0
  29. package/src/components/command-bar/assist/runtime.ts +185 -0
  30. package/src/components/command-bar/commands/direct/index.ts +1 -1
  31. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  32. package/src/components/command-bar/list/model.ts +15 -2
  33. package/src/components/command-bar/list/navigation.ts +6 -0
  34. package/src/components/command-bar/list/view.tsx +16 -3
  35. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  36. package/src/components/command-bar/panel/body.tsx +2 -0
  37. package/src/components/command-bar/panel/index.tsx +3 -0
  38. package/src/components/command-bar/panel/runtime.ts +6 -0
  39. package/src/components/command-bar/panel/types.ts +1 -0
  40. package/src/components/command-bar/routes/root/results.ts +28 -2
  41. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  42. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  43. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  44. package/src/components/command-bar/selection-runtime.ts +26 -0
  45. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  46. package/src/components/command-bar/surface/index.tsx +93 -2
  47. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  48. package/src/components/command-bar/view-model.ts +7 -1
  49. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  50. package/src/components/command-bar/workflow/broker.ts +1 -0
  51. package/src/components/layout/detached-pane-shell.tsx +40 -2
  52. package/src/components/layout/floating-pane.tsx +4 -1
  53. package/src/components/layout/pane/header.tsx +64 -6
  54. package/src/components/layout/pane/index.tsx +4 -1
  55. package/src/components/layout/shell/index.tsx +23 -0
  56. package/src/components/layout/shell/pane/layers.tsx +5 -0
  57. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  58. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  59. package/src/components/onboarding/account-step/index.tsx +58 -0
  60. package/src/components/onboarding/account-step/model.test.ts +67 -0
  61. package/src/components/onboarding/account-step/model.ts +126 -0
  62. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  63. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  64. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  65. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  66. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  67. package/src/components/onboarding/wizard-account.ts +235 -0
  68. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  69. package/src/components/onboarding/wizard-model.ts +2 -2
  70. package/src/i18n/es.ts +1093 -0
  71. package/src/i18n/index.test.ts +5 -2
  72. package/src/i18n/index.ts +3 -0
  73. package/src/i18n/ja.ts +64 -3
  74. package/src/i18n/ko.ts +64 -3
  75. package/src/i18n/languages.test.ts +4 -1
  76. package/src/i18n/languages.ts +12 -1
  77. package/src/i18n/zh-cn.ts +64 -3
  78. package/src/i18n/zh-tw.ts +64 -3
  79. package/src/market-data/coordinator/quotes.ts +63 -1
  80. package/src/market-data/coordinator/subscriptions.test.ts +33 -0
  81. package/src/market-data/quotes/cadence.ts +2 -0
  82. package/src/plugins/builtin/account-management/footer.ts +9 -3
  83. package/src/plugins/builtin/account-management/model.ts +53 -1
  84. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  85. package/src/plugins/builtin/ai/index.tsx +7 -2
  86. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  87. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  88. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  89. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  90. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  91. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  92. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  93. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  94. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  95. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  96. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  97. package/src/plugins/builtin/help/index.tsx +38 -0
  98. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  99. package/src/plugins/builtin/options/footer.test.ts +20 -124
  100. package/src/plugins/builtin/options/footer.ts +32 -90
  101. package/src/plugins/builtin/options/index.tsx +6 -0
  102. package/src/plugins/builtin/options/view.tsx +3 -0
  103. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  104. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  105. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  106. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  107. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  108. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  109. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  110. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  111. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  112. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  113. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  114. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  115. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  116. package/src/plugins/registry/index.test.ts +51 -0
  117. package/src/plugins/registry/index.ts +12 -0
  118. package/src/plugins/registry/pane-settings.ts +29 -0
  119. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  120. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  121. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  122. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  123. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  124. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  125. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  126. package/src/renderers/electrobun/view/host/style.ts +1 -1
  127. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  128. package/src/renderers/electrobun/view/main.tsx +2 -0
  129. package/src/renderers/opentui/chart-surface.tsx +2 -0
  130. package/src/renderers/opentui/host.tsx +3 -0
  131. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  132. package/src/sources/gloomberb-cloud/index.ts +4 -4
  133. package/src/sources/provider-router/history.test.ts +3 -3
  134. package/src/sources/provider-router/history.ts +15 -4
  135. package/src/sources/yahoo-finance/history.test.ts +47 -0
  136. package/src/sources/yahoo-finance/history.ts +5 -1
  137. package/src/state/hooks/quote-streaming.test.tsx +124 -1
  138. package/src/state/hooks/quote-streaming.ts +90 -7
  139. package/src/theme/colors.ts +20 -1
  140. package/src/theme/themes.test.ts +3 -0
  141. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  142. package/src/time-series/resolution.test.ts +51 -0
  143. package/src/time-series/resolution.ts +41 -0
  144. package/src/time-series/resolve.test.ts +207 -1
  145. package/src/time-series/resolve.ts +196 -29
  146. package/src/time-series/spec-studies.test.ts +4 -0
  147. package/src/time-series/spec.ts +15 -1
  148. package/src/time-series/types.ts +7 -0
  149. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  150. package/src/time-series/use-chart-resolution.ts +17 -1
  151. package/src/types/plugin.ts +9 -0
  152. 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 label = isCursorRow ? cursorLabel : (axisLabels.get(row) ?? null);
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(label, isCursorRow ? cursorColor : axisColor)}
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");