Zoom in on a point (using scale and translate)
Asked Answered
A

17

187

I want to be able to zoom in on the point under the mouse in an HTML 5 canvas, like zooming on Google Maps. How can I achieve that?

Astrodynamics answered 26/5, 2010 at 19:24 Comment(4)
I used this for zooming my canvas and it works great! The only thing i have to add is, that the calculation of the zoom amount is not as you would expect. "var zoom = 1 + wheel/2;" i.e. this results in 1.5 for zooming in and 0.5 for zooming out. I edited this in my version so that i have 1.5 for zooming in and 1/1.5 for zooming out which makes the amount of zooming in and zooming out equal. So if you zoom in once and zoom back you will have the same picture as before the zooming.Warfeld
Note that this doesn't work on Firefox, but the method can easily be applied to jQuery mousewheel plugin. Thanks for sharing!Firth
var zoom = Math.pow(1.5f, wheel); // Use this to calculate zoom. It has the benefit that zooming by wheel=2 is the same as zooming twice by wheel=1. In addition, zooming in by +2 and out by +2 restores the original scale.Marketable
For a photoshop-alike edit area, see: ZoomPanNadanadab
A
94

Finally solved it:

const zoomIntensity = 0.2;

const canvas = document.getElementById("canvas");
let context = canvas.getContext("2d");
const width = 600;
const height = 200;

let scale = 1;
let originx = 0;
let originy = 0;
let visibleWidth = width;
let visibleHeight = height;


function draw(){
    // Clear screen to white.
    context.fillStyle = "white";
    context.fillRect(originx, originy, width/scale, height/scale);
    // Draw the black square.
    context.fillStyle = "black";
    context.fillRect(50, 50, 100, 100);

    // Schedule the redraw for the next display refresh.
    window.requestAnimationFrame(draw);
}
// Begin the animation loop.
draw();

canvas.onwheel = function (event){
    event.preventDefault();
    // Get mouse offset.
    const mousex = event.clientX - canvas.offsetLeft;
    const mousey = event.clientY - canvas.offsetTop;
    // Normalize mouse wheel movement to +1 or -1 to avoid unusual jumps.
    const wheel = event.deltaY < 0 ? 1 : -1;

    // Compute zoom factor.
    const zoom = Math.exp(wheel * zoomIntensity);
    
    // Translate so the visible origin is at the context's origin.
    context.translate(originx, originy);
  
    // Compute the new visible origin. Originally the mouse is at a
    // distance mouse/scale from the corner, we want the point under
    // the mouse to remain in the same place after the zoom, but this
    // is at mouse/new_scale away from the corner. Therefore we need to
    // shift the origin (coordinates of the corner) to account for this.
    originx -= mousex/(scale*zoom) - mousex/scale;
    originy -= mousey/(scale*zoom) - mousey/scale;
    
    // Scale it (centered around the origin due to the translate above).
    context.scale(zoom, zoom);
    // Offset the visible origin to it's proper position.
    context.translate(-originx, -originy);

    // Update scale and others.
    scale *= zoom;
    visibleWidth = width / scale;
    visibleHeight = height / scale;
}
<canvas id="canvas" width="600" height="200"></canvas>

The key, as @Tatarize pointed out, is to compute the axis position such that the zoom point (mouse pointer) remains in the same place after the zoom.

Originally the mouse is at a distance mouse/scale from the corner, we want the point under the mouse to remain in the same place after the zoom, but this is at mouse/new_scale away from the corner. Therefore we need to shift the origin (coordinates of the corner) to account for this.

originx -= mousex/(scale*zoom) - mousex/scale;
originy -= mousey/(scale*zoom) - mousey/scale;
scale *= zoom

The remaining code then needs to apply the scaling and translate to the draw context so it's origin coincides with the canvas corner.

Astrodynamics answered 30/6, 2010 at 17:55 Comment(6)
how can this apply to a dom node?Carmine
What does 800 and 600 values represent when you clear the canvas?Peper
@GeorgianStan that was the width and height that I forgot to change. Replaced them with named variables now.Astrodynamics
Hi Thanks a lot for sharing this code! I wanted to ask if you know how to handle the transforms when the users changes the mouse position while zoomed in? In my case when this happens and I zoom out, the canvas seems to get moved a bit because the origin becomes negative at some point.Arhat
@Carmine you can use css's transform property to apply translations much like you can .translate() or .scale() a canvasDialectics
It's weird, your code works here, but not in my application. I'm also using translate to pan though. I'm not sure what I'm doing wrong, but it zooming is not happening directly under the mouse.Meave
B
159

The better solution is to simply move the position of the viewport based on the change in the zoom. The zoom point is simply the point in the old zoom and the new zoom that you want to remain the same. Which is to say the viewport pre-zoomed and the viewport post-zoomed have the same zoompoint relative to the viewport. Given that we're scaling relative to the origin. You can adjust the viewport position accordingly:

