Tuesday, June 11, 2013

SVG Navigable Viewport Part 4: The Quad Viewport and Fixing some Assumptions (again)

In my last post I had implemented viewport rotations, and I had assumed that I had fixed the problem with nested SVG elements. Unfortunately, as I started working on a "quad" viewport demo, I realized there were some problems with my assumptions about how SVG establishes new viewports. In short, I had to devise a new way to get the transformation matrix of an individual element.

Transformation Matrix of an Element

The primary problem with how I was retrieving the transformation matrix of a single element was the use of getTransformToElement. In particular, consider a document fragment:

<svg id='svg_base' width='400' height'=400' viewBox='100, 100, 200, 200'>
 <svg x='0' y='0' width='200' height='200'>
  <g id='viewport'>
  </g>
 </svg>
</svg>

If I tried to use viewport.getTransformToElement(viewport.parentNode), surprisingly it would include any transforms (explicit or implicit) on the intermediate SVG element. In this case, the implicit transform due to the svg_base viewBox is being lumped in so the resulting transform matrix is:

\begin{bmatrix} 0.5&0&100\\ 0&0.5&100\\ 0&0&1 \end{bmatrix}

This happens dispite the fact that the viewport element itself has not transform. So how do we get around this problem? As far as I can tell the simplest way to get a "stable" matrix definition is to use the getScreenCTM() method. Consider the screen CTM of a given element:

\[ \bf S = \bf A \times \bf M\\ \bf M = \bf A^{-1} \times \bf A \times \bf M = \bf A^{-1} \times \bf S \]

Here \(\bf A\) is the screen CTM of the viewport's parent node, \(\bf S\) is the screen CTM of the viewport element, and \(\bf M\) is the transformation matrix of just the viewport element. In code, this might look like:

// assume elem is not root svg element
function getElementTransform(elem)
{
 var matrix = elem.getScreenCTM();
 var parMat = elem.parentNode.getScreenCTM();
 return parMat.inverse().multiply(matrix);
}

Here's my implementation of the SVG quad viewports. I used a SVG re-use semantics to have all viewports display the same scene. I added an offset on the left filled with a gray rectangle to demonstrate the updated transformation code works.

<svg id='svg_base' style='border: 1px solid black' overflow='hidden' width='640' height='512' viewBox='-50 0 400 400'>
 <defs id='svg_defs'>
 </defs>
 <rect x='-100' y='0' width='100' height='400' fill='gray'></rect>
 <svg x='0' y='0' width='50%' height='50%' overflow='hidden' pointer-events='all' viewBox='0 0 100 100'>
  <g id='view1' stroke='black'>
   <use xlink:href='#scene'></use>
  </g>
  <rect x='0' y='0' fill='none' stroke-width='2' stroke='black' width='100' height='100'></rect>
 </svg>
 <svg x='50%' y='0' width='50%' height='50%' overflow='hidden' pointer-events='all' viewBox='0 0 100 100'>
  <g id='view2' stroke='black'>
   <use xlink:href='#scene'></use>
  </g>
  <rect x='0' y='0' fill='none' stroke-width='2' stroke='black' width='100' height='100'></rect>
 </svg>
 <svg x='0' y='50%' width='50%' height='50%' overflow='hidden' pointer-events='all' viewBox='0 0 100 100'>
  <g id='view3' stroke='black'>
   <use xlink:href='#scene'></use>
  </g>
  <rect x='0' y='0' fill='none' stroke-width='2' stroke='black' width='100' height='100'></rect>
 </svg>
 <svg x='50%' y='50%' width='50%' height='50%' overflow='hidden' pointer-events='all' viewBox='0 0 100 100'>
  <g id='view4' stroke='black'>
   <use xlink:href='#scene'></use>
  </g>
  <rect x='0' y='0' fill='none' stroke-width='2' stroke='black' width='100' height='100'></rect>
 </svg>
</svg>

There are a few things I wanted to point out:

  1. nested SVG elements can't catch pointer events! This is a huge pain because a big portion of viewport navigation is receiving pointer events. To get around this, I've had to place a background a background rectangle which will allow pointer events to be captured. I simply used this rect as a "border" element because I haven't found any way to draw a border using just the SVG or group element.
  2. There are issues with using percentage dimensions, particular when taking into browser zoom levels. The easy solution is to just specify the viewBox and provide unspecified coordinates (a.k.a. just the number).

Here's the handler code:

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);};
 viewport.parentNode.addEventListener('mousedown', function(e){that.onmousedown(e);});
 document.addEventListener('mouseup', function(e){that.onmouseup(e);});
 viewport.parentNode.addEventListener('mousewheel', function(e){that.onmousewheel(e);});
 // firefox does not support mousewheel yet!
 viewport.parentNode.addEventListener('DOMMouseScroll', function(e){that.onmousewheel_ff(e);});
}

// assume elem is not root svg element
function getElementTransform(elem)
{
 var matrix = elem.getScreenCTM();
 var parMat = elem.parentNode.getScreenCTM();
 return parMat.inverse().multiply(matrix);
}

SVGViewportHandler.prototype = {
  onmousedown: function(e)
  {
   if(e.button === 0)
   {
    document.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.viewport.parentNode.getScreenCTM().inverse());
     // rotation
     //this.transformStart.x = temp.x;
     //this.transformStart.y = temp.y;
     this.transformStart.x = 50;
     this.transformStart.y = 50;
     
     console.log(temp);
     console.log(this.transformStart);
     
     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.nextElementSibling.setAttribute('stroke-width', '6');
     this.viewport.nextElementSibling.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.nextElementSibling.setAttribute('stroke-width', '6');
     this.viewport.nextElementSibling.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 = getElementTransform(this.viewport);
   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.viewport.parentNode.getScreenCTM().inverse());
    // relative to the center of the viewport
    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;
     console.log(dst);
     console.log(matrix);
     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.nextElementSibling.setAttribute('stroke-width', '2');
     this.viewport.nextElementSibling.setAttribute('stroke', 'black');
     document.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 = getElementTransform(this.viewport);
   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 = getElementTransform(this.viewport);
   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;
  }
};

And finally, a live demo:

No comments :

Post a Comment