1

I have a SVG HTML element and I have implemented panning and zooming into it using the mouse. The current implementation of the zooming functionality just multiplies the original width and height of the element by a number that changes when the user scrolls the mouse.

This implementation preserves the origin (0,0) and all other points appear to move closer/further away from it depending on the direction of the zoom.

Intuitively and based o this question. I know, that If I want to zoom in/out on the point the mouse is currently pointing at, I have to pan the viewBox.

I have already looked at the linked questio, as well as two otheres, but I was unable to successfully apply the suggested solutions to my problem. I have also tried to derive the correct formula multiple times, but all my attempts so far have failed.

I am most likely missunderstanding something about the problem and I seem to be unable to generalise the existing answers to my problem.

The following values represent the current state of my viewBox:

  • offsetX
  • offsetY
  • scroll
  • width
  • height

I compute the zoomFactor as a function of the scroll variable (Math.exp(scroll/1000)) and set the viewBox property of my SVG as follows: `${offsetX} ${offsetY} ${width * zoomFactor} ${height * zoomFactor}`.

What I am struggling with, is computing the new offsetX and offsetY values based on the previous state and the current position of the mouse inside of the SVG.

processMouseScroll(event: WheelEvent) {
    const oldZoomFactor = zoomFactor(this.scroll);
    const newZoomFactor = zoomFactor(this.scroll + event.deltaY);

    this.scroll = this.scroll + event.deltaY;
    this.offsetX = ???;
    this.offsetY = ???;
}

How do I compute the new offsets, based on the previous state, so that the when scrolling the mouse, the point bellow it will appear to be stationary?

Thank you for your answers.

Minop
  • 386
  • 4
  • 13
  • I was able to identify at least one of my problems. I assumed that the `event.x` property returns mouse coordinates relative to the svg element, but the value appears to be relative to the screen. The formula I am currently using still does not work, but maybe one of the others might now... – Minop Oct 14 '22 at 09:38

2 Answers2

2

I have finally managed to get it working. Turns out, the answer from the first question I found was correct, but my understanding of SVG viewBox was incorrect and I used bad mouse coordinates.

coordinate changes when scaling a viewBox

the offset (min-x and min-y; drawn green) of a viewBox is abbsolute and does not depend on the width and height of the viewBox. The mouse coordinates relative to the SVG element (coordinates drawn in black, SVG element drawn in red) are relative to the size of the viewBox. If I enlarge the viewBox, then the part of the picture I can see inside of it shrinks and 100px line drawn by the mouse will cover more of the image.

If we set the size of the dimensions of the viewBox to be the same as the size of the SVG element (initial state), we have a 1:1 scale between the image and the viewBox (the red rectangle would cover the entire image, bordered black). When we make the viewBox smaller we will not fit the entire image into it and therefore the image will appear to be larger.

If we want to compute the absolute position of our mouse in relation to the entire image we can do it like this (same for Y): position = offsetX + zoomFactor * mouseX (mouseX relative to the SVG element).

When we zoom, we change the factor, but don't change the position of the mouse. If we want the absolute position under the mouse to remain the same, we have to solve the following set of equations:

oldPosition = oldOffsetX + oldZoomFactor * mouseX

newPosition = newOffsetX + newZoomFactor * mouseX

oldPosition = newPosition

we know the mouse position, both zoom factors and the old offset, therefore we solve for the new offset and get:

newOffsetX = oldOffsetX + mouseX * (oldZoomFactor - newZoomFactor)

which is the final formula and very similar to this answer.

Put together we get the final working solution:

