Spaces:
Running
Running
| '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> | |
| ); | |
| } | |