scalechange = newscale - oldscale;
offsetX = -(zoomPointX * scalechange);
offsetY = -(zoomPointY * scalechange);

So really you can just pan over down and to the right when you zoom in, by a factor of how much you zoomed in, relative to the point you zoomed at.

enter image description here

Buddle answered 23/5, 2015 at 9:12 Comment(17)
More valuable than cut and paste code is the explanation of what the best solution is and why it works without the baggage especially if it is three lines long.Buddle
scalechange = newscale / oldscale?Claudetteclaudia
No. They are subtracted. Because you use that as the factor to solve for the pan. Dividing them would be in error.Buddle
also, i would like to add for the ones seeking to achieve a map like pan-zoom component, that the mouse X, Y should be (mousePosRelativeToContainer - currentTransform)/currentScale otherwise it will treat the current mouse position as relative to the container.Bedraggle
Yes, this math assumes that the zoom as well as the pan are in the coords relevant to the origin. If they are relative to the viewport you must adjust them appropriately. Though I would suppose the correct math is zoomPoint = (mousePosRelativeToContainer + currentTranslation). This math also assumes the origin point is typically in the upper left of the field. But, adjusting for slightly atypical situations is much easier given the simplicity.Buddle
Tatarize, would you be willing to help with me a problem of mine similar to this ? In short: Im drawing a hex grid on the canvas, i can scroll fine using offset but im having huge troubles with anything regarding zoom. Can you advice me if i post a codepen with full functionallity ?Hidebound
@C.Finke sure. Such things depend a lot on how you are doing a zoom. Is it scaling up the canvas or are you trying to scale down the size of the shapes. In either case, are the shapes laid out properly to dynamically draw more of the during the zoom. If you get too overly complex sometimes it's worth coding up the actual solution with matrixes and implementation of the transformations (like if you want rotations, pan, zoom). My guess is you likely need to manually set your viewport or something and are messing that up. But I'll happily check.Buddle
I would greatly appreciate if you could check this codepen.io/AncientSion/pen/dGqjrp I left a bunch of remarks in the HTML portion. I think what i want is the simplest "zoom" function possible. The only requirements are that the hex-grid should still be centered after a zoom and the mouse-move event should also accord for the zoom factor (similar to how it currently accords for the global offset after scrolling). Any hint or advice would be greatly appreciated.Hidebound
@C.Finke There's two ways to do this. First you can multiply the hexsize by the zoom and recreate the grid each time the zoom changes. This will basically treat zoom like a scale factor for the shapes and recreate them at a size change depending zoom factor. Since you recreated everything you don't really have to care where anything was. (Do note your hexsize is radius rather than diameter and adjust the factor properly. (hexsize/2 * zoom) * 2. Since you're currently recreating everything you'd basically be done.Buddle
@C.Finke The second way it can be done is by using the translations within the ctx. You draw everything the same size and in the same positions. But, you simply use the matrix multiplications within the javascript canvas to set the pan and the scale (zoom) of the context. So rather than redraw all the shapes in a different location. You draw them in the same place and move the viewport within javascript. This method would also require that you take the mouse events and translate them backwards. So you'd subtract the pan, then reverse the factor by the zoom.Buddle
@C.Finke I would suppose given your criteria for simplicity and not wanting to write a way to translate back mouse events into the transformed context, you should be able to just scale everything by the zoom by multiplying the zoomin the hex size and recreating everything (especially given you have pretty much set already). If you had to do it again, you would want to draw everything in place, then adjust the transformations for the canvas. And then translate the mouse click locations by the inverse matrix. But naively, redrawing it bigger would take minimal changes.Buddle
Thanks for looking into it. I will spend some time pondering it. I might leave another comment at one point once im done, or stuck. Thanks for your time.Hidebound
This is nice solution, but for me, i needed to divide result offsets by new_zoom. var offsetX = -(mouse_offset_x * scalechange)/newzoom;Patrilineal
scalechange = newscale - oldscale, so your scale change is really (newscale - oldscale) / newscale? But that's 1 - (oldscale/newscale);. But, then you aren't really dealing with typical windowed affine transforms. It's like it normalizes your zoom back to 1, each time.Buddle
I suppose it's a worthwhile note that they could well by systems with First Quadrant positioning systems so +y rather than -, and some might well give you a zoom factor rather than scale. I betcha if you looked at the code you would see something like setZoom(newzoom) = scale *= newzoom; Which really means your newscale = oldscale * zoom. -- Which all makes perfect sense, my code is specifically based on scale rather than a zoom, except the global zoom with respect to the scene aka scale. In fact, if your zoom is what it looks like, it might actually be equal to scalechange or 1/scalechange.Buddle
it didn't work for me :( , i am using this code newtouchx=(touchx)/scale-translatex ,(where translatex is how much i moved the canvas ) ,now from what i understand from your code i should add the offsetX to the newtouchx ? can you help me plz , i already posted a question about #38416750 if you could check it plzOssiferous
No. The amount you've panned doesn't matter. What you need to be able to do is if you can zoom at the upper-left hand corner of the viewbox. This is how nearly all scale operations are applied. What I'm saying is that in addition to that zoom, also pan by an amount determined by that zoom and the point within that viewbox that you want to remain the same. You are adding to your panX and panY whatever they are (or subtracting if zooming out).Buddle
A
94

Finally solved it:

const zoomIntensity = 0.2;

const canvas = document.getElementById("canvas");
let context = canvas.getContext("2d");
const width = 600;
const height = 200;

let scale = 1;
let originx = 0;
let originy = 0;
let visibleWidth = width;
let visibleHeight = height;


function draw(){
    // Clear screen to white.
    context.fillStyle = "white";
    context.fillRect(originx, originy, width/scale, height/scale);
    // Draw the black square.
    context.fillStyle = "black";
    context.fillRect(50, 50, 100, 100);

    // Schedule the redraw for the next display refresh.
    window.requestAnimationFrame(draw);
}
// Begin the animation loop.
draw();

canvas.onwheel = function (event){
    event.preventDefault();
    // Get mouse offset.
    const mousex = event.clientX - canvas.offsetLeft;
    const mousey = event.clientY - canvas.offsetTop;
    // Normalize mouse wheel movement to +1 or -1 to avoid unusual jumps.
    const wheel = event.deltaY < 0 ? 1 : -1;

    // Compute zoom factor.
    const zoom = Math.exp(wheel * zoomIntensity);
    
    // Translate so the visible origin is at the context's origin.
    context.translate(originx, originy);
  
    // Compute the new visible origin. Originally the mouse is at a
    // distance mouse/scale from the corner, we want the point under
    // the mouse to remain in the same place after the zoom, but this
    // is at mouse/new_scale away from the corner. Therefore we need to
    // shift the origin (coordinates of the corner) to account for this.
    originx -= mousex/(scale*zoom) - mousex/scale;
    originy -= mousey/(scale*zoom) - mousey/scale;
    
    // Scale it (centered around the origin due to the translate above).
    context.scale(zoom, zoom);
    // Offset the visible origin to it's proper position.
    context.translate(-originx, -originy);

    // Update scale and others.
    scale *= zoom;
    visibleWidth = width / scale;
    visibleHeight = height / scale;
}
<canvas id="canvas" width="600" height="200"></canvas>

The key, as @Tatarize pointed out, is to compute the axis position such that the zoom point (mouse pointer) remains in the same place after the zoom.

Originally the mouse is at a distance mouse/scale from the corner, we want the point under the mouse to remain in the same place after the zoom, but this is at mouse/new_scale away from the corner. Therefore we need to shift the origin (coordinates of the corner) to account for this.

originx -= mousex/(scale*zoom) - mousex/scale;
originy -= mousey/(scale*zoom) - mousey/scale;
scale *= zoom

The remaining code then needs to apply the scaling and translate to the draw context so it's origin coincides with the canvas corner.

Astrodynamics answered 30/6, 2010 at 17:55 Comment(6)
how can this apply to a dom node?Carmine
What does 800 and 600 values represent when you clear the canvas?Peper
@GeorgianStan that was the width and height that I forgot to change. Replaced them with named variables now.Astrodynamics
Hi Thanks a lot for sharing this code! I wanted to ask if you know how to handle the transforms when the users changes the mouse position while zoomed in? In my case when this happens and I zoom out, the canvas seems to get moved a bit because the origin becomes negative at some point.Arhat
@Carmine you can use css's transform property to apply translations much like you can .translate() or .scale() a canvasDialectics
It's weird, your code works here, but not in my application. I'm also using translate to pan though. I'm not sure what I'm doing wrong, but it zooming is not happening directly under the mouse.Meave
E
28

This is actually a very difficult problem (mathematically), and I'm working on the same thing almost. I asked a similar question on Stackoverflow but got no response, but posted in DocType (StackOverflow for HTML/CSS) and got a response. Check it out http://doctype.com/javascript-image-zoom-css3-transforms-calculate-origin-example

I'm in the middle of building a jQuery plugin that does this (Google Maps style zoom using CSS3 Transforms). I've got the zoom to mouse cursor bit working fine, still trying to figure out how to allow the user to drag the canvas around like you can do in Google Maps. When I get it working I'll post code here, but check out above link for the mouse-zoom-to-point part.

I didn't realise there was scale and translate methods on Canvas context, you can achieve the same thing using CSS3 eg. using jQuery:

$('div.canvasContainer > canvas')
    .css('transform', 'scale(1) translate(0px, 0px)');

Make sure you set the CSS3 transform-origin to 0, 0 (transform-origin: 0 0). Using CSS3 transform allows you to zoom in on anything, just make sure the container DIV is set to overflow: hidden to stop the zoomed edges spilling out of the sides.

Whether you use CSS3 transforms, or canvas' own scale and translate methods is up to you, but check the above link for the calculations.


Update: Meh! I'll just post the code here rather than get you to follow a link:

$(document).ready(function()
{
    var scale = 1;  // scale of the image
    var xLast = 0;  // last x location on the screen
    var yLast = 0;  // last y location on the screen
    var xImage = 0; // last x location on the image
    var yImage = 0; // last y location on the image

    // if mousewheel is moved
    $("#mosaicContainer").mousewheel(function(e, delta)
    {
        // find current location on screen 
        var xScreen = e.pageX - $(this).offset().left;
        var yScreen = e.pageY - $(this).offset().top;

        // find current location on the image at the current scale
        xImage = xImage + ((xScreen - xLast) / scale);
        yImage = yImage + ((yScreen - yLast) / scale);

        // determine the new scale
        if (delta > 0)
        {
            scale *= 2;
        }
        else
        {
            scale /= 2;
        }
        scale = scale < 1 ? 1 : (scale > 64 ? 64 : scale);

        // determine the location on the screen at the new scale
        var xNew = (xScreen - xImage) / scale;
        var yNew = (yScreen - yImage) / scale;

        // save the current screen location
        xLast = xScreen;
        yLast = yScreen;

        // redraw
        $(this).find('div').css('transform', 'scale(' + scale + ')' + 'translate(' + xNew + 'px, ' + yNew + 'px' + ')')
                           .css('transform-origin', xImage + 'px ' + yImage + 'px')
        return false;
    });
});

You will of course need to adapt it to use the canvas scale and translate methods.

Epic answered 27/5, 2010 at 8:9 Comment(6)
i finally solved it, took me 3 minutes now after about 2weeks of doing something elseAstrodynamics
as of today (sept.2014) the link to MosaicTest.html is dead.Warfeld
mosaic demo is gone. I use vanilla js usually and not jQuery. what is $(this) referring to? the document.body.offsetTop? I really want to see the mosaic demo my foreverscape.com project could really stand to benefit from it.Paramaribo
ah, it must be the mosaic container, as we're trying to get the coordinate relative to the container? So if the container is at negative pixels, it would ADD the value, resulting in a positive coordinate.Paramaribo
eff. my image is so big that when scrolled, all the math becomes wrong.Paramaribo
The mosaic demo page is saved on archive.org: web.archive.org/web/20130126152008/http://…Warfeld
M
20

I like Tatarize's answer, but I'll provide an alternative. This is a trivial linear algebra problem, and the method I present works well with pan, zoom, skew, etc. That is, it works well if your image is already transformed.

When a matrix is scaled, the scale is at point (0, 0). So, if you have an image and scale it by a factor of 2, the bottom-right point will double in both the x and y directions (using the convention that [0, 0] is the top-left of the image).

If instead you would like to zoom the image about the center, then a solution is as follows: (1) translate the image such that its center is at (0, 0); (2) scale the image by x and y factors; (3) translate the image back. i.e.

myMatrix
  .translate(image.width / 2, image.height / 2)    // 3
  .scale(xFactor, yFactor)                         // 2
  .translate(-image.width / 2, -image.height / 2); // 1

More abstractly, the same strategy works for any point. If, for example, you want to scale the image at a point P:

myMatrix
  .translate(P.x, P.y)
  .scale(xFactor, yFactor)
  .translate(-P.x, -P.y);

And lastly, if the image is already transformed in some manner (for example, if it's rotated, skewed, translated, or scaled), then the current transformation needs to be preserved. Specifically, the transform defined above needs to be post-multiplied (or right-multiplied) by the current transform.

myMatrix
  .translate(P.x, P.y)
  .scale(xFactor, yFactor)
  .translate(-P.x, -P.y)
  .multiply(myMatrix);

There you have it. Here's a plunk that shows this in action. Scroll with the mousewheel on the dots and you'll see that they consistently stay put. (Tested in Chrome only.) http://plnkr.co/edit/3aqsWHPLlSXJ9JCcJzgH?p=preview

Moitoso answered 22/5, 2017 at 23:1 Comment(4)
I must say, if you have a affine transformation matrix available to you, use that with enthusiasm. A lot of transformation matrices will even have zoom(sx,sy,x,y) functions that does exactly that. It's almost worth cooking one up if you aren't given one to use.Buddle
In fact, I confess that in the code I used this solution in, has since been replaced it with a matrix class. And I've done this exact thing multiple times and have cooked up matrix classes no fewer than twice. ( github.com/EmbroidePy/pyembroidery/blob/master/pyembroidery/… ), ( github.com/EmbroidePy/EmbroidePy/blob/master/embroidepy/… ). If you want anything more complex than exactly these operations a matrix is basically the correct answer and once you've got a handle on the linear algebra you realize this answer is actually the best answer.Buddle
I'm using the Canvas API, but it doesn't have a direct multiply() API. Instead, I've been doing resetTransform(), then applying the "zoom" translation, scaling, undoing the zoom translation, and then applying the actual desired translation. This nearly works, but it's sometimes causing the origin of the image to move. Can you provide an example of how you'd do the above with a CanvasRenderingContext2D object?Clayclaybank
@Clayclaybank I wrote the following article, which provides more detail and has an example using a canvas: medium.com/@benjamin.botto/…Moitoso
R
10

I ran into this problem using c++, which I probably shouldn't have had i just used OpenGL matrices to begin with...anyways, if you're using a control whose origin is the top left corner, and you want pan/zoom like google maps, here's the layout (using allegro as my event handler):

// initialize
double originx = 0; // or whatever its base offset is
double originy = 0; // or whatever its base offset is
double zoom = 1;

.
.
.

main(){

    // ...set up your window with whatever
    //  tool you want, load resources, etc

    .
    .
    .
    while (running){
        /* Pan */
        /* Left button scrolls. */
        if (mouse == 1) {
            // get the translation (in window coordinates)
            double scroll_x = event.mouse.dx; // (x2-x1) 
            double scroll_y = event.mouse.dy; // (y2-y1) 

            // Translate the origin of the element (in window coordinates)      
            originx += scroll_x;
            originy += scroll_y;
        }

        /* Zoom */ 
        /* Mouse wheel zooms */
        if (event.mouse.dz!=0){    
            // Get the position of the mouse with respect to 
            //  the origin of the map (or image or whatever).
            // Let us call these the map coordinates
            double mouse_x = event.mouse.x - originx;
            double mouse_y = event.mouse.y - originy;

            lastzoom = zoom;

            // your zoom function 
            zoom += event.mouse.dz * 0.3 * zoom;

            // Get the position of the mouse
            // in map coordinates after scaling
            double newx = mouse_x * (zoom/lastzoom);
            double newy = mouse_y * (zoom/lastzoom);

            // reverse the translation caused by scaling
            originx += mouse_x - newx;
            originy += mouse_y - newy;
        }
    }
}  

.
.
.

draw(originx,originy,zoom){
    // NOTE:The following is pseudocode
    //          the point is that this method applies so long as
    //          your object scales around its top-left corner
    //          when you multiply it by zoom without applying a translation.

    // draw your object by first scaling...
    object.width = object.width * zoom;
    object.height = object.height * zoom;

    //  then translating...
    object.X = originx;
    object.Y = originy; 
}
Rhododendron answered 19/7, 2013 at 4:43 Comment(0)
W
8

Here's my solution for a center-oriented image:

var MIN_SCALE = 1;
var MAX_SCALE = 5;
var scale = MIN_SCALE;

var offsetX = 0;
var offsetY = 0;

var $image     = $('#myImage');
var $container = $('#container');

var areaWidth  = $container.width();
var areaHeight = $container.height();

$container.on('wheel', function(event) {
    event.preventDefault();
    var clientX = event.originalEvent.pageX - $container.offset().left;
    var clientY = event.originalEvent.pageY - $container.offset().top;

    var nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.originalEvent.deltaY / 100));

    var percentXInCurrentBox = clientX / areaWidth;
    var percentYInCurrentBox = clientY / areaHeight;

    var currentBoxWidth  = areaWidth / scale;
    var currentBoxHeight = areaHeight / scale;

    var nextBoxWidth  = areaWidth / nextScale;
    var nextBoxHeight = areaHeight / nextScale;

    var deltaX = (nextBoxWidth - currentBoxWidth) * (percentXInCurrentBox - 0.5);
    var deltaY = (nextBoxHeight - currentBoxHeight) * (percentYInCurrentBox - 0.5);

    var nextOffsetX = offsetX - deltaX;
    var nextOffsetY = offsetY - deltaY;

    $image.css({
        transform : 'scale(' + nextScale + ')',
        left      : -1 * nextOffsetX * nextScale,
        right     : nextOffsetX * nextScale,
        top       : -1 * nextOffsetY * nextScale,
        bottom    : nextOffsetY * nextScale
    });

    offsetX = nextOffsetX;
    offsetY = nextOffsetY;
    scale   = nextScale;
});
body {
    background-color: orange;
}
#container {
    margin: 30px;
    width: 500px;
    height: 500px;
    background-color: white;
    position: relative;
    overflow: hidden;
}
img {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    max-width: 100%;
    max-height: 100%;
    margin: auto;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>

<div id="container">
    <img id="myImage" src="https://via.placeholder.com/300">
</div>
Warfeld answered 16/3, 2016 at 6:15 Comment(0)
C
6

Here's an alternate way to do it that uses setTransform() instead of scale() and translate(). Everything is stored in the same object. The canvas is assumed to be at 0,0 on the page, otherwise you'll need to subtract its position from the page coords.

this.zoomIn = function (pageX, pageY) {
    var zoomFactor = 1.1;
    this.scale = this.scale * zoomFactor;
    this.lastTranslation = {
        x: pageX - (pageX - this.lastTranslation.x) * zoomFactor,
        y: pageY - (pageY - this.lastTranslation.y) * zoomFactor
    };
    this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
                                    this.lastTranslation.x,
                                    this.lastTranslation.y);
};
this.zoomOut = function (pageX, pageY) {
    var zoomFactor = 1.1;
    this.scale = this.scale / zoomFactor;
    this.lastTranslation = {
        x: pageX - (pageX - this.lastTranslation.x) / zoomFactor,
        y: pageY - (pageY - this.lastTranslation.y) / zoomFactor
    };
    this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
                                    this.lastTranslation.x,
                                    this.lastTranslation.y);
};

