Files
real-time-fund/app/components/FundTrendChart.jsx
2026-03-05 18:33:33 +08:00

569 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
);
const CHART_COLORS = {
dark: {
danger: '#f87171',
success: '#34d399',
primary: '#22d3ee',
muted: '#9ca3af',
border: '#1f2937',
text: '#e5e7eb',
crosshairText: '#0f172a',
},
light: {
danger: '#dc2626',
success: '#059669',
primary: '#0891b2',
muted: '#475569',
border: '#e2e8f0',
text: '#0f172a',
crosshairText: '#ffffff',
}
};
function getChartThemeColors(theme) {
return CHART_COLORS[theme] || CHART_COLORS.dark;
}
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) {
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);
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
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' },
{ label: '成立来', value: 'all' }
];
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),随主题使用 CSS 变量
const upColor = chartColors.danger;
const downColor = chartColors.success;
const lineColor = change >= 0 ? upColor : downColor;
const primaryColor = chartColors.primary;
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, transactions, lineColor, primaryColor, upColor]);
const options = useMemo(() => {
const colors = getChartThemeColors(theme);
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: colors.muted,
font: { size: 10 },
maxTicksLimit: 4,
maxRotation: 0
},
border: { display: false }
},
y: {
display: true,
position: 'left',
grid: {
color: colors.border,
drawBorder: false,
tickLength: 0
},
ticks: {
color: colors.muted,
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: () => {}
};
}, [theme]);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
const plugins = useMemo(() => {
const colors = getChartThemeColors(theme);
return [{
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 = colors.primary;
// 绘制圆角矩形(兼容无 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;
// 计算原始 left并对左右边界做收缩避免在最右/最左侧被裁剪
const chartLeft = chart.scales.x.left;
const chartRight = chart.scales.x.right;
let left = x - w / 2;
if (left < chartLeft) left = chartLeft;
if (left + w > chartRight) left = chartRight - w;
const centerX = left + 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, centerX, 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 = colors.muted;
// 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 = colors.crosshairText;
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 = colors.crosshairText;
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 : colors.danger;
// 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();
}
}
}];
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
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%', touchAction: 'pan-y' }}>
{loading && (
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
</div>
)}
{!loading && data.length === 0 && (
<div className="chart-overlay">
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
</div>
)}
{data.length > 0 && (
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
)}
</div>
<div className="trend-range-bar">
{ranges.map(r => (
<button
key={r.value}
type="button"
className={`trend-range-btn ${range === r.value ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
>
{r.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}