Tuesday, June 4, 2013

SVG Navigable Viewport Part 3: Adding Rotations and Extending Functionality

This is a sample of how to rotate a SVG viewport using matrix transformations. There are two methods I explored. They basically are implemented near-identically, but operate somewhat differently. I'm also going to explore some slight changes to my previous implementation which made a few assumptions about the SVG DOM state.

Handling Rotations

The primary basis of the rotation mechanics is similar to those of the zoom mechanics. The rotation transformation is applied, and a post transformation is done to adjust for where the center of rotation is.

The two remaining questions are where that center of rotation is, and by what angle the viewport should be rotated.

First we'll choose what the rotation delta is. Let's imagine a vector from the center of rotation to the current pointer location. We can also take a vector from the center of rotation to the previous pointer location. There's some angle between these vectors, and this is the amount we should rotate by.

The next part is choosing the center of rotation. There are two logical choices:

  1. The center of the currently visible viewport
  2. The location of the pointer when the user initializes the rotation

Now there are a few issues with picking the second value, namely our initial vector is a point, and it's impossible to calculate the angle between a point and the vector. There is a solution, we can simply assume the original vector points in the positive-x direction, but I found this to have rather chaotic behavior.

Which-ever center of rotation we use, there's still the possibility that the user could move the pointer to the center of rotation, and we again run into the same problem described above with a 0-length vector. The easiest and most logical solution is to ignore this case (i.e. do nothing).

Fixing Some Generalization Issues

So in my previous codes, I was making two assumptions:

  1. The top-most SVG element has a viewbox of 0, 0, w, h (where w is the svg element width, and h is the height)
  2. The viewport group element is a direct descendent of an SVG element.
  3. I believe this code only works in certain cases when there are nested SVG elements. I'm not positive, I'll have to do more testing.

The first item causes issues with the translation process of "mouse" document coordinates down to user coordinates.

The second item also causes problems with the coordinate system translations, but also wrecks havoc on the actual transformation process because the transformation matrices of parent nodes are being clumped into the viewport's transformation matrix. Fixing both of these problems can be done by putting the primary coordinate system in terms of viewport's parent node, and applying transforms in term of only the viewport's transform matrix.

As it turns out, even though the base SVG element may have it's "transformation" modified using the viewBox attribute, the SVG element's transformation matrix does have information reflecting this change. Points which should be phrased in terms of the viewport's parent element can be found using:

var point = p.matrixTransform(viewport.parentNode.getCTM().inverse());

Now there's a caveat that getCTM() only gets the transformation matrix up to the nearest SVG element. It may be possible to use getTransformToElement(svg_base), but the problem is that this method is suppose to transform from user space to user space, not user space to viewport space. We can use a two-step process to try and get to the outer SVG element's viewport space.

Additionally, there are currently ambiguities in the standard which involve using getCTM() on the outer SVG element. The idea is that getCTM() is defined to return the transformation matrix from the current element up to the result of nearestViewportElement. Under normal circumstances, this is the current transformation matrix (CTM). However, nearestViewportElement is defined to return null if called on the outer SVG element. That means getCTM() must get the transformation of a null element, which doesn't exist. Firefox decided to resolve this ambiguity by returning a null transform, though the use of exceptions has also been considered.

On the other hand, we can analyze the definition of the CTM as being the accumulation of all transformations on the current element and all ancestors up to the element which establishes the current viewport. The null element cannot establish the current viewport, and indeed for the outer SVG element it establishes its own viewport via the SVG user agent to negotiate with the parent user agent. This transform does exist, and I know this approach is taken by Chrome, Internet Explorer, and Opera. I have no idea which approach Safari takes because I am unable to test it. So the question is how do we get around this issue?

The easiest method I could think of was to use the getScreenCTM() method, and zero out and adjust the translation elements to account for the viewBox. Here's an example implementation:

// from elems user space to svg_base viewport space
function getTransformToRoot(svg_base, elem)
{
 var matrix = svg_base.getCTM();
 if(matrix === null)
 {
  // Firefox currently returns null, manually calculate it
  matrix = svg_base.getScreenCTM();
  matrix.e = 0;
  matrix.f = 0;
  matrix = matrix.translate(-svg_base.viewBox.baseVal.x, -svg_base.viewBox.baseVal.y);
 }
 if( elem !== svg_base )
 {
  matrix = matrix.multiply(elem.getTransformToElement(svg_base));
 }
 return matrix;
}

