@sybilion/uilib 1.3.56 → 1.3.58

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.
@@ -19,7 +19,7 @@ function CardTitle({ className, ...props }) {
19
19
  return (jsx(TextWithDeferTooltip, { maxWidth: 400, "data-slot": "card-title", className: cn(S.title, className), ...props }));
20
20
  }
21
21
  function CardDescription({ className, ...props }) {
22
- return (jsx(TextWithDeferTooltip, { maxWidth: 400, "data-slot": "card-description", className: cn(S.description, className), ...props }));
22
+ return (jsx(TextWithDeferTooltip, { maxWidth: 400, "data-slot": "card-description", className: cn(S.description, className), overTrigger: true, ...props }));
23
23
  }
24
24
  function CardAction({ className, ...props }) {
25
25
  return (jsx("div", { "data-slot": "card-action", className: cn(S.action, className), ...props }));
@@ -315,7 +315,7 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
315
315
  }
316
316
  const ChartComponent = chartType === 'line' ? LineChart : ComposedChart;
317
317
  const defaultLabelFormatter = (v) => formatDateFn(v, true);
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 &&
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, 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, 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 &&
319
319
  (legendVariant === 'svg' ? (jsx(LegendSvg, { payload: legendPayload.map(p => ({
320
320
  value: p.value,
321
321
  color: p.color,
@@ -28,7 +28,7 @@ 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 { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
31
+ import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, computeDriversComparisonHistoricalWindowFloor, 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) {
@@ -82,6 +82,16 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
82
82
  }, [payloadForView?.drivers]);
83
83
  const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
84
84
  const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
85
+ const historicalWindowFloor = useMemo(() => {
86
+ const overlappedMerged = mergeBacktestsChartData(payload);
87
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, overlappedMerged);
88
+ const sortedDrivers = [...(payload?.drivers ?? [])]
89
+ .filter(d => d.normalized_series &&
90
+ Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
91
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
92
+ const forecastIds = sortedDrivers.map((_, idx) => DRIVER_FORECAST_ID_BASE + idx);
93
+ return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
94
+ }, [payload, datasetHistorical]);
85
95
  const chartForecastData = useMemo(() => {
86
96
  if (!payloadForView?.target?.normalized_series)
87
97
  return [];
@@ -114,8 +124,7 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
114
124
  };
115
125
  });
116
126
  }, [originalDriversById, sortedDriversWithData, viewTab]);
117
- const seriesInitKeyResolved = seriesInitKey ??
118
- `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
127
+ const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
119
128
  const backtestsSeriesInitKeyRef = useRef('');
120
129
  useEffect(() => {
121
130
  if (tableSeriesRows.length === 0)
@@ -135,7 +144,12 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
135
144
  return next;
136
145
  });
137
146
  }, [seriesInitKeyResolved, tableSeriesRows]);
138
- const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
147
+ const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id), historicalWindowFloor), [
148
+ chartForecastData,
149
+ datasetHistorical,
150
+ historicalWindowFloor,
151
+ mergedWithHistorical,
152
+ ]);
139
153
  const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
140
154
  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
141
155
  ? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
@@ -231,17 +231,26 @@ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
231
231
  }
232
232
  return [...lead, ...points];
233
233
  }
234
- function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds) {
234
+ /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
235
+ function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds) {
236
+ if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
237
+ return null;
238
+ const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
239
+ if (anchorMonth === null)
240
+ return null;
241
+ return subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
242
+ }
243
+ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds, historicalWindowFloor) {
235
244
  if (mergedWithHistorical.length === 0)
236
245
  return mergedWithHistorical;
237
246
  if (forecastIds.length === 0)
238
247
  return mergedWithHistorical;
239
- const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
240
- if (anchorMonth === null)
248
+ const xMin = historicalWindowFloor ??
249
+ computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds);
250
+ if (xMin === null)
241
251
  return mergedWithHistorical;
242
- const xMin = subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
243
252
  const withLead = prependHistoricalLeadFromDataset(mergedWithHistorical, datasetHistorical, xMin);
244
253
  return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
245
254
  }
246
255
 
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 };
256
+ export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, computeDriversComparisonHistoricalWindowFloor, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
@@ -35,4 +35,6 @@ export declare function subtractMonthsFromMonthStart(date: string, count: number
35
35
  * (scaled to the first normalized point) so the lead-in shows the target line alone.
36
36
  */
37
37
  export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
38
- export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[]): ChartDataPoint[];
38
+ /** Overlapped (unshifted) anchor minus lead months stable xMin across lagged/overlapped tabs. */
39
+ export declare function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical: ChartDataPoint[], forecastIds: number[]): string | null;
40
+ export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[], historicalWindowFloor?: string | null): ChartDataPoint[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.56",
3
+ "version": "1.3.58",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -107,6 +107,7 @@ function CardDescription({ className, ...props }: CardDescriptionProps) {
107
107
  maxWidth={400}
108
108
  data-slot="card-description"
109
109
  className={cn(S.description, className)}
110
+ overTrigger
110
111
  {...props}
111
112
  />
112
113
  );
@@ -620,11 +620,7 @@ const BaseChartWrapperContent = forwardRef<
620
620
  className={cn(S.gridLayer, chartClassName)}
621
621
  style={height ? { height: `${height}px` } : undefined}
622
622
  >
623
- <ChartComponent
624
- data={chartData}
625
- margin={margin}
626
- isAnimationActive={!disableAnimation}
627
- >
623
+ <ChartComponent data={chartData} margin={margin}>
628
624
  <ChartGrid />
629
625
  {showAxes && (
630
626
  <ChartAxes
@@ -652,11 +648,7 @@ const BaseChartWrapperContent = forwardRef<
652
648
  style={height ? { height: `${height}px` } : undefined}
653
649
  {...containerProps}
654
650
  >
655
- <ChartComponent
656
- data={chartData}
657
- margin={margin}
658
- isAnimationActive={!disableAnimation}
659
- >
651
+ <ChartComponent data={chartData} margin={margin}>
660
652
  {/* Render invisible axes for coordinate system, but hide labels */}
661
653
  {showAxes && (
662
654
  <ChartAxes
@@ -20,4 +20,6 @@ Requires: `payload` — target + driver normalized_series; `loading` / `chartLoa
20
20
 
21
21
  Lag column: **Overlapped** shows API `lag` string (may be a range). **Lagged** shows single `N month(s)` from `parseLagMonthsFromLabel` (range uses max month).
22
22
 
23
+ Historical window: lead-in is anchored to the **overlapped** view (`computeDriversComparisonHistoricalWindowFloor`); lagged tab shifts driver lines only — switching tabs does not change historical extent.
24
+
23
25
  Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
@@ -35,6 +35,7 @@ import {
35
35
  INITIAL_VISIBLE_SERIES_COUNT,
36
36
  applyDriversComparisonViewToPayload,
37
37
  buildDriversComparisonChartData,
38
+ computeDriversComparisonHistoricalWindowFloor,
38
39
  formatSeriesImportance,
39
40
  getLagDisplayForView,
40
41
  mergeBacktestsChartData,
@@ -158,6 +159,27 @@ export function DriversComparisonChart({
158
159
  [datasetHistorical, mergedChartData],
159
160
  );
160
161
 
162
+ const historicalWindowFloor = useMemo(() => {
163
+ const overlappedMerged = mergeBacktestsChartData(payload);
164
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(
165
+ datasetHistorical,
166
+ overlappedMerged,
167
+ );
168
+ const sortedDrivers = [...(payload?.drivers ?? [])]
169
+ .filter(
170
+ d =>
171
+ d.normalized_series &&
172
+ Object.keys(d.normalized_series).some(
173
+ key => d.normalized_series![key] != null,
174
+ ),
175
+ )
176
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
177
+ const forecastIds = sortedDrivers.map(
178
+ (_, idx) => DRIVER_FORECAST_ID_BASE + idx,
179
+ );
180
+ return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
181
+ }, [payload, datasetHistorical]);
182
+
161
183
  const chartForecastData = useMemo((): ForecastItemData[] => {
162
184
  if (!payloadForView?.target?.normalized_series) return [];
163
185
  return sortedDriversWithData.map((driver, idx) => ({
@@ -193,8 +215,7 @@ export function DriversComparisonChart({
193
215
  }, [originalDriversById, sortedDriversWithData, viewTab]);
194
216
 
195
217
  const seriesInitKeyResolved =
196
- seriesInitKey ??
197
- `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
218
+ seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
198
219
 
199
220
  const backtestsSeriesInitKeyRef = useRef<string>('');
200
221
 
@@ -222,8 +243,14 @@ export function DriversComparisonChart({
222
243
  mergedWithHistorical,
223
244
  datasetHistorical,
224
245
  chartForecastData.map(f => f.id),
246
+ historicalWindowFloor,
225
247
  ),
226
- [chartForecastData, datasetHistorical, mergedWithHistorical],
248
+ [
249
+ chartForecastData,
250
+ datasetHistorical,
251
+ historicalWindowFloor,
252
+ mergedWithHistorical,
253
+ ],
227
254
  );
228
255
 
229
256
  const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
@@ -1,10 +1,19 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+
1
3
  import {
4
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
5
+ DRIVER_FORECAST_ID_BASE,
2
6
  applyDriversComparisonViewToPayload,
7
+ buildDriversComparisonChartData,
8
+ computeDriversComparisonHistoricalWindowFloor,
3
9
  formatLagMonthsLabel,
4
10
  getLagDisplayForView,
11
+ mergeBacktestsChartData,
12
+ mergeDatasetHistoricalWithBacktestsChartData,
5
13
  parseLagMonthsFromLabel,
6
14
  resolveDriverLagLabel,
7
15
  shiftNormalizedSeriesForward,
16
+ subtractMonthsFromMonthStart,
8
17
  } from './driversComparisonChart.helpers';
9
18
 
10
19
  describe('parseLagMonthsFromLabel', () => {
@@ -133,3 +142,173 @@ describe('applyDriversComparisonViewToPayload', () => {
133
142
  expect(lagged?.drivers[0].normalized_series['2015-07-01']).toBe(0.8);
134
143
  });
135
144
  });
145
+
146
+ describe('computeDriversComparisonHistoricalWindowFloor', () => {
147
+ const laggedDriverPayload = {
148
+ target: {
149
+ id: 'target',
150
+ name: 'Target',
151
+ normalized_series: {
152
+ '2014-10-01': 1.0,
153
+ '2014-11-01': 1.01,
154
+ '2014-12-01': 1.02,
155
+ '2015-01-01': 1.03,
156
+ '2015-02-01': 1.04,
157
+ '2015-03-01': 1.05,
158
+ '2015-04-01': 1.06,
159
+ '2015-05-01': 1.07,
160
+ },
161
+ },
162
+ drivers: [
163
+ {
164
+ id: 'd1',
165
+ name: 'Driver',
166
+ lag: '3 month(s)',
167
+ normalized_series: {
168
+ '2015-04-01': 0.5,
169
+ '2015-05-01': 0.6,
170
+ },
171
+ },
172
+ ],
173
+ };
174
+
175
+ const datasetHistorical: ChartDataPoint[] = [
176
+ { date: '2014-10-01', historical: 100 },
177
+ { date: '2014-11-01', historical: 101 },
178
+ { date: '2014-12-01', historical: 102 },
179
+ { date: '2015-01-01', historical: 103 },
180
+ { date: '2015-02-01', historical: 104 },
181
+ { date: '2015-03-01', historical: 105 },
182
+ { date: '2015-04-01', historical: 106 },
183
+ ];
184
+
185
+ const forecastIds = [DRIVER_FORECAST_ID_BASE];
186
+
187
+ it('returns anchor minus lead months from overlapped merged data', () => {
188
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(
189
+ datasetHistorical,
190
+ mergeBacktestsChartData(laggedDriverPayload),
191
+ );
192
+ const floor = computeDriversComparisonHistoricalWindowFloor(
193
+ merged,
194
+ forecastIds,
195
+ );
196
+ expect(floor).toBe(
197
+ subtractMonthsFromMonthStart(
198
+ '2015-04-01',
199
+ DRIVER_COMPARISON_CHART_LEAD_MONTHS,
200
+ ),
201
+ );
202
+ expect(floor).toBe('2014-10-01');
203
+ });
204
+ });
205
+
206
+ describe('buildDriversComparisonChartData historical window floor', () => {
207
+ const laggedDriverPayload = {
208
+ target: {
209
+ id: 'target',
210
+ name: 'Target',
211
+ normalized_series: {
212
+ '2014-10-01': 1.0,
213
+ '2014-11-01': 1.01,
214
+ '2014-12-01': 1.02,
215
+ '2015-01-01': 1.03,
216
+ '2015-02-01': 1.04,
217
+ '2015-03-01': 1.05,
218
+ '2015-04-01': 1.06,
219
+ '2015-05-01': 1.07,
220
+ },
221
+ },
222
+ drivers: [
223
+ {
224
+ id: 'd1',
225
+ name: 'Driver',
226
+ lag: '3 month(s)',
227
+ normalized_series: {
228
+ '2015-04-01': 0.5,
229
+ '2015-05-01': 0.6,
230
+ },
231
+ },
232
+ ],
233
+ };
234
+
235
+ const datasetHistorical: ChartDataPoint[] = [
236
+ { date: '2014-10-01', historical: 100 },
237
+ { date: '2014-11-01', historical: 101 },
238
+ { date: '2014-12-01', historical: 102 },
239
+ { date: '2015-01-01', historical: 103 },
240
+ { date: '2015-02-01', historical: 104 },
241
+ { date: '2015-03-01', historical: 105 },
242
+ { date: '2015-04-01', historical: 106 },
243
+ ];
244
+
245
+ const forecastIds = [DRIVER_FORECAST_ID_BASE];
246
+
247
+ it('keeps same xMin for lagged and overlapped when floor is pinned', () => {
248
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
249
+ datasetHistorical,
250
+ mergeBacktestsChartData(laggedDriverPayload),
251
+ );
252
+ const laggedPayload = applyDriversComparisonViewToPayload(
253
+ laggedDriverPayload,
254
+ 'lagged',
255
+ );
256
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
257
+ datasetHistorical,
258
+ mergeBacktestsChartData(laggedPayload),
259
+ );
260
+ const floor = computeDriversComparisonHistoricalWindowFloor(
261
+ overlappedMerged,
262
+ forecastIds,
263
+ )!;
264
+
265
+ const overlappedChart = buildDriversComparisonChartData(
266
+ overlappedMerged,
267
+ datasetHistorical,
268
+ forecastIds,
269
+ floor,
270
+ );
271
+ const laggedChart = buildDriversComparisonChartData(
272
+ laggedMerged,
273
+ datasetHistorical,
274
+ forecastIds,
275
+ floor,
276
+ );
277
+
278
+ expect(overlappedChart[0]?.date).toBe('2014-10-01');
279
+ expect(laggedChart[0]?.date).toBe('2014-10-01');
280
+ expect(laggedChart[0]?.date).toBe(overlappedChart[0]?.date);
281
+ });
282
+
283
+ it('without floor override lagged chart starts later than overlapped', () => {
284
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
285
+ datasetHistorical,
286
+ mergeBacktestsChartData(laggedDriverPayload),
287
+ );
288
+ const laggedPayload = applyDriversComparisonViewToPayload(
289
+ laggedDriverPayload,
290
+ 'lagged',
291
+ );
292
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
293
+ datasetHistorical,
294
+ mergeBacktestsChartData(laggedPayload),
295
+ );
296
+
297
+ const overlappedChart = buildDriversComparisonChartData(
298
+ overlappedMerged,
299
+ datasetHistorical,
300
+ forecastIds,
301
+ );
302
+ const laggedChart = buildDriversComparisonChartData(
303
+ laggedMerged,
304
+ datasetHistorical,
305
+ forecastIds,
306
+ );
307
+
308
+ expect(overlappedChart[0]?.date).toBe('2014-10-01');
309
+ expect(laggedChart[0]?.date).toBe('2015-01-01');
310
+ expect(laggedChart[0]?.date!.localeCompare(overlappedChart[0]?.date!)).toBe(
311
+ 1,
312
+ );
313
+ });
314
+ });
@@ -290,22 +290,41 @@ export function prependHistoricalLeadFromDataset(
290
290
  return [...lead, ...points];
291
291
  }
292
292
 
293
- export function buildDriversComparisonChartData(
293
+ /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
294
+ export function computeDriversComparisonHistoricalWindowFloor(
294
295
  mergedWithHistorical: ChartDataPoint[],
295
- datasetHistorical: ChartDataPoint[],
296
296
  forecastIds: number[],
297
- ): ChartDataPoint[] {
298
- if (mergedWithHistorical.length === 0) return mergedWithHistorical;
299
- if (forecastIds.length === 0) return mergedWithHistorical;
297
+ ): string | null {
298
+ if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
299
+ return null;
300
300
  const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(
301
301
  mergedWithHistorical,
302
302
  forecastIds,
303
303
  );
304
- if (anchorMonth === null) return mergedWithHistorical;
305
- const xMin = subtractMonthsFromMonthStart(
304
+ if (anchorMonth === null) return null;
305
+ return subtractMonthsFromMonthStart(
306
306
  anchorMonth,
307
307
  DRIVER_COMPARISON_CHART_LEAD_MONTHS,
308
308
  );
309
+ }
310
+
311
+ export function buildDriversComparisonChartData(
312
+ mergedWithHistorical: ChartDataPoint[],
313
+ datasetHistorical: ChartDataPoint[],
314
+ forecastIds: number[],
315
+ historicalWindowFloor?: string | null,
316
+ ): ChartDataPoint[] {
317
+ if (mergedWithHistorical.length === 0) return mergedWithHistorical;
318
+ if (forecastIds.length === 0) return mergedWithHistorical;
319
+
320
+ const xMin =
321
+ historicalWindowFloor ??
322
+ computeDriversComparisonHistoricalWindowFloor(
323
+ mergedWithHistorical,
324
+ forecastIds,
325
+ );
326
+ if (xMin === null) return mergedWithHistorical;
327
+
309
328
  const withLead = prependHistoricalLeadFromDataset(
310
329
  mergedWithHistorical,
311
330
  datasetHistorical,