@sybilion/uilib 1.3.21 → 1.3.23

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 (26) hide show
  1. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +21 -4
  2. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +139 -0
  3. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.js +7 -0
  4. package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +159 -0
  5. package/dist/esm/index.js +2 -0
  6. package/dist/esm/types/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.d.ts +9 -1
  7. package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +2 -2
  8. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -0
  9. package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +18 -0
  10. package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +26 -0
  11. package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -0
  12. package/dist/esm/types/src/docs/pages/DriversComparisonChartPage.d.ts +1 -0
  13. package/dist/esm/types/src/index.d.ts +1 -0
  14. package/dist/esm/utils/chartConnectionPoint.js +9 -1
  15. package/package.json +1 -1
  16. package/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.ts +17 -1
  17. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +23 -4
  18. package/src/components/ui/Chat/index.ts +5 -0
  19. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl +145 -0
  20. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.d.ts +29 -0
  21. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +325 -0
  22. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +206 -0
  23. package/src/components/widgets/DriversComparisonChart/index.ts +13 -0
  24. package/src/docs/pages/DriversComparisonChartPage.tsx +174 -0
  25. package/src/docs/registry.ts +6 -0
  26. package/src/index.ts +1 -0
@@ -0,0 +1,145 @@
1
+ @import '../../../lib/theme.styl';
2
+
3
+ .root
4
+ display flex
5
+ flex-direction column
6
+ gap var(--p-4)
7
+ width 100%
8
+
9
+ .chartShell
10
+ position relative
11
+ width 100%
12
+
13
+ .chartShellLoading
14
+ .chartSlot
15
+ opacity 0.3
16
+ pointer-events none
17
+
18
+ :global(.recharts-line-dots)
19
+ display none
20
+
21
+ .chartSlot
22
+ width 100%
23
+
24
+ .loadingLayer
25
+ position absolute
26
+ inset 0
27
+ z-index 10
28
+ pointer-events none
29
+
30
+ .loadingMessage
31
+ position absolute
32
+ z-index 1
33
+ top calc(50% - 4em)
34
+ left 0
35
+ width 100%
36
+ display block
37
+ text-align center
38
+ color var(--foreground)
39
+
40
+ .loadingText
41
+ font-size 16px
42
+ font-weight 500
43
+ color var(--foreground)
44
+ border-radius var(--p-4)
45
+ padding 0 var(--p-3)
46
+ backdrop-filter blur(10px)
47
+ box-shadow 0 0 0 2px var(--page-color)
48
+
49
+ .chartWithOverlay
50
+ position relative
51
+ width 100%
52
+
53
+ .chartInteractiveLayer
54
+ width 100%
55
+ transition opacity 0.2s ease-out
56
+
57
+ .chartInteractiveDimmed
58
+ opacity 0.3
59
+ pointer-events none
60
+
61
+ :global(.recharts-line-dots)
62
+ display none
63
+
64
+ .chartEmptyOverlay
65
+ position absolute
66
+ inset 0
67
+ z-index 6
68
+ display flex
69
+ align-items center
70
+ justify-content center
71
+ padding var(--p-4)
72
+ pointer-events none
73
+
74
+ .chartEmptyBlurb
75
+ max-width 42rem
76
+ padding 0 var(--p-3)
77
+
78
+ .seriesEmptyWrap
79
+ display flex
80
+ justify-content center
81
+ padding var(--p-8) var(--p-4)
82
+
83
+ .seriesSection
84
+ width 100%
85
+
86
+ .seriesTableWrapper
87
+ position relative
88
+ margin 0 calc(var(--page-x-padding) * -1)
89
+
90
+ .seriesTableContainer
91
+ position relative
92
+ width 100%
93
+ overflow-x auto
94
+
95
+ .seriesScrollbar
96
+ bottom calc(var(--p-7) * -1 + 2px) !important
97
+
98
+ .seriesTable
99
+ table-layout auto
100
+ width 100%
101
+ max-width 100%
102
+
103
+ tr
104
+ position relative
105
+ cursor pointer
106
+
107
+ td
108
+ th
109
+ &:not(:first-child)
110
+ text-align right
111
+
112
+ .seriesColSeries
113
+ text-align left
114
+ vertical-align middle
115
+
116
+ .rowHidden
117
+ opacity .4
118
+
119
+ .colorSwatch
120
+ display inline-flex
121
+ flex-shrink 0
122
+ width 10px
123
+ height 10px
124
+ margin-right var(--p-2)
125
+ border-radius 2px
126
+
127
+ .seriesLabel
128
+ min-width 0
129
+ max-width 60vw
130
+ padding var(--p-2) 0
131
+ line-clamp(3)
132
+ white-space break-spaces
133
+
134
+ .chartContainer
135
+ margin-left calc(-1 * var(--page-x-padding) + 26px)
136
+ width calc(100% + 60px)
137
+ max-width @width
138
+ transition opacity 300ms ease-out
139
+
140
+ :global(.recharts-yAxis-tick-labels)
141
+ display none
142
+
143
+ @media (max-width: unit(MOBILE, 'px'))
144
+ margin-left calc(-1 * var(--page-x-padding) + 10px)
145
+ width calc(100% + 30px)
@@ -0,0 +1,29 @@
1
+ // This file is automatically generated.
2
+ // Please do not change this file!
3
+ interface CssExports {
4
+ 'chartContainer': string;
5
+ 'chartEmptyBlurb': string;
6
+ 'chartEmptyOverlay': string;
7
+ 'chartInteractiveDimmed': string;
8
+ 'chartInteractiveLayer': string;
9
+ 'chartShell': string;
10
+ 'chartShellLoading': string;
11
+ 'chartSlot': string;
12
+ 'chartWithOverlay': string;
13
+ 'colorSwatch': string;
14
+ 'loadingLayer': string;
15
+ 'loadingMessage': string;
16
+ 'loadingText': string;
17
+ 'root': string;
18
+ 'rowHidden': string;
19
+ 'seriesColSeries': string;
20
+ 'seriesEmptyWrap': string;
21
+ 'seriesLabel': string;
22
+ 'seriesScrollbar': string;
23
+ 'seriesSection': string;
24
+ 'seriesTable': string;
25
+ 'seriesTableContainer': string;
26
+ 'seriesTableWrapper': string;
27
+ }
28
+ export const cssExports: CssExports;
29
+ export default cssExports;
@@ -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
+ }