This is not in general going to work because it will miss out on any translation operations attached to svg_base via the transform attribute. A better implementation would take this into account. It's also possible to create a double nested viewport, with one being a dummy outer wrapper just to ensure that viewport.parentNode is never the outer SVG element. There would need to be some modifications to the rotation code as I'm using absolute viewport coordinates to get the rotation of the viewport around the center of the root svg viewport space, not necessarily the viewport's parent node.

I also made some improvements to my code by getting rid of the hack to get the mouse coordinates relative to the SVG document fragment. Rather than adding up offsets, I decided to use the getScreenCTM method. Oddly, this function operates on the same coordinate system as the mouseEvent clientX/clientY, and it is even noted that a better name for this function should have been getClientCTM.

Demo

Here's a live demo with the fixes and rotations added. To rotate the viewport, hold shift down and press and hold button 0 (LMB). I implemented rotations such that the center of rotation is the center of the SVG root element.

function SVGViewportHandler(svg_base, viewport)
{
 this.svg_base = svg_base;
 this.viewport = viewport;
 this.panning = false;
 this.rotating = false;
 this.transformStart = svg_base.createSVGPoint();
 this.oldRotation = 0;
 
 var that = this;
 
 this.mouse_move_wrapper = function(e){that.onmousemove(e);};
 svg_base.addEventListener('mousedown', function(e){that.onmousedown(e);});
 document.addEventListener('mouseup', function(e){that.onmouseup(e);});
 svg_base.addEventListener('mousewheel', function(e){that.onmousewheel(e);});
 // firefox does not support mousewheel yet!
 svg_base.addEventListener('DOMMouseScroll', function(e){that.onmousewheel_ff(e);});
}

// from elems user space to svg_base viewport space
function getTransformToRoot(svg_base, elem)
{
 var matrix = svg_base.getCTM();
 if(matrix === null)
 {
  // Firefox currently returns null, manually calculate it
  matrix = svg_base.getScreenCTM();
  matrix.e = 0;
  matrix.f = 0;
  matrix = matrix.translate(-svg_base.viewBox.baseVal.x, -svg_base.viewBox.baseVal.y);
 }
 if( elem !== svg_base )
 {
  matrix = matrix.multiply(elem.getTransformToElement(svg_base));
 }
 return matrix;
}

