16

Even if the following code snippet seems short, I struggled during days (shame on me!) to find a way to zoom on the point that is clicked using only CSS3 transform. It works now:

    var current = {x: 0, y: 0, zoom: 1}, c = document.getElementById('container');
    window.onclick = function(e) {
      wx = current.x + e.clientX / current.zoom;
      wy = current.y + e.clientY / current.zoom;
      var coef = e.ctrlKey ? 0.5 : 2;
      current.zoom *= coef;    
      current.x = wx - e.clientX / current.zoom; 
      current.y = wy - e.clientY / current.zoom; 
      c.style.transform = 'scale(' + current.zoom +') translate(' + (-current.x) + 'px,' + (-current.y) + 'px)';
    };
    html, body { margin: 0; padding: 0; overflow: hidden; min-height: 100%; }
    #container { position: absolute; transform-origin: 0 0; transition-duration: 3s;}
    #item { position: absolute; left:0px; top:0px; }
  <div id="container"><div id="item"><img src="http://fadili.users.greyc.fr/demos/WaveRestore/EMInpaint/parrot_original.png"></img></div></div>

The only problem is that the transition is weird, like if it first translates and then zooms ; it produces a weird zigzag effet. How to have a smooth CSS3 transition in this case?

See animated GIF here of the weird transition effect: http://gget.it/zf3fmwum/weirdtransition.gif

Note: the point which is clicked on is a fixed point of the scaling transform (example: click on the eye, the image is zoomed, and the cursor is still on the eye), like in GoogleMaps-doubleclick-zooming.

Basj
  • 41,386
  • 99
  • 383
  • 673
  • this might help you http://stackoverflow.com/questions/15464055/css-transition-effect-makes-image-blurry-moves-image-1px-in-chrome – Vitorino fernandes Dec 22 '14 at 14:06
  • Sidenote: I tried unsucessfully with `transform-origin`: it brought me to composition of affine transformations, that can be tricky: if you compose an affine scaling of factor k with an affine scaling of factor 1/k the result is NOT an affine scaling, but a translation, etc. (It brought me to some awful code that worked half of the time) – Basj Dec 22 '14 at 14:07
  • tried with `scale3d` http://jsfiddle.net/zpb5jxzw/ somewhat works – Vitorino fernandes Dec 22 '14 at 14:16
  • @VitorinoFernandes the problem is not solved, the transition is still weird, see the animated GIF here: http://gget.it/zf3fmwum/weirdtransition.gif – Basj Dec 22 '14 at 14:33
  • give this a try: (1) set [`transition-property: none`](https://developer.mozilla.org/en-US/docs/Web/CSS/transition-property) (2) apply *only* the `translate` transform (3) set `transition-property: all` (4) update the `scale` transform. if the transition persists, try triggering a reflow after #3 – Amir Nissim Dec 22 '14 at 14:47
  • @amirnissim would you have a small example/jsfiddle? – Basj Dec 22 '14 at 14:54
  • @Basj posted as answer – Amir Nissim Dec 22 '14 at 15:01
  • @Basj deleted my answer since it doesn't comply to the (new) fixed-point requirement – Amir Nissim Dec 22 '14 at 19:49
  • @amirnissim it's not a new requirement (I just added a note at the end of the question to make it clearer). Not having a fixed-point would be only named "zooming" , but not "zooming *on a point*". Don't you think so ? – Basj Dec 22 '14 at 19:55
  • Thanks for the up and down votes, I just got a new hat -- Seasons Greetings people! :D (+1 for an interesting question btw.) – Pebbl Dec 23 '14 at 11:35

2 Answers2

24

One thing to watch out for when using transforms is the order that you apply them. You'll find your example works rather differently if you switch the scale and the translate around.

Here is an interesting article on the matter:

https://staff.washington.edu/fmf/2011/07/15/css3-transform-attribute-order/

I wasn't able to repair your version, mainly because it misbehaves unexpectedly when you switch the order of the transforms. Basically it seems you are running into odd behaviour because the scale itself causes an automatic translation in position, and then you also translate... and it seems these different translations are occurring at a slightly different pace.

I did however re-implement a version that works, and allows you to translate before scaling. Keeping the transforms in this order seems to avoid the issue.

http://jsfiddle.net/fxpc5rao/32/

I've modified the version below to use translate3D just because it performs better for many systems.

var current = {x: 0, y: 0, zoom: 1},
    con = document.getElementById('container');
    
window.onclick = function(e) {
    var coef = e.shiftKey || e.ctrlKey ? 0.5 : 2,
        oz = current.zoom,
        nz = current.zoom * coef,
        /// offset of container
        ox = 20,
        oy = 20,
        /// mouse cords
        mx = e.clientX - ox,
        my = e.clientY - oy,
        /// calculate click at current zoom
        ix = (mx - current.x) / oz,
        iy = (my - current.y) / oz,
        /// calculate click at new zoom
        nx = ix * nz,
        ny = iy * nz,
        /// move to the difference
        /// make sure we take mouse pointer offset into account!
        cx = mx - nx,
        cy = my - ny
    ;
    // update current
    current.zoom = nz;
    current.x = cx;
    current.y = cy;
    /// make sure we translate before scale!
    con.style.transform
        = 'translate3D('+cx+'px, '+cy+'px,0) '
        + 'scale('+nz+')'
    ;
};
#container {
    position: absolute;
    left: 20px;
    top: 20px;
    width: 100%;
    height: 100%;
    transform-origin: 0 0 0;
    transition: transform 0.3s;
    transition-timing-function: ease-in-out;
    transform: translate3D(0,0,0) scale(1);
}

