feat: 详情页K线增加技术指标 MA/MACD/RSI/KDJ
- 新增 indicators.ts 指标计算库:SMA/EMA/MACD(12,26,9)/RSI(14)/KDJ(9,3,3) - KLineChart 支持指标开关:MA(5/10/20/30) 主图叠加、MACD/RSI/KDJ 独立副图 pane - 图表高度随指标 pane 数量动态增长(主图 300 + 每个副图 95),主图不被压缩 - 指标全部前端本地计算,无需后端
This commit is contained in:
+182
-24
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* K 线图组件(基于 TradingView Lightweight Charts v5)
|
||||
* 支持折线/蜡烛切换、成交量副图、自选日标记。
|
||||
* 主图:蜡烛/折线 + MA(5/10/20/30);
|
||||
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3)、KDJ(pane4),可开关。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
@@ -14,8 +15,10 @@ import {
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type ISeriesApi as ISeriesAny,
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
import { sma, macd as calcMacd, rsi as calcRsi, kdj as calcKdj, type KBar } from "@/lib/indicators";
|
||||
|
||||
export interface KLineItem {
|
||||
time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需)
|
||||
@@ -27,36 +30,66 @@ export interface KLineItem {
|
||||
isAddedDate?: boolean;
|
||||
}
|
||||
|
||||
export interface IndicatorToggles {
|
||||
ma: boolean;
|
||||
macd: boolean;
|
||||
rsi: boolean;
|
||||
kdj: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KLineItem[];
|
||||
mode: "line" | "candle";
|
||||
hasAddedDate?: boolean; // 是否在自选集合中(决定是否显示标记)
|
||||
hasAddedDate?: boolean;
|
||||
indicators: IndicatorToggles;
|
||||
}
|
||||
|
||||
// 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"; // 自选日标记
|
||||
const TEXT = "#71717a";
|
||||
const GRID = "#e4e4e7";
|
||||
const MARKER = "#f59e0b";
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
// MA / 指标线配色
|
||||
const MA_COLORS = ["#f59e0b", "#3b82f6", "#a855f7", "#10b981"];
|
||||
const MA_PERIODS = [5, 10, 20, 30];
|
||||
const MACD_DIF = "#3b82f6";
|
||||
const MACD_DEA = "#f59e0b";
|
||||
const RSI_COLOR = "#a855f7";
|
||||
const KDJ_K = "#3b82f6";
|
||||
const KDJ_D = "#f59e0b";
|
||||
const KDJ_J = "#a855f7";
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators }: 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);
|
||||
// 指标 series(重建用)
|
||||
const maSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
|
||||
const macdSeriesRef = useRef<ISeriesAny<"Line" | "Histogram">[]>([]);
|
||||
const rsiSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
|
||||
const kdjSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
|
||||
|
||||
// 创建图表(仅一次)
|
||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||
const extraPanes = [indicators.macd, indicators.rsi, indicators.kdj].filter(Boolean).length;
|
||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
||||
const chartHeight = 300 + extraPanes * 95;
|
||||
|
||||
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const chart = createChart(containerRef.current, {
|
||||
autoSize: true,
|
||||
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 },
|
||||
@@ -68,14 +101,11 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
});
|
||||
chartRef.current = chart;
|
||||
|
||||
// 成交量副图(pane 1)
|
||||
const vol = chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "vol",
|
||||
});
|
||||
vol.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.8, bottom: 0 },
|
||||
});
|
||||
vol.priceScale().applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
|
||||
volSeriesRef.current = vol;
|
||||
|
||||
return () => {
|
||||
@@ -83,14 +113,140 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
chartRef.current = null;
|
||||
priceSeriesRef.current = null;
|
||||
volSeriesRef.current = null;
|
||||
maSeriesRef.current = [];
|
||||
macdSeriesRef.current = [];
|
||||
rsiSeriesRef.current = [];
|
||||
kdjSeriesRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 模式切换:重建价格 series
|
||||
// 移除某组 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);
|
||||
}
|
||||
|
||||
// --- KDJ(pane4)---
|
||||
removeGroup(kdjSeriesRef.current, chart);
|
||||
if (indicators.kdj && bars.length > 0) {
|
||||
const { k, d, j } = calcKdj(bars);
|
||||
const mk = (color: string) =>
|
||||
chart.addSeries(
|
||||
LineSeries,
|
||||
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
|
||||
4,
|
||||
);
|
||||
const kS = mk(KDJ_K);
|
||||
const dS = mk(KDJ_D);
|
||||
const jS = mk(KDJ_J);
|
||||
kS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: k[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
|
||||
dS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: d[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
|
||||
jS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: j[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
|
||||
kdjSeriesRef.current.push(kS, dS, jS);
|
||||
}
|
||||
}
|
||||
|
||||
// 模式切换:重建价格 series + 指标
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
// 移除旧的价格 series
|
||||
if (priceSeriesRef.current) {
|
||||
chart.removeSeries(priceSeriesRef.current);
|
||||
priceSeriesRef.current = null;
|
||||
@@ -110,16 +266,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
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]);
|
||||
}, [data, hasAddedDate, indicators]);
|
||||
|
||||
function fillData() {
|
||||
const chart = chartRef.current;
|
||||
@@ -133,7 +289,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 蜡烛/折线数据
|
||||
if (mode === "candle") {
|
||||
(ps as ISeriesApi<"Candlestick">).setData(
|
||||
data.map((d) => ({
|
||||
@@ -150,7 +305,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// 成交量柱(涨红跌绿,按当日开收)
|
||||
vs.setData(
|
||||
data.map((d) => ({
|
||||
time: d.time as Time,
|
||||
@@ -159,7 +313,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
})),
|
||||
);
|
||||
|
||||
// 自选日标记
|
||||
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
|
||||
if (markerItem) {
|
||||
createSeriesMarkers(ps, [
|
||||
@@ -175,11 +328,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
||||
createSeriesMarkers(ps, []);
|
||||
}
|
||||
|
||||
// 自适应可见范围
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className="h-[320px] sm:h-[380px] md:h-[440px] w-full" />;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height: `${chartHeight}px` }}
|
||||
className="w-full transition-[height] duration-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default KLineChart;
|
||||
|
||||
Reference in New Issue
Block a user