@sybilion/uilib 1.3.62 → 1.3.64

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.
Files changed (19) hide show
  1. package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +10 -5
  2. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useChartYRange.js +111 -61
  3. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +2 -2
  4. package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +21 -5
  5. package/dist/esm/types/src/components/ui/Chart/components/BaseChartWrapper.d.ts +2 -0
  6. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.d.ts +15 -0
  7. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.test.d.ts +1 -0
  8. package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +3 -1
  9. package/dist/esm/types/src/docs/pages/IncludeHiddenInYScalePage.d.ts +1 -0
  10. package/package.json +1 -1
  11. package/src/components/ui/Chart/components/BaseChartWrapper.tsx +14 -4
  12. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.test.ts +87 -0
  13. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.ts +152 -73
  14. package/src/components/widgets/DriversComparisonChart/AGENT.md +3 -3
  15. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +2 -2
  16. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +65 -45
  17. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +22 -4
  18. package/src/docs/pages/IncludeHiddenInYScalePage.tsx +152 -0
  19. package/src/docs/registry.ts +6 -0
@@ -48,7 +48,7 @@ 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, 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;
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, includeHiddenInYScale = false, 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
53
  const lineDataInitializedRef = useRef(false);
54
54
  const rootRef = useRef(null);
@@ -291,12 +291,17 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
291
291
  let effectiveAutoScale = autoScaleYAxis;
