gloomberb 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -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
|
-
|
|
331
|
-
|
|
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
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
402
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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
|
|
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
|
-
() =>
|
|
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(
|
|
805
|
-
|
|
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(
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
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>
|