SVG panning in React results in weird behavior

334 Views Asked by At

The below React component should allow for panning of an SVG. It does, but for some reason the SVG's movement accelerates exponentially so that a few pixels movement of the mouse pointer results in increasingly large movement of the SVG (or rather, the SVG viewBox). Eg, a slight drag of the mouse and the circle zips off the screen.

here's a fiddle of it: https://jsfiddle.net/bupham/ax473r52/4/

It seems like there may be some React feedback loop happening, but I am not sure. The panning behavior code is from another SO post here.

I tried moving the method calls to the container, or the SVG, but it still happens. I've tried passing a function to setState, still happens. I tried making shallow copies of state.viewBox and not making shallow copies -- still happens. What am I doing wrong?

export default class SVGContainer extends React.Component {
  constructor(props) {
    super(props);

    this.state= {
      viewBox: {x:0,y:0,w:500,h:500},
      svgSize: {w: 500, h: 500},
      scale: 1,
      isPanning: false, 
      startPoint: {x:0,y:0},
      endPoint: {x:0,y:0},
    }
  }

  handleMouseDown = (e) => {
    console.log('handleMouseDown e', e)
    this.setState({
      isPanning: true,
      startPoint: {x: e.clientX, y: e.clientY}, 
    })
  }

  handleMouseMove = (e) => {
      this.setState((prevState, props) => {
        if (prevState.isPanning) {
          console.log('handleMouseMove e', e.clientX, e.clientY)
          
          let startPoint = prevState.startPoint;
          const scale = prevState.scale; 
          const viewBox = prevState.viewBox;
          const endPoint = {x: e.clientX, y: e.clientY};
          const dx = (startPoint.x - endPoint.x) / scale;
          const dy = (startPoint.y - endPoint.y) / scale;
          const newViewbox = {x:viewBox.x+dx, y:viewBox.y+dy, w:viewBox.w, h:viewBox.h};
          console.log('the view box', newViewbox)
          return {viewBox: newViewbox};
        }
      }) 
  }

  handleMouseUp = (e) => {
    if (this.state.isPanning){ 
      let startPoint = this.state.startPoint;
      const scale = this.state.scale; 
      const viewBox = this.state.viewBox;
      const endPoint = {x: e.clientX, y: e.clientY};
      var dx = (startPoint.x - endPoint.x)/scale;
      var dy = (startPoint.y - endPoint.y)/scale;
      const endViewBox = {x: viewBox.x+dx, y: viewBox.y+dy, w: viewBox.w, h: viewBox.h};
      console.log('viewbox at mouseup',endViewBox)
      this.setState({
        viewBox: endViewBox,
        isPanning: false,
      });
      
    }
  }

  handleMouseLeave = (e) => {
    this.setState({
      isPanning: false,
    })
  }

  render() {

    return (
      <div className="container" >
        <svg width="500" height="500" 
          onWheel={this.handleWheelZoom}
          onMouseDown={this.handleMouseDown}
          onMouseMove={this.handleMouseMove}
          onMouseUp={this.handleMouseUp}
          onMouseLeave={this.handleMouseLeave}          
          viewBox={`${this.state.viewBox.x} ${this.state.viewBox.y} ${this.state.viewBox.w} ${this.state.viewBox.h}`}>
          <circle cx="50" cy="50" r="50" /> 
        </svg>
      </div>
    );  
  }
}
2

There are 2 best solutions below

0
Ben Upham On

This is not technically an answer, but rather a solution: I decided to use d3 (4.13.0) instead because the cross-browser/touch complexities of zoom is too much to bother writing by hand.

As for why it wasn't working, it most likely has to do with the async nature of React and React state. A friend suggested using requestAnimationFrame() and/or throttling the mouse events.

Here's what the relevant code I used looks like. I added two React refs for the two DOM nodes I needed to manipulate with D3:

export default class SVGContainer extends React.Component {
  constructor(props) {
    super(props);
    this.svgRef = React.createRef();
    this.gRef = React.createRef();

    this.state= {
      container: {width: 1000, height: 1000},
    }
  }