292
292
  if (autoScaleYAxis !== false && yMin !== undefined && yMax !== undefined) {
293
293
  const dataKeys = chartData.length > 0 ? Object.keys(chartData[0]) : [];
294
- const historicalValues = chartData
295
- .map(p => p.historical)
296
- .filter(v => v !== null && v !== undefined);
294
+ const historicalValues = !includeHiddenInYScale && hiddenSeries?.has('historical')
295
+ ? []
296
+ : chartData
297
+ .map(p => p.historical)
298
+ .filter(v => v !== null && v !== undefined);
297
299
  const forecastKeys = dataKeys.filter(k => k.startsWith('forecast_'));
300
+ const visibleForecastKeys = !includeHiddenInYScale && hiddenSeries
301
+ ? forecastKeys.filter(k => !hiddenSeries.has(k))
302
+ : forecastKeys;
298
303
  const forecastValues = chartData
299
- .flatMap(p => forecastKeys.map(k => p[k]))
304
+ .flatMap(p => visibleForecastKeys.map(k => p[k]))
300
305
  .filter(v => v !== null && v !== undefined);
301
306
  const allLineValues = [...historicalValues, ...forecastValues];
302
307
  if (allLineValues.length > 0) {
@@ -1,75 +1,116 @@
1
1
  import { useRef, useMemo } from 'react';
2
2
 
3
+ /** Serialize hidden series keys for stable cache comparison. */
4
+ function serializeHiddenSeries(hiddenSeries) {
5
+ if (!hiddenSeries || hiddenSeries.size === 0)
6
+ return '';
7
+ return [...hiddenSeries].sort().join(',');
8
+ }
3
9
  /**
4
- * Hook to calculate yMin and yMax from chart data
5
- * Optionally includes quantile values from forecast data
10
+ * Returns true when a chart data key should be skipped for Y-scale (series hidden).
6
11
  */
7
- function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileChanges, excludeQuantileBands = false, selectedForecastId, }) {
8
- // Store initial Y-range when disableRescaleWhenQuantileChanges is true
9
- const stableYRangeRef = useRef(null);
10
- const prevBaseChartDataRef = useRef(baseChartProps.chartData);
11
- // Helper function to calculate Y-range from data
12
- const calculateYRange = (dataToUse, excludeQuantileBands, forecastId) => {
13
- const allValues = [];
14
- dataToUse.forEach(point => {
15
- Object.entries(point).forEach(([key, value]) => {
16
- if (key === 'date')
12
+ function isKeyExcludedFromYScale(key, hiddenSeries) {
13
+ if (!hiddenSeries || hiddenSeries.size === 0)
14
+ return false;
15
+ if (key === 'historical') {
16
+ return hiddenSeries.has('historical');
17
+ }
18
+ if (key.startsWith('forecast_')) {
19
+ return hiddenSeries.has(key);
20
+ }
21
+ if (key.startsWith('q') && key.includes('_')) {
22
+ const forecastId = key.slice(key.lastIndexOf('_') + 1);
23
+ return hiddenSeries.has(`forecast_${forecastId}`);
24
+ }
25
+ if (key.startsWith('band_')) {
26
+ const forecastId = key.slice(key.lastIndexOf('_') + 1);
27
+ return hiddenSeries.has(`forecast_${forecastId}`);
28
+ }
29
+ return false;
30
+ }
31
+ /** Pure Y-range calculation from chart data points. */
32
+ function calculateChartYRange(dataToUse, { excludeQuantileBands, forecastId, hiddenSeries, includeHiddenInYScale = false, }) {
33
+ const allValues = [];
34
+ dataToUse.forEach(point => {
35
+ Object.entries(point).forEach(([key, value]) => {
36
+ if (key === 'date')
37
+ return;
38
+ if (!includeHiddenInYScale &&
39
+ isKeyExcludedFromYScale(key, hiddenSeries)) {
40
+ return;
41
+ }
42
+ // When selectedForecastId is provided, scale from historical + selected forecast + its
43
+ // quantile band (exclude other forecasts and their quantile values only).
44
+ if (forecastId !== undefined) {
45
+ if (key.startsWith('forecast_') && key !== `forecast_${forecastId}`)
46
+ return;
47
+ // Exclude q{quantile}_{otherAnalysisId} - only include selected forecast's quantiles
48
+ if (key.startsWith('q') && !key.endsWith(`_${forecastId}`))
49
+ return;
50
+ }
51
+ // When excludeQuantileBands is true, exclude:
52
+ // 1. Quantile band arrays ([number, number])
53
+ // 2. Individual quantile values (q{quantile}_{analysisId} properties)
54
+ // Only include historical and forecast line values
55
+ if (excludeQuantileBands) {
56
+ // Skip quantile band arrays
57
+ if (Array.isArray(value) &&
58
+ value.length === 2 &&
59
+ typeof value[0] === 'number' &&
60
+ typeof value[1] === 'number') {
17
61
  return;
18
- // When selectedForecastId is provided, scale from historical + selected forecast + its
19
- // quantile band (exclude other forecasts and their quantile values only).
20
- if (forecastId !== undefined) {
21
- if (key.startsWith('forecast_') && key !== `forecast_${forecastId}`)
22
- return;
23
- // Exclude q{quantile}_{otherAnalysisId} - only include selected forecast's quantiles
24
- if (key.startsWith('q') && !key.endsWith(`_${forecastId}`))
25
- return;
26
62
  }
27
- // When excludeQuantileBands is true, exclude:
28
- // 1. Quantile band arrays ([number, number])
29
- // 2. Individual quantile values (q{quantile}_{analysisId} properties)
30
- // Only include historical and forecast line values
31
- if (excludeQuantileBands) {
32
- // Skip quantile band arrays
33
- if (Array.isArray(value) &&
34
- value.length === 2 &&
35
- typeof value[0] === 'number' &&
36
- typeof value[1] === 'number') {
37
- return;
38
- }
39
- // Skip individual quantile values (properties starting with 'q' followed by number/underscore)
40
- // Format: q{quantile}_{analysisId} (e.g., q0.05_123, q0.95_123)
41
- if (key.startsWith('q') && typeof value === 'number') {
42
- return;
43
- }
44
- // Include only historical and forecast line values
45
- if (typeof value === 'number') {
46
- allValues.push(value);
47
- }
63
+ // Skip individual quantile values (properties starting with 'q' followed by number/underscore)
64
+ // Format: q{quantile}_{analysisId} (e.g., q0.05_123, q0.95_123)
65
+ if (key.startsWith('q') && typeof value === 'number') {
48
66
  return;
49
67
  }
50
- // When not excluding, include both numbers and quantile band arrays
68
+ // Include only historical and forecast line values
51
69
  if (typeof value === 'number') {
52
70
  allValues.push(value);
53
71
  }
54
- else if (Array.isArray(value) &&
55
- value.length === 2 &&
56
- typeof value[0] === 'number' &&
57
- typeof value[1] === 'number') {
58
- allValues.push(value[0], value[1]);
59
- }
60
- });
72
+ return;
73
+ }
74
+ // When not excluding, include both numbers and quantile band arrays
75
+ if (typeof value === 'number') {
76
+ allValues.push(value);
77
+ }
78
+ else if (Array.isArray(value) &&
79
+ value.length === 2 &&
80
+ typeof value[0] === 'number' &&
81
+ typeof value[1] === 'number') {
82
+ allValues.push(value[0], value[1]);
83
+ }
61
84
  });
62
- if (allValues.length === 0) {
63
- return { yMin: 0, yMax: 100 };
64
- }
65
- const min = Math.min(...allValues);
66
- const max = Math.max(...allValues);
67
- const diff = max - min;
68
- return {
69
- yMin: min - diff * 0.1,
70
- yMax: max + diff * 0.1,
71
- };
85
+ });
86
+ if (allValues.length === 0) {
87
+ return { yMin: 0, yMax: 100 };
88
+ }
89
+ const min = Math.min(...allValues);
90
+ const max = Math.max(...allValues);
91
+ const diff = max - min;
92
+ return {
93
+ yMin: min - diff * 0.1,
94
+ yMax: max + diff * 0.1,
72
95
  };
96
+ }
97
+ /**
98
+ * Hook to calculate yMin and yMax from chart data
99
+ * Optionally includes quantile values from forecast data
100
+ */
101
+ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileChanges, excludeQuantileBands = false, selectedForecastId, }) {
102
+ const includeHiddenInYScale = baseChartProps.includeHiddenInYScale ?? false;
103
+ const hiddenSeries = baseChartProps.hiddenSeries;
104
+ // Store initial Y-range when disableRescaleWhenQuantileChanges is true
105
+ const stableYRangeRef = useRef(null);
106
+ const prevBaseChartDataRef = useRef(baseChartProps.chartData);
107
+ const prevHiddenSeriesKeyRef = useRef(serializeHiddenSeries(hiddenSeries));
108
+ const calculateYRange = (dataToUse, excludeBands, forecastId) => calculateChartYRange(dataToUse, {
109
+ excludeQuantileBands: excludeBands,
110
+ forecastId,
111
+ hiddenSeries,
112
+ includeHiddenInYScale,
113
+ });
73
114
  // When disableRescaleWhenQuantileChanges is true, calculate initial stable range
74
115
  // If chartData (transformedChartData) is provided, use it WITH quantile bands for initial calculation
75
116
  // Then keep it stable when dragging quantiles
@@ -84,7 +125,7 @@ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileC
84
125
  if (!disableRescaleWhenQuantileChanges) {
85
126
  return null;
86
127
  }
87
- // Reset stable range when dataset or selected forecast changes
128
+ // Reset stable range when dataset, selected forecast, or hidden series changes
88
129
  if (prevBaseChartDataRef.current !== baseChartProps.chartData) {
89
130
  prevBaseChartDataRef.current = baseChartProps.chartData;
90
131
  stableYRangeRef.current = null;
@@ -92,6 +133,11 @@ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileC
92
133
  if (stableYRangeRef.current?.forecastId !== selectedForecastId) {
93
134
  stableYRangeRef.current = null;
94
135
  }
136
+ const hiddenSeriesKey = serializeHiddenSeries(hiddenSeries);
137
+ if (prevHiddenSeriesKeyRef.current !== hiddenSeriesKey) {
138
+ prevHiddenSeriesKeyRef.current = hiddenSeriesKey;
139
+ stableYRangeRef.current = null;
140
+ }
95
141
  // If chartData (transformedChartData) is provided, use it WITH quantile bands for initial calculation
96
142
  // This ensures IntervalsOverlay includes quantile bands in Y-scale
97
143
  const dataToUse = chartData ?? baseChartProps.chartData;
@@ -115,6 +161,8 @@ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileC
115
161
  disableRescaleWhenQuantileChanges,
116
162
  excludeQuantileBands,
117
163
  selectedForecastId,
164
+ hiddenSeries,
165
+ includeHiddenInYScale,
118
166
  ]);
119
167
  // When disableRescaleWhenQuantileChanges is false, calculate from transformed data
120
168
  const dynamicRange = useMemo(() => {
@@ -138,6 +186,8 @@ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileC
138
186
  disableRescaleWhenQuantileChanges,
139
187
  excludeQuantileBands,
140
188
  selectedForecastId,
189
+ hiddenSeries,
190
+ includeHiddenInYScale,
141
191
  ]);
142
192
  // Return appropriate range
143
193
  if (disableRescaleWhenQuantileChanges) {
@@ -150,4 +200,4 @@ function useChartYRange({ baseChartProps, chartData, disableRescaleWhenQuantileC
150
200
  return dynamicRange;
151
201
  }
152
202
 
153
- export { useChartYRange };
203
+ export { calculateChartYRange, isKeyExcludedFromYScale, useChartYRange };
@@ -83,8 +83,8 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
83
83
  const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
84
84
  const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
85
85
  const historicalWindowFloor = useMemo(() => {
86
- const overlappedMerged = mergeBacktestsChartData(payload);
87
- const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, overlappedMerged);
86
+ const laggedMerged = mergeBacktestsChartData(payload);
87
+ const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, laggedMerged);
88
88
  const sortedDrivers = [...(payload?.drivers ?? [])]
89
89
  .filter(d => d.normalized_series &&
90
90
  Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
@@ -57,7 +57,7 @@ function formatLagMonthsLabel(months) {
57
57
  return `${months} month(s)`;
58
58
  }
59
59
  function getLagDisplayForView(lag, tab) {
60
- if (tab === 'overlapped') {
60
+ if (tab === 'lagged') {
61
61
  return lag?.trim() ? lag : '—';
62
62
  }
63
63
  const months = parseLagMonthsFromLabel(lag);
@@ -78,8 +78,24 @@ function shiftNormalizedSeriesForward(series, lagMonths) {
78
78
  }
79
79
  return out;
80
80
  }
81
+ /** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
82
+ function shiftNormalizedSeriesBackward(series, lagMonths) {
83
+ if (lagMonths <= 0)
84
+ return { ...series };
85
+ const out = {};
86
+ for (const [dateStr, val] of Object.entries(series)) {
87
+ if (val === null || val === undefined)
88
+ continue;
89
+ let shifted = normalizeToMonthStart(dateStr);
90
+ for (let i = 0; i < lagMonths; i++) {
91
+ shifted = getPreviousMonth(shifted);
92
+ }
93
+ out[shifted] = val;
94
+ }
95
+ return out;
96
+ }
81
97
  function applyDriversComparisonViewToPayload(payload, tab) {
82
- if (!payload || tab === 'overlapped')
98
+ if (!payload || tab === 'lagged')
83
99
  return payload;
84
100
  return {
85
101
  target: {
@@ -90,7 +106,7 @@ function applyDriversComparisonViewToPayload(payload, tab) {
90
106
  const lagMonths = parseLagMonthsFromLabel(resolveDriverLagLabel(driver));
91
107
  const series = driver.normalized_series ?? {};
92
108
  const normalized_series = lagMonths != null && lagMonths > 0
93
- ? shiftNormalizedSeriesForward(series, lagMonths)
109
+ ? shiftNormalizedSeriesBackward(series, lagMonths)
94
110
  : { ...series };
95
111
  return {
96
112
  ...driver,
@@ -231,7 +247,7 @@ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
231
247
  }
232
248
  return [...lead, ...points];
233
249
  }
234
- /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
250
+ /** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
235
251
  function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds) {
236
252
  if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
237
253
  return null;
@@ -253,4 +269,4 @@ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical
253
269
  return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
254
270
  }
255
271
 
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 };
272
+ 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, shiftNormalizedSeriesBackward, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
@@ -58,6 +58,8 @@ export interface BaseChartWrapperProps {
58
58
  showActiveDots?: boolean;
59
59
  overlayElements?: ReactNode;
60
60
  hiddenSeries?: Set<string>;
61
+ /** When false (default), hidden series values are excluded from Y-axis domain calculation. */
62
+ includeHiddenInYScale?: boolean;
61
63
  excludeLegendIds?: number[];
62
64
  onAnalysisSelect?: (analysisId: number | string) => void;
63
65
  onFailedAnalysisClick?: (analysisId?: number | string) => void;
@@ -7,6 +7,21 @@ interface UseChartYRangeOptions {
7
7
  excludeQuantileBands?: boolean;
8
8
  selectedForecastId?: number;
9
9
  }
10
+ export interface CalculateChartYRangeOptions {
11
+ excludeQuantileBands: boolean;
12
+ forecastId?: number;
13
+ hiddenSeries?: Set<string>;
14
+ includeHiddenInYScale?: boolean;
15
+ }
16
+ /**
17
+ * Returns true when a chart data key should be skipped for Y-scale (series hidden).
18
+ */
19
+ export declare function isKeyExcludedFromYScale(key: string, hiddenSeries?: Set<string>): boolean;
20
+ /** Pure Y-range calculation from chart data points. */
21
+ export declare function calculateChartYRange(dataToUse: ChartDataPoint[], { excludeQuantileBands, forecastId, hiddenSeries, includeHiddenInYScale, }: CalculateChartYRangeOptions): {
22
+ yMin: number;
23
+ yMax: number;
24
+ };
10
25
  /**
11
26
  * Hook to calculate yMin and yMax from chart data
12
27
  * Optionally includes quantile values from forecast data
@@ -16,6 +16,8 @@ export declare function parseLagMonthsFromLabel(lag: string | null | undefined):
16
16
  export declare function formatLagMonthsLabel(months: number): string;
17
17
  export declare function getLagDisplayForView(lag: string | null | undefined, tab: DriversComparisonViewTab): string;
18
18
  export declare function shiftNormalizedSeriesForward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
19
+ /** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
20
+ export declare function shiftNormalizedSeriesBackward(series: Record<string, number | null>, lagMonths: number): Record<string, number | null>;
19
21
  export declare function applyDriversComparisonViewToPayload(payload: BacktestsComponentPayload | null, tab: DriversComparisonViewTab): BacktestsComponentPayload | null;
20
22
  export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
21
23
  /** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
@@ -35,6 +37,6 @@ export declare function subtractMonthsFromMonthStart(date: string, count: number
35
37
  * (scaled to the first normalized point) so the lead-in shows the target line alone.
36
38
  */
37
39
  export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
38
- /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
40
+ /** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
39
41
  export declare function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical: ChartDataPoint[], forecastIds: number[]): string | null;
40
42
  export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[], historicalWindowFloor?: string | null): ChartDataPoint[];
@@ -0,0 +1 @@
1
+ export default function IncludeHiddenInYScalePage(): import("react/jsx-runtime").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.62",
3
+ "version": "1.3.64",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -121,6 +121,8 @@ export interface BaseChartWrapperProps {
121
121
  showActiveDots?: boolean;
122
122
  overlayElements?: ReactNode;
123
123
  hiddenSeries?: Set<string>;
124
+ /** When false (default), hidden series values are excluded from Y-axis domain calculation. */
125
+ includeHiddenInYScale?: boolean;
124
126
  excludeLegendIds?: number[];
125
127
  onAnalysisSelect?: (analysisId: number | string) => void;
126
128
  onFailedAnalysisClick?: (analysisId?: number | string) => void;
@@ -225,6 +227,7 @@ const BaseChartWrapperContent = forwardRef<
225
227
  showActiveDots = true,
226
228
  overlayElements,
227
229
  hiddenSeries,
230
+ includeHiddenInYScale = false,
228
231
  excludeLegendIds,
229
232
  onAnalysisSelect,
230
233
  onFailedAnalysisClick,
@@ -567,12 +570,19 @@ const BaseChartWrapperContent = forwardRef<
567
570
  let effectiveAutoScale = autoScaleYAxis;
568
571
  if (autoScaleYAxis !== false && yMin !== undefined && yMax !== undefined) {
569
572
  const dataKeys = chartData.length > 0 ? Object.keys(chartData[0]) : [];
570
- const historicalValues = chartData
571
- .map(p => p.historical)
572
- .filter(v => v !== null && v !== undefined);
573
+ const historicalValues =
574
+ !includeHiddenInYScale && hiddenSeries?.has('historical')
575
+ ? []
576
+ : chartData
577
+ .map(p => p.historical)
578
+ .filter(v => v !== null && v !== undefined);
573
579
  const forecastKeys = dataKeys.filter(k => k.startsWith('forecast_'));
580
+ const visibleForecastKeys =
581
+ !includeHiddenInYScale && hiddenSeries
582
+ ? forecastKeys.filter(k => !hiddenSeries.has(k))
583
+ : forecastKeys;
574
584
  const forecastValues = chartData
575
- .flatMap(p => forecastKeys.map(k => p[k]))
585
+ .flatMap(p => visibleForecastKeys.map(k => p[k]))
576
586
  .filter(v => v !== null && v !== undefined);
577
587
  const allLineValues = [...historicalValues, ...forecastValues];
578
588
 
@@ -0,0 +1,87 @@
1
+ import { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+
3
+ import {
4
+ calculateChartYRange,
5
+ isKeyExcludedFromYScale,
6
+ } from './useChartYRange';
7
+
8
+ const makePoint = (values: Record<string, number>): ChartDataPoint => ({
9
+ date: '2024-01-01',
10
+ ...values,
11
+ });
12
+
13
+ describe('isKeyExcludedFromYScale', () => {
14
+ it('excludes historical when hidden', () => {
15
+ expect(isKeyExcludedFromYScale('historical', new Set(['historical']))).toBe(
16
+ true,
17
+ );
18
+ });
19
+
20
+ it('excludes quantile keys when parent forecast is hidden', () => {
21
+ expect(
22
+ isKeyExcludedFromYScale('q0.05_123', new Set(['forecast_123'])),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it('excludes band keys when parent forecast is hidden', () => {
27
+ expect(
28
+ isKeyExcludedFromYScale('band_10_90_123', new Set(['forecast_123'])),
29
+ ).toBe(true);
30
+ });
31
+ });
32
+
33
+ describe('calculateChartYRange hidden series exclusion', () => {
34
+ const chartData: ChartDataPoint[] = [
35
+ makePoint({ historical: 10, forecast_1: 100, forecast_2: 50 }),
36
+ ];
37
+
38
+ it('pin mode: two forecasts, one hidden - Y range from visible only', () => {
39
+ const hiddenSeries = new Set(['forecast_1']);
40
+ const range = calculateChartYRange(chartData, {
41
+ excludeQuantileBands: true,
42
+ hiddenSeries,
43
+ includeHiddenInYScale: false,
44
+ });
45
+
46
+ expect(range).toEqual({ yMin: 6, yMax: 54 });
47
+ });
48
+
49
+ it('hidden historical excluded from Y range', () => {
50
+ const dataWithOutlierHistorical: ChartDataPoint[] = [
51
+ makePoint({ historical: 1000, forecast_1: 50, forecast_2: 60 }),
52
+ ];
53
+ const hiddenSeries = new Set(['historical']);
54
+ const range = calculateChartYRange(dataWithOutlierHistorical, {
55
+ excludeQuantileBands: true,
56
+ hiddenSeries,
57
+ includeHiddenInYScale: false,
58
+ });
59
+
60
+ expect(range).toEqual({ yMin: 49, yMax: 61 });
61
+ });
62
+
63
+ it('includeHiddenInYScale=true preserves old behavior', () => {
64
+ const hiddenSeries = new Set(['forecast_1']);
65
+ const range = calculateChartYRange(chartData, {
66
+ excludeQuantileBands: true,
67
+ hiddenSeries,
68
+ includeHiddenInYScale: true,
69
+ });
70
+
71
+ expect(range).toEqual({ yMin: 1, yMax: 109 });
72
+ });
73
+
74
+ it('quantile keys excluded when forecast hidden', () => {
75
+ const dataWithQuantile: ChartDataPoint[] = [
76
+ makePoint({ forecast_456: 50, forecast_789: 70, 'q0.05_123': 500 }),
77
+ ];
78
+ const hiddenSeries = new Set(['forecast_123']);
79
+ const range = calculateChartYRange(dataWithQuantile, {
80
+ excludeQuantileBands: false,
81
+ hiddenSeries,
82
+ includeHiddenInYScale: false,
83
+ });
84
+
85
+ expect(range).toEqual({ yMin: 48, yMax: 72 });
86
+ });
87
+ });
@@ -11,6 +11,137 @@ interface UseChartYRangeOptions {
11
11
  selectedForecastId?: number;
12
12
  }
13
13
 
14
+ export interface CalculateChartYRangeOptions {
15
+ excludeQuantileBands: boolean;
16
+ forecastId?: number;
17
+ hiddenSeries?: Set<string>;
18
+ includeHiddenInYScale?: boolean;
19
+ }
20
+
21
+ /** Serialize hidden series keys for stable cache comparison. */
22
+ function serializeHiddenSeries(hiddenSeries?: Set<string>): string {
23
+ if (!hiddenSeries || hiddenSeries.size === 0) return '';
24
+ return [...hiddenSeries].sort().join(',');
25
+ }
26
+
27
+ /**
28
+ * Returns true when a chart data key should be skipped for Y-scale (series hidden).
29
+ */
30
+ export function isKeyExcludedFromYScale(
31
+ key: string,
32
+ hiddenSeries?: Set<string>,
33
+ ): boolean {
34
+ if (!hiddenSeries || hiddenSeries.size === 0) return false;
35
+
36
+ if (key === 'historical') {
37
+ return hiddenSeries.has('historical');
38
+ }
39
+
40
+ if (key.startsWith('forecast_')) {
41
+ return hiddenSeries.has(key);
42
+ }
43
+
44
+ if (key.startsWith('q') && key.includes('_')) {
45
+ const forecastId = key.slice(key.lastIndexOf('_') + 1);
46
+ return hiddenSeries.has(`forecast_${forecastId}`);
47
+ }
48
+
49
+ if (key.startsWith('band_')) {
50
+ const forecastId = key.slice(key.lastIndexOf('_') + 1);
51
+ return hiddenSeries.has(`forecast_${forecastId}`);
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ /** Pure Y-range calculation from chart data points. */
58
+ export function calculateChartYRange(
59
+ dataToUse: ChartDataPoint[],
60
+ {
61
+ excludeQuantileBands,
62
+ forecastId,
63
+ hiddenSeries,
64
+ includeHiddenInYScale = false,
65
+ }: CalculateChartYRangeOptions,
66
+ ) {
67
+ const allValues: number[] = [];
68
+
69
+ dataToUse.forEach(point => {
70
+ Object.entries(point).forEach(([key, value]) => {
71
+ if (key === 'date') return;
72
+
73
+ if (
74
+ !includeHiddenInYScale &&
75
+ isKeyExcludedFromYScale(key, hiddenSeries)
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ // When selectedForecastId is provided, scale from historical + selected forecast + its
81
+ // quantile band (exclude other forecasts and their quantile values only).
82
+ if (forecastId !== undefined) {
83
+ if (key.startsWith('forecast_') && key !== `forecast_${forecastId}`)
84
+ return;
85
+ // Exclude q{quantile}_{otherAnalysisId} - only include selected forecast's quantiles
86
+ if (key.startsWith('q') && !key.endsWith(`_${forecastId}`)) return;
87
+ }
88
+
89
+ // When excludeQuantileBands is true, exclude:
90
+ // 1. Quantile band arrays ([number, number])
91
+ // 2. Individual quantile values (q{quantile}_{analysisId} properties)
92
+ // Only include historical and forecast line values
93
+ if (excludeQuantileBands) {
94
+ // Skip quantile band arrays
95
+ if (
96
+ Array.isArray(value) &&
97
+ value.length === 2 &&
98
+ typeof value[0] === 'number' &&
99
+ typeof value[1] === 'number'
100
+ ) {
101
+ return;
102
+ }
103
+
104
+ // Skip individual quantile values (properties starting with 'q' followed by number/underscore)
105
+ // Format: q{quantile}_{analysisId} (e.g., q0.05_123, q0.95_123)
106
+ if (key.startsWith('q') && typeof value === 'number') {
107
+ return;
108
+ }
109
+
110
+ // Include only historical and forecast line values
111
+ if (typeof value === 'number') {
112
+ allValues.push(value);
113
+ }
114
+ return;
115
+ }
116
+
117
+ // When not excluding, include both numbers and quantile band arrays
118
+ if (typeof value === 'number') {
119
+ allValues.push(value);
120
+ } else if (
121
+ Array.isArray(value) &&
122
+ value.length === 2 &&
123
+ typeof value[0] === 'number' &&
124
+ typeof value[1] === 'number'
125
+ ) {
126
+ allValues.push(value[0], value[1]);
127
+ }
128
+ });
129
+ });
130
+
131
+ if (allValues.length === 0) {
132
+ return { yMin: 0, yMax: 100 };
133
+ }
134
+
135
+ const min = Math.min(...allValues);
136
+ const max = Math.max(...allValues);
137
+ const diff = max - min;
138
+
139
+ return {
140
+ yMin: min - diff * 0.1,
141
+ yMax: max + diff * 0.1,
142
+ };
143
+ }
144
+
14
145
  /**
15
146
  * Hook to calculate yMin and yMax from chart data
16
147
  * Optionally includes quantile values from forecast data
@@ -22,6 +153,9 @@ export function useChartYRange({
22
153
  excludeQuantileBands = false,
23
154
  selectedForecastId,
24
155
  }: UseChartYRangeOptions) {
156
+ const includeHiddenInYScale = baseChartProps.includeHiddenInYScale ?? false;
157
+ const hiddenSeries = baseChartProps.hiddenSeries;
158
+
25
159
  // Store initial Y-range when disableRescaleWhenQuantileChanges is true
26
160
  const stableYRangeRef = useRef<{
27
161
  yMin: number;
@@ -29,84 +163,20 @@ export function useChartYRange({
29
163
  forecastId?: number;
30
164
  } | null>(null);
31
165
  const prevBaseChartDataRef = useRef(baseChartProps.chartData);
166
+ const prevHiddenSeriesKeyRef = useRef(serializeHiddenSeries(hiddenSeries));
32
167
 
33
- // Helper function to calculate Y-range from data
34
168
  const calculateYRange = (
35
169
  dataToUse: ChartDataPoint[],
36
- excludeQuantileBands: boolean,
170
+ excludeBands: boolean,
37
171
  forecastId?: number,
38
- ) => {
39
- const allValues: number[] = [];
40
-
41
- dataToUse.forEach(point => {
42
- Object.entries(point).forEach(([key, value]) => {
43
- if (key === 'date') return;
44
-
45
- // When selectedForecastId is provided, scale from historical + selected forecast + its
46
- // quantile band (exclude other forecasts and their quantile values only).
47
- if (forecastId !== undefined) {
48
- if (key.startsWith('forecast_') && key !== `forecast_${forecastId}`)
49
- return;
50
- // Exclude q{quantile}_{otherAnalysisId} - only include selected forecast's quantiles
51
- if (key.startsWith('q') && !key.endsWith(`_${forecastId}`)) return;
52
- }
53
-
54
- // When excludeQuantileBands is true, exclude:
55
- // 1. Quantile band arrays ([number, number])
56
- // 2. Individual quantile values (q{quantile}_{analysisId} properties)
57
- // Only include historical and forecast line values
58
- if (excludeQuantileBands) {
59
- // Skip quantile band arrays
60
- if (
61
- Array.isArray(value) &&
62
- value.length === 2 &&
63
- typeof value[0] === 'number' &&
64
- typeof value[1] === 'number'
65
- ) {
66
- return;
67
- }
68
-
69
- // Skip individual quantile values (properties starting with 'q' followed by number/underscore)
70
- // Format: q{quantile}_{analysisId} (e.g., q0.05_123, q0.95_123)
71
- if (key.startsWith('q') && typeof value === 'number') {
72
- return;
73
- }
74
-
75
- // Include only historical and forecast line values
76
- if (typeof value === 'number') {
77
- allValues.push(value);
78
- }
79
- return;
80
- }
81
-
82
- // When not excluding, include both numbers and quantile band arrays
83
- if (typeof value === 'number') {
84
- allValues.push(value);
85
- } else if (
86
- Array.isArray(value) &&
87
- value.length === 2 &&
88
- typeof value[0] === 'number' &&
89
- typeof value[1] === 'number'
90
- ) {
91
- allValues.push(value[0], value[1]);
92
- }
93
- });
172
+ ) =>
173
+ calculateChartYRange(dataToUse, {
174
+ excludeQuantileBands: excludeBands,
175
+ forecastId,
176
+ hiddenSeries,
177
+ includeHiddenInYScale,
94
178
  });
95
179
 
96
- if (allValues.length === 0) {
97
- return { yMin: 0, yMax: 100 };
98
- }
99
-
100
- const min = Math.min(...allValues);
101
- const max = Math.max(...allValues);
102
- const diff = max - min;
103
-
104
- return {
105
- yMin: min - diff * 0.1,
106
- yMax: max + diff * 0.1,
107
- };
108
- };
109
-
110
180
  // When disableRescaleWhenQuantileChanges is true, calculate initial stable range
111
181
  // If chartData (transformedChartData) is provided, use it WITH quantile bands for initial calculation
112
182
  // Then keep it stable when dragging quantiles
@@ -125,7 +195,7 @@ export function useChartYRange({
125
195
  return null;
126
196
  }
127
197
 
128
- // Reset stable range when dataset or selected forecast changes
198
+ // Reset stable range when dataset, selected forecast, or hidden series changes
129
199
  if (prevBaseChartDataRef.current !== baseChartProps.chartData) {
130
200
  prevBaseChartDataRef.current = baseChartProps.chartData;
131
201
  stableYRangeRef.current = null;
@@ -133,6 +203,11 @@ export function useChartYRange({
133
203
  if (stableYRangeRef.current?.forecastId !== selectedForecastId) {
134
204
  stableYRangeRef.current = null;
135
205
  }
206
+ const hiddenSeriesKey = serializeHiddenSeries(hiddenSeries);
207
+ if (prevHiddenSeriesKeyRef.current !== hiddenSeriesKey) {
208
+ prevHiddenSeriesKeyRef.current = hiddenSeriesKey;
209
+ stableYRangeRef.current = null;
210
+ }
136
211
 
137
212
  // If chartData (transformedChartData) is provided, use it WITH quantile bands for initial calculation
138
213
  // This ensures IntervalsOverlay includes quantile bands in Y-scale
@@ -164,6 +239,8 @@ export function useChartYRange({
164
239
  disableRescaleWhenQuantileChanges,
165
240
  excludeQuantileBands,
166
241
  selectedForecastId,
242
+ hiddenSeries,
243
+ includeHiddenInYScale,
167
244
  ]);
168
245
 
169
246
  // When disableRescaleWhenQuantileChanges is false, calculate from transformed data
@@ -192,6 +269,8 @@ export function useChartYRange({
192
269
  disableRescaleWhenQuantileChanges,
193
270
  excludeQuantileBands,
194
271
  selectedForecastId,
272
+ hiddenSeries,
273
+ includeHiddenInYScale,
195
274
  ]);
196
275
 
197
276
  // Return appropriate range
@@ -10,7 +10,7 @@ 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)
13
+ - `viewTab` / `onViewTabChange`: `lagged` (calendar-aligned, raw dates) or `overlapped` (driver series shifted backward by parsed lag months)
14
14
 
15
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.
16
16
 
@@ -18,8 +18,8 @@ Report tile: `drivers_comparison_chart` — host loads normalized backtests payl
18
18
 
19
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
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).
21
+ Lag column: **Lagged** shows API `lag` string (may be a range). **Overlapped** 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.
23
+ Historical window: lead-in is anchored to the **lagged** (unshifted) view (`computeDriversComparisonHistoricalWindowFloor`); overlapped tab shifts driver lines backward only — switching tabs does not change historical extent when floor is pinned.
24
24
 
25
25
  Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
@@ -160,10 +160,10 @@ export function DriversComparisonChart({
160
160
  );
161
161
 
162
162
  const historicalWindowFloor = useMemo(() => {
163
- const overlappedMerged = mergeBacktestsChartData(payload);
163
+ const laggedMerged = mergeBacktestsChartData(payload);
164
164
  const merged = mergeDatasetHistoricalWithBacktestsChartData(
165
165
  datasetHistorical,
166
- overlappedMerged,
166
+ laggedMerged,
167
167
  );
168
168
  const sortedDrivers = [...(payload?.drivers ?? [])]
169
169
  .filter(
@@ -12,6 +12,7 @@ import {
12
12
  mergeDatasetHistoricalWithBacktestsChartData,
13
13
  parseLagMonthsFromLabel,
14
14
  resolveDriverLagLabel,
15
+ shiftNormalizedSeriesBackward,
15
16
  shiftNormalizedSeriesForward,
16
17
  subtractMonthsFromMonthStart,
17
18
  } from './driversComparisonChart.helpers';
@@ -58,17 +59,17 @@ describe('formatLagMonthsLabel', () => {
58
59
  });
59
60
 
60
61
  describe('getLagDisplayForView', () => {
61
- it('shows API lag on overlapped tab', () => {
62
- expect(getLagDisplayForView('9 to 12 month(s)', 'overlapped')).toBe(
62
+ it('shows API lag on lagged tab', () => {
63
+ expect(getLagDisplayForView('9 to 12 month(s)', 'lagged')).toBe(
63
64
  '9 to 12 month(s)',
64
65
  );
65
66
  });
66
67
 
67
- it('shows parsed month on lagged tab', () => {
68
- expect(getLagDisplayForView('9 to 12 month(s)', 'lagged')).toBe(
68
+ it('shows parsed month on overlapped tab', () => {
69
+ expect(getLagDisplayForView('9 to 12 month(s)', 'overlapped')).toBe(
69
70
  '12 month(s)',
70
71
  );
71
- expect(getLagDisplayForView('Unknown', 'lagged')).toBe('—');
72
+ expect(getLagDisplayForView('Unknown', 'overlapped')).toBe('—');
72
73
  });
73
74
  });
74
75
 
@@ -90,6 +91,24 @@ describe('shiftNormalizedSeriesForward', () => {
90
91
  });
91
92
  });
92
93
 
94
+ describe('shiftNormalizedSeriesBackward', () => {
95
+ it('moves points backward by lag months', () => {
96
+ const shifted = shiftNormalizedSeriesBackward(
97
+ { '2015-03-01': 1.2, '2015-04-01': 1.4 },
98
+ 2,
99
+ );
100
+ expect(shifted['2015-01-01']).toBe(1.2);
101
+ expect(shifted['2015-02-01']).toBe(1.4);
102
+ });
103
+
104
+ it('returns a shallow copy when lag is zero', () => {
105
+ const series = { '2015-01-01': 1 };
106
+ const shifted = shiftNormalizedSeriesBackward(series, 0);
107
+ expect(shifted).toEqual(series);
108
+ expect(shifted).not.toBe(series);
109
+ });
110
+ });
111
+
93
112
  describe('applyDriversComparisonViewToPayload', () => {
94
113
  const payload = {
95
114
  target: {
@@ -107,18 +126,19 @@ describe('applyDriversComparisonViewToPayload', () => {
107
126
  ],
108
127
  };
109
128
 
110
- it('returns original payload for overlapped tab', () => {
111
- expect(applyDriversComparisonViewToPayload(payload, 'overlapped')).toBe(
112
- payload,
113
- );
129
+ it('returns original payload for lagged tab', () => {
130
+ expect(applyDriversComparisonViewToPayload(payload, 'lagged')).toBe(payload);
114
131
  });
115
132
 
116
- it('shifts driver series for lagged tab without mutating source', () => {
117
- const lagged = applyDriversComparisonViewToPayload(payload, 'lagged');
118
- expect(lagged).not.toBe(payload);
133
+ it('shifts driver series backward for overlapped tab without mutating source', () => {
134
+ const overlapped = applyDriversComparisonViewToPayload(
135
+ payload,
136
+ 'overlapped',
137
+ );
138
+ expect(overlapped).not.toBe(payload);
119
139
  expect(payload.drivers[0].normalized_series['2015-01-01']).toBe(0.5);
120
- expect(lagged?.drivers[0].normalized_series['2015-03-01']).toBe(0.5);
121
- expect(lagged?.target.normalized_series).toEqual(
140
+ expect(overlapped?.drivers[0].normalized_series['2014-11-01']).toBe(0.5);
141
+ expect(overlapped?.target.normalized_series).toEqual(
122
142
  payload.target.normalized_series,
123
143
  );
124
144
  });
@@ -135,11 +155,11 @@ describe('applyDriversComparisonViewToPayload', () => {
135
155
  },
136
156
  ],
137
157
  };
138
- const lagged = applyDriversComparisonViewToPayload(
158
+ const overlapped = applyDriversComparisonViewToPayload(
139
159
  withOverallLag,
140
- 'lagged',
160
+ 'overlapped',
141
161
  );
142
- expect(lagged?.drivers[0].normalized_series['2015-07-01']).toBe(0.8);
162
+ expect(overlapped?.drivers[0].normalized_series['2015-05-01']).toBe(0.8);
143
163
  });
144
164
  });
145
165
 
@@ -184,7 +204,7 @@ describe('computeDriversComparisonHistoricalWindowFloor', () => {
184
204
 
185
205
  const forecastIds = [DRIVER_FORECAST_ID_BASE];
186
206
 
187
- it('returns anchor minus lead months from overlapped merged data', () => {
207
+ it('returns anchor minus lead months from lagged (unshifted) merged data', () => {
188
208
  const merged = mergeDatasetHistoricalWithBacktestsChartData(
189
209
  datasetHistorical,
190
210
  mergeBacktestsChartData(laggedDriverPayload),
@@ -245,70 +265,70 @@ describe('buildDriversComparisonChartData historical window floor', () => {
245
265
  const forecastIds = [DRIVER_FORECAST_ID_BASE];
246
266
 
247
267
  it('keeps same xMin for lagged and overlapped when floor is pinned', () => {
248
- const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
268
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
249
269
  datasetHistorical,
250
270
  mergeBacktestsChartData(laggedDriverPayload),
251
271
  );
252
- const laggedPayload = applyDriversComparisonViewToPayload(
272
+ const overlappedPayload = applyDriversComparisonViewToPayload(
253
273
  laggedDriverPayload,
254
- 'lagged',
274
+ 'overlapped',
255
275
  );
256
- const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
276
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
257
277
  datasetHistorical,
258
- mergeBacktestsChartData(laggedPayload),
278
+ mergeBacktestsChartData(overlappedPayload),
259
279
  );
260
280
  const floor = computeDriversComparisonHistoricalWindowFloor(
261
- overlappedMerged,
281
+ laggedMerged,
262
282
  forecastIds,
263
283
  )!;
264
284
 
265
- const overlappedChart = buildDriversComparisonChartData(
266
- overlappedMerged,
285
+ const laggedChart = buildDriversComparisonChartData(
286
+ laggedMerged,
267
287
  datasetHistorical,
268
288
  forecastIds,
269
289
  floor,
270
290
  );
271
- const laggedChart = buildDriversComparisonChartData(
272
- laggedMerged,
291
+ const overlappedChart = buildDriversComparisonChartData(
292
+ overlappedMerged,
273
293
  datasetHistorical,
274
294
  forecastIds,
275
295
  floor,
276
296
  );
277
297
 
278
- expect(overlappedChart[0]?.date).toBe('2014-10-01');
279
298
  expect(laggedChart[0]?.date).toBe('2014-10-01');
280
- expect(laggedChart[0]?.date).toBe(overlappedChart[0]?.date);
299
+ expect(overlappedChart[0]?.date).toBe('2014-10-01');
300
+ expect(overlappedChart[0]?.date).toBe(laggedChart[0]?.date);
281
301
  });
282
302
 
283
- it('without floor override lagged chart starts later than overlapped', () => {
284
- const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
303
+ it('without floor override overlapped chart starts earlier than lagged', () => {
304
+ const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
285
305
  datasetHistorical,
286
306
  mergeBacktestsChartData(laggedDriverPayload),
287
307
  );
288
- const laggedPayload = applyDriversComparisonViewToPayload(
308
+ const overlappedPayload = applyDriversComparisonViewToPayload(
289
309
  laggedDriverPayload,
290
- 'lagged',
310
+ 'overlapped',
291
311
  );
292
- const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
312
+ const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
293
313
  datasetHistorical,
294
- mergeBacktestsChartData(laggedPayload),
314
+ mergeBacktestsChartData(overlappedPayload),
295
315
  );
296
316
 
297
- const overlappedChart = buildDriversComparisonChartData(
298
- overlappedMerged,
317
+ const laggedChart = buildDriversComparisonChartData(
318
+ laggedMerged,
299
319
  datasetHistorical,
300
320
  forecastIds,
301
321
  );
302
- const laggedChart = buildDriversComparisonChartData(
303
- laggedMerged,
322
+ const overlappedChart = buildDriversComparisonChartData(
323
+ overlappedMerged,
304
324
  datasetHistorical,
305
325
  forecastIds,
306
326
  );
307
327
 
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
- );
328
+ expect(laggedChart[0]?.date).toBe('2014-10-01');
329
+ expect(overlappedChart[0]?.date).toBe('2014-07-01');
330
+ expect(
331
+ overlappedChart[0]?.date!.localeCompare(laggedChart[0]?.date!),
332
+ ).toBe(-1);
313
333
  });
314
334
  });
@@ -78,7 +78,7 @@ export function getLagDisplayForView(
78
78
  lag: string | null | undefined,
79
79
  tab: DriversComparisonViewTab,
80
80
  ): string {
81
- if (tab === 'overlapped') {
81
+ if (tab === 'lagged') {
82
82
  return lag?.trim() ? lag : '—';
83
83
  }
84
84
  const months = parseLagMonthsFromLabel(lag);
@@ -102,11 +102,29 @@ export function shiftNormalizedSeriesForward(
102
102
  return out;
103
103
  }
104
104
 
105
+ /** Shift driver points left (earlier months) by lag — overlapped alignment vs target. */
106
+ export function shiftNormalizedSeriesBackward(
107
+ series: Record<string, number | null>,
108
+ lagMonths: number,
109
+ ): Record<string, number | null> {
110
+ if (lagMonths <= 0) return { ...series };
111
+ const out: Record<string, number | null> = {};
112
+ for (const [dateStr, val] of Object.entries(series)) {
113
+ if (val === null || val === undefined) continue;
114
+ let shifted = normalizeToMonthStart(dateStr);
115
+ for (let i = 0; i < lagMonths; i++) {
116
+ shifted = getPreviousMonth(shifted);
117
+ }
118
+ out[shifted] = val;
119
+ }
120
+ return out;
121
+ }
122
+
105
123
  export function applyDriversComparisonViewToPayload(
106
124
  payload: BacktestsComponentPayload | null,
107
125
  tab: DriversComparisonViewTab,
108
126
  ): BacktestsComponentPayload | null {
109
- if (!payload || tab === 'overlapped') return payload;
127
+ if (!payload || tab === 'lagged') return payload;
110
128
 
111
129
  return {
112
130
  target: {
@@ -118,7 +136,7 @@ export function applyDriversComparisonViewToPayload(
118
136
  const series = driver.normalized_series ?? {};
119
137
  const normalized_series =
120
138
  lagMonths != null && lagMonths > 0
121
- ? shiftNormalizedSeriesForward(series, lagMonths)
139
+ ? shiftNormalizedSeriesBackward(series, lagMonths)
122
140
  : { ...series };
123
141
  return {
124
142
  ...driver,
@@ -290,7 +308,7 @@ export function prependHistoricalLeadFromDataset(
290
308
  return [...lead, ...points];
291
309
  }
292
310
 
293
- /** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
311
+ /** Lagged (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
294
312
  export function computeDriversComparisonHistoricalWindowFloor(
295
313
  mergedWithHistorical: ChartDataPoint[],
296
314
  forecastIds: number[],
@@ -0,0 +1,152 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ import { ChartAreaInteractive } from '#uilib/components/ui/ChartAreaInteractive';
4
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
5
+ import type { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
6
+ import { calculateChartYRange } from '#uilib/components/ui/ChartAreaInteractive/overlays/useChartYRange';
7
+ import { Label } from '#uilib/components/ui/Label';
8
+ import { PageContentSection } from '#uilib/components/ui/Page';
9
+ import { Switch } from '#uilib/components/ui/Switch';
10
+ import { useTheme } from '#uilib/contexts/theme-context';
11
+
12
+ import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
13
+ import { DocsHeaderActions } from '../docsHeaderActions';
14
+
15
+ const FORECAST_BASELINE_ID = 1;
16
+ const FORECAST_HIGH_ID = 2;
17
+
18
+ const DEMO_FORECAST_ITEMS: ForecastItemData[] = [
19
+ { id: FORECAST_BASELINE_ID, name: 'Baseline forecast' },
20
+ { id: FORECAST_HIGH_ID, name: 'High scenario (hidden)' },
21
+ ];
22
+
23
+ const INITIAL_CHART: ChartDataPoint[] = [
24
+ { date: '2022-01-01', historical: 8 },
25
+ { date: '2022-02-01', historical: 10 },
26
+ { date: '2022-03-01', historical: 9 },
27
+ { date: '2022-04-01', historical: 12 },
28
+ { date: '2022-05-01', historical: 11 },
29
+ { date: '2022-06-01', historical: 12 },
30
+ { date: '2022-07-01', historical: 13 },
31
+ { date: '2022-08-01', historical: 12 },
32
+ { date: '2022-09-01', historical: 14 },
33
+ { date: '2022-10-01', historical: 13 },
34
+ { date: '2022-11-01', historical: 15 },
35
+ { date: '2022-12-01', historical: 14 },
36
+ { date: '2023-01-01', historical: 10 },
37
+ { date: '2023-02-01', historical: 12 },
38
+ { date: '2023-03-01', historical: 11 },
39
+ { date: '2023-04-01', historical: 14 },
40
+ { date: '2023-05-01', historical: 13 },
41
+ {
42
+ date: '2023-06-01',
43
+ [`forecast_${FORECAST_BASELINE_ID}`]: 13.5,
44
+ [`forecast_${FORECAST_HIGH_ID}`]: 88,
45
+ },
46
+ {
47
+ date: '2023-07-01',
48
+ [`forecast_${FORECAST_BASELINE_ID}`]: 14,
49
+ [`forecast_${FORECAST_HIGH_ID}`]: 92,
50
+ },
51
+ {
52
+ date: '2023-08-01',
53
+ [`forecast_${FORECAST_BASELINE_ID}`]: 15,
54
+ [`forecast_${FORECAST_HIGH_ID}`]: 95,
55
+ },
56
+ ];
57
+
58
+ export default function IncludeHiddenInYScalePage() {
59
+ const { isDarkMode } = useTheme();
60
+ const [timeRange, setTimeRange] = useState<string>('All');
61
+ const [chartData] = useState<ChartDataPoint[]>(INITIAL_CHART);
62
+ const [hidden, setHidden] = useState<Set<string>>(
63
+ () => new Set([`forecast_${FORECAST_HIGH_ID}`]),
64
+ );
65
+ const [includeHiddenInYScale, setIncludeHiddenInYScale] = useState(false);
66
+
67
+ const toggleLegendSeries = useCallback((key: string) => {
68
+ setHidden(prev => {
69
+ const next = new Set(prev);
70
+ if (next.has(key)) next.delete(key);
71
+ else next.add(key);
72
+ return next;
73
+ });
74
+ }, []);
75
+
76
+ const ensureAnalysisSeriesVisible = useCallback(() => {}, []);
77
+
78
+ const yRange = useMemo(
79
+ () =>
80
+ calculateChartYRange(chartData, {
81
+ excludeQuantileBands: true,
82
+ hiddenSeries: hidden,
83
+ includeHiddenInYScale,
84
+ }),
85
+ [chartData, hidden, includeHiddenInYScale],
86
+ );
87
+
88
+ return (
89
+ <>
90
+ <AppPageHeader
91
+ breadcrumbs={[{ label: 'includeHiddenInYScale' }]}
92
+ title="includeHiddenInYScale"
93
+ subheader={
94
+ <>
95
+ <code>BaseChartWrapper</code> / <code>ChartAreaInteractive</code>{' '}
96
+ prop. When <code>false</code> (default), hidden legend series are
97
+ excluded from Y-axis domain; when <code>true</code>, hidden values
98
+ still expand the scale (legacy behavior). Toggle the high scenario
99
+ in the legend, then flip the switch to compare.
100
+ </>
101
+ }
102
+ actions={<DocsHeaderActions />}
103
+ />
104
+ <PageContentSection>
105
+ <div
106
+ style={{
107
+ display: 'flex',
108
+ flexWrap: 'wrap',
109
+ alignItems: 'center',
110
+ gap: 16,
111
+ marginBottom: 16,
112
+ }}
113
+ >
114
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
115
+ <Switch
116
+ id="include-hidden-y-scale"
117
+ checked={includeHiddenInYScale}
118
+ onCheckedChange={setIncludeHiddenInYScale}
119
+ />
120
+ <Label htmlFor="include-hidden-y-scale">
121
+ includeHiddenInYScale
122
+ </Label>
123
+ </div>
124
+ <span style={{ color: 'var(--muted-foreground)', fontSize: 13 }}>
125
+ Y domain: {yRange.yMin} – {yRange.yMax}
126
+ {hidden.has(`forecast_${FORECAST_HIGH_ID}`)
127
+ ? ' (high scenario hidden)'
128
+ : ' (all series visible)'}
129
+ </span>
130
+ </div>
131
+ <ChartAreaInteractive
132
+ timeRange={timeRange}
133
+ onTimeRangeChange={setTimeRange}
134
+ pinMonth={undefined}
135
+ onPinMonthChange={() => {}}
136
+ chartData={chartData}
137
+ forecastData={DEMO_FORECAST_ITEMS}
138
+ loading={false}
139
+ isDarkTheme={isDarkMode}
140
+ toggleLegendSeries={toggleLegendSeries}
141
+ ensureAnalysisSeriesVisible={ensureAnalysisSeriesVisible}
142
+ hiddenSeries={hidden}
143
+ includeHiddenInYScale={includeHiddenInYScale}
144
+ yMin={yRange.yMin}
145
+ yMax={yRange.yMax}
146
+ autoScaleYAxis={false}
147
+ disableTimeRangeSelector
148
+ />
149
+ </PageContentSection>
150
+ </>
151
+ );
152
+ }
@@ -97,6 +97,12 @@ export const DOC_REGISTRY: DocEntry[] = [
97
97
  section: 'Charts',
98
98
  load: () => import('./pages/ChartAreaInteractivePage'),
99
99
  },
100
+ {
101
+ slug: 'include-hidden-in-y-scale',
102
+ title: 'includeHiddenInYScale',
103
+ section: 'Charts',
104
+ load: () => import('./pages/IncludeHiddenInYScalePage'),
105
+ },
100
106
  {
101
107
  slug: 'lightweight-forecast-chart',
102
108
  title: 'LightweightForecastChart',