'use client'; import { useState, useEffect, useMemo, useRef } from 'react'; import { fetchFundHistory } from '../api/fund'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronIcon } from './Icons'; import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler } from 'chart.js'; import { Line } from 'react-chartjs-2'; import {cachedRequest} from "../lib/cacheRequest"; ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler ); export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) { const [range, setRange] = useState('1m'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const chartRef = useRef(null); const hoverTimeoutRef = useRef(null); useEffect(() => { // If collapsed, don't fetch data unless we have no data yet if (!isExpanded && data.length > 0) return; let active = true; setLoading(true); setError(null); const cacheKey = `fund_history_${code}_${range}`; if (isExpanded) { cachedRequest( () => fetchFundHistory(code, range), cacheKey, { cacheTime: 10 * 60 * 1000 } ) .then(res => { if (active) { setData(res || []); setLoading(false); } }) .catch(err => { if (active) { setError(err); setLoading(false); } }); } return () => { active = false; }; }, [code, range, isExpanded, data.length]); const ranges = [ { label: '近1月', value: '1m' }, { label: '近3月', value: '3m' }, { label: '近6月', value: '6m' }, { label: '近1年', value: '1y' }, { label: '近3年', value: '3y'} ]; const change = useMemo(() => { if (!data.length) return 0; const first = data[0].value; const last = data[data.length - 1].value; return ((last - first) / first) * 100; }, [data]); // Red for up, Green for down (CN market style) // Hardcoded hex values from globals.css for Chart.js const upColor = '#f87171'; // --danger,与折线图红色一致 const downColor = '#34d399'; // --success const lineColor = change >= 0 ? upColor : downColor; const primaryColor = typeof document !== 'undefined' ? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee') : '#22d3ee'; const chartData = useMemo(() => { // Calculate percentage change based on the first data point const firstValue = data.length > 0 ? data[0].value : 1; const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100); // Map transaction dates to chart indices const dateToIndex = new Map(data.map((d, i) => [d.date, i])); const buyPoints = new Array(data.length).fill(null); const sellPoints = new Array(data.length).fill(null); transactions.forEach(t => { // Simple date matching (assuming formats match) // If formats differ, dayjs might be needed const idx = dateToIndex.get(t.date); if (idx !== undefined) { const val = percentageData[idx]; if (t.type === 'buy') { buyPoints[idx] = val; } else { sellPoints[idx] = val; } } }); return { labels: data.map(d => d.date), datasets: [ { type: 'line', label: '涨跌幅', data: percentageData, borderColor: lineColor, backgroundColor: (context) => { const ctx = context.chart.ctx; const gradient = ctx.createLinearGradient(0, 0, 0, 200); gradient.addColorStop(0, `${lineColor}33`); // 20% opacity gradient.addColorStop(1, `${lineColor}00`); // 0% opacity return gradient; }, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, fill: true, tension: 0.2, order: 2 }, { type: 'line', // Use line type with showLine: false to simulate scatter on Category scale label: '买入', data: buyPoints, borderColor: '#ffffff', borderWidth: 1, backgroundColor: primaryColor, pointStyle: 'circle', pointRadius: 2.5, pointHoverRadius: 4, showLine: false, order: 1 }, { type: 'line', label: '卖出', data: sellPoints, borderColor: '#ffffff', borderWidth: 1, backgroundColor: upColor, pointStyle: 'circle', pointRadius: 2.5, pointHoverRadius: 4, showLine: false, order: 1 } ] }; }, [data, lineColor, transactions, primaryColor]); const options = useMemo(() => { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false, // 禁用默认 Tooltip,使用自定义绘制 mode: 'index', intersect: false, external: () => {} // 禁用外部 HTML tooltip } }, scales: { x: { display: true, grid: { display: false, drawBorder: false }, ticks: { color: '#9ca3af', font: { size: 10 }, maxTicksLimit: 4, maxRotation: 0 }, border: { display: false } }, y: { display: true, position: 'left', grid: { color: '#1f2937', drawBorder: false, tickLength: 0 }, ticks: { color: '#9ca3af', font: { size: 10 }, count: 5, callback: (value) => `${value.toFixed(2)}%` }, border: { display: false } } }, interaction: { mode: 'index', intersect: false, }, onHover: (event, chartElement, chart) => { const target = event?.native?.target; const currentChart = chart || chartRef.current; if (!currentChart) return; const tooltipActive = currentChart.tooltip?._active ?? []; const activeElements = currentChart.getActiveElements ? currentChart.getActiveElements() : []; const hasActive = (chartElement && chartElement.length > 0) || (tooltipActive && tooltipActive.length > 0) || (activeElements && activeElements.length > 0); if (target) { target.style.cursor = hasActive ? 'crosshair' : 'default'; } // 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定 }, onClick: () => {} }; }, []); useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }; }, []); const plugins = useMemo(() => [{ id: 'crosshair', afterEvent: (chart, args) => { const { event, replay } = args || {}; if (!event || replay) return; // 忽略动画重放 const type = event.type; if (type === 'mousemove' || type === 'click') { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } hoverTimeoutRef.current = setTimeout(() => { if (!chart) return; chart.setActiveElements([]); if (chart.tooltip) { chart.tooltip.setActiveElements([], { x: 0, y: 0 }); } chart.update(); }, 2000); } }, afterDraw: (chart) => { const ctx = chart.ctx; const datasets = chart.data.datasets; const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee'; // 绘制圆角矩形(兼容无 roundRect 的环境) const drawRoundRect = (left, top, w, h, r) => { const rad = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(left + rad, top); ctx.lineTo(left + w - rad, top); ctx.quadraticCurveTo(left + w, top, left + w, top + rad); ctx.lineTo(left + w, top + h - rad); ctx.quadraticCurveTo(left + w, top + h, left + w - rad, top + h); ctx.lineTo(left + rad, top + h); ctx.quadraticCurveTo(left, top + h, left, top + h - rad); ctx.lineTo(left, top + rad); ctx.quadraticCurveTo(left, top, left + rad, top); ctx.closePath(); }; const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => { const meta = chart.getDatasetMeta(datasetIndex); if (!meta.data[index]) return; const element = meta.data[index]; if (element.skip) return; const x = element.x; const y = element.y + yOffset; const paddingH = 10; const paddingV = 6; const radius = 8; ctx.save(); ctx.font = 'bold 11px sans-serif'; const textW = ctx.measureText(text).width; const w = textW + paddingH * 2; const h = 18; const left = x - w / 2; const top = y - 24; drawRoundRect(left, top, w, h, radius); ctx.globalAlpha = 0.7; ctx.fillStyle = bgColor; ctx.fill(); ctx.globalAlpha = 0.7; ctx.fillStyle = textColor; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, x, top + h / 2); ctx.restore(); }; // Resolve active elements (hover/focus) first — used to decide whether to show default labels let activeElements = []; if (chart.tooltip?._active?.length) { activeElements = chart.tooltip._active; } else { activeElements = chart.getActiveElements(); } // 1. Draw default labels for first buy and sell points only when NOT focused/hovering // Index 1 is Buy, Index 2 is Sell if (!activeElements?.length && datasets[1] && datasets[1].data) { const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined); if (firstBuyIndex !== -1) { let sellIndex = -1; if (datasets[2] && datasets[2].data) { sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); } const isCollision = (firstBuyIndex === sellIndex); drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0); } } if (!activeElements?.length && datasets[2] && datasets[2].data) { const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); if (firstSellIndex !== -1) { drawPointLabel(2, firstSellIndex, '卖出', '#f87171'); } } // 2. Handle active elements (hover crosshair) if (activeElements && activeElements.length) { const activePoint = activeElements[0]; const x = activePoint.element.x; const y = activePoint.element.y; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; const leftX = chart.scales.x.left; const rightX = chart.scales.x.right; ctx.save(); ctx.beginPath(); ctx.setLineDash([3, 3]); ctx.lineWidth = 1; ctx.strokeStyle = '#9ca3af'; // Draw vertical line ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); // Draw horizontal line (based on first point - usually the main line) ctx.moveTo(leftX, y); ctx.lineTo(rightX, y); ctx.stroke(); // Draw labels ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Draw Axis Labels based on the first point (main line) const datasetIndex = activePoint.datasetIndex; const index = activePoint.index; const labels = chart.data.labels; if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) { const dateStr = labels[index]; const value = datasets[datasetIndex].data[index]; if (dateStr !== undefined && value !== undefined) { // X axis label (date) with boundary clamping const textWidth = ctx.measureText(dateStr).width + 8; const chartLeft = chart.scales.x.left; const chartRight = chart.scales.x.right; let labelLeft = x - textWidth / 2; if (labelLeft < chartLeft) labelLeft = chartLeft; if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth; const labelCenterX = labelLeft + textWidth / 2; ctx.fillStyle = primaryColor; ctx.fillRect(labelLeft, bottomY, textWidth, 16); ctx.fillStyle = '#0f172a'; // --background ctx.fillText(dateStr, labelCenterX, bottomY + 8); // Y axis label (value) const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%'; const valWidth = ctx.measureText(valueStr).width + 8; ctx.fillStyle = primaryColor; ctx.fillRect(leftX, y - 8, valWidth, 16); ctx.fillStyle = '#0f172a'; // --background ctx.textAlign = 'center'; ctx.fillText(valueStr, leftX + valWidth / 2, y); } } // Check for collision between Buy (1) and Sell (2) in active elements const activeBuy = activeElements.find(e => e.datasetIndex === 1); const activeSell = activeElements.find(e => e.datasetIndex === 2); const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index; // Iterate through all active points to find transaction points and draw their labels activeElements.forEach(element => { const dsIndex = element.datasetIndex; // Only for transaction datasets (index > 0) if (dsIndex > 0 && datasets[dsIndex]) { const label = datasets[dsIndex].label; // Determine background color based on dataset index // 1 = Buy (主题色), 2 = Sell (与折线图红色一致) const bgColor = dsIndex === 1 ? primaryColor : '#f87171'; // If collision, offset Buy label upwards let yOffset = 0; if (isCollision && dsIndex === 1) { yOffset = -20; } drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset); } }); ctx.restore(); } } }], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据 return (
e.stopPropagation()}>
业绩走势
{data.length > 0 && (
{ranges.find(r => r.value === range)?.label}涨跌幅 {change > 0 ? '+' : ''}{change.toFixed(2)}%
)}
{isExpanded && (
{loading && (
加载中...
)} {!loading && data.length === 0 && (
暂无数据
)} {data.length > 0 && ( )}
{ranges.map(r => ( ))}
)}
); }