gloomberb 0.3.7 → 0.4.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 +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { ChartResolution, TimeRange } from "./chart-types";
|
|
2
|
+
|
|
3
|
+
export type ManualChartResolution = Exclude<ChartResolution, "auto">;
|
|
4
|
+
|
|
5
|
+
export interface ChartResolutionSupport {
|
|
6
|
+
resolution: ManualChartResolution;
|
|
7
|
+
maxRange: TimeRange;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TIME_RANGE_ORDER: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
11
|
+
|
|
12
|
+
export const CHART_RESOLUTION_ORDER: ChartResolution[] = [
|
|
13
|
+
"auto",
|
|
14
|
+
"1m",
|
|
15
|
+
"5m",
|
|
16
|
+
"15m",
|
|
17
|
+
"30m",
|
|
18
|
+
"45m",
|
|
19
|
+
"1h",
|
|
20
|
+
"1d",
|
|
21
|
+
"1wk",
|
|
22
|
+
"1mo",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const CHART_RESOLUTION_LABELS: Record<ChartResolution, string> = {
|
|
26
|
+
auto: "AUTO",
|
|
27
|
+
"1m": "1M",
|
|
28
|
+
"5m": "5M",
|
|
29
|
+
"15m": "15M",
|
|
30
|
+
"30m": "30M",
|
|
31
|
+
"45m": "45M",
|
|
32
|
+
"1h": "1H",
|
|
33
|
+
"1d": "1D",
|
|
34
|
+
"1wk": "1W",
|
|
35
|
+
"1mo": "1MO",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const RANGE_PRESET_RESOLUTION: Record<TimeRange, ManualChartResolution> = {
|
|
39
|
+
"1D": "1m",
|
|
40
|
+
"1W": "5m",
|
|
41
|
+
"1M": "15m",
|
|
42
|
+
"3M": "1h",
|
|
43
|
+
"6M": "1d",
|
|
44
|
+
"1Y": "1d",
|
|
45
|
+
"5Y": "1wk",
|
|
46
|
+
"ALL": "1mo",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const RANGE_PRELOAD_BUFFER: Record<TimeRange, TimeRange> = {
|
|
50
|
+
"1D": "1W",
|
|
51
|
+
"1W": "1M",
|
|
52
|
+
"1M": "3M",
|
|
53
|
+
"3M": "6M",
|
|
54
|
+
"6M": "1Y",
|
|
55
|
+
"1Y": "5Y",
|
|
56
|
+
"5Y": "ALL",
|
|
57
|
+
"ALL": "ALL",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_TICKER_CHART_RANGE_PRESET: TimeRange = "5Y";
|
|
61
|
+
export const DEFAULT_TICKER_CHART_RESOLUTION: ManualChartResolution = "1wk";
|
|
62
|
+
export const DEFAULT_COMPARISON_CHART_RANGE_PRESET: TimeRange = "1Y";
|
|
63
|
+
export const DEFAULT_COMPARISON_CHART_RESOLUTION: ManualChartResolution = "1d";
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS: ManualChartResolution[] = ["1m", "5m", "15m", "1h", "1d", "1wk", "1mo"];
|
|
66
|
+
|
|
67
|
+
const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
|
|
68
|
+
const RANGE_PRESETS_BY_RESOLUTION = TIME_RANGE_ORDER.reduce<Record<ManualChartResolution, TimeRange[]>>((acc, range) => {
|
|
69
|
+
const resolution = RANGE_PRESET_RESOLUTION[range];
|
|
70
|
+
acc[resolution] ??= [];
|
|
71
|
+
acc[resolution].push(range);
|
|
72
|
+
return acc;
|
|
73
|
+
}, {
|
|
74
|
+
"1m": [],
|
|
75
|
+
"5m": [],
|
|
76
|
+
"15m": [],
|
|
77
|
+
"30m": [],
|
|
78
|
+
"45m": [],
|
|
79
|
+
"1h": [],
|
|
80
|
+
"1d": [],
|
|
81
|
+
"1wk": [],
|
|
82
|
+
"1mo": [],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function getTimeRangeIndex(range: TimeRange): number {
|
|
86
|
+
return TIME_RANGE_INDEX.get(range) ?? 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compareTimeRange(left: TimeRange, right: TimeRange): number {
|
|
90
|
+
return getTimeRangeIndex(left) - getTimeRangeIndex(right);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isChartResolution(value: unknown): value is ChartResolution {
|
|
94
|
+
return CHART_RESOLUTION_ORDER.includes(value as ChartResolution);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isManualChartResolution(value: unknown): value is ManualChartResolution {
|
|
98
|
+
return value !== "auto" && CHART_RESOLUTION_ORDER.includes(value as ChartResolution);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeChartResolution(value: unknown, fallback: ChartResolution = "auto"): ChartResolution {
|
|
102
|
+
return isChartResolution(value) ? value : fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getChartResolutionLabel(resolution: ChartResolution): string {
|
|
106
|
+
return CHART_RESOLUTION_LABELS[resolution];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getPresetResolution(range: TimeRange): ManualChartResolution {
|
|
110
|
+
return RANGE_PRESET_RESOLUTION[range];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getManualResolutionOrderIndex(resolution: ManualChartResolution): number {
|
|
114
|
+
return CHART_RESOLUTION_ORDER.indexOf(resolution);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getPresetBufferRange(range: TimeRange): TimeRange {
|
|
118
|
+
return RANGE_PRELOAD_BUFFER[range];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getNextBufferRange(range: TimeRange): TimeRange {
|
|
122
|
+
return RANGE_PRELOAD_BUFFER[range];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function sortChartResolutions<T extends ChartResolution>(resolutions: readonly T[]): T[] {
|
|
126
|
+
return [...resolutions].sort((left, right) => (
|
|
127
|
+
CHART_RESOLUTION_ORDER.indexOf(left) - CHART_RESOLUTION_ORDER.indexOf(right)
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function normalizeManualChartResolutions(resolutions: readonly string[]): ManualChartResolution[] {
|
|
132
|
+
const seen = new Set<ManualChartResolution>();
|
|
133
|
+
for (const resolution of resolutions) {
|
|
134
|
+
if (isManualChartResolution(resolution)) {
|
|
135
|
+
seen.add(resolution);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return sortChartResolutions([...seen]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function intersectChartResolutions(resolutionSets: Array<readonly string[]>): ManualChartResolution[] {
|
|
142
|
+
if (resolutionSets.length === 0) return [];
|
|
143
|
+
const [first, ...rest] = resolutionSets.map((set) => new Set(normalizeManualChartResolutions(set)));
|
|
144
|
+
return sortChartResolutions(
|
|
145
|
+
[...first].filter((resolution) => rest.every((set) => set.has(resolution))),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sortChartResolutionSupport(support: readonly ChartResolutionSupport[]): ChartResolutionSupport[] {
|
|
150
|
+
return [...support].sort((left, right) => (
|
|
151
|
+
CHART_RESOLUTION_ORDER.indexOf(left.resolution) - CHART_RESOLUTION_ORDER.indexOf(right.resolution)
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function normalizeChartResolutionSupport(support: readonly ChartResolutionSupport[]): ChartResolutionSupport[] {
|
|
156
|
+
const byResolution = new Map<ManualChartResolution, TimeRange>();
|
|
157
|
+
for (const entry of support) {
|
|
158
|
+
if (!isManualChartResolution(entry.resolution)) continue;
|
|
159
|
+
const current = byResolution.get(entry.resolution);
|
|
160
|
+
byResolution.set(
|
|
161
|
+
entry.resolution,
|
|
162
|
+
current ? (compareTimeRange(current, entry.maxRange) >= 0 ? current : entry.maxRange) : entry.maxRange,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return sortChartResolutionSupport(
|
|
166
|
+
[...byResolution.entries()].map(([resolution, maxRange]) => ({ resolution, maxRange })),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function intersectChartResolutionSupport(
|
|
171
|
+
supportSets: Array<readonly ChartResolutionSupport[]>,
|
|
172
|
+
): ChartResolutionSupport[] {
|
|
173
|
+
if (supportSets.length === 0) return [];
|
|
174
|
+
const normalized = supportSets.map((support) => normalizeChartResolutionSupport(support));
|
|
175
|
+
const first = normalized[0] ?? [];
|
|
176
|
+
return sortChartResolutionSupport(first.flatMap((entry) => {
|
|
177
|
+
let maxRange = entry.maxRange;
|
|
178
|
+
for (const set of normalized.slice(1)) {
|
|
179
|
+
const match = set.find((candidate) => candidate.resolution === entry.resolution);
|
|
180
|
+
if (!match) return [];
|
|
181
|
+
maxRange = minTimeRange(maxRange, match.maxRange);
|
|
182
|
+
}
|
|
183
|
+
return [{ resolution: entry.resolution, maxRange }];
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function buildChartResolutionSupportMap(
|
|
188
|
+
support: readonly ChartResolutionSupport[],
|
|
189
|
+
): ReadonlyMap<ManualChartResolution, TimeRange> {
|
|
190
|
+
return new Map(normalizeChartResolutionSupport(support).map((entry) => [entry.resolution, entry.maxRange] as const));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getSupportMaxRange(
|
|
194
|
+
support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
|
|
195
|
+
resolution: ManualChartResolution,
|
|
196
|
+
): TimeRange | null {
|
|
197
|
+
if (support instanceof Map) {
|
|
198
|
+
return support.get(resolution) ?? null;
|
|
199
|
+
}
|
|
200
|
+
return normalizeChartResolutionSupport(support).find((entry) => entry.resolution === resolution)?.maxRange ?? null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function minTimeRange(left: TimeRange, right: TimeRange): TimeRange {
|
|
204
|
+
return compareTimeRange(left, right) <= 0 ? left : right;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function maxTimeRange(left: TimeRange, right: TimeRange): TimeRange {
|
|
208
|
+
return compareTimeRange(left, right) >= 0 ? left : right;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function isTimeRangeAtOrBelow(candidate: TimeRange, maxRange: TimeRange): boolean {
|
|
212
|
+
return compareTimeRange(candidate, maxRange) <= 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function clampTimeRangeToMaxRange(range: TimeRange, maxRange: TimeRange): TimeRange {
|
|
216
|
+
return isTimeRangeAtOrBelow(range, maxRange) ? range : maxRange;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function clampTimeRangeForResolution(range: TimeRange, resolution: ChartResolution): TimeRange {
|
|
220
|
+
if (resolution === "auto") return range;
|
|
221
|
+
const maxRange = getSupportMaxRange(
|
|
222
|
+
normalizeChartResolutionSupport([{ resolution, maxRange: "ALL" }]),
|
|
223
|
+
resolution,
|
|
224
|
+
) ?? "ALL";
|
|
225
|
+
const intrinsicMaxRange = resolution === "1m" || resolution === "5m"
|
|
226
|
+
? "1W"
|
|
227
|
+
: resolution === "15m"
|
|
228
|
+
? "1M"
|
|
229
|
+
: resolution === "30m" || resolution === "45m" || resolution === "1h"
|
|
230
|
+
? "3M"
|
|
231
|
+
: "ALL";
|
|
232
|
+
return clampTimeRangeToMaxRange(range, minTimeRange(maxRange, intrinsicMaxRange));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function isIntradayResolution(resolution: ManualChartResolution): boolean {
|
|
236
|
+
return resolution === "1m"
|
|
237
|
+
|| resolution === "5m"
|
|
238
|
+
|| resolution === "15m"
|
|
239
|
+
|| resolution === "30m"
|
|
240
|
+
|| resolution === "45m"
|
|
241
|
+
|| resolution === "1h";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isRangePresetSupported(
|
|
245
|
+
range: TimeRange,
|
|
246
|
+
support: readonly ChartResolutionSupport[] | readonly ManualChartResolution[],
|
|
247
|
+
): boolean {
|
|
248
|
+
const resolution = RANGE_PRESET_RESOLUTION[range];
|
|
249
|
+
if (support.length === 0) return false;
|
|
250
|
+
if (typeof support[0] === "string") {
|
|
251
|
+
return (support as readonly ManualChartResolution[]).includes(resolution);
|
|
252
|
+
}
|
|
253
|
+
const maxRange = getSupportMaxRange(support as readonly ChartResolutionSupport[], resolution);
|
|
254
|
+
return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function getActiveRangePreset(
|
|
258
|
+
range: TimeRange,
|
|
259
|
+
resolution: ChartResolution,
|
|
260
|
+
zoomLevel: number,
|
|
261
|
+
panOffset: number,
|
|
262
|
+
): TimeRange | null {
|
|
263
|
+
if (zoomLevel !== 1 || panOffset !== 0) return null;
|
|
264
|
+
return RANGE_PRESET_RESOLUTION[range] === resolution ? range : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function getWidestPresetForResolution(
|
|
268
|
+
resolution: ManualChartResolution,
|
|
269
|
+
maxRange: TimeRange,
|
|
270
|
+
): TimeRange {
|
|
271
|
+
const presets = RANGE_PRESETS_BY_RESOLUTION[resolution] ?? [];
|
|
272
|
+
const supported = presets.filter((range) => isTimeRangeAtOrBelow(range, maxRange));
|
|
273
|
+
if (supported.length > 0) {
|
|
274
|
+
return supported[supported.length - 1]!;
|
|
275
|
+
}
|
|
276
|
+
const fallback = TIME_RANGE_ORDER.filter((range) => isTimeRangeAtOrBelow(range, maxRange));
|
|
277
|
+
return fallback[fallback.length - 1] ?? "1D";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function getBestSupportedResolutionForPreset(
|
|
281
|
+
range: TimeRange,
|
|
282
|
+
support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
|
|
283
|
+
preferredResolution = getPresetResolution(range),
|
|
284
|
+
): ManualChartResolution | null {
|
|
285
|
+
const supportedResolutions = CHART_RESOLUTION_ORDER
|
|
286
|
+
.filter((resolution): resolution is ManualChartResolution => resolution !== "auto")
|
|
287
|
+
.filter((resolution) => {
|
|
288
|
+
const maxRange = getSupportMaxRange(support, resolution);
|
|
289
|
+
return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (supportedResolutions.length === 0) return null;
|
|
293
|
+
if (supportedResolutions.includes(preferredResolution)) return preferredResolution;
|
|
294
|
+
|
|
295
|
+
const preferredIndex = getManualResolutionOrderIndex(preferredResolution);
|
|
296
|
+
const coarserResolution = supportedResolutions.find((resolution) => (
|
|
297
|
+
getManualResolutionOrderIndex(resolution) > preferredIndex
|
|
298
|
+
));
|
|
299
|
+
if (coarserResolution) return coarserResolution;
|
|
300
|
+
|
|
301
|
+
return supportedResolutions[supportedResolutions.length - 1] ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function getNextFallbackResolution(
|
|
305
|
+
range: TimeRange,
|
|
306
|
+
currentResolution: ManualChartResolution,
|
|
307
|
+
support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
|
|
308
|
+
): ChartResolution | null {
|
|
309
|
+
const currentIndex = getManualResolutionOrderIndex(currentResolution);
|
|
310
|
+
const nextResolution = CHART_RESOLUTION_ORDER
|
|
311
|
+
.filter((resolution): resolution is ManualChartResolution => resolution !== "auto")
|
|
312
|
+
.find((resolution) => (
|
|
313
|
+
getManualResolutionOrderIndex(resolution) > currentIndex
|
|
314
|
+
&& (() => {
|
|
315
|
+
const maxRange = getSupportMaxRange(support, resolution);
|
|
316
|
+
return maxRange !== null && isTimeRangeAtOrBelow(range, maxRange);
|
|
317
|
+
})()
|
|
318
|
+
));
|
|
319
|
+
|
|
320
|
+
return nextResolution ?? "auto";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getCompatibleBufferRange(presetRange: TimeRange, maxRange: TimeRange | null): TimeRange {
|
|
324
|
+
const preloadRange = getPresetBufferRange(presetRange);
|
|
325
|
+
return maxRange ? clampTimeRangeToMaxRange(preloadRange, maxRange) : preloadRange;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function subtractTimeRange(endDate: Date, range: TimeRange): Date {
|
|
329
|
+
const startDate = new Date(endDate);
|
|
330
|
+
switch (range) {
|
|
331
|
+
case "1D":
|
|
332
|
+
startDate.setDate(startDate.getDate() - 1);
|
|
333
|
+
break;
|
|
334
|
+
case "1W":
|
|
335
|
+
startDate.setDate(startDate.getDate() - 7);
|
|
336
|
+
break;
|
|
337
|
+
case "1M":
|
|
338
|
+
startDate.setMonth(startDate.getMonth() - 1);
|
|
339
|
+
break;
|
|
340
|
+
case "3M":
|
|
341
|
+
startDate.setMonth(startDate.getMonth() - 3);
|
|
342
|
+
break;
|
|
343
|
+
case "6M":
|
|
344
|
+
startDate.setMonth(startDate.getMonth() - 6);
|
|
345
|
+
break;
|
|
346
|
+
case "1Y":
|
|
347
|
+
startDate.setFullYear(startDate.getFullYear() - 1);
|
|
348
|
+
break;
|
|
349
|
+
case "5Y":
|
|
350
|
+
startDate.setFullYear(startDate.getFullYear() - 5);
|
|
351
|
+
break;
|
|
352
|
+
case "ALL":
|
|
353
|
+
startDate.setFullYear(startDate.getFullYear() - 50);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
return startDate;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function isDateWindowWithinTimeRange(startDate: Date, endDate: Date, maxRange: TimeRange): boolean {
|
|
360
|
+
if (maxRange === "ALL") return true;
|
|
361
|
+
return startDate.getTime() >= subtractTimeRange(endDate, maxRange).getTime();
|
|
362
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { PricePoint } from "../../types/financials";
|
|
2
2
|
|
|
3
|
-
export type TimeRange = "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
|
|
3
|
+
export type TimeRange = "1D" | "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
|
|
4
|
+
export type ChartResolution = "auto" | "1m" | "5m" | "15m" | "30m" | "45m" | "1h" | "1d" | "1wk" | "1mo";
|
|
4
5
|
export type ChartRenderMode = "area" | "line" | "candles" | "ohlc";
|
|
5
6
|
export type ChartAxisMode = "price" | "percent";
|
|
6
7
|
export type ChartRendererPreference = "auto" | "kitty" | "braille";
|
|
7
8
|
export type ResolvedChartRenderer = "kitty" | "braille";
|
|
8
9
|
export type ComparisonChartRenderMode = "area" | "line";
|
|
9
10
|
|
|
10
|
-
export const TIME_RANGES: TimeRange[] = ["1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
11
|
+
export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
11
12
|
export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc"];
|
|
12
13
|
export const CHART_AXIS_MODES: ChartAxisMode[] = ["price", "percent"];
|
|
13
14
|
export const CHART_RENDERER_PREFERENCES: ChartRendererPreference[] = ["auto", "kitty", "braille"];
|
|
@@ -15,6 +16,7 @@ export const COMPARISON_RENDER_MODES: ComparisonChartRenderMode[] = ["area", "li
|
|
|
15
16
|
|
|
16
17
|
/** Number of trading days for each time range */
|
|
17
18
|
export const RANGE_DAYS: Record<TimeRange, number> = {
|
|
19
|
+
"1D": 1,
|
|
18
20
|
"1W": 5,
|
|
19
21
|
"1M": 21,
|
|
20
22
|
"3M": 63,
|
|
@@ -25,7 +27,10 @@ export const RANGE_DAYS: Record<TimeRange, number> = {
|
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export interface ChartViewState {
|
|
28
|
-
|
|
30
|
+
presetRange: TimeRange;
|
|
31
|
+
bufferRange: TimeRange;
|
|
32
|
+
activePreset: TimeRange | null;
|
|
33
|
+
resolution: ChartResolution;
|
|
29
34
|
panOffset: number; // data points shifted left from right edge (0 = most recent)
|
|
30
35
|
zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
|
|
31
36
|
cursorX: number | null; // local plot x in cell units; may be fractional with pixel mouse
|
|
@@ -72,7 +77,10 @@ export interface ComparisonChartSeries {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export interface ComparisonChartViewState {
|
|
75
|
-
|
|
80
|
+
presetRange: TimeRange;
|
|
81
|
+
bufferRange: TimeRange;
|
|
82
|
+
activePreset: TimeRange | null;
|
|
83
|
+
resolution: ChartResolution;
|
|
76
84
|
panOffset: number;
|
|
77
85
|
zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
|
|
78
86
|
cursorX: number | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { RIGHT_EDGE_ANCHOR_RATIO, resolveAnchoredChartZoom } from "./chart-viewport";
|
|
3
|
+
|
|
4
|
+
describe("resolveAnchoredChartZoom", () => {
|
|
5
|
+
test("keeps keyboard zoom pinned to the latest data when anchored to the right edge", () => {
|
|
6
|
+
expect(resolveAnchoredChartZoom(252, 1, 0, 2, RIGHT_EDGE_ANCHOR_RATIO)).toEqual({
|
|
7
|
+
zoomLevel: 2,
|
|
8
|
+
panOffset: 0,
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("preserves the current right edge when zooming a panned view", () => {
|
|
13
|
+
expect(resolveAnchoredChartZoom(252, 2, 20, 3, RIGHT_EDGE_ANCHOR_RATIO)).toEqual({
|
|
14
|
+
zoomLevel: 3,
|
|
15
|
+
panOffset: 20,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("still supports center-anchored zoom for pointer-driven interactions", () => {
|
|
20
|
+
expect(resolveAnchoredChartZoom(252, 1, 0, 2, 0.5)).toEqual({
|
|
21
|
+
zoomLevel: 2,
|
|
22
|
+
panOffset: 63,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const MIN_VISIBLE_POINTS = 10;
|
|
2
|
+
export const RIGHT_EDGE_ANCHOR_RATIO = 1;
|
|
2
3
|
|
|
3
4
|
function clamp(value: number, min: number, max: number): number {
|
|
4
5
|
return Math.max(min, Math.min(max, value));
|
|
@@ -19,3 +20,30 @@ export function getVisiblePointCount(totalPoints: number, zoomLevel: number): nu
|
|
|
19
20
|
const clampedZoom = clampChartZoom(totalPoints, zoomLevel);
|
|
20
21
|
return Math.min(totalPoints, Math.max(Math.floor(totalPoints / clampedZoom), MIN_VISIBLE_POINTS));
|
|
21
22
|
}
|
|
23
|
+
|
|
24
|
+
export function resolveAnchoredChartZoom(
|
|
25
|
+
totalPoints: number,
|
|
26
|
+
currentZoomLevel: number,
|
|
27
|
+
currentPanOffset: number,
|
|
28
|
+
nextZoomLevel: number,
|
|
29
|
+
anchorRatio: number,
|
|
30
|
+
): { zoomLevel: number; panOffset: number } {
|
|
31
|
+
if (totalPoints <= 0) {
|
|
32
|
+
return { zoomLevel: 1, panOffset: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const clampedZoom = clampChartZoom(totalPoints, nextZoomLevel);
|
|
36
|
+
const currentVisibleCount = getVisiblePointCount(totalPoints, currentZoomLevel);
|
|
37
|
+
const nextVisibleCount = getVisiblePointCount(totalPoints, clampedZoom);
|
|
38
|
+
const currentPan = clamp(currentPanOffset, 0, Math.max(totalPoints - currentVisibleCount, 0));
|
|
39
|
+
const ratio = clamp(anchorRatio, 0, 1);
|
|
40
|
+
const anchorIndex = totalPoints - currentPan - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
|
|
41
|
+
const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
|
|
42
|
+
const clampedStart = clamp(nextStart, 0, Math.max(totalPoints - nextVisibleCount, 0));
|
|
43
|
+
const nextPanOffset = totalPoints - nextVisibleCount - clampedStart;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
zoomLevel: clampedZoom,
|
|
47
|
+
panOffset: clamp(nextPanOffset, 0, Math.max(totalPoints - nextVisibleCount, 0)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
TimeRange,
|
|
8
8
|
} from "./chart-types";
|
|
9
9
|
import { RANGE_DAYS } from "./chart-types";
|
|
10
|
-
import {
|
|
10
|
+
import { getVisiblePointCount, resolveAnchoredChartZoom } from "./chart-viewport";
|
|
11
11
|
|
|
12
12
|
export interface ComparisonProjectedPoint {
|
|
13
13
|
date: Date;
|
|
@@ -97,23 +97,21 @@ function getUniqueSortedDates(series: ComparisonChartSeries[]): Date[] {
|
|
|
97
97
|
|
|
98
98
|
export function getMaxComparisonPanOffset(
|
|
99
99
|
series: ComparisonChartSeries[],
|
|
100
|
-
|
|
100
|
+
_timeRange: TimeRange,
|
|
101
101
|
zoomLevel: number,
|
|
102
102
|
chartWidth: number,
|
|
103
103
|
): number {
|
|
104
|
-
const
|
|
105
|
-
const dates = getUniqueSortedDates(filtered);
|
|
104
|
+
const dates = getUniqueSortedDates(series);
|
|
106
105
|
const visibleCount = getVisiblePointCount(dates.length, zoomLevel);
|
|
107
106
|
return Math.max(dates.length - visibleCount, 0);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
export function getVisibleComparisonWindow(
|
|
111
110
|
series: ComparisonChartSeries[],
|
|
112
|
-
viewState: Pick<ComparisonChartViewState, "
|
|
111
|
+
viewState: Pick<ComparisonChartViewState, "panOffset" | "zoomLevel">,
|
|
113
112
|
chartWidth: number,
|
|
114
113
|
): ComparisonVisibleWindow {
|
|
115
|
-
const
|
|
116
|
-
const dates = getUniqueSortedDates(filtered);
|
|
114
|
+
const dates = getUniqueSortedDates(series);
|
|
117
115
|
|
|
118
116
|
if (dates.length === 0) {
|
|
119
117
|
return { dates: [], startIdx: 0, endIdx: 0, totalDates: 0 };
|
|
@@ -138,26 +136,21 @@ export function applyComparisonZoomAroundAnchor(
|
|
|
138
136
|
nextZoomLevel: number,
|
|
139
137
|
anchorRatio: number,
|
|
140
138
|
series: ComparisonChartSeries[],
|
|
141
|
-
chartWidth: number,
|
|
142
139
|
): ComparisonChartViewState {
|
|
143
|
-
const
|
|
144
|
-
const dates = getUniqueSortedDates(filtered);
|
|
140
|
+
const dates = getUniqueSortedDates(series);
|
|
145
141
|
if (dates.length === 0) return view;
|
|
146
142
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const clampedStart = clamp(nextStart, 0, Math.max(dates.length - nextVisibleCount, 0));
|
|
155
|
-
const nextPanOffset = dates.length - nextVisibleCount - clampedStart;
|
|
143
|
+
const nextZoom = resolveAnchoredChartZoom(
|
|
144
|
+
dates.length,
|
|
145
|
+
view.zoomLevel,
|
|
146
|
+
view.panOffset,
|
|
147
|
+
nextZoomLevel,
|
|
148
|
+
anchorRatio,
|
|
149
|
+
);
|
|
156
150
|
|
|
157
151
|
return {
|
|
158
152
|
...view,
|
|
159
|
-
|
|
160
|
-
panOffset: clamp(nextPanOffset, 0, Math.max(dates.length - nextVisibleCount, 0)),
|
|
153
|
+
...nextZoom,
|
|
161
154
|
};
|
|
162
155
|
}
|
|
163
156
|
|
|
@@ -231,17 +224,17 @@ export function resolveComparisonAxisMode(
|
|
|
231
224
|
export function projectComparisonChartData(
|
|
232
225
|
series: ComparisonChartSeries[],
|
|
233
226
|
chartWidth: number,
|
|
234
|
-
viewState: Pick<ComparisonChartViewState, "
|
|
227
|
+
viewState: Pick<ComparisonChartViewState, "panOffset" | "zoomLevel" | "renderMode">,
|
|
235
228
|
requestedAxisMode: ChartAxisMode,
|
|
236
229
|
): ComparisonChartProjection {
|
|
237
|
-
const
|
|
238
|
-
const window = getVisibleComparisonWindow(
|
|
239
|
-
const axisResolution = resolveComparisonAxisMode(requestedAxisMode,
|
|
230
|
+
const normalizedSeries = series.map((entry) => ({ ...entry, points: normalizeSeriesPoints(entry.points) }));
|
|
231
|
+
const window = getVisibleComparisonWindow(normalizedSeries, viewState, chartWidth);
|
|
232
|
+
const axisResolution = resolveComparisonAxisMode(requestedAxisMode, normalizedSeries, window.dates);
|
|
240
233
|
const requestedMode = normalizeComparisonMode(viewState.renderMode);
|
|
241
234
|
if (window.dates.length === 0) {
|
|
242
235
|
return {
|
|
243
236
|
dates: [],
|
|
244
|
-
series:
|
|
237
|
+
series: normalizedSeries.map((entry) => ({
|
|
245
238
|
symbol: entry.symbol,
|
|
246
239
|
color: entry.color,
|
|
247
240
|
fillColor: entry.fillColor,
|
|
@@ -261,7 +254,7 @@ export function projectComparisonChartData(
|
|
|
261
254
|
const bucketCount = Math.min(Math.max(chartWidth, 1), Math.max(window.dates.length, 1));
|
|
262
255
|
const bucketSize = window.dates.length > 0 ? window.dates.length / bucketCount : 1;
|
|
263
256
|
|
|
264
|
-
const projectedSeries =
|
|
257
|
+
const projectedSeries = normalizedSeries.map((entry) => {
|
|
265
258
|
const rawByDate = buildSeriesMap(entry.points);
|
|
266
259
|
const visibleRawValues = window.dates
|
|
267
260
|
.map((date) => rawByDate.get(date.getTime()) ?? null)
|