@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,110 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+
3
+ import { type LineSeriesOptions, type TimePoint, normalizeTime } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from './context';
6
+
7
+ export interface LineSeriesProps {
8
+ /** Array of datasets — one per layer. A single line uses `[data]`. */
9
+ data: TimePoint[][];
10
+ options?: Partial<LineSeriesOptions>;
11
+ /** Stable series ID — same value across remounts. */
12
+ id?: string;
13
+ }
14
+
15
+ /** Only fall back to a full `setSeriesData` replace when more than this many new
16
+ * points appear in a single tick — otherwise streamed updates would always look
17
+ * like bulk loads and the renderer would clear its entrance-animation entries. */
18
+ const BULK_THRESHOLD = 20;
19
+
20
+ export function LineSeries({ data, options, id: idProp }: LineSeriesProps) {
21
+ const chart = useChartInstance();
22
+ const seriesRef = useRef<string | null>(null);
23
+ const prevLensRef = useRef<number[]>([]);
24
+ const prevFirstTimesRef = useRef<(number | null)[]>([]);
25
+ const prevLastTimesRef = useRef<(number | null)[]>([]);
26
+
27
+ useLayoutEffect(() => {
28
+ const id = chart.addLineSeries({ ...options, layers: data.length, id: idProp });
29
+ seriesRef.current = id;
30
+ prevLensRef.current = new Array(data.length).fill(0);
31
+ prevFirstTimesRef.current = new Array(data.length).fill(null);
32
+ prevLastTimesRef.current = new Array(data.length).fill(null);
33
+ return () => {
34
+ chart.removeSeries(id);
35
+ seriesRef.current = null;
36
+ prevLensRef.current = [];
37
+ prevFirstTimesRef.current = [];
38
+ prevLastTimesRef.current = [];
39
+ };
40
+ }, [chart, data.length, idProp]);
41
+
42
+ useLayoutEffect(() => {
43
+ const id = seriesRef.current;
44
+ if (!id) return;
45
+
46
+ chart.batch(() => {
47
+ for (let i = 0; i < data.length; i++) {
48
+ const layer = data[i];
49
+ const prevLen = prevLensRef.current[i] ?? 0;
50
+ const prevFirst = prevFirstTimesRef.current[i] ?? null;
51
+
52
+ if (layer.length === 0) {
53
+ chart.setSeriesData(id, [], i);
54
+ prevLensRef.current[i] = 0;
55
+ prevFirstTimesRef.current[i] = null;
56
+ prevLastTimesRef.current[i] = null;
57
+ continue;
58
+ }
59
+
60
+ const firstTime = normalizeTime(layer[0].time);
61
+ const lastTime = normalizeTime(layer[layer.length - 1].time);
62
+ const prevLast = prevLastTimesRef.current[i] ?? null;
63
+ const shifted = prevFirst !== null && prevFirst !== firstTime;
64
+ const added = layer.length - prevLen;
65
+ const hasNewLast = prevLast !== null && prevLast !== lastTime;
66
+
67
+ // Rolling-window slide (maxPoints cap): drop oldest, append newest,
68
+ // length unchanged. Sync prefix then appendData the new tail so the
69
+ // entrance animation fires instead of getting wiped by setSeriesData.
70
+ if (shifted && added === 0 && hasNewLast) {
71
+ chart.setSeriesData(id, layer.slice(0, -1), i);
72
+ chart.appendData(id, layer[layer.length - 1], i);
73
+ } else if (prevLen === 0 || layer.length < prevLen || added > BULK_THRESHOLD || shifted) {
74
+ chart.setSeriesData(id, layer, i);
75
+ } else if (layer.length === prevLen) {
76
+ chart.updateData(id, layer[layer.length - 1], i);
77
+ } else {
78
+ for (let j = prevLen; j < layer.length; j++) {
79
+ chart.appendData(id, layer[j], i);
80
+ }
81
+ }
82
+
83
+ prevLensRef.current[i] = layer.length;
84
+ prevFirstTimesRef.current[i] = firstTime;
85
+ prevLastTimesRef.current[i] = lastTime;
86
+ }
87
+ });
88
+ }, [chart, data]);
89
+
90
+ useEffect(() => {
91
+ if (seriesRef.current && options) {
92
+ chart.updateSeriesOptions(seriesRef.current, options);
93
+ }
94
+ }, [
95
+ chart,
96
+ options?.colors?.join(','),
97
+ options?.strokeWidth,
98
+ options?.area?.visible,
99
+ (options as { areaFill?: boolean } | undefined)?.areaFill,
100
+ options?.pulse,
101
+ options?.stacking,
102
+ options?.entryAnimation,
103
+ options?.enterAnimation,
104
+ options?.entryMs,
105
+ options?.enterMs,
106
+ options?.smoothMs,
107
+ ]);
108
+
109
+ return null;
110
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useLayoutEffect, useRef } from 'react';
2
+
3
+ import type { PieSeriesOptions, PieSliceData } from '@wick-charts/core';
4
+
5
+ import { useChartInstance } from './context';
6
+
7
+ export interface PieSeriesProps {
8
+ data: PieSliceData[];
9
+ options?: Partial<PieSeriesOptions>;
10
+ /** Stable series ID — same value across remounts. */
11
+ id?: string;
12
+ }
13
+
14
+ /** Pie chart series. Set `options.innerRadiusRatio` > 0 for donut. */
15
+ export function PieSeries({ data, options, id: idProp }: PieSeriesProps) {
16
+ const chart = useChartInstance();
17
+ const seriesRef = useRef<string | null>(null);
18
+
19
+ useLayoutEffect(() => {
20
+ const id = chart.addPieSeries({ ...options, id: idProp });
21
+ seriesRef.current = id;
22
+ return () => {
23
+ chart.removeSeries(id);
24
+ seriesRef.current = null;
25
+ };
26
+ }, [chart, idProp]);
27
+
28
+ useEffect(() => {
29
+ if (seriesRef.current && options) {
30
+ chart.updateSeriesOptions(seriesRef.current, options);
31
+ }
32
+ }, [
33
+ chart,
34
+ options?.innerRadiusRatio,
35
+ options?.padAngle,
36
+ options?.animate,
37
+ options?.shadow,
38
+ options?.innerShadow,
39
+ options?.colors,
40
+ options?.sliceLabels?.mode,
41
+ options?.sliceLabels?.content,
42
+ options?.sliceLabels?.fontSize,
43
+ options?.sliceLabels?.minSliceAngle,
44
+ options?.sliceLabels?.elbowLen,
45
+ options?.sliceLabels?.legPad,
46
+ options?.sliceLabels?.labelGap,
47
+ options?.sliceLabels?.distance,
48
+ options?.sliceLabels?.railWidth,
49
+ options?.sliceLabels?.balanceSides,
50
+ ]);
51
+
52
+ useLayoutEffect(() => {
53
+ if (seriesRef.current) {
54
+ chart.setSeriesData(seriesRef.current, data);
55
+ }
56
+ }, [chart, data]);
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ import type { ChartTheme } from '@wick-charts/core';
4
+
5
+ const ThemeCtx = createContext<ChartTheme | null>(null);
6
+
7
+ export const ThemeProvider = ThemeCtx.Provider;
8
+
9
+ /** Read the current chart theme from context. Must be inside a ThemeProvider. */
10
+ export function useTheme(): ChartTheme {
11
+ const theme = useContext(ThemeCtx);
12
+ if (!theme) {
13
+ throw new Error('useTheme must be used within <ThemeProvider>');
14
+ }
15
+ return theme;
16
+ }
17
+
18
+ /** Read the theme from context, or return null if no provider. */
19
+ export function useThemeOptional(): ChartTheme | null {
20
+ return useContext(ThemeCtx);
21
+ }
package/src/context.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ import type { ChartInstance } from '@wick-charts/core';
4
+
5
+ export const ChartContext = createContext<ChartInstance | null>(null);
6
+
7
+ export function useChartInstance(): ChartInstance {
8
+ const chart = useContext(ChartContext);
9
+ if (!chart) {
10
+ throw new Error('useChartInstance must be used within <ChartContainer>');
11
+ }
12
+ return chart;
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Re-export core (users import everything from '@wick-charts/react')
2
+
3
+ export type {
4
+ AxisBound,
5
+ AxisConfig,
6
+ BarSeriesOptions,
7
+ /** @deprecated Use {@link StackingMode} instead. */
8
+ BarStacking,
9
+ BuildHoverSnapshotsArgs,
10
+ BuildLastSnapshotsArgs,
11
+ CandlestickSeriesOptions,
12
+ ChartLayout,
13
+ ChartOptions,
14
+ ChartTheme,
15
+ CrosshairPosition,
16
+ HoverInfo,
17
+ LegendItem,
18
+ /** @deprecated Use {@link TimePoint} instead. */
19
+ LineData,
20
+ LineSeriesOptions,
21
+ OHLCData,
22
+ OHLCInput,
23
+ PieSeriesOptions,
24
+ PieSliceData,
25
+ SeriesSnapshot,
26
+ SeriesType,
27
+ SliceInfo,
28
+ SnapshotSort,
29
+ StackingMode,
30
+ ThemeConfig,
31
+ ThemePreset,
32
+ TimePoint,
33
+ TimePointInput,
34
+ TimeValue,
35
+ TooltipField,
36
+ TooltipFormatter,
37
+ TooltipPosition,
38
+ TooltipPositionArgs,
39
+ Typography,
40
+ ValueFormatter,
41
+ VisibleRange,
42
+ XAxisConfig,
43
+ YAxisConfig,
44
+ YRange,
45
+ } from '@wick-charts/core';
46
+ export {
47
+ ChartInstance,
48
+ andromeda,
49
+ ayuMirage,
50
+ buildHoverSnapshots,
51
+ buildLastSnapshots,
52
+ catppuccin,
53
+ computeTooltipPosition,
54
+ createTheme,
55
+ darkTheme,
56
+ detectInterval,
57
+ dracula,
58
+ formatCompact,
59
+ formatDate,
60
+ formatPriceAdaptive,
61
+ formatTime,
62
+ githubLight,
63
+ gruvbox,
64
+ handwritten,
65
+ highContrast,
66
+ lavenderMist,
67
+ lightPink,
68
+ lightTheme,
69
+ materialPalenight,
70
+ minimalLight,
71
+ mintBreeze,
72
+ monokaiPro,
73
+ nightOwl,
74
+ normalizeTime,
75
+ oneDarkPro,
76
+ panda,
77
+ peachCream,
78
+ quietLight,
79
+ rosePineDawn,
80
+ sandDune,
81
+ solarizedLight,
82
+ } from '@wick-charts/core';
83
+
84
+ export { BarSeries } from './BarSeries';
85
+ export { CandlestickSeries } from './CandlestickSeries';
86
+ // React components
87
+ export { ChartContainer } from './ChartContainer';
88
+ // React hooks
89
+ export { useChartInstance } from './context';
90
+ export { LineSeries } from './LineSeries';
91
+ export { PieSeries } from './PieSeries';
92
+ export {
93
+ useCrosshairPosition,
94
+ useLastYValue,
95
+ usePreviousClose,
96
+ useVisibleRange,
97
+ useYRange,
98
+ } from './store-bridge';
99
+ export { ThemeProvider, useTheme } from './ThemeContext';
100
+ export { Crosshair } from './ui/Crosshair';
101
+ export type { InfoBarProps, InfoBarRenderContext } from './ui/InfoBar';
102
+ export { InfoBar } from './ui/InfoBar';
103
+ export type { LegendItemOverride, LegendProps } from './ui/Legend';
104
+ // Legend
105
+ export { Legend } from './ui/Legend';
106
+ export { NumberFlow } from './ui/NumberFlow';
107
+ export type { PieLegendMode, PieLegendPosition, PieLegendProps, PieLegendRenderContext } from './ui/PieLegend';
108
+ export { PieLegend } from './ui/PieLegend';
109
+ export { PieTooltip } from './ui/PieTooltip';
110
+ export type { SparklineProps, SparklineValuePosition, SparklineVariant } from './ui/Sparkline';
111
+ export { Sparkline } from './ui/Sparkline';
112
+ export { TimeAxis, TimeAxis as XAxis } from './ui/TimeAxis';
113
+ export type { TitleProps } from './ui/Title';
114
+ export { Title } from './ui/Title';
115
+ export type { TooltipProps, TooltipRenderContext, TooltipSort } from './ui/Tooltip';
116
+ // UI overlays
117
+ export { Tooltip } from './ui/Tooltip';
118
+ export type { YAxisProps } from './ui/YAxis';
119
+ export { YAxis } from './ui/YAxis';
120
+ export { YLabel } from './ui/YLabel';
@@ -0,0 +1,100 @@
1
+ import { useMemo, useSyncExternalStore } from 'react';
2
+
3
+ import type { ChartInstance, CrosshairPosition, VisibleRange, YRange } from '@wick-charts/core';
4
+
5
+ type ChartEvent = 'crosshairMove' | 'viewportChange' | 'dataUpdate' | 'seriesChange';
6
+
7
+ // `chart.on` is typed per-event (e.g. `crosshairMove` passes `CrosshairPosition`)
8
+ // but the React bridge only needs a generic "something changed" ping. Narrow
9
+ // via `Parameters<ChartInstance['on']>` so we stay inside the public surface
10
+ // without collapsing to `any`.
11
+ type ChartOnListener = Parameters<ChartInstance['on']>[1];
12
+
13
+ function createStore<T>(chart: ChartInstance, events: ChartEvent | ChartEvent[], getSnapshot: () => T) {
14
+ const list = Array.isArray(events) ? events : [events];
15
+ return {
16
+ subscribe: (callback: () => void) => {
17
+ const listener = callback as unknown as ChartOnListener;
18
+ for (const e of list) chart.on(e, listener);
19
+ return () => {
20
+ for (const e of list) chart.off(e, listener);
21
+ };
22
+ },
23
+ getSnapshot,
24
+ };
25
+ }
26
+
27
+ export function useVisibleRange(chart: ChartInstance): VisibleRange {
28
+ const store = useMemo(
29
+ () => createStore(chart, ['viewportChange', 'dataUpdate', 'seriesChange'], () => chart.getVisibleRange()),
30
+ [chart],
31
+ );
32
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
33
+ }
34
+
35
+ export function useYRange(chart: ChartInstance): YRange {
36
+ const store = useMemo(
37
+ () => createStore(chart, ['viewportChange', 'dataUpdate', 'seriesChange'], () => chart.getYRange()),
38
+ [chart],
39
+ );
40
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
41
+ }
42
+
43
+ export function useLastYValue(chart: ChartInstance, seriesId: string): { value: number; isLive: boolean } | null {
44
+ const store = useMemo(() => {
45
+ let snapshot = chart.getLastValue(seriesId);
46
+ // Remember the pixel Y that corresponds to `snapshot` so we can detect
47
+ // viewport shifts (resize, headerLayout toggle, zoom/pan) where the value
48
+ // is unchanged but the badge needs to move. Computing both prev and next
49
+ // against the current yScale would always compare equal.
50
+ let lastY = snapshot ? chart.yScale.valueToY(snapshot.value) : null;
51
+ const getSnapshot = () => snapshot;
52
+
53
+ const refresh = (): boolean => {
54
+ const next = chart.getLastValue(seriesId);
55
+ const nextY = next ? chart.yScale.valueToY(next.value) : null;
56
+ if (snapshot?.value === next?.value && snapshot?.isLive === next?.isLive && lastY === nextY) {
57
+ return false;
58
+ }
59
+
60
+ snapshot = next;
61
+ lastY = nextY;
62
+ return true;
63
+ };
64
+
65
+ return {
66
+ subscribe: (callback: () => void) => {
67
+ // The snapshot captured in useMemo can predate the series being added
68
+ // (LineSeries's useLayoutEffect runs after YLabel's initial render, so
69
+ // getLastValue returned null). Reconcile before listeners attach —
70
+ // useSyncExternalStore re-reads getSnapshot after subscribe and will
71
+ // force a re-render when the value differs from the last one it saw.
72
+ refresh();
73
+ const onChange = () => {
74
+ if (refresh()) callback();
75
+ };
76
+ chart.on('dataUpdate', onChange);
77
+ chart.on('viewportChange', onChange);
78
+ return () => {
79
+ chart.off('dataUpdate', onChange);
80
+ chart.off('viewportChange', onChange);
81
+ };
82
+ },
83
+ getSnapshot,
84
+ };
85
+ }, [chart, seriesId]);
86
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
87
+ }
88
+
89
+ export function usePreviousClose(chart: ChartInstance, seriesId: string): number | null {
90
+ const store = useMemo(
91
+ () => createStore(chart, 'dataUpdate', () => chart.getPreviousClose(seriesId)),
92
+ [chart, seriesId],
93
+ );
94
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
95
+ }
96
+
97
+ export function useCrosshairPosition(chart: ChartInstance): CrosshairPosition | null {
98
+ const store = useMemo(() => createStore(chart, 'crosshairMove', () => chart.getCrosshairPosition()), [chart]);
99
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
100
+ }
@@ -0,0 +1,61 @@
1
+ import { formatTime } from '@wick-charts/core';
2
+
3
+ import { useChartInstance } from '../context';
4
+ import { useCrosshairPosition } from '../store-bridge';
5
+
6
+ export function Crosshair() {
7
+ const chart = useChartInstance();
8
+ const position = useCrosshairPosition(chart);
9
+
10
+ if (!position) return null;
11
+
12
+ const theme = chart.getTheme();
13
+ const dataInterval = chart.getDataInterval();
14
+
15
+ const labelStyle = {
16
+ // Blend the theme's labelBackground at 80% opacity so the axis grid
17
+ // shows through — matches TradingView-style overlays and keeps the
18
+ // badge from looking like an opaque block.
19
+ background: `color-mix(in srgb, ${theme.crosshair.labelBackground} 80%, transparent)`,
20
+ color: theme.crosshair.labelTextColor,
21
+ fontSize: theme.typography.axisFontSize,
22
+ fontFamily: theme.typography.fontFamily,
23
+ fontVariantNumeric: 'tabular-nums' as const,
24
+ padding: '2px 6px',
25
+ borderRadius: 2,
26
+ whiteSpace: 'nowrap' as const,
27
+ pointerEvents: 'none' as const,
28
+ // Sit above axis ticks (z:0) but below the YLabel badge (z:3) so the
29
+ // live last-value stays visible when the crosshair crosses its row.
30
+ zIndex: 2,
31
+ };
32
+
33
+ return (
34
+ <>
35
+ {/* Y label on right axis */}
36
+ <div
37
+ style={{
38
+ position: 'absolute',
39
+ right: 0,
40
+ top: position.mediaY,
41
+ transform: 'translateY(-50%)',
42
+ ...labelStyle,
43
+ }}
44
+ >
45
+ {chart.yScale.formatY(position.y)}
46
+ </div>
47
+ {/* Time label on bottom axis */}
48
+ <div
49
+ style={{
50
+ position: 'absolute',
51
+ bottom: 0,
52
+ left: position.mediaX,
53
+ transform: 'translateX(-50%)',
54
+ ...labelStyle,
55
+ }}
56
+ >
57
+ {formatTime(position.time, dataInterval)}
58
+ </div>
59
+ </>
60
+ );
61
+ }
@@ -0,0 +1,194 @@
1
+ import { type ReactNode, useLayoutEffect, useState } from 'react';
2
+
3
+ import {
4
+ type OHLCData,
5
+ type SeriesSnapshot,
6
+ type TimePoint,
7
+ type TooltipFormatter,
8
+ buildHoverSnapshots,
9
+ buildLastSnapshots,
10
+ formatCompact,
11
+ formatPriceAdaptive,
12
+ formatTime,
13
+ } from '@wick-charts/core';
14
+
15
+ import { useChartInstance } from '../context';
16
+ import { useCrosshairPosition } from '../store-bridge';
17
+ import { useTheme } from '../ThemeContext';
18
+ import type { TooltipSort } from './Tooltip';
19
+
20
+ /** Context passed to the {@link InfoBar} render-prop. */
21
+ export interface InfoBarRenderContext {
22
+ readonly snapshots: readonly SeriesSnapshot[];
23
+ /** Timestamp displayed. In hover mode it's the crosshair time; in last-mode it's the newest point. */
24
+ readonly time: number;
25
+ /** `true` while the user's pointer is over the chart (hover mode). */
26
+ readonly isHover: boolean;
27
+ }
28
+
29
+ /** Props for the {@link InfoBar} component. */
30
+ export interface InfoBarProps {
31
+ /** Sort order for line values (default: 'none'). */
32
+ sort?: TooltipSort;
33
+ /**
34
+ * Custom formatter for every displayed number in the default UI. Called per
35
+ * cell with the field hint (`'open' | 'high' | 'low' | 'close' | 'volume' | 'value'`).
36
+ * Defaults: adaptive precision for ohlc/value, compact (K/M/B/T) for volume.
37
+ * Ignored when {@link children} is a render-prop.
38
+ */
39
+ format?: TooltipFormatter;
40
+ /**
41
+ * Render-prop escape hatch. Receives the computed snapshots and replaces the
42
+ * entire built-in layout. Filter, reorder, or re-style rows here without
43
+ * re-implementing any data wiring.
44
+ */
45
+ children?: (ctx: InfoBarRenderContext) => ReactNode;
46
+ }
47
+
48
+ /** Default InfoBar formatter — adaptive for ohlc/value, compact for volume. */
49
+ const defaultInfoBarFormat: TooltipFormatter = (v, field) =>
50
+ field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v);
51
+
52
+ /**
53
+ * Compact OHLC/series info bar rendered as a flex row above the chart canvas.
54
+ * Pairs with {@link Tooltip} (which then only renders its floating near-cursor part).
55
+ *
56
+ * Pass a render-prop child for a custom layout — the built-in UI is used when
57
+ * {@link children} is omitted.
58
+ */
59
+ export function InfoBar({ sort = 'none', format = defaultInfoBarFormat, children }: InfoBarProps) {
60
+ const chart = useChartInstance();
61
+ const theme = useTheme();
62
+ const crosshair = useCrosshairPosition(chart);
63
+
64
+ const [, bump] = useState(0);
65
+ useLayoutEffect(() => {
66
+ const onOverlayChange = () => bump((n) => n + 1);
67
+ chart.on('overlayChange', onOverlayChange);
68
+ // Catch-up: a sibling series' layout effect may have registered data in
69
+ // the same commit. Bump so the next synchronous render picks it up.
70
+ if (chart.getSeriesIds().length > 0) bump((n) => n + 1);
71
+
72
+ return () => {
73
+ chart.off('overlayChange', onOverlayChange);
74
+ };
75
+ }, [chart]);
76
+
77
+ // Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
78
+ // so a crosshair event fires for offsets past the plotted data. The
79
+ // nearest-time lookup then snaps to an out-of-range timestamp and returns
80
+ // no samples. Falling back to the last-mode snapshots here keeps the bar
81
+ // populated (showing last values at 0.6 opacity) instead of blinking out
82
+ // every time the pointer grazes the y-axis.
83
+ const lastSnapshots = buildLastSnapshots(chart, { sort, cacheKey: 'infobar-last' });
84
+ let snapshots = lastSnapshots;
85
+ let displayTime = lastSnapshots.length === 0 ? 0 : Math.max(...lastSnapshots.map((s) => s.data.time));
86
+ let isHover = false;
87
+ if (crosshair !== null) {
88
+ const hoverSnapshots = buildHoverSnapshots(chart, { time: crosshair.time, sort, cacheKey: 'infobar-hover' });
89
+ if (hoverSnapshots.length > 0) {
90
+ snapshots = hoverSnapshots;
91
+ // `snapshots[0].data.time` is index-0 → shifts when `sort` reorders.
92
+ // Use the raw crosshair time (what the user is pointing at) so the
93
+ // header stays stable across sort toggles.
94
+ displayTime = crosshair.time;
95
+ isHover = true;
96
+ }
97
+ }
98
+
99
+ if (snapshots.length === 0) return null;
100
+
101
+ if (children) {
102
+ return (
103
+ <div
104
+ data-tooltip-legend=""
105
+ style={{
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ flexShrink: 0,
109
+ fontFamily: theme.typography.fontFamily,
110
+ fontSize: theme.typography.fontSize,
111
+ fontVariantNumeric: 'tabular-nums',
112
+ opacity: isHover ? 1 : 0.6,
113
+ transition: 'opacity 0.2s ease',
114
+ pointerEvents: 'none',
115
+ }}
116
+ >
117
+ {children({ snapshots, time: displayTime, isHover })}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ const dataInterval = chart.getDataInterval();
123
+
124
+ return (
125
+ <div
126
+ data-tooltip-legend=""
127
+ style={{
128
+ display: 'flex',
129
+ alignItems: 'center',
130
+ gap: 4,
131
+ flexWrap: 'wrap',
132
+ padding: '4px 8px',
133
+ flexShrink: 0,
134
+ fontSize: theme.typography.fontSize,
135
+ fontFamily: theme.typography.fontFamily,
136
+ fontVariantNumeric: 'tabular-nums',
137
+ opacity: isHover ? 1 : 0.6,
138
+ transition: 'opacity 0.2s ease',
139
+ pointerEvents: 'none',
140
+ }}
141
+ >
142
+ <span style={{ color: theme.axis.textColor, marginRight: 2 }}>{formatTime(displayTime, dataInterval)}</span>
143
+ {snapshots.map((s) => {
144
+ const isOHLC = 'open' in s.data;
145
+ if (isOHLC) {
146
+ const ohlc = s.data as OHLCData;
147
+ const isUp = ohlc.close >= ohlc.open;
148
+ const c = isUp ? theme.candlestick.upColor : theme.candlestick.downColor;
149
+ return (
150
+ <span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
151
+ <LegendItem label="O" display={format(ohlc.open, 'open')} color={c} dim={theme.axis.textColor} />
152
+ <LegendItem label="H" display={format(ohlc.high, 'high')} color={c} dim={theme.axis.textColor} />
153
+ <LegendItem label="L" display={format(ohlc.low, 'low')} color={c} dim={theme.axis.textColor} />
154
+ <LegendItem label="C" display={format(ohlc.close, 'close')} color={c} dim={theme.axis.textColor} />
155
+ {ohlc.volume != null && (
156
+ <LegendItem
157
+ label="V"
158
+ display={format(ohlc.volume, 'volume')}
159
+ color={theme.axis.textColor}
160
+ dim={theme.axis.textColor}
161
+ />
162
+ )}
163
+ </span>
164
+ );
165
+ }
166
+ const line = s.data as TimePoint;
167
+
168
+ return (
169
+ <span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
170
+ <span
171
+ style={{
172
+ width: 6,
173
+ height: 6,
174
+ borderRadius: '50%',
175
+ background: s.color,
176
+ flexShrink: 0,
177
+ }}
178
+ />
179
+ <span style={{ color: s.color, fontWeight: 500 }}>{format(line.value, 'value')}</span>
180
+ </span>
181
+ );
182
+ })}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function LegendItem({ label, display, color, dim }: { label: string; display: string; color: string; dim: string }) {
188
+ return (
189
+ <>
190
+ <span style={{ color: dim, opacity: 0.5, marginLeft: 5 }}>{label}</span>
191
+ <span style={{ color, fontWeight: 500, marginLeft: 2 }}>{display}</span>
192
+ </>
193
+ );
194
+ }