processMouseScroll(event: WheelEvent) {
    const oldZoomFactor = zoomFactor(this.scroll);
    const newZoomFactor = zoomFactor(this.scroll + event.deltaY);

    // mouse position relative to the SVG element
    const mouseX = event.pageX - (event.target as SVGElement).getBoundingClientRect().x;
    const mouseY = event.pageY - (event.target as SVGElement).getBoundingClientRect().y;

    this.scroll = this.scroll + event.deltaY;
    this.offsetX = this.offsetX + mouseX * (oldZoomFactor - newZoomFactor);
    this.offsetY = this.offsetY + mouseY * (oldZoomFactor - newZoomFactor);
}
Minop
  • 386
  • 4
  • 13
  • Ok, this saved me so much time! THanks a million. Couldn't quite wrap my head around it. Nice how you show the derivations of the formula's using good old maths. – Pianoman Jul 16 '23 at 17:44
  • Yes, thank you! I too was really confused when I tried to figure this out and once I did, I thought it would be helpfull for others to see my process. What I find interesting is that the final formula is the exact same as the one in the linked answer, but just multiplied with -1. Must have something to do with the way the two coordinate systems are setup. – Minop Jul 17 '23 at 11:27
  • 1
    Ok, I still had weird behaviours so I invested some hours in discovering step-by-step what was wrong in my specific situation. I will setup my own anwser for documentation's sake – Pianoman Jul 31 '23 at 09:24
1

So here is my solution to the problem. My situation is best described using an image. screendump There is a toolbar above and a selection list on the left. Therefore, the SVG-drawing is not at (0,0) in the browser, but could vary.

The trick I am using is that you can ask any SVG-element for its boundingbox. However, this box is absolute in the browsers' viewport. So I need to correct for the offset of the toolbar and selection list.

