Adobe SVG Viewer and mousewheel zoom (Part II)
In the first part of this article, the difficulties of enhancing WaterSums to use the mousewheel with the Adobe SVG Viewer (ASV) were reported. Zoom (scale change) and Scroll (translation) operations are not handled synchronously by ASV and animations complicate this even more. Searches of the web did not help and experimentation was the only option. The conclusions were that for ASV:
- mousewheel zooming is not possible without stopping animations
- map zooming and scrolling is much slower when existing animations are paused
- maximum zoom limits in ASV must be handled
An asynchronous model has to be adopted. Every step can take some time to happen and earlier settings are lost if the have not been applied before the next scale/translation setting is changed. Fortunately, ASV supports onzoom and onscroll event handlers and these must be used to determine when the zoom or scroll operation has completed.
For best performance, mousewheel zoom must cope with:
- simple zoom where view changes are 'immediate'
- fast mousewheel operation
- zoom with active animations
Swift operation of the mousewheel generates multiple messages and also slows down ASV response time, so asynchronous handling is needed at some stages in the process. Active animations must be stopped before zooming can occur, and asynchronous handling is always needed. Moving the mouse wheel quickly makes the problem worse. Oh, and one other point: WaterSums rescales some items when the zoom level changes, so we mustn't forget to do this.
When expressed as an ordered set of asynchronous operations, the components of our zoom become:
- pause animations if they are active
- once animations are paused, set scale
- after scale has been set, translate x
- after x translation has been set, translate y
- after y translation has been set, resize automatically scaled items
- once automatic scaling is finished, restart animations if we paused them
- check that animations have restarted if we paused them
The tools we can use to achieve this are Javascript, mousewheel, onzoom and onscroll messages. I guessed that adding several mousewheel event listeners using the JQuery mousewheel plugin (http://plugins.jquery.com/project/mousewheel) would help maximise performance in the simple case and would also simplify coding. An inspired guess. 7 listeners were necessary, multiplexed through a single function JQuerywheel() to help with state control.
$('#SVGdiv') .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 0)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 1)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 2)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 3)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 4)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 5)); }) .mousewheel(function(event, delta) { return (JQuerywheel(event, delta, 6)); })
The JQuery mousewheel plugin provides listeners with the mousewheel event and a delta indicating the direction and magnitude of the wheel movement, and we add a extra integer to show which callback has received the message. Thus, for each mousewheel movement, the JQuerywheel() function is called 7 times, so we need to make sure we handle them properly. Various global variables help with storing state during our zoom process. These are defined as follows:
var svgCurrentScale = 1.0; // used and updated by onZoom callback // used for our multi-stage zoom... var zoominprogress = 0; // set when we start a zoom and cleared when we finish var dontrescale = 0; // set to make sure we don't try auto-rescale of objects during a zoom var pausedforzoom = 0; // set if we have to pause animation when a zoom starts var zoomstep = 0; // step counter for the zoom process (1 to 6 with 3 extra mid-points) var svgxlate = null; // reference to ASV currentTranslate SVGPoint object var svgoldscale = 0.0; // what the scale was before the zoom started var svgnewscale = 0.0; // what the required scale is - calculated when the zoom starts var svgnewxoffset=0.0; // what the new x translation needs to be - calculated when the zoom starts var svgnewyoffset=0.0; // what the new y translation needs to be - calculated when the zoom starts var svgwheelevent=null; // mousewheel event being processed (used for resending events) var svgresend = 0; // count of the number of times an event is resent // some extra details explained later var killzoomtimer = null; // timer object to kill zoom process if it takes too long - more later var zoomblockg = null; // an SVG group containing a white rectangle - more later
With these variables, our listener workhorse JQuerywheel() can get to work.
function JQuerywheel(evt, delta, listener) { var posx = 0; var posy = 0; /* ignore new clicks that come when we are busy */ if (evt !== null && listener != zoomstep && zoominprogress > 0) return; if (evt == null) evt = svgwheelevent; if (evt == null) return (false); /* find the mouse location on the page window */ if (evt.pageX || evt.pageY) { posx = evt.pageX; posy = evt.pageY; } else if (evt.clientX || evt.clientY) { posx = evt.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; posy = evt.clientY + document.body.scrollTop + document.documentElement.scrollTop; } /* change scale by 35% for each click on the mousewheel */ var factor; if (delta < 0) factor = 1.0/(1.0-(delta*0.35)); else factor = 1.0+(delta*0.35); /* posx and posy contain the mouse position relative to the document */ if (listener == 0 && zoomstep == 0) { if (zoominprogress) return (false); /* already busy zooming */ zoominprogress = 1; /* from now on, we are busy zooming */ zoomPauseAnimation(factor, posx, posy, evt); /* pause animations if we need to */ if (zoominprogress) { /* * set a timer so that we can cancel the zoom if something goes wrong * and it is taking too long. If we try to zoom in too far, the * zoom or translation will fail, so this is needed. */ killzoomtimer = setTimeout(timedkillZoom,5000); } } else if (listener == 1 && zoomstep == 1) { zoomRescale(factor, posx, posy, evt); /* do the rescale */ } else if (listener == 2 && zoomstep == 2) { zoomScrollX(factor, posx, posy, evt); /* scroll x */ } else if (listener == 3 && zoomstep == 3) { zoomScrollY(factor, posx, posy, evt); /* scroll y */ } else if (listener == 4 && zoomstep == 4) { zoomResize(factor, posx, posy, evt); /* rescale auto-scaled items */ } else if (listener == 5 && zoomstep == 5) { zoomUnpauseAnimation(factor, posx, posy, evt); /* unpause if necessary */ } else if (listener == 6 && zoomstep == 6) { /* make sure unpause has finished */ zoomFinishedCheck(factor, posx, posy, evt); if (!zoominprogress) { /* we have finished, so we don't need this timer anymore */ clearTimeout(killzoomtimer); killzoomtimer = null; } } else { // log("skipping message: listener " + listener + ", zoomstep=" + zoomstep); } return false; }
The log() function referred to above is an optional function that can report useful information as part of debugging.
JQuerywheel() pulls together the pieces used to do the actual work. Each step has a separate function and some interact with other event listeners to make sure the step is completed. If the step functions find that the previous step has not been completed, the mousewheel event is triggered again using setTimeout().
Before we look at the zoom components, let's look at the termination cases. Since our zoom now relies on the coordination of messages, we need to make sure that unexpected behaviour or a simple programming error won't lock up the entire session. This is done by setting a kill switch function timedkillZoom() when we start the zoom through setTimeout(). We store the timer so that we can cancel it with clearTimeout() if the zoom is successful. If the zoom doesn't complete within 5 seconds, we need to reset our global variables to unlock the gate we close when the zoom begins.
/* called to reset globals when zoom has finished or when zoom is being abandoned */ function killZoom() { pausedforzoom = 0; dontrescale = 0; zoomstep = 0; svgresend = 0; svgwheelevent = null; svgoldscale = 0.0; svgnewscale = 0.0; svgnewxoffset = 0.0; svgnewyoffset = 0.0; zoominprogress = 0; } /* called if the zoom doesn't finish within 5 seconds */ function timedkillZoom() { if (pausedforzoom) unpauseAnimations(); killZoom(); killzoomtimer = null; svg.change(zoomblockg,{ display: 'none' } ); }
And now to the simple functions that really do the work! They are complicated by the need to re-trigger the mousewheel event if the previous step has not finished yet, but apart from that, they are very simple.
/* Step 0 of dynamic zoom - pause animations if necessary. */ function zoomPauseAnimation(factor, posx, posy, evt) { if (svg && svg._svg) { /* if we don't need to zoom, give up straight away */ if (factor == 1.0) { killZoom(); return; } dontrescale = 1; bringZoomBlockerToFront(); svg.change(zoomblockg,{ display: 'inline' } ); var paused = animationsPaused(); if (!paused) { pauseAnimations(); pausedforzoom = 1; } zoomstep = 1; svgwheelevent = evt; } } /* * Step 1 of dynamic zoom - set globals and rescale. * posx, posy are screen coordinates to be used * as the centre of the zoom operation. * If not set, the centre of the currently visible map * area will be used. */ function zoomRescale(factor, posx, posy, evt) { if (svg && svg._svg) { if (!animationsPaused()) { if (svgresend < 20) { setTimeout(function() { svgresend++; $('#SVGdiv').trigger('mousewheel', null); }, 10); } return; } svgresend = 0; if (posx == undefined) posx=Math.ceil(svg._width() / 2.0) - 1; if (posy == undefined) posy=Math.ceil(svg._height() / 2.0) - 1; /* find the current settings first and set our globals */ svgoldscale = svg._svg.currentScale; if (!factor) factor = 2.0; svgnewscale = svgoldscale * factor; /* currentTranslate is an SVGPoint in screen pixels and * records how far the top left of the map has been moved * from its position with x increasing as the map is moved * to the right and y increasing with downward movement. */ if (svgxlate == null) svgxlate = svg._svg.currentTranslate; svgnewxoffset = posx + Math.round(factor * (svgxlate.x - posx)); svgnewyoffset = posy + Math.round(factor * (svgxlate.y - posy)); /* zooming uses the top left of the view area as the anchor * We set the scale: onZoom will be called when it happens * and will set the zoomstep to 2.0*/ //svg._svg.currentScale = svgnewscale; svg._svg.setCurrentScale(svgnewscale); /* And that's all we do in this step. * Steps 2 and 3 will do the translation corrections * for us to fix up the relative movement of the map due to scaling. */ if (zoomstep == 1) zoomstep = 1.5; } } /* * event callback for onzoom event registered with a command like: * svg._svg.addEventListener("zoom", onZoom, false); */ function onZoom(evt) { var newscale; if (evt) { newscale = evt.target.currentScale; } else { if (svg && svg._svg) { newscale = svg._svg.currentScale; } else { newscale = 1.0; } } if (zoominprogress) { /* Step 1.5 of dynamic zoom - set zoomstep to 2 */ if (zoomstep == 1) { /* we are still in the middle of processing the * mousewheel message, so no need to worry about * sending it again. */ zoomstep = 2; } else if (zoomstep == 1.5) { /* our zoom has happened, so set the step counter and * resend message. */ setTimeout(function() { zoomstep = 2; $('#SVGdiv').trigger('mousewheel', null); }, 5); } else { //log("unknown zoomstep: " + zoomstep); } } else { if (newscale != svgCurrentScale) { // do any automatic rescaling necessary } } /* set our global */ svgCurrentScale = newscale; } /* Step 2 of dynamic zoom - translate X. */ function zoomScrollX(factor, posx, posy, evt) { if (svg && svg._svg) { if (!singleEqual(svg._svg.currentScale, svgnewscale)) { if (svgresend < 20) { setTimeout(function() { svgresend++; WSDebug("2:Sent message"); }, 10); } return; } svgresend = 0; /* we pan to keep posx,posy at the same map location * note that svgxlate is a 'live' object - changes made to * it are reflected in the DOM 'immediately'. */ svgxlate.setX(svgnewxoffset); if (zoomstep == 2) zoomstep = 2.5; /* see onScroll() event callback */ } } /* Step 2.5 of dynamic zoom - translate X has finished. */ /* Step 3.5 of dynamic zoom - translate Y has finished. */ function onScroll(evt) { if (zoominprogress) { /* * One of our translations has happened, so set the step * counter and resend message to continue. * sometimes we get this message while the mousewheel event is still * being processed, while in other cases, the mousewheel event has been * processed and we need to re-trigger it. We can tell which case we are * dealing with by the value of zoomstep */ if (zoomstep == 2) { /* We are still in the middle of processing the message, * so no need to worry about sending it again. */ zoomstep = 3; } else if (zoomstep == 2.5) { setTimeout(function() { zoomstep = 3; $('#SVGdiv').trigger('mousewheel', null); }, 5); } else if (zoomstep == 3) { /* We are still in the middle of processing the message, * so no need to worry about sending it again. */ zoomstep = 4; } else if (zoomstep == 3.5) { setTimeout(function() { zoomstep = 4; $('#SVGdiv').trigger('mousewheel', null); }, 5); } else { //log("unknown zoomstep: " + zoomstep); } } else { // TODO auto-update anything that may change due to an ordinary pan/scroll } } /* Step 3 of dynamic zoom - translate Y. */ function zoomScrollY(factor, posx, posy, evt) { if (svg && svg._svg) { /* we pan to keep posx,posy at the same map location * note that svgxlate is a 'live' object - changes made to * it are reflected in the DOM 'immediately'. */ if (!singleEqual(svgxlate.x, svgnewxoffset)) { if (svgresend < 20) { setTimeout(function() { svgresend++; $('#SVGdiv').trigger('mousewheel', null); }, 10); } return; } svgresend = 0; svgxlate.setY(svgnewyoffset); if (zoomstep == 3) zoomstep = 3.5; /* see onScroll() event callback */ } } /* Step 4 of dynamic zoom - auto-rescaling of map contents. */ function zoomResize(factor, posx, posy, evt) { if (svg && svg._svg) { if (!singleEqual(svgxlate.y, svgnewyoffset)) { if (svgresend < 20) { setTimeout(function() { svgresend++; $('#SVGdiv').trigger('mousewheel', null); }, 10); } return; } svgresend = 0; dontrescale = 0; // TODO auto-scale any necessary items zoomstep = 5; } } /* Step 5 of dynamic zoom - unpause animations. */ function zoomUnpauseAnimation(factor, posx, posy, evt) { if (svg && svg._svg) { if (/* TODO check that auto-rescale has finished */) { if (svgresend < 20) { setTimeout(function() { svgresend++; $('#SVGdiv').trigger('mousewheel', null); }, 10); } return; } svgresend = 0; if (pausedforzoom) unpauseAnimations(); zoomstep = 6; } } /* Step 6 of dynamic zoom - final check that animations have restarted. */ function zoomFinishedCheck(factor, posx, posy, evt) { if (svg && svg._svg) { if (pausedforzoom && animationsPaused()) { if (svgresend < 20) { setTimeout(function() { svgresend++; $('#SVGdiv').trigger('mousewheel', null); }, 10); } return; } svgresend = 0; svg.change(zoomblockg,{ display: 'none' } ); // everything has finished successfully killZoom(); } }
If you are looking carefully at the code above, you will see references to zoomblockg. This is a cosmetic fig leaf. If the mousewheel events need to be re-triggered during our multi-stage zoom, the map jerks. zoomblockg is a white rectangle sized to cover the map contents and normally invisible. When we start to zoom, we set display: 'inline' for the group. On successful completion, we reset the display display: 'none'. The result is that the screen goes blank for a short time during the zoom if animations were running when a mousewheel zoom was triggered. Believe me, this is much better than the vertigo inducing map dancing that happens without the covering rectangle!
zoomblockg = svg.group(null,{id: 'zoomblockg', transform: 'scale(1,-1)', display: 'none' }); var zoomblock = svg.rect(zoomblockg, -11.93721, 7.862105, 101.5141, 86.8745, { id: 'zoomblock', stroke: 'white', display: 'inline', strokeWidth: '0.01', fill: 'white' });
Since SVG does not have a z-index attribute, keeping this rectangle on top when new features are added to the map is one last complication to handle. After elements are added, we pull the rectangle to the front by calling:
function bringZoomBlockerToFront() { if (zoomblockg) { var root = zoomblockg.ownerDocument.rootElement; root.appendChild(zoomblockg); } }
Oh, to be able to make a long story short, rather than always needing to make a short story long! Whatever the field of endeavour, achieving a good result isn't as easy as it first appears.
Moral: Easy wins aren't always easy, but never give up.