Accompanying code to handle panning:

this.startPan = function (pageX, pageY) {
    this.startTranslation = {
        x: pageX - this.lastTranslation.x,
        y: pageY - this.lastTranslation.y
    };
};
this.continuePan = function (pageX, pageY) {
    var newTranslation = {x: pageX - this.startTranslation.x,
                          y: pageY - this.startTranslation.y};
    this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
                                    newTranslation.x, newTranslation.y);
};
this.endPan = function (pageX, pageY) {
    this.lastTranslation = {
        x: pageX - this.startTranslation.x,
        y: pageY - this.startTranslation.y
    };
};

To derive the answer yourself, consider that the same page coordinates need to match the same canvas coordinates before and after the zoom. Then you can do some algebra starting from this equation:

(pageCoords - translation) / scale = canvasCoords

Cns answered 29/12, 2013 at 2:31 Comment(0)
S
5

I want to put here some information for those, who do separately drawing of picture and moving -zooming it.

This may be useful when you want to store zooms and position of viewport.

Here is drawer:

function redraw_ctx(){
   self.ctx.clearRect(0,0,canvas_width, canvas_height)
   self.ctx.save()
   self.ctx.scale(self.data.zoom, self.data.zoom) // 
   self.ctx.translate(self.data.position.left, self.data.position.top) // position second
   // Here We draw useful scene My task - image:
   self.ctx.drawImage(self.img ,0,0) // position 0,0 - we already prepared
   self.ctx.restore(); // Restore!!!
}

