@exegia/corpora-ui 2.0.0 → 3.0.0

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 (55) hide show
  1. package/dist-lib/components/composed/chat/chart/chart-atom.d.ts +30 -0
  2. package/dist-lib/components/composed/chat/chart/chart-context.d.ts +2 -0
  3. package/dist-lib/components/composed/chat/chart/chart-legend.d.ts +4 -0
  4. package/dist-lib/components/composed/chat/chart/chart-plot.d.ts +3 -0
  5. package/dist-lib/components/composed/chat/chart/chart-root.d.ts +3 -0
  6. package/dist-lib/components/composed/chat/chart/chart-tooltip.d.ts +4 -0
  7. package/dist-lib/components/composed/chat/chart/constants.d.ts +3 -0
  8. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-area-chart.d.ts +93 -0
  9. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-bar-chart.d.ts +86 -0
  10. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-brush.d.ts +65 -0
  11. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-chart.d.ts +44 -0
  12. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-dot.d.ts +16 -0
  13. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-legend.d.ts +24 -0
  14. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-line-chart.d.ts +90 -0
  15. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-pie-chart.d.ts +73 -0
  16. package/dist-lib/components/composed/chat/chart/evilcharts/echarts-tooltip.d.ts +29 -0
  17. package/dist-lib/components/composed/chat/chart/index.d.ts +6 -0
  18. package/dist-lib/components/composed/chat/chart/type.d.ts +80 -0
  19. package/dist-lib/components/composed/chat/chart/use-chart-state.d.ts +3 -0
  20. package/dist-lib/components/composed/chat/chart/use-chart.d.ts +6 -0
  21. package/dist-lib/components/composed/chat/chart/utils.d.ts +5 -0
  22. package/dist-lib/components/composed/chat/chart.d.ts +1 -44
  23. package/dist-lib/components/composed/chat/index.d.ts +1 -1
  24. package/dist-lib/components/stories/chart.story.d.ts +39 -2
  25. package/dist-lib/components/stories/insight-cards.story.d.ts +30 -1
  26. package/dist-lib/index.js +10020 -5549
  27. package/dist-lib/index.js.map +1 -1
  28. package/package.json +2 -1
  29. package/src/components/composed/chat/__tests__/chart.test.tsx +255 -1
  30. package/src/components/composed/chat/chart/chart-atom.ts +55 -0
  31. package/src/components/composed/chat/chart/chart-context.ts +9 -0
  32. package/src/components/composed/chat/chart/chart-legend.tsx +75 -0
  33. package/src/components/composed/chat/chart/chart-plot.tsx +233 -0
  34. package/src/components/composed/chat/chart/chart-root.tsx +229 -0
  35. package/src/components/composed/chat/chart/chart-tooltip.tsx +74 -0
  36. package/src/components/composed/chat/chart/constants.ts +8 -0
  37. package/src/components/composed/chat/chart/evilcharts/LICENSE +21 -0
  38. package/src/components/composed/chat/chart/evilcharts/README.md +8 -0
  39. package/src/components/composed/chat/chart/evilcharts/echarts-area-chart.tsx +2358 -0
  40. package/src/components/composed/chat/chart/evilcharts/echarts-bar-chart.tsx +2261 -0
  41. package/src/components/composed/chat/chart/evilcharts/echarts-brush.tsx +233 -0
  42. package/src/components/composed/chat/chart/evilcharts/echarts-chart.tsx +214 -0
  43. package/src/components/composed/chat/chart/evilcharts/echarts-dot.tsx +111 -0
  44. package/src/components/composed/chat/chart/evilcharts/echarts-legend.tsx +131 -0
  45. package/src/components/composed/chat/chart/evilcharts/echarts-line-chart.tsx +2112 -0
  46. package/src/components/composed/chat/chart/evilcharts/echarts-pie-chart.tsx +1235 -0
  47. package/src/components/composed/chat/chart/evilcharts/echarts-tooltip.tsx +125 -0
  48. package/src/components/composed/chat/chart/index.ts +13 -0
  49. package/src/components/composed/chat/chart/type.ts +88 -0
  50. package/src/components/composed/chat/chart/use-chart-state.ts +22 -0
  51. package/src/components/composed/chat/chart/use-chart.ts +70 -0
  52. package/src/components/composed/chat/chart/utils.ts +47 -0
  53. package/src/components/composed/chat/chart.tsx +2 -400
  54. package/src/components/composed/chat/index.ts +1 -7
  55. package/src/components/stories/chart.story.tsx +16 -1
