416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
/**
|
||
* 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;
|