superxuu
style: Make chart legend adaptive and wrap on small screens
92512e6
'use client';
/**
* Lightweight Charts 图表组件
* 支持多指标切换、多窗口布局
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { createChart, ColorType, IChartApi, CandlestickData, LineData, Time, HistogramData, SeriesMarker } from 'lightweight-charts';
import type { KLine } from '@/lib/api';
import type { Trade } from '@/store/gameStore';
import * as indicators from '@/lib/indicators';
interface ChartProps {
data: KLine[];
currentIndex: number;
className?: string;
mainIndicator: string;
subIndicator1: string;
subIndicator2: string;
onCrosshairMove?: (data: KLine | null) => void;
initialIndex?: number; // 初始索引,用于判断是否需要滚动到起始位置
history?: Trade[]; // 交易历史,用于显示买卖标记
}
export default function Chart({
data,
currentIndex,
className = '',
mainIndicator,
subIndicator1,
subIndicator2,
onCrosshairMove,
initialIndex,
history = []
}: ChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<any>({}); // 存储所有 series 引用
const [legendData, setLegendData] = useState<any>(null);
// 使用 ref 存储最新的数据和回调,避免闭包陷阱
const dataRef = useRef(data);
const currentIndexRef = useRef(currentIndex);
const onCrosshairMoveRef = useRef(onCrosshairMove);
// 记录上一次的数据长度,用于检测游戏开始或重开
const prevDataLengthRef = useRef(0);
// 记录上一次的 initialIndex,用于检测游戏是否重开
const prevInitialIndexRef = useRef<number | undefined>(undefined);
// 同步更新 refs
useEffect(() => {
dataRef.current = data;
}, [data]);
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
useEffect(() => {
onCrosshairMoveRef.current = onCrosshairMove;
}, [onCrosshairMove]);
// 转换 K 线数据
const convertToChartData = useCallback((klines: KLine[], endIndex: number): CandlestickData[] => {
return klines.slice(0, endIndex + 1).map((k) => ({
time: k.date as Time,
open: k.open,
high: k.high,
low: k.low,
close: k.close,
}));
}, []);
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#0D1421' },
textColor: '#9CA3AF',
fontFamily: "'Roboto', sans-serif",
},
grid: {
vertLines: { color: '#1F2937' },
horzLines: { color: '#1F2937' },
},
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight,
crosshair: {
mode: 1,
vertLine: {
color: '#6B7280',
width: 1,
style: 3,
labelBackgroundColor: '#374151',
},
horzLine: {
color: '#6B7280',
width: 1,
style: 3,
labelBackgroundColor: '#374151',
},
},
timeScale: {
borderColor: '#374151',
timeVisible: true,
rightOffset: 12,
barSpacing: 6,
minBarSpacing: 0.1, // 允许极致缩小,使 K 线看起来像曲线
},
});
chartRef.current = chart;
// 基础 K 线系列 (始终存在)
const candlestickSeries = chart.addCandlestickSeries({
upColor: '#FD1050',
downColor: '#00F0F0',
borderUpColor: '#FD1050',
borderDownColor: '#00F0F0',
wickUpColor: '#FD1050',
wickDownColor: '#00F0F0',
});
seriesRef.current.candlestick = candlestickSeries;
// 十字光标移动事件
chart.subscribeCrosshairMove((param) => {
if (!param.time || !param.point || param.point.x < 0 || param.point.y < 0) {
setLegendData(null);
// 无光标时,传递 null 表示应显示最新数据
onCrosshairMoveRef.current?.(null);
return;
}
const data: any = { time: param.time }; // 捕获时间
// 获取 K 线数据
const candleData = param.seriesData.get(candlestickSeries) as CandlestickData;
if (candleData) {
data.open = candleData.open;
data.high = candleData.high;
data.low = candleData.low;
data.close = candleData.close;
}
// 获取其他指标数据
Object.entries(seriesRef.current).forEach(([key, series]: [string, any]) => {
if (key === 'candlestick') return;
const val = param.seriesData.get(series);
if (val !== undefined) {
data[key] = (val as any).value;
}
});
setLegendData(data);
// 根据 time 找到原始 K 线数据并回调
const timeStr = param.time.toString();
const currentData = dataRef.current.slice(0, currentIndexRef.current + 1);
const originalKLine = currentData.find(k => k.date === timeStr);
if (originalKLine) {
onCrosshairMoveRef.current?.(originalKLine);
}
});
const handleResize = () => {
if (chartContainerRef.current && chartRef.current) {
chartRef.current.applyOptions({
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight,
});
}
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartContainerRef.current);
return () => {
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
seriesRef.current = {};
};
}, []);
// 布局与指标更新逻辑
useEffect(() => {
if (!chartRef.current || data.length === 0) return;
const chart = chartRef.current;
const currentData = data.slice(0, currentIndex + 1);
// 1. 更新 K 线数据
seriesRef.current.candlestick.setData(convertToChartData(data, currentIndex));
// 1.5 添加买卖标记
// 按天去重:同一天只显示一个 B 或 S
const dailyMarkers = new Map<string, { type: 'buy' | 'sell' }>();
history.forEach(trade => {
const date = trade.date;
// 如果同一天既有买又有卖,保留买入标记(或者你可以根据逻辑调整优先级)
if (!dailyMarkers.has(date) || trade.type === 'buy') {
dailyMarkers.set(date, { type: trade.type });
}
});
const markers: SeriesMarker<Time>[] = Array.from(dailyMarkers.entries())
.filter(([date]) => {
const visibleData = currentData.find(k => k.date === date);
return visibleData !== undefined;
})
.map(([date, trade]) => ({
time: date as Time,
position: trade.type === 'buy' ? 'belowBar' as const : 'aboveBar' as const,
color: trade.type === 'buy' ? '#A855F7' : '#3B82F6', // 紫色/蓝色
shape: trade.type === 'buy' ? 'arrowUp' as const : 'arrowDown' as const,
text: trade.type === 'buy' ? '𝗕' : '𝗦', // 数学粗体字符
size: 1 as const,
}));
seriesRef.current.candlestick.setMarkers(markers);
// 2. 清理旧指标 Series
Object.keys(seriesRef.current).forEach(key => {
if (key !== 'candlestick') {
chart.removeSeries(seriesRef.current[key]);
delete seriesRef.current[key];
}
});
// 3. 计算布局 (Scale Margins)
const sub1Visible = subIndicator1 !== 'HIDE';
const sub2Visible = subIndicator2 !== 'HIDE';
// 定义间隙 (2%)
const GAP = 0.02;
let mainBottom = 0;
let sub1Top = 0, sub1Bottom = 0;
let sub2Top = 0;
if (sub1Visible && sub2Visible) {
// 两个副图:主图 58%, 副图1 18%, 副图2 18%, 间隙 4%
mainBottom = 0.40 + GAP; // 0.42
sub1Top = 0.60 + GAP; // 0.62
sub1Bottom = 0.20; // 0.20
sub2Top = 0.80 + GAP; // 0.82
} else if (sub1Visible || sub2Visible) {
// 一个副图:主图 73%, 副图 23%, 间隙 2%
mainBottom = 0.25 + GAP; // 0.27
sub1Top = 0.75 + GAP; // 0.77
sub1Bottom = 0;
sub2Top = 0.75 + GAP; // 0.77
} else {
mainBottom = 0; // 主图 100%
}
// 设置主图布局
chart.priceScale('right').applyOptions({
scaleMargins: { top: 0.05, bottom: mainBottom },
});
// 4. 绘制主图指标
if (mainIndicator === 'MA') {
const ma5 = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const ma10 = chart.addLineSeries({ color: '#E6A23C', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const ma20 = chart.addLineSeries({ color: '#D81B60', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const ma60 = chart.addLineSeries({ color: '#00E676', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const ma120 = chart.addLineSeries({ color: '#2979FF', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
ma5.setData(indicators.calculateMA(currentData, 5).map(d => ({ time: d.time as Time, value: d.value })));
ma10.setData(indicators.calculateMA(currentData, 10).map(d => ({ time: d.time as Time, value: d.value })));
ma20.setData(indicators.calculateMA(currentData, 20).map(d => ({ time: d.time as Time, value: d.value })));
ma60.setData(indicators.calculateMA(currentData, 60).map(d => ({ time: d.time as Time, value: d.value })));
ma120.setData(indicators.calculateMA(currentData, 120).map(d => ({ time: d.time as Time, value: d.value })));
seriesRef.current.ma5 = ma5;
seriesRef.current.ma10 = ma10;
seriesRef.current.ma20 = ma20;
seriesRef.current.ma60 = ma60;
seriesRef.current.ma120 = ma120;
} else if (mainIndicator === 'BOLL') {
const bollData = indicators.calculateBOLL(currentData);
const up = chart.addLineSeries({ color: '#E6A23C', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const mb = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const dn = chart.addLineSeries({ color: '#D81B60', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
up.setData(bollData.map(d => ({ time: d.time as Time, value: d.up })));
mb.setData(bollData.map(d => ({ time: d.time as Time, value: d.mb })));
dn.setData(bollData.map(d => ({ time: d.time as Time, value: d.dn })));
seriesRef.current.boll_up = up;
seriesRef.current.boll_mb = mb;
seriesRef.current.boll_dn = dn;
} else if (mainIndicator === 'ENE') {
const eneData = indicators.calculateENE(currentData);
const up = chart.addLineSeries({ color: '#E6A23C', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const mb = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
const dn = chart.addLineSeries({ color: '#D81B60', lineWidth: 1, priceScaleId: 'right', priceLineVisible: false, lastValueVisible: false });
up.setData(eneData.map(d => ({ time: d.time as Time, value: d.up })));
mb.setData(eneData.map(d => ({ time: d.time as Time, value: d.mb })));
dn.setData(eneData.map(d => ({ time: d.time as Time, value: d.dn })));
seriesRef.current.ene_up = up;
seriesRef.current.ene_mb = mb;
seriesRef.current.ene_dn = dn;
}
// 5. 绘制副图指标函数
const drawSubIndicator = (type: string, scaleId: string, top: number, bottom: number) => {
if (type === 'HIDE') return;
if (type === 'VOL') {
const volSeries = chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: scaleId,
});
volSeries.setData(currentData.map(k => ({
time: k.date as Time,
value: k.volume,
color: k.close >= k.open ? '#FD1050' : '#00F0F0', // 使用与 K 线一致的实色
})));
seriesRef.current[`${scaleId}_vol`] = volSeries;
} else if (type === 'MACD') {
const macdData = indicators.calculateMACD(currentData);
// 先画柱子,再画线(后画的在上层)
const macdSeries = chart.addHistogramSeries({ priceScaleId: scaleId });
const diffSeries = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: scaleId });
const deaSeries = chart.addLineSeries({ color: '#E6A23C', lineWidth: 1, priceScaleId: scaleId });
macdSeries.setData(macdData.map(d => ({
time: d.time as Time,
value: d.macd,
color: d.macd > 0 ? '#FD1050' : '#00F0F0'
})));
diffSeries.setData(macdData.map(d => ({ time: d.time as Time, value: d.diff })));
deaSeries.setData(macdData.map(d => ({ time: d.time as Time, value: d.dea })));
seriesRef.current[`${scaleId}_macd`] = macdSeries;
seriesRef.current[`${scaleId}_diff`] = diffSeries;
seriesRef.current[`${scaleId}_dea`] = deaSeries;
} else if (type === 'KDJ') {
const kdjData = indicators.calculateKDJ(currentData);
const kSeries = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: scaleId });
const dSeries = chart.addLineSeries({ color: '#E6A23C', lineWidth: 1, priceScaleId: scaleId });
const jSeries = chart.addLineSeries({ color: '#D81B60', lineWidth: 1, priceScaleId: scaleId });
kSeries.setData(kdjData.map(d => ({ time: d.time as Time, value: d.k })));
dSeries.setData(kdjData.map(d => ({ time: d.time as Time, value: d.d })));
jSeries.setData(kdjData.map(d => ({ time: d.time as Time, value: d.j })));
seriesRef.current[`${scaleId}_k`] = kSeries;
seriesRef.current[`${scaleId}_d`] = dSeries;
seriesRef.current[`${scaleId}_j`] = jSeries;
} else if (type === 'RSI') {
const rsiData = indicators.calculateRSI(currentData);
const rsiSeries = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: scaleId });
rsiSeries.setData(rsiData.map(d => ({ time: d.time as Time, value: d.value })));
seriesRef.current[`${scaleId}_rsi`] = rsiSeries;
} else if (type === 'WR') {
const wrData = indicators.calculateWR(currentData);
const wrSeries = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: scaleId });
wrSeries.setData(wrData.map(d => ({ time: d.time as Time, value: d.value })));
seriesRef.current[`${scaleId}_wr`] = wrSeries;
} else if (type === 'CCI') {
const cciData = indicators.calculateCCI(currentData);
const cciSeries = chart.addLineSeries({ color: '#FFFFFF', lineWidth: 1, priceScaleId: scaleId });
cciSeries.setData(cciData.map(d => ({ time: d.time as Time, value: d.value })));
seriesRef.current[`${scaleId}_cci`] = cciSeries;
}
// 关键修正:先创建 Series,再配置 PriceScale
chart.priceScale(scaleId).applyOptions({
scaleMargins: { top, bottom },
visible: true,
});
};
// 绘制副图
if (sub1Visible) {
drawSubIndicator(subIndicator1, 'sub1', sub1Top, sub1Bottom);
}
if (sub2Visible) {
drawSubIndicator(subIndicator2, 'sub2', sub2Top, 0);
}
// 判断是否需要滚动到起始位置
// 情况1: 数据从空变为有数据(游戏开始)
// 情况2: initialIndex 发生变化(游戏重开)
// 情况3: 数据长度变小(游戏重置)
const isNewData = prevDataLengthRef.current === 0 && currentData.length > 0;
const isInitialIndexChanged = prevInitialIndexRef.current !== initialIndex;
const isReset = prevDataLengthRef.current > currentData.length;
const shouldScrollToStart = isNewData || isInitialIndexChanged || isReset;
prevDataLengthRef.current = currentData.length;
prevInitialIndexRef.current = initialIndex;
if (shouldScrollToStart && currentData.length > 0) {
// 延迟执行滚动,确保图表已完全渲染
setTimeout(() => {
if (chartRef.current) {
const timeScale = chartRef.current.timeScale();
// 使用 setVisibleLogicalRange 确保逻辑索引生效
// from: 0 (第一根K线)
// to: 120 (屏幕宽度对应120根K线,如果数据不足则右侧留白)
timeScale.setVisibleLogicalRange({
from: 0,
to: 120
});
}
}, 100);
}
}, [data, currentIndex, mainIndicator, subIndicator1, subIndicator2, convertToChartData, initialIndex, history]);
// 渲染图例
const renderLegend = () => {
// 1. 确定要显示的数据源
let displayValues: any = {};
if (legendData) {
// 有光标:使用光标数据
displayValues = legendData;
} else {
// 无光标:计算最新数据的指标值
// 注意:这里每次渲染都计算可能会有性能损耗,但在数据量不大(500)时可接受
// 理想情况下应该在 useEffect 中计算并存入 state,但为了逻辑简单先这样做
const currentData = data.slice(0, currentIndex + 1);
const lastIndex = currentData.length - 1;
if (lastIndex >= 0) {
if (mainIndicator === 'MA') {
const ma5 = indicators.calculateMA(currentData, 5)[lastIndex]?.value;
const ma10 = indicators.calculateMA(currentData, 10)[lastIndex]?.value;
const ma20 = indicators.calculateMA(currentData, 20)[lastIndex]?.value;
const ma60 = indicators.calculateMA(currentData, 60)[lastIndex]?.value;
const ma120 = indicators.calculateMA(currentData, 120)[lastIndex]?.value;
displayValues = { ma5, ma10, ma20, ma60, ma120 };
} else if (mainIndicator === 'BOLL') {
const boll = indicators.calculateBOLL(currentData)[lastIndex];
displayValues = { boll_up: boll?.up, boll_mb: boll?.mb, boll_dn: boll?.dn };
} else if (mainIndicator === 'ENE') {
const ene = indicators.calculateENE(currentData)[lastIndex];
displayValues = { ene_up: ene?.up, ene_mb: ene?.mb, ene_dn: ene?.dn };
}
// 副图指标计算 (可选,如果需要副图也常显示)
// 这里暂时只处理主图,因为副图数据在 StockHeader 中没有显示,
// 如果用户需要看副图数值,通常会移动光标。
// 如果需要副图常显,可以继续添加逻辑。
}
}
const formatVal = (v: any) => {
if (typeof v === 'number' && !isNaN(v)) return v.toFixed(2);
return '--';
};
return (
<div className="absolute top-2 left-2 flex flex-col gap-1 text-[10px] lg:text-xs font-mono select-none pointer-events-none z-10 bg-transparent p-1 rounded max-w-[calc(100%-20px)]">
{/* 主图指标 - 只显示当前选中的,支持自动换行 */}
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{mainIndicator === 'MA' && (
<>
<span className="text-white whitespace-nowrap">MA5:{formatVal(displayValues.ma5)}</span>
<span className="text-[#E6A23C] whitespace-nowrap">MA10:{formatVal(displayValues.ma10)}</span>
<span className="text-[#D81B60] whitespace-nowrap">MA20:{formatVal(displayValues.ma20)}</span>
<span className="text-[#00E676] whitespace-nowrap">MA60:{formatVal(displayValues.ma60)}</span>
<span className="text-[#2979FF] whitespace-nowrap">MA120:{formatVal(displayValues.ma120)}</span>
</>
)}
{mainIndicator === 'BOLL' && (
<>
<span className="text-[#E6A23C] whitespace-nowrap">UP:{formatVal(displayValues.boll_up)}</span>
<span className="text-white whitespace-nowrap">MB:{formatVal(displayValues.boll_mb)}</span>
<span className="text-[#D81B60] whitespace-nowrap">DN:{formatVal(displayValues.boll_dn)}</span>
</>
)}
{mainIndicator === 'ENE' && (
<>
<span className="text-[#E6A23C] whitespace-nowrap">UP:{formatVal(displayValues.ene_up)}</span>
<span className="text-white whitespace-nowrap">MB:{formatVal(displayValues.ene_mb)}</span>
<span className="text-[#D81B60] whitespace-nowrap">DN:{formatVal(displayValues.ene_dn)}</span>
</>
)}
</div>
</div>
);
};
// 渲染分隔线
const renderSeparators = () => {
const sub1Visible = subIndicator1 !== 'HIDE';
const sub2Visible = subIndicator2 !== 'HIDE';
if (!sub1Visible && !sub2Visible) return null;
let sep1Top = '0%';
let sep2Top = '0%';
if (sub1Visible && sub2Visible) {
sep1Top = '60%'; // 主图与副图1的分隔
sep2Top = '80%'; // 副图1与副图2的分隔
} else {
sep1Top = '75%'; // 主图与副图的分隔
}
return (
<>
{/* 主图与副图的分隔线 (明显区分) */}
<div
className="absolute left-0 right-0 h-[2px] bg-[#2A2D3C] z-20 pointer-events-none"
style={{ top: sep1Top }}
/>
{/* 副图1与副图2的分隔线 (轻微区分) */}
{sub1Visible && sub2Visible && (
<div
className="absolute left-0 right-0 h-[1px] bg-[#1F2937] z-20 pointer-events-none border-t border-dashed border-gray-700"
style={{ top: sep2Top }}
/>
)}
</>
);
};
return (
<div className={`relative w-full h-full ${className}`}>
<div ref={chartContainerRef} className="w-full h-full" />
{renderSeparators()}
{renderLegend()}
</div>
);
}