feat: 业绩走势增加对比线

This commit is contained in:
hzm
2026-03-16 21:04:04 +08:00
parent a7eb537e67
commit 26bb966f90
5 changed files with 324 additions and 75 deletions

View File

@@ -382,6 +382,15 @@ export default function FundCard({
</TabsList>
{hasHoldings && (
<TabsContent value="holdings" className="mt-3 outline-none">
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: 4,
}}
>
<span className="muted">涨跌幅 / 占比</span>
</div>
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
@@ -409,7 +418,8 @@ export default function FundCard({
code={f.code}
isExpanded
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
// 未设置持仓金额时不展示买入/卖出标记与标签
transactions={profit ? (transactions?.[f.code] || []) : []}
theme={theme}
hideHeader
/>
@@ -480,7 +490,8 @@ export default function FundCard({
code={f.code}
isExpanded={!collapsedTrends?.has(f.code)}
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
// 未设置持仓金额时不展示买入/卖出标记与标签
transactions={profit ? (transactions?.[f.code] || []) : []}
theme={theme}
/>
</>

View File

@@ -62,6 +62,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const [error, setError] = useState(null);
const chartRef = useRef(null);
const hoverTimeoutRef = useRef(null);
const [hiddenGrandSeries, setHiddenGrandSeries] = useState(() => new Set());
const [activeIndex, setActiveIndex] = useState(null);
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
@@ -119,10 +121,15 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const lineColor = change >= 0 ? upColor : downColor;
const primaryColor = chartColors.primary;
const percentageData = useMemo(() => {
if (!data.length) return [];
const firstValue = data[0].value ?? 1;
return data.map(d => ((d.value - firstValue) / firstValue) * 100);
}, [data]);
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);
// Data_grandTotal在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
// Map transaction dates to chart indices
const dateToIndex = new Map(data.map((d, i) => [d.date, i]));
@@ -143,12 +150,48 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}
});
// 将 Data_grandTotal 的多条曲线按日期对齐到主 labels 上
const labels = data.map(d => d.date);
// 对比线颜色:避免与主线红/绿upColor/downColor重复
// 第三条对比线需要在亮/暗主题下都足够清晰,因此使用高对比的橙色强调
const grandAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
const grandColors = [
primaryColor,
chartColors.muted,
grandAccent3,
chartColors.text,
];
const grandDatasets = grandTotalSeries.map((series, idx) => {
const color = grandColors[idx % grandColors.length];
const key = `${series.name || 'series'}_${idx}`;
const isHidden = hiddenGrandSeries.has(key);
const pointsByDate = new Map(series.points.map(p => [p.date, p.value]));
const seriesData = labels.map(date => {
const v = pointsByDate.get(date);
if (isHidden) return null;
return typeof v === 'number' ? v : null;
});
return {
type: 'line',
label: series.name || '累计收益',
data: seriesData,
borderColor: color,
backgroundColor: color,
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 3,
fill: false,
tension: 0.2,
order: 2,
};
});
return {
labels: data.map(d => d.date),
datasets: [
{
type: 'line',
label: '涨跌幅',
label: '净值涨跌幅',
data: percentageData,
borderColor: lineColor,
backgroundColor: (context) => {
@@ -165,9 +208,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
tension: 0.2,
order: 2
},
...grandDatasets,
{
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
label: '买入',
isTradePoint: true,
data: buyPoints,
borderColor: '#ffffff',
borderWidth: 1,
@@ -181,6 +226,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
{
type: 'line',
label: '卖出',
isTradePoint: true,
data: sellPoints,
borderColor: '#ffffff',
borderWidth: 1,
@@ -193,7 +239,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}
]
};
}, [data, transactions, lineColor, primaryColor, upColor]);
}, [data, transactions, lineColor, primaryColor, upColor, chartColors, theme, hiddenGrandSeries, percentageData]);
const options = useMemo(() => {
const colors = getChartThemeColors(theme);
@@ -265,9 +311,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
target.style.cursor = hasActive ? 'crosshair' : 'default';
}
// 记录当前激活的横轴索引,用于图示下方展示对应百分比
if (Array.isArray(chartElement) && chartElement.length > 0) {
const idx = chartElement[0].index;
setActiveIndex(typeof idx === 'number' ? idx : null);
} else {
setActiveIndex(null);
}
// 仅用于桌面端 hover 改变光标,不在这里做 2 秒清除,避免移动端 hover 事件不稳定
},
onClick: () => {}
onClick: (_event, elements) => {
if (Array.isArray(elements) && elements.length > 0) {
const idx = elements[0].index;
setActiveIndex(typeof idx === 'number' ? idx : null);
}
}
};
}, [theme]);
@@ -286,14 +345,14 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
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([]);
@@ -374,27 +433,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
activeElements = chart.getActiveElements();
}
const isBuyOrSellDataset = (ds) =>
!!ds && (ds.isTradePoint === true || ds.label === '买入' || ds.label === '卖出');
// 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);
// datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
if (firstBuyIndex !== -1) {
let sellIndex = -1;
if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
sellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
}
const isCollision = (firstBuyIndex === sellIndex);
drawPointLabel(buyDatasetIndex, 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');
}
if (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
if (firstSellIndex !== -1) {
drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
}
}
// 2. Handle active elements (hover crosshair)
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
if (activeElements && activeElements.length) {
const activePoint = activeElements[0];
const x = activePoint.element.x;
@@ -425,64 +492,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Draw Axis Labels based on the first point (main line)
const datasetIndex = activePoint.datasetIndex;
const index = activePoint.index;
// Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0作为数值来源
// 避免对比线在悬停时显示自己的数值标签
const baseIndex = activePoint.index;
const labels = chart.data.labels;
const mainDataset = datasets[0];
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
const dateStr = labels[index];
const value = datasets[datasetIndex].data[index];
if (labels && mainDataset && Array.isArray(mainDataset.data)) {
const dateStr = labels[baseIndex];
const value = mainDataset.data[baseIndex];
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);
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);
}
// 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);
// Check for collision between Buy and Sell in active elements
const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index;
// Iterate through all active points to find transaction points and draw their labels
// Iterate through active points,仅为买入/卖出绘制标签
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;
const dsIndex = element.datasetIndex;
const ds = datasets?.[dsIndex];
if (!isBuyOrSellDataset(ds)) return;
// If collision, offset Buy label upwards
let yOffset = 0;
if (isCollision && dsIndex === 1) {
yOffset = -20;
}
const label = ds.label;
const bgColor = label === '买入' ? primaryColor : colors.danger;
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
}
// 如果买入/卖出在同一天,买入标签上移避免遮挡
let yOffset = 0;
if (isCollision && label === '买入') {
yOffset = -20;
}
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
});
ctx.restore();
@@ -491,8 +556,150 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
}];
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
const lastIndex = data.length > 0 ? data.length - 1 : null;
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
const chartBlock = (
<>
{/* 顶部图示:说明不同颜色/标记代表的含义 */}
<div
className="row"
style={{ marginBottom: 8, gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11 }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span
style={{
width: 10,
height: 2,
borderRadius: 999,
backgroundColor: lineColor
}}
/>
<span className="muted">净值涨跌幅</span>
</div>
{currentIndex != null && percentageData[currentIndex] !== undefined && (
<span
className="muted"
style={{
fontSize: 10,
fontVariantNumeric: 'tabular-nums',
paddingLeft: 14,
}}
>
{percentageData[currentIndex].toFixed(2)}%
</span>
)}
</div>
{Array.isArray(data.grandTotalSeries) &&
data.grandTotalSeries.map((series, idx) => {
// 与折线数据使用同一套对比色,且排除红/绿
const legendAccent3 = theme === 'light' ? '#f97316' : '#fb923c';
const legendColors = [
primaryColor,
chartColors.muted,
legendAccent3,
chartColors.text,
];
const color = legendColors[idx % legendColors.length];
const key = `${series.name || 'series'}_${idx}`;
const isHidden = hiddenGrandSeries.has(key);
let valueText = '--';
if (!isHidden && currentIndex != null && data[currentIndex]) {
const targetDate = data[currentIndex].date;
const point = Array.isArray(series.points)
? series.points.find(p => p.date === targetDate)
: null;
if (point && typeof point.value === 'number') {
valueText = `${point.value.toFixed(2)}%`;
}
}
return (
<div
key={series.name || idx}
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
onClick={(e) => {
e.stopPropagation();
setHiddenGrandSeries(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 10,
height: 2,
borderRadius: 999,
backgroundColor: isHidden ? '#4b5563' : color,
}}
/>
<span
className="muted"
style={{ opacity: isHidden ? 0.5 : 1 }}
>
{series.name || '累计收益'}
</span>
<button
type="button"
style={{
border: 'none',
padding: 0,
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
aria-hidden="true"
style={{ opacity: isHidden ? 0.4 : 0.9 }}
>
<path
d="M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
{isHidden && (
<line
x1="4"
y1="20"
x2="20"
y2="4"
stroke="currentColor"
strokeWidth="1.6"
/>
)}
</svg>
</button>
</div>
{!isHidden && valueText !== '--' && (
<span
className="muted"
style={{
fontSize: 10,
fontVariantNumeric: 'tabular-nums',
paddingLeft: 14,
}}
>
{valueText}
</span>
)}
</div>
);
})}
</div>
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
{loading && (
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>

View File

@@ -1212,7 +1212,7 @@ export default function PcFundTable({
</DialogTitle>
</DialogHeader>
<div
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
className="flex-1 min-h-0 overflow-y-auto px-6 py-4 scrollbar-y-styled"
>
{cardDialogRow && getFundCardProps ? (
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />