@sybilion/uilib 1.3.63 → 1.3.65

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 (26) hide show
  1. package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +10 -5
  2. package/dist/esm/components/ui/Chart/components/QuantileBands.js +1 -1
  3. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +60 -1
  4. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useChartYRange.js +111 -61
  5. package/dist/esm/components/widgets/PerformanceChart/performanceChart.helpers.js +132 -33
  6. package/dist/esm/index.js +3 -1
  7. package/dist/esm/types/src/components/ui/Chart/components/BaseChartWrapper.d.ts +2 -0
  8. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +3 -2
  9. package/dist/esm/types/src/components/ui/ChartAreaInteractive/index.d.ts +2 -0
  10. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.d.ts +15 -0
  11. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.test.d.ts +1 -0
  12. package/dist/esm/types/src/components/widgets/PerformanceChart/index.d.ts +1 -1
  13. package/dist/esm/types/src/components/widgets/PerformanceChart/performanceChart.helpers.d.ts +22 -2
  14. package/dist/esm/types/src/docs/pages/IncludeHiddenInYScalePage.d.ts +1 -0
  15. package/package.json +4 -2
  16. package/src/components/ui/Chart/components/BaseChartWrapper.tsx +14 -4
  17. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +4 -3
  18. package/src/components/ui/ChartAreaInteractive/index.ts +2 -0
  19. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.test.ts +87 -0
  20. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.ts +152 -73
  21. package/src/components/ui/Page/AGENT.md +165 -0
  22. package/src/components/widgets/AGENT.md +1 -1
  23. package/src/components/widgets/PerformanceChart/index.ts +3 -0
  24. package/src/components/widgets/PerformanceChart/performanceChart.helpers.ts +197 -41
  25. package/src/docs/pages/IncludeHiddenInYScalePage.tsx +152 -0
  26. package/src/docs/registry.ts +6 -0
