@sybilion/uilib 1.3.22 → 1.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +139 -0
  2. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.js +7 -0
  3. package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +159 -0
  4. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.js +34 -0
  5. package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.js +7 -0
  6. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.constants.js +17 -0
  7. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.js +807 -0
  8. package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.styl.js +7 -0
  9. package/dist/esm/components/widgets/PerformanceChart/PerformanceTable.js +130 -0
  10. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.js +20 -0
  11. package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.js +7 -0
  12. package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +591 -0
  13. package/dist/esm/components/widgets/PerformanceChart/performanceChartUserSeries.js +109 -0
  14. package/dist/esm/index.js +6 -0
  15. package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +18 -0
  16. package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +26 -0
  17. package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -0
  18. package/dist/esm/types/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.d.ts +7 -0
  19. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.constants.d.ts +3 -0
  20. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.d.ts +54 -0
  21. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceTable.d.ts +31 -0
  22. package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.d.ts +20 -0
  23. package/dist/esm/types/src/components/widgets/PerformanceChart/index.d.ts +4 -0
  24. package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts +212 -0
  25. package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChartUserSeries.d.ts +20 -0
  26. package/dist/esm/types/src/docs/pages/DriversComparisonChartPage.d.ts +1 -0
  27. package/dist/esm/types/src/docs/pages/PerformanceChartPage.d.ts +1 -0
  28. package/dist/esm/types/src/index.d.ts +2 -0
  29. package/dist/esm/utils/chartConnectionPoint.js +9 -1
  30. package/package.json +1 -1
  31. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl +145 -0
  32. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.d.ts +29 -0
  33. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +325 -0
  34. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +206 -0
  35. package/src/components/widgets/DriversComparisonChart/index.ts +13 -0
  36. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl +25 -0
  37. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.d.ts +11 -0
  38. package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.tsx +67 -0
  39. package/src/components/widgets/PerformanceChart/PerformanceChart.constants.ts +17 -0
  40. package/src/components/widgets/PerformanceChart/PerformanceChart.styl +194 -0
  41. package/src/components/widgets/PerformanceChart/PerformanceChart.styl.d.ts +30 -0
  42. package/src/components/widgets/PerformanceChart/PerformanceChart.tsx +1251 -0
  43. package/src/components/widgets/PerformanceChart/PerformanceTable.tsx +381 -0
  44. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl +49 -0
  45. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.d.ts +12 -0
  46. package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.tsx +83 -0
  47. package/src/components/widgets/PerformanceChart/index.ts +28 -0
  48. package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +790 -0
  49. package/src/components/widgets/PerformanceChart/performanceChartUserSeries.ts +149 -0
  50. package/src/docs/pages/DriversComparisonChartPage.tsx +174 -0
  51. package/src/docs/pages/PerformanceChartPage.tsx +211 -0
  52. package/src/docs/registry.ts +12 -0
  53. package/src/index.ts +2 -0
