feat: 业绩走势增加对比线
This commit is contained in:
@@ -770,14 +770,18 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
default: start = start.subtract(1, 'month');
|
default: start = start.subtract(1, 'month');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend
|
// 业绩走势统一走 pingzhongdata.Data_netWorthTrend,
|
||||||
|
// 同时附带 Data_grandTotal(若存在,格式为 [{ name, data: [[ts, val], ...] }, ...])
|
||||||
try {
|
try {
|
||||||
const pz = await fetchFundPingzhongdata(code);
|
const pz = await fetchFundPingzhongdata(code);
|
||||||
const trend = pz?.Data_netWorthTrend;
|
const trend = pz?.Data_netWorthTrend;
|
||||||
|
const grandTotal = pz?.Data_grandTotal;
|
||||||
|
|
||||||
if (Array.isArray(trend) && trend.length) {
|
if (Array.isArray(trend) && trend.length) {
|
||||||
const startMs = start.startOf('day').valueOf();
|
const startMs = start.startOf('day').valueOf();
|
||||||
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
// end 可能是当日任意时刻,这里用 end-of-day 包含最后一天
|
||||||
const endMs = end.endOf('day').valueOf();
|
const endMs = end.endOf('day').valueOf();
|
||||||
|
|
||||||
const out = trend
|
const out = trend
|
||||||
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
.filter((d) => d && typeof d.x === 'number' && d.x >= startMs && d.x <= endMs)
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
@@ -788,6 +792,33 @@ export const fetchFundHistory = async (code, range = '1m') => {
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 解析 Data_grandTotal 为多条对比曲线,保存在数组属性 out.grandTotalSeries 上
|
||||||
|
if (Array.isArray(grandTotal) && grandTotal.length) {
|
||||||
|
const grandTotalSeries = grandTotal
|
||||||
|
.map((series) => {
|
||||||
|
if (!series || !series.data || !Array.isArray(series.data)) return null;
|
||||||
|
const name = series.name || '';
|
||||||
|
const points = series.data
|
||||||
|
.filter((item) => Array.isArray(item) && typeof item[0] === 'number')
|
||||||
|
.map(([ts, val]) => {
|
||||||
|
if (ts < startMs || ts > endMs) return null;
|
||||||
|
const numVal = Number(val);
|
||||||
|
if (!Number.isFinite(numVal)) return null;
|
||||||
|
const date = dayjs(ts).tz(TZ).format('YYYY-MM-DD');
|
||||||
|
return { ts, date, value: numVal };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!points.length) return null;
|
||||||
|
return { name, points };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (grandTotalSeries.length) {
|
||||||
|
// 给数组挂一个属性,供前端图表组件读取
|
||||||
|
out.grandTotalSeries = grandTotalSeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (out.length) return out;
|
if (out.length) return out;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -382,6 +382,15 @@ export default function FundCard({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
{hasHoldings && (
|
{hasHoldings && (
|
||||||
<TabsContent value="holdings" className="mt-3 outline-none">
|
<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">
|
<div className="list">
|
||||||
{f.holdings.map((h, idx) => (
|
{f.holdings.map((h, idx) => (
|
||||||
<div className="item" key={idx}>
|
<div className="item" key={idx}>
|
||||||
@@ -409,7 +418,8 @@ export default function FundCard({
|
|||||||
code={f.code}
|
code={f.code}
|
||||||
isExpanded
|
isExpanded
|
||||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
transactions={transactions?.[f.code] || []}
|
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||||
|
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
hideHeader
|
hideHeader
|
||||||
/>
|
/>
|
||||||
@@ -480,7 +490,8 @@ export default function FundCard({
|
|||||||
code={f.code}
|
code={f.code}
|
||||||
isExpanded={!collapsedTrends?.has(f.code)}
|
isExpanded={!collapsedTrends?.has(f.code)}
|
||||||
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
|
||||||
transactions={transactions?.[f.code] || []}
|
// 未设置持仓金额时,不展示买入/卖出标记与标签
|
||||||
|
transactions={profit ? (transactions?.[f.code] || []) : []}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const hoverTimeoutRef = useRef(null);
|
const hoverTimeoutRef = useRef(null);
|
||||||
|
const [hiddenGrandSeries, setHiddenGrandSeries] = useState(() => new Set());
|
||||||
|
const [activeIndex, setActiveIndex] = useState(null);
|
||||||
|
|
||||||
const chartColors = useMemo(() => getChartThemeColors(theme), [theme]);
|
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 lineColor = change >= 0 ? upColor : downColor;
|
||||||
const primaryColor = chartColors.primary;
|
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(() => {
|
const chartData = useMemo(() => {
|
||||||
// Calculate percentage change based on the first data point
|
// Data_grandTotal:在 fetchFundHistory 中解析为 data.grandTotalSeries 数组
|
||||||
const firstValue = data.length > 0 ? data[0].value : 1;
|
const grandTotalSeries = Array.isArray(data.grandTotalSeries) ? data.grandTotalSeries : [];
|
||||||
const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100);
|
|
||||||
|
|
||||||
// Map transaction dates to chart indices
|
// Map transaction dates to chart indices
|
||||||
const dateToIndex = new Map(data.map((d, i) => [d.date, i]));
|
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 {
|
return {
|
||||||
labels: data.map(d => d.date),
|
labels: data.map(d => d.date),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
label: '涨跌幅',
|
label: '净值涨跌幅',
|
||||||
data: percentageData,
|
data: percentageData,
|
||||||
borderColor: lineColor,
|
borderColor: lineColor,
|
||||||
backgroundColor: (context) => {
|
backgroundColor: (context) => {
|
||||||
@@ -165,9 +208,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
tension: 0.2,
|
tension: 0.2,
|
||||||
order: 2
|
order: 2
|
||||||
},
|
},
|
||||||
|
...grandDatasets,
|
||||||
{
|
{
|
||||||
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
type: 'line', // Use line type with showLine: false to simulate scatter on Category scale
|
||||||
label: '买入',
|
label: '买入',
|
||||||
|
isTradePoint: true,
|
||||||
data: buyPoints,
|
data: buyPoints,
|
||||||
borderColor: '#ffffff',
|
borderColor: '#ffffff',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -181,6 +226,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
{
|
{
|
||||||
type: 'line',
|
type: 'line',
|
||||||
label: '卖出',
|
label: '卖出',
|
||||||
|
isTradePoint: true,
|
||||||
data: sellPoints,
|
data: sellPoints,
|
||||||
borderColor: '#ffffff',
|
borderColor: '#ffffff',
|
||||||
borderWidth: 1,
|
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 options = useMemo(() => {
|
||||||
const colors = getChartThemeColors(theme);
|
const colors = getChartThemeColors(theme);
|
||||||
@@ -265,9 +311,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
target.style.cursor = hasActive ? 'crosshair' : 'default';
|
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 事件不稳定
|
// 仅用于桌面端 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]);
|
}, [theme]);
|
||||||
|
|
||||||
@@ -374,27 +433,35 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
activeElements = chart.getActiveElements();
|
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
|
// 1. Draw default labels for first buy and sell points only when NOT focused/hovering
|
||||||
// Index 1 is Buy, Index 2 is Sell
|
// datasets 顺序是动态的:主线(0) + 对比线(若干) + 买入 + 卖出
|
||||||
if (!activeElements?.length && datasets[1] && datasets[1].data) {
|
const buyDatasetIndex = datasets.findIndex(ds => ds?.label === '买入' || (ds?.isTradePoint === true && ds?.label === '买入'));
|
||||||
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
|
const sellDatasetIndex = datasets.findIndex(ds => ds?.label === '卖出' || (ds?.isTradePoint === true && ds?.label === '卖出'));
|
||||||
if (firstBuyIndex !== -1) {
|
|
||||||
let sellIndex = -1;
|
if (!activeElements?.length && buyDatasetIndex !== -1 && datasets[buyDatasetIndex]?.data) {
|
||||||
if (datasets[2] && datasets[2].data) {
|
const firstBuyIndex = datasets[buyDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||||
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
|
if (firstBuyIndex !== -1) {
|
||||||
}
|
let sellIndex = -1;
|
||||||
const isCollision = (firstBuyIndex === sellIndex);
|
if (sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||||
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
|
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 (!activeElements?.length && sellDatasetIndex !== -1 && datasets[sellDatasetIndex]?.data) {
|
||||||
if (firstSellIndex !== -1) {
|
const firstSellIndex = datasets[sellDatasetIndex].data.findIndex(v => v !== null && v !== undefined);
|
||||||
drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
|
if (firstSellIndex !== -1) {
|
||||||
}
|
drawPointLabel(sellDatasetIndex, firstSellIndex, '卖出', '#f87171');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle active elements (hover crosshair)
|
// 2. Handle active elements (hover crosshair)
|
||||||
|
// 始终保留十字线与 X/Y 坐标轴对应标签(坐标参照)
|
||||||
if (activeElements && activeElements.length) {
|
if (activeElements && activeElements.length) {
|
||||||
const activePoint = activeElements[0];
|
const activePoint = activeElements[0];
|
||||||
const x = activePoint.element.x;
|
const x = activePoint.element.x;
|
||||||
@@ -425,64 +492,62 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
// Draw Axis Labels based on the first point (main line)
|
// Draw Axis Labels:始终使用主线(净值涨跌幅,索引 0)作为数值来源,
|
||||||
const datasetIndex = activePoint.datasetIndex;
|
// 避免对比线在悬停时显示自己的数值标签
|
||||||
const index = activePoint.index;
|
const baseIndex = activePoint.index;
|
||||||
|
|
||||||
const labels = chart.data.labels;
|
const labels = chart.data.labels;
|
||||||
|
const mainDataset = datasets[0];
|
||||||
|
|
||||||
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
|
if (labels && mainDataset && Array.isArray(mainDataset.data)) {
|
||||||
const dateStr = labels[index];
|
const dateStr = labels[baseIndex];
|
||||||
const value = datasets[datasetIndex].data[index];
|
const value = mainDataset.data[baseIndex];
|
||||||
|
|
||||||
if (dateStr !== undefined && value !== undefined) {
|
if (dateStr !== undefined && value !== undefined) {
|
||||||
// X axis label (date) with boundary clamping
|
// X axis label (date) with boundary clamping
|
||||||
const textWidth = ctx.measureText(dateStr).width + 8;
|
const textWidth = ctx.measureText(dateStr).width + 8;
|
||||||
const chartLeft = chart.scales.x.left;
|
const chartLeft = chart.scales.x.left;
|
||||||
const chartRight = chart.scales.x.right;
|
const chartRight = chart.scales.x.right;
|
||||||
let labelLeft = x - textWidth / 2;
|
let labelLeft = x - textWidth / 2;
|
||||||
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
if (labelLeft < chartLeft) labelLeft = chartLeft;
|
||||||
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
if (labelLeft + textWidth > chartRight) labelLeft = chartRight - textWidth;
|
||||||
const labelCenterX = labelLeft + textWidth / 2;
|
const labelCenterX = labelLeft + textWidth / 2;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
ctx.fillRect(labelLeft, bottomY, textWidth, 16);
|
||||||
ctx.fillStyle = colors.crosshairText;
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
ctx.fillText(dateStr, labelCenterX, bottomY + 8);
|
||||||
|
|
||||||
// Y axis label (value)
|
// Y axis label (value) — 始终基于主线百分比
|
||||||
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
const valueStr = (typeof value === 'number' ? value.toFixed(2) : value) + '%';
|
||||||
const valWidth = ctx.measureText(valueStr).width + 8;
|
const valWidth = ctx.measureText(valueStr).width + 8;
|
||||||
ctx.fillStyle = primaryColor;
|
ctx.fillStyle = primaryColor;
|
||||||
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
ctx.fillRect(leftX, y - 8, valWidth, 16);
|
||||||
ctx.fillStyle = colors.crosshairText;
|
ctx.fillStyle = colors.crosshairText;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
ctx.fillText(valueStr, leftX + valWidth / 2, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for collision between Buy (1) and Sell (2) in active elements
|
// Check for collision between Buy and Sell in active elements
|
||||||
const activeBuy = activeElements.find(e => e.datasetIndex === 1);
|
const activeBuy = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '买入');
|
||||||
const activeSell = activeElements.find(e => e.datasetIndex === 2);
|
const activeSell = activeElements.find(e => datasets?.[e.datasetIndex]?.label === '卖出');
|
||||||
const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index;
|
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 => {
|
activeElements.forEach(element => {
|
||||||
const dsIndex = element.datasetIndex;
|
const dsIndex = element.datasetIndex;
|
||||||
// Only for transaction datasets (index > 0)
|
const ds = datasets?.[dsIndex];
|
||||||
if (dsIndex > 0 && datasets[dsIndex]) {
|
if (!isBuyOrSellDataset(ds)) return;
|
||||||
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
|
const label = ds.label;
|
||||||
let yOffset = 0;
|
const bgColor = label === '买入' ? primaryColor : colors.danger;
|
||||||
if (isCollision && dsIndex === 1) {
|
|
||||||
yOffset = -20;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
ctx.restore();
|
||||||
@@ -491,8 +556,150 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
|||||||
}];
|
}];
|
||||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||||
|
|
||||||
|
const lastIndex = data.length > 0 ? data.length - 1 : null;
|
||||||
|
const currentIndex = activeIndex != null && activeIndex < data.length ? activeIndex : lastIndex;
|
||||||
|
|
||||||
const chartBlock = (
|
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' }}>
|
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||||
|
|||||||
@@ -1212,7 +1212,7 @@ export default function PcFundTable({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div
|
<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 ? (
|
{cardDialogRow && getFundCardProps ? (
|
||||||
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
|
<FundCard {...getFundCardProps(cardDialogRow)} layoutMode="drawer" />
|
||||||
|
|||||||
@@ -3719,7 +3719,7 @@ export default function HomePage() {
|
|||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="search-dropdown glass"
|
className="search-dropdown glass scrollbar-y-styled"
|
||||||
>
|
>
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
<div className="search-results">
|
<div className="search-results">
|
||||||
|
|||||||
Reference in New Issue
Block a user