feat: 优化折线图标签绘制,添加圆角矩形背景
This commit is contained in:
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user