Files
auv/src/components/kline-chart.tsx
T

416 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* K 线图组件(基于 TradingView Lightweight Charts v5)
* 主图:蜡烛/折线 + MA(5/10/20/30);
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3),可开关。
*/
import { useEffect, useRef } from "react";
import {
createChart,
CandlestickSeries,
LineSeries,
HistogramSeries,
ColorType,
CrosshairMode,
LineStyle,
TickMarkType,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type ISeriesApi as ISeriesAny,
type Time,
} from "lightweight-charts";
import { sma, macd as calcMacd, rsi as calcRsi, type KBar } from "@/lib/indicators";
export interface KLineItem {
time: string | number; // ISO 日期 yyyy-mm-dd 或 Unix seconds UTC(lightweight-charts 两者均支持)
open: number;
close: number;
high: number;
low: number;
volume: number;
isAddedDate?: boolean;
}
export interface IndicatorToggles {
ma: boolean;
macd: boolean;
rsi: boolean;
}
interface Props {
data: KLineItem[];
mode: "line" | "candle";
hasAddedDate?: boolean;
indicators: IndicatorToggles;
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
defaultVisibleDays?: number;
}
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
const UP = "#ef4444";
const DOWN = "#22c55e";
// 成交量柱:浅色调,避免与 K 线柱体视觉争夺
const VOL_UP = "#fca5a5";
const VOL_DOWN = "#86efac";
const PRIMARY = "#ef4444";
const TEXT = "#71717a";
const GRID = "#e4e4e7";
const MARKER = "#f59e0b";
// MA / 指标线配色
const MA_COLORS = ["#f59e0b", "#3b82f6", "#a855f7"];
const MA_PERIODS = [5, 10, 20];
const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7";
// 时间统一按 UTC 取值格式化(kline-card 把北京时间墙钟视作 UTC 存储),
// 用本地时区方法会导致日期错位一天
function timeToDate(t: Time): Date {
if (typeof t === "number") return new Date(t * 1000);
if (typeof t === "string") return new Date(t.length === 10 ? `${t}T00:00:00Z` : t);
return new Date(Date.UTC(t.year, t.month - 1, t.day));
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 十字光标处的完整日期:2026-09-01 */
function formatCrosshairTime(t: Time): string {
const d = timeToDate(t);
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
/** 底部时间轴刻度:年→2026、月→2026-09、日→09-01(替代默认的 "01 9月 '26") */
function formatTickMark(t: Time, tickMarkType: TickMarkType): string {
const d = timeToDate(t);
if (tickMarkType === TickMarkType.Year) return `${d.getUTCFullYear()}`;
if (tickMarkType === TickMarkType.Month) return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
// 各 pane 固定高度:主图加高、成交量压低,指标副图居中
const MAIN_PANE_H = 320;
const VOL_PANE_H = 70;
const IND_PANE_H = 95;
export function KLineChart({ data, mode, hasAddedDate, indicators, defaultVisibleDays }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
const lastDataRef = useRef<KLineItem[] | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用)
const maSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
const macdSeriesRef = useRef<ISeriesAny<"Line" | "Histogram">[]>([]);
const rsiSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
// 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
const chartHeight = MAIN_PANE_H + VOL_PANE_H + extraPanes * IND_PANE_H;
// pane 顺序固定:0=主图 1=成交量 2+=指标副图;指标开关增删 pane 后重新应用高度
function applyPaneHeights(chart: IChartApi) {
chart.panes().forEach((pane, i) => {
pane.setHeight(i === 0 ? MAIN_PANE_H : i === 1 ? VOL_PANE_H : IND_PANE_H);
});
}
// 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const chart = createChart(el, {
width: el.clientWidth || 800,
height: chartHeight,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: TEXT,
fontSize: 10,
panes: { separatorColor: GRID, separatorHoverColor: "#94a3b8" },
},
grid: {
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
},
rightPriceScale: { borderColor: GRID },
localization: { timeFormatter: formatCrosshairTime },
timeScale: {
borderColor: GRID,
timeVisible: false,
tickMarkFormatter: formatTickMark,
},
crosshair: { mode: CrosshairMode.Normal },
});
chartRef.current = chart;
// 成交量放到独立 pane1,避免与主图K线重叠
const vol = chart.addSeries(
HistogramSeries,
{
priceFormat: { type: "volume" },
priceScaleId: "vol",
},
1,
);
volSeriesRef.current = vol;
applyPaneHeights(chart);
return () => {
chart.remove();
chartRef.current = null;
priceSeriesRef.current = null;
volSeriesRef.current = null;
maSeriesRef.current = [];
macdSeriesRef.current = [];
rsiSeriesRef.current = [];
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
lastDataRef.current = null;
};
}, []);
// 移除某组 series
function removeGroup(list: ISeriesAny<"Line" | "Histogram">[], chart: IChartApi) {
for (const s of list) {
try {
chart.removeSeries(s);
} catch {
// 已被移除
}
}
list.length = 0;
}
// 填充/重建全部指标 series
function rebuildIndicators() {
const chart = chartRef.current;
if (!chart) return;
// 指标开关改变容器高度后,同步图表尺寸
if (containerRef.current) {
chart.applyOptions({
width: containerRef.current.clientWidth || 800,
height: chartHeight,
});
}
const bars: KBar[] = data.map((d) => ({
time: d.time,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
}));
const closes = bars.map((b) => b.close);
// --- MA(主图 pane0 叠加)---
removeGroup(maSeriesRef.current, chart);
if (indicators.ma && bars.length > 0) {
MA_PERIODS.forEach((p, i) => {
const vals = sma(closes, p);
const s = chart.addSeries(
LineSeries,
{ color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
0,
);
s.setData(
bars
.map((b, idx) => ({ time: b.time as Time, value: vals[idx] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
maSeriesRef.current.push(s);
});
}
// --- MACD(pane2)---
removeGroup(macdSeriesRef.current, chart);
if (indicators.macd && bars.length > 0) {
const { dif, dea, hist } = calcMacd(closes);
const line = (color: string) =>
chart.addSeries(
LineSeries,
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
2,
);
const difS = line(MACD_DIF);
const deaS = line(MACD_DEA);
difS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dif[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
deaS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dea[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
const histS = chart.addSeries(
HistogramSeries,
{
priceLineVisible: false,
lastValueVisible: false,
},
2,
);
histS.setData(
bars
.map((b, i) => ({
time: b.time as Time,
value: hist[i],
color: (hist[i] ?? 0) >= 0 ? UP : DOWN,
}))
.filter((x) => x.value != null) as { time: Time; value: number; color: string }[],
);
macdSeriesRef.current.push(difS, deaS, histS);
}
// --- RSI(pane3)---
removeGroup(rsiSeriesRef.current, chart);
if (indicators.rsi && bars.length > 0) {
const vals = calcRsi(closes, 14);
const s = chart.addSeries(
LineSeries,
{ color: RSI_COLOR, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
3,
);
s.setData(
bars
.map((b, i) => ({ time: b.time as Time, value: vals[i] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
rsiSeriesRef.current.push(s);
}
applyPaneHeights(chart);
}
// 模式切换:重建价格 series + 指标
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
if (priceSeriesRef.current) {
chart.removeSeries(priceSeriesRef.current);
priceSeriesRef.current = null;
}
if (mode === "candle") {
priceSeriesRef.current = chart.addSeries(CandlestickSeries, {
upColor: UP,
downColor: DOWN,
borderUpColor: UP,
borderDownColor: DOWN,
wickUpColor: UP,
wickDownColor: DOWN,
});
} else {
priceSeriesRef.current = chart.addSeries(LineSeries, {
color: PRIMARY,
lineWidth: 2,
});
}
fillData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// 数据或指标开关变化:重填数据 + 重建指标
useEffect(() => {
fillData();
rebuildIndicators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasAddedDate, indicators]);
function fillData() {
const chart = chartRef.current;
const ps = priceSeriesRef.current;
const vs = volSeriesRef.current;
if (!chart || !ps || !vs) return;
if (data.length === 0) {
ps.setData([]);
vs.setData([]);
return;
}
if (mode === "candle") {
(ps as ISeriesApi<"Candlestick">).setData(
data.map((d) => ({
time: d.time as Time,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})),
);
} else {
(ps as ISeriesApi<"Line">).setData(
data.map((d) => ({ time: d.time as Time, value: d.close })),
);
}
vs.setData(
data.map((d) => ({
time: d.time as Time,
value: d.volume,
color: d.close >= d.open ? VOL_UP : VOL_DOWN,
})),
);
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
if (markerItem) {
createSeriesMarkers(ps, [
{
time: markerItem.time as Time,
position: "aboveBar",
color: MARKER,
shape: "circle",
text: "自选",
},
]);
} else {
createSeriesMarkers(ps, []);
}
if (lastDataRef.current === data) return;
lastDataRef.current = data;
if (defaultVisibleDays) {
// 默认视口只展示最近 N 个自然日,更早的数据靠向左拖动查看
const toMs = (t: string | number) =>
typeof t === "number" ? t * 1000 : new Date(t).getTime();
const lastMs = toMs(data[data.length - 1].time);
let fromIdx = data.findIndex(
(d) => toMs(d.time) >= lastMs - defaultVisibleDays * 86400_000,
);
if (fromIdx < 0) fromIdx = 0;
chart
.timeScale()
.setVisibleLogicalRange({ from: fromIdx, to: data.length - 1 + 2 });
} else {
chart.timeScale().fitContent();
}
}
// 颜色图例:根据启用的指标生成
const legend: { color: string; label: string }[] = [];
if (indicators.ma) {
MA_PERIODS.forEach((p, i) => legend.push({ color: MA_COLORS[i % MA_COLORS.length], label: `MA${p}` }));
}
if (indicators.macd) {
legend.push({ color: MACD_DIF, label: "DIF" }, { color: MACD_DEA, label: "DEA" });
}
if (indicators.rsi) legend.push({ color: RSI_COLOR, label: "RSI14" });
return (
<div className="w-full">
<div
ref={containerRef}
style={{ height: `${chartHeight}px` }}
className="w-full transition-[height] duration-200"
/>
{legend.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-[11px] text-muted-foreground">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1">
<span className="h-0.5 w-3 rounded" style={{ backgroundColor: l.color }} />
{l.label}
</span>
))}
</div>
)}
</div>
);
}
export default KLineChart;