@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.
- package/dist/esm/components/ui/TextWithDeferTooltip/TextWithDeferTooltip.js +1 -25
- package/dist/esm/components/ui/Tooltip/Tooltip.js +92 -7
- package/dist/esm/components/ui/Tooltip/Tooltip.styl.js +2 -2
- package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +1 -2
- package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.js +34 -0
- package/dist/esm/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.js +7 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.constants.js +17 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.js +807 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceChart.styl.js +7 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceTable.js +130 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.js +20 -0
- package/dist/esm/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.js +7 -0
- package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +591 -0
- package/dist/esm/components/widgets/PerformanceChart/performanceChartUserSeries.js +109 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/types/src/components/ui/Tooltip/Tooltip.d.ts +3 -3
- package/dist/esm/types/src/components/ui/Tooltip/Tooltip.types.d.ts +1 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.d.ts +7 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.constants.d.ts +3 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceChart.d.ts +54 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceTable.d.ts +31 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.d.ts +20 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/index.d.ts +4 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts +212 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChartUserSeries.d.ts +20 -0
- package/dist/esm/types/src/docs/pages/PerformanceChartPage.d.ts +1 -0
- package/dist/esm/types/src/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/ui/TextWithDeferTooltip/TextWithDeferTooltip.tsx +5 -37
- package/src/components/ui/Tooltip/Tooltip.styl +12 -0
- package/src/components/ui/Tooltip/Tooltip.styl.d.ts +1 -0
- package/src/components/ui/Tooltip/Tooltip.tsx +156 -8
- package/src/components/ui/Tooltip/Tooltip.types.ts +1 -0
- package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl +25 -0
- package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.styl.d.ts +11 -0
- package/src/components/widgets/PerformanceChart/HorizonsSelector/HorizonsSelector.tsx +67 -0
- package/src/components/widgets/PerformanceChart/PerformanceChart.constants.ts +17 -0
- package/src/components/widgets/PerformanceChart/PerformanceChart.styl +194 -0
- package/src/components/widgets/PerformanceChart/PerformanceChart.styl.d.ts +30 -0
- package/src/components/widgets/PerformanceChart/PerformanceChart.tsx +1251 -0
- package/src/components/widgets/PerformanceChart/PerformanceTable.tsx +381 -0
- package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl +49 -0
- package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.styl.d.ts +12 -0
- package/src/components/widgets/PerformanceChart/PerformanceUnderChartLegend/PerformanceUnderChartLegend.tsx +83 -0
- package/src/components/widgets/PerformanceChart/index.ts +28 -0
- package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +790 -0
- package/src/components/widgets/PerformanceChart/performanceChartUserSeries.ts +149 -0
- package/src/docs/pages/PerformanceChartPage.tsx +211 -0
- package/src/docs/pages/TextWithDeferTooltipPage.tsx +26 -10
- package/src/docs/pages/TooltipPage.tsx +30 -0
- package/src/docs/registry.ts +6 -0
- 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
|
+
}
|