@sybilion/uilib 1.3.22 → 1.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +139 -0
  2. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.js +7 -0
  3. package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +159 -0
  4. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.js +34 -0
  5. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.js +7 -0
  6. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.constants.js +17 -0
  7. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.js +807 -0
  8. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.styl.js +7 -0
  9. package/dist/esm/components/widgets/PerformanceChart/PerformanceTable.js +130 -0
  10. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.js +20 -0
  11. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.js +7 -0
  12. package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +591 -0
  13. package/dist/esm/components/widgets/PerformanceChart/performanceChartUserSeries.js +109 -0
  14. package/dist/esm/index.js +6 -0
  15. package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +18 -0
  16. package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +26 -0
  17. package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -0
  18. package/dist/esm/types/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.d.ts +7 -0
  19. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.constants.d.ts +3 -0
  20. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.d.ts +54 -0
  21. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceTable.d.ts +31 -0
  22. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.d.ts +20 -0
  23. package/dist/esm/types/src/components/widgets/PerformanceChart/index.d.ts +4 -0
  24. package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts +212 -0
  25. package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChartUserSeries.d.ts +20 -0
  26. package/dist/esm/types/src/docs/pages/DriversComparisonChartPage.d.ts +1 -0
  27. package/dist/esm/types/src/docs/pages/PerformanceChartPage.d.ts +1 -0
  28. package/dist/esm/types/src/index.d.ts +2 -0
  29. package/dist/esm/utils/chartConnectionPoint.js +9 -1
  30. package/package.json +1 -1
  31. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl +145 -0
  32. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.d.ts +29 -0
  33. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +325 -0
  34. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +206 -0
  35. package/src/components/widgets/DriversComparisonChart/index.ts +13 -0
  36. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl +25 -0
  37. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.d.ts +11 -0
  38. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.tsx +67 -0
  39. package/src/components/widgets/PerformanceChart/PerformanceChart.constants.ts +17 -0
  40. package/src/components/widgets/PerformanceChart/PerformanceChart.styl +194 -0
  41. package/src/components/widgets/PerformanceChart/PerformanceChart.styl.d.ts +30 -0
  42. package/src/components/widgets/PerformanceChart/PerformanceChart.tsx +1251 -0
  43. package/src/components/widgets/PerformanceChart/PerformanceTable.tsx +381 -0
  44. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl +49 -0
  45. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.d.ts +12 -0
  46. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.tsx +83 -0
  47. package/src/components/widgets/PerformanceChart/index.ts +28 -0
  48. package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +790 -0
  49. package/src/components/widgets/PerformanceChart/performanceChartUserSeries.ts +149 -0
  50. package/src/docs/pages/DriversComparisonChartPage.tsx +174 -0
  51. package/src/docs/pages/PerformanceChartPage.tsx +211 -0
  52. package/src/docs/registry.ts +12 -0
  53. package/src/index.ts +2 -0
