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