@sybilion/uilib 1.3.22 → 1.3.23
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/widgets/DriversComparisonChart/DriversComparisonChart.js +139 -0
- package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.js +7 -0
- package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +159 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +18 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +26 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -0
- package/dist/esm/types/src/docs/pages/DriversComparisonChartPage.d.ts +1 -0
- package/dist/esm/types/src/index.d.ts +1 -0
- package/dist/esm/utils/chartConnectionPoint.js +9 -1
- package/package.json +1 -1
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl +145 -0
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.d.ts +29 -0
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +325 -0
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +206 -0
- package/src/components/widgets/DriversComparisonChart/index.ts +13 -0
- package/src/docs/pages/DriversComparisonChartPage.tsx +174 -0
- package/src/docs/registry.ts +6 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
2
|
+
import cn from 'classnames';
|
|
3
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
4
|
+
import { ChartEmptyState } from '../../ui/Chart/components/ChartEmptyState/ChartEmptyState.js';
|
|
5
|
+
import { ChartAreaInteractive } from '../../ui/ChartAreaInteractive/ChartAreaInteractive.js';
|
|
6
|
+
import { getForecastColor } from '../../ui/ChartAreaInteractive/ChartLines.js';
|
|
7
|
+
import '../../ui/Page/AppShell/AppShell.js';
|
|
8
|
+
import 'react-router-dom';
|
|
9
|
+
import '../../ui/Sidebar/Sidebar.js';
|
|
10
|
+
import 'lucide-react';
|
|
11
|
+
import '../../ui/Page/Breadcrumbs/Breadcrumbs.styl.js';
|
|
12
|
+
import '../../ui/Page/pageContext.js';
|
|
13
|
+
import '@radix-ui/react-tooltip';
|
|
14
|
+
import '../../ui/Tooltip/Tooltip.styl.js';
|
|
15
|
+
import '../../ui/Page/PageHeader/PageHeader.styl.js';
|
|
16
|
+
import '../../ui/Page/PageEmptyCanvas/PageEmptyCanvas.styl.js';
|
|
17
|
+
import '../../ui/Page/PageContent/PageContent.styl.js';
|
|
18
|
+
import '@homecode/ui';
|
|
19
|
+
import '../../ui/Page/PageScroll/PageScroll.styl.js';
|
|
20
|
+
import { PageXScroll } from '../../ui/Page/PageXScroll/PageXScroll.js';
|
|
21
|
+
import '../../ui/Page/PageFooter/PageFooter.styl.js';
|
|
22
|
+
import '@radix-ui/react-tabs';
|
|
23
|
+
import '../../ui/Tabs/Tabs.styl.js';
|
|
24
|
+
import '../../ui/Page/PageTabs/PageTabs.styl.js';
|
|
25
|
+
import '../../ui/Page/PageColumns/PageColumns.styl.js';
|
|
26
|
+
import '../../ui/Page/SectionHeader/SectionHeader.styl.js';
|
|
27
|
+
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '../../ui/Table/Table.js';
|
|
28
|
+
import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
|
|
29
|
+
import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
|
|
30
|
+
import S from './DriversComparisonChart.styl.js';
|
|
31
|
+
import { mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
|
|
32
|
+
|
|
33
|
+
const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
|
|
34
|
+
function toForecastDataKey(id) {
|
|
35
|
+
const s = String(id);
|
|
36
|
+
return s.startsWith('forecast_') ? s : `forecast_${id}`;
|
|
37
|
+
}
|
|
38
|
+
function DriversComparisonChart({ payload, datasetHistorical = [], loading = false, chartLoading = false, statusHint = null, statusTone = 'muted', runAnalysisHint = false, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme = false, className, seriesInitKey, }) {
|
|
39
|
+
const [internalTimeRange, setInternalTimeRange] = useState(ALL_TIME_RANGE);
|
|
40
|
+
const [hiddenSeries, setHiddenSeries] = useState(new Set());
|
|
41
|
+
const timeRange = timeRangeProp ?? internalTimeRange;
|
|
42
|
+
const handleTimeRangeChange = useCallback((val) => {
|
|
43
|
+
if (!val)
|
|
44
|
+
return;
|
|
45
|
+
onTimeRangeChange?.(val);
|
|
46
|
+
if (timeRangeProp === undefined) {
|
|
47
|
+
setInternalTimeRange(val);
|
|
48
|
+
}
|
|
49
|
+
}, [onTimeRangeChange, timeRangeProp]);
|
|
50
|
+
const toggleSeries = useCallback((id) => {
|
|
51
|
+
const key = toForecastDataKey(id);
|
|
52
|
+
setHiddenSeries(prev => {
|
|
53
|
+
const next = new Set(prev);
|
|
54
|
+
if (next.has(key))
|
|
55
|
+
next.delete(key);
|
|
56
|
+
else
|
|
57
|
+
next.add(key);
|
|
58
|
+
return next;
|
|
59
|
+
});
|
|
60
|
+
}, []);
|
|
61
|
+
const showSeries = useCallback((id) => {
|
|
62
|
+
const key = toForecastDataKey(id);
|
|
63
|
+
setHiddenSeries(prev => {
|
|
64
|
+
if (!prev.has(key))
|
|
65
|
+
return prev;
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
next.delete(key);
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
const sortedDriversWithData = useMemo(() => {
|
|
72
|
+
const driversList = payload?.drivers ?? [];
|
|
73
|
+
if (!driversList.length)
|
|
74
|
+
return [];
|
|
75
|
+
return [...driversList]
|
|
76
|
+
.filter(d => d.normalized_series &&
|
|
77
|
+
Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
|
|
78
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
79
|
+
}, [payload?.drivers]);
|
|
80
|
+
const mergedChartData = useMemo(() => mergeBacktestsChartData(payload), [payload]);
|
|
81
|
+
const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
|
|
82
|
+
const chartForecastData = useMemo(() => {
|
|
83
|
+
if (!payload?.target?.normalized_series)
|
|
84
|
+
return [];
|
|
85
|
+
return sortedDriversWithData.map((driver, idx) => ({
|
|
86
|
+
id: DRIVER_FORECAST_ID_BASE + idx,
|
|
87
|
+
name: driver.name || String(driver.id),
|
|
88
|
+
color: getForecastColor(idx + 1),
|
|
89
|
+
}));
|
|
90
|
+
}, [payload?.target?.normalized_series, sortedDriversWithData]);
|
|
91
|
+
const tableSeriesRows = useMemo(() => {
|
|
92
|
+
if (sortedDriversWithData.length === 0)
|
|
93
|
+
return [];
|
|
94
|
+
return sortedDriversWithData.map((driver, idx) => {
|
|
95
|
+
const raw = driver.importance;
|
|
96
|
+
const importance = typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
|
|
97
|
+
return {
|
|
98
|
+
id: DRIVER_FORECAST_ID_BASE + idx,
|
|
99
|
+
name: driver.name || String(driver.id),
|
|
100
|
+
color: getForecastColor(idx + 1),
|
|
101
|
+
importance,
|
|
102
|
+
lag: driver.lag,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}, [sortedDriversWithData]);
|
|
106
|
+
const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
107
|
+
const backtestsSeriesInitKeyRef = useRef('');
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (tableSeriesRows.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const initKey = seriesInitKeyResolved;
|
|
112
|
+
if (backtestsSeriesInitKeyRef.current === initKey)
|
|
113
|
+
return;
|
|
114
|
+
backtestsSeriesInitKeyRef.current = initKey;
|
|
115
|
+
setHiddenSeries(() => {
|
|
116
|
+
const next = new Set();
|
|
117
|
+
tableSeriesRows.forEach((row, idx) => {
|
|
118
|
+
const key = toForecastDataKey(row.id);
|
|
119
|
+
if (idx >= INITIAL_VISIBLE_SERIES_COUNT) {
|
|
120
|
+
next.add(key);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
125
|
+
}, [seriesInitKeyResolved, tableSeriesRows]);
|
|
126
|
+
const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
|
|
127
|
+
const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
|
|
128
|
+
return (jsxs("div", { className: cn(S.root, className), children: [jsxs("div", { className: cn(S.chartShell, loading && S.chartShellLoading), children: [jsx("div", { className: S.chartSlot, children: jsxs("div", { className: S.chartWithOverlay, children: [jsx("div", { className: cn(S.chartInteractiveLayer, showEmptyOverlay && S.chartInteractiveDimmed), children: jsx(ChartAreaInteractive, { disableHistoricalAnimation: true, disableTimeRangeSelector: true, enableTimeRangeBrush: true, chartContainerClassName: S.chartContainer, chartData: driversComparisonChartData, forecastData: chartForecastData, timeRange: timeRange, onTimeRangeChange: handleTimeRangeChange, pinMonth: undefined, onPinMonthChange: () => { }, isDarkTheme: isDarkTheme, loading: chartLoading, hasCombinedData: mergedWithHistorical.length > 0, forecastLineStyle: "solid", showLegend: false, hiddenSeries: hiddenSeries, toggleLegendSeries: toggleSeries, ensureAnalysisSeriesVisible: showSeries }) }), showEmptyOverlay && (jsx("div", { className: S.chartEmptyOverlay, role: "status", "aria-live": "polite", children: jsx("div", { className: S.chartEmptyBlurb, children: jsx(ChartEmptyState, { variant: "inline", hint: runAnalysisHint
|
|
129
|
+
? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
|
|
130
|
+
: undefined, status: statusHint ?? undefined, statusTone: statusTone }) }) }))] }) }), loading && (jsx("div", { className: S.loadingLayer, "aria-busy": "true", "aria-live": "polite", children: jsx("div", { className: S.loadingMessage, children: jsx(TextShimmer, { as: "span", className: S.loadingText, children: "Loading drivers comparison\u2026" }) }) }))] }), jsx("div", { className: S.seriesSection, children: tableSeriesRows.length === 0 ? (jsx("div", { className: S.seriesEmptyWrap, children: jsx("div", { className: S.chartEmptyBlurb, children: jsx(ChartEmptyState, { variant: "inline", align: "center", status: "No series" }) }) })) : (jsx("div", { className: S.seriesTableWrapper, children: jsx(PageXScroll, { size: "md", fullWidth: true, innerClassName: S.seriesTableContainer, scrollbarClassName: S.seriesScrollbar, children: jsxs(Table, { withBackground: true, withPaddings: true, className: S.seriesTable, children: [jsx(TableHeader, { children: jsxs(TableRow, { children: [jsx(TableHead, { className: S.seriesColSeries, children: "Driver name" }), jsx(TableHead, { children: "Importance" }), jsx(TableHead, { children: "Lag" })] }) }), jsx(TableBody, { children: tableSeriesRows.map(row => {
|
|
131
|
+
const dataKey = toForecastDataKey(row.id);
|
|
132
|
+
const hidden = hiddenSeries.has(dataKey);
|
|
133
|
+
return (jsxs(TableRow, { className: cn(hidden && S.rowHidden), onClick: () => toggleSeries(row.id), children: [jsx(TableCell, { children: jsxs("span", { className: S.seriesLabel, children: [jsx("span", { className: S.colorSwatch, style: {
|
|
134
|
+
backgroundColor: row.color,
|
|
135
|
+
} }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag ?? '—' })] }, row.id));
|
|
136
|
+
}) })] }) }) })) })] }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { DriversComparisonChart };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import styleInject from 'style-inject';
|
|
2
|
+
|
|
3
|
+
var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.DriversComparisonChart_root__GLnlF{display:flex;flex-direction:column;gap:var(--p-4);width:100%}.DriversComparisonChart_chartShell__0AzAz{position:relative;width:100%}.DriversComparisonChart_chartShellLoading__wTLod .DriversComparisonChart_chartSlot__WKe6N{opacity:.3;pointer-events:none}.DriversComparisonChart_chartShellLoading__wTLod .recharts-line-dots{display:none}.DriversComparisonChart_chartSlot__WKe6N{width:100%}.DriversComparisonChart_loadingLayer__HlEmA{inset:0;pointer-events:none;position:absolute;z-index:10}.DriversComparisonChart_loadingMessage__Li3S7{color:var(--foreground);display:block;left:0;position:absolute;text-align:center;top:calc(50% - 4em);width:100%;z-index:1}.DriversComparisonChart_loadingText__0y3Ms{backdrop-filter:blur(10px);border-radius:var(--p-4);box-shadow:0 0 0 2px var(--page-color);color:var(--foreground);font-size:16px;font-weight:500;padding:0 var(--p-3)}.DriversComparisonChart_chartWithOverlay__SMToK{position:relative;width:100%}.DriversComparisonChart_chartInteractiveLayer__0aquh{transition:opacity .2s ease-out;width:100%}.DriversComparisonChart_chartInteractiveDimmed__us1dv{opacity:.3;pointer-events:none}.DriversComparisonChart_chartInteractiveDimmed__us1dv .recharts-line-dots{display:none}.DriversComparisonChart_chartEmptyOverlay__eXqzk{align-items:center;display:flex;inset:0;justify-content:center;padding:var(--p-4);pointer-events:none;position:absolute;z-index:6}.DriversComparisonChart_chartEmptyBlurb__Tjy9E{max-width:42rem;padding:0 var(--p-3)}.DriversComparisonChart_seriesEmptyWrap__RyRs5{display:flex;justify-content:center;padding:var(--p-8) var(--p-4)}.DriversComparisonChart_seriesSection__yrf4I{width:100%}.DriversComparisonChart_seriesTableWrapper__oredC{margin:0 calc(var(--page-x-padding)*-1);position:relative}.DriversComparisonChart_seriesTableContainer__dhfXK{overflow-x:auto;position:relative;width:100%}.DriversComparisonChart_seriesScrollbar__2jgk6{bottom:calc(var(--p-7)*-1 + 2px)!important}.DriversComparisonChart_seriesTable__NybPR{max-width:100%;table-layout:auto;width:100%}.DriversComparisonChart_seriesTable__NybPR tr{cursor:pointer;position:relative}.DriversComparisonChart_seriesTable__NybPR td:not(:first-child),.DriversComparisonChart_seriesTable__NybPR th:not(:first-child){text-align:right}.DriversComparisonChart_seriesColSeries__--0mj{text-align:left;vertical-align:middle}.DriversComparisonChart_rowHidden__Fwhna{opacity:.4}.DriversComparisonChart_colorSwatch__o3ZVr{border-radius:2px;display:inline-flex;flex-shrink:0;height:10px;margin-right:var(--p-2);width:10px}.DriversComparisonChart_seriesLabel__Aidof{display:-webkit-box;-webkit-line-clamp:3;max-width:60vw;min-width:0;padding:var(--p-2) 0;-webkit-box-orient:vertical;white-space:break-spaces}.DriversComparisonChart_chartContainer__1eM-O{margin-left:calc(var(--page-x-padding)*-1 + 26px);max-width:calc(100% + 60px);transition:opacity .3s ease-out;width:calc(100% + 60px)}.DriversComparisonChart_chartContainer__1eM-O .recharts-yAxis-tick-labels{display:none}@media (max-width:768px){.DriversComparisonChart_chartContainer__1eM-O{margin-left:calc(var(--page-x-padding)*-1 + 10px);width:calc(100% + 30px)}}";
|
|
4
|
+
var S = {"root":"DriversComparisonChart_root__GLnlF","chartShell":"DriversComparisonChart_chartShell__0AzAz","chartShellLoading":"DriversComparisonChart_chartShellLoading__wTLod","chartSlot":"DriversComparisonChart_chartSlot__WKe6N","loadingLayer":"DriversComparisonChart_loadingLayer__HlEmA","loadingMessage":"DriversComparisonChart_loadingMessage__Li3S7","loadingText":"DriversComparisonChart_loadingText__0y3Ms","chartWithOverlay":"DriversComparisonChart_chartWithOverlay__SMToK","chartInteractiveLayer":"DriversComparisonChart_chartInteractiveLayer__0aquh","chartInteractiveDimmed":"DriversComparisonChart_chartInteractiveDimmed__us1dv","chartEmptyOverlay":"DriversComparisonChart_chartEmptyOverlay__eXqzk","chartEmptyBlurb":"DriversComparisonChart_chartEmptyBlurb__Tjy9E","seriesEmptyWrap":"DriversComparisonChart_seriesEmptyWrap__RyRs5","seriesSection":"DriversComparisonChart_seriesSection__yrf4I","seriesTableWrapper":"DriversComparisonChart_seriesTableWrapper__oredC","seriesTableContainer":"DriversComparisonChart_seriesTableContainer__dhfXK","seriesScrollbar":"DriversComparisonChart_seriesScrollbar__2jgk6","seriesTable":"DriversComparisonChart_seriesTable__NybPR","seriesColSeries":"DriversComparisonChart_seriesColSeries__--0mj","rowHidden":"DriversComparisonChart_rowHidden__Fwhna","colorSwatch":"DriversComparisonChart_colorSwatch__o3ZVr","seriesLabel":"DriversComparisonChart_seriesLabel__Aidof","chartContainer":"DriversComparisonChart_chartContainer__1eM-O"};
|
|
5
|
+
styleInject(css_248z);
|
|
6
|
+
|
|
7
|
+
export { S as default };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { normalizeToMonthStart, getPreviousMonth, getNextMonth } from '../../../utils/chartConnectionPoint.js';
|
|
2
|
+
|
|
3
|
+
const DRIVER_FORECAST_ID_BASE = 8_000_000;
|
|
4
|
+
const INITIAL_VISIBLE_SERIES_COUNT = 3;
|
|
5
|
+
/** Months of historical context before the earliest forecast month (inclusive). */
|
|
6
|
+
const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
|
|
7
|
+
/** Ignore leading months where drivers are numerically zero (API often fills 0; lines look empty until real signal). */
|
|
8
|
+
const DRIVER_FORECAST_NONZERO_EPS = 1e-9;
|
|
9
|
+
function formatSeriesImportance(value) {
|
|
10
|
+
if (value === null)
|
|
11
|
+
return '—';
|
|
12
|
+
return `${value.toFixed(1)}%`;
|
|
13
|
+
}
|
|
14
|
+
function mergeBacktestsChartData(payload) {
|
|
15
|
+
if (!payload?.target?.normalized_series)
|
|
16
|
+
return [];
|
|
17
|
+
const norm = (d) => normalizeToMonthStart(d);
|
|
18
|
+
const map = new Map();
|
|
19
|
+
const targetSeries = payload.target.normalized_series;
|
|
20
|
+
Object.entries(targetSeries).forEach(([dateStr, val]) => {
|
|
21
|
+
if (val === null || val === undefined)
|
|
22
|
+
return;
|
|
23
|
+
const k = norm(dateStr);
|
|
24
|
+
const existing = map.get(k) ?? { date: k };
|
|
25
|
+
existing.historical = val;
|
|
26
|
+
map.set(k, existing);
|
|
27
|
+
});
|
|
28
|
+
if (payload.drivers?.length) {
|
|
29
|
+
const sorted = [...payload.drivers]
|
|
30
|
+
.filter(d => d.normalized_series &&
|
|
31
|
+
Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
|
|
32
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
33
|
+
sorted.forEach((driver, idx) => {
|
|
34
|
+
const fid = DRIVER_FORECAST_ID_BASE + idx;
|
|
35
|
+
const series = driver.normalized_series;
|
|
36
|
+
Object.entries(series).forEach(([dateStr, val]) => {
|
|
37
|
+
if (val === null || val === undefined)
|
|
38
|
+
return;
|
|
39
|
+
const k = norm(dateStr);
|
|
40
|
+
const existing = map.get(k) ?? { date: k };
|
|
41
|
+
existing[`forecast_${fid}`] = val;
|
|
42
|
+
map.set(k, existing);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
47
|
+
}
|
|
48
|
+
/** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
|
|
49
|
+
function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, backtestsMerged) {
|
|
50
|
+
if (backtestsMerged.length === 0) {
|
|
51
|
+
if (datasetHistorical.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
return datasetHistorical
|
|
54
|
+
.map(p => {
|
|
55
|
+
const k = normalizeToMonthStart(p.date);
|
|
56
|
+
const histVal = p.historical;
|
|
57
|
+
return {
|
|
58
|
+
date: k,
|
|
59
|
+
...(typeof histVal === 'number' ? { historical: histVal } : {}),
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
63
|
+
}
|
|
64
|
+
return backtestsMerged;
|
|
65
|
+
}
|
|
66
|
+
/** Lower-median of ISO month strings (YYYY-MM-01 sorts lexicographically). */
|
|
67
|
+
function medianSortedIsoMonth(months) {
|
|
68
|
+
const s = [...months].sort((a, b) => a.localeCompare(b));
|
|
69
|
+
return s[Math.floor((s.length - 1) / 2)];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* For each driver, first month with materially non-zero value; anchor = median of those months.
|
|
73
|
+
* (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
|
|
74
|
+
*/
|
|
75
|
+
function getZoomAnchorMonthFromDriverMaterialStarts(points, forecastIds) {
|
|
76
|
+
const perDriverFirstMaterial = [];
|
|
77
|
+
for (const id of forecastIds) {
|
|
78
|
+
let minForId = null;
|
|
79
|
+
for (const p of points) {
|
|
80
|
+
const v = p[`forecast_${id}`];
|
|
81
|
+
if (typeof v !== 'number' ||
|
|
82
|
+
!Number.isFinite(v) ||
|
|
83
|
+
Math.abs(v) <= DRIVER_FORECAST_NONZERO_EPS) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const d = normalizeToMonthStart(p.date);
|
|
87
|
+
if (minForId === null || d.localeCompare(minForId) < 0)
|
|
88
|
+
minForId = d;
|
|
89
|
+
}
|
|
90
|
+
if (minForId === null) {
|
|
91
|
+
return { anchor: null, perDriverFirstMaterial };
|
|
92
|
+
}
|
|
93
|
+
perDriverFirstMaterial.push(minForId);
|
|
94
|
+
}
|
|
95
|
+
const anchor = medianSortedIsoMonth(perDriverFirstMaterial);
|
|
96
|
+
return { anchor, perDriverFirstMaterial };
|
|
97
|
+
}
|
|
98
|
+
function subtractMonthsFromMonthStart(date, count) {
|
|
99
|
+
let result = date;
|
|
100
|
+
for (let i = 0; i < count; i++) {
|
|
101
|
+
result = getPreviousMonth(result);
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Backtests payload often starts at the same month for target + drivers, so xMin falls
|
|
107
|
+
* in a range with no rows. Prepend historical-only months from raw dataset series
|
|
108
|
+
* (scaled to the first normalized point) so the lead-in shows the target line alone.
|
|
109
|
+
*/
|
|
110
|
+
function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
|
|
111
|
+
if (points.length === 0)
|
|
112
|
+
return points;
|
|
113
|
+
const firstMonth = normalizeToMonthStart(points[0].date);
|
|
114
|
+
if (xMin.localeCompare(firstMonth) >= 0)
|
|
115
|
+
return points;
|
|
116
|
+
const rawByMonth = new Map();
|
|
117
|
+
for (const p of datasetHistorical) {
|
|
118
|
+
const k = normalizeToMonthStart(p.date);
|
|
119
|
+
const v = p.historical;
|
|
120
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
121
|
+
rawByMonth.set(k, v);
|
|
122
|
+
}
|
|
123
|
+
const histFirst = points[0].historical;
|
|
124
|
+
const rawFirst = rawByMonth.get(firstMonth);
|
|
125
|
+
if (typeof histFirst !== 'number' ||
|
|
126
|
+
!Number.isFinite(histFirst) ||
|
|
127
|
+
typeof rawFirst !== 'number' ||
|
|
128
|
+
!Number.isFinite(rawFirst) ||
|
|
129
|
+
Math.abs(rawFirst) <= 1e-15) {
|
|
130
|
+
return points;
|
|
131
|
+
}
|
|
132
|
+
const lead = [];
|
|
133
|
+
let m = xMin;
|
|
134
|
+
while (m.localeCompare(firstMonth) < 0) {
|
|
135
|
+
const rawM = rawByMonth.get(m);
|
|
136
|
+
if (typeof rawM === 'number' && Number.isFinite(rawM)) {
|
|
137
|
+
lead.push({
|
|
138
|
+
date: m,
|
|
139
|
+
historical: histFirst * (rawM / rawFirst),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
m = getNextMonth(m);
|
|
143
|
+
}
|
|
144
|
+
return [...lead, ...points];
|
|
145
|
+
}
|
|
146
|
+
function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds) {
|
|
147
|
+
if (mergedWithHistorical.length === 0)
|
|
148
|
+
return mergedWithHistorical;
|
|
149
|
+
if (forecastIds.length === 0)
|
|
150
|
+
return mergedWithHistorical;
|
|
151
|
+
const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
|
|
152
|
+
if (anchorMonth === null)
|
|
153
|
+
return mergedWithHistorical;
|
|
154
|
+
const xMin = subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
|
|
155
|
+
const withLead = prependHistoricalLeadFromDataset(mergedWithHistorical, datasetHistorical, xMin);
|
|
156
|
+
return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, prependHistoricalLeadFromDataset, subtractMonthsFromMonthStart };
|
package/dist/esm/index.js
CHANGED
|
@@ -104,6 +104,8 @@ export { DriverMap } from './components/widgets/DriverMap/DriverMap.js';
|
|
|
104
104
|
export { getCategoryIcon } from './components/widgets/DriverMap/driverCategoryIcon.js';
|
|
105
105
|
export { getDriverImportance, getHighestImportanceDriver } from './components/widgets/DriverMap/driverMapSelection.js';
|
|
106
106
|
export { geographicCoordinates, geographicToSVG, getContinentFromRegion, getPreciseCoordinates, getResponsiveCoordinates, svgToPercentage } from './components/widgets/DriverMap/driverMapGeography.js';
|
|
107
|
+
export { DriversComparisonChart } from './components/widgets/DriversComparisonChart/DriversComparisonChart.js';
|
|
108
|
+
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData } from './components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js';
|
|
107
109
|
export { SybilionAppHeader } from './components/widgets/SybilionAppHeader/SybilionAppHeader.js';
|
|
108
110
|
export { SybilionAuthLayout } from './components/widgets/SybilionAuthLayout/SybilionAuthLayout.js';
|
|
109
111
|
export { SybilionAuthHeadline } from './components/widgets/SybilionAuthLayout/SybilionAuthHeadline.js';
|
package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
2
|
+
import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
|
|
3
|
+
export type DriversComparisonChartProps = {
|
|
4
|
+
payload: BacktestsComponentPayload | null;
|
|
5
|
+
datasetHistorical?: ChartDataPoint[];
|
|
6
|
+
loading?: boolean;
|
|
7
|
+
chartLoading?: boolean;
|
|
8
|
+
statusHint?: string | null;
|
|
9
|
+
statusTone?: 'destructive' | 'muted';
|
|
10
|
+
runAnalysisHint?: boolean;
|
|
11
|
+
timeRange?: string;
|
|
12
|
+
onTimeRangeChange?: (range: string) => void;
|
|
13
|
+
isDarkTheme?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Resets visible series when this key changes (e.g. selected analysis id). */
|
|
16
|
+
seriesInitKey?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function DriversComparisonChart({ payload, datasetHistorical, loading, chartLoading, statusHint, statusTone, runAnalysisHint, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme, className, seriesInitKey, }: DriversComparisonChartProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
2
|
+
import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
|
|
3
|
+
export declare const DRIVER_FORECAST_ID_BASE = 8000000;
|
|
4
|
+
export declare const INITIAL_VISIBLE_SERIES_COUNT = 3;
|
|
5
|
+
/** Months of historical context before the earliest forecast month (inclusive). */
|
|
6
|
+
export declare const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
|
|
7
|
+
export declare function formatSeriesImportance(value: number | null): string;
|
|
8
|
+
export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
|
|
9
|
+
/** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
|
|
10
|
+
export declare function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical: ChartDataPoint[], backtestsMerged: ChartDataPoint[]): ChartDataPoint[];
|
|
11
|
+
/**
|
|
12
|
+
* For each driver, first month with materially non-zero value; anchor = median of those months.
|
|
13
|
+
* (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
|
|
14
|
+
*/
|
|
15
|
+
export declare function getZoomAnchorMonthFromDriverMaterialStarts(points: ChartDataPoint[], forecastIds: number[]): {
|
|
16
|
+
anchor: string | null;
|
|
17
|
+
perDriverFirstMaterial: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare function subtractMonthsFromMonthStart(date: string, count: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Backtests payload often starts at the same month for target + drivers, so xMin falls
|
|
22
|
+
* in a range with no rows. Prepend historical-only months from raw dataset series
|
|
23
|
+
* (scaled to the first normalized point) so the lead-in shows the target line alone.
|
|
24
|
+
*/
|
|
25
|
+
export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
|
|
26
|
+
export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[]): ChartDataPoint[];
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { DriversComparisonChart, type DriversComparisonChartProps, } from './DriversComparisonChart';
|
|
2
|
+
export { buildDriversComparisonChartData, DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, formatSeriesImportance, INITIAL_VISIBLE_SERIES_COUNT, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, } from './driversComparisonChart.helpers';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function DriversComparisonChartPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -68,6 +68,7 @@ export * from './components/ui/WorkspaceAppSwitcher';
|
|
|
68
68
|
export * from './components/widgets/SidebarDatasetsItemsGrouped';
|
|
69
69
|
export * from './components/widgets/DriverCard';
|
|
70
70
|
export * from './components/widgets/DriverMap';
|
|
71
|
+
export * from './components/widgets/DriversComparisonChart';
|
|
71
72
|
export * from './components/widgets/SybilionAppHeader';
|
|
72
73
|
export * from './components/widgets/SybilionAuthLayout';
|
|
73
74
|
export * from './components/widgets/SybilionSignInPanel';
|
|
@@ -101,5 +101,13 @@ function getPreviousMonth(date) {
|
|
|
101
101
|
d.setMonth(d.getMonth() - 1);
|
|
102
102
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Get the next month date (first day of next month)
|
|
106
|
+
*/
|
|
107
|
+
function getNextMonth(date) {
|
|
108
|
+
const d = new Date(date);
|
|
109
|
+
d.setMonth(d.getMonth() + 1);
|
|
110
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
|
111
|
+
}
|
|
104
112
|
|
|
105
|
-
export { ensureChartForecastBridge, findLastValidHistoricalChartPoint, getPreviousMonth, normalizeToMonthStart };
|
|
113
|
+
export { ensureChartForecastBridge, findLastValidHistoricalChartPoint, getNextMonth, getPreviousMonth, normalizeToMonthStart };
|
package/package.json
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
@import '../../../lib/theme.styl';
|
|
2
|
+
|
|
3
|
+
.root
|
|
4
|
+
display flex
|
|
5
|
+
flex-direction column
|
|
6
|
+
gap var(--p-4)
|
|
7
|
+
width 100%
|
|
8
|
+
|
|
9
|
+
.chartShell
|
|
10
|
+
position relative
|
|
11
|
+
width 100%
|
|
12
|
+
|
|
13
|
+
.chartShellLoading
|
|
14
|
+
.chartSlot
|
|
15
|
+
opacity 0.3
|
|
16
|
+
pointer-events none
|
|
17
|
+
|
|
18
|
+
:global(.recharts-line-dots)
|
|
19
|
+
display none
|
|
20
|
+
|
|
21
|
+
.chartSlot
|
|
22
|
+
width 100%
|
|
23
|
+
|
|
24
|
+
.loadingLayer
|
|
25
|
+
position absolute
|
|
26
|
+
inset 0
|
|
27
|
+
z-index 10
|
|
28
|
+
pointer-events none
|
|
29
|
+
|
|
30
|
+
.loadingMessage
|
|
31
|
+
position absolute
|
|
32
|
+
z-index 1
|
|
33
|
+
top calc(50% - 4em)
|
|
34
|
+
left 0
|
|
35
|
+
width 100%
|
|
36
|
+
display block
|
|
37
|
+
text-align center
|
|
38
|
+
color var(--foreground)
|
|
39
|
+
|
|
40
|
+
.loadingText
|
|
41
|
+
font-size 16px
|
|
42
|
+
font-weight 500
|
|
43
|
+
color var(--foreground)
|
|
44
|
+
border-radius var(--p-4)
|
|
45
|
+
padding 0 var(--p-3)
|
|
46
|
+
backdrop-filter blur(10px)
|
|
47
|
+
box-shadow 0 0 0 2px var(--page-color)
|
|
48
|
+
|
|
49
|
+
.chartWithOverlay
|
|
50
|
+
position relative
|
|
51
|
+
width 100%
|
|
52
|
+
|
|
53
|
+
.chartInteractiveLayer
|
|
54
|
+
width 100%
|
|
55
|
+
transition opacity 0.2s ease-out
|
|
56
|
+
|
|
57
|
+
.chartInteractiveDimmed
|
|
58
|
+
opacity 0.3
|
|
59
|
+
pointer-events none
|
|
60
|
+
|
|
61
|
+
:global(.recharts-line-dots)
|
|
62
|
+
display none
|
|
63
|
+
|
|
64
|
+
.chartEmptyOverlay
|
|
65
|
+
position absolute
|
|
66
|
+
inset 0
|
|
67
|
+
z-index 6
|
|
68
|
+
display flex
|
|
69
|
+
align-items center
|
|
70
|
+
justify-content center
|
|
71
|
+
padding var(--p-4)
|
|
72
|
+
pointer-events none
|
|
73
|
+
|
|
74
|
+
.chartEmptyBlurb
|
|
75
|
+
max-width 42rem
|
|
76
|
+
padding 0 var(--p-3)
|
|
77
|
+
|
|
78
|
+
.seriesEmptyWrap
|
|
79
|
+
display flex
|
|
80
|
+
justify-content center
|
|
81
|
+
padding var(--p-8) var(--p-4)
|
|
82
|
+
|
|
83
|
+
.seriesSection
|
|
84
|
+
width 100%
|
|
85
|
+
|
|
86
|
+
.seriesTableWrapper
|
|
87
|
+
position relative
|
|
88
|
+
margin 0 calc(var(--page-x-padding) * -1)
|
|
89
|
+
|
|
90
|
+
.seriesTableContainer
|
|
91
|
+
position relative
|
|
92
|
+
width 100%
|
|
93
|
+
overflow-x auto
|
|
94
|
+
|
|
95
|
+
.seriesScrollbar
|
|
96
|
+
bottom calc(var(--p-7) * -1 + 2px) !important
|
|
97
|
+
|
|
98
|
+
.seriesTable
|
|
99
|
+
table-layout auto
|
|
100
|
+
width 100%
|
|
101
|
+
max-width 100%
|
|
102
|
+
|
|
103
|
+
tr
|
|
104
|
+
position relative
|
|
105
|
+
cursor pointer
|
|
106
|
+
|
|
107
|
+
td
|
|
108
|
+
th
|
|
109
|
+
&:not(:first-child)
|
|
110
|
+
text-align right
|
|
111
|
+
|
|
112
|
+
.seriesColSeries
|
|
113
|
+
text-align left
|
|
114
|
+
vertical-align middle
|
|
115
|
+
|
|
116
|
+
.rowHidden
|
|
117
|
+
opacity .4
|
|
118
|
+
|
|
119
|
+
.colorSwatch
|
|
120
|
+
display inline-flex
|
|
121
|
+
flex-shrink 0
|
|
122
|
+
width 10px
|
|
123
|
+
height 10px
|
|
124
|
+
margin-right var(--p-2)
|
|
125
|
+
border-radius 2px
|
|
126
|
+
|
|
127
|
+
.seriesLabel
|
|
128
|
+
min-width 0
|
|
129
|
+
max-width 60vw
|
|
130
|
+
padding var(--p-2) 0
|
|
131
|
+
line-clamp(3)
|
|
132
|
+
white-space break-spaces
|
|
133
|
+
|
|
134
|
+
.chartContainer
|
|
135
|
+
margin-left calc(-1 * var(--page-x-padding) + 26px)
|
|
136
|
+
width calc(100% + 60px)
|
|
137
|
+
max-width @width
|
|
138
|
+
transition opacity 300ms ease-out
|
|
139
|
+
|
|
140
|
+
:global(.recharts-yAxis-tick-labels)
|
|
141
|
+
display none
|
|
142
|
+
|
|
143
|
+
@media (max-width: unit(MOBILE, 'px'))
|
|
144
|
+
margin-left calc(-1 * var(--page-x-padding) + 10px)
|
|
145
|
+
width calc(100% + 30px)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// This file is automatically generated.
|
|
2
|
+
// Please do not change this file!
|
|
3
|
+
interface CssExports {
|
|
4
|
+
'chartContainer': string;
|
|
5
|
+
'chartEmptyBlurb': string;
|
|
6
|
+
'chartEmptyOverlay': string;
|
|
7
|
+
'chartInteractiveDimmed': string;
|
|
8
|
+
'chartInteractiveLayer': string;
|
|
9
|
+
'chartShell': string;
|
|
10
|
+
'chartShellLoading': string;
|
|
11
|
+
'chartSlot': string;
|
|
12
|
+
'chartWithOverlay': string;
|
|
13
|
+
'colorSwatch': string;
|
|
14
|
+
'loadingLayer': string;
|
|
15
|
+
'loadingMessage': string;
|
|
16
|
+
'loadingText': string;
|
|
17
|
+
'root': string;
|
|
18
|
+
'rowHidden': string;
|
|
19
|
+
'seriesColSeries': string;
|
|
20
|
+
'seriesEmptyWrap': string;
|
|
21
|
+
'seriesLabel': string;
|
|
22
|
+
'seriesScrollbar': string;
|
|
23
|
+
'seriesSection': string;
|
|
24
|
+
'seriesTable': string;
|
|
25
|
+
'seriesTableContainer': string;
|
|
26
|
+
'seriesTableWrapper': string;
|
|
27
|
+
}
|
|
28
|
+
export const cssExports: CssExports;
|
|
29
|
+
export default cssExports;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import cn from 'classnames';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { ChartEmptyState } from '#uilib/components/ui/Chart/components/ChartEmptyState/ChartEmptyState';
|
|
5
|
+
import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
|
|
6
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
7
|
+
import {
|
|
8
|
+
type ForecastItemData,
|
|
9
|
+
getForecastColor,
|
|
10
|
+
} from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
|
|
11
|
+
import { PageXScroll } from '#uilib/components/ui/Page';
|
|
12
|
+
import {
|
|
13
|
+
Table,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableHead,
|
|
17
|
+
TableHeader,
|
|
18
|
+
TableRow,
|
|
19
|
+
} from '#uilib/components/ui/Table';
|
|
20
|
+
import { TextShimmer } from '#uilib/components/ui/TextShimmer/TextShimmer';
|
|
21
|
+
import { TIME_RANGES } from '#uilib/components/ui/TimeRangeControls/TimeRangeControls.constants';
|
|
22
|
+
import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
|
|
23
|
+
|
|
24
|
+
import S from './DriversComparisonChart.styl';
|
|
25
|
+
import {
|
|
26
|
+
DRIVER_FORECAST_ID_BASE,
|
|
27
|
+
INITIAL_VISIBLE_SERIES_COUNT,
|
|
28
|
+
buildDriversComparisonChartData,
|
|
29
|
+
formatSeriesImportance,
|
|
30
|
+
mergeBacktestsChartData,
|
|
31
|
+
mergeDatasetHistoricalWithBacktestsChartData,
|
|
32
|
+
} from './driversComparisonChart.helpers';
|
|
33
|
+
|
|
34
|
+
const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
|
|
35
|
+
|
|
36
|
+
function toForecastDataKey(id: number | string): string {
|
|
37
|
+
const s = String(id);
|
|
38
|
+
return s.startsWith('forecast_') ? s : `forecast_${id}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DriversComparisonChartProps = {
|
|
42
|
+
payload: BacktestsComponentPayload | null;
|
|
43
|
+
datasetHistorical?: ChartDataPoint[];
|
|
44
|
+
loading?: boolean;
|
|
45
|
+
chartLoading?: boolean;
|
|
46
|
+
statusHint?: string | null;
|
|
47
|
+
statusTone?: 'destructive' | 'muted';
|
|
48
|
+
runAnalysisHint?: boolean;
|
|
49
|
+
timeRange?: string;
|
|
50
|
+
onTimeRangeChange?: (range: string) => void;
|
|
51
|
+
isDarkTheme?: boolean;
|
|
52
|
+
className?: string;
|
|
53
|
+
/** Resets visible series when this key changes (e.g. selected analysis id). */
|
|
54
|
+
seriesInitKey?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function DriversComparisonChart({
|
|
58
|
+
payload,
|
|
59
|
+
datasetHistorical = [],
|
|
60
|
+
loading = false,
|
|
61
|
+
chartLoading = false,
|
|
62
|
+
statusHint = null,
|
|
63
|
+
statusTone = 'muted',
|
|
64
|
+
runAnalysisHint = false,
|
|
65
|
+
timeRange: timeRangeProp,
|
|
66
|
+
onTimeRangeChange,
|
|
67
|
+
isDarkTheme = false,
|
|
68
|
+
className,
|
|
69
|
+
seriesInitKey,
|
|
70
|
+
}: DriversComparisonChartProps) {
|
|
71
|
+
const [internalTimeRange, setInternalTimeRange] =
|
|
72
|
+
useState<string>(ALL_TIME_RANGE);
|
|
73
|
+
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
|
|
74
|
+
|
|
75
|
+
const timeRange = timeRangeProp ?? internalTimeRange;
|
|
76
|
+
const handleTimeRangeChange = useCallback(
|
|
77
|
+
(val: string) => {
|
|
78
|
+
if (!val) return;
|
|
79
|
+
onTimeRangeChange?.(val);
|
|
80
|
+
if (timeRangeProp === undefined) {
|
|
81
|
+
setInternalTimeRange(val);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[onTimeRangeChange, timeRangeProp],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const toggleSeries = useCallback((id: number | string) => {
|
|
88
|
+
const key = toForecastDataKey(id);
|
|
89
|
+
setHiddenSeries(prev => {
|
|
90
|
+
const next = new Set(prev);
|
|
91
|
+
if (next.has(key)) next.delete(key);
|
|
92
|
+
else next.add(key);
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const showSeries = useCallback((id: number | string) => {
|
|
98
|
+
const key = toForecastDataKey(id);
|
|
99
|
+
setHiddenSeries(prev => {
|
|
100
|
+
if (!prev.has(key)) return prev;
|
|
101
|
+
const next = new Set(prev);
|
|
102
|
+
next.delete(key);
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const sortedDriversWithData = useMemo(() => {
|
|
108
|
+
const driversList = payload?.drivers ?? [];
|
|
109
|
+
if (!driversList.length) return [];
|
|
110
|
+
return [...driversList]
|
|
111
|
+
.filter(
|
|
112
|
+
d =>
|
|
113
|
+
d.normalized_series &&
|
|
114
|
+
Object.keys(d.normalized_series).some(
|
|
115
|
+
key => d.normalized_series![key] != null,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
119
|
+
}, [payload?.drivers]);
|
|
120
|
+
|
|
121
|
+
const mergedChartData = useMemo(
|
|
122
|
+
() => mergeBacktestsChartData(payload),
|
|
123
|
+
[payload],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const mergedWithHistorical = useMemo(
|
|
127
|
+
() =>
|
|
128
|
+
mergeDatasetHistoricalWithBacktestsChartData(
|
|
129
|
+
datasetHistorical,
|
|
130
|
+
mergedChartData,
|
|
131
|
+
),
|
|
132
|
+
[datasetHistorical, mergedChartData],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const chartForecastData = useMemo((): ForecastItemData[] => {
|
|
136
|
+
if (!payload?.target?.normalized_series) return [];
|
|
137
|
+
return sortedDriversWithData.map((driver, idx) => ({
|
|
138
|
+
id: DRIVER_FORECAST_ID_BASE + idx,
|
|
139
|
+
name: driver.name || String(driver.id),
|
|
140
|
+
color: getForecastColor(idx + 1),
|
|
141
|
+
}));
|
|
142
|
+
}, [payload?.target?.normalized_series, sortedDriversWithData]);
|
|
143
|
+
|
|
144
|
+
const tableSeriesRows = useMemo(() => {
|
|
145
|
+
if (sortedDriversWithData.length === 0) return [];
|
|
146
|
+
return sortedDriversWithData.map((driver, idx) => {
|
|
147
|
+
const raw = driver.importance;
|
|
148
|
+
const importance =
|
|
149
|
+
typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
|
|
150
|
+
return {
|
|
151
|
+
id: DRIVER_FORECAST_ID_BASE + idx,
|
|
152
|
+
name: driver.name || String(driver.id),
|
|
153
|
+
color: getForecastColor(idx + 1),
|
|
154
|
+
importance,
|
|
155
|
+
lag: driver.lag,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}, [sortedDriversWithData]);
|
|
159
|
+
|
|
160
|
+
const seriesInitKeyResolved =
|
|
161
|
+
seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
162
|
+
|
|
163
|
+
const backtestsSeriesInitKeyRef = useRef<string>('');
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (tableSeriesRows.length === 0) return;
|
|
167
|
+
const initKey = seriesInitKeyResolved;
|
|
168
|
+
if (backtestsSeriesInitKeyRef.current === initKey) return;
|
|
169
|
+
backtestsSeriesInitKeyRef.current = initKey;
|
|
170
|
+
|
|
171
|
+
setHiddenSeries(() => {
|
|
172
|
+
const next = new Set<string>();
|
|
173
|
+
tableSeriesRows.forEach((row, idx) => {
|
|
174
|
+
const key = toForecastDataKey(row.id);
|
|
175
|
+
if (idx >= INITIAL_VISIBLE_SERIES_COUNT) {
|
|
176
|
+
next.add(key);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return next;
|
|
180
|
+
});
|
|
181
|
+
}, [seriesInitKeyResolved, tableSeriesRows]);
|
|
182
|
+
|
|
183
|
+
const driversComparisonChartData = useMemo(
|
|
184
|
+
() =>
|
|
185
|
+
buildDriversComparisonChartData(
|
|
186
|
+
mergedWithHistorical,
|
|
187
|
+
datasetHistorical,
|
|
188
|
+
chartForecastData.map(f => f.id),
|
|
189
|
+
),
|
|
190
|
+
[chartForecastData, datasetHistorical, mergedWithHistorical],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div className={cn(S.root, className)}>
|
|
197
|
+
<div className={cn(S.chartShell, loading && S.chartShellLoading)}>
|
|
198
|
+
<div className={S.chartSlot}>
|
|
199
|
+
<div className={S.chartWithOverlay}>
|
|
200
|
+
<div
|
|
201
|
+
className={cn(
|
|
202
|
+
S.chartInteractiveLayer,
|
|
203
|
+
showEmptyOverlay && S.chartInteractiveDimmed,
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<ChartAreaInteractive
|
|
207
|
+
disableHistoricalAnimation
|
|
208
|
+
disableTimeRangeSelector
|
|
209
|
+
enableTimeRangeBrush
|
|
210
|
+
chartContainerClassName={S.chartContainer}
|
|
211
|
+
chartData={driversComparisonChartData}
|
|
212
|
+
forecastData={chartForecastData}
|
|
213
|
+
timeRange={timeRange}
|
|
214
|
+
onTimeRangeChange={handleTimeRangeChange}
|
|
215
|
+
pinMonth={undefined}
|
|
216
|
+
onPinMonthChange={() => {}}
|
|
217
|
+
isDarkTheme={isDarkTheme}
|
|
218
|
+
loading={chartLoading}
|
|
219
|
+
hasCombinedData={mergedWithHistorical.length > 0}
|
|
220
|
+
forecastLineStyle="solid"
|
|
221
|
+
showLegend={false}
|
|
222
|
+
hiddenSeries={hiddenSeries}
|
|
223
|
+
toggleLegendSeries={toggleSeries}
|
|
224
|
+
ensureAnalysisSeriesVisible={showSeries}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
{showEmptyOverlay && (
|
|
228
|
+
<div
|
|
229
|
+
className={S.chartEmptyOverlay}
|
|
230
|
+
role="status"
|
|
231
|
+
aria-live="polite"
|
|
232
|
+
>
|
|
233
|
+
<div className={S.chartEmptyBlurb}>
|
|
234
|
+
<ChartEmptyState
|
|
235
|
+
variant="inline"
|
|
236
|
+
hint={
|
|
237
|
+
runAnalysisHint
|
|
238
|
+
? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
|
|
239
|
+
: undefined
|
|
240
|
+
}
|
|
241
|
+
status={statusHint ?? undefined}
|
|
242
|
+
statusTone={statusTone}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
{loading && (
|
|
250
|
+
<div className={S.loadingLayer} aria-busy="true" aria-live="polite">
|
|
251
|
+
<div className={S.loadingMessage}>
|
|
252
|
+
<TextShimmer as="span" className={S.loadingText}>
|
|
253
|
+
Loading drivers comparison…
|
|
254
|
+
</TextShimmer>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div className={S.seriesSection}>
|
|
261
|
+
{tableSeriesRows.length === 0 ? (
|
|
262
|
+
<div className={S.seriesEmptyWrap}>
|
|
263
|
+
<div className={S.chartEmptyBlurb}>
|
|
264
|
+
<ChartEmptyState
|
|
265
|
+
variant="inline"
|
|
266
|
+
align="center"
|
|
267
|
+
status="No series"
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
) : (
|
|
272
|
+
<div className={S.seriesTableWrapper}>
|
|
273
|
+
<PageXScroll
|
|
274
|
+
size="md"
|
|
275
|
+
fullWidth
|
|
276
|
+
innerClassName={S.seriesTableContainer}
|
|
277
|
+
scrollbarClassName={S.seriesScrollbar}
|
|
278
|
+
>
|
|
279
|
+
<Table withBackground withPaddings className={S.seriesTable}>
|
|
280
|
+
<TableHeader>
|
|
281
|
+
<TableRow>
|
|
282
|
+
<TableHead className={S.seriesColSeries}>
|
|
283
|
+
Driver name
|
|
284
|
+
</TableHead>
|
|
285
|
+
<TableHead>Importance</TableHead>
|
|
286
|
+
<TableHead>Lag</TableHead>
|
|
287
|
+
</TableRow>
|
|
288
|
+
</TableHeader>
|
|
289
|
+
<TableBody>
|
|
290
|
+
{tableSeriesRows.map(row => {
|
|
291
|
+
const dataKey = toForecastDataKey(row.id);
|
|
292
|
+
const hidden = hiddenSeries.has(dataKey);
|
|
293
|
+
return (
|
|
294
|
+
<TableRow
|
|
295
|
+
key={row.id}
|
|
296
|
+
className={cn(hidden && S.rowHidden)}
|
|
297
|
+
onClick={() => toggleSeries(row.id)}
|
|
298
|
+
>
|
|
299
|
+
<TableCell>
|
|
300
|
+
<span className={S.seriesLabel}>
|
|
301
|
+
<span
|
|
302
|
+
className={S.colorSwatch}
|
|
303
|
+
style={{
|
|
304
|
+
backgroundColor: row.color,
|
|
305
|
+
}}
|
|
306
|
+
/>
|
|
307
|
+
{row.name ?? String(row.id)}
|
|
308
|
+
</span>
|
|
309
|
+
</TableCell>
|
|
310
|
+
<TableCell>
|
|
311
|
+
{formatSeriesImportance(row.importance)}
|
|
312
|
+
</TableCell>
|
|
313
|
+
<TableCell>{row.lag ?? '—'}</TableCell>
|
|
314
|
+
</TableRow>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
317
|
+
</TableBody>
|
|
318
|
+
</Table>
|
|
319
|
+
</PageXScroll>
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
2
|
+
import {
|
|
3
|
+
getNextMonth,
|
|
4
|
+
getPreviousMonth,
|
|
5
|
+
normalizeToMonthStart,
|
|
6
|
+
} from '#uilib/utils/chartConnectionPoint';
|
|
7
|
+
import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
|
|
8
|
+
|
|
9
|
+
export const DRIVER_FORECAST_ID_BASE = 8_000_000;
|
|
10
|
+
export const INITIAL_VISIBLE_SERIES_COUNT = 3;
|
|
11
|
+
/** Months of historical context before the earliest forecast month (inclusive). */
|
|
12
|
+
export const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
|
|
13
|
+
|
|
14
|
+
/** Ignore leading months where drivers are numerically zero (API often fills 0; lines look empty until real signal). */
|
|
15
|
+
const DRIVER_FORECAST_NONZERO_EPS = 1e-9;
|
|
16
|
+
|
|
17
|
+
export function formatSeriesImportance(value: number | null): string {
|
|
18
|
+
if (value === null) return '—';
|
|
19
|
+
return `${value.toFixed(1)}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mergeBacktestsChartData(
|
|
23
|
+
payload: BacktestsComponentPayload | null,
|
|
24
|
+
): ChartDataPoint[] {
|
|
25
|
+
if (!payload?.target?.normalized_series) return [];
|
|
26
|
+
|
|
27
|
+
const norm = (d: string) => normalizeToMonthStart(d);
|
|
28
|
+
const map = new Map<string, ChartDataPoint>();
|
|
29
|
+
|
|
30
|
+
const targetSeries = payload.target.normalized_series;
|
|
31
|
+
Object.entries(targetSeries).forEach(([dateStr, val]) => {
|
|
32
|
+
if (val === null || val === undefined) return;
|
|
33
|
+
const k = norm(dateStr);
|
|
34
|
+
const existing = map.get(k) ?? { date: k };
|
|
35
|
+
existing.historical = val;
|
|
36
|
+
map.set(k, existing);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (payload.drivers?.length) {
|
|
40
|
+
const sorted = [...payload.drivers]
|
|
41
|
+
.filter(
|
|
42
|
+
d =>
|
|
43
|
+
d.normalized_series &&
|
|
44
|
+
Object.keys(d.normalized_series).some(
|
|
45
|
+
key => d.normalized_series![key] != null,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
49
|
+
|
|
50
|
+
sorted.forEach((driver, idx) => {
|
|
51
|
+
const fid = DRIVER_FORECAST_ID_BASE + idx;
|
|
52
|
+
const series = driver.normalized_series!;
|
|
53
|
+
Object.entries(series).forEach(([dateStr, val]) => {
|
|
54
|
+
if (val === null || val === undefined) return;
|
|
55
|
+
const k = norm(dateStr);
|
|
56
|
+
const existing = map.get(k) ?? { date: k };
|
|
57
|
+
existing[`forecast_${fid}`] = val;
|
|
58
|
+
map.set(k, existing);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
|
|
67
|
+
export function mergeDatasetHistoricalWithBacktestsChartData(
|
|
68
|
+
datasetHistorical: ChartDataPoint[],
|
|
69
|
+
backtestsMerged: ChartDataPoint[],
|
|
70
|
+
): ChartDataPoint[] {
|
|
71
|
+
if (backtestsMerged.length === 0) {
|
|
72
|
+
if (datasetHistorical.length === 0) return [];
|
|
73
|
+
return datasetHistorical
|
|
74
|
+
.map(p => {
|
|
75
|
+
const k = normalizeToMonthStart(p.date);
|
|
76
|
+
const histVal = p.historical;
|
|
77
|
+
return {
|
|
78
|
+
date: k,
|
|
79
|
+
...(typeof histVal === 'number' ? { historical: histVal } : {}),
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
83
|
+
}
|
|
84
|
+
return backtestsMerged;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Lower-median of ISO month strings (YYYY-MM-01 sorts lexicographically). */
|
|
88
|
+
function medianSortedIsoMonth(months: string[]): string {
|
|
89
|
+
const s = [...months].sort((a, b) => a.localeCompare(b));
|
|
90
|
+
return s[Math.floor((s.length - 1) / 2)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* For each driver, first month with materially non-zero value; anchor = median of those months.
|
|
95
|
+
* (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
|
|
96
|
+
*/
|
|
97
|
+
export function getZoomAnchorMonthFromDriverMaterialStarts(
|
|
98
|
+
points: ChartDataPoint[],
|
|
99
|
+
forecastIds: number[],
|
|
100
|
+
): { anchor: string | null; perDriverFirstMaterial: string[] } {
|
|
101
|
+
const perDriverFirstMaterial: string[] = [];
|
|
102
|
+
for (const id of forecastIds) {
|
|
103
|
+
let minForId: string | null = null;
|
|
104
|
+
for (const p of points) {
|
|
105
|
+
const v = p[`forecast_${id}`];
|
|
106
|
+
if (
|
|
107
|
+
typeof v !== 'number' ||
|
|
108
|
+
!Number.isFinite(v) ||
|
|
109
|
+
Math.abs(v) <= DRIVER_FORECAST_NONZERO_EPS
|
|
110
|
+
) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const d = normalizeToMonthStart(p.date);
|
|
114
|
+
if (minForId === null || d.localeCompare(minForId) < 0) minForId = d;
|
|
115
|
+
}
|
|
116
|
+
if (minForId === null) {
|
|
117
|
+
return { anchor: null, perDriverFirstMaterial };
|
|
118
|
+
}
|
|
119
|
+
perDriverFirstMaterial.push(minForId);
|
|
120
|
+
}
|
|
121
|
+
const anchor = medianSortedIsoMonth(perDriverFirstMaterial);
|
|
122
|
+
return { anchor, perDriverFirstMaterial };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function subtractMonthsFromMonthStart(
|
|
126
|
+
date: string,
|
|
127
|
+
count: number,
|
|
128
|
+
): string {
|
|
129
|
+
let result = date;
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
result = getPreviousMonth(result);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Backtests payload often starts at the same month for target + drivers, so xMin falls
|
|
138
|
+
* in a range with no rows. Prepend historical-only months from raw dataset series
|
|
139
|
+
* (scaled to the first normalized point) so the lead-in shows the target line alone.
|
|
140
|
+
*/
|
|
141
|
+
export function prependHistoricalLeadFromDataset(
|
|
142
|
+
points: ChartDataPoint[],
|
|
143
|
+
datasetHistorical: ChartDataPoint[],
|
|
144
|
+
xMin: string,
|
|
145
|
+
): ChartDataPoint[] {
|
|
146
|
+
if (points.length === 0) return points;
|
|
147
|
+
const firstMonth = normalizeToMonthStart(points[0].date);
|
|
148
|
+
if (xMin.localeCompare(firstMonth) >= 0) return points;
|
|
149
|
+
|
|
150
|
+
const rawByMonth = new Map<string, number>();
|
|
151
|
+
for (const p of datasetHistorical) {
|
|
152
|
+
const k = normalizeToMonthStart(p.date);
|
|
153
|
+
const v = p.historical;
|
|
154
|
+
if (typeof v === 'number' && Number.isFinite(v)) rawByMonth.set(k, v);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const histFirst = points[0].historical;
|
|
158
|
+
const rawFirst = rawByMonth.get(firstMonth);
|
|
159
|
+
if (
|
|
160
|
+
typeof histFirst !== 'number' ||
|
|
161
|
+
!Number.isFinite(histFirst) ||
|
|
162
|
+
typeof rawFirst !== 'number' ||
|
|
163
|
+
!Number.isFinite(rawFirst) ||
|
|
164
|
+
Math.abs(rawFirst) <= 1e-15
|
|
165
|
+
) {
|
|
166
|
+
return points;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const lead: ChartDataPoint[] = [];
|
|
170
|
+
let m = xMin;
|
|
171
|
+
while (m.localeCompare(firstMonth) < 0) {
|
|
172
|
+
const rawM = rawByMonth.get(m);
|
|
173
|
+
if (typeof rawM === 'number' && Number.isFinite(rawM)) {
|
|
174
|
+
lead.push({
|
|
175
|
+
date: m,
|
|
176
|
+
historical: histFirst * (rawM / rawFirst),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
m = getNextMonth(m);
|
|
180
|
+
}
|
|
181
|
+
return [...lead, ...points];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildDriversComparisonChartData(
|
|
185
|
+
mergedWithHistorical: ChartDataPoint[],
|
|
186
|
+
datasetHistorical: ChartDataPoint[],
|
|
187
|
+
forecastIds: number[],
|
|
188
|
+
): ChartDataPoint[] {
|
|
189
|
+
if (mergedWithHistorical.length === 0) return mergedWithHistorical;
|
|
190
|
+
if (forecastIds.length === 0) return mergedWithHistorical;
|
|
191
|
+
const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(
|
|
192
|
+
mergedWithHistorical,
|
|
193
|
+
forecastIds,
|
|
194
|
+
);
|
|
195
|
+
if (anchorMonth === null) return mergedWithHistorical;
|
|
196
|
+
const xMin = subtractMonthsFromMonthStart(
|
|
197
|
+
anchorMonth,
|
|
198
|
+
DRIVER_COMPARISON_CHART_LEAD_MONTHS,
|
|
199
|
+
);
|
|
200
|
+
const withLead = prependHistoricalLeadFromDataset(
|
|
201
|
+
mergedWithHistorical,
|
|
202
|
+
datasetHistorical,
|
|
203
|
+
xMin,
|
|
204
|
+
);
|
|
205
|
+
return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
|
|
206
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DriversComparisonChart,
|
|
3
|
+
type DriversComparisonChartProps,
|
|
4
|
+
} from './DriversComparisonChart';
|
|
5
|
+
export {
|
|
6
|
+
buildDriversComparisonChartData,
|
|
7
|
+
DRIVER_COMPARISON_CHART_LEAD_MONTHS,
|
|
8
|
+
DRIVER_FORECAST_ID_BASE,
|
|
9
|
+
formatSeriesImportance,
|
|
10
|
+
INITIAL_VISIBLE_SERIES_COUNT,
|
|
11
|
+
mergeBacktestsChartData,
|
|
12
|
+
mergeDatasetHistoricalWithBacktestsChartData,
|
|
13
|
+
} from './driversComparisonChart.helpers';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
4
|
+
import { PageContentSection } from '#uilib/components/ui/Page';
|
|
5
|
+
import { Switch } from '#uilib/components/ui/Switch';
|
|
6
|
+
import { TIME_RANGES } from '#uilib/components/ui/TimeRangeControls/TimeRangeControls.constants';
|
|
7
|
+
import { DriversComparisonChart } from '#uilib/components/widgets/DriversComparisonChart';
|
|
8
|
+
import { useTheme } from '#uilib/contexts/theme-context';
|
|
9
|
+
import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
|
|
10
|
+
|
|
11
|
+
import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
|
|
12
|
+
import { DocsHeaderActions } from '../docsHeaderActions';
|
|
13
|
+
|
|
14
|
+
const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
|
|
15
|
+
|
|
16
|
+
function monthKey(year: number, month: number): string {
|
|
17
|
+
return `${year}-${String(month).padStart(2, '0')}-01`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildMonthlySeries(
|
|
21
|
+
startYear: number,
|
|
22
|
+
startMonth: number,
|
|
23
|
+
count: number,
|
|
24
|
+
base: number,
|
|
25
|
+
drift = 0.08,
|
|
26
|
+
phase = 0,
|
|
27
|
+
): Record<string, number> {
|
|
28
|
+
const out: Record<string, number> = {};
|
|
29
|
+
let y = startYear;
|
|
30
|
+
let m = startMonth;
|
|
31
|
+
for (let i = 0; i < count; i++) {
|
|
32
|
+
out[monthKey(y, m)] = base + Math.sin(i * 0.4 + phase) * 0.35 + i * drift;
|
|
33
|
+
m += 1;
|
|
34
|
+
if (m > 12) {
|
|
35
|
+
m = 1;
|
|
36
|
+
y += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildTargetSeries(): Record<string, number> {
|
|
43
|
+
return buildMonthlySeries(2018, 1, 48, 1.0, 0.012, 0.2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MOCK_PAYLOAD: BacktestsComponentPayload = {
|
|
47
|
+
target: {
|
|
48
|
+
id: 'target',
|
|
49
|
+
name: 'Normalized target',
|
|
50
|
+
normalized_series: buildTargetSeries(),
|
|
51
|
+
},
|
|
52
|
+
drivers: [
|
|
53
|
+
{
|
|
54
|
+
id: 'driver-us-pmi',
|
|
55
|
+
name: 'U.S. manufacturing PMI composite',
|
|
56
|
+
importance: 84.2,
|
|
57
|
+
lag: '1 quarter(s)',
|
|
58
|
+
normalized_series: buildMonthlySeries(2019, 4, 42, 0.45, 0.015, 0),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'driver-eu-orders',
|
|
62
|
+
name: 'German industrial orders (domestic + foreign)',
|
|
63
|
+
importance: 81.6,
|
|
64
|
+
lag: '2 month(s)',
|
|
65
|
+
normalized_series: buildMonthlySeries(2019, 1, 42, 0.52, -0.006, 1.1),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'driver-jp-machinery',
|
|
69
|
+
name: 'Japan machinery orders (core private-sector)',
|
|
70
|
+
importance: 77,
|
|
71
|
+
lag: '~1 month(s)',
|
|
72
|
+
normalized_series: buildMonthlySeries(2019, 7, 42, 0.38, 0.01, 2.4),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'driver-global-risk',
|
|
76
|
+
name: 'Global equity risk appetite composite',
|
|
77
|
+
importance: 66.5,
|
|
78
|
+
lag: 'Unknown',
|
|
79
|
+
normalized_series: buildMonthlySeries(2018, 10, 42, 0.41, 0.004, 3.8),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'driver-china-credit',
|
|
83
|
+
name: 'China aggregate financing impulse',
|
|
84
|
+
importance: 58.3,
|
|
85
|
+
lag: '3 month(s)',
|
|
86
|
+
normalized_series: buildMonthlySeries(2020, 1, 36, 0.29, 0.018, 4.2),
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function buildDatasetHistorical(): ChartDataPoint[] {
|
|
92
|
+
const raw = buildMonthlySeries(2017, 7, 54, 112, 0.9, 0.5);
|
|
93
|
+
return Object.entries(raw).map(([date, historical]) => ({
|
|
94
|
+
date,
|
|
95
|
+
historical,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default function DriversComparisonChartPage() {
|
|
100
|
+
const { isDarkMode } = useTheme();
|
|
101
|
+
const [loading, setLoading] = useState(false);
|
|
102
|
+
const [runAnalysisHint, setRunAnalysisHint] = useState(false);
|
|
103
|
+
const [emptyPayload, setEmptyPayload] = useState(false);
|
|
104
|
+
const [timeRange, setTimeRange] = useState<string>(ALL_TIME_RANGE);
|
|
105
|
+
|
|
106
|
+
const payload = useMemo(
|
|
107
|
+
() => (emptyPayload ? null : MOCK_PAYLOAD),
|
|
108
|
+
[emptyPayload],
|
|
109
|
+
);
|
|
110
|
+
const datasetHistorical = useMemo(() => buildDatasetHistorical(), []);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<AppPageHeader
|
|
115
|
+
breadcrumbs={[{ label: 'Drivers comparison chart' }]}
|
|
116
|
+
title="DriversComparisonChart"
|
|
117
|
+
subheader="Normalized target line with driver series; table rows toggle chart visibility."
|
|
118
|
+
actions={<DocsHeaderActions />}
|
|
119
|
+
/>
|
|
120
|
+
<PageContentSection>
|
|
121
|
+
<div
|
|
122
|
+
style={{
|
|
123
|
+
display: 'flex',
|
|
124
|
+
flexWrap: 'wrap',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
gap: 16,
|
|
127
|
+
marginBottom: 16,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
131
|
+
<Switch
|
|
132
|
+
id="loading-overlay"
|
|
133
|
+
checked={loading}
|
|
134
|
+
onCheckedChange={setLoading}
|
|
135
|
+
/>
|
|
136
|
+
<label htmlFor="loading-overlay">Loading overlay</label>
|
|
137
|
+
</div>
|
|
138
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
139
|
+
<Switch
|
|
140
|
+
id="run-analysis-hint"
|
|
141
|
+
checked={runAnalysisHint}
|
|
142
|
+
onCheckedChange={setRunAnalysisHint}
|
|
143
|
+
/>
|
|
144
|
+
<label htmlFor="run-analysis-hint">Run analysis hint</label>
|
|
145
|
+
</div>
|
|
146
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
147
|
+
<Switch
|
|
148
|
+
id="empty-payload"
|
|
149
|
+
checked={emptyPayload}
|
|
150
|
+
onCheckedChange={setEmptyPayload}
|
|
151
|
+
/>
|
|
152
|
+
<label htmlFor="empty-payload">Empty payload</label>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<DriversComparisonChart
|
|
156
|
+
payload={payload}
|
|
157
|
+
datasetHistorical={datasetHistorical}
|
|
158
|
+
loading={loading}
|
|
159
|
+
chartLoading={false}
|
|
160
|
+
runAnalysisHint={runAnalysisHint}
|
|
161
|
+
statusHint={
|
|
162
|
+
emptyPayload
|
|
163
|
+
? 'No normalized driver series in the response yet.'
|
|
164
|
+
: null
|
|
165
|
+
}
|
|
166
|
+
timeRange={timeRange}
|
|
167
|
+
onTimeRangeChange={setTimeRange}
|
|
168
|
+
isDarkTheme={isDarkMode}
|
|
169
|
+
seriesInitKey={emptyPayload ? 'empty' : 'mock'}
|
|
170
|
+
/>
|
|
171
|
+
</PageContentSection>
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
package/src/docs/registry.ts
CHANGED
|
@@ -145,6 +145,12 @@ export const DOC_REGISTRY: DocEntry[] = [
|
|
|
145
145
|
section: 'Widgets',
|
|
146
146
|
load: () => import('./pages/DriverMapPage'),
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
slug: 'drivers-comparison-chart',
|
|
150
|
+
title: 'DriversComparisonChart',
|
|
151
|
+
section: 'Widgets',
|
|
152
|
+
load: () => import('./pages/DriversComparisonChartPage'),
|
|
153
|
+
},
|
|
148
154
|
{
|
|
149
155
|
slug: 'dropdown-menu',
|
|
150
156
|
title: 'DropdownMenu',
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ export * from './components/ui/WorkspaceAppSwitcher';
|
|
|
73
73
|
export * from './components/widgets/SidebarDatasetsItemsGrouped';
|
|
74
74
|
export * from './components/widgets/DriverCard';
|
|
75
75
|
export * from './components/widgets/DriverMap';
|
|
76
|
+
export * from './components/widgets/DriversComparisonChart';
|
|
76
77
|
export * from './components/widgets/SybilionAppHeader';
|
|
77
78
|
export * from './components/widgets/SybilionAuthLayout';
|
|
78
79
|
export * from './components/widgets/SybilionSignInPanel';
|