/** * 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(null); const chartRef = useRef(null); // 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口, // 指标开关/显示方式切换时保留用户拖动缩放后的位置 const lastDataRef = useRef(null); const priceSeriesRef = useRef | null>(null); const volSeriesRef = useRef | null>(null); // 指标 series(重建用) const maSeriesRef = useRef[]>([]); const macdSeriesRef = useRef[]>([]); const rsiSeriesRef = useRef[]>([]); // 容器高度随指标 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 (
{legend.length > 0 && (
{legend.map((l) => ( {l.label} ))}
)}
); } export default KLineChart;