  componentDidMount() {
    window.addEventListener('resize', this.handleWindowResize);
    const width = window.innerWidth;
    const height = window.innerHeight;
    this.setState({
      container: {width, height},
    })

    const svg = d3.select(this.svgRef.current);
    // D3 wants you to call zoom on a container and then apply the zoom transformations
    // elsewhere...you can read why in the docs. 
    svg.call(this.handleZoom).call(this.handleZoom.transform, initialZoom);
  }
  
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleWindowResize);
  }

  handleWindowResize = (e) => {
    const height = this.svgRef.current.clientHeight;
    const width = this.svgRef.current.clientWidth;

    this.setState({
      container: {width, height},
    });    
  }

  handleZoom = d3.zoom().on('zoom', e => {
      const g = d3.select(this.gRef.current);
      g.attr('transform', d3.event.transform)
  })

  render() {

    return (
      <div className="SVGContainer">
        <svg 
          width={this.state.container.width} height={this.state.container.height} 
          ref={this.svgRef}>
          <circle cx="50" cy="50" r="50" ref={this.gRef} /> 
        </svg>
      </div>
    );  
  }
}
0
WillBuchanan On

I just figured out a solution to a similar issue I was facing trying to implement drag and drop in React with SVG. This is how I did it:

const width = 1800;
const height = 1200;

export default function Page() {
  const { currentState: draggableX, dispatchUpdate: updateX } =
    useSelectiveContextDispatchNumber('draggableX', 0);
  const { currentState: draggableY, dispatchUpdate: updateY } =
    useSelectiveContextDispatchNumber('draggableY', 0);

  const [isDragging, setIsDragging] = useState(false);
  const [startPos, setStartPos] = useState({ x: 0, y: 0 });
  const svgRef = useRef<SVGSVGElement | null>(null);
  const [svgScale, setSvgScale] = useState(1);

  const handleMouseDown = (
    event: React.MouseEvent,
    unDraggedPosition: Coordinates
  ) => {
    console.log('handleMouseDown', unDraggedPosition, event);

    setIsDragging(true);
    setStartPos({
      x: event.nativeEvent.clientX,
      y: event.nativeEvent.clientY
    });
  };

  const handleMouseUp = (e: React.MouseEvent) => {
    console.log('handleMouseUp', e);
    setIsDragging(false);
  };

  const handleMouseMove = (e: React.MouseEvent) => {
    if (!isDragging) return;
    updateX({
      contextKey: 'draggableX',
      value: draggableX + (e.nativeEvent.clientX - startPos.x) * svgScale
    });
    updateY({
      contextKey: 'draggableY',
      value: draggableY + (e.nativeEvent.clientY - startPos.y) * svgScale
    });
    setStartPos({ x: e.nativeEvent.clientX, y: e.nativeEvent.clientY });
  };

  useEffect(() => {
    const svg = svgRef.current;
    if (svg) {
      const viewBox = svg.viewBox;
      console.log('viewbox', viewBox.animVal);
      console.log('scale', svg.currentScale);
      console.log('width and height:', svg.width, svg.height);
      setSvgScale(viewBox.baseVal.width / svg.width.baseVal.value);
    }
  }, [svgRef]);

  return (
    <svg
      ref={svgRef}
      className={'border-2 border-slate-600 rounded-lg'}
      viewBox={`0 0 ${width} ${height}`}
      style={{ width: '800', height: '600' }}
      xmlns="http://www.w3.org/2000/svg"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      <rect
        transform={`translate(${draggableX}, ${draggableY})`}
        width={'10%'}
        height={'10%'}
        className={'fill-teal-400'}
        onMouseDown={(e) =>
          handleMouseDown(e, { x: draggableX, y: draggableY })
        }
        onMouseMove={handleMouseMove}
        onMouseUp={(e) => handleMouseUp(e)}
      />
    </svg>
  );
}

FYI, useSelectiveContextDispatchNumber is a custom hook that references a general-purpose string:number context I use.