@sybilion/uilib 1.3.64 → 1.3.65
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/Chart/components/QuantileBands.js +1 -1
- package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +60 -1
- package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +132 -33
- package/dist/esm/index.js +3 -1
- package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +3 -2
- package/dist/esm/types/src/components/ui/ChartAreaInteractive/index.d.ts +2 -0
- package/dist/esm/types/src/components/widgets/PerformanceChart/index.d.ts +1 -1
- package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts +22 -2
- package/package.json +4 -2
- package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +4 -3
- package/src/components/ui/ChartAreaInteractive/index.ts +2 -0
- package/src/components/ui/Page/AGENT.md +165 -0
- package/src/components/widgets/AGENT.md +1 -1
- package/src/components/widgets/PerformanceChart/index.ts +3 -0
- package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +197 -41
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
2
|
-
import {
|
|
2
|
+
import { FORECAST_COLORS_MAP, getForecastQuantileBandColor } from '../../ChartAreaInteractive/ChartLines.js';
|
|
3
3
|
import { Area } from 'recharts';
|
|
4
4
|
|
|
5
5
|
const DEFAULT_QUANTILE_BAND_COLOR = FORECAST_COLORS_MAP['#04ADC3'];
|
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
// Helper function to format large numbers with k/m abbreviations
|
|
2
|
+
const formatNumber = (value) => {
|
|
3
|
+
if (value >= 1000000) {
|
|
4
|
+
return (value / 1000000).toFixed(1).replace(/\.0$/, '') + 'm';
|
|
5
|
+
}
|
|
6
|
+
else if (value >= 1000) {
|
|
7
|
+
return (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
8
|
+
}
|
|
9
|
+
return value.toFixed(0);
|
|
10
|
+
};
|
|
11
|
+
// Find pin position for a specific date in the current filtered data
|
|
12
|
+
const findPinPositionForDate = (targetDate, filteredData) => {
|
|
13
|
+
if (!filteredData.length)
|
|
14
|
+
return 0;
|
|
15
|
+
const targetDateObj = new Date(targetDate);
|
|
16
|
+
let closestIndex = 0;
|
|
17
|
+
let minDistance = Infinity;
|
|
18
|
+
// First, try to find an exact match
|
|
19
|
+
const exactMatch = filteredData.findIndex(dataPoint => dataPoint.date &&
|
|
20
|
+
new Date(dataPoint.date).getTime() === targetDateObj.getTime());
|
|
21
|
+
if (exactMatch !== -1) {
|
|
22
|
+
return (exactMatch / (filteredData.length - 1)) * 100;
|
|
23
|
+
}
|
|
24
|
+
// If no exact match, find the closest date
|
|
25
|
+
filteredData.forEach((dataPoint, index) => {
|
|
26
|
+
if (dataPoint.date) {
|
|
27
|
+
const pointDate = new Date(dataPoint.date);
|
|
28
|
+
const distance = Math.abs(pointDate.getTime() - targetDateObj.getTime());
|
|
29
|
+
if (distance < minDistance) {
|
|
30
|
+
minDistance = distance;
|
|
31
|
+
closestIndex = index;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// If the target date is outside the current range, clamp to the nearest edge
|
|
36
|
+
const firstDate = new Date(filteredData[0].date);
|
|
37
|
+
const lastDate = new Date(filteredData[filteredData.length - 1].date);
|
|
38
|
+
if (targetDateObj < firstDate) {
|
|
39
|
+
return 0; // Pin at the beginning
|
|
40
|
+
}
|
|
41
|
+
else if (targetDateObj > lastDate) {
|
|
42
|
+
return 100; // Pin at the end
|
|
43
|
+
}
|
|
44
|
+
return (closestIndex / (filteredData.length - 1)) * 100;
|
|
45
|
+
};
|
|
2
46
|
const timeRangeToMonths = {
|
|
3
47
|
'6m': 6,
|
|
4
48
|
'1y': 12,
|
|
@@ -147,5 +191,20 @@ const longDateFormatter = (value) => {
|
|
|
147
191
|
year: 'numeric',
|
|
148
192
|
});
|
|
149
193
|
};
|
|
194
|
+
/**
|
|
195
|
+
* Checks if an analysis status is 'fail' (case-insensitive).
|
|
196
|
+
* @param analysis - The analysis object with optional status field
|
|
197
|
+
* @returns true if status is 'fail', false otherwise
|
|
198
|
+
*/
|
|
199
|
+
const isAnalysisFailed = (analysis) => {
|
|
200
|
+
return analysis.status?.toLowerCase() === 'fail';
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Checks if an analysis status is not 'fail' (case-insensitive).
|
|
204
|
+
* @param analysis - The analysis object with optional status field
|
|
205
|
+
* @returns true if status is not 'fail', false otherwise
|
|
206
|
+
*/
|
|
207
|
+
const isAnalysisNotFailed = (analysis) => !isAnalysisFailed(analysis);
|
|
208
|
+
const isAnalysisDone = (analysis) => analysis.status?.toLowerCase() === 'done';
|
|
150
209
|
|
|
151
|
-
export { DRAG_TIME_RANGE_PREFIX, encodeDragTimeRange, filterDataForTimeRange, isTimeRangePreset, longDateFormatter, parseDragTimeRange, shortDateFormatter };
|
|
210
|
+
export { DRAG_TIME_RANGE_PREFIX, encodeDragTimeRange, filterDataForTimeRange, findPinPositionForDate, formatNumber, isAnalysisDone, isAnalysisFailed, isAnalysisNotFailed, isTimeRangePreset, longDateFormatter, parseDragTimeRange, shortDateFormatter };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeToMonthStart, getPreviousMonth } from '../../../utils/chartConnectionPoint.js';
|
|
1
|
+
import { normalizeToMonthStart, getNextMonth, getPreviousMonth } from '../../../utils/chartConnectionPoint.js';
|
|
2
2
|
|
|
3
3
|
/** Legacy: API backtest spaghetti lines used `SPAGHETTI_FORECAST_ID_BASE + i` (spaghetti view now uses drift per-horizon instead). */
|
|
4
4
|
const SPAGHETTI_FORECAST_ID_BASE = 6000;
|
|
@@ -36,14 +36,10 @@ function addSpaghettiHistoricalBridgeForSeries(map, dataKey, earliestForecastDat
|
|
|
36
36
|
row[dataKey] = hist;
|
|
37
37
|
map.set(connectionDate, row);
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*/
|
|
44
|
-
function buildPerHorizonSpaghettiEntries(forecastRoot, horizonKeys) {
|
|
45
|
-
if (!forecastRoot?.forecasts || horizonKeys.length === 0)
|
|
46
|
-
return [];
|
|
39
|
+
/** When horizons have different lengths, align on the latest `n` months (suffix), not the oldest. */
|
|
40
|
+
function perHorizonSortedKeyLists(forecastRoot, horizonKeys) {
|
|
41
|
+
if (!forecastRoot.forecasts || horizonKeys.length === 0)
|
|
42
|
+
return null;
|
|
47
43
|
const forecasts = forecastRoot.forecasts;
|
|
48
44
|
const sortedHorizons = [...horizonKeys].sort((a, b) => {
|
|
49
45
|
const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
|
|
@@ -59,15 +55,45 @@ function buildPerHorizonSpaghettiEntries(forecastRoot, horizonKeys) {
|
|
|
59
55
|
});
|
|
60
56
|
const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
|
|
61
57
|
if (lengths.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return { sortedHorizons, perHorizonKeyLists };
|
|
60
|
+
}
|
|
61
|
+
function alignedHorizonRowCount(perHorizonKeyLists) {
|
|
62
|
+
const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
|
|
63
|
+
if (lengths.length === 0)
|
|
64
|
+
return 0;
|
|
65
|
+
return Math.min(...lengths);
|
|
66
|
+
}
|
|
67
|
+
function horizonDateKeyAtRow(perHorizonKeyLists, horizonIndex, rowIndex, rowCount) {
|
|
68
|
+
const keys = perHorizonKeyLists[horizonIndex];
|
|
69
|
+
if (!keys?.length)
|
|
70
|
+
return undefined;
|
|
71
|
+
const offset = keys.length - rowCount;
|
|
72
|
+
return keys[offset + rowIndex];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
|
|
76
|
+
* backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
|
|
77
|
+
* (aligned on the latest shared months per horizon).
|
|
78
|
+
*/
|
|
79
|
+
function buildPerHorizonSpaghettiEntries(forecastRoot, horizonKeys) {
|
|
80
|
+
const aligned = perHorizonSortedKeyLists(forecastRoot ?? {}, horizonKeys);
|
|
81
|
+
if (!aligned)
|
|
82
|
+
return [];
|
|
83
|
+
const { sortedHorizons, perHorizonKeyLists } = aligned;
|
|
84
|
+
const forecasts = forecastRoot.forecasts;
|
|
85
|
+
const normalizeDateKey = (d) => d.split(' ')[0];
|
|
86
|
+
const n = alignedHorizonRowCount(perHorizonKeyLists);
|
|
87
|
+
if (n === 0)
|
|
62
88
|
return [];
|
|
63
|
-
const n = Math.min(...lengths);
|
|
64
89
|
const entries = [];
|
|
65
90
|
for (let i = 0; i < n; i++) {
|
|
66
91
|
const forecast_series = {};
|
|
67
92
|
for (let hi = 0; hi < sortedHorizons.length; hi++) {
|
|
68
93
|
const h = sortedHorizons[hi];
|
|
69
|
-
const
|
|
70
|
-
|
|
94
|
+
const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
|
|
95
|
+
if (!dateKey)
|
|
96
|
+
continue;
|
|
71
97
|
const rawVal = forecasts[h]?.[dateKey];
|
|
72
98
|
if (typeof rawVal !== 'number' || !Number.isFinite(rawVal))
|
|
73
99
|
continue;
|
|
@@ -87,29 +113,19 @@ function buildPerHorizonSpaghettiEntries(forecastRoot, horizonKeys) {
|
|
|
87
113
|
}
|
|
88
114
|
/**
|
|
89
115
|
* Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
|
|
90
|
-
*
|
|
116
|
+
* latest-aligned forecast month; `dates[i]` is horizon_1's month at that row (Date column).
|
|
91
117
|
* Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
|
|
92
118
|
*/
|
|
93
119
|
function buildDriftSpaghettiMatrixForCustomDialog(driftRoot, horizonKeys) {
|
|
94
|
-
|
|
120
|
+
const aligned = perHorizonSortedKeyLists(driftRoot ?? {}, horizonKeys);
|
|
121
|
+
if (!aligned)
|
|
95
122
|
return null;
|
|
123
|
+
const { sortedHorizons, perHorizonKeyLists } = aligned;
|
|
96
124
|
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
125
|
const normalizeDateKey = (d) => d.split(' ')[0];
|
|
103
|
-
const
|
|
104
|
-
|
|
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)
|
|
126
|
+
const n = alignedHorizonRowCount(perHorizonKeyLists);
|
|
127
|
+
if (n === 0)
|
|
111
128
|
return null;
|
|
112
|
-
const n = Math.min(...lengths);
|
|
113
129
|
const dates = [];
|
|
114
130
|
const grid = [];
|
|
115
131
|
const perHorizonDates = [];
|
|
@@ -118,15 +134,19 @@ function buildDriftSpaghettiMatrixForCustomDialog(driftRoot, horizonKeys) {
|
|
|
118
134
|
const dateRow = [];
|
|
119
135
|
for (let hi = 0; hi < sortedHorizons.length; hi++) {
|
|
120
136
|
const h = sortedHorizons[hi];
|
|
121
|
-
const
|
|
122
|
-
|
|
137
|
+
const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
|
|
138
|
+
if (!dateKey) {
|
|
139
|
+
row.push(0);
|
|
140
|
+
dateRow.push('');
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
123
143
|
dateRow.push(normalizeToMonthStart(normalizeDateKey(dateKey)));
|
|
124
144
|
const rawVal = forecasts[h]?.[dateKey];
|
|
125
145
|
const v = typeof rawVal === 'number' && Number.isFinite(rawVal) ? rawVal : NaN;
|
|
126
146
|
row.push(v);
|
|
127
147
|
}
|
|
128
|
-
const d0 = perHorizonKeyLists
|
|
129
|
-
dates.push(normalizeToMonthStart(normalizeDateKey(d0)));
|
|
148
|
+
const d0 = horizonDateKeyAtRow(perHorizonKeyLists, 0, i, n);
|
|
149
|
+
dates.push(d0 ? normalizeToMonthStart(normalizeDateKey(d0)) : '');
|
|
130
150
|
perHorizonDates.push(dateRow);
|
|
131
151
|
grid.push(row.map(c => (Number.isFinite(c) ? c : 0)));
|
|
132
152
|
}
|
|
@@ -136,6 +156,85 @@ function buildDriftSpaghettiMatrixForCustomDialog(driftRoot, horizonKeys) {
|
|
|
136
156
|
perHorizonDates,
|
|
137
157
|
};
|
|
138
158
|
}
|
|
159
|
+
function latestHistoricalMonthKey(historicalByDate) {
|
|
160
|
+
let latest = null;
|
|
161
|
+
historicalByDate.forEach((_, d) => {
|
|
162
|
+
if (!latest || d.localeCompare(latest) > 0)
|
|
163
|
+
latest = d;
|
|
164
|
+
});
|
|
165
|
+
return latest;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Append monthly spaghetti rows after drift backtests so the dialog (and chart) reach the latest
|
|
169
|
+
* historical month (e.g. Apr 2026), not only the last drift origin (e.g. Nov 2025).
|
|
170
|
+
*/
|
|
171
|
+
function extendCustomPerformanceDriftSeedToHistoricalEnd(seed, historicalByDate) {
|
|
172
|
+
const latest = latestHistoricalMonthKey(historicalByDate);
|
|
173
|
+
if (!latest || seed.dates.length === 0)
|
|
174
|
+
return seed;
|
|
175
|
+
const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
|
|
176
|
+
const dates = seed.dates.map(d => norm(d));
|
|
177
|
+
const grid = seed.grid.map(r => [...r]);
|
|
178
|
+
const perHorizonDates = seed.perHorizonDates.map(row => row.map(d => norm(String(d))));
|
|
179
|
+
const horizonCount = grid[0]?.length ?? perHorizonDates[0]?.length ?? 0;
|
|
180
|
+
if (horizonCount === 0)
|
|
181
|
+
return seed;
|
|
182
|
+
let lastOrigin = dates[dates.length - 1];
|
|
183
|
+
if (!lastOrigin)
|
|
184
|
+
return seed;
|
|
185
|
+
while (lastOrigin.localeCompare(latest) < 0) {
|
|
186
|
+
const nextOrigin = norm(getNextMonth(lastOrigin));
|
|
187
|
+
const prevPh = perHorizonDates[perHorizonDates.length - 1];
|
|
188
|
+
const newPh = prevPh.length === horizonCount
|
|
189
|
+
? prevPh.map(d => norm(getNextMonth(norm(d))))
|
|
190
|
+
: Array.from({ length: horizonCount }, () => nextOrigin);
|
|
191
|
+
perHorizonDates.push(newPh);
|
|
192
|
+
dates.push(nextOrigin);
|
|
193
|
+
const baselineRow = spaghettiGridFromHistoricalPreviousMonth([newPh], horizonCount, historicalByDate);
|
|
194
|
+
grid.push(baselineRow[0] ?? Array.from({ length: horizonCount }, () => 0));
|
|
195
|
+
lastOrigin = nextOrigin;
|
|
196
|
+
}
|
|
197
|
+
return { dates, grid, perHorizonDates };
|
|
198
|
+
}
|
|
199
|
+
/** Pad a saved custom matrix with drift/baseline rows so edit + chart cover latest backtest months. */
|
|
200
|
+
function extendCustomPerformanceMatrixWithDriftSeed(saved, driftSeed, baselineGrid) {
|
|
201
|
+
const norm = (d) => normalizeToMonthStart(String(d).split(' ')[0] ?? d);
|
|
202
|
+
const horizonCount = saved.horizonKeys.length;
|
|
203
|
+
const savedByDate = new Map();
|
|
204
|
+
for (let r = 0; r < saved.dates.length; r++) {
|
|
205
|
+
savedByDate.set(norm(saved.dates[r]), {
|
|
206
|
+
grid: saved.grid[r],
|
|
207
|
+
perHorizon: saved.perHorizonDates?.[r],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const dates = [];
|
|
211
|
+
const grid = [];
|
|
212
|
+
const perHorizonDates = [];
|
|
213
|
+
for (let i = 0; i < driftSeed.dates.length; i++) {
|
|
214
|
+
const d = norm(driftSeed.dates[i]);
|
|
215
|
+
if (!d)
|
|
216
|
+
continue;
|
|
217
|
+
dates.push(d);
|
|
218
|
+
const existing = savedByDate.get(d);
|
|
219
|
+
if (existing) {
|
|
220
|
+
grid.push([...existing.grid]);
|
|
221
|
+
const ph = existing.perHorizon && existing.perHorizon.length === horizonCount
|
|
222
|
+
? existing.perHorizon.map(c => norm(String(c)))
|
|
223
|
+
: (driftSeed.perHorizonDates[i]?.map(c => norm(String(c))) ?? []);
|
|
224
|
+
perHorizonDates.push(ph);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
grid.push(baselineGrid?.[i] ? [...baselineGrid[i]] : [...driftSeed.grid[i]]);
|
|
228
|
+
perHorizonDates.push((driftSeed.perHorizonDates[i] ?? []).map(c => norm(String(c))));
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
v: saved.v,
|
|
232
|
+
dates,
|
|
233
|
+
horizonKeys: [...saved.horizonKeys],
|
|
234
|
+
grid,
|
|
235
|
+
perHorizonDates,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
139
238
|
/**
|
|
140
239
|
* Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
|
|
141
240
|
* historical value for the month before the earliest forecast month in that row (same anchor as
|
|
@@ -588,4 +687,4 @@ function calculateYRangeFromChartData(chartData) {
|
|
|
588
687
|
};
|
|
589
688
|
}
|
|
590
689
|
|
|
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 };
|
|
690
|
+
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, extendCustomPerformanceDriftSeedToHistoricalEnd, extendCustomPerformanceMatrixWithDriftSeed, filterChartDataLast24Months, filterSpaghettiDataFromEarliestForecastStart, formatAccuracy, formatBenefit, formatError, formatROI, getForecastModelDisplayName, isSpaghettiDriftPerHorizonLineId, isSpaghettiModelPerHorizonLineId, latestHistoricalMonthKey, mergeHistoricalIntoSpaghettiChartData, mergeSpaghettiMergedBases, mergeSpaghettiUserSeriesFromForecastData, spaghettiGridFromHistoricalPreviousMonth };
|
package/dist/esm/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
|
|
|
21
21
|
export { ChartLegend, ChartTooltip } from './components/ui/Chart/Chart.js';
|
|
22
22
|
export { THEMES } from './components/ui/Chart/Chart.types.js';
|
|
23
23
|
export { ChartAreaInteractive, chartConfig } from './components/ui/ChartAreaInteractive/ChartAreaInteractive.js';
|
|
24
|
+
export { DRAG_TIME_RANGE_PREFIX, encodeDragTimeRange, filterDataForTimeRange, findPinPositionForDate, formatNumber, isAnalysisDone, isAnalysisFailed, isAnalysisNotFailed, isTimeRangePreset, longDateFormatter, parseDragTimeRange, shortDateFormatter } from './components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js';
|
|
25
|
+
export { ChartLines, FORECAST_COLORS_MAP, FORECAST_LINE_COLORS, getForecastColor, getForecastQuantileBandColor, resolveQuantileBandFillForForecastLine } from './components/ui/ChartAreaInteractive/ChartLines.js';
|
|
24
26
|
export { Chat } from './components/ui/Chat/Chat.js';
|
|
25
27
|
export { formatChatTranscript, usedPresetIdsFromMessages } from './components/ui/Chat/chat-preset-utils.js';
|
|
26
28
|
export { ChatChrome } from './components/ui/Chat/ChatChrome/ChatChrome.js';
|
|
@@ -111,7 +113,7 @@ export { DriversComparisonChart } from './components/widgets/DriversComparisonCh
|
|
|
111
113
|
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, resolveDriverLagLabel, shiftNormalizedSeriesForward } from './components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js';
|
|
112
114
|
export { PerformanceChart } from './components/widgets/PerformanceChart/PerformanceChart.js';
|
|
113
115
|
export { PerformanceTable } from './components/widgets/PerformanceChart/PerformanceTable.js';
|
|
114
|
-
export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, calculateYRangeFromChartData, getForecastModelDisplayName, spaghettiGridFromHistoricalPreviousMonth } from './components/widgets/PerformanceChart/performanceChart.helpers.js';
|
|
116
|
+
export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, calculateYRangeFromChartData, extendCustomPerformanceDriftSeedToHistoricalEnd, extendCustomPerformanceMatrixWithDriftSeed, getForecastModelDisplayName, latestHistoricalMonthKey, spaghettiGridFromHistoricalPreviousMonth } from './components/widgets/PerformanceChart/performanceChart.helpers.js';
|
|
115
117
|
export { SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, SPAGHETTI_TIME_SERIES_MATRIX_V, tryParseSpaghettiPerformanceMatrix } from './components/widgets/PerformanceChart/performanceChartUserSeries.js';
|
|
116
118
|
export { SybilionAppHeader } from './components/widgets/SybilionAppHeader/SybilionAppHeader.js';
|
|
117
119
|
export { SybilionAuthLayout } from './components/widgets/SybilionAuthLayout/SybilionAuthLayout.js';
|
package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ declare const timeRangeToMonths: {
|
|
|
9
9
|
readonly All: 12;
|
|
10
10
|
};
|
|
11
11
|
export type TimeRangePreset = keyof typeof timeRangeToMonths;
|
|
12
|
-
/** @deprecated Use `TimeRangePreset` or `string` for brush-encoded ranges. */
|
|
13
|
-
export type TimeRange = TimeRangePreset;
|
|
14
12
|
export declare const DRAG_TIME_RANGE_PREFIX: "__drag:";
|
|
15
13
|
export declare function encodeDragTimeRange(a: Date, b: Date): string;
|
|
16
14
|
export declare function parseDragTimeRange(s: string): {
|
|
@@ -42,4 +40,7 @@ export declare const isAnalysisFailed: (analysis: Analysis | {
|
|
|
42
40
|
export declare const isAnalysisNotFailed: (analysis: Analysis | {
|
|
43
41
|
status?: string;
|
|
44
42
|
}) => boolean;
|
|
43
|
+
export declare const isAnalysisDone: (analysis: Analysis | {
|
|
44
|
+
status?: string;
|
|
45
|
+
}) => boolean;
|
|
45
46
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { PerformanceChart, type PerformanceChartPayload, type PerformanceChartProps, type PerformanceViewTab, } from './PerformanceChart';
|
|
2
2
|
export { type AdjustParameters, type ForecastModelData, PerformanceTable, } from './PerformanceTable';
|
|
3
|
-
export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, calculateYRangeFromChartData, getForecastModelDisplayName, spaghettiGridFromHistoricalPreviousMonth, } from './performanceChart.helpers';
|
|
3
|
+
export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, extendCustomPerformanceDriftSeedToHistoricalEnd, extendCustomPerformanceMatrixWithDriftSeed, latestHistoricalMonthKey, buildSpaghettiMergedChartData, calculateYRangeFromChartData, getForecastModelDisplayName, spaghettiGridFromHistoricalPreviousMonth, } from './performanceChart.helpers';
|
|
4
4
|
export { SPAGHETTI_LOCAL_LS_USER_SERIES_ROW_ID, SPAGHETTI_TIME_SERIES_MATRIX_V, tryParseSpaghettiPerformanceMatrix, type SpaghettiPerformanceMatrixPayload, } from './performanceChartUserSeries';
|
package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts
CHANGED
|
@@ -21,14 +21,14 @@ export declare function isSpaghettiDriftPerHorizonLineId(id: number): boolean;
|
|
|
21
21
|
/**
|
|
22
22
|
* Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
|
|
23
23
|
* backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
|
|
24
|
-
* (aligned
|
|
24
|
+
* (aligned on the latest shared months per horizon).
|
|
25
25
|
*/
|
|
26
26
|
export declare function buildPerHorizonSpaghettiEntries(forecastRoot: {
|
|
27
27
|
forecasts?: Record<string, Record<string, number>>;
|
|
28
28
|
} | null | undefined, horizonKeys: string[]): RealBacktestsEntry[];
|
|
29
29
|
/**
|
|
30
30
|
* Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
|
|
31
|
-
*
|
|
31
|
+
* latest-aligned forecast month; `dates[i]` is horizon_1's month at that row (Date column).
|
|
32
32
|
* Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
|
|
33
33
|
*/
|
|
34
34
|
export declare function buildDriftSpaghettiMatrixForCustomDialog(driftRoot: {
|
|
@@ -38,6 +38,26 @@ export declare function buildDriftSpaghettiMatrixForCustomDialog(driftRoot: {
|
|
|
38
38
|
grid: number[][];
|
|
39
39
|
perHorizonDates: string[][];
|
|
40
40
|
} | null;
|
|
41
|
+
export declare function latestHistoricalMonthKey(historicalByDate: Map<string, number>): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* Append monthly spaghetti rows after drift backtests so the dialog (and chart) reach the latest
|
|
44
|
+
* historical month (e.g. Apr 2026), not only the last drift origin (e.g. Nov 2025).
|
|
45
|
+
*/
|
|
46
|
+
export declare function extendCustomPerformanceDriftSeedToHistoricalEnd(seed: {
|
|
47
|
+
dates: string[];
|
|
48
|
+
grid: number[][];
|
|
49
|
+
perHorizonDates: string[][];
|
|
50
|
+
}, historicalByDate: Map<string, number>): {
|
|
51
|
+
dates: string[];
|
|
52
|
+
grid: number[][];
|
|
53
|
+
perHorizonDates: string[][];
|
|
54
|
+
};
|
|
55
|
+
/** Pad a saved custom matrix with drift/baseline rows so edit + chart cover latest backtest months. */
|
|
56
|
+
export declare function extendCustomPerformanceMatrixWithDriftSeed(saved: SpaghettiPerformanceMatrixPayload, driftSeed: {
|
|
57
|
+
dates: string[];
|
|
58
|
+
grid: number[][];
|
|
59
|
+
perHorizonDates: string[][];
|
|
60
|
+
}, baselineGrid?: number[][]): SpaghettiPerformanceMatrixPayload;
|
|
41
61
|
/**
|
|
42
62
|
* Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
|
|
43
63
|
* historical value for the month before the earliest forecast month in that row (same anchor as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sybilion/uilib",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.65",
|
|
4
4
|
"description": "Sybilion Design System — React UI components (Webpack + Stylus)",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"docs",
|
|
43
43
|
"src",
|
|
44
44
|
"dist",
|
|
45
|
+
"dist/agent-glossary",
|
|
45
46
|
"package.json"
|
|
46
47
|
],
|
|
47
48
|
"directories": {
|
|
@@ -53,7 +54,8 @@
|
|
|
53
54
|
"ts": "tsc --noEmit",
|
|
54
55
|
"dev": "NODE_OPTIONS='--loader ts-node/esm' NODE_ENV=development webpack-dev-server --mode=development --config ./src/docs/config/webpack.config.js --progress",
|
|
55
56
|
"prebuild": "cp src/components/ui/Logo/logo.svg assets/logo.svg",
|
|
56
|
-
"build": "
|
|
57
|
+
"build:agent-glossary": "node scripts/build-agent-glossary.mjs --all",
|
|
58
|
+
"build": "yarn build:agent-glossary && rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
|
57
59
|
"build:watch": "yarn build --watch",
|
|
58
60
|
"build:compress": "COMPRESS=true yarn build",
|
|
59
61
|
"publish:npm": "yarn build && npm publish --access public",
|
|
@@ -67,9 +67,6 @@ const timeRangeToMonths = {
|
|
|
67
67
|
|
|
68
68
|
export type TimeRangePreset = keyof typeof timeRangeToMonths;
|
|
69
69
|
|
|
70
|
-
/** @deprecated Use `TimeRangePreset` or `string` for brush-encoded ranges. */
|
|
71
|
-
export type TimeRange = TimeRangePreset;
|
|
72
|
-
|
|
73
70
|
export const DRAG_TIME_RANGE_PREFIX = '__drag:' as const;
|
|
74
71
|
|
|
75
72
|
export function encodeDragTimeRange(a: Date, b: Date): string {
|
|
@@ -258,3 +255,7 @@ export const isAnalysisFailed = (
|
|
|
258
255
|
export const isAnalysisNotFailed = (
|
|
259
256
|
analysis: Analysis | { status?: string },
|
|
260
257
|
): boolean => !isAnalysisFailed(analysis);
|
|
258
|
+
|
|
259
|
+
export const isAnalysisDone = (
|
|
260
|
+
analysis: Analysis | { status?: string },
|
|
261
|
+
): boolean => analysis.status?.toLowerCase() === 'done';
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Page layout system
|
|
2
|
+
|
|
3
|
+
Sybilion workspace and report surfaces use a small set of **Page** primitives from `@sybilion/uilib`. Agents must compose these instead of inventing layout with inline styles, Tailwind structure classes, or ad-hoc wrappers.
|
|
4
|
+
|
|
5
|
+
## Two modes
|
|
6
|
+
|
|
7
|
+
| Mode | When | Shell |
|
|
8
|
+
| ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------- |
|
|
9
|
+
| **workspace-page** | Route inside `AppShell` / scrolled main column | `PageHeader` → `PageContent` → `PageContentSection`(s). Optional `PageTabs`. |
|
|
10
|
+
| **content** | Report tiles, json-dashboard, embedded body only | No `PageHeader` / `AppShell` chrome. Map React blocks to dashboard tiles (see below). |
|
|
11
|
+
|
|
12
|
+
Do not nest workspace shell components inside **content** mode output.
|
|
13
|
+
|
|
14
|
+
## Mandatory workspace-page skeleton
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<PageHeader title="…" subheader="…" breadcrumbs={[…]} actions={…} />
|
|
18
|
+
<PageContent>
|
|
19
|
+
<PageContentSection>{/* section A */}</PageContentSection>
|
|
20
|
+
<PageContentSection grow={false}>{/* section B */}</PageContentSection>
|
|
21
|
+
</PageContent>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **PageHeader**: one per route view in the main column; title, optional subheader, breadcrumbs, actions.
|
|
25
|
+
- **PageContent**: vertical stack for the body below the header; `variant="clean"` when you need a flatter body (no extra chrome).
|
|
26
|
+
- **PageContentSection**: primary vertical band; applies horizontal **page-x-padding** via Stylus (`pageXPadding()`). Use multiple sections to separate logical blocks.
|
|
27
|
+
|
|
28
|
+
Global tokens (host app `:root`, e.g. `standalone-global.css`): `--page-width`, `--page-x-padding`, `--page-y-padding`. `PageContent` caps width at `--page-width`.
|
|
29
|
+
|
|
30
|
+
## PageTabs — rules and patterns
|
|
31
|
+
|
|
32
|
+
**NEVER** place `PageTabs` inside `PageContentSection`. Tabs manage their own horizontal inset via `PageXScroll` / `tabsListProps.withPaddings`.
|
|
33
|
+
|
|
34
|
+
### Unified (tabs + panels inside PageContent)
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
<PageHeader … />
|
|
38
|
+
<PageContent>
|
|
39
|
+
<PageTabs
|
|
40
|
+
items={[
|
|
41
|
+
{ value: 'a', label: 'Tab A', content: <PageContentSection>…</PageContentSection> },
|
|
42
|
+
{ value: 'b', label: 'Tab B', content: <PageContentSection>…</PageContentSection> },
|
|
43
|
+
]}
|
|
44
|
+
/>
|
|
45
|
+
</PageContent>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Tab **panels** use `PageContentSection` so section padding stays correct.
|
|
49
|
+
|
|
50
|
+
### Split (bar-only sibling)
|
|
51
|
+
|
|
52
|
+
Use when the tab bar must sit between header and a custom scroll region:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<PageHeader … />
|
|
56
|
+
<PageTabs
|
|
57
|
+
items={[
|
|
58
|
+
{ value: 'a', label: 'Tab A', content: null },
|
|
59
|
+
{ value: 'b', label: 'Tab B', content: null },
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
62
|
+
<PageContent>…</PageContent>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Set `content: null` (or omit) for bar-only items; render panel bodies in `PageContent` yourself.
|
|
66
|
+
|
|
67
|
+
## Spacing ownership
|
|
68
|
+
|
|
69
|
+
| Component | Owns horizontal page padding? |
|
|
70
|
+
| ---------------------- | --------------------------------------------------------- |
|
|
71
|
+
| **PageContentSection** | Yes (`pageXPadding`) |
|
|
72
|
+
| **PageTabs** | Via `tabsListProps.withPaddings` + internal `PageXScroll` |
|
|
73
|
+
| **PageXScroll** | Reads `--page-x-padding` for inset when used standalone |
|
|
74
|
+
| **PageHeader** | Own header inner layout (not `PageContentSection`) |
|
|
75
|
+
|
|
76
|
+
Do not duplicate `padding-left/right` on children that already sit in `PageContentSection`.
|
|
77
|
+
|
|
78
|
+
## Styling rules
|
|
79
|
+
|
|
80
|
+
- **No** `style={{…}}` for layout or spacing (margins, padding, flex, width, gap).
|
|
81
|
+
- **No** Tailwind (or other) utility classes for **structural** layout on page primitives.
|
|
82
|
+
- Custom layout CSS only in co-located `ComponentName.styl`, using design tokens (`var(--p-*)`, `var(--page-x-padding)`, `pageXPadding()`, etc.).
|
|
83
|
+
- `className` on Page components is for Stylus module classes or state hooks, not one-off pixel tweaks.
|
|
84
|
+
|
|
85
|
+
## Component reference
|
|
86
|
+
|
|
87
|
+
### PageHeader
|
|
88
|
+
|
|
89
|
+
Sticky-style page title row: breadcrumbs (optional sidebar trigger in full app), `title`, `subheader`, `actions`. Lives **above** `PageContent`, never inside a section.
|
|
90
|
+
|
|
91
|
+
### PageContent / PageContentSection
|
|
92
|
+
|
|
93
|
+
`PageContent`: column flex child, `max-width: var(--page-width)`.
|
|
94
|
+
`PageContentSection`: `grow` (default `true`) lets the section absorb remaining height in flex layouts.
|
|
95
|
+
|
|
96
|
+
### PageColumns
|
|
97
|
+
|
|
98
|
+
Side-by-side columns with gap. **`fill`** controls which column(s) grow:
|
|
99
|
+
|
|
100
|
+
| `fill` | Behavior |
|
|
101
|
+
| --------------- | ----------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `all` (default) | Every column `flex-grow: 1`, `max-width` unset — use for equal split or fluid grids of columns. |
|
|
103
|
+
| `left` | First column grows; others stay at fixed min/max column width. |
|
|
104
|
+
| `right` | Last column grows. |
|
|
105
|
+
|
|
106
|
+
Pass `columns={[node1, node2, …]}`. On mobile, stacks vertically. Typical pattern: main + aside inside one `PageContentSection`.
|
|
107
|
+
|
|
108
|
+
### PageXScroll
|
|
109
|
+
|
|
110
|
+
Horizontal scroll strip; used inside `PageTabs` and anywhere a overflowing row needs page-aligned padding. Props: `size`, `fullWidth`, `innerClassName`, `scrollbarClassName`.
|
|
111
|
+
|
|
112
|
+
### SectionHeader
|
|
113
|
+
|
|
114
|
+
In-section title block (`title`, `description`, `actions`). Use **inside** `PageContentSection`, not as a replacement for `PageHeader`.
|
|
115
|
+
|
|
116
|
+
### PageEmptyCanvas
|
|
117
|
+
|
|
118
|
+
Centered empty state: `title`, `hint`, optional `children` (e.g. CTA button). Use when a route has no data yet.
|
|
119
|
+
|
|
120
|
+
### GridLayout
|
|
121
|
+
|
|
122
|
+
Responsive CSS grid (`repeat(auto-fit, minmax(colWidth, 1fr))`). Use for card/tile dashboards inside a section. Report mapping: dashboard **grid** rows / multi-column tile groups.
|
|
123
|
+
|
|
124
|
+
### Card / Foldable
|
|
125
|
+
|
|
126
|
+
Use inside sections for grouped content; see package `AGENT.md` on those components when present. Do not wrap the whole page in Card.
|
|
127
|
+
|
|
128
|
+
## Content mode — React → json-dashboard tiles
|
|
129
|
+
|
|
130
|
+
When generating **report** / dashboard specs (not workspace routes), map UI blocks to tiles:
|
|
131
|
+
|
|
132
|
+
| React / intent | Dashboard tile / markdown |
|
|
133
|
+
| ------------------------------- | ----------------------------------------------------------------- |
|
|
134
|
+
| `PageHeader` title text | First `markdown` tile `# Title` or report title field |
|
|
135
|
+
| `SectionHeader` `title` | `markdown` with `## …` |
|
|
136
|
+
| `SectionHeader` `description` | markdown paragraph under that heading |
|
|
137
|
+
| `PageContentSection` block | group of tiles or one markdown section |
|
|
138
|
+
| `GridLayout` children | grid row of widget tiles |
|
|
139
|
+
| `ChartAreaInteractive` / charts | `dataset_card`, `performance_chart`, etc. (see widget `AGENT.md`) |
|
|
140
|
+
| `PageEmptyCanvas` copy | `markdown` explanatory tile |
|
|
141
|
+
|
|
142
|
+
See `AGENT.report-only-tiles.md` in the app repo for tiles without a uilib widget.
|
|
143
|
+
|
|
144
|
+
## Shell components (workspace only)
|
|
145
|
+
|
|
146
|
+
Not part of the per-route skeleton but part of **workspace-page** host wiring:
|
|
147
|
+
|
|
148
|
+
- **AppShell** / **AppShellMainContent** — sidebar + main column + header slot + footer.
|
|
149
|
+
- **PageScroll** — scroll root for the main column.
|
|
150
|
+
- **SybilionAppHeader** — workspace app switcher + user menu (lives in header slot, not inside `PageContent`).
|
|
151
|
+
|
|
152
|
+
## Anti-patterns
|
|
153
|
+
|
|
154
|
+
- `PageTabs` inside `PageContentSection`.
|
|
155
|
+
- Inline styles or Tailwind for page structure.
|
|
156
|
+
- Second `PageHeader` nested in content.
|
|
157
|
+
- `PageContent` without sections for multi-block pages (prefer multiple `PageContentSection`).
|
|
158
|
+
- Wrapping data widgets in extra layout divs with hard-coded padding.
|
|
159
|
+
|
|
160
|
+
## Future candidates (not implemented)
|
|
161
|
+
|
|
162
|
+
- **PageToolbar** — dedicated action row between header and tabs.
|
|
163
|
+
- **PageFullBleed** — edge-to-edge section escape hatch.
|
|
164
|
+
|
|
165
|
+
Do not add these locally; extend uilib when product needs them.
|
|
@@ -6,7 +6,7 @@ Each component file ≤18 lines. Signal for LLM prompts — no noise.
|
|
|
6
6
|
|
|
7
7
|
**Include:** Renders (1 sentence); Use when / Not when; Host provides (3–5 bullets); Report tile (one line or "Not used"); Requires (prop names + role); Empty/loading (one line).
|
|
8
8
|
|
|
9
|
-
**Do not include:** import examples; type/doc/demo/glossary links; page-shell boilerplate; related-components lists unless choosing between exports; implementation/styling/keyboard notes unless binding-relevant; secrets, env vars, API URLs.
|
|
9
|
+
**Do not include:** import examples; type/doc/demo/glossary links; page-shell boilerplate; related-components lists unless choosing between exports; implementation/styling/keyboard notes unless binding-relevant; secrets, env vars, API URLs; inline style examples (`style={{…}}`).
|
|
10
10
|
|
|
11
11
|
```markdown
|
|
12
12
|
# Name
|
|
@@ -15,6 +15,9 @@ export {
|
|
|
15
15
|
averageForecastErrorsVsHistoricalForMatrixColumn,
|
|
16
16
|
buildDriftSpaghettiMatrixForCustomDialog,
|
|
17
17
|
buildPerHorizonSpaghettiEntries,
|
|
18
|
+
extendCustomPerformanceDriftSeedToHistoricalEnd,
|
|
19
|
+
extendCustomPerformanceMatrixWithDriftSeed,
|
|
20
|
+
latestHistoricalMonthKey,
|
|
18
21
|
buildSpaghettiMergedChartData,
|
|
19
22
|
calculateYRangeFromChartData,
|
|
20
23
|
getForecastModelDisplayName,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
6
6
|
import type { ForecastData } from '#uilib/types/forecast-data';
|
|
7
7
|
import {
|
|
8
|
+
getNextMonth,
|
|
8
9
|
getPreviousMonth,
|
|
9
10
|
normalizeToMonthStart,
|
|
10
11
|
} from '#uilib/utils/chartConnectionPoint';
|
|
@@ -67,19 +68,16 @@ function addSpaghettiHistoricalBridgeForSeries(
|
|
|
67
68
|
map.set(connectionDate, row);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
*/
|
|
75
|
-
|
|
76
|
-
forecastRoot:
|
|
77
|
-
| { forecasts?: Record<string, Record<string, number>> }
|
|
78
|
-
| null
|
|
79
|
-
| undefined,
|
|
71
|
+
type PerHorizonForecastRoot = {
|
|
72
|
+
forecasts?: Record<string, Record<string, number>>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** When horizons have different lengths, align on the latest `n` months (suffix), not the oldest. */
|
|
76
|
+
function perHorizonSortedKeyLists(
|
|
77
|
+
forecastRoot: PerHorizonForecastRoot,
|
|
80
78
|
horizonKeys: string[],
|
|
81
|
-
):
|
|
82
|
-
if (!forecastRoot
|
|
79
|
+
): { sortedHorizons: string[]; perHorizonKeyLists: string[][] } | null {
|
|
80
|
+
if (!forecastRoot.forecasts || horizonKeys.length === 0) return null;
|
|
83
81
|
|
|
84
82
|
const forecasts = forecastRoot.forecasts;
|
|
85
83
|
const sortedHorizons = [...horizonKeys].sort((a, b) => {
|
|
@@ -99,16 +97,57 @@ export function buildPerHorizonSpaghettiEntries(
|
|
|
99
97
|
});
|
|
100
98
|
|
|
101
99
|
const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
|
|
102
|
-
if (lengths.length === 0) return
|
|
103
|
-
|
|
100
|
+
if (lengths.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
return { sortedHorizons, perHorizonKeyLists };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function alignedHorizonRowCount(perHorizonKeyLists: string[][]): number {
|
|
106
|
+
const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
|
|
107
|
+
if (lengths.length === 0) return 0;
|
|
108
|
+
return Math.min(...lengths);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function horizonDateKeyAtRow(
|
|
112
|
+
perHorizonKeyLists: string[][],
|
|
113
|
+
horizonIndex: number,
|
|
114
|
+
rowIndex: number,
|
|
115
|
+
rowCount: number,
|
|
116
|
+
): string | undefined {
|
|
117
|
+
const keys = perHorizonKeyLists[horizonIndex];
|
|
118
|
+
if (!keys?.length) return undefined;
|
|
119
|
+
const offset = keys.length - rowCount;
|
|
120
|
+
return keys[offset + rowIndex];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
|
|
125
|
+
* backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
|
|
126
|
+
* (aligned on the latest shared months per horizon).
|
|
127
|
+
*/
|
|
128
|
+
export function buildPerHorizonSpaghettiEntries(
|
|
129
|
+
forecastRoot:
|
|
130
|
+
| { forecasts?: Record<string, Record<string, number>> }
|
|
131
|
+
| null
|
|
132
|
+
| undefined,
|
|
133
|
+
horizonKeys: string[],
|
|
134
|
+
): RealBacktestsEntry[] {
|
|
135
|
+
const aligned = perHorizonSortedKeyLists(forecastRoot ?? {}, horizonKeys);
|
|
136
|
+
if (!aligned) return [];
|
|
137
|
+
|
|
138
|
+
const { sortedHorizons, perHorizonKeyLists } = aligned;
|
|
139
|
+
const forecasts = forecastRoot!.forecasts!;
|
|
140
|
+
const normalizeDateKey = (d: string) => d.split(' ')[0];
|
|
141
|
+
const n = alignedHorizonRowCount(perHorizonKeyLists);
|
|
142
|
+
if (n === 0) return [];
|
|
104
143
|
|
|
105
144
|
const entries: RealBacktestsEntry[] = [];
|
|
106
145
|
for (let i = 0; i < n; i++) {
|
|
107
146
|
const forecast_series: Record<string, number> = {};
|
|
108
147
|
for (let hi = 0; hi < sortedHorizons.length; hi++) {
|
|
109
148
|
const h = sortedHorizons[hi];
|
|
110
|
-
const
|
|
111
|
-
|
|
149
|
+
const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
|
|
150
|
+
if (!dateKey) continue;
|
|
112
151
|
const rawVal = forecasts[h]?.[dateKey];
|
|
113
152
|
if (typeof rawVal !== 'number' || !Number.isFinite(rawVal)) continue;
|
|
114
153
|
const norm = normalizeToMonthStart(normalizeDateKey(dateKey));
|
|
@@ -127,7 +166,7 @@ export function buildPerHorizonSpaghettiEntries(
|
|
|
127
166
|
|
|
128
167
|
/**
|
|
129
168
|
* Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
|
|
130
|
-
*
|
|
169
|
+
* latest-aligned forecast month; `dates[i]` is horizon_1's month at that row (Date column).
|
|
131
170
|
* Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
|
|
132
171
|
*/
|
|
133
172
|
export function buildDriftSpaghettiMatrixForCustomDialog(
|
|
@@ -141,28 +180,14 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
|
|
|
141
180
|
grid: number[][];
|
|
142
181
|
perHorizonDates: string[][];
|
|
143
182
|
} | null {
|
|
144
|
-
|
|
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
|
-
});
|
|
183
|
+
const aligned = perHorizonSortedKeyLists(driftRoot ?? {}, horizonKeys);
|
|
184
|
+
if (!aligned) return null;
|
|
152
185
|
|
|
186
|
+
const { sortedHorizons, perHorizonKeyLists } = aligned;
|
|
187
|
+
const forecasts = driftRoot!.forecasts!;
|
|
153
188
|
const normalizeDateKey = (d: string) => d.split(' ')[0];
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
189
|
+
const n = alignedHorizonRowCount(perHorizonKeyLists);
|
|
190
|
+
if (n === 0) return null;
|
|
166
191
|
|
|
167
192
|
const dates: string[] = [];
|
|
168
193
|
const grid: number[][] = [];
|
|
@@ -173,16 +198,20 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
|
|
|
173
198
|
const dateRow: string[] = [];
|
|
174
199
|
for (let hi = 0; hi < sortedHorizons.length; hi++) {
|
|
175
200
|
const h = sortedHorizons[hi];
|
|
176
|
-
const
|
|
177
|
-
|
|
201
|
+
const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
|
|
202
|
+
if (!dateKey) {
|
|
203
|
+
row.push(0);
|
|
204
|
+
dateRow.push('');
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
178
207
|
dateRow.push(normalizeToMonthStart(normalizeDateKey(dateKey)));
|
|
179
208
|
const rawVal = forecasts[h]?.[dateKey];
|
|
180
209
|
const v =
|
|
181
210
|
typeof rawVal === 'number' && Number.isFinite(rawVal) ? rawVal : NaN;
|
|
182
211
|
row.push(v);
|
|
183
212
|
}
|
|
184
|
-
const d0 = perHorizonKeyLists
|
|
185
|
-
dates.push(normalizeToMonthStart(normalizeDateKey(d0)));
|
|
213
|
+
const d0 = horizonDateKeyAtRow(perHorizonKeyLists, 0, i, n);
|
|
214
|
+
dates.push(d0 ? normalizeToMonthStart(normalizeDateKey(d0)) : '');
|
|
186
215
|
perHorizonDates.push(dateRow);
|
|
187
216
|
grid.push(row.map(c => (Number.isFinite(c) ? c : 0)));
|
|
188
217
|
}
|
|
@@ -194,6 +223,133 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
|
|
|
194
223
|
};
|
|
195
224
|
}
|
|
196
225
|
|
|
226
|
+
export function latestHistoricalMonthKey(
|
|
227
|
+
historicalByDate: Map<string, number>,
|
|
228
|
+
): string | null {
|
|
229
|
+
let latest: string | null = null;
|
|
230
|
+
historicalByDate.forEach((_, d) => {
|
|
231
|
+
if (!latest || d.localeCompare(latest) > 0) latest = d;
|
|
232
|
+
});
|
|
233
|
+
return latest;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Append monthly spaghetti rows after drift backtests so the dialog (and chart) reach the latest
|
|
238
|
+
* historical month (e.g. Apr 2026), not only the last drift origin (e.g. Nov 2025).
|
|
239
|
+
*/
|
|
240
|
+
export function extendCustomPerformanceDriftSeedToHistoricalEnd(
|
|
241
|
+
seed: {
|
|
242
|
+
dates: string[];
|
|
243
|
+
grid: number[][];
|
|
244
|
+
perHorizonDates: string[][];
|
|
245
|
+
},
|
|
246
|
+
historicalByDate: Map<string, number>,
|
|
247
|
+
): {
|
|
248
|
+
dates: string[];
|
|
249
|
+
grid: number[][];
|
|
250
|
+
perHorizonDates: string[][];
|
|
251
|
+
} {
|
|
252
|
+
const latest = latestHistoricalMonthKey(historicalByDate);
|
|
253
|
+
if (!latest || seed.dates.length === 0) return seed;
|
|
254
|
+
|
|
255
|
+
const norm = (d: string) =>
|
|
256
|
+
normalizeToMonthStart(String(d).split(' ')[0] ?? d);
|
|
257
|
+
|
|
258
|
+
const dates = seed.dates.map(d => norm(d));
|
|
259
|
+
const grid = seed.grid.map(r => [...r]);
|
|
260
|
+
const perHorizonDates = seed.perHorizonDates.map(row =>
|
|
261
|
+
row.map(d => norm(String(d))),
|
|
262
|
+
);
|
|
263
|
+
const horizonCount = grid[0]?.length ?? perHorizonDates[0]?.length ?? 0;
|
|
264
|
+
if (horizonCount === 0) return seed;
|
|
265
|
+
|
|
266
|
+
let lastOrigin = dates[dates.length - 1];
|
|
267
|
+
if (!lastOrigin) return seed;
|
|
268
|
+
|
|
269
|
+
while (lastOrigin.localeCompare(latest) < 0) {
|
|
270
|
+
const nextOrigin = norm(getNextMonth(lastOrigin));
|
|
271
|
+
const prevPh = perHorizonDates[perHorizonDates.length - 1];
|
|
272
|
+
const newPh =
|
|
273
|
+
prevPh.length === horizonCount
|
|
274
|
+
? prevPh.map(d => norm(getNextMonth(norm(d))))
|
|
275
|
+
: Array.from({ length: horizonCount }, () => nextOrigin);
|
|
276
|
+
|
|
277
|
+
perHorizonDates.push(newPh);
|
|
278
|
+
dates.push(nextOrigin);
|
|
279
|
+
|
|
280
|
+
const baselineRow = spaghettiGridFromHistoricalPreviousMonth(
|
|
281
|
+
[newPh],
|
|
282
|
+
horizonCount,
|
|
283
|
+
historicalByDate,
|
|
284
|
+
);
|
|
285
|
+
grid.push(baselineRow[0] ?? Array.from({ length: horizonCount }, () => 0));
|
|
286
|
+
|
|
287
|
+
lastOrigin = nextOrigin;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { dates, grid, perHorizonDates };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Pad a saved custom matrix with drift/baseline rows so edit + chart cover latest backtest months. */
|
|
294
|
+
export function extendCustomPerformanceMatrixWithDriftSeed(
|
|
295
|
+
saved: SpaghettiPerformanceMatrixPayload,
|
|
296
|
+
driftSeed: {
|
|
297
|
+
dates: string[];
|
|
298
|
+
grid: number[][];
|
|
299
|
+
perHorizonDates: string[][];
|
|
300
|
+
},
|
|
301
|
+
baselineGrid?: number[][],
|
|
302
|
+
): SpaghettiPerformanceMatrixPayload {
|
|
303
|
+
const norm = (d: string) =>
|
|
304
|
+
normalizeToMonthStart(String(d).split(' ')[0] ?? d);
|
|
305
|
+
const horizonCount = saved.horizonKeys.length;
|
|
306
|
+
|
|
307
|
+
const savedByDate = new Map<
|
|
308
|
+
string,
|
|
309
|
+
{ grid: number[]; perHorizon?: string[] }
|
|
310
|
+
>();
|
|
311
|
+
for (let r = 0; r < saved.dates.length; r++) {
|
|
312
|
+
savedByDate.set(norm(saved.dates[r]), {
|
|
313
|
+
grid: saved.grid[r],
|
|
314
|
+
perHorizon: saved.perHorizonDates?.[r],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const dates: string[] = [];
|
|
319
|
+
const grid: number[][] = [];
|
|
320
|
+
const perHorizonDates: string[][] = [];
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < driftSeed.dates.length; i++) {
|
|
323
|
+
const d = norm(driftSeed.dates[i]);
|
|
324
|
+
if (!d) continue;
|
|
325
|
+
dates.push(d);
|
|
326
|
+
const existing = savedByDate.get(d);
|
|
327
|
+
if (existing) {
|
|
328
|
+
grid.push([...existing.grid]);
|
|
329
|
+
const ph =
|
|
330
|
+
existing.perHorizon && existing.perHorizon.length === horizonCount
|
|
331
|
+
? existing.perHorizon.map(c => norm(String(c)))
|
|
332
|
+
: (driftSeed.perHorizonDates[i]?.map(c => norm(String(c))) ?? []);
|
|
333
|
+
perHorizonDates.push(ph);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
grid.push(
|
|
337
|
+
baselineGrid?.[i] ? [...baselineGrid[i]] : [...driftSeed.grid[i]],
|
|
338
|
+
);
|
|
339
|
+
perHorizonDates.push(
|
|
340
|
+
(driftSeed.perHorizonDates[i] ?? []).map(c => norm(String(c))),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
v: saved.v,
|
|
346
|
+
dates,
|
|
347
|
+
horizonKeys: [...saved.horizonKeys],
|
|
348
|
+
grid,
|
|
349
|
+
perHorizonDates,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
197
353
|
/**
|
|
198
354
|
* Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
|
|
199
355
|
* historical value for the month before the earliest forecast month in that row (same anchor as
|