@sybilion/uilib 1.3.54 → 1.3.55

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.
@@ -28,16 +28,18 @@ import { Table, TableHeader, TableRow, TableHead, TableCellValue, TableBody, Tab
28
28
  import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
29
29
  import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
30
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';
31
+ import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
32
32
 
33
33
  const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
34
34
  function toForecastDataKey(id) {
35
35
  const s = String(id);
36
36
  return s.startsWith('forecast_') ? s : `forecast_${id}`;
37
37
  }
38
- function DriversComparisonChart({ payload, datasetHistorical = [], loading = false, chartLoading = false, statusHint = null, statusTone = 'muted', runAnalysisHint = false, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme = false, className, seriesInitKey, }) {
38
+ function DriversComparisonChart({ payload, datasetHistorical = [], loading = false, chartLoading = false, statusHint = null, statusTone = 'muted', runAnalysisHint = false, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme = false, className, seriesInitKey, viewTab: viewTabProp, onViewTabChange, }) {
39
39
  const [internalTimeRange, setInternalTimeRange] = useState(ALL_TIME_RANGE);
40
+ const [internalViewTab, setInternalViewTab] = useState('overlapped');
40
41
  const [hiddenSeries, setHiddenSeries] = useState(new Set());
42
+ const viewTab = viewTabProp ?? internalViewTab;
41
43
  const timeRange = timeRangeProp ?? internalTimeRange;
42
44
  const handleTimeRangeChange = useCallback((val) => {
43
45
  if (!val)
@@ -68,42 +70,51 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
68
70
  return next;
69
71
  });
70
72
  }, []);
73
+ const payloadForView = useMemo(() => applyDriversComparisonViewToPayload(payload, viewTab), [payload, viewTab]);
71
74
  const sortedDriversWithData = useMemo(() => {
72
- const driversList = payload?.drivers ?? [];
75
+ const driversList = payloadForView?.drivers ?? [];
73
76
  if (!driversList.length)
74
77
  return [];
75
78
  return [...driversList]
76
79
  .filter(d => d.normalized_series &&
77
80
  Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
78
81
  .sort((a, b) => String(a.id).localeCompare(String(b.id)));
79
- }, [payload?.drivers]);
80
- const mergedChartData = useMemo(() => mergeBacktestsChartData(payload), [payload]);
82
+ }, [payloadForView?.drivers]);
83
+ const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
81
84
  const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