Notice scale MUST be first.

And here is zoomer:

function zoom(zf, px, py){
    // zf - is a zoom factor, which in my case was one of (0.1, -0.1)
    // px, py coordinates - is point within canvas 
    // eg. px = evt.clientX - canvas.offset().left
    // py = evt.clientY - canvas.offset().top
    var z = self.data.zoom;
    var x = self.data.position.left;
    var y = self.data.position.top;

    var nz = z + zf; // getting new zoom
    var K = (z*z + z*zf) // putting some magic

    var nx = x - ( (px*zf) / K ); 
    var ny = y - ( (py*zf) / K);

    self.data.position.left = nx; // renew positions
    self.data.position.top = ny;   
    self.data.zoom = nz; // ... and zoom
    self.redraw_ctx(); // redraw context
    }

and, of course, we would need a dragger:

this.my_cont.mousemove(function(evt){
    if (is_drag){
        var cur_pos = {x: evt.clientX - off.left,
                       y: evt.clientY - off.top}
        var diff = {x: cur_pos.x - old_pos.x,
                    y: cur_pos.y - old_pos.y}

        self.data.position.left += (diff.x / self.data.zoom);  // we want to move the point of cursor strictly
        self.data.position.top += (diff.y / self.data.zoom);

        old_pos = cur_pos;
        self.redraw_ctx();

    }


})
Sanjak answered 15/5, 2013 at 3:34 Comment(0)
A
5
if(wheel > 0) {
    this.scale *= 1.1; 
    this.offsetX -= (mouseX - this.offsetX) * (1.1 - 1);
    this.offsetY -= (mouseY - this.offsetY) * (1.1 - 1);
}
else {
    this.scale *= 1/1.1; 
    this.offsetX -= (mouseX - this.offsetX) * (1/1.1 - 1);
    this.offsetY -= (mouseY - this.offsetY) * (1/1.1 - 1);
}
Atlee answered 30/4, 2015 at 15:23 Comment(1)
Reference for mouseX and mouseY would be helpful.Coronal
B
4