@@ -0,0 +1,591 @@
1
+ import { normalizeToMonthStart, getPreviousMonth } from '../../../utils/chartConnectionPoint.js';
2
+
3
+ /** Legacy: API backtest spaghetti lines used `SPAGHETTI_FORECAST_ID_BASE + i` (spaghetti view now uses drift per-horizon instead). */
4
+ const SPAGHETTI_FORECAST_ID_BASE = 6000;
5
+ /** Model PER_HORIZON_TO_SPAGHETTI lines use `SPAGHETTI_MODEL_PER_HORIZON_ID_BASE + i`. */
6
+ const SPAGHETTI_MODEL_PER_HORIZON_ID_BASE = 7000;
7
+ /** Drift PER_HORIZON_TO_SPAGHETTI lines use `SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE + i`. */
8
+ const SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE = 8000;
9
+ const SPAGHETTI_DRIFT_ID_RANGE_END = 9000;
10
+ function isSpaghettiModelPerHorizonLineId(id) {
11
+ return (id >= SPAGHETTI_MODEL_PER_HORIZON_ID_BASE &&
12
+ id < SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE);
13
+ }
14
+ function isSpaghettiDriftPerHorizonLineId(id) {
15
+ return (id >= SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE &&
16
+ id < SPAGHETTI_DRIFT_ID_RANGE_END);
17
+ }
18
+ /**
19
+ * Anchor a spaghetti forecast line to the historical curve at the month before
20
+ * the earliest forecast point (same strategy as ensureForecastConnectionPoint).
21
+ */
22
+ function addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDate) {
23
+ if (earliestForecastDate === null)
24
+ return;
25
+ const connectionDate = normalizeToMonthStart(getPreviousMonth(earliestForecastDate));
26
+ const row = map.get(connectionDate);
27
+ if (row === undefined)
28
+ return;
29
+ const hist = row.historical;
30
+ if (typeof hist !== 'number' || !Number.isFinite(hist))
31
+ return;
32
+ const existingVal = row[dataKey];
33
+ if (typeof existingVal === 'number' && Number.isFinite(existingVal)) {
34
+ return;
35
+ }
36
+ row[dataKey] = hist;
37
+ map.set(connectionDate, row);
38
+ }
39
+ /**
40
+ * Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
41
+ * backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
42
+ * (aligned by sorted key index per horizon).
43
+ */
44
+ function buildPerHorizonSpaghettiEntries(forecastRoot, horizonKeys) {
45
+ if (!forecastRoot?.forecasts || horizonKeys.length === 0)
46
+ return [];
47
+ const forecasts = forecastRoot.forecasts;
48
+ const sortedHorizons = [...horizonKeys].sort((a, b) => {
49
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
50
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
51
+ return na - nb;
52
+ });
53
+ const normalizeDateKey = (d) => d.split(' ')[0];
54
+ const perHorizonKeyLists = sortedHorizons.map(h => {
55
+ const m = forecasts[h];
56
+ if (!m || typeof m !== 'object')
57
+ return [];
58
+ return Object.keys(m).sort((a, b) => normalizeDateKey(a).localeCompare(normalizeDateKey(b)));
59
+ });
60
+ const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
61
+ if (lengths.length === 0)
62
+ return [];
63
+ const n = Math.min(...lengths);
64
+ const entries = [];
65
+ for (let i = 0; i < n; i++) {
66
+ const forecast_series = {};
67
+ for (let hi = 0; hi < sortedHorizons.length; hi++) {
68
+ const h = sortedHorizons[hi];
69
+ const keys = perHorizonKeyLists[hi];
70
+ const dateKey = keys[i];
71
+ const rawVal = forecasts[h]?.[dateKey];
72
+ if (typeof rawVal !== 'number' || !Number.isFinite(rawVal))
73
+ continue;
74
+ const norm = normalizeToMonthStart(normalizeDateKey(dateKey));
75
+ forecast_series[norm] = rawVal;
76
+ }
77
+ const dates = Object.keys(forecast_series).sort();
78
+ if (dates.length === 0)
79
+ continue;
80
+ entries.push({
81
+ forecast_start: dates[0],
82
+ forecast_end: dates[dates.length - 1],
83
+ forecast_series,
84
+ });
85
+ }
86
+ return entries;
87
+ }
88
+ /**
89
+ * Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
90
+ * i-th sorted forecast month; `dates[i]` is horizon_1's month at that row (Date column).
91
+ * Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
92
+ */
93
+ function buildDriftSpaghettiMatrixForCustomDialog(driftRoot, horizonKeys) {
94
+ if (!driftRoot?.forecasts || horizonKeys.length === 0)
95
+ return null;
96
+ const forecasts = driftRoot.forecasts;
97
+ const sortedHorizons = [...horizonKeys].sort((a, b) => {
98
+ const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
99
+ const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
100
+ return na - nb;
101
+ });
102
+ const normalizeDateKey = (d) => d.split(' ')[0];
103
+ const perHorizonKeyLists = sortedHorizons.map(h => {
104
+ const m = forecasts[h];
105
+ if (!m || typeof m !== 'object')
106
+ return [];
107
+ return Object.keys(m).sort((a, b) => normalizeDateKey(a).localeCompare(normalizeDateKey(b)));
108
+ });
109
+ const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
110
+ if (lengths.length === 0)
111
+ return null;
112
+ const n = Math.min(...lengths);
113
+ const dates = [];
114
+ const grid = [];
115
+ const perHorizonDates = [];
116
+ for (let i = 0; i < n; i++) {
117
+ const row = [];
118
+ const dateRow = [];
119
+ for (let hi = 0; hi < sortedHorizons.length; hi++) {
120
+ const h = sortedHorizons[hi];
121
+ const keys = perHorizonKeyLists[hi];
122
+ const dateKey = keys[i];
123
+ dateRow.push(normalizeToMonthStart(normalizeDateKey(dateKey)));
124
+ const rawVal = forecasts[h]?.[dateKey];
125
+ const v = typeof rawVal === 'number' && Number.isFinite(rawVal) ? rawVal : NaN;
126
+ row.push(v);
127
+ }
128
+ const d0 = perHorizonKeyLists[0][i];
129
+ dates.push(normalizeToMonthStart(normalizeDateKey(d0)));
130
+ perHorizonDates.push(dateRow);
131
+ grid.push(row.map(c => (Number.isFinite(c) ? c : 0)));
132
+ }
133
+ return {
134
+ dates,
135
+ grid,
136
+ perHorizonDates,
137
+ };
138
+ }
139
+ /**
140
+ * Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
141
+ * historical value for the month before the earliest forecast month in that row (same anchor as
142
+ * chart connection-point logic).
143
+ */
144
+ function spaghettiGridFromHistoricalPreviousMonth(perHorizonDates, horizonCount, historicalByDate) {
145
+ const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
146
+ return perHorizonDates.map(row => {
147
+ if (!Array.isArray(row) || row.length === 0) {
148
+ return Array.from({ length: horizonCount }, () => 0);
149
+ }
150
+ const sorted = [...row]
151
+ .map(d => norm(String(d)))
152
+ .filter(Boolean)
153
+ .sort((a, b) => a.localeCompare(b));
154
+ const earliest = sorted[0];
155
+ if (!earliest) {
156
+ return Array.from({ length: horizonCount }, () => 0);
157
+ }
158
+ const prevMonth = normalizeToMonthStart(getPreviousMonth(earliest));
159
+ const v = historicalByDate.get(prevMonth);
160
+ const flat = typeof v === 'number' && Number.isFinite(v) ? v : 0;
161
+ return Array.from({ length: horizonCount }, () => flat);
162
+ });
163
+ }
164
+ /** Mean absolute error / MAPE vs historical for one horizon column of a spaghetti matrix (aligned by month). */
165
+ function averageForecastErrorsVsHistoricalForMatrixColumn(matrix, horizonColumnIndex, historicalByDate) {
166
+ const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
167
+ const maeList = [];
168
+ const mapeList = [];
169
+ const nR = matrix.grid.length;
170
+ for (let r = 0; r < nR; r++) {
171
+ const dSrc = matrix.perHorizonDates?.[r]?.[horizonColumnIndex] ?? matrix.dates[r];
172
+ const d = norm(String(dSrc));
173
+ const actual = historicalByDate.get(d);
174
+ const fc = matrix.grid[r]?.[horizonColumnIndex];
175
+ if (actual === undefined ||
176
+ typeof fc !== 'number' ||
177
+ !Number.isFinite(fc)) {
178
+ continue;
179
+ }
180
+ const err = Math.abs(fc - actual);
181
+ maeList.push(err);
182
+ if (Math.abs(actual) > 1e-9)
183
+ mapeList.push(err / Math.abs(actual));
184
+ }
185
+ if (maeList.length === 0)
186
+ return null;
187
+ return {
188
+ mae: maeList.reduce((a, b) => a + b, 0) / maeList.length,
189
+ mape: mapeList.length > 0
190
+ ? mapeList.reduce((a, b) => a + b, 0) / mapeList.length
191
+ : 0,
192
+ };
193
+ }
194
+ function mergeSpaghettiMergedBases(a, b) {
195
+ const norm = (d) => normalizeToMonthStart(d);
196
+ const map = new Map();
197
+ const mergePoints = (points) => {
198
+ points.forEach(point => {
199
+ const k = norm(point.date);
200
+ const prev = map.get(k);
201
+ if (!prev) {
202
+ map.set(k, { ...point, date: k });
203
+ return;
204
+ }
205
+ const merged = { ...prev };
206
+ Object.keys(point).forEach(key => {
207
+ if (key === 'date')
208
+ return;
209
+ if (key.startsWith('forecast_')) {
210
+ merged[key] = point[key];
211
+ }
212
+ });
213
+ if (point.historical !== undefined) {
214
+ merged.historical = point.historical;
215
+ }
216
+ map.set(k, merged);
217
+ });
218
+ };
219
+ mergePoints(a.mergedData);
220
+ mergePoints(b.mergedData);
221
+ const mergedData = Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
222
+ return {
223
+ mergedData,
224
+ seriesMeta: [...a.seriesMeta, ...b.seriesMeta],
225
+ };
226
+ }
227
+ function buildSpaghettiMergedChartData(entries, historicalChartData, idBase = SPAGHETTI_FORECAST_ID_BASE) {
228
+ const norm = (d) => normalizeToMonthStart(d);
229
+ const map = new Map();
230
+ historicalChartData.forEach(point => {
231
+ const k = norm(point.date);
232
+ map.set(k, { ...point, date: k });
233
+ });
234
+ const seriesMeta = [];
235
+ entries.forEach((entry, i) => {
236
+ const id = idBase + i;
237
+ const dataKey = `forecast_${id}`;
238
+ seriesMeta.push({
239
+ id,
240
+ label: `${entry.forecast_start} – ${entry.forecast_end}`,
241
+ });
242
+ let earliestForecastDate = null;
243
+ Object.entries(entry.forecast_series).forEach(([dateStr, val]) => {
244
+ if (typeof val !== 'number' || !Number.isFinite(val))
245
+ return;
246
+ const k = norm(dateStr);
247
+ if (earliestForecastDate === null ||
248
+ k.localeCompare(earliestForecastDate) < 0) {
249
+ earliestForecastDate = k;
250
+ }
251
+ const existing = map.get(k) ?? { date: k };
252
+ existing[dataKey] = val;
253
+ map.set(k, existing);
254
+ });
255
+ addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDate);
256
+ });
257
+ const mergedData = Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
258
+ return { mergedData, seriesMeta };
259
+ }
260
+ function mergeUserSeriesForecastIntoSpaghettiPoints(mergedData, seriesId, dates, forecastValues) {
261
+ const norm = (d) => normalizeToMonthStart(d);
262
+ const map = new Map();
263
+ mergedData.forEach(point => {
264
+ const k = norm(point.date);
265
+ map.set(k, { ...point, date: k });
266
+ });
267
+ const dataKey = `forecast_${seriesId}`;
268
+ let earliestForecastDate = null;
269
+ dates.forEach((dateStr, i) => {
270
+ const val = forecastValues[i];
271
+ if (typeof val !== 'number' || !Number.isFinite(val))
272
+ return;
273
+ const k = norm(dateStr);
274
+ if (earliestForecastDate === null ||
275
+ k.localeCompare(earliestForecastDate) < 0) {
276
+ earliestForecastDate = k;
277
+ }
278
+ const existing = map.get(k) ?? { date: k };
279
+ existing[dataKey] = val;
280
+ map.set(k, existing);
281
+ });
282
+ addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDate);
283
+ return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
284
+ }
285
+ /** Appends forecast-backed user series (from dataset context) onto spaghetti backtest lines. */
286
+ function mergeSpaghettiUserSeriesFromForecastData(base, userSeries, forecastData) {
287
+ let mergedData = base.mergedData;
288
+ const extendedMeta = [...base.seriesMeta];
289
+ userSeries.forEach(analysis => {
290
+ const fd = forecastData[String(analysis.id)];
291
+ if (!fd)
292
+ return;
293
+ const { dates, forecastValues } = fd;
294
+ if (dates.length === 0 || forecastValues.length !== dates.length)
295
+ return;
296
+ if (extendedMeta.some(m => m.id === analysis.id))
297
+ return;
298
+ mergedData = mergeUserSeriesForecastIntoSpaghettiPoints(mergedData, analysis.id, dates, forecastValues);
299
+ extendedMeta.push({
300
+ id: analysis.id,
301
+ label: analysis.name?.trim() || 'User series',
302
+ });
303
+ });
304
+ return { mergedData, seriesMeta: extendedMeta };
305
+ }
306
+ function filterChartDataLast24Months(data, months) {
307
+ if (data.length === 0)
308
+ return [];
309
+ const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date));
310
+ const cutoffDate = new Date(sorted[sorted.length - 1].date);
311
+ cutoffDate.setMonth(cutoffDate.getMonth() - months);
312
+ return sorted.filter(point => new Date(point.date) >= cutoffDate);
313
+ }
314
+ /** Earlier month string (ISO-style sortable). */
315
+ function earlierMonthStart(a, b) {
316
+ return a.localeCompare(b) < 0 ? a : b;
317
+ }
318
+ /**
319
+ * Spaghetti chart x-range: from two months before the earliest forecast_start
320
+ * (per-horizon synthetic entries + mergeable user forecast series), inclusive.
321
+ * Falls back to last N months when no forecast candidates exist.
322
+ *
323
+ * When `historicalWindowFloor` is set (e.g. first month of per-horizon `filtered24mData`),
324
+ * the range start is floored to that month so the historical line matches the per-horizon tab.
325
+ *
326
+ * When `historicalWindowCeiling` is set (e.g. last month of `filtered24mData`), drops points after
327
+ * that month so spaghetti X matches per-horizon (per-horizon chart trims forecasts past last historical).
328
+ */
329
+ function filterSpaghettiDataFromEarliestForecastStart(mergedData, entries, userSeries, forecastData,
330
+ /** Series meta from the first merged batch (e.g. drift) — used to skip duplicate user-series ids. */
331
+ firstBatchSeriesMeta, fallbackMonths, extraForecastCandidates, historicalWindowFloor, historicalWindowCeiling) {
332
+ const candidates = [];
333
+ for (const e of entries) {
334
+ candidates.push(normalizeToMonthStart(e.forecast_start));
335
+ }
336
+ extraForecastCandidates?.forEach(e => {
337
+ candidates.push(normalizeToMonthStart(e.forecast_start));
338
+ });
339
+ userSeries.forEach(analysis => {
340
+ const fd = forecastData[String(analysis.id)];
341
+ if (!fd)
342
+ return;
343
+ const { dates, forecastValues } = fd;
344
+ if (dates.length === 0 || forecastValues.length !== dates.length)
345
+ return;
346
+ if (firstBatchSeriesMeta.some(m => m.id === analysis.id))
347
+ return;
348
+ candidates.push(normalizeToMonthStart(dates[0]));
349
+ });
350
+ if (candidates.length === 0) {
351
+ let result = filterChartDataLast24Months(mergedData, fallbackMonths);
352
+ if (historicalWindowFloor && result.length > 0 && mergedData.length > 0) {
353
+ const floor = normalizeToMonthStart(historicalWindowFloor);
354
+ const start = earlierMonthStart(floor, result[0].date);
355
+ const maxDate = result[result.length - 1].date;
356
+ const sorted = [...mergedData].sort((a, b) => a.date.localeCompare(b.date));
357
+ result = sorted.filter(p => p.date >= start && p.date <= maxDate);
358
+ }
359
+ if (historicalWindowCeiling && result.length > 0) {
360
+ const cap = normalizeToMonthStart(historicalWindowCeiling);
361
+ result = result.filter(p => normalizeToMonthStart(String(p.date).split(' ')[0] ?? p.date) <= cap);
362
+ }
363
+ return result;
364
+ }
365
+ const normalizedMin = candidates.reduce((min, c) => c.localeCompare(min) < 0 ? c : min);
366
+ let rangeStart = normalizeToMonthStart(getPreviousMonth(getPreviousMonth(normalizedMin)));
367
+ if (historicalWindowFloor) {
368
+ const floor = normalizeToMonthStart(historicalWindowFloor);
369
+ rangeStart = earlierMonthStart(floor, rangeStart);
370
+ }
371
+ const sorted = [...mergedData].sort((a, b) => a.date.localeCompare(b.date));
372
+ let result = sorted.filter(point => point.date >= rangeStart);
373
+ if (historicalWindowCeiling && result.length > 0) {
374
+ const cap = normalizeToMonthStart(historicalWindowCeiling);
375
+ result = result.filter(p => normalizeToMonthStart(String(p.date).split(' ')[0] ?? p.date) <= cap);
376
+ }
377
+ return result;
378
+ }
379
+ /**
380
+ * Ensure each spaghetti row has `historical` from {@link historicalChartData} when that month
381
+ * falls in the filtered window — matches per-horizon chart (actuals line), and fills months that
382
+ * exist in history but were missing after merge/filter.
383
+ */
384
+ function mergeHistoricalIntoSpaghettiChartData(filteredRows, historicalChartData) {
385
+ if (filteredRows.length === 0 || historicalChartData.length === 0) {
386
+ return filteredRows;
387
+ }
388
+ const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
389
+ const histByDate = new Map();
390
+ historicalChartData.forEach(p => {
391
+ const v = p.historical;
392
+ if (typeof v !== 'number' || !Number.isFinite(v))
393
+ return;
394
+ histByDate.set(norm(p.date), v);
395
+ });
396
+ const sorted = [...filteredRows].sort((a, b) => a.date.localeCompare(b.date));
397
+ const minD = norm(sorted[0].date);
398
+ const maxD = norm(sorted[sorted.length - 1].date);
399
+ const rowMap = new Map();
400
+ sorted.forEach(p => {
401
+ const k = norm(p.date);
402
+ const fromHist = histByDate.get(k);
403
+ const merged = { ...p, date: k };
404
+ if (fromHist !== undefined) {
405
+ merged.historical = fromHist;
406
+ }
407
+ rowMap.set(k, merged);
408
+ });
409
+ histByDate.forEach((histVal, dateKey) => {
410
+ if (dateKey < minD || dateKey > maxD)
411
+ return;
412
+ if (!rowMap.has(dateKey)) {
413
+ rowMap.set(dateKey, { date: dateKey, historical: histVal });
414
+ }
415
+ });
416
+ return [...rowMap.values()].sort((a, b) => a.date.localeCompare(b.date));
417
+ }
418
+ /**
419
+ * Calculate Accuracy: 100% - MAPE
420
+ * @param mape - MAPE value as decimal (e.g., 0.0436 for 4.36%)
421
+ * @returns Accuracy percentage
422
+ */
423
+ function calculateAccuracy(mape) {
424
+ return 100 - mape * 100;
425
+ }
426
+ /**
427
+ * Format Accuracy as percentage
428
+ * @param value - Accuracy value
429
+ * @returns Formatted accuracy string (e.g., "97%")
430
+ */
431
+ function formatAccuracy(value) {
432
+ if (value < 10)
433
+ return `< 10%`;
434
+ return `${Math.round(value)}%`;
435
+ }
436
+ /**
437
+ * Format Error as "$/Ton"
438
+ * @param mae - MAE value from metrics_summary.24m.mae
439
+ * @returns Formatted error string (e.g., "0.05$/Ton")
440
+ */
441
+ function formatError(mae) {
442
+ return `${mae.toFixed(2)} $/Ton`;
443
+ }
444
+ /**
445
+ * Calculate ROI
446
+ * Reference: ROI/calculatons.ts for example logic
447
+ * @param totalBenefit - Total annual benefit
448
+ * @param operatingCost - Annual operating cost
449
+ * @returns ROI percentage
450
+ */
451
+ function calculateROI(totalBenefit, operatingCost) {
452
+ if (operatingCost <= 0)
453
+ return 0;
454
+ const netBenefit = totalBenefit - operatingCost;
455
+ return (netBenefit / operatingCost) * 100;
456
+ }
457
+ /**
458
+ * Calculate ROI Multiple
459
+ * Formula: ROI Multiple = ROI / 100
460
+ * Where ROI = (AB - IC) / IC × 100
461
+ * @param totalBenefit - Total annual benefit (AB)
462
+ * @param operatingCost - Annual operating cost (IC)
463
+ * @returns ROI multiple (e.g., 1.0 for 100% ROI, 2.0 for 200% ROI)
464
+ */
465
+ function calculateROIMultiple(totalBenefit, operatingCost) {
466
+ if (operatingCost <= 0)
467
+ return 0;
468
+ const roi = calculateROI(totalBenefit, operatingCost);
469
+ return roi / 100;
470
+ }
471
+ /**
472
+ * Format ROI as multiplier (xN format)
473
+ * Formula: ROI Multiple = ROI / 100
474
+ * Examples: 100% ROI = x1, 200% ROI = x2, 0% ROI = x0
475
+ * Note: Negative ROI (when benefit < cost) is displayed as x1
476
+ * @param totalBenefit - Total annual benefit (AB)
477
+ * @param operatingCost - Annual operating cost (IC)
478
+ * @returns Formatted ROI string (e.g., "x1.5")
479
+ */
480
+ function formatROI(totalBenefit, operatingCost) {
481
+ const multiple = calculateROIMultiple(totalBenefit, operatingCost);
482
+ // If multiple is negative (benefit < cost), show as x1
483
+ const adjustedMultiple = multiple < 0 ? 1 : multiple;
484
+ // Round to 1 decimal place
485
+ const rounded = Math.round(adjustedMultiple * 10) / 10;
486
+ // Format: show as integer if whole number, otherwise 1 decimal place
487
+ const formatted = rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1);
488
+ return `x${formatted}`;
489
+ }
490
+ /**
491
+ * Calculate Benefit p/a
492
+ * Formula: Annual Benefit = PV × V × C × PI
493
+ * Where:
494
+ * - PV = Procurement Volume
495
+ * - V = Variable Raw Material Share (as decimal)
496
+ * - C = Controllable Cost Share (as decimal)
497
+ * - PI = Price Improvement = |FA_new - FA_current| × 0.2 (correlation factor)
498
+ * @param mae - MAE value (not used in calculation, kept for backward compatibility)
499
+ * @param mape - MAPE value (used to calculate actual forecast accuracy)
500
+ * @param adjustParams - Adjust parameters from dialog
501
+ * @param baselineAccuracy - Optional baseline accuracy for comparison (if provided, uses actual improvement)
502
+ * @returns Benefit per annum
503
+ */
504
+ function calculateBenefit(mae, mape, adjustParams, baselineAccuracy) {
505
+ // Calculate Price Improvement (PI) as decimal
506
+ // If baselineAccuracy is provided, use actual accuracy improvement
507
+ // Otherwise, use user-entered expected improvement
508
+ let accuracyImprovement;
509
+ if (baselineAccuracy !== undefined) {
510
+ // Use actual forecast accuracy improvement
511
+ const currentAccuracy = calculateAccuracy(mape);
512
+ accuracyImprovement = Math.abs(currentAccuracy - baselineAccuracy);
513
+ }
514
+ else {
515
+ // Use user-entered expected improvement
516
+ accuracyImprovement = adjustParams.expectedImprovement;
517
+ }
518
+ // PI = accuracyImprovement × 0.2 (correlation factor)
519
+ const priceImprovement = (accuracyImprovement / 100) * 0.2;
520
+ // Convert percentages to decimals
521
+ const variableShare = adjustParams.variableRawMaterialCostShare / 100;
522
+ const controllableShare = adjustParams.controllableCosts / 100;
523
+ // Annual Benefit = PV × V × C × PI
524
+ const annualBenefit = adjustParams.procurementVolume *
525
+ variableShare *
526
+ controllableShare *
527
+ priceImprovement;
528
+ return Math.round(annualBenefit);
529
+ }
530
+ /**
531
+ * Format Benefit as euros
532
+ * @param value - Benefit value
533
+ * @returns Formatted benefit string (e.g., "€616.640")
534
+ */
535
+ function formatBenefit(value) {
536
+ return new Intl.NumberFormat('de-DE', {
537
+ style: 'currency',
538
+ currency: 'EUR',
539
+ minimumFractionDigits: 0,
540
+ maximumFractionDigits: 0,
541
+ }).format(value);
542
+ }
543
+ /**
544
+ * Map forecast model key to display name
545
+ * @param key - Forecast model key (e.g., "mean", "drift", "seasonal")
546
+ * @returns Display name (e.g., "Mean", "Drift", "Seasonal")
547
+ */
548
+ function getForecastModelDisplayName(key) {
549
+ const nameMap = {
550
+ mean: 'Mean',
551
+ drift: 'Statistical baseline',
552
+ seasonal: 'Seasonal',
553
+ ARMA: 'ARMA',
554
+ ARIMA: 'ARIMA',
555
+ linear: 'Linear',
556
+ theta: 'Theta',
557
+ model: 'Sybilion AI',
558
+ };
559
+ return nameMap[key] || key.charAt(0).toUpperCase() + key.slice(1);
560
+ }
561
+ /**
562
+ * Calculate Y range (min/max) from chart data points
563
+ * Extracts all numeric values from chart data points and calculates min/max with padding
564
+ * @param chartData - Array of chart data points
565
+ * @returns Object with yMin and yMax values
566
+ */
567
+ function calculateYRangeFromChartData(chartData) {
568
+ const allValues = [];
569
+ chartData.forEach(point => {
570
+ Object.entries(point).forEach(([key, value]) => {
571
+ if (key === 'date')
572
+ return;
573
+ // Include only numeric values (historical and forecast line values)
574
+ if (typeof value === 'number') {
575
+ allValues.push(value);
576
+ }
577
+ });
578
+ });
579
+ if (allValues.length === 0) {
580
+ return { yMin: 0, yMax: 100 };
581
+ }
582
+ const min = Math.min(...allValues);
583
+ const max = Math.max(...allValues);
584
+ const diff = max - min;
585
+ return {
586
+ yMin: min - diff * 0.1,
587
+ yMax: max + diff * 0.1,
588
+ };
589
+ }
590
+
591
+ export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_FORECAST_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, calculateAccuracy, calculateBenefit, calculateROI, calculateROIMultiple, calculateYRangeFromChartData, filterChartDataLast24Months, filterSpaghettiDataFromEarliestForecastStart, formatAccuracy, formatBenefit, formatError, formatROI, getForecastModelDisplayName, isSpaghettiDriftPerHorizonLineId, isSpaghettiModelPerHorizonLineId, mergeHistoricalIntoSpaghettiChartData, mergeSpaghettiMergedBases, mergeSpaghettiUserSeriesFromForecastData, spaghettiGridFromHistoricalPreviousMonth };
@@ -0,0 +1,109 @@
1
+ import { normalizeToMonthStart } from '../../../utils/chartConnectionPoint.js';
2
+
3
+ const SPAGHETTI_TIME_SERIES_MATRIX_V = 2;
4
+ const SPAGHETTI_MATRIX_SYNTHETIC_BASE = 9_800_000;
5
+ const SPAGHETTI_MATRIX_MAX_COLS = 128;
6
+ const SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID = 9_000_001;
7
+ const PER_HORIZON_VIEW_CUSTOM_LINE_ID_BASE = SPAGHETTI_MATRIX_SYNTHETIC_BASE + 4_000_000;
8
+ function spaghettiMatrixSyntheticId(userSeriesRowId, horizonIndex) {
9
+ return (SPAGHETTI_MATRIX_SYNTHETIC_BASE +
10
+ userSeriesRowId * SPAGHETTI_MATRIX_MAX_COLS +
11
+ horizonIndex);
12
+ }
13
+ function isSpaghettiMatrixSyntheticLineId(id) {
14
+ return id >= SPAGHETTI_MATRIX_SYNTHETIC_BASE;
15
+ }
16
+ function perHorizonViewCustomMatrixSyntheticId(colIdx) {
17
+ return PER_HORIZON_VIEW_CUSTOM_LINE_ID_BASE + colIdx;
18
+ }
19
+ function matrixHasValidPerHorizonDates(matrix) {
20
+ const ph = matrix.perHorizonDates;
21
+ if (!ph || ph.length !== matrix.grid.length)
22
+ return false;
23
+ const h = matrix.horizonKeys.length;
24
+ return ph.every((row, r) => Array.isArray(row) &&
25
+ row.length === h &&
26
+ row.length === matrix.grid[r].length);
27
+ }
28
+ function getCustomMatrixSeriesForHorizonTab(matrix, horizonKey, rowId, forecastData) {
29
+ const colIdx = matrix.horizonKeys.indexOf(horizonKey);
30
+ if (colIdx < 0)
31
+ return null;
32
+ if (matrixHasValidPerHorizonDates(matrix)) {
33
+ const ph = matrix.perHorizonDates;
34
+ const dates = [];
35
+ const forecastValues = [];
36
+ for (let r = 0; r < matrix.grid.length; r++) {
37
+ const rawD = ph[r][colIdx];
38
+ const v = matrix.grid[r][colIdx];
39
+ if (typeof v !== 'number' || !Number.isFinite(v))
40
+ continue;
41
+ dates.push(normalizeToMonthStart(typeof rawD === 'string' ? rawD : String(rawD)));
42
+ forecastValues.push(v);
43
+ }
44
+ if (dates.length === 0)
45
+ return null;
46
+ return {
47
+ lineId: perHorizonViewCustomMatrixSyntheticId(colIdx),
48
+ dates,
49
+ forecastValues,
50
+ };
51
+ }
52
+ const lineId = spaghettiMatrixSyntheticId(rowId, colIdx);
53
+ const fd = forecastData?.[String(lineId)];
54
+ if (!fd?.dates?.length || fd.forecastValues.length !== fd.dates.length) {
55
+ return null;
56
+ }
57
+ return {
58
+ lineId,
59
+ dates: fd.dates,
60
+ forecastValues: fd.forecastValues,
61
+ };
62
+ }
63
+ function isRecord(value) {
64
+ return typeof value === 'object' && value !== null;
65
+ }
66
+ function tryParseSpaghettiPerformanceMatrix(parsed) {
67
+ if (!isRecord(parsed))
68
+ return null;
69
+ if (parsed.v !== SPAGHETTI_TIME_SERIES_MATRIX_V)
70
+ return null;
71
+ const dates = parsed.dates;
72
+ const horizonKeys = parsed.horizonKeys;
73
+ const grid = parsed.grid;
74
+ if (!Array.isArray(dates) ||
75
+ !Array.isArray(horizonKeys) ||
76
+ !Array.isArray(grid)) {
77
+ return null;
78
+ }
79
+ if (dates.length === 0 ||
80
+ horizonKeys.length === 0 ||
81
+ grid.length !== dates.length) {
82
+ return null;
83
+ }
84
+ const h = horizonKeys.length;
85
+ if (!grid.every(row => Array.isArray(row) && row.length === h))
86
+ return null;
87
+ const perHorizonDates = parsed.perHorizonDates;
88
+ let ph;
89
+ if (perHorizonDates !== undefined) {
90
+ if (!Array.isArray(perHorizonDates) ||
91
+ perHorizonDates.length !== grid.length) {
92
+ return null;
93
+ }
94
+ ph = perHorizonDates.map(row => {
95
+ if (!Array.isArray(row) || row.length !== h)
96
+ return [];
97
+ return row.map(d => String(d));
98
+ });
99
+ }
100
+ return {
101
+ v: SPAGHETTI_TIME_SERIES_MATRIX_V,
102
+ dates: dates.map(d => String(d)),
103
+ horizonKeys: horizonKeys.map(k => String(k)),
104
+ grid: grid.map(row => row.map(v => Number(v))),
105
+ ...(ph ? { perHorizonDates: ph } : {}),
106
+ };
107
+ }
108
+
109
+ export { SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, SPAGHETTI_MATRIX_MAX_COLS, SPAGHETTI_MATRIX_SYNTHETIC_BASE, SPAGHETTI_TIME_SERIES_MATRIX_V, getCustomMatrixSeriesForHorizonTab, isSpaghettiMatrixSyntheticLineId, spaghettiMatrixSyntheticId, tryParseSpaghettiPerformanceMatrix };