@sybilion/uilib 1.3.0 → 1.3.1

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 (34) hide show
  1. package/dist/esm/components/ui/Chart/Chart.js +4 -0
  2. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.js +460 -0
  3. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.styl.js +7 -0
  4. package/dist/esm/components/ui/Chart/lightweight/chartTime.js +16 -0
  5. package/dist/esm/components/ui/Chart/lightweight/lightweightForecastChart.helpers.js +114 -0
  6. package/dist/esm/components/ui/Chart/lightweight/quantileBandCustomSeries.js +147 -0
  7. package/dist/esm/components/ui/Chart/quantileBandConeChartData.js +131 -0
  8. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useQuantileBands.js +4 -102
  9. package/dist/esm/components/widgets/DriverCard/DriverPerformanceChart.js +4 -0
  10. package/dist/esm/index.js +1 -0
  11. package/dist/esm/types/src/components/ui/Chart/Chart.d.ts +1 -0
  12. package/dist/esm/types/src/components/ui/Chart/lightweight/LightweightForecastChart.d.ts +26 -0
  13. package/dist/esm/types/src/components/ui/Chart/lightweight/chartTime.d.ts +5 -0
  14. package/dist/esm/types/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.d.ts +13 -0
  15. package/dist/esm/types/src/components/ui/Chart/lightweight/quantileBandCustomSeries.d.ts +24 -0
  16. package/dist/esm/types/src/components/ui/Chart/quantileBandConeChartData.d.ts +7 -0
  17. package/dist/esm/types/src/docs/pages/LightweightChartPage.d.ts +1 -0
  18. package/package.json +3 -2
  19. package/src/components/ui/Chart/Chart.tsx +4 -0
  20. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl +25 -0
  21. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl.d.ts +11 -0
  22. package/src/components/ui/Chart/lightweight/LightweightForecastChart.tsx +721 -0
  23. package/src/components/ui/Chart/lightweight/chartTime.ts +18 -0
  24. package/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.ts +141 -0
  25. package/src/components/ui/Chart/lightweight/quantileBandCustomSeries.ts +215 -0
  26. package/src/components/ui/Chart/quantileBandConeChartData.ts +171 -0
  27. package/src/components/ui/ChartAreaInteractive/overlays/useQuantileBands.ts +5 -131
  28. package/src/declarations.d.ts +2 -0
  29. package/src/docs/config/webpack.config.js +25 -2
  30. package/src/docs/index.tsx +1 -1
  31. package/src/docs/pages/LightweightChartPage.styl +18 -0
  32. package/src/docs/pages/LightweightChartPage.styl.d.ts +10 -0
  33. package/src/docs/pages/LightweightChartPage.tsx +195 -0
  34. package/src/docs/registry.ts +6 -0
