@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,790 @@
1
+ /**
2
+ * Helper functions for Performance Tab calculations
3
+ * Note: ROI/calculatons.ts is only for reference/example
4
+ */
5
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
6
+ import type { ForecastData } from '#uilib/types/forecast-data';
7
+ import {
8
+ getPreviousMonth,
9
+ normalizeToMonthStart,
10
+ } from '#uilib/utils/chartConnectionPoint';
11
+
12
+ import type { SpaghettiPerformanceMatrixPayload } from './performanceChartUserSeries';
13
+
14
+ export type RealBacktestsEntry = {
15
+ forecast_start: string;
16
+ forecast_end: string;
17
+ forecast_series: Record<string, number>;
18
+ };
19
+
20
+ /** Legacy: API backtest spaghetti lines used `SPAGHETTI_FORECAST_ID_BASE + i` (spaghetti view now uses drift per-horizon instead). */
21
+ export const SPAGHETTI_FORECAST_ID_BASE = 6000;
22
+
23
+ /** Model PER_HORIZON_TO_SPAGHETTI lines use `SPAGHETTI_MODEL_PER_HORIZON_ID_BASE + i`. */
24
+ export const SPAGHETTI_MODEL_PER_HORIZON_ID_BASE = 7000;
25
+
26
+ /** Drift PER_HORIZON_TO_SPAGHETTI lines use `SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE + i`. */
27
+ export const SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE = 8000;
28
+
29
+ const SPAGHETTI_DRIFT_ID_RANGE_END = 9000;
30
+
31
+ export function isSpaghettiModelPerHorizonLineId(id: number): boolean {
32
+ return (
33
+ id >= SPAGHETTI_MODEL_PER_HORIZON_ID_BASE &&
34
+ id < SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE
35
+ );
36
+ }
37
+
38
+ export function isSpaghettiDriftPerHorizonLineId(id: number): boolean {
39
+ return (
40
+ id >= SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE &&
41
+ id < SPAGHETTI_DRIFT_ID_RANGE_END
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Anchor a spaghetti forecast line to the historical curve at the month before
47
+ * the earliest forecast point (same strategy as ensureForecastConnectionPoint).
48
+ */
49
+ function addSpaghettiHistoricalBridgeForSeries(
50
+ map: Map<string, ChartDataPoint>,
51
+ dataKey: string,
52
+ earliestForecastDate: string | null,
53
+ ): void {
54
+ if (earliestForecastDate === null) return;
55
+ const connectionDate = normalizeToMonthStart(
56
+ getPreviousMonth(earliestForecastDate),
57
+ );
58
+ const row = map.get(connectionDate);
59
+ if (row === undefined) return;
60
+ const hist = row.historical;
61
+ if (typeof hist !== 'number' || !Number.isFinite(hist)) return;
62
+ const existingVal = row[dataKey];
63
+ if (typeof existingVal === 'number' && Number.isFinite(existingVal)) {
64
+ return;
65
+ }
66
+ row[dataKey] = hist;
67
+ map.set(connectionDate, row);
68
+ }
69
+
70
+ /**
71
+ * Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
72
+ * backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
73
+ * (aligned by sorted key index per horizon).
74
+ */
75
+ export function buildPerHorizonSpaghettiEntries(
76
+ forecastRoot:
77
+ | { forecasts?: Record<string, Record<string, number>> }
78
+ | null
79
+ | undefined,
80
+ horizonKeys: string[],
81
+ ): RealBacktestsEntry[] {
82
+ if (!forecastRoot?.forecasts || horizonKeys.length === 0) return [];
83
+
84
+ const forecasts = forecastRoot.forecasts;
85
+ const sortedHorizons = [...horizonKeys].sort((a, b) => {
86
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
87
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
88
+ return na - nb;
89
+ });
90
+
91
+ const normalizeDateKey = (d: string) => d.split(' ')[0];
92
+
93
+ const perHorizonKeyLists: string[][] = sortedHorizons.map(h => {
94
+ const m = forecasts[h];
95
+ if (!m || typeof m !== 'object') return [];
96
+ return Object.keys(m).sort((a, b) =>
97
+ normalizeDateKey(a).localeCompare(normalizeDateKey(b)),
98
+ );
99
+ });
100
+
101
+ const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
102
+ if (lengths.length === 0) return [];
103
+ const n = Math.min(...lengths);
104
+
105
+ const entries: RealBacktestsEntry[] = [];
106
+ for (let i = 0; i < n; i++) {
107
+ const forecast_series: Record<string, number> = {};
108
+ for (let hi = 0; hi < sortedHorizons.length; hi++) {
109
+ const h = sortedHorizons[hi];
110
+ const keys = perHorizonKeyLists[hi];
111
+ const dateKey = keys[i];
112
+ const rawVal = forecasts[h]?.[dateKey];
113
+ if (typeof rawVal !== 'number' || !Number.isFinite(rawVal)) continue;
114
+ const norm = normalizeToMonthStart(normalizeDateKey(dateKey));
115
+ forecast_series[norm] = rawVal;
116
+ }
117
+ const dates = Object.keys(forecast_series).sort();
118
+ if (dates.length === 0) continue;
119
+ entries.push({
120
+ forecast_start: dates[0],
121
+ forecast_end: dates[dates.length - 1],
122
+ forecast_series,
123
+ });
124
+ }
125
+ return entries;
126
+ }
127
+
128
+ /**
129
+ * Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
130
+ * i-th sorted forecast month; `dates[i]` is horizon_1's month at that row (Date column).
131
+ * Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
132
+ */
133
+ export function buildDriftSpaghettiMatrixForCustomDialog(
134
+ driftRoot:
135
+ | { forecasts?: Record<string, Record<string, number>> }
136
+ | null
137
+ | undefined,
138
+ horizonKeys: string[],
139
+ ): {
140
+ dates: string[];
141
+ grid: number[][];
142
+ perHorizonDates: string[][];
143
+ } | null {
144
+ if (!driftRoot?.forecasts || horizonKeys.length === 0) return null;
145
+
146
+ const forecasts = driftRoot.forecasts;
147
+ const sortedHorizons = [...horizonKeys].sort((a, b) => {
148
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
149
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
150
+ return na - nb;
151
+ });
152
+
153
+ const normalizeDateKey = (d: string) => d.split(' ')[0];
154
+
155
+ const perHorizonKeyLists: string[][] = sortedHorizons.map(h => {
156
+ const m = forecasts[h];
157
+ if (!m || typeof m !== 'object') return [];
158
+ return Object.keys(m).sort((a, b) =>
159
+ normalizeDateKey(a).localeCompare(normalizeDateKey(b)),
160
+ );
161
+ });
162
+
163
+ const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
164
+ if (lengths.length === 0) return null;
165
+ const n = Math.min(...lengths);
166
+
167
+ const dates: string[] = [];
168
+ const grid: number[][] = [];
169
+ const perHorizonDates: string[][] = [];
170
+
171
+ for (let i = 0; i < n; i++) {
172
+ const row: number[] = [];
173
+ const dateRow: string[] = [];
174
+ for (let hi = 0; hi < sortedHorizons.length; hi++) {
175
+ const h = sortedHorizons[hi];
176
+ const keys = perHorizonKeyLists[hi];
177
+ const dateKey = keys[i];
178
+ dateRow.push(normalizeToMonthStart(normalizeDateKey(dateKey)));
179
+ const rawVal = forecasts[h]?.[dateKey];
180
+ const v =
181
+ typeof rawVal === 'number' && Number.isFinite(rawVal) ? rawVal : NaN;
182
+ row.push(v);
183
+ }
184
+ const d0 = perHorizonKeyLists[0][i];
185
+ dates.push(normalizeToMonthStart(normalizeDateKey(d0)));
186
+ perHorizonDates.push(dateRow);
187
+ grid.push(row.map(c => (Number.isFinite(c) ? c : 0)));
188
+ }
189
+
190
+ return {
191
+ dates,
192
+ grid,
193
+ perHorizonDates,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
199
+ * historical value for the month before the earliest forecast month in that row (same anchor as
200
+ * chart connection-point logic).
201
+ */
202
+ export function spaghettiGridFromHistoricalPreviousMonth(
203
+ perHorizonDates: string[][],
204
+ horizonCount: number,
205
+ historicalByDate: Map<string, number>,
206
+ ): number[][] {
207
+ const norm = (d: string) =>
208
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
209
+ return perHorizonDates.map(row => {
210
+ if (!Array.isArray(row) || row.length === 0) {
211
+ return Array.from({ length: horizonCount }, () => 0);
212
+ }
213
+ const sorted = [...row]
214
+ .map(d => norm(String(d)))
215
+ .filter(Boolean)
216
+ .sort((a, b) => a.localeCompare(b));
217
+ const earliest = sorted[0];
218
+ if (!earliest) {
219
+ return Array.from({ length: horizonCount }, () => 0);
220
+ }
221
+ const prevMonth = normalizeToMonthStart(getPreviousMonth(earliest));
222
+ const v = historicalByDate.get(prevMonth);
223
+ const flat = typeof v === 'number' && Number.isFinite(v) ? v : 0;
224
+ return Array.from({ length: horizonCount }, () => flat);
225
+ });
226
+ }
227
+
228
+ /** Mean absolute error / MAPE vs historical for one horizon column of a spaghetti matrix (aligned by month). */
229
+ export function averageForecastErrorsVsHistoricalForMatrixColumn(
230
+ matrix: SpaghettiPerformanceMatrixPayload,
231
+ horizonColumnIndex: number,
232
+ historicalByDate: Map<string, number>,
233
+ ): { mae: number; mape: number } | null {
234
+ const norm = (d: string) =>
235
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
236
+ const maeList: number[] = [];
237
+ const mapeList: number[] = [];
238
+ const nR = matrix.grid.length;
239
+ for (let r = 0; r < nR; r++) {
240
+ const dSrc =
241
+ matrix.perHorizonDates?.[r]?.[horizonColumnIndex] ?? matrix.dates[r];
242
+ const d = norm(String(dSrc));
243
+ const actual = historicalByDate.get(d);
244
+ const fc = matrix.grid[r]?.[horizonColumnIndex];
245
+ if (
246
+ actual === undefined ||
247
+ typeof fc !== 'number' ||
248
+ !Number.isFinite(fc)
249
+ ) {
250
+ continue;
251
+ }
252
+ const err = Math.abs(fc - actual);
253
+ maeList.push(err);
254
+ if (Math.abs(actual) > 1e-9) mapeList.push(err / Math.abs(actual));
255
+ }
256
+ if (maeList.length === 0) return null;
257
+ return {
258
+ mae: maeList.reduce((a, b) => a + b, 0) / maeList.length,
259
+ mape:
260
+ mapeList.length > 0
261
+ ? mapeList.reduce((a, b) => a + b, 0) / mapeList.length
262
+ : 0,
263
+ };
264
+ }
265
+
266
+ /** @deprecated Use {@link buildPerHorizonSpaghettiEntries} (same implementation). */
267
+ export const buildModelPerHorizonSpaghettiEntries =
268
+ buildPerHorizonSpaghettiEntries;
269
+
270
+ export function mergeSpaghettiMergedBases(
271
+ a: {
272
+ mergedData: ChartDataPoint[];
273
+ seriesMeta: { id: number; label: string }[];
274
+ },
275
+ b: {
276
+ mergedData: ChartDataPoint[];
277
+ seriesMeta: { id: number; label: string }[];
278
+ },
279
+ ): {
280
+ mergedData: ChartDataPoint[];
281
+ seriesMeta: { id: number; label: string }[];
282
+ } {
283
+ const norm = (d: string) => normalizeToMonthStart(d);
284
+ const map = new Map<string, ChartDataPoint>();
285
+
286
+ const mergePoints = (points: ChartDataPoint[]) => {
287
+ points.forEach(point => {
288
+ const k = norm(point.date);
289
+ const prev = map.get(k);
290
+ if (!prev) {
291
+ map.set(k, { ...point, date: k });
292
+ return;
293
+ }
294
+ const merged: ChartDataPoint = { ...prev };
295
+ Object.keys(point).forEach(key => {
296
+ if (key === 'date') return;
297
+ if (key.startsWith('forecast_')) {
298
+ merged[key] = point[key];
299
+ }
300
+ });
301
+ if (point.historical !== undefined) {
302
+ merged.historical = point.historical;
303
+ }
304
+ map.set(k, merged);
305
+ });
306
+ };
307
+
308
+ mergePoints(a.mergedData);
309
+ mergePoints(b.mergedData);
310
+
311
+ const mergedData = Array.from(map.values()).sort((a, b) =>
312
+ a.date.localeCompare(b.date),
313
+ );
314
+
315
+ return {
316
+ mergedData,
317
+ seriesMeta: [...a.seriesMeta, ...b.seriesMeta],
318
+ };
319
+ }
320
+
321
+ export function buildSpaghettiMergedChartData(
322
+ entries: RealBacktestsEntry[],
323
+ historicalChartData: ChartDataPoint[],
324
+ idBase: number = SPAGHETTI_FORECAST_ID_BASE,
325
+ ): {
326
+ mergedData: ChartDataPoint[];
327
+ seriesMeta: { id: number; label: string }[];
328
+ } {
329
+ const norm = (d: string) => normalizeToMonthStart(d);
330
+ const map = new Map<string, ChartDataPoint>();
331
+
332
+ historicalChartData.forEach(point => {
333
+ const k = norm(point.date);
334
+ map.set(k, { ...point, date: k });
335
+ });
336
+
337
+ const seriesMeta: { id: number; label: string }[] = [];
338
+
339
+ entries.forEach((entry, i) => {
340
+ const id = idBase + i;
341
+ const dataKey = `forecast_${id}`;
342
+ seriesMeta.push({
343
+ id,
344
+ label: `${entry.forecast_start} – ${entry.forecast_end}`,
345
+ });
346
+ let earliestForecastDate: string | null = null;
347
+ Object.entries(entry.forecast_series).forEach(([dateStr, val]) => {
348
+ if (typeof val !== 'number' || !Number.isFinite(val)) return;
349
+ const k = norm(dateStr);
350
+ if (
351
+ earliestForecastDate === null ||
352
+ k.localeCompare(earliestForecastDate) < 0
353
+ ) {
354
+ earliestForecastDate = k;
355
+ }
356
+ const existing = map.get(k) ?? { date: k };
357
+ existing[dataKey] = val;
358
+ map.set(k, existing);
359
+ });
360
+ addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDate);
361
+ });
362
+
363
+ const mergedData = Array.from(map.values()).sort((a, b) =>
364
+ a.date.localeCompare(b.date),
365
+ );
366
+
367
+ return { mergedData, seriesMeta };
368
+ }
369
+
370
+ function mergeUserSeriesForecastIntoSpaghettiPoints(
371
+ mergedData: ChartDataPoint[],
372
+ seriesId: number,
373
+ dates: string[],
374
+ forecastValues: number[],
375
+ ): ChartDataPoint[] {
376
+ const norm = (d: string) => normalizeToMonthStart(d);
377
+ const map = new Map<string, ChartDataPoint>();
378
+ mergedData.forEach(point => {
379
+ const k = norm(point.date);
380
+ map.set(k, { ...point, date: k });
381
+ });
382
+ const dataKey = `forecast_${seriesId}`;
383
+ let earliestForecastDate: string | null = null;
384
+ dates.forEach((dateStr, i) => {
385
+ const val = forecastValues[i];
386
+ if (typeof val !== 'number' || !Number.isFinite(val)) return;
387
+ const k = norm(dateStr);
388
+ if (
389
+ earliestForecastDate === null ||
390
+ k.localeCompare(earliestForecastDate) < 0
391
+ ) {
392
+ earliestForecastDate = k;
393
+ }
394
+ const existing = map.get(k) ?? { date: k };
395
+ existing[dataKey] = val;
396
+ map.set(k, existing);
397
+ });
398
+ addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDate);
399
+ return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
400
+ }
401
+
402
+ /** Appends forecast-backed user series (from dataset context) onto spaghetti backtest lines. */
403
+ export function mergeSpaghettiUserSeriesFromForecastData(
404
+ base: {
405
+ mergedData: ChartDataPoint[];
406
+ seriesMeta: { id: number; label: string }[];
407
+ },
408
+ userSeries: { id: number; name?: string }[],
409
+ forecastData: Record<string, ForecastData>,
410
+ ): {
411
+ mergedData: ChartDataPoint[];
412
+ seriesMeta: { id: number; label: string }[];
413
+ } {
414
+ let mergedData = base.mergedData;
415
+ const extendedMeta = [...base.seriesMeta];
416
+
417
+ userSeries.forEach(analysis => {
418
+ const fd = forecastData[String(analysis.id)];
419
+ if (!fd) return;
420
+ const { dates, forecastValues } = fd;
421
+ if (dates.length === 0 || forecastValues.length !== dates.length) return;
422
+ if (extendedMeta.some(m => m.id === analysis.id)) return;
423
+
424
+ mergedData = mergeUserSeriesForecastIntoSpaghettiPoints(
425
+ mergedData,
426
+ analysis.id,
427
+ dates,
428
+ forecastValues,
429
+ );
430
+ extendedMeta.push({
431
+ id: analysis.id,
432
+ label: analysis.name?.trim() || 'User series',
433
+ });
434
+ });
435
+
436
+ return { mergedData, seriesMeta: extendedMeta };
437
+ }
438
+
439
+ export function filterChartDataLast24Months(
440
+ data: ChartDataPoint[],
441
+ months: number,
442
+ ): ChartDataPoint[] {
443
+ if (data.length === 0) return [];
444
+ const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date));
445
+ const cutoffDate = new Date(sorted[sorted.length - 1].date);
446
+ cutoffDate.setMonth(cutoffDate.getMonth() - months);
447
+ return sorted.filter(point => new Date(point.date) >= cutoffDate);
448
+ }
449
+
450
+ /** Earlier month string (ISO-style sortable). */
451
+ function earlierMonthStart(a: string, b: string): string {
452
+ return a.localeCompare(b) < 0 ? a : b;
453
+ }
454
+
455
+ /**
456
+ * Spaghetti chart x-range: from two months before the earliest forecast_start
457
+ * (per-horizon synthetic entries + mergeable user forecast series), inclusive.
458
+ * Falls back to last N months when no forecast candidates exist.
459
+ *
460
+ * When `historicalWindowFloor` is set (e.g. first month of per-horizon `filtered24mData`),
461
+ * the range start is floored to that month so the historical line matches the per-horizon tab.
462
+ *
463
+ * When `historicalWindowCeiling` is set (e.g. last month of `filtered24mData`), drops points after
464
+ * that month so spaghetti X matches per-horizon (per-horizon chart trims forecasts past last historical).
465
+ */
466
+ export function filterSpaghettiDataFromEarliestForecastStart(
467
+ mergedData: ChartDataPoint[],
468
+ entries: RealBacktestsEntry[],
469
+ userSeries: { id: number; name?: string }[],
470
+ forecastData: Record<string, ForecastData>,
471
+ /** Series meta from the first merged batch (e.g. drift) — used to skip duplicate user-series ids. */
472
+ firstBatchSeriesMeta: { id: number; label: string }[],
473
+ fallbackMonths: number,
474
+ extraForecastCandidates?: RealBacktestsEntry[],
475
+ historicalWindowFloor?: string | null,
476
+ historicalWindowCeiling?: string | null,
477
+ ): ChartDataPoint[] {
478
+ const candidates: string[] = [];
479
+ for (const e of entries) {
480
+ candidates.push(normalizeToMonthStart(e.forecast_start));
481
+ }
482
+ extraForecastCandidates?.forEach(e => {
483
+ candidates.push(normalizeToMonthStart(e.forecast_start));
484
+ });
485
+ userSeries.forEach(analysis => {
486
+ const fd = forecastData[String(analysis.id)];
487
+ if (!fd) return;
488
+ const { dates, forecastValues } = fd;
489
+ if (dates.length === 0 || forecastValues.length !== dates.length) return;
490
+ if (firstBatchSeriesMeta.some(m => m.id === analysis.id)) return;
491
+ candidates.push(normalizeToMonthStart(dates[0]));
492
+ });
493
+
494
+ if (candidates.length === 0) {
495
+ let result = filterChartDataLast24Months(mergedData, fallbackMonths);
496
+ if (historicalWindowFloor && result.length > 0 && mergedData.length > 0) {
497
+ const floor = normalizeToMonthStart(historicalWindowFloor);
498
+ const start = earlierMonthStart(floor, result[0].date);
499
+ const maxDate = result[result.length - 1].date;
500
+ const sorted = [...mergedData].sort((a, b) =>
501
+ a.date.localeCompare(b.date),
502
+ );
503
+ result = sorted.filter(p => p.date >= start && p.date <= maxDate);
504
+ }
505
+ if (historicalWindowCeiling && result.length > 0) {
506
+ const cap = normalizeToMonthStart(historicalWindowCeiling);
507
+ result = result.filter(
508
+ p =>
509
+ normalizeToMonthStart(String(p.date).split(' ')[0] ?? p.date) <= cap,
510
+ );
511
+ }
512
+ return result;
513
+ }
514
+
515
+ const normalizedMin = candidates.reduce((min, c) =>
516
+ c.localeCompare(min) < 0 ? c : min,
517
+ );
518
+ let rangeStart = normalizeToMonthStart(
519
+ getPreviousMonth(getPreviousMonth(normalizedMin)),
520
+ );
521
+ if (historicalWindowFloor) {
522
+ const floor = normalizeToMonthStart(historicalWindowFloor);
523
+ rangeStart = earlierMonthStart(floor, rangeStart);
524
+ }
525
+
526
+ const sorted = [...mergedData].sort((a, b) => a.date.localeCompare(b.date));
527
+ let result = sorted.filter(point => point.date >= rangeStart);
528
+ if (historicalWindowCeiling && result.length > 0) {
529
+ const cap = normalizeToMonthStart(historicalWindowCeiling);
530
+ result = result.filter(
531
+ p => normalizeToMonthStart(String(p.date).split(' ')[0] ?? p.date) <= cap,
532
+ );
533
+ }
534
+ return result;
535
+ }
536
+
537
+ /**
538
+ * Ensure each spaghetti row has `historical` from {@link historicalChartData} when that month
539
+ * falls in the filtered window — matches per-horizon chart (actuals line), and fills months that
540
+ * exist in history but were missing after merge/filter.
541
+ */
542
+ export function mergeHistoricalIntoSpaghettiChartData(
543
+ filteredRows: ChartDataPoint[],
544
+ historicalChartData: ChartDataPoint[],
545
+ ): ChartDataPoint[] {
546
+ if (filteredRows.length === 0 || historicalChartData.length === 0) {
547
+ return filteredRows;
548
+ }
549
+
550
+ const norm = (d: string) =>
551
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
552
+
553
+ const histByDate = new Map<string, number>();
554
+ historicalChartData.forEach(p => {
555
+ const v = p.historical;
556
+ if (typeof v !== 'number' || !Number.isFinite(v)) return;
557
+ histByDate.set(norm(p.date), v);
558
+ });
559
+
560
+ const sorted = [...filteredRows].sort((a, b) => a.date.localeCompare(b.date));
561
+ const minD = norm(sorted[0].date);
562
+ const maxD = norm(sorted[sorted.length - 1].date);
563
+
564
+ const rowMap = new Map<string, ChartDataPoint>();
565
+ sorted.forEach(p => {
566
+ const k = norm(p.date);
567
+ const fromHist = histByDate.get(k);
568
+ const merged: ChartDataPoint = { ...p, date: k };
569
+ if (fromHist !== undefined) {
570
+ merged.historical = fromHist;
571
+ }
572
+ rowMap.set(k, merged);
573
+ });
574
+
575
+ histByDate.forEach((histVal, dateKey) => {
576
+ if (dateKey < minD || dateKey > maxD) return;
577
+ if (!rowMap.has(dateKey)) {
578
+ rowMap.set(dateKey, { date: dateKey, historical: histVal });
579
+ }
580
+ });
581
+
582
+ return [...rowMap.values()].sort((a, b) => a.date.localeCompare(b.date));
583
+ }
584
+
585
+ /**
586
+ * Calculate Accuracy: 100% - MAPE
587
+ * @param mape - MAPE value as decimal (e.g., 0.0436 for 4.36%)
588
+ * @returns Accuracy percentage
589
+ */
590
+ export function calculateAccuracy(mape: number): number {
591
+ return 100 - mape * 100;
592
+ }
593
+
594
+ /**
595
+ * Format Accuracy as percentage
596
+ * @param value - Accuracy value
597
+ * @returns Formatted accuracy string (e.g., "97%")
598
+ */
599
+ export function formatAccuracy(value: number): string {
600
+ if (value < 10) return `< 10%`;
601
+ return `${Math.round(value)}%`;
602
+ }
603
+
604
+ /**
605
+ * Format Error as "$/Ton"
606
+ * @param mae - MAE value from metrics_summary.24m.mae
607
+ * @returns Formatted error string (e.g., "0.05$/Ton")
608
+ */
609
+ export function formatError(mae: number): string {
610
+ return `${mae.toFixed(2)} $/Ton`;
611
+ }
612
+
613
+ /**
614
+ * Calculate ROI
615
+ * Reference: ROI/calculatons.ts for example logic
616
+ * @param totalBenefit - Total annual benefit
617
+ * @param operatingCost - Annual operating cost
618
+ * @returns ROI percentage
619
+ */
620
+ export function calculateROI(
621
+ totalBenefit: number,
622
+ operatingCost: number,
623
+ ): number {
624
+ if (operatingCost <= 0) return 0;
625
+ const netBenefit = totalBenefit - operatingCost;
626
+ return (netBenefit / operatingCost) * 100;
627
+ }
628
+
629
+ /**
630
+ * Calculate ROI Multiple
631
+ * Formula: ROI Multiple = ROI / 100
632
+ * Where ROI = (AB - IC) / IC × 100
633
+ * @param totalBenefit - Total annual benefit (AB)
634
+ * @param operatingCost - Annual operating cost (IC)
635
+ * @returns ROI multiple (e.g., 1.0 for 100% ROI, 2.0 for 200% ROI)
636
+ */
637
+ export function calculateROIMultiple(
638
+ totalBenefit: number,
639
+ operatingCost: number,
640
+ ): number {
641
+ if (operatingCost <= 0) return 0;
642
+ const roi = calculateROI(totalBenefit, operatingCost);
643
+ return roi / 100;
644
+ }
645
+
646
+ /**
647
+ * Format ROI as multiplier (xN format)
648
+ * Formula: ROI Multiple = ROI / 100
649
+ * Examples: 100% ROI = x1, 200% ROI = x2, 0% ROI = x0
650
+ * Note: Negative ROI (when benefit < cost) is displayed as x1
651
+ * @param totalBenefit - Total annual benefit (AB)
652
+ * @param operatingCost - Annual operating cost (IC)
653
+ * @returns Formatted ROI string (e.g., "x1.5")
654
+ */
655
+ export function formatROI(totalBenefit: number, operatingCost: number): string {
656
+ const multiple = calculateROIMultiple(totalBenefit, operatingCost);
657
+ // If multiple is negative (benefit < cost), show as x1
658
+ const adjustedMultiple = multiple < 0 ? 1 : multiple;
659
+ // Round to 1 decimal place
660
+ const rounded = Math.round(adjustedMultiple * 10) / 10;
661
+ // Format: show as integer if whole number, otherwise 1 decimal place
662
+ const formatted = rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1);
663
+ return `x${formatted}`;
664
+ }
665
+
666
+ /**
667
+ * Calculate Benefit p/a
668
+ * Formula: Annual Benefit = PV × V × C × PI
669
+ * Where:
670
+ * - PV = Procurement Volume
671
+ * - V = Variable Raw Material Share (as decimal)
672
+ * - C = Controllable Cost Share (as decimal)
673
+ * - PI = Price Improvement = |FA_new - FA_current| × 0.2 (correlation factor)
674
+ * @param mae - MAE value (not used in calculation, kept for backward compatibility)
675
+ * @param mape - MAPE value (used to calculate actual forecast accuracy)
676
+ * @param adjustParams - Adjust parameters from dialog
677
+ * @param baselineAccuracy - Optional baseline accuracy for comparison (if provided, uses actual improvement)
678
+ * @returns Benefit per annum
679
+ */
680
+ export function calculateBenefit(
681
+ mae: number,
682
+ mape: number,
683
+ adjustParams: {
684
+ procurementVolume: number;
685
+ variableRawMaterialCostShare: number;
686
+ controllableCosts: number;
687
+ currentForecastAccuracy: number;
688
+ expectedImprovement: number;
689
+ analystHourlyRate: number;
690
+ },
691
+ baselineAccuracy?: number,
692
+ ): number {
693
+ // Calculate Price Improvement (PI) as decimal
694
+ // If baselineAccuracy is provided, use actual accuracy improvement
695
+ // Otherwise, use user-entered expected improvement
696
+ let accuracyImprovement: number;
697
+ if (baselineAccuracy !== undefined) {
698
+ // Use actual forecast accuracy improvement
699
+ const currentAccuracy = calculateAccuracy(mape);
700
+ accuracyImprovement = Math.abs(currentAccuracy - baselineAccuracy);
701
+ } else {
702
+ // Use user-entered expected improvement
703
+ accuracyImprovement = adjustParams.expectedImprovement;
704
+ }
705
+
706
+ // PI = accuracyImprovement × 0.2 (correlation factor)
707
+ const priceImprovement = (accuracyImprovement / 100) * 0.2;
708
+
709
+ // Convert percentages to decimals
710
+ const variableShare = adjustParams.variableRawMaterialCostShare / 100;
711
+ const controllableShare = adjustParams.controllableCosts / 100;
712
+
713
+ // Annual Benefit = PV × V × C × PI
714
+ const annualBenefit =
715
+ adjustParams.procurementVolume *
716
+ variableShare *
717
+ controllableShare *
718
+ priceImprovement;
719
+
720
+ return Math.round(annualBenefit);
721
+ }
722
+
723
+ /**
724
+ * Format Benefit as euros
725
+ * @param value - Benefit value
726
+ * @returns Formatted benefit string (e.g., "€616.640")
727
+ */
728
+ export function formatBenefit(value: number): string {
729
+ return new Intl.NumberFormat('de-DE', {
730
+ style: 'currency',
731
+ currency: 'EUR',
732
+ minimumFractionDigits: 0,
733
+ maximumFractionDigits: 0,
734
+ }).format(value);
735
+ }
736
+
737
+ /**
738
+ * Map forecast model key to display name
739
+ * @param key - Forecast model key (e.g., "mean", "drift", "seasonal")
740
+ * @returns Display name (e.g., "Mean", "Drift", "Seasonal")
741
+ */
742
+ export function getForecastModelDisplayName(key: string): string {
743
+ const nameMap: Record<string, string> = {
744
+ mean: 'Mean',
745
+ drift: 'Statistical baseline',
746
+ seasonal: 'Seasonal',
747
+ ARMA: 'ARMA',
748
+ ARIMA: 'ARIMA',
749
+ linear: 'Linear',
750
+ theta: 'Theta',
751
+ model: 'Sybilion AI',
752
+ };
753
+ return nameMap[key] || key.charAt(0).toUpperCase() + key.slice(1);
754
+ }
755
+
756
+ /**
757
+ * Calculate Y range (min/max) from chart data points
758
+ * Extracts all numeric values from chart data points and calculates min/max with padding
759
+ * @param chartData - Array of chart data points
760
+ * @returns Object with yMin and yMax values
761
+ */
762
+ export function calculateYRangeFromChartData(
763
+ chartData: Array<Record<string, unknown>>,
764
+ ): { yMin: number; yMax: number } {
765
+ const allValues: number[] = [];
766
+
767
+ chartData.forEach(point => {
768
+ Object.entries(point).forEach(([key, value]) => {
769
+ if (key === 'date') return;
770
+
771
+ // Include only numeric values (historical and forecast line values)
772
+ if (typeof value === 'number') {
773
+ allValues.push(value);
774
+ }
775
+ });
776
+ });
777
+
778
+ if (allValues.length === 0) {
779
+ return { yMin: 0, yMax: 100 };
780
+ }
781
+
782
+ const min = Math.min(...allValues);
783
+ const max = Math.max(...allValues);
784
+ const diff = max - min;
785
+
786
+ return {
787
+ yMin: min - diff * 0.1,
788
+ yMax: max + diff * 0.1,
789
+ };
790
+ }