@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,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
|
+
}
|
package/src/ui/YAxis.tsx
ADDED
|
@@ -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
|
+
}
|