feat: 详情页K线改用 TradingView Lightweight Charts,蜡烛图原生支持
- 新增 KLineChart 组件(lightweight-charts v5):原生蜡烛图/折线切换、成交量副图(涨红跌绿)、自选日标记、双指缩放拖动 - 保留时间范围切换(3月/6月/1年,默认3月,一次性拉取365天前端切片) - 修复 lightweight-charts 无法解析 CSS 变量/oklch 颜色的问题(改用固定 hex) - 替换原 recharts K线(recharts Bar 自定义 shape 无法渲染蜡烛的问题彻底解决)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* K 线图组件(基于 TradingView Lightweight Charts v5)
|
||||
* 支持折线/蜡烛切换、成交量副图、自选日标记。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
|
||||
export interface KLineItem {
|
||||
time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
isAddedDate?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KLineItem[];
|
||||
mode: "line" | "candle";
|
||||
hasAddedDate?: boolean; // 是否在自选集合中(决定是否显示标记)
|
||||
}
|
||||
|
||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||
// 涨红跌绿 + 中性灰边框,亮色模式为主(暗色下亦可读)。
|
||||
const UP = "#ef4444";
|
||||
const DOWN = "#22c55e";
|
||||
const PRIMARY = "#ef4444";
|
||||
const TEXT = "#71717a"; // muted-foreground 近似灰
|
||||
const GRID = "#e4e4e7"; // border 近似灰
|
||||
const MARKER = "#f59e0b"; // 自选日标记
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||
|
||||
// 创建图表(仅一次)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const chart = createChart(containerRef.current, {
|
||||
autoSize: true,
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "transparent" },
|
||||
textColor: TEXT,
|
||||
fontSize: 10,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
},
|
||||
rightPriceScale: { borderColor: GRID },
|
||||
timeScale: { borderColor: GRID, timeVisible: false },
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
|
||||
// 成交量副图(pane 1)
|
||||
const vol = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "vol",
|
||||
});
|
||||
vol.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
volSeriesRef.current = vol;
|
||||
|
||||
return () => {
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
priceSeriesRef.current = null;
|
||||
volSeriesRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 模式切换:重建价格 series
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
// 移除旧的价格 series
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, hasAddedDate]);
|
||||
|
||||
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 ? UP : 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, []);
|
||||
}
|
||||
|
||||
// 自适应可见范围
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className="h-[320px] sm:h-[380px] md:h-[440px] w-full" />;
|
||||
}
|
||||
|
||||
export default KLineChart;
|
||||
Reference in New Issue
Block a user