@sybilion/uilib 1.3.54 → 1.3.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +16 -8
- package/dist/esm/components/ui/Chart/components/BaseChartWrapper.styl.js +2 -2
- package/dist/esm/components/ui/ChartAreaInteractive/ChartLines.js +8 -3
- package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +24 -12
- package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +90 -2
- package/dist/esm/index.js +1 -1
- package/dist/esm/types/src/components/ui/Chart/components/BaseChartWrapper.d.ts +6 -1
- package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartLines.d.ts +2 -1
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +5 -1
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +12 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.d.ts +1 -0
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/ui/Chart/components/BaseChartWrapper.styl +5 -0
- package/src/components/ui/Chart/components/BaseChartWrapper.styl.d.ts +1 -0
- package/src/components/ui/Chart/components/BaseChartWrapper.tsx +36 -12
- package/src/components/ui/ChartAreaInteractive/ChartLines.tsx +19 -8
- package/src/components/widgets/DriversComparisonChart/AGENT.md +6 -1
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +41 -10
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +135 -0
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +109 -0
- package/src/components/widgets/DriversComparisonChart/index.ts +7 -0
- package/src/docs/pages/DriversComparisonChartPage.tsx +33 -3
|
@@ -5,16 +5,16 @@ import { getForecastColor, ChartLines } from '../../ChartAreaInteractive/ChartLi
|
|
|
5
5
|
import { Skeleton } from '../../Skeleton/Skeleton.js';
|
|
6
6
|
import { chartRenderQueue } from '../../../../utils/chartRenderQueue.js';
|
|
7
7
|
import { Tooltip, LineChart, ComposedChart } from 'recharts';
|
|
8
|
-
import { ChartContainer } from './ChartContainer.js';
|
|
9
|
-
import { ChartTooltipContent } from './ChartTooltipContent.js';
|
|
10
|
-
import { CustomChartLegend } from './CustomChartLegend/CustomChartLegend.js';
|
|
11
|
-
import { QuantileBands } from './QuantileBands.js';
|
|
12
8
|
import { resolveChartMargin, getPlotViewBox } from '../tools/chartPlotGeometry.js';
|
|
13
9
|
import { formatDate } from '../tools/formatters.js';
|
|
14
10
|
import S from './BaseChartWrapper.styl.js';
|
|
15
11
|
import { ChartAxes } from './ChartAxes.js';
|
|
12
|
+
import { ChartContainer } from './ChartContainer.js';
|
|
16
13
|
import { ChartGrid } from './ChartGrid.js';
|
|
14
|
+
import { ChartTooltipContent } from './ChartTooltipContent.js';
|
|
15
|
+
import { CustomChartLegend } from './CustomChartLegend/CustomChartLegend.js';
|
|
17
16
|
import { LegendSvg } from './LegendSvg/LegendSvg.js';
|
|
17
|
+
import { QuantileBands } from './QuantileBands.js';
|
|
18
18
|
|
|
19
19
|
function clampTooltipTranslate(args) {
|
|
20
20
|
const { coordinate, viewBox, tooltipWidth: tw, tooltipHeight: th, offset, edgeMargin, } = args;
|
|
@@ -48,8 +48,9 @@ BaseChartWrapperLoading.displayName = 'BaseChartWrapperLoading';
|
|
|
48
48
|
* Separated to maintain hook order consistency
|
|
49
49
|
*/
|
|
50
50
|
const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
51
|
-
const { chartConfig = {}, chartData, historicalLineColor, forecastData = [], loading, hasCombinedData, renderId, isDarkTheme, height, className, loadingComponentClassName, footerClassName, chartClassName, xAxisClassName, yAxisClassName, legendClassName, footerActions, quantileBands, quantileBandKey, xMin, xMax, yMin, yMax, autoScaleYAxis = true, formatNumber, formatDate: formatDateFn = formatDate, labelFormatter, onLegendClick, margin, chartType = 'composed', disableAnimation = false, showGrid = true, showAxes = true, showTooltip = true, showLegend = true, showChartAxesLegend = true, xAxisLabel, yAxisLabel, showActiveDots = true, overlayElements, hiddenSeries, excludeLegendIds, onAnalysisSelect, onFailedAnalysisClick, containerProps, error, loadingMessage, noDataMessage = 'No data available', onGridHeightChange, forecastLineStyle = 'dashed', disableHistoricalAnimation = false, onShowAll: _onShowAll, onShowOnly: _onShowOnly, maxVisibleItems, preventDeselection, legendVariant = 'default', legendWidth = 1000, legendMarginLeft = 0, } = props;
|
|
51
|
+
const { chartConfig = {}, chartData, historicalLineColor, forecastData = [], loading, hasCombinedData, renderId, isDarkTheme, height, className, loadingComponentClassName, footerClassName, chartClassName, xAxisClassName, yAxisClassName, legendClassName, footerActions, quantileBands, quantileBandKey, xMin, xMax, yMin, yMax, autoScaleYAxis = true, formatNumber, formatDate: formatDateFn = formatDate, labelFormatter, onLegendClick, margin, chartType = 'composed', disableAnimation = false, disableLineDrawAnimation = false, showGrid = true, showAxes = true, showTooltip = true, showLegend = true, showChartAxesLegend = true, xAxisLabel, yAxisLabel, showActiveDots = true, overlayElements, hiddenSeries, excludeLegendIds, onAnalysisSelect, onFailedAnalysisClick, containerProps, error, loadingMessage, noDataMessage = 'No data available', onGridHeightChange, forecastLineStyle = 'dashed', disableHistoricalAnimation = false, onShowAll: _onShowAll, onShowOnly: _onShowOnly, maxVisibleItems, preventDeselection, legendVariant = 'default', legendWidth = 1000, legendMarginLeft = 0, } = props;
|
|
52
52
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
53
|
+
const lineDataInitializedRef = useRef(false);
|
|
53
54
|
const rootRef = useRef(null);
|
|
54
55
|
const setRefs = (node) => {
|
|
55
56
|
rootRef.current = node;
|
|
@@ -238,13 +239,20 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
238
239
|
}));
|
|
239
240
|
}, [forecastData, excludeLegendIds, showLegend]);
|
|
240
241
|
useEffect(() => {
|
|
241
|
-
if (disableAnimation)
|
|
242
|
+
if (disableAnimation) {
|
|
243
|
+
setShouldAnimate(false);
|
|
242
244
|
return;
|
|
245
|
+
}
|
|
246
|
+
if (disableLineDrawAnimation && !lineDataInitializedRef.current) {
|
|
247
|
+
lineDataInitializedRef.current = true;
|
|
248
|
+
setShouldAnimate(false);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
243
251
|
// Enable animation briefly when line data changes (not quantile band data)
|
|
244
252
|
setShouldAnimate(true);
|
|
245
253
|
const timer = setTimeout(() => setShouldAnimate(false), 1000);
|
|
246
254
|
return () => clearTimeout(timer);
|
|
247
|
-
}, [lineDataForAnimation, disableAnimation]);
|
|
255
|
+
}, [lineDataForAnimation, disableAnimation, disableLineDrawAnimation]);
|
|
248
256
|
const isLoaded = useMemo(() => !loading && chartData.length > 0, [loading, chartData.length]);
|
|
249
257
|
// const resizePinContainer = useThrottleCallback(() => {
|
|
250
258
|
// const grid = rootRef.current?.querySelector('.recharts-cartesian-grid');
|
|
@@ -307,7 +315,7 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
307
315
|
}
|
|
308
316
|
const ChartComponent = chartType === 'line' ? LineChart : ComposedChart;
|
|
309
317
|
const defaultLabelFormatter = (v) => formatDateFn(v, true);
|
|
310
|
-
return (jsxs("div", { className: cn(S.root, !showLegend && S.noLegend, !showChartAxesLegend && S.hideChartAxesLegend, isLoaded && S.loaded, className), ref: setRefs, children: [loading && (jsx("div", { className: S.loadingOverlay, children: jsx(Skeleton, {}) })), showGrid && (jsx(ChartContainer, { config: chartConfig, className: cn(S.gridLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [jsx(ChartGrid, {}), showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: xAxisClassName, yAxisClassName: yAxisClassName, xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale }))] }) })), jsx(ChartContainer, { config: chartConfig, className: cn(S.chartLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, ...containerProps, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: cn(xAxisClassName), yAxisClassName: cn(yAxisClassName), xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale })), quantileBands?.[0] && (jsx(QuantileBands, { hiddenBands: hiddenSeries, quantileBandKey: quantileBandKey, animate: true, animationDuration: 150, animationBegin: 0, customBands: quantileBands, showLegend: showLegend })), jsx(ChartLines, { historicalLineColor: historicalLineColor, chartData: chartData, forecastData: forecastData, hiddenSeries: hiddenSeries, isDarkTheme: isDarkTheme, shouldAnimate: shouldAnimate, showLegend: showLegend, forecastLineStyle: forecastLineStyle }), showTooltip && (jsx("div", { children: jsx(Tooltip, { cursor: false, offset: TOOLTIP_OFFSET, allowEscapeViewBox: { x: false, y: false }, content: renderTooltipContent }) }))] }) }), overlayElements, jsxs("div", { className: cn(S.footer, footerClassName), children: [showLegend &&
|
|
318
|
+
return (jsxs("div", { className: cn(S.root, !showLegend && S.noLegend, !showChartAxesLegend && S.hideChartAxesLegend, disableLineDrawAnimation && S.noLineDrawAnimation, isLoaded && S.loaded, className), ref: setRefs, children: [loading && (jsx("div", { className: S.loadingOverlay, children: jsx(Skeleton, {}) })), showGrid && (jsx(ChartContainer, { config: chartConfig, className: cn(S.gridLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, children: jsxs(ChartComponent, { data: chartData, margin: margin, isAnimationActive: !disableAnimation, children: [jsx(ChartGrid, {}), showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: xAxisClassName, yAxisClassName: yAxisClassName, xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale }))] }) })), jsx(ChartContainer, { config: chartConfig, className: cn(S.chartLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, ...containerProps, children: jsxs(ChartComponent, { data: chartData, margin: margin, isAnimationActive: !disableAnimation, children: [showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: cn(xAxisClassName), yAxisClassName: cn(yAxisClassName), xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale })), quantileBands?.[0] && (jsx(QuantileBands, { hiddenBands: hiddenSeries, quantileBandKey: quantileBandKey, animate: true, animationDuration: 150, animationBegin: 0, customBands: quantileBands, showLegend: showLegend })), jsx(ChartLines, { historicalLineColor: historicalLineColor, chartData: chartData, forecastData: forecastData, hiddenSeries: hiddenSeries, isDarkTheme: isDarkTheme, shouldAnimate: shouldAnimate, disableAnimation: disableAnimation, disableHistoricalAnimation: disableHistoricalAnimation, showLegend: showLegend, forecastLineStyle: forecastLineStyle }), showTooltip && (jsx("div", { children: jsx(Tooltip, { cursor: false, offset: TOOLTIP_OFFSET, allowEscapeViewBox: { x: false, y: false }, content: renderTooltipContent }) }))] }) }), overlayElements, jsxs("div", { className: cn(S.footer, footerClassName), children: [showLegend &&
|
|
311
319
|
(legendVariant === 'svg' ? (jsx(LegendSvg, { payload: legendPayload.map(p => ({
|
|
312
320
|
value: p.value,
|
|
313
321
|
color: p.color,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import styleInject from 'style-inject';
|
|
2
2
|
|
|
3
|
-
var css_248z = ".BaseChartWrapper_chartContainer__J6CIc,.BaseChartWrapper_root__hhfho{min-height:100%}.BaseChartWrapper_root__hhfho{position:relative;width:100%}.BaseChartWrapper_gridLayer__cwJXA{pointer-events:none;position:absolute;z-index:1}.BaseChartWrapper_gridLayer__cwJXA .recharts-cartesian-axis{pointer-events:auto;z-index:1}.BaseChartWrapper_chartLayer__HmBaJ{inset:0;pointer-events:auto;position:relative;z-index:3}.BaseChartWrapper_chartLayer__HmBaJ .recharts-surface{background:transparent}.BaseChartWrapper_chartLayer__HmBaJ .recharts-layer{pointer-events:none}.BaseChartWrapper_chartLayer__HmBaJ .recharts-tooltip-wrapper{visibility:visible!important}.BaseChartWrapper_invisibleAxis__TQ-Zk .recharts-cartesian-axis-tick{visibility:hidden}.BaseChartWrapper_legend__gkSy1{position:absolute;top:100%;width:100%}.BaseChartWrapper_noLegend__jqaPa{display:block}.BaseChartWrapper_hideChartAxesLegend__dgVx- .recharts-cartesian-axis-tick-label{display:none!important}.BaseChartWrapper_chartLoadingContainer__AJP-0{align-items:center;display:flex;height:var(--chart-height);justify-content:center;width:100%}.BaseChartWrapper_loadingOverlay__GcOkA{height:100%;inset:0;opacity:.5;pointer-events:none;position:absolute;transition:opacity .1s ease-out;width:100%;z-index:10}.BaseChartWrapper_loadingOverlay__GcOkA [data-slot=skeleton]{border-radius:var(--p-4)!important}.BaseChartWrapper_loaded__Gj15C .BaseChartWrapper_loadingOverlay__GcOkA{opacity:0}.BaseChartWrapper_errorContainer__1bXy1{align-items:center;color:var(--destructive);display:flex;justify-content:center;min-height:250px}.BaseChartWrapper_errorMessage__veSmB{color:var(--destructive)}.BaseChartWrapper_footer__nP5u6{align-items:center;display:flex;gap:var(--p-2);justify-content:space-between}";
|
|
4
|
-
var S = {"root":"BaseChartWrapper_root__hhfho","chartContainer":"BaseChartWrapper_chartContainer__J6CIc","gridLayer":"BaseChartWrapper_gridLayer__cwJXA","chartLayer":"BaseChartWrapper_chartLayer__HmBaJ","invisibleAxis":"BaseChartWrapper_invisibleAxis__TQ-Zk","legend":"BaseChartWrapper_legend__gkSy1","noLegend":"BaseChartWrapper_noLegend__jqaPa","hideChartAxesLegend":"BaseChartWrapper_hideChartAxesLegend__dgVx-","chartLoadingContainer":"BaseChartWrapper_chartLoadingContainer__AJP-0","loadingOverlay":"BaseChartWrapper_loadingOverlay__GcOkA","loaded":"BaseChartWrapper_loaded__Gj15C","errorContainer":"BaseChartWrapper_errorContainer__1bXy1","errorMessage":"BaseChartWrapper_errorMessage__veSmB","footer":"BaseChartWrapper_footer__nP5u6"};
|
|
3
|
+
var css_248z = ".BaseChartWrapper_chartContainer__J6CIc,.BaseChartWrapper_root__hhfho{min-height:100%}.BaseChartWrapper_root__hhfho{position:relative;width:100%}.BaseChartWrapper_gridLayer__cwJXA{pointer-events:none;position:absolute;z-index:1}.BaseChartWrapper_gridLayer__cwJXA .recharts-cartesian-axis{pointer-events:auto;z-index:1}.BaseChartWrapper_chartLayer__HmBaJ{inset:0;pointer-events:auto;position:relative;z-index:3}.BaseChartWrapper_chartLayer__HmBaJ .recharts-surface{background:transparent}.BaseChartWrapper_chartLayer__HmBaJ .recharts-layer{pointer-events:none}.BaseChartWrapper_chartLayer__HmBaJ .recharts-tooltip-wrapper{visibility:visible!important}.BaseChartWrapper_invisibleAxis__TQ-Zk .recharts-cartesian-axis-tick{visibility:hidden}.BaseChartWrapper_legend__gkSy1{position:absolute;top:100%;width:100%}.BaseChartWrapper_noLegend__jqaPa{display:block}.BaseChartWrapper_hideChartAxesLegend__dgVx- .recharts-cartesian-axis-tick-label{display:none!important}.BaseChartWrapper_chartLoadingContainer__AJP-0{align-items:center;display:flex;height:var(--chart-height);justify-content:center;width:100%}.BaseChartWrapper_loadingOverlay__GcOkA{height:100%;inset:0;opacity:.5;pointer-events:none;position:absolute;transition:opacity .1s ease-out;width:100%;z-index:10}.BaseChartWrapper_loadingOverlay__GcOkA [data-slot=skeleton]{border-radius:var(--p-4)!important}.BaseChartWrapper_loaded__Gj15C .BaseChartWrapper_loadingOverlay__GcOkA{opacity:0}.BaseChartWrapper_errorContainer__1bXy1{align-items:center;color:var(--destructive);display:flex;justify-content:center;min-height:250px}.BaseChartWrapper_errorMessage__veSmB{color:var(--destructive)}.BaseChartWrapper_noLineDrawAnimation__cyrzV .recharts-line-curve{stroke-dasharray:inherit!important}.BaseChartWrapper_footer__nP5u6{align-items:center;display:flex;gap:var(--p-2);justify-content:space-between}";
|
|
4
|
+
var S = {"root":"BaseChartWrapper_root__hhfho","chartContainer":"BaseChartWrapper_chartContainer__J6CIc","gridLayer":"BaseChartWrapper_gridLayer__cwJXA","chartLayer":"BaseChartWrapper_chartLayer__HmBaJ","invisibleAxis":"BaseChartWrapper_invisibleAxis__TQ-Zk","legend":"BaseChartWrapper_legend__gkSy1","noLegend":"BaseChartWrapper_noLegend__jqaPa","hideChartAxesLegend":"BaseChartWrapper_hideChartAxesLegend__dgVx-","chartLoadingContainer":"BaseChartWrapper_chartLoadingContainer__AJP-0","loadingOverlay":"BaseChartWrapper_loadingOverlay__GcOkA","loaded":"BaseChartWrapper_loaded__Gj15C","errorContainer":"BaseChartWrapper_errorContainer__1bXy1","errorMessage":"BaseChartWrapper_errorMessage__veSmB","noLineDrawAnimation":"BaseChartWrapper_noLineDrawAnimation__cyrzV","footer":"BaseChartWrapper_footer__nP5u6"};
|
|
5
5
|
styleInject(css_248z);
|
|
6
6
|
|
|
7
7
|
export { S as default };
|
|
@@ -23,7 +23,7 @@ function resolveQuantileBandFillForForecastLine(lineColor, forecastIndex) {
|
|
|
23
23
|
return mapped ?? getForecastQuantileBandColor(forecastIndex);
|
|
24
24
|
}
|
|
25
25
|
// Memoized component for chart lines - only re-renders when data/analyses/hiddenSeries change
|
|
26
|
-
const ChartLines = memo(({ chartData, forecastData, hiddenSeries, isDarkTheme, shouldAnimate, historicalLineColor = isDarkTheme ? '#ffffff' : '#000000', showLegend = true, forecastLineStyle = 'dashed', disableHistoricalAnimation = false, }) => {
|
|
26
|
+
const ChartLines = memo(({ chartData, forecastData, hiddenSeries, isDarkTheme, shouldAnimate, historicalLineColor = isDarkTheme ? '#ffffff' : '#000000', showLegend = true, forecastLineStyle = 'dashed', disableAnimation = false, disableHistoricalAnimation = false, }) => {
|
|
27
27
|
const dotStroke = isDarkTheme ? '#000000' : '#FFFFFF';
|
|
28
28
|
const activeDotHistorical = {
|
|
29
29
|
r: 6,
|
|
@@ -75,10 +75,15 @@ const ChartLines = memo(({ chartData, forecastData, hiddenSeries, isDarkTheme, s
|
|
|
75
75
|
};
|
|
76
76
|
const renderHistoricalDot = (props) => renderDot(props, lastHistoricalIndex, 'historical', historicalLineColor);
|
|
77
77
|
const renderForecastDot = (props, id, color) => renderDot(props, lastForecastIndices[id] ?? -1, `forecast_${id}`, color);
|
|
78
|
-
|
|
78
|
+
const animationActive = Boolean(shouldAnimate) && !disableAnimation;
|
|
79
|
+
const historicalAnimationActive = animationActive && !disableHistoricalAnimation;
|
|
80
|
+
const forecastAnimationActive = animationActive;
|
|
81
|
+
const animationDuration = disableAnimation ? 0 : ANIMATION_DURATION;
|
|
82
|
+
const valueAnimationEnabled = !disableAnimation;
|
|
83
|
+
return (jsxs(Fragment, { children: [jsx(Line, { type: "monotone", dataKey: "historical", stroke: historicalLineColor, strokeWidth: 1, dot: props => renderHistoricalDot(props), activeDot: activeDotHistorical, name: "Historical Data", legendType: showLegend === false ? 'none' : undefined, strokeDasharray: "0", hide: hiddenSeries?.has('historical'), isAnimationActive: historicalAnimationActive, animationDuration: animationDuration, animateNewValues: valueAnimationEnabled, animationBegin: 0, animationEasing: "ease-out" }, "historical"), forecastData?.map((data, index) => {
|
|
79
84
|
const { id, name, color = FORECAST_LINE_COLORS[index % FORECAST_LINE_COLORS.length], } = data;
|
|
80
85
|
const dataKey = `forecast_${id}`;
|
|
81
|
-
return (jsx(Line, { type: "monotone", dataKey: dataKey, stroke: color, strokeWidth: 1, strokeDasharray: forecastLineStyle === 'dashed' ? '4 2' : '0', dot: props => renderForecastDot(props, id, color), activeDot: activeDotForecast(color), name: `${name || `${id}`}`, legendType: showLegend === false ? 'none' : undefined, hide: hiddenSeries?.has(dataKey), isAnimationActive:
|
|
86
|
+
return (jsx(Line, { type: "monotone", dataKey: dataKey, stroke: color, strokeWidth: 1, strokeDasharray: forecastLineStyle === 'dashed' ? '4 2' : '0', dot: props => renderForecastDot(props, id, color), activeDot: activeDotForecast(color), name: `${name || `${id}`}`, legendType: showLegend === false ? 'none' : undefined, hide: hiddenSeries?.has(dataKey), isAnimationActive: forecastAnimationActive, animationDuration: animationDuration, animateNewValues: valueAnimationEnabled, animationBegin: 0, animationEasing: "ease-out", className: data.status === 'pending' ? 'chart-line-blinking' : undefined }, `forecast-${id}`));
|
|
82
87
|
})] }));
|
|
83
88
|
});
|
|
84
89
|
ChartLines.displayName = 'ChartLines';
|
|
@@ -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,52 @@ 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 =
|
|
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
|
-
}, [
|
|
80
|
-
const mergedChartData = useMemo(() => mergeBacktestsChartData(
|
|
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 (!
|
|
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
|
-
}, [
|
|
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:
|
|
113
|
+
lag: getLagDisplayForView(resolveDriverLagLabel(lagSource), viewTab),
|
|
103
114
|
};
|
|
104
115
|
});
|
|
105
|
-
}, [sortedDriversWithData]);
|
|
106
|
-
const seriesInitKeyResolved = seriesInitKey ??
|
|
116
|
+
}, [originalDriversById, sortedDriversWithData, viewTab]);
|
|
117
|
+
const seriesInitKeyResolved = seriesInitKey ??
|
|
118
|
+
`${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
107
119
|
const backtestsSeriesInitKeyRef = useRef('');
|
|
108
120
|
useEffect(() => {
|
|
109
121
|
if (tableSeriesRows.length === 0)
|
|
@@ -125,14 +137,14 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
|
|
|
125
137
|
}, [seriesInitKeyResolved, tableSeriesRows]);
|
|
126
138
|
const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
|
|
127
139
|
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
|
|
140
|
+
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", disableLineDrawAnimation: true, 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
141
|
? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
|
|
130
142
|
: 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
143
|
const dataKey = toForecastDataKey(row.id);
|
|
132
144
|
const hidden = hiddenSeries.has(dataKey);
|
|
133
145
|
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
146
|
backgroundColor: row.color,
|
|
135
|
-
} }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag
|
|
147
|
+
} }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag })] }, row.id));
|
|
136
148
|
}) })] }) }) })) })] }));
|
|
137
149
|
}
|
|
138
150
|
|
package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeToMonthStart,
|
|
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,94 @@ 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 overallLag = driver.overall_lag ?? driver.overallLag;
|
|
19
|
+
if (typeof overallLag === 'string' && overallLag.trim()) {
|
|
20
|
+
return overallLag;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Parse lag label to month count; ranges use the upper bound. */
|
|
25
|
+
function parseLagMonthsFromLabel(lag) {
|
|
26
|
+
if (lag == null)
|
|
27
|
+
return null;
|
|
28
|
+
const trimmed = lag.trim();
|
|
29
|
+
if (!trimmed)
|
|
30
|
+
return null;
|
|
31
|
+
const lower = trimmed.toLowerCase().replace(/\u2013|\u2014/g, '-');
|
|
32
|
+
if (lower === 'unknown' || lower === 'no lag' || lower.includes('no lag')) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const rangeMatch = lower.match(/(\d+)\s*(?:to|-)\s*(\d+)\s*months?/);
|
|
36
|
+
if (rangeMatch) {
|
|
37
|
+
const a = parseInt(rangeMatch[1], 10);
|
|
38
|
+
const b = parseInt(rangeMatch[2], 10);
|
|
39
|
+
if (Number.isFinite(a) && Number.isFinite(b))
|
|
40
|
+
return Math.max(a, b);
|
|
41
|
+
}
|
|
42
|
+
const quarterMatch = lower.match(/~?\s*(\d+)\s*quarter/);
|
|
43
|
+
if (quarterMatch) {
|
|
44
|
+
const q = parseInt(quarterMatch[1], 10);
|
|
45
|
+
if (Number.isFinite(q) && q > 0)
|
|
46
|
+
return q * 3;
|
|
47
|
+
}
|
|
48
|
+
const monthMatch = lower.match(/~?\s*(\d+)\s*months?/);
|
|
49
|
+
if (monthMatch) {
|
|
50
|
+
const m = parseInt(monthMatch[1], 10);
|
|
51
|
+
if (Number.isFinite(m) && m > 0)
|
|
52
|
+
return m;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function formatLagMonthsLabel(months) {
|
|
57
|
+
return `${months} month(s)`;
|
|
58
|
+
}
|
|
59
|
+
function getLagDisplayForView(lag, tab) {
|
|
60
|
+
if (tab === 'overlapped') {
|
|
61
|
+
return lag?.trim() ? lag : '—';
|
|
62
|
+
}
|
|
63
|
+
const months = parseLagMonthsFromLabel(lag);
|
|
64
|
+
return months != null ? formatLagMonthsLabel(months) : '—';
|
|
65
|
+
}
|
|
66
|
+
function shiftNormalizedSeriesForward(series, lagMonths) {
|
|
67
|
+
if (lagMonths <= 0)
|
|
68
|
+
return { ...series };
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const [dateStr, val] of Object.entries(series)) {
|
|
71
|
+
if (val === null || val === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
let shifted = normalizeToMonthStart(dateStr);
|
|
74
|
+
for (let i = 0; i < lagMonths; i++) {
|
|
75
|
+
shifted = getNextMonth(shifted);
|
|
76
|
+
}
|
|
77
|
+
out[shifted] = val;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function applyDriversComparisonViewToPayload(payload, tab) {
|
|
82
|
+
if (!payload || tab === 'overlapped')
|
|
83
|
+
return payload;
|
|
84
|
+
return {
|
|
85
|
+
target: {
|
|
86
|
+
...payload.target,
|
|
87
|
+
normalized_series: { ...payload.target.normalized_series },
|
|
88
|
+
},
|
|
89
|
+
drivers: payload.drivers.map(driver => {
|
|
90
|
+
const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
|
|
91
|
+
const series = driver.normalized_series ?? {};
|
|
92
|
+
const normalized_series = lagMonths != null && lagMonths > 0
|
|
93
|
+
? shiftNormalizedSeriesForward(series, lagMonths)
|
|
94
|
+
: { ...series };
|
|
95
|
+
return {
|
|
96
|
+
...driver,
|
|
97
|
+
normalized_series,
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
14
102
|
function mergeBacktestsChartData(payload) {
|
|
15
103
|
if (!payload?.target?.normalized_series)
|
|
16
104
|
return [];
|
|
@@ -156,4 +244,4 @@ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical
|
|
|
156
244
|
return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
|
|
157
245
|
}
|
|
158
246
|
|
|
159
|
-
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, prependHistoricalLeadFromDataset, subtractMonthsFromMonthStart };
|
|
247
|
+
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';
|
|
@@ -2,9 +2,9 @@ import { ComponentProps, ReactNode } from 'react';
|
|
|
2
2
|
import type { QuantileBandConfig } from '#uilib/components/ui/Chart/chartForecastVisualization.types';
|
|
3
3
|
import { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
4
4
|
import { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
|
|
5
|
+
import { LegendPayload } from 'recharts/types/component/DefaultLegendContent';
|
|
5
6
|
import type { ChartConfig } from '../Chart.types';
|
|
6
7
|
import { ChartContainer } from './ChartContainer';
|
|
7
|
-
import { LegendPayload } from 'recharts/types/component/DefaultLegendContent';
|
|
8
8
|
export interface BaseChartWrapperProps {
|
|
9
9
|
renderId?: string;
|
|
10
10
|
chartConfig?: ChartConfig;
|
|
@@ -43,6 +43,11 @@ export interface BaseChartWrapperProps {
|
|
|
43
43
|
children?: ReactNode;
|
|
44
44
|
chartType?: 'composed' | 'line';
|
|
45
45
|
disableAnimation?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Skip left-to-right line path draw; still animate Y on data updates.
|
|
48
|
+
* Independent of `disableAnimation` (full off).
|
|
49
|
+
*/
|
|
50
|
+
disableLineDrawAnimation?: boolean;
|
|
46
51
|
showGrid?: boolean;
|
|
47
52
|
showAxes?: boolean;
|
|
48
53
|
showTooltip?: boolean;
|
|
@@ -34,7 +34,7 @@ export declare const getForecastColor: (index: number) => string;
|
|
|
34
34
|
export declare const getForecastQuantileBandColor: (index: number) => any;
|
|
35
35
|
/** Same tint logic as intervals/threshold overlays: band fill follows forecast line hex when known. */
|
|
36
36
|
export declare function resolveQuantileBandFillForForecastLine(lineColor: string, forecastIndex: number): string;
|
|
37
|
-
export declare const ChartLines: import("react").MemoExoticComponent<({ chartData, forecastData, hiddenSeries, isDarkTheme, shouldAnimate, historicalLineColor, showLegend, forecastLineStyle, disableHistoricalAnimation, }: {
|
|
37
|
+
export declare const ChartLines: import("react").MemoExoticComponent<({ chartData, forecastData, hiddenSeries, isDarkTheme, shouldAnimate, historicalLineColor, showLegend, forecastLineStyle, disableAnimation, disableHistoricalAnimation, }: {
|
|
38
38
|
chartData: ChartDataPoint[];
|
|
39
39
|
forecastData?: ForecastItemData[];
|
|
40
40
|
hiddenSeries?: Set<string>;
|
|
@@ -43,5 +43,6 @@ export declare const ChartLines: import("react").MemoExoticComponent<({ chartDat
|
|
|
43
43
|
historicalLineColor?: string;
|
|
44
44
|
showLegend?: boolean;
|
|
45
45
|
forecastLineStyle?: "dashed" | "solid";
|
|
46
|
+
disableAnimation?: boolean;
|
|
46
47
|
disableHistoricalAnimation?: boolean;
|
|
47
48
|
}) => import("react/jsx-runtime").JSX.Element>;
|
package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts
CHANGED
|
@@ -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
|
|
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,19 @@ 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
|
+
overall_lag?: string | null;
|
|
12
|
+
overallLag?: string | null;
|
|
13
|
+
}): string | null;
|
|
14
|
+
/** Parse lag label to month count; ranges use the upper bound. */
|
|
15
|
+
export declare function parseLagMonthsFromLabel(lag: string | null | undefined): number | null;
|
|
16
|
+
export declare function formatLagMonthsLabel(months: number): string;
|
|
17
|
+
export declare function getLagDisplayForView(lag: string | null | undefined, tab: DriversComparisonViewTab): string;
|
|
18
|
+
export declare function shiftNormalizedSeriesForward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
|
|
19
|
+
export declare function applyDriversComparisonViewToPayload(payload: BacktestsComponentPayload | null, tab: DriversComparisonViewTab): BacktestsComponentPayload | null;
|
|
8
20
|
export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
|
|
9
21
|
/** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
|
|
10
22
|
export declare function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical: ChartDataPoint[], backtestsMerged: ChartDataPoint[]): ChartDataPoint[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
@@ -18,21 +18,20 @@ import {
|
|
|
18
18
|
} from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
|
|
19
19
|
import { Skeleton } from '#uilib/components/ui/Skeleton';
|
|
20
20
|
import { chartRenderQueue } from '#uilib/utils/chartRenderQueue';
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
import type { ChartConfig } from '../Chart.types';
|
|
24
|
-
import { ChartContainer } from './ChartContainer';
|
|
25
|
-
import { ChartTooltipContent } from './ChartTooltipContent';
|
|
26
|
-
import { CustomChartLegend } from './CustomChartLegend/CustomChartLegend';
|
|
27
|
-
import { QuantileBands } from './QuantileBands';
|
|
21
|
+
import { Tooltip as ChartTooltip, ComposedChart, LineChart } from 'recharts';
|
|
28
22
|
import { LegendPayload } from 'recharts/types/component/DefaultLegendContent';
|
|
29
23
|
|
|
24
|
+
import type { ChartConfig } from '../Chart.types';
|
|
30
25
|
import { getPlotViewBox, resolveChartMargin } from '../tools/chartPlotGeometry';
|
|
31
26
|
import { formatDate } from '../tools/formatters';
|
|
32
27
|
import S from './BaseChartWrapper.styl';
|
|
33
28
|
import { ChartAxes } from './ChartAxes';
|
|
29
|
+
import { ChartContainer } from './ChartContainer';
|
|
34
30
|
import { ChartGrid } from './ChartGrid';
|
|
31
|
+
import { ChartTooltipContent } from './ChartTooltipContent';
|
|
32
|
+
import { CustomChartLegend } from './CustomChartLegend/CustomChartLegend';
|
|
35
33
|
import { LegendSvg } from './LegendSvg/LegendSvg';
|
|
34
|
+
import { QuantileBands } from './QuantileBands';
|
|
36
35
|
|
|
37
36
|
function clampTooltipTranslate(args: {
|
|
38
37
|
coordinate: { x: number; y: number };
|
|
@@ -107,6 +106,11 @@ export interface BaseChartWrapperProps {
|
|
|
107
106
|
children?: ReactNode;
|
|
108
107
|
chartType?: 'composed' | 'line';
|
|
109
108
|
disableAnimation?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Skip left-to-right line path draw; still animate Y on data updates.
|
|
111
|
+
* Independent of `disableAnimation` (full off).
|
|
112
|
+
*/
|
|
113
|
+
disableLineDrawAnimation?: boolean;
|
|
110
114
|
showGrid?: boolean;
|
|
111
115
|
showAxes?: boolean;
|
|
112
116
|
showTooltip?: boolean;
|
|
@@ -210,6 +214,7 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
210
214
|
margin,
|
|
211
215
|
chartType = 'composed',
|
|
212
216
|
disableAnimation = false,
|
|
217
|
+
disableLineDrawAnimation = false,
|
|
213
218
|
showGrid = true,
|
|
214
219
|
showAxes = true,
|
|
215
220
|
showTooltip = true,
|
|
@@ -240,6 +245,7 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
240
245
|
} = props;
|
|
241
246
|
|
|
242
247
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
248
|
+
const lineDataInitializedRef = useRef(false);
|
|
243
249
|
|
|
244
250
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
245
251
|
|
|
@@ -483,12 +489,20 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
483
489
|
}, [forecastData, excludeLegendIds, showLegend]);
|
|
484
490
|
|
|
485
491
|
useEffect(() => {
|
|
486
|
-
if (disableAnimation)
|
|
492
|
+
if (disableAnimation) {
|
|
493
|
+
setShouldAnimate(false);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (disableLineDrawAnimation && !lineDataInitializedRef.current) {
|
|
497
|
+
lineDataInitializedRef.current = true;
|
|
498
|
+
setShouldAnimate(false);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
487
501
|
// Enable animation briefly when line data changes (not quantile band data)
|
|
488
502
|
setShouldAnimate(true);
|
|
489
503
|
const timer = setTimeout(() => setShouldAnimate(false), 1000);
|
|
490
504
|
return () => clearTimeout(timer);
|
|
491
|
-
}, [lineDataForAnimation, disableAnimation]);
|
|
505
|
+
}, [lineDataForAnimation, disableAnimation, disableLineDrawAnimation]);
|
|
492
506
|
|
|
493
507
|
const isLoaded = useMemo(
|
|
494
508
|
() => !loading && chartData.length > 0,
|
|
@@ -587,6 +601,7 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
587
601
|
S.root,
|
|
588
602
|
!showLegend && S.noLegend,
|
|
589
603
|
!showChartAxesLegend && S.hideChartAxesLegend,
|
|
604
|
+
disableLineDrawAnimation && S.noLineDrawAnimation,
|
|
590
605
|
isLoaded && S.loaded,
|
|
591
606
|
className,
|
|
592
607
|
)}
|
|
@@ -605,7 +620,11 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
605
620
|
className={cn(S.gridLayer, chartClassName)}
|
|
606
621
|
style={height ? { height: `${height}px` } : undefined}
|
|
607
622
|
>
|
|
608
|
-
<ChartComponent
|
|
623
|
+
<ChartComponent
|
|
624
|
+
data={chartData}
|
|
625
|
+
margin={margin}
|
|
626
|
+
isAnimationActive={!disableAnimation}
|
|
627
|
+
>
|
|
609
628
|
<ChartGrid />
|
|
610
629
|
{showAxes && (
|
|
611
630
|
<ChartAxes
|
|
@@ -633,7 +652,11 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
633
652
|
style={height ? { height: `${height}px` } : undefined}
|
|
634
653
|
{...containerProps}
|
|
635
654
|
>
|
|
636
|
-
<ChartComponent
|
|
655
|
+
<ChartComponent
|
|
656
|
+
data={chartData}
|
|
657
|
+
margin={margin}
|
|
658
|
+
isAnimationActive={!disableAnimation}
|
|
659
|
+
>
|
|
637
660
|
{/* Render invisible axes for coordinate system, but hide labels */}
|
|
638
661
|
{showAxes && (
|
|
639
662
|
<ChartAxes
|
|
@@ -671,9 +694,10 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
671
694
|
hiddenSeries={hiddenSeries}
|
|
672
695
|
isDarkTheme={isDarkTheme}
|
|
673
696
|
shouldAnimate={shouldAnimate}
|
|
697
|
+
disableAnimation={disableAnimation}
|
|
698
|
+
disableHistoricalAnimation={disableHistoricalAnimation}
|
|
674
699
|
showLegend={showLegend}
|
|
675
700
|
forecastLineStyle={forecastLineStyle}
|
|
676
|
-
// disableHistoricalAnimation={disableHistoricalAnimation}
|
|
677
701
|
/>
|
|
678
702
|
|
|
679
703
|
{showTooltip && (
|
|
@@ -73,6 +73,7 @@ export const ChartLines = memo(
|
|
|
73
73
|
historicalLineColor = isDarkTheme ? '#ffffff' : '#000000',
|
|
74
74
|
showLegend = true,
|
|
75
75
|
forecastLineStyle = 'dashed',
|
|
76
|
+
disableAnimation = false,
|
|
76
77
|
disableHistoricalAnimation = false,
|
|
77
78
|
}: {
|
|
78
79
|
chartData: ChartDataPoint[];
|
|
@@ -83,6 +84,7 @@ export const ChartLines = memo(
|
|
|
83
84
|
historicalLineColor?: string;
|
|
84
85
|
showLegend?: boolean;
|
|
85
86
|
forecastLineStyle?: 'dashed' | 'solid';
|
|
87
|
+
disableAnimation?: boolean;
|
|
86
88
|
disableHistoricalAnimation?: boolean;
|
|
87
89
|
}) => {
|
|
88
90
|
const dotStroke = isDarkTheme ? '#000000' : '#FFFFFF';
|
|
@@ -152,6 +154,14 @@ export const ChartLines = memo(
|
|
|
152
154
|
|
|
153
155
|
const renderForecastDot = (props: any, id: number, color: string) =>
|
|
154
156
|
renderDot(props, lastForecastIndices[id] ?? -1, `forecast_${id}`, color);
|
|
157
|
+
|
|
158
|
+
const animationActive = Boolean(shouldAnimate) && !disableAnimation;
|
|
159
|
+
const historicalAnimationActive =
|
|
160
|
+
animationActive && !disableHistoricalAnimation;
|
|
161
|
+
const forecastAnimationActive = animationActive;
|
|
162
|
+
const animationDuration = disableAnimation ? 0 : ANIMATION_DURATION;
|
|
163
|
+
const valueAnimationEnabled = !disableAnimation;
|
|
164
|
+
|
|
155
165
|
return (
|
|
156
166
|
<>
|
|
157
167
|
{/* Historical Data Line */}
|
|
@@ -167,10 +177,9 @@ export const ChartLines = memo(
|
|
|
167
177
|
legendType={showLegend === false ? 'none' : undefined}
|
|
168
178
|
strokeDasharray="0"
|
|
169
179
|
hide={hiddenSeries?.has('historical')}
|
|
170
|
-
isAnimationActive={
|
|
171
|
-
animationDuration={
|
|
172
|
-
|
|
173
|
-
}
|
|
180
|
+
isAnimationActive={historicalAnimationActive}
|
|
181
|
+
animationDuration={animationDuration}
|
|
182
|
+
animateNewValues={valueAnimationEnabled}
|
|
174
183
|
animationBegin={0}
|
|
175
184
|
animationEasing="ease-out"
|
|
176
185
|
/>
|
|
@@ -197,12 +206,14 @@ export const ChartLines = memo(
|
|
|
197
206
|
name={`${name || `${id}`}`}
|
|
198
207
|
legendType={showLegend === false ? 'none' : undefined}
|
|
199
208
|
hide={hiddenSeries?.has(dataKey)}
|
|
200
|
-
isAnimationActive={
|
|
201
|
-
animationDuration={
|
|
209
|
+
isAnimationActive={forecastAnimationActive}
|
|
210
|
+
animationDuration={animationDuration}
|
|
211
|
+
animateNewValues={valueAnimationEnabled}
|
|
202
212
|
animationBegin={0}
|
|
203
213
|
animationEasing="ease-out"
|
|
204
|
-
className=
|
|
205
|
-
|
|
214
|
+
className={
|
|
215
|
+
data.status === 'pending' ? 'chart-line-blinking' : undefined
|
|
216
|
+
}
|
|
206
217
|
/>
|
|
207
218
|
);
|
|
208
219
|
})}
|
|
@@ -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 =
|
|
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
|
-
}, [
|
|
145
|
+
}, [payloadForView?.drivers]);
|
|
127
146
|
|
|
128
147
|
const mergedChartData = useMemo(
|
|
129
|
-
() => mergeBacktestsChartData(
|
|
130
|
-
[
|
|
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 (!
|
|
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
|
-
}, [
|
|
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,20 @@ 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:
|
|
190
|
+
lag: getLagDisplayForView(resolveDriverLagLabel(lagSource), viewTab),
|
|
163
191
|
};
|
|
164
192
|
});
|
|
165
|
-
}, [sortedDriversWithData]);
|
|
193
|
+
}, [originalDriversById, sortedDriversWithData, viewTab]);
|
|
166
194
|
|
|
167
195
|
const seriesInitKeyResolved =
|
|
168
|
-
seriesInitKey ??
|
|
196
|
+
seriesInitKey ??
|
|
197
|
+
`${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
169
198
|
|
|
170
199
|
const backtestsSeriesInitKeyRef = useRef<string>('');
|
|
171
200
|
|
|
@@ -211,6 +240,8 @@ export function DriversComparisonChart({
|
|
|
211
240
|
)}
|
|
212
241
|
>
|
|
213
242
|
<ChartAreaInteractive
|
|
243
|
+
chartRenderId="drivers-comparison"
|
|
244
|
+
disableLineDrawAnimation
|
|
214
245
|
disableHistoricalAnimation
|
|
215
246
|
disableTimeRangeSelector
|
|
216
247
|
enableTimeRangeBrush
|
|
@@ -351,7 +382,7 @@ export function DriversComparisonChart({
|
|
|
351
382
|
<TableCell>
|
|
352
383
|
{formatSeriesImportance(row.importance)}
|
|
353
384
|
</TableCell>
|
|
354
|
-
<TableCell>{row.lag
|
|
385
|
+
<TableCell>{row.lag}</TableCell>
|
|
355
386
|
</TableRow>
|
|
356
387
|
);
|
|
357
388
|
})}
|
|
@@ -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,113 @@ 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
|
+
overall_lag?: string | null;
|
|
27
|
+
overallLag?: string | null;
|
|
28
|
+
}): string | null {
|
|
29
|
+
if (typeof driver.lag === 'string' && driver.lag.trim()) {
|
|
30
|
+
return driver.lag;
|
|
31
|
+
}
|
|
32
|
+
const overallLag = driver.overall_lag ?? driver.overallLag;
|
|
33
|
+
if (typeof overallLag === 'string' && overallLag.trim()) {
|
|
34
|
+
return overallLag;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse lag label to month count; ranges use the upper bound. */
|
|
40
|
+
export function parseLagMonthsFromLabel(
|
|
41
|
+
lag: string | null | undefined,
|
|
42
|
+
): number | null {
|
|
43
|
+
if (lag == null) return null;
|
|
44
|
+
const trimmed = lag.trim();
|
|
45
|
+
if (!trimmed) return null;
|
|
46
|
+
const lower = trimmed.toLowerCase().replace(/\u2013|\u2014/g, '-');
|
|
47
|
+
if (lower === 'unknown' || lower === 'no lag' || lower.includes('no lag')) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rangeMatch = lower.match(/(\d+)\s*(?:to|-)\s*(\d+)\s*months?/);
|
|
52
|
+
if (rangeMatch) {
|
|
53
|
+
const a = parseInt(rangeMatch[1], 10);
|
|
54
|
+
const b = parseInt(rangeMatch[2], 10);
|
|
55
|
+
if (Number.isFinite(a) && Number.isFinite(b)) return Math.max(a, b);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const quarterMatch = lower.match(/~?\s*(\d+)\s*quarter/);
|
|
59
|
+
if (quarterMatch) {
|
|
60
|
+
const q = parseInt(quarterMatch[1], 10);
|
|
61
|
+
if (Number.isFinite(q) && q > 0) return q * 3;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const monthMatch = lower.match(/~?\s*(\d+)\s*months?/);
|
|
65
|
+
if (monthMatch) {
|
|
66
|
+
const m = parseInt(monthMatch[1], 10);
|
|
67
|
+
if (Number.isFinite(m) && m > 0) return m;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatLagMonthsLabel(months: number): string {
|
|
74
|
+
return `${months} month(s)`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getLagDisplayForView(
|
|
78
|
+
lag: string | null | undefined,
|
|
79
|
+
tab: DriversComparisonViewTab,
|
|
80
|
+
): string {
|
|
81
|
+
if (tab === 'overlapped') {
|
|
82
|
+
return lag?.trim() ? lag : '—';
|
|
83
|
+
}
|
|
84
|
+
const months = parseLagMonthsFromLabel(lag);
|
|
85
|
+
return months != null ? formatLagMonthsLabel(months) : '—';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function shiftNormalizedSeriesForward(
|
|
89
|
+
series: Record<string, number | null>,
|
|
90
|
+
lagMonths: number,
|
|
91
|
+
): Record<string, number | null> {
|
|
92
|
+
if (lagMonths <= 0) return { ...series };
|
|
93
|
+
const out: Record<string, number | null> = {};
|
|
94
|
+
for (const [dateStr, val] of Object.entries(series)) {
|
|
95
|
+
if (val === null || val === undefined) continue;
|
|
96
|
+
let shifted = normalizeToMonthStart(dateStr);
|
|
97
|
+
for (let i = 0; i < lagMonths; i++) {
|
|
98
|
+
shifted = getNextMonth(shifted);
|
|
99
|
+
}
|
|
100
|
+
out[shifted] = val;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function applyDriversComparisonViewToPayload(
|
|
106
|
+
payload: BacktestsComponentPayload | null,
|
|
107
|
+
tab: DriversComparisonViewTab,
|
|
108
|
+
): BacktestsComponentPayload | null {
|
|
109
|
+
if (!payload || tab === 'overlapped') return payload;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
target: {
|
|
113
|
+
...payload.target,
|
|
114
|
+
normalized_series: { ...payload.target.normalized_series },
|
|
115
|
+
},
|
|
116
|
+
drivers: payload.drivers.map(driver => {
|
|
117
|
+
const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
|
|
118
|
+
const series = driver.normalized_series ?? {};
|
|
119
|
+
const normalized_series =
|
|
120
|
+
lagMonths != null && lagMonths > 0
|
|
121
|
+
? shiftNormalizedSeriesForward(series, lagMonths)
|
|
122
|
+
: { ...series };
|
|
123
|
+
return {
|
|
124
|
+
...driver,
|
|
125
|
+
normalized_series,
|
|
126
|
+
};
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
22
131
|
export function mergeBacktestsChartData(
|
|
23
132
|
payload: BacktestsComponentPayload | null,
|
|
24
133
|
): 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 {
|
|
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: '
|
|
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
|
</>
|