Having done that I know where my drawing is in normal (browsers') coordinates system. By having some variables to administer panning and zoom correction I can do the right calculations.

I have tested this in Angular 16. An extraction from my template:


<svg xmlns="http://www.w3.org/2000/svg"
       width="calc(100% - 1px)"
       height="calc(100% - 1px)"
       (wheel)="zoomInOut($event)"
       (mouseup)="endAllDragging($event)"
       #svgBaseElement>
<g *ngIf="store.workflowWasSelected" [attr.transform]="transformStr" #transformcontainer> 
....
</g>
</svg>

Notice the following:

  1. mouse wheel (for zooming) is caught at the SVG-element level
  2. the transformation for panning & zooming is set at a <g>-element

Ok. So now for the interesting stuff I discovered. Hopefully this will help others.

Be aware!! When using 'transform' on an SVG <G>-element the order in wich the transformations are done will matter. So

  <g transform='scale(2) transform(10,20)'>....</g>

will yield different results from

  <g transform='transform(10,20) scale(2) '>....</g>

In the first case first the whole contents of the element are scaled by a factor 2, including the coordinates system. After transformation the shapes in the <g>-element are moved in the new coordinates system.

In the second case the whole contents of the element are moved to the given location , and then the whole drawing including coordinates system are scaled to a factor of 2.

Both use cases are valid; it just depends whether you want to do the calculations in the original coordinates system or in the scaled coordinates system.

Compare the output of

<html>
<body>
<div style="width: 500px;height:500px">
  <svg width="100" height="100" viewbox="0 0 100 100" style="background: gray;">
    <circle cx="50" cy="50" r="30" fill="blue"/>
  </svg>
  <svg width="100" height="100" viewbox="0 0 100 100"   style="background: gray;">
    <g transform=" translate(50,0) scale(0.5) ">
      <circle cx="50" cy="50" r="30" fill="orange" />
    </g>
  </svg>
</div>
</body>
</html>

to the output of

<html>
<body>
<div style="width: 500px;height:500px">
  <svg width="100" height="100" viewbox="0 0 100 100" style="background: gray;">
    <circle cx="50" cy="50" r="30" fill="blue"/>
  </svg>
  <svg width="100" height="100" viewbox="0 0 100 100"   style="background: gray;">
    <g transform=" scale(0.5) translate(50,0) ">
      <circle cx="50" cy="50" r="30" fill="orange" />
    </g>
  </svg>
</div>
</body>
</html>

I choose to do the calculations using the original coordinates system and will not take the following two cases into account:

  1. the position of the mouse so 'zoom in place' is done
  2. the image could be already moved ('panned') in either X or Y direction

These will be dealt with later!

Now calculate the amount of correction that must be done because the scaling will impact the coordinate system. So, when scaling to 25% (scaleFactor = 0.25) then the drawing will move in the direction of the top-left corner.

The (x,y) coordinates of the -element will be reduced to 25%; so if a rectangle is at (80,100)x(50,60) (x,y)*(w,h) then after transform='scale(0.25)' , it will appear as if it was drawn at a 100% scale with coordinates (20,25)x(12.5,15).

So, in order to keep the drawing at exact the same (x,y) we need to correct using a translation: we have to move the drawing back a certain amount. It should again appear as if it was drawn at (80,100). So we correct it to translate right 60 pixels (calculations displayed are for the X-coordinates, same applies for the Y-coordinates)

80 - 0.25 * 80 = 80 - 20 = 60. 

So scaling down means a positive translation to the right. Scaling up means a translation the left.

Scaling up example

Top/left = (80, 100) , scale = 150% (=1.5). Formula:

80 - 1.5 * 80 = 80 - 120 = -40. 

So this works: we translate to left because when zooming in, the coordinates will become bigger visually and we the drawing will move to the right. By using a negative translation we will move the coordinates back to the left and the image will stay at the same place visually.

So the formula will be (scalingfactor 1 = 100%, 0.5 = 50%, 1,25 = 125% etc)

new(x) = old(x) * newScalingFactor
correction(x) = old(x) - old(x) * newScalingFactor

Dealing with negative values

What if values for (x,y) position of the drawing turn out to be negative? This can happen if the user moved the image partly out of view to the left.

For instance drawing is at (top/left) (-40, -12) with zoom-factor 1. Then if zoom-factor if changed to 25% the coordinates are scaled down so the top-left is visible as if it was drawn at 100% at coordinates (-10,-3). So now the drawing suddenly moves to the RIGHT according to the coordinates: -10 is to the right of -40 in coordinate systems.

However, because the scale is reduced the drawing is visually still moving to the LEFT! You can try this in the example above.

What if already a translation(x,y) is present?!

So now lets have a look at when the image is already moved.

The diagram could already be moved ('panned') to a different location by the user or because of the correction we applied due to zooming. So we need to know what part of the position is determined by user-panning and what part is determined by the zoom correction. By introducing new variables this can be taken into account:

let zoomCorrectionPanX: number;
let zoomCorrectionPanY: number;

will administer the correction needed due to zooming.


let userPanningX: number;
let userPanningY: number; 

will administer the panning done by a user.

Please note: these variables all have values that are in the ORIGINAL coordinates system. Therefore, we can use them to do calculation.

Important: when panning, we do not need to correct for the zoom-factor anymore. The panning can be administered in the normal coordinate system of the browsers' units.

Calculate the RELATIVE position of the drawing within the SVG. This is

  1. calculated in the original coordinate system (so unzoomed!)
  2. including panning in X & Y direction.

The panning can be obtained from the variables (userPanningX,userPanningY). So the actual position on the screen is determined by three factors:

  1. the position of the SVG in the browser's viewport
  2. the amount of panning
  3. the position of the complete drawing on screen.

Now the position of the drawing can be determined. In order to facilitate 'center weight zooming' (the center of the image will remain in the same position) the position is increased with half the height and half the width.

Another way of zooming would be to use in the 'in place zooming' (the drawing is zoomed at the place where the mouse is positioned). All we need to do then is use the position of the mouse corrected by the user panning. Both scenarios are depicted below in the final code


zoomInOut(event: WheelEvent): void {

    const mouseX = (event.offsetX);
    const mouseY = (event.offsetY);

    if (event.deltaY < 0) {
      this.scaleFactor = Math.min(this.scaleFactor + 0.1, 2);  // zoom in, max zoom = 2
    } else {
      this.scaleFactor = Math.max(this.scaleFactor - 0.1, 0.4); // zoom out, max zoom = 0.4
    }

    // get the absolute position of the drawing and the SVG so we can calculate where the drawing was originally drawn
    const svgRect = this.svgBaseElement.nativeElement.getBoundingClientRect();
    // note that the getBoundingClientRect() will show the current bounding box as drawn using the Scale + Translate!
    const drawingRect = this.transformcontainer.nativeElement.getBoundingClientRect();


    const centerOfDrawingX = drawingRect.x - svgRect.x - this.userPanningX + drawingRect.width / 2;
    const centerOfDrawingY = drawingRect.y - svgRect.y - this.userPanningY + drawingRect.height / 2;



    // ok, this is not yet working as it should. looking at it presently and will update when it does work!
    let zoomOnMouseX = mouseX - this.userPanningX;
    let zoomOnMouseY = mouseY - this.userPanningY;

    // now the 'in place zooming' is in use.
    this.zoomCorrectionPanX = zoomOnMouseX - (zoomOnMouseX * this.scaleFactor);
    this.zoomCorrectionPanY = zoomOnMouseY - (zoomOnMouseY * this.scaleFactor);

    // when you want to use 'center of drawing' zoom, use the two lines below
    // this.zoomCorrectionPanX = centerOfDrawingX - (centerOfDrawingX * this.scaleFactor);
    // this.zoomCorrectionPanY = centerOfDrawingY - (centerOfDrawingY * this.scaleFactor);

  this.createTransformMatrixString();
}

the function to create the actual transformation string on the <g>-element is crucial:

  /**
   * Method to setup the actual SVG-transform string; is bound using [()] on a SVG-element
   */
  createTransformMatrixString(): void {
    const translateStr = `translate(${this.userPanningX + this.zoomCorrectionPanX} ${this.userPanningY + this.zoomCorrectionPanY})`;
    const scaleStr = `scale(${this.scaleFactor})`;

    this.transformStr = `${translateStr} ${scaleStr}  `;
  }

In order to get a reference to the elements in the <SVG> we use ViewChildin Angular:

  @ViewChild('svgBaseElement') svgBaseElement: ElementRef | undefined;
  @ViewChild('transformcontainer') transformcontainer: ElementRef | undefined;

Finally, my drag-function which is quite simple. The variables startPanDiagramX and startPanDiagramY are set in the startDragging function.

Notice that I want to enable double-click so therefore there is a two-stage drag-detection: click will register the intent to drag. Only when the mouse-move actually detects movement, the other flag (isDraggingDiagram) is set.

  dragDiagramStart(event: MouseEvent): void {
    if (event.button !== 0) {
      return
    }
    event.stopPropagation();
    event.preventDefault();

    this.startDragDiagramX = event.offsetX;
    this.startDragDiagramY = event.offsetY;

    // record the current panning
    this.startPanDiagramX = this.userPanningX;
    this.startPanDiagramY = this.userPanningY;

    this.dragDiagramHasStarted = true;
  }


  dragDiagram(event: MouseEvent): void {
    if (event.button !== 0) {
      return;
    }

    event.stopPropagation();
    event.preventDefault();

    if (this.dragDiagramHasStarted) {
      this.isDraggingDiagram = true;

      const newX = event.offsetX;
      const newY = event.offsetY;

      // calculate the new position by calculating the difference between the start-drag and current-drag position
      const diffX = this.startDragDiagramX - newX;
      const diffY = this.startDragDiagramY - newY;

      // adjust the panning by subtracting the differences (note that dragging left yields a negative PanX-value
      this.userPanningX = this.startPanDiagramX - diffX;
      this.userPanningY = this.startPanDiagramY - diffY;

      this.createTransformMatrixString();
    }
  }

Pianoman
  • 327
  • 2
  • 10