gloomberb 0.10.3 → 0.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +4 -4
  3. package/README.zh-CN.md +3 -3
  4. package/package.json +4 -5
  5. package/src/api-client/auth.ts +8 -0
  6. package/src/api-client/index.ts +70 -0
  7. package/src/api-client/types.ts +49 -0
  8. package/src/app/runtime/desktop-deeplink.test.ts +8 -0
  9. package/src/app/runtime/desktop-deeplink.ts +14 -1
  10. package/src/cli/entry.ts +11 -1
  11. package/src/cli/native-smoke.ts +5 -0
  12. package/src/components/chart/composite/composite-chart.test.tsx +340 -17
  13. package/src/components/chart/composite/composite-chart.tsx +859 -68
  14. package/src/components/chart/composite/interactions.test.ts +78 -22
  15. package/src/components/chart/composite/interactions.ts +45 -17
  16. package/src/components/chart/composite/rasterizer.ts +8 -2
  17. package/src/components/chart/composite/renderers.test.ts +61 -1
  18. package/src/components/chart/composite/scene.ts +14 -4
  19. package/src/components/chart/composite/tools.test.ts +371 -0
  20. package/src/components/chart/composite/tools.ts +508 -0
  21. package/src/components/chart/composite/types.ts +4 -0
  22. package/src/components/chart/core/pointer.ts +2 -0
  23. package/src/components/chart/native/raster/primitives.ts +18 -0
  24. package/src/components/chart/price-axis-labels.tsx +42 -2
  25. package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
  26. package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
  27. package/src/components/command-bar/action-runtime.ts +3 -0
  28. package/src/components/command-bar/assist/inventory.test.ts +93 -0
  29. package/src/components/command-bar/assist/inventory.ts +92 -0
  30. package/src/components/command-bar/assist/model.test.ts +104 -0
  31. package/src/components/command-bar/assist/model.ts +169 -0
  32. package/src/components/command-bar/assist/runtime.ts +185 -0
  33. package/src/components/command-bar/commands/direct/index.ts +1 -1
  34. package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
  35. package/src/components/command-bar/list/model.ts +15 -2
  36. package/src/components/command-bar/list/navigation.ts +6 -0
  37. package/src/components/command-bar/list/view.tsx +16 -3
  38. package/src/components/command-bar/pane-templates/workflow.ts +1 -0
  39. package/src/components/command-bar/panel/body.tsx +2 -0
  40. package/src/components/command-bar/panel/index.tsx +3 -0
  41. package/src/components/command-bar/panel/runtime.ts +6 -0
  42. package/src/components/command-bar/panel/types.ts +1 -0
  43. package/src/components/command-bar/routes/root/provider-search.ts +7 -3
  44. package/src/components/command-bar/routes/root/results.ts +28 -2
  45. package/src/components/command-bar/routes/root/runtime.ts +66 -36
  46. package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
  47. package/src/components/command-bar/routes/ticker-search/results.test.ts +34 -0
  48. package/src/components/command-bar/routes/ticker-search/results.ts +4 -6
  49. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  50. package/src/components/command-bar/routing/list-state.ts +2 -1
  51. package/src/components/command-bar/routing/navigation-state.ts +17 -0
  52. package/src/components/command-bar/selection-runtime.ts +26 -0
  53. package/src/components/command-bar/surface/assist.test.tsx +276 -0
  54. package/src/components/command-bar/surface/index.test.tsx +65 -0
  55. package/src/components/command-bar/surface/index.tsx +93 -2
  56. package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
  57. package/src/components/command-bar/view-model.test.ts +24 -0
  58. package/src/components/command-bar/view-model.ts +9 -2
  59. package/src/components/command-bar/workflow/broker.test.ts +22 -0
  60. package/src/components/command-bar/workflow/broker.ts +1 -0
  61. package/src/components/layout/detached-pane-shell.tsx +40 -2
  62. package/src/components/layout/floating-pane.tsx +4 -1
  63. package/src/components/layout/pane/header.tsx +64 -6
  64. package/src/components/layout/pane/index.tsx +4 -1
  65. package/src/components/layout/shell/index.tsx +23 -0
  66. package/src/components/layout/shell/pane/layers.tsx +5 -0
  67. package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
  68. package/src/components/onboarding/account-step/form-panel.tsx +157 -0
  69. package/src/components/onboarding/account-step/index.tsx +58 -0
  70. package/src/components/onboarding/account-step/model.test.ts +67 -0
  71. package/src/components/onboarding/account-step/model.ts +126 -0
  72. package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
  73. package/src/components/onboarding/onboarding-steps.tsx +91 -55
  74. package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
  75. package/src/components/onboarding/onboarding-wizard.tsx +93 -9
  76. package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
  77. package/src/components/onboarding/wizard-account.ts +235 -0
  78. package/src/components/onboarding/wizard-keyboard.ts +67 -1
  79. package/src/components/onboarding/wizard-model.ts +2 -2
  80. package/src/i18n/es.ts +1093 -0
  81. package/src/i18n/index.test.ts +5 -2
  82. package/src/i18n/index.ts +3 -0
  83. package/src/i18n/ja.ts +64 -3
  84. package/src/i18n/ko.ts +64 -3
  85. package/src/i18n/languages.test.ts +4 -1
  86. package/src/i18n/languages.ts +12 -1
  87. package/src/i18n/zh-cn.ts +64 -3
  88. package/src/i18n/zh-tw.ts +64 -3
  89. package/src/market-data/coordinator/quotes.ts +63 -1
  90. package/src/market-data/coordinator/subscriptions.test.ts +33 -0
  91. package/src/market-data/quotes/cadence.ts +2 -0
  92. package/src/plugins/builtin/account-management/footer.ts +9 -3
  93. package/src/plugins/builtin/account-management/model.ts +53 -1
  94. package/src/plugins/builtin/account-management/pane.tsx +107 -24
  95. package/src/plugins/builtin/ai/index.tsx +7 -2
  96. package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
  97. package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
  98. package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
  99. package/src/plugins/builtin/chart-composer/index.tsx +10 -2
  100. package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
  101. package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
  102. package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
  103. package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
  104. package/src/plugins/builtin/cloud/plugin.tsx +9 -1
  105. package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
  106. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
  107. package/src/plugins/builtin/econ/fred-cache.test.ts +8 -1
  108. package/src/plugins/builtin/help/index.tsx +39 -1
  109. package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
  110. package/src/plugins/builtin/options/footer.test.ts +20 -124
  111. package/src/plugins/builtin/options/footer.ts +32 -90
  112. package/src/plugins/builtin/options/index.tsx +6 -0
  113. package/src/plugins/builtin/options/view.tsx +3 -0
  114. package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
  115. package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
  116. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
  117. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
  118. package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
  119. package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
  120. package/src/plugins/builtin/shared/live-streaming.ts +60 -0
  121. package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
  122. package/src/plugins/builtin/shared/plan-access.ts +109 -0
  123. package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
  124. package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
  125. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
  126. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
  127. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  128. package/src/plugins/registry/index.test.ts +51 -0
  129. package/src/plugins/registry/index.ts +12 -0
  130. package/src/plugins/registry/pane-settings.ts +29 -0
  131. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  132. package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
  133. package/src/renderers/electrobun/view/host/box.tsx +3 -0
  134. package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
  135. package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
  136. package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
  137. package/src/renderers/electrobun/view/host/input.tsx +16 -1
  138. package/src/renderers/electrobun/view/host/mouse.ts +1 -0
  139. package/src/renderers/electrobun/view/host/style.ts +1 -1
  140. package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
  141. package/src/renderers/electrobun/view/main.tsx +2 -0
  142. package/src/renderers/opentui/chart-surface.tsx +2 -0
  143. package/src/renderers/opentui/dialog-host.tsx +279 -80
  144. package/src/renderers/opentui/host.tsx +3 -0
  145. package/src/renderers/opentui/test-utils.tsx +9 -6
  146. package/src/renderers/opentui/toast-host.tsx +136 -7
  147. package/src/renderers/opentui/ui-host.tsx +19 -3
  148. package/src/sources/gloomberb-cloud/index.test.ts +8 -0
  149. package/src/sources/gloomberb-cloud/index.ts +4 -4
  150. package/src/sources/provider-router/history.test.ts +3 -3
  151. package/src/sources/provider-router/history.ts +15 -4
  152. package/src/sources/yahoo-finance/history.test.ts +47 -0
  153. package/src/sources/yahoo-finance/history.ts +5 -1
  154. package/src/state/hooks/quote-streaming.test.tsx +124 -1
  155. package/src/state/hooks/quote-streaming.ts +90 -7
  156. package/src/theme/colors.ts +20 -1
  157. package/src/theme/themes.test.ts +3 -0
  158. package/src/tickers/search/index.test.ts +284 -2
  159. package/src/tickers/search/index.ts +55 -18
  160. package/src/tickers/search/ranking.ts +143 -18
  161. package/src/tickers/search/types.ts +1 -0
  162. package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
  163. package/src/time-series/resolution.test.ts +51 -0
  164. package/src/time-series/resolution.ts +41 -0
  165. package/src/time-series/resolve.test.ts +207 -1
  166. package/src/time-series/resolve.ts +196 -29
  167. package/src/time-series/spec-studies.test.ts +4 -0
  168. package/src/time-series/spec.ts +15 -1
  169. package/src/time-series/types.ts +7 -0
  170. package/src/time-series/use-chart-resolution.test.tsx +37 -0
  171. package/src/time-series/use-chart-resolution.ts +17 -1
  172. package/src/types/plugin.ts +9 -0
  173. package/src/ui/host.tsx +17 -0
  174. package/src/updater/github-releases.test.ts +3 -3
  175. package/src/updater/github-releases.ts +1 -1
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../../ui";
14
14
  import { useShortcut } from "../../../react/input";
