@wick-charts/react 0.2.2 → 0.3.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/README.md +5 -3
- package/dist/index.cjs +2 -2
- package/dist/index.d.ts +110 -19
- package/dist/index.js +1787 -1722
- package/package.json +28 -3
- package/src/BarSeries.tsx +107 -0
- package/src/CandlestickSeries.tsx +108 -0
- package/src/ChartContainer.tsx +427 -0
- package/src/LineSeries.tsx +110 -0
- package/src/PieSeries.tsx +59 -0
- package/src/ThemeContext.tsx +21 -0
- package/src/context.ts +13 -0
- package/src/index.ts +132 -0
- package/src/store-bridge.ts +100 -0
- package/src/ui/Crosshair.tsx +61 -0
- package/src/ui/InfoBar.tsx +195 -0
- package/src/ui/Legend.tsx +274 -0
- package/src/ui/NumberFlow.tsx +118 -0
- package/src/ui/PieLegend.tsx +152 -0
- package/src/ui/PieTooltip.tsx +204 -0
- package/src/ui/Sparkline.tsx +216 -0
- package/src/ui/TimeAxis.tsx +112 -0
- package/src/ui/Title.tsx +62 -0
- package/src/ui/Tooltip.tsx +325 -0
- package/src/ui/YAxis.tsx +122 -0
- package/src/ui/YLabel.tsx +167 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ChartInstance, LegendItem } from '@wick-charts/core';
|
|
4
|
+
|
|
5
|
+
import { useChartInstance } from '../context';
|
|
6
|
+
import { useTheme } from '../ThemeContext';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal visual shape the {@link LegendProps.items} override accepts — just
|
|
10
|
+
* the pieces the built-in swatch/label UI needs. The canonical
|
|
11
|
+
* {@link LegendItem} (re-exported from `@wick-charts/core`) carries full
|
|
12
|
+
* identity plus `toggle`/`isolate` closures; those aren't meaningful when a
|
|
13
|
+
* consumer hands in a pre-baked, non-interactive legend.
|
|
14
|
+
*/
|
|
15
|
+
export interface LegendItemOverride {
|
|
16
|
+
label: string;
|
|
17
|
+
color: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Legend interaction mode.
|
|
22
|
+
* - `'toggle'` — click toggles the clicked item on/off (default).
|
|
23
|
+
* - `'isolate'` — click shows only that item; click again shows all.
|
|
24
|
+
* - `'solo'` — **@deprecated** alias for `'isolate'`, kept for back-compat.
|
|
25
|
+
*/
|
|
26
|
+
export type LegendMode = 'toggle' | 'isolate' | 'solo';
|
|
27
|
+
|
|
28
|
+
/** Context passed to the {@link Legend} render-prop. */
|
|
29
|
+
export interface LegendRenderContext {
|
|
30
|
+
readonly items: readonly LegendItem[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LegendProps {
|
|
34
|
+
/**
|
|
35
|
+
* Static override for auto-detected items. Renders a non-interactive legend
|
|
36
|
+
* with just swatch + label. Ignored when {@link children} is a render-prop.
|
|
37
|
+
*/
|
|
38
|
+
items?: LegendItemOverride[];
|
|
39
|
+
/** Layout position. Default: 'bottom'. */
|
|
40
|
+
position?: 'bottom' | 'right';
|
|
41
|
+
/** Click behavior for the built-in UI. Default: `'toggle'`. Ignored when {@link children} is provided. */
|
|
42
|
+
mode?: LegendMode;
|
|
43
|
+
/**
|
|
44
|
+
* Render-prop escape hatch. Receives the computed `items` (each carrying
|
|
45
|
+
* its own `toggle()` / `isolate()` closures) and fully replaces the
|
|
46
|
+
* built-in flex row / column. Callers can filter, reorder, and re-style
|
|
47
|
+
* without reimplementing visibility wiring.
|
|
48
|
+
*/
|
|
49
|
+
children?: (ctx: LegendRenderContext) => ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface BuildArgs {
|
|
53
|
+
chart: ChartInstance;
|
|
54
|
+
isolatedIdRef: { current: string | null };
|
|
55
|
+
setIsolatedId: (v: string | null) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildLegendItems({ chart, isolatedIdRef, setIsolatedId }: BuildArgs): LegendItem[] {
|
|
59
|
+
const items: LegendItem[] = [];
|
|
60
|
+
const seriesIds = chart.getSeriesIds();
|
|
61
|
+
|
|
62
|
+
for (const seriesId of seriesIds) {
|
|
63
|
+
const layers = chart.getSeriesLayers(seriesId);
|
|
64
|
+
if (layers) {
|
|
65
|
+
const baseLabel = chart.getSeriesLabel(seriesId);
|
|
66
|
+
for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
|
|
67
|
+
const id = `${seriesId}_layer${layerIndex}`;
|
|
68
|
+
const visible = chart.isSeriesVisible(seriesId) && chart.isLayerVisible(seriesId, layerIndex);
|
|
69
|
+
items.push(
|
|
70
|
+
makeItem({
|
|
71
|
+
id,
|
|
72
|
+
seriesId,
|
|
73
|
+
layerIndex,
|
|
74
|
+
label: baseLabel ? `${baseLabel} ${layerIndex + 1}` : `Series ${layerIndex + 1}`,
|
|
75
|
+
color: layers[layerIndex].color,
|
|
76
|
+
isDisabled: !visible,
|
|
77
|
+
chart,
|
|
78
|
+
isolatedIdRef,
|
|
79
|
+
setIsolatedId,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const color = chart.getSeriesColor(seriesId);
|
|
85
|
+
if (!color) continue;
|
|
86
|
+
|
|
87
|
+
const label = chart.getSeriesLabel(seriesId);
|
|
88
|
+
const visible = chart.isSeriesVisible(seriesId);
|
|
89
|
+
items.push(
|
|
90
|
+
makeItem({
|
|
91
|
+
id: seriesId,
|
|
92
|
+
seriesId,
|
|
93
|
+
layerIndex: undefined,
|
|
94
|
+
label: label ?? 'Series',
|
|
95
|
+
color,
|
|
96
|
+
isDisabled: !visible,
|
|
97
|
+
chart,
|
|
98
|
+
isolatedIdRef,
|
|
99
|
+
setIsolatedId,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface MakeItemArgs {
|
|
109
|
+
id: string;
|
|
110
|
+
seriesId: string;
|
|
111
|
+
layerIndex: number | undefined;
|
|
112
|
+
label: string;
|
|
113
|
+
color: string;
|
|
114
|
+
isDisabled: boolean;
|
|
115
|
+
chart: ChartInstance;
|
|
116
|
+
isolatedIdRef: { current: string | null };
|
|
117
|
+
setIsolatedId: (v: string | null) => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function makeItem(args: MakeItemArgs): LegendItem {
|
|
121
|
+
const { id, seriesId, layerIndex, label, color, isDisabled, chart, isolatedIdRef, setIsolatedId } = args;
|
|
122
|
+
|
|
123
|
+
const toggle = () => {
|
|
124
|
+
if (layerIndex !== undefined) {
|
|
125
|
+
chart.setLayerVisible(seriesId, layerIndex, !chart.isLayerVisible(seriesId, layerIndex));
|
|
126
|
+
} else {
|
|
127
|
+
chart.setSeriesVisible(seriesId, !chart.isSeriesVisible(seriesId));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const isolate = () => {
|
|
132
|
+
if (isolatedIdRef.current === id) {
|
|
133
|
+
chart.batch(() => {
|
|
134
|
+
for (const sid of chart.getSeriesIds()) {
|
|
135
|
+
chart.setSeriesVisible(sid, true);
|
|
136
|
+
const layers = chart.getSeriesLayers(sid);
|
|
137
|
+
if (layers) {
|
|
138
|
+
for (let i = 0; i < layers.length; i++) chart.setLayerVisible(sid, i, true);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// Mutate the ref synchronously so a back-to-back second `isolate()`
|
|
143
|
+
// sees the unisolated state even before React's re-render commits.
|
|
144
|
+
isolatedIdRef.current = null;
|
|
145
|
+
setIsolatedId(null);
|
|
146
|
+
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
chart.batch(() => {
|
|
151
|
+
for (const sid of chart.getSeriesIds()) {
|
|
152
|
+
const layers = chart.getSeriesLayers(sid);
|
|
153
|
+
if (layers) {
|
|
154
|
+
chart.setSeriesVisible(sid, sid === seriesId);
|
|
155
|
+
for (let i = 0; i < layers.length; i++) {
|
|
156
|
+
chart.setLayerVisible(sid, i, sid === seriesId && i === layerIndex);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
chart.setSeriesVisible(sid, sid === id);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
isolatedIdRef.current = id;
|
|
164
|
+
setIsolatedId(id);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return { id, seriesId, layerIndex, label, color, isDisabled, toggle, isolate };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function Legend({ items, position = 'bottom', mode = 'toggle', children }: LegendProps) {
|
|
171
|
+
const chart = useChartInstance();
|
|
172
|
+
const theme = useTheme();
|
|
173
|
+
const [isolatedId, setIsolatedId] = useState<string | null>(null);
|
|
174
|
+
const [bumpSignal, setBumpSignal] = useState(0);
|
|
175
|
+
|
|
176
|
+
// Closure bound to every LegendItem.isolate() reads live state through this
|
|
177
|
+
// ref, not the captured-at-render value, so a second click correctly sees
|
|
178
|
+
// `isolatedId` even when the component hasn't re-rendered yet.
|
|
179
|
+
const isolatedIdRef = useRef(isolatedId);
|
|
180
|
+
isolatedIdRef.current = isolatedId;
|
|
181
|
+
|
|
182
|
+
useLayoutEffect(() => {
|
|
183
|
+
const onOverlayChange = () => setBumpSignal((n) => n + 1);
|
|
184
|
+
const onSeriesChange = () => setIsolatedId(null);
|
|
185
|
+
chart.on('overlayChange', onOverlayChange);
|
|
186
|
+
chart.on('seriesChange', onSeriesChange);
|
|
187
|
+
// Catch-up: a sibling series' layout effect may have registered data in
|
|
188
|
+
// the same commit. Bump so the next synchronous render picks it up.
|
|
189
|
+
if (chart.getSeriesIds().length > 0) setBumpSignal((n) => n + 1);
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
chart.off('overlayChange', onOverlayChange);
|
|
193
|
+
chart.off('seriesChange', onSeriesChange);
|
|
194
|
+
};
|
|
195
|
+
}, [chart]);
|
|
196
|
+
|
|
197
|
+
const legendItems = useMemo(
|
|
198
|
+
() => buildLegendItems({ chart, isolatedIdRef, setIsolatedId }),
|
|
199
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `bumpSignal` is the subscription signal; isolatedId triggers the opacity-only refresh
|
|
200
|
+
[chart, isolatedId, bumpSignal],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const isRight = position === 'right';
|
|
204
|
+
const containerStyle: React.CSSProperties = {
|
|
205
|
+
display: 'flex',
|
|
206
|
+
flexDirection: isRight ? 'column' : 'row',
|
|
207
|
+
flexWrap: 'wrap',
|
|
208
|
+
gap: isRight ? 6 : 14,
|
|
209
|
+
padding: isRight ? '8px 6px' : '6px 8px',
|
|
210
|
+
alignItems: isRight ? 'flex-start' : 'center',
|
|
211
|
+
justifyContent: isRight ? 'flex-start' : 'center',
|
|
212
|
+
fontFamily: theme.typography.fontFamily,
|
|
213
|
+
fontSize: theme.axis.fontSize,
|
|
214
|
+
color: theme.axis.textColor,
|
|
215
|
+
pointerEvents: 'auto',
|
|
216
|
+
flexShrink: 0,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (children) {
|
|
220
|
+
if (legendItems.length === 0) return null;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div data-legend={position} style={containerStyle}>
|
|
224
|
+
{children({ items: legendItems })}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (items) {
|
|
230
|
+
if (items.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div data-legend={position} style={containerStyle}>
|
|
234
|
+
{items.map((item, i) => (
|
|
235
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static override — caller chose the order
|
|
236
|
+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, userSelect: 'none' }}>
|
|
237
|
+
<span style={{ width: 8, height: 8, borderRadius: 2, background: item.color, flexShrink: 0 }} />
|
|
238
|
+
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (legendItems.length === 0) return null;
|
|
246
|
+
|
|
247
|
+
const handleClick = (item: LegendItem) => {
|
|
248
|
+
if (mode === 'isolate' || mode === 'solo') item.isolate();
|
|
249
|
+
else item.toggle();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div data-legend={position} style={containerStyle}>
|
|
254
|
+
{legendItems.map((item) => (
|
|
255
|
+
<div
|
|
256
|
+
key={item.id}
|
|
257
|
+
onClick={() => handleClick(item)}
|
|
258
|
+
style={{
|
|
259
|
+
display: 'flex',
|
|
260
|
+
alignItems: 'center',
|
|
261
|
+
gap: 4,
|
|
262
|
+
cursor: 'pointer',
|
|
263
|
+
opacity: item.isDisabled ? 0.35 : 1,
|
|
264
|
+
transition: 'opacity 0.15s ease',
|
|
265
|
+
userSelect: 'none',
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<span style={{ width: 8, height: 8, borderRadius: 2, background: item.color, flexShrink: 0 }} />
|
|
269
|
+
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type CSSProperties, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface NumberFlowProps {
|
|
4
|
+
value: number;
|
|
5
|
+
/**
|
|
6
|
+
* Value-to-string formatter. Defaults to the current locale's
|
|
7
|
+
* `Intl.NumberFormat` when omitted. Pass the shared `formatCompact` /
|
|
8
|
+
* `formatPriceAdaptive` helpers or your own function to customize.
|
|
9
|
+
*
|
|
10
|
+
* `Intl.NumberFormatOptions` is also accepted (legacy) — it's routed
|
|
11
|
+
* through the built-in `Intl.NumberFormat` for back-compat with callers
|
|
12
|
+
* from before this prop was a function.
|
|
13
|
+
*/
|
|
14
|
+
format?: ((value: number) => string) | Intl.NumberFormatOptions;
|
|
15
|
+
locale?: string;
|
|
16
|
+
spinDuration?: number;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
|
22
|
+
|
|
23
|
+
interface CharPart {
|
|
24
|
+
type: 'digit' | 'symbol';
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decompose(formatted: string): CharPart[] {
|
|
29
|
+
const parts: CharPart[] = [];
|
|
30
|
+
for (const char of formatted) {
|
|
31
|
+
if (char >= '0' && char <= '9') {
|
|
32
|
+
parts.push({ type: 'digit', value: char });
|
|
33
|
+
} else {
|
|
34
|
+
parts.push({ type: 'symbol', value: char });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function NumberFlow({ value, format, locale = 'en-US', spinDuration = 350, className, style }: NumberFlowProps) {
|
|
41
|
+
const effectiveFormat = useMemo<(v: number) => string>(() => {
|
|
42
|
+
if (typeof format === 'function') return format;
|
|
43
|
+
const nf = new Intl.NumberFormat(locale, typeof format === 'object' ? format : undefined);
|
|
44
|
+
return (v: number) => nf.format(v);
|
|
45
|
+
}, [format, locale]);
|
|
46
|
+
|
|
47
|
+
const formatted = effectiveFormat(value);
|
|
48
|
+
const parts = decompose(formatted);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
className={className}
|
|
53
|
+
style={{
|
|
54
|
+
display: 'inline-flex',
|
|
55
|
+
fontVariantNumeric: 'tabular-nums',
|
|
56
|
+
lineHeight: 1.2,
|
|
57
|
+
...style,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{parts.map((part, i) =>
|
|
61
|
+
part.type === 'digit' ? (
|
|
62
|
+
<DigitSlot key={`d${i}`} digit={parseInt(part.value, 10)} duration={spinDuration} />
|
|
63
|
+
) : (
|
|
64
|
+
<span key={`s${i}`} style={{ display: 'inline-block' }}>
|
|
65
|
+
{part.value}
|
|
66
|
+
</span>
|
|
67
|
+
),
|
|
68
|
+
)}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface DigitSlotProps {
|
|
74
|
+
digit: number;
|
|
75
|
+
duration: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function DigitSlot({ digit, duration }: DigitSlotProps) {
|
|
79
|
+
const mountedRef = useRef(false);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
mountedRef.current = true;
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
style={{
|
|
88
|
+
display: 'inline-block',
|
|
89
|
+
height: '1.2em',
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
position: 'relative',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span
|
|
95
|
+
style={{
|
|
96
|
+
display: 'flex',
|
|
97
|
+
flexDirection: 'column',
|
|
98
|
+
transform: `translateY(${-digit * 1.2}em)`,
|
|
99
|
+
transition: mountedRef.current ? `transform ${duration}ms cubic-bezier(0.16, 1, 0.3, 1)` : 'none',
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{DIGITS.map((d) => (
|
|
103
|
+
<span
|
|
104
|
+
key={d}
|
|
105
|
+
style={{
|
|
106
|
+
display: 'flex',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
height: '1.2em',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{d}
|
|
113
|
+
</span>
|
|
114
|
+
))}
|
|
115
|
+
</span>
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { type ReactNode, useLayoutEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { type ChartInstance, type SliceInfo, type ValueFormatter, formatCompact } from '@wick-charts/core';
|
|
4
|
+
|
|
5
|
+
import { useChartInstance } from '../context';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Legend row content.
|
|
9
|
+
*
|
|
10
|
+
* - `'value'` — only the absolute value (e.g. `25`).
|
|
11
|
+
* - `'percent'` — only the percentage (e.g. `25.0%`).
|
|
12
|
+
* - `'both'` (default) — value + percent side-by-side; value is bold, percent dimmed.
|
|
13
|
+
*/
|
|
14
|
+
export type PieLegendMode = 'value' | 'percent' | 'both';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where the legend sits relative to the pie canvas.
|
|
18
|
+
*
|
|
19
|
+
* - `'bottom'` (default) — flex sibling below the canvas. Matches the time-series `<Legend>` layout.
|
|
20
|
+
* - `'right'` — flex sibling on the right of the canvas.
|
|
21
|
+
* - `'overlay'` — absolute overlay on top of the canvas. Back-compat escape hatch for callers
|
|
22
|
+
* that were relying on the old positioning; stacks with any `<Title>` at the top-left and can
|
|
23
|
+
* collide with it, so use sparingly.
|
|
24
|
+
*/
|
|
25
|
+
export type PieLegendPosition = 'bottom' | 'right' | 'overlay';
|
|
26
|
+
|
|
27
|
+
/** Context passed to the {@link PieLegend} render-prop. */
|
|
28
|
+
export interface PieLegendRenderContext {
|
|
29
|
+
readonly slices: readonly SliceInfo[];
|
|
30
|
+
readonly mode: PieLegendMode;
|
|
31
|
+
readonly format: ValueFormatter;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PieLegendProps {
|
|
35
|
+
/**
|
|
36
|
+
* Owning series id. **Optional** — when omitted, the first visible pie
|
|
37
|
+
* series is picked.
|
|
38
|
+
*/
|
|
39
|
+
seriesId?: string;
|
|
40
|
+
/** Default: `'both'`. See {@link PieLegendMode}. */
|
|
41
|
+
mode?: PieLegendMode;
|
|
42
|
+
/** Custom formatter for the absolute slice value. Default: shared `formatCompact`. */
|
|
43
|
+
format?: ValueFormatter;
|
|
44
|
+
/** Layout placement. Default: `'bottom'`. See {@link PieLegendPosition}. */
|
|
45
|
+
position?: PieLegendPosition;
|
|
46
|
+
/** Render-prop escape hatch. Receives slices + mode + format, replaces default UI. */
|
|
47
|
+
children?: (ctx: PieLegendRenderContext) => ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolvePieSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
|
|
51
|
+
if (explicit !== undefined) return explicit;
|
|
52
|
+
|
|
53
|
+
const pies = chart.getSeriesIdsByType('pie', { visibleOnly: true });
|
|
54
|
+
|
|
55
|
+
return pies.length > 0 ? pies[0] : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function PieLegend({ seriesId, mode: modeProp, format, position, children }: PieLegendProps) {
|
|
59
|
+
const mode: PieLegendMode = modeProp ?? 'both';
|
|
60
|
+
const resolvedPosition: PieLegendPosition = position ?? 'bottom';
|
|
61
|
+
const formatter: ValueFormatter = format ?? formatCompact;
|
|
62
|
+
const chart = useChartInstance();
|
|
63
|
+
const theme = chart.getTheme();
|
|
64
|
+
|
|
65
|
+
const [, setBumpSignal] = useState(0);
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
const handler = () => setBumpSignal((n) => n + 1);
|
|
68
|
+
chart.on('overlayChange', handler);
|
|
69
|
+
if (chart.getSeriesIds().length > 0) handler();
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
chart.off('overlayChange', handler);
|
|
73
|
+
};
|
|
74
|
+
}, [chart]);
|
|
75
|
+
|
|
76
|
+
const resolvedId = resolvePieSeriesId(chart, seriesId);
|
|
77
|
+
const slices = resolvedId !== null ? chart.getSliceInfo(resolvedId) : null;
|
|
78
|
+
if (!slices || slices.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
if (children) return <>{children({ slices, mode, format: formatter })}</>;
|
|
81
|
+
|
|
82
|
+
// When hoisted as a flex sibling (`bottom` / `right`), the container already
|
|
83
|
+
// reserves the legend's box. The extra 8px × 12px block padding only applies
|
|
84
|
+
// in `overlay` mode where the legend floats above the canvas and needs to
|
|
85
|
+
// breathe away from the edges.
|
|
86
|
+
const isOverlay = resolvedPosition === 'overlay';
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
data-chart-pie-legend=""
|
|
91
|
+
data-chart-pie-legend-position={resolvedPosition}
|
|
92
|
+
style={{
|
|
93
|
+
display: 'flex',
|
|
94
|
+
flexDirection: 'column',
|
|
95
|
+
gap: 6,
|
|
96
|
+
padding: isOverlay ? '8px 12px' : '6px 10px',
|
|
97
|
+
fontFamily: theme.typography.fontFamily,
|
|
98
|
+
fontSize: theme.typography.fontSize,
|
|
99
|
+
color: theme.tooltip.textColor,
|
|
100
|
+
pointerEvents: 'auto',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{slices.map((slice, i) => (
|
|
104
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: slice index is stable within a render
|
|
105
|
+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
106
|
+
<span
|
|
107
|
+
style={{
|
|
108
|
+
width: 10,
|
|
109
|
+
height: 10,
|
|
110
|
+
borderRadius: '50%',
|
|
111
|
+
background: slice.color,
|
|
112
|
+
flexShrink: 0,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
<span style={{ flex: 1, opacity: 0.8 }}>{slice.label}</span>
|
|
116
|
+
{(mode === 'value' || mode === 'both') && (
|
|
117
|
+
<span
|
|
118
|
+
style={{
|
|
119
|
+
fontWeight: 600,
|
|
120
|
+
fontVariantNumeric: 'tabular-nums',
|
|
121
|
+
// In 'value'-only mode the cell is the primary number and wants
|
|
122
|
+
// a wider reserved slot; in 'both' it's followed by the percent
|
|
123
|
+
// so keep it tight.
|
|
124
|
+
minWidth: mode === 'value' ? 40 : undefined,
|
|
125
|
+
textAlign: 'right',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{formatter(slice.value)}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
{(mode === 'percent' || mode === 'both') && (
|
|
132
|
+
<span
|
|
133
|
+
style={{
|
|
134
|
+
// In 'percent' mode the percent IS the value → bold at full
|
|
135
|
+
// opacity. In 'both' it's a secondary reading next to the
|
|
136
|
+
// absolute value → dim + smaller.
|
|
137
|
+
opacity: mode === 'percent' ? 1 : 0.5,
|
|
138
|
+
fontWeight: mode === 'percent' ? 600 : 400,
|
|
139
|
+
fontSize: mode === 'percent' ? theme.typography.fontSize : theme.axis.fontSize,
|
|
140
|
+
fontVariantNumeric: 'tabular-nums',
|
|
141
|
+
minWidth: 40,
|
|
142
|
+
textAlign: 'right',
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{slice.percent.toFixed(1)}%
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|