#item {
    position: absolute;
}
<div id="container">
    <div id="item">
        <img src="http://fadili.users.greyc.fr/demos/WaveRestore/EMInpaint/parrot_original.png" />
    </div>
</div>

update

I've updated my answer (and the snippet above) to take into account your additional requirement, you just need to modify the calculation to include the difference in mouse pointer offset.

http://jsfiddle.net/fxpc5rao/33/

Now with every click the difference between the calculated unscaled position and e.clientX, e.clientY is added. This gives you the offset you need to keep the zoomed translation occurring around the mouse pointer. The key change is here:

cx = (ix + (e.clientX - ix) - nx),
cy = (iy + (e.clientY - iy) - ny)

NOTE: Because you are relying on e.clientX and e.clientY you will find annoying offseting will occur if you move #container away from its current 0,0 coordinate. This can be done, but you will have to modify your calculations to localise the coordinates to whatever #container's location ends up being.

update 2

Good call @Basj, I wasn't aware that the transformations occurred in reverse order, I'll add the link in from your comment here:

CSS3 transform order matters: rightmost operation first

So as you say, you require the scale to occur before the translate in processing terms, but the translate to be written before the scale in the actual transform value — if that makes sense :) Still not exactly sure why doing one before the other results in the odd interpolation however.

Also, I've noticed there is a rather obvious optimisation — which I'm sure, as you are implementing this, you will have spotted — no point adding something only to subtract it later. I guess I'd just had too much festive cheer that day!

cx = e.clientX - nx,
cy = e.clientY - ny

update 3

No problem @jdavies, it is just a matter of converting your mouse coordinates so they are relative to the container's top left. How you calculate this offset will depend entirely on your project (it is much easier to get a layer's offset — cross browser — using something like jQuery.offset). However I've updated the code in this answer to take into account a hard-coded/fixed offset away from 0,0 using position absolute — just to illustrate. Here is an updated fiddle too:

http://jsfiddle.net/fxpc5rao/5/

As we are using clientX and clientY the mouse coordinates will always be calculated from the top left of the browser window, making them global to the page (disregarding scrolling). In order to localise them to the container, you just need to subtract the containers x and y position.

Container at 0,0                Container at 80,100

+------+------- screen x 0      +--------------- 0
|      |                        |      
|      |                        |  +------+
|   x  | <-- mouse click        |  |x     | <-- mouse click
+------+     at 100,120         |  |      |     at 100,120
|                               |  |      |     but relative
|                               |  +------+     20,20
|                               |               so we us 20,20

0 screen y                      0

The #container can also be contained within other elements, you just again have to take into account any positional offset these elements give to the #container. In the following fiddle there is a #page-setting element that is offsetting everything with margin, as long as the ox, oy variables are updated with the margin values everything should behave.

