@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,807 @@
1
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
+ import cn from 'classnames';
3
+ import { useState, useCallback, useMemo, useEffect } from 'react';
4
+ import { ChartEmptyState } from '../../ui/Chart/components/ChartEmptyState/ChartEmptyState.js';
5
+ import { ChartAreaInteractive } from '../../ui/ChartAreaInteractive/ChartAreaInteractive.js';
6
+ import { filterDataForTimeRange } from '../../ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js';
7
+ import { FORECAST_LINE_COLORS } from '../../ui/ChartAreaInteractive/ChartLines.js';
8
+ import { Tabs, TabsList, TabsTrigger } from '../../ui/Tabs/Tabs.js';
9
+ import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
10
+ import '../../ui/TimeRangeControls/TimeRangeControls.js';
11
+ import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
12
+ import { normalizeToMonthStart } from '../../../utils/chartConnectionPoint.js';
13
+ import { HorizonsSelector } from './HorizonsSelector/HorizonsSelector.js';
14
+ import { MONTHS_24, MONTH_NAMES } from './PerformanceChart.constants.js';
15
+ import S from './PerformanceChart.styl.js';
16
+ import { PerformanceTable } from './PerformanceTable.js';
17
+ import { PerformanceUnderChartLegendFromItems } from './PerformanceUnderChartLegend/PerformanceUnderChartLegend.js';
18
+ import { getForecastModelDisplayName, averageForecastErrorsVsHistoricalForMatrixColumn, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, mergeSpaghettiMergedBases, mergeSpaghettiUserSeriesFromForecastData, filterSpaghettiDataFromEarliestForecastStart, mergeHistoricalIntoSpaghettiChartData, calculateYRangeFromChartData, isSpaghettiModelPerHorizonLineId, isSpaghettiDriftPerHorizonLineId } from './performanceChart.helpers.js';
19
+ import { getCustomMatrixSeriesForHorizonTab, isSpaghettiMatrixSyntheticLineId, SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID } from './performanceChartUserSeries.js';
20
+
21
+ const SPAGHETTI_DRIFT_LINE_COLOR = FORECAST_LINE_COLORS[0];
22
+ const SPAGHETTI_MODEL_LINE_COLOR = 'var(--sb-cyan-400)';
23
+ const SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR = 'var(--sb-purple-400)';
24
+ function reorderSpaghettiMatrixToHorizons(m, targetKeys) {
25
+ if (m.horizonKeys.length !== targetKeys.length)
26
+ return m;
27
+ const idxMap = targetKeys.map(tk => m.horizonKeys.indexOf(tk));
28
+ if (idxMap.some(j => j < 0))
29
+ return m;
30
+ const perHorizonDates = m.perHorizonDates &&
31
+ m.perHorizonDates.length === m.grid.length &&
32
+ m.perHorizonDates.every((row, r) => row.length === m.horizonKeys.length && row.length === m.grid[r].length)
33
+ ? m.perHorizonDates.map(row => idxMap.map(j => row[j]))
34
+ : m.perHorizonDates;
35
+ return {
36
+ ...m,
37
+ horizonKeys: [...targetKeys],
38
+ grid: m.grid.map(row => idxMap.map(j => row[j])),
39
+ perHorizonDates,
40
+ };
41
+ }
42
+ function spaghettiForecastLineColor(id, index) {
43
+ if (isSpaghettiDriftPerHorizonLineId(id))
44
+ return SPAGHETTI_DRIFT_LINE_COLOR;
45
+ if (isSpaghettiModelPerHorizonLineId(id))
46
+ return SPAGHETTI_MODEL_LINE_COLOR;
47
+ if (isSpaghettiMatrixSyntheticLineId(id))
48
+ return SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR;
49
+ return FORECAST_LINE_COLORS[index % FORECAST_LINE_COLORS.length];
50
+ }
51
+ function PerformanceChart({ performanceData, historicalData = [], combinedData = [], forecastData = {}, userSeries = [], spaghettiUserSeries = [], customPerformanceMatrix = null, customPerformanceLabel = null, loading = false, chartLoading = false, performanceSectionPending = false, isEmpty = false, perfFetchSettled = true, performanceDataLoading = false, performanceAnalysisId = null, statusHint = null, statusTone = 'muted', runAnalysisHint = false, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme = false, className, seriesInitKey, toolbarStart, onEditCustomPerformance, showAddEditCustomDataButton = false, addEditCustomDataDisabled = false, hiddenSeries: hiddenSeriesProp, onToggleLegendSeries, onEnsureSeriesVisible, onHiddenSeriesChange, }) {
52
+ const [viewTab, setViewTab] = useState('perHorizon');
53
+ const [pinMonth, setPinMonth] = useState(undefined);
54
+ const [spaghettiTimeRange, setSpaghettiTimeRange] = useState('All');
55
+ const [perHorizonChartTimeRange, setPerHorizonChartTimeRange] = useState('All');
56
+ const [internalHiddenSeries, setInternalHiddenSeries] = useState(new Set());
57
+ const [adjustParameters, setAdjustParameters] = useState({
58
+ procurementVolume: 100000000,
59
+ variableRawMaterialCostShare: 50,
60
+ controllableCosts: 60,
61
+ currentForecastAccuracy: 70,
62
+ expectedImprovement: 10,
63
+ analystHourlyRate: 80,
64
+ });
65
+ const hiddenSeriesControlled = hiddenSeriesProp !== undefined;
66
+ const hiddenSeries = hiddenSeriesProp ?? internalHiddenSeries;
67
+ const setHiddenSeries = useCallback((update) => {
68
+ if (onHiddenSeriesChange) {
69
+ onHiddenSeriesChange(update);
70
+ return;
71
+ }
72
+ setInternalHiddenSeries(update);
73
+ }, [onHiddenSeriesChange]);
74
+ const toggleLegendSeriesKey = useCallback((key) => {
75
+ if (onToggleLegendSeries) {
76
+ onToggleLegendSeries(key);
77
+ return;
78
+ }
79
+ setHiddenSeries(prev => {
80
+ const next = new Set(prev);
81
+ if (next.has(key))
82
+ next.delete(key);
83
+ else
84
+ next.add(key);
85
+ return next;
86
+ });
87
+ }, [onToggleLegendSeries, setHiddenSeries]);
88
+ const ensureSeriesVisible = useCallback((key) => {
89
+ if (onEnsureSeriesVisible) {
90
+ onEnsureSeriesVisible(key);
91
+ return;
92
+ }
93
+ setHiddenSeries(prev => {
94
+ if (!prev.has(key))
95
+ return prev;
96
+ const next = new Set(prev);
97
+ next.delete(key);
98
+ return next;
99
+ });
100
+ }, [onEnsureSeriesVisible, setHiddenSeries]);
101
+ const userSeriesForSpaghetti = useMemo(() => [...userSeries, ...spaghettiUserSeries], [userSeries, spaghettiUserSeries]);
102
+ const availableHorizons = useMemo(() => {
103
+ if (!performanceData)
104
+ return [];
105
+ const driftModelData = performanceData['drift'];
106
+ if (!driftModelData)
107
+ return [];
108
+ const forecasts = driftModelData?.forecasts;
109
+ if (forecasts && typeof forecasts === 'object') {
110
+ return Object.keys(forecasts).filter(key => key.startsWith('horizon_'));
111
+ }
112
+ return [];
113
+ }, [performanceData]);
114
+ const sortedHorizonKeysForCustom = useMemo(() => [...availableHorizons].sort((a, b) => {
115
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
116
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
117
+ return na - nb;
118
+ }), [availableHorizons]);
119
+ const parsedCustomMatrixForCharts = useMemo(() => {
120
+ if (!customPerformanceMatrix)
121
+ return null;
122
+ return {
123
+ matrix: reorderSpaghettiMatrixToHorizons(customPerformanceMatrix, sortedHorizonKeysForCustom),
124
+ displayLabel: customPerformanceLabel?.trim() || 'Custom performance',
125
+ };
126
+ }, [
127
+ customPerformanceMatrix,
128
+ customPerformanceLabel,
129
+ sortedHorizonKeysForCustom,
130
+ ]);
131
+ const [selectedHorizon, setSelectedHorizon] = useState(availableHorizons[0] || 'horizon_1');
132
+ useEffect(() => {
133
+ if (availableHorizons.length > 0 &&
134
+ !availableHorizons.includes(selectedHorizon)) {
135
+ setSelectedHorizon(availableHorizons[0]);
136
+ }
137
+ }, [availableHorizons, selectedHorizon]);
138
+ const getTimeRangeForForecastLength = useCallback((mode) => {
139
+ if (mode === 'pin')
140
+ return '5y';
141
+ if (performanceAnalysisId == null ||
142
+ !forecastData[String(performanceAnalysisId)]) {
143
+ return TIME_RANGES[0];
144
+ }
145
+ const forecastDataForSelected = forecastData[String(performanceAnalysisId)];
146
+ const dates = forecastDataForSelected.dates;
147
+ if (!dates || dates.length === 0)
148
+ return TIME_RANGES[0];
149
+ const firstDate = new Date(dates[0]);
150
+ const lastDate = new Date(dates[dates.length - 1]);
151
+ const monthsDiff = (lastDate.getFullYear() - firstDate.getFullYear()) * 12 +
152
+ (lastDate.getMonth() - firstDate.getMonth()) +
153
+ 1;
154
+ if (monthsDiff < 6)
155
+ return '6m';
156
+ if (monthsDiff < 12)
157
+ return '1y';
158
+ if (monthsDiff < 36)
159
+ return '3y';
160
+ if (monthsDiff < 60)
161
+ return '5y';
162
+ return 'All';
163
+ }, [performanceAnalysisId, forecastData]);
164
+ const forecastModelsData = useMemo(() => {
165
+ if (!performanceData || !selectedHorizon)
166
+ return [];
167
+ const models = [];
168
+ ['model', 'drift'].forEach(key => {
169
+ const modelData = performanceData[key];
170
+ const horizonMetrics = modelData?.metrics_history?.[selectedHorizon];
171
+ if (horizonMetrics?.mae && horizonMetrics?.mape) {
172
+ const maeValues = Object.values(horizonMetrics.mae);
173
+ const mapeValues = Object.values(horizonMetrics.mape);
174
+ models.push({
175
+ key,
176
+ mae: maeValues.reduce((sum, val) => sum + val, 0) / maeValues.length,
177
+ mape: mapeValues.reduce((sum, val) => sum + val, 0) / mapeValues.length,
178
+ });
179
+ }
180
+ });
181
+ return models;
182
+ }, [performanceData, selectedHorizon]);
183
+ const historicalChartData = useMemo(() => historicalData.map(point => ({ ...point })), [historicalData]);
184
+ const chartForecastData = useMemo(() => {
185
+ const forecastItems = [];
186
+ if (performanceData) {
187
+ ['model', 'drift'].forEach((key, index) => {
188
+ const modelData = performanceData[key];
189
+ const horizonData = modelData?.forecasts?.[selectedHorizon];
190
+ if (horizonData) {
191
+ forecastItems.push({
192
+ id: index + 1000,
193
+ name: getForecastModelDisplayName(key),
194
+ color: key === 'model' ? 'var(--sb-cyan-400)' : FORECAST_LINE_COLORS[0],
195
+ });
196
+ }
197
+ });
198
+ }
199
+ if (parsedCustomMatrixForCharts) {
200
+ const slice = getCustomMatrixSeriesForHorizonTab(parsedCustomMatrixForCharts.matrix, selectedHorizon, SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, forecastData);
201
+ if (slice) {
202
+ forecastItems.push({
203
+ id: slice.lineId,
204
+ name: parsedCustomMatrixForCharts.displayLabel,
205
+ color: SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR,
206
+ });
207
+ }
208
+ }
209
+ return forecastItems;
210
+ }, [
211
+ performanceData,
212
+ selectedHorizon,
213
+ parsedCustomMatrixForCharts,
214
+ forecastData,
215
+ ]);
216
+ const forecastChartData = useMemo(() => {
217
+ const chartDataMap = new Map();
218
+ const modelKeys = ['model', 'drift'];
219
+ const normalizeDate = (dateStr) => dateStr.split(' ')[0];
220
+ const latestHistoricalDate = historicalChartData.length > 0
221
+ ? historicalChartData[historicalChartData.length - 1].date
222
+ : null;
223
+ historicalChartData.forEach(point => {
224
+ chartDataMap.set(point.date, { ...point });
225
+ });
226
+ if (performanceData) {
227
+ modelKeys.forEach(key => {
228
+ const index = modelKeys.indexOf(key);
229
+ const modelData = performanceData[key];
230
+ const horizonData = modelData?.forecasts?.[selectedHorizon];
231
+ const dataKey = `forecast_${index + 1000}`;
232
+ if (horizonData) {
233
+ Object.entries(horizonData).forEach(([date, value]) => {
234
+ if (value !== null && value !== undefined) {
235
+ const normalizedDate = normalizeDate(String(date));
236
+ if (latestHistoricalDate &&
237
+ normalizedDate > latestHistoricalDate) {
238
+ return;
239
+ }
240
+ const existingPoint = chartDataMap.get(normalizedDate);
241
+ if (existingPoint) {
242
+ chartDataMap.set(normalizedDate, {
243
+ ...existingPoint,
244
+ [dataKey]: value,
245
+ });
246
+ }
247
+ else {
248
+ chartDataMap.set(normalizedDate, {
249
+ date: normalizedDate,
250
+ [dataKey]: value,
251
+ });
252
+ }
253
+ }
254
+ });
255
+ }
256
+ });
257
+ }
258
+ const customSlice = parsedCustomMatrixForCharts
259
+ ? getCustomMatrixSeriesForHorizonTab(parsedCustomMatrixForCharts.matrix, selectedHorizon, SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, forecastData)
260
+ : null;
261
+ if (customSlice) {
262
+ const dataKey = `forecast_${customSlice.lineId}`;
263
+ const n = Math.min(customSlice.dates.length, customSlice.forecastValues.length);
264
+ for (let i = 0; i < n; i++) {
265
+ const value = customSlice.forecastValues[i];
266
+ if (typeof value !== 'number' || !Number.isFinite(value))
267
+ continue;
268
+ const normalizedDate = normalizeDate(String(customSlice.dates[i]));
269
+ if (latestHistoricalDate && normalizedDate > latestHistoricalDate) {
270
+ continue;
271
+ }
272
+ const existingPoint = chartDataMap.get(normalizedDate);
273
+ if (existingPoint) {
274
+ chartDataMap.set(normalizedDate, {
275
+ ...existingPoint,
276
+ [dataKey]: value,
277
+ });
278
+ }
279
+ else {
280
+ chartDataMap.set(normalizedDate, {
281
+ date: normalizedDate,
282
+ [dataKey]: value,
283
+ });
284
+ }
285
+ }
286
+ }
287
+ const sortedData = Array.from(chartDataMap.values()).sort((a, b) => a.date.localeCompare(b.date));
288
+ if (latestHistoricalDate) {
289
+ return sortedData.filter(point => {
290
+ if (historicalChartData.some(h => h.date === point.date))
291
+ return true;
292
+ return point.date <= latestHistoricalDate;
293
+ });
294
+ }
295
+ return sortedData;
296
+ }, [
297
+ performanceData,
298
+ selectedHorizon,
299
+ historicalChartData,
300
+ parsedCustomMatrixForCharts,
301
+ forecastData,
302
+ ]);
303
+ const maxYScale = useMemo(() => {
304
+ if (!performanceData || availableHorizons.length === 0)
305
+ return undefined;
306
+ const modelKeys = ['model', 'drift'];
307
+ const normalizeDate = (dateStr) => dateStr.split(' ')[0];
308
+ const latestHistoricalDate = historicalChartData.length > 0
309
+ ? historicalChartData[historicalChartData.length - 1].date
310
+ : null;
311
+ const allValues = [];
312
+ availableHorizons.forEach(horizon => {
313
+ const chartDataMap = new Map();
314
+ historicalChartData.forEach(point => {
315
+ chartDataMap.set(point.date, { ...point });
316
+ });
317
+ modelKeys.forEach(key => {
318
+ const index = modelKeys.indexOf(key);
319
+ const modelData = performanceData[key];
320
+ const horizonData = modelData?.forecasts?.[horizon];
321
+ const dataKey = `forecast_${index + 1000}`;
322
+ if (horizonData) {
323
+ Object.entries(horizonData).forEach(([date, value]) => {
324
+ if (value !== null && value !== undefined) {
325
+ const normalizedDate = normalizeDate(String(date));
326
+ if (latestHistoricalDate &&
327
+ normalizedDate > latestHistoricalDate) {
328
+ return;
329
+ }
330
+ const existingPoint = chartDataMap.get(normalizedDate);
331
+ if (existingPoint) {
332
+ chartDataMap.set(normalizedDate, {
333
+ ...existingPoint,
334
+ [dataKey]: value,
335
+ });
336
+ }
337
+ else {
338
+ chartDataMap.set(normalizedDate, {
339
+ date: normalizedDate,
340
+ [dataKey]: value,
341
+ });
342
+ }
343
+ }
344
+ });
345
+ }
346
+ });
347
+ if (parsedCustomMatrixForCharts) {
348
+ const customSlice = getCustomMatrixSeriesForHorizonTab(parsedCustomMatrixForCharts.matrix, horizon, SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, forecastData);
349
+ if (customSlice) {
350
+ const dataKey = `forecast_${customSlice.lineId}`;
351
+ const n = Math.min(customSlice.dates.length, customSlice.forecastValues.length);
352
+ for (let i = 0; i < n; i++) {
353
+ const value = customSlice.forecastValues[i];
354
+ if (typeof value !== 'number' || !Number.isFinite(value))
355
+ continue;
356
+ const normalizedDate = normalizeDate(String(customSlice.dates[i]));
357
+ if (latestHistoricalDate && normalizedDate > latestHistoricalDate) {
358
+ continue;
359
+ }
360
+ const existingPoint = chartDataMap.get(normalizedDate);
361
+ if (existingPoint) {
362
+ chartDataMap.set(normalizedDate, {
363
+ ...existingPoint,
364
+ [dataKey]: value,
365
+ });
366
+ }
367
+ else {
368
+ chartDataMap.set(normalizedDate, {
369
+ date: normalizedDate,
370
+ [dataKey]: value,
371
+ });
372
+ }
373
+ }
374
+ }
375
+ }
376
+ const sortedData = Array.from(chartDataMap.values()).sort((a, b) => a.date.localeCompare(b.date));
377
+ const filteredData = latestHistoricalDate
378
+ ? sortedData.filter(point => {
379
+ if (historicalChartData.some(h => h.date === point.date)) {
380
+ return true;
381
+ }
382
+ return point.date <= latestHistoricalDate;
383
+ })
384
+ : sortedData;
385
+ if (filteredData.length > 0) {
386
+ const cutoffDate = new Date(filteredData[filteredData.length - 1].date);
387
+ cutoffDate.setMonth(cutoffDate.getMonth() - MONTHS_24);
388
+ filteredData
389
+ .filter(point => new Date(point.date) >= cutoffDate)
390
+ .forEach(point => {
391
+ Object.entries(point).forEach(([key, value]) => {
392
+ if (key === 'date')
393
+ return;
394
+ if (typeof value === 'number')
395
+ allValues.push(value);
396
+ });
397
+ });
398
+ }
399
+ });
400
+ if (allValues.length === 0)
401
+ return undefined;
402
+ const min = Math.min(...allValues);
403
+ const max = Math.max(...allValues);
404
+ const diff = max - min;
405
+ return { yMin: min - diff * 0.1, yMax: max + diff * 0.1 };
406
+ }, [
407
+ performanceData,
408
+ availableHorizons,
409
+ historicalChartData,
410
+ parsedCustomMatrixForCharts,
411
+ forecastData,
412
+ ]);
413
+ const filtered24mData = useMemo(() => {
414
+ const dataToUse = forecastChartData.length > 0 ? forecastChartData : combinedData;
415
+ if (dataToUse.length === 0)
416
+ return [];
417
+ const sorted = [...dataToUse].sort((a, b) => a.date.localeCompare(b.date));
418
+ const cutoffDate = new Date(sorted[sorted.length - 1].date);
419
+ cutoffDate.setMonth(cutoffDate.getMonth() - MONTHS_24);
420
+ return sorted.filter(point => new Date(point.date) >= cutoffDate);
421
+ }, [forecastChartData, combinedData]);
422
+ const historicalByDateForCustomMetrics = useMemo(() => {
423
+ const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
424
+ const m = new Map();
425
+ historicalChartData.forEach(p => {
426
+ const v = p.historical;
427
+ if (typeof v === 'number' && Number.isFinite(v)) {
428
+ m.set(norm(p.date), v);
429
+ }
430
+ });
431
+ return m;
432
+ }, [historicalChartData]);
433
+ const customPerformanceTableRow = useMemo(() => {
434
+ if (!parsedCustomMatrixForCharts)
435
+ return null;
436
+ const colIdx = sortedHorizonKeysForCustom.indexOf(selectedHorizon);
437
+ if (colIdx < 0)
438
+ return null;
439
+ const errs = averageForecastErrorsVsHistoricalForMatrixColumn(parsedCustomMatrixForCharts.matrix, colIdx, historicalByDateForCustomMetrics);
440
+ if (!errs)
441
+ return null;
442
+ return {
443
+ label: parsedCustomMatrixForCharts.displayLabel,
444
+ mae: errs.mae,
445
+ mape: errs.mape,
446
+ };
447
+ }, [
448
+ parsedCustomMatrixForCharts,
449
+ sortedHorizonKeysForCustom,
450
+ selectedHorizon,
451
+ historicalByDateForCustomMetrics,
452
+ ]);
453
+ const perHorizonWindowStart = useMemo(() => {
454
+ if (filtered24mData.length === 0)
455
+ return null;
456
+ const sorted = [...filtered24mData].sort((a, b) => a.date.localeCompare(b.date));
457
+ return sorted[0]?.date ?? null;
458
+ }, [filtered24mData]);
459
+ const perHorizonWindowEnd = useMemo(() => {
460
+ if (filtered24mData.length === 0)
461
+ return null;
462
+ const sorted = [...filtered24mData].sort((a, b) => a.date.localeCompare(b.date));
463
+ return sorted[sorted.length - 1]?.date ?? null;
464
+ }, [filtered24mData]);
465
+ const modelPerHorizonEntries = useMemo(() => buildPerHorizonSpaghettiEntries(performanceData?.model, availableHorizons), [performanceData, availableHorizons]);
466
+ const driftPerHorizonEntries = useMemo(() => buildPerHorizonSpaghettiEntries(performanceData?.drift, availableHorizons), [performanceData, availableHorizons]);
467
+ const spaghettiPipeline = useMemo(() => {
468
+ if (viewTab !== 'spaghetti') {
469
+ return {
470
+ merged: {
471
+ mergedData: [],
472
+ seriesMeta: [],
473
+ },
474
+ driftSeriesMeta: [],
475
+ };
476
+ }
477
+ const driftMerged = driftPerHorizonEntries.length > 0
478
+ ? buildSpaghettiMergedChartData(driftPerHorizonEntries, historicalChartData, SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE)
479
+ : { mergedData: [], seriesMeta: [] };
480
+ const modelMerged = modelPerHorizonEntries.length > 0
481
+ ? buildSpaghettiMergedChartData(modelPerHorizonEntries, historicalChartData, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE)
482
+ : { mergedData: [], seriesMeta: [] };
483
+ let merged;
484
+ if (driftMerged.seriesMeta.length === 0 &&
485
+ modelMerged.seriesMeta.length === 0) {
486
+ const historicalOnly = historicalChartData.length > 0
487
+ ? [...historicalChartData]
488
+ .map(p => ({
489
+ ...p,
490
+ date: normalizeToMonthStart(String(p.date).split(' ')[0] ?? p.date),
491
+ }))
492
+ .sort((a, b) => a.date.localeCompare(b.date))
493
+ : [];
494
+ merged = { mergedData: historicalOnly, seriesMeta: [] };
495
+ }
496
+ else if (driftMerged.seriesMeta.length === 0) {
497
+ merged = modelMerged;
498
+ }
499
+ else if (modelMerged.seriesMeta.length === 0) {
500
+ merged = driftMerged;
501
+ }
502
+ else {
503
+ merged = mergeSpaghettiMergedBases(driftMerged, modelMerged);
504
+ }
505
+ return { merged, driftSeriesMeta: driftMerged.seriesMeta };
506
+ }, [
507
+ viewTab,
508
+ driftPerHorizonEntries,
509
+ historicalChartData,
510
+ modelPerHorizonEntries,
511
+ ]);
512
+ const spaghettiWithUserSeries = useMemo(() => {
513
+ if (viewTab !== 'spaghetti') {
514
+ return { mergedData: [], seriesMeta: [] };
515
+ }
516
+ return mergeSpaghettiUserSeriesFromForecastData(spaghettiPipeline.merged, userSeriesForSpaghetti, forecastData);
517
+ }, [viewTab, spaghettiPipeline.merged, userSeriesForSpaghetti, forecastData]);
518
+ const spaghettiFilteredChartData = useMemo(() => {
519
+ if (viewTab !== 'spaghetti')
520
+ return [];
521
+ const filtered = filterSpaghettiDataFromEarliestForecastStart(spaghettiWithUserSeries.mergedData, [], userSeriesForSpaghetti, forecastData, spaghettiPipeline.driftSeriesMeta, MONTHS_24, [...modelPerHorizonEntries, ...driftPerHorizonEntries], perHorizonWindowStart, perHorizonWindowEnd);
522
+ return mergeHistoricalIntoSpaghettiChartData(filtered, historicalChartData);
523
+ }, [
524
+ viewTab,
525
+ spaghettiWithUserSeries.mergedData,
526
+ userSeriesForSpaghetti,
527
+ forecastData,
528
+ spaghettiPipeline.driftSeriesMeta,
529
+ modelPerHorizonEntries,
530
+ driftPerHorizonEntries,
531
+ perHorizonWindowStart,
532
+ perHorizonWindowEnd,
533
+ historicalChartData,
534
+ ]);
535
+ const spaghettiDisplayedForScale = useMemo(() => filterDataForTimeRange(spaghettiFilteredChartData, spaghettiTimeRange, undefined), [spaghettiFilteredChartData, spaghettiTimeRange]);
536
+ const spaghettiYScale = useMemo(() => {
537
+ if (spaghettiDisplayedForScale.length === 0)
538
+ return undefined;
539
+ return calculateYRangeFromChartData(spaghettiDisplayedForScale);
540
+ }, [spaghettiDisplayedForScale]);
541
+ const spaghettiForecastItems = useMemo(() => {
542
+ return spaghettiWithUserSeries.seriesMeta.map((meta, i) => ({
543
+ id: meta.id,
544
+ name: meta.label,
545
+ color: spaghettiForecastLineColor(meta.id, i),
546
+ }));
547
+ }, [spaghettiWithUserSeries.seriesMeta]);
548
+ useEffect(() => {
549
+ if (performanceAnalysisId != null &&
550
+ forecastData[String(performanceAnalysisId)] &&
551
+ performanceData) {
552
+ setSpaghettiTimeRange(getTimeRangeForForecastLength('pin'));
553
+ }
554
+ }, [
555
+ performanceAnalysisId,
556
+ forecastData,
557
+ performanceData,
558
+ getTimeRangeForForecastLength,
559
+ ]);
560
+ useEffect(() => {
561
+ if (isEmpty && viewTab === 'spaghetti') {
562
+ setSpaghettiTimeRange('All');
563
+ }
564
+ }, [isEmpty, viewTab]);
565
+ const updatePinMonth = useCallback((month) => {
566
+ if (month)
567
+ setPinMonth(month);
568
+ }, []);
569
+ const handlePerformanceChartTimeRangeChange = useCallback((val) => {
570
+ if (!val)
571
+ return;
572
+ onTimeRangeChange?.(val, { viewTab });
573
+ if (timeRangeProp === undefined) {
574
+ if (viewTab === 'spaghetti')
575
+ setSpaghettiTimeRange(val);
576
+ else
577
+ setPerHorizonChartTimeRange(val);
578
+ }
579
+ }, [onTimeRangeChange, timeRangeProp, viewTab]);
580
+ useEffect(() => {
581
+ if (filtered24mData.length > 0 && historicalData.length > 0) {
582
+ const lastHistoricalPoint = historicalData[historicalData.length - 1];
583
+ const date = new Date(lastHistoricalPoint.date);
584
+ updatePinMonth(`${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`);
585
+ }
586
+ }, [filtered24mData, historicalData, updatePinMonth]);
587
+ useEffect(() => {
588
+ if (hiddenSeriesControlled)
589
+ return;
590
+ if (viewTab === 'spaghetti') {
591
+ setHiddenSeries(prev => {
592
+ const next = new Set(prev);
593
+ next.delete('forecast_1000');
594
+ next.delete('forecast_1001');
595
+ return next;
596
+ });
597
+ }
598
+ }, [viewTab, hiddenSeriesControlled, setHiddenSeries]);
599
+ useEffect(() => {
600
+ if (hiddenSeriesControlled)
601
+ return;
602
+ if (viewTab === 'perHorizon') {
603
+ setHiddenSeries(prev => {
604
+ const next = new Set(prev);
605
+ let changed = false;
606
+ prev.forEach(k => {
607
+ const m = /^forecast_(\d+)$/.exec(k);
608
+ if (!m)
609
+ return;
610
+ const id = parseInt(m[1], 10);
611
+ if (id >= 6000 && id < 9000) {
612
+ next.delete(k);
613
+ changed = true;
614
+ }
615
+ });
616
+ return changed ? next : prev;
617
+ });
618
+ }
619
+ }, [viewTab, hiddenSeriesControlled, setHiddenSeries]);
620
+ const performanceLineIdsForLegend = useMemo(() => spaghettiWithUserSeries.seriesMeta
621
+ .filter(m => isSpaghettiModelPerHorizonLineId(m.id))
622
+ .map(m => m.id), [spaghettiWithUserSeries.seriesMeta]);
623
+ const driftLineIdsForLegend = useMemo(() => spaghettiWithUserSeries.seriesMeta
624
+ .filter(m => isSpaghettiDriftPerHorizonLineId(m.id))
625
+ .map(m => m.id), [spaghettiWithUserSeries.seriesMeta]);
626
+ const customMatrixLineIdsForLegend = useMemo(() => spaghettiWithUserSeries.seriesMeta
627
+ .filter(m => isSpaghettiMatrixSyntheticLineId(m.id))
628
+ .map(m => m.id), [spaghettiWithUserSeries.seriesMeta]);
629
+ const customMatrixLegendGroupLabel = useMemo(() => {
630
+ const first = spaghettiWithUserSeries.seriesMeta.find(m => isSpaghettiMatrixSyntheticLineId(m.id));
631
+ if (!first?.label)
632
+ return 'Custom performance';
633
+ const head = first.label.split(' · ')[0]?.trim();
634
+ return head || 'Custom performance';
635
+ }, [spaghettiWithUserSeries.seriesMeta]);
636
+ const performanceGroupHidden = useMemo(() => {
637
+ if (performanceLineIdsForLegend.length === 0)
638
+ return false;
639
+ return performanceLineIdsForLegend.every(id => hiddenSeries.has(`forecast_${id}`));
640
+ }, [performanceLineIdsForLegend, hiddenSeries]);
641
+ const driftGroupHidden = useMemo(() => {
642
+ if (driftLineIdsForLegend.length === 0)
643
+ return false;
644
+ return driftLineIdsForLegend.every(id => hiddenSeries.has(`forecast_${id}`));
645
+ }, [driftLineIdsForLegend, hiddenSeries]);
646
+ const customMatrixGroupHidden = useMemo(() => {
647
+ if (customMatrixLineIdsForLegend.length === 0)
648
+ return false;
649
+ return customMatrixLineIdsForLegend.every(id => hiddenSeries.has(`forecast_${id}`));
650
+ }, [customMatrixLineIdsForLegend, hiddenSeries]);
651
+ const toggleSpaghettiGroup = useCallback((group) => {
652
+ const ids = group === 'performance'
653
+ ? performanceLineIdsForLegend
654
+ : group === 'drift'
655
+ ? driftLineIdsForLegend
656
+ : customMatrixLineIdsForLegend;
657
+ if (ids.length === 0)
658
+ return;
659
+ setHiddenSeries(prev => {
660
+ const next = new Set(prev);
661
+ const allHidden = ids.every(id => next.has(`forecast_${id}`));
662
+ if (allHidden)
663
+ ids.forEach(id => next.delete(`forecast_${id}`));
664
+ else
665
+ ids.forEach(id => next.add(`forecast_${id}`));
666
+ return next;
667
+ });
668
+ }, [
669
+ performanceLineIdsForLegend,
670
+ driftLineIdsForLegend,
671
+ customMatrixLineIdsForLegend,
672
+ setHiddenSeries,
673
+ ]);
674
+ const performanceHistoricalLegendLineColor = isDarkTheme
675
+ ? '#ffffff'
676
+ : '#000000';
677
+ const performanceUnderChartLegend = useMemo(() => {
678
+ if (isEmpty)
679
+ return null;
680
+ const items = [];
681
+ if (historicalChartData.length > 0) {
682
+ items.push({
683
+ id: 'historical',
684
+ label: 'Historical',
685
+ lineColor: performanceHistoricalLegendLineColor,
686
+ lineStyle: 'solid',
687
+ hidden: hiddenSeries.has('historical'),
688
+ });
689
+ }
690
+ if (viewTab === 'perHorizon') {
691
+ chartForecastData.forEach(item => {
692
+ items.push({
693
+ id: `forecast-${item.id}`,
694
+ label: item.name ?? `Series ${item.id}`,
695
+ lineColor: String(item.color ?? 'var(--foreground)'),
696
+ lineStyle: 'dashed',
697
+ hidden: hiddenSeries.has(`forecast_${item.id}`),
698
+ onToggle: () => toggleLegendSeriesKey(`forecast_${item.id}`),
699
+ });
700
+ });
701
+ }
702
+ if (viewTab === 'spaghetti') {
703
+ if (performanceLineIdsForLegend.length > 0) {
704
+ items.push({
705
+ id: 'spaghetti-performance',
706
+ label: 'Sybilion AI',
707
+ lineColor: SPAGHETTI_MODEL_LINE_COLOR,
708
+ lineStyle: 'dashed',
709
+ hidden: performanceGroupHidden,
710
+ onToggle: () => toggleSpaghettiGroup('performance'),
711
+ });
712
+ }
713
+ if (driftLineIdsForLegend.length > 0) {
714
+ items.push({
715
+ id: 'spaghetti-drift',
716
+ label: getForecastModelDisplayName('drift'),
717
+ lineColor: SPAGHETTI_DRIFT_LINE_COLOR,
718
+ lineStyle: 'dashed',
719
+ hidden: driftGroupHidden,
720
+ onToggle: () => toggleSpaghettiGroup('drift'),
721
+ });
722
+ }
723
+ if (customMatrixLineIdsForLegend.length > 0) {
724
+ items.push({
725
+ id: 'spaghetti-custom-matrix',
726
+ label: customMatrixLegendGroupLabel,
727
+ lineColor: SPAGHETTI_CUSTOM_PERFORMANCE_LINE_COLOR,
728
+ lineStyle: 'dashed',
729
+ hidden: customMatrixGroupHidden,
730
+ onToggle: () => toggleSpaghettiGroup('custom'),
731
+ });
732
+ }
733
+ }
734
+ return jsx(PerformanceUnderChartLegendFromItems, { items: items });
735
+ }, [
736
+ isEmpty,
737
+ viewTab,
738
+ historicalChartData.length,
739
+ chartForecastData,
740
+ performanceHistoricalLegendLineColor,
741
+ hiddenSeries,
742
+ toggleLegendSeriesKey,
743
+ performanceLineIdsForLegend.length,
744
+ driftLineIdsForLegend.length,
745
+ performanceGroupHidden,
746
+ driftGroupHidden,
747
+ toggleSpaghettiGroup,
748
+ customMatrixLineIdsForLegend.length,
749
+ customMatrixLegendGroupLabel,
750
+ customMatrixGroupHidden,
751
+ ]);
752
+ const isSpaghettiView = viewTab === 'spaghetti';
753
+ const hasSpaghettiPoints = spaghettiFilteredChartData.length > 0;
754
+ const spaghettiChartLoading = isSpaghettiView && performanceSectionPending;
755
+ const spaghettiTabShowsChartData = isSpaghettiView && hasSpaghettiPoints;
756
+ const spaghettiOverridesPerformanceEmpty = isSpaghettiView &&
757
+ (hasSpaghettiPoints || performanceDataLoading || spaghettiChartLoading);
758
+ const isLoaded = useMemo(() => !loading &&
759
+ !performanceDataLoading &&
760
+ perfFetchSettled &&
761
+ (!isEmpty || spaghettiOverridesPerformanceEmpty), [
762
+ loading,
763
+ performanceDataLoading,
764
+ perfFetchSettled,
765
+ isEmpty,
766
+ spaghettiOverridesPerformanceEmpty,
767
+ ]);
768
+ const spaghettiNoDataMessage = isSpaghettiView &&
769
+ perfFetchSettled &&
770
+ !performanceDataLoading &&
771
+ spaghettiFilteredChartData.length === 0
772
+ ? 'No spaghetti plot data for this dataset.'
773
+ : undefined;
774
+ const activeTimeRange = timeRangeProp ??
775
+ (isSpaghettiView ? spaghettiTimeRange : perHorizonChartTimeRange);
776
+ const perHorizonChartProps = viewTab === 'perHorizon'
777
+ ? {
778
+ chartData: filtered24mData,
779
+ forecastData: chartForecastData,
780
+ yMin: maxYScale?.yMin,
781
+ yMax: maxYScale?.yMax,
782
+ loading: performanceSectionPending,
783
+ noDataMessage: undefined,
784
+ noDataStatusTone: undefined,
785
+ hasCombinedData: undefined,
786
+ }
787
+ : {
788
+ chartData: spaghettiFilteredChartData,
789
+ forecastData: spaghettiForecastItems,
790
+ yMin: spaghettiYScale?.yMin ?? maxYScale?.yMin,
791
+ yMax: spaghettiYScale?.yMax ?? maxYScale?.yMax,
792
+ loading: spaghettiChartLoading,
793
+ noDataMessage: spaghettiNoDataMessage,
794
+ noDataStatusTone: spaghettiNoDataMessage
795
+ ? 'destructive'
796
+ : undefined,
797
+ hasCombinedData: spaghettiFilteredChartData.length > 0,
798
+ };
799
+ const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint) || isEmpty) && !loading;
800
+ return (jsx("div", { className: cn(S.root, performanceSectionPending && !spaghettiTabShowsChartData && S.loading, isLoaded && S.loaded, isEmpty && S.isEmpty, className), children: jsxs(Tabs, { variant: "button", value: viewTab, onValueChange: v => {
801
+ if (v === 'perHorizon' || v === 'spaghetti') {
802
+ setViewTab(v);
803
+ }
804
+ }, className: S.viewTabs, children: [jsxs("div", { className: S.toolbarRow, children: [toolbarStart, jsxs(TabsList, { className: S.viewTabsList, children: [jsx(TabsTrigger, { value: "perHorizon", children: "Per horizon plot" }), jsx(TabsTrigger, { value: "spaghetti", children: "Spaghetti plots" })] }), jsx("div", { className: S.toolBarRight, children: viewTab === 'perHorizon' && performanceData && (jsx(HorizonsSelector, { selectedHorizon: selectedHorizon, onHorizonChange: setSelectedHorizon, availableHorizons: availableHorizons })) })] }), jsxs("div", { className: cn(S.perHorizonChartWrap, performanceSectionPending && S.perHorizonChartWrapLoading), children: [jsx(ChartAreaInteractive, { disableHistoricalAnimation: true, disableTimeRangeSelector: true, enableTimeRangeBrush: true, disableAnimation: isSpaghettiView, chartContainerClassName: S.chartContainer, chartData: perHorizonChartProps.chartData, forecastData: perHorizonChartProps.forecastData, timeRange: activeTimeRange, onTimeRangeChange: handlePerformanceChartTimeRangeChange, pinMonth: pinMonth, onPinMonthChange: updatePinMonth, isDarkTheme: isDarkTheme, loading: perHorizonChartProps.loading || chartLoading, yMin: perHorizonChartProps.yMin, yMax: perHorizonChartProps.yMax, autoScaleYAxis: false, maxVisibleItems: 2, preventDeselection: true, showLegend: false, footerActions: performanceUnderChartLegend ?? undefined, hasCombinedData: perHorizonChartProps.hasCombinedData, hiddenSeries: hiddenSeries, toggleLegendSeries: toggleLegendSeriesKey, ensureAnalysisSeriesVisible: ensureSeriesVisible }), showEmptyOverlay && (jsx("div", { className: S.chartEmptyOverlay, role: "status", "aria-live": "polite", children: jsx(ChartEmptyState, { variant: "inline", hint: runAnalysisHint ? ('Run a completed analysis to load performance data for an analysis.') : isEmpty ? (jsxs(Fragment, { children: ["No performance data for this dataset.", jsx("br", {}), "Please contact", ' ', jsx("a", { href: "mailto:support@sybilion.com", children: "support team" }), " to gain access to the latest performance data."] })) : undefined, status: statusHint ?? undefined, statusTone: statusTone }) })), performanceSectionPending && (jsx("div", { className: S.performanceLoadingLayer, "aria-busy": "true", "aria-live": "polite", children: jsx("div", { className: S.emptyState, children: jsx(TextShimmer, { as: "span", className: S.performanceLoadingText, children: "Loading performance data\u2026" }) }) }))] }), !performanceDataLoading && performanceData && (jsx(PerformanceTable, { forecastModels: forecastModelsData, adjustParameters: adjustParameters, onAdjustParametersChange: setAdjustParameters, customPerformance: customPerformanceTableRow, onEditCustomPerformance: onEditCustomPerformance, showAddEditCustomDataButton: showAddEditCustomDataButton, addEditCustomDataDisabled: addEditCustomDataDisabled }))] }) }));
805
+ }
806
+
807
+ export { PerformanceChart };