@@ -0,0 +1,2112 @@
1
+ "use client";
2
+
3
+ import {
4
+ DEFAULT_ECHARTS_RENDERER,
5
+ buildChartCss,
6
+ flattenColor,
7
+ getColorsCount,
8
+ resolveColors,
9
+ seriesPaint,
10
+ withAlpha,
11
+ type ChartConfig,
12
+ type EChartsRenderer,
13
+ type ResolvedColors,
14
+ } from "./echarts-chart";
15
+ import {
16
+ tooltipBaseOption,
17
+ tooltipIndicatorHtml,
18
+ tooltipRow,
19
+ tooltipShell,
20
+ type TooltipPosition,
21
+ type TooltipRoundness,
22
+ type TooltipVariant,
23
+ } from "./echarts-tooltip";
24
+ import {
25
+ Brush,
26
+ buildBrushDataZoom,
27
+ syncBrushOverlay,
28
+ type BrushGeometry,
29
+ type BrushOverlayElements,
30
+ type BrushProps,
31
+ type BrushRange,
32
+ } from "./echarts-brush";
33
+ import {
34
+ DataZoomComponent,
35
+ GridComponent,
36
+ TooltipComponent,
37
+ type DataZoomComponentOption,
38
+ type GridComponentOption,
39
+ type TooltipComponentOption,
40
+ } from "echarts/components";
41
+ import {
42
+ Children,
43
+ isValidElement,
44
+ useCallback,
45
+ useEffect,
46
+ useId,
47
+ useMemo,
48
+ useRef,
49
+ useState,
50
+ type CSSProperties,
51
+ type FC,
52
+ type ReactNode,
53
+ } from "react";
54
+ import {
55
+ dotItemStyle,
56
+ dotStyle,
57
+ sampleGradient,
58
+ type DotItemStyleOption,
59
+ type DotVariant,
60
+ } from "./echarts-dot";
61
+ import { LegendOverlay, type LegendVariant } from "./echarts-legend";
62
+ import { LineChart, type LineSeriesOption } from "echarts/charts";
63
+ import { motion, useReducedMotion } from "motion/react";
64
+ import type { ComposeOption } from "echarts/core";
65
+ import * as echarts from "echarts/core";
66
+
67
+ // Re-export the shared types that were previously declared inline here, so
68
+ // existing consumers/examples keep importing them from the chart module.
69
+ export type {
70
+ ChartConfig,
71
+ DotVariant,
72
+ EChartsRenderer,
73
+ LegendVariant,
74
+ TooltipPosition,
75
+ TooltipRoundness,
76
+ TooltipVariant,
77
+ };
78
+
79
+ // Modular registration keeps the bundle lean — only the pieces this chart needs.
80
+ // `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/drag)
81
+ // zoom. The brush's frame/handles/labels are raw zrender elements, not the
82
+ // graphic component — see syncBrushOverlay. No GraphicComponent is registered.
83
+ echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent]);
84
+
85
+ type EChartsInstance = ReturnType<typeof echarts.init>;
86
+
87
+ // The exact option surface this chart uses — line series, grid, tooltip, and
88
+ // dataZoom, plus the axis options they pull in as dependencies. Narrower than
89
+ // echarts' full EChartsOption, so a misspelled key fails the compile instead of
90
+ // silently reaching setOption.
91
+ type EChartsOption = ComposeOption<
92
+ LineSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption
93
+ >;
94
+
95
+ // Single-entry views of the composed option's array-or-single fields — the
96
+ // modular entry points don't export the axis option types directly.
97
+ type ArrayItem<T> = T extends readonly (infer U)[] ? U : T;
98
+ type XAxisOption = ArrayItem<NonNullable<EChartsOption["xAxis"]>>;
99
+ type YAxisOption = ArrayItem<NonNullable<EChartsOption["yAxis"]>>;
100
+
101
+ // DotItemStyleOption now lives in ./echarts-dot and is imported at
102
+ // the top of this file.
103
+
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+ // Constants
106
+ // ─────────────────────────────────────────────────────────────────────────────
107
+
108
+ const STROKE_WIDTH = 0.8; // default series stroke — <Line strokeWidth> overrides it
109
+ const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds
110
+ const REVEAL_DURATION = 1000; // intro draw-in length, in milliseconds
111
+ // NOTE: the intro draw-in runs ECharts' RAW default entrance animation. Custom
112
+ // easing was tried and abandoned in the area twin — ECharts hardcodes the
113
+ // line-entrance clip to linear and ignores animationEasing at every level.
114
+ const LOADING_DEFAULT_POINTS = 14;
115
+ // Buffer line: the last segment renders as this dash while the rest stays solid,
116
+ // echoing the Recharts twin's 4px dash / 3px gap forecast tail.
117
+ const BUFFER_DASH: [number, number] = [4, 3];
118
+
119
+ // <Line glowing> glow. Canvas has no SVG blur filter over a whole shape, so the
120
+ // glow is built from SILENT, stacked copies of the line laid UNDER the real one.
121
+ //
122
+ // The layers are all the SAME NARROW WIDTH on purpose. A wide translucent stroke
123
+ // has a HARD edge, so widening each copy (the obvious approach) paints concentric
124
+ // contour rings, not a glow — no number of layers hides it, because every layer
125
+ // contributes another visible boundary. Here each copy stays hidden beneath the
126
+ // real line and the visible halo comes entirely from its canvas `shadowBlur`,
127
+ // which is a true gaussian: edgeless by construction, and summing several at
128
+ // different radii stays perfectly smooth.
129
+ //
130
+ // The trade: a canvas shadow is a single flat color, so the halo is cast in the
131
+ // gradient's mid tone (`sampleGradient(slots, 0.5)`) rather than tracking the
132
+ // stroke's color along its length. The stroke copies themselves still carry the
133
+ // real gradient, so the bright core reads correctly; only the soft bloom is one
134
+ // hue. Smooth beats hue-accurate here. `symbolPad` grows the glow disc under each
135
+ // visible dot so haloed markers bloom too.
136
+ const GLOW_LAYERS: { width: number; opacity: number; blur: number; symbolPad: number }[] = [
137
+ { width: 2, opacity: 0.9, blur: 5, symbolPad: 2 },
138
+ { width: 2, opacity: 0.6, blur: 12, symbolPad: 6 },
139
+ { width: 2, opacity: 0.38, blur: 24, symbolPad: 11 },
140
+ { width: 2, opacity: 0.22, blur: 42, symbolPad: 16 },
141
+ ];
142
+
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+ // Theme knobs — every neutral line in the chart draws from these. Base colors
145
+ // come from the consumer's CSS tokens (resolved from the live DOM), so only the
146
+ // opacity factors live here. Factors MULTIPLY the token's own alpha — a border
147
+ // token that is already 10%-white stays subtle. Tune here, not in the builder.
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // Recharts draws its grid at border/50, but SVG dashes render pixel-crisp while
150
+ // canvas at 2× DPR spreads a 1px line across device pixels — roughly halving
151
+ // perceived intensity. Using the border token's full alpha lands both engines at
152
+ // the same apparent brightness.
153
+ const GRID_LINE_OPACITY = 1; // dashed y-axis split lines, × border alpha
154
+ const AXIS_POINTER_OPACITY = 1; // tooltip cursor line, × border alpha
155
+ // The skeleton is CLIPPED to a small sweeping window — only the stroke section
156
+ // inside it exists, everything outside is fully transparent, like a clip-path
157
+ // sliding across the chart.
158
+ const LOADING_STROKE_OPACITY = 0.5; // outline inside the window, × foreground alpha
159
+ const LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of chart width
160
+ const LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window
161
+ const BRUSH_STROKE_OPACITY = 0.5; // mini-chart series stroke (evil-brush "line" variant)
162
+ const BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none
163
+
164
+ // ─────────────────────────────────────────────────────────────────────────────
165
+ // Public types
166
+ // ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ export type StrokeVariant = "solid" | "dashed" | "animated-dashed";
169
+ export type LineAnimationType =
170
+ | "none"
171
+ | "left-to-right"
172
+ | "right-to-left"
173
+ | "center-out"
174
+ | "edges-in";
175
+ export type CurveType =
176
+ | "linear"
177
+ | "smooth"
178
+ | "bump"
179
+ | "monotone"
180
+ | "monotoneX"
181
+ | "monotoneY"
182
+ | "natural"
183
+ | "step";
184
+ // DotVariant, TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig
185
+ // now live in the shared ./echarts/* modules and are imported +
186
+ // re-exported at the top of this file.
187
+
188
+ export interface EChartsLineChartProps<TData extends Record<string, unknown>> {
189
+ data: TData[]; // rows rendered by the chart
190
+ config: ChartConfig; // series colors + labels
191
+ renderer?: EChartsRenderer; // rendering engine — defaults to canvas
192
+ xDataKey?: keyof TData & string; // x category key — falls back to the <XAxis> dataKey / first free column
193
+ className?: string; // extra classes for the chart container
194
+ curveType?: CurveType; // default curve interpolation each <Line> inherits
195
+ animation?: boolean; // master switch for the intro draw-in — false renders instantly
196
+ animationType?: LineAnimationType; // default intro reveal (first <Line> overrides)
197
+ enableHoverHighlight?: boolean; // hovering a series dims the others, like a temporary selection
198
+ enableHoverReveal?: boolean; // hovering colors each line up to the pointer's x and mutes the rest
199
+ selectedDataKey?: string | null;
200
+ defaultSelectedDataKey?: string | null; // series selected on first render
201
+ onSelectionChange?: (key: string | null) => void; // fires when the selected series changes
202
+ isLoading?: boolean; // shows the animated loading skeleton
203
+ loadingPoints?: number; // number of points in the loading skeleton
204
+ chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option
205
+ children?: ReactNode; // declarative config — <Line>, <XAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>, …
206
+ }
207
+
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+ // Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
210
+ // walks `children` by reference (child.type === Line, …) to collect its props.
211
+ // Presence semantics mirror the Recharts twin: omit a child and that part does
212
+ // not render. These are never mounted into the tree — they only carry props.
213
+ // ─────────────────────────────────────────────────────────────────────────────
214
+
215
+ export interface LineProps {
216
+ dataKey: string; // series key — must exist on the data + config
217
+ strokeVariant?: StrokeVariant; // stroke style for this line
218
+ strokeWidth?: number; // stroke thickness in pixels for this line
219
+ curveType?: CurveType; // curve interpolation — falls back to the root curveType
220
+ animationType?: LineAnimationType; // intro reveal — first line drives the wrapper wipe
221
+ connectNulls?: boolean; // join segments across null/missing values
222
+ isClickable?: boolean; // lets this line be selected by clicking it
223
+ glowing?: boolean; // applies a soft outer glow to this line
224
+ enableBufferLine?: boolean; // renders this line's last segment as a dashed buffer
225
+ children?: ReactNode; // optional <Dot> and <ActiveDot> config
226
+ }
227
+
228
+ /**
229
+ * A single line series. Declares its own stroke/curve/glow/clickability and,
230
+ * optionally, resting/active point markers via composed <Dot> / <ActiveDot>.
231
+ * Renders nothing — the root reads these props to build the ECharts series.
232
+ */
233
+ const Line: FC<LineProps> = () => null;
234
+
235
+ export interface DotProps {
236
+ variant?: DotVariant; // visual style of the point marker
237
+ }
238
+
239
+ /** Declares the resting point marker for the enclosing <Line>. Renders nothing. */
240
+ const Dot: FC<DotProps> = () => null;
241
+
242
+ /** Declares the hovered/active point marker for the enclosing <Line>. Renders nothing. */
243
+ const ActiveDot: FC<DotProps> = () => null;
244
+
245
+ export interface XAxisProps {
246
+ dataKey?: string; // x category key — overrides the root xDataKey
247
+ // Category-axis values are always stringified, so the formatter sees a string —
248
+ // letting examples share `(value) => value.substring(0, 3)` with the Recharts twin.
249
+ tickFormatter?: (value: string, index: number) => string; // formats x tick labels
250
+ label?: string; // axis title, centered below the tick labels
251
+ hideDots?: boolean; // hides the tick dots beside this axis's labels
252
+ }
253
+
254
+ /** Presence shows the x-axis category labels. Renders nothing. */
255
+ const XAxis: FC<XAxisProps> = () => null;
256
+
257
+ export interface YAxisProps {
258
+ dataKey?: string; // reserved for parity with the Recharts twin
259
+ tickFormatter?: (value: number, index: number) => string; // formats y tick labels
260
+ label?: string; // axis title, rotated alongside the tick labels
261
+ hideDots?: boolean; // hides the tick dots beside this axis's labels
262
+ }
263
+
264
+ /** Presence shows the y value axis. Renders nothing. */
265
+ const YAxis: FC<YAxisProps> = () => null;
266
+
267
+ /** Presence shows the dashed horizontal split lines. Renders nothing. */
268
+ const Grid: FC = () => null;
269
+
270
+ export interface TooltipProps {
271
+ variant?: TooltipVariant; // visual style of the tooltip surface
272
+ roundness?: TooltipRoundness; // border-radius of the tooltip
273
+ cursor?: boolean; // whether the vertical cursor line follows the pointer
274
+ position?: TooltipPosition; // "variable" follows both axes (default); "fixed" pins the tooltip near the top and tracks the pointer's X
275
+ }
276
+
277
+ /** Presence enables the hover tooltip. Renders nothing. */
278
+ const Tooltip: FC<TooltipProps> = () => null;
279
+
280
+ export interface LegendProps {
281
+ variant?: LegendVariant; // visual style of the legend indicators
282
+ align?: "left" | "center" | "right"; // horizontal placement
283
+ verticalAlign?: "top" | "middle" | "bottom"; // vertical placement
284
+ isClickable?: boolean; // lets each entry toggle selection of its series
285
+ }
286
+
287
+ /** Presence enables the HTML legend overlay. Renders nothing. */
288
+ const Legend: FC<LegendProps> = () => null;
289
+
290
+ // ─────────────────────────────────────────────────────────────────────────────
291
+ // Children collection — walk the declarative config into plain objects the
292
+ // option builder consumes. <Dot> / <ActiveDot> are read from each <Line>'s own
293
+ // children; a missing dot child means that marker does not render.
294
+ // ─────────────────────────────────────────────────────────────────────────────
295
+
296
+ type LineSeriesConfig = {
297
+ dataKey: string;
298
+ strokeVariant: StrokeVariant;
299
+ strokeWidth: number;
300
+ curveType?: CurveType;
301
+ animationType?: LineAnimationType;
302
+ connectNulls: boolean;
303
+ isClickable: boolean;
304
+ glowing: boolean;
305
+ enableBufferLine: boolean;
306
+ dotVariant: DotVariant; // "none" when no <Dot> child is present
307
+ activeDotVariant: DotVariant; // "none" when no <ActiveDot> child is present
308
+ };
309
+
310
+ type XAxisSlot = {
311
+ present: boolean;
312
+ dataKey?: string;
313
+ tickFormatter?: (value: string, index: number) => string;
314
+ label?: string;
315
+ hideDots: boolean;
316
+ };
317
+ type YAxisSlot = {
318
+ present: boolean;
319
+ dataKey?: string;
320
+ tickFormatter?: (value: number, index: number) => string;
321
+ label?: string;
322
+ hideDots: boolean;
323
+ };
324
+ type TooltipSlot = {
325
+ present: boolean;
326
+ variant: TooltipVariant;
327
+ roundness: TooltipRoundness;
328
+ cursor: boolean;
329
+ position: TooltipPosition;
330
+ };
331
+ type LegendSlot = {
332
+ present: boolean;
333
+ variant: LegendVariant;
334
+ align: "left" | "center" | "right";
335
+ verticalAlign: "top" | "middle" | "bottom";
336
+ isClickable: boolean;
337
+ };
338
+ type BrushSlot = {
339
+ present: boolean; // a <Brush> child was passed — replaces the old showBrush prop
340
+ height?: number;
341
+ formatLabel?: (value: string, index: number) => string;
342
+ onChange?: (range: { startIndex: number; endIndex: number }) => void;
343
+ };
344
+
345
+ type CollectedConfig = {
346
+ lines: LineSeriesConfig[];
347
+ xAxis: XAxisSlot;
348
+ yAxis: YAxisSlot;
349
+ showGrid: boolean;
350
+ tooltip: TooltipSlot;
351
+ legend: LegendSlot;
352
+ brush: BrushSlot;
353
+ };
354
+
355
+ function collectConfig(children: ReactNode): CollectedConfig {
356
+ const lines: LineSeriesConfig[] = [];
357
+ let xAxis: XAxisSlot = { present: false, hideDots: false };
358
+ let yAxis: YAxisSlot = { present: false, hideDots: false };
359
+ let showGrid = false;
360
+ let tooltip: TooltipSlot = {
361
+ present: false,
362
+ variant: "default",
363
+ roundness: "lg",
364
+ cursor: true,
365
+ position: "variable",
366
+ };
367
+ let legend: LegendSlot = {
368
+ present: false,
369
+ variant: "rounded-square",
370
+ align: "right",
371
+ verticalAlign: "top",
372
+ isClickable: false,
373
+ };
374
+ let brush: BrushSlot = { present: false };
375
+
376
+ Children.forEach(children, (child) => {
377
+ if (!isValidElement(child)) return;
378
+ const type = child.type;
379
+
380
+ if (type === Line) {
381
+ const props = child.props as LineProps;
382
+ let dotVariant: DotVariant = "none";
383
+ let activeDotVariant: DotVariant = "none";
384
+ Children.forEach(props.children, (dotChild) => {
385
+ if (!isValidElement(dotChild)) return;
386
+ if (dotChild.type === Dot) {
387
+ dotVariant = (dotChild.props as DotProps).variant ?? "default";
388
+ } else if (dotChild.type === ActiveDot) {
389
+ activeDotVariant = (dotChild.props as DotProps).variant ?? "default";
390
+ }
391
+ });
392
+ lines.push({
393
+ dataKey: props.dataKey,
394
+ // The Recharts twin defaults a <Line> to a solid stroke (its <Area>
395
+ // defaults to dashed — a divergence intentionally preserved here).
396
+ strokeVariant: props.strokeVariant ?? "solid",
397
+ strokeWidth: props.strokeWidth ?? STROKE_WIDTH,
398
+ curveType: props.curveType,
399
+ animationType: props.animationType,
400
+ connectNulls: props.connectNulls ?? false,
401
+ isClickable: props.isClickable ?? false,
402
+ glowing: props.glowing ?? false,
403
+ enableBufferLine: props.enableBufferLine ?? false,
404
+ dotVariant,
405
+ activeDotVariant,
406
+ });
407
+ } else if (type === XAxis) {
408
+ const props = child.props as XAxisProps;
409
+ xAxis = {
410
+ present: true,
411
+ dataKey: props.dataKey,
412
+ tickFormatter: props.tickFormatter,
413
+ label: props.label,
414
+ hideDots: props.hideDots ?? false,
415
+ };
416
+ } else if (type === YAxis) {
417
+ const props = child.props as YAxisProps;
418
+ yAxis = {
419
+ present: true,
420
+ dataKey: props.dataKey,
421
+ tickFormatter: props.tickFormatter,
422
+ label: props.label,
423
+ hideDots: props.hideDots ?? false,
424
+ };
425
+ } else if (type === Grid) {
426
+ showGrid = true;
427
+ } else if (type === Tooltip) {
428
+ const props = child.props as TooltipProps;
429
+ tooltip = {
430
+ present: true,
431
+ variant: props.variant ?? "default",
432
+ roundness: props.roundness ?? "lg",
433
+ cursor: props.cursor ?? true,
434
+ position: props.position ?? "variable",
435
+ };
436
+ } else if (type === Legend) {
437
+ const props = child.props as LegendProps;
438
+ legend = {
439
+ present: true,
440
+ variant: props.variant ?? "rounded-square",
441
+ align: props.align ?? "right",
442
+ verticalAlign: props.verticalAlign ?? "top",
443
+ isClickable: props.isClickable ?? false,
444
+ };
445
+ } else if (type === Brush) {
446
+ const props = child.props as BrushProps;
447
+ brush = {
448
+ present: true,
449
+ height: props.height,
450
+ formatLabel: props.formatLabel,
451
+ onChange: props.onChange,
452
+ };
453
+ }
454
+ });
455
+
456
+ return { lines, xAxis, yAxis, showGrid, tooltip, legend, brush };
457
+ }
458
+
459
+ // Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,
460
+ // normalizeColor, withAlpha, ResolvedColors, resolveColors, seriesPaint) now
461
+ // lives in ./echarts-chart and is imported at the top of this file.
462
+ // Dot helpers (DotVariant, DotItemStyleOption, DotStyle, DOT_SIZES, dotItemStyle,
463
+ // dotStyle, sampleGradient) live in ./echarts-dot.
464
+
465
+ // Builds the stacked glow overlay series for one <Line glowing> (see
466
+ // GLOW_LAYERS). Each copy is silent, tooltip-less, and z-ordered beneath the real
467
+ // line, so the widening low-alpha gradient strokes read as a soft colored blur
468
+ // that tracks the series' gradient exactly like the stroke does. When the line
469
+ // shows resting dots, each copy also draws an oversized faint symbol — coloured
470
+ // per-datum via sampleGradient — so the markers bloom too. `selectionDim` fades
471
+ // the whole glow with its parent when another series is selected; the
472
+ // emphasis/blur styles let it focus/dim WITH its parent under
473
+ // enableHoverHighlight (the root dispatch-links these ids — see companionIdsByKey).
474
+ function buildGlowSeries(params: {
475
+ key: string;
476
+ paint: string | echarts.graphic.LinearGradient;
477
+ slots: string[];
478
+ values: (number | null)[];
479
+ curve: { smooth: boolean; step: "middle" | false };
480
+ connectNulls: boolean;
481
+ z: number;
482
+ selectionDim: number;
483
+ dotSize: number;
484
+ }): LineSeriesOption[] {
485
+ const { key, paint, slots, values, curve, connectNulls, z, selectionDim, dotSize } = params;
486
+ const multiColor = slots.length > 1;
487
+ const base = slots[0] ?? "rgba(120, 120, 120, 1)";
488
+ const showDots = dotSize > 0;
489
+
490
+ return GLOW_LAYERS.map((layer, i): LineSeriesOption => {
491
+ const glowOpacity = layer.opacity * selectionDim;
492
+ const blurOpacity = glowOpacity * 0.3;
493
+ // Per-datum halo colours so a gradient glow tints each dot at its own
494
+ // x-position, matching sampleGradient on the real dots.
495
+ const glowData: LinePoint[] =
496
+ !multiColor || !showDots
497
+ ? values
498
+ : values.map((value, idx): LinePoint => {
499
+ if (value === null) return null;
500
+ const t = values.length > 1 ? idx / (values.length - 1) : 0;
501
+ const color = sampleGradient(slots, t);
502
+ return {
503
+ value,
504
+ itemStyle: { color, opacity: glowOpacity },
505
+ emphasis: { itemStyle: { color, opacity: glowOpacity } },
506
+ };
507
+ });
508
+
509
+ return {
510
+ id: `__glow-${i}-${key}`,
511
+ type: "line",
512
+ data: glowData,
513
+ smooth: curve.smooth,
514
+ step: curve.step,
515
+ connectNulls,
516
+ silent: true,
517
+ showSymbol: showDots,
518
+ symbol: "circle",
519
+ symbolSize: showDots ? dotSize + layer.symbolPad : 0,
520
+ tooltip: { show: false },
521
+ z,
522
+ lineStyle: {
523
+ color: paint,
524
+ width: layer.width,
525
+ opacity: glowOpacity,
526
+ // Feathers this layer's edge so the stack reads as one smooth falloff
527
+ // rather than concentric bands (see GLOW_LAYERS).
528
+ shadowBlur: layer.blur,
529
+ // Full-alpha shadow color: the element's own `opacity` above already
530
+ // scales its shadow, so pre-dimming here squares the alpha and washes
531
+ // the halo out.
532
+ shadowColor: sampleGradient(slots, 0.5),
533
+ cap: "round",
534
+ join: "round",
535
+ },
536
+ itemStyle: multiColor ? { opacity: glowOpacity } : { color: base, opacity: glowOpacity },
537
+ // Focus/dim WITH the parent line: on the parent's hover the root highlights
538
+ // these ids (emphasis → normal glow); when another series is hovered the
539
+ // parent's focus:"series" blurs these to a fainter still.
540
+ emphasis: {
541
+ focus: "none",
542
+ scale: false,
543
+ lineStyle: { opacity: glowOpacity },
544
+ itemStyle: { opacity: glowOpacity },
545
+ },
546
+ blur: { lineStyle: { opacity: blurOpacity }, itemStyle: { opacity: blurOpacity } },
547
+ };
548
+ });
549
+ }
550
+
551
+ // Brush overlays (BrushRange, BrushGeometry, BrushOverlayElements,
552
+ // BrushOverlayParams, syncBrushOverlay) live in ./echarts-brush
553
+ // and are imported at the top of this file.
554
+
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+ // Curve mapping — linear → straight, step → step:"middle", everything else → smooth.
557
+ // ─────────────────────────────────────────────────────────────────────────────
558
+
559
+ function curveConfig(curveType: CurveType): { smooth: boolean; step: "middle" | false } {
560
+ // Recharts "step" is d3's curveStep: the transition happens at the MIDPOINT
561
+ // between points, so each dot sits centered on its plateau.
562
+ if (curveType === "step") return { smooth: false, step: "middle" };
563
+ if (curveType === "linear") return { smooth: false, step: false };
564
+ return { smooth: true, step: false };
565
+ }
566
+
567
+ // ─────────────────────────────────────────────────────────────────────────────
568
+ // Selection opacities — dims a series only when another one is selected. Lines
569
+ // have no fill, so only the stroke and dots carry an opacity here.
570
+ // ─────────────────────────────────────────────────────────────────────────────
571
+
572
+ function getOpacity(selected: string | null, key: string) {
573
+ if (selected === null || selected === key) return { stroke: 1, dot: 1 };
574
+ return { stroke: 0.3, dot: 0.3 };
575
+ }
576
+
577
+ // ─────────────────────────────────────────────────────────────────────────────
578
+ // Loading skeleton helpers
579
+ // ─────────────────────────────────────────────────────────────────────────────
580
+
581
+ // Skeleton data as a smooth random walk in a comfortable band — reads like a
582
+ // resting chart instead of raw noise spikes.
583
+ function getLoadingData(points: number): number[] {
584
+ const rows: number[] = [];
585
+ let value = 30 + Math.random() * 20;
586
+ for (let i = 0; i < points; i++) {
587
+ value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16));
588
+ rows.push(Math.round(value));
589
+ }
590
+ return rows;
591
+ }
592
+
593
+ // Gradient stops forming a hard clip window around `center`: full `peak` alpha
594
+ // inside, zero outside, with a small feather so the edge isn't aliased.
595
+ // `center` may run outside [0, 1] so the window fully enters and exits the frame.
596
+ function shimmerWindowStops(center: number, color: string, peak: number) {
597
+ const half = LOADING_SHIMMER_BAND;
598
+ const feather = LOADING_SHIMMER_FEATHER;
599
+
600
+ const alphaAt = (x: number) => {
601
+ const dist = Math.abs(x - center);
602
+ if (dist <= half - feather) return peak;
603
+ if (dist >= half) return 0;
604
+ // Sine-eased falloff — a linear ramp still reads as a hard cut.
605
+ return peak * Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);
606
+ };
607
+
608
+ const offsets = [
609
+ 0,
610
+ center - half,
611
+ center - half + feather,
612
+ center,
613
+ center + half - feather,
614
+ center + half,
615
+ 1,
616
+ ]
617
+ .filter((x) => x >= 0 && x <= 1)
618
+ .sort((a, b) => a - b);
619
+
620
+ const stops: { offset: number; color: string }[] = [];
621
+ for (const offset of offsets) {
622
+ if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {
623
+ stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });
624
+ }
625
+ }
626
+ return stops;
627
+ }
628
+
629
+ // Tooltip HTML primitives (roundnessClass, tooltipVariantClass,
630
+ // tooltipIndicatorHtml, tooltipRow, tooltipShell) live in
631
+ // ./echarts-tooltip; indicatorBackground lives in
632
+ // ./echarts-chart. Both are imported at the top of this file.
633
+
634
+ // The `__buffer-` prefix marks the dashed forecast overlay of a buffer line; it
635
+ // carries the SAME key's value, so the tooltip recovers the key from it (see
636
+ // createTooltipFormatter). Every other `__`-prefixed series (mini chart, loading
637
+ // skeleton, hover-reveal base) is truly internal and never surfaces.
638
+ const BUFFER_PREFIX = "__buffer-";
639
+ // The `__reveal-` prefix marks the muted base layer of a hover-reveal line — see
640
+ // buildLineSeries. Internal, so the tooltip drops it like the mini/loading rows.
641
+ const REVEAL_PREFIX = "__reveal-";
642
+
643
+ // Legend overlay (legendFillStyle, legendOutlineStyle, LegendIndicator,
644
+ // LegendOverlay) lives in ./echarts-legend and is imported at the
645
+ // top of this file.
646
+
647
+ // ─────────────────────────────────────────────────────────────────────────────
648
+ // Option builders — pure functions from a snapshot context to ECharts option
649
+ // fragments. The component reads its refs and renderer size ONCE per build into
650
+ // this context; nothing below touches React state or the chart instance, so
651
+ // each fragment can be reasoned about (and tested) in isolation.
652
+ // ─────────────────────────────────────────────────────────────────────────────
653
+
654
+ type OptionBuildContext = {
655
+ data: Record<string, unknown>[];
656
+ config: ChartConfig;
657
+ lines: LineSeriesConfig[];
658
+ curveType: CurveType;
659
+ selectedDataKey: string | null;
660
+ hasSelection: boolean;
661
+ showGrid: boolean;
662
+ xAxisSlot: XAxisSlot;
663
+ yAxisSlot: YAxisSlot;
664
+ tooltipSlot: TooltipSlot;
665
+ legendSlot: LegendSlot;
666
+ isLoading: boolean;
667
+ loadingData: () => number[];
668
+ showBrush: boolean;
669
+ brushHeight: number;
670
+ enableHoverHighlight: boolean;
671
+ enableHoverReveal: boolean; // hover colors each line up to the pointer, mutes the rest
672
+ revealIndex: number | null; // pointer's x-index while revealing (null = idle → chart looks normal)
673
+ revealSink: Record<string, unknown[]>; // buildLineSeries writes each line's full per-datum points here for the hover handler
674
+ resolved: ResolvedColors;
675
+ rendererSize: { width: number; height: number }; // anchored reveal stroke gradients span the plot in absolute pixels
676
+ categories: string[];
677
+ brushRange: BrushRange; // zoom window carried through rebuilds
678
+ getHoveredKey: () => string | null; // read per tooltip render — hover never repushes the option
679
+ };
680
+
681
+ // Grid insets plus the footer band reserved for the brush. ECharts 6 contains
682
+ // axis labels automatically (the legacy `containLabel` flag now only triggers a
683
+ // deprecation warning).
684
+ function buildChartLayout({ legendSlot, xAxisSlot, showBrush, brushHeight }: OptionBuildContext): {
685
+ grid: GridComponentOption;
686
+ brushBottom: number;
687
+ } {
688
+ const legendTop = legendSlot.present && legendSlot.verticalAlign === "top";
689
+ const legendBottom = legendSlot.present && legendSlot.verticalAlign === "bottom";
690
+ // Clearance covers the x-axis labels plus the same breathing room the
691
+ // Recharts twin leaves between them and the brush. An x-axis TITLE renders
692
+ // below the labels (nameGap), so it needs its own band above the brush frame.
693
+ const brushGap = showBrush ? brushHeight + 30 + (xAxisSlot.label ? 22 : 0) : 0;
694
+
695
+ return {
696
+ grid: {
697
+ left: 8,
698
+ right: 8,
699
+ top: legendTop ? 42 : 16,
700
+ bottom: 8 + brushGap + (legendBottom ? 34 : 0),
701
+ },
702
+ brushBottom: legendBottom ? 34 : 6,
703
+ };
704
+ }
705
+
706
+ function buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {
707
+ const { xAxisSlot, yAxisSlot, showGrid, isLoading, categories, loadingData } = ctx;
708
+ const { tokens } = ctx.resolved;
709
+
710
+ const axisLabelColor = tokens.mutedForeground;
711
+ const splitLineColor = withAlpha(tokens.border, GRID_LINE_OPACITY);
712
+ // Gridline gray as an opaque color — see flattenColor.
713
+ const tickDotColor = flattenColor(splitLineColor, tokens.background);
714
+
715
+ const xTickFormatter = xAxisSlot.tickFormatter;
716
+ const yTickFormatter = yAxisSlot.tickFormatter;
717
+
718
+ const xAxis: XAxisOption = {
719
+ type: "category",
720
+ boundaryGap: false,
721
+ show: true,
722
+ data: isLoading ? loadingData().map((_, i) => i) : categories,
723
+ // Axis title — same size/color as the tick labels, pushed clear of them.
724
+ name: isLoading ? undefined : xAxisSlot.label,
725
+ nameLocation: "middle",
726
+ nameGap: 30,
727
+ nameTextStyle: { color: axisLabelColor, fontSize: 10 },
728
+ axisLine: { show: false },
729
+ // Tick DOTS: a near-zero-length tick whose round caps form a true circle,
730
+ // in the gridline gray (flattened opaque so the caps don't stack).
731
+ axisTick: {
732
+ show: !isLoading && xAxisSlot.present && !xAxisSlot.hideDots,
733
+ // Category ticks default to the BOUNDARY between categories, which on a
734
+ // boundaryGap axis drops the dot in the gap instead of under its label. A
735
+ // no-op here (boundaryGap is false) — set for parity with the bar/composed
736
+ // charts. The y-axis is always type:"value", which has no such option.
737
+ alignWithLabel: true,
738
+ length: 0.5,
739
+ lineStyle: { color: tickDotColor, width: 3, cap: "round" },
740
+ },
741
+ splitLine: { show: false },
742
+ axisLabel: {
743
+ show: !isLoading && xAxisSlot.present,
744
+ color: axisLabelColor,
745
+ fontSize: 10,
746
+ margin: 8,
747
+ formatter: xTickFormatter
748
+ ? (value: string, index: number) => xTickFormatter(value, index)
749
+ : undefined,
750
+ },
751
+ };
752
+
753
+ // An ECharts axis with `show: false` hides its splitLines too, but Recharts'
754
+ // <CartesianGrid> draws with or without a visible <YAxis>. Keep the axis on
755
+ // whenever <Grid/> is present and gate the LABELS on <YAxis/> instead.
756
+ const yAxis: YAxisOption = {
757
+ type: "value",
758
+ show: yAxisSlot.present || showGrid,
759
+ // Axis title — rendered rotated alongside the tick labels, same styling.
760
+ name: isLoading ? undefined : yAxisSlot.label,
761
+ nameLocation: "middle",
762
+ nameGap: 38,
763
+ nameTextStyle: { color: axisLabelColor, fontSize: 10 },
764
+ axisLine: { show: false },
765
+ // Same tick dots as the x-axis, beside each value label. No alignWithLabel
766
+ // here: ECharts types it on the CATEGORY axis only, and a value axis already
767
+ // puts its ticks on the labels.
768
+ axisTick: {
769
+ show: yAxisSlot.present && !isLoading && !yAxisSlot.hideDots,
770
+ length: 0.5,
771
+ lineStyle: { color: tickDotColor, width: 3, cap: "round" },
772
+ },
773
+ splitLine: {
774
+ // Hidden while loading — the skeleton floats on a clean canvas.
775
+ show: showGrid && !isLoading,
776
+ lineStyle: { color: splitLineColor, type: [3, 3] as [number, number], width: 1 },
777
+ },
778
+ axisLabel: {
779
+ // Hidden while loading — skeleton values are meaningless, and the
780
+ // Recharts YAxis unmounts during loading too.
781
+ show: yAxisSlot.present && !isLoading,
782
+ color: axisLabelColor,
783
+ fontSize: 10,
784
+ margin: 8,
785
+ formatter: yTickFormatter
786
+ ? (value: number, index: number) => yTickFormatter(value, index)
787
+ : undefined,
788
+ },
789
+ };
790
+
791
+ return { xAxis, yAxis };
792
+ }
793
+
794
+ // Tooltip HTML builder, closed over the build context. `trigger: "axis"` hands
795
+ // the formatter every series' value at the hovered x; buffer overlays and the
796
+ // mini/loading series are folded out here (see BUFFER_PREFIX).
797
+ function createTooltipFormatter(ctx: OptionBuildContext) {
798
+ const { config, selectedDataKey, tooltipSlot, getHoveredKey } = ctx;
799
+
800
+ return (params: unknown): string => {
801
+ const rows = Array.isArray(params) ? params : [params];
802
+ if (!rows.length) return "";
803
+
804
+ const first = rows[0] as { axisValue?: string | number; name?: string };
805
+ // Label shows the RAW axis value — matches ChartTooltipContent (no tick formatter).
806
+ const axisValue = first.axisValue ?? first.name ?? "";
807
+ const label = String(axisValue);
808
+
809
+ // Dedupe by effective key: a buffer line contributes both its solid part
810
+ // (id=key) and its dashed overlay (id=`__buffer-{key}`) at the shared
811
+ // second-to-last point. Keep the first non-null value seen per key so the
812
+ // final point (only the overlay has data there) still shows its number.
813
+ const seen = new Set<string>();
814
+ const body = rows
815
+ .map((param) => {
816
+ const p = param as {
817
+ seriesId?: string;
818
+ seriesName?: string;
819
+ value?: number | string | null;
820
+ };
821
+ const rawId = String(p.seriesId ?? "");
822
+ // Map the dashed buffer overlay back onto its series; drop every other
823
+ // internal series (mini chart, loading skeleton).
824
+ const key = rawId.startsWith(BUFFER_PREFIX)
825
+ ? rawId.slice(BUFFER_PREFIX.length)
826
+ : rawId.startsWith("__")
827
+ ? ""
828
+ : (p.seriesId ?? p.seriesName ?? "");
829
+ if (!key) return "";
830
+ // A null value means this series does not reach the hovered x (a buffer
831
+ // line's solid part stops before the last point) — skip it, and let the
832
+ // overlay row for the same key stand in.
833
+ if (p.value === null || p.value === undefined) return "";
834
+ if (seen.has(key)) return "";
835
+ seen.add(key);
836
+
837
+ const item = config[key];
838
+ const colorsCount = item ? getColorsCount(item) : 1;
839
+ const labelText = typeof item?.label === "string" ? item.label : (p.seriesName ?? key);
840
+ const hovered = getHoveredKey();
841
+ const dimmed =
842
+ (selectedDataKey != null && selectedDataKey !== key) ||
843
+ (hovered != null && hovered !== key)
844
+ ? " opacity-30"
845
+ : "";
846
+ const value =
847
+ typeof p.value === "number" ? p.value.toLocaleString() : String(p.value ?? "");
848
+
849
+ return tooltipRow({
850
+ indicatorHtml: tooltipIndicatorHtml(key, colorsCount),
851
+ labelText,
852
+ valueText: value,
853
+ dimmed,
854
+ });
855
+ })
856
+ .join("");
857
+
858
+ return tooltipShell({
859
+ label,
860
+ body,
861
+ roundness: tooltipSlot.roundness,
862
+ variant: tooltipSlot.variant,
863
+ });
864
+ };
865
+ }
866
+
867
+ function buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {
868
+ const { tooltipSlot, isLoading } = ctx;
869
+ const { tokens } = ctx.resolved;
870
+
871
+ return {
872
+ ...tooltipBaseOption({
873
+ present: tooltipSlot.present && !isLoading,
874
+ cursor: tooltipSlot.cursor,
875
+ tokens,
876
+ position: tooltipSlot.position,
877
+ axisPointerColor: withAlpha(tokens.border, AXIS_POINTER_OPACITY),
878
+ strokeWidth: STROKE_WIDTH,
879
+ }),
880
+ formatter: createTooltipFormatter(ctx),
881
+ };
882
+ }
883
+
884
+ // ── Brush — the evil-brush "line" look, canvas-style: a real mini chart of the
885
+ // full data (strokes only, no fill, like EvilBrush variant="line") in a second
886
+ // grid, with a transparent slider dataZoom laid over it. Both zoom entries target
887
+ // only the MAIN x-axis, so the mini chart never filters itself. Only called when
888
+ // `showBrush` is set.
889
+ function buildBrushOption(
890
+ ctx: OptionBuildContext,
891
+ brushBottom: number,
892
+ ): {
893
+ miniGrid: GridComponentOption;
894
+ miniXAxis: XAxisOption;
895
+ miniYAxis: YAxisOption;
896
+ miniSeries: LineSeriesOption[];
897
+ dataZoom: DataZoomComponentOption[];
898
+ } {
899
+ const { data, lines, curveType, selectedDataKey, brushHeight, categories } = ctx;
900
+ const { tokens } = ctx.resolved;
901
+
902
+ const miniGrid: GridComponentOption = {
903
+ left: 8,
904
+ right: 8,
905
+ bottom: brushBottom,
906
+ height: brushHeight,
907
+ // No visible axes here — opt out of label containment so the mini chart
908
+ // spans the full brush frame.
909
+ outerBoundsMode: "none",
910
+ };
911
+
912
+ const miniXAxis: XAxisOption = {
913
+ type: "category",
914
+ gridIndex: 1,
915
+ boundaryGap: false,
916
+ show: false,
917
+ data: categories,
918
+ axisPointer: { show: false },
919
+ };
920
+
921
+ const miniYAxis: YAxisOption = { type: "value", gridIndex: 1, show: false };
922
+
923
+ const miniSeries: LineSeriesOption[] = lines.map((line) => {
924
+ const key = line.dataKey;
925
+ const base = (ctx.resolved.series[key] ?? [])[0] ?? "rgba(120, 120, 120, 1)";
926
+ const curve = curveConfig(line.curveType ?? curveType);
927
+
928
+ // The mini chart mirrors the click selection: unselected series recede
929
+ // by the same ratio as the main plot.
930
+ const strokeDim = getOpacity(selectedDataKey, key).stroke;
931
+
932
+ return {
933
+ id: `__mini-${key}`,
934
+ type: "line",
935
+ xAxisIndex: 1,
936
+ yAxisIndex: 1,
937
+ data: data.map((row) => Number(row[key]) || 0),
938
+ smooth: curve.smooth,
939
+ step: curve.step,
940
+ connectNulls: line.connectNulls,
941
+ silent: true,
942
+ showSymbol: false,
943
+ emphasis: { disabled: true },
944
+ tooltip: { show: false },
945
+ lineStyle: { color: base, width: 1, opacity: BRUSH_STROKE_OPACITY * strokeDim },
946
+ z: 0,
947
+ };
948
+ });
949
+
950
+ const dataZoom = buildBrushDataZoom({
951
+ brushBottom,
952
+ brushHeight,
953
+ brushRange: ctx.brushRange,
954
+ fillerColor: withAlpha(tokens.foreground, BRUSH_FILLER_OPACITY),
955
+ });
956
+
957
+ return { miniGrid, miniXAxis, miniYAxis, miniSeries, dataZoom };
958
+ }
959
+
960
+ // Loading skeleton — ONE gray wave regardless of declared lines (Recharts
961
+ // parity: its skeleton is a single stroke-only LoadingLine), swept by the
962
+ // shimmer rAF. No fill: a <Line> has no area, so the skeleton is stroke-only too.
963
+ function buildLoadingOption(
964
+ ctx: OptionBuildContext,
965
+ frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },
966
+ ): EChartsOption {
967
+ const { tokens } = ctx.resolved;
968
+ const curve = curveConfig(ctx.curveType);
969
+
970
+ return {
971
+ animation: false,
972
+ grid: frame.grid,
973
+ xAxis: frame.xAxis,
974
+ yAxis: frame.yAxis,
975
+ tooltip: { show: false },
976
+ series: [
977
+ {
978
+ id: "__loading",
979
+ type: "line",
980
+ data: ctx.loadingData(),
981
+ smooth: curve.smooth,
982
+ step: curve.step,
983
+ showSymbol: false,
984
+ silent: true,
985
+ // Invisible until the first shimmer tick positions the clip window.
986
+ lineStyle: { color: withAlpha(tokens.foreground, 0), width: 1 },
987
+ z: 1,
988
+ },
989
+ ],
990
+ };
991
+ }
992
+
993
+ // The plotted values for a line, optionally decorated per-datum. Multi-color
994
+ // lines tint each symbol with the gradient's color at its own x-position (like
995
+ // the Recharts dots); single-color lines return the raw numbers. `null` entries
996
+ // pass through untouched — they carve the gap a buffer line's two parts leave.
997
+ type LinePoint =
998
+ | number
999
+ | null
1000
+ | {
1001
+ value: number | null;
1002
+ itemStyle: DotItemStyleOption;
1003
+ emphasis: { itemStyle: DotItemStyleOption };
1004
+ };
1005
+
1006
+ function buildLineSeries(ctx: OptionBuildContext): LineSeriesOption[] {
1007
+ const {
1008
+ data,
1009
+ config,
1010
+ lines,
1011
+ curveType,
1012
+ selectedDataKey,
1013
+ hasSelection,
1014
+ enableHoverHighlight,
1015
+ enableHoverReveal,
1016
+ revealIndex,
1017
+ revealSink,
1018
+ resolved,
1019
+ rendererSize,
1020
+ } = ctx;
1021
+ const background = resolved.tokens.background;
1022
+
1023
+ return lines.flatMap((line): LineSeriesOption[] => {
1024
+ const key = line.dataKey;
1025
+ const slots = resolved.series[key] ?? ["rgba(120, 120, 120, 1)"];
1026
+ const paint = seriesPaint(slots);
1027
+ const isSelected = selectedDataKey === key;
1028
+ const opacity = getOpacity(selectedDataKey, key);
1029
+ const curve = curveConfig(line.curveType ?? curveType);
1030
+ const multiColor = slots.length > 1;
1031
+
1032
+ const restingDot = dotStyle(line.dotVariant, paint, background);
1033
+ const activeDot = dotStyle(line.activeDotVariant, paint, background);
1034
+ const restingVisible = line.dotVariant !== "none";
1035
+ const dotOpacity = opacity.dot;
1036
+
1037
+ const values = data.map((row) => Number(row[key]) || 0);
1038
+ const n = values.length;
1039
+ // Hover-reveal is a root-level mode and owns the whole line rendering, so it
1040
+ // takes precedence over a per-line buffer tail (and the glow overlay) when
1041
+ // both are set.
1042
+ const reveal = enableHoverReveal;
1043
+ const buffer = !reveal && line.enableBufferLine && n >= 2;
1044
+ const revealActive = reveal && revealIndex !== null;
1045
+
1046
+ // The dash pattern for the MAIN line. A buffer line keeps its body solid and
1047
+ // dashes only the tail overlay, so its main part is always solid regardless
1048
+ // of strokeVariant (matches the Recharts twin, which suppresses the base
1049
+ // dasharray while the buffer shape manages its own).
1050
+ const mainDash: "solid" | [number, number] =
1051
+ buffer || line.strokeVariant === "solid" ? "solid" : [3, 3];
1052
+
1053
+ // The reveal truncates the line to the cursor, which would COMPRESS a
1054
+ // bbox-relative stroke gradient into the shorter span — misaligning it from
1055
+ // the index-sampled dots. Anchor the stroke to the plot in absolute pixels so
1056
+ // every x keeps its own color even when the line stops short.
1057
+ const strokePaint =
1058
+ reveal && multiColor
1059
+ ? new echarts.graphic.LinearGradient(
1060
+ 8,
1061
+ 0,
1062
+ Math.max(rendererSize.width - 8, 9),
1063
+ 0,
1064
+ slots.map((color, i) => ({ offset: i / (slots.length - 1), color })),
1065
+ true,
1066
+ )
1067
+ : paint;
1068
+
1069
+ // Turn a value list into ECharts data — attaching per-datum symbol colors
1070
+ // for multi-color lines, and passing `null` gaps through so a buffer line's
1071
+ // two parts each draw only their own segment.
1072
+ const toPoints = (vals: (number | null)[]): LinePoint[] =>
1073
+ !multiColor
1074
+ ? vals
1075
+ : vals.map((value, i): LinePoint => {
1076
+ if (value === null) return null;
1077
+ const t = vals.length > 1 ? i / (vals.length - 1) : 0;
1078
+ const pointColor = sampleGradient(slots, t);
1079
+ return {
1080
+ value,
1081
+ itemStyle: {
1082
+ ...dotItemStyle(
1083
+ restingVisible ? line.dotVariant : line.activeDotVariant,
1084
+ pointColor,
1085
+ background,
1086
+ ),
1087
+ opacity: dotOpacity,
1088
+ },
1089
+ emphasis: {
1090
+ itemStyle: {
1091
+ ...dotItemStyle(
1092
+ line.activeDotVariant === "none" ? "default" : line.activeDotVariant,
1093
+ pointColor,
1094
+ background,
1095
+ ),
1096
+ opacity: 1,
1097
+ },
1098
+ },
1099
+ };
1100
+ });
1101
+
1102
+ // Snapshot the FULL per-datum points (with the multi-color dot itemStyle) so
1103
+ // the reveal hover handler can slice them without losing each dot's sampled
1104
+ // gradient color — plain values would fall back to the default palette.
1105
+ if (reveal) revealSink[key] = toPoints(values);
1106
+
1107
+ // Buffer line: the solid MAIN part drops the last point (its final segment
1108
+ // becomes the dashed overlay). Reveal instead TRUNCATES the real series at the
1109
+ // cursor's x-index (points beyond it null'd), so its line stops there and the
1110
+ // muted base layer shows through past it. When idle (revealIndex null) the
1111
+ // real series carries its full data — the chart looks completely normal.
1112
+ const mainValues: (number | null)[] = buffer
1113
+ ? values.map((v, i) => (i === n - 1 ? null : v))
1114
+ : revealActive
1115
+ ? sliceToNull(values, revealIndex as number)
1116
+ : values;
1117
+
1118
+ const z = isSelected ? 3 : hasSelection ? 1 : 2;
1119
+
1120
+ // Glow overlays sit UNDER the real line (built first, same z; equal-z series
1121
+ // paint in array order). They follow the FULL solid path so the halo stays
1122
+ // continuous even beneath a dashed or buffer tail. Suppressed under reveal:
1123
+ // a full-length colored halo would bleed past the cursor and defeat the mute.
1124
+ const glowSeries =
1125
+ line.glowing && !reveal
1126
+ ? buildGlowSeries({
1127
+ key,
1128
+ paint,
1129
+ slots,
1130
+ values,
1131
+ curve,
1132
+ connectNulls: line.connectNulls,
1133
+ z,
1134
+ selectionDim: opacity.stroke,
1135
+ dotSize: restingVisible ? restingDot.size : 0,
1136
+ })
1137
+ : [];
1138
+
1139
+ const mainSeries: LineSeriesOption = {
1140
+ id: key,
1141
+ name: typeof config[key]?.label === "string" ? config[key]?.label : key,
1142
+ type: "line",
1143
+ data: toPoints(mainValues),
1144
+ smooth: curve.smooth,
1145
+ step: curve.step,
1146
+ connectNulls: line.connectNulls,
1147
+ cursor: line.isClickable ? "pointer" : "default",
1148
+ // By default ECharts only fires mouse events on the symbols — this makes
1149
+ // the line itself clickable too, like the Recharts <Line>.
1150
+ // (`true` covers both; the deprecated `triggerLineEvent` did the same.)
1151
+ triggerEvent: line.isClickable,
1152
+ showSymbol: restingVisible,
1153
+ symbol: "circle",
1154
+ symbolSize: restingVisible ? restingDot.size : activeDot.size,
1155
+ z,
1156
+ lineStyle: {
1157
+ // Anchored plot-wide gradient while revealing a multi-color line (see
1158
+ // strokePaint), the normal series paint otherwise.
1159
+ color: strokePaint,
1160
+ width: line.strokeWidth,
1161
+ opacity: opacity.stroke,
1162
+ type: mainDash,
1163
+ dashOffset: 0,
1164
+ },
1165
+ itemStyle: multiColor
1166
+ ? { opacity: dotOpacity }
1167
+ : {
1168
+ ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),
1169
+ opacity: dotOpacity,
1170
+ },
1171
+ emphasis: {
1172
+ // focus "series" blurs every other series in this grid while one is
1173
+ // hovered — the hover twin of the click selection (opt-in via
1174
+ // enableHoverHighlight). Suppressed entirely while a series is
1175
+ // click-selected: the selection dim owns the canvas, so hover
1176
+ // highlighting stops until the selection clears (the option rebuilds on
1177
+ // selection change, making this a build-time conditional). Reveal owns the
1178
+ // hover visual, so native focus-blur stands down when it is on (they must
1179
+ // not blend). Otherwise the active dot is the only emphasis.
1180
+ focus: enableHoverHighlight && !enableHoverReveal && !hasSelection ? "series" : "none",
1181
+ scale: restingVisible ? activeDot.size / Math.max(restingDot.size, 1) : 1,
1182
+ ...(multiColor ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }),
1183
+ },
1184
+ // Blur styling mirrors the click-selection dim (stroke 0.3 / dot 0.3);
1185
+ // inert unless a series is focused via enableHoverHighlight.
1186
+ blur: {
1187
+ lineStyle: { opacity: 0.3 },
1188
+ itemStyle: { opacity: 0.3 },
1189
+ },
1190
+ };
1191
+
1192
+ // Hover-reveal: a muted gray BASE line of the FULL series sits one z below the
1193
+ // real one. It is invisible while idle (opacity 0 → the chart looks normal)
1194
+ // and fades in only while hovering, so the region PAST the cursor — where the
1195
+ // truncated real line has stopped — shows as neutral gray. Lines have no fill,
1196
+ // so the base is a line only (no areaStyle) and needs no stack mirror.
1197
+ if (reveal) {
1198
+ const muted = resolved.tokens.mutedForeground;
1199
+ const revealBase: LineSeriesOption = {
1200
+ id: `${REVEAL_PREFIX}${key}`,
1201
+ type: "line",
1202
+ // Only the region FROM the cursor onward (null before it), so the gray
1203
+ // never sits under the colored part — the two meet exactly at the pointer
1204
+ // and their colors can't mix.
1205
+ data: revealActive ? sliceFrom(values, revealIndex as number) : values,
1206
+ smooth: curve.smooth,
1207
+ step: curve.step,
1208
+ connectNulls: false,
1209
+ silent: true,
1210
+ showSymbol: false,
1211
+ symbol: "circle",
1212
+ z: z - 1,
1213
+ // Neutral gray, SAME dash pattern as the colored line, no fill.
1214
+ lineStyle: {
1215
+ color: muted,
1216
+ width: line.strokeWidth,
1217
+ type: mainDash,
1218
+ opacity: revealActive ? 0.3 : 0,
1219
+ },
1220
+ emphasis: { disabled: true },
1221
+ blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } },
1222
+ tooltip: { show: false },
1223
+ };
1224
+ return [revealBase, mainSeries];
1225
+ }
1226
+
1227
+ if (!buffer) return [...glowSeries, mainSeries];
1228
+
1229
+ // Dashed forecast overlay — draws ONLY the last segment. Silent, so it never
1230
+ // intercepts clicks/hover; it still feeds the axis tooltip (silent series
1231
+ // are aggregated by axis), which is why the last point keeps its number.
1232
+ const bufferValues: (number | null)[] = values.map((v, i) => (i >= n - 2 ? v : null));
1233
+ const bufferSeries: LineSeriesOption = {
1234
+ id: `${BUFFER_PREFIX}${key}`,
1235
+ type: "line",
1236
+ data: toPoints(bufferValues),
1237
+ smooth: curve.smooth,
1238
+ step: curve.step,
1239
+ connectNulls: true,
1240
+ silent: true,
1241
+ showSymbol: restingVisible,
1242
+ symbol: "circle",
1243
+ symbolSize: restingVisible ? restingDot.size : activeDot.size,
1244
+ z,
1245
+ lineStyle: {
1246
+ color: paint,
1247
+ width: line.strokeWidth,
1248
+ opacity: opacity.stroke,
1249
+ type: BUFFER_DASH,
1250
+ },
1251
+ itemStyle: multiColor
1252
+ ? { opacity: dotOpacity }
1253
+ : {
1254
+ ...(restingVisible ? restingDot.itemStyle : activeDot.itemStyle),
1255
+ opacity: dotOpacity,
1256
+ },
1257
+ // The dashed tail is a separate silent series, so focus:"series" on its
1258
+ // parent would blur it apart from the line it belongs to. The root
1259
+ // dispatch-links this id (see companionIdsByKey) so it focuses WITH its
1260
+ // parent; these styles give it the parent's look while focused and the
1261
+ // click-selection dim while another series is hovered.
1262
+ emphasis: {
1263
+ focus: "none",
1264
+ scale: false,
1265
+ lineStyle: { opacity: opacity.stroke },
1266
+ itemStyle: { opacity: dotOpacity },
1267
+ },
1268
+ blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } },
1269
+ };
1270
+
1271
+ return [...glowSeries, mainSeries, bufferSeries];
1272
+ });
1273
+ }
1274
+
1275
+ // Copy a value list with everything AFTER `idx` nulled — the hover-reveal cut:
1276
+ // the colored real line keeps its data up to the cursor and drops the rest, so
1277
+ // (with connectNulls false) its stroke stops dead at the pointer.
1278
+ function sliceToNull<T>(vals: readonly T[], idx: number): (T | null)[] {
1279
+ return vals.map((v, i) => (i > idx ? null : v));
1280
+ }
1281
+
1282
+ // Copy a value list with everything BEFORE `idx` nulled — the reveal's gray tail.
1283
+ // The muted base keeps only the region from the cursor onward, so it never sits
1284
+ // under the colored part; both include `idx` so they meet at the pointer.
1285
+ // Generic so it preserves per-datum point objects (multi-color dot itemStyle).
1286
+ function sliceFrom<T>(vals: readonly T[], idx: number): (T | null)[] {
1287
+ return vals.map((v, i) => (i < idx ? null : v));
1288
+ }
1289
+
1290
+ // ─────────────────────────────────────────────────────────────────────────────
1291
+ // Live imperative state — everything the ECharts event handlers, rAF loops, and
1292
+ // theme/resize repushes read or write OUTSIDE the React render cycle, grouped in
1293
+ // one ref-stable object so the whole imperative surface is visible at a glance.
1294
+ // None of it is render output, which is exactly why it is not React state.
1295
+ // ─────────────────────────────────────────────────────────────────────────────
1296
+
1297
+ type LiveState = {
1298
+ resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops
1299
+ hoveredKey: string | null; // tooltip's view of hover — the legend's twin lives in React state
1300
+ hasRevealed: boolean; // the intro draw-in already played on this chart instance
1301
+ revealEndsAt: number; // performance.now() timestamp when the entrance settles
1302
+ loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep
1303
+ categories: string[]; // x labels of the last build, for the brush label pills
1304
+ dataLength: number; // row count, for the datazoom index math
1305
+ brushRange: BrushRange; // live zoom window — carried through every rebuild
1306
+ brushGeom: BrushGeometry | null; // brush footer layout of the last build
1307
+ brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay
1308
+ brushHover: { inside: boolean; left: boolean; right: boolean };
1309
+ // seriesIndex → clickable key for the last build, `undefined` for internal
1310
+ // series. A line-body click (triggerEvent) reports only a seriesIndex, and
1311
+ // buffer lines add a second (`__buffer-`) series per key — so the index no
1312
+ // longer equals the key's position in seriesKeys and must be mapped explicitly.
1313
+ seriesKeyByIndex: (string | undefined)[];
1314
+ // key → its silent companion series ids (glow overlays + buffer tail) in the
1315
+ // main grid. Under enableHoverHighlight the root highlights/downplays these
1316
+ // together with the hovered parent, so focus:"series" can't strand a line's own
1317
+ // glow or forecast tail apart from it.
1318
+ companionIdsByKey: Map<string, string[]>;
1319
+ revealIndex: number | null; // hover-reveal pointer x-index (null = idle); read by builds and the reveal hover handler
1320
+ revealValues: Record<string, unknown[]>; // per-line FULL per-datum points (with dot itemStyle), sliced to the cursor on hover without a rebuild
1321
+ // Latest callbacks/flags for the imperative ECharts event handlers.
1322
+ handlers: {
1323
+ onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;
1324
+ onSelectionChange?: (key: string | null) => void;
1325
+ clickableKeys: Set<string>;
1326
+ selectedDataKey: string | null;
1327
+ brushFormatLabel?: (value: string, index: number) => string;
1328
+ seriesKeys: string[];
1329
+ enableHoverHighlight: boolean;
1330
+ enableHoverReveal: boolean;
1331
+ };
1332
+ // Update-style re-push for paths that bypass React entirely (theme flips,
1333
+ // resizes) — set by the sync effect.
1334
+ repush: () => void;
1335
+ };
1336
+
1337
+ // ─────────────────────────────────────────────────────────────────────────────
1338
+ // Component
1339
+ // ─────────────────────────────────────────────────────────────────────────────
1340
+
1341
+ /**
1342
+ * Apache ECharts port of the EvilCharts line chart, exposing a compound-as-config
1343
+ * API so its JSX reads identically to the Recharts twin. The root owns the data,
1344
+ * config, selection state, loading skeleton, intro reveal, and optional zoom
1345
+ * brush; every visual part — `<Line>`, `<XAxis>`, `<YAxis>`, `<Grid>`,
1346
+ * `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders
1347
+ * nothing. The root walks those children by reference and drives a single
1348
+ * imperative ECharts instance. Fully self-contained: its only dependencies are
1349
+ * `react`, `echarts`, and `motion`.
1350
+ */
1351
+ export function EChartsLineChart<TData extends Record<string, unknown>>({
1352
+ data,
1353
+ config,
1354
+ renderer = DEFAULT_ECHARTS_RENDERER,
1355
+ xDataKey,
1356
+ className,
1357
+ curveType = "linear",
1358
+ animation = true,
1359
+ animationType = "left-to-right",
1360
+ enableHoverHighlight = false,
1361
+ enableHoverReveal = false,
1362
+ selectedDataKey: selectedDataKeyProp,
1363
+ defaultSelectedDataKey = null,
1364
+ onSelectionChange,
1365
+ isLoading = false,
1366
+ loadingPoints = LOADING_DEFAULT_POINTS,
1367
+ chartOptions,
1368
+ children,
1369
+ }: EChartsLineChartProps<TData>) {
1370
+ const rawId = useId();
1371
+ const chartId = `chart-${rawId.replace(/:/g, "")}`;
1372
+
1373
+ const containerRef = useRef<HTMLDivElement>(null);
1374
+ const mountRef = useRef<HTMLDivElement>(null);
1375
+ const echartsRef = useRef<EChartsInstance | null>(null);
1376
+
1377
+ // The single imperative surface (see LiveState). `resolved` lives here rather
1378
+ // than in state: as state it forced an extra render pass and an effect whose
1379
+ // only job was to trigger the option push — the "chain of computations"
1380
+ // react.dev/learn/you-might-not-need-an-effect warns about. The object
1381
+ // identity is stable for the component's lifetime.
1382
+ const live = useRef<LiveState>({
1383
+ resolved: null,
1384
+ hoveredKey: null,
1385
+ hasRevealed: false,
1386
+ revealEndsAt: 0,
1387
+ loadingRows: null,
1388
+ categories: [],
1389
+ dataLength: 0,
1390
+ brushRange: { start: 0, end: 100 },
1391
+ brushGeom: null,
1392
+ brushOverlay: null,
1393
+ brushHover: { inside: false, left: false, right: false },
1394
+ seriesKeyByIndex: [],
1395
+ companionIdsByKey: new Map(),
1396
+ revealIndex: null,
1397
+ revealValues: {},
1398
+ handlers: {
1399
+ onBrushChange: undefined, // set per-render from the <Brush> child's onChange
1400
+ onSelectionChange,
1401
+ clickableKeys: new Set<string>(),
1402
+ selectedDataKey: defaultSelectedDataKey,
1403
+ brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel
1404
+ seriesKeys: [],
1405
+ enableHoverHighlight,
1406
+ enableHoverReveal,
1407
+ },
1408
+ repush: () => {},
1409
+ }).current;
1410
+
1411
+ // Skeleton rows roll lazily on first use — an impure useRef initializer would
1412
+ // re-roll Math.random() on every render.
1413
+ const loadingData = useCallback(
1414
+ () => (live.loadingRows ??= getLoadingData(loadingPoints)),
1415
+ [live, loadingPoints],
1416
+ );
1417
+ const shouldReduceMotion = useReducedMotion();
1418
+
1419
+ const [internalSelectedKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);
1420
+ const selectedDataKey = selectedDataKeyProp !== undefined ? selectedDataKeyProp : internalSelectedKey;
1421
+
1422
+ // Hover-highlight mirrors into the legend (React state) and tooltip
1423
+ // (live.hoveredKey — its formatter runs on every hover, and pushing an option
1424
+ // to sync it would reset ECharts' native blur state mid-hover).
1425
+ const [hoveredDataKey, setHoveredDataKey] = useState<string | null>(null);
1426
+
1427
+ // ── Declarative config, collected from children by reference ─────────────────
1428
+ const collected = useMemo(() => collectConfig(children), [children]);
1429
+ const {
1430
+ lines,
1431
+ xAxis: xAxisSlot,
1432
+ yAxis: yAxisSlot,
1433
+ showGrid,
1434
+ tooltip: tooltipSlot,
1435
+ legend: legendSlot,
1436
+ brush: brushSlot,
1437
+ } = collected;
1438
+ // Brush is a <Brush> child now (not props): presence turns it on, its props
1439
+ // carry height/formatLabel/onChange.
1440
+ const showBrush = brushSlot.present;
1441
+ const brushHeight = brushSlot.height ?? 56;
1442
+
1443
+ const seriesKeys = useMemo(() => lines.map((line) => line.dataKey), [lines]);
1444
+
1445
+ // x category key: <XAxis dataKey> → root xDataKey → first data column no <Line> claims.
1446
+ const xCategoryKey = useMemo(() => {
1447
+ if (xAxisSlot.dataKey) return xAxisSlot.dataKey;
1448
+ if (xDataKey) return xDataKey as string;
1449
+ const firstRow = data[0];
1450
+ if (firstRow) {
1451
+ const claimed = new Set(seriesKeys);
1452
+ const found = Object.keys(firstRow).find((key) => !claimed.has(key));
1453
+ if (found) return found;
1454
+ }
1455
+ return "";
1456
+ }, [xAxisSlot.dataKey, xDataKey, data, seriesKeys]);
1457
+
1458
+ // The intro draw-in follows the first line's setting, falling back to the root default.
1459
+ const effectiveAnimation = lines[0]?.animationType ?? animationType;
1460
+
1461
+ const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);
1462
+
1463
+ const hasSelection = selectedDataKey !== null;
1464
+
1465
+ // Which series may be clicked to toggle selection (consulted by the click handler).
1466
+ const clickableKeys = useMemo(
1467
+ () => new Set(lines.filter((line) => line.isClickable).map((line) => line.dataKey)),
1468
+ [lines],
1469
+ );
1470
+
1471
+ // Refresh the handlers' snapshot of the latest callbacks/flags every render.
1472
+ live.handlers = {
1473
+ onBrushChange: brushSlot.onChange,
1474
+ onSelectionChange,
1475
+ clickableKeys,
1476
+ selectedDataKey,
1477
+ brushFormatLabel: brushSlot.formatLabel,
1478
+ seriesKeys,
1479
+ enableHoverHighlight,
1480
+ enableHoverReveal,
1481
+ };
1482
+ live.dataLength = data.length;
1483
+
1484
+ const toggleSelection = useCallback(
1485
+ (key: string) => {
1486
+ // A new click selection takes over the canvas dim, so any live hover
1487
+ // highlight is torn down at this moment — the mouseover guard then keeps it
1488
+ // from re-arming while the selection stands. Hover is only ever active
1489
+ // while NO selection exists (see that guard), so a hovered key here always
1490
+ // means this click is establishing a selection.
1491
+ if (live.hoveredKey !== null) {
1492
+ const chart = echartsRef.current;
1493
+ const companions = chart ? live.companionIdsByKey.get(live.hoveredKey) : undefined;
1494
+ if (chart && companions) {
1495
+ for (const seriesId of companions) chart.dispatchAction({ type: "downplay", seriesId });
1496
+ }
1497
+ live.hoveredKey = null;
1498
+ setHoveredDataKey(null);
1499
+ }
1500
+ const next = selectedDataKey === key ? null : key;
1501
+ if (selectedDataKeyProp === undefined) setSelectedDataKey(next);
1502
+ onSelectionChange?.(next);
1503
+ },
1504
+ [live, onSelectionChange, selectedDataKey, selectedDataKeyProp],
1505
+ );
1506
+
1507
+ // Reposition the brush overlays from the live refs — safe to call from drag
1508
+ // events, hover tracking, and pushes alike, since it never touches setOption.
1509
+ const syncBrushOverlayNow = useCallback(() => {
1510
+ const chart = echartsRef.current;
1511
+ if (!chart) return;
1512
+
1513
+ const geom = live.brushGeom;
1514
+ const tokens = live.resolved?.tokens;
1515
+ if (!geom || !tokens) {
1516
+ syncBrushOverlay(chart, live, null);
1517
+ return;
1518
+ }
1519
+
1520
+ const range = live.brushRange;
1521
+ const categories = live.categories;
1522
+ const format = live.handlers.brushFormatLabel;
1523
+ const lastIndex = Math.max(categories.length - 1, 0);
1524
+ const startIndex = Math.round((range.start / 100) * lastIndex);
1525
+ const endIndex = Math.round((range.end / 100) * lastIndex);
1526
+ const labels =
1527
+ format && categories.length
1528
+ ? {
1529
+ start: format(categories[startIndex] ?? "", startIndex),
1530
+ end: format(categories[endIndex] ?? "", endIndex),
1531
+ }
1532
+ : null;
1533
+
1534
+ syncBrushOverlay(chart, live, {
1535
+ range,
1536
+ geom,
1537
+ size: { width: chart.getWidth(), height: chart.getHeight() },
1538
+ tokens,
1539
+ labels,
1540
+ showLabels: live.brushHover.inside,
1541
+ hover: live.brushHover,
1542
+ });
1543
+ }, [live]);
1544
+
1545
+ // ── Option builder ─────────────────────────────────────────────────────────
1546
+ // Thin orchestrator over the pure builders above: snapshot the imperative
1547
+ // surface (refs, renderer size) into an OptionBuildContext, then assemble.
1548
+ const buildOption = useCallback((): EChartsOption => {
1549
+ const resolved = live.resolved;
1550
+ if (!resolved) return {};
1551
+
1552
+ const categories = data.map((row) => String(row[xCategoryKey]));
1553
+ live.categories = categories;
1554
+
1555
+ // buildLineSeries fills this with each line's full per-datum points (with the
1556
+ // multi-color dot itemStyle) so the reveal hover handler slices real data.
1557
+ const revealSink: Record<string, unknown[]> = {};
1558
+
1559
+ const ctx: OptionBuildContext = {
1560
+ data,
1561
+ config,
1562
+ lines,
1563
+ curveType,
1564
+ selectedDataKey,
1565
+ hasSelection,
1566
+ showGrid,
1567
+ xAxisSlot,
1568
+ yAxisSlot,
1569
+ tooltipSlot,
1570
+ legendSlot,
1571
+ isLoading,
1572
+ loadingData,
1573
+ showBrush,
1574
+ brushHeight,
1575
+ enableHoverHighlight,
1576
+ enableHoverReveal,
1577
+ revealIndex: live.revealIndex,
1578
+ revealSink,
1579
+ resolved,
1580
+ rendererSize: {
1581
+ width: echartsRef.current?.getWidth() ?? mountRef.current?.clientWidth ?? 0,
1582
+ height: echartsRef.current?.getHeight() ?? mountRef.current?.clientHeight ?? 0,
1583
+ },
1584
+ categories,
1585
+ brushRange: live.brushRange,
1586
+ getHoveredKey: () => live.hoveredKey,
1587
+ };
1588
+
1589
+ const { grid, brushBottom } = buildChartLayout(ctx);
1590
+ live.brushGeom = showBrush ? { bottom: brushBottom, height: brushHeight } : null;
1591
+
1592
+ const { xAxis, yAxis } = buildMainAxes(ctx);
1593
+
1594
+ if (isLoading) return buildLoadingOption(ctx, { grid, xAxis, yAxis });
1595
+
1596
+ const brush = showBrush ? buildBrushOption(ctx, brushBottom) : null;
1597
+
1598
+ const series = [...buildLineSeries(ctx), ...(brush?.miniSeries ?? [])];
1599
+ // buildLineSeries has now filled revealSink with each line's full per-datum
1600
+ // points — hand them to the hover handler for slicing.
1601
+ if (enableHoverReveal) live.revealValues = revealSink;
1602
+ // Record the exact series order so a line-body click (which reports only a
1603
+ // seriesIndex) can recover its key — buffer/reveal/mini/loading series break
1604
+ // the "index === key position" shortcut, so map each index to its id here.
1605
+ live.seriesKeyByIndex = series.map((s) => {
1606
+ const id = String(s.id ?? "");
1607
+ return id && !id.startsWith("__") ? id : undefined;
1608
+ });
1609
+ // Map each key to its silent companion series ids (glow overlays, buffer tail,
1610
+ // hover-reveal base), mirroring exactly what buildLineSeries emits — the hover
1611
+ // handlers highlight/downplay these together with the parent so focus:"series"
1612
+ // never strands a line's own glow or forecast tail apart from it.
1613
+ const companionIdsByKey = new Map<string, string[]>();
1614
+ for (const line of lines) {
1615
+ const ids: string[] = [];
1616
+ if (line.glowing) {
1617
+ for (let i = 0; i < GLOW_LAYERS.length; i++) ids.push(`__glow-${i}-${line.dataKey}`);
1618
+ }
1619
+ if (line.enableBufferLine && data.length >= 2) ids.push(`${BUFFER_PREFIX}${line.dataKey}`);
1620
+ if (enableHoverReveal) ids.push(`${REVEAL_PREFIX}${line.dataKey}`);
1621
+ if (ids.length) companionIdsByKey.set(line.dataKey, ids);
1622
+ }
1623
+ live.companionIdsByKey = companionIdsByKey;
1624
+
1625
+ return {
1626
+ animation: false,
1627
+ grid: brush ? [grid, brush.miniGrid] : grid,
1628
+ xAxis: brush ? [xAxis, brush.miniXAxis] : xAxis,
1629
+ yAxis: brush ? [yAxis, brush.miniYAxis] : yAxis,
1630
+ tooltip: buildTooltipOption(ctx),
1631
+ dataZoom: brush?.dataZoom,
1632
+ series,
1633
+ };
1634
+ }, [
1635
+ live,
1636
+ data,
1637
+ config,
1638
+ lines,
1639
+ xCategoryKey,
1640
+ curveType,
1641
+ selectedDataKey,
1642
+ hasSelection,
1643
+ showGrid,
1644
+ xAxisSlot,
1645
+ yAxisSlot,
1646
+ tooltipSlot,
1647
+ legendSlot,
1648
+ isLoading,
1649
+ loadingData,
1650
+ showBrush,
1651
+ brushHeight,
1652
+ enableHoverHighlight,
1653
+ enableHoverReveal,
1654
+ ]);
1655
+
1656
+ // ── Init + resize + theme observer (per renderer instance) ──────────────────
1657
+ useEffect(() => {
1658
+ const mount = mountRef.current;
1659
+ const container = containerRef.current;
1660
+ if (!mount || !container) return;
1661
+
1662
+ // Pointer state belongs to the renderer instance. A renderer switch disposes
1663
+ // that surface without emitting mouseout/globalout, so do not carry a hover,
1664
+ // reveal slice, or brush-hover treatment into the replacement instance.
1665
+ // Keep brushRange: the selected zoom window should survive the switch.
1666
+ live.hoveredKey = null;
1667
+ live.revealIndex = null;
1668
+ live.brushHover = { inside: false, left: false, right: false };
1669
+ setHoveredDataKey(null);
1670
+
1671
+ const chart = echarts.init(mount, null, { renderer });
1672
+ echartsRef.current = chart;
1673
+
1674
+ const resizeObserver = new ResizeObserver(() => {
1675
+ // Observers always fire once right after observe(). Repushing on that
1676
+ // no-op fire would land one frame into the intro and stomp the line's
1677
+ // reveal clip — only react when the renderer size actually changed.
1678
+ if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {
1679
+ return;
1680
+ }
1681
+ chart.resize();
1682
+ live.repush();
1683
+ });
1684
+ resizeObserver.observe(mount);
1685
+
1686
+ // Light/dark flips change no React state — re-resolve and push directly.
1687
+ const themeObserver = new MutationObserver(() => {
1688
+ live.repush();
1689
+ });
1690
+ themeObserver.observe(document.documentElement, {
1691
+ attributes: true,
1692
+ attributeFilter: ["class"],
1693
+ });
1694
+
1695
+ chart.on("click", (params) => {
1696
+ const { clickableKeys: clickable } = live.handlers;
1697
+ const p = params as { seriesId?: string; seriesIndex?: number };
1698
+ // Symbol clicks carry seriesId; line clicks (triggerEvent) only carry
1699
+ // seriesIndex — recover the key from the last build's index map, which
1700
+ // accounts for the extra `__buffer-`/`__mini-` series interleaved between
1701
+ // the main ones (a raw seriesKeys lookup would land on the wrong key).
1702
+ const id =
1703
+ p.seriesId ??
1704
+ (typeof p.seriesIndex === "number" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);
1705
+ if (typeof id === "string" && clickable.has(id)) toggleSelection(id);
1706
+ });
1707
+
1708
+ // Hover-highlight bookkeeping — the canvas blur is ECharts-native
1709
+ // (emphasis.focus:"series" + blur), but the HTML legend and tooltip need to
1710
+ // know which series is hovered, and the silent glow/buffer overlays must be
1711
+ // focus-linked to their parent (they are separate series, so focus:"series"
1712
+ // would otherwise blur a line's own glow or forecast tail).
1713
+ chart.on("mouseover", (params) => {
1714
+ const { enableHoverHighlight: hoverOn, enableHoverReveal: revealOn } = live.handlers;
1715
+ // Reveal owns the hover visual, so native highlight stands down while it is on.
1716
+ if (!hoverOn || revealOn) return;
1717
+ // While a series is click-selected, hover highlighting is disabled — the
1718
+ // selection dim owns the canvas, so never arm hover emphasis/blur (or the
1719
+ // legend/tooltip hover dimming) until the selection clears.
1720
+ if (live.handlers.selectedDataKey !== null) return;
1721
+ const p = params as { seriesId?: string; seriesIndex?: number; componentType?: string };
1722
+ if (p.componentType !== "series") return;
1723
+ const id =
1724
+ p.seriesId ??
1725
+ (typeof p.seriesIndex === "number" ? live.seriesKeyByIndex[p.seriesIndex] : undefined);
1726
+ if (typeof id !== "string" || id.startsWith("__")) return;
1727
+ live.hoveredKey = id;
1728
+ setHoveredDataKey(id);
1729
+ const companions = live.companionIdsByKey.get(id);
1730
+ if (companions) {
1731
+ for (const seriesId of companions) chart.dispatchAction({ type: "highlight", seriesId });
1732
+ }
1733
+ });
1734
+ chart.on("mouseout", () => {
1735
+ const prev = live.hoveredKey;
1736
+ if (prev === null) return;
1737
+ live.hoveredKey = null;
1738
+ setHoveredDataKey(null);
1739
+ const companions = live.companionIdsByKey.get(prev);
1740
+ if (companions) {
1741
+ for (const seriesId of companions) chart.dispatchAction({ type: "downplay", seriesId });
1742
+ }
1743
+ });
1744
+
1745
+ // Hover-reveal: color each line up to the pointer's x-index, mute the rest.
1746
+ // Purely TARGETED series updates (real series data + muted base opacity) — we
1747
+ // NEVER rebuild the whole option on mousemove, which would replay transitions
1748
+ // and fight the tooltip's axis pointer.
1749
+ const zrReveal = chart.getZr();
1750
+ const pushReveal = (idx: number | null) => {
1751
+ const keys = live.handlers.seriesKeys;
1752
+ const on = idx !== null;
1753
+ chart.setOption(
1754
+ {
1755
+ series: keys.flatMap((key) => [
1756
+ {
1757
+ id: key,
1758
+ data: on
1759
+ ? sliceToNull(live.revealValues[key] ?? [], idx)
1760
+ : (live.revealValues[key] ?? []),
1761
+ },
1762
+ {
1763
+ id: `${REVEAL_PREFIX}${key}`,
1764
+ // Gray tail keeps only the region from the cursor onward.
1765
+ data: on
1766
+ ? sliceFrom(live.revealValues[key] ?? [], idx)
1767
+ : (live.revealValues[key] ?? []),
1768
+ lineStyle: { opacity: on ? 0.3 : 0 },
1769
+ },
1770
+ ]),
1771
+ },
1772
+ // NOT lazy: the highlight dispatched just below re-draws the active dot
1773
+ // the setOption wipes, so the option must be committed first — a queued
1774
+ // (lazy) update would land after the dispatch and erase the dot again.
1775
+ { silent: true },
1776
+ );
1777
+ // The per-frame setOption above cancels the axis tooltip's transient hover
1778
+ // symbol, so the <ActiveDot> never lands at the cursor. Re-assert it:
1779
+ // highlighting a real series at the cursor index draws its emphasis symbol
1780
+ // (the active dot) even with showSymbol:false; downplay clears it on exit.
1781
+ for (const key of keys) {
1782
+ chart.dispatchAction(
1783
+ on
1784
+ ? { type: "highlight", seriesId: key, dataIndex: idx as number }
1785
+ : { type: "downplay", seriesId: key },
1786
+ );
1787
+ }
1788
+ };
1789
+ const clearReveal = () => {
1790
+ if (live.revealIndex === null) return;
1791
+ live.revealIndex = null;
1792
+ pushReveal(null);
1793
+ };
1794
+ const applyReveal = (event: { offsetX?: number; offsetY?: number }) => {
1795
+ const len = live.dataLength;
1796
+ if (len < 1) return;
1797
+ const x = event.offsetX ?? -1;
1798
+ const y = event.offsetY ?? -1;
1799
+ if (!chart.containPixel({ gridIndex: 0 }, [x, y])) {
1800
+ clearReveal();
1801
+ return;
1802
+ }
1803
+ const raw = chart.convertFromPixel({ gridIndex: 0 }, [x, y])[0];
1804
+ const idx = Math.max(0, Math.min(len - 1, Math.round(raw)));
1805
+ if (idx === live.revealIndex) return;
1806
+ live.revealIndex = idx;
1807
+ pushReveal(idx);
1808
+ };
1809
+ const onZrRevealMove = (event: { offsetX?: number; offsetY?: number }) => {
1810
+ if (!live.handlers.enableHoverReveal) return;
1811
+ applyReveal(event);
1812
+ };
1813
+ const onZrRevealOut = () => {
1814
+ if (live.handlers.enableHoverReveal) clearReveal();
1815
+ };
1816
+ zrReveal.on("mousemove", onZrRevealMove);
1817
+ zrReveal.on("globalout", onZrRevealOut);
1818
+
1819
+ chart.on("datazoom", () => {
1820
+ const option = chart.getOption() as { dataZoom?: { start?: number; end?: number }[] };
1821
+ const zoom = option.dataZoom?.[0];
1822
+ if (!zoom) return;
1823
+
1824
+ // Ride the selection — pure zrender updates, so the drag stays 1:1.
1825
+ live.brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };
1826
+ syncBrushOverlayNow();
1827
+
1828
+ const { onBrushChange: onChange } = live.handlers;
1829
+ if (!onChange) return;
1830
+ const len = live.dataLength;
1831
+ const startIndex = Math.round(((zoom.start ?? 0) / 100) * (len - 1));
1832
+ const endIndex = Math.round(((zoom.end ?? 100) / 100) * (len - 1));
1833
+ onChange({ startIndex, endIndex });
1834
+ });
1835
+
1836
+ // Hover tracking for the overlay: labels show while the pointer is over the
1837
+ // brush, and each pill brightens when the pointer is near its edge.
1838
+ const zr = chart.getZr();
1839
+ const applyHover = (next: { inside: boolean; left: boolean; right: boolean }) => {
1840
+ const prev = live.brushHover;
1841
+ if (prev.inside === next.inside && prev.left === next.left && prev.right === next.right) {
1842
+ return;
1843
+ }
1844
+ live.brushHover = next;
1845
+ syncBrushOverlayNow();
1846
+ };
1847
+ const onZrMove = (event: { offsetX?: number; offsetY?: number }) => {
1848
+ const geom = live.brushGeom;
1849
+ if (!geom) return;
1850
+ const x = event.offsetX ?? -1;
1851
+ const y = event.offsetY ?? -1;
1852
+ const top = chart.getHeight() - geom.bottom - geom.height;
1853
+ const inside = y >= top - 4 && y <= top + geom.height + 4;
1854
+ const trackLeft = 8;
1855
+ const trackWidth = Math.max(chart.getWidth() - 16, 1);
1856
+ const { start, end } = live.brushRange;
1857
+ const selectionLeft = trackLeft + (trackWidth * start) / 100;
1858
+ const selectionRight = trackLeft + (trackWidth * end) / 100;
1859
+ applyHover({
1860
+ inside,
1861
+ left: inside && Math.abs(x - selectionLeft) <= 8,
1862
+ right: inside && Math.abs(x - selectionRight) <= 8,
1863
+ });
1864
+ };
1865
+ const onZrOut = () => applyHover({ inside: false, left: false, right: false });
1866
+ zr.on("mousemove", onZrMove);
1867
+ zr.on("globalout", onZrOut);
1868
+
1869
+ return () => {
1870
+ zrReveal.off("mousemove", onZrRevealMove);
1871
+ zrReveal.off("globalout", onZrRevealOut);
1872
+ zr.off("mousemove", onZrMove);
1873
+ zr.off("globalout", onZrOut);
1874
+ resizeObserver.disconnect();
1875
+ themeObserver.disconnect();
1876
+ chart.dispose();
1877
+ echartsRef.current = null;
1878
+ // The overlay elements died with the zrender instance.
1879
+ live.brushOverlay = null;
1880
+ // The reveal guard belongs to the chart instance it guarded. Without this
1881
+ // reset, StrictMode's dev-only mount→unmount→remount plays the entrance on
1882
+ // the throwaway instance and the surviving one renders without it.
1883
+ live.hasRevealed = false;
1884
+ };
1885
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1886
+ }, [renderer]);
1887
+
1888
+ // ── Sync ECharts with props/theme/selection — resolve, build, push ────────────
1889
+ useEffect(() => {
1890
+ const chart = echartsRef.current;
1891
+ const container = containerRef.current;
1892
+ if (!chart || !container) return;
1893
+
1894
+ // Colors come from the <style> committed just before this effect ran — read
1895
+ // them here, right before the push, rather than round-tripping through state.
1896
+ live.resolved = resolveColors(container, config, seriesKeys);
1897
+
1898
+ const push = (withEntrance: boolean) => {
1899
+ const option = buildOption();
1900
+ const merged = chartOptions ? { ...option, ...chartOptions } : option;
1901
+ Object.assign(merged, {
1902
+ animation: withEntrance,
1903
+ animationDuration: REVEAL_DURATION,
1904
+ animationDurationUpdate: 0,
1905
+ });
1906
+ // chartOptions is an untyped escape hatch — the spread erases the option's
1907
+ // shape, so re-assert it. The only cast in the file.
1908
+ chart.setOption(merged as EChartsOption, { notMerge: true });
1909
+ // Overlays live outside the option — reposition them after every push.
1910
+ syncBrushOverlayNow();
1911
+ };
1912
+
1913
+ // Intro reveal — ECharts' native progressive draw, enabled only for the first
1914
+ // real render: the line traces in, dots pop up as its front passes. Every
1915
+ // later push (selection, theme, zoom) applies instantly, since notMerge would
1916
+ // otherwise replay the entrance on each of them. A loading cycle re-arms it:
1917
+ // the Recharts twin unmounts its <Line>s while loading and replays the intro
1918
+ // on remount, so data → loading → data draws in again here too.
1919
+ if (isLoading) live.hasRevealed = false;
1920
+ const shouldReveal = !live.hasRevealed && !isLoading;
1921
+ if (shouldReveal) live.hasRevealed = true;
1922
+ const revealEnabled =
1923
+ animation && shouldReveal && effectiveAnimation !== "none" && !shouldReduceMotion;
1924
+ if (revealEnabled) live.revealEndsAt = performance.now() + REVEAL_DURATION;
1925
+ push(revealEnabled);
1926
+
1927
+ // Theme flips and resizes re-enter here without touching React: re-read the
1928
+ // tokens (the .dark class changed, or the renderer resized) and push an
1929
+ // update-style option.
1930
+ live.repush = () => {
1931
+ live.resolved = resolveColors(container, config, seriesKeys);
1932
+ push(false);
1933
+ };
1934
+ }, [
1935
+ renderer,
1936
+ live,
1937
+ buildOption,
1938
+ chartOptions,
1939
+ isLoading,
1940
+ animation,
1941
+ effectiveAnimation,
1942
+ shouldReduceMotion,
1943
+ config,
1944
+ seriesKeys,
1945
+ syncBrushOverlayNow,
1946
+ ]);
1947
+
1948
+ // ── Animated dashed stroke — rAF sweeps the dash offset while unselected ─────
1949
+ useEffect(() => {
1950
+ const chart = echartsRef.current;
1951
+ if (!chart || isLoading) return;
1952
+ const animatedKeys = lines
1953
+ // A buffer line's body is solid (only its tail dashes), so the sweep skips it.
1954
+ .filter((line) => line.strokeVariant === "animated-dashed" && !line.enableBufferLine)
1955
+ .map((line) => line.dataKey);
1956
+ if (animatedKeys.length === 0 || hasSelection) return;
1957
+
1958
+ let raf = 0;
1959
+ let delayTimer: ReturnType<typeof setTimeout> | undefined;
1960
+ const begin = () => {
1961
+ const loopStart = performance.now();
1962
+ const tick = (now: number) => {
1963
+ const offset = -(((now - loopStart) / 1000) % 1) * 6; // 0 → -6 per second
1964
+ chart.setOption(
1965
+ { series: animatedKeys.map((id) => ({ id, lineStyle: { dashOffset: offset } })) },
1966
+ { silent: true, lazyUpdate: true },
1967
+ );
1968
+ raf = requestAnimationFrame(tick);
1969
+ };
1970
+ raf = requestAnimationFrame(tick);
1971
+ };
1972
+
1973
+ // Per-frame setOption churn fights the intro draw-in (each update pass
1974
+ // recomputes the reveal clip, crawling it to a standstill) — hold the dash
1975
+ // sweep until the entrance has finished.
1976
+ const delay = Math.max(0, live.revealEndsAt - performance.now());
1977
+ if (delay > 0) delayTimer = setTimeout(begin, delay + 50);
1978
+ else begin();
1979
+
1980
+ return () => {
1981
+ if (delayTimer !== undefined) clearTimeout(delayTimer);
1982
+ cancelAnimationFrame(raf);
1983
+ };
1984
+ }, [renderer, live, lines, hasSelection, isLoading]);
1985
+
1986
+ // ── Loading shimmer — rAF sweeps a bright band, regenerating data off-screen ─
1987
+ useEffect(() => {
1988
+ const chart = echartsRef.current;
1989
+ if (!chart || !isLoading) return;
1990
+
1991
+ let raf = 0;
1992
+ let lastPhase = 0;
1993
+ const start = performance.now();
1994
+ const tick = (now: number) => {
1995
+ const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;
1996
+ // Wrapped past 1 → the band is off-screen; swap in fresh random data.
1997
+ if (phase < lastPhase) live.loadingRows = getLoadingData(loadingPoints);
1998
+ lastPhase = phase;
1999
+
2000
+ // Read tokens per frame, so a theme flip mid-loading retints the shimmer.
2001
+ const foreground = live.resolved?.tokens.foreground ?? "rgba(120, 120, 120, 1)";
2002
+ // Sweep the clip window from fully off-screen left to fully off-screen
2003
+ // right, leaned 45°. The gradient uses ABSOLUTE pixel coordinates so the
2004
+ // window lands at the same place along the stroke regardless of the wave's
2005
+ // bounding box.
2006
+ const w = chart.getWidth();
2007
+ const h = chart.getHeight();
2008
+ if (!w || !h) {
2009
+ raf = requestAnimationFrame(tick);
2010
+ return;
2011
+ }
2012
+ // Farthest plot corner projected onto the 45° axis — keeps the sweep
2013
+ // tight instead of dawdling off-plot at the end of each loop.
2014
+ const maxT = (w + h) / (2 * w);
2015
+ const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;
2016
+ const clip = (peak: number) =>
2017
+ new echarts.graphic.LinearGradient(
2018
+ 0,
2019
+ 0,
2020
+ w,
2021
+ w,
2022
+ shimmerWindowStops(center, foreground, peak),
2023
+ true,
2024
+ );
2025
+ chart.setOption(
2026
+ {
2027
+ series: [
2028
+ {
2029
+ id: "__loading",
2030
+ data: loadingData(),
2031
+ lineStyle: { color: clip(LOADING_STROKE_OPACITY), width: 1 },
2032
+ },
2033
+ ],
2034
+ },
2035
+ { silent: true, lazyUpdate: true },
2036
+ );
2037
+ raf = requestAnimationFrame(tick);
2038
+ };
2039
+ raf = requestAnimationFrame(tick);
2040
+ return () => cancelAnimationFrame(raf);
2041
+ }, [renderer, live, isLoading, loadingPoints, loadingData]);
2042
+
2043
+ // ── Legend overlay position ──────────────────────────────────────────────────
2044
+ // Insets match the Recharts legend's breathing room inside the plot frame.
2045
+ const legendStyle: CSSProperties = {
2046
+ position: "absolute",
2047
+ left: 16,
2048
+ right: 16,
2049
+ pointerEvents: "auto",
2050
+ ...(legendSlot.verticalAlign === "top"
2051
+ ? { top: 12 }
2052
+ : legendSlot.verticalAlign === "bottom"
2053
+ ? { bottom: showBrush ? brushHeight + 16 : 12 }
2054
+ : { top: "50%", transform: "translateY(-50%)" }),
2055
+ };
2056
+
2057
+ return (
2058
+ <div
2059
+ ref={containerRef}
2060
+ data-chart={chartId}
2061
+ className={`relative flex flex-col text-xs ${className ?? ""}`}
2062
+ >
2063
+ <style dangerouslySetInnerHTML={{ __html: css }} />
2064
+
2065
+ <div className="relative min-h-0 w-full flex-1">
2066
+ <div ref={mountRef} className="h-full min-h-0 w-full" />
2067
+ </div>
2068
+
2069
+ {legendSlot.present && !isLoading && (
2070
+ <LegendOverlay
2071
+ seriesKeys={seriesKeys}
2072
+ config={config}
2073
+ variant={legendSlot.variant}
2074
+ align={legendSlot.align}
2075
+ verticalAlign={legendSlot.verticalAlign}
2076
+ selectedKey={selectedDataKey}
2077
+ hoveredKey={hoveredDataKey}
2078
+ isClickable={legendSlot.isClickable}
2079
+ onToggle={toggleSelection}
2080
+ style={legendStyle}
2081
+ />
2082
+ )}
2083
+
2084
+ {isLoading && (
2085
+ <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
2086
+ <motion.div
2087
+ initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}
2088
+ animate={{ opacity: 1, scale: 1 }}
2089
+ transition={{ duration: 0.25, ease: "easeOut" }}
2090
+ className="text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm"
2091
+ >
2092
+ <div className="border-border border-t-primary h-3 w-3 animate-spin rounded-full border" />
2093
+ <span>Loading</span>
2094
+ </motion.div>
2095
+ </div>
2096
+ )}
2097
+ </div>
2098
+ );
2099
+ }
2100
+
2101
+ // Compound API: every part hangs off the root as a static member, so a consumer
2102
+ // writes <EChartsLineChart.Line/>, <EChartsLineChart.Tooltip/>, … from a single
2103
+ // import — no colliding named marker exports when several charts share one file.
2104
+ EChartsLineChart.Line = Line;
2105
+ EChartsLineChart.Dot = Dot;
2106
+ EChartsLineChart.ActiveDot = ActiveDot;
2107
+ EChartsLineChart.XAxis = XAxis;
2108
+ EChartsLineChart.YAxis = YAxis;
2109
+ EChartsLineChart.Grid = Grid;
2110
+ EChartsLineChart.Tooltip = Tooltip;
2111
+ EChartsLineChart.Legend = Legend;
2112
+ EChartsLineChart.Brush = Brush;