82
85
  const chartForecastData = useMemo(() => {
83
- if (!payload?.target?.normalized_series)
86
+ if (!payloadForView?.target?.normalized_series)
84
87
  return [];
85
88
  return sortedDriversWithData.map((driver, idx) => ({
86
89
  id: DRIVER_FORECAST_ID_BASE + idx,
87
90
  name: driver.name || String(driver.id),
88
91
  color: getForecastColor(idx + 1),
89
92
  }));
90
- }, [payload?.target?.normalized_series, sortedDriversWithData]);
93
+ }, [payloadForView?.target?.normalized_series, sortedDriversWithData]);
94
+ const originalDriversById = useMemo(() => {
95
+ const map = new Map();
96
+ for (const driver of payload?.drivers ?? []) {
97
+ map.set(String(driver.id), driver);
98
+ }
99
+ return map;
100
+ }, [payload?.drivers]);
91
101
  const tableSeriesRows = useMemo(() => {
92
102
  if (sortedDriversWithData.length === 0)
93
103
  return [];
94
104
  return sortedDriversWithData.map((driver, idx) => {
95
105
  const raw = driver.importance;
96
106
  const importance = typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
107
+ const lagSource = originalDriversById.get(String(driver.id)) ?? driver;
97
108
  return {
98
109
  id: DRIVER_FORECAST_ID_BASE + idx,
99
110
  name: driver.name || String(driver.id),
100
111
  color: getForecastColor(idx + 1),
101
112
  importance,
102
- lag: driver.lag,
113
+ lag: getLagDisplayForView(resolveDriverLagLabel(lagSource), viewTab),
103
114
  };
104
115
  });
105
- }, [sortedDriversWithData]);
106
- const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
116
+ }, [originalDriversById, sortedDriversWithData, viewTab]);
117
+ const seriesInitKeyResolved = `${seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`}:${viewTab}`;
107
118
  const backtestsSeriesInitKeyRef = useRef('');
108
119
  useEffect(() => {
109
120
  if (tableSeriesRows.length === 0)
@@ -125,14 +136,14 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
125
136
  }, [seriesInitKeyResolved, tableSeriesRows]);
126
137
  const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
127
138
  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
139
+ 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, { chartRenderId: `drivers-comparison-${viewTab}`, 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 }, viewTab) }), 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
140
  ? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
130
141
  : 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: jsxs(TableCellValue, { children: ["Importance", jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: jsx("span", { children: jsx(InfoIcon, { size: 16, style: { cursor: 'help' } }) }) }), jsx(TooltipContent, { side: "top", maxWidth: 300, children: "How much this driver contributes to price movements in the forecast model. Higher = stronger influence. Reflects relative weight of each driver, scored from 0% to 100%." })] })] }) }), jsx(TableHead, { children: jsxs(TableCellValue, { children: ["Lag", jsxs(Tooltip, { children: [jsx(TooltipTrigger, { asChild: true, children: jsx("span", { children: jsx(InfoIcon, { size: 16, style: { cursor: 'help' } }) }) }), jsx(TooltipContent, { side: "top", maxWidth: 300, children: "Lag shows how far ahead this driver predicts price movement. A range (e.g. 9\u201312 months) means the predictive signal is strongest somewhere in that window \u2014 not at a single fixed point." })] })] }) })] }) }), jsx(TableBody, { children: tableSeriesRows.map(row => {
131
142
  const dataKey = toForecastDataKey(row.id);
132
143
  const hidden = hiddenSeries.has(dataKey);
133
144
  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
145
  backgroundColor: row.color,
135
- } }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag ?? '—' })] }, row.id));
146
+ } }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag })] }, row.id));
136
147
  }) })] }) }) })) })] }));
137
148
  }
138
149
 
@@ -1,4 +1,4 @@
1
- import { normalizeToMonthStart, getPreviousMonth, getNextMonth } from '../../../utils/chartConnectionPoint.js';
1
+ import { normalizeToMonthStart, getNextMonth, getPreviousMonth } from '../../../utils/chartConnectionPoint.js';
2
2
 
3
3
  const DRIVER_FORECAST_ID_BASE = 8_000_000;
4
4
  const INITIAL_VISIBLE_SERIES_COUNT = 3;
@@ -11,6 +11,95 @@ function formatSeriesImportance(value) {
11
11
  return '—';
12
12
  return `${value.toFixed(1)}%`;
13
13
  }
14
+ function resolveDriverLagLabel(driver) {
15
+ if (typeof driver.lag === 'string' && driver.lag.trim()) {
16
+ return driver.lag;
17
+ }
18
+ const raw = driver;
19
+ const overallLag = raw.overall_lag ?? raw.overallLag;
20
+ if (typeof overallLag === 'string' && overallLag.trim()) {
21
+ return overallLag;
22
+ }
23
+ return null;
24
+ }
25
+ /** Parse lag label to month count; ranges use the upper bound. */
26
+ function parseLagMonthsFromLabel(lag) {
27
+ if (lag == null)
28
+ return null;
29
+ const trimmed = lag.trim();
30
+ if (!trimmed)
31
+ return null;
32
+ const lower = trimmed.toLowerCase().replace(/\u2013|\u2014/g, '-');
33
+ if (lower === 'unknown' || lower === 'no lag' || lower.includes('no lag')) {
34
+ return null;
35
+ }
36
+ const rangeMatch = lower.match(/(\d+)\s*(?:to|-)\s*(\d+)\s*months?/);
37
+ if (rangeMatch) {
38
+ const a = parseInt(rangeMatch[1], 10);
39
+ const b = parseInt(rangeMatch[2], 10);
40
+ if (Number.isFinite(a) && Number.isFinite(b))
41
+ return Math.max(a, b);
42
+ }
43
+ const quarterMatch = lower.match(/~?\s*(\d+)\s*quarter/);
44
+ if (quarterMatch) {
45
+ const q = parseInt(quarterMatch[1], 10);
46
+ if (Number.isFinite(q) && q > 0)
47
+ return q * 3;
48
+ }
49
+ const monthMatch = lower.match(/~?\s*(\d+)\s*months?/);
50
+ if (monthMatch) {
51
+ const m = parseInt(monthMatch[1], 10);
52
+ if (Number.isFinite(m) && m > 0)
53
+ return m;
54
+ }
55
+ return null;
56
+ }
57
+ function formatLagMonthsLabel(months) {
58
+ return `${months} month(s)`;
59
+ }
60
+ function getLagDisplayForView(lag, tab) {
61
+ if (tab === 'overlapped') {
62
+ return lag?.trim() ? lag : '—';
63
+ }
64
+ const months = parseLagMonthsFromLabel(lag);
65
+ return months != null ? formatLagMonthsLabel(months) : '—';
66
+ }
67
+ function shiftNormalizedSeriesForward(series, lagMonths) {
68
+ if (lagMonths <= 0)
69
+ return { ...series };
70
+ const out = {};
71
+ for (const [dateStr, val] of Object.entries(series)) {
72
+ if (val === null || val === undefined)
73
+ continue;
74
+ let shifted = normalizeToMonthStart(dateStr);
75
+ for (let i = 0; i < lagMonths; i++) {
76
+ shifted = getNextMonth(shifted);
77
+ }
78
+ out[shifted] = val;
79
+ }
80
+ return out;
81
+ }
82
+ function applyDriversComparisonViewToPayload(payload, tab) {
83
+ if (!payload || tab === 'overlapped')
84
+ return payload;
85
+ return {
86
+ target: {
87
+ ...payload.target,
88
+ normalized_series: { ...payload.target.normalized_series },
89
+ },
90
+ drivers: payload.drivers.map(driver => {
91
+ const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
92
+ const series = driver.normalized_series ?? {};
93
+ const normalized_series = lagMonths != null && lagMonths > 0
94
+ ? shiftNormalizedSeriesForward(series, lagMonths)
95
+ : { ...series };
96
+ return {
97
+ ...driver,
98
+ normalized_series,
99
+ };
100
+ }),
101
+ };
102
+ }
14
103
  function mergeBacktestsChartData(payload) {
15
104
  if (!payload?.target?.normalized_series)
16
105
  return [];
@@ -156,4 +245,4 @@ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical
156
245
  return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
157
246
  }
158
247
 
159
- export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, prependHistoricalLeadFromDataset, subtractMonthsFromMonthStart };
248
+ export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
package/dist/esm/index.js CHANGED
@@ -108,7 +108,7 @@ export { getCategoryIcon } from './components/widgets/DriverMap/driverCategoryIc
108
108
  export { getDriverImportance, getHighestImportanceDriver } from './components/widgets/DriverMap/driverMapSelection.js';
109
109
  export { geographicCoordinates, geographicToSVG, getContinentFromRegion, getPreciseCoordinates, getResponsiveCoordinates, svgToPercentage } from './components/widgets/DriverMap/driverMapGeography.js';
110
110
  export { DriversComparisonChart } from './components/widgets/DriversComparisonChart/DriversComparisonChart.js';
111
- 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';
111
+ export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, resolveDriverLagLabel, shiftNormalizedSeriesForward } from './components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js';
112
112
  export { PerformanceChart } from './components/widgets/PerformanceChart/PerformanceChart.js';
113
113
  export { PerformanceTable } from './components/widgets/PerformanceChart/PerformanceTable.js';
114
114
  export { SPAGHETTI_DRIFT_PER_HORIZON_ID_BASE, SPAGHETTI_MODEL_PER_HORIZON_ID_BASE, averageForecastErrorsVsHistoricalForMatrixColumn, buildDriftSpaghettiMatrixForCustomDialog, buildPerHorizonSpaghettiEntries, buildSpaghettiMergedChartData, calculateYRangeFromChartData, getForecastModelDisplayName, spaghettiGridFromHistoricalPreviousMonth } from './components/widgets/PerformanceChart/performanceChart.helpers.js';
@@ -1,5 +1,6 @@
1
1
  import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
2
  import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
3
+ import { type DriversComparisonViewTab } from './driversComparisonChart.helpers';
3
4
  export type DriversComparisonChartProps = {
4
5
  payload: BacktestsComponentPayload | null;
5
6
  datasetHistorical?: ChartDataPoint[];
@@ -14,5 +15,8 @@ export type DriversComparisonChartProps = {
14
15
  className?: string;
15
16
  /** Resets visible series when this key changes (e.g. selected analysis id). */
16
17
  seriesInitKey?: string;
18
+ viewTab?: DriversComparisonViewTab;
19
+ onViewTabChange?: (tab: DriversComparisonViewTab) => void;
17
20
  };
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;
21
+ export type { DriversComparisonViewTab } from './driversComparisonChart.helpers';
22
+ export declare function DriversComparisonChart({ payload, datasetHistorical, loading, chartLoading, statusHint, statusTone, runAnalysisHint, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme, className, seriesInitKey, viewTab: viewTabProp, onViewTabChange, }: DriversComparisonChartProps): import("react/jsx-runtime").JSX.Element;
@@ -4,7 +4,17 @@ export declare const DRIVER_FORECAST_ID_BASE = 8000000;
4
4
  export declare const INITIAL_VISIBLE_SERIES_COUNT = 3;
5
5
  /** Months of historical context before the earliest forecast month (inclusive). */
6
6
  export declare const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
7
+ export type DriversComparisonViewTab = 'overlapped' | 'lagged';
7
8
  export declare function formatSeriesImportance(value: number | null): string;
9
+ export declare function resolveDriverLagLabel(driver: {
10
+ lag?: string | null;
11
+ }): string | null;
12
+ /** Parse lag label to month count; ranges use the upper bound. */
13
+ export declare function parseLagMonthsFromLabel(lag: string | null | undefined): number | null;
14
+ export declare function formatLagMonthsLabel(months: number): string;
15
+ export declare function getLagDisplayForView(lag: string | null | undefined, tab: DriversComparisonViewTab): string;
16
+ export declare function shiftNormalizedSeriesForward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
17
+ export declare function applyDriversComparisonViewToPayload(payload: BacktestsComponentPayload | null, tab: DriversComparisonViewTab): BacktestsComponentPayload | null;
8
18
  export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
9
19
  /** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
10
20
  export declare function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical: ChartDataPoint[], backtestsMerged: ChartDataPoint[]): ChartDataPoint[];
@@ -1,2 +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';
1
+ export { DriversComparisonChart, type DriversComparisonChartProps, type DriversComparisonViewTab, } from './DriversComparisonChart';
2
+ export { applyDriversComparisonViewToPayload, buildDriversComparisonChartData, DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, shiftNormalizedSeriesForward, } from './driversComparisonChart.helpers';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.54",
3
+ "version": "1.3.55",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -10,9 +10,14 @@ Host provides:
10
10
  - `payload`: BacktestsComponentPayload from platform SDK (host fetch per analysis)
11
11
  - Optional `datasetHistorical` overlay
12
12
  - `seriesInitKey` when selected analysis changes
13
+ - `viewTab` / `onViewTabChange`: `overlapped` (calendar-aligned) or `lagged` (driver series shifted forward by parsed lag months)
14
+
15
+ View tabs: host should render uilib `Tabs variant="button"` with **Lagged** / **Overlapped** in the toolbar (analysis selector left, tabs right). Chart applies `applyDriversComparisonViewToPayload` internally.
13
16
 
14
17
  Report tile: `drivers_comparison_chart` — host loads normalized backtests payload + dataset historical; built-in analysis selector.
15
18
 
16
- Requires: `payload` — target + driver normalized_series; `loading` / `chartLoading` — spinners; `seriesInitKey` — reset visible series on analysis change; `runAnalysisHint` / `statusHint` — empty/error text.
19
+ Requires: `payload` — target + driver normalized_series; `loading` / `chartLoading` — spinners; `seriesInitKey` — reset visible series on analysis or view tab change; `runAnalysisHint` / `statusHint` — empty/error text.
20
+
21
+ Lag column: **Overlapped** shows API `lag` string (may be a range). **Lagged** shows single `N month(s)` from `parseLagMonthsFromLabel` (range uses max month).
17
22
 
18
23
  Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
@@ -31,11 +31,15 @@ import { InfoIcon } from 'lucide-react';
31
31
  import S from './DriversComparisonChart.styl';
32
32
  import {
33
33
  DRIVER_FORECAST_ID_BASE,
34
+ type DriversComparisonViewTab,
34
35
  INITIAL_VISIBLE_SERIES_COUNT,
36
+ applyDriversComparisonViewToPayload,
35
37
  buildDriversComparisonChartData,
36
38
  formatSeriesImportance,
39
+ getLagDisplayForView,
37
40
  mergeBacktestsChartData,
38
41
  mergeDatasetHistoricalWithBacktestsChartData,
42
+ resolveDriverLagLabel,
39
43
  } from './driversComparisonChart.helpers';
40
44
 
41
45
  const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
@@ -59,8 +63,12 @@ export type DriversComparisonChartProps = {
59
63
  className?: string;
60
64
  /** Resets visible series when this key changes (e.g. selected analysis id). */
61
65
  seriesInitKey?: string;
66
+ viewTab?: DriversComparisonViewTab;
67
+ onViewTabChange?: (tab: DriversComparisonViewTab) => void;
62
68
  };
63
69
 
70
+ export type { DriversComparisonViewTab } from './driversComparisonChart.helpers';
71
+
64
72
  export function DriversComparisonChart({
65
73
  payload,
66
74
  datasetHistorical = [],
@@ -74,11 +82,17 @@ export function DriversComparisonChart({
74
82
  isDarkTheme = false,
75
83
  className,
76
84
  seriesInitKey,
85
+ viewTab: viewTabProp,
86
+ onViewTabChange,
77
87
  }: DriversComparisonChartProps) {
78
88
  const [internalTimeRange, setInternalTimeRange] =
79
89
  useState<string>(ALL_TIME_RANGE);
90
+ const [internalViewTab, setInternalViewTab] =
91
+ useState<DriversComparisonViewTab>('overlapped');
80
92
  const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
81
93
 
94
+ const viewTab = viewTabProp ?? internalViewTab;
95
+
82
96
  const timeRange = timeRangeProp ?? internalTimeRange;
83
97
  const handleTimeRangeChange = useCallback(
84
98
  (val: string) => {
@@ -111,8 +125,13 @@ export function DriversComparisonChart({
111
125
  });
112
126
  }, []);
113
127
 
128
+ const payloadForView = useMemo(
129
+ () => applyDriversComparisonViewToPayload(payload, viewTab),
130
+ [payload, viewTab],
131
+ );
132
+
114
133
  const sortedDriversWithData = useMemo(() => {
115
- const driversList = payload?.drivers ?? [];
134
+ const driversList = payloadForView?.drivers ?? [];
116
135
  if (!driversList.length) return [];
117
136
  return [...driversList]
118
137
  .filter(
@@ -123,11 +142,11 @@ export function DriversComparisonChart({
123
142
  ),
124
143
  )
125
144
  .sort((a, b) => String(a.id).localeCompare(String(b.id)));
126
- }, [payload?.drivers]);
145
+ }, [payloadForView?.drivers]);
127
146
 
128
147
  const mergedChartData = useMemo(
129
- () => mergeBacktestsChartData(payload),
130
- [payload],
148
+ () => mergeBacktestsChartData(payloadForView),
149
+ [payloadForView],
131
150
  );
132
151
 
133
152
  const mergedWithHistorical = useMemo(
@@ -140,13 +159,21 @@ export function DriversComparisonChart({
140
159
  );
141
160
 
142
161
  const chartForecastData = useMemo((): ForecastItemData[] => {
143
- if (!payload?.target?.normalized_series) return [];
162
+ if (!payloadForView?.target?.normalized_series) return [];
144
163
  return sortedDriversWithData.map((driver, idx) => ({
145
164
  id: DRIVER_FORECAST_ID_BASE + idx,
146
165
  name: driver.name || String(driver.id),
147
166
  color: getForecastColor(idx + 1),
148
167
  }));
149
- }, [payload?.target?.normalized_series, sortedDriversWithData]);
168
+ }, [payloadForView?.target?.normalized_series, sortedDriversWithData]);
169
+
170
+ const originalDriversById = useMemo(() => {
171
+ const map = new Map<string, (typeof sortedDriversWithData)[number]>();
172
+ for (const driver of payload?.drivers ?? []) {
173
+ map.set(String(driver.id), driver);
174
+ }
175
+ return map;
176
+ }, [payload?.drivers]);
150
177
 
151
178
  const tableSeriesRows = useMemo(() => {
152
179
  if (sortedDriversWithData.length === 0) return [];
@@ -154,18 +181,18 @@ export function DriversComparisonChart({
154
181
  const raw = driver.importance;
155
182
  const importance =
156
183
  typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
184
+ const lagSource = originalDriversById.get(String(driver.id)) ?? driver;
157
185
  return {
158
186
  id: DRIVER_FORECAST_ID_BASE + idx,
159
187
  name: driver.name || String(driver.id),
160
188
  color: getForecastColor(idx + 1),
161
189
  importance,
162
- lag: driver.lag,
190
+ lag: getLagDisplayForView(resolveDriverLagLabel(lagSource), viewTab),
163
191
  };
164
192
  });
165
- }, [sortedDriversWithData]);
193
+ }, [originalDriversById, sortedDriversWithData, viewTab]);
166
194
 
167
- const seriesInitKeyResolved =
168
- seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
195
+ const seriesInitKeyResolved = `${seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`}:${viewTab}`;
169
196
 
170
197
  const backtestsSeriesInitKeyRef = useRef<string>('');
171
198
 
@@ -211,6 +238,8 @@ export function DriversComparisonChart({
211
238
  )}
212
239
  >
213
240
  <ChartAreaInteractive
241
+ key={viewTab}
242
+ chartRenderId={`drivers-comparison-${viewTab}`}
214
243
  disableHistoricalAnimation
215
244
  disableTimeRangeSelector
216
245
  enableTimeRangeBrush
@@ -351,7 +380,7 @@ export function DriversComparisonChart({
351
380
  <TableCell>
352
381
  {formatSeriesImportance(row.importance)}
353
382
  </TableCell>
354
- <TableCell>{row.lag ?? '—'}</TableCell>
383
+ <TableCell>{row.lag}</TableCell>
355
384
  </TableRow>
356
385
  );
357
386
  })}