Here's a code implementation of @tatarize's answer, using PIXI.js. I have a viewport looking at part of a very big image (e.g. google maps style).

$canvasContainer.on('wheel', function (ev) {

    var scaleDelta = 0.02;
    var currentScale = imageContainer.scale.x;
    var nextScale = currentScale + scaleDelta;

    var offsetX = -(mousePosOnImage.x * scaleDelta);
    var offsetY = -(mousePosOnImage.y * scaleDelta);

    imageContainer.position.x += offsetX;
    imageContainer.position.y += offsetY;

    imageContainer.scale.set(nextScale);

    renderer.render(stage);
});
  • $canvasContainer is my html container.
  • imageContainer is my PIXI container that has the image in it.
  • mousePosOnImage is the mouse position relative to the entire image (not just the view port).

Here's how I got the mouse position:

  imageContainer.on('mousemove', _.bind(function(ev) {
    mousePosOnImage = ev.data.getLocalPosition(imageContainer);
    mousePosOnViewport.x = ev.data.originalEvent.offsetX;
    mousePosOnViewport.y = ev.data.originalEvent.offsetY;
  },self));
Beatitude answered 26/10, 2016 at 22:46 Comment(0)
L
2

You need to get the point in world space (opposed to screen space) before and after zooming, and then translate by the delta.

