@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.
@@ -0,0 +1,204 @@
1
+ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ type ChartInstance,
5
+ type HoverInfo,
6
+ type ValueFormatter,
7
+ computeTooltipPosition,
8
+ formatCompact,
9
+ } from '@wick-charts/core';
10
+
11
+ import { useChartInstance } from '../context';
12
+ import { useCrosshairPosition } from '../store-bridge';
13
+
14
+ /** Context passed to the {@link PieTooltip} render-prop. */
15
+ export interface PieTooltipRenderContext {
16
+ readonly info: HoverInfo;
17
+ readonly format: ValueFormatter;
18
+ }
19
+
20
+ export interface PieTooltipProps {
21
+ /**
22
+ * Owning series id. **Optional** — when omitted, the first visible pie
23
+ * series is picked.
24
+ */
25
+ seriesId?: string;
26
+ /** Custom formatter for the slice value. Default: shared `formatCompact`. */
27
+ format?: ValueFormatter;
28
+ /** Render-prop escape hatch — receives hover info + format, replaces default UI. */
29
+ children?: (ctx: PieTooltipRenderContext) => ReactNode;
30
+ }
31
+
32
+ function resolvePieSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
33
+ if (explicit !== undefined) return explicit;
34
+
35
+ const pies = chart.getSeriesIdsByType('pie', { visibleOnly: true });
36
+
37
+ return pies.length > 0 ? pies[0] : null;
38
+ }
39
+
40
+ const DEFAULT_TOOLTIP_WIDTH = 160;
41
+ const DEFAULT_TOOLTIP_HEIGHT = 70;
42
+
43
+ /** Tooltip for pie/donut charts. Shows hovered slice label, value, and percentage. */
44
+ export function PieTooltip({ seriesId, format = formatCompact, children }: PieTooltipProps) {
45
+ const chart = useChartInstance();
46
+ const crosshair = useCrosshairPosition(chart);
47
+
48
+ const [, setBumpSignal] = useState(0);
49
+ useLayoutEffect(() => {
50
+ const handler = () => setBumpSignal((n) => n + 1);
51
+ chart.on('overlayChange', handler);
52
+
53
+ return () => {
54
+ chart.off('overlayChange', handler);
55
+ };
56
+ }, [chart]);
57
+
58
+ const resolvedId = resolvePieSeriesId(chart, seriesId);
59
+ const info = resolvedId !== null ? chart.getHoverInfo(resolvedId) : null;
60
+ if (!info || !crosshair) return null;
61
+
62
+ const theme = chart.getTheme();
63
+ const mediaSize = chart.getMediaSize();
64
+
65
+ if (children) {
66
+ return (
67
+ <CustomPieTooltip
68
+ x={crosshair.mediaX}
69
+ y={crosshair.mediaY}
70
+ chartWidth={mediaSize.width}
71
+ chartHeight={mediaSize.height}
72
+ >
73
+ {children({ info, format })}
74
+ </CustomPieTooltip>
75
+ );
76
+ }
77
+
78
+ const { left, top } = computeTooltipPosition({
79
+ x: crosshair.mediaX,
80
+ y: crosshair.mediaY,
81
+ chartWidth: mediaSize.width,
82
+ chartHeight: mediaSize.height,
83
+ tooltipWidth: DEFAULT_TOOLTIP_WIDTH,
84
+ tooltipHeight: DEFAULT_TOOLTIP_HEIGHT,
85
+ offsetX: 16,
86
+ offsetY: 16,
87
+ });
88
+
89
+ return (
90
+ <div
91
+ style={{
92
+ position: 'absolute',
93
+ left,
94
+ top,
95
+ pointerEvents: 'none',
96
+ zIndex: 10,
97
+ background: theme.tooltip.background,
98
+ backdropFilter: 'blur(12px)',
99
+ WebkitBackdropFilter: 'blur(12px)',
100
+ border: `1px solid ${theme.tooltip.borderColor}`,
101
+ borderRadius: 8,
102
+ padding: '10px 14px',
103
+ boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
104
+ fontSize: theme.typography.fontSize,
105
+ fontFamily: theme.typography.fontFamily,
106
+ color: theme.tooltip.textColor,
107
+ display: 'flex',
108
+ flexDirection: 'column',
109
+ gap: 6,
110
+ }}
111
+ >
112
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
113
+ <span
114
+ style={{
115
+ width: 10,
116
+ height: 10,
117
+ borderRadius: '50%',
118
+ background: info.color,
119
+ flexShrink: 0,
120
+ }}
121
+ />
122
+ <span style={{ fontWeight: 600 }}>{info.label}</span>
123
+ </div>
124
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
125
+ <span style={{ opacity: 0.6 }}>{format(info.value)}</span>
126
+ <span style={{ fontWeight: 600 }}>{info.percent.toFixed(1)}%</span>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // Custom slot content has unknown dimensions — measure the container, then
133
+ // position it. Matches the pattern in `Tooltip` (PR #39) so user-rendered pie
134
+ // tooltips flip/clamp correctly near edges instead of overflowing with the
135
+ // hardcoded 160×70 defaults.
136
+ function CustomPieTooltip({
137
+ x,
138
+ y,
139
+ chartWidth,
140
+ chartHeight,
141
+ children,
142
+ }: {
143
+ x: number;
144
+ y: number;
145
+ chartWidth: number;
146
+ chartHeight: number;
147
+ children: ReactNode;
148
+ }) {
149
+ const nodeRef = useRef<HTMLDivElement | null>(null);
150
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
151
+
152
+ useEffect(() => {
153
+ const node = nodeRef.current;
154
+ if (!node || typeof ResizeObserver === 'undefined') return;
155
+
156
+ const ro = new ResizeObserver((entries) => {
157
+ const box = entries[0]?.contentRect;
158
+ if (!box) return;
159
+ setSize((prev) =>
160
+ prev && prev.width === box.width && prev.height === box.height
161
+ ? prev
162
+ : { width: box.width, height: box.height },
163
+ );
164
+ });
165
+ ro.observe(node);
166
+
167
+ return () => ro.disconnect();
168
+ }, []);
169
+
170
+ const position = size
171
+ ? computeTooltipPosition({
172
+ x,
173
+ y,
174
+ chartWidth,
175
+ chartHeight,
176
+ tooltipWidth: size.width,
177
+ tooltipHeight: size.height,
178
+ offsetX: 16,
179
+ offsetY: 16,
180
+ })
181
+ : { left: 0, top: 0 };
182
+
183
+ return (
184
+ <div
185
+ ref={nodeRef}
186
+ data-measured={size ? 'true' : 'false'}
187
+ style={{
188
+ position: 'absolute',
189
+ left: position.left,
190
+ top: position.top,
191
+ pointerEvents: 'none',
192
+ zIndex: 10,
193
+ width: 'max-content',
194
+ maxWidth: chartWidth,
195
+ boxSizing: 'border-box',
196
+ // Hide until the first measurement so the user never sees a paint
197
+ // with out-of-bounds position.
198
+ visibility: size ? 'visible' : 'hidden',
199
+ }}
200
+ >
201
+ {children}
202
+ </div>
203
+ );
204
+ }
@@ -0,0 +1,216 @@
1
+ import { type CSSProperties, useMemo } from 'react';
2
+
3
+ import { type ChartTheme, type TimePoint, formatCompact, resolveCandlestickBodyColor } from '@wick-charts/core';
4
+
5
+ import { BarSeries } from '../BarSeries';
6
+ import { ChartContainer } from '../ChartContainer';
7
+ import { LineSeries } from '../LineSeries';
8
+
9
+ export type SparklineVariant = 'line' | 'bar';
10
+ export type SparklineValuePosition = 'left' | 'right' | 'none';
11
+
12
+ export interface SparklineProps {
13
+ data: TimePoint[];
14
+ theme: ChartTheme;
15
+ /** 'line' (default) or 'bar' */
16
+ variant?: SparklineVariant;
17
+ /** Where to show the value label */
18
+ valuePosition?: SparklineValuePosition;
19
+ /** Custom format for the value */
20
+ formatValue?: (value: number) => string;
21
+ /** Label text above the value */
22
+ label?: string;
23
+ /** Sublabel text below the value (defaults to the change %) */
24
+ sublabel?: string;
25
+ /** Line/bar color override (defaults to theme) */
26
+ color?: string;
27
+ /** Secondary color for negative bars */
28
+ negativeColor?: string;
29
+ /** Show area fill under line */
30
+ area?: { visible: boolean };
31
+ /** @deprecated Use {@link area} instead. */
32
+ areaFill?: boolean;
33
+ /** Chart width (default: 140) */
34
+ width?: number;
35
+ /** Overall height (default: 48) */
36
+ height?: number;
37
+ /** Stroke width in CSS pixels (default: 1.5) */
38
+ strokeWidth?: number;
39
+ /** Show chart background gradient (default: true) */
40
+ gradient?: boolean;
41
+ /** Container style override */
42
+ style?: CSSProperties;
43
+ }
44
+
45
+ function hexToRgba(color: string, alpha: number): string {
46
+ if (color.startsWith('rgba')) return color;
47
+ if (!color.startsWith('#')) return color;
48
+ const r = parseInt(color.slice(1, 3), 16);
49
+ const g = parseInt(color.slice(3, 5), 16);
50
+ const b = parseInt(color.slice(5, 7), 16);
51
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
52
+ }
53
+
54
+ function computeChange(data: TimePoint[]): { value: number; pct: number; positive: boolean } {
55
+ if (data.length < 2) return { value: 0, pct: 0, positive: true };
56
+ const first = data[0].value;
57
+ const last = data[data.length - 1].value;
58
+ const diff = last - first;
59
+ const pct = first !== 0 ? (diff / first) * 100 : 0;
60
+ return { value: diff, pct, positive: diff >= 0 };
61
+ }
62
+
63
+ export function Sparkline({
64
+ data,
65
+ theme,
66
+ variant = 'line',
67
+ valuePosition = 'right',
68
+ formatValue = formatCompact,
69
+ label,
70
+ sublabel,
71
+ color,
72
+ negativeColor,
73
+ area,
74
+ areaFill,
75
+ width = 140,
76
+ height = 48,
77
+ strokeWidth = 1.5,
78
+ gradient = true,
79
+ style,
80
+ }: SparklineProps) {
81
+ // Default area-visible = true. `area` wins if caller passes it; otherwise
82
+ // fall back to the deprecated flat `areaFill` flag for backward compatibility.
83
+ const areaVisible = area?.visible ?? areaFill ?? true;
84
+ const lastValue = data.length > 0 ? data[data.length - 1].value : 0;
85
+ const change = useMemo(() => computeChange(data), [data]);
86
+
87
+ const resolvedColor = color ?? theme.seriesColors[0];
88
+ const resolvedNegColor = negativeColor ?? resolveCandlestickBodyColor(theme.candlestick.down.body);
89
+ const changeColor = resolveCandlestickBodyColor(
90
+ change.positive ? theme.candlestick.up.body : theme.candlestick.down.body,
91
+ );
92
+
93
+ const valueBlock = valuePosition !== 'none' && (
94
+ <div
95
+ style={{
96
+ display: 'flex',
97
+ flexDirection: 'column',
98
+ justifyContent: 'center',
99
+ gap: 1,
100
+ minWidth: 0,
101
+ flexShrink: 0,
102
+ }}
103
+ >
104
+ {label && (
105
+ <div
106
+ style={{
107
+ fontSize: theme.axis.fontSize,
108
+ color: theme.axis.textColor,
109
+ lineHeight: 1.2,
110
+ whiteSpace: 'nowrap',
111
+ overflow: 'hidden',
112
+ textOverflow: 'ellipsis',
113
+ }}
114
+ >
115
+ {label}
116
+ </div>
117
+ )}
118
+ <div
119
+ style={{
120
+ fontSize: theme.typography.fontSize + 3,
121
+ fontWeight: 700,
122
+ color: theme.tooltip.textColor,
123
+ lineHeight: 1.1,
124
+ whiteSpace: 'nowrap',
125
+ fontVariantNumeric: 'tabular-nums',
126
+ }}
127
+ >
128
+ {formatValue(lastValue)}
129
+ </div>
130
+ {sublabel !== undefined ? (
131
+ <div
132
+ style={{
133
+ fontSize: theme.axis.fontSize - 1,
134
+ color: theme.axis.textColor,
135
+ lineHeight: 1.2,
136
+ whiteSpace: 'nowrap',
137
+ }}
138
+ >
139
+ {sublabel}
140
+ </div>
141
+ ) : (
142
+ <div
143
+ style={{
144
+ fontSize: theme.axis.fontSize - 1,
145
+ fontWeight: 500,
146
+ color: changeColor,
147
+ lineHeight: 1.2,
148
+ whiteSpace: 'nowrap',
149
+ fontVariantNumeric: 'tabular-nums',
150
+ }}
151
+ >
152
+ {change.positive ? '+' : ''}
153
+ {change.pct.toFixed(1)}%
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+
159
+ const chartBlock = (
160
+ <div style={{ width, height, flexShrink: 0, borderRadius: 4, overflow: 'hidden' }}>
161
+ <ChartContainer
162
+ theme={theme}
163
+ axis={{
164
+ y: { visible: false, width: 0 },
165
+ x: { visible: false, height: 0 },
166
+ }}
167
+ padding={{ top: 5, right: 0, bottom: 0, left: 0 }}
168
+ gradient={gradient}
169
+ interactive={false}
170
+ grid={{ visible: false }}
171
+ >
172
+ {variant === 'line' ? (
173
+ <LineSeries
174
+ data={[data]}
175
+ options={{
176
+ colors: [resolvedColor],
177
+ strokeWidth,
178
+ area: { visible: areaVisible },
179
+ pulse: false,
180
+ stacking: 'off',
181
+ }}
182
+ />
183
+ ) : (
184
+ <BarSeries
185
+ data={[data]}
186
+ options={{
187
+ colors: [resolvedColor, resolvedNegColor],
188
+ barWidthRatio: 0.7,
189
+ stacking: 'off',
190
+ }}
191
+ />
192
+ )}
193
+ </ChartContainer>
194
+ </div>
195
+ );
196
+
197
+ return (
198
+ <div
199
+ style={{
200
+ display: 'inline-flex',
201
+ alignItems: 'center',
202
+ gap: 12,
203
+ padding: '8px 12px',
204
+ borderRadius: 8,
205
+ background: hexToRgba(theme.tooltip.background, 0.7),
206
+ border: `1px solid ${theme.tooltip.borderColor}`,
207
+ fontFamily: theme.typography.fontFamily,
208
+ ...style,
209
+ }}
210
+ >
211
+ {valuePosition === 'left' && valueBlock}
212
+ {chartBlock}
213
+ {valuePosition === 'right' && valueBlock}
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,112 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+
3
+ import { formatTime, resolveAxisFontSize, resolveAxisTextColor } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from '../context';
6
+ import { useVisibleRange } from '../store-bridge';
7
+
8
+ interface TrackedTick {
9
+ opacity: number;
10
+ addedAt: number;
11
+ fadedAt?: number;
12
+ }
13
+
14
+ export interface TimeAxisProps {
15
+ /** Desired number of labels (≥ 2). Overrides chart-level `axis.x.labelCount`. */
16
+ labelCount?: number;
17
+ /** Minimum pixel gap between adjacent labels (hard floor). Overrides chart-level. */
18
+ minLabelSpacing?: number;
19
+ }
20
+
21
+ export function TimeAxis({ labelCount, minLabelSpacing }: TimeAxisProps = {}) {
22
+ const chart = useChartInstance();
23
+ useVisibleRange(chart); // subscribe to viewport changes so ticks re-render
24
+
25
+ useLayoutEffect(() => {
26
+ chart.setTimeAxisLabelDensity({
27
+ labelCount: labelCount ?? null,
28
+ minLabelSpacing: minLabelSpacing ?? null,
29
+ });
30
+
31
+ return () => {
32
+ chart.setTimeAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
33
+ };
34
+ }, [chart, labelCount, minLabelSpacing]);
35
+ const theme = chart.getTheme();
36
+ const dataInterval = chart.getDataInterval();
37
+ const { ticks: currentTicks, tickInterval } = chart.timeScale.niceTickValues(dataInterval);
38
+ const currentSet = new Set(currentTicks);
39
+
40
+ // Persistent map: tick value → tracked state
41
+ const mapRef = useRef<Map<number, TrackedTick>>(new Map());
42
+ const map = mapRef.current;
43
+ const now = performance.now();
44
+
45
+ // Mark current ticks as visible
46
+ for (const t of currentTicks) {
47
+ if (!map.has(t)) {
48
+ map.set(t, { opacity: 1, addedAt: now });
49
+ } else {
50
+ map.get(t)!.opacity = 1;
51
+ }
52
+ }
53
+
54
+ // Mark missing ticks for fade-out
55
+ for (const [t, entry] of map) {
56
+ if (!currentSet.has(t)) {
57
+ if (entry.opacity !== 0) {
58
+ entry.opacity = 0;
59
+ entry.fadedAt = now;
60
+ }
61
+ }
62
+ }
63
+
64
+ // Clean up ticks that have finished fading (400ms CSS transition + buffer)
65
+ for (const [t, entry] of map) {
66
+ if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > 600) {
67
+ map.delete(t);
68
+ }
69
+ }
70
+
71
+ // Collect all ticks to render (current + fading out)
72
+ const allTicks = Array.from(map.entries());
73
+
74
+ return (
75
+ <div
76
+ style={{
77
+ position: 'absolute',
78
+ left: 0,
79
+ bottom: 0,
80
+ right: chart.yAxisWidth,
81
+ height: chart.xAxisHeight,
82
+ pointerEvents: 'none',
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ }}
86
+ >
87
+ {allTicks.map(([time, entry]) => {
88
+ const x = chart.timeScale.timeToX(time);
89
+ return (
90
+ <span
91
+ key={time}
92
+ style={{
93
+ position: 'absolute',
94
+ left: x,
95
+ transform: 'translateX(-50%)',
96
+ color: resolveAxisTextColor(theme, 'x'),
97
+ fontSize: resolveAxisFontSize(theme, 'x'),
98
+ fontFamily: theme.typography.fontFamily,
99
+ userSelect: 'none',
100
+ whiteSpace: 'nowrap',
101
+ opacity: entry.opacity,
102
+ transition: 'opacity 0.3s ease',
103
+ willChange: 'opacity',
104
+ }}
105
+ >
106
+ {formatTime(time, tickInterval)}
107
+ </span>
108
+ );
109
+ })}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,62 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+
3
+ import { useTheme } from '../ThemeContext';
4
+
5
+ /** Props for the {@link Title} component. */
6
+ export interface TitleProps {
7
+ /** Primary label (e.g. "BTC/USD"). */
8
+ children?: ReactNode;
9
+ /**
10
+ * Secondary label rendered in a muted colour next to the primary one (e.g.
11
+ * "Live Candlestick", "1m", series count).
12
+ */
13
+ sub?: ReactNode;
14
+ /** Extra styles merged onto the flex row. */
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ /**
19
+ * Chart title / subtitle bar rendered as a flex row above the chart canvas
20
+ * (above {@link InfoBar} when both are present). Hoisted out of the
21
+ * overlay layer by {@link ChartContainer}, so browser flex layout reserves
22
+ * its height and ResizeObserver drives a Y-range recompute.
23
+ *
24
+ * Place it at the top of a chart's children — its slot in the DOM is
25
+ * determined by `ChartContainer`, not by source order:
26
+ * ```tsx
27
+ * <ChartContainer>
28
+ * <Title sub="Live Candlestick">BTC/USD</Title>
29
+ * <InfoBar />
30
+ * <CandlestickSeries data={data} />
31
+ * ...
32
+ * </ChartContainer>
33
+ * ```
34
+ */
35
+ export function Title({ children, sub, style }: TitleProps) {
36
+ const theme = useTheme();
37
+ return (
38
+ <div
39
+ data-chart-title=""
40
+ style={{
41
+ display: 'flex',
42
+ alignItems: 'baseline',
43
+ gap: 6,
44
+ padding: '6px 8px 4px',
45
+ flexShrink: 0,
46
+ fontFamily: theme.typography.fontFamily,
47
+ fontSize: theme.typography.fontSize,
48
+ fontWeight: 600,
49
+ color: theme.tooltip.textColor,
50
+ pointerEvents: 'none',
51
+ ...style,
52
+ }}
53
+ >
54
+ {children != null && children !== false && <span>{children}</span>}
55
+ {sub != null && sub !== false && (
56
+ <span style={{ fontWeight: 400, color: theme.axis.textColor, fontSize: theme.axis.fontSize }}>
57
+ {sub}
58
+ </span>
59
+ )}
60
+ </div>
61
+ );
62
+ }