http://jsfiddle.net/fxpc5rao/34/

NOTE: If you place this system inside a scrollable page you will also need to add the viewport's scroll offsets to the mouse coordinates, I give an example here, but this is most likely not a full cross browser solution. You are better off looking at an established library like jQuery to calculate coordinates and offsets for you.

Community
  • 1
  • 1
Pebbl
  • 34,937
  • 6
  • 62
  • 64
  • Thanks for your code @pebbl. Unfortunately, "zoom on point" doesn't really work (I was struggling with the same thing the past days) : 1) click in the parrot's eye once or twice => it works, the eye is "fixed" by the transform 2) then click somewhere else, e.g. on the bird's claw => this point will *not be fixed by the scale transform*, sadly. This is really important (see double clicking on a Google maps) for my application. – Basj Dec 22 '14 at 22:59
  • @Basj ~ Ah, I didn't spot the modification at the end of your question. That change is quite straight-forward, but has no bearing on the initial problem you were describing -- it is just a calculation alteration. I've updated my answer with a version that does as you need. – Pebbl Dec 23 '14 at 11:18
  • Great! Problem solved @pebbl! I'll try to do a bounty, just to award you the points because it solved a problem I had during days... – Basj Dec 23 '14 at 11:38
  • @Basj ~ No problem, oh and no worries about the bounty, I do it for the code (and the occasional hat) :) I'll have to admit I did have to fall back randomly to Photoshop to "visualise" the problem a couple of times. Something about zooming larger than the viewport that plays games with the brain... glad it helped anyway. – Pebbl Dec 23 '14 at 11:45
  • 1
    Thanks so much @pebbl, the problem is solved. I can only give you the bounty after 23 hours (now if I click: impossible). Your code works great, just a small mistake in your explanations (maybe you can edit?): it's not : `make sure we translate before scale!` but `make sure we scale before translate!`, I'm now 99% sure thanks to: http://stackoverflow.com/questions/27635272/css3-transform-order-matters-rightmost-operation-first The first affine transform (in the math meaning) to be done with `transform: operation1 operation2` is the one *the most on the right*, ie `operation2`. – Basj Dec 25 '14 at 11:32
  • 1
    I'm upvoting this because this question and answer are absolutely badass. Well done! – Purag Dec 25 '14 at 11:37
  • @Basj ~ Thanks very much for the bounty... much appreciated :) and very good point on the transformational order -- something I wasn't aware of despite performing far too many CSS transformations myself -- I've updated my answer to make note of it. Have a great new year! – Pebbl Dec 27 '14 at 11:45
  • 1
    @pebbl - Thanks so much for this solution it has helped me no end with a workflow application I am building using jsPlumb. Would it be possible for you to elaborate on the point you made about the offset which occurs when the container is not at coordinate 0,0? I have tried to modify the code to fix this offset but am getting nowhere. I would be extremely grateful. Thanks again. – jdavies Jan 09 '15 at 17:02
  • 1
    @jdavies ~ no problem, glad it was useful :) I've updated my answer with something that should hopefully help with your issue. – Pebbl Jan 10 '15 at 00:22
  • 1
    @pebbl - Thank you so much! This is just what I needed. I have made the changes to the application and it is now functioning correctly. I really appreciate the time you spent to explain this. – jdavies Jan 10 '15 at 01:32
  • @pebbl your help was very useful for what I'm currently coding! could I join you in pvt? (mail? irc? ...) – Basj Jan 12 '15 at 15:58
  • @Basj ~ Unfortunately my spare time is rather sporadic at the moment (far too much going on in the offline world). If you have further issues with the above (or perhaps another question) just post/update on SO and let me @ know -- I'll see what I can help with. – Pebbl Jan 13 '15 at 12:24
3

Tough problem viewing / zooming and paning an image, right? :)

I finally succeeded in calibrating the zoom algorithm so I want to share it with the community. I created a viewer class to interact with underlying image. One important point in my solution is that it doesn't modify default transform-origin, which could be useful for some other transforms.

You could use click to zoom / ctrl + click to unzoom, or pinch in pinch out (uses Hammer JS). Warning, touch events are not enabled by default on Firefox.