mouse_world_position = to_world_position(mouse_screen_position);
zoom();
mouse_world_position_new = to_world_position(mouse_screen_position);
translation += mouse_world_position_new - mouse_world_position;

Mouse position is in screen space, so you have to transform it to world space. Simple transforming should be similar to this:

world_position = screen_position / scale - translation
Loferski answered 29/2, 2016 at 22:0 Comment(0)
V
1

One important thing... if you have something like:

body {
  zoom: 0.9;
}

You need make the equivilent thing in canvas:

canvas {
  zoom: 1.1;
}
Vida answered 9/2, 2020 at 19:24 Comment(0)
M
1

Here is my solution:

// helpers
const diffPoints = (p1, p2) => {
    return {
        x: p1.x - p2.x,
        y: p1.y - p2.y,
    };
};

const addPoints = (p1, p2) => {
    return {
        x: p1.x + p2.x,
        y: p1.y + p2.y,
    };
};

function scalePoint(p1, scale) {
    return { x: p1.x / scale, y: p1.y / scale };
}

// constants
const ORIGIN = Object.freeze({ x: 0, y: 0 });
const SQUARE_SIZE = 20;
const ZOOM_SENSITIVITY = 500; // bigger for lower zoom per scroll
const MAX_SCALE = 50;
const MIN_SCALE = 0.1;

