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

D3.js交互配置:鼠标滚轮与触摸板的缩放平移设置

解决D3.js触摸板与鼠标的缩放/平移行为区分问题

问题分析

当前代码中,触摸板双指同向移动被误识别为缩放而非平移,核心原因是D3默认将触摸板的双指操作统一归为wheel事件,未区分双指平移(应触发平移)和双指缩放(应触发缩放);同时原filter逻辑仅允许滚轮按下(event.which === 2)和wheel事件,进一步导致行为错位。

解决方案

要实现目标交互:鼠标滚轮滚动/触摸板双指缩放 → 缩放;鼠标滚轮按下拖动/触摸板双指同向拖动 → 平移,需从事件区分、过滤逻辑、变换应用三个维度调整:

核心修改代码

useEffect(() => {
  let isTouchPadPan = false;

  const zoom = d3.zoom()
    .filter(function(event) {
      const sourceEvent = event.sourceEvent;
      // 允许鼠标滚轮按下拖动(平移)
      if (event.which === 2) return true;
      // 处理wheel事件(鼠标滚轮/触摸板)
      if (sourceEvent?.type === 'wheel') {
        // 触摸板双指平移:deltaX/Y同时非零、无ctrl键、像素模式
        if (sourceEvent.deltaX !== 0 && sourceEvent.deltaY !== 0 && !sourceEvent.ctrlKey && sourceEvent.deltaMode === 0) {
          isTouchPadPan = true;
          return true;
        }
        // 鼠标滚轮缩放/触摸板双指缩放:单轴变化或带ctrl键
        if ((sourceEvent.deltaX === 0 && sourceEvent.deltaY !== 0) || sourceEvent.ctrlKey) {
          isTouchPadPan = false;
          return true;
        }
      }
      // 处理触摸事件双指操作
      if (sourceEvent?.type === 'touchmove' && sourceEvent.touches.length === 2) {
        const touch1 = sourceEvent.touches[0];
        const touch2 = sourceEvent.touches[1];
        const prevTouch1 = sourceEvent.changedTouches[0];
        const prevTouch2 = sourceEvent.changedTouches[1];
        
        const deltaX1 = touch1.clientX - prevTouch1.clientX;
        const deltaX2 = touch2.clientX - prevTouch2.clientX;
        const deltaY1 = touch1.clientY - prevTouch1.clientY;
        const deltaY2 = touch2.clientY - prevTouch2.clientY;
        
        // 同向移动判定阈值(可根据设备调整)
        const threshold = 5;
        if (Math.abs(deltaX1 - deltaX2) < threshold && Math.abs(deltaY1 - deltaY2) < threshold) {
          isTouchPadPan = true;
          return true;
        } else {
          isTouchPadPan = false;
          return true;
        }
      }
      return false;
    })
    .scaleExtent([0.1, 10]) // 修正缩放范围为正数(原负数范围会导致图形翻转)
    .on('zoom', function(event) {
      const group = d3.select('#group');
      if (isTouchPadPan) {
        // 触摸板双指平移:仅应用平移,保持当前缩放比例
        const currentTransform = d3.zoomTransform(d3.select('#workflow').node());
        const translateX = event.transform.x - currentTransform.x;
        const translateY = event.transform.y - currentTransform.y;
        const newTransform = currentTransform.translate(translateX, translateY);
        group.attr('transform', newTransform);
      } else {
        // 缩放/鼠标平移:应用完整变换(缩放+平移)
        group.attr('transform', event.transform);
      }
    });

  d3.select('#workflow').call(zoom);
}, []); // 移除不必要的handleZoom依赖,避免重复渲染

关键改动说明

  1. 修正缩放范围:将scaleExtent从[-10,10]改为[0.1,10],负数缩放会导致图形翻转,不符合常规交互需求。
  2. 触摸板事件区分:
    • 通过wheel事件的deltaX/deltaY和ctrlKey判断:双指平移会同时产生X/Y轴位移,且不带ctrl;缩放则为单轴位移或带ctrl。
    • 通过touchmove事件中双指的移动差值判断:差值小于阈值时视为同向平移,否则为缩放。
  3. 自定义变换应用:在zoom回调中,针对触摸板平移场景仅应用平移变换,保持缩放比例不变,确保行为符合预期。

完整可运行代码

const { useEffect } = React;

const App = () => {
  
  useEffect(() => {
    let isTouchPadPan = false;

    const zoom = d3.zoom()
      .filter(function(event) {
        const sourceEvent = event.sourceEvent;
        if (event.which === 2) return true;
        if (sourceEvent?.type === 'wheel') {
          if (sourceEvent.deltaX !== 0 && sourceEvent.deltaY !== 0 && !sourceEvent.ctrlKey && sourceEvent.deltaMode === 0) {
            isTouchPadPan = true;
            return true;
          }
          if ((sourceEvent.deltaX === 0 && sourceEvent.deltaY !== 0) || sourceEvent.ctrlKey) {
            isTouchPadPan = false;
            return true;
          }
        }
        if (sourceEvent?.type === 'touchmove' && sourceEvent.touches.length === 2) {
          const touch1 = sourceEvent.touches[0];
          const touch2 = sourceEvent.touches[1];
          const prevTouch1 = sourceEvent.changedTouches[0];
          const prevTouch2 = sourceEvent.changedTouches[1];
          
          const deltaX1 = touch1.clientX - prevTouch1.clientX;
          const deltaX2 = touch2.clientX - prevTouch2.clientX;
          const deltaY1 = touch1.clientY - prevTouch1.clientY;
          const deltaY2 = touch2.clientY - prevTouch2.clientY;
          
          const threshold = 5;
          if (Math.abs(deltaX1 - deltaX2) < threshold && Math.abs(deltaY1 - deltaY2) < threshold) {
            isTouchPadPan = true;
            return true;
          } else {
            isTouchPadPan = false;
            return true;
          }
        }
        return false;
      })
      .scaleExtent([0.1, 10])
      .on('zoom', function(event) {
        const group = d3.select('#group');
        if (isTouchPadPan) {
          const currentTransform = d3.zoomTransform(d3.select('#workflow').node());
          const translateX = event.transform.x - currentTransform.x;
          const translateY = event.transform.y - currentTransform.y;
          const newTransform = currentTransform.translate(translateX, translateY);
          group.attr('transform', newTransform);
        } else {
          group.attr('transform', event.transform);
        }
      });

    d3.select('#workflow').call(zoom);
  }, []);
  
  return (
    <svg id="workflow">
      <g id="group">
        <foreignObject x="100" y="50">
           <div>Node 1</div>
        </foreignObject>
        <foreignObject x="200" y="150">
           <div>Node 2</div>
        </foreignObject>
      </g>
    </svg>
  )
}

const root = ReactDOM.createRoot(
  document.getElementById('app')
);

root.render(<App/>)
* {
 margin: 0;
 padding: 0;
 box-sizing: border-box;
}

html, body, #app {
  width: 100%;
  height: 100%;
}

#workflow {
  width: 100%;
  height: 100%;
}

foreignObject {
  width: 1px;
  height: 1px;
  overflow: visible;
}

foreignObject > div {
 padding: 10px;
 background-color: #c0c0c0;
 border-radius: 10px;
 width: 100px
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.1/d3.min.js"></script>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>

<div id="app"></div>

注意事项

  • 触摸板事件行为存在设备/浏览器差异,可调整threshold阈值适配不同设备。
  • 移除了handleZoom依赖,避免React不必要的重渲染。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 01:01:12