I am sorry, I know it uses Hammer and home made Transform & Point classes but please focus on the zoomTo method which is framework agnostic and is the main point of this zoom problem.

(You will find the TypeScript version below if you prefer)

Try it in this snippet

// LOAD VIEWER
window.onload = function() {
var v = new UI.Viewer(document.getElementById('viewer'));
   v.setViewPortSize({width: 900, height: 600});
   v.setSource('https://upload.wikimedia.org/wikipedia/commons/d/d9/Big_Bear_Valley,_California.jpg');
}


var Point = (function () {
    function Point(x, y) {
        this.x = x;
        this.y = y;
    }
    Point.prototype.toString = function () {
        return '(' + this.x + ';' + this.y + ')';
    };
    return Point;
})();
var Transform = (function () {
    function Transform() {
        this.translate = new Point(0, 0);
        this.scale = 1;
        this.angle = 0;
    }
    return Transform;
})();
var UI;
(function (UI) {
    var Viewer = (function () {
        function Viewer(viewer) {
            this.ticking = false;
            console.info("viewer browser on: " + viewer);
            this.viewer = viewer;
            this.viewer.style.position = 'relative';
            this.viewer.style.overflow = 'hidden';
            this.viewer.style.touchAction = 'none';
            this.viewer.style.backgroundColor = '#000000';
            this.viewer.style['-webkit-user-select'] = 'none';
            this.viewer.style['-webkit-user-drag'] = 'none';
            this.viewer.style['-webkit-tap-highlight-color'] = 'rgba(0, 0, 0, 0)';
            this.viewerContent = this.viewer.querySelector(".image");
            if (this.viewerContent == null) {
                this.viewerContent = document.createElement('img');
                this.viewerContent.className = 'image';
                this.viewer.appendChild(this.viewerContent);
            }
            this.viewerContent.style.position = 'absolute';
            this.viewerContent.style.transition = 'transform 100ms linear';
            console.info("image width = " + this.viewer.clientWidth + "x" + this.viewer.clientHeight);
            this.transform = new Transform();
            this.initializeHammerEvents();
            console.info("viewer controller constructed: " + this.transform);
            this.setViewPortSize({ width: this.viewer.clientWidth, height: this.viewer.clientHeight });
        }
        Viewer.prototype.initializeHammerEvents = function () {
            var _this = this;
            this.gestureManager = new Hammer.Manager(this.viewer, {
                touchAction: 'pan-x pan-y'
            });
            this.gestureManager.add(new Hammer.Pinch({
                threshold: 0
            }));
            this.gestureManager.on("pinchstart pinchmove", function (event) { _this.onPinch(event); });
            this.viewerContent.addEventListener("click", function (event) {
                _this.onImageClick(event);
            });
        };
        Viewer.prototype.enableGestures = function () {
            this.initializeHammerEvents();
            this.viewer.style.pointerEvents = 'auto';
        };
        Viewer.prototype.disableGestures = function () {
            this.viewer.style.pointerEvents = 'none';
            this.gestureManager.off('panstart panmove rotatestart rotatemove pinchstart pinchmove pinchend rotateend press doubletap');
        };
        Viewer.prototype.setViewPortSize = function (size) {
            this.viewer.style.width = size.width + 'px';
            this.viewer.style.height = size.height + 'px';
            this.adjustZoom();
        };
        Viewer.prototype.getViewPortSize = function () {
            return {
                width: this.viewer.clientWidth,
                height: this.viewer.clientHeight
            };
        };
        Viewer.prototype.getDocumentSize = function () {
            return {
                width: this.viewerContent.clientWidth,
                height: this.viewerContent.clientHeight
            };
        };
        Viewer.prototype.setSource = function (source) {
            var _this = this;
            this.viewerContent.src = source;
            this.viewerContent.onload = function () {
                console.info("image loaded");
                _this.adjustZoom();
            };
        };
        Viewer.prototype.adjustZoom = function () {
            var size = this.getViewPortSize();
            var documentSize = this.getDocumentSize();
            console.info("adjust zoom, documentSize: " + documentSize.width + "x" + documentSize.height);
            console.info("adjust zoom, viewPortSize: " + size.width + "x" + size.height);
            this.minScale = 100 / documentSize.width;
            console.info("minScale=" + this.minScale);
            var widthScale = size.width / documentSize.width;
            var heightScale = size.height / documentSize.height;
            var scale = Math.min(widthScale, heightScale);
            var left = (size.width - documentSize.width) / 2;
            var top = (size.height - documentSize.height) / 2;
            console.log("setting content to : left => " + left + "  , top => " + top, ", scale => ", scale);
            this.viewerContent.style.left = left + 'px';
            this.viewerContent.style.top = top + 'px';
            this.transform.scale = scale;
            this.updateElementTransform();
        };
        Viewer.prototype.onPinch = function (ev) {
            var pinchCenter = new Point(ev.center.x - this.viewer.offsetLeft, ev.center.y - this.viewer.offsetTop);
            console.info("pinch - center=" + pinchCenter + " scale=" + ev.scale);
            if (ev.type == 'pinchstart') {
                this.pinchInitialScale = this.transform.scale || 1;
            }
            var targetScale = this.pinchInitialScale * ev.scale;
            if (targetScale <= this.minScale) {
                targetScale = this.minScale;
            }
            if (Math.abs(this.transform.scale - this.minScale) < 1e-10
                && Math.abs(targetScale - this.minScale) < 1e-10) {
                console.debug('already at min scale');
                this.requestElementUpdate();
                return;
            }
            this.zoomTo(new Point(ev.center.x, ev.center.y), targetScale);
        };
        Viewer.prototype.onImageClick = function (event) {
            console.info("click");
            var zoomCenter = new Point(event.pageX - this.viewer.offsetLeft, event.pageY - this.viewer.offsetTop);
            var scaleFactor = event.shiftKey || event.ctrlKey ? 0.75 : 1.25;
            this.zoomTo(zoomCenter, scaleFactor * this.transform.scale);
        };
        Viewer.prototype.zoomTo = function (zoomCenter, newScale) {
            var viewPortSize = this.getViewPortSize();
            var viewPortCenter = new Point(viewPortSize.width / 2, viewPortSize.height / 2);
            var zoomRelativeCenter = new Point(zoomCenter.x - viewPortCenter.x, zoomCenter.y - viewPortCenter.y);
            console.debug('clicked at ' + zoomRelativeCenter + ' (relative to center)');
            var oldScale = this.transform.scale;
            // calculate translate difference 
            // 1. center on new coordinates
            var zoomDx = -(zoomRelativeCenter.x) / oldScale;
            var zoomDy = -(zoomRelativeCenter.y) / oldScale;
            // 2. translate from center to clicked point with new zoom
            zoomDx += (zoomRelativeCenter.x) / newScale;
            zoomDy += (zoomRelativeCenter.y) / newScale;
            console.debug('dx=' + zoomDx + ' dy=' + zoomDy + ' oldScale=' + oldScale);
            /// move to the difference
            this.transform.translate.x += zoomDx;
            this.transform.translate.y += zoomDy;
            this.transform.scale = newScale;
            console.debug("applied zoom: scale= " + this.transform.scale + ' translate=' + this.transform.translate);
            this.requestElementUpdate();
        };
        Viewer.prototype.requestElementUpdate = function () {
            var _this = this;
            if (!this.ticking) {
                window.requestAnimationFrame(function () { _this.updateElementTransform(); });
                this.ticking = true;
            }
        };
        Viewer.prototype.updateElementTransform = function () {
            var value = [
                'rotate(' + this.transform.angle + 'deg)',
                'scale(' + this.transform.scale + ', ' + this.transform.scale + ')',
                'translate3d(' + this.transform.translate.x + 'px, ' + this.transform.translate.y + 'px, 0px)',
            ];
            var stringValue = value.join(" ");
            console.debug("transform = " + stringValue);
            this.viewerContent.style.transform = stringValue;
            this.viewerContent.style.webkitTransform = stringValue;
            this.viewerContent.style.MozTransform = stringValue;
            this.viewerContent.style.msTransform = stringValue;
            this.viewerContent.style.OTransform = stringValue;
            this.ticking = false;
        };
        return Viewer;
    })();
    UI.Viewer = Viewer;
})(UI || (UI = {}));
<!DOCTYPE html>
<html lang="fr">
 <head>
  <link rel="shortcut icon" href="images/favicon.ico" type="image/x-icon">
 </head>

 <body>
