@wick-charts/react 0.2.2 → 0.2.3

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.
@@ -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.typography.axisFontSize,
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.typography.axisFontSize,
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
+ }