@sybilion/uilib 1.3.57 → 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.
@@ -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.57",
3
+ "version": "1.3.58",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -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,