@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,325 @@
1
+ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ type ChartTheme,
5
+ type OHLCData,
6
+ type SeriesSnapshot,
7
+ type TimePoint,
8
+ type TooltipFormatter,
9
+ buildHoverSnapshots,
10
+ computeTooltipPosition,
11
+ formatCompact,
12
+ formatDate,
13
+ formatPriceAdaptive,
14
+ formatTime,
15
+ resolveCandlestickBodyColor,
16
+ } from '@wick-charts/core';
17
+
18
+ export { computeTooltipPosition } from '@wick-charts/core';
19
+
20
+ import { useChartInstance } from '../context';
21
+ import { useCrosshairPosition } from '../store-bridge';
22
+
23
+ /** Sort order for multi-series tooltip values. */
24
+ export type TooltipSort = 'none' | 'asc' | 'desc';
25
+
26
+ /** Context passed to the {@link Tooltip} render-prop. */
27
+ export interface TooltipRenderContext {
28
+ readonly snapshots: readonly SeriesSnapshot[];
29
+ /** Crosshair timestamp — the tooltip is hover-only, so this is always a real hover time. */
30
+ readonly time: number;
31
+ }
32
+
33
+ /**
34
+ * Props for the {@link Tooltip} component.
35
+ * Renders the built-in floating glass panel by default. Pass a render-prop
36
+ * child to replace its *contents* — the positioned container (with flip/clamp)
37
+ * stays.
38
+ */
39
+ export interface TooltipProps {
40
+ /** Sort order for line values (default: 'none'). */
41
+ sort?: TooltipSort;
42
+ /**
43
+ * Custom formatter for every displayed number in the default UI. Called per
44
+ * row with the field hint (`'open' | 'high' | 'low' | 'close' | 'volume' | 'value'`).
45
+ * Defaults: adaptive precision for ohlc/value, compact (K/M/B/T) for volume.
46
+ * Ignored when {@link children} is a render-prop.
47
+ */
48
+ format?: TooltipFormatter;
49
+ /**
50
+ * Render-prop escape hatch. Receives the hover snapshots and replaces the
51
+ * built-in panel contents. The floating container (positioning, blur glass,
52
+ * clamping) is preserved — use
53
+ * [`computeTooltipPosition`](../../core/src/tooltip-position.ts) directly if
54
+ * you need your own container.
55
+ */
56
+ children?: (ctx: TooltipRenderContext) => ReactNode;
57
+ }
58
+
59
+ /** Default tooltip formatter — adaptive precision + compact volumes. */
60
+ const defaultTooltipFormat: TooltipFormatter = (v, field) =>
61
+ field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v);
62
+
63
+ /**
64
+ * Floating near-cursor glass tooltip that appears while hovering the chart.
65
+ *
66
+ * Hover-only: without a crosshair position, the component renders `null`.
67
+ * The companion {@link InfoBar} shows last-known values when no hover is active.
68
+ */
69
+ export function Tooltip({ sort = 'none', format = defaultTooltipFormat, children }: TooltipProps) {
70
+ const chart = useChartInstance();
71
+ const crosshair = useCrosshairPosition(chart);
72
+
73
+ const [, bump] = useState(0);
74
+ useLayoutEffect(() => {
75
+ const onOverlayChange = () => bump((n) => n + 1);
76
+ chart.on('overlayChange', onOverlayChange);
77
+ if (chart.getSeriesIds().length > 0) bump((n) => n + 1);
78
+
79
+ return () => {
80
+ chart.off('overlayChange', onOverlayChange);
81
+ };
82
+ }, [chart]);
83
+
84
+ if (!crosshair) return null;
85
+
86
+ const snapshots = buildHoverSnapshots(chart, { time: crosshair.time, sort, cacheKey: 'tooltip' });
87
+ if (snapshots.length === 0) return null;
88
+
89
+ const theme = chart.getTheme();
90
+ const dataInterval = chart.getDataInterval();
91
+ const mediaSize = chart.getMediaSize();
92
+ const chartWidth = mediaSize.width - chart.yAxisWidth;
93
+ const chartHeight = mediaSize.height - chart.xAxisHeight;
94
+
95
+ if (children) {
96
+ return (
97
+ <CustomFloatingTooltip
98
+ x={crosshair.mediaX}
99
+ y={crosshair.mediaY}
100
+ chartWidth={chartWidth}
101
+ chartHeight={chartHeight}
102
+ theme={theme}
103
+ >
104
+ {/* `crosshair.time` is the semantic truth — snapshots[0].data.time
105
+ shifts with `sort` and, for ragged multi-layer data, disagrees
106
+ with the actual hover moment. */}
107
+ {children({ snapshots, time: crosshair.time })}
108
+ </CustomFloatingTooltip>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <FloatingTooltip
114
+ snapshots={snapshots}
115
+ displayTime={crosshair.time}
116
+ x={crosshair.mediaX}
117
+ y={crosshair.mediaY}
118
+ chartWidth={chartWidth}
119
+ chartHeight={chartHeight}
120
+ theme={theme}
121
+ dataInterval={dataInterval}
122
+ format={format}
123
+ />
124
+ );
125
+ }
126
+
127
+ function CustomFloatingTooltip({
128
+ x,
129
+ y,
130
+ chartWidth,
131
+ chartHeight,
132
+ theme,
133
+ children,
134
+ }: {
135
+ x: number;
136
+ y: number;
137
+ chartWidth: number;
138
+ chartHeight: number;
139
+ theme: ChartTheme;
140
+ children: ReactNode;
141
+ }) {
142
+ // Custom content has unknown dimensions until the first paint — measure the
143
+ // container, then position it. Hide with `visibility: hidden` on the
144
+ // pre-measured frame so the user never sees an un-clamped paint.
145
+ const nodeRef = useRef<HTMLDivElement | null>(null);
146
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
147
+
148
+ useEffect(() => {
149
+ const node = nodeRef.current;
150
+ if (!node || typeof ResizeObserver === 'undefined') return;
151
+
152
+ const ro = new ResizeObserver((entries) => {
153
+ const box = entries[0]?.contentRect;
154
+ if (!box) return;
155
+ setSize((prev) =>
156
+ prev && prev.width === box.width && prev.height === box.height
157
+ ? prev
158
+ : { width: box.width, height: box.height },
159
+ );
160
+ });
161
+ ro.observe(node);
162
+
163
+ return () => ro.disconnect();
164
+ }, []);
165
+
166
+ const position = size
167
+ ? computeTooltipPosition({ x, y, chartWidth, chartHeight, tooltipWidth: size.width, tooltipHeight: size.height })
168
+ : { left: 0, top: 0 };
169
+
170
+ return (
171
+ <div
172
+ ref={nodeRef}
173
+ data-measured={size ? 'true' : 'false'}
174
+ style={{
175
+ position: 'absolute',
176
+ left: position.left,
177
+ top: position.top,
178
+ pointerEvents: 'none',
179
+ background: theme.tooltip.background,
180
+ backdropFilter: 'blur(12px)',
181
+ WebkitBackdropFilter: 'blur(12px)',
182
+ border: `1px solid ${theme.tooltip.borderColor}`,
183
+ borderRadius: 8,
184
+ padding: '10px 14px',
185
+ boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
186
+ fontFamily: theme.typography.fontFamily,
187
+ fontSize: theme.tooltip.fontSize,
188
+ fontVariantNumeric: 'tabular-nums',
189
+ color: theme.tooltip.textColor,
190
+ width: 'max-content',
191
+ maxWidth: chartWidth,
192
+ boxSizing: 'border-box',
193
+ zIndex: 10,
194
+ visibility: size ? 'visible' : 'hidden',
195
+ }}
196
+ >
197
+ {children}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function FloatingTooltip({
203
+ snapshots,
204
+ displayTime,
205
+ x,
206
+ y,
207
+ chartWidth,
208
+ chartHeight,
209
+ theme,
210
+ dataInterval,
211
+ format,
212
+ }: {
213
+ snapshots: readonly SeriesSnapshot[];
214
+ displayTime: number;
215
+ x: number;
216
+ y: number;
217
+ chartWidth: number;
218
+ chartHeight: number;
219
+ theme: ChartTheme;
220
+ dataInterval: number;
221
+ format: TooltipFormatter;
222
+ }) {
223
+ const hasOHLC = snapshots.some((s) => 'open' in s.data);
224
+ const lineCount = snapshots.filter((s) => !('open' in s.data)).length;
225
+
226
+ const tooltipWidth = 160;
227
+ const tooltipHeight = hasOHLC ? 140 : 40 + lineCount * 22;
228
+
229
+ const { left, top } = computeTooltipPosition({ x, y, chartWidth, chartHeight, tooltipWidth, tooltipHeight });
230
+
231
+ const bg = theme.tooltip.background;
232
+ const border = theme.tooltip.borderColor;
233
+ const shadow = '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)';
234
+
235
+ return (
236
+ <div
237
+ style={{
238
+ position: 'absolute',
239
+ left,
240
+ top,
241
+ pointerEvents: 'none',
242
+ background: bg,
243
+ backdropFilter: 'blur(12px)',
244
+ WebkitBackdropFilter: 'blur(12px)',
245
+ border: `1px solid ${border}`,
246
+ borderRadius: 8,
247
+ padding: '10px 14px',
248
+ boxShadow: shadow,
249
+ fontSize: theme.tooltip.fontSize,
250
+ fontFamily: theme.typography.fontFamily,
251
+ fontVariantNumeric: 'tabular-nums',
252
+ color: theme.tooltip.textColor,
253
+ // Fix the rendered width to the value `computeTooltipPosition` assumes
254
+ // so content growth (e.g. long labels) can't push the tooltip past the
255
+ // clamp and back out of the plot area.
256
+ width: tooltipWidth,
257
+ boxSizing: 'border-box',
258
+ zIndex: 10,
259
+ transition: 'opacity 0.15s ease',
260
+ }}
261
+ >
262
+ {/* Time header */}
263
+ <div
264
+ style={{
265
+ fontSize: theme.axis.fontSize,
266
+ color: theme.axis.textColor,
267
+ marginBottom: 8,
268
+ paddingBottom: 6,
269
+ borderBottom: `1px solid ${border}`,
270
+ letterSpacing: '0.02em',
271
+ }}
272
+ >
273
+ {formatDate(displayTime)} {formatTime(displayTime, dataInterval)}
274
+ </div>
275
+
276
+ {snapshots.map((s) => {
277
+ const isOHLC = 'open' in s.data;
278
+ if (isOHLC) {
279
+ const ohlc = s.data as OHLCData;
280
+ const isUp = ohlc.close >= ohlc.open;
281
+ const valColor = resolveCandlestickBodyColor(
282
+ isUp ? theme.candlestick.up.body : theme.candlestick.down.body,
283
+ );
284
+ return (
285
+ <div key={s.id} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px' }}>
286
+ <TooltipRow label="Open" color={valColor} display={format(ohlc.open, 'open')} />
287
+ <TooltipRow label="High" color={valColor} display={format(ohlc.high, 'high')} />
288
+ <TooltipRow label="Low" color={valColor} display={format(ohlc.low, 'low')} />
289
+ <TooltipRow label="Close" color={valColor} display={format(ohlc.close, 'close')} />
290
+ {ohlc.volume != null && (
291
+ <TooltipRow label="Volume" color={theme.tooltip.textColor} display={format(ohlc.volume, 'volume')} />
292
+ )}
293
+ </div>
294
+ );
295
+ }
296
+ const line = s.data as TimePoint;
297
+
298
+ return (
299
+ <div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '2px 0' }}>
300
+ <span
301
+ style={{
302
+ width: 8,
303
+ height: 8,
304
+ borderRadius: '50%',
305
+ background: s.color,
306
+ flexShrink: 0,
307
+ }}
308
+ />
309
+ <span style={{ opacity: 0.6, flex: 1 }}>{s.label ?? 'Value'}</span>
310
+ <span style={{ fontWeight: 600, color: s.color }}>{format(line.value, 'value')}</span>
311
+ </div>
312
+ );
313
+ })}
314
+ </div>
315
+ );
316
+ }
317
+
318
+ function TooltipRow({ label, color, display }: { label: string; color: string; display: string }) {
319
+ return (
320
+ <>
321
+ <span style={{ opacity: 0.5 }}>{label}</span>
322
+ <span style={{ fontWeight: 600, color, textAlign: 'right' }}>{display}</span>
323
+ </>
324
+ );
325
+ }
@@ -0,0 +1,122 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+
3
+ import { resolveAxisFontSize, resolveAxisTextColor, type ValueFormatter } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from '../context';
6
+ import { useYRange } from '../store-bridge';
7
+
8
+ interface TrackedTick {
9
+ opacity: number;
10
+ addedAt: number;
11
+ fadedAt?: number;
12
+ }
13
+
14
+ export interface YAxisProps {
15
+ /**
16
+ * Custom tick-label formatter. When supplied, overrides the built-in
17
+ * range-adaptive formatter for this axis.
18
+ */
19
+ format?: ValueFormatter;
20
+ /**
21
+ * Desired number of labels (≥ 2). Overrides any chart-level `axis.y.labelCount`.
22
+ * Realized count may differ ±1 after the 1-2-5 snap.
23
+ */
24
+ labelCount?: number;
25
+ /** Minimum pixel gap between adjacent labels (hard floor). Overrides chart-level. */
26
+ minLabelSpacing?: number;
27
+ }
28
+
29
+ export function YAxis({ format, labelCount, minLabelSpacing }: YAxisProps = {}) {
30
+ const chart = useChartInstance();
31
+ useYRange(chart); // subscribe to viewport changes so ticks re-render
32
+
33
+ // Route the prop through yScale so the *same* formatter drives every
34
+ // surface that reads `yScale.formatY()` (Crosshair, YLabel fallback).
35
+ useLayoutEffect(() => {
36
+ chart.yScale.setFormat(format ?? null);
37
+
38
+ return () => chart.yScale.setFormat(null);
39
+ }, [chart, format]);
40
+
41
+ useLayoutEffect(() => {
42
+ chart.setYAxisLabelDensity({
43
+ labelCount: labelCount ?? null,
44
+ minLabelSpacing: minLabelSpacing ?? null,
45
+ });
46
+
47
+ return () => {
48
+ chart.setYAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
49
+ };
50
+ }, [chart, labelCount, minLabelSpacing]);
51
+
52
+ const theme = chart.getTheme();
53
+ const currentTicks = chart.yScale.niceTickValues();
54
+ const currentSet = new Set(currentTicks);
55
+
56
+ const mapRef = useRef<Map<number, TrackedTick>>(new Map());
57
+ const map = mapRef.current;
58
+ const now = performance.now();
59
+
60
+ for (const p of currentTicks) {
61
+ if (!map.has(p)) {
62
+ map.set(p, { opacity: 1, addedAt: now });
63
+ } else {
64
+ map.get(p)!.opacity = 1;
65
+ }
66
+ }
67
+
68
+ for (const [p, entry] of map) {
69
+ if (!currentSet.has(p)) {
70
+ if (entry.opacity !== 0) {
71
+ entry.opacity = 0;
72
+ entry.fadedAt = now;
73
+ }
74
+ }
75
+ }
76
+
77
+ for (const [p, entry] of map) {
78
+ if (entry.opacity === 0 && entry.fadedAt !== undefined && now - entry.fadedAt > 600) {
79
+ map.delete(p);
80
+ }
81
+ }
82
+
83
+ const allTicks = Array.from(map.entries());
84
+
85
+ return (
86
+ <div
87
+ style={{
88
+ position: 'absolute',
89
+ right: 0,
90
+ top: 0,
91
+ bottom: chart.xAxisHeight,
92
+ width: chart.yAxisWidth,
93
+ pointerEvents: 'none',
94
+ }}
95
+ >
96
+ {allTicks.map(([price, entry]) => {
97
+ const y = chart.yScale.valueToY(price);
98
+ return (
99
+ <span
100
+ key={price}
101
+ style={{
102
+ position: 'absolute',
103
+ right: 8,
104
+ top: y,
105
+ transform: 'translateY(-50%)',
106
+ color: resolveAxisTextColor(theme, 'y'),
107
+ fontSize: resolveAxisFontSize(theme, 'y'),
108
+ fontFamily: theme.typography.fontFamily,
109
+ fontVariantNumeric: 'tabular-nums',
110
+ userSelect: 'none',
111
+ opacity: entry.opacity,
112
+ transition: 'opacity 0.3s ease',
113
+ willChange: 'opacity',
114
+ }}
115
+ >
116
+ {chart.yScale.formatY(price)}
117
+ </span>
118
+ );
119
+ })}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,167 @@
1
+ import { type ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react';
2
+
3
+ import type { ChartInstance, ValueFormatter } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from '../context';
6
+ import { NumberFlow } from './NumberFlow';
7
+
8
+ /** Direction of the current value vs. previous close. Drives the badge color in the default UI. */
9
+ export type YLabelDirection = 'up' | 'down' | 'neutral';
10
+
11
+ /** Context passed to the {@link YLabel} render-prop. */
12
+ export interface YLabelRenderContext {
13
+ readonly value: number;
14
+ /** Pixel Y of the badge anchor (already account for current viewport). */
15
+ readonly y: number;
16
+ /** Final background color chosen by the built-in UI — handy if you want to match the dashed line accent. */
17
+ readonly bgColor: string;
18
+ /** `true` while the chart is tracking a live last point (still mutating). */
19
+ readonly isLive: boolean;
20
+ readonly direction: YLabelDirection;
21
+ readonly format: ValueFormatter;
22
+ }
23
+
24
+ export interface YLabelProps {
25
+ /**
26
+ * Owning series id. **Optional** — when omitted, the first visible
27
+ * single-layer time series is picked, falling back to the first visible
28
+ * multi-layer time series. `null` (no compatible series) renders nothing.
29
+ */
30
+ seriesId?: string;
31
+ /** Override badge color (e.g. line color). If not set, uses up/down/neutral from theme. */
32
+ color?: string;
33
+ /**
34
+ * Custom formatter. Routed through NumberFlow as its `format` prop so the
35
+ * digit-by-digit animation still plays on the output string — NumberFlow
36
+ * animates whichever characters the formatter returns.
37
+ */
38
+ format?: ValueFormatter;
39
+ /**
40
+ * Render-prop escape hatch. Receives the resolved value, pixel position, and
41
+ * direction metadata. Replaces the built-in badge + dashed line entirely.
42
+ */
43
+ children?: (ctx: YLabelRenderContext) => ReactNode;
44
+ }
45
+
46
+ function resolveSeriesId(chart: ChartInstance, explicit: string | undefined): string | null {
47
+ if (explicit !== undefined) return explicit;
48
+
49
+ const singleLayer = chart.getSeriesIdsByType('time', { visibleOnly: true, singleLayerOnly: true });
50
+ if (singleLayer.length > 0) return singleLayer[0];
51
+
52
+ const anyTime = chart.getSeriesIdsByType('time', { visibleOnly: true });
53
+
54
+ return anyTime.length > 0 ? anyTime[0] : null;
55
+ }
56
+
57
+ export function YLabel({ seriesId, color, format, children }: YLabelProps) {
58
+ const chart = useChartInstance();
59
+
60
+ // Notify chart that YLabel is present (affects right padding).
61
+ useEffect(() => {
62
+ chart.setYLabel(true);
63
+ return () => chart.setYLabel(false);
64
+ }, [chart]);
65
+
66
+ // Single subscription covering data/visibility/theme/options changes, plus
67
+ // viewportChange for pixel-Y drift on pan/zoom where the value is unchanged
68
+ // but the badge must move.
69
+ const [, setBumpSignal] = useState(0);
70
+ useLayoutEffect(() => {
71
+ const onChange = () => setBumpSignal((n) => n + 1);
72
+ chart.on('overlayChange', onChange);
73
+ chart.on('viewportChange', onChange);
74
+ if (chart.getSeriesIds().length > 0) setBumpSignal((n) => n + 1);
75
+
76
+ return () => {
77
+ chart.off('overlayChange', onChange);
78
+ chart.off('viewportChange', onChange);
79
+ };
80
+ }, [chart]);
81
+
82
+ const resolvedId = resolveSeriesId(chart, seriesId);
83
+ const last = resolvedId !== null ? chart.getStackedLastValue(resolvedId) : null;
84
+ const previousClose = resolvedId !== null ? chart.getPreviousClose(resolvedId) : null;
85
+
86
+ const yRange = chart.yScale.getRange();
87
+ const range = yRange.max - yRange.min;
88
+ const fractionDigits = range < 0.1 ? 6 : range < 10 ? 4 : range < 1000 ? 2 : 0;
89
+
90
+ // Build the fallback range-adaptive Intl formatter before the early return
91
+ // so this hook call can't be skipped on subsequent renders (Rules of Hooks).
92
+ const effectiveFormat = useMemo<ValueFormatter>(() => {
93
+ if (format) return format;
94
+ const nf = new Intl.NumberFormat('en-US', {
95
+ minimumFractionDigits: fractionDigits,
96
+ maximumFractionDigits: fractionDigits,
97
+ useGrouping: false,
98
+ });
99
+
100
+ return (v: number) => nf.format(v);
101
+ }, [format, fractionDigits]);
102
+
103
+ if (!last || resolvedId === null) return null;
104
+
105
+ const { value, isLive } = last;
106
+ const theme = chart.getTheme();
107
+ const y = chart.yScale.valueToY(value);
108
+
109
+ const direction: YLabelDirection =
110
+ previousClose === null ? 'neutral' : value > previousClose ? 'up' : value < previousClose ? 'down' : 'neutral';
111
+
112
+ let bgColor: string;
113
+ if (!isLive) {
114
+ bgColor = theme.axis.textColor;
115
+ } else if (color) {
116
+ bgColor = color;
117
+ } else {
118
+ bgColor =
119
+ direction === 'up'
120
+ ? theme.yLabel.upBackground
121
+ : direction === 'down'
122
+ ? theme.yLabel.downBackground
123
+ : theme.yLabel.neutralBackground;
124
+ }
125
+
126
+ if (children) {
127
+ return <>{children({ value, y, bgColor, isLive, direction, format: effectiveFormat })}</>;
128
+ }
129
+
130
+ return (
131
+ <>
132
+ <div
133
+ style={{
134
+ position: 'absolute',
135
+ left: 0,
136
+ right: chart.yAxisWidth,
137
+ top: y,
138
+ height: 0,
139
+ borderTop: `1px dashed ${bgColor}`,
140
+ opacity: 0.5,
141
+ pointerEvents: 'none',
142
+ zIndex: 2,
143
+ }}
144
+ />
145
+ <div
146
+ style={{
147
+ position: 'absolute',
148
+ right: 4,
149
+ top: y,
150
+ transform: 'translateY(-50%)',
151
+ pointerEvents: 'auto',
152
+ zIndex: 3,
153
+ background: bgColor,
154
+ color: theme.yLabel.textColor,
155
+ fontSize: theme.yLabel.fontSize,
156
+ fontFamily: theme.typography.fontFamily,
157
+ padding: '3px 8px',
158
+ borderRadius: 3,
159
+ whiteSpace: 'nowrap',
160
+ transition: 'background-color 0.3s ease',
161
+ }}
162
+ >
163
+ <NumberFlow value={value} format={effectiveFormat} spinDuration={350} />
164
+ </div>
165
+ </>
166
+ );
167
+ }