@@ -15,6 +15,9 @@ export {
15
15
  averageForecastErrorsVsHistoricalForMatrixColumn,
16
16
  buildDriftSpaghettiMatrixForCustomDialog,
17
17
  buildPerHorizonSpaghettiEntries,
18
+ extendCustomPerformanceDriftSeedToHistoricalEnd,
19
+ extendCustomPerformanceMatrixWithDriftSeed,
20
+ latestHistoricalMonthKey,
18
21
  buildSpaghettiMergedChartData,
19
22
  calculateYRangeFromChartData,
20
23
  getForecastModelDisplayName,
@@ -5,6 +5,7 @@
5
5
  import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
6
6
  import type { ForecastData } from '#uilib/types/forecast-data';
7
7
  import {
8
+ getNextMonth,
8
9
  getPreviousMonth,
9
10
  normalizeToMonthStart,
10
11
  } from '#uilib/utils/chartConnectionPoint';
@@ -67,19 +68,16 @@ function addSpaghettiHistoricalBridgeForSeries(
67
68
  map.set(connectionDate, row);
68
69
  }
69
70
 
70
- /**
71
- * Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
72
- * backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
73
- * (aligned by sorted key index per horizon).
74
- */
75
- export function buildPerHorizonSpaghettiEntries(
76
- forecastRoot:
77
- | { forecasts?: Record<string, Record<string, number>> }
78
- | null
79
- | undefined,
71
+ type PerHorizonForecastRoot = {
72
+ forecasts?: Record<string, Record<string, number>>;
73
+ };
74
+
75
+ /** When horizons have different lengths, align on the latest `n` months (suffix), not the oldest. */
76
+ function perHorizonSortedKeyLists(
77
+ forecastRoot: PerHorizonForecastRoot,
80
78
  horizonKeys: string[],
81
- ): RealBacktestsEntry[] {
82
- if (!forecastRoot?.forecasts || horizonKeys.length === 0) return [];
79
+ ): { sortedHorizons: string[]; perHorizonKeyLists: string[][] } | null {
80
+ if (!forecastRoot.forecasts || horizonKeys.length === 0) return null;
83
81
 
84
82
  const forecasts = forecastRoot.forecasts;
85
83
  const sortedHorizons = [...horizonKeys].sort((a, b) => {
@@ -99,16 +97,57 @@ export function buildPerHorizonSpaghettiEntries(
99
97
  });
100
98
 
101
99
  const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
102
- if (lengths.length === 0) return [];
103
- const n = Math.min(...lengths);
100
+ if (lengths.length === 0) return null;
101
+
102
+ return { sortedHorizons, perHorizonKeyLists };
103
+ }
104
+
105
+ function alignedHorizonRowCount(perHorizonKeyLists: string[][]): number {
106
+ const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
107
+ if (lengths.length === 0) return 0;
108
+ return Math.min(...lengths);
109
+ }
110
+
111
+ function horizonDateKeyAtRow(
112
+ perHorizonKeyLists: string[][],
113
+ horizonIndex: number,
114
+ rowIndex: number,
115
+ rowCount: number,
116
+ ): string | undefined {
117
+ const keys = perHorizonKeyLists[horizonIndex];
118
+ if (!keys?.length) return undefined;
119
+ const offset = keys.length - rowCount;
120
+ return keys[offset + rowIndex];
121
+ }
122
+
123
+ /**
124
+ * Converts `performance.model` / `performance.drift` per-horizon forecasts into synthetic
125
+ * backtest-shaped entries: each line is [horizon_1[i], …, horizon_n[i]] as date→value points
126
+ * (aligned on the latest shared months per horizon).
127
+ */
128
+ export function buildPerHorizonSpaghettiEntries(
129
+ forecastRoot:
130
+ | { forecasts?: Record<string, Record<string, number>> }
131
+ | null
132
+ | undefined,
133
+ horizonKeys: string[],
134
+ ): RealBacktestsEntry[] {
135
+ const aligned = perHorizonSortedKeyLists(forecastRoot ?? {}, horizonKeys);
136
+ if (!aligned) return [];
137
+
138
+ const { sortedHorizons, perHorizonKeyLists } = aligned;
139
+ const forecasts = forecastRoot!.forecasts!;
140
+ const normalizeDateKey = (d: string) => d.split(' ')[0];
141
+ const n = alignedHorizonRowCount(perHorizonKeyLists);
142
+ if (n === 0) return [];
104
143
 
105
144
  const entries: RealBacktestsEntry[] = [];
106
145
  for (let i = 0; i < n; i++) {
107
146
  const forecast_series: Record<string, number> = {};
108
147
  for (let hi = 0; hi < sortedHorizons.length; hi++) {
109
148
  const h = sortedHorizons[hi];
110
- const keys = perHorizonKeyLists[hi];
111
- const dateKey = keys[i];
149
+ const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
150
+ if (!dateKey) continue;
112
151
  const rawVal = forecasts[h]?.[dateKey];
113
152
  if (typeof rawVal !== 'number' || !Number.isFinite(rawVal)) continue;
114
153
  const norm = normalizeToMonthStart(normalizeDateKey(dateKey));
@@ -127,7 +166,7 @@ export function buildPerHorizonSpaghettiEntries(
127
166
 
128
167
  /**
129
168
  * Same row alignment as {@link buildPerHorizonSpaghettiEntries}: row `i` uses each horizon's
130
- * i-th sorted forecast month; `dates[i]` is horizon_1's month at that row (Date column).
169
+ * latest-aligned forecast month; `dates[i]` is horizon_1's month at that row (Date column).
131
170
  * Use this for custom dialog seed + “copy statistical baseline (drift)” prefill.
132
171
  */
133
172
  export function buildDriftSpaghettiMatrixForCustomDialog(
@@ -141,28 +180,14 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
141
180
  grid: number[][];
142
181
  perHorizonDates: string[][];
143
182
  } | null {
144
- if (!driftRoot?.forecasts || horizonKeys.length === 0) return null;
145
-
146
- const forecasts = driftRoot.forecasts;
147
- const sortedHorizons = [...horizonKeys].sort((a, b) => {
148
- const na = parseInt(a.replace(/\D/g, ''), 10) || 0;
149
- const nb = parseInt(b.replace(/\D/g, ''), 10) || 0;
150
- return na - nb;
151
- });
183
+ const aligned = perHorizonSortedKeyLists(driftRoot ?? {}, horizonKeys);
184
+ if (!aligned) return null;
152
185
 
186
+ const { sortedHorizons, perHorizonKeyLists } = aligned;
187
+ const forecasts = driftRoot!.forecasts!;
153
188
  const normalizeDateKey = (d: string) => d.split(' ')[0];
154
-
155
- const perHorizonKeyLists: string[][] = sortedHorizons.map(h => {
156
- const m = forecasts[h];
157
- if (!m || typeof m !== 'object') return [];
158
- return Object.keys(m).sort((a, b) =>
159
- normalizeDateKey(a).localeCompare(normalizeDateKey(b)),
160
- );
161
- });
162
-
163
- const lengths = perHorizonKeyLists.map(l => l.length).filter(l => l > 0);
164
- if (lengths.length === 0) return null;
165
- const n = Math.min(...lengths);
189
+ const n = alignedHorizonRowCount(perHorizonKeyLists);
190
+ if (n === 0) return null;
166
191
 
167
192
  const dates: string[] = [];
168
193
  const grid: number[][] = [];
@@ -173,16 +198,20 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
173
198
  const dateRow: string[] = [];
174
199
  for (let hi = 0; hi < sortedHorizons.length; hi++) {
175
200
  const h = sortedHorizons[hi];
176
- const keys = perHorizonKeyLists[hi];
177
- const dateKey = keys[i];
201
+ const dateKey = horizonDateKeyAtRow(perHorizonKeyLists, hi, i, n);
202
+ if (!dateKey) {
203
+ row.push(0);
204
+ dateRow.push('');
205
+ continue;
206
+ }
178
207
  dateRow.push(normalizeToMonthStart(normalizeDateKey(dateKey)));
179
208
  const rawVal = forecasts[h]?.[dateKey];
180
209
  const v =
181
210
  typeof rawVal === 'number' && Number.isFinite(rawVal) ? rawVal : NaN;
182
211
  row.push(v);
183
212
  }
184
- const d0 = perHorizonKeyLists[0][i];
185
- dates.push(normalizeToMonthStart(normalizeDateKey(d0)));
213
+ const d0 = horizonDateKeyAtRow(perHorizonKeyLists, 0, i, n);
214
+ dates.push(d0 ? normalizeToMonthStart(normalizeDateKey(d0)) : '');
186
215
  perHorizonDates.push(dateRow);
187
216
  grid.push(row.map(c => (Number.isFinite(c) ? c : 0)));
188
217
  }
@@ -194,6 +223,133 @@ export function buildDriftSpaghettiMatrixForCustomDialog(
194
223
  };
195
224
  }
196
225
 
226
+ export function latestHistoricalMonthKey(
227
+ historicalByDate: Map<string, number>,
228
+ ): string | null {
229
+ let latest: string | null = null;
230
+ historicalByDate.forEach((_, d) => {
231
+ if (!latest || d.localeCompare(latest) > 0) latest = d;
232
+ });
233
+ return latest;
234
+ }
235
+
236
+ /**
237
+ * Append monthly spaghetti rows after drift backtests so the dialog (and chart) reach the latest
238
+ * historical month (e.g. Apr 2026), not only the last drift origin (e.g. Nov 2025).
239
+ */
240
+ export function extendCustomPerformanceDriftSeedToHistoricalEnd(
241
+ seed: {
242
+ dates: string[];
243
+ grid: number[][];
244
+ perHorizonDates: string[][];
245
+ },
246
+ historicalByDate: Map<string, number>,
247
+ ): {
248
+ dates: string[];
249
+ grid: number[][];
250
+ perHorizonDates: string[][];
251
+ } {
252
+ const latest = latestHistoricalMonthKey(historicalByDate);
253
+ if (!latest || seed.dates.length === 0) return seed;
254
+
255
+ const norm = (d: string) =>
256
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
257
+
258
+ const dates = seed.dates.map(d => norm(d));
259
+ const grid = seed.grid.map(r => [...r]);
260
+ const perHorizonDates = seed.perHorizonDates.map(row =>
261
+ row.map(d => norm(String(d))),
262
+ );
263
+ const horizonCount = grid[0]?.length ?? perHorizonDates[0]?.length ?? 0;
264
+ if (horizonCount === 0) return seed;
265
+
266
+ let lastOrigin = dates[dates.length - 1];
267
+ if (!lastOrigin) return seed;
268
+
269
+ while (lastOrigin.localeCompare(latest) < 0) {
270
+ const nextOrigin = norm(getNextMonth(lastOrigin));
271
+ const prevPh = perHorizonDates[perHorizonDates.length - 1];
272
+ const newPh =
273
+ prevPh.length === horizonCount
274
+ ? prevPh.map(d => norm(getNextMonth(norm(d))))
275
+ : Array.from({ length: horizonCount }, () => nextOrigin);
276
+
277
+ perHorizonDates.push(newPh);
278
+ dates.push(nextOrigin);
279
+
280
+ const baselineRow = spaghettiGridFromHistoricalPreviousMonth(
281
+ [newPh],
282
+ horizonCount,
283
+ historicalByDate,
284
+ );
285
+ grid.push(baselineRow[0] ?? Array.from({ length: horizonCount }, () => 0));
286
+
287
+ lastOrigin = nextOrigin;
288
+ }
289
+
290
+ return { dates, grid, perHorizonDates };
291
+ }
292
+
293
+ /** Pad a saved custom matrix with drift/baseline rows so edit + chart cover latest backtest months. */
294
+ export function extendCustomPerformanceMatrixWithDriftSeed(
295
+ saved: SpaghettiPerformanceMatrixPayload,
296
+ driftSeed: {
297
+ dates: string[];
298
+ grid: number[][];
299
+ perHorizonDates: string[][];
300
+ },
301
+ baselineGrid?: number[][],
302
+ ): SpaghettiPerformanceMatrixPayload {
303
+ const norm = (d: string) =>
304
+ normalizeToMonthStart(String(d).split(' ')[0] ?? d);
305
+ const horizonCount = saved.horizonKeys.length;
306
+
307
+ const savedByDate = new Map<
308
+ string,
309
+ { grid: number[]; perHorizon?: string[] }
310
+ >();
311
+ for (let r = 0; r < saved.dates.length; r++) {
312
+ savedByDate.set(norm(saved.dates[r]), {
313
+ grid: saved.grid[r],
314
+ perHorizon: saved.perHorizonDates?.[r],
315
+ });
316
+ }
317
+
318
+ const dates: string[] = [];
319
+ const grid: number[][] = [];
320
+ const perHorizonDates: string[][] = [];
321
+
322
+ for (let i = 0; i < driftSeed.dates.length; i++) {
323
+ const d = norm(driftSeed.dates[i]);
324
+ if (!d) continue;
325
+ dates.push(d);
326
+ const existing = savedByDate.get(d);
327
+ if (existing) {
328
+ grid.push([...existing.grid]);
329
+ const ph =
330
+ existing.perHorizon && existing.perHorizon.length === horizonCount
331
+ ? existing.perHorizon.map(c => norm(String(c)))
332
+ : (driftSeed.perHorizonDates[i]?.map(c => norm(String(c))) ?? []);
333
+ perHorizonDates.push(ph);
334
+ continue;
335
+ }
336
+ grid.push(
337
+ baselineGrid?.[i] ? [...baselineGrid[i]] : [...driftSeed.grid[i]],
338
+ );
339
+ perHorizonDates.push(
340
+ (driftSeed.perHorizonDates[i] ?? []).map(c => norm(String(c))),
341
+ );
342
+ }
343
+
344
+ return {
345
+ v: saved.v,
346
+ dates,
347
+ horizonKeys: [...saved.horizonKeys],
348
+ grid,
349
+ perHorizonDates,
350
+ };
351
+ }
352
+
197
353
  /**
198
354
  * Prefill for custom performance when copying drift layout: each spaghetti row is flat at the
199
355
  * historical value for the month before the earliest forecast month in that row (same anchor as
@@ -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',