Files
real-time-fund/app/components/FundTrendChart.jsx
2026-02-24 23:03:03 +08:00

540 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)
const textWidth = ctx.measureText(dateStr).width + 8;
ctx.fillStyle = primaryColor;
ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16);
ctx.fillStyle = '#0f172a'; // --background
ctx.fillText(dateStr, x, 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 (
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
onClick={onToggleExpand}
>
<div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>业绩走势</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
/>
</div>
{data.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
<span style={{ color: lineColor, fontWeight: 600 }}>
{change > 0 ? '+' : ''}{change.toFixed(2)}%
</span>
</div>
)}
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div style={{ position: 'relative', height: 180, width: '100%' }}>
{loading && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
}}>
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
</div>
)}
{!loading && data.length === 0 && (
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10
}}>
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
</div>
)}
{data.length > 0 && (
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
)}
</div>
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
{ranges.map(r => (
<button
key={r.value}
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
style={{
flex: 1,
padding: '6px 0',
fontSize: '11px',
borderRadius: '6px',
border: 'none',
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
cursor: 'pointer',
transition: 'all 0.2s',
fontWeight: range === r.value ? 600 : 400
}}
>
{r.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}