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

D3.js v6+React力导向图无法拖拽整个画布问题排查

问题

使用D3 v6结合React开发了具备缩放功能与可拖拽节点的力导向图。由于图表会随动态数据变得复杂庞大,希望用户能够拖拽包裹所有节点的<g>元素(尤其是图表放大后),但该<g>元素调用与单个节点相同的拖拽函数却无任何响应,尝试拆分拖拽函数也未解决问题。忽略TypeScript相关问题,以下是相关代码:

const data = jsonFyStory(selectedVariable, stories)
const links = data.links.map((d) => d)
const nodes = data.nodes.map((d: any) => d)
const containerRect = container.getBoundingClientRect()
const height = containerRect.height
const width = containerRect.width

function dragstarted() {
  // @ts-ignore
  d3.select(this).classed('fixing', true)
  setDisplayCta(false)
  setDisplayNodeDescription(false)
  setNodeData({})
}

function dragged(event: DragEvent, d: any) {
  d.fx = event.x
  d.fy = event.y
  simulation.alpha(1).restart()
  setDisplayNodeDescription(true)
  d.class === 'story-node' && setDisplayCta(true)
  setNodeData({
    name: d.name as string,
    class: d.class as string,
    definition: d.definition as string,
    summary: d.summary as string,
  })
}

//   dragended function in case we move away from sticky dragging!
function dragended(event: DragEvent, d: DNode) {
  // @ts-ignore
  d3.select(this).classed('fixed', true)
  console.log(d)
}

function click(event: TouchEvent, d: DNode) {
  delete d.fx
  delete d.fy
  console.log(d)
  // @ts-ignore
  d3.select(this).classed('fixed', false)
  // @ts-ignore
  d3.select(this).classed('fixing', false)
  simulation.alpha(1).restart()
}

const simulation = d3
  .forceSimulation(nodes as any[])
  .force(
    'link',
    d3.forceLink(links).id((d: any) => d.id)
  )
  .force('charge', d3.forceManyBody().strength(isMobile ? -600 : -1300))
  .force('collision', d3.forceCollide().radius(isMobile ? 5 : 20))
  .force('x', d3.forceX())
  .force('y', d3.forceY())

if (container.children) {
  d3.select(container).selectAll('*').remove()
}

const zoom = d3
  .zoom()
  .on('zoom', (event) => {
    group.attr('transform', event.transform)
  })
  .scaleExtent([0.2, 100])

const svg = d3
  .select(container)
  .append('svg')
  .attr('viewBox', [-width / 2, -height / 2, width, height])

const group = svg
  .append('g')
  .attr('width', '100%')
  .attr('height', '100%')
  .call(
    d3
      .drag()
      .on('start', dragstarted)
      .on('drag', dragged as any)
      .on('end', dragended as any) as any
  )
// .call(zoom as any)

const link = group
  .append('g')
  .attr('stroke', '#1e1e1e')
  .attr('stroke-opacity', 0.2)
  .selectAll('line')
  .data(links)
  .join('line')

const node = group
  .append('g')
  .selectAll<SVGCircleElement, { x: number; y: number }>('g')
  .data(nodes)
  .join('g')
  .classed('node', true)
  .classed('fixed', (d: any) => d.fx !== undefined)
  .attr('class', (d: any) => d.class as string)
  .call(
    d3
      .drag()
      .on('start', dragstarted)
      .on('drag', dragged as any)
      .on('end', dragended as any) as any
  )
  .on('click', click as any)

d3.selectAll('.category-node')
  .append('circle')
  .attr('fill', '#0083C5')
  .attr('r', isMobile ? 4 : 7)

d3.selectAll('.tag-node')
  .append('circle')
  .attr('fill', '#FFC434')
  .attr('r', isMobile ? 4 : 7)

d3.selectAll('.story-node')
  .append('foreignObject')
  .attr('height', isMobile ? 18 : 35)
  .attr('width', isMobile ? 18 : 35)
  .attr('x', isMobile ? -9 : -17)
  .attr('y', isMobile ? -18 : -30)
  .attr('r', isMobile ? 16 : 30)
  .append('xhtml:div')
  .attr('class', 'node-image')
  .append('xhtml:img')
  .attr('src', (d: any) => d.image)
  .attr('transform-origin', 'center')
  .attr('height', isMobile ? 18 : 35)
  .attr('width', isMobile ? 18 : 35)

