@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.
- package/dist-lib/components/composed/chat/chart/chart-atom.d.ts +30 -0
- package/dist-lib/components/composed/chat/chart/chart-context.d.ts +2 -0
- package/dist-lib/components/composed/chat/chart/chart-legend.d.ts +4 -0
- package/dist-lib/components/composed/chat/chart/chart-plot.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/chart-root.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/chart-tooltip.d.ts +4 -0
- package/dist-lib/components/composed/chat/chart/constants.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-area-chart.d.ts +93 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-bar-chart.d.ts +86 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-brush.d.ts +65 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-chart.d.ts +44 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-dot.d.ts +16 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-legend.d.ts +24 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-line-chart.d.ts +90 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-pie-chart.d.ts +73 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-tooltip.d.ts +29 -0
- package/dist-lib/components/composed/chat/chart/index.d.ts +6 -0
- package/dist-lib/components/composed/chat/chart/type.d.ts +80 -0
- package/dist-lib/components/composed/chat/chart/use-chart-state.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/use-chart.d.ts +6 -0
- package/dist-lib/components/composed/chat/chart/utils.d.ts +5 -0
- package/dist-lib/components/composed/chat/chart.d.ts +1 -44
- package/dist-lib/components/composed/chat/index.d.ts +1 -1
- package/dist-lib/components/stories/chart.story.d.ts +39 -2
- package/dist-lib/components/stories/insight-cards.story.d.ts +30 -1
- package/dist-lib/index.js +10020 -5549
- package/dist-lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/composed/chat/__tests__/chart.test.tsx +255 -1
- package/src/components/composed/chat/chart/chart-atom.ts +55 -0
- package/src/components/composed/chat/chart/chart-context.ts +9 -0
- package/src/components/composed/chat/chart/chart-legend.tsx +75 -0
- package/src/components/composed/chat/chart/chart-plot.tsx +233 -0
- package/src/components/composed/chat/chart/chart-root.tsx +229 -0
- package/src/components/composed/chat/chart/chart-tooltip.tsx +74 -0
- package/src/components/composed/chat/chart/constants.ts +8 -0
- package/src/components/composed/chat/chart/evilcharts/LICENSE +21 -0
- package/src/components/composed/chat/chart/evilcharts/README.md +8 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-area-chart.tsx +2358 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-bar-chart.tsx +2261 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-brush.tsx +233 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-chart.tsx +214 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-dot.tsx +111 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-legend.tsx +131 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-line-chart.tsx +2112 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-pie-chart.tsx +1235 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-tooltip.tsx +125 -0
- package/src/components/composed/chat/chart/index.ts +13 -0
- package/src/components/composed/chat/chart/type.ts +88 -0
- package/src/components/composed/chat/chart/use-chart-state.ts +22 -0
- package/src/components/composed/chat/chart/use-chart.ts +70 -0
- package/src/components/composed/chat/chart/utils.ts +47 -0
- package/src/components/composed/chat/chart.tsx +2 -400
- package/src/components/composed/chat/index.ts +1 -7
- package/src/components/stories/chart.story.tsx +16 -1
|
@@ -0,0 +1,2261 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ECHARTS_RENDERER,
|
|
5
|
+
buildChartCss,
|
|
6
|
+
flattenColor,
|
|
7
|
+
getColorsCount,
|
|
8
|
+
resolveColors,
|
|
9
|
+
withAlpha,
|
|
10
|
+
type ChartConfig,
|
|
11
|
+
type EChartsRenderer,
|
|
12
|
+
type ResolvedColors,
|
|
13
|
+
} from "./echarts-chart";
|
|
14
|
+
import {
|
|
15
|
+
tooltipBaseOption,
|
|
16
|
+
tooltipIndicatorHtml,
|
|
17
|
+
tooltipRow,
|
|
18
|
+
tooltipShell,
|
|
19
|
+
type TooltipPosition,
|
|
20
|
+
type TooltipRoundness,
|
|
21
|
+
type TooltipVariant,
|
|
22
|
+
} from "./echarts-tooltip";
|
|
23
|
+
import {
|
|
24
|
+
Brush,
|
|
25
|
+
buildBrushDataZoom,
|
|
26
|
+
syncBrushOverlay,
|
|
27
|
+
type BrushGeometry,
|
|
28
|
+
type BrushOverlayElements,
|
|
29
|
+
type BrushProps,
|
|
30
|
+
type BrushRange,
|
|
31
|
+
} from "./echarts-brush";
|
|
32
|
+
import {
|
|
33
|
+
DataZoomComponent,
|
|
34
|
+
GridComponent,
|
|
35
|
+
TooltipComponent,
|
|
36
|
+
type DataZoomComponentOption,
|
|
37
|
+
type GridComponentOption,
|
|
38
|
+
type TooltipComponentOption,
|
|
39
|
+
} from "echarts/components";
|
|
40
|
+
import {
|
|
41
|
+
Children,
|
|
42
|
+
isValidElement,
|
|
43
|
+
useCallback,
|
|
44
|
+
useEffect,
|
|
45
|
+
useId,
|
|
46
|
+
useMemo,
|
|
47
|
+
useRef,
|
|
48
|
+
useState,
|
|
49
|
+
type CSSProperties,
|
|
50
|
+
type FC,
|
|
51
|
+
type ReactNode,
|
|
52
|
+
} from "react";
|
|
53
|
+
import { LegendOverlay, type LegendVariant } from "./echarts-legend";
|
|
54
|
+
import type { ComposeOption, ImagePatternObject } from "echarts/core";
|
|
55
|
+
import { BarChart, type BarSeriesOption } from "echarts/charts";
|
|
56
|
+
import { sampleGradient } from "./echarts-dot";
|
|
57
|
+
import { motion, useReducedMotion } from "motion/react";
|
|
58
|
+
import * as echarts from "echarts/core";
|
|
59
|
+
|
|
60
|
+
// Re-export the shared types that were previously declared inline here, so
|
|
61
|
+
// existing consumers/examples keep importing them from the chart module.
|
|
62
|
+
export type {
|
|
63
|
+
ChartConfig,
|
|
64
|
+
EChartsRenderer,
|
|
65
|
+
LegendVariant,
|
|
66
|
+
TooltipPosition,
|
|
67
|
+
TooltipRoundness,
|
|
68
|
+
TooltipVariant,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Modular registration keeps the bundle lean — only the pieces this chart needs.
|
|
72
|
+
// `DataZoomComponent` bundles both the slider (brush footer) and inside (wheel/drag)
|
|
73
|
+
// zoom. The brush's frame/handles/labels are raw zrender elements, not the
|
|
74
|
+
// graphic component — see syncBrushOverlay. No LineChart: the main plot, the
|
|
75
|
+
// loading skeleton, and the brush mini chart are ALL bar series.
|
|
76
|
+
echarts.use([BarChart, GridComponent, TooltipComponent, DataZoomComponent]);
|
|
77
|
+
|
|
78
|
+
type EChartsInstance = ReturnType<typeof echarts.init>;
|
|
79
|
+
|
|
80
|
+
// The exact option surface this chart uses — bar series, grid, tooltip, and
|
|
81
|
+
// dataZoom, plus the axis options they pull in as dependencies. Narrower than
|
|
82
|
+
// echarts' full EChartsOption, so a misspelled key fails the compile instead of
|
|
83
|
+
// silently reaching setOption.
|
|
84
|
+
type EChartsOption = ComposeOption<
|
|
85
|
+
BarSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption
|
|
86
|
+
>;
|
|
87
|
+
|
|
88
|
+
// Single-entry views of the composed option's array-or-single fields — the
|
|
89
|
+
// modular entry points don't export the axis option types directly.
|
|
90
|
+
type ArrayItem<T> = T extends readonly (infer U)[] ? U : T;
|
|
91
|
+
type XAxisOption = ArrayItem<NonNullable<EChartsOption["xAxis"]>>;
|
|
92
|
+
type YAxisOption = ArrayItem<NonNullable<EChartsOption["yAxis"]>>;
|
|
93
|
+
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
// Constants
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const DEFAULT_BAR_RADIUS = 2;
|
|
99
|
+
const STROKE_WIDTH = 1; // buffer-bar outline width
|
|
100
|
+
const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds
|
|
101
|
+
const BAR_GROW_DURATION = 500; // per-bar grow-in length, in milliseconds
|
|
102
|
+
const BAR_STAGGER = 50; // delay between consecutive bars in the reveal, in milliseconds
|
|
103
|
+
const LOADING_DEFAULT_BARS = 12;
|
|
104
|
+
// `revealEndsAt` marks when the intro grow-in finishes. The stripped-cap post-layout
|
|
105
|
+
// correction (a notMerge repush) waits for it so it never lands mid-entrance and
|
|
106
|
+
// stomps the grow — the same reason the area chart tracks this timestamp.
|
|
107
|
+
const SELECTION_DIM = 0.3; // opacity of an unselected series while a selection is active
|
|
108
|
+
const HOVER_BLUR = 0.3; // opacity of the non-hovered bars while hover-highlight is on
|
|
109
|
+
// Soft outer glow — the canvas analogue of the Recharts feGaussianBlur filter
|
|
110
|
+
// (stdDeviation 8, alpha 0.5). A generous shadowBlur keeps the halo soft with no
|
|
111
|
+
// hard rim; the shadowColor is sampled PER BAR so a multi-stop gradient series
|
|
112
|
+
// glows in its own colors across the plot instead of one flat tint.
|
|
113
|
+
const GLOW_BLUR = 18; // shadowBlur radius, in device-independent pixels
|
|
114
|
+
const GLOW_OPACITY = 0.65; // per-datum shadowColor alpha, × the sampled series color
|
|
115
|
+
|
|
116
|
+
// The `blocks` variant renders each bar as a stack of segments instead of a solid
|
|
117
|
+
// column: a repeating tile paints a BLOCK_SIZE band then leaves a BLOCK_GAP of
|
|
118
|
+
// transparency. The same tile, in a muted tone, fills the column's unused space
|
|
119
|
+
// via ECharts' native `showBackground`, so the empty part of every bar reads as a
|
|
120
|
+
// dim grid of the same blocks. Both tile from the renderer origin, so the bands
|
|
121
|
+
// line up across every column.
|
|
122
|
+
// The `expandable` variant draws every bar at full width but fills only a narrow
|
|
123
|
+
// centre strip, so it reads as a thin line; hovering one grows its strip out to
|
|
124
|
+
// the full width and back on leave. The bar geometry never changes — only the
|
|
125
|
+
// horizontal extent of its fill — so nothing re-lays out mid-hover.
|
|
126
|
+
const EXPAND_COLLAPSED = 0.12; // resting strip width, as a fraction of the bar
|
|
127
|
+
const EXPAND_TAU = 70; // ease time-constant, in milliseconds (exponential approach)
|
|
128
|
+
|
|
129
|
+
const BLOCK_SIZE = 8; // filled segment height, in pixels
|
|
130
|
+
const BLOCK_GAP = 4; // transparent gap between segments, in pixels
|
|
131
|
+
const BLOCK_TRACK_OPACITY = 0.22; // unfilled block tone, x the muted-foreground alpha
|
|
132
|
+
// Stacked segments would otherwise butt straight into each other and read as one
|
|
133
|
+
// solid column. The separation is a REAL gap — transparent spacer series stacked
|
|
134
|
+
// between the real ones — not a background-colored border: a border paints on all
|
|
135
|
+
// four sides, so it outlines each segment (obvious the moment a bar glows) instead
|
|
136
|
+
// of only parting them.
|
|
137
|
+
const STACK_SEGMENT_GAP = 4; // separation between stacked segments, in pixels
|
|
138
|
+
const MAX_HIGHLIGHT_DIM = 0.16; // non-winning columns under enableMaxValueHighlight, x muted-foreground
|
|
139
|
+
|
|
140
|
+
// The `stripped` variant caps each bar with a small BRIGHT pill of CONSTANT pixel
|
|
141
|
+
// height (Recharts draws a fixed ~2px strip on top of a dimmed body, identical on
|
|
142
|
+
// tall and short bars). The cap is expressed PER DATUM as a fraction of that bar's
|
|
143
|
+
// own pixel height, so a fixed pixel height maps to a shrinking fraction as the bar
|
|
144
|
+
// grows — the fraction is derived at runtime from the measured value-axis
|
|
145
|
+
// pixels-per-unit (see measureValuePxPerUnit). A canvas gradient alone can't do
|
|
146
|
+
// this: its bright band is a fraction of the bounding box, so it would scale with
|
|
147
|
+
// bar length (the bug this replaced).
|
|
148
|
+
const STRIPPED_CAP_HEIGHT = 4; // bright cap height, in device-independent pixels
|
|
149
|
+
const STRIPPED_BODY_ALPHA = 0.2; // dimmed bar body below the cap, × series color
|
|
150
|
+
const STRIPPED_CAP_MAX_FRACTION = 0.85; // cap never swallows a whole (very short) bar
|
|
151
|
+
const STRIPPED_FALLBACK_FRACTION = 0.12; // used before the axis geometry is measured
|
|
152
|
+
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
// Theme knobs — every neutral line in the chart draws from these. Base colors
|
|
155
|
+
// come from the consumer's CSS tokens (resolved from the live DOM), so only the
|
|
156
|
+
// opacity factors live here. Factors MULTIPLY the token's own alpha — a border
|
|
157
|
+
// token that is already 10%-white stays subtle. Tune here, not in the builder.
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Recharts draws its grid at border/50, but SVG dashes render pixel-crisp while
|
|
160
|
+
// canvas at 2× DPR spreads a 1px line across device pixels — roughly halving
|
|
161
|
+
// perceived intensity. Using the border token's full alpha lands both engines at
|
|
162
|
+
// the same apparent brightness.
|
|
163
|
+
const GRID_LINE_OPACITY = 1; // dashed value-axis split lines, × border alpha
|
|
164
|
+
// The skeleton is CLIPPED to a small sweeping window — only the bars inside it
|
|
165
|
+
// exist, everything outside is fully transparent, like a spotlight sliding across.
|
|
166
|
+
const LOADING_SHIMMER_MAX_OPACITY = 0.22; // gray bar fill inside the window, × foreground alpha
|
|
167
|
+
const LOADING_SHIMMER_BAND = 0.2; // window half-width, fraction of the 45° sweep axis
|
|
168
|
+
const LOADING_SHIMMER_FEATHER = 0.2; // eased edge softening of the clip window
|
|
169
|
+
const BRUSH_FILL_OPACITY = 0.5; // mini-chart bar fill
|
|
170
|
+
const BRUSH_FILLER_OPACITY = 0; // selected-range wash — evil-brush draws none
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
// Public types
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export type BarVariant =
|
|
177
|
+
| "default"
|
|
178
|
+
| "hatched"
|
|
179
|
+
| "duotone"
|
|
180
|
+
| "duotone-reverse"
|
|
181
|
+
| "gradient"
|
|
182
|
+
| "stripped"
|
|
183
|
+
| "blocks"
|
|
184
|
+
| "expandable";
|
|
185
|
+
export type StackType = "default" | "stacked" | "percent";
|
|
186
|
+
export type BarLayout = "vertical" | "horizontal";
|
|
187
|
+
export type BarAnimationType =
|
|
188
|
+
| "none"
|
|
189
|
+
| "left-to-right"
|
|
190
|
+
| "right-to-left"
|
|
191
|
+
| "center-out"
|
|
192
|
+
| "edges-in";
|
|
193
|
+
// TooltipVariant, TooltipRoundness, LegendVariant, and ChartConfig now live in
|
|
194
|
+
// the shared ./echarts/* modules and are imported + re-exported at
|
|
195
|
+
// the top of this file.
|
|
196
|
+
|
|
197
|
+
export interface EChartsBarChartProps<TData extends Record<string, unknown>> {
|
|
198
|
+
data: TData[]; // rows rendered by the chart
|
|
199
|
+
config: ChartConfig; // series colors + labels
|
|
200
|
+
renderer?: EChartsRenderer; // rendering engine — defaults to canvas
|
|
201
|
+
xDataKey?: keyof TData & string; // category key — falls back to the axis dataKey / first free column
|
|
202
|
+
className?: string; // extra classes for the chart container
|
|
203
|
+
stackType?: StackType; // how multiple bars combine
|
|
204
|
+
layout?: BarLayout; // orientation of the bars
|
|
205
|
+
barRadius?: number; // default corner radius every <Bar> inherits
|
|
206
|
+
animation?: boolean; // master switch for the intro grow-in — false renders instantly
|
|
207
|
+
animationType?: BarAnimationType; // default grow-in order each <Bar> inherits
|
|
208
|
+
barGap?: number; // gap between bars within the same category, in pixels
|
|
209
|
+
barCategoryGap?: number; // gap between categories of bars, in pixels
|
|
210
|
+
selectedDataKey?: string | null;
|
|
211
|
+
defaultSelectedDataKey?: string | null; // series selected on first render
|
|
212
|
+
onSelectionChange?: (key: string | null) => void; // fires when the selected series changes
|
|
213
|
+
// Colors ONLY the tallest column and mutes the rest. With several series the
|
|
214
|
+
// comparison is per COLUMN — the totals across every series at that category —
|
|
215
|
+
// so a whole stack or group lights up together, not one bar inside it.
|
|
216
|
+
enableMaxValueHighlight?: boolean;
|
|
217
|
+
isLoading?: boolean; // shows the animated loading skeleton
|
|
218
|
+
loadingBars?: number; // number of bars in the loading skeleton
|
|
219
|
+
chartOptions?: Record<string, unknown>; // escape hatch merged over the built ECharts option
|
|
220
|
+
children?: ReactNode; // declarative config — <Bar>, <XAxis>, <YAxis>, <Grid>, <Tooltip>, <Legend>, <Brush>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// Composible parts — DECLARATIVE CONFIG. Every part renders `null`; the root
|
|
225
|
+
// walks `children` by reference (child.type === Bar, …) to collect its props.
|
|
226
|
+
// Presence semantics mirror the Recharts twin: omit a child and that part does
|
|
227
|
+
// not render. These are never mounted into the tree — they only carry props.
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export interface BarProps {
|
|
231
|
+
dataKey: string; // series key — must exist on the data + config
|
|
232
|
+
variant?: BarVariant; // fill style for this bar only
|
|
233
|
+
radius?: number; // corner radius — falls back to the root barRadius
|
|
234
|
+
animationType?: BarAnimationType; // grow-in order — falls back to the root animationType
|
|
235
|
+
isClickable?: boolean; // lets this bar be selected by clicking it
|
|
236
|
+
enableHoverHighlight?: boolean; // dims the other bars while one is hovered
|
|
237
|
+
glowing?: boolean; // applies a soft outer glow to this bar
|
|
238
|
+
bufferBar?: boolean; // renders the last data point as a hatched "buffer" bar
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A single bar series. Declares its own fill variant, radius, glow, buffer, and
|
|
243
|
+
* clickability. Renders nothing — the root reads these props to build the
|
|
244
|
+
* ECharts series.
|
|
245
|
+
*/
|
|
246
|
+
const Bar: FC<BarProps> = () => null;
|
|
247
|
+
|
|
248
|
+
export interface XAxisProps {
|
|
249
|
+
dataKey?: string; // category key — overrides the root xDataKey (vertical layout)
|
|
250
|
+
// Category values are stringified, so the formatter always sees a string —
|
|
251
|
+
// letting examples share `(value) => value.substring(0, 3)` with the Recharts twin.
|
|
252
|
+
tickFormatter?: (value: string, index: number) => string; // formats x tick labels
|
|
253
|
+
label?: string; // axis title, centered below the x-position tick labels
|
|
254
|
+
hideDots?: boolean; // hides the tick dots beside this axis's labels
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The x-axis. Category axis in the default (vertical) layout, value axis when
|
|
259
|
+
* `layout="horizontal"`. Presence shows its tick labels. Renders nothing.
|
|
260
|
+
*/
|
|
261
|
+
const XAxis: FC<XAxisProps> = () => null;
|
|
262
|
+
|
|
263
|
+
export interface YAxisProps {
|
|
264
|
+
dataKey?: string; // category key — overrides the root xDataKey (horizontal layout)
|
|
265
|
+
tickFormatter?: (value: string, index: number) => string; // formats y tick labels
|
|
266
|
+
label?: string; // axis title, rotated alongside the y-position tick labels
|
|
267
|
+
hideDots?: boolean; // hides the tick dots beside this axis's labels
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The y-axis. Value axis in the default (vertical) layout, category axis when
|
|
272
|
+
* `layout="horizontal"`. Presence shows its tick labels. Renders nothing.
|
|
273
|
+
*/
|
|
274
|
+
const YAxis: FC<YAxisProps> = () => null;
|
|
275
|
+
|
|
276
|
+
/** Presence shows the dashed split lines on the value axis. Renders nothing. */
|
|
277
|
+
const Grid: FC = () => null;
|
|
278
|
+
|
|
279
|
+
export interface TooltipProps {
|
|
280
|
+
variant?: TooltipVariant; // visual style of the tooltip surface
|
|
281
|
+
roundness?: TooltipRoundness; // border-radius of the tooltip
|
|
282
|
+
defaultIndex?: number; // data index the tooltip shows by default, with no hover
|
|
283
|
+
position?: TooltipPosition; // "variable" follows the pointer (default); "fixed" pins the tooltip near the top and only tracks the pointer's X
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Presence enables the hover tooltip. Renders nothing. */
|
|
287
|
+
const Tooltip: FC<TooltipProps> = () => null;
|
|
288
|
+
|
|
289
|
+
export interface LegendProps {
|
|
290
|
+
variant?: LegendVariant; // visual style of the legend indicators
|
|
291
|
+
align?: "left" | "center" | "right"; // horizontal placement
|
|
292
|
+
verticalAlign?: "top" | "middle" | "bottom"; // vertical placement
|
|
293
|
+
isClickable?: boolean; // lets each entry toggle selection of its series
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Presence enables the HTML legend overlay. Renders nothing. */
|
|
297
|
+
const Legend: FC<LegendProps> = () => null;
|
|
298
|
+
|
|
299
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
// Children collection — walk the declarative config into plain objects the
|
|
301
|
+
// option builder consumes.
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
type BarSeriesConfig = {
|
|
305
|
+
dataKey: string;
|
|
306
|
+
variant: BarVariant;
|
|
307
|
+
radius?: number;
|
|
308
|
+
animationType?: BarAnimationType;
|
|
309
|
+
isClickable: boolean;
|
|
310
|
+
enableHoverHighlight: boolean;
|
|
311
|
+
glowing: boolean;
|
|
312
|
+
bufferBar: boolean;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
type AxisSlot = {
|
|
316
|
+
present: boolean;
|
|
317
|
+
dataKey?: string;
|
|
318
|
+
tickFormatter?: (value: string, index: number) => string;
|
|
319
|
+
label?: string;
|
|
320
|
+
hideDots: boolean;
|
|
321
|
+
};
|
|
322
|
+
type TooltipSlot = {
|
|
323
|
+
present: boolean;
|
|
324
|
+
variant: TooltipVariant;
|
|
325
|
+
roundness: TooltipRoundness;
|
|
326
|
+
defaultIndex?: number;
|
|
327
|
+
position: TooltipPosition;
|
|
328
|
+
};
|
|
329
|
+
type LegendSlot = {
|
|
330
|
+
present: boolean;
|
|
331
|
+
variant: LegendVariant;
|
|
332
|
+
align: "left" | "center" | "right";
|
|
333
|
+
verticalAlign: "top" | "middle" | "bottom";
|
|
334
|
+
isClickable: boolean;
|
|
335
|
+
};
|
|
336
|
+
type BrushSlot = {
|
|
337
|
+
present: boolean; // a <Brush> child was passed — replaces the old showBrush prop
|
|
338
|
+
height?: number;
|
|
339
|
+
formatLabel?: (value: string, index: number) => string;
|
|
340
|
+
onChange?: (range: { startIndex: number; endIndex: number }) => void;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
type CollectedConfig = {
|
|
344
|
+
bars: BarSeriesConfig[];
|
|
345
|
+
xAxis: AxisSlot;
|
|
346
|
+
yAxis: AxisSlot;
|
|
347
|
+
showGrid: boolean;
|
|
348
|
+
tooltip: TooltipSlot;
|
|
349
|
+
legend: LegendSlot;
|
|
350
|
+
brush: BrushSlot;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
function collectConfig(children: ReactNode): CollectedConfig {
|
|
354
|
+
const bars: BarSeriesConfig[] = [];
|
|
355
|
+
let xAxis: AxisSlot = { present: false, hideDots: false };
|
|
356
|
+
let yAxis: AxisSlot = { present: false, hideDots: false };
|
|
357
|
+
let showGrid = false;
|
|
358
|
+
let tooltip: TooltipSlot = {
|
|
359
|
+
present: false,
|
|
360
|
+
variant: "default",
|
|
361
|
+
roundness: "lg",
|
|
362
|
+
position: "variable",
|
|
363
|
+
};
|
|
364
|
+
let legend: LegendSlot = {
|
|
365
|
+
present: false,
|
|
366
|
+
variant: "rounded-square",
|
|
367
|
+
align: "right",
|
|
368
|
+
verticalAlign: "top",
|
|
369
|
+
isClickable: false,
|
|
370
|
+
};
|
|
371
|
+
let brush: BrushSlot = { present: false };
|
|
372
|
+
|
|
373
|
+
Children.forEach(children, (child) => {
|
|
374
|
+
if (!isValidElement(child)) return;
|
|
375
|
+
const type = child.type;
|
|
376
|
+
|
|
377
|
+
if (type === Bar) {
|
|
378
|
+
const props = child.props as BarProps;
|
|
379
|
+
bars.push({
|
|
380
|
+
dataKey: props.dataKey,
|
|
381
|
+
variant: props.variant ?? "default",
|
|
382
|
+
radius: props.radius,
|
|
383
|
+
animationType: props.animationType,
|
|
384
|
+
isClickable: props.isClickable ?? false,
|
|
385
|
+
enableHoverHighlight: props.enableHoverHighlight ?? false,
|
|
386
|
+
glowing: props.glowing ?? false,
|
|
387
|
+
bufferBar: props.bufferBar ?? false,
|
|
388
|
+
});
|
|
389
|
+
} else if (type === XAxis) {
|
|
390
|
+
const props = child.props as XAxisProps;
|
|
391
|
+
xAxis = {
|
|
392
|
+
present: true,
|
|
393
|
+
dataKey: props.dataKey,
|
|
394
|
+
tickFormatter: props.tickFormatter,
|
|
395
|
+
label: props.label,
|
|
396
|
+
hideDots: props.hideDots ?? false,
|
|
397
|
+
};
|
|
398
|
+
} else if (type === YAxis) {
|
|
399
|
+
const props = child.props as YAxisProps;
|
|
400
|
+
yAxis = {
|
|
401
|
+
present: true,
|
|
402
|
+
dataKey: props.dataKey,
|
|
403
|
+
tickFormatter: props.tickFormatter,
|
|
404
|
+
label: props.label,
|
|
405
|
+
hideDots: props.hideDots ?? false,
|
|
406
|
+
};
|
|
407
|
+
} else if (type === Grid) {
|
|
408
|
+
showGrid = true;
|
|
409
|
+
} else if (type === Tooltip) {
|
|
410
|
+
const props = child.props as TooltipProps;
|
|
411
|
+
tooltip = {
|
|
412
|
+
present: true,
|
|
413
|
+
variant: props.variant ?? "default",
|
|
414
|
+
roundness: props.roundness ?? "lg",
|
|
415
|
+
defaultIndex: props.defaultIndex,
|
|
416
|
+
position: props.position ?? "variable",
|
|
417
|
+
};
|
|
418
|
+
} else if (type === Legend) {
|
|
419
|
+
const props = child.props as LegendProps;
|
|
420
|
+
legend = {
|
|
421
|
+
present: true,
|
|
422
|
+
variant: props.variant ?? "rounded-square",
|
|
423
|
+
align: props.align ?? "right",
|
|
424
|
+
verticalAlign: props.verticalAlign ?? "top",
|
|
425
|
+
isClickable: props.isClickable ?? false,
|
|
426
|
+
};
|
|
427
|
+
} else if (type === Brush) {
|
|
428
|
+
const props = child.props as BrushProps;
|
|
429
|
+
brush = {
|
|
430
|
+
present: true,
|
|
431
|
+
height: props.height,
|
|
432
|
+
formatLabel: props.formatLabel,
|
|
433
|
+
onChange: props.onChange,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return { bars, xAxis, yAxis, showGrid, tooltip, legend, brush };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Color plumbing (ChartConfig, getColorsCount, distributeColors, buildChartCss,
|
|
442
|
+
// normalizeColor, withAlpha, ResolvedColors, resolveColors, flattenColor) plus
|
|
443
|
+
// the theme keys now live in ./echarts-chart and are imported at the
|
|
444
|
+
// top of this file.
|
|
445
|
+
|
|
446
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
447
|
+
// Fill paints — the ECharts analogue of the Recharts bar fill variants. Unlike
|
|
448
|
+
// the area chart's fills (which run the color gradient HORIZONTALLY), a bar's
|
|
449
|
+
// base color gradient runs VERTICALLY top→bottom (Recharts `ColorGradient` uses
|
|
450
|
+
// x1=x2=0), so each bar shows the full multi-stop gradient in its own box.
|
|
451
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
const GRAY = "rgba(120, 120, 120, 1)";
|
|
454
|
+
|
|
455
|
+
// sampleGradient (the color the series gradient shows at t ∈ [0, 1]) now lives in
|
|
456
|
+
// ./echarts-dot and is imported at the top of this file.
|
|
457
|
+
|
|
458
|
+
// Solid vertical top→bottom color for a series — a plain string when there is
|
|
459
|
+
// one color, else a vertical multi-stop LinearGradient in each bar's own box.
|
|
460
|
+
// The `default` variant paints from this at full alpha.
|
|
461
|
+
function solidVerticalPaint(
|
|
462
|
+
slots: string[],
|
|
463
|
+
alpha: number,
|
|
464
|
+
): string | echarts.graphic.LinearGradient {
|
|
465
|
+
if (slots.length <= 1) {
|
|
466
|
+
const base = slots[0] ?? GRAY;
|
|
467
|
+
return alpha === 1 ? base : withAlpha(base, alpha);
|
|
468
|
+
}
|
|
469
|
+
const stops = slots.map((color, i) => ({
|
|
470
|
+
offset: i / (slots.length - 1),
|
|
471
|
+
color: withAlpha(color, alpha),
|
|
472
|
+
}));
|
|
473
|
+
return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The `gradient` variant: the vertical color gradient faded from solid at the
|
|
477
|
+
// top to clear at the bottom. Recharts masks with white@1 at 20% → white@0 at
|
|
478
|
+
// 90%, so the alpha holds full through the top fifth and vanishes by 90%.
|
|
479
|
+
function verticalFadePaint(slots: string[]): echarts.graphic.LinearGradient {
|
|
480
|
+
const offsets = [0, 0.2, 0.45, 0.7, 0.9, 1];
|
|
481
|
+
const alphaAt = (t: number) => (t <= 0.2 ? 1 : t >= 0.9 ? 0 : 1 - (t - 0.2) / 0.7);
|
|
482
|
+
const stops = offsets.map((t) => ({
|
|
483
|
+
offset: t,
|
|
484
|
+
color: withAlpha(sampleGradient(slots, t), alphaAt(t)),
|
|
485
|
+
}));
|
|
486
|
+
return new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The `duotone` family: a hard alpha split across the bar's short axis (its width
|
|
490
|
+
// for vertical bars, its height for horizontal). Recharts splits at 50% via an
|
|
491
|
+
// objectBoundingBox mask — exact for single-color series; multi-color duotone
|
|
492
|
+
// falls back to the base color (an accepted approximation, matching the twin's
|
|
493
|
+
// single-color examples).
|
|
494
|
+
function duotoneSplitPaint(
|
|
495
|
+
base: string,
|
|
496
|
+
leftAlpha: number,
|
|
497
|
+
rightAlpha: number,
|
|
498
|
+
isHorizontal: boolean,
|
|
499
|
+
): echarts.graphic.LinearGradient {
|
|
500
|
+
const stops = [
|
|
501
|
+
{ offset: 0, color: withAlpha(base, leftAlpha) },
|
|
502
|
+
{ offset: 0.5, color: withAlpha(base, leftAlpha) },
|
|
503
|
+
{ offset: 0.5, color: withAlpha(base, rightAlpha) },
|
|
504
|
+
{ offset: 1, color: withAlpha(base, rightAlpha) },
|
|
505
|
+
];
|
|
506
|
+
// Split across the cross-axis: horizontal (0→1 in x) for vertical bars, and
|
|
507
|
+
// vertical (0→1 in y) for horizontal bars, so it always reads across the bar.
|
|
508
|
+
return isHorizontal
|
|
509
|
+
? new echarts.graphic.LinearGradient(0, 0, 0, 1, stops)
|
|
510
|
+
: new echarts.graphic.LinearGradient(1, 0, 0, 0, stops);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// The `stripped` variant: a small BRIGHT cap sitting on top of a dimmed (20%) body
|
|
514
|
+
// — the canvas twin of Recharts' fixed strip. The cap is baked into a per-datum
|
|
515
|
+
// vertical gradient whose bright band spans exactly `capFraction` of the bar
|
|
516
|
+
// (offset 0 = the tip). Because `capFraction` is passed in as
|
|
517
|
+
// STRIPPED_CAP_HEIGHT / barPixelHeight (see strippedCapFraction), the cap reads the
|
|
518
|
+
// SAME pixel height on every bar — the fraction shrinks as the bar grows. A hard
|
|
519
|
+
// two-stop edge (coincident offsets at `capFraction`) keeps the cap a crisp pill
|
|
520
|
+
// rather than a fade, and the bar's rounded top corners round the cap's top. The
|
|
521
|
+
// cap sits at the tip: the top for vertical bars, the value end (right) for
|
|
522
|
+
// horizontal.
|
|
523
|
+
function strippedDatumPaint(
|
|
524
|
+
slots: string[],
|
|
525
|
+
isHorizontal: boolean,
|
|
526
|
+
capFraction: number,
|
|
527
|
+
): echarts.graphic.LinearGradient {
|
|
528
|
+
const f = Math.min(Math.max(capFraction, 0), 1);
|
|
529
|
+
const cap = withAlpha(sampleGradient(slots, 0), 1);
|
|
530
|
+
const bodyTop = withAlpha(sampleGradient(slots, f), STRIPPED_BODY_ALPHA);
|
|
531
|
+
const bodyEnd = withAlpha(sampleGradient(slots, 1), STRIPPED_BODY_ALPHA);
|
|
532
|
+
const stops = [
|
|
533
|
+
{ offset: 0, color: cap },
|
|
534
|
+
{ offset: f, color: cap },
|
|
535
|
+
{ offset: f, color: bodyTop },
|
|
536
|
+
{ offset: 1, color: bodyEnd },
|
|
537
|
+
];
|
|
538
|
+
// Tip at offset 0: top (y 0→1) for vertical bars, right (x 1→0) for horizontal.
|
|
539
|
+
return isHorizontal
|
|
540
|
+
? new echarts.graphic.LinearGradient(1, 0, 0, 0, stops)
|
|
541
|
+
: new echarts.graphic.LinearGradient(0, 0, 0, 1, stops);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The gradient fraction that renders a STRIPPED_CAP_HEIGHT-pixel cap on a bar whose
|
|
545
|
+
// value-axis magnitude is `value`, given the measured pixels-per-unit. Falls back to
|
|
546
|
+
// a small constant before the coordinate system has been measured (the very first
|
|
547
|
+
// paint, corrected right after layout).
|
|
548
|
+
function strippedCapFraction(value: number, valuePxPerUnit: number | null): number {
|
|
549
|
+
if (valuePxPerUnit == null) return STRIPPED_FALLBACK_FRACTION;
|
|
550
|
+
const barPx = Math.abs(value) * valuePxPerUnit;
|
|
551
|
+
if (!(barPx > 0)) return STRIPPED_FALLBACK_FRACTION;
|
|
552
|
+
return Math.min(STRIPPED_CAP_HEIGHT / barPx, STRIPPED_CAP_MAX_FRACTION);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Pixels per one value-axis unit, read straight off the live coordinate system.
|
|
556
|
+
// Returns null before the first layout (no coordinate system yet) — callers fall
|
|
557
|
+
// back then. Turns the stripped cap's fixed pixel height into a per-bar gradient
|
|
558
|
+
// fraction, so the cap stays constant as the value axis rescales on resize/zoom.
|
|
559
|
+
function measureValuePxPerUnit(chart: EChartsInstance, isHorizontal: boolean): number | null {
|
|
560
|
+
const finder = isHorizontal ? { xAxisIndex: 0 } : { yAxisIndex: 0 };
|
|
561
|
+
// convertToPixel throws before the first setOption (no coordinate system yet) and
|
|
562
|
+
// whenever the value axis isn't laid out — treat any failure as "not measurable".
|
|
563
|
+
try {
|
|
564
|
+
const p0 = chart.convertToPixel(finder, 0);
|
|
565
|
+
const p1 = chart.convertToPixel(finder, 1);
|
|
566
|
+
if (typeof p0 !== "number" || typeof p1 !== "number") return null;
|
|
567
|
+
const delta = Math.abs(p1 - p0);
|
|
568
|
+
return Number.isFinite(delta) && delta > 0 ? delta : null;
|
|
569
|
+
} catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Measures the rendered width of one bar, so the `blocks` variant can make its
|
|
575
|
+
// segments square (their width IS the bar width, and only layout knows it). The
|
|
576
|
+
// category pitch comes from the axis; the bar occupies that minus the category
|
|
577
|
+
// gap — a px number when the consumer set one, else ECharts' own "20%" default.
|
|
578
|
+
function measureBarWidthPx(
|
|
579
|
+
chart: EChartsInstance,
|
|
580
|
+
isHorizontal: boolean,
|
|
581
|
+
barCategoryGap: number | undefined,
|
|
582
|
+
): number | null {
|
|
583
|
+
const finder = isHorizontal ? { yAxisIndex: 0 } : { xAxisIndex: 0 };
|
|
584
|
+
try {
|
|
585
|
+
const p0 = chart.convertToPixel(finder, 0);
|
|
586
|
+
const p1 = chart.convertToPixel(finder, 1);
|
|
587
|
+
if (typeof p0 !== "number" || typeof p1 !== "number") return null;
|
|
588
|
+
const pitch = Math.abs(p1 - p0);
|
|
589
|
+
if (!Number.isFinite(pitch) || pitch <= 0) return null;
|
|
590
|
+
const width = barCategoryGap != null ? pitch - barCategoryGap : pitch * 0.8;
|
|
591
|
+
return width > 1 ? width : null;
|
|
592
|
+
} catch {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Tiling texture fills tinted with the series' first color. Stripes are drawn
|
|
598
|
+
// STRAIGHT (trivially seamless) and the pattern itself is rotated — zrender
|
|
599
|
+
// applies pattern transforms the same way ECharts decals do. Baking a diagonal
|
|
600
|
+
// into a square tile clips the stroke at the corners, which reads as periodic
|
|
601
|
+
// gaps once tiled. Tiles render at devicePixelRatio and scale back down so the
|
|
602
|
+
// texture stays crisp on retina canvases.
|
|
603
|
+
function patternFill(
|
|
604
|
+
kind: "hatched" | "buffer" | "blocks",
|
|
605
|
+
color: string,
|
|
606
|
+
blockSize = BLOCK_SIZE,
|
|
607
|
+
): ImagePatternObject | null {
|
|
608
|
+
if (typeof document === "undefined") return null;
|
|
609
|
+
const dpr = Math.max(window.devicePixelRatio || 1, 1);
|
|
610
|
+
const canvas = document.createElement("canvas");
|
|
611
|
+
const ctx = canvas.getContext("2d");
|
|
612
|
+
if (!ctx) return null;
|
|
613
|
+
|
|
614
|
+
const size = (width: number, height: number) => {
|
|
615
|
+
canvas.width = width * dpr;
|
|
616
|
+
canvas.height = height * dpr;
|
|
617
|
+
ctx.scale(dpr, dpr);
|
|
618
|
+
};
|
|
619
|
+
const pattern = (rotation = 0): ImagePatternObject => ({
|
|
620
|
+
image: canvas,
|
|
621
|
+
repeat: "repeat",
|
|
622
|
+
rotation,
|
|
623
|
+
scaleX: 1 / dpr,
|
|
624
|
+
scaleY: 1 / dpr,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
if (kind === "blocks") {
|
|
628
|
+
// 1px-wide tile: it repeats horizontally into a full-width band, and
|
|
629
|
+
// vertically into the stack of blocks.
|
|
630
|
+
size(1, blockSize + BLOCK_GAP);
|
|
631
|
+
ctx.fillStyle = withAlpha(color, 1);
|
|
632
|
+
ctx.fillRect(0, 0, 1, blockSize);
|
|
633
|
+
return pattern();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (kind === "hatched") {
|
|
637
|
+
// Recharts hatched: the color shown at 0.3 everywhere, punched to full along
|
|
638
|
+
// a 1.5px stripe every 5px, leaning -45°.
|
|
639
|
+
size(5, 5);
|
|
640
|
+
ctx.fillStyle = withAlpha(color, 0.3);
|
|
641
|
+
ctx.fillRect(0, 0, 5, 5);
|
|
642
|
+
ctx.fillStyle = withAlpha(color, 1);
|
|
643
|
+
ctx.fillRect(0, 0, 1.5, 5);
|
|
644
|
+
return pattern(-Math.PI / 4);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// buffer: bare diagonal lines on a transparent ground (no body fill), for the
|
|
648
|
+
// last "projected" bar.
|
|
649
|
+
size(5, 5);
|
|
650
|
+
ctx.fillStyle = withAlpha(color, 1);
|
|
651
|
+
ctx.fillRect(0, 0, 1, 5);
|
|
652
|
+
return pattern(-Math.PI / 4);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The `expandable` fill at a given openness: a horizontal gradient with HARD
|
|
656
|
+
// stops, transparent outside the centre strip and the series paint inside it.
|
|
657
|
+
// Animating `fraction` slides those stops outward from the middle, which is the
|
|
658
|
+
// expand; a real width change would relayout the bar group instead.
|
|
659
|
+
function expandableDatumPaint(slots: string[], fraction: number): echarts.graphic.LinearGradient {
|
|
660
|
+
const base = slots[0] ?? GRAY;
|
|
661
|
+
const half = Math.max(0, Math.min(1, fraction)) / 2;
|
|
662
|
+
const left = 0.5 - half;
|
|
663
|
+
const right = 0.5 + half;
|
|
664
|
+
const clear = withAlpha(base, 0);
|
|
665
|
+
return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
|
666
|
+
{ offset: 0, color: clear },
|
|
667
|
+
{ offset: left, color: clear },
|
|
668
|
+
{ offset: left, color: base },
|
|
669
|
+
{ offset: right, color: base },
|
|
670
|
+
{ offset: right, color: clear },
|
|
671
|
+
{ offset: 1, color: clear },
|
|
672
|
+
]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Resolves a bar variant into an ECharts fill for its series. `base` is the
|
|
676
|
+
// first color slot; `slots` the full vertical color run.
|
|
677
|
+
function barFillPaint(
|
|
678
|
+
variant: BarVariant,
|
|
679
|
+
slots: string[],
|
|
680
|
+
isHorizontal: boolean,
|
|
681
|
+
blockSize = BLOCK_SIZE,
|
|
682
|
+
): string | echarts.graphic.LinearGradient | ImagePatternObject {
|
|
683
|
+
const base = slots[0] ?? GRAY;
|
|
684
|
+
switch (variant) {
|
|
685
|
+
case "gradient":
|
|
686
|
+
return verticalFadePaint(slots);
|
|
687
|
+
case "duotone":
|
|
688
|
+
return duotoneSplitPaint(base, 0.4, 1, isHorizontal);
|
|
689
|
+
case "duotone-reverse":
|
|
690
|
+
return duotoneSplitPaint(base, 1, 0.4, isHorizontal);
|
|
691
|
+
case "hatched":
|
|
692
|
+
return patternFill("hatched", base) ?? solidVerticalPaint(slots, 1);
|
|
693
|
+
case "blocks":
|
|
694
|
+
return patternFill("blocks", base, blockSize) ?? solidVerticalPaint(slots, 1);
|
|
695
|
+
case "expandable":
|
|
696
|
+
// Series-level fallback only; buildBarSeries gives every datum its own
|
|
697
|
+
// openness so a single hovered bar can expand on its own.
|
|
698
|
+
return expandableDatumPaint(slots, EXPAND_COLLAPSED);
|
|
699
|
+
case "stripped":
|
|
700
|
+
// Series-level fallback only; buildBarSeries overrides every stripped datum
|
|
701
|
+
// with its own fixed-pixel cap fraction.
|
|
702
|
+
return strippedDatumPaint(slots, isHorizontal, STRIPPED_FALLBACK_FRACTION);
|
|
703
|
+
default:
|
|
704
|
+
return solidVerticalPaint(slots, 1);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Border radius per variant/layout. Non-stripped bars round every corner
|
|
709
|
+
// (Recharts passes a plain number); stripped rounds only the tip corners — the
|
|
710
|
+
// top for vertical bars, the right end for horizontal.
|
|
711
|
+
function barBorderRadius(
|
|
712
|
+
radius: number,
|
|
713
|
+
variant: BarVariant,
|
|
714
|
+
isHorizontal: boolean,
|
|
715
|
+
): number | number[] {
|
|
716
|
+
// Blocks draw their own square segments; a radius would clip the end one. An
|
|
717
|
+
// expandable bar is a thin line at rest, where a radius would swallow it.
|
|
718
|
+
if (variant === "blocks" || variant === "expandable") return 0;
|
|
719
|
+
if (variant !== "stripped") return radius;
|
|
720
|
+
// ECharts corner order: [top-left, top-right, bottom-right, bottom-left].
|
|
721
|
+
return isHorizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
725
|
+
// Selection + entrance helpers
|
|
726
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
// A bar dims to SELECTION_DIM only when a DIFFERENT series is selected.
|
|
729
|
+
function selectionOpacity(selected: string | null, key: string): number {
|
|
730
|
+
return selected === null || selected === key ? 1 : SELECTION_DIM;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// How many stagger steps a bar at `index` waits before it grows in — the order
|
|
734
|
+
// encoded by `animationType`. Bars are independent rectangles, so unlike the
|
|
735
|
+
// area chart's single left-to-right clip, the direction values are honored here
|
|
736
|
+
// via a per-datum `animationDelay`.
|
|
737
|
+
function barStaggerDelay(type: BarAnimationType, index: number, count: number): number {
|
|
738
|
+
if (type === "none" || count <= 0) return 0;
|
|
739
|
+
const last = count - 1;
|
|
740
|
+
const center = last / 2;
|
|
741
|
+
let step: number;
|
|
742
|
+
switch (type) {
|
|
743
|
+
case "right-to-left":
|
|
744
|
+
step = last - index;
|
|
745
|
+
break;
|
|
746
|
+
case "center-out":
|
|
747
|
+
step = Math.abs(index - center);
|
|
748
|
+
break;
|
|
749
|
+
case "edges-in":
|
|
750
|
+
step = center - Math.abs(index - center);
|
|
751
|
+
break;
|
|
752
|
+
default: // left-to-right
|
|
753
|
+
step = index;
|
|
754
|
+
}
|
|
755
|
+
return step * BAR_STAGGER;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// The brush overlay primitives (BrushRange, BrushGeometry, BrushOverlayElements,
|
|
759
|
+
// syncBrushOverlay) and the dataZoom builder (buildBrushDataZoom) now live in
|
|
760
|
+
// ./echarts-brush and are imported at the top of this file. The
|
|
761
|
+
// tooltip shell/row/styling (roundnessClass, tooltipVariantClass,
|
|
762
|
+
// tooltipShell/tooltipRow/tooltipIndicatorHtml), the legend indicators
|
|
763
|
+
// (LegendIndicator/LegendOverlay + fill/outline styles), and indicatorBackground
|
|
764
|
+
// likewise live in the shared tooltip/legend/chart modules.
|
|
765
|
+
|
|
766
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
767
|
+
// Option builders — pure functions from a snapshot context to ECharts option
|
|
768
|
+
// fragments. The component reads its refs ONCE per build into this context;
|
|
769
|
+
// nothing below touches React state or the chart instance, so each fragment can
|
|
770
|
+
// be reasoned about in isolation.
|
|
771
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
type OptionBuildContext = {
|
|
774
|
+
data: Record<string, unknown>[];
|
|
775
|
+
config: ChartConfig;
|
|
776
|
+
bars: BarSeriesConfig[];
|
|
777
|
+
seriesKeys: string[];
|
|
778
|
+
animationType: BarAnimationType;
|
|
779
|
+
barRadius: number;
|
|
780
|
+
isHorizontal: boolean;
|
|
781
|
+
isStacked: boolean;
|
|
782
|
+
isPercent: boolean;
|
|
783
|
+
selectedDataKey: string | null;
|
|
784
|
+
hasSelection: boolean;
|
|
785
|
+
showGrid: boolean;
|
|
786
|
+
// Category axis is x (vertical layout) or y (horizontal); value axis the other.
|
|
787
|
+
categorySlot: AxisSlot;
|
|
788
|
+
valueSlot: AxisSlot;
|
|
789
|
+
tooltipSlot: TooltipSlot;
|
|
790
|
+
legendSlot: LegendSlot;
|
|
791
|
+
isLoading: boolean;
|
|
792
|
+
loadingData: () => number[];
|
|
793
|
+
showBrush: boolean;
|
|
794
|
+
brushHeight: number;
|
|
795
|
+
barGap?: number;
|
|
796
|
+
barCategoryGap?: number;
|
|
797
|
+
resolved: ResolvedColors;
|
|
798
|
+
categories: string[];
|
|
799
|
+
brushRange: BrushRange; // zoom window carried through rebuilds
|
|
800
|
+
valuePxPerUnit: number | null; // measured value-axis pixels-per-unit (null pre-layout)
|
|
801
|
+
barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares (null pre-layout)
|
|
802
|
+
// Openness per bar index for the expandable variant, plus which one the pointer
|
|
803
|
+
// is on — driven by the hover rAF, read at build.
|
|
804
|
+
expand: { key: string | null; hovered: number | null; progress: Map<number, number> };
|
|
805
|
+
maxHighlightIndex: number | null; // column to keep colored under enableMaxValueHighlight
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// Grid insets plus the footer band reserved for the brush. ECharts 6 contains
|
|
809
|
+
// axis labels automatically (the legacy `containLabel` flag now only triggers a
|
|
810
|
+
// deprecation warning).
|
|
811
|
+
function buildChartLayout({
|
|
812
|
+
legendSlot,
|
|
813
|
+
showBrush,
|
|
814
|
+
brushHeight,
|
|
815
|
+
isHorizontal,
|
|
816
|
+
categorySlot,
|
|
817
|
+
valueSlot,
|
|
818
|
+
}: OptionBuildContext): {
|
|
819
|
+
grid: GridComponentOption;
|
|
820
|
+
brushBottom: number;
|
|
821
|
+
} {
|
|
822
|
+
const legendTop = legendSlot.present && legendSlot.verticalAlign === "top";
|
|
823
|
+
const legendBottom = legendSlot.present && legendSlot.verticalAlign === "bottom";
|
|
824
|
+
// Clearance covers the axis labels plus the same breathing room the Recharts
|
|
825
|
+
// twin leaves between them and the brush. A bottom-axis TITLE renders below the
|
|
826
|
+
// labels (nameGap), so it needs its own band above the brush frame. The brush
|
|
827
|
+
// is vertical-layout only, where the bottom (x-position) axis is the category axis.
|
|
828
|
+
const bottomAxisLabel = isHorizontal ? valueSlot.label : categorySlot.label;
|
|
829
|
+
const brushGap = showBrush ? brushHeight + 30 + (bottomAxisLabel ? 22 : 0) : 0;
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
grid: {
|
|
833
|
+
left: 8,
|
|
834
|
+
right: 8,
|
|
835
|
+
top: legendTop ? 42 : 16,
|
|
836
|
+
bottom: 8 + brushGap + (legendBottom ? 34 : 0),
|
|
837
|
+
},
|
|
838
|
+
brushBottom: legendBottom ? 34 : 6,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// The category + value axes, laid onto x/y per layout. Vertical bars → x is
|
|
843
|
+
// category, y is value; horizontal bars → x is value, y is category.
|
|
844
|
+
function buildMainAxes(ctx: OptionBuildContext): { xAxis: XAxisOption; yAxis: YAxisOption } {
|
|
845
|
+
const {
|
|
846
|
+
isHorizontal,
|
|
847
|
+
showGrid,
|
|
848
|
+
isLoading,
|
|
849
|
+
isPercent,
|
|
850
|
+
categories,
|
|
851
|
+
loadingData,
|
|
852
|
+
categorySlot,
|
|
853
|
+
valueSlot,
|
|
854
|
+
} = ctx;
|
|
855
|
+
const { tokens } = ctx.resolved;
|
|
856
|
+
|
|
857
|
+
const axisLabelColor = tokens.mutedForeground;
|
|
858
|
+
const splitLineColor = withAlpha(tokens.border, GRID_LINE_OPACITY);
|
|
859
|
+
// Gridline gray as an opaque color — see flattenColor.
|
|
860
|
+
const tickDotColor = flattenColor(splitLineColor, tokens.background);
|
|
861
|
+
const catData = isLoading ? loadingData().map((_, i) => i) : categories;
|
|
862
|
+
const catFormatter = categorySlot.tickFormatter;
|
|
863
|
+
const valFormatter = valueSlot.tickFormatter;
|
|
864
|
+
|
|
865
|
+
// The axis title (name) follows the axis PART, not its category/value role: the
|
|
866
|
+
// category axis wears whichever <XAxis>/<YAxis> child renders it per layout, and
|
|
867
|
+
// its label sits at that child's physical position. nameGap is 30 for the bottom
|
|
868
|
+
// (x-position) axis and 38 for the side (y-position) one, so it swaps with the
|
|
869
|
+
// layout alongside the axisLabel styling.
|
|
870
|
+
const categoryNameGap = isHorizontal ? 38 : 30;
|
|
871
|
+
const valueNameGap = isHorizontal ? 30 : 38;
|
|
872
|
+
|
|
873
|
+
// NOTE: these are left un-annotated so their inferred literal type stays free
|
|
874
|
+
// of an axis-specific `position` — the layout swap below assigns the category
|
|
875
|
+
// axis to y (and the value axis to x) for horizontal bars, and XAxisOption vs
|
|
876
|
+
// YAxisOption disagree on `position`, so a fixed annotation would reject one
|
|
877
|
+
// branch. `type` is pinned with `as const` to satisfy the axis-kind union.
|
|
878
|
+
const categoryAxis = {
|
|
879
|
+
type: "category" as const,
|
|
880
|
+
// Bars sit BETWEEN ticks — the opposite of the area chart's boundaryGap:false.
|
|
881
|
+
boundaryGap: true,
|
|
882
|
+
show: true,
|
|
883
|
+
// The Recharts YAxis lists its first category at the TOP; ECharts' y category
|
|
884
|
+
// axis defaults to bottom-up, so flip it when the category axis is on y.
|
|
885
|
+
inverse: isHorizontal,
|
|
886
|
+
data: catData,
|
|
887
|
+
// Axis title — same size/color as the tick labels, pushed clear of them. The
|
|
888
|
+
// category axis carries the label of whichever <XAxis>/<YAxis> child renders it.
|
|
889
|
+
name: isLoading ? undefined : categorySlot.label,
|
|
890
|
+
nameLocation: "middle" as const,
|
|
891
|
+
nameGap: categoryNameGap,
|
|
892
|
+
nameTextStyle: { color: axisLabelColor, fontSize: 10 },
|
|
893
|
+
axisLine: { show: false },
|
|
894
|
+
// Tick DOTS: a near-zero-length tick whose round caps form a true circle, in
|
|
895
|
+
// the gridline gray (flattened opaque so the caps don't stack).
|
|
896
|
+
axisTick: {
|
|
897
|
+
show: !isLoading && categorySlot.present && !categorySlot.hideDots,
|
|
898
|
+
length: 0.5,
|
|
899
|
+
// Bars use boundaryGap, so ECharts would drop each tick on the BOUNDARY
|
|
900
|
+
// between two categories — a dot floating between labels rather than under
|
|
901
|
+
// one. Align them to the labels instead.
|
|
902
|
+
alignWithLabel: true,
|
|
903
|
+
lineStyle: { color: tickDotColor, width: 3, cap: "round" as const },
|
|
904
|
+
},
|
|
905
|
+
splitLine: { show: false },
|
|
906
|
+
axisLabel: {
|
|
907
|
+
show: !isLoading && categorySlot.present,
|
|
908
|
+
color: axisLabelColor,
|
|
909
|
+
fontSize: 10,
|
|
910
|
+
margin: 8,
|
|
911
|
+
formatter: catFormatter
|
|
912
|
+
? (value: string, index: number) => catFormatter(value, index)
|
|
913
|
+
: undefined,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// An ECharts axis with `show: false` hides its splitLines too, but Recharts'
|
|
918
|
+
// <CartesianGrid> draws with or without a visible value axis. Keep the axis on
|
|
919
|
+
// whenever <Grid/> is present and gate the LABELS on the slot instead.
|
|
920
|
+
const valueAxis = {
|
|
921
|
+
type: "value" as const,
|
|
922
|
+
show: valueSlot.present || showGrid,
|
|
923
|
+
max: isPercent ? 1 : undefined,
|
|
924
|
+
// Axis title — same styling as the category axis; the value axis carries the
|
|
925
|
+
// label of the other of the two <XAxis>/<YAxis> children.
|
|
926
|
+
name: isLoading ? undefined : valueSlot.label,
|
|
927
|
+
nameLocation: "middle" as const,
|
|
928
|
+
nameGap: valueNameGap,
|
|
929
|
+
nameTextStyle: { color: axisLabelColor, fontSize: 10 },
|
|
930
|
+
axisLine: { show: false },
|
|
931
|
+
// Same tick dots as the category axis, beside each value label.
|
|
932
|
+
axisTick: {
|
|
933
|
+
show: valueSlot.present && !isLoading && !valueSlot.hideDots,
|
|
934
|
+
length: 0.5,
|
|
935
|
+
// Inert here — ECharts only honors it for CATEGORY ticks, and this axis is
|
|
936
|
+
// always type:"value". Carried so both axes' tick config stays identical.
|
|
937
|
+
lineStyle: { color: tickDotColor, width: 3, cap: "round" as const },
|
|
938
|
+
},
|
|
939
|
+
splitLine: {
|
|
940
|
+
// Hidden while loading — the skeleton floats on a clean canvas.
|
|
941
|
+
show: showGrid && !isLoading,
|
|
942
|
+
lineStyle: { color: splitLineColor, type: [3, 3] as [number, number], width: 1 },
|
|
943
|
+
},
|
|
944
|
+
axisLabel: {
|
|
945
|
+
// Hidden while loading — skeleton values are meaningless, and the Recharts
|
|
946
|
+
// axes unmount during loading too.
|
|
947
|
+
show: valueSlot.present && !isLoading,
|
|
948
|
+
color: axisLabelColor,
|
|
949
|
+
fontSize: 10,
|
|
950
|
+
margin: 8,
|
|
951
|
+
formatter: isPercent
|
|
952
|
+
? (value: number) => `${Math.round(value * 100)}%`
|
|
953
|
+
: valFormatter
|
|
954
|
+
? (value: number, index: number) => valFormatter(String(value), index)
|
|
955
|
+
: undefined,
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
return isHorizontal
|
|
960
|
+
? { xAxis: valueAxis, yAxis: categoryAxis }
|
|
961
|
+
: { xAxis: categoryAxis, yAxis: valueAxis };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Tooltip HTML builder, closed over the build context. Dims by the click
|
|
965
|
+
// selection only — the Recharts twin passes `cursor={false}`, so there is no
|
|
966
|
+
// axis-pointer line and hover-highlight never touches the tooltip.
|
|
967
|
+
function createTooltipFormatter(ctx: OptionBuildContext) {
|
|
968
|
+
const { config, selectedDataKey, tooltipSlot } = ctx;
|
|
969
|
+
|
|
970
|
+
return (params: unknown): string => {
|
|
971
|
+
const rows = Array.isArray(params) ? params : [params];
|
|
972
|
+
if (!rows.length) return "";
|
|
973
|
+
|
|
974
|
+
const first = rows[0] as { axisValue?: string | number; name?: string };
|
|
975
|
+
// Label shows the RAW axis value — matches ChartTooltipContent (no tick formatter).
|
|
976
|
+
const axisValue = first.axisValue ?? first.name ?? "";
|
|
977
|
+
const label = String(axisValue);
|
|
978
|
+
|
|
979
|
+
const body = rows
|
|
980
|
+
.map((param) => {
|
|
981
|
+
const p = param as {
|
|
982
|
+
seriesId?: string;
|
|
983
|
+
seriesName?: string;
|
|
984
|
+
value?: number | string;
|
|
985
|
+
};
|
|
986
|
+
// Internal series (the brush's mini chart, the loading skeleton) never
|
|
987
|
+
// surface in the tooltip.
|
|
988
|
+
if (String(p.seriesId ?? "").startsWith("__")) return "";
|
|
989
|
+
const key = p.seriesId ?? p.seriesName ?? "";
|
|
990
|
+
const item = config[key];
|
|
991
|
+
const colorsCount = item ? getColorsCount(item) : 1;
|
|
992
|
+
const labelText = typeof item?.label === "string" ? item.label : (p.seriesName ?? key);
|
|
993
|
+
const dimmed = selectedDataKey != null && selectedDataKey !== key ? " opacity-30" : "";
|
|
994
|
+
const value =
|
|
995
|
+
typeof p.value === "number" ? p.value.toLocaleString() : String(p.value ?? "");
|
|
996
|
+
|
|
997
|
+
return tooltipRow({
|
|
998
|
+
indicatorHtml: tooltipIndicatorHtml(key, colorsCount),
|
|
999
|
+
labelText,
|
|
1000
|
+
valueText: value,
|
|
1001
|
+
dimmed,
|
|
1002
|
+
});
|
|
1003
|
+
})
|
|
1004
|
+
.join("");
|
|
1005
|
+
|
|
1006
|
+
return tooltipShell({
|
|
1007
|
+
label,
|
|
1008
|
+
body,
|
|
1009
|
+
roundness: tooltipSlot.roundness,
|
|
1010
|
+
variant: tooltipSlot.variant,
|
|
1011
|
+
});
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function buildTooltipOption(ctx: OptionBuildContext): TooltipComponentOption {
|
|
1016
|
+
const { tooltipSlot, isLoading } = ctx;
|
|
1017
|
+
const { tokens } = ctx.resolved;
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
...tooltipBaseOption({
|
|
1021
|
+
present: tooltipSlot.present && !isLoading,
|
|
1022
|
+
// The twin disables the cursor (`cursor={false}`) — no shadow, no line —
|
|
1023
|
+
// so the axisPointer color/width below go unused; only `position` applies.
|
|
1024
|
+
cursor: false,
|
|
1025
|
+
tokens,
|
|
1026
|
+
position: tooltipSlot.position,
|
|
1027
|
+
axisPointerColor: tokens.border,
|
|
1028
|
+
strokeWidth: STROKE_WIDTH,
|
|
1029
|
+
}),
|
|
1030
|
+
formatter: createTooltipFormatter(ctx),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── Brush — the evil-brush "bar" look, canvas-style: a real mini chart of the
|
|
1035
|
+
// full data in a second grid, with a transparent slider dataZoom laid over it.
|
|
1036
|
+
// Both zoom entries target only the MAIN x-axis, so the mini chart never filters
|
|
1037
|
+
// itself. Only called for the vertical layout, where the category axis is x.
|
|
1038
|
+
function buildBrushOption(
|
|
1039
|
+
ctx: OptionBuildContext,
|
|
1040
|
+
brushBottom: number,
|
|
1041
|
+
): {
|
|
1042
|
+
miniGrid: GridComponentOption;
|
|
1043
|
+
miniXAxis: XAxisOption;
|
|
1044
|
+
miniYAxis: YAxisOption;
|
|
1045
|
+
miniSeries: BarSeriesOption[];
|
|
1046
|
+
dataZoom: DataZoomComponentOption[];
|
|
1047
|
+
} {
|
|
1048
|
+
const { data, bars, isStacked, selectedDataKey, hasSelection, brushHeight, categories } = ctx;
|
|
1049
|
+
const { tokens } = ctx.resolved;
|
|
1050
|
+
|
|
1051
|
+
const miniGrid: GridComponentOption = {
|
|
1052
|
+
left: 8,
|
|
1053
|
+
right: 8,
|
|
1054
|
+
bottom: brushBottom,
|
|
1055
|
+
height: brushHeight,
|
|
1056
|
+
// No visible axes here — opt out of label containment so the mini chart
|
|
1057
|
+
// spans the full brush frame.
|
|
1058
|
+
outerBoundsMode: "none",
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
const miniXAxis: XAxisOption = {
|
|
1062
|
+
type: "category",
|
|
1063
|
+
gridIndex: 1,
|
|
1064
|
+
boundaryGap: true,
|
|
1065
|
+
show: false,
|
|
1066
|
+
data: categories,
|
|
1067
|
+
axisPointer: { show: false },
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
const miniYAxis: YAxisOption = { type: "value", gridIndex: 1, show: false };
|
|
1071
|
+
|
|
1072
|
+
const miniSeries: BarSeriesOption[] = bars.map((bar) => {
|
|
1073
|
+
const key = bar.dataKey;
|
|
1074
|
+
const base = (ctx.resolved.series[key] ?? [])[0] ?? GRAY;
|
|
1075
|
+
// The mini chart mirrors the click selection: unselected series recede.
|
|
1076
|
+
const dim = hasSelection && selectedDataKey !== key ? SELECTION_DIM : 1;
|
|
1077
|
+
|
|
1078
|
+
return {
|
|
1079
|
+
id: `__mini-${key}`,
|
|
1080
|
+
type: "bar",
|
|
1081
|
+
xAxisIndex: 1,
|
|
1082
|
+
yAxisIndex: 1,
|
|
1083
|
+
data: data.map((row) => Number(row[key]) || 0),
|
|
1084
|
+
stack: isStacked ? "__mini-total" : undefined,
|
|
1085
|
+
silent: true,
|
|
1086
|
+
barCategoryGap: "20%",
|
|
1087
|
+
emphasis: { disabled: true },
|
|
1088
|
+
tooltip: { show: false },
|
|
1089
|
+
itemStyle: { color: base, opacity: BRUSH_FILL_OPACITY * dim, borderRadius: 1 },
|
|
1090
|
+
z: 0,
|
|
1091
|
+
animation: false,
|
|
1092
|
+
};
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
const dataZoom = buildBrushDataZoom({
|
|
1096
|
+
brushBottom,
|
|
1097
|
+
brushHeight,
|
|
1098
|
+
brushRange: ctx.brushRange,
|
|
1099
|
+
fillerColor: withAlpha(tokens.foreground, BRUSH_FILLER_OPACITY),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
return { miniGrid, miniXAxis, miniYAxis, miniSeries, dataZoom };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Loading skeleton — ONE gray row of bars regardless of declared series (Recharts
|
|
1106
|
+
// parity: its skeleton is a single LoadingBar), swept by the shimmer rAF.
|
|
1107
|
+
function buildLoadingOption(
|
|
1108
|
+
ctx: OptionBuildContext,
|
|
1109
|
+
frame: { grid: GridComponentOption; xAxis: XAxisOption; yAxis: YAxisOption },
|
|
1110
|
+
): EChartsOption {
|
|
1111
|
+
const { tokens } = ctx.resolved;
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
animation: false,
|
|
1115
|
+
grid: frame.grid,
|
|
1116
|
+
xAxis: frame.xAxis,
|
|
1117
|
+
yAxis: frame.yAxis,
|
|
1118
|
+
tooltip: { show: false },
|
|
1119
|
+
series: [
|
|
1120
|
+
{
|
|
1121
|
+
id: "__loading",
|
|
1122
|
+
type: "bar",
|
|
1123
|
+
data: ctx.loadingData(),
|
|
1124
|
+
barCategoryGap: "30%",
|
|
1125
|
+
silent: true,
|
|
1126
|
+
// Invisible until the first shimmer tick positions the clip window.
|
|
1127
|
+
itemStyle: {
|
|
1128
|
+
color: withAlpha(tokens.foreground, 0),
|
|
1129
|
+
borderRadius: barBorderRadius(DEFAULT_BAR_RADIUS, "default", ctx.isHorizontal),
|
|
1130
|
+
},
|
|
1131
|
+
z: 1,
|
|
1132
|
+
},
|
|
1133
|
+
],
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function buildBarSeries(ctx: OptionBuildContext): BarSeriesOption[] {
|
|
1138
|
+
const {
|
|
1139
|
+
data,
|
|
1140
|
+
config,
|
|
1141
|
+
bars,
|
|
1142
|
+
seriesKeys,
|
|
1143
|
+
animationType,
|
|
1144
|
+
isHorizontal,
|
|
1145
|
+
isStacked,
|
|
1146
|
+
isPercent,
|
|
1147
|
+
selectedDataKey,
|
|
1148
|
+
hasSelection,
|
|
1149
|
+
barGap,
|
|
1150
|
+
barCategoryGap,
|
|
1151
|
+
resolved,
|
|
1152
|
+
} = ctx;
|
|
1153
|
+
|
|
1154
|
+
const lastIndex = data.length - 1;
|
|
1155
|
+
|
|
1156
|
+
// Optional per-row normalization for the percent (100%) stack.
|
|
1157
|
+
const rowTotals = isPercent
|
|
1158
|
+
? data.map((row) => seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0))
|
|
1159
|
+
: [];
|
|
1160
|
+
|
|
1161
|
+
const series: BarSeriesOption[] = bars.map((bar) => {
|
|
1162
|
+
const key = bar.dataKey;
|
|
1163
|
+
const slots = resolved.series[key] ?? [GRAY];
|
|
1164
|
+
const base = slots[0] ?? GRAY;
|
|
1165
|
+
const isSelected = selectedDataKey === key;
|
|
1166
|
+
const dim = selectionOpacity(selectedDataKey, key);
|
|
1167
|
+
const resolvedRadius = bar.radius ?? ctx.barRadius;
|
|
1168
|
+
const borderRadius = barBorderRadius(resolvedRadius, bar.variant, isHorizontal);
|
|
1169
|
+
// Square segments: the tile's height matches the measured bar width, so each
|
|
1170
|
+
// block is 1:1. Falls back to BLOCK_SIZE on the first push, before layout.
|
|
1171
|
+
const blockSize = ctx.barWidthPx ?? BLOCK_SIZE;
|
|
1172
|
+
const fill = barFillPaint(bar.variant, slots, isHorizontal, blockSize);
|
|
1173
|
+
const barAnim = bar.animationType ?? animationType;
|
|
1174
|
+
const isStripped = bar.variant === "stripped";
|
|
1175
|
+
const isExpandable = bar.variant === "expandable";
|
|
1176
|
+
// Under enableMaxValueHighlight every column except the tallest is muted, so a
|
|
1177
|
+
// single flat tone replaces whatever fill the variant would have painted.
|
|
1178
|
+
const mutedFill = withAlpha(resolved.tokens.mutedForeground, MAX_HIGHLIGHT_DIM);
|
|
1179
|
+
const isMuted = (i: number) => ctx.maxHighlightIndex != null && i !== ctx.maxHighlightIndex;
|
|
1180
|
+
|
|
1181
|
+
// Openness per datum, driven by the hover rAF. Bars not in the map are shut.
|
|
1182
|
+
const expandOf = (i: number) =>
|
|
1183
|
+
ctx.expand.key === key ? (ctx.expand.progress.get(i) ?? EXPAND_COLLAPSED) : EXPAND_COLLAPSED;
|
|
1184
|
+
const expandHovered = ctx.expand.key === key ? ctx.expand.hovered : null;
|
|
1185
|
+
// The unfilled part of a blocks bar: the same tile in a muted tone, drawn by
|
|
1186
|
+
// ECharts' own bar background so it spans the column's full height.
|
|
1187
|
+
const isBlocks = bar.variant === "blocks";
|
|
1188
|
+
const blockTrack = isBlocks
|
|
1189
|
+
? patternFill(
|
|
1190
|
+
"blocks",
|
|
1191
|
+
withAlpha(resolved.tokens.mutedForeground, BLOCK_TRACK_OPACITY),
|
|
1192
|
+
blockSize,
|
|
1193
|
+
)
|
|
1194
|
+
: null;
|
|
1195
|
+
|
|
1196
|
+
const values = data.map((row, i) => {
|
|
1197
|
+
const value = Number(row[key]) || 0;
|
|
1198
|
+
if (!isPercent) return value;
|
|
1199
|
+
const total = rowTotals[i];
|
|
1200
|
+
return total ? value / total : 0;
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// Buffer bar: the last datum becomes a bare hatched rectangle with a
|
|
1204
|
+
// series-colored outline, marking projected/incomplete data.
|
|
1205
|
+
const bufferStyle = bar.bufferBar
|
|
1206
|
+
? {
|
|
1207
|
+
color: patternFill("buffer", base) ?? "transparent",
|
|
1208
|
+
borderColor: base,
|
|
1209
|
+
borderWidth: STROKE_WIDTH,
|
|
1210
|
+
borderRadius,
|
|
1211
|
+
}
|
|
1212
|
+
: null;
|
|
1213
|
+
|
|
1214
|
+
// Per-bar glow shadow — the shadowColor is sampled from the series gradient
|
|
1215
|
+
// at each bar's horizontal position, so a multi-stop series glows in its own
|
|
1216
|
+
// colors across the plot instead of one flat tint (a single shadowColor was
|
|
1217
|
+
// the bug). A canvas shape carries only one shadow, so the sample is per bar,
|
|
1218
|
+
// not within a bar; the wide, soft shadowBlur reads as the Recharts blur's
|
|
1219
|
+
// colored halo with no hard rim.
|
|
1220
|
+
// `glowing` haloes every bar in the series; enableMaxValueHighlight haloes only
|
|
1221
|
+
// the winning column — the muted ones must stay flat or the "one bar stands
|
|
1222
|
+
// out" reading collapses. Same shadow either way, so the two share a builder.
|
|
1223
|
+
const glowAt = (i: number) => ({
|
|
1224
|
+
shadowBlur: GLOW_BLUR,
|
|
1225
|
+
shadowColor: withAlpha(
|
|
1226
|
+
sampleGradient(slots, values.length > 1 ? i / (values.length - 1) : 0),
|
|
1227
|
+
GLOW_OPACITY,
|
|
1228
|
+
),
|
|
1229
|
+
});
|
|
1230
|
+
const glowFor = bar.glowing
|
|
1231
|
+
? glowAt
|
|
1232
|
+
: ctx.maxHighlightIndex != null
|
|
1233
|
+
? (i: number) => (i === ctx.maxHighlightIndex ? glowAt(i) : {})
|
|
1234
|
+
: null;
|
|
1235
|
+
|
|
1236
|
+
// Only wrap a datum in an object when it needs per-point overrides (stripped
|
|
1237
|
+
// cap, buffer tip, or glow); otherwise keep the bare number so the series
|
|
1238
|
+
// itemStyle applies untouched. Stripped and glow touch every datum; buffer only
|
|
1239
|
+
// the last one.
|
|
1240
|
+
const dataPoints =
|
|
1241
|
+
isStripped ||
|
|
1242
|
+
isExpandable ||
|
|
1243
|
+
glowFor ||
|
|
1244
|
+
ctx.maxHighlightIndex != null ||
|
|
1245
|
+
(bufferStyle && lastIndex >= 0)
|
|
1246
|
+
? values.map((value, i) => {
|
|
1247
|
+
const isBuffer = !!bufferStyle && i === lastIndex;
|
|
1248
|
+
if (!isBuffer && !glowFor && !isStripped && !isExpandable && !isMuted(i)) return value;
|
|
1249
|
+
return {
|
|
1250
|
+
value,
|
|
1251
|
+
...(isExpandable ? { label: { show: i === expandHovered } } : {}),
|
|
1252
|
+
itemStyle: {
|
|
1253
|
+
// The stripped cap is per datum: its fixed pixel height becomes a
|
|
1254
|
+
// fraction of THIS bar's own height, so the cap is a constant pixel
|
|
1255
|
+
// height across bars. The buffer tip (bare hatched) still wins on
|
|
1256
|
+
// the last datum.
|
|
1257
|
+
...(isStripped && !isBuffer
|
|
1258
|
+
? {
|
|
1259
|
+
color: strippedDatumPaint(
|
|
1260
|
+
slots,
|
|
1261
|
+
isHorizontal,
|
|
1262
|
+
strippedCapFraction(value, ctx.valuePxPerUnit),
|
|
1263
|
+
),
|
|
1264
|
+
}
|
|
1265
|
+
: {}),
|
|
1266
|
+
...(isExpandable && !isBuffer
|
|
1267
|
+
? { color: expandableDatumPaint(slots, expandOf(i)) }
|
|
1268
|
+
: {}),
|
|
1269
|
+
...(isBuffer && bufferStyle ? bufferStyle : {}),
|
|
1270
|
+
...(glowFor ? glowFor(i) : {}),
|
|
1271
|
+
// Last so it overrides the variant's own paint.
|
|
1272
|
+
...(isMuted(i) ? { color: mutedFill } : {}),
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
})
|
|
1276
|
+
: values;
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
id: key,
|
|
1280
|
+
name: typeof config[key]?.label === "string" ? config[key]?.label : key,
|
|
1281
|
+
type: "bar",
|
|
1282
|
+
data: dataPoints,
|
|
1283
|
+
stack: isStacked ? "total" : undefined,
|
|
1284
|
+
barGap,
|
|
1285
|
+
barCategoryGap,
|
|
1286
|
+
cursor: bar.isClickable ? "pointer" : "default",
|
|
1287
|
+
// Selected series ride on top; when a selection is active the rest sink below.
|
|
1288
|
+
z: isSelected ? 3 : hasSelection ? 1 : 2,
|
|
1289
|
+
// The hovered bar names its value above itself (Recharts twin parity).
|
|
1290
|
+
label: isExpandable
|
|
1291
|
+
? {
|
|
1292
|
+
show: false,
|
|
1293
|
+
position: "top",
|
|
1294
|
+
color: resolved.tokens.foreground,
|
|
1295
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
1296
|
+
fontSize: 11,
|
|
1297
|
+
}
|
|
1298
|
+
: undefined,
|
|
1299
|
+
showBackground: isBlocks,
|
|
1300
|
+
backgroundStyle: blockTrack ? { color: blockTrack, borderRadius } : undefined,
|
|
1301
|
+
itemStyle: {
|
|
1302
|
+
color: fill,
|
|
1303
|
+
borderRadius,
|
|
1304
|
+
opacity: dim,
|
|
1305
|
+
// The glow lives on each datum's itemStyle (per-bar sampled shadowColor),
|
|
1306
|
+
// not here — a single series-level shadowColor can't follow a gradient.
|
|
1307
|
+
},
|
|
1308
|
+
// Hover-highlight uses ECharts-native focus/blur: `self` keeps only the
|
|
1309
|
+
// hovered bar lit and dims every other, matching the twin's per-bar dim.
|
|
1310
|
+
// A click-selection OWNS the dim while it is active, so hover highlighting
|
|
1311
|
+
// switches off entirely whenever a selection exists (this option rebuilds on
|
|
1312
|
+
// every selection change, and the notMerge push clears any live blur) and
|
|
1313
|
+
// resumes once the selection clears. Otherwise emphasis is disabled so
|
|
1314
|
+
// hovering leaves the bar untouched.
|
|
1315
|
+
emphasis:
|
|
1316
|
+
bar.enableHoverHighlight && !hasSelection
|
|
1317
|
+
? { focus: "self" as const, blurScope: "coordinateSystem" as const }
|
|
1318
|
+
: { disabled: true },
|
|
1319
|
+
blur:
|
|
1320
|
+
bar.enableHoverHighlight && !hasSelection
|
|
1321
|
+
? { itemStyle: { opacity: HOVER_BLUR } }
|
|
1322
|
+
: undefined,
|
|
1323
|
+
// The grow-in envelope. Only takes effect on the reveal push (top-level
|
|
1324
|
+
// `animation: true`); every later push sends `animation: false`, so the
|
|
1325
|
+
// per-datum stagger is dormant then.
|
|
1326
|
+
animationDuration: BAR_GROW_DURATION,
|
|
1327
|
+
animationEasing: "cubicOut",
|
|
1328
|
+
animationDelay: (idx: number) => barStaggerDelay(barAnim, idx, data.length),
|
|
1329
|
+
};
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// Stacked segments butt together into one solid column, so part them with a REAL
|
|
1333
|
+
// gap: a transparent series stacked between each adjacent pair. A background
|
|
1334
|
+
// -colored border can't do this — a border paints all four sides, outlining every
|
|
1335
|
+
// segment (glaring the moment a bar glows) instead of only separating them.
|
|
1336
|
+
//
|
|
1337
|
+
// The spacer's value is in DATA units, so it is derived from the measured
|
|
1338
|
+
// pixels-per-unit to keep the gap a constant pixel height whatever the scale.
|
|
1339
|
+
// Before the first layout that measurement is null and the gap is simply skipped;
|
|
1340
|
+
// the push re-applies once it exists, in the same frame (see the sync effect).
|
|
1341
|
+
const gapUnits =
|
|
1342
|
+
(isStacked || isPercent) && series.length > 1 && ctx.valuePxPerUnit
|
|
1343
|
+
? STACK_SEGMENT_GAP / ctx.valuePxPerUnit
|
|
1344
|
+
: 0;
|
|
1345
|
+
if (!gapUnits) return series;
|
|
1346
|
+
|
|
1347
|
+
const spaced: BarSeriesOption[] = [];
|
|
1348
|
+
series.forEach((entry, i) => {
|
|
1349
|
+
spaced.push(entry);
|
|
1350
|
+
if (i === series.length - 1) return;
|
|
1351
|
+
spaced.push({
|
|
1352
|
+
id: `__stackgap-${i}`,
|
|
1353
|
+
type: "bar",
|
|
1354
|
+
stack: isStacked ? "total" : undefined,
|
|
1355
|
+
data: data.map(() => gapUnits),
|
|
1356
|
+
itemStyle: { color: "transparent" },
|
|
1357
|
+
silent: true,
|
|
1358
|
+
tooltip: { show: false },
|
|
1359
|
+
legendHoverLink: false,
|
|
1360
|
+
emphasis: { disabled: true },
|
|
1361
|
+
animation: false,
|
|
1362
|
+
z: 1,
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
return spaced;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1369
|
+
// Live imperative state — everything the ECharts event handlers, rAF loops, and
|
|
1370
|
+
// theme/resize repushes read or write OUTSIDE the React render cycle, grouped in
|
|
1371
|
+
// one ref-stable object. None of it is render output, which is exactly why it is
|
|
1372
|
+
// not React state.
|
|
1373
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1374
|
+
|
|
1375
|
+
type LiveState = {
|
|
1376
|
+
resolved: ResolvedColors | null; // colors read off the live DOM — feeds builds and rAF loops
|
|
1377
|
+
hasRevealed: boolean; // the intro grow-in already played on this chart instance
|
|
1378
|
+
revealEndsAt: number; // performance.now() the entrance settles — gates the stripped-cap correction
|
|
1379
|
+
valuePxPerUnit: number | null; // measured value-axis pixels-per-unit — sizes the stripped cap
|
|
1380
|
+
barWidthPx: number | null; // measured bar width — sizes the blocks variant's squares
|
|
1381
|
+
// Openness per bar index for the expandable variant, plus which one the pointer
|
|
1382
|
+
// is on. Per-index so a bar being left keeps easing shut while the next one
|
|
1383
|
+
// opens — a single shared value made the outgoing bar snap.
|
|
1384
|
+
expand: { key: string | null; hovered: number | null; progress: Map<number, number> };
|
|
1385
|
+
expandRaf: number; // in-flight expand animation frame
|
|
1386
|
+
animateExpand: (key: string | null, index: number | null) => void;
|
|
1387
|
+
loadingRows: number[] | null; // skeleton data, lazily rolled and re-rolled per shimmer sweep
|
|
1388
|
+
categories: string[]; // x labels of the last build, for the brush label pills
|
|
1389
|
+
dataLength: number; // row count, for the datazoom index math
|
|
1390
|
+
brushRange: BrushRange; // live zoom window — carried through every rebuild
|
|
1391
|
+
brushGeom: BrushGeometry | null; // brush footer layout of the last build
|
|
1392
|
+
brushOverlay: BrushOverlayElements | null; // zrender elements, owned by syncBrushOverlay
|
|
1393
|
+
brushHover: { inside: boolean; left: boolean; right: boolean };
|
|
1394
|
+
// Latest callbacks/flags for the imperative ECharts event handlers.
|
|
1395
|
+
handlers: {
|
|
1396
|
+
onBrushChange?: (range: { startIndex: number; endIndex: number }) => void;
|
|
1397
|
+
clickableKeys: Set<string>;
|
|
1398
|
+
brushFormatLabel?: (value: string, index: number) => string;
|
|
1399
|
+
seriesKeys: string[];
|
|
1400
|
+
hasStripped: boolean; // any visible stripped bar → run the post-layout cap correction
|
|
1401
|
+
hasBlocks: boolean; // any blocks bar → re-push once the bar width is measurable
|
|
1402
|
+
hasStackGap: boolean; // stacked with >1 series → the segment gap needs the axis scale
|
|
1403
|
+
expandableKey: string | null; // the expandable series, if any — drives the column hover
|
|
1404
|
+
barCategoryGap?: number; // consumer's category gap, needed to derive the bar width
|
|
1405
|
+
isHorizontal: boolean; // layout, for measuring the value axis in the finished handler
|
|
1406
|
+
};
|
|
1407
|
+
// Update-style re-push for paths that bypass React entirely (theme flips,
|
|
1408
|
+
// resizes) — set by the sync effect.
|
|
1409
|
+
repush: () => void;
|
|
1410
|
+
// Rebuilds ONLY the stripped bar series (fresh per-datum cap fractions) and
|
|
1411
|
+
// merges them with a silent lazyUpdate — never notMerge, so it leaves the
|
|
1412
|
+
// dataZoom drag anchor and the running entrance untouched. Set by the sync effect.
|
|
1413
|
+
patchStrippedCaps: () => void;
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1417
|
+
// Component
|
|
1418
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Apache ECharts port of the EvilCharts bar chart, exposing a compound-as-config
|
|
1422
|
+
* API so its JSX reads identically to the Recharts twin. The root owns the data,
|
|
1423
|
+
* config, selection state, loading skeleton, intro grow-in, and optional zoom
|
|
1424
|
+
* brush; every visual part — `<Bar>`, `<XAxis>`, `<YAxis>`, `<Grid>`,
|
|
1425
|
+
* `<Tooltip>`, `<Legend>` — is composed as a declarative child that renders
|
|
1426
|
+
* nothing. The root walks those children by reference and drives a single
|
|
1427
|
+
* imperative ECharts instance. Fully self-contained: its only dependencies are
|
|
1428
|
+
* `react`, `echarts`, and `motion`.
|
|
1429
|
+
*/
|
|
1430
|
+
export function EChartsBarChart<TData extends Record<string, unknown>>({
|
|
1431
|
+
data,
|
|
1432
|
+
config,
|
|
1433
|
+
renderer = DEFAULT_ECHARTS_RENDERER,
|
|
1434
|
+
xDataKey,
|
|
1435
|
+
className,
|
|
1436
|
+
stackType = "default",
|
|
1437
|
+
layout = "vertical",
|
|
1438
|
+
barRadius = DEFAULT_BAR_RADIUS,
|
|
1439
|
+
animation = true,
|
|
1440
|
+
animationType = "left-to-right",
|
|
1441
|
+
barGap,
|
|
1442
|
+
barCategoryGap,
|
|
1443
|
+
selectedDataKey: selectedDataKeyProp,
|
|
1444
|
+
defaultSelectedDataKey = null,
|
|
1445
|
+
onSelectionChange,
|
|
1446
|
+
enableMaxValueHighlight = false,
|
|
1447
|
+
isLoading = false,
|
|
1448
|
+
loadingBars = LOADING_DEFAULT_BARS,
|
|
1449
|
+
chartOptions,
|
|
1450
|
+
children,
|
|
1451
|
+
}: EChartsBarChartProps<TData>) {
|
|
1452
|
+
const rawId = useId();
|
|
1453
|
+
const chartId = `chart-${rawId.replace(/:/g, "")}`;
|
|
1454
|
+
|
|
1455
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1456
|
+
const mountRef = useRef<HTMLDivElement>(null);
|
|
1457
|
+
const echartsRef = useRef<EChartsInstance | null>(null);
|
|
1458
|
+
|
|
1459
|
+
// The single imperative surface (see LiveState). `resolved` lives here rather
|
|
1460
|
+
// than in state: as state it forced an extra render pass and an effect whose
|
|
1461
|
+
// only job was to trigger the option push. The object identity is stable for
|
|
1462
|
+
// the component's lifetime.
|
|
1463
|
+
const live = useRef<LiveState>({
|
|
1464
|
+
resolved: null,
|
|
1465
|
+
hasRevealed: false,
|
|
1466
|
+
revealEndsAt: 0,
|
|
1467
|
+
valuePxPerUnit: null,
|
|
1468
|
+
barWidthPx: null,
|
|
1469
|
+
expand: { key: null, hovered: null, progress: new Map<number, number>() },
|
|
1470
|
+
expandRaf: 0,
|
|
1471
|
+
animateExpand: () => {},
|
|
1472
|
+
loadingRows: null,
|
|
1473
|
+
categories: [],
|
|
1474
|
+
dataLength: 0,
|
|
1475
|
+
brushRange: { start: 0, end: 100 },
|
|
1476
|
+
brushGeom: null,
|
|
1477
|
+
brushOverlay: null,
|
|
1478
|
+
brushHover: { inside: false, left: false, right: false },
|
|
1479
|
+
handlers: {
|
|
1480
|
+
onBrushChange: undefined, // set per-render from the <Brush> child's onChange
|
|
1481
|
+
clickableKeys: new Set<string>(),
|
|
1482
|
+
brushFormatLabel: undefined, // set per-render from the <Brush> child's formatLabel
|
|
1483
|
+
seriesKeys: [],
|
|
1484
|
+
hasStripped: false,
|
|
1485
|
+
hasBlocks: false,
|
|
1486
|
+
hasStackGap: false,
|
|
1487
|
+
expandableKey: null,
|
|
1488
|
+
isHorizontal: false,
|
|
1489
|
+
},
|
|
1490
|
+
repush: () => {},
|
|
1491
|
+
patchStrippedCaps: () => {},
|
|
1492
|
+
}).current;
|
|
1493
|
+
|
|
1494
|
+
// Skeleton rows roll lazily on first use — an impure useRef initializer would
|
|
1495
|
+
// re-roll Math.random() on every render.
|
|
1496
|
+
const loadingData = useCallback(
|
|
1497
|
+
() => (live.loadingRows ??= getLoadingBarData(loadingBars)),
|
|
1498
|
+
[live, loadingBars],
|
|
1499
|
+
);
|
|
1500
|
+
const shouldReduceMotion = useReducedMotion();
|
|
1501
|
+
|
|
1502
|
+
const [internalSelectedKey, setSelectedDataKey] = useState<string | null>(defaultSelectedDataKey);
|
|
1503
|
+
const selectedDataKey = selectedDataKeyProp !== undefined ? selectedDataKeyProp : internalSelectedKey;
|
|
1504
|
+
|
|
1505
|
+
// ── Declarative config, collected from children by reference ─────────────────
|
|
1506
|
+
const collected = useMemo(() => collectConfig(children), [children]);
|
|
1507
|
+
const {
|
|
1508
|
+
bars,
|
|
1509
|
+
xAxis: xAxisSlot,
|
|
1510
|
+
yAxis: yAxisSlot,
|
|
1511
|
+
showGrid,
|
|
1512
|
+
tooltip: tooltipSlot,
|
|
1513
|
+
legend: legendSlot,
|
|
1514
|
+
brush: brushSlot,
|
|
1515
|
+
} = collected;
|
|
1516
|
+
// Brush is a <Brush> child now (not props): presence turns it on, its props
|
|
1517
|
+
// carry height/formatLabel/onChange.
|
|
1518
|
+
const showBrush = brushSlot.present;
|
|
1519
|
+
const brushHeight = brushSlot.height ?? 56;
|
|
1520
|
+
|
|
1521
|
+
const isHorizontal = layout === "horizontal";
|
|
1522
|
+
const isPercent = stackType === "percent";
|
|
1523
|
+
const isStacked = stackType === "stacked" || isPercent;
|
|
1524
|
+
|
|
1525
|
+
// Category axis is x when vertical, y when horizontal; value axis the other.
|
|
1526
|
+
const categorySlot = isHorizontal ? yAxisSlot : xAxisSlot;
|
|
1527
|
+
const valueSlot = isHorizontal ? xAxisSlot : yAxisSlot;
|
|
1528
|
+
|
|
1529
|
+
const seriesKeys = useMemo(() => bars.map((bar) => bar.dataKey), [bars]);
|
|
1530
|
+
|
|
1531
|
+
// category key: category axis dataKey → root xDataKey → first data column no <Bar> claims.
|
|
1532
|
+
const categoryKey = useMemo(() => {
|
|
1533
|
+
if (categorySlot.dataKey) return categorySlot.dataKey;
|
|
1534
|
+
if (xDataKey) return xDataKey as string;
|
|
1535
|
+
const firstRow = data[0];
|
|
1536
|
+
if (firstRow) {
|
|
1537
|
+
const claimed = new Set(seriesKeys);
|
|
1538
|
+
const found = Object.keys(firstRow).find((key) => !claimed.has(key));
|
|
1539
|
+
if (found) return found;
|
|
1540
|
+
}
|
|
1541
|
+
return "";
|
|
1542
|
+
}, [categorySlot.dataKey, xDataKey, data, seriesKeys]);
|
|
1543
|
+
|
|
1544
|
+
// The intro grow-in follows the first bar's setting, falling back to the root default.
|
|
1545
|
+
const effectiveAnimation = bars[0]?.animationType ?? animationType;
|
|
1546
|
+
|
|
1547
|
+
// The tallest COLUMN, comparing totals across every series so a stack or group
|
|
1548
|
+
// wins together rather than one bar inside it. Null when the flag is off.
|
|
1549
|
+
const maxHighlightIndex = useMemo(() => {
|
|
1550
|
+
if (!enableMaxValueHighlight || !data.length || !seriesKeys.length) return null;
|
|
1551
|
+
let best = 0;
|
|
1552
|
+
let bestTotal = -Infinity;
|
|
1553
|
+
data.forEach((row, i) => {
|
|
1554
|
+
const total = seriesKeys.reduce((sum, key) => sum + (Number(row[key]) || 0), 0);
|
|
1555
|
+
if (total > bestTotal) {
|
|
1556
|
+
bestTotal = total;
|
|
1557
|
+
best = i;
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
return best;
|
|
1561
|
+
}, [enableMaxValueHighlight, data, seriesKeys]);
|
|
1562
|
+
|
|
1563
|
+
const css = useMemo(() => buildChartCss(chartId, config), [chartId, config]);
|
|
1564
|
+
|
|
1565
|
+
const hasSelection = selectedDataKey !== null;
|
|
1566
|
+
|
|
1567
|
+
// Which series may be clicked to toggle selection (consulted by the click handler).
|
|
1568
|
+
const clickableKeys = useMemo(
|
|
1569
|
+
() => new Set(bars.filter((bar) => bar.isClickable).map((bar) => bar.dataKey)),
|
|
1570
|
+
[bars],
|
|
1571
|
+
);
|
|
1572
|
+
|
|
1573
|
+
// Any visible stripped bar? (never while loading — the skeleton has no stripped
|
|
1574
|
+
// series.) Gates the post-layout cap correction in the `finished` handler.
|
|
1575
|
+
const hasStrippedBars = !isLoading && bars.some((bar) => bar.variant === "stripped");
|
|
1576
|
+
|
|
1577
|
+
// Refresh the handlers' snapshot of the latest callbacks/flags every render.
|
|
1578
|
+
live.handlers = {
|
|
1579
|
+
onBrushChange: brushSlot.onChange,
|
|
1580
|
+
clickableKeys,
|
|
1581
|
+
brushFormatLabel: brushSlot.formatLabel,
|
|
1582
|
+
seriesKeys,
|
|
1583
|
+
hasStripped: hasStrippedBars,
|
|
1584
|
+
hasBlocks: bars.some((bar) => bar.variant === "blocks"),
|
|
1585
|
+
hasStackGap: (stackType === "stacked" || stackType === "percent") && bars.length > 1,
|
|
1586
|
+
expandableKey: bars.find((bar) => bar.variant === "expandable")?.dataKey ?? null,
|
|
1587
|
+
barCategoryGap,
|
|
1588
|
+
isHorizontal,
|
|
1589
|
+
};
|
|
1590
|
+
live.dataLength = data.length;
|
|
1591
|
+
|
|
1592
|
+
const toggleSelection = useCallback(
|
|
1593
|
+
(key: string) => {
|
|
1594
|
+
const next = selectedDataKey === key ? null : key;
|
|
1595
|
+
if (selectedDataKeyProp === undefined) setSelectedDataKey(next);
|
|
1596
|
+
onSelectionChange?.(next);
|
|
1597
|
+
},
|
|
1598
|
+
[onSelectionChange, selectedDataKey, selectedDataKeyProp],
|
|
1599
|
+
);
|
|
1600
|
+
|
|
1601
|
+
// The brush is meaningful only when the category axis is on x (vertical layout).
|
|
1602
|
+
const brushEnabled = showBrush && !isHorizontal;
|
|
1603
|
+
|
|
1604
|
+
// Reposition the brush overlays from the live refs — safe to call from drag
|
|
1605
|
+
// events, hover tracking, and pushes alike, since it never touches setOption.
|
|
1606
|
+
const syncBrushOverlayNow = useCallback(() => {
|
|
1607
|
+
const chart = echartsRef.current;
|
|
1608
|
+
if (!chart) return;
|
|
1609
|
+
|
|
1610
|
+
const geom = live.brushGeom;
|
|
1611
|
+
const tokens = live.resolved?.tokens;
|
|
1612
|
+
if (!geom || !tokens) {
|
|
1613
|
+
syncBrushOverlay(chart, live, null);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const range = live.brushRange;
|
|
1618
|
+
const categories = live.categories;
|
|
1619
|
+
const format = live.handlers.brushFormatLabel;
|
|
1620
|
+
const lastIndex = Math.max(categories.length - 1, 0);
|
|
1621
|
+
const startIndex = Math.round((range.start / 100) * lastIndex);
|
|
1622
|
+
const endIndex = Math.round((range.end / 100) * lastIndex);
|
|
1623
|
+
const labels =
|
|
1624
|
+
format && categories.length
|
|
1625
|
+
? {
|
|
1626
|
+
start: format(categories[startIndex] ?? "", startIndex),
|
|
1627
|
+
end: format(categories[endIndex] ?? "", endIndex),
|
|
1628
|
+
}
|
|
1629
|
+
: null;
|
|
1630
|
+
|
|
1631
|
+
syncBrushOverlay(chart, live, {
|
|
1632
|
+
range,
|
|
1633
|
+
geom,
|
|
1634
|
+
size: { width: chart.getWidth(), height: chart.getHeight() },
|
|
1635
|
+
tokens,
|
|
1636
|
+
labels,
|
|
1637
|
+
showLabels: live.brushHover.inside,
|
|
1638
|
+
hover: live.brushHover,
|
|
1639
|
+
});
|
|
1640
|
+
}, [live]);
|
|
1641
|
+
|
|
1642
|
+
// ── Option builder ─────────────────────────────────────────────────────────
|
|
1643
|
+
const buildOption = useCallback((): EChartsOption => {
|
|
1644
|
+
const resolved = live.resolved;
|
|
1645
|
+
if (!resolved) return {};
|
|
1646
|
+
|
|
1647
|
+
const categories = data.map((row) => String(row[categoryKey]));
|
|
1648
|
+
live.categories = categories;
|
|
1649
|
+
|
|
1650
|
+
const ctx: OptionBuildContext = {
|
|
1651
|
+
data,
|
|
1652
|
+
config,
|
|
1653
|
+
bars,
|
|
1654
|
+
seriesKeys,
|
|
1655
|
+
animationType,
|
|
1656
|
+
barRadius,
|
|
1657
|
+
isHorizontal,
|
|
1658
|
+
isStacked,
|
|
1659
|
+
isPercent,
|
|
1660
|
+
selectedDataKey,
|
|
1661
|
+
hasSelection,
|
|
1662
|
+
showGrid,
|
|
1663
|
+
categorySlot,
|
|
1664
|
+
valueSlot,
|
|
1665
|
+
tooltipSlot,
|
|
1666
|
+
legendSlot,
|
|
1667
|
+
isLoading,
|
|
1668
|
+
loadingData,
|
|
1669
|
+
showBrush: brushEnabled,
|
|
1670
|
+
brushHeight,
|
|
1671
|
+
barGap,
|
|
1672
|
+
barCategoryGap,
|
|
1673
|
+
resolved,
|
|
1674
|
+
categories,
|
|
1675
|
+
brushRange: live.brushRange,
|
|
1676
|
+
valuePxPerUnit: live.valuePxPerUnit,
|
|
1677
|
+
barWidthPx: live.barWidthPx,
|
|
1678
|
+
expand: live.expand,
|
|
1679
|
+
maxHighlightIndex,
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
const { grid, brushBottom } = buildChartLayout(ctx);
|
|
1683
|
+
live.brushGeom = brushEnabled ? { bottom: brushBottom, height: brushHeight } : null;
|
|
1684
|
+
|
|
1685
|
+
const { xAxis, yAxis } = buildMainAxes(ctx);
|
|
1686
|
+
|
|
1687
|
+
if (isLoading) return buildLoadingOption(ctx, { grid, xAxis, yAxis });
|
|
1688
|
+
|
|
1689
|
+
const brush = brushEnabled ? buildBrushOption(ctx, brushBottom) : null;
|
|
1690
|
+
|
|
1691
|
+
return {
|
|
1692
|
+
animation: false,
|
|
1693
|
+
grid: brush ? [grid, brush.miniGrid] : grid,
|
|
1694
|
+
xAxis: brush ? [xAxis, brush.miniXAxis] : xAxis,
|
|
1695
|
+
yAxis: brush ? [yAxis, brush.miniYAxis] : yAxis,
|
|
1696
|
+
tooltip: buildTooltipOption(ctx),
|
|
1697
|
+
dataZoom: brush?.dataZoom,
|
|
1698
|
+
series: [...buildBarSeries(ctx), ...(brush?.miniSeries ?? [])],
|
|
1699
|
+
};
|
|
1700
|
+
}, [
|
|
1701
|
+
live,
|
|
1702
|
+
data,
|
|
1703
|
+
config,
|
|
1704
|
+
bars,
|
|
1705
|
+
seriesKeys,
|
|
1706
|
+
categoryKey,
|
|
1707
|
+
animationType,
|
|
1708
|
+
barRadius,
|
|
1709
|
+
isHorizontal,
|
|
1710
|
+
isStacked,
|
|
1711
|
+
isPercent,
|
|
1712
|
+
selectedDataKey,
|
|
1713
|
+
hasSelection,
|
|
1714
|
+
showGrid,
|
|
1715
|
+
categorySlot,
|
|
1716
|
+
valueSlot,
|
|
1717
|
+
tooltipSlot,
|
|
1718
|
+
legendSlot,
|
|
1719
|
+
isLoading,
|
|
1720
|
+
loadingData,
|
|
1721
|
+
brushEnabled,
|
|
1722
|
+
brushHeight,
|
|
1723
|
+
barGap,
|
|
1724
|
+
barCategoryGap,
|
|
1725
|
+
maxHighlightIndex,
|
|
1726
|
+
]);
|
|
1727
|
+
|
|
1728
|
+
// ── Init + resize + theme observer (per renderer instance) ──────────────────
|
|
1729
|
+
useEffect(() => {
|
|
1730
|
+
const mount = mountRef.current;
|
|
1731
|
+
const container = containerRef.current;
|
|
1732
|
+
if (!mount || !container) return;
|
|
1733
|
+
|
|
1734
|
+
const chart = echarts.init(mount, null, { renderer });
|
|
1735
|
+
echartsRef.current = chart;
|
|
1736
|
+
|
|
1737
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1738
|
+
// Observers always fire once right after observe(). Repushing on that
|
|
1739
|
+
// no-op fire would land one frame into the intro and stomp the grow-in —
|
|
1740
|
+
// only react when the renderer size actually changed.
|
|
1741
|
+
if (mount.clientWidth === chart.getWidth() && mount.clientHeight === chart.getHeight()) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
chart.resize();
|
|
1745
|
+
live.repush();
|
|
1746
|
+
});
|
|
1747
|
+
resizeObserver.observe(mount);
|
|
1748
|
+
|
|
1749
|
+
// Light/dark flips change no React state — re-resolve and push directly.
|
|
1750
|
+
const themeObserver = new MutationObserver(() => {
|
|
1751
|
+
live.repush();
|
|
1752
|
+
});
|
|
1753
|
+
themeObserver.observe(document.documentElement, {
|
|
1754
|
+
attributes: true,
|
|
1755
|
+
attributeFilter: ["class"],
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
// Expandable hover, driven by the pointer's COLUMN rather than the bar element.
|
|
1759
|
+
// An expandable bar is a hairline at rest, so element hover would only catch a
|
|
1760
|
+
// couple of pixels — and hovering the empty space above a bar (where the axis
|
|
1761
|
+
// tooltip still responds) would highlight it without expanding it. Converting
|
|
1762
|
+
// the pointer's x back to a category index makes the whole column the target,
|
|
1763
|
+
// matching what the tooltip already does. Registered ONCE here rather than in
|
|
1764
|
+
// the sync effect, which re-runs on every prop/theme change and would stack
|
|
1765
|
+
// duplicate listeners; it calls through live.animateExpand, always the current one.
|
|
1766
|
+
chart.getZr().on("mousemove", (event: { offsetX: number; offsetY: number }) => {
|
|
1767
|
+
const { expandableKey } = live.handlers;
|
|
1768
|
+
if (!expandableKey) return;
|
|
1769
|
+
const point = [event.offsetX, event.offsetY];
|
|
1770
|
+
if (!chart.containPixel({ gridIndex: 0 }, point)) {
|
|
1771
|
+
live.animateExpand(expandableKey, null);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
// A grid finder returns [xValue, yValue]; on a category axis the x value IS
|
|
1775
|
+
// the index. An xAxisIndex finder returns null for a 2D point.
|
|
1776
|
+
const converted = chart.convertFromPixel({ gridIndex: 0 }, point);
|
|
1777
|
+
const index = Array.isArray(converted) ? converted[0] : converted;
|
|
1778
|
+
live.animateExpand(expandableKey, typeof index === "number" ? Math.round(index) : null);
|
|
1779
|
+
});
|
|
1780
|
+
chart.getZr().on("globalout", () => {
|
|
1781
|
+
const { expandableKey } = live.handlers;
|
|
1782
|
+
if (expandableKey) live.animateExpand(expandableKey, null);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
chart.on("click", (params) => {
|
|
1786
|
+
const { clickableKeys: clickable, seriesKeys: keys } = live.handlers;
|
|
1787
|
+
const p = params as { seriesId?: string; seriesIndex?: number };
|
|
1788
|
+
// Bar clicks carry seriesId; keep the seriesIndex fallback for safety. Main
|
|
1789
|
+
// series come first in the series array, so the index maps directly.
|
|
1790
|
+
const id =
|
|
1791
|
+
p.seriesId ?? (typeof p.seriesIndex === "number" ? keys[p.seriesIndex] : undefined);
|
|
1792
|
+
if (typeof id === "string" && clickable.has(id)) toggleSelection(id);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
chart.on("datazoom", () => {
|
|
1796
|
+
const option = chart.getOption() as { dataZoom?: { start?: number; end?: number }[] };
|
|
1797
|
+
const zoom = option.dataZoom?.[0];
|
|
1798
|
+
if (!zoom) return;
|
|
1799
|
+
|
|
1800
|
+
// Ride the selection — pure zrender updates, so the drag stays 1:1.
|
|
1801
|
+
live.brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };
|
|
1802
|
+
syncBrushOverlayNow();
|
|
1803
|
+
|
|
1804
|
+
const { onBrushChange: onChange } = live.handlers;
|
|
1805
|
+
if (!onChange) return;
|
|
1806
|
+
const len = live.dataLength;
|
|
1807
|
+
const startIndex = Math.round(((zoom.start ?? 0) / 100) * (len - 1));
|
|
1808
|
+
const endIndex = Math.round(((zoom.end ?? 100) / 100) * (len - 1));
|
|
1809
|
+
onChange({ startIndex, endIndex });
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
// Every push measures the axis scale and corrects stripped caps before it
|
|
1813
|
+
// paints, so this only catches rescales that BYPASS push — a dataZoom drag
|
|
1814
|
+
// narrowing the window until the value axis re-ranges. The correction is a
|
|
1815
|
+
// SILENT series-only merge (patchStrippedCaps), so it can't reset a dataZoom
|
|
1816
|
+
// drag. Held off until the entrance finishes (revealEndsAt) so it never lands
|
|
1817
|
+
// mid-grow; guarded by an epsilon so a stable measurement doesn't loop.
|
|
1818
|
+
chart.on("finished", () => {
|
|
1819
|
+
const { hasStripped, isHorizontal: horiz } = live.handlers;
|
|
1820
|
+
if (!hasStripped || performance.now() < live.revealEndsAt) return;
|
|
1821
|
+
const measured = measureValuePxPerUnit(chart, horiz);
|
|
1822
|
+
if (measured == null) return;
|
|
1823
|
+
if (live.valuePxPerUnit != null && Math.abs(measured - live.valuePxPerUnit) < 0.5) return;
|
|
1824
|
+
live.valuePxPerUnit = measured;
|
|
1825
|
+
live.patchStrippedCaps();
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// Hover tracking for the overlay: labels show while the pointer is over the
|
|
1829
|
+
// brush, and each pill brightens when the pointer is near its edge.
|
|
1830
|
+
const zr = chart.getZr();
|
|
1831
|
+
const applyHover = (next: { inside: boolean; left: boolean; right: boolean }) => {
|
|
1832
|
+
const prev = live.brushHover;
|
|
1833
|
+
if (prev.inside === next.inside && prev.left === next.left && prev.right === next.right) {
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
live.brushHover = next;
|
|
1837
|
+
syncBrushOverlayNow();
|
|
1838
|
+
};
|
|
1839
|
+
const onZrMove = (event: { offsetX?: number; offsetY?: number }) => {
|
|
1840
|
+
const geom = live.brushGeom;
|
|
1841
|
+
if (!geom) return;
|
|
1842
|
+
const x = event.offsetX ?? -1;
|
|
1843
|
+
const y = event.offsetY ?? -1;
|
|
1844
|
+
const top = chart.getHeight() - geom.bottom - geom.height;
|
|
1845
|
+
const inside = y >= top - 4 && y <= top + geom.height + 4;
|
|
1846
|
+
const trackLeft = 8;
|
|
1847
|
+
const trackWidth = Math.max(chart.getWidth() - 16, 1);
|
|
1848
|
+
const { start, end } = live.brushRange;
|
|
1849
|
+
const selectionLeft = trackLeft + (trackWidth * start) / 100;
|
|
1850
|
+
const selectionRight = trackLeft + (trackWidth * end) / 100;
|
|
1851
|
+
applyHover({
|
|
1852
|
+
inside,
|
|
1853
|
+
left: inside && Math.abs(x - selectionLeft) <= 8,
|
|
1854
|
+
right: inside && Math.abs(x - selectionRight) <= 8,
|
|
1855
|
+
});
|
|
1856
|
+
};
|
|
1857
|
+
const onZrOut = () => applyHover({ inside: false, left: false, right: false });
|
|
1858
|
+
zr.on("mousemove", onZrMove);
|
|
1859
|
+
zr.on("globalout", onZrOut);
|
|
1860
|
+
|
|
1861
|
+
return () => {
|
|
1862
|
+
zr.off("mousemove", onZrMove);
|
|
1863
|
+
zr.off("globalout", onZrOut);
|
|
1864
|
+
resizeObserver.disconnect();
|
|
1865
|
+
themeObserver.disconnect();
|
|
1866
|
+
if (live.expandRaf) {
|
|
1867
|
+
cancelAnimationFrame(live.expandRaf);
|
|
1868
|
+
live.expandRaf = 0;
|
|
1869
|
+
}
|
|
1870
|
+
chart.dispose();
|
|
1871
|
+
echartsRef.current = null;
|
|
1872
|
+
// The overlay elements died with the zrender instance.
|
|
1873
|
+
live.brushOverlay = null;
|
|
1874
|
+
live.brushHover = { inside: false, left: false, right: false };
|
|
1875
|
+
// Expand progress and layout measurements belong to the disposed painter.
|
|
1876
|
+
// Recompute them for the replacement renderer instead of briefly painting
|
|
1877
|
+
// its first frame with geometry captured from the old surface.
|
|
1878
|
+
live.expand = { key: null, hovered: null, progress: new Map<number, number>() };
|
|
1879
|
+
live.animateExpand = () => {};
|
|
1880
|
+
live.valuePxPerUnit = null;
|
|
1881
|
+
live.barWidthPx = null;
|
|
1882
|
+
// The reveal guard belongs to the chart instance it guarded. Without this
|
|
1883
|
+
// reset, StrictMode's dev-only mount→unmount→remount plays the entrance on
|
|
1884
|
+
// the throwaway instance and the surviving one renders without it.
|
|
1885
|
+
live.hasRevealed = false;
|
|
1886
|
+
};
|
|
1887
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1888
|
+
}, [renderer]);
|
|
1889
|
+
|
|
1890
|
+
// ── Sync ECharts with props/theme/selection — resolve, build, push ────────────
|
|
1891
|
+
useEffect(() => {
|
|
1892
|
+
const chart = echartsRef.current;
|
|
1893
|
+
const container = containerRef.current;
|
|
1894
|
+
if (!chart || !container) return;
|
|
1895
|
+
|
|
1896
|
+
// Colors come from the <style> committed just before this effect ran — read
|
|
1897
|
+
// them here, right before the push, rather than round-tripping through state.
|
|
1898
|
+
live.resolved = resolveColors(container, config, seriesKeys);
|
|
1899
|
+
|
|
1900
|
+
const push = (withEntrance: boolean) => {
|
|
1901
|
+
// Refresh the value-axis pixel scale before building, so stripped caps get the
|
|
1902
|
+
// right per-bar fraction on this same push (after a resize the coordinate
|
|
1903
|
+
// system is already updated here). Null before the very first push, and stale
|
|
1904
|
+
// when this push rescales the axis (loading skeleton → real data) — the
|
|
1905
|
+
// post-apply re-measure below corrects both before anything paints.
|
|
1906
|
+
const measured = measureValuePxPerUnit(chart, isHorizontal);
|
|
1907
|
+
if (measured != null) live.valuePxPerUnit = measured;
|
|
1908
|
+
|
|
1909
|
+
const apply = () => {
|
|
1910
|
+
const option = buildOption();
|
|
1911
|
+
const merged = chartOptions ? { ...option, ...chartOptions } : option;
|
|
1912
|
+
Object.assign(merged, {
|
|
1913
|
+
animation: withEntrance,
|
|
1914
|
+
animationDuration: BAR_GROW_DURATION,
|
|
1915
|
+
animationDurationUpdate: 0,
|
|
1916
|
+
});
|
|
1917
|
+
// chartOptions is an untyped escape hatch — the spread erases the option's
|
|
1918
|
+
// shape, so re-assert it. The only cast in the file.
|
|
1919
|
+
chart.setOption(merged as EChartsOption, { notMerge: true });
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
apply();
|
|
1923
|
+
|
|
1924
|
+
// Some things can only be sized once a coordinate system has been laid out, so
|
|
1925
|
+
// the first build uses fallbacks: the `blocks` variant's square segments (bar
|
|
1926
|
+
// width), the stacked-segment gap, and the stripped variant's constant-pixel
|
|
1927
|
+
// cap (both value-axis pixels-per-unit). Measure now and, if anything moved,
|
|
1928
|
+
// rebuild IMMEDIATELY — still inside this task, before the browser paints, so
|
|
1929
|
+
// the corrected chart is the only thing ever shown. Doing this from the async
|
|
1930
|
+
// `finished` handler instead made the bars visibly re-align a frame later —
|
|
1931
|
+
// exactly the stripped-cap flicker this replaces.
|
|
1932
|
+
let needsRebuild = false;
|
|
1933
|
+
if (live.handlers.hasBlocks) {
|
|
1934
|
+
const width = measureBarWidthPx(chart, isHorizontal, barCategoryGap);
|
|
1935
|
+
if (width != null && (live.barWidthPx == null || Math.abs(width - live.barWidthPx) > 0.5)) {
|
|
1936
|
+
live.barWidthPx = width;
|
|
1937
|
+
needsRebuild = true;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (live.handlers.hasStackGap || live.handlers.hasStripped) {
|
|
1941
|
+
const scale = measureValuePxPerUnit(chart, isHorizontal);
|
|
1942
|
+
if (scale != null && (live.valuePxPerUnit == null || live.valuePxPerUnit !== scale)) {
|
|
1943
|
+
live.valuePxPerUnit = scale;
|
|
1944
|
+
needsRebuild = true;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
if (needsRebuild) apply();
|
|
1948
|
+
// Mark when the entrance settles, so the stripped-cap correction holds off
|
|
1949
|
+
// until the grow finishes (0 = nothing animating, correct immediately).
|
|
1950
|
+
const maxStagger = data.length > 1 ? (data.length - 1) * BAR_STAGGER : 0;
|
|
1951
|
+
live.revealEndsAt = withEntrance ? performance.now() + BAR_GROW_DURATION + maxStagger : 0;
|
|
1952
|
+
// Overlays live outside the option — reposition them after every push.
|
|
1953
|
+
syncBrushOverlayNow();
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1956
|
+
// A stripped-cap correction that never disturbs the entrance or a brush drag:
|
|
1957
|
+
// rebuild only the stripped series with fresh per-datum cap fractions (the just
|
|
1958
|
+
// -measured live.valuePxPerUnit) and merge them silently.
|
|
1959
|
+
// Drives the `expandable` hover: eases live.expand.progress toward its target
|
|
1960
|
+
// and re-merges ONLY the expandable series each frame, so the strip grows out
|
|
1961
|
+
// of the bar's middle. A series-scoped silent merge (same shape as
|
|
1962
|
+
// patchStrippedCaps) — never a full notMerge push, which would fight the
|
|
1963
|
+
// hover state it is animating.
|
|
1964
|
+
live.animateExpand = (key: string | null, index: number | null) => {
|
|
1965
|
+
const expandKeys = new Set(
|
|
1966
|
+
bars.filter((bar) => bar.variant === "expandable").map((bar) => bar.dataKey),
|
|
1967
|
+
);
|
|
1968
|
+
if (!expandKeys.size) return;
|
|
1969
|
+
|
|
1970
|
+
const next = index != null && key != null ? index : null;
|
|
1971
|
+
if (live.expand.hovered === next && (key == null || live.expand.key === key)) return;
|
|
1972
|
+
if (key != null) live.expand.key = key;
|
|
1973
|
+
live.expand.hovered = next;
|
|
1974
|
+
// Seed the newly hovered bar so it has something to ease from.
|
|
1975
|
+
if (next != null && !live.expand.progress.has(next)) {
|
|
1976
|
+
live.expand.progress.set(next, EXPAND_COLLAPSED);
|
|
1977
|
+
}
|
|
1978
|
+
if (live.expandRaf) return; // a loop is already running; it picks up the new target
|
|
1979
|
+
|
|
1980
|
+
const patchOnce = () => {
|
|
1981
|
+
const option = buildOption();
|
|
1982
|
+
const series = Array.isArray(option.series)
|
|
1983
|
+
? option.series
|
|
1984
|
+
: option.series
|
|
1985
|
+
? [option.series]
|
|
1986
|
+
: [];
|
|
1987
|
+
const patch = series.filter(
|
|
1988
|
+
(s): s is BarSeriesOption => typeof s?.id === "string" && expandKeys.has(s.id),
|
|
1989
|
+
);
|
|
1990
|
+
if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
let last = performance.now();
|
|
1994
|
+
const step = () => {
|
|
1995
|
+
const now = performance.now();
|
|
1996
|
+
const dt = Math.min(64, now - last);
|
|
1997
|
+
last = now;
|
|
1998
|
+
// Exponential approach — every bar eases toward its own target, so the one
|
|
1999
|
+
// being left keeps animating shut while the next one opens.
|
|
2000
|
+
const k = 1 - Math.exp(-dt / EXPAND_TAU);
|
|
2001
|
+
let moving = false;
|
|
2002
|
+
for (const [i, value] of live.expand.progress) {
|
|
2003
|
+
const target = i === live.expand.hovered ? 1 : EXPAND_COLLAPSED;
|
|
2004
|
+
const eased = value + (target - value) * k;
|
|
2005
|
+
if (Math.abs(target - eased) < 0.004) {
|
|
2006
|
+
if (target === EXPAND_COLLAPSED) live.expand.progress.delete(i);
|
|
2007
|
+
else live.expand.progress.set(i, target);
|
|
2008
|
+
} else {
|
|
2009
|
+
live.expand.progress.set(i, eased);
|
|
2010
|
+
moving = true;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
patchOnce();
|
|
2014
|
+
live.expandRaf = moving ? requestAnimationFrame(step) : 0;
|
|
2015
|
+
};
|
|
2016
|
+
live.expandRaf = requestAnimationFrame(step);
|
|
2017
|
+
};
|
|
2018
|
+
|
|
2019
|
+
live.patchStrippedCaps = () => {
|
|
2020
|
+
const option = buildOption();
|
|
2021
|
+
const series = Array.isArray(option.series)
|
|
2022
|
+
? option.series
|
|
2023
|
+
: option.series
|
|
2024
|
+
? [option.series]
|
|
2025
|
+
: [];
|
|
2026
|
+
const strippedKeys = new Set(
|
|
2027
|
+
bars.filter((bar) => bar.variant === "stripped").map((bar) => bar.dataKey),
|
|
2028
|
+
);
|
|
2029
|
+
const patch = series.filter(
|
|
2030
|
+
(s): s is BarSeriesOption => typeof s?.id === "string" && strippedKeys.has(s.id),
|
|
2031
|
+
);
|
|
2032
|
+
if (patch.length) chart.setOption({ series: patch }, { silent: true, lazyUpdate: true });
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
// Intro grow-in — ECharts' native bar entrance (bars rise from the baseline),
|
|
2036
|
+
// staggered per-datum by animationType, enabled only for the first real
|
|
2037
|
+
// render: every later push (selection, theme, zoom) applies instantly, since
|
|
2038
|
+
// notMerge would otherwise replay the entrance on each. A loading cycle
|
|
2039
|
+
// re-arms it: the Recharts twin remounts its <Bar>s while loading and replays
|
|
2040
|
+
// the intro, so data → loading → data grows in again here too.
|
|
2041
|
+
if (isLoading) live.hasRevealed = false;
|
|
2042
|
+
const shouldReveal = !live.hasRevealed && !isLoading;
|
|
2043
|
+
if (shouldReveal) live.hasRevealed = true;
|
|
2044
|
+
const revealEnabled =
|
|
2045
|
+
animation && shouldReveal && effectiveAnimation !== "none" && !shouldReduceMotion;
|
|
2046
|
+
push(revealEnabled);
|
|
2047
|
+
|
|
2048
|
+
// Theme flips and resizes re-enter here without touching React: re-read the
|
|
2049
|
+
// tokens (the .dark class changed, or textures need renderer-sized rebakes)
|
|
2050
|
+
// and push an update-style option.
|
|
2051
|
+
live.repush = () => {
|
|
2052
|
+
live.resolved = resolveColors(container, config, seriesKeys);
|
|
2053
|
+
push(false);
|
|
2054
|
+
};
|
|
2055
|
+
}, [
|
|
2056
|
+
renderer,
|
|
2057
|
+
live,
|
|
2058
|
+
buildOption,
|
|
2059
|
+
chartOptions,
|
|
2060
|
+
isLoading,
|
|
2061
|
+
animation,
|
|
2062
|
+
effectiveAnimation,
|
|
2063
|
+
shouldReduceMotion,
|
|
2064
|
+
config,
|
|
2065
|
+
seriesKeys,
|
|
2066
|
+
data.length,
|
|
2067
|
+
bars,
|
|
2068
|
+
isHorizontal,
|
|
2069
|
+
barCategoryGap,
|
|
2070
|
+
syncBrushOverlayNow,
|
|
2071
|
+
]);
|
|
2072
|
+
|
|
2073
|
+
// ── Default tooltip — show the tooltip at `defaultIndex` with no hover ────────
|
|
2074
|
+
// Recharts' `defaultIndex` keeps a tooltip open on load; ECharts has no static
|
|
2075
|
+
// equivalent, so dispatch `showTip` once the layout has settled.
|
|
2076
|
+
useEffect(() => {
|
|
2077
|
+
const chart = echartsRef.current;
|
|
2078
|
+
const index = tooltipSlot.defaultIndex;
|
|
2079
|
+
if (!chart || isLoading || !tooltipSlot.present || index == null) return;
|
|
2080
|
+
const timer = setTimeout(() => {
|
|
2081
|
+
chart.dispatchAction({ type: "showTip", seriesIndex: 0, dataIndex: index });
|
|
2082
|
+
}, 300);
|
|
2083
|
+
return () => clearTimeout(timer);
|
|
2084
|
+
}, [
|
|
2085
|
+
renderer,
|
|
2086
|
+
tooltipSlot.present,
|
|
2087
|
+
tooltipSlot.defaultIndex,
|
|
2088
|
+
isLoading,
|
|
2089
|
+
data.length,
|
|
2090
|
+
seriesKeys.length,
|
|
2091
|
+
]);
|
|
2092
|
+
|
|
2093
|
+
// ── Loading shimmer — rAF sweeps a bright band across the gray bars ──────────
|
|
2094
|
+
useEffect(() => {
|
|
2095
|
+
const chart = echartsRef.current;
|
|
2096
|
+
if (!chart || !isLoading) return;
|
|
2097
|
+
|
|
2098
|
+
let raf = 0;
|
|
2099
|
+
let lastPhase = 0;
|
|
2100
|
+
const start = performance.now();
|
|
2101
|
+
const tick = (now: number) => {
|
|
2102
|
+
const phase = ((((now - start) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;
|
|
2103
|
+
// Wrapped past 1 → the band is off-screen; swap in fresh random heights.
|
|
2104
|
+
if (phase < lastPhase) live.loadingRows = getLoadingBarData(loadingBars);
|
|
2105
|
+
lastPhase = phase;
|
|
2106
|
+
|
|
2107
|
+
// Read tokens per frame, so a theme flip mid-loading retints the shimmer.
|
|
2108
|
+
const foreground = live.resolved?.tokens.foreground ?? GRAY;
|
|
2109
|
+
const w = chart.getWidth();
|
|
2110
|
+
const h = chart.getHeight();
|
|
2111
|
+
if (!w || !h) {
|
|
2112
|
+
raf = requestAnimationFrame(tick);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
// Sweep the clip window from fully off-screen to fully off-screen, leaned
|
|
2116
|
+
// 45°. The gradient runs on ABSOLUTE pixel coordinates (0,0)→(w,w) shared
|
|
2117
|
+
// by every bar — the whole skeleton lives in one coordinate frame, so each
|
|
2118
|
+
// bar brightens as the diagonal band passes diagonally over it, the same
|
|
2119
|
+
// sweep language as the area chart's loading shimmer. `maxT` is the farthest
|
|
2120
|
+
// plot corner projected onto the 45° axis, keeping the sweep tight instead
|
|
2121
|
+
// of dawdling off-plot at the end of each loop.
|
|
2122
|
+
const maxT = (w + h) / (2 * w);
|
|
2123
|
+
const center = phase * (maxT + 2 * LOADING_SHIMMER_BAND) - LOADING_SHIMMER_BAND;
|
|
2124
|
+
const fill = new echarts.graphic.LinearGradient(
|
|
2125
|
+
0,
|
|
2126
|
+
0,
|
|
2127
|
+
w,
|
|
2128
|
+
w,
|
|
2129
|
+
shimmerWindowStops(center, foreground, LOADING_SHIMMER_MAX_OPACITY),
|
|
2130
|
+
true,
|
|
2131
|
+
);
|
|
2132
|
+
chart.setOption(
|
|
2133
|
+
{ series: [{ id: "__loading", data: loadingData(), itemStyle: { color: fill } }] },
|
|
2134
|
+
{ silent: true, lazyUpdate: true },
|
|
2135
|
+
);
|
|
2136
|
+
raf = requestAnimationFrame(tick);
|
|
2137
|
+
};
|
|
2138
|
+
raf = requestAnimationFrame(tick);
|
|
2139
|
+
return () => cancelAnimationFrame(raf);
|
|
2140
|
+
}, [renderer, live, isLoading, loadingBars, loadingData]);
|
|
2141
|
+
|
|
2142
|
+
// ── Legend overlay position ──────────────────────────────────────────────────
|
|
2143
|
+
// Insets match the Recharts legend's breathing room inside the plot frame.
|
|
2144
|
+
const legendStyle: CSSProperties = {
|
|
2145
|
+
position: "absolute",
|
|
2146
|
+
left: 16,
|
|
2147
|
+
right: 16,
|
|
2148
|
+
pointerEvents: "auto",
|
|
2149
|
+
...(legendSlot.verticalAlign === "top"
|
|
2150
|
+
? { top: 12 }
|
|
2151
|
+
: legendSlot.verticalAlign === "bottom"
|
|
2152
|
+
? { bottom: brushEnabled ? brushHeight + 16 : 12 }
|
|
2153
|
+
: { top: "50%", transform: "translateY(-50%)" }),
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
return (
|
|
2157
|
+
<div
|
|
2158
|
+
ref={containerRef}
|
|
2159
|
+
data-chart={chartId}
|
|
2160
|
+
className={`relative flex flex-col text-xs ${className ?? ""}`}
|
|
2161
|
+
>
|
|
2162
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
2163
|
+
|
|
2164
|
+
<div className="relative min-h-0 w-full flex-1">
|
|
2165
|
+
<div ref={mountRef} className="h-full min-h-0 w-full" />
|
|
2166
|
+
</div>
|
|
2167
|
+
|
|
2168
|
+
{legendSlot.present && !isLoading && (
|
|
2169
|
+
<LegendOverlay
|
|
2170
|
+
seriesKeys={seriesKeys}
|
|
2171
|
+
config={config}
|
|
2172
|
+
variant={legendSlot.variant}
|
|
2173
|
+
align={legendSlot.align}
|
|
2174
|
+
verticalAlign={legendSlot.verticalAlign}
|
|
2175
|
+
selectedKey={selectedDataKey}
|
|
2176
|
+
hoveredKey={null}
|
|
2177
|
+
isClickable={legendSlot.isClickable}
|
|
2178
|
+
onToggle={toggleSelection}
|
|
2179
|
+
style={legendStyle}
|
|
2180
|
+
/>
|
|
2181
|
+
)}
|
|
2182
|
+
|
|
2183
|
+
{isLoading && (
|
|
2184
|
+
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
|
2185
|
+
<motion.div
|
|
2186
|
+
initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}
|
|
2187
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
2188
|
+
transition={{ duration: 0.25, ease: "easeOut" }}
|
|
2189
|
+
className="text-primary bg-background flex items-center justify-center gap-2 rounded-md border px-2 py-0.5 text-sm"
|
|
2190
|
+
>
|
|
2191
|
+
<div className="border-border border-t-primary h-3 w-3 animate-spin rounded-full border" />
|
|
2192
|
+
<span>Loading</span>
|
|
2193
|
+
</motion.div>
|
|
2194
|
+
</div>
|
|
2195
|
+
)}
|
|
2196
|
+
</div>
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2201
|
+
// Loading skeleton helpers
|
|
2202
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2203
|
+
|
|
2204
|
+
// Skeleton bar heights as a smooth random walk in a comfortable band — reads
|
|
2205
|
+
// like a resting chart instead of raw noise spikes.
|
|
2206
|
+
function getLoadingBarData(bars: number): number[] {
|
|
2207
|
+
const rows: number[] = [];
|
|
2208
|
+
let value = 40 + Math.random() * 25;
|
|
2209
|
+
for (let i = 0; i < bars; i++) {
|
|
2210
|
+
value = Math.min(85, Math.max(20, value + (Math.random() - 0.5) * 30));
|
|
2211
|
+
rows.push(Math.round(value));
|
|
2212
|
+
}
|
|
2213
|
+
return rows;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// Gradient stops forming a hard clip window around `center`: full `peak` alpha
|
|
2217
|
+
// inside, zero outside, with a small feather so the edge isn't aliased.
|
|
2218
|
+
// `center` may run outside [0, 1] so the window fully enters and exits the frame.
|
|
2219
|
+
function shimmerWindowStops(center: number, color: string, peak: number) {
|
|
2220
|
+
const half = LOADING_SHIMMER_BAND;
|
|
2221
|
+
const feather = LOADING_SHIMMER_FEATHER;
|
|
2222
|
+
|
|
2223
|
+
const alphaAt = (x: number) => {
|
|
2224
|
+
const dist = Math.abs(x - center);
|
|
2225
|
+
if (dist <= half - feather) return peak;
|
|
2226
|
+
if (dist >= half) return 0;
|
|
2227
|
+
// Sine-eased falloff — a linear ramp still reads as a hard cut.
|
|
2228
|
+
return peak * Math.sin(((1 - (dist - (half - feather)) / feather) * Math.PI) / 2);
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
const offsets = [
|
|
2232
|
+
0,
|
|
2233
|
+
center - half,
|
|
2234
|
+
center - half + feather,
|
|
2235
|
+
center,
|
|
2236
|
+
center + half - feather,
|
|
2237
|
+
center + half,
|
|
2238
|
+
1,
|
|
2239
|
+
]
|
|
2240
|
+
.filter((x) => x >= 0 && x <= 1)
|
|
2241
|
+
.sort((a, b) => a - b);
|
|
2242
|
+
|
|
2243
|
+
const stops: { offset: number; color: string }[] = [];
|
|
2244
|
+
for (const offset of offsets) {
|
|
2245
|
+
if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {
|
|
2246
|
+
stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
return stops;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// Compound API: every part hangs off the root as a static member, so a consumer
|
|
2253
|
+
// writes <EChartsBarChart.Bar/>, <EChartsBarChart.Tooltip/>, … from a single
|
|
2254
|
+
// import — no colliding named marker exports when several charts share one file.
|
|
2255
|
+
EChartsBarChart.Bar = Bar;
|
|
2256
|
+
EChartsBarChart.XAxis = XAxis;
|
|
2257
|
+
EChartsBarChart.YAxis = YAxis;
|
|
2258
|
+
EChartsBarChart.Grid = Grid;
|
|
2259
|
+
EChartsBarChart.Tooltip = Tooltip;
|
|
2260
|
+
EChartsBarChart.Legend = Legend;
|
|
2261
|
+
EChartsBarChart.Brush = Brush;
|