@sybilion/uilib 1.3.23 → 1.3.26

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 (52) hide show
  1. package/dist/esm/components/ui/TextWithDeferTooltip/TextWithDeferTooltip.js +1 -25
  2. package/dist/esm/components/ui/Tooltip/Tooltip.js +92 -7
  3. package/dist/esm/components/ui/Tooltip/Tooltip.styl.js +2 -2
  4. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +1 -2
  5. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.js +34 -0
  6. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.js +7 -0
  7. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.constants.js +17 -0
  8. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.js +807 -0
  9. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.styl.js +7 -0
  10. package/dist/esm/components/widgets/PerformanceChart/PerformanceTable.js +130 -0
  11. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.js +20 -0
  12. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.js +7 -0
  13. package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +591 -0
  14. package/dist/esm/components/widgets/PerformanceChart/performanceChartUserSeries.js +109 -0
  15. package/dist/esm/index.js +4 -0
  16. package/dist/esm/types/src/components/ui/Tooltip/Tooltip.d.ts +3 -3
  17. package/dist/esm/types/src/components/ui/Tooltip/Tooltip.types.d.ts +1 -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/PerformanceChartPage.d.ts +1 -0
  27. package/dist/esm/types/src/index.d.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/components/ui/TextWithDeferTooltip/TextWithDeferTooltip.tsx +5 -37
  30. package/src/components/ui/Tooltip/Tooltip.styl +12 -0
  31. package/src/components/ui/Tooltip/Tooltip.styl.d.ts +1 -0
  32. package/src/components/ui/Tooltip/Tooltip.tsx +156 -8
  33. package/src/components/ui/Tooltip/Tooltip.types.ts +1 -0
  34. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl +25 -0
  35. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.d.ts +11 -0
  36. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.tsx +67 -0
  37. package/src/components/widgets/PerformanceChart/PerformanceChart.constants.ts +17 -0
  38. package/src/components/widgets/PerformanceChart/PerformanceChart.styl +194 -0
  39. package/src/components/widgets/PerformanceChart/PerformanceChart.styl.d.ts +30 -0
  40. package/src/components/widgets/PerformanceChart/PerformanceChart.tsx +1251 -0
  41. package/src/components/widgets/PerformanceChart/PerformanceTable.tsx +381 -0
  42. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl +49 -0
  43. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.d.ts +12 -0
  44. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.tsx +83 -0
  45. package/src/components/widgets/PerformanceChart/index.ts +28 -0
  46. package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +790 -0
  47. package/src/components/widgets/PerformanceChart/performanceChartUserSeries.ts +149 -0
  48. package/src/docs/pages/PerformanceChartPage.tsx +211 -0
  49. package/src/docs/pages/TextWithDeferTooltipPage.tsx +26 -10
  50. package/src/docs/pages/TooltipPage.tsx +30 -0
  51. package/src/docs/registry.ts +6 -0
  52. package/src/index.ts +1 -0
