@sybilion/uilib 1.2.26 → 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 (137) hide show
  1. package/dist/esm/components/ui/Chart/Chart.js +5 -0
  2. package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +7 -32
  3. package/dist/esm/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.js +21 -0
  4. package/dist/esm/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl.js +7 -0
  5. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.js +460 -0
  6. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.styl.js +7 -0
  7. package/dist/esm/components/ui/Chart/lightweight/chartTime.js +16 -0
  8. package/dist/esm/components/ui/Chart/lightweight/lightweightForecastChart.helpers.js +114 -0
  9. package/dist/esm/components/ui/Chart/lightweight/quantileBandCustomSeries.js +147 -0
  10. package/dist/esm/components/ui/Chart/quantileBandConeChartData.js +131 -0
  11. package/dist/esm/components/ui/Chart/tools/chartPlotGeometry.js +65 -0
  12. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +37 -1
  13. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.js +5 -2
  14. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.js +205 -0
  15. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.styl.js +7 -0
  16. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.js +37 -0
  17. package/dist/esm/components/ui/ChartAreaInteractive/overlays/IntervalsOverlay/IntervalsOverlay.hooks.js +1 -0
  18. package/dist/esm/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.js +7 -60
  19. package/dist/esm/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl.js +2 -2
  20. package/dist/esm/components/ui/ChartAreaInteractive/overlays/ThresholdsOverlay/ThresholdsOverlay.hooks.js +1 -0
  21. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useChartYRange.js +2 -4
  22. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useQuantileBands.js +4 -102
  23. package/dist/esm/components/ui/TimeRangeControls/TimeRangeControls.js +7 -2
  24. package/dist/esm/components/ui/WorldMap/WorldMap.js +11 -0
  25. package/dist/esm/components/ui/WorldMap/WorldMap.styl.js +7 -0
  26. package/dist/esm/components/widgets/DriverCard/DriverCard.js +89 -0
  27. package/dist/esm/components/widgets/DriverCard/DriverCard.styl.js +7 -0
  28. package/dist/esm/components/widgets/DriverCard/DriverPerformanceChart.js +83 -0
  29. package/dist/esm/components/widgets/DriverCard/DriverPerformanceChart.styl.js +7 -0
  30. package/dist/esm/components/widgets/DriverCard/driverPerformanceChartData.js +50 -0
  31. package/dist/esm/components/widgets/DriverMap/DriverMap.js +2 -2
  32. package/dist/esm/components/widgets/DriverMap/DriverMap.styl.js +2 -2
  33. package/dist/esm/index.js +4 -2
  34. package/dist/esm/types/src/components/ui/Chart/Chart.d.ts +2 -0
  35. package/dist/esm/types/src/components/ui/Chart/components/BaseChartWrapper.d.ts +2 -1
  36. package/dist/esm/types/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.d.ts +14 -0
  37. package/dist/esm/types/src/components/ui/Chart/lightweight/LightweightForecastChart.d.ts +26 -0
  38. package/dist/esm/types/src/components/ui/Chart/lightweight/chartTime.d.ts +5 -0
  39. package/dist/esm/types/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.d.ts +13 -0
  40. package/dist/esm/types/src/components/ui/Chart/lightweight/quantileBandCustomSeries.d.ts +24 -0
  41. package/dist/esm/types/src/components/ui/Chart/quantileBandConeChartData.d.ts +7 -0
  42. package/dist/esm/types/src/components/ui/Chart/tools/chartPlotGeometry.d.ts +30 -0
  43. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.d.ts +1 -1
  44. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +11 -2
  45. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.d.ts +2 -2
  46. package/dist/esm/types/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.d.ts +15 -0
  47. package/dist/esm/types/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.d.ts +14 -0
  48. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.d.ts +1 -1
  49. package/dist/esm/types/src/components/ui/Page/PageColumns/PageColumns.d.ts +1 -1
  50. package/dist/esm/types/src/components/ui/TimeRangeControls/TimeRangeControls.d.ts +5 -7
  51. package/dist/esm/types/src/components/ui/WorldMap/WorldMap.d.ts +4 -0
  52. package/dist/esm/types/src/components/ui/WorldMap/index.d.ts +2 -0
  53. package/dist/esm/types/src/components/widgets/DriverCard/DriverCard.d.ts +9 -0
  54. package/dist/esm/types/src/components/widgets/DriverCard/DriverPerformanceChart.d.ts +5 -0
  55. package/dist/esm/types/src/components/widgets/DriverCard/driverPerformanceChartData.d.ts +7 -0
  56. package/dist/esm/types/src/components/widgets/DriverCard/index.d.ts +1 -0
  57. package/dist/esm/types/src/components/widgets/DriverMap/index.d.ts +0 -2
  58. package/dist/esm/types/src/docs/pages/LightweightChartPage.d.ts +1 -0
  59. package/dist/esm/types/src/docs/pages/PageColumnsPage.d.ts +1 -0
  60. package/dist/esm/types/src/docs/pages/WorldMapPage.d.ts +1 -0
  61. package/dist/esm/types/src/index.d.ts +2 -0
  62. package/package.json +3 -2
  63. package/src/components/ui/Chart/Chart.tsx +9 -0
  64. package/src/components/ui/Chart/components/BaseChartWrapper.tsx +8 -41
  65. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl +60 -0
  66. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl.d.ts +15 -0
  67. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.tsx +66 -0
  68. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl +25 -0
  69. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl.d.ts +11 -0
  70. package/src/components/ui/Chart/lightweight/LightweightForecastChart.tsx +721 -0
  71. package/src/components/ui/Chart/lightweight/chartTime.ts +18 -0
  72. package/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.ts +141 -0
  73. package/src/components/ui/Chart/lightweight/quantileBandCustomSeries.ts +215 -0
  74. package/src/components/ui/Chart/quantileBandConeChartData.ts +171 -0
  75. package/src/components/ui/Chart/tools/chartPlotGeometry.ts +89 -0
  76. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +44 -2
  77. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.tsx +14 -1
  78. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.ts +2 -3
  79. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.styl +21 -0
  80. package/src/components/{widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl.d.ts → ui/ChartAreaInteractive/TimeRangeBrushLayer.styl.d.ts} +3 -3
  81. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.tsx +285 -0
  82. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.ts +55 -0
  83. package/src/components/ui/ChartAreaInteractive/overlays/IntervalsOverlay/IntervalsOverlay.hooks.ts +1 -0
  84. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl +2 -7
  85. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl.d.ts +0 -1
  86. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.tsx +7 -71
  87. package/src/components/ui/ChartAreaInteractive/overlays/ThresholdsOverlay/ThresholdsOverlay.hooks.ts +1 -0
  88. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.ts +2 -3
  89. package/src/components/ui/ChartAreaInteractive/overlays/useQuantileBands.ts +5 -131
  90. package/src/components/ui/Page/PageColumns/PageColumns.tsx +1 -1
  91. package/src/components/ui/TimeRangeControls/TimeRangeControls.tsx +16 -17
  92. package/src/components/{widgets/DriverMap/MapBackground/MapBackground.styl → ui/WorldMap/WorldMap.styl} +1 -3
  93. package/src/components/{widgets/DriverMap/MapBackground/MapBackground.styl.d.ts → ui/WorldMap/WorldMap.styl.d.ts} +1 -1
  94. package/src/components/ui/WorldMap/WorldMap.tsx +22 -0
  95. package/src/components/ui/WorldMap/index.ts +2 -0
  96. package/src/components/widgets/DriverCard/DriverCard.styl +169 -0
  97. package/src/components/widgets/DriverCard/DriverCard.styl.d.ts +40 -0
  98. package/src/components/widgets/DriverCard/DriverCard.tsx +219 -0
  99. package/src/components/widgets/DriverCard/DriverPerformanceChart.styl +43 -0
  100. package/src/components/widgets/DriverCard/DriverPerformanceChart.styl.d.ts +13 -0
  101. package/src/components/widgets/DriverCard/DriverPerformanceChart.tsx +150 -0
  102. package/src/components/widgets/DriverCard/driverPerformanceChartData.ts +64 -0
  103. package/src/components/widgets/DriverCard/index.ts +1 -0
  104. package/src/components/widgets/DriverMap/DriverIcon/DriverIcon.tsx +0 -2
  105. package/src/components/widgets/DriverMap/DriverMap.styl +6 -1
  106. package/src/components/widgets/DriverMap/DriverMap.styl.d.ts +1 -0
  107. package/src/components/widgets/DriverMap/DriverMap.tsx +2 -4
  108. package/src/components/widgets/DriverMap/driverCategoryIcon.tsx +0 -2
  109. package/src/components/widgets/DriverMap/index.ts +0 -2
  110. package/src/declarations.d.ts +2 -0
  111. package/src/docs/config/webpack.config.js +26 -3
  112. package/src/docs/index.tsx +1 -1
  113. package/src/docs/pages/ChartAreaInteractivePage.tsx +2 -3
  114. package/src/docs/pages/DriverMapPage.tsx +214 -60
  115. package/src/docs/pages/LightweightChartPage.styl +18 -0
  116. package/src/docs/pages/LightweightChartPage.styl.d.ts +10 -0
  117. package/src/docs/pages/LightweightChartPage.tsx +195 -0
  118. package/src/docs/pages/PageColumnsPage.tsx +92 -0
  119. package/src/docs/pages/TimeRangeControlsPage.tsx +2 -3
  120. package/src/docs/pages/WorldMapPage.styl +14 -0
  121. package/src/docs/pages/WorldMapPage.styl.d.ts +8 -0
  122. package/src/docs/pages/WorldMapPage.tsx +26 -0
  123. package/src/docs/registry.ts +19 -1
  124. package/src/index.ts +2 -0
  125. package/dist/esm/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.js +0 -8
  126. package/dist/esm/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl.js +0 -7
  127. package/dist/esm/components/widgets/DriverMap/MapBackground/MapBackground.js +0 -10
  128. package/dist/esm/components/widgets/DriverMap/MapBackground/MapBackground.styl.js +0 -7
  129. package/dist/esm/types/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.d.ts +0 -1
  130. package/dist/esm/types/src/components/widgets/DriverMap/MapBackground/MapBackground.d.ts +0 -1
  131. package/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl +0 -24
  132. package/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.tsx +0 -11
  133. package/src/components/widgets/DriverMap/MapBackground/MapBackground.tsx +0 -18
  134. /package/dist/esm/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/map.svg.js +0 -0
  135. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/map.svg +0 -0
  136. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/mapAspect.mixin.styl +0 -0
  137. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/mapAspect.mixin.styl.d.ts +0 -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
+ }