gloomberb 0.4.1 → 0.5.0
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 +2 -2
- package/package.json +1 -1
- package/src/app.tsx +168 -65
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +292 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/header.tsx +2 -1
- package/src/components/layout/shell.test.tsx +186 -27
- package/src/components/layout/shell.tsx +5 -4
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +54 -1
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +60 -3
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +62 -18
- package/src/plugins/builtin/chat-controller.ts +23 -12
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +415 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +24 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +1 -1
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +8 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +13 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
- package/src/plugins/registry.ts +22 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { colors } from "../../theme/colors";
|
|
3
|
+
|
|
4
|
+
type HintMouseEvent = {
|
|
5
|
+
stopPropagation?: () => void;
|
|
6
|
+
preventDefault?: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface ChartControlHintProps {
|
|
10
|
+
hotkey: string;
|
|
11
|
+
label: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onPress?: (event?: HintMouseEvent) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stopMouseEvent(event?: HintMouseEvent) {
|
|
17
|
+
event?.stopPropagation?.();
|
|
18
|
+
event?.preventDefault?.();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ChartControlHint({
|
|
22
|
+
hotkey,
|
|
23
|
+
label,
|
|
24
|
+
disabled = false,
|
|
25
|
+
onPress,
|
|
26
|
+
}: ChartControlHintProps) {
|
|
27
|
+
const interactive = !!onPress && !disabled;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<text
|
|
31
|
+
fg={disabled ? colors.textMuted : colors.textDim}
|
|
32
|
+
attributes={interactive ? TextAttributes.BOLD : 0}
|
|
33
|
+
onMouseDown={interactive ? stopMouseEvent : undefined}
|
|
34
|
+
onMouseUp={interactive ? onPress : undefined}
|
|
35
|
+
>
|
|
36
|
+
<span fg={disabled ? colors.textMuted : colors.textBright}>[{hotkey}]</span>{label}
|
|
37
|
+
</text>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
clampDateWindowToBounds,
|
|
4
|
+
getCanonicalZoomLevel,
|
|
5
|
+
needsCanonicalPresetViewportReset,
|
|
6
|
+
resolveCanonicalPresetViewport,
|
|
7
|
+
resolveVisibleActivePreset,
|
|
8
|
+
shiftDateWindow,
|
|
9
|
+
} from "./chart-controller";
|
|
10
|
+
|
|
11
|
+
const dayMs = 24 * 60 * 60_000;
|
|
12
|
+
|
|
13
|
+
function utcDate(day: number): Date {
|
|
14
|
+
return new Date(Date.UTC(2026, 0, day));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function utcDates(count: number): Date[] {
|
|
18
|
+
return Array.from({ length: count }, (_, index) => utcDate(index + 1));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("chart-controller date windows", () => {
|
|
22
|
+
test("keeps the visible span when panning past the right edge", () => {
|
|
23
|
+
const current = {
|
|
24
|
+
start: utcDate(8),
|
|
25
|
+
end: utcDate(10),
|
|
26
|
+
};
|
|
27
|
+
const shifted = shiftDateWindow(current, -2);
|
|
28
|
+
|
|
29
|
+
const clamped = clampDateWindowToBounds(shifted, {
|
|
30
|
+
start: utcDate(1),
|
|
31
|
+
end: utcDate(11),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(clamped).toEqual({
|
|
35
|
+
start: utcDate(9),
|
|
36
|
+
end: utcDate(11),
|
|
37
|
+
});
|
|
38
|
+
expect(clamped!.end!.getTime() - clamped!.start!.getTime()).toBe(2 * dayMs);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("keeps the visible span when panning past the left edge", () => {
|
|
42
|
+
const current = {
|
|
43
|
+
start: utcDate(2),
|
|
44
|
+
end: utcDate(4),
|
|
45
|
+
};
|
|
46
|
+
const shifted = shiftDateWindow(current, 2);
|
|
47
|
+
|
|
48
|
+
const clamped = clampDateWindowToBounds(shifted, {
|
|
49
|
+
start: utcDate(1),
|
|
50
|
+
end: utcDate(11),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(clamped).toEqual({
|
|
54
|
+
start: utcDate(1),
|
|
55
|
+
end: utcDate(3),
|
|
56
|
+
});
|
|
57
|
+
expect(clamped!.end!.getTime() - clamped!.start!.getTime()).toBe(2 * dayMs);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("expands tiny windows around their center without crossing bounds", () => {
|
|
61
|
+
const clamped = clampDateWindowToBounds({
|
|
62
|
+
start: utcDate(11),
|
|
63
|
+
end: utcDate(11),
|
|
64
|
+
}, {
|
|
65
|
+
start: utcDate(1),
|
|
66
|
+
end: utcDate(11),
|
|
67
|
+
}, 2 * dayMs);
|
|
68
|
+
|
|
69
|
+
expect(clamped).toEqual({
|
|
70
|
+
start: utcDate(9),
|
|
71
|
+
end: utcDate(11),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("keeps an active preset visible while loaded data still needs the canonical reset", () => {
|
|
76
|
+
const dates = utcDates(300);
|
|
77
|
+
const state = {
|
|
78
|
+
presetRange: "1M" as const,
|
|
79
|
+
activePreset: "1M" as const,
|
|
80
|
+
resolution: "15m" as const,
|
|
81
|
+
panOffset: 0,
|
|
82
|
+
zoomLevel: 1,
|
|
83
|
+
cursorX: 12,
|
|
84
|
+
cursorY: 3,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
expect(needsCanonicalPresetViewportReset(dates, state)).toBe(true);
|
|
88
|
+
expect(resolveVisibleActivePreset(dates, state)).toBe("1M");
|
|
89
|
+
|
|
90
|
+
const resolved = resolveCanonicalPresetViewport(state, dates);
|
|
91
|
+
expect(resolved.zoomLevel).toBe(getCanonicalZoomLevel(dates, "1M"));
|
|
92
|
+
expect(resolved.panOffset).toBe(0);
|
|
93
|
+
expect(resolved.cursorX).toBeNull();
|
|
94
|
+
expect(resolved.cursorY).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("does not keep a preset active after the viewport was panned", () => {
|
|
98
|
+
const dates = utcDates(300);
|
|
99
|
+
const state = {
|
|
100
|
+
presetRange: "1M" as const,
|
|
101
|
+
activePreset: "1M" as const,
|
|
102
|
+
resolution: "15m" as const,
|
|
103
|
+
panOffset: 2,
|
|
104
|
+
zoomLevel: 1,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
expect(needsCanonicalPresetViewportReset(dates, state)).toBe(false);
|
|
108
|
+
expect(resolveVisibleActivePreset(dates, state)).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { QueryEntry } from "../../market-data/result-types";
|
|
2
2
|
import type { PricePoint } from "../../types/financials";
|
|
3
3
|
import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
|
|
4
|
-
import type { ChartResolution, ChartViewState, ComparisonChartViewState, TimeRange } from "./chart-types";
|
|
4
|
+
import type { ChartResolution, ChartViewState, ComparisonChartViewState, TimeRange, VisibleWindow } from "./chart-types";
|
|
5
5
|
import {
|
|
6
6
|
clampTimeRangeToMaxRange,
|
|
7
7
|
getBestSupportedResolutionForPreset,
|
|
@@ -37,6 +37,11 @@ export interface ChartBodyState<T> {
|
|
|
37
37
|
errorMessage: string | null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export interface DateWindowRange {
|
|
41
|
+
start: Date | null;
|
|
42
|
+
end: Date | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
function clamp(value: number, min: number, max: number): number {
|
|
41
46
|
return Math.max(min, Math.min(max, value));
|
|
42
47
|
}
|
|
@@ -45,10 +50,235 @@ function coerceDate(value: Date | string | number): Date {
|
|
|
45
50
|
return value instanceof Date ? value : new Date(value);
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
function normalizeDateWindowRange(window: DateWindowRange | null | undefined): { startMs: number; endMs: number } | null {
|
|
54
|
+
if (!window?.start || !window.end) return null;
|
|
55
|
+
const rawStartMs = window.start.getTime();
|
|
56
|
+
const rawEndMs = window.end.getTime();
|
|
57
|
+
if (!Number.isFinite(rawStartMs) || !Number.isFinite(rawEndMs)) return null;
|
|
58
|
+
return rawStartMs <= rawEndMs
|
|
59
|
+
? { startMs: rawStartMs, endMs: rawEndMs }
|
|
60
|
+
: { startMs: rawEndMs, endMs: rawStartMs };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lowerBoundDate(dates: readonly Date[], targetMs: number): number {
|
|
64
|
+
let low = 0;
|
|
65
|
+
let high = dates.length;
|
|
66
|
+
while (low < high) {
|
|
67
|
+
const mid = Math.floor((low + high) / 2);
|
|
68
|
+
if (dates[mid]!.getTime() < targetMs) {
|
|
69
|
+
low = mid + 1;
|
|
70
|
+
} else {
|
|
71
|
+
high = mid;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return low;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function upperBoundDate(dates: readonly Date[], targetMs: number): number {
|
|
78
|
+
let low = 0;
|
|
79
|
+
let high = dates.length;
|
|
80
|
+
while (low < high) {
|
|
81
|
+
const mid = Math.floor((low + high) / 2);
|
|
82
|
+
if (dates[mid]!.getTime() <= targetMs) {
|
|
83
|
+
low = mid + 1;
|
|
84
|
+
} else {
|
|
85
|
+
high = mid;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return low;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveMinimumPointWindow(
|
|
92
|
+
dates: readonly Date[],
|
|
93
|
+
startMs: number,
|
|
94
|
+
endMs: number,
|
|
95
|
+
minimumPoints: number,
|
|
96
|
+
): { startIdx: number; endIdx: number } {
|
|
97
|
+
if (dates.length === 0) return { startIdx: 0, endIdx: 0 };
|
|
98
|
+
|
|
99
|
+
let startIdx = lowerBoundDate(dates, startMs);
|
|
100
|
+
let endIdx = upperBoundDate(dates, endMs);
|
|
101
|
+
|
|
102
|
+
if (minimumPoints <= 0) {
|
|
103
|
+
return {
|
|
104
|
+
startIdx: Math.min(startIdx, dates.length),
|
|
105
|
+
endIdx: Math.min(Math.max(endIdx, startIdx), dates.length),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (endIdx - startIdx >= minimumPoints || dates.length <= minimumPoints) {
|
|
110
|
+
return {
|
|
111
|
+
startIdx: Math.min(startIdx, dates.length),
|
|
112
|
+
endIdx: Math.min(Math.max(endIdx, startIdx), dates.length),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const centerMs = startMs + ((endMs - startMs) / 2);
|
|
117
|
+
let anchorIdx = lowerBoundDate(dates, centerMs);
|
|
118
|
+
if (anchorIdx >= dates.length) anchorIdx = dates.length - 1;
|
|
119
|
+
if (anchorIdx > 0) {
|
|
120
|
+
const currentDistance = Math.abs(dates[anchorIdx]!.getTime() - centerMs);
|
|
121
|
+
const previousDistance = Math.abs(dates[anchorIdx - 1]!.getTime() - centerMs);
|
|
122
|
+
if (previousDistance <= currentDistance) {
|
|
123
|
+
anchorIdx -= 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
startIdx = Math.max(Math.min(anchorIdx - Math.floor((minimumPoints - 1) / 2), dates.length - minimumPoints), 0);
|
|
128
|
+
endIdx = Math.min(startIdx + minimumPoints, dates.length);
|
|
129
|
+
|
|
130
|
+
return { startIdx, endIdx };
|
|
131
|
+
}
|
|
132
|
+
|
|
48
133
|
export function getPointDates(points: readonly Pick<PricePoint, "date">[]): Date[] {
|
|
49
134
|
return points.map((point) => coerceDate(point.date as Date | string | number));
|
|
50
135
|
}
|
|
51
136
|
|
|
137
|
+
export function getDateWindowBounds(dates: readonly Date[]): DateWindowRange | null {
|
|
138
|
+
if (dates.length === 0) return null;
|
|
139
|
+
return {
|
|
140
|
+
start: dates[0] ?? null,
|
|
141
|
+
end: dates[dates.length - 1] ?? null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getMinimumDateStepMs(dates: readonly Date[]): number {
|
|
146
|
+
let minimum = Number.POSITIVE_INFINITY;
|
|
147
|
+
for (let index = 1; index < dates.length; index += 1) {
|
|
148
|
+
const delta = dates[index]!.getTime() - dates[index - 1]!.getTime();
|
|
149
|
+
if (delta > 0 && delta < minimum) {
|
|
150
|
+
minimum = delta;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Number.isFinite(minimum) ? minimum : 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function buildPresetDateWindow(dates: readonly Date[], presetRange: TimeRange): DateWindowRange | null {
|
|
157
|
+
if (dates.length === 0) return null;
|
|
158
|
+
if (presetRange === "ALL") {
|
|
159
|
+
return {
|
|
160
|
+
start: dates[0] ?? null,
|
|
161
|
+
end: dates[dates.length - 1] ?? null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const end = dates[dates.length - 1]!;
|
|
166
|
+
const threshold = subtractTimeRange(end, presetRange).getTime();
|
|
167
|
+
const startIdx = dates.findIndex((date) => date.getTime() >= threshold);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
start: dates[startIdx < 0 ? 0 : startIdx] ?? null,
|
|
171
|
+
end,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function sameDateWindow(
|
|
176
|
+
left: DateWindowRange | null | undefined,
|
|
177
|
+
right: DateWindowRange | null | undefined,
|
|
178
|
+
toleranceMs = 1,
|
|
179
|
+
): boolean {
|
|
180
|
+
if (!left?.start || !left.end || !right?.start || !right.end) {
|
|
181
|
+
return left?.start === right?.start && left?.end === right?.end;
|
|
182
|
+
}
|
|
183
|
+
return Math.abs(left.start.getTime() - right.start.getTime()) <= toleranceMs
|
|
184
|
+
&& Math.abs(left.end.getTime() - right.end.getTime()) <= toleranceMs;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function clampDateWindowToBounds(
|
|
188
|
+
window: DateWindowRange | null | undefined,
|
|
189
|
+
bounds: DateWindowRange | null | undefined,
|
|
190
|
+
minimumSpanMs = 1,
|
|
191
|
+
): DateWindowRange | null {
|
|
192
|
+
const normalizedWindow = normalizeDateWindowRange(window);
|
|
193
|
+
const normalizedBounds = normalizeDateWindowRange(bounds);
|
|
194
|
+
if (!normalizedWindow || !normalizedBounds) return null;
|
|
195
|
+
|
|
196
|
+
const availableSpanMs = Math.max(normalizedBounds.endMs - normalizedBounds.startMs, 0);
|
|
197
|
+
const effectiveMinimumSpanMs = availableSpanMs === 0
|
|
198
|
+
? 0
|
|
199
|
+
: Math.min(Math.max(minimumSpanMs, 1), availableSpanMs);
|
|
200
|
+
const requestedSpanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 0);
|
|
201
|
+
const targetSpanMs = Math.min(
|
|
202
|
+
Math.max(requestedSpanMs, effectiveMinimumSpanMs),
|
|
203
|
+
availableSpanMs,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (availableSpanMs === 0 || targetSpanMs === 0) {
|
|
207
|
+
return {
|
|
208
|
+
start: new Date(normalizedBounds.startMs),
|
|
209
|
+
end: new Date(normalizedBounds.endMs),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (targetSpanMs >= availableSpanMs) {
|
|
214
|
+
return {
|
|
215
|
+
start: new Date(normalizedBounds.startMs),
|
|
216
|
+
end: new Date(normalizedBounds.endMs),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let startMs: number;
|
|
221
|
+
let endMs: number;
|
|
222
|
+
|
|
223
|
+
if (requestedSpanMs < targetSpanMs) {
|
|
224
|
+
const centerMs = normalizedWindow.startMs + (requestedSpanMs / 2);
|
|
225
|
+
startMs = centerMs - (targetSpanMs / 2);
|
|
226
|
+
endMs = centerMs + (targetSpanMs / 2);
|
|
227
|
+
} else {
|
|
228
|
+
startMs = normalizedWindow.startMs;
|
|
229
|
+
endMs = normalizedWindow.startMs + targetSpanMs;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (startMs < normalizedBounds.startMs) {
|
|
233
|
+
startMs = normalizedBounds.startMs;
|
|
234
|
+
endMs = startMs + targetSpanMs;
|
|
235
|
+
} else if (endMs > normalizedBounds.endMs) {
|
|
236
|
+
endMs = normalizedBounds.endMs;
|
|
237
|
+
startMs = endMs - targetSpanMs;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
start: new Date(startMs),
|
|
242
|
+
end: new Date(endMs),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function scaleDateWindow(
|
|
247
|
+
window: DateWindowRange | null | undefined,
|
|
248
|
+
spanScale: number,
|
|
249
|
+
anchorRatio: number,
|
|
250
|
+
): DateWindowRange | null {
|
|
251
|
+
const normalizedWindow = normalizeDateWindowRange(window);
|
|
252
|
+
if (!normalizedWindow) return null;
|
|
253
|
+
|
|
254
|
+
const currentSpanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 1);
|
|
255
|
+
const nextSpanMs = Math.max(currentSpanMs * spanScale, 1);
|
|
256
|
+
const ratio = clamp(anchorRatio, 0, 1);
|
|
257
|
+
const anchorMs = normalizedWindow.startMs + (currentSpanMs * ratio);
|
|
258
|
+
const nextStartMs = anchorMs - (nextSpanMs * ratio);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
start: new Date(nextStartMs),
|
|
262
|
+
end: new Date(nextStartMs + nextSpanMs),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function shiftDateWindow(
|
|
267
|
+
window: DateWindowRange | null | undefined,
|
|
268
|
+
shiftRatio: number,
|
|
269
|
+
): DateWindowRange | null {
|
|
270
|
+
const normalizedWindow = normalizeDateWindowRange(window);
|
|
271
|
+
if (!normalizedWindow) return null;
|
|
272
|
+
|
|
273
|
+
const spanMs = Math.max(normalizedWindow.endMs - normalizedWindow.startMs, 1);
|
|
274
|
+
const shiftMs = spanMs * shiftRatio;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
start: new Date(normalizedWindow.startMs - shiftMs),
|
|
278
|
+
end: new Date(normalizedWindow.endMs - shiftMs),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
52
282
|
export function getCanonicalVisiblePointCount(dates: readonly Date[], presetRange: TimeRange): number {
|
|
53
283
|
if (dates.length === 0) return 0;
|
|
54
284
|
if (presetRange === "ALL") return dates.length;
|
|
@@ -89,10 +319,93 @@ export function buildVisibleDateWindow(
|
|
|
89
319
|
};
|
|
90
320
|
}
|
|
91
321
|
|
|
322
|
+
export function buildVisibleDateWindowFromRange(
|
|
323
|
+
dates: readonly Date[],
|
|
324
|
+
window: DateWindowRange | null | undefined,
|
|
325
|
+
minimumPoints = 2,
|
|
326
|
+
): VisibleDateWindow {
|
|
327
|
+
if (dates.length === 0) {
|
|
328
|
+
return { start: null, end: null, dates: [], startIdx: 0, endIdx: 0, totalDates: 0 };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const normalizedWindow = normalizeDateWindowRange(window);
|
|
332
|
+
if (!normalizedWindow) {
|
|
333
|
+
return {
|
|
334
|
+
start: dates[0] ?? null,
|
|
335
|
+
end: dates[dates.length - 1] ?? null,
|
|
336
|
+
dates: [...dates],
|
|
337
|
+
startIdx: 0,
|
|
338
|
+
endIdx: dates.length,
|
|
339
|
+
totalDates: dates.length,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const { startIdx, endIdx } = resolveMinimumPointWindow(dates, normalizedWindow.startMs, normalizedWindow.endMs, minimumPoints);
|
|
344
|
+
const visibleDates = dates.slice(startIdx, endIdx);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
start: visibleDates[0] ?? null,
|
|
348
|
+
end: visibleDates[visibleDates.length - 1] ?? null,
|
|
349
|
+
dates: visibleDates,
|
|
350
|
+
startIdx,
|
|
351
|
+
endIdx,
|
|
352
|
+
totalDates: dates.length,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function getVisibleWindowForDateRange(
|
|
357
|
+
points: readonly PricePoint[],
|
|
358
|
+
window: DateWindowRange | null | undefined,
|
|
359
|
+
minimumPoints = 2,
|
|
360
|
+
): VisibleWindow {
|
|
361
|
+
if (points.length === 0) {
|
|
362
|
+
return { points: [], startIdx: 0, endIdx: 0 };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const pointDates = getPointDates(points);
|
|
366
|
+
const visibleWindow = buildVisibleDateWindowFromRange(pointDates, window, minimumPoints);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
points: points.slice(visibleWindow.startIdx, visibleWindow.endIdx),
|
|
370
|
+
startIdx: visibleWindow.startIdx,
|
|
371
|
+
endIdx: visibleWindow.endIdx,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
92
375
|
export function formatVisibleSpanLabel(window: Pick<VisibleDateWindow, "start" | "end">): string {
|
|
93
376
|
if (!window.start || !window.end) return "view: --";
|
|
94
377
|
const sameYear = window.start.getFullYear() === window.end.getFullYear();
|
|
95
378
|
const sameMonth = sameYear && window.start.getMonth() === window.end.getMonth();
|
|
379
|
+
const sameDay = sameMonth && window.start.getDate() === window.end.getDate();
|
|
380
|
+
const spanMs = Math.max(window.end.getTime() - window.start.getTime(), 0);
|
|
381
|
+
const showTime = sameDay || spanMs <= 2 * 24 * 60 * 60_000;
|
|
382
|
+
|
|
383
|
+
if (showTime) {
|
|
384
|
+
const startLabel = window.start.toLocaleDateString("en-US", {
|
|
385
|
+
month: "short",
|
|
386
|
+
day: "numeric",
|
|
387
|
+
...(sameYear ? {} : { year: "numeric" }),
|
|
388
|
+
});
|
|
389
|
+
const startTime = window.start.toLocaleTimeString("en-US", {
|
|
390
|
+
hour: "numeric",
|
|
391
|
+
minute: "2-digit",
|
|
392
|
+
});
|
|
393
|
+
const endLabel = sameDay
|
|
394
|
+
? window.end.toLocaleTimeString("en-US", {
|
|
395
|
+
hour: "numeric",
|
|
396
|
+
minute: "2-digit",
|
|
397
|
+
})
|
|
398
|
+
: `${window.end.toLocaleDateString("en-US", {
|
|
399
|
+
month: "short",
|
|
400
|
+
day: "numeric",
|
|
401
|
+
year: sameYear ? undefined : "numeric",
|
|
402
|
+
})} ${window.end.toLocaleTimeString("en-US", {
|
|
403
|
+
hour: "numeric",
|
|
404
|
+
minute: "2-digit",
|
|
405
|
+
})}`;
|
|
406
|
+
return `view:${startLabel} ${startTime}-${endLabel}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
96
409
|
const startLabel = window.start.toLocaleDateString("en-US", {
|
|
97
410
|
month: "short",
|
|
98
411
|
day: "numeric",
|
|
@@ -116,6 +429,52 @@ export function isCanonicalPresetViewport(
|
|
|
116
429
|
return Math.abs(state.zoomLevel - canonicalZoom) < 0.001;
|
|
117
430
|
}
|
|
118
431
|
|
|
432
|
+
type CanonicalPresetViewportState = Pick<ViewStateWithViewport, "presetRange" | "activePreset" | "panOffset" | "zoomLevel" | "cursorX" | "cursorY">;
|
|
433
|
+
|
|
434
|
+
export function needsCanonicalPresetViewportReset(
|
|
435
|
+
dates: readonly Date[],
|
|
436
|
+
state: Pick<CanonicalPresetViewportState, "presetRange" | "activePreset" | "panOffset" | "zoomLevel">,
|
|
437
|
+
): boolean {
|
|
438
|
+
if (dates.length === 0) return false;
|
|
439
|
+
if (state.activePreset !== state.presetRange) return false;
|
|
440
|
+
if (state.panOffset !== 0) return false;
|
|
441
|
+
const canonicalZoom = getCanonicalZoomLevel(dates, state.presetRange);
|
|
442
|
+
return Math.abs(state.zoomLevel - canonicalZoom) >= 0.001;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function resolvePresetRangeViewport<S extends CanonicalPresetViewportState>(
|
|
446
|
+
state: S,
|
|
447
|
+
dates: readonly Date[],
|
|
448
|
+
): S {
|
|
449
|
+
if (dates.length === 0) return state;
|
|
450
|
+
const canonicalZoom = getCanonicalZoomLevel(dates, state.presetRange);
|
|
451
|
+
if (state.zoomLevel === canonicalZoom && state.panOffset === 0) return state;
|
|
452
|
+
return {
|
|
453
|
+
...state,
|
|
454
|
+
panOffset: 0,
|
|
455
|
+
zoomLevel: canonicalZoom,
|
|
456
|
+
cursorX: null,
|
|
457
|
+
cursorY: null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function resolveCanonicalPresetViewport<S extends CanonicalPresetViewportState>(
|
|
462
|
+
state: S,
|
|
463
|
+
dates: readonly Date[],
|
|
464
|
+
): S {
|
|
465
|
+
if (!needsCanonicalPresetViewportReset(dates, state)) return state;
|
|
466
|
+
return resolvePresetRangeViewport(state, dates);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function resolveVisibleActivePreset(
|
|
470
|
+
dates: readonly Date[],
|
|
471
|
+
state: Pick<ViewStateWithViewport, "presetRange" | "activePreset" | "panOffset" | "zoomLevel" | "resolution">,
|
|
472
|
+
): TimeRange | null {
|
|
473
|
+
if (!state.activePreset) return null;
|
|
474
|
+
if (isCanonicalPresetViewport(dates, state)) return state.activePreset;
|
|
475
|
+
return needsCanonicalPresetViewportReset(dates, state) ? state.activePreset : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
119
478
|
export function resolvePresetSelectionWithResolution<S extends ViewStateWithViewport>(
|
|
120
479
|
state: S,
|
|
121
480
|
presetRange: TimeRange,
|
|
@@ -245,9 +604,12 @@ export function resolveStoredChartSelection<S extends ViewStateWithViewport>(
|
|
|
245
604
|
);
|
|
246
605
|
}
|
|
247
606
|
|
|
607
|
+
const bestSupportedResolution = getBestSupportedResolutionForPreset(resolvedSelection.presetRange, support);
|
|
248
608
|
return {
|
|
249
609
|
...resolvedSelection,
|
|
250
|
-
activePreset: resolvedSelection.resolution
|
|
610
|
+
activePreset: resolvedSelection.resolution !== "auto"
|
|
611
|
+
&& bestSupportedResolution !== null
|
|
612
|
+
&& resolvedSelection.resolution === bestSupportedResolution
|
|
251
613
|
? resolvedSelection.presetRange
|
|
252
614
|
: null,
|
|
253
615
|
};
|