d3.selectAll('.main-story-node')
  .append('foreignObject')
  .attr('height', isMobile ? 50 : 100)
  .attr('width', isMobile ? 50 : 100)
  .attr('x', isMobile ? -25 : -50)
  .attr('y', isMobile ? -25 : -50)
  .attr('r', isMobile ? 50 : 100)
  .append('xhtml:div')
  .attr('class', 'node-image')
  .append('xhtml:img')
  .attr('src', (d: any) => d.image)
  .attr('transform-origin', 'center')
  .attr('height', isMobile ? 50 : 100)
  .attr('width', isMobile ? 50 : 100)

node
  .append('foreignObject')
  .attr('height', (d: any) => (d.class === 'main-story-node' ? 65 : 55))
  .attr('width', (d: any) =>
    isMobile
      ? d.class === 'main-story-node'
        ? 80
        : 50
      : d.class === 'main-story-node'
      ? 120
      : 70
  )
  .attr('x', (d: any) =>
    isMobile
      ? d.class === 'main-story-node'
        ? -40
        : -25
      : d.class === 'main-story-node'
      ? -60
      : -35
  )
  .attr('y', (d: any) =>
    isMobile
      ? d.class === 'main-story-node'
        ? 32
        : 7
      : d.class === 'main-story-node'
      ? 60
      : 12
  )
  .append('xhtml:p')
  .attr('class', (d: any) => d.class)
  .text((d: any) => d.name)

simulation.on('tick', () => {
  link
    .attr('x1', (d: any) => d.source.x)
    .attr('y1', (d: any) => d.source.y)
    .attr('x2', (d: any) => d.target.x)
    .attr('y2', (d: any) => d.target.y)
  node
    .attr('cx', (d: any) => d.x as number)
    .attr('cy', (d: any) => d.y as number)
    .attr('transform', (d: any) => {
      return `translate(${d.x},${d.y})`
    })
})

function transition(zoomLevel: number) {
  group
    .transition()
    .delay(100)
    .duration(500)
    .call(zoom.scaleBy as any, zoomLevel)
}

transition(0.7)

d3.selectAll('.zoom-button').on('click', function () {
  // @ts-ignore
  if (this && this.id === 'zoom-in') {
    transition(1.2) // increase on 0.2 each time
  }
  // @ts-ignore
  if (this.id === 'zoom-out') {
    transition(0.8) // deacrease on 0.2 each time
  }
  // @ts-ignore
  if (this.id === 'zoom-init') {
    group
      .transition()
      .delay(100)
      .duration(500)
      .call(zoom.scaleTo as any, 0.7) // return to initial state
  }
})
解决方案

问题核心是<g>无事件捕获区域、拖拽逻辑与节点混淆,以下是修复步骤:

1. 给<g>添加可捕获事件的背景

<g>本身是容器元素,没有可视区域无法触发鼠标事件,需添加透明全屏矩形确保能捕获拖拽:

const group = svg
  .append('g')
  .attr('width', '100%')
  .attr('height', '100%')

// 添加透明背景矩形用于捕获拖拽事件
group.append('rect')
  .attr('width', width)
  .attr('height', height)
  .attr('x', -width/2)
  .attr('y', -height/2)
  .attr('fill', 'transparent')
  .attr('pointer-events', 'all')

2. 为<g>编写独立的拖拽逻辑

节点拖拽是修改单个节点的fx/fy,而<g>拖拽是平移整个图表,需用独立逻辑并与zoom状态同步:

// 记录拖拽起始时的transform状态
let initialTransform

function groupDragStarted(event) {
  initialTransform = d3.zoomTransform(svg.node())
  // 阻止事件冒泡,避免触发节点拖拽逻辑
  event.sourceEvent.stopPropagation()
}

function groupDragged(event) {
  // 计算拖拽偏移量
  const dx = event.x - event.sourceEvent.offsetX
  const dy = event.y - event.sourceEvent.offsetY
  // 生成新的变换矩阵
  const newTransform = initialTransform.translate(dx, dy)
  // 更新group的transform
  group.attr('transform', newTransform)
  // 同步zoom状态,保证缩放平移一致
  svg.call(zoom.transform, newTransform)
}

function groupDragEnded() {
  initialTransform = null
}

3. 绑定拖拽到<g>并启用zoom

将新拖拽函数绑定到<g>,同时启用svg的zoom功能:

// 绑定group的拖拽
group.call(
  d3.drag()
    .on('start', groupDragStarted)
    .on('drag', groupDragged)
    .on('end', groupDragEnded)
)

// 启用svg的zoom功能
svg.call(zoom as any)

4. 保留节点原有拖拽逻辑

节点拖拽逻辑无需修改,groupDragStarted中的stopPropagation()已确保拖拽节点时不会触发group的拖拽事件。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.21 21:14:52