You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

D3 SVG在Next.js中mouseenter仅触发一次的问题修复求助

问题描述

开发集成D3的React组件时,发现SVG圆圈的鼠标事件仅能触发一次,对应的Tooltip仅渲染一次(已确认Tooltip组件功能正常)。相关代码如下:

import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import D3ColorLegend from './D3ColorLegend';
import {Tooltip} from './ToolTip'


const Scatterplot = ({ data, colorProperty, hoverProp }) => {
  const svgRef = useRef();
  const [details, setDetails] = useState(null);

  useEffect(() => {
    const handleMouseOver = (event, d) => {
      const [x, y] = d3.pointer(event);
      setDetails({
        xPos : x,
        yPos : y,
        name: hoverProp[data.indexOf(d)],
      });
    };
    
    // set the dimensions and margins of the graph
    const margin = { top: 10, right: 30, bottom: 30, left: 60 };
    const width = 800 - margin.left - margin.right;
    const height = 600 - margin.top - margin.bottom;

    // Find the minimum and maximum values in your data for both x and y
    const xExtent = d3.extent(data, d => d.x);
    const yExtent = d3.extent(data, d => d.y);

    // Append the SVG object to the body of the page
    const svg = d3.select(svgRef.current)
      .append('svg')
      .attr('width', width + margin.left + margin.right)
      .attr('height', height + margin.top + margin.bottom)
      .append('g')
      .attr('transform', `translate(${margin.left},${margin.top})`);

    // Add X axis
    const x = d3.scaleLinear()
      .domain([Math.min(0, xExtent[0]), xExtent[1]])
      .range([0, width]);
    const xAxis = svg.append('g')
      .attr('transform', `translate(0,${height})`)
      .call(d3.axisBottom(x));

    // Add Y axis
    const y = d3.scaleLinear()
      .domain([Math.min(0, yExtent[0]), yExtent[1]])
      .range([height, 0]);
    const yAxis = svg.append('g')
      .call(d3.axisLeft(y));

    // Add a clipPath: everything out of this area won't be drawn.
    const clip = svg.append('defs').append('clipPath')
      .attr('id', 'clip')
      .append('rect')
      .attr('width', width)
      .attr('height', height)
      .attr('x', 0)
      .attr('y', 0);

    // Create the scatter variable: where both the circles and the brush take place
    const scatter = svg.append('g')
      .attr('clip-path', 'url(#clip)');

    const colorScale = d3.scaleSequential().domain([1, 10]).interpolator(d3.interpolateSinebow);

    // Add circles
    scatter
      .selectAll('circle')
      .data(data)
      .enter()
      .append('circle')
      .attr('cx', (d) => x(d.x))
      .attr('cy', (d) => y(d.y))
      .attr('r', 8)
      .style('fill', (_, i) => colorScale(colorProperty[i]))
      .style('opacity', 0.5)
      .on("mouseover", () => {} )
      .on('mousemove', (event, d) => handleMouseOver(event, d))
      .on('mouseleave', () => setDetails(null));


    // Set the zoom and Pan features: how much you can zoom, on which part, and what to do when there is a zoom
    const zoom = d3.zoom()
      .scaleExtent([0.5, 20]) // This controls how much you can unzoom (x0.5) and zoom (x20)
      .extent([[0, 0], [width, height]])
      .on('zoom', updateChart);

    // This adds an invisible rect on top of the chart area. This rect can recover pointer events: necessary to understand when the user zoom
    svg.append('rect')
      .attr('width', width)
      .attr('height', height)
      .style('fill', 'none')
      .style('pointer-events', 'all')
      .attr('transform', `translate(${margin.left},${margin.top})`)
      .call(zoom);

    // now the user can zoom, and it will trigger the function called updateChart

    // A function that updates the chart when the user zooms and thus new boundaries are available
    function updateChart(event) {
      // recover the new scale
      const newX = event.transform.rescaleX(x);
      const newY = event.transform.rescaleY(y);

      // update axes with these new boundaries
      xAxis.call(d3.axisBottom(newX));
      yAxis.call(d3.axisLeft(newY));

      // update circle position
      scatter
        .selectAll('circle')
        .attr('cx', (d) => newX(d.x))
        .attr('cy', (d) => newY(d.y));
    }
  }, [data]);

  return (
    <div className='container'>
      <D3ColorLegend />
      <div id="dataviz_axisZoom" ref={svgRef}></div>
      {details && <Tooltip interactionData={details} />}
    </div>
  );
};

export default Scatterplot;
修复方案

1. 清空重复SVG元素,避免事件拦截

每次useEffect执行时,会向容器追加新的SVG元素,新SVG中的zoom矩形(设置了pointer-events: all)会覆盖在旧SVG的圆圈之上,导致后续鼠标事件被矩形拦截。需在创建新SVG前清空容器:

// 在append('svg')前添加清空逻辑
const svgContainer = d3.select(svgRef.current);
svgContainer.selectAll("*").remove();

const svg = svgContainer
  .append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g')
  .attr('transform', `translate(${margin.left},${margin.top})`);

2. 修正事件绑定与状态更新逻辑

  • 移除空的mouseover事件绑定,避免干扰其他鼠标事件的触发
  • 优化状态更新逻辑:添加唯一标识(如时间戳)确保每次状态对象引用不同,绕过React浅比较的限制,强制状态更新
  • 替换data.indexOf(d)为findIndex,避免数据有重复项时取值错误

修改后的事件绑定代码:

scatter
  .selectAll('circle')
  .data(data)
  .enter()
  .append('circle')
  .attr('cx', (d) => x(d.x))
  .attr('cy', (d) => y(d.y))
  .attr('r', 8)
  .style('fill', (_, i) => colorScale(colorProperty[i]))
  .style('opacity', 0.5)
  .on('mousemove', (event, d) => {
    const [xPos, yPos] = d3.pointer(event);
    // 添加timestamp确保每次状态对象引用不同
    setDetails({
      xPos,
      yPos,
      name: hoverProp[data.findIndex(item => item === d)],
      timestamp: Date.now()
    });
  })
  .on('mouseleave', () => setDetails(null));

3. 修正Zoom矩形的位置偏移

原代码中给Zoom矩形添加了重复的transform,导致矩形位置偏移,无法正确覆盖图表区域,修正后:

svg.append('rect')
  .attr('width', width)
  .attr('height', height)
  .style('fill', 'none')
  .style('pointer-events', 'all')
  // 移除多余的transform,父g已做偏移处理
  .call(zoom);

4. 完善useEffect依赖数组

当前useEffect仅依赖data,但colorProperty、hoverProp变化时也需要重新渲染图表,否则会出现数据与视图不一致的问题:

useEffect(() => {
  // ... 原代码逻辑
}, [data, colorProperty, hoverProp]);

内容的提问来源于stack exchange,提问作者Zayyan Masud

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 14:15:55