15
15
  import { colors as themeColors, hoverBg } from "../../../theme/colors";
16
+ import { formatPercentRaw } from "../../../utils/format";
16
17
  import { isPlainKey } from "../../../utils/keyboard";
17
18
  import { truncateWithEllipsis } from "../../../utils/text-wrap";
18
19
  import type { ResolvedSeries } from "../../../time-series/types";
@@ -30,6 +31,7 @@ import {
30
31
  import { StaticXAxisLabels } from "../static/chart/axis-overlays";
31
32
  import { PriceAxisLabels } from "../price-axis-labels";
32
33
  import {
34
+ compositeAxisTicks,
33
35
  formatCompositeAxisValue,
34
36
  formatCompositeCursorDate,
35
37
  formatCompositePointDetails,
@@ -52,6 +54,27 @@ import {
52
54
  } from "./interactions";
53
55
  import { buildCompositeColumnLayout, type CompositeColumnLayout } from "./column-layout";
54
56
  import { renderCompositePanelBitmap } from "./rasterizer";
57
+ import {
58
+ buildChartToolVectors,
59
+ CHART_DRAWING_COLORS,
60
+ countMeasureBars,
61
+ drawChartToolOverlay,
62
+ resolveChartToolKind,
63
+ resolveMeasureAxisDomain,
64
+ resolveMeasureDirection,
65
+ hitTestDrawings,
66
+ isDrawingTool,
67
+ nextDrawingColor,
68
+ resolveDrawingFromDrag,
69
+ resolveZoomBoxRange,
70
+ shiftDrawing,
71
+ summarizeMeasure,
72
+ summarizeZoomSelection,
73
+ type ChartDrawing,
74
+ type ChartToolDrag,
75
+ type ChartToolKind,
76
+ } from "./tools";
77
+ import { unprojectCompositeTimestamp } from "./time-scale";
55
78
  import {
56
79
  allocateCompositePanelHeights,
57
80
  applyCompositeChartCursor,
@@ -70,6 +93,7 @@ import {
70
93
  buildCompositeViewportTimeAxisLayout,
71
94
  } from "./time-axis";
72
95
  import type {
96
+ CompositeAxisDomain,
73
97
  CompositeChartColors,
74
98
  CompositeChartProps,
75
99
  CompositeChartScene,
@@ -266,6 +290,287 @@ function cursorAxisLabel(
266
290
  return value === null ? null : formatCompositeAxisValue(value, domain);
267
291
  }
268
292
 
293
+ const MINIMUM_AXIS_LABEL_WIDTH = 3;
294
+
295
+ /**
296
+ * Cells the gutter needs for its own labels. Ticks cover the axis itself, and a
297
+ * mid-domain sample covers the wider cursor readout that lands between them.
298
+ */
299
+ function compositeAxisLabelWidth(domain: CompositeAxisDomain | undefined): number {
300
+ if (!domain) return 0;
301
+ const labels = compositeAxisTicks(domain).map((tick) => tick.label);
302
+ labels.push(formatCompositeAxisValue((domain.min + domain.max) / 2, domain));
303
+ return labels.reduce((widest, label) => Math.max(widest, [...label].length), 0);
304
+ }
305
+
306
+
307
+ const HAND_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M6.4 8V3.5a1.15 1.15 0 0 1 2.3 0V7m0-.6a1.15 1.15 0 0 1 2.3 0V8m0-.5a1.15 1.15 0 0 1 2.3 0v3.1c0 2.1-1.7 3.8-3.8 3.8H8.9c-1.2 0-2.3-.6-3-1.6L3.6 9.4a1.15 1.15 0 0 1 1.8-1.4L6.4 9.2" fill="none" stroke="#000" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
308
+ const RULER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1.4" y="4.6" width="13.2" height="6.8" rx="1.4" fill="none" stroke="#000" stroke-width="1.4"/><path d="M5 4.6v2.6M8 4.6v3.6M11 4.6v2.6" stroke="#000" stroke-width="1.3" stroke-linecap="round"/></svg>`;
309
+ const PEN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2.4 13.6 4 9.9 10.6 3.3a1.6 1.6 0 0 1 2.3 0l0 0a1.6 1.6 0 0 1 0 2.3L6.2 12 2.4 13.6Z" fill="none" stroke="#000" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
310
+ const LINE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3.2 12.8 12.8 3.2" stroke="#000" stroke-width="1.6" stroke-linecap="round"/><circle cx="3.2" cy="12.8" r="2" fill="none" stroke="#000" stroke-width="1.4"/><circle cx="12.8" cy="3.2" r="2" fill="none" stroke="#000" stroke-width="1.4"/></svg>`;
311
+ const MARQUEE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2.2 6V3.4a1.2 1.2 0 0 1 1.2-1.2H6M10 2.2h2.6a1.2 1.2 0 0 1 1.2 1.2V6M13.8 10v2.6a1.2 1.2 0 0 1-1.2 1.2H10M6 13.8H3.4a1.2 1.2 0 0 1-1.2-1.2V10" fill="none" stroke="#000" stroke-width="1.7" stroke-linecap="round"/></svg>`;
312
+
313
+ const CHART_TOOLS: ReadonlyArray<{
314
+ /** null is the resting state: the pointer pans and nothing is armed. */
315
+ kind: ChartToolKind | null;
316
+ label: string;
317
+ shortcut: string;
318
+ hint: string;
319
+ glyph: string;
320
+ icon: string;
321
+ }> = [
322
+ {
323
+ kind: null,
324
+ label: "Pan",
325
+ shortcut: "Esc",
326
+ hint: "Drag to move through time, the resting state of the pointer",
327
+ glyph: "\u2725",
328
+ icon: HAND_ICON,
329
+ },
330
+ {
331
+ kind: "measure",
332
+ label: "Ruler",
333
+ shortcut: "Shift+M",
334
+ hint: "Drag to measure change, percent, bars, and elapsed time",
335
+ glyph: "\u2194",
336
+ icon: RULER_ICON,
337
+ },
338
+ {
339
+ kind: "zoom",
340
+ label: "Zoom to range",
341
+ shortcut: "Shift+Z",
342
+ hint: "Drag to select a time range to zoom into",
343
+ glyph: "\u229e",
344
+ icon: MARQUEE_ICON,
345
+ },
346
+ {
347
+ kind: "line",
348
+ label: "Trend line",
349
+ shortcut: "Shift+D",
350
+ hint: "Drag a straight line, grab an end to reshape it, Backspace deletes",
351
+ glyph: "\u2571",
352
+ icon: LINE_ICON,
353
+ },
354
+ {
355
+ kind: "pencil",
356
+ label: "Freehand",
357
+ shortcut: "Shift+P",
358
+ hint: "Draw freehand, drag a shape to move it, Backspace deletes",
359
+ glyph: "\u223f",
360
+ icon: PEN_ICON,
361
+ },
362
+ ];
363
+
364
+ interface ChartToolSpan {
365
+ startXRatio: number;
366
+ endXRatio: number;
367
+ color: string;
368
+ }
369
+
370
+ const ARMED_TOOL_BY_INTERACTION = {
371
+ "arm-measure": "measure",
372
+ "arm-zoom": "zoom",
373
+ "arm-line": "line",
374
+ "arm-pencil": "pencil",
375
+ } as const satisfies Record<string, ChartToolKind>;
376
+
377
+ const COMPOSITE_PANEL_ROLE = "composite-chart-panel";
378
+
379
+ let nextDrawingSequence = 1;
380
+
381
+ function nextDrawingId(): string {
382
+ return `drawing:${nextDrawingSequence++}`;
383
+ }
384
+
385
+ /** Icon cells plus the gap between chips. */
386
+ const CHART_TOOLBAR_WIDTH = CHART_TOOLS.length * 3 + (CHART_TOOLS.length - 1);
387
+
388
+ function ChartToolChip({
389
+ tool,
390
+ active,
391
+ isDesktopWeb,
392
+ onPress,
393
+ }: {
394
+ tool: (typeof CHART_TOOLS)[number];
395
+ active: boolean;
396
+ isDesktopWeb: boolean;
397
+ onPress: () => void;
398
+ }) {
399
+ const label = `${tool.label} (${tool.shortcut}). ${tool.hint}`;
400
+ const color = active ? themeColors.text : themeColors.textDim;
401
+ return (
402
+ <Box
403
+ flexDirection="row"
404
+ alignItems="center"
405
+ justifyContent="center"
406
+ width={3}
407
+ height={1}
408
+ flexShrink={0}
409
+ backgroundColor={active ? themeColors.selected : undefined}
410
+ hoverBackgroundColor={hoverBg()}
411
+ onMouseDown={(event: ChartMouseEvent) => {
412
+ consumeChartMouseEvent(event);
413
+ onPress();
414
+ }}
415
+ cursor="pointer"
416
+ data-gloom-interactive="true"
417
+ data-gloom-role="composite-chart-tool"
418
+ data-gloom-label={label}
419
+ data-active={active ? "true" : "false"}
420
+ title={isDesktopWeb ? label : undefined}
421
+ // Cells size the terminal strip; the desktop chip sizes to its icon.
422
+ style={isDesktopWeb ? { width: "auto", paddingInline: 4, borderRadius: 4 } : undefined}
423
+ >
424
+ {isDesktopWeb ? (
425
+ <Box
426
+ flexShrink={0}
427
+ style={{
428
+ width: 13,
429
+ height: 13,
430
+ backgroundColor: color,
431
+ maskImage: svgMaskUrl(tool.icon),
432
+ WebkitMaskImage: svgMaskUrl(tool.icon),
433
+ maskSize: "contain",
434
+ WebkitMaskSize: "contain",
435
+ maskRepeat: "no-repeat",
436
+ WebkitMaskRepeat: "no-repeat",
437
+ maskPosition: "center",
438
+ WebkitMaskPosition: "center",
439
+ }}
440
+ />
441
+ ) : (
442
+ <Text fg={color}>{tool.glyph}</Text>
443
+ )}
444
+ </Box>
445
+ );
446
+ }
447
+
448
+
449
+ /** Blurs a focused text field, the focus change a consumed mousedown prevents. */
450
+ function releaseEditableFocus(event: ChartMouseEvent): void {
451
+ if (!event.target?.closest?.(`[data-gloom-role="${COMPOSITE_PANEL_ROLE}"]`)) return;
452
+ const active = (globalThis as {
453
+ document?: { activeElement?: { tagName?: string; blur?: () => void } };
454
+ }).document?.activeElement;
455
+ const tag = active?.tagName?.toUpperCase();
456
+ if (tag !== "INPUT" && tag !== "TEXTAREA") return;
457
+ active?.blur?.();
458
+ }
459
+
460
+ function svgMaskUrl(svg: string): string {
461
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
462
+ }
463
+
464
+ function ChartColorSwatch({
465
+ color,
466
+ active,
467
+ isDesktopWeb,
468
+ onPress,
469
+ }: {
470
+ color: string;
471
+ active: boolean;
472
+ isDesktopWeb: boolean;
473
+ onPress: () => void;
474
+ }) {
475
+ return (
476
+ <Box
477
+ flexDirection="row"
478
+ alignItems="center"
479
+ justifyContent="center"
480
+ width={2}
481
+ height={1}
482
+ flexShrink={0}
483
+ backgroundColor={active ? themeColors.selected : undefined}
484
+ hoverBackgroundColor={hoverBg()}
485
+ onMouseDown={(event: ChartMouseEvent) => {
486
+ consumeChartMouseEvent(event);
487
+ onPress();
488
+ }}
489
+ cursor="pointer"
490
+ data-gloom-interactive="true"
491
+ data-gloom-role="composite-chart-color"
492
+ data-gloom-label={`Drawing colour ${color}`}
493
+ data-active={active ? "true" : "false"}
494
+ style={isDesktopWeb
495
+ ? {
496
+ width: 14,
497
+ height: 14,
498
+ borderRadius: 999,
499
+ backgroundColor: color,
500
+ border: `1.5px solid ${active ? themeColors.text : "transparent"}`,
501
+ }
502
+ : undefined}
503
+ >
504
+ {isDesktopWeb ? null : <Text fg={color}>{active ? "\u25c9" : "\u25cf"}</Text>}
505
+ </Box>
506
+ );
507
+ }
508
+
509
+ function ChartToolbar({
510
+ armedTool,
511
+ isDesktopWeb,
512
+ left,
513
+ top,
514
+ drawColor,
515
+ showColors,
516
+ onArmTool,
517
+ onPickColor,
518
+ }: {
519
+ armedTool: ChartToolKind | null;
520
+ isDesktopWeb: boolean;
521
+ left: number;
522
+ top: number;
523
+ drawColor: string;
524
+ showColors: boolean;
525
+ onArmTool: (tool: ChartToolKind | null) => void;
526
+ onPickColor: (color: string) => void;
527
+ }) {
528
+ return (
529
+ <Box
530
+ position="absolute"
531
+ left={left}
532
+ top={top}
533
+ height={1}
534
+ flexDirection="row"
535
+ gap={1}
536
+ zIndex={30}
537
+ backgroundColor={themeColors.bg}
538
+ style={isDesktopWeb
539
+ ? {
540
+ gap: 3,
541
+ padding: 3,
542
+ borderRadius: 7,
543
+ backgroundColor: `color-mix(in srgb, ${themeColors.bg} 78%, transparent)`,
544
+ backdropFilter: "blur(6px)",
545
+ width: "auto",
546
+ height: "auto",
547
+ }
548
+ : undefined}
549
+ data-gloom-role="composite-chart-toolbar"
550
+ >
551
+ {CHART_TOOLS.map((tool) => (
552
+ <ChartToolChip
553
+ key={tool.kind ?? "pan"}
554
+ tool={tool}
555
+ active={armedTool === tool.kind}
556
+ isDesktopWeb={isDesktopWeb}
557
+ onPress={() => onArmTool(tool.kind)}
558
+ />
559
+ ))}
560
+ {/* Colours only take space while something can use them. */}
561
+ {showColors ? CHART_DRAWING_COLORS.map((color) => (
562
+ <ChartColorSwatch
563
+ key={color}
564
+ color={color}
565
+ active={color === drawColor}
566
+ isDesktopWeb={isDesktopWeb}
567
+ onPress={() => onPickColor(color)}
568
+ />
569
+ )) : null}
570
+ </Box>
571
+ );
572
+ }
573
+
269
574
  function resolveSeriesCursorYRatio(
270
575
  panel: CompositePanelScene,
271
576
  scene: CompositeChartScene,
@@ -292,10 +597,20 @@ interface CompositePanelSurfaceProps {
292
597
  colors: CompositeChartColors;
293
598
  interactive: boolean;
294
599
  viewport: CompositeViewportRange;
600
+ minimumViewportSpanMs: number;
601
+ armedTool: ChartToolKind | null;
602
+ drawings: readonly ChartDrawing[];
603
+ selectedDrawingId: string | null;
604
+ drawColor: string;
605
+ onDraw: (drawing: ChartDrawing) => void;
606
+ onEditDrawing: (id: string, update: (drawing: ChartDrawing) => ChartDrawing) => void;
607
+ onSelectDrawing: (id: string | null) => void;
295
608
  onActivate?: () => void;
296
609
  onCursorDateChange: (date: Date | null) => void;
297
610
  onPanViewport: (shiftRatio: number, fromViewport?: CompositeViewportRange) => void;
298
611
  onZoomViewport: (zoomFactor: number, anchorRatio: number) => void;
612
+ onSetViewport: (range: CompositeViewportRange) => void;
613
+ onToolSpanChange: (span: ChartToolSpan | null) => void;
299
614
  }
300
615
 
301
616
  function CompositePanelSurface({
@@ -308,13 +623,23 @@ function CompositePanelSurface({
308
623
  colors,
309
624
  interactive,
310
625
  viewport,
626
+ minimumViewportSpanMs,
627
+ armedTool,
628
+ drawings,
629
+ selectedDrawingId,
630
+ drawColor,
631
+ onDraw,
632
+ onEditDrawing,
633
+ onSelectDrawing,
311
634
  onActivate,
312
635
  onCursorDateChange,
313
636
  onPanViewport,
314
637
  onZoomViewport,
638
+ onSetViewport,
639
+ onToolSpanChange,
315
640
  }: CompositePanelSurfaceProps) {
316
641
  const isDesktopWeb = useUiHost().kind === "desktop-web";
317
- const { cellHeightPx = 18 } = useUiCapabilities();
642
+ const { cellHeightPx = 18, cellWidthPx = 8 } = useUiCapabilities();
318
643
  const renderer = useNativeRenderer();
319
644
  const plotRef = useRef<BoxRenderable | null>(null);
320
645
  const [cursorYRatio, setCursorYRatio] = useState<number | null>(null);
@@ -325,10 +650,20 @@ function CompositePanelSurface({
325
650
  const activeCursorYRatio = scene.cursorXRatio === null
326
651
  ? null
327
652
  : cursorYRatio ?? seriesCursorYRatio;
328
- const dragRef = useRef<{
329
- startGlobalX: number;
330
- startViewport: CompositeViewportRange;
331
- } | null>(null);
653
+ const dragRef = useRef<
654
+ | { kind: "pan"; startGlobalX: number; startViewport: CompositeViewportRange }
655
+ | {
656
+ kind: "edit";
657
+ drawingId: string;
658
+ pointIndex: number | null;
659
+ lastXRatio: number;
660
+ lastYRatio: number;
661
+ }
662
+ | ChartToolDrag
663
+ | null
664
+ >(null);
665
+ const [toolDrag, setToolDrag] = useState<ChartToolDrag | null>(null);
666
+ const plotAspect = (plotWidth * cellWidthPx) / Math.max(panel.height * cellHeightPx, 1);
332
667
  const bitmapSize = useStaticChartBitmapSize(plotWidth, panel.height);
333
668
  const bitmap = useCompositePanelBitmap({ panel, bitmapSize, colors, isDesktopWeb });
334
669
  const columnLayout = useMemo(() => buildCompositeColumnLayout(panel), [panel]);
@@ -336,7 +671,121 @@ function CompositePanelSurface({
336
671
  () => resolvePanelCrosshair(panel, columnLayout, bitmap, scene.cursorXRatio, activeCursorYRatio, colors.crosshair),
337
672
  [activeCursorYRatio, bitmap, colors.crosshair, columnLayout, panel, scene.cursorXRatio],
338
673
  );
339
- const bitmapLayers = useMemo(() => bitmap ? [bitmap] : null, [bitmap]);
674
+ const measureDomain = useMemo(() => resolveMeasureAxisDomain(panel), [panel]);
675
+ const toolReadout = useMemo(() => {
676
+ if (!toolDrag) return null;
677
+ if (toolDrag.kind === "zoom") {
678
+ return {
679
+ direction: "up" as const,
680
+ summary: summarizeZoomSelection(scene, toolDrag),
681
+ startValueLabel: null,
682
+ endValueLabel: null,
683
+ };
684
+ }
685
+ const startValue = measureDomain
686
+ ? unprojectCompositeValue(toolDrag.startYRatio, measureDomain)
687
+ : null;
688
+ const endValue = measureDomain
689
+ ? unprojectCompositeValue(toolDrag.endYRatio, measureDomain)
690
+ : null;
691
+ const startTime = unprojectCompositeTimestamp(scene.timeScale, toolDrag.startXRatio);
692
+ const endTime = unprojectCompositeTimestamp(scene.timeScale, toolDrag.endXRatio);
693
+ return {
694
+ direction: resolveMeasureDirection(startValue, endValue),
695
+ summary: summarizeMeasure({
696
+ startValue,
697
+ endValue,
698
+ startTime,
699
+ endTime,
700
+ bars: countMeasureBars(scene.dates, startTime, endTime),
701
+ domain: measureDomain,
702
+ }),
703
+ startValueLabel: measureDomain && startValue !== null
704
+ ? formatCompositeAxisValue(startValue, measureDomain)
705
+ : null,
706
+ endValueLabel: measureDomain && endValue !== null
707
+ ? formatCompositeAxisValue(endValue, measureDomain)
708
+ : null,
709
+ };
710
+ }, [measureDomain, scene, toolDrag]);
711
+ const toolSummary = toolReadout?.summary ?? null;
712
+ const toolSpan = useMemo(() => {
713
+ if (!toolDrag || !toolSummary) return null;
714
+ return {
715
+ startXRatio: toolDrag.startXRatio,
716
+ endXRatio: toolDrag.endXRatio,
717
+ color: toolDrag.kind === "zoom"
718
+ ? colors.crosshair
719
+ : toolReadout?.direction === "down" ? colors.negative : themeColors.positive,
720
+ };
721
+ }, [colors.crosshair, colors.negative, toolDrag, toolReadout?.direction, toolSummary]);
722
+ useEffect(() => {
723
+ onToolSpanChange(toolSpan);
724
+ }, [onToolSpanChange, toolSpan]);
725
+ const panelDrawings = useMemo(
726
+ () => drawings.filter((drawing) => drawing.panelId === panel.id),
727
+ [drawings, panel.id],
728
+ );
729
+ const bitmapLayers = useMemo(() => {
730
+ if (!bitmap) return null;
731
+ // The desktop composites overlays as vectors, so the plot raster stays put
732
+ // while a tool drags. Copying and reblending it per frame is what made the
733
+ // ruler feel heavy.
734
+ if (isDesktopWeb || (!toolDrag && panelDrawings.length === 0)) return [bitmap];
735
+ return [drawChartToolOverlay(
736
+ bitmap,
737
+ toolDrag,
738
+ {
739
+ positive: themeColors.positive,
740
+ negative: colors.negative,
741
+ zoom: colors.crosshair,
742
+ draw: drawColor,
743
+ },
744
+ toolReadout?.direction ?? "up",
745
+ { scene, panel, items: panelDrawings, selectedId: selectedDrawingId },
746
+ )];
747
+ }, [
748
+ bitmap,
749
+ colors.crosshair,
750
+ colors.negative,
751
+ drawColor,
752
+ isDesktopWeb,
753
+ panel,
754
+ panelDrawings,
755
+ scene,
756
+ selectedDrawingId,
757
+ toolDrag,
758
+ toolReadout?.direction,
759
+ ]);
760
+ const vectors = useMemo<ChartSurfaceProps["vectors"]>(() => {
761
+ if (!isDesktopWeb) return null;
762
+ const shapes = buildChartToolVectors({
763
+ scene,
764
+ panel,
765
+ drawings: panelDrawings,
766
+ selectedId: selectedDrawingId,
767
+ drag: toolDrag,
768
+ colors: {
769
+ positive: themeColors.positive,
770
+ negative: colors.negative,
771
+ zoom: colors.crosshair,
772
+ draw: drawColor,
773
+ },
774
+ direction: toolReadout?.direction ?? "up",
775
+ });
776
+ return shapes.length > 0 ? shapes : null;
777
+ }, [
778
+ colors.crosshair,
779
+ colors.negative,
780
+ drawColor,
781
+ isDesktopWeb,
782
+ panel,
783
+ panelDrawings,
784
+ scene,
785
+ selectedDrawingId,
786
+ toolDrag,
787
+ toolReadout?.direction,
788
+ ]);
340
789
  const textLines = useMemo(
341
790
  () => isDesktopWeb
342
791
  ? []
@@ -363,7 +812,66 @@ function CompositePanelSurface({
363
812
  : activeCursorYRatio * Math.max(panel.height * cellHeightPx - 1, 0);
364
813
  const leftCursorLabel = cursorAxisLabel(panel, "left", activeCursorYRatio);
365
814
  const rightCursorLabel = cursorAxisLabel(panel, "right", activeCursorYRatio);
815
+ const measureReadout = useMemo(() => {
816
+ if (!toolDrag || !toolReadout?.summary) return null;
817
+ const text = toolReadout.summary;
818
+ const centreX = (toolDrag.startXRatio + toolDrag.endXRatio) / 2;
819
+ const centreY = toolDrag.kind === "zoom"
820
+ ? 0.5
821
+ : (toolDrag.startYRatio + toolDrag.endYRatio) / 2;
822
+ const width = Math.min([...text].length, plotWidth);
823
+ return {
824
+ text,
825
+ width,
826
+ color: toolDrag.kind === "zoom"
827
+ ? colors.text
828
+ : toolReadout.direction === "down" ? colors.negative : themeColors.positive,
829
+ left: leftAxisWidth + (leftAxisWidth ? axisGap : 0)
830
+ + Math.max(0, Math.min(plotWidth - width, Math.round(centreX * plotWidth - width / 2))),
831
+ top: Math.max(0, Math.min(panel.height - 1, Math.round(centreY * (panel.height - 1)))),
832
+ };
833
+ }, [
834
+ axisGap,
835
+ colors.negative,
836
+ colors.text,
837
+ leftAxisWidth,
838
+ panel.height,
839
+ plotWidth,
840
+ toolDrag,
841
+ toolReadout,
842
+ ]);
843
+ const axisMarkers = useMemo(() => {
844
+ // The crosshair already marks the moving end, so the axes only need the
845
+ // anchor the drag started from.
846
+ if (!toolDrag || toolDrag.kind === "zoom" || !toolReadout?.startValueLabel) return null;
847
+ return {
848
+ yRatio: toolDrag.startYRatio,
849
+ label: toolReadout.startValueLabel,
850
+ color: toolReadout.direction === "down" ? colors.negative : themeColors.positive,
851
+ };
852
+ }, [colors.negative, toolDrag, toolReadout]);
853
+ const buildAxisMarkers = (side: "left" | "right") => {
854
+ if (!axisMarkers || !panel.axes[side]) return undefined;
855
+ const row = Math.round(axisMarkers.yRatio * Math.max(panel.height - 1, 0));
856
+ return [{
857
+ row,
858
+ pixelY: axisMarkers.yRatio * Math.max(panel.height * cellHeightPx - 1, 0),
859
+ label: axisMarkers.label,
860
+ color: axisMarkers.color,
861
+ }];
862
+ };
863
+ const leftAxisMarkers = buildAxisMarkers("left");
864
+ const rightAxisMarkers = buildAxisMarkers("right");
366
865
 
866
+ const pointerRatios = useCallback((event: ChartMouseEvent) => {
867
+ const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
868
+ const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
869
+ if (!pointer) return null;
870
+ return {
871
+ xRatio: plotWidth <= 1 ? 0 : Math.max(0, Math.min(1, pointer.cellX / (plotWidth - 1))),
872
+ yRatio: panel.height <= 1 ? 0.5 : Math.max(0, Math.min(1, pointer.cellY / (panel.height - 1))),
873
+ };
874
+ }, [panel.height, plotWidth, renderer]);
367
875
  const updateCursor = useCallback((event: ChartMouseEvent): boolean => {
368
876
  const pointerTarget = plotRef.current as unknown as Parameters<typeof getLocalPlotPointer>[1];
369
877
  const pointer = getLocalPlotPointer(event, pointerTarget, renderer);
@@ -384,24 +892,136 @@ function CompositePanelSurface({
384
892
  }, [onCursorDateChange]);
385
893
  const startDrag = useCallback((event: ChartMouseEvent) => {
386
894
  onActivate?.();
895
+ // The plot consumes the press, so the browser never moves focus off a text
896
+ // field for us. Hand it back, but only for a press that truly landed here:
897
+ // a dialog over the plot must keep the focus it just took.
898
+ if (isDesktopWeb) releaseEditableFocus(event);
387
899
  consumeChartMouseEvent(event);
900
+ // A keyboard-armed tool covers terminals that never forward modifier drags.
901
+ const tool = resolveChartToolKind(event.modifiers) ?? armedTool;
902
+ if (tool) {
903
+ const ratios = pointerRatios(event);
904
+ if (!ratios) return;
905
+ // With a drawing tool in hand, grabbing an existing shape edits it
906
+ // instead of starting a new one on top of it.
907
+ const hit = isDrawingTool(tool)
908
+ ? hitTestDrawings(drawings, scene, panel, ratios, plotAspect)
909
+ : null;
910
+ if (hit) {
911
+ onSelectDrawing(hit.drawing.id);
912
+ dragRef.current = {
913
+ kind: "edit",
914
+ drawingId: hit.drawing.id,
915
+ pointIndex: hit.pointIndex,
916
+ lastXRatio: ratios.xRatio,
917
+ lastYRatio: ratios.yRatio,
918
+ };
919
+ updateCursor(event);
920
+ return;
921
+ }
922
+ const started: ChartToolDrag = {
923
+ kind: tool,
924
+ startXRatio: ratios.xRatio,
925
+ startYRatio: ratios.yRatio,
926
+ endXRatio: ratios.xRatio,
927
+ endYRatio: ratios.yRatio,
928
+ path: [{ xRatio: ratios.xRatio, yRatio: ratios.yRatio }],
929
+ };
930
+ if (isDrawingTool(tool)) onSelectDrawing(null);
931
+ dragRef.current = { ...started, path: [...started.path] };
932
+ setToolDrag(started);
933
+ updateCursor(event);
934
+ return;
935
+ }
388
936
  if (!updateCursor(event)) return;
389
937
  dragRef.current = {
938
+ kind: "pan",
390
939
  startGlobalX: getGlobalMouseX(event, renderer),
391
940
  startViewport: viewport,
392
941
  };
393
- }, [onActivate, renderer, updateCursor, viewport]);
942
+ }, [
943
+ armedTool,
944
+ drawings,
945
+ onActivate,
946
+ onSelectDrawing,
947
+ panel,
948
+ plotAspect,
949
+ pointerRatios,
950
+ renderer,
951
+ scene,
952
+ updateCursor,
953
+ viewport,
954
+ ]);
394
955
  const dragViewport = useCallback((event: ChartMouseEvent) => {
395
956
  const drag = dragRef.current;
396
957
  if (!drag) return;
397
958
  consumeChartMouseEvent(event);
398
959
  updateCursor(event);
960
+ if (drag.kind === "edit") {
961
+ const ratios = pointerRatios(event);
962
+ if (!ratios) return;
963
+ // Read the deltas before advancing the ref: the state updater runs later,
964
+ // and a mutable capture would hand it a zero move every time.
965
+ const deltaXRatio = ratios.xRatio - drag.lastXRatio;
966
+ const deltaYRatio = ratios.yRatio - drag.lastYRatio;
967
+ const { drawingId, pointIndex } = drag;
968
+ drag.lastXRatio = ratios.xRatio;
969
+ drag.lastYRatio = ratios.yRatio;
970
+ onEditDrawing(
971
+ drawingId,
972
+ (drawing) => shiftDrawing(drawing, scene, panel, deltaXRatio, deltaYRatio, pointIndex),
973
+ );
974
+ return;
975
+ }
976
+ if (drag.kind !== "pan") {
977
+ const ratios = pointerRatios(event);
978
+ if (!ratios) return;
979
+ drag.endXRatio = ratios.xRatio;
980
+ drag.endYRatio = ratios.yRatio;
981
+ if (drag.kind === "pencil") drag.path.push({ xRatio: ratios.xRatio, yRatio: ratios.yRatio });
982
+ setToolDrag({ ...drag, path: [...drag.path] });
983
+ return;
984
+ }
399
985
  const deltaCells = getGlobalMouseX(event, renderer) - drag.startGlobalX;
400
986
  onPanViewport(deltaCells / Math.max(plotWidth, 1), drag.startViewport);
401
- }, [onPanViewport, plotWidth, renderer, updateCursor]);
402
- const resetDrag = useCallback(() => {
987
+ }, [
988
+ onEditDrawing,
989
+ onPanViewport,
990
+ panel,
991
+ plotWidth,
992
+ pointerRatios,
993
+ renderer,
994
+ scene,
995
+ updateCursor,
996
+ ]);
997
+ const finishToolDrag = useCallback(() => {
998
+ const drag = dragRef.current;
999
+ if (!drag || drag.kind === "pan") return;
403
1000
  dragRef.current = null;
404
- }, []);
1001
+ if (drag.kind === "edit") return;
1002
+ setToolDrag(null);
1003
+ if (isDrawingTool(drag.kind)) {
1004
+ const drawing = resolveDrawingFromDrag(scene, panel, drag, drawColor, nextDrawingId());
1005
+ if (drawing) onDraw(drawing);
1006
+ return;
1007
+ }
1008
+ if (drag.kind !== "zoom") return;
1009
+ const range = resolveZoomBoxRange(scene, drag, minimumViewportSpanMs);
1010
+ if (range) onSetViewport(range);
1011
+ }, [drawColor, minimumViewportSpanMs, onDraw, onSetViewport, panel, scene]);
1012
+ const resetDrag = useCallback(() => {
1013
+ if (dragRef.current?.kind === "pan") {
1014
+ dragRef.current = null;
1015
+ return;
1016
+ }
1017
+ finishToolDrag();
1018
+ }, [finishToolDrag]);
1019
+ const handleMouseMove = useCallback((event: ChartMouseEvent) => {
1020
+ // A release over a neighbouring pane never reaches this surface, so treat a
1021
+ // plain move, which only fires with no button held, as the missing release.
1022
+ finishToolDrag();
1023
+ updateCursor(event);
1024
+ }, [finishToolDrag, updateCursor]);
405
1025
  const panFromWheel = useCallback((event: ChartMouseEvent) => {
406
1026
  const direction = event.scroll?.direction;
407
1027
  if (!direction) return;
@@ -425,7 +1045,34 @@ function CompositePanelSurface({
425
1045
  }, [onActivate, onPanViewport, onZoomViewport, plotWidth, renderer, updateCursor]);
426
1046
 
427
1047
  return (
428
- <Box flexDirection="row" height={panel.height} width={plotWidth + leftAxisWidth + rightAxisWidth + axisGap * ((leftAxisWidth ? 1 : 0) + (rightAxisWidth ? 1 : 0))}>
1048
+ <Box
1049
+ flexDirection="row"
1050
+ height={panel.height}
1051
+ width={plotWidth + leftAxisWidth + rightAxisWidth + axisGap * ((leftAxisWidth ? 1 : 0) + (rightAxisWidth ? 1 : 0))}
1052
+ position="relative"
1053
+ >
1054
+ {measureReadout ? (
1055
+ <Box
1056
+ position="absolute"
1057
+ left={measureReadout.left}
1058
+ top={measureReadout.top}
1059
+ width={measureReadout.width}
1060
+ height={1}
1061
+ zIndex={25}
1062
+ backgroundColor={colors.background}
1063
+ style={isDesktopWeb
1064
+ ? {
1065
+ width: "auto",
1066
+ paddingInline: 4,
1067
+ borderRadius: 4,
1068
+ backgroundColor: `color-mix(in srgb, ${colors.background} 82%, transparent)`,
1069
+ }
1070
+ : undefined}
1071
+ data-gloom-role="composite-chart-measure"
1072
+ >
1073
+ <Text fg={measureReadout.color}>{measureReadout.text}</Text>
1074
+ </Box>
1075
+ ) : null}
429
1076
  {leftAxisWidth > 0 ? (
430
1077
  <>
431
1078
  <PriceAxisLabels
@@ -440,6 +1087,7 @@ function CompositePanelSurface({
440
1087
  cursorColor={colors.crosshair}
441
1088
  cursorBackgroundColor={colors.background}
442
1089
  axisColor={colors.textDim}
1090
+ extraMarkers={leftAxisMarkers}
443
1091
  />
444
1092
  <Box width={axisGap} />
445
1093
  </>
@@ -451,16 +1099,17 @@ function CompositePanelSurface({
451
1099
  flexDirection="column"
452
1100
  bitmaps={bitmapLayers}
453
1101
  crosshair={crosshair}
454
- onMouseMove={interactive ? updateCursor : undefined}
1102
+ vectors={vectors}
1103
+ onMouseMove={interactive ? handleMouseMove : undefined}
455
1104
  onMouseDown={interactive ? startDrag : undefined}
456
1105
  onMouseDrag={interactive ? dragViewport : undefined}
457
1106
  onMouseUp={interactive ? resetDrag : undefined}
458
1107
  onMouseDragEnd={interactive ? resetDrag : undefined}
459
1108
  onMouseScroll={interactive ? panFromWheel : undefined}
460
1109
  onMouseOut={interactive ? clearCursor : undefined}
461
- cursor={interactive ? "grab" : undefined}
1110
+ cursor={interactive ? toolDrag ? "crosshair" : "grab" : undefined}
462
1111
  data-gloom-interactive={interactive ? "true" : undefined}
463
- data-gloom-role="composite-chart-panel"
1112
+ data-gloom-role={COMPOSITE_PANEL_ROLE}
464
1113
  data-gloom-label={panel.label ?? panel.id}
465
1114
  >
466
1115
  {textLines.map((line, index) => <Text key={index} fg={colors.text}>{line}</Text>)}
@@ -480,6 +1129,7 @@ function CompositePanelSurface({
480
1129
  cursorColor={colors.crosshair}
481
1130
  cursorBackgroundColor={colors.background}
482
1131
  axisColor={colors.textDim}
1132
+ extraMarkers={rightAxisMarkers}
483
1133
  />
484
1134
  </>
485
1135
  ) : null}
@@ -504,6 +1154,7 @@ function CompositeLegend({
504
1154
  accessory,
505
1155
  accessoryWidth,
506
1156
  formatValue,
1157
+ showLatestChangePercent,
507
1158
  onActivate,
508
1159
  onToggleSeries,
509
1160
  isSeriesToggleable,
@@ -516,6 +1167,7 @@ function CompositeLegend({
516
1167
  accessory: CompositeChartProps["legendAccessory"];
517
1168
  accessoryWidth: CompositeChartProps["legendAccessoryWidth"];
518
1169
  formatValue: CompositeChartProps["formatValue"];
1170
+ showLatestChangePercent: CompositeChartProps["showLatestChangePercent"];
519
1171
  onActivate: CompositeChartProps["onActivate"];
520
1172
  onToggleSeries: CompositeChartProps["onToggleSeries"];
521
1173
  isSeriesToggleable: CompositeChartProps["isSeriesToggleable"];
@@ -523,29 +1175,32 @@ function CompositeLegend({
523
1175
  }) {
524
1176
  const isDesktopWeb = useUiHost().kind === "desktop-web";
525
1177
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
526
- const dateLabel = scene
527
- ? scene.cursorDate
528
- ? formatCompositeCursorDate(scene.cursorDate, scene.startTime, scene.endTime)
529
- : "Latest"
530
- : "";
531
1178
  const cursorValueById = new Map(
532
1179
  scene?.cursorValues.map((entry) => [entry.seriesId, entry] as const) ?? [],
533
1180
  );
534
1181
  const entries = series.map((entry) => {
535
1182
  const toggleable = !!onToggleSeries && (isSeriesToggleable?.(entry) ?? true);
536
1183
  const cursorValue = cursorValueById.get(entry.id);
537
- const fullText = `${entry.label} ${legendValue(
538
- entry,
539
- cursorValue?.value ?? null,
540
- formatValue,
541
- )}`;
1184
+ const changeText = showLatestChangePercent
1185
+ && !scene?.cursorDate
1186
+ && typeof entry.latestChangePercent === "number"
1187
+ && Number.isFinite(entry.latestChangePercent)
1188
+ ? ` ${formatPercentRaw(entry.latestChangePercent)}`
1189
+ : "";
1190
+ const fullText = entry.points.length === 0
1191
+ ? `${entry.label}${entry.hidden ? "" : " no data"}`
1192
+ : `${entry.label} ${legendValue(
1193
+ entry,
1194
+ cursorValue?.value ?? null,
1195
+ formatValue,
1196
+ )}${changeText}`;
542
1197
  const details = formatCompositePointDetails(cursorValue?.point);
543
1198
  const tooltip = details ? `${fullText} · ${details}` : fullText;
544
1199
  const textWidth = Math.max(1, Math.min(30, [...fullText].length));
545
1200
  return {
546
1201
  entry,
547
1202
  text: truncateWithEllipsis(fullText, textWidth),
548
- width: textWidth + 2 + (toggleable ? 5 : 0),
1203
+ width: textWidth + 2,
549
1204
  toggleable,
550
1205
  tooltip,
551
1206
  };
@@ -558,25 +1213,11 @@ function CompositeLegend({
558
1213
  ? Math.max(1, Math.min(width, Math.floor(accessoryWidth ?? 14)))
559
1214
  : 0;
560
1215
  const reservedAccessoryGap = accessory && width > resolvedAccessoryWidth ? 1 : 0;
1216
+ // The cursor date lives on the time axis, where the crosshair points at it.
561
1217
  const widthBeforeAccessory = Math.max(0, width - resolvedAccessoryWidth - reservedAccessoryGap);
562
- const minimumSeriesPreviewWidth = entries.length > 0
563
- ? Math.min(7, desiredSeriesWidth)
564
- : 0;
565
- const showDate = dateLabel.length > 0 && widthBeforeAccessory >= (
566
- dateLabel.length
567
- + (minimumSeriesPreviewWidth > 0 ? minimumSeriesPreviewWidth + 1 : 0)
568
- );
569
- const dateWidth = showDate ? dateLabel.length : 0;
570
- const dateSeriesGap = showDate && entries.length > 0 ? 1 : 0;
571
- const seriesWidth = Math.min(
572
- desiredSeriesWidth,
573
- Math.max(0, widthBeforeAccessory - dateWidth - dateSeriesGap),
574
- );
1218
+ const seriesWidth = Math.min(desiredSeriesWidth, widthBeforeAccessory);
575
1219
  const accessorySpacerWidth = accessory
576
- ? Math.max(
577
- reservedAccessoryGap,
578
- width - dateWidth - dateSeriesGap - seriesWidth - resolvedAccessoryWidth,
579
- )
1220
+ ? Math.max(reservedAccessoryGap, width - seriesWidth - resolvedAccessoryWidth)
580
1221
  : 0;
581
1222
  const keyboardEntryStart = keyboardIndex === null || keyboardIndex === undefined
582
1223
  ? null
@@ -639,12 +1280,6 @@ function CompositeLegend({
639
1280
  zIndex={20}
640
1281
  data-gloom-role="composite-chart-legend"
641
1282
  >
642
- {showDate ? (
643
- <Box width={dateWidth} height={1} flexShrink={0} overflow="hidden">
644
- <Text fg={themeColors.textDim}>{dateLabel}</Text>
645
- </Box>
646
- ) : null}
647
- {dateSeriesGap > 0 ? <Box width={dateSeriesGap} flexShrink={0} /> : null}
648
1283
  {seriesWidth > 0 ? (
649
1284
  <ScrollBox
650
1285
  ref={scrollRef}
@@ -701,12 +1336,9 @@ function CompositeLegend({
701
1336
  ) : (
702
1337
  <Text fg={entryVisible ? entry.color : themeColors.textMuted}>● </Text>
703
1338
  )}
1339
+ {/* The filled/hollow marker already says whether a series is
1340
+ shown; the word only repeated it in every legend slot. */}
704
1341
  <Text fg={entryVisible ? themeColors.text : themeColors.textDim}>{text}</Text>
705
- {toggleable ? (
706
- <Text fg={themeColors.textMuted}>
707
- {entryVisible ? " Hide" : " Show"}
708
- </Text>
709
- ) : null}
710
1342
  </Box>
711
1343
  );
712
1344
  })}
@@ -745,8 +1377,10 @@ export function CompositeChart({
745
1377
  viewportResetKey,
746
1378
  colors,
747
1379
  interactive = true,
1380
+ allowHistoricalBackfill = false,
748
1381
  axisWidth = 9,
749
1382
  showLegend = true,
1383
+ showLatestChangePercent = false,
750
1384
  legendAccessory,
751
1385
  legendAccessoryWidth,
752
1386
  showTimeAxis = true,
@@ -759,8 +1393,43 @@ export function CompositeChart({
759
1393
  isSeriesToggleable,
760
1394
  }: CompositeChartProps) {
761
1395
  const { cellWidthPx = 8 } = useUiCapabilities();
1396
+ const isDesktopWeb = useUiHost().kind === "desktop-web";
762
1397
  const [internalCursorDate, setInternalCursorDate] = useState<Date | null>(null);
763
1398
  const [legendKeyboardIndex, setLegendKeyboardIndex] = useState<number | null>(null);
1399
+ const [toolSpan, setToolSpan] = useState<ChartToolSpan | null>(null);
1400
+ const [armedTool, setArmedTool] = useState<ChartToolKind | null>(null);
1401
+ // ponytail: drawings live with the mounted chart. Persisting them belongs with
1402
+ // pane settings, which is a separate plumbing job.
1403
+ const [drawings, setDrawings] = useState<readonly ChartDrawing[]>([]);
1404
+ const [selectedDrawingId, setSelectedDrawingId] = useState<string | null>(null);
1405
+ const [drawColor, setDrawColor] = useState<string>(CHART_DRAWING_COLORS[0]);
1406
+ const addDrawing = useCallback((drawing: ChartDrawing) => {
1407
+ setDrawings((current) => [...current, drawing]);
1408
+ setSelectedDrawingId(drawing.id);
1409
+ }, []);
1410
+ const editDrawing = useCallback((
1411
+ id: string,
1412
+ update: (drawing: ChartDrawing) => ChartDrawing,
1413
+ ) => {
1414
+ setDrawings((current) => current.map((drawing) => drawing.id === id ? update(drawing) : drawing));
1415
+ }, []);
1416
+ const removeDrawing = useCallback(() => {
1417
+ setDrawings((current) => {
1418
+ if (current.length === 0) return current;
1419
+ const target = selectedDrawingId
1420
+ ? current.filter((drawing) => drawing.id !== selectedDrawingId)
1421
+ : current.slice(0, -1);
1422
+ return target.length === current.length ? current.slice(0, -1) : target;
1423
+ });
1424
+ setSelectedDrawingId(null);
1425
+ }, [selectedDrawingId]);
1426
+ const pickDrawColor = useCallback((color: string) => {
1427
+ setDrawColor(color);
1428
+ if (!selectedDrawingId) return;
1429
+ setDrawings((current) => current.map(
1430
+ (drawing) => drawing.id === selectedDrawingId ? { ...drawing, color } : drawing,
1431
+ ));
1432
+ }, [selectedDrawingId]);
764
1433
  const resolvedCursorDate = cursorDate === undefined ? internalCursorDate : cursorDate;
765
1434
  const totalWidth = Math.max(1, Math.floor(width));
766
1435
  const totalHeight = Math.max(1, Math.floor(height));
@@ -771,8 +1440,11 @@ export function CompositeChart({
771
1440
  ? supplied
772
1441
  : visibleSeries;
773
1442
  }, [timelineSeries, visibleSeries]);
1443
+ // A series with no observation in this window stays listed. Dropping it made
1444
+ // the legend disagree with the series editor and looked like the chart had
1445
+ // silently thrown the series away.
774
1446
  const visibleLegendSeries = useMemo(
775
- () => (legendSeries ?? visibleSeries).filter((entry) => entry.points.length > 0),
1447
+ () => legendSeries ?? visibleSeries,
776
1448
  [legendSeries, visibleSeries],
777
1449
  );
778
1450
  const visibleSeriesIds = useMemo(
@@ -801,8 +1473,12 @@ export function CompositeChart({
801
1473
  ? viewport
802
1474
  : interactionViewport ?? viewport;
803
1475
  const navigationBounds = useMemo(
804
- () => resolveCompositeNavigationBounds(visibleSeries, navigationAnchorViewport),
805
- [navigationAnchorViewport, visibleSeries],
1476
+ () => resolveCompositeNavigationBounds(
1477
+ visibleSeries,
1478
+ navigationAnchorViewport,
1479
+ { historicalPaddingRatio: allowHistoricalBackfill ? 1 : 0 },
1480
+ ),
1481
+ [allowHistoricalBackfill, navigationAnchorViewport, visibleSeries],
806
1482
  );
807
1483
  const initialViewport = useMemo(() => (
808
1484
  navigationBounds
@@ -918,27 +1594,36 @@ export function CompositeChart({
918
1594
  viewportInteractionRef.current = "pan";
919
1595
  setInteractionViewport((current) => {
920
1596
  const base = fromViewport ?? current ?? initialViewport;
921
- const next = panCompositeViewport(base, navigationBounds, shiftRatio, marketTimelineSeries);
1597
+ const next = panCompositeViewport(
1598
+ base,
1599
+ navigationBounds,
1600
+ shiftRatio,
1601
+ marketTimelineSeries,
1602
+ allowHistoricalBackfill,
1603
+ );
922
1604
  if (sameCompositeViewport(next, base)) {
923
1605
  if (!fromViewport) return current;
924
1606
  return sameCompositeViewport(base, initialViewport) ? null : base;
925
1607
  }
926
1608
  return sameCompositeViewport(next, initialViewport) ? null : next;
927
1609
  });
928
- }, [initialViewport, marketTimelineSeries, navigationBounds]);
1610
+ }, [allowHistoricalBackfill, initialViewport, marketTimelineSeries, navigationBounds]);
1611
+ const setViewportRange = useCallback((range: CompositeViewportRange) => {
1612
+ if (!navigationBounds || !initialViewport) return;
1613
+ viewportInteractionRef.current = "zoom";
1614
+ const next = clampCompositeViewport(range, navigationBounds);
1615
+ setInteractionViewport(sameCompositeViewport(next, initialViewport) ? null : next);
1616
+ }, [initialViewport, navigationBounds]);
929
1617
  const resetViewport = useCallback(() => {
930
1618
  viewportInteractionRef.current = "reset";
931
1619
  setInteractionViewport(null);
932
1620
  }, []);
933
- const hasLeftAxis = visibleSeries.some((entry) => entry.axis === "left");
934
- const hasRightAxis = visibleSeries.some((entry) => entry.axis === "right");
935
- const resolvedAxisWidth = Math.max(0, Math.floor(axisWidth));
936
- const leftAxisWidth = hasLeftAxis ? resolvedAxisWidth : 0;
937
- const rightAxisWidth = hasRightAxis ? resolvedAxisWidth : 0;
938
- const axisGap = resolvedAxisWidth > 0 ? 1 : 0;
939
- const horizontalReserved = leftAxisWidth + rightAxisWidth
940
- + axisGap * ((leftAxisWidth ? 1 : 0) + (rightAxisWidth ? 1 : 0));
941
- const plotWidth = Math.max(1, totalWidth - horizontalReserved);
1621
+ // Sticky while armed: the toolbar chip shows which tool owns the drag, and a
1622
+ // one-shot tool would blink off before the user could see it.
1623
+ const armTool = useCallback((tool: ChartToolKind | null) => {
1624
+ setArmedTool((current) => current === tool ? null : tool);
1625
+ if (tool === null) setSelectedDrawingId(null);
1626
+ }, []);
942
1627
  const legendRows = showLegend && (visibleSeries.length > 0 || legendAccessory)
943
1628
  ? 1
944
1629
  : 0;
@@ -959,6 +1644,35 @@ export function CompositeChart({
959
1644
  viewport: effectiveViewport ?? undefined,
960
1645
  timelineSeries: marketTimelineSeries,
961
1646
  }), [effectiveViewport, marketTimelineSeries, panelCount, panels, visibleSeries]);
1647
+ // Gutters follow the axes the scene actually built. Reading the series list
1648
+ // instead drops a gutter the moment its series has no observation in view,
1649
+ // which is exactly what a zoom does, taking the axis labels with it.
1650
+ const scenePanels = projectedScene?.panels;
1651
+ const hasLeftAxis = scenePanels
1652
+ ? scenePanels.some((panel) => !!panel.axes.left)
1653
+ : visibleSeries.some((entry) => entry.axis === "left");
1654
+ const hasRightAxis = scenePanels
1655
+ ? scenePanels.some((panel) => !!panel.axes.right)
1656
+ : visibleSeries.some((entry) => entry.axis === "right");
1657
+ const maximumAxisWidth = Math.max(0, Math.floor(axisWidth));
1658
+ // Gutters follow their labels. A fixed budget left dead space beside short
1659
+ // prices, which costs plot width on every chart that does not need it.
1660
+ const resolvedAxisWidth = useMemo(() => Math.min(
1661
+ maximumAxisWidth,
1662
+ Math.max(
1663
+ MINIMUM_AXIS_LABEL_WIDTH,
1664
+ ...(projectedScene?.panels ?? []).flatMap((panel) => [
1665
+ compositeAxisLabelWidth(panel.axes.left),
1666
+ compositeAxisLabelWidth(panel.axes.right),
1667
+ ]),
1668
+ ),
1669
+ ), [maximumAxisWidth, projectedScene]);
1670
+ const leftAxisWidth = hasLeftAxis ? resolvedAxisWidth : 0;
1671
+ const rightAxisWidth = hasRightAxis ? resolvedAxisWidth : 0;
1672
+ const axisGap = resolvedAxisWidth > 0 ? 1 : 0;
1673
+ const horizontalReserved = leftAxisWidth + rightAxisWidth
1674
+ + axisGap * ((leftAxisWidth ? 1 : 0) + (rightAxisWidth ? 1 : 0));
1675
+ const plotWidth = Math.max(1, totalWidth - horizontalReserved);
962
1676
  const layoutPanels = useMemo<CompositePanelScene[] | null>(() => {
963
1677
  if (!projectedScene) return null;
964
1678
  const panelSpecById = new Map(panels.map((panel) => [panel.id, panel] as const));
@@ -1060,6 +1774,13 @@ export function CompositeChart({
1060
1774
  onToggleSeries(entry.id);
1061
1775
  return;
1062
1776
  }
1777
+ if (isPlainKey(event, "escape") && (armedTool || selectedDrawingId)) {
1778
+ event.preventDefault();
1779
+ event.stopPropagation();
1780
+ setArmedTool(null);
1781
+ setSelectedDrawingId(null);
1782
+ return;
1783
+ }
1063
1784
  if (isPlainKey(event, "escape") && legendKeyboardIndex !== null && !scene?.cursorDate) {
1064
1785
  event.preventDefault();
1065
1786
  event.stopPropagation();
@@ -1070,6 +1791,12 @@ export function CompositeChart({
1070
1791
  if (!interaction) return;
1071
1792
  if (
1072
1793
  (interaction === "clear-cursor" && !scene?.cursorDate)
1794
+ || ((interaction === "arm-measure"
1795
+ || interaction === "arm-zoom"
1796
+ || interaction === "arm-line"
1797
+ || interaction === "arm-pencil") && !scene)
1798
+ || (interaction === "delete-drawing" && drawings.length === 0)
1799
+ || (interaction === "cycle-colour" && !isDrawingTool(armedTool) && !selectedDrawingId)
1073
1800
  || ((interaction === "cursor-left" || interaction === "cursor-right") && !scene)
1074
1801
  || ((interaction === "zoom-in"
1075
1802
  || interaction === "zoom-out"
@@ -1081,6 +1808,19 @@ export function CompositeChart({
1081
1808
  event.preventDefault();
1082
1809
  event.stopPropagation();
1083
1810
  switch (interaction) {
1811
+ case "arm-measure":
1812
+ case "arm-zoom":
1813
+ case "arm-line":
1814
+ case "arm-pencil":
1815
+ onActivate?.();
1816
+ armTool(ARMED_TOOL_BY_INTERACTION[interaction]);
1817
+ return;
1818
+ case "delete-drawing":
1819
+ removeDrawing();
1820
+ return;
1821
+ case "cycle-colour":
1822
+ pickDrawColor(nextDrawingColor(drawColor));
1823
+ return;
1084
1824
  case "clear-cursor":
1085
1825
  keyboardCursorDateRef.current = null;
1086
1826
  updateCursor(null);
@@ -1141,6 +1881,7 @@ export function CompositeChart({
1141
1881
  accessory={legendAccessory}
1142
1882
  accessoryWidth={legendAccessoryWidth}
1143
1883
  formatValue={formatValue}
1884
+ showLatestChangePercent={showLatestChangePercent}
1144
1885
  onActivate={onActivate}
1145
1886
  onToggleSeries={onToggleSeries}
1146
1887
  isSeriesToggleable={isSeriesToggleable}
@@ -1191,8 +1932,28 @@ export function CompositeChart({
1191
1932
  const timeAxisCursorLabel = scene.cursorDate
1192
1933
  ? formatCompositeTimeAxisDate(scene.cursorDate, scene.startTime, scene.endTime)
1193
1934
  : null;
1935
+ // The crosshair labels the moving end, so the axis only adds the anchor.
1936
+ const timeAxisMarkers = toolSpan
1937
+ ? [{
1938
+ ratio: toolSpan.startXRatio,
1939
+ label: formatCompositeTimeAxisDate(
1940
+ new Date(unprojectCompositeTimestamp(scene.timeScale, toolSpan.startXRatio)),
1941
+ scene.startTime,
1942
+ scene.endTime,
1943
+ ),
1944
+ color: toolSpan.color,
1945
+ }]
1946
+ : undefined;
1194
1947
  return (
1195
- <Box flexDirection="column" width={totalWidth} height={totalHeight} overflow="hidden" data-gloom-role="composite-chart">
1948
+ <Box
1949
+ flexDirection="column"
1950
+ width={totalWidth}
1951
+ height={totalHeight}
1952
+ overflow="hidden"
1953
+ // The tool overlay anchors here; without it the desktop pins it to the page.
1954
+ position="relative"
1955
+ data-gloom-role="composite-chart"
1956
+ >
1196
1957
  {showLegend ? (
1197
1958
  <CompositeLegend
1198
1959
  scene={scene}
@@ -1202,12 +1963,31 @@ export function CompositeChart({
1202
1963
  accessory={legendAccessory}
1203
1964
  accessoryWidth={legendAccessoryWidth}
1204
1965
  formatValue={formatValue}
1966
+ showLatestChangePercent={showLatestChangePercent}
1205
1967
  onActivate={onActivate}
1206
1968
  onToggleSeries={onToggleSeries}
1207
1969
  isSeriesToggleable={isSeriesToggleable}
1208
1970
  keyboardIndex={legendKeyboardIndex}
1209
1971
  />
1210
1972
  ) : null}
1973
+ {interactive && plotWidth > CHART_TOOLBAR_WIDTH + 4 ? (
1974
+ <ChartToolbar
1975
+ armedTool={armedTool}
1976
+ isDesktopWeb={isDesktopWeb}
1977
+ left={leftPadding}
1978
+ top={legendRows}
1979
+ drawColor={drawColor}
1980
+ showColors={isDrawingTool(armedTool) || !!selectedDrawingId}
1981
+ onArmTool={(tool) => {
1982
+ onActivate?.();
1983
+ armTool(tool);
1984
+ }}
1985
+ onPickColor={(color) => {
1986
+ onActivate?.();
1987
+ pickDrawColor(color);
1988
+ }}
1989
+ />
1990
+ ) : null}
1211
1991
  {scene.panels.map((panel) => (
1212
1992
  <CompositePanelSurface
1213
1993
  key={panel.id}
@@ -1220,10 +2000,20 @@ export function CompositeChart({
1220
2000
  colors={resolvedColors}
1221
2001
  interactive={interactive}
1222
2002
  viewport={effectiveViewport!}
2003
+ minimumViewportSpanMs={minimumViewportSpanMs}
2004
+ armedTool={armedTool}
2005
+ drawings={drawings}
2006
+ selectedDrawingId={selectedDrawingId}
2007
+ drawColor={drawColor}
2008
+ onDraw={addDrawing}
2009
+ onEditDrawing={editDrawing}
2010
+ onSelectDrawing={setSelectedDrawingId}
1223
2011
  onActivate={onActivate}
1224
2012
  onCursorDateChange={updateCursor}
1225
2013
  onPanViewport={panViewport}
1226
2014
  onZoomViewport={zoomViewport}
2015
+ onSetViewport={setViewportRange}
2016
+ onToolSpanChange={setToolSpan}
1227
2017
  />
1228
2018
  ))}
1229
2019
  {timeAxisLayout ? (
@@ -1239,6 +2029,7 @@ export function CompositeChart({
1239
2029
  cursorLabel={timeAxisCursorLabel}
1240
2030
  cursorColor={resolvedColors.crosshair}
1241
2031
  cursorBackgroundColor={resolvedColors.background}
2032
+ extraMarkers={timeAxisMarkers}
1242
2033
  />
1243
2034
  {rightPadding > 0 ? <Box width={rightPadding} /> : null}
1244
2035
  </Box>