<br />
<br />
<br />
  <div id="viewer">
  </div>

  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>

 </body>
</html>

TypeScript version

class Point {

    public x: number;
    public y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    public toString(): string {
        return '(' + this.x + ';' + this.y + ')';
    }
}

interface Dimension {
    width: number;
    height: number;
}

class Transform {
    constructor() {
        this.translate = new Point(0, 0);
        this.scale = 1;
        this.angle = 0;
    }
    public translate: Point;
    public scale: number;
    public angle: number;
}

namespace UI {

    export class Viewer {

        private transform: Transform;
        private gestureManager: HammerManager;

        private viewer: HTMLDivElement;
        private viewerContent: HTMLImageElement;

        private ticking: boolean = false;

        private minScale: number;
        private pinchInitialScale: number;

        constructor(viewer: HTMLDivElement) {
            console.info("viewer browser on: " + viewer);

            this.viewer = viewer;
            this.viewer.style.position = 'relative';
            this.viewer.style.overflow = 'hidden';
            this.viewer.style.touchAction = 'none';
            this.viewer.style.backgroundColor = '#000000';
            this.viewer.style['-webkit-user-select'] = 'none';
            this.viewer.style['-webkit-user-drag'] = 'none';
            this.viewer.style['-webkit-tap-highlight-color'] = 'rgba(0, 0, 0, 0)';

            this.viewerContent = <HTMLImageElement>this.viewer.querySelector(".image");
            if (this.viewerContent == null) {
                this.viewerContent = document.createElement('img');
                this.viewerContent.className = 'image';
                this.viewer.appendChild(this.viewerContent);
            }
            this.viewerContent.style.position = 'absolute';
            this.viewerContent.style.transition = 'transform 100ms linear';
            console.info("image width = " + this.viewer.clientWidth + "x" + this.viewer.clientHeight);

            this.transform = new Transform();

            this.initializeHammerEvents();

            console.info("viewer controller constructed: " + this.transform);

            this.setViewPortSize({ width: this.viewer.clientWidth, height: this.viewer.clientHeight });
        }

