feat: 业绩走势增加对比线
This commit is contained in:
@@ -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)' }}>
|
||||
|
||||
Reference in New Issue
Block a user