@@ -0,0 +1,325 @@
1
+ import cn from 'classnames';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+
4
+ import { ChartEmptyState } from '#uilib/components/ui/Chart/components/ChartEmptyState/ChartEmptyState';
5
+ import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
6
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
7
+ import {
8
+ type ForecastItemData,
9
+ getForecastColor,
10
+ } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
11
+ import { PageXScroll } from '#uilib/components/ui/Page';
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from '#uilib/components/ui/Table';
20
+ import { TextShimmer } from '#uilib/components/ui/TextShimmer/TextShimmer';
21
+ import { TIME_RANGES } from '#uilib/components/ui/TimeRangeControls/TimeRangeControls.constants';
22
+ import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
23
+
24
+ import S from './DriversComparisonChart.styl';
25
+ import {
26
+ DRIVER_FORECAST_ID_BASE,
27
+ INITIAL_VISIBLE_SERIES_COUNT,
28
+ buildDriversComparisonChartData,
29
+ formatSeriesImportance,
30
+ mergeBacktestsChartData,
31
+ mergeDatasetHistoricalWithBacktestsChartData,
32
+ } from './driversComparisonChart.helpers';
33
+
34
+ const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
35
+
36
+ function toForecastDataKey(id: number | string): string {
37
+ const s = String(id);
38
+ return s.startsWith('forecast_') ? s : `forecast_${id}`;
39
+ }
40
+
41
+ export type DriversComparisonChartProps = {
42
+ payload: BacktestsComponentPayload | null;
43
+ datasetHistorical?: ChartDataPoint[];
44
+ loading?: boolean;
45
+ chartLoading?: boolean;
46
+ statusHint?: string | null;
47
+ statusTone?: 'destructive' | 'muted';
48
+ runAnalysisHint?: boolean;
49
+ timeRange?: string;
50
+ onTimeRangeChange?: (range: string) => void;
51
+ isDarkTheme?: boolean;
52
+ className?: string;
53
+ /** Resets visible series when this key changes (e.g. selected analysis id). */
54
+ seriesInitKey?: string;
55
+ };
56
+
57
+ export function DriversComparisonChart({
58
+ payload,
59
+ datasetHistorical = [],
60
+ loading = false,
61
+ chartLoading = false,
62
+ statusHint = null,
63
+ statusTone = 'muted',
64
+ runAnalysisHint = false,
65
+ timeRange: timeRangeProp,
66
+ onTimeRangeChange,
67
+ isDarkTheme = false,
68
+ className,
69
+ seriesInitKey,
70
+ }: DriversComparisonChartProps) {
71
+ const [internalTimeRange, setInternalTimeRange] =
72
+ useState<string>(ALL_TIME_RANGE);
73
+ const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
74
+
75
+ const timeRange = timeRangeProp ?? internalTimeRange;
76
+ const handleTimeRangeChange = useCallback(
77
+ (val: string) => {
78
+ if (!val) return;
79
+ onTimeRangeChange?.(val);
80
+ if (timeRangeProp === undefined) {
81
+ setInternalTimeRange(val);
82
+ }
83
+ },
84
+ [onTimeRangeChange, timeRangeProp],
85
+ );
86
+
87
+ const toggleSeries = useCallback((id: number | string) => {
88
+ const key = toForecastDataKey(id);
89
+ setHiddenSeries(prev => {
90
+ const next = new Set(prev);
91
+ if (next.has(key)) next.delete(key);
92
+ else next.add(key);
93
+ return next;
94
+ });
95
+ }, []);
96
+
97
+ const showSeries = useCallback((id: number | string) => {
98
+ const key = toForecastDataKey(id);
99
+ setHiddenSeries(prev => {
100
+ if (!prev.has(key)) return prev;
101
+ const next = new Set(prev);
102
+ next.delete(key);
103
+ return next;
104
+ });
105
+ }, []);
106
+
107
+ const sortedDriversWithData = useMemo(() => {
108
+ const driversList = payload?.drivers ?? [];
109
+ if (!driversList.length) return [];
110
+ return [...driversList]
111
+ .filter(
112
+ d =>
113
+ d.normalized_series &&
114
+ Object.keys(d.normalized_series).some(
115
+ key => d.normalized_series![key] != null,
116
+ ),
117
+ )
118
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
119
+ }, [payload?.drivers]);
120
+
121
+ const mergedChartData = useMemo(
122
+ () => mergeBacktestsChartData(payload),
123
+ [payload],
124
+ );
125
+
126
+ const mergedWithHistorical = useMemo(
127
+ () =>
128
+ mergeDatasetHistoricalWithBacktestsChartData(
129
+ datasetHistorical,
130
+ mergedChartData,
131
+ ),
132
+ [datasetHistorical, mergedChartData],
133
+ );
134
+
135
+ const chartForecastData = useMemo((): ForecastItemData[] => {
136
+ if (!payload?.target?.normalized_series) return [];
137
+ return sortedDriversWithData.map((driver, idx) => ({
138
+ id: DRIVER_FORECAST_ID_BASE + idx,
139
+ name: driver.name || String(driver.id),
140
+ color: getForecastColor(idx + 1),
141
+ }));
142
+ }, [payload?.target?.normalized_series, sortedDriversWithData]);
143
+
144
+ const tableSeriesRows = useMemo(() => {
145
+ if (sortedDriversWithData.length === 0) return [];
146
+ return sortedDriversWithData.map((driver, idx) => {
147
+ const raw = driver.importance;
148
+ const importance =
149
+ typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
150
+ return {
151
+ id: DRIVER_FORECAST_ID_BASE + idx,
152
+ name: driver.name || String(driver.id),
153
+ color: getForecastColor(idx + 1),
154
+ importance,
155
+ lag: driver.lag,
156
+ };
157
+ });
158
+ }, [sortedDriversWithData]);
159
+
160
+ const seriesInitKeyResolved =
161
+ seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
162
+
163
+ const backtestsSeriesInitKeyRef = useRef<string>('');
164
+
165
+ useEffect(() => {
166
+ if (tableSeriesRows.length === 0) return;
167
+ const initKey = seriesInitKeyResolved;
168
+ if (backtestsSeriesInitKeyRef.current === initKey) return;
169
+ backtestsSeriesInitKeyRef.current = initKey;
170
+
171
+ setHiddenSeries(() => {
172
+ const next = new Set<string>();
173
+ tableSeriesRows.forEach((row, idx) => {
174
+ const key = toForecastDataKey(row.id);
175
+ if (idx >= INITIAL_VISIBLE_SERIES_COUNT) {
176
+ next.add(key);
177
+ }
178
+ });
179
+ return next;
180
+ });
181
+ }, [seriesInitKeyResolved, tableSeriesRows]);
182
+
183
+ const driversComparisonChartData = useMemo(
184
+ () =>
185
+ buildDriversComparisonChartData(
186
+ mergedWithHistorical,
187
+ datasetHistorical,
188
+ chartForecastData.map(f => f.id),
189
+ ),
190
+ [chartForecastData, datasetHistorical, mergedWithHistorical],
191
+ );
192
+
193
+ const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
194
+
195
+ return (
196
+ <div className={cn(S.root, className)}>
197
+ <div className={cn(S.chartShell, loading && S.chartShellLoading)}>
198
+ <div className={S.chartSlot}>
199
+ <div className={S.chartWithOverlay}>
200
+ <div
201
+ className={cn(
202
+ S.chartInteractiveLayer,
203
+ showEmptyOverlay && S.chartInteractiveDimmed,
204
+ )}
205
+ >
206
+ <ChartAreaInteractive
207
+ disableHistoricalAnimation
208
+ disableTimeRangeSelector
209
+ enableTimeRangeBrush
210
+ chartContainerClassName={S.chartContainer}
211
+ chartData={driversComparisonChartData}
212
+ forecastData={chartForecastData}
213
+ timeRange={timeRange}
214
+ onTimeRangeChange={handleTimeRangeChange}
215
+ pinMonth={undefined}
216
+ onPinMonthChange={() => {}}
217
+ isDarkTheme={isDarkTheme}
218
+ loading={chartLoading}
219
+ hasCombinedData={mergedWithHistorical.length > 0}
220
+ forecastLineStyle="solid"
221
+ showLegend={false}
222
+ hiddenSeries={hiddenSeries}
223
+ toggleLegendSeries={toggleSeries}
224
+ ensureAnalysisSeriesVisible={showSeries}
225
+ />
226
+ </div>
227
+ {showEmptyOverlay && (
228
+ <div
229
+ className={S.chartEmptyOverlay}
230
+ role="status"
231
+ aria-live="polite"
232
+ >
233
+ <div className={S.chartEmptyBlurb}>
234
+ <ChartEmptyState
235
+ variant="inline"
236
+ hint={
237
+ runAnalysisHint
238
+ ? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
239
+ : undefined
240
+ }
241
+ status={statusHint ?? undefined}
242
+ statusTone={statusTone}
243
+ />
244
+ </div>
245
+ </div>
246
+ )}
247
+ </div>
248
+ </div>
249
+ {loading && (
250
+ <div className={S.loadingLayer} aria-busy="true" aria-live="polite">
251
+ <div className={S.loadingMessage}>
252
+ <TextShimmer as="span" className={S.loadingText}>
253
+ Loading drivers comparison…
254
+ </TextShimmer>
255
+ </div>
256
+ </div>
257
+ )}
258
+ </div>
259
+
260
+ <div className={S.seriesSection}>
261
+ {tableSeriesRows.length === 0 ? (
262
+ <div className={S.seriesEmptyWrap}>
263
+ <div className={S.chartEmptyBlurb}>
264
+ <ChartEmptyState
265
+ variant="inline"
266
+ align="center"
267
+ status="No series"
268
+ />
269
+ </div>
270
+ </div>
271
+ ) : (
272
+ <div className={S.seriesTableWrapper}>
273
+ <PageXScroll
274
+ size="md"
275
+ fullWidth
276
+ innerClassName={S.seriesTableContainer}
277
+ scrollbarClassName={S.seriesScrollbar}
278
+ >
279
+ <Table withBackground withPaddings className={S.seriesTable}>
280
+ <TableHeader>
281
+ <TableRow>
282
+ <TableHead className={S.seriesColSeries}>
283
+ Driver name
284
+ </TableHead>
285
+ <TableHead>Importance</TableHead>
286
+ <TableHead>Lag</TableHead>
287
+ </TableRow>
288
+ </TableHeader>
289
+ <TableBody>
290
+ {tableSeriesRows.map(row => {
291
+ const dataKey = toForecastDataKey(row.id);
292
+ const hidden = hiddenSeries.has(dataKey);
293
+ return (
294
+ <TableRow
295
+ key={row.id}
296
+ className={cn(hidden && S.rowHidden)}
297
+ onClick={() => toggleSeries(row.id)}
298
+ >
299
+ <TableCell>
300
+ <span className={S.seriesLabel}>
301
+ <span
302
+ className={S.colorSwatch}
303
+ style={{
304
+ backgroundColor: row.color,
305
+ }}
306
+ />
307
+ {row.name ?? String(row.id)}
308
+ </span>
309
+ </TableCell>
310
+ <TableCell>
311
+ {formatSeriesImportance(row.importance)}
312
+ </TableCell>
313
+ <TableCell>{row.lag ?? '—'}</TableCell>
314
+ </TableRow>
315
+ );
316
+ })}
317
+ </TableBody>
318
+ </Table>
319
+ </PageXScroll>
320
+ </div>
321
+ )}
322
+ </div>
323
+ </div>
324
+ );
325
+ }
@@ -0,0 +1,206 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+ import {
3
+ getNextMonth,
4
+ getPreviousMonth,
5
+ normalizeToMonthStart,
6
+ } from '#uilib/utils/chartConnectionPoint';
7
+ import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
8
+
9
+ export const DRIVER_FORECAST_ID_BASE = 8_000_000;
10
+ export const INITIAL_VISIBLE_SERIES_COUNT = 3;
11
+ /** Months of historical context before the earliest forecast month (inclusive). */
12
+ export const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
13
+
14
+ /** Ignore leading months where drivers are numerically zero (API often fills 0; lines look empty until real signal). */
15
+ const DRIVER_FORECAST_NONZERO_EPS = 1e-9;
16
+
17
+ export function formatSeriesImportance(value: number | null): string {
18
+ if (value === null) return '—';
19
+ return `${value.toFixed(1)}%`;
20
+ }
21
+
22
+ export function mergeBacktestsChartData(
23
+ payload: BacktestsComponentPayload | null,
24
+ ): ChartDataPoint[] {
25
+ if (!payload?.target?.normalized_series) return [];
26
+
27
+ const norm = (d: string) => normalizeToMonthStart(d);
28
+ const map = new Map<string, ChartDataPoint>();
29
+
30
+ const targetSeries = payload.target.normalized_series;
31
+ Object.entries(targetSeries).forEach(([dateStr, val]) => {
32
+ if (val === null || val === undefined) return;
33
+ const k = norm(dateStr);
34
+ const existing = map.get(k) ?? { date: k };
35
+ existing.historical = val;
36
+ map.set(k, existing);
37
+ });
38
+
39
+ if (payload.drivers?.length) {
40
+ const sorted = [...payload.drivers]
41
+ .filter(
42
+ d =>
43
+ d.normalized_series &&
44
+ Object.keys(d.normalized_series).some(
45
+ key => d.normalized_series![key] != null,
46
+ ),
47
+ )
48
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
49
+
50
+ sorted.forEach((driver, idx) => {
51
+ const fid = DRIVER_FORECAST_ID_BASE + idx;
52
+ const series = driver.normalized_series!;
53
+ Object.entries(series).forEach(([dateStr, val]) => {
54
+ if (val === null || val === undefined) return;
55
+ const k = norm(dateStr);
56
+ const existing = map.get(k) ?? { date: k };
57
+ existing[`forecast_${fid}`] = val;
58
+ map.set(k, existing);
59
+ });
60
+ });
61
+ }
62
+
63
+ return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
64
+ }
65
+
66
+ /** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
67
+ export function mergeDatasetHistoricalWithBacktestsChartData(
68
+ datasetHistorical: ChartDataPoint[],
69
+ backtestsMerged: ChartDataPoint[],
70
+ ): ChartDataPoint[] {
71
+ if (backtestsMerged.length === 0) {
72
+ if (datasetHistorical.length === 0) return [];
73
+ return datasetHistorical
74
+ .map(p => {
75
+ const k = normalizeToMonthStart(p.date);
76
+ const histVal = p.historical;
77
+ return {
78
+ date: k,
79
+ ...(typeof histVal === 'number' ? { historical: histVal } : {}),
80
+ };
81
+ })
82
+ .sort((a, b) => a.date.localeCompare(b.date));
83
+ }
84
+ return backtestsMerged;
85
+ }
86
+
87
+ /** Lower-median of ISO month strings (YYYY-MM-01 sorts lexicographically). */
88
+ function medianSortedIsoMonth(months: string[]): string {
89
+ const s = [...months].sort((a, b) => a.localeCompare(b));
90
+ return s[Math.floor((s.length - 1) / 2)];
91
+ }
92
+
93
+ /**
94
+ * For each driver, first month with materially non-zero value; anchor = median of those months.
95
+ * (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
96
+ */
97
+ export function getZoomAnchorMonthFromDriverMaterialStarts(
98
+ points: ChartDataPoint[],
99
+ forecastIds: number[],
100
+ ): { anchor: string | null; perDriverFirstMaterial: string[] } {
101
+ const perDriverFirstMaterial: string[] = [];
102
+ for (const id of forecastIds) {
103
+ let minForId: string | null = null;
104
+ for (const p of points) {
105
+ const v = p[`forecast_${id}`];
106
+ if (
107
+ typeof v !== 'number' ||
108
+ !Number.isFinite(v) ||
109
+ Math.abs(v) <= DRIVER_FORECAST_NONZERO_EPS
110
+ ) {
111
+ continue;
112
+ }
113
+ const d = normalizeToMonthStart(p.date);
114
+ if (minForId === null || d.localeCompare(minForId) < 0) minForId = d;
115
+ }
116
+ if (minForId === null) {
117
+ return { anchor: null, perDriverFirstMaterial };
118
+ }
119
+ perDriverFirstMaterial.push(minForId);
120
+ }
121
+ const anchor = medianSortedIsoMonth(perDriverFirstMaterial);
122
+ return { anchor, perDriverFirstMaterial };
123
+ }
124
+
125
+ export function subtractMonthsFromMonthStart(
126
+ date: string,
127
+ count: number,
128
+ ): string {
129
+ let result = date;
130
+ for (let i = 0; i < count; i++) {
131
+ result = getPreviousMonth(result);
132
+ }
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Backtests payload often starts at the same month for target + drivers, so xMin falls
138
+ * in a range with no rows. Prepend historical-only months from raw dataset series
139
+ * (scaled to the first normalized point) so the lead-in shows the target line alone.
140
+ */
141
+ export function prependHistoricalLeadFromDataset(
142
+ points: ChartDataPoint[],
143
+ datasetHistorical: ChartDataPoint[],
144
+ xMin: string,
145
+ ): ChartDataPoint[] {
146
+ if (points.length === 0) return points;
147
+ const firstMonth = normalizeToMonthStart(points[0].date);
148
+ if (xMin.localeCompare(firstMonth) >= 0) return points;
149
+
150
+ const rawByMonth = new Map<string, number>();
151
+ for (const p of datasetHistorical) {
152
+ const k = normalizeToMonthStart(p.date);
153
+ const v = p.historical;
154
+ if (typeof v === 'number' && Number.isFinite(v)) rawByMonth.set(k, v);
155
+ }
156
+
157
+ const histFirst = points[0].historical;
158
+ const rawFirst = rawByMonth.get(firstMonth);
159
+ if (
160
+ typeof histFirst !== 'number' ||
161
+ !Number.isFinite(histFirst) ||
162
+ typeof rawFirst !== 'number' ||
163
+ !Number.isFinite(rawFirst) ||
164
+ Math.abs(rawFirst) <= 1e-15
165
+ ) {
166
+ return points;
167
+ }
168
+
169
+ const lead: ChartDataPoint[] = [];
170
+ let m = xMin;
171
+ while (m.localeCompare(firstMonth) < 0) {
172
+ const rawM = rawByMonth.get(m);
173
+ if (typeof rawM === 'number' && Number.isFinite(rawM)) {
174
+ lead.push({
175
+ date: m,
176
+ historical: histFirst * (rawM / rawFirst),
177
+ });
178
+ }
179
+ m = getNextMonth(m);
180
+ }
181
+ return [...lead, ...points];
182
+ }
183
+
184
+ export function buildDriversComparisonChartData(
185
+ mergedWithHistorical: ChartDataPoint[],
186
+ datasetHistorical: ChartDataPoint[],
187
+ forecastIds: number[],
188
+ ): ChartDataPoint[] {
189
+ if (mergedWithHistorical.length === 0) return mergedWithHistorical;
190
+ if (forecastIds.length === 0) return mergedWithHistorical;
191
+ const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(
192
+ mergedWithHistorical,
193
+ forecastIds,
194
+ );
195
+ if (anchorMonth === null) return mergedWithHistorical;
196
+ const xMin = subtractMonthsFromMonthStart(
197
+ anchorMonth,
198
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
199
+ );
200
+ const withLead = prependHistoricalLeadFromDataset(
201
+ mergedWithHistorical,
202
+ datasetHistorical,
203
+ xMin,
204
+ );
205
+ return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
206
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ DriversComparisonChart,
3
+ type DriversComparisonChartProps,
4
+ } from './DriversComparisonChart';
5
+ export {
6
+ buildDriversComparisonChartData,
7
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
8
+ DRIVER_FORECAST_ID_BASE,
9
+ formatSeriesImportance,
10
+ INITIAL_VISIBLE_SERIES_COUNT,
11
+ mergeBacktestsChartData,
12
+ mergeDatasetHistoricalWithBacktestsChartData,
13
+ } from './driversComparisonChart.helpers';
@@ -0,0 +1,25 @@
1
+ @import '../../../../lib/theme.styl'
2
+
3
+ .root
4
+ display flex
5
+ align-items center
6
+ justify-content flex-end
7
+ gap var(--p-4)
8
+ flex-grow 1
9
+
10
+ .horizonsLabel
11
+ font-size 14px
12
+ color var(--foreground)
13
+ font-weight 500
14
+
15
+ .horizonsButtons
16
+ display flex
17
+ gap var(--p-2)
18
+
19
+ .horizonButton
20
+ min-width 40px
21
+
22
+ .infoIcon
23
+ margin-left 4px
24
+ font-size 12px
25
+ opacity 0.6
@@ -0,0 +1,11 @@
1
+ // This file is automatically generated.
2
+ // Please do not change this file!
3
+ interface CssExports {
4
+ 'horizonButton': string;
5
+ 'horizonsButtons': string;
6
+ 'horizonsLabel': string;
7
+ 'infoIcon': string;
8
+ 'root': string;
9
+ }
10
+ export const cssExports: CssExports;
11
+ export default cssExports;
@@ -0,0 +1,67 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
4
+ import { useThrottledCallback } from '#uilib/hooks/useThrottledCallback';
5
+
6
+ import S from './HorizonsSelector.styl';
7
+
8
+ interface HorizonsSelectorProps {
9
+ selectedHorizon: string;
10
+ onHorizonChange: (horizon: string) => void;
11
+ availableHorizons: string[];
12
+ }
13
+
14
+ export function HorizonsSelector({
15
+ selectedHorizon,
16
+ onHorizonChange,
17
+ availableHorizons,
18
+ }: HorizonsSelectorProps) {
19
+ const horizons = useMemo(() => {
20
+ return availableHorizons
21
+ .sort((a, b) => {
22
+ // Sort by horizon number: horizon_1, horizon_2, etc.
23
+ const numA = parseInt(a.replace('horizon_', ''), 10);
24
+ const numB = parseInt(b.replace('horizon_', ''), 10);
25
+ return numA - numB;
26
+ })
27
+ .map(horizon => {
28
+ const num = horizon.replace('horizon_', '');
29
+ return {
30
+ value: horizon,
31
+ label: num,
32
+ };
33
+ });
34
+ }, [availableHorizons]);
35
+
36
+ const onChangeThrottled = useThrottledCallback(onHorizonChange, 300);
37
+
38
+ if (horizons.length === 0) {
39
+ return null;
40
+ }
41
+
42
+ return (
43
+ <div className={S.root}>
44
+ <span className={S.horizonsLabel}>Planning horizon in months:</span>
45
+ <Tabs
46
+ value={selectedHorizon}
47
+ onValueChange={onHorizonChange}
48
+ variant="button"
49
+ >
50
+ <TabsList>
51
+ {horizons.map(horizon => (
52
+ <TabsTrigger
53
+ key={horizon.value}
54
+ value={horizon.value}
55
+ onPointerMove={() => {
56
+ if (selectedHorizon !== horizon.value)
57
+ onChangeThrottled(horizon.value);
58
+ }}
59
+ >
60
+ {horizon.label}
61
+ </TabsTrigger>
62
+ ))}
63
+ </TabsList>
64
+ </Tabs>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,17 @@
1
+ export const METRICS_KEY = '24m' as const;
2
+ export const MONTHS_24 = 24;
3
+
4
+ export const MONTH_NAMES = [
5
+ 'Jan',
6
+ 'Feb',
7
+ 'Mar',
8
+ 'Apr',
9
+ 'May',
10
+ 'Jun',
11
+ 'Jul',
12
+ 'Aug',
13
+ 'Sep',
14
+ 'Oct',
15
+ 'Nov',
16
+ 'Dec',
17
+ ];