diff --git a/client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx b/client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx index 79563fa..2cec541 100644 --- a/client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx +++ b/client/src/sections/wuyuanbiaoba/components/RealtimeCharts.jsx @@ -32,6 +32,152 @@ ChartJS.register( const { Title } = Typography; +// 使用 HTML Tooltip,避免 Chart.js Canvas 将多标靶内容裁剪掉。 +const getOrCreateExternalTooltip = (chart) => { + const canvas = chart.canvas; + let tooltipEl = canvas._externalTooltipEl; + + if (!tooltipEl) { + tooltipEl = document.createElement("div"); + tooltipEl.className = "chartjs-external-tooltip"; + Object.assign(tooltipEl.style, { + position: "fixed", + maxWidth: "260px", + maxHeight: "min(280px, calc(100vh - 20px))", + overflowY: "auto", + padding: "10px 12px", + background: "#ffffff", + border: "1px solid #d9d9d9", + borderRadius: "4px", + boxShadow: "0 3px 12px rgba(0, 0, 0, 0.18)", + color: "#333333", + fontSize: "12px", + lineHeight: "1.6", + whiteSpace: "nowrap", + pointerEvents: "none", + opacity: "0", + zIndex: "20", + transform: "translate(-50%, -100%)", + transition: "opacity 0.08s ease", + }); + tooltipEl.addEventListener("mouseenter", () => { + // Tooltip 已经离开图表后,不允许仅靠再次移入旧位置重新显示。 + if (tooltipEl.dataset.active !== "true") { + return; + } + if (tooltipEl._hideTimer) { + clearTimeout(tooltipEl._hideTimer); + tooltipEl._hideTimer = null; + } + tooltipEl.dataset.hovered = "true"; + tooltipEl.style.opacity = "1"; + tooltipEl.style.pointerEvents = "auto"; + }); + tooltipEl.addEventListener("mouseleave", () => { + tooltipEl.dataset.hovered = "false"; + tooltipEl._hideTimer = setTimeout(() => { + if (tooltipEl.dataset.hovered !== "true") { + tooltipEl.style.opacity = "0"; + tooltipEl.style.pointerEvents = "none"; + tooltipEl.dataset.active = "false"; + } + }, 150); + }); + document.body.appendChild(tooltipEl); + canvas._externalTooltipEl = tooltipEl; + } + + return tooltipEl; +}; + +const externalTooltipHandler = ({ chart, tooltip }) => { + const tooltipEl = getOrCreateExternalTooltip(chart); + + if (tooltip.opacity === 0) { + if (tooltipEl.dataset.hovered !== "true") { + if (tooltipEl._hideTimer) { + clearTimeout(tooltipEl._hideTimer); + } + tooltipEl._hideTimer = setTimeout(() => { + if (tooltipEl.dataset.hovered !== "true") { + tooltipEl.style.opacity = "0"; + tooltipEl.style.pointerEvents = "none"; + tooltipEl.dataset.active = "false"; + } + }, 150); + } + return; + } + + if (tooltipEl._hideTimer) { + clearTimeout(tooltipEl._hideTimer); + tooltipEl._hideTimer = null; + } + + tooltipEl.replaceChildren(); + + (tooltip.title || []).forEach((title) => { + const titleEl = document.createElement("div"); + titleEl.style.fontWeight = "600"; + titleEl.style.marginBottom = "4px"; + titleEl.textContent = title; + tooltipEl.appendChild(titleEl); + }); + + (tooltip.body || []).forEach((bodyItem) => { + (bodyItem.lines || []).forEach((line, index) => { + const lineEl = document.createElement("div"); + lineEl.style.display = "flex"; + lineEl.style.alignItems = "center"; + + const color = tooltip.labelColors?.[tooltip.body.indexOf(bodyItem)]; + if (color) { + const markerEl = document.createElement("span"); + Object.assign(markerEl.style, { + width: "8px", + height: "8px", + marginRight: "6px", + borderRadius: "50%", + // 与折线保持一致,优先使用不透明的 borderColor。 + background: color.borderColor || color.backgroundColor, + flex: "0 0 auto", + }); + lineEl.appendChild(markerEl); + } + + const textEl = document.createElement("span"); + textEl.textContent = line; + lineEl.appendChild(textEl); + tooltipEl.appendChild(lineEl); + }); + }); + + const canvasRect = chart.canvas.getBoundingClientRect(); + const positionX = canvasRect.left + tooltip.caretX; + const positionY = canvasRect.top + tooltip.caretY; + const tooltipHeight = tooltipEl.offsetHeight; + const tooltipWidth = tooltipEl.offsetWidth; + const spaceAbove = positionY; + const spaceBelow = window.innerHeight - positionY; + const canPlaceAbove = + spaceAbove >= tooltipHeight + 8 || spaceBelow < tooltipHeight + 8; + const clampedX = Math.max( + tooltipWidth / 2 + 8, + Math.min(positionX, window.innerWidth - tooltipWidth / 2 - 8) + ); + + // 使用 fixed 定位,避免 Tooltip 超出图表后撑高整个页面。 + // 上方空间不足时放到数据点下方,避免鼠标向上移动时离开可交互区域。 + tooltipEl.style.transform = canPlaceAbove + ? "translate(-50%, calc(-100% - 8px))" + : "translate(-50%, 8px)"; + tooltipEl.style.opacity = "1"; + tooltipEl.style.pointerEvents = "auto"; + tooltipEl.dataset.active = "true"; + tooltipEl.style.left = `${clampedX}px`; + tooltipEl.style.top = `${positionY}px`; +}; + const RealtimeCharts = ({ tableData, lastUpdateTime, onDataExport,exportCount = 200, setExportCount }) => { const xChartRef = useRef(null); const yChartRef = useRef(null); @@ -39,6 +185,18 @@ const RealtimeCharts = ({ tableData, lastUpdateTime, onDataExport,exportCount = // 添加标靶显示状态控制 const [visibleTargets, setVisibleTargets] = useState({}); + // 外部 Tooltip 挂载在 body 上,组件卸载时主动清理,避免遗留 DOM。 + useEffect(() => { + return () => { + [xChartRef.current, yChartRef.current].forEach((chart) => { + const tooltipEl = chart?.canvas?._externalTooltipEl; + if (tooltipEl?.parentNode) { + tooltipEl.parentNode.removeChild(tooltipEl); + } + }); + }; + }, []); + // 添加数据验证 if (!Array.isArray(tableData)) { return