        public initializeHammerEvents(): void {

            this.gestureManager = new Hammer.Manager(this.viewer, {
                touchAction: 'pan-x pan-y'
            });

            this.gestureManager.add(new Hammer.Pinch({
                threshold: 0
            }));

            this.gestureManager.on("pinchstart pinchmove", (event) => { this.onPinch(event); });

            this.viewerContent.addEventListener("click", (event: MouseEvent) => {
                this.onImageClick(event);
            });
        }

        private enableGestures(): void {
            this.initializeHammerEvents();
            this.viewer.style.pointerEvents = 'auto';
        }

        private disableGestures(): void {
            this.viewer.style.pointerEvents = 'none';
            this.gestureManager.off('panstart panmove rotatestart rotatemove pinchstart pinchmove pinchend rotateend press doubletap');
        }

        public setViewPortSize(size: Dimension): void {
            this.viewer.style.width = size.width + 'px';
            this.viewer.style.height = size.height + 'px';

            this.adjustZoom();
        }

        public getViewPortSize(): Dimension {
            return {
                width: this.viewer.clientWidth,
                height: this.viewer.clientHeight
            };
        }

        public getDocumentSize(): Dimension {
            return {
                width: this.viewerContent.clientWidth,
                height: this.viewerContent.clientHeight
            };
        }

        public setSource(source: string): void {
            this.viewerContent.src = source;
            this.viewerContent.onload = () => {
                console.info("image loaded");
                this.adjustZoom();
            };
        }

