Zoom Canvas to Mouse Cursor
Asked Answered
C

6

91

I'm programming a HTML5 < canvas > project that involves zooming in and out of images using the scroll wheel. I want to zoom towards the cursor like google maps does but I'm completely lost on how to calculate the movements.

What I have: image x and y (top-left corner); image width and height; cursor x and y relative to the center of the canvas.

Cringle answered 4/3, 2011 at 5:13 Comment(1)
you should accept this answer or revise your questionOriginal
F
281

In short, you want to translate() the canvas context by your offset, scale() it to zoom in or out, and then translate() back by the opposite of the mouse offset. Note that you need to transform the cursor position from screen space into the transformed canvas context.

ctx.translate(pt.x,pt.y);
ctx.scale(factor,factor);
ctx.translate(-pt.x,-pt.y);

Demo: http://phrogz.net/tmp/canvas_zoom_to_cursor.html

I've put up a full working example on my website for you to examine, supporting dragging, click to zoom in, shift-click to out, or scroll wheel up/down.

The only (current) issue is that Safari zooms too fast compared to Chrome or Firefox.

Freda answered 3/4, 2011 at 0:10 Comment(15)
Hey @Freda this is awesome! I just wonder if we can limit the dragging so that one cant drag the image out of the boundaries. If there is no more image left to drag, the dragging should just stop there instead of allowing to drag indefinetly. I took my stab on it but it seems I can't get the math right :-(Buhrstone
Hi Phrogz - the code is brilliant and works great but I have found a situation where it falls down. If you have a dynamically sized canvas then the next time you attempt to move or scale anything it doesnt work correctly. What would you have to do to fix it under this situation? ThanksZig
Did someone found a solution to the dynamically sized canvas problem from @Chris. Also is there the possibility to limit the zoom factor so a and d are between 1 and 5, so i can restrict the zooming level? ThanksExistentialism
@Buhrstone it is easy. get the scale - you can take scale from : var scale = ctx.getTransform().a; then take the current top left position of image: var curX = ctx.getTransform().e; var curY = ctx.getTransform().f; estimate the change in position: var deltaX = pt.x - dragStart.x; var deltaY = pt.y - dragStart.y; then there is original image size, lets take width for example (when scale=1): imageW and there is canvas width: canvasW then the condition should be false to allow drag: curX + deltaX + imageW * scale < canvasW and one more (curX + deltaX > 0 || curY + deltaY > 0)Tarkany
@Existentialism I did it using following code in zoom function, in place of ctx.scale function call: var scale = ctx.getTransform().a; var decision = true; if (factor < 1) { // this is zoom out if (scale < scaleLowerLimit) { decision = false; } } else if (factor > 1) { // this is zoom in if (scale > scaleUpperLimit) { decision = false; } } else { decision = false; } if (decision == true) { ctx.scale(factor, factor); } set the scaleLowerLimit to 1 and scaleUpperLimit to 5. hope it helpsTarkany
@phrogz can we do the zoom to already generated canvas with text and images ?Blague
Would this be hard to implement on mobile with gestures? Like, allow 2 finger pinch to zoom in on only the image but not the entire website?Copolymer
The single click to zoom in feature and Shift-Click to zoom out do not work in Chrome, but the work fine in IE and FireFox. Any ideas?Hock
@Hock works fine for me in Chrome 43 on Windows. Email me via the link at the bottom of the test page to discuss further.Freda
Weird! I'm certain it wasn't working. Restarted Chrome, and it's working fine now. Thanks.Hock
How to reset this zoom?Brade
@Brade ctx.setTransform(1, 0, 0, 1, 0, 0);Freda
@Freda is there any easy way to snap the contents of the canvas to the canvas when zooming?Cochran
@Freda is there an easy way to save the position of zoom/drag? I can't seem to get it to work. I have a function for redrawing images which clears the canvas. I want it redraw images and focus on the same point as before (for image comparison).Pretended
@Phrogz, I did that but it always shifts to the right once i set it again. As a workaround, I'm now just clearing the canvas and redrawing images.Pretended
U
15

I hope, these JS libraries will help you: (HTML5, JS)

  1. Loupe

http://www.netzgesta.de/loupe/

  1. CanvasZoom

https://github.com/akademy/CanvasZoom

  1. Scroller

https://github.com/zynga/scroller

As for me, I'm using loupe. It's awesome! For you the best case - scroller.

Ursuline answered 22/11, 2011 at 9:57 Comment(0)
I
13

I recently needed to archive same results as Phrogz had already done but instead of using context.scale(), I calculated each object size based on ratio.

This is what I came up with. Logic behind it is very simple. Before scaling, I calculate point distance from edge in percentages and later adjust viewport to correct place.

It took me quite a while to come up with it, hope it saves someones time.

$(function () {
  var canvas = $('canvas.main').get(0)
  var canvasContext = canvas.getContext('2d')

  var ratio = 1
  var vpx = 0
  var vpy = 0
  var vpw = window.innerWidth
  var vph = window.innerHeight

  var orig_width = 4000
  var orig_height = 4000

  var width = 4000
  var height = 4000

  $(window).on('resize', function () {
    $(canvas).prop({
      width: window.innerWidth,
      height: window.innerHeight,
    })
  }).trigger('resize')

  $(canvas).on('wheel', function (ev) {
    ev.preventDefault() // for stackoverflow

    var step

    if (ev.originalEvent.wheelDelta) {
      step = (ev.originalEvent.wheelDelta > 0) ? 0.05 : -0.05
    }

    if (ev.originalEvent.deltaY) {
      step = (ev.originalEvent.deltaY > 0) ? 0.05 : -0.05
    }

    if (!step) return false // yea..

    var new_ratio = ratio + step
    var min_ratio = Math.max(vpw / orig_width, vph / orig_height)
    var max_ratio = 3.0

    if (new_ratio < min_ratio) {
      new_ratio = min_ratio
    }

    if (new_ratio > max_ratio) {
      new_ratio = max_ratio
    }

    // zoom center point
    var targetX = ev.originalEvent.clientX || (vpw / 2)
    var targetY = ev.originalEvent.clientY || (vph / 2)

    // percentages from side
    var pX = ((vpx * -1) + targetX) * 100 / width
    var pY = ((vpy * -1) + targetY) * 100 / height

    // update ratio and dimentsions
    ratio = new_ratio
    width = orig_width * new_ratio
    height = orig_height * new_ratio

    // translate view back to center point
    var x = ((width * pX / 100) - targetX)
    var y = ((height * pY / 100) - targetY)

    // don't let viewport go over edges
    if (x < 0) {
      x = 0
    }

    if (x + vpw > width) {
      x = width - vpw
    }

    if (y < 0) {
      y = 0
    }

    if (y + vph > height) {
      y = height - vph
    }

    vpx = x * -1
    vpy = y * -1
  })

  var is_down, is_drag, last_drag

  $(canvas).on('mousedown', function (ev) {
    is_down = true
    is_drag = false
    last_drag = { x: ev.clientX, y: ev.clientY }
  })

  $(canvas).on('mousemove', function (ev) {
    is_drag = true

    if (is_down) {
      var x = vpx - (last_drag.x - ev.clientX)
      var y = vpy - (last_drag.y - ev.clientY)

      if (x <= 0 && vpw < x + width) {
        vpx = x
      }

      if (y <= 0 && vph < y + height) {
        vpy = y
      }

      last_drag = { x: ev.clientX, y: ev.clientY }
    }
  })

  $(canvas).on('mouseup', function (ev) {
    is_down = false
    last_drag = null

    var was_click = !is_drag
    is_drag = false

    if (was_click) {

    }
  })

  $(canvas).css({ position: 'absolute', top: 0, left: 0 }).appendTo(document.body)

  function animate () {
    window.requestAnimationFrame(animate)

    canvasContext.clearRect(0, 0, canvas.width, canvas.height)

    canvasContext.lineWidth = 1
    canvasContext.strokeStyle = '#ccc'

    var step = 100 * ratio

    for (var x = vpx; x < width + vpx; x += step) {
      canvasContext.beginPath()
      canvasContext.moveTo(x, vpy)
      canvasContext.lineTo(x, vpy + height)
      canvasContext.stroke()
    }
    for (var y = vpy; y < height + vpy; y += step) {
      canvasContext.beginPath()
      canvasContext.moveTo(vpx, y)
      canvasContext.lineTo(vpx + width, y)
      canvasContext.stroke()
    }

    canvasContext.strokeRect(vpx, vpy, width, height)

    canvasContext.beginPath()
    canvasContext.moveTo(vpx, vpy)
    canvasContext.lineTo(vpx + width, vpy + height)
    canvasContext.stroke()

    canvasContext.beginPath()
    canvasContext.moveTo(vpx + width, vpy)
    canvasContext.lineTo(vpx, vpy + height)
    canvasContext.stroke()

    canvasContext.restore()
  }

  animate()
})
<!DOCTYPE html>
<html>
<head>
	<title></title>
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
</head>
<body>
	<canvas class="main"></canvas>
</body>
</html>
Irreproachable answered 16/3, 2016 at 17:52 Comment(0)
S
7

I took @Phrogz's answer as a basis and made a small library that enables canvas with dragging, zooming and rotating. Here is the example.

var canvas = document.getElementById('canvas')
//assuming that @param draw is a function where you do your main drawing.
var control = new CanvasManipulation(canvas, draw)
control.init()
control.layout()
//now you can drag, zoom and rotate in canvas

You can find more detailed examples and documentation on the project's page

Slouch answered 19/6, 2013 at 7:22 Comment(0)
H
6

Faster

Using ctx.setTransform gives you more performance than multiple matrix calls ctx.translate, ctx.scale, ctx.translate.

No need for complex transformation inversions as and expensive DOM matrix calls tp converts point between zoomed and screen coordinate systems.

Flexible

Flexibility as you don't need to use ctx.save and ctx.restore if you are rendering content at using different transforms. Returning to the transform with ctx.setTransform rather than the potentially frame rate wreaking ctx.restorecall

Easy to invert the transform and get the world coordinates of a (screen) pixel position and the other way round.

Examples

Using mouse and mouse wheel to zoom in and out at mouse position

An example using this method to scale page content at a point (mouse) via CSS transform CSS Demo at bottom of answer also has a copy of the demo from the next example.

And an example of this method used to scale canvas content at a point using setTransform

How

Given a scale and pixel position you can get the new scale as follow...

const origin = {x:0, y:0};         // canvas origin
var scale = 1;                     // current scale
function scaleAt(x, y, scaleBy) {  // at pixel coords x, y scale by scaleBy
    scale *= scaleBy;
    origin.x = x - (x - origin.x) * scaleBy;
    origin.y = y - (y - origin.y) * scaleBy;
}

To position the canvas and draw content

ctx.setTransform(scale, 0, 0, scale, origin.x, origin.y);
ctx.drawImage(img, 0, 0);

To use if you have the mouse coordinates

const zoomBy = 1.1;                    // zoom in amount
scaleAt(mouse.x, mouse.y, zoomBy);     // will zoom in at mouse x, y
scaleAt(mouse.x, mouse.y, 1 / zoomBy); // will zoom out by same amount at mouse x,y

To restore the default transform

ctx.setTransform(1,0,0,1,0,0);

The inversions

To get the coordinates of a point in the zoomed coordinate system and the screen position of a point in the zoomed coordinate system

Screen to world

function toWorld(x, y) {  // convert to world coordinates
    x = (x - origin.x) / scale;
    y = (y - origin.y) / scale;
    return {x, y};
}

World to screen

function toScreen(x, y) {
    x = x * scale + origin.x;
    y = y * scale + origin.y;
    return {x, y};
}
Headstone answered 4/7, 2021 at 18:52 Comment(0)
I
1

I have modified the solution from @Phrogz (which is awesome in itself btw) to include the following features:

  1. Allow a range of zoom levels (not infinite)
  2. Default or start with the most zoomed out level
  3. Allow for layers of canvas objects (zoom all canvas objects in an array simultaneously)
  4. Zooming out does not show area outside original view.

Hope this helps someone.

    let layer = [];
    layer[0] = document.getElementById("canvas-layer-001");
    layer[1] = document.getElementById("canvas-layer-002");
    layer[2] = document.getElementById("canvas-layer-top");

    let context = [];
    context[0] = layer[0].getContext("2d");
    context[1] = layer[1].getContext("2d");
    context[2] = layer[2].getContext("2d");

    const MIN_ZOOM_LEVEL = 0;
    const MAX_ZOOM_LEVEL = 20;

    let scaleFactor = 1.1;
    let zoomLevel = 0;

    let zoom = function(clicks) {
        if( zoomLevel + clicks < MIN_ZOOM_LEVEL ||
            zoomLevel + clicks > MAX_ZOOM_LEVEL ) { return; }

        let factor = Math.pow(scaleFactor,clicks);

        for( let i = 0 ; i < context.length ; ++i ) {
            let pt = context[i].transformedPoint(lastX,lastY);

            context[i].translate( pt.x, pt.y );
            context[i].scale( factor, factor );
            context[i].translate( -pt.x, -pt.y );

            let adjust = false;
            pt = context[i].transformedPoint( 0, 0 );
            if( pt.x < 0 ) { adjust = true; }
            if( pt.y < 0 ) { adjust = true; }

            if( adjust ) {
                context[i].translate( pt.x, pt.y );
                adjust = false;
            }

            pt = context[i].transformedPoint( layer[i].width, layer[i].height );
            if( pt.x > layer[i].width ) { adjust = true; }
            if( pt.y > layer[i].height ) { adjust = true; }

            if( adjust ) {
                context[i].translate( pt.x - layer[i].width, pt.y - layer[i].height );
                adjust = false;
            }
        }

        zoomLevel += clicks;
        draw();
    }
Interjoin answered 11/3, 2023 at 22:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.