@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.
@@ -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';
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.22",
3
+ "version": "1.3.23",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -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
+ }
@@ -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';