// dom
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
const debugDiv = document.getElementById("debug");

// "props"
const initialScale = 0.75;
const initialOffset = { x: 10, y: 20 };

// "state"
let mousePos = ORIGIN;
let lastMousePos = ORIGIN;
let offset = initialOffset;
let scale = initialScale;

// when setting up canvas, set width/height to devicePixelRation times normal
const { devicePixelRatio = 1 } = window;
context.canvas.width = context.canvas.width * devicePixelRatio;
context.canvas.height = context.canvas.height * devicePixelRatio;

function draw() {
    window.requestAnimationFrame(draw);

    // clear canvas
    context.canvas.width = context.canvas.width;

    // transform coordinates - scale multiplied by devicePixelRatio
    context.scale(scale * devicePixelRatio, scale * devicePixelRatio);
    context.translate(offset.x, offset.y);

    // draw
    context.fillRect(200 + -SQUARE_SIZE / 2, 50 + -SQUARE_SIZE / 2, SQUARE_SIZE, SQUARE_SIZE);

    // debugging
    context.beginPath();
    context.moveTo(0, 0);
    context.lineTo(0, 50);
    context.moveTo(0, 0);
    context.lineTo(50, 0);
    context.stroke();
    // debugDiv.innerText = `scale: ${scale}
    // mouse: ${JSON.stringify(mousePos)}
    // offset: ${JSON.stringify(offset)}
    // `;
}

// calculate mouse position on canvas relative to top left canvas point on page
function calculateMouse(event, canvas) {
    const viewportMousePos = { x: event.pageX, y: event.pageY };
    const boundingRect = canvas.getBoundingClientRect();
    const topLeftCanvasPos = { x: boundingRect.left, y: boundingRect.top };
    return diffPoints(viewportMousePos, topLeftCanvasPos);
}

// zoom
function handleWheel(event) {
    event.preventDefault();

    // update mouse position
    const newMousePos = calculateMouse(event, canvas);
    lastMousePos = mousePos;
    mousePos = newMousePos;

    // calculate new scale/zoom
    const zoom = 1 - event.deltaY / ZOOM_SENSITIVITY;
    const newScale = scale * zoom;
    if (MIN_SCALE > newScale || newScale > MAX_SCALE) {
        return;
    }

    // offset the canvas such that the point under the mouse doesn't move
    const lastMouse = scalePoint(mousePos, scale);
    const newMouse = scalePoint(mousePos, newScale);
    const mouseOffset = diffPoints(lastMouse, newMouse);
    offset = diffPoints(offset, mouseOffset);
    scale = newScale;
}
canvas.addEventListener("wheel", handleWheel);