SVGViewportHandler.prototype = {
  onmousedown: function(e)
  {
   if(e.button === 0)
   {
    this.svg_base.addEventListener('mousemove', this.mouse_move_wrapper);
    
    if(e.shiftKey === true)
    {
     var temp = this.svg_base.createSVGPoint();
     temp.x = e.clientX;
     temp.y = e.clientY;
     temp = temp.matrixTransform(this.svg_base.getScreenCTM().inverse()).matrixTransform(getTransformToRoot(this.svg_base, this.svg_base));
     // rotation
     //this.transformStart.x = temp.x;
     //this.transformStart.y = temp.y;
     this.transformStart.x = this.svg_base.width.baseVal.value / 2;
     this.transformStart.y = this.svg_base.height.baseVal.value / 2;
     
     if(Math.abs(temp.y - this.transformStart.y) + Math.abs(temp.x - this.transformStart.x) > 1e-12)
     {
      this.oldRotation = Math.atan2(temp.y - this.transformStart.y, temp.x - this.transformStart.x);
     }
     else
     {
      this.oldRotation = 0;
     }
     
     this.viewport.setAttribute('stroke', 'green');
     this.rotating = true;
    }
    else
    {
     this.transformStart.x = e.clientX;
     this.transformStart.y = e.clientY;
     this.transformStart = this.transformStart.matrixTransform(this.viewport.getScreenCTM().inverse());
     
     this.viewport.setAttribute('stroke', 'red');
     this.panning = true;
    }
   }
   
   e.preventDefault();
   e.stopPropagation();
   return false;
  },
  
  updateViewport: function(matrix)
  {
   var transform = this.svg_base.createSVGTransformFromMatrix(matrix);
   this.viewport.transform.baseVal.initialize(transform);
  },
  
  pan: function(dst, origin, matrix)
  {
   var beta = (matrix.a * (dst.y - matrix.d * origin.y - matrix.f) - matrix.b * (dst.x - matrix.c * origin.y - matrix.e)) / (matrix.a * matrix.d - matrix.b * matrix.c);
   var alpha = (dst.x - matrix.a * origin.x - matrix.c * origin.y - matrix.e - matrix.c * beta) / matrix.a;
   matrix = matrix.translate(alpha, beta);
   return matrix;
  },
  
  zoom: function(userCenter, zoomFactor, matrix)
  {
   var focus = userCenter.matrixTransform(matrix.inverse());
   return this.pan(userCenter, focus, matrix.scale(zoomFactor));
  },
  
  rotate: function(userCenter, theta, matrix)
  {
   var focus = userCenter.matrixTransform(matrix.inverse());
   return this.pan(userCenter, focus, matrix.rotate(theta));
  },
  
  onmousemove: function(e)
  {
   var matrix = this.viewport.getTransformToElement(this.viewport.parentNode);
   var dst = this.svg_base.createSVGPoint();
   dst.x = e.clientX;
   dst.y = e.clientY;
   
   if(this.panning)
   {
    // account for parent transformations
    dst = dst.matrixTransform(this.viewport.parentNode.getScreenCTM().inverse());
    this.updateViewport(this.pan(dst, this.transformStart, matrix));
    
    e.stopPropagation();
    e.preventDefault();
    return false;
   }
   else if(this.rotating)
   {
    dst = dst.matrixTransform(this.svg_base.getScreenCTM().inverse()).matrixTransform(getTransformToRoot(this.svg_base, this.svg_base));
    // relative to the center of the screen
    if(Math.abs(dst.y - this.transformStart.y) + Math.abs(dst.x - this.transformStart.x) > 1e-12)
    {
     var theta = Math.atan2(dst.y - this.transformStart.y, dst.x - this.transformStart.x);
     // find the center of rotation in viewports parent coords
     dst = this.transformStart.matrixTransform(getTransformToRoot(this.svg_base, this.viewport.parentNode).inverse());
     this.updateViewport(this.rotate(dst, (theta - this.oldRotation) * 180 / Math.PI, matrix));
     this.oldRotation = theta;
    }
    
    e.stopPropagation();
    e.preventDefault();
    return false;
   }
  },
  
  onmouseup: function(e)
  {
   if(e.button === 0)
   {
    if(this.rotating === true || this.panning === true)
    {
     this.panning = false;
     this.rotating = false;
     this.viewport.setAttribute('stroke', 'black');
     this.svg_base.removeEventListener('mousemove', this.mouse_move_wrapper);
     
     e.preventDefault();
     e.stopPropagation();
     return false;
    }
   }
  },
  
  onmousewheel: function(e)
  {
   if(e.deltaY !== undefined)
   {
    // standard
    var zoomFactor = Math.pow(1.1, e.deltaY / 56);
   }
   else if(e.wheelDeltaY !== undefined)
   {
    // chrome
    var zoomFactor = Math.pow(1.1, e.wheelDeltaY / 56);
   }
   else if(e.wheelDelta !== undefined)
   {
    // IE and MouseWheelEvent
    var zoomFactor = Math.pow(1.1, e.wheelDelta / 56);
   }
   else
   {
    // err... just disable zooming with mouse wheel
    var zoomFactor = 1;
   }
   
   var matrix = this.viewport.getTransformToElement(this.viewport.parentNode);
   var dst = this.svg_base.createSVGPoint();
   dst.x = e.clientX;
   dst.y = e.clientY;
   dst = dst.matrixTransform(this.viewport.parentNode.getScreenCTM().inverse());
   this.updateViewport(this.zoom(dst, zoomFactor, matrix));
   
   e.preventDefault();
   e.stopPropagation();
   return false;
  },
  
  onmousewheel_ff: function(e)
  {
   // not only is FF coords backwards, but stored in radians!
   var zoomFactor = Math.pow(1.1, -e.detail * 180 / Math.PI / 56);
   
   var matrix = this.viewport.getTransformToElement(this.viewport.parentNode);
   var dst = this.svg_base.createSVGPoint();
   dst.x = e.clientX;
   dst.y = e.clientY;
   dst = dst.matrixTransform(this.viewport.parentNode.getScreenCTM().inverse());
   
   this.updateViewport(this.zoom(dst, zoomFactor, matrix));
   
   e.preventDefault();
   e.stopPropagation();
   return false;
  }
};

No comments :

Post a Comment