@@ -0,0 +1,721 @@
1
+ import cn from 'classnames';
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ import { ChartContext } from '#uilib/components/ui/Chart/Chart.context';
12
+ import type { ChartConfig } from '#uilib/components/ui/Chart/Chart.types';
13
+ import type { QuantileBandConfig } from '#uilib/components/ui/Chart/chartForecastVisualization.types';
14
+ import { ChartStyle } from '#uilib/components/ui/Chart/components/ChartContainer';
15
+ import { ChartTooltipContent } from '#uilib/components/ui/Chart/components/ChartTooltipContent';
16
+ import { CustomChartLegend } from '#uilib/components/ui/Chart/components/CustomChartLegend/CustomChartLegend';
17
+ import { formatDate as formatDateDefault } from '#uilib/components/ui/Chart/tools/formatters';
18
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
19
+ import {
20
+ ForecastItemData,
21
+ getForecastColor,
22
+ getForecastQuantileBandColor,
23
+ } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
24
+ import { Skeleton } from '#uilib/components/ui/Skeleton';
25
+ import { ensureChartForecastBridge } from '#uilib/utils/chartConnectionPoint';
26
+ import type {
27
+ IChartApi,
28
+ ISeriesApi,
29
+ MouseEventParams,
30
+ UTCTimestamp,
31
+ } from 'lightweight-charts';
32
+ import {
33
+ LineSeries,
34
+ LineStyle,
35
+ LineType,
36
+ createChart,
37
+ } from 'lightweight-charts';
38
+
39
+ import S from './LightweightForecastChart.styl';
40
+ import {
41
+ buildForecastLineData,
42
+ buildHistoricalLineData,
43
+ buildLightweightChartOptions,
44
+ buildQuantileBandCustomData,
45
+ findNearestChartRow,
46
+ } from './lightweightForecastChart.helpers';
47
+ import { QuantileBandPaneView } from './quantileBandCustomSeries';
48
+
49
+ type TooltipRow = {
50
+ type: string;
51
+ name: string;
52
+ value: number | [number, number];
53
+ payload: unknown;
54
+ color: string;
55
+ dataKey?: string;
56
+ };
57
+
58
+ function clampTooltipTranslate(args: {
59
+ coordinate: { x: number; y: number };
60
+ viewW: number;
61
+ viewH: number;
62
+ tooltipWidth: number;
63
+ tooltipHeight: number;
64
+ offset: number;
65
+ edgeMargin: number;
66
+ }): { x: number; y: number } {
67
+ const {
68
+ coordinate,
69
+ viewW,
70
+ viewH,
71
+ tooltipWidth: tw,
72
+ tooltipHeight: th,
73
+ offset,
74
+ edgeMargin,
75
+ } = args;
76
+
77
+ const minX = edgeMargin;
78
+ const maxX = Math.max(edgeMargin, viewW - tw - edgeMargin);
79
+ const minY = edgeMargin;
80
+ const maxY = Math.max(edgeMargin, viewH - th - edgeMargin);
81
+
82
+ const clamp = (v: number, lo: number, hi: number) =>
83
+ Math.min(Math.max(v, lo), Math.max(lo, hi));
84
+
85
+ let tx = coordinate.x + offset;
86
+ if (tx + tw > viewW - edgeMargin) {
87
+ tx = coordinate.x - tw - offset;
88
+ }
89
+ tx = clamp(tx, minX, maxX);
90
+
91
+ let ty = coordinate.y + offset;
92
+ if (ty + th > viewH - edgeMargin) {
93
+ ty = coordinate.y - th - offset;
94
+ }
95
+ ty = clamp(ty, minY, maxY);
96
+
97
+ return { x: tx, y: ty };
98
+ }
99
+
100
+ function scheduleFitTimeScale(chart: IChartApi): void {
101
+ requestAnimationFrame(() => {
102
+ chart.timeScale().fitContent();
103
+ });
104
+ }
105
+
106
+ type LwModel = {
107
+ chart: IChartApi;
108
+ historical: ISeriesApi<'Line'> | null;
109
+ forecasts: Map<string, ISeriesApi<'Line'>>;
110
+ bands: Map<string, { api: ISeriesApi<'Custom'>; view: QuantileBandPaneView }>;
111
+ };
112
+
113
+ export interface LightweightForecastChartProps {
114
+ chartData: ChartDataPoint[];
115
+ forecastData?: ForecastItemData[];
116
+ quantileBands?: QuantileBandConfig[];
117
+ chartConfig?: ChartConfig;
118
+ historicalLineColor?: string;
119
+ isDarkTheme: boolean;
120
+ height?: number;
121
+ className?: string;
122
+ hiddenSeries?: Set<string>;
123
+ onLegendClick?: (data: unknown, index: number, event: unknown) => void;
124
+ disableForecastHistoricalBridge?: boolean;
125
+ forecastLineStyle?: 'dashed' | 'solid';
126
+ formatDate?: (value: string, detailed?: boolean) => string;
127
+ formatNumber?: (value: number) => string;
128
+ loading?: boolean;
129
+ error?: string | null;
130
+ noDataMessage?: string;
131
+ showLegend?: boolean;
132
+ showTooltip?: boolean;
133
+ }
134
+
135
+ export function LightweightForecastChart(props: LightweightForecastChartProps) {
136
+ const {
137
+ chartData,
138
+ forecastData = [],
139
+ quantileBands,
140
+ chartConfig: chartConfigProp = {},
141
+ historicalLineColor: historicalLineColorProp,
142
+ isDarkTheme,
143
+ height,
144
+ className,
145
+ hiddenSeries: hiddenSeriesProp,
146
+ onLegendClick,
147
+ disableForecastHistoricalBridge = false,
148
+ forecastLineStyle = 'dashed',
149
+ formatDate: formatDateFn = formatDateDefault,
150
+ formatNumber,
151
+ loading = false,
152
+ error = null,
153
+ noDataMessage = 'No data available',
154
+ showLegend = true,
155
+ showTooltip = true,
156
+ } = props;
157
+
158
+ const chartId = useId().replace(/:/g, '');
159
+ const shellRef = useRef<HTMLDivElement>(null);
160
+ const hostRef = useRef<HTMLDivElement>(null);
161
+ const modelRef = useRef<LwModel | null>(null);
162
+ const bridgedRef = useRef<ChartDataPoint[]>([]);
163
+ const tooltipRef = useRef<HTMLDivElement | null>(null);
164
+ const tooltipSizeRef = useRef({ width: 0, height: 0 });
165
+
166
+ const [localHidden, setLocalHidden] = useState(() => new Set<string>());
167
+ const hiddenSeries = hiddenSeriesProp ?? localHidden;
168
+ const hiddenControlled = hiddenSeriesProp !== undefined;
169
+
170
+ const pixelHeight = height ?? 280;
171
+
172
+ const historicalLineColor =
173
+ historicalLineColorProp ?? (isDarkTheme ? '#ffffff' : '#000000');
174
+
175
+ const bridgedChartData = useMemo(() => {
176
+ if (disableForecastHistoricalBridge) {
177
+ return chartData;
178
+ }
179
+ return ensureChartForecastBridge(chartData, {
180
+ forecastSeriesIds: forecastData?.map(f => f.id),
181
+ });
182
+ }, [chartData, disableForecastHistoricalBridge, forecastData]);
183
+
184
+ bridgedRef.current = bridgedChartData;
185
+
186
+ const hiddenRef = useRef(hiddenSeries);
187
+ const forecastRef = useRef(forecastData);
188
+ const quantileBandsRef = useRef(quantileBands);
189
+ const formatDateRef = useRef(formatDateFn);
190
+ const histColorRef = useRef(historicalLineColor);
191
+
192
+ useEffect(() => {
193
+ hiddenRef.current = hiddenSeries;
194
+ }, [hiddenSeries]);
195
+ useEffect(() => {
196
+ forecastRef.current = forecastData;
197
+ }, [forecastData]);
198
+ useEffect(() => {
199
+ quantileBandsRef.current = quantileBands;
200
+ }, [quantileBands]);
201
+ useEffect(() => {
202
+ formatDateRef.current = formatDateFn;
203
+ }, [formatDateFn]);
204
+ useEffect(() => {
205
+ histColorRef.current = historicalLineColor;
206
+ }, [historicalLineColor]);
207
+ const mergedChartConfig: ChartConfig = useMemo(() => {
208
+ const base: ChartConfig = {
209
+ historical: { label: 'Historical Data', color: historicalLineColor },
210
+ ...chartConfigProp,
211
+ };
212
+
213
+ forecastData.forEach((f, index) => {
214
+ const key = `forecast_${f.id}`;
215
+ base[key] = {
216
+ label: f.name?.toString() ?? key,
217
+ color: f.color?.toString() ?? getForecastColor(index),
218
+ };
219
+ });
220
+
221
+ quantileBands?.forEach((band, index) => {
222
+ base[band.key] = {
223
+ label: band.name,
224
+ color: band.color ?? getForecastQuantileBandColor(index),
225
+ };
226
+ });
227
+
228
+ return base;
229
+ }, [chartConfigProp, forecastData, historicalLineColor, quantileBands]);
230
+
231
+ const legendPayload = useMemo(() => {
232
+ if (!showLegend) return [];
233
+ return forecastData.map((item, index) => ({
234
+ value: item.name?.toString() || item.id?.toString() || '',
235
+ color: item.color?.toString() || getForecastColor(index),
236
+ dataKey: `forecast_${item.id}`,
237
+ icon: item.icon,
238
+ status: item.status,
239
+ dimmed: item.dimmed,
240
+ updated_at: item.updated_at,
241
+ }));
242
+ }, [forecastData, showLegend]);
243
+
244
+ const structureKey = useMemo(
245
+ () =>
246
+ [
247
+ quantileBands?.map(b => b.key).join(',') ?? '',
248
+ forecastData.map(f => f.id).join(','),
249
+ ].join('|'),
250
+ [forecastData, quantileBands],
251
+ );
252
+
253
+ const [tooltipState, setTooltipState] = useState<{
254
+ active: boolean;
255
+ x: number;
256
+ y: number;
257
+ label: string;
258
+ payload: TooltipRow[];
259
+ }>({
260
+ active: false,
261
+ x: 0,
262
+ y: 0,
263
+ label: '',
264
+ payload: [],
265
+ });
266
+
267
+ const applyTooltipPosition = useCallback(() => {
268
+ const shell = shellRef.current;
269
+ const wrapper = tooltipRef.current;
270
+ if (!shell || !wrapper) return;
271
+
272
+ const tw = tooltipSizeRef.current.width || wrapper.offsetWidth;
273
+ const th = tooltipSizeRef.current.height || wrapper.offsetHeight;
274
+ if (tw <= 0 || th <= 0) return;
275
+
276
+ const { width: viewW, height: viewH } = shell.getBoundingClientRect();
277
+
278
+ const next = clampTooltipTranslate({
279
+ coordinate: { x: tooltipState.x, y: tooltipState.y },
280
+ viewW,
281
+ viewH,
282
+ tooltipWidth: tw,
283
+ tooltipHeight: th,
284
+ offset: 10,
285
+ edgeMargin: 8,
286
+ });
287
+
288
+ wrapper.style.transform = `translate(${next.x}px, ${next.y}px)`;
289
+ }, [tooltipState.x, tooltipState.y]);
290
+
291
+ useEffect(() => {
292
+ if (!tooltipState.active) return;
293
+ applyTooltipPosition();
294
+ }, [
295
+ applyTooltipPosition,
296
+ tooltipState.active,
297
+ tooltipState.payload,
298
+ tooltipState.label,
299
+ tooltipState.x,
300
+ tooltipState.y,
301
+ ]);
302
+
303
+ useEffect(() => {
304
+ const el = tooltipRef.current;
305
+ if (!el || typeof ResizeObserver === 'undefined') return;
306
+ const ro = new ResizeObserver(entries => {
307
+ const entry = entries[0];
308
+ if (!entry) return;
309
+ const { width, height: h } = entry.contentRect;
310
+ tooltipSizeRef.current = { width, height: h };
311
+ requestAnimationFrame(() => applyTooltipPosition());
312
+ });
313
+ ro.observe(el);
314
+ return () => ro.disconnect();
315
+ }, [applyTooltipPosition]);
316
+
317
+ useEffect(() => {
318
+ const onWin = () =>
319
+ requestAnimationFrame(() => {
320
+ applyTooltipPosition();
321
+ });
322
+ window.addEventListener('resize', onWin);
323
+ return () => window.removeEventListener('resize', onWin);
324
+ }, [applyTooltipPosition]);
325
+
326
+ // Structural chart lifecycle
327
+ useEffect(() => {
328
+ const host = hostRef.current;
329
+ if (!host) return;
330
+
331
+ if (!bridgedChartData.length) {
332
+ modelRef.current?.chart.remove();
333
+ modelRef.current = null;
334
+ return;
335
+ }
336
+
337
+ const iw = Math.max(
338
+ 1,
339
+ Math.floor(host.clientWidth || host.offsetWidth || 640),
340
+ );
341
+ const ih = Math.max(1, Math.floor(pixelHeight));
342
+
343
+ const chart = createChart(host, {
344
+ ...buildLightweightChartOptions({
345
+ isDarkTheme,
346
+ width: iw,
347
+ height: ih,
348
+ }),
349
+ });
350
+
351
+ const bands = new Map<
352
+ string,
353
+ { api: ISeriesApi<'Custom'>; view: QuantileBandPaneView }
354
+ >();
355
+
356
+ quantileBands?.forEach((band, index) => {
357
+ const fill = band.color ?? getForecastQuantileBandColor(index);
358
+ const view = new QuantileBandPaneView({
359
+ fill,
360
+ stroke: band.strokeWidth ? fill : undefined,
361
+ strokeWidth: band.strokeWidth ?? 0,
362
+ strokeDasharray: band.strokeDasharray,
363
+ strokeOpacity: band.strokeOpacity,
364
+ });
365
+ const api = chart.addCustomSeries(view, {
366
+ color: fill,
367
+ lastValueVisible: false,
368
+ priceLineVisible: false,
369
+ visible: !hiddenRef.current.has(band.key),
370
+ });
371
+ bands.set(band.key, { api, view });
372
+ });
373
+
374
+ const forecasts = new Map<string, ISeriesApi<'Line'>>();
375
+ forecastData.forEach((f, index) => {
376
+ const key = `forecast_${f.id}`;
377
+ const color = f.color?.toString() ?? getForecastColor(index);
378
+ const api = chart.addSeries(LineSeries, {
379
+ color,
380
+ lineWidth: 1,
381
+ lineType: LineType.Curved,
382
+ lineStyle:
383
+ forecastLineStyle === 'dashed' ? LineStyle.Dashed : LineStyle.Solid,
384
+ lastValueVisible: false,
385
+ priceLineVisible: false,
386
+ visible: !hiddenRef.current.has(key),
387
+ });
388
+ forecasts.set(key, api);
389
+ });
390
+
391
+ const historical = chart.addSeries(LineSeries, {
392
+ color: historicalLineColor,
393
+ lineWidth: 1,
394
+ lineType: LineType.Curved,
395
+ lineStyle: LineStyle.Solid,
396
+ lastValueVisible: false,
397
+ priceLineVisible: false,
398
+ visible: !hiddenRef.current.has('historical'),
399
+ });
400
+
401
+ modelRef.current = {
402
+ chart,
403
+ historical,
404
+ forecasts,
405
+ bands,
406
+ };
407
+
408
+ const onMove = (param: MouseEventParams) => {
409
+ if (!showTooltip) {
410
+ setTooltipState(s => ({ ...s, active: false, payload: [] }));
411
+ return;
412
+ }
413
+
414
+ const rows = bridgedRef.current;
415
+ if (!param.point || param.time === undefined || rows.length === 0) {
416
+ setTooltipState(s => ({ ...s, active: false, payload: [] }));
417
+ return;
418
+ }
419
+
420
+ const time = param.time as UTCTimestamp;
421
+ const row = findNearestChartRow(rows, time);
422
+ if (!row) {
423
+ setTooltipState(s => ({ ...s, active: false, payload: [] }));
424
+ return;
425
+ }
426
+
427
+ const forecastsList = forecastRef.current;
428
+ const bandsCfg = quantileBandsRef.current;
429
+ const hid = hiddenRef.current;
430
+ const fmt = formatDateRef.current;
431
+ const histColor = histColorRef.current;
432
+
433
+ const payload: TooltipRow[] = [];
434
+
435
+ const hVal = row.historical;
436
+ if (typeof hVal === 'number' && Number.isFinite(hVal)) {
437
+ payload.push({
438
+ type: 'line',
439
+ name: 'Historical Data',
440
+ value: hVal,
441
+ color: histColor,
442
+ dataKey: 'historical',
443
+ payload: row,
444
+ });
445
+ }
446
+
447
+ forecastsList.forEach((f, index) => {
448
+ const forecastKey = `forecast_${f.id}`;
449
+ if (hid.has(forecastKey)) return;
450
+ const value = row[forecastKey];
451
+ if (typeof value !== 'number' || !Number.isFinite(value)) return;
452
+ const color = f.color?.toString() ?? getForecastColor(index);
453
+ payload.push({
454
+ type: 'line',
455
+ name: f.name?.toString() ?? forecastKey,
456
+ value,
457
+ color,
458
+ dataKey: forecastKey,
459
+ payload: row,
460
+ });
461
+ });
462
+
463
+ bandsCfg?.forEach((band, index) => {
464
+ if (hid.has(band.key)) return;
465
+ const tuple = row[band.key];
466
+ if (
467
+ Array.isArray(tuple) &&
468
+ tuple.length === 2 &&
469
+ typeof tuple[0] === 'number' &&
470
+ typeof tuple[1] === 'number'
471
+ ) {
472
+ const color = band.color ?? getForecastQuantileBandColor(index);
473
+ payload.push({
474
+ type: 'line',
475
+ name: band.name,
476
+ value: [tuple[0], tuple[1]],
477
+ color,
478
+ dataKey: band.key,
479
+ payload: row,
480
+ });
481
+ }
482
+ });
483
+
484
+ if (!payload.length) {
485
+ setTooltipState(s => ({ ...s, active: false, payload: [] }));
486
+ return;
487
+ }
488
+
489
+ const label = fmt(row.date, true);
490
+ setTooltipState({
491
+ active: true,
492
+ x: param.point.x,
493
+ y: param.point.y,
494
+ label,
495
+ payload,
496
+ });
497
+ };
498
+
499
+ chart.subscribeCrosshairMove(onMove);
500
+
501
+ const resizeToHost = () => {
502
+ const ww = Math.max(1, Math.floor(host.clientWidth));
503
+ const hh = Math.max(1, Math.floor(host.clientHeight));
504
+ chart.resize(ww, hh);
505
+ scheduleFitTimeScale(chart);
506
+ };
507
+
508
+ let resizeObserver: ResizeObserver | undefined;
509
+ if (typeof ResizeObserver !== 'undefined') {
510
+ resizeObserver = new ResizeObserver(() => {
511
+ requestAnimationFrame(resizeToHost);
512
+ });
513
+ resizeObserver.observe(host);
514
+ }
515
+ requestAnimationFrame(resizeToHost);
516
+
517
+ return () => {
518
+ resizeObserver?.disconnect();
519
+ chart.unsubscribeCrosshairMove(onMove);
520
+ chart.remove();
521
+ modelRef.current = null;
522
+ };
523
+ }, [
524
+ structureKey,
525
+ isDarkTheme,
526
+ pixelHeight,
527
+ showTooltip,
528
+ bridgedChartData.length,
529
+ ]);
530
+
531
+ // Push data / band styles
532
+ useEffect(() => {
533
+ const model = modelRef.current;
534
+ if (!model) return;
535
+
536
+ model.historical?.setData(buildHistoricalLineData(bridgedChartData));
537
+
538
+ for (const [key, api] of model.forecasts.entries()) {
539
+ api.setData(buildForecastLineData(bridgedChartData, key));
540
+ }
541
+
542
+ quantileBands?.forEach((band, index) => {
543
+ const entry = model.bands.get(band.key);
544
+ if (!entry) return;
545
+ const fill = band.color ?? getForecastQuantileBandColor(index);
546
+ entry.view.updateStyle({
547
+ fill,
548
+ stroke: band.strokeWidth ? fill : undefined,
549
+ strokeWidth: band.strokeWidth ?? 0,
550
+ strokeDasharray: band.strokeDasharray,
551
+ strokeOpacity: band.strokeOpacity,
552
+ });
553
+ entry.api.applyOptions({ color: fill });
554
+ entry.api.setData(
555
+ buildQuantileBandCustomData(bridgedChartData, band.key),
556
+ );
557
+ });
558
+
559
+ scheduleFitTimeScale(model.chart);
560
+ }, [bridgedChartData, quantileBands]);
561
+
562
+ // Visibility toggles
563
+ useEffect(() => {
564
+ const model = modelRef.current;
565
+ if (!model) return;
566
+
567
+ model.historical?.applyOptions({
568
+ visible: !hiddenSeries.has('historical'),
569
+ });
570
+
571
+ for (const [key, api] of model.forecasts.entries()) {
572
+ api.applyOptions({ visible: !hiddenSeries.has(key) });
573
+ }
574
+
575
+ for (const [key, { api }] of model.bands.entries()) {
576
+ api.applyOptions({ visible: !hiddenSeries.has(key) });
577
+ }
578
+
579
+ scheduleFitTimeScale(model.chart);
580
+ }, [hiddenSeries]);
581
+
582
+ // Line styling updates without structural rebuild
583
+ useEffect(() => {
584
+ const model = modelRef.current;
585
+ if (!model?.historical) return;
586
+ model.historical.applyOptions({ color: historicalLineColor });
587
+ }, [historicalLineColor]);
588
+
589
+ useEffect(() => {
590
+ const model = modelRef.current;
591
+ if (!model) return;
592
+ const style =
593
+ forecastLineStyle === 'dashed' ? LineStyle.Dashed : LineStyle.Solid;
594
+ for (const api of model.forecasts.values()) {
595
+ api.applyOptions({ lineStyle: style });
596
+ }
597
+ }, [forecastLineStyle]);
598
+
599
+ const handleLegendClick = useCallback(
600
+ (data: unknown, index: number, event: unknown) => {
601
+ const payloadItem = data as { dataKey?: string };
602
+ const key = payloadItem.dataKey;
603
+ if (key) {
604
+ if (!hiddenControlled) {
605
+ setLocalHidden(prev => {
606
+ const next = new Set(prev);
607
+ if (next.has(key)) next.delete(key);
608
+ else next.add(key);
609
+ return next;
610
+ });
611
+ }
612
+ }
613
+ onLegendClick?.(data, index, event);
614
+ },
615
+ [hiddenControlled, onLegendClick],
616
+ );
617
+
618
+ if (error) {
619
+ return (
620
+ <div className={cn(S.root, className)}>
621
+ <div style={{ color: 'var(--destructive, #f43f5e)' }}>
622
+ Error: {error}
623
+ </div>
624
+ </div>
625
+ );
626
+ }
627
+
628
+ if (loading) {
629
+ return (
630
+ <div className={cn(S.root, className)}>
631
+ <div style={{ height: pixelHeight }}>
632
+ <Skeleton style={{ width: '100%', height: '100%' }} />
633
+ </div>
634
+ </div>
635
+ );
636
+ }
637
+
638
+ if (!bridgedChartData.length) {
639
+ return (
640
+ <div className={cn(S.root, className)}>
641
+ <div style={{ height: pixelHeight }}>{noDataMessage}</div>
642
+ </div>
643
+ );
644
+ }
645
+
646
+ return (
647
+ <ChartContext.Provider value={{ config: mergedChartConfig }}>
648
+ <div className={cn(S.root, className)}>
649
+ <div
650
+ data-slot="chart"
651
+ data-chart={`chart-${chartId}`}
652
+ className={S.shell}
653
+ ref={shellRef}
654
+ style={{ position: 'relative', width: '100%' }}
655
+ >
656
+ <ChartStyle id={`chart-${chartId}`} config={mergedChartConfig} />
657
+ <div
658
+ ref={hostRef}
659
+ className={S.host}
660
+ style={{ width: '100%', height: pixelHeight }}
661
+ />
662
+
663
+ {showTooltip ? (
664
+ <div
665
+ ref={tooltipRef}
666
+ className={S.tooltipMove}
667
+ style={{
668
+ opacity:
669
+ tooltipState.active && tooltipState.payload.length ? 1 : 0,
670
+ }}
671
+ >
672
+ <ChartTooltipContent
673
+ active={tooltipState.active && tooltipState.payload.length > 0}
674
+ label={tooltipState.label}
675
+ payload={
676
+ tooltipState.active && tooltipState.payload.length
677
+ ? (tooltipState.payload as never)
678
+ : ([] as never)
679
+ }
680
+ labelFormatter={lbl =>
681
+ formatDateFn(
682
+ typeof lbl === 'string' ? lbl : String(lbl),
683
+ true,
684
+ )
685
+ }
686
+ formatter={
687
+ formatNumber
688
+ ? (value, name) => {
689
+ const v =
690
+ typeof value === 'number'
691
+ ? formatNumber(value)
692
+ : Array.isArray(value)
693
+ ? `${formatNumber(value[0])} – ${formatNumber(value[1])}`
694
+ : String(value ?? '');
695
+ return (
696
+ <>
697
+ <span>{name}: </span>
698
+ <span>{v}</span>
699
+ </>
700
+ );
701
+ }
702
+ : undefined
703
+ }
704
+ />
705
+ </div>
706
+ ) : null}
707
+ </div>
708
+
709
+ {showLegend ? (
710
+ <div className={S.footer}>
711
+ <CustomChartLegend
712
+ payload={legendPayload}
713
+ hiddenSeries={hiddenSeries}
714
+ onClick={handleLegendClick}
715
+ />
716
+ </div>
717
+ ) : null}
718
+ </div>
719
+ </ChartContext.Provider>
720
+ );
721
+ }