@@ -0,0 +1,1251 @@
1
+ import cn from 'classnames';
2
+ import {
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from 'react';
9
+
10
+ import { ChartEmptyState } from '#uilib/components/ui/Chart/components/ChartEmptyState/ChartEmptyState';
11
+ import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
12
+ import { filterDataForTimeRange } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers';
13
+ import type { OverlayMode } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
14
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
15
+ import {
16
+ FORECAST_LINE_COLORS,
17
+ type ForecastItemData,
18
+ } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
19
+ import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
20
+ import { TextShimmer } from '#uilib/components/ui/TextShimmer/TextShimmer';
21
+ import {
22
+ TIME_RANGES,
23
+ type TimeRange,
24
+ } from '#uilib/components/ui/TimeRangeControls';
25
+ import type { ForecastData } from '#uilib/types/forecast-data';
26
+ import { normalizeToMonthStart } from '#uilib/utils/chartConnectionPoint';
27
+
28
+ import { HorizonsSelector } from './HorizonsSelector/HorizonsSelector';
29
+ import { MONTHS_24, MONTH_NAMES } from './PerformanceChart.constants';
30
+ import S from './PerformanceChart.styl';
31
+ import { PerformanceTable } from './PerformanceTable';
32
+ import type { AdjustParameters } from './PerformanceTable';
33
+ import { PerformanceUnderChartLegendFromItems } from './PerformanceUnderChartLegend/PerformanceUnderChartLegend';
34
+ import {
35
+ SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE,
36
+ SPAGHETTI_MODEL_PER_HORIZON_ID_BASE,
37
+ averageForecastErrorsVsHistoricalForMatrixColumn,
38
+ buildPerHorizonSpaghettiEntries,
39
+ buildSpaghettiMergedChartData,
40
+ calculateYRangeFromChartData,
41
+ filterSpaghettiDataFromEarliestForecastStart,
42
+ getForecastModelDisplayName,
43
+ isSpaghettiDriftPerHorizonLineId,
44
+ isSpaghettiModelPerHorizonLineId,
45
+ mergeHistoricalIntoSpaghettiChartData,
46
+ mergeSpaghettiMergedBases,
47
+ mergeSpaghettiUserSeriesFromForecastData,
48
+ } from './performanceChart.helpers';
49
+ import {
50
+ SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID,
51
+ type SpaghettiPerformanceMatrixPayload,
52
+ getCustomMatrixSeriesForHorizonTab,
53
+ isSpaghettiMatrixSyntheticLineId,
54
+ } from './performanceChartUserSeries';
55
+
56
+ type PerformanceUnderChartLegendItemConfig = Parameters<
57
+ typeof PerformanceUnderChartLegendFromItems
58
+ >[0]['items'][number];
59
+
60
+ export type PerformanceViewTab = 'perHorizon' | 'spaghetti';
61
+
62
+ export type PerformanceChartPayload = Record<
63
+ string,
64
+ {
65
+ forecasts?: Record<string, Record<string, number>>;
66
+ metrics_history?: Record<
67
+ string,
68
+ { mae?: Record<string, number>; mape?: Record<string, number> }
69
+ >;
70
+ }
71
+ >;
72
+
73
+ const SPAGHETTI_DRIFT_LINE_COLOR = FORECAST_LINE_COLORS[0];
74
+ const SPAGHETTI_MODEL_LINE_COLOR = 'var(--sb-cyan-400)';
75
+ const SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR = 'var(--sb-purple-400)';
76
+
77
+ function reorderSpaghettiMatrixToHorizons(
78
+ m: SpaghettiPerformanceMatrixPayload,
79
+ targetKeys: string[],
80
+ ): SpaghettiPerformanceMatrixPayload {
81
+ if (m.horizonKeys.length !== targetKeys.length) return m;
82
+ const idxMap = targetKeys.map(tk => m.horizonKeys.indexOf(tk));
83
+ if (idxMap.some(j => j < 0)) return m;
84
+ const perHorizonDates =
85
+ m.perHorizonDates &&
86
+ m.perHorizonDates.length === m.grid.length &&
87
+ m.perHorizonDates.every(
88
+ (row, r) =>
89
+ row.length === m.horizonKeys.length && row.length === m.grid[r].length,
90
+ )
91
+ ? m.perHorizonDates.map(row => idxMap.map(j => row[j]))
92
+ : m.perHorizonDates;
93
+ return {
94
+ ...m,
95
+ horizonKeys: [...targetKeys],
96
+ grid: m.grid.map(row => idxMap.map(j => row[j])),
97
+ perHorizonDates,
98
+ };
99
+ }
100
+
101
+ function spaghettiForecastLineColor(id: number, index: number): string {
102
+ if (isSpaghettiDriftPerHorizonLineId(id)) return SPAGHETTI_DRIFT_LINE_COLOR;
103
+ if (isSpaghettiModelPerHorizonLineId(id)) return SPAGHETTI_MODEL_LINE_COLOR;
104
+ if (isSpaghettiMatrixSyntheticLineId(id))
105
+ return SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR;
106
+ return FORECAST_LINE_COLORS[index % FORECAST_LINE_COLORS.length];
107
+ }
108
+
109
+ export type PerformanceChartProps = {
110
+ performanceData: PerformanceChartPayload | null;
111
+ historicalData?: ChartDataPoint[];
112
+ combinedData?: ChartDataPoint[];
113
+ forecastData?: Record<string, ForecastData>;
114
+ userSeries?: { id: number; name?: string }[];
115
+ spaghettiUserSeries?: { id: number; name?: string }[];
116
+ customPerformanceMatrix?: SpaghettiPerformanceMatrixPayload | null;
117
+ customPerformanceLabel?: string | null;
118
+
119
+ loading?: boolean;
120
+ chartLoading?: boolean;
121
+ performanceSectionPending?: boolean;
122
+ isEmpty?: boolean;
123
+ perfFetchSettled?: boolean;
124
+ performanceDataLoading?: boolean;
125
+ performanceAnalysisId?: number | null;
126
+
127
+ statusHint?: string | null;
128
+ statusTone?: 'destructive' | 'muted';
129
+ runAnalysisHint?: boolean;
130
+
131
+ timeRange?: string;
132
+ onTimeRangeChange?: (
133
+ range: string,
134
+ meta?: { viewTab: PerformanceViewTab },
135
+ ) => void;
136
+
137
+ isDarkTheme?: boolean;
138
+ className?: string;
139
+ seriesInitKey?: string;
140
+
141
+ toolbarStart?: ReactNode;
142
+
143
+ onEditCustomPerformance?: () => void;
144
+ showAddEditCustomDataButton?: boolean;
145
+ addEditCustomDataDisabled?: boolean;
146
+
147
+ hiddenSeries?: Set<string>;
148
+ onToggleLegendSeries?: (key: string) => void;
149
+ onEnsureSeriesVisible?: (key: string) => void;
150
+ onHiddenSeriesChange?: (
151
+ update: Set<string> | ((prev: Set<string>) => Set<string>),
152
+ ) => void;
153
+ };
154
+
155
+ export function PerformanceChart({
156
+ performanceData,
157
+ historicalData = [],
158
+ combinedData = [],
159
+ forecastData = {},
160
+ userSeries = [],
161
+ spaghettiUserSeries = [],
162
+ customPerformanceMatrix = null,
163
+ customPerformanceLabel = null,
164
+
165
+ loading = false,
166
+ chartLoading = false,
167
+ performanceSectionPending = false,
168
+ isEmpty = false,
169
+ perfFetchSettled = true,
170
+ performanceDataLoading = false,
171
+ performanceAnalysisId = null,
172
+
173
+ statusHint = null,
174
+ statusTone = 'muted',
175
+ runAnalysisHint = false,
176
+
177
+ timeRange: timeRangeProp,
178
+ onTimeRangeChange,
179
+
180
+ isDarkTheme = false,
181
+ className,
182
+ seriesInitKey,
183
+
184
+ toolbarStart,
185
+
186
+ onEditCustomPerformance,
187
+ showAddEditCustomDataButton = false,
188
+ addEditCustomDataDisabled = false,
189
+
190
+ hiddenSeries: hiddenSeriesProp,
191
+ onToggleLegendSeries,
192
+ onEnsureSeriesVisible,
193
+ onHiddenSeriesChange,
194
+ }: PerformanceChartProps) {
195
+ const [viewTab, setViewTab] = useState<PerformanceViewTab>('perHorizon');
196
+ const [pinMonth, setPinMonth] = useState<string | undefined>(undefined);
197
+ const [spaghettiTimeRange, setSpaghettiTimeRange] = useState<string>('All');
198
+ const [perHorizonChartTimeRange, setPerHorizonChartTimeRange] =
199
+ useState<string>('All');
200
+ const [internalHiddenSeries, setInternalHiddenSeries] = useState<Set<string>>(
201
+ new Set(),
202
+ );
203
+
204
+ const [adjustParameters, setAdjustParameters] = useState<AdjustParameters>({
205
+ procurementVolume: 100000000,
206
+ variableRawMaterialCostShare: 50,
207
+ controllableCosts: 60,
208
+ currentForecastAccuracy: 70,
209
+ expectedImprovement: 10,
210
+ analystHourlyRate: 80,
211
+ });
212
+
213
+ const hiddenSeriesControlled = hiddenSeriesProp !== undefined;
214
+ const hiddenSeries = hiddenSeriesProp ?? internalHiddenSeries;
215
+
216
+ const setHiddenSeries = useCallback(
217
+ (update: Set<string> | ((prev: Set<string>) => Set<string>)) => {
218
+ if (onHiddenSeriesChange) {
219
+ onHiddenSeriesChange(update);
220
+ return;
221
+ }
222
+ setInternalHiddenSeries(update);
223
+ },
224
+ [onHiddenSeriesChange],
225
+ );
226
+
227
+ const toggleLegendSeriesKey = useCallback(
228
+ (key: string) => {
229
+ if (onToggleLegendSeries) {
230
+ onToggleLegendSeries(key);
231
+ return;
232
+ }
233
+ setHiddenSeries(prev => {
234
+ const next = new Set(prev);
235
+ if (next.has(key)) next.delete(key);
236
+ else next.add(key);
237
+ return next;
238
+ });
239
+ },
240
+ [onToggleLegendSeries, setHiddenSeries],
241
+ );
242
+
243
+ const ensureSeriesVisible = useCallback(
244
+ (key: string) => {
245
+ if (onEnsureSeriesVisible) {
246
+ onEnsureSeriesVisible(key);
247
+ return;
248
+ }
249
+ setHiddenSeries(prev => {
250
+ if (!prev.has(key)) return prev;
251
+ const next = new Set(prev);
252
+ next.delete(key);
253
+ return next;
254
+ });
255
+ },
256
+ [onEnsureSeriesVisible, setHiddenSeries],
257
+ );
258
+
259
+ const userSeriesForSpaghetti = useMemo(
260
+ () => [...userSeries, ...spaghettiUserSeries],
261
+ [userSeries, spaghettiUserSeries],
262
+ );
263
+
264
+ const availableHorizons = useMemo(() => {
265
+ if (!performanceData) return [];
266
+ const driftModelData = performanceData['drift'];
267
+ if (!driftModelData) return [];
268
+ const forecasts = driftModelData?.forecasts;
269
+ if (forecasts && typeof forecasts === 'object') {
270
+ return Object.keys(forecasts).filter(key => key.startsWith('horizon_'));
271
+ }
272
+ return [];
273
+ }, [performanceData]);
274
+
275
+ const sortedHorizonKeysForCustom = useMemo(
276
+ () =>
277
+ [...availableHorizons].sort((a, b) => {
278
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
279
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
280
+ return na - nb;
281
+ }),
282
+ [availableHorizons],
283
+ );
284
+
285
+ const parsedCustomMatrixForCharts = useMemo(() => {
286
+ if (!customPerformanceMatrix) return null;
287
+ return {
288
+ matrix: reorderSpaghettiMatrixToHorizons(
289
+ customPerformanceMatrix,
290
+ sortedHorizonKeysForCustom,
291
+ ),
292
+ displayLabel: customPerformanceLabel?.trim() || 'Custom performance',
293
+ };
294
+ }, [
295
+ customPerformanceMatrix,
296
+ customPerformanceLabel,
297
+ sortedHorizonKeysForCustom,
298
+ ]);
299
+
300
+ const [selectedHorizon, setSelectedHorizon] = useState<string>(
301
+ availableHorizons[0] || 'horizon_1',
302
+ );
303
+
304
+ useEffect(() => {
305
+ if (
306
+ availableHorizons.length > 0 &&
307
+ !availableHorizons.includes(selectedHorizon)
308
+ ) {
309
+ setSelectedHorizon(availableHorizons[0]);
310
+ }
311
+ }, [availableHorizons, selectedHorizon]);
312
+
313
+ const getTimeRangeForForecastLength = useCallback(
314
+ (mode: OverlayMode): TimeRange => {
315
+ if (mode === 'pin') return '5y';
316
+ if (
317
+ performanceAnalysisId == null ||
318
+ !forecastData[String(performanceAnalysisId)]
319
+ ) {
320
+ return TIME_RANGES[0];
321
+ }
322
+ const forecastDataForSelected =
323
+ forecastData[String(performanceAnalysisId)];
324
+ const dates = forecastDataForSelected.dates;
325
+ if (!dates || dates.length === 0) return TIME_RANGES[0];
326
+ const firstDate = new Date(dates[0]);
327
+ const lastDate = new Date(dates[dates.length - 1]);
328
+ const monthsDiff =
329
+ (lastDate.getFullYear() - firstDate.getFullYear()) * 12 +
330
+ (lastDate.getMonth() - firstDate.getMonth()) +
331
+ 1;
332
+ if (monthsDiff < 6) return '6m';
333
+ if (monthsDiff < 12) return '1y';
334
+ if (monthsDiff < 36) return '3y';
335
+ if (monthsDiff < 60) return '5y';
336
+ return 'All';
337
+ },
338
+ [performanceAnalysisId, forecastData],
339
+ );
340
+
341
+ const forecastModelsData = useMemo(() => {
342
+ if (!performanceData || !selectedHorizon) return [];
343
+ const models: { key: string; mae: number; mape: number }[] = [];
344
+ ['model', 'drift'].forEach(key => {
345
+ const modelData = performanceData[key];
346
+ const horizonMetrics = modelData?.metrics_history?.[selectedHorizon];
347
+ if (horizonMetrics?.mae && horizonMetrics?.mape) {
348
+ const maeValues = Object.values(horizonMetrics.mae) as number[];
349
+ const mapeValues = Object.values(horizonMetrics.mape) as number[];
350
+ models.push({
351
+ key,
352
+ mae: maeValues.reduce((sum, val) => sum + val, 0) / maeValues.length,
353
+ mape:
354
+ mapeValues.reduce((sum, val) => sum + val, 0) / mapeValues.length,
355
+ });
356
+ }
357
+ });
358
+ return models;
359
+ }, [performanceData, selectedHorizon]);
360
+
361
+ const historicalChartData = useMemo(
362
+ () => historicalData.map(point => ({ ...point })),
363
+ [historicalData],
364
+ );
365
+
366
+ const chartForecastData = useMemo((): ForecastItemData[] => {
367
+ const forecastItems: ForecastItemData[] = [];
368
+ if (performanceData) {
369
+ ['model', 'drift'].forEach((key, index) => {
370
+ const modelData = performanceData[key];
371
+ const horizonData = modelData?.forecasts?.[selectedHorizon];
372
+ if (horizonData) {
373
+ forecastItems.push({
374
+ id: index + 1000,
375
+ name: getForecastModelDisplayName(key),
376
+ color:
377
+ key === 'model' ? 'var(--sb-cyan-400)' : FORECAST_LINE_COLORS[0],
378
+ });
379
+ }
380
+ });
381
+ }
382
+ if (parsedCustomMatrixForCharts) {
383
+ const slice = getCustomMatrixSeriesForHorizonTab(
384
+ parsedCustomMatrixForCharts.matrix,
385
+ selectedHorizon,
386
+ SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID,
387
+ forecastData,
388
+ );
389
+ if (slice) {
390
+ forecastItems.push({
391
+ id: slice.lineId,
392
+ name: parsedCustomMatrixForCharts.displayLabel,
393
+ color: SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR,
394
+ });
395
+ }
396
+ }
397
+ return forecastItems;
398
+ }, [
399
+ performanceData,
400
+ selectedHorizon,
401
+ parsedCustomMatrixForCharts,
402
+ forecastData,
403
+ ]);
404
+
405
+ const forecastChartData = useMemo(() => {
406
+ const chartDataMap = new Map<string, ChartDataPoint>();
407
+ const modelKeys = ['model', 'drift'];
408
+ const normalizeDate = (dateStr: string): string => dateStr.split(' ')[0];
409
+ const latestHistoricalDate =
410
+ historicalChartData.length > 0
411
+ ? historicalChartData[historicalChartData.length - 1].date
412
+ : null;
413
+
414
+ historicalChartData.forEach(point => {
415
+ chartDataMap.set(point.date, { ...point });
416
+ });
417
+
418
+ if (performanceData) {
419
+ modelKeys.forEach(key => {
420
+ const index = modelKeys.indexOf(key);
421
+ const modelData = performanceData[key];
422
+ const horizonData = modelData?.forecasts?.[selectedHorizon];
423
+ const dataKey = `forecast_${index + 1000}`;
424
+ if (horizonData) {
425
+ Object.entries(horizonData).forEach(([date, value]) => {
426
+ if (value !== null && value !== undefined) {
427
+ const normalizedDate = normalizeDate(String(date));
428
+ if (
429
+ latestHistoricalDate &&
430
+ normalizedDate > latestHistoricalDate
431
+ ) {
432
+ return;
433
+ }
434
+ const existingPoint = chartDataMap.get(normalizedDate);
435
+ if (existingPoint) {
436
+ chartDataMap.set(normalizedDate, {
437
+ ...existingPoint,
438
+ [dataKey]: value as number,
439
+ });
440
+ } else {
441
+ chartDataMap.set(normalizedDate, {
442
+ date: normalizedDate,
443
+ [dataKey]: value as number,
444
+ });
445
+ }
446
+ }
447
+ });
448
+ }
449
+ });
450
+ }
451
+
452
+ const customSlice = parsedCustomMatrixForCharts
453
+ ? getCustomMatrixSeriesForHorizonTab(
454
+ parsedCustomMatrixForCharts.matrix,
455
+ selectedHorizon,
456
+ SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID,
457
+ forecastData,
458
+ )
459
+ : null;
460
+ if (customSlice) {
461
+ const dataKey = `forecast_${customSlice.lineId}`;
462
+ const n = Math.min(
463
+ customSlice.dates.length,
464
+ customSlice.forecastValues.length,
465
+ );
466
+ for (let i = 0; i < n; i++) {
467
+ const value = customSlice.forecastValues[i];
468
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue;
469
+ const normalizedDate = normalizeDate(String(customSlice.dates[i]));
470
+ if (latestHistoricalDate && normalizedDate > latestHistoricalDate) {
471
+ continue;
472
+ }
473
+ const existingPoint = chartDataMap.get(normalizedDate);
474
+ if (existingPoint) {
475
+ chartDataMap.set(normalizedDate, {
476
+ ...existingPoint,
477
+ [dataKey]: value,
478
+ });
479
+ } else {
480
+ chartDataMap.set(normalizedDate, {
481
+ date: normalizedDate,
482
+ [dataKey]: value,
483
+ });
484
+ }
485
+ }
486
+ }
487
+
488
+ const sortedData = Array.from(chartDataMap.values()).sort((a, b) =>
489
+ a.date.localeCompare(b.date),
490
+ );
491
+ if (latestHistoricalDate) {
492
+ return sortedData.filter(point => {
493
+ if (historicalChartData.some(h => h.date === point.date)) return true;
494
+ return point.date <= latestHistoricalDate;
495
+ });
496
+ }
497
+ return sortedData;
498
+ }, [
499
+ performanceData,
500
+ selectedHorizon,
501
+ historicalChartData,
502
+ parsedCustomMatrixForCharts,
503
+ forecastData,
504
+ ]);
505
+
506
+ const maxYScale = useMemo(() => {
507
+ if (!performanceData || availableHorizons.length === 0) return undefined;
508
+ const modelKeys = ['model', 'drift'];
509
+ const normalizeDate = (dateStr: string): string => dateStr.split(' ')[0];
510
+ const latestHistoricalDate =
511
+ historicalChartData.length > 0
512
+ ? historicalChartData[historicalChartData.length - 1].date
513
+ : null;
514
+ const allValues: number[] = [];
515
+
516
+ availableHorizons.forEach(horizon => {
517
+ const chartDataMap = new Map<string, ChartDataPoint>();
518
+ historicalChartData.forEach(point => {
519
+ chartDataMap.set(point.date, { ...point });
520
+ });
521
+ modelKeys.forEach(key => {
522
+ const index = modelKeys.indexOf(key);
523
+ const modelData = performanceData[key];
524
+ const horizonData = modelData?.forecasts?.[horizon];
525
+ const dataKey = `forecast_${index + 1000}`;
526
+ if (horizonData) {
527
+ Object.entries(horizonData).forEach(([date, value]) => {
528
+ if (value !== null && value !== undefined) {
529
+ const normalizedDate = normalizeDate(String(date));
530
+ if (
531
+ latestHistoricalDate &&
532
+ normalizedDate > latestHistoricalDate
533
+ ) {
534
+ return;
535
+ }
536
+ const existingPoint = chartDataMap.get(normalizedDate);
537
+ if (existingPoint) {
538
+ chartDataMap.set(normalizedDate, {
539
+ ...existingPoint,
540
+ [dataKey]: value as number,
541
+ });
542
+ } else {
543
+ chartDataMap.set(normalizedDate, {
544
+ date: normalizedDate,
545
+ [dataKey]: value as number,
546
+ });
547
+ }
548
+ }
549
+ });
550
+ }
551
+ });
552
+ if (parsedCustomMatrixForCharts) {
553
+ const customSlice = getCustomMatrixSeriesForHorizonTab(
554
+ parsedCustomMatrixForCharts.matrix,
555
+ horizon,
556
+ SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID,
557
+ forecastData,
558
+ );
559
+ if (customSlice) {
560
+ const dataKey = `forecast_${customSlice.lineId}`;
561
+ const n = Math.min(
562
+ customSlice.dates.length,
563
+ customSlice.forecastValues.length,
564
+ );
565
+ for (let i = 0; i < n; i++) {
566
+ const value = customSlice.forecastValues[i];
567
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue;
568
+ const normalizedDate = normalizeDate(String(customSlice.dates[i]));
569
+ if (latestHistoricalDate && normalizedDate > latestHistoricalDate) {
570
+ continue;
571
+ }
572
+ const existingPoint = chartDataMap.get(normalizedDate);
573
+ if (existingPoint) {
574
+ chartDataMap.set(normalizedDate, {
575
+ ...existingPoint,
576
+ [dataKey]: value,
577
+ });
578
+ } else {
579
+ chartDataMap.set(normalizedDate, {
580
+ date: normalizedDate,
581
+ [dataKey]: value,
582
+ });
583
+ }
584
+ }
585
+ }
586
+ }
587
+ const sortedData = Array.from(chartDataMap.values()).sort((a, b) =>
588
+ a.date.localeCompare(b.date),
589
+ );
590
+ const filteredData = latestHistoricalDate
591
+ ? sortedData.filter(point => {
592
+ if (historicalChartData.some(h => h.date === point.date)) {
593
+ return true;
594
+ }
595
+ return point.date <= latestHistoricalDate;
596
+ })
597
+ : sortedData;
598
+ if (filteredData.length > 0) {
599
+ const cutoffDate = new Date(filteredData[filteredData.length - 1].date);
600
+ cutoffDate.setMonth(cutoffDate.getMonth() - MONTHS_24);
601
+ filteredData
602
+ .filter(point => new Date(point.date) >= cutoffDate)
603
+ .forEach(point => {
604
+ Object.entries(point).forEach(([key, value]) => {
605
+ if (key === 'date') return;
606
+ if (typeof value === 'number') allValues.push(value);
607
+ });
608
+ });
609
+ }
610
+ });
611
+
612
+ if (allValues.length === 0) return undefined;
613
+ const min = Math.min(...allValues);
614
+ const max = Math.max(...allValues);
615
+ const diff = max - min;
616
+ return { yMin: min - diff * 0.1, yMax: max + diff * 0.1 };
617
+ }, [
618
+ performanceData,
619
+ availableHorizons,
620
+ historicalChartData,
621
+ parsedCustomMatrixForCharts,
622
+ forecastData,
623
+ ]);
624
+
625
+ const filtered24mData = useMemo(() => {
626
+ const dataToUse =
627
+ forecastChartData.length > 0 ? forecastChartData : combinedData;
628
+ if (dataToUse.length === 0) return [];
629
+ const sorted = [...dataToUse].sort((a, b) => a.date.localeCompare(b.date));
630
+ const cutoffDate = new Date(sorted[sorted.length - 1].date);
631
+ cutoffDate.setMonth(cutoffDate.getMonth() - MONTHS_24);
632
+ return sorted.filter(point => new Date(point.date) >= cutoffDate);
633
+ }, [forecastChartData, combinedData]);
634
+
635
+ const historicalByDateForCustomMetrics = useMemo(() => {
636
+ const norm = (d: string) =>
637
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
638
+ const m = new Map<string, number>();
639
+ historicalChartData.forEach(p => {
640
+ const v = p.historical;
641
+ if (typeof v === 'number' && Number.isFinite(v)) {
642
+ m.set(norm(p.date), v);
643
+ }
644
+ });
645
+ return m;
646
+ }, [historicalChartData]);
647
+
648
+ const customPerformanceTableRow = useMemo(() => {
649
+ if (!parsedCustomMatrixForCharts) return null;
650
+ const colIdx = sortedHorizonKeysForCustom.indexOf(selectedHorizon);
651
+ if (colIdx < 0) return null;
652
+ const errs = averageForecastErrorsVsHistoricalForMatrixColumn(
653
+ parsedCustomMatrixForCharts.matrix,
654
+ colIdx,
655
+ historicalByDateForCustomMetrics,
656
+ );
657
+ if (!errs) return null;
658
+ return {
659
+ label: parsedCustomMatrixForCharts.displayLabel,
660
+ mae: errs.mae,
661
+ mape: errs.mape,
662
+ };
663
+ }, [
664
+ parsedCustomMatrixForCharts,
665
+ sortedHorizonKeysForCustom,
666
+ selectedHorizon,
667
+ historicalByDateForCustomMetrics,
668
+ ]);
669
+
670
+ const perHorizonWindowStart = useMemo(() => {
671
+ if (filtered24mData.length === 0) return null;
672
+ const sorted = [...filtered24mData].sort((a, b) =>
673
+ a.date.localeCompare(b.date),
674
+ );
675
+ return sorted[0]?.date ?? null;
676
+ }, [filtered24mData]);
677
+
678
+ const perHorizonWindowEnd = useMemo(() => {
679
+ if (filtered24mData.length === 0) return null;
680
+ const sorted = [...filtered24mData].sort((a, b) =>
681
+ a.date.localeCompare(b.date),
682
+ );
683
+ return sorted[sorted.length - 1]?.date ?? null;
684
+ }, [filtered24mData]);
685
+
686
+ const modelPerHorizonEntries = useMemo(
687
+ () =>
688
+ buildPerHorizonSpaghettiEntries(
689
+ performanceData?.model,
690
+ availableHorizons,
691
+ ),
692
+ [performanceData, availableHorizons],
693
+ );
694
+
695
+ const driftPerHorizonEntries = useMemo(
696
+ () =>
697
+ buildPerHorizonSpaghettiEntries(
698
+ performanceData?.drift,
699
+ availableHorizons,
700
+ ),
701
+ [performanceData, availableHorizons],
702
+ );
703
+
704
+ const spaghettiPipeline = useMemo(() => {
705
+ if (viewTab !== 'spaghetti') {
706
+ return {
707
+ merged: {
708
+ mergedData: [] as ChartDataPoint[],
709
+ seriesMeta: [] as { id: number; label: string }[],
710
+ },
711
+ driftSeriesMeta: [] as { id: number; label: string }[],
712
+ };
713
+ }
714
+ const driftMerged =
715
+ driftPerHorizonEntries.length > 0
716
+ ? buildSpaghettiMergedChartData(
717
+ driftPerHorizonEntries,
718
+ historicalChartData,
719
+ SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE,
720
+ )
721
+ : { mergedData: [] as ChartDataPoint[], seriesMeta: [] };
722
+ const modelMerged =
723
+ modelPerHorizonEntries.length > 0
724
+ ? buildSpaghettiMergedChartData(
725
+ modelPerHorizonEntries,
726
+ historicalChartData,
727
+ SPAGHETTI_MODEL_PER_HORIZON_ID_BASE,
728
+ )
729
+ : { mergedData: [] as ChartDataPoint[], seriesMeta: [] };
730
+
731
+ let merged: {
732
+ mergedData: ChartDataPoint[];
733
+ seriesMeta: { id: number; label: string }[];
734
+ };
735
+ if (
736
+ driftMerged.seriesMeta.length === 0 &&
737
+ modelMerged.seriesMeta.length === 0
738
+ ) {
739
+ const historicalOnly =
740
+ historicalChartData.length > 0
741
+ ? [...historicalChartData]
742
+ .map(p => ({
743
+ ...p,
744
+ date: normalizeToMonthStart(
745
+ String(p.date).split(' ')[0] ?? p.date,
746
+ ),
747
+ }))
748
+ .sort((a, b) => a.date.localeCompare(b.date))
749
+ : [];
750
+ merged = { mergedData: historicalOnly, seriesMeta: [] };
751
+ } else if (driftMerged.seriesMeta.length === 0) {
752
+ merged = modelMerged;
753
+ } else if (modelMerged.seriesMeta.length === 0) {
754
+ merged = driftMerged;
755
+ } else {
756
+ merged = mergeSpaghettiMergedBases(driftMerged, modelMerged);
757
+ }
758
+ return { merged, driftSeriesMeta: driftMerged.seriesMeta };
759
+ }, [
760
+ viewTab,
761
+ driftPerHorizonEntries,
762
+ historicalChartData,
763
+ modelPerHorizonEntries,
764
+ ]);
765
+
766
+ const spaghettiWithUserSeries = useMemo(() => {
767
+ if (viewTab !== 'spaghetti') {
768
+ return { mergedData: [] as ChartDataPoint[], seriesMeta: [] };
769
+ }
770
+ return mergeSpaghettiUserSeriesFromForecastData(
771
+ spaghettiPipeline.merged,
772
+ userSeriesForSpaghetti,
773
+ forecastData,
774
+ );
775
+ }, [viewTab, spaghettiPipeline.merged, userSeriesForSpaghetti, forecastData]);
776
+
777
+ const spaghettiFilteredChartData = useMemo(() => {
778
+ if (viewTab !== 'spaghetti') return [];
779
+ const filtered = filterSpaghettiDataFromEarliestForecastStart(
780
+ spaghettiWithUserSeries.mergedData,
781
+ [],
782
+ userSeriesForSpaghetti,
783
+ forecastData,
784
+ spaghettiPipeline.driftSeriesMeta,
785
+ MONTHS_24,
786
+ [...modelPerHorizonEntries, ...driftPerHorizonEntries],
787
+ perHorizonWindowStart,
788
+ perHorizonWindowEnd,
789
+ );
790
+ return mergeHistoricalIntoSpaghettiChartData(filtered, historicalChartData);
791
+ }, [
792
+ viewTab,
793
+ spaghettiWithUserSeries.mergedData,
794
+ userSeriesForSpaghetti,
795
+ forecastData,
796
+ spaghettiPipeline.driftSeriesMeta,
797
+ modelPerHorizonEntries,
798
+ driftPerHorizonEntries,
799
+ perHorizonWindowStart,
800
+ perHorizonWindowEnd,
801
+ historicalChartData,
802
+ ]);
803
+
804
+ const spaghettiDisplayedForScale = useMemo(
805
+ () =>
806
+ filterDataForTimeRange(
807
+ spaghettiFilteredChartData,
808
+ spaghettiTimeRange,
809
+ undefined,
810
+ ),
811
+ [spaghettiFilteredChartData, spaghettiTimeRange],
812
+ );
813
+
814
+ const spaghettiYScale = useMemo(() => {
815
+ if (spaghettiDisplayedForScale.length === 0) return undefined;
816
+ return calculateYRangeFromChartData(spaghettiDisplayedForScale);
817
+ }, [spaghettiDisplayedForScale]);
818
+
819
+ const spaghettiForecastItems = useMemo((): ForecastItemData[] => {
820
+ return spaghettiWithUserSeries.seriesMeta.map((meta, i) => ({
821
+ id: meta.id,
822
+ name: meta.label,
823
+ color: spaghettiForecastLineColor(meta.id, i),
824
+ }));
825
+ }, [spaghettiWithUserSeries.seriesMeta]);
826
+
827
+ useEffect(() => {
828
+ if (
829
+ performanceAnalysisId != null &&
830
+ forecastData[String(performanceAnalysisId)] &&
831
+ performanceData
832
+ ) {
833
+ setSpaghettiTimeRange(getTimeRangeForForecastLength('pin'));
834
+ }
835
+ }, [
836
+ performanceAnalysisId,
837
+ forecastData,
838
+ performanceData,
839
+ getTimeRangeForForecastLength,
840
+ ]);
841
+
842
+ useEffect(() => {
843
+ if (isEmpty && viewTab === 'spaghetti') {
844
+ setSpaghettiTimeRange('All');
845
+ }
846
+ }, [isEmpty, viewTab]);
847
+
848
+ const updatePinMonth = useCallback((month: string | undefined) => {
849
+ if (month) setPinMonth(month);
850
+ }, []);
851
+
852
+ const handlePerformanceChartTimeRangeChange = useCallback(
853
+ (val: string) => {
854
+ if (!val) return;
855
+ onTimeRangeChange?.(val, { viewTab });
856
+ if (timeRangeProp === undefined) {
857
+ if (viewTab === 'spaghetti') setSpaghettiTimeRange(val);
858
+ else setPerHorizonChartTimeRange(val);
859
+ }
860
+ },
861
+ [onTimeRangeChange, timeRangeProp, viewTab],
862
+ );
863
+
864
+ useEffect(() => {
865
+ if (filtered24mData.length > 0 && historicalData.length > 0) {
866
+ const lastHistoricalPoint = historicalData[historicalData.length - 1];
867
+ const date = new Date(lastHistoricalPoint.date);
868
+ updatePinMonth(`${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`);
869
+ }
870
+ }, [filtered24mData, historicalData, updatePinMonth]);
871
+
872
+ useEffect(() => {
873
+ if (hiddenSeriesControlled) return;
874
+ if (viewTab === 'spaghetti') {
875
+ setHiddenSeries(prev => {
876
+ const next = new Set(prev);
877
+ next.delete('forecast_1000');
878
+ next.delete('forecast_1001');
879
+ return next;
880
+ });
881
+ }
882
+ }, [viewTab, hiddenSeriesControlled, setHiddenSeries]);
883
+
884
+ useEffect(() => {
885
+ if (hiddenSeriesControlled) return;
886
+ if (viewTab === 'perHorizon') {
887
+ setHiddenSeries(prev => {
888
+ const next = new Set(prev);
889
+ let changed = false;
890
+ prev.forEach(k => {
891
+ const m = /^forecast_(\d+)$/.exec(k);
892
+ if (!m) return;
893
+ const id = parseInt(m[1], 10);
894
+ if (id >= 6000 && id < 9000) {
895
+ next.delete(k);
896
+ changed = true;
897
+ }
898
+ });
899
+ return changed ? next : prev;
900
+ });
901
+ }
902
+ }, [viewTab, hiddenSeriesControlled, setHiddenSeries]);
903
+
904
+ const performanceLineIdsForLegend = useMemo(
905
+ () =>
906
+ spaghettiWithUserSeries.seriesMeta
907
+ .filter(m => isSpaghettiModelPerHorizonLineId(m.id))
908
+ .map(m => m.id),
909
+ [spaghettiWithUserSeries.seriesMeta],
910
+ );
911
+
912
+ const driftLineIdsForLegend = useMemo(
913
+ () =>
914
+ spaghettiWithUserSeries.seriesMeta
915
+ .filter(m => isSpaghettiDriftPerHorizonLineId(m.id))
916
+ .map(m => m.id),
917
+ [spaghettiWithUserSeries.seriesMeta],
918
+ );
919
+
920
+ const customMatrixLineIdsForLegend = useMemo(
921
+ () =>
922
+ spaghettiWithUserSeries.seriesMeta
923
+ .filter(m => isSpaghettiMatrixSyntheticLineId(m.id))
924
+ .map(m => m.id),
925
+ [spaghettiWithUserSeries.seriesMeta],
926
+ );
927
+
928
+ const customMatrixLegendGroupLabel = useMemo(() => {
929
+ const first = spaghettiWithUserSeries.seriesMeta.find(m =>
930
+ isSpaghettiMatrixSyntheticLineId(m.id),
931
+ );
932
+ if (!first?.label) return 'Custom performance';
933
+ const head = first.label.split(' · ')[0]?.trim();
934
+ return head || 'Custom performance';
935
+ }, [spaghettiWithUserSeries.seriesMeta]);
936
+
937
+ const performanceGroupHidden = useMemo(() => {
938
+ if (performanceLineIdsForLegend.length === 0) return false;
939
+ return performanceLineIdsForLegend.every(id =>
940
+ hiddenSeries.has(`forecast_${id}`),
941
+ );
942
+ }, [performanceLineIdsForLegend, hiddenSeries]);
943
+
944
+ const driftGroupHidden = useMemo(() => {
945
+ if (driftLineIdsForLegend.length === 0) return false;
946
+ return driftLineIdsForLegend.every(id =>
947
+ hiddenSeries.has(`forecast_${id}`),
948
+ );
949
+ }, [driftLineIdsForLegend, hiddenSeries]);
950
+
951
+ const customMatrixGroupHidden = useMemo(() => {
952
+ if (customMatrixLineIdsForLegend.length === 0) return false;
953
+ return customMatrixLineIdsForLegend.every(id =>
954
+ hiddenSeries.has(`forecast_${id}`),
955
+ );
956
+ }, [customMatrixLineIdsForLegend, hiddenSeries]);
957
+
958
+ const toggleSpaghettiGroup = useCallback(
959
+ (group: 'performance' | 'drift' | 'custom') => {
960
+ const ids =
961
+ group === 'performance'
962
+ ? performanceLineIdsForLegend
963
+ : group === 'drift'
964
+ ? driftLineIdsForLegend
965
+ : customMatrixLineIdsForLegend;
966
+ if (ids.length === 0) return;
967
+ setHiddenSeries(prev => {
968
+ const next = new Set(prev);
969
+ const allHidden = ids.every(id => next.has(`forecast_${id}`));
970
+ if (allHidden) ids.forEach(id => next.delete(`forecast_${id}`));
971
+ else ids.forEach(id => next.add(`forecast_${id}`));
972
+ return next;
973
+ });
974
+ },
975
+ [
976
+ performanceLineIdsForLegend,
977
+ driftLineIdsForLegend,
978
+ customMatrixLineIdsForLegend,
979
+ setHiddenSeries,
980
+ ],
981
+ );
982
+
983
+ const performanceHistoricalLegendLineColor = isDarkTheme
984
+ ? '#ffffff'
985
+ : '#000000';
986
+
987
+ const performanceUnderChartLegend = useMemo(() => {
988
+ if (isEmpty) return null;
989
+ const items: PerformanceUnderChartLegendItemConfig[] = [];
990
+ if (historicalChartData.length > 0) {
991
+ items.push({
992
+ id: 'historical',
993
+ label: 'Historical',
994
+ lineColor: performanceHistoricalLegendLineColor,
995
+ lineStyle: 'solid',
996
+ hidden: hiddenSeries.has('historical'),
997
+ });
998
+ }
999
+ if (viewTab === 'perHorizon') {
1000
+ chartForecastData.forEach(item => {
1001
+ items.push({
1002
+ id: `forecast-${item.id}`,
1003
+ label: item.name ?? `Series ${item.id}`,
1004
+ lineColor: String(item.color ?? 'var(--foreground)'),
1005
+ lineStyle: 'dashed',
1006
+ hidden: hiddenSeries.has(`forecast_${item.id}`),
1007
+ onToggle: () => toggleLegendSeriesKey(`forecast_${item.id}`),
1008
+ });
1009
+ });
1010
+ }
1011
+ if (viewTab === 'spaghetti') {
1012
+ if (performanceLineIdsForLegend.length > 0) {
1013
+ items.push({
1014
+ id: 'spaghetti-performance',
1015
+ label: 'Sybilion AI',
1016
+ lineColor: SPAGHETTI_MODEL_LINE_COLOR,
1017
+ lineStyle: 'dashed',
1018
+ hidden: performanceGroupHidden,
1019
+ onToggle: () => toggleSpaghettiGroup('performance'),
1020
+ });
1021
+ }
1022
+ if (driftLineIdsForLegend.length > 0) {
1023
+ items.push({
1024
+ id: 'spaghetti-drift',
1025
+ label: getForecastModelDisplayName('drift'),
1026
+ lineColor: SPAGHETTI_DRIFT_LINE_COLOR,
1027
+ lineStyle: 'dashed',
1028
+ hidden: driftGroupHidden,
1029
+ onToggle: () => toggleSpaghettiGroup('drift'),
1030
+ });
1031
+ }
1032
+ if (customMatrixLineIdsForLegend.length > 0) {
1033
+ items.push({
1034
+ id: 'spaghetti-custom-matrix',
1035
+ label: customMatrixLegendGroupLabel,
1036
+ lineColor: SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR,
1037
+ lineStyle: 'dashed',
1038
+ hidden: customMatrixGroupHidden,
1039
+ onToggle: () => toggleSpaghettiGroup('custom'),
1040
+ });
1041
+ }
1042
+ }
1043
+ return <PerformanceUnderChartLegendFromItems items={items} />;
1044
+ }, [
1045
+ isEmpty,
1046
+ viewTab,
1047
+ historicalChartData.length,
1048
+ chartForecastData,
1049
+ performanceHistoricalLegendLineColor,
1050
+ hiddenSeries,
1051
+ toggleLegendSeriesKey,
1052
+ performanceLineIdsForLegend.length,
1053
+ driftLineIdsForLegend.length,
1054
+ performanceGroupHidden,
1055
+ driftGroupHidden,
1056
+ toggleSpaghettiGroup,
1057
+ customMatrixLineIdsForLegend.length,
1058
+ customMatrixLegendGroupLabel,
1059
+ customMatrixGroupHidden,
1060
+ ]);
1061
+
1062
+ const isSpaghettiView = viewTab === 'spaghetti';
1063
+ const hasSpaghettiPoints = spaghettiFilteredChartData.length > 0;
1064
+ const spaghettiChartLoading = isSpaghettiView && performanceSectionPending;
1065
+ const spaghettiTabShowsChartData = isSpaghettiView && hasSpaghettiPoints;
1066
+ const spaghettiOverridesPerformanceEmpty =
1067
+ isSpaghettiView &&
1068
+ (hasSpaghettiPoints || performanceDataLoading || spaghettiChartLoading);
1069
+
1070
+ const isLoaded = useMemo(
1071
+ () =>
1072
+ !loading &&
1073
+ !performanceDataLoading &&
1074
+ perfFetchSettled &&
1075
+ (!isEmpty || spaghettiOverridesPerformanceEmpty),
1076
+ [
1077
+ loading,
1078
+ performanceDataLoading,
1079
+ perfFetchSettled,
1080
+ isEmpty,
1081
+ spaghettiOverridesPerformanceEmpty,
1082
+ ],
1083
+ );
1084
+
1085
+ const spaghettiNoDataMessage =
1086
+ isSpaghettiView &&
1087
+ perfFetchSettled &&
1088
+ !performanceDataLoading &&
1089
+ spaghettiFilteredChartData.length === 0
1090
+ ? 'No spaghetti plot data for this dataset.'
1091
+ : undefined;
1092
+
1093
+ const activeTimeRange =
1094
+ timeRangeProp ??
1095
+ (isSpaghettiView ? spaghettiTimeRange : perHorizonChartTimeRange);
1096
+
1097
+ const perHorizonChartProps =
1098
+ viewTab === 'perHorizon'
1099
+ ? {
1100
+ chartData: filtered24mData,
1101
+ forecastData: chartForecastData,
1102
+ yMin: maxYScale?.yMin,
1103
+ yMax: maxYScale?.yMax,
1104
+ loading: performanceSectionPending,
1105
+ noDataMessage: undefined as string | undefined,
1106
+ noDataStatusTone: undefined as 'muted' | 'destructive' | undefined,
1107
+ hasCombinedData: undefined as boolean | undefined,
1108
+ }
1109
+ : {
1110
+ chartData: spaghettiFilteredChartData,
1111
+ forecastData: spaghettiForecastItems,
1112
+ yMin: spaghettiYScale?.yMin ?? maxYScale?.yMin,
1113
+ yMax: spaghettiYScale?.yMax ?? maxYScale?.yMax,
1114
+ loading: spaghettiChartLoading,
1115
+ noDataMessage: spaghettiNoDataMessage,
1116
+ noDataStatusTone: spaghettiNoDataMessage
1117
+ ? ('destructive' as const)
1118
+ : undefined,
1119
+ hasCombinedData: spaghettiFilteredChartData.length > 0,
1120
+ };
1121
+
1122
+ const showEmptyOverlay =
1123
+ (runAnalysisHint || Boolean(statusHint) || isEmpty) && !loading;
1124
+
1125
+ return (
1126
+ <div
1127
+ className={cn(
1128
+ S.root,
1129
+ performanceSectionPending && !spaghettiTabShowsChartData && S.loading,
1130
+ isLoaded && S.loaded,
1131
+ isEmpty && S.isEmpty,
1132
+ className,
1133
+ )}
1134
+ >
1135
+ <Tabs
1136
+ variant="button"
1137
+ value={viewTab}
1138
+ onValueChange={v => {
1139
+ if (v === 'perHorizon' || v === 'spaghetti') {
1140
+ setViewTab(v);
1141
+ }
1142
+ }}
1143
+ className={S.viewTabs}
1144
+ >
1145
+ <div className={S.toolbarRow}>
1146
+ {toolbarStart}
1147
+ <TabsList className={S.viewTabsList}>
1148
+ <TabsTrigger value="perHorizon">Per horizon plot</TabsTrigger>
1149
+ <TabsTrigger value="spaghetti">Spaghetti plots</TabsTrigger>
1150
+ </TabsList>
1151
+ <div className={S.toolBarRight}>
1152
+ {viewTab === 'perHorizon' && performanceData && (
1153
+ <HorizonsSelector
1154
+ selectedHorizon={selectedHorizon}
1155
+ onHorizonChange={setSelectedHorizon}
1156
+ availableHorizons={availableHorizons}
1157
+ />
1158
+ )}
1159
+ </div>
1160
+ </div>
1161
+
1162
+ <div
1163
+ className={cn(
1164
+ S.perHorizonChartWrap,
1165
+ performanceSectionPending && S.perHorizonChartWrapLoading,
1166
+ )}
1167
+ >
1168
+ <ChartAreaInteractive
1169
+ disableHistoricalAnimation
1170
+ disableTimeRangeSelector
1171
+ enableTimeRangeBrush
1172
+ disableAnimation={isSpaghettiView}
1173
+ chartContainerClassName={S.chartContainer}
1174
+ chartData={perHorizonChartProps.chartData}
1175
+ forecastData={perHorizonChartProps.forecastData}
1176
+ timeRange={activeTimeRange}
1177
+ onTimeRangeChange={handlePerformanceChartTimeRangeChange}
1178
+ pinMonth={pinMonth}
1179
+ onPinMonthChange={updatePinMonth}
1180
+ isDarkTheme={isDarkTheme}
1181
+ loading={perHorizonChartProps.loading || chartLoading}
1182
+ yMin={perHorizonChartProps.yMin}
1183
+ yMax={perHorizonChartProps.yMax}
1184
+ autoScaleYAxis={false}
1185
+ maxVisibleItems={2}
1186
+ preventDeselection
1187
+ showLegend={false}
1188
+ footerActions={performanceUnderChartLegend ?? undefined}
1189
+ hasCombinedData={perHorizonChartProps.hasCombinedData}
1190
+ hiddenSeries={hiddenSeries}
1191
+ toggleLegendSeries={toggleLegendSeriesKey}
1192
+ ensureAnalysisSeriesVisible={ensureSeriesVisible}
1193
+ />
1194
+
1195
+ {showEmptyOverlay && (
1196
+ <div
1197
+ className={S.chartEmptyOverlay}
1198
+ role="status"
1199
+ aria-live="polite"
1200
+ >
1201
+ <ChartEmptyState
1202
+ variant="inline"
1203
+ hint={
1204
+ runAnalysisHint ? (
1205
+ 'Run a completed analysis to load performance data for an analysis.'
1206
+ ) : isEmpty ? (
1207
+ <>
1208
+ No performance data for this dataset.
1209
+ <br />
1210
+ Please contact{' '}
1211
+ <a href="mailto:support@sybilion.com">support team</a> to
1212
+ gain access to the latest performance data.
1213
+ </>
1214
+ ) : undefined
1215
+ }
1216
+ status={statusHint ?? undefined}
1217
+ statusTone={statusTone}
1218
+ />
1219
+ </div>
1220
+ )}
1221
+
1222
+ {performanceSectionPending && (
1223
+ <div
1224
+ className={S.performanceLoadingLayer}
1225
+ aria-busy="true"
1226
+ aria-live="polite"
1227
+ >
1228
+ <div className={S.emptyState}>
1229
+ <TextShimmer as="span" className={S.performanceLoadingText}>
1230
+ Loading performance data…
1231
+ </TextShimmer>
1232
+ </div>
1233
+ </div>
1234
+ )}
1235
+ </div>
1236
+
1237
+ {!performanceDataLoading && performanceData && (
1238
+ <PerformanceTable
1239
+ forecastModels={forecastModelsData}
1240
+ adjustParameters={adjustParameters}
1241
+ onAdjustParametersChange={setAdjustParameters}
1242
+ customPerformance={customPerformanceTableRow}
1243
+ onEditCustomPerformance={onEditCustomPerformance}
1244
+ showAddEditCustomDataButton={showAddEditCustomDataButton}
1245
+ addEditCustomDataDisabled={addEditCustomDataDisabled}
1246
+ />
1247
+ )}
1248
+ </Tabs>
1249
+ </div>
1250
+ );
1251
+ }