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依赖,避免重复渲染
关键改动说明
- 修正缩放范围:将
scaleExtent从[-10,10]改为[0.1,10],负数缩放会导致图形翻转,不符合常规交互需求。 - 触摸板事件区分:
- 通过
wheel事件的deltaX/deltaY和ctrlKey判断:双指平移会同时产生X/Y轴位移,且不带ctrl;缩放则为单轴位移或带ctrl。 - 通过
touchmove事件中双指的移动差值判断:差值小于阈值时视为同向平移,否则为缩放。
- 通过
- 自定义变换应用:在
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
相关产品推荐
相关产品推荐