@@ -0,0 +1,135 @@
1
+ import {
2
+ applyDriversComparisonViewToPayload,
3
+ formatLagMonthsLabel,
4
+ getLagDisplayForView,
5
+ parseLagMonthsFromLabel,
6
+ resolveDriverLagLabel,
7
+ shiftNormalizedSeriesForward,
8
+ } from './driversComparisonChart.helpers';
9
+
10
+ describe('parseLagMonthsFromLabel', () => {
11
+ it('uses max month for ranges', () => {
12
+ expect(parseLagMonthsFromLabel('9 to 12 month(s)')).toBe(12);
13
+ });
14
+
15
+ it('parses single month labels', () => {
16
+ expect(parseLagMonthsFromLabel('2 month(s)')).toBe(2);
17
+ expect(parseLagMonthsFromLabel('~1 month(s)')).toBe(1);
18
+ });
19
+
20
+ it('converts quarters to months', () => {
21
+ expect(parseLagMonthsFromLabel('1 quarter(s)')).toBe(3);
22
+ });
23
+
24
+ it('returns null for unknown or empty labels', () => {
25
+ expect(parseLagMonthsFromLabel('Unknown')).toBeNull();
26
+ expect(parseLagMonthsFromLabel('no lag')).toBeNull();
27
+ expect(parseLagMonthsFromLabel('')).toBeNull();
28
+ expect(parseLagMonthsFromLabel(null)).toBeNull();
29
+ });
30
+
31
+ it('parses en-dash month ranges', () => {
32
+ expect(parseLagMonthsFromLabel('9–12 month(s)')).toBe(12);
33
+ });
34
+ });
35
+
36
+ describe('resolveDriverLagLabel', () => {
37
+ it('reads lag or overall_lag', () => {
38
+ expect(resolveDriverLagLabel({ lag: '2 month(s)' })).toBe('2 month(s)');
39
+ expect(resolveDriverLagLabel({ overall_lag: '3 month(s)' })).toBe(
40
+ '3 month(s)',
41
+ );
42
+ });
43
+ });
44
+
45
+ describe('formatLagMonthsLabel', () => {
46
+ it('formats month count', () => {
47
+ expect(formatLagMonthsLabel(12)).toBe('12 month(s)');
48
+ });
49
+ });
50
+
51
+ describe('getLagDisplayForView', () => {
52
+ it('shows API lag on overlapped tab', () => {
53
+ expect(getLagDisplayForView('9 to 12 month(s)', 'overlapped')).toBe(
54
+ '9 to 12 month(s)',
55
+ );
56
+ });
57
+
58
+ it('shows parsed month on lagged tab', () => {
59
+ expect(getLagDisplayForView('9 to 12 month(s)', 'lagged')).toBe(
60
+ '12 month(s)',
61
+ );
62
+ expect(getLagDisplayForView('Unknown', 'lagged')).toBe('—');
63
+ });
64
+ });
65
+
66
+ describe('shiftNormalizedSeriesForward', () => {
67
+ it('moves points forward by lag months', () => {
68
+ const shifted = shiftNormalizedSeriesForward(
69
+ { '2015-01-01': 1.2, '2015-02-01': 1.4 },
70
+ 2,
71
+ );
72
+ expect(shifted['2015-03-01']).toBe(1.2);
73
+ expect(shifted['2015-04-01']).toBe(1.4);
74
+ });
75
+
76
+ it('returns a shallow copy when lag is zero', () => {
77
+ const series = { '2015-01-01': 1 };
78
+ const shifted = shiftNormalizedSeriesForward(series, 0);
79
+ expect(shifted).toEqual(series);
80
+ expect(shifted).not.toBe(series);
81
+ });
82
+ });
83
+
84
+ describe('applyDriversComparisonViewToPayload', () => {
85
+ const payload = {
86
+ target: {
87
+ id: 'target',
88
+ name: 'Target',
89
+ normalized_series: { '2015-01-01': 1 },
90
+ },
91
+ drivers: [
92
+ {
93
+ id: 'd1',
94
+ name: 'Driver',
95
+ lag: '2 month(s)',
96
+ normalized_series: { '2015-01-01': 0.5 },
97
+ },
98
+ ],
99
+ };
100
+
101
+ it('returns original payload for overlapped tab', () => {
102
+ expect(applyDriversComparisonViewToPayload(payload, 'overlapped')).toBe(
103
+ payload,
104
+ );
105
+ });
106
+
107
+ it('shifts driver series for lagged tab without mutating source', () => {
108
+ const lagged = applyDriversComparisonViewToPayload(payload, 'lagged');
109
+ expect(lagged).not.toBe(payload);
110
+ expect(payload.drivers[0].normalized_series['2015-01-01']).toBe(0.5);
111
+ expect(lagged?.drivers[0].normalized_series['2015-03-01']).toBe(0.5);
112
+ expect(lagged?.target.normalized_series).toEqual(
113
+ payload.target.normalized_series,
114
+ );
115
+ });
116
+
117
+ it('shifts using overall_lag when lag is absent', () => {
118
+ const withOverallLag = {
119
+ ...payload,
120
+ drivers: [
121
+ {
122
+ id: 'd2',
123
+ name: 'Driver 2',
124
+ overall_lag: '1 month(s)',
125
+ normalized_series: { '2015-06-01': 0.8 },
126
+ },
127
+ ],
128
+ };
129
+ const lagged = applyDriversComparisonViewToPayload(
130
+ withOverallLag,
131
+ 'lagged',
132
+ );
133
+ expect(lagged?.drivers[0].normalized_series['2015-07-01']).toBe(0.8);
134
+ });
135
+ });
@@ -11,6 +11,8 @@ export const INITIAL_VISIBLE_SERIES_COUNT = 3;
11
11
  /** Months of historical context before the earliest forecast month (inclusive). */
