@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,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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wick-charts/react — React bindings for Wick Charts.
|
|
3
|
+
*
|
|
4
|
+
* Everything importable from the library is re-exported here: components
|
|
5
|
+
* (`<ChartContainer>`, `<CandlestickSeries>`, …), option types, utility
|
|
6
|
+
* hooks, and themes. The underlying `@wick-charts/core` engine is bundled
|
|
7
|
+
* into this package — its source lives at
|
|
8
|
+
* https://github.com/mo4islona/wick-charts/tree/main/packages/core/src.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
AxisBound,
|
|
13
|
+
AxisConfig,
|
|
14
|
+
BarSeriesOptions,
|
|
15
|
+
/** @deprecated Use {@link StackingMode} instead. */
|
|
16
|
+
BarStacking,
|
|
17
|
+
BuildHoverSnapshotsArgs,
|
|
18
|
+
BuildLastSnapshotsArgs,
|
|
19
|
+
CandlestickSeriesOptions,
|
|
20
|
+
ChartLayout,
|
|
21
|
+
ChartOptions,
|
|
22
|
+
ChartTheme,
|
|
23
|
+
CrosshairPosition,
|
|
24
|
+
HoverInfo,
|
|
25
|
+
LegendItem,
|
|
26
|
+
/** @deprecated Use {@link TimePoint} instead. */
|
|
27
|
+
LineData,
|
|
28
|
+
LineSeriesOptions,
|
|
29
|
+
OHLCData,
|
|
30
|
+
OHLCInput,
|
|
31
|
+
PieSeriesOptions,
|
|
32
|
+
PieSliceData,
|
|
33
|
+
SeriesSnapshot,
|
|
34
|
+
SeriesType,
|
|
35
|
+
SliceInfo,
|
|
36
|
+
SnapshotSort,
|
|
37
|
+
StackingMode,
|
|
38
|
+
ThemeConfig,
|
|
39
|
+
ThemePreset,
|
|
40
|
+
TimePoint,
|
|
41
|
+
TimePointInput,
|
|
42
|
+
TimeValue,
|
|
43
|
+
TooltipField,
|
|
44
|
+
TooltipFormatter,
|
|
45
|
+
TooltipPosition,
|
|
46
|
+
TooltipPositionArgs,
|
|
47
|
+
Typography,
|
|
48
|
+
ValueFormatter,
|
|
49
|
+
VisibleRange,
|
|
50
|
+
XAxisConfig,
|
|
51
|
+
YAxisConfig,
|
|
52
|
+
YRange,
|
|
53
|
+
} from '@wick-charts/core';
|
|
54
|
+
export {
|
|
55
|
+
ChartInstance,
|
|
56
|
+
andromeda,
|
|
57
|
+
autoGradient,
|
|
58
|
+
ayuMirage,
|
|
59
|
+
buildHoverSnapshots,
|
|
60
|
+
buildLastSnapshots,
|
|
61
|
+
catppuccin,
|
|
62
|
+
computeTooltipPosition,
|
|
63
|
+
createTheme,
|
|
64
|
+
darkTheme,
|
|
65
|
+
detectInterval,
|
|
66
|
+
dracula,
|
|
67
|
+
formatCompact,
|
|
68
|
+
formatDate,
|
|
69
|
+
formatPriceAdaptive,
|
|
70
|
+
formatTime,
|
|
71
|
+
githubLight,
|
|
72
|
+
gruvbox,
|
|
73
|
+
handwritten,
|
|
74
|
+
highContrast,
|
|
75
|
+
lavenderMist,
|
|
76
|
+
lightPink,
|
|
77
|
+
lightTheme,
|
|
78
|
+
materialPalenight,
|
|
79
|
+
minimalLight,
|
|
80
|
+
mintBreeze,
|
|
81
|
+
monokaiPro,
|
|
82
|
+
nightOwl,
|
|
83
|
+
normalizeTime,
|
|
84
|
+
oneDarkPro,
|
|
85
|
+
panda,
|
|
86
|
+
peachCream,
|
|
87
|
+
quietLight,
|
|
88
|
+
resolveAxisFontSize,
|
|
89
|
+
resolveAxisTextColor,
|
|
90
|
+
resolveCandlestickBodyColor,
|
|
91
|
+
rosePineDawn,
|
|
92
|
+
sandDune,
|
|
93
|
+
solarizedLight,
|
|
94
|
+
} from '@wick-charts/core';
|
|
95
|
+
|
|
96
|
+
export { BarSeries } from './BarSeries';
|
|
97
|
+
export { CandlestickSeries } from './CandlestickSeries';
|
|
98
|
+
// React components
|
|
99
|
+
export { ChartContainer } from './ChartContainer';
|
|
100
|
+
// React hooks
|
|
101
|
+
export { useChartInstance } from './context';
|
|
102
|
+
export { LineSeries } from './LineSeries';
|
|
103
|
+
export { PieSeries } from './PieSeries';
|
|
104
|
+
export {
|
|
105
|
+
useCrosshairPosition,
|
|
106
|
+
useLastYValue,
|
|
107
|
+
usePreviousClose,
|
|
108
|
+
useVisibleRange,
|
|
109
|
+
useYRange,
|
|
110
|
+
} from './store-bridge';
|
|
111
|
+
export { ThemeProvider, useTheme } from './ThemeContext';
|
|
112
|
+
export { Crosshair } from './ui/Crosshair';
|
|
113
|
+
export type { InfoBarProps, InfoBarRenderContext } from './ui/InfoBar';
|
|
114
|
+
export { InfoBar } from './ui/InfoBar';
|
|
115
|
+
export type { LegendItemOverride, LegendProps } from './ui/Legend';
|
|
116
|
+
// Legend
|
|
117
|
+
export { Legend } from './ui/Legend';
|
|
118
|
+
export { NumberFlow } from './ui/NumberFlow';
|
|
119
|
+
export type { PieLegendMode, PieLegendPosition, PieLegendProps, PieLegendRenderContext } from './ui/PieLegend';
|
|
120
|
+
export { PieLegend } from './ui/PieLegend';
|
|
121
|
+
export { PieTooltip } from './ui/PieTooltip';
|
|
122
|
+
export type { SparklineProps, SparklineValuePosition, SparklineVariant } from './ui/Sparkline';
|
|
123
|
+
export { Sparkline } from './ui/Sparkline';
|
|
124
|
+
export { TimeAxis, TimeAxis as XAxis } from './ui/TimeAxis';
|
|
125
|
+
export type { TitleProps } from './ui/Title';
|
|
126
|
+
export { Title } from './ui/Title';
|
|
127
|
+
export type { TooltipProps, TooltipRenderContext, TooltipSort } from './ui/Tooltip';
|
|
128
|
+
// UI overlays
|
|
129
|
+
export { Tooltip } from './ui/Tooltip';
|
|
130
|
+
export type { YAxisProps } from './ui/YAxis';
|
|
131
|
+
export { YAxis } from './ui/YAxis';
|
|
132
|
+
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.axis.fontSize,
|
|
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,195 @@
|
|
|
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
|
+
resolveCandlestickBodyColor,
|
|
14
|
+
} from '@wick-charts/core';
|
|
15
|
+
|
|
16
|
+
import { useChartInstance } from '../context';
|
|
17
|
+
import { useCrosshairPosition } from '../store-bridge';
|
|
18
|
+
import { useTheme } from '../ThemeContext';
|
|
19
|
+
import type { TooltipSort } from './Tooltip';
|
|
20
|
+
|
|
21
|
+
/** Context passed to the {@link InfoBar} render-prop. */
|
|
22
|
+
export interface InfoBarRenderContext {
|
|
23
|
+
readonly snapshots: readonly SeriesSnapshot[];
|
|
24
|
+
/** Timestamp displayed. In hover mode it's the crosshair time; in last-mode it's the newest point. */
|
|
25
|
+
readonly time: number;
|
|
26
|
+
/** `true` while the user's pointer is over the chart (hover mode). */
|
|
27
|
+
readonly isHover: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Props for the {@link InfoBar} component. */
|
|
31
|
+
export interface InfoBarProps {
|
|
32
|
+
/** Sort order for line values (default: 'none'). */
|
|
33
|
+
sort?: TooltipSort;
|
|
34
|
+
/**
|
|
35
|
+
* Custom formatter for every displayed number in the default UI. Called per
|
|
36
|
+
* cell with the field hint (`'open' | 'high' | 'low' | 'close' | 'volume' | 'value'`).
|
|
37
|
+
* Defaults: adaptive precision for ohlc/value, compact (K/M/B/T) for volume.
|
|
38
|
+
* Ignored when {@link children} is a render-prop.
|
|
39
|
+
*/
|
|
40
|
+
format?: TooltipFormatter;
|
|
41
|
+
/**
|
|
42
|
+
* Render-prop escape hatch. Receives the computed snapshots and replaces the
|
|
43
|
+
* entire built-in layout. Filter, reorder, or re-style rows here without
|
|
44
|
+
* re-implementing any data wiring.
|
|
45
|
+
*/
|
|
46
|
+
children?: (ctx: InfoBarRenderContext) => ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default InfoBar formatter — adaptive for ohlc/value, compact for volume. */
|
|
50
|
+
const defaultInfoBarFormat: TooltipFormatter = (v, field) =>
|
|
51
|
+
field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compact OHLC/series info bar rendered as a flex row above the chart canvas.
|
|
55
|
+
* Pairs with {@link Tooltip} (which then only renders its floating near-cursor part).
|
|
56
|
+
*
|
|
57
|
+
* Pass a render-prop child for a custom layout — the built-in UI is used when
|
|
58
|
+
* {@link children} is omitted.
|
|
59
|
+
*/
|
|
60
|
+
export function InfoBar({ sort = 'none', format = defaultInfoBarFormat, children }: InfoBarProps) {
|
|
61
|
+
const chart = useChartInstance();
|
|
62
|
+
const theme = useTheme();
|
|
63
|
+
const crosshair = useCrosshairPosition(chart);
|
|
64
|
+
|
|
65
|
+
const [, bump] = useState(0);
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
const onOverlayChange = () => bump((n) => n + 1);
|
|
68
|
+
chart.on('overlayChange', onOverlayChange);
|
|
69
|
+
// Catch-up: a sibling series' layout effect may have registered data in
|
|
70
|
+
// the same commit. Bump so the next synchronous render picks it up.
|
|
71
|
+
if (chart.getSeriesIds().length > 0) bump((n) => n + 1);
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
chart.off('overlayChange', onOverlayChange);
|
|
75
|
+
};
|
|
76
|
+
}, [chart]);
|
|
77
|
+
|
|
78
|
+
// Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
|
|
79
|
+
// so a crosshair event fires for offsets past the plotted data. The
|
|
80
|
+
// nearest-time lookup then snaps to an out-of-range timestamp and returns
|
|
81
|
+
// no samples. Falling back to the last-mode snapshots here keeps the bar
|
|
82
|
+
// populated (showing last values at 0.6 opacity) instead of blinking out
|
|
83
|
+
// every time the pointer grazes the y-axis.
|
|
84
|
+
const lastSnapshots = buildLastSnapshots(chart, { sort, cacheKey: 'infobar-last' });
|
|
85
|
+
let snapshots = lastSnapshots;
|
|
86
|
+
let displayTime = lastSnapshots.length === 0 ? 0 : Math.max(...lastSnapshots.map((s) => s.data.time));
|
|
87
|
+
let isHover = false;
|
|
88
|
+
if (crosshair !== null) {
|
|
89
|
+
const hoverSnapshots = buildHoverSnapshots(chart, { time: crosshair.time, sort, cacheKey: 'infobar-hover' });
|
|
90
|
+
if (hoverSnapshots.length > 0) {
|
|
91
|
+
snapshots = hoverSnapshots;
|
|
92
|
+
// `snapshots[0].data.time` is index-0 → shifts when `sort` reorders.
|
|
93
|
+
// Use the raw crosshair time (what the user is pointing at) so the
|
|
94
|
+
// header stays stable across sort toggles.
|
|
95
|
+
displayTime = crosshair.time;
|
|
96
|
+
isHover = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (snapshots.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
if (children) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
data-tooltip-legend=""
|
|
106
|
+
style={{
|
|
107
|
+
display: 'flex',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
flexShrink: 0,
|
|
110
|
+
fontFamily: theme.typography.fontFamily,
|
|
111
|
+
fontSize: theme.typography.fontSize,
|
|
112
|
+
fontVariantNumeric: 'tabular-nums',
|
|
113
|
+
opacity: isHover ? 1 : 0.6,
|
|
114
|
+
transition: 'opacity 0.2s ease',
|
|
115
|
+
pointerEvents: 'none',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{children({ snapshots, time: displayTime, isHover })}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const dataInterval = chart.getDataInterval();
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
data-tooltip-legend=""
|
|
128
|
+
style={{
|
|
129
|
+
display: 'flex',
|
|
130
|
+
alignItems: 'center',
|
|
131
|
+
gap: 4,
|
|
132
|
+
flexWrap: 'wrap',
|
|
133
|
+
padding: '4px 8px',
|
|
134
|
+
flexShrink: 0,
|
|
135
|
+
fontSize: theme.typography.fontSize,
|
|
136
|
+
fontFamily: theme.typography.fontFamily,
|
|
137
|
+
fontVariantNumeric: 'tabular-nums',
|
|
138
|
+
opacity: isHover ? 1 : 0.6,
|
|
139
|
+
transition: 'opacity 0.2s ease',
|
|
140
|
+
pointerEvents: 'none',
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<span style={{ color: theme.axis.textColor, marginRight: 2 }}>{formatTime(displayTime, dataInterval)}</span>
|
|
144
|
+
{snapshots.map((s) => {
|
|
145
|
+
const isOHLC = 'open' in s.data;
|
|
146
|
+
if (isOHLC) {
|
|
147
|
+
const ohlc = s.data as OHLCData;
|
|
148
|
+
const isUp = ohlc.close >= ohlc.open;
|
|
149
|
+
const c = resolveCandlestickBodyColor(isUp ? theme.candlestick.up.body : theme.candlestick.down.body);
|
|
150
|
+
return (
|
|
151
|
+
<span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
152
|
+
<LegendItem label="O" display={format(ohlc.open, 'open')} color={c} dim={theme.axis.textColor} />
|
|
153
|
+
<LegendItem label="H" display={format(ohlc.high, 'high')} color={c} dim={theme.axis.textColor} />
|
|
154
|
+
<LegendItem label="L" display={format(ohlc.low, 'low')} color={c} dim={theme.axis.textColor} />
|
|
155
|
+
<LegendItem label="C" display={format(ohlc.close, 'close')} color={c} dim={theme.axis.textColor} />
|
|
156
|
+
{ohlc.volume != null && (
|
|
157
|
+
<LegendItem
|
|
158
|
+
label="V"
|
|
159
|
+
display={format(ohlc.volume, 'volume')}
|
|
160
|
+
color={theme.axis.textColor}
|
|
161
|
+
dim={theme.axis.textColor}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</span>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const line = s.data as TimePoint;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<span key={s.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
|
171
|
+
<span
|
|
172
|
+
style={{
|
|
173
|
+
width: 6,
|
|
174
|
+
height: 6,
|
|
175
|
+
borderRadius: '50%',
|
|
176
|
+
background: s.color,
|
|
177
|
+
flexShrink: 0,
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
<span style={{ color: s.color, fontWeight: 500 }}>{format(line.value, 'value')}</span>
|
|
181
|
+
</span>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function LegendItem({ label, display, color, dim }: { label: string; display: string; color: string; dim: string }) {
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<span style={{ color: dim, opacity: 0.5, marginLeft: 5 }}>{label}</span>
|
|
192
|
+
<span style={{ color, fontWeight: 500, marginLeft: 2 }}>{display}</span>
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
}
|