        private adjustZoom(): void {

            var size: Dimension = this.getViewPortSize();
            var documentSize: Dimension = this.getDocumentSize();
            console.info("adjust zoom, documentSize: " + documentSize.width + "x" + documentSize.height);
            console.info("adjust zoom, viewPortSize: " + size.width + "x" + size.height);

            this.minScale = 100 / documentSize.width;

            console.info("minScale=" + this.minScale);

            var widthScale: number = size.width / documentSize.width;
            var heightScale: number = size.height / documentSize.height;
            var scale: number = Math.min(widthScale, heightScale);

            var left: number = (size.width - documentSize.width) / 2;
            var top: number = (size.height - documentSize.height) / 2;

            console.log("setting content to : left => " + left + "  , top => " + top, ", scale => ", scale);

            this.viewerContent.style.left = left + 'px';
            this.viewerContent.style.top = top + 'px';

            this.transform.scale = scale;
            this.updateElementTransform();
        }

        private onPinch(ev: HammerInput): void {

            var pinchCenter: Point = new Point(ev.center.x - this.viewer.offsetLeft, ev.center.y - this.viewer.offsetTop);

            console.info("pinch - center=" + pinchCenter + " scale=" + ev.scale);
            if (ev.type == 'pinchstart') {
                this.pinchInitialScale = this.transform.scale || 1;
            }

            var targetScale: number = this.pinchInitialScale * ev.scale;
            if (targetScale <= this.minScale) {
                targetScale = this.minScale;
            }

            if (Math.abs(this.transform.scale - this.minScale) < 1e-10
                && Math.abs(targetScale - this.minScale) < 1e-10) {
                console.debug('already at min scale');
                this.requestElementUpdate();
                return;
            }

            this.zoomTo(new Point(ev.center.x, ev.center.y), targetScale);
        }

        private onImageClick(event: MouseEvent) {
            console.info("click");

            var zoomCenter = new Point(event.pageX - this.viewer.offsetLeft, event.pageY - this.viewer.offsetTop);
            var scaleFactor = event.shiftKey || event.ctrlKey ? 0.75 : 1.25;
            this.zoomTo(zoomCenter, scaleFactor * this.transform.scale);
        }

        private zoomTo(zoomCenter: Point, newScale: number): void {
            var viewPortSize: Dimension = this.getViewPortSize();
            var viewPortCenter: Point = new Point(viewPortSize.width / 2, viewPortSize.height / 2);

            var zoomRelativeCenter: Point = new Point(zoomCenter.x - viewPortCenter.x, zoomCenter.y - viewPortCenter.y);
            console.debug('clicked at ' + zoomRelativeCenter + ' (relative to center)');

            var oldScale: number = this.transform.scale;

            // calculate translate difference 

            // 1. center on new coordinates
            var zoomDx: number = -(zoomRelativeCenter.x) / oldScale;
            var zoomDy: number = -(zoomRelativeCenter.y) / oldScale;

            // 2. translate from center to clicked point with new zoom
            zoomDx += (zoomRelativeCenter.x) / newScale;
            zoomDy += (zoomRelativeCenter.y) / newScale;

            console.debug('dx=' + zoomDx + ' dy=' + zoomDy + ' oldScale=' + oldScale);

            /// move to the difference
            this.transform.translate.x += zoomDx;
            this.transform.translate.y += zoomDy;

            this.transform.scale = newScale;
            console.debug("applied zoom: scale= " + this.transform.scale + ' translate=' + this.transform.translate);
            this.requestElementUpdate();
        }

        private requestElementUpdate() {
            if (!this.ticking) {
                window.requestAnimationFrame(() => { this.updateElementTransform() });
                this.ticking = true;
            }
        }

        private updateElementTransform() {

            var value = [
                'rotate(' + this.transform.angle + 'deg)',
                'scale(' + this.transform.scale + ', ' + this.transform.scale + ')',
                'translate3d(' + this.transform.translate.x + 'px, ' + this.transform.translate.y + 'px, 0px)',
            ];

            var stringValue: string = value.join(" ");
            console.debug("transform = " + stringValue);
            this.viewerContent.style.transform = stringValue;
            (<any>this.viewerContent.style).webkitTransform = stringValue;
            (<any>this.viewerContent.style).MozTransform = stringValue;
            (<any>this.viewerContent.style).msTransform = stringValue;
            (<any>this.viewerContent.style).OTransform = stringValue;

            this.ticking = false;
        }
    }
}
Louis GRIGNON
  • 699
  • 6
  • 17