12
12
  export const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
13
13
 
14
+ export type DriversComparisonViewTab = 'overlapped' | 'lagged';
15
+
14
16
  /** Ignore leading months where drivers are numerically zero (API often fills 0; lines look empty until real signal). */
15
17
  const DRIVER_FORECAST_NONZERO_EPS = 1e-9;
16
18
 
@@ -19,6 +21,112 @@ export function formatSeriesImportance(value: number | null): string {
19
21
  return `${value.toFixed(1)}%`;
20
22
  }
21
23
 
24
+ export function resolveDriverLagLabel(driver: {
25
+ lag?: string | null;
26
+ }): string | null {
27
+ if (typeof driver.lag === 'string' && driver.lag.trim()) {
28
+ return driver.lag;
29
+ }
30
+ const raw = driver as Record<string, unknown>;
31
+ const overallLag = raw.overall_lag ?? raw.overallLag;
32
+ if (typeof overallLag === 'string' && overallLag.trim()) {
33
+ return overallLag;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Parse lag label to month count; ranges use the upper bound. */
39
+ export function parseLagMonthsFromLabel(
40
+ lag: string | null | undefined,
41
+ ): number | null {
42
+ if (lag == null) return null;
43
+ const trimmed = lag.trim();
44
+ if (!trimmed) return null;
45
+ const lower = trimmed.toLowerCase().replace(/\u2013|\u2014/g, '-');
46
+ if (lower === 'unknown' || lower === 'no lag' || lower.includes('no lag')) {
47
+ return null;
48
+ }
49
+
50
+ const rangeMatch = lower.match(/(\d+)\s*(?:to|-)\s*(\d+)\s*months?/);
51
+ if (rangeMatch) {
52
+ const a = parseInt(rangeMatch[1], 10);
53
+ const b = parseInt(rangeMatch[2], 10);
54
+ if (Number.isFinite(a) && Number.isFinite(b)) return Math.max(a, b);
55
+ }
56
+
57
+ const quarterMatch = lower.match(/~?\s*(\d+)\s*quarter/);
58
+ if (quarterMatch) {
59
+ const q = parseInt(quarterMatch[1], 10);
60
+ if (Number.isFinite(q) && q > 0) return q * 3;
61
+ }
62
+
63
+ const monthMatch = lower.match(/~?\s*(\d+)\s*months?/);
64
+ if (monthMatch) {
65
+ const m = parseInt(monthMatch[1], 10);
66
+ if (Number.isFinite(m) && m > 0) return m;
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export function formatLagMonthsLabel(months: number): string {
73
+ return `${months} month(s)`;
74
+ }
75
+
76
+ export function getLagDisplayForView(
77
+ lag: string | null | undefined,
78
+ tab: DriversComparisonViewTab,
79
+ ): string {
80
+ if (tab === 'overlapped') {
81
+ return lag?.trim() ? lag : '—';
82
+ }
83
+ const months = parseLagMonthsFromLabel(lag);
84
+ return months != null ? formatLagMonthsLabel(months) : '—';
85
+ }
86
+
87
+ export function shiftNormalizedSeriesForward(
88
+ series: Record<string, number | null>,
89
+ lagMonths: number,
90
+ ): Record<string, number | null> {
91
+ if (lagMonths <= 0) return { ...series };
92
+ const out: Record<string, number | null> = {};
93
+ for (const [dateStr, val] of Object.entries(series)) {
94
+ if (val === null || val === undefined) continue;
95
+ let shifted = normalizeToMonthStart(dateStr);
96
+ for (let i = 0; i < lagMonths; i++) {
97
+ shifted = getNextMonth(shifted);
98
+ }
99
+ out[shifted] = val;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export function applyDriversComparisonViewToPayload(
105
+ payload: BacktestsComponentPayload | null,
106
+ tab: DriversComparisonViewTab,
107
+ ): BacktestsComponentPayload | null {
108
+ if (!payload || tab === 'overlapped') return payload;
109
+
110
+ return {
111
+ target: {
112
+ ...payload.target,
113
+ normalized_series: { ...payload.target.normalized_series },
114
+ },
115
+ drivers: payload.drivers.map(driver => {
116
+ const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
117
+ const series = driver.normalized_series ?? {};
118
+ const normalized_series =
119
+ lagMonths != null && lagMonths > 0
120
+ ? shiftNormalizedSeriesForward(series, lagMonths)
121
+ : { ...series };
122
+ return {
123
+ ...driver,
124
+ normalized_series,
125
+ };
126
+ }),
127
+ };
128
+ }
129
+
22
130
  export function mergeBacktestsChartData(
23
131
  payload: BacktestsComponentPayload | null,
24
132
  ): ChartDataPoint[] {
@@ -1,13 +1,20 @@
1
1
  export {
2
2
  DriversComparisonChart,
3
3
  type DriversComparisonChartProps,
4
+ type DriversComparisonViewTab,
4
5
  } from './DriversComparisonChart';
5
6
  export {
7
+ applyDriversComparisonViewToPayload,
6
8
  buildDriversComparisonChartData,
7
9
  DRIVER_COMPARISON_CHART_LEAD_MONTHS,
8
10
  DRIVER_FORECAST_ID_BASE,
11
+ formatLagMonthsLabel,
9
12
  formatSeriesImportance,
13
+ getLagDisplayForView,
14
+ resolveDriverLagLabel,
10
15
  INITIAL_VISIBLE_SERIES_COUNT,
11
16
  mergeBacktestsChartData,
12
17
  mergeDatasetHistoricalWithBacktestsChartData,
18
+ parseLagMonthsFromLabel,
19
+ shiftNormalizedSeriesForward,
13
20
  } from './driversComparisonChart.helpers';
@@ -3,8 +3,12 @@ import { useMemo, useState } from 'react';
3
3
  import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
4
4
  import { PageContentSection } from '#uilib/components/ui/Page';
5
5
  import { Switch } from '#uilib/components/ui/Switch';
6
+ import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
6
7
  import { TIME_RANGES } from '#uilib/components/ui/TimeRangeControls/TimeRangeControls.constants';
7
- import { DriversComparisonChart } from '#uilib/components/widgets/DriversComparisonChart';
8
+ import {
9
+ DriversComparisonChart,
10
+ type DriversComparisonViewTab,
11
+ } from '#uilib/components/widgets/DriversComparisonChart';
8
12
  import { useTheme } from '#uilib/contexts/theme-context';
9
13
  import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
10
14
 
@@ -82,7 +86,7 @@ const MOCK_PAYLOAD: BacktestsComponentPayload = {
82
86
  id: 'driver-china-credit',
83
87
  name: 'China aggregate financing impulse',
84
88
  importance: 58.3,
85
- lag: '3 month(s)',
89
+ lag: '9 to 12 month(s)',
86
90
  normalized_series: buildMonthlySeries(2020, 1, 36, 0.29, 0.018, 4.2),
87
91
  },
88
92
  ],
@@ -102,6 +106,8 @@ export default function DriversComparisonChartPage() {
102
106
  const [runAnalysisHint, setRunAnalysisHint] = useState(false);
103
107
  const [emptyPayload, setEmptyPayload] = useState(false);
104
108
  const [timeRange, setTimeRange] = useState<string>(ALL_TIME_RANGE);
109
+ const [viewTab, setViewTab] =
110
+ useState<DriversComparisonViewTab>('overlapped');
105
111
 
106
112
  const payload = useMemo(
107
113
  () => (emptyPayload ? null : MOCK_PAYLOAD),
@@ -114,7 +120,7 @@ export default function DriversComparisonChartPage() {
114
120
  <AppPageHeader
115
121
  breadcrumbs={[{ label: 'Drivers comparison chart' }]}
116
122
  title="DriversComparisonChart"
117
- subheader="Normalized target line with driver series; table rows toggle chart visibility."
123
+ subheader="Normalized target line with driver series; Lagged/Overlapped tabs shift driver alignment; table rows toggle chart visibility."
118
124
  actions={<DocsHeaderActions />}
119
125
  />
120
126
  <PageContentSection>
@@ -152,6 +158,28 @@ export default function DriversComparisonChartPage() {
152
158
  <label htmlFor="empty-payload">Empty payload</label>
153
159
  </div>
154
160
  </div>
161
+ <div
162
+ style={{
163
+ display: 'flex',
164
+ justifyContent: 'flex-end',
165
+ marginBottom: 16,
166
+ }}
167
+ >
168
+ <Tabs
169
+ variant="button"
170
+ value={viewTab}
171
+ onValueChange={v => {
172
+ if (v === 'lagged' || v === 'overlapped') {
173
+ setViewTab(v);
174
+ }
175
+ }}
176
+ >
177
+ <TabsList>
178
+ <TabsTrigger value="lagged">Lagged</TabsTrigger>
179
+ <TabsTrigger value="overlapped">Overlapped</TabsTrigger>
180
+ </TabsList>
181
+ </Tabs>
182
+ </div>
155
183
  <DriversComparisonChart
156
184
  payload={payload}
157
185
  datasetHistorical={datasetHistorical}
@@ -167,6 +195,8 @@ export default function DriversComparisonChartPage() {
167
195
  onTimeRangeChange={setTimeRange}
168
196
  isDarkTheme={isDarkMode}
169
197
  seriesInitKey={emptyPayload ? 'empty' : 'mock'}
198
+ viewTab={viewTab}
199
+ onViewTabChange={setViewTab}
170
200
  />
171
201
  </PageContentSection>
172
202
  </>