// panning
const mouseMove = (event) => {
    // update mouse position
    const newMousePos = calculateMouse(event, canvas);
    lastMousePos = mousePos;
    mousePos = newMousePos;
    const mouseDiff = scalePoint(diffPoints(mousePos, lastMousePos), scale);
    offset = addPoints(offset, mouseDiff);
};
const mouseUp = () => {
    document.removeEventListener("mousemove", mouseMove);
    document.removeEventListener("mouseup", mouseUp);
};
const startPan = (event) => {
    document.addEventListener("mousemove", mouseMove);
    document.addEventListener("mouseup", mouseUp);
    // set initial mouse position in case user hasn't moved mouse yet
    mousePos = calculateMouse(event, canvas);
};
canvas.addEventListener("mousedown", startPan);

// repeatedly redraw
window.requestAnimationFrame(draw);
#canvas {
  /*set fixed width and height for what you actually want in css!*/
  /*should be the same as what's passed into canvas element*/
  width: 500px;
  height: 150px;

  position: fixed;
  border: 2px solid black;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
<!DOCTYPE html>

<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="styles.css" />
</head>

<body>
<!--still need width and height here, same as css-->
<canvas id="canvas" width="500" height="150"></canvas>
<div id="debug"></div>
<script type="module" src="pan_zoom.js"></script>
</body>
</html>
Moreover answered 12/10, 2021 at 13:21 Comment(0)
P
0

you can use scrollto(x,y) function to handle the position of scrollbar right to the point that you need to be showed after zooming.for finding the position of mouse use event.clientX and event.clientY. this will help you

Purveyance answered 10/8, 2016 at 8:6 Comment(0)
C
0

Here's an approach I use for tighter control over how things are drawn

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

var scale = 1;
var xO = 0;
var yO = 0;

draw();

function draw(){
    // Clear screen
    ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);

    // Original coordinates
    const xData = 50, yData = 50, wData = 100, hData = 100;
    
    // Transformed coordinates
    const x = xData * scale + xO,
     y = yData * scale + yO,
     w = wData * scale,
     h = hData * scale;

    // Draw transformed positions
    ctx.fillStyle = "black";
    ctx.fillRect(x,y,w,h);
}

canvas.onwheel = function (e){
    e.preventDefault();

    const r = canvas.getBoundingClientRect(),
      xNode =  e.pageX - r.left,
      yNode =  e.pageY - r.top;

    const newScale = scale * Math.exp(-Math.sign(e.deltaY) * 0.2),
      scaleFactor = newScale/scale;

    xO = xNode - scaleFactor * (xNode - xO);
    yO = yNode - scaleFactor * (yNode - yO);
    scale = newScale;

    draw();
}
<canvas id="canvas" width="600" height="200"></canvas>
Crumple answered 14/2, 2021 at 21:6 Comment(0)
T
0

Adding an answer that worked for me in C# & WPF:

double zoom = scroll > 0 ? 1.2 : (1/1.2);

var CursorPosCanvas = e.GetPosition(Canvas);
pan.X += -(CursorPosCanvas.X - Canvas.RenderSize.Width / 2.0 - pan.X) * (zoom - 1.0);
pan.Y += -(CursorPosCanvas.Y - Canvas.RenderSize.Height / 2.0 - pan.Y) * (zoom - 1.0);

transform.ScaleX *= zoom;
transform.ScaleY *= zoom;
Trim answered 14/12, 2021 at 3:39 Comment(0)
T
0

For some reason the answers didn't work for me or I wasn't able to adapt them in Flutter Flame, but I was guided by the answers and I managed to make it work.

Zoom in on a point on flutter flame.

camera.viewfinder
      ..zoom = 1
      ..anchor = Anchor.topLeft;
@override
  void onScroll(PointerScrollInfo info) {
    final currentPosition = camera.viewfinder.position;

    var mousex = info.eventPosition.global.x;
    var mousey = info.eventPosition.global.y;

    final deltaZoom = info.scrollDelta.global.y * 0.001;
    final newZoom = camera.viewfinder.zoom + deltaZoom;
    final clampedZoom = newZoom.clamp(_minZoom, _maxZoom);
    final oldscale = camera.viewfinder.zoom;
    camera.viewfinder.zoom = clampedZoom;


    // Compute the new visible origin. Originally the mouse is at a
    // distance mouse/scale from the corner, we want the point under
    // the mouse to remain in the same place after the zoom, but this
    // is at mouse/new_scale away from the corner. Therefore we need to
    // shift the origin (coordinates of the corner) to account for this.
    originx = (mousex / oldscale) - (mousex / clampedZoom);
    originy = (mousey / oldscale) - (mousey / clampedZoom);

    camera.viewfinder.position = currentPosition.translated(
      originx,
      originy,
    );
  }
Therm answered 17/12, 2023 at 21:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.