feat: 优化折线图标签绘制,添加圆角矩形背景

This commit is contained in:
hzm
2026-02-24 10:14:27 +08:00
parent faecf13df8
commit aaa91868a3
2 changed files with 57 additions and 36 deletions

View File

@@ -39,7 +39,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
useEffect(() => { useEffect(() => {
// If collapsed, don't fetch data unless we have no data yet // If collapsed, don't fetch data unless we have no data yet
if (!isExpanded && data.length > 0) return; if (!isExpanded && data.length > 0) return;
let active = true; let active = true;
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -229,41 +229,69 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
const ctx = chart.ctx; const ctx = chart.ctx;
const datasets = chart.data.datasets; const datasets = chart.data.datasets;
const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee'; const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee';
// Helper function to draw point label // 绘制圆角矩形(兼容无 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 drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => {
const meta = chart.getDatasetMeta(datasetIndex); const meta = chart.getDatasetMeta(datasetIndex);
if (!meta.data[index]) return; if (!meta.data[index]) return;
const element = meta.data[index]; const element = meta.data[index];
// Check if element is visible/not skipped
if (element.skip) return; if (element.skip) return;
const x = element.x; const x = element.x;
const y = element.y + yOffset; const y = element.y + yOffset;
const paddingH = 10;
const paddingV = 6;
const radius = 8;
ctx.save(); ctx.save();
ctx.font = 'bold 11px sans-serif'; ctx.font = 'bold 11px sans-serif';
const labelWidth = ctx.measureText(text).width + 12; const textW = ctx.measureText(text).width;
const w = textW + paddingH * 2;
// Draw label above the point const h = 18;
const left = x - w / 2;
const top = y - 24;
drawRoundRect(left, top, w, h, radius);
ctx.globalAlpha = 0.7; ctx.globalAlpha = 0.7;
ctx.fillStyle = bgColor; ctx.fillStyle = bgColor;
ctx.fillRect(x - labelWidth/2, y - 24, labelWidth, 18); ctx.fill();
ctx.globalAlpha = 0.7; ctx.globalAlpha = 0.7;
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, y - 15); ctx.fillText(text, x, top + h / 2);
ctx.restore(); ctx.restore();
}; };
// 1. Draw default labels for first buy and sell points // 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 // Index 1 is Buy, Index 2 is Sell
if (datasets[1] && datasets[1].data) { if (!activeElements?.length && datasets[1] && datasets[1].data) {
const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined); const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined);
if (firstBuyIndex !== -1) { if (firstBuyIndex !== -1) {
// Check collision with Sell
let sellIndex = -1; let sellIndex = -1;
if (datasets[2] && datasets[2].data) { if (datasets[2] && datasets[2].data) {
sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
@@ -272,7 +300,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0); drawPointLabel(1, firstBuyIndex, '买入', primaryColor, '#ffffff', isCollision ? -20 : 0);
} }
} }
if (datasets[2] && datasets[2].data) { if (!activeElements?.length && datasets[2] && datasets[2].data) {
const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined);
if (firstSellIndex !== -1) { if (firstSellIndex !== -1) {
drawPointLabel(2, firstSellIndex, '卖出', '#f87171'); drawPointLabel(2, firstSellIndex, '卖出', '#f87171');
@@ -280,13 +308,6 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
} }
// 2. Handle active elements (hover crosshair) // 2. Handle active elements (hover crosshair)
let activeElements = [];
if (chart.tooltip?._active?.length) {
activeElements = chart.tooltip._active;
} else {
activeElements = chart.getActiveElements();
}
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;
@@ -305,11 +326,11 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
// Draw vertical line // Draw vertical line
ctx.moveTo(x, topY); ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY); ctx.lineTo(x, bottomY);
// Draw horizontal line (based on first point - usually the main line) // Draw horizontal line (based on first point - usually the main line)
ctx.moveTo(leftX, y); ctx.moveTo(leftX, y);
ctx.lineTo(rightX, y); ctx.lineTo(rightX, y);
ctx.stroke(); ctx.stroke();
// Draw labels // Draw labels
@@ -320,7 +341,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
// Draw Axis Labels based on the first point (main line) // Draw Axis Labels based on the first point (main line)
const datasetIndex = activePoint.datasetIndex; const datasetIndex = activePoint.datasetIndex;
const index = activePoint.index; const index = activePoint.index;
const labels = chart.data.labels; const labels = chart.data.labels;
if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) { if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) {
@@ -360,13 +381,13 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
// Determine background color based on dataset index // Determine background color based on dataset index
// 1 = Buy (主题色), 2 = Sell (与折线图红色一致) // 1 = Buy (主题色), 2 = Sell (与折线图红色一致)
const bgColor = dsIndex === 1 ? primaryColor : '#f87171'; const bgColor = dsIndex === 1 ? primaryColor : '#f87171';
// If collision, offset Buy label upwards // If collision, offset Buy label upwards
let yOffset = 0; let yOffset = 0;
if (isCollision && dsIndex === 1) { if (isCollision && dsIndex === 1) {
yOffset = -20; yOffset = -20;
} }
drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset); drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset);
} }
}); });
@@ -375,10 +396,10 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
} }
} }
}], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据 }], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据
return ( return (
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}> <div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
<div <div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }} style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title" className="title"
onClick={onToggleExpand} onClick={onToggleExpand}
@@ -406,7 +427,7 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
)} )}
</div> </div>
</div> </div>
<AnimatePresence> <AnimatePresence>
{isExpanded && ( {isExpanded && (
<motion.div <motion.div
@@ -418,16 +439,16 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
> >
<div style={{ position: 'relative', height: 180, width: '100%' }}> <div style={{ position: 'relative', height: 180, width: '100%' }}>
{loading && ( {loading && (
<div style={{ <div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)' background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
}}> }}>
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span> <span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
</div> </div>
)} )}
{!loading && data.length === 0 && ( {!loading && data.length === 0 && (
<div style={{ <div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(255,255,255,0.02)', zIndex: 10 background: 'rgba(255,255,255,0.02)', zIndex: 10
}}> }}>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.5", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.5", "version": "0.1.6",
"dependencies": { "dependencies": {
"@dicebear/collection": "^9.3.1", "@dicebear/collection": "^9.3.1",
"@dicebear/core": "^9.3.1", "@dicebear/core": "^9.3.1",