Canvas 2D Web Apps/Transformable Objects

This chapter introduces “transformable objects.” Here, “transformable objects” denote images that can be moved (“translated”), rotated, and scaled (uniformly in both directions) by two-finger gestures. Transformable objects are used very much like draggable objects; however, their process functions requires another image for the case that they are touched by two fingers and it takes another boolean value to determine whether the object can be dragged with one finger or the mouse (instead of only with two fingers).

As with draggable objects, any number of objects can be transformed simultaneously (as long as the touch device supports the required number of touch events). Moreover, application programmers don't have to worry about the handling of multiple simultaneous touch events: the process function receives only events with a single pair of coordinates and processes only one object at a time.

The Example edit

The example of this chapter (which is available online; also as downloadable version) shows two objects: both can be dragged with one finger (or a mouse) and transformed with two fingers but one cannot be scaled and the other cannot be rotated. The following sections discuss how to use transformable objects and how them. See the chapter on draggable objects for other parts.

<!DOCTYPE HTML>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
    <meta name="viewport" 
      content="width=device-width, initial-scale=1.0, user-scalable=no">

    <script src="cui2d.js"></script>

    <script>
      function init() {
        // get images
        imageNormalAlien.src = "alien_sleepy.png";
        imageNormalAlien.onload = cuiRepaint;
        imageFocusedAlien.src = "alien_wow.png";
        imageFocusedAlien.onload = cuiRepaint;
        imageGrabbedOnceAlien.src = "alien_lipbite.png";
        imageGrabbedOnceAlien.onload = cuiRepaint;
        imageGrabbedTwiceAlien.src = "alien_smiley.png";
        imageGrabbedTwiceAlien.onload = cuiRepaint;

        // set defaults for all pages
        cuiBackgroundFillStyle = "#00A000";

        // initialize cui2d and start with myPage
        cuiInit(myPage);
      }

      // create images for the smiley
      var imageNormalAlien = new Image();
      var imageFocusedAlien = new Image();
      var imageGrabbedOnceAlien = new Image();
      var imageGrabbedTwiceAlien = new Image();

      // create draggable objects
      var transformable0 = new cuiTransformable();
      var transformable1 = new cuiTransformable();

      // create a page
      var myPage = new cuiPage(400, 300, myPageProcess);

      // a function to repaint the canvas and return false (if null == event)
      // or to process user events (if null != event) and return true
      // if the event has been processed
      function myPageProcess(event) {

        // draw and react to transformables
        if (transformable0.process(event, 50, 100, 200, 200, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien, imageGrabbedTwiceAlien,
          cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers)) {
          transformable0.scale = 1.0; // always reset scale (i.e. don't scale)
          return true;
        }

        if (transformable1.process(event, 250, 100, 200, 200, null,
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien, imageGrabbedTwiceAlien,
          cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers)) {
          transformable1.rotation = 0.0; // always reset rotation (i.e. dont' rotate)
          return true;
        }

       // repaint this page?
        if (null == event) {
          // background
          cuiContext.fillStyle = "#F0F0F0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }

        return false; // event has not been processed
      }
    </script>
  </head>

  <body bgcolor="#000000" onload="init()"
    style="-webkit-user-drag:none; -webkit-user-select:none; ">
    <span style="color:white;">A canvas element cannot be displayed.</span>
  </body>
</html>

Using Transformable Objects edit

Similarly to draggable object, a global variable should be defined for each transformable object. In the example, this looks like this:

      // create draggable objects
      var transformable0 = new cuiTransformable();
      var transformable1 = new cuiTransformable();

The transformable objects are then processed in the page's process functions:

        // draw and react to transformables
        if (transformable0.process(event, 50, 100, 200, 200, null, 
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien, imageGrabbedTwiceAlien, 
          cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers)) {
          transformable0.scale = 1.0; // always reset scale (i.e. don't scale)
          return true;
        }
 
        if (transformable1.process(event, 250, 100, 200, 200, null, 
          imageNormalAlien, imageFocusedAlien, imageGrabbedOnceAlien, imageGrabbedTwiceAlien, 
          cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers)) {
          transformable1.rotation = 0.0; // always reset rotation (i.e. dont' rotate)
          return true;
        }

The last argument of the process function (here it's cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers) specifies the available forms of interaction. To support multiple forms of interaction, the constants have to be combined with a bitwise-or as in the example. Other useful constants are cuiConstants.isDraggableWithTwoFingers, cuiConstants.isRotatableWithTwoFingers and cuiConstants.isUniformlyScalableWithTwoFingers. Deactivating rotation and scaling with these flags is not as flexible as the method in the example; however, it avoids some translational movement that comes with rotation and scaling gestures.

The function process returns true whenever the object processed the event. In these cases, additional actions should be taken. Here, the scaling of the first object is deactivated by always setting it to 1.0. Analogously, the rotation of the second object is deactivated by always setting the rotation angle to 0 degrees.

Of course, there are many more possibilities of reacting to the transformation of objects; e.g., restricting translationX and translationY.

Implementation of Transformable Objects edit

Due to the different transformations and the use of two touch points for the gesture, transformable objects require considerably more properties than draggable objects:

/**
 * @class cuiTransformable
 * @classdesc Transformable objects can be translated, rotated, and scaled with one- and 
 * two-finger gestures.
 *
 * @desc Create a new cuiTransformable.
 */
function cuiTransformable() {
  /** 
   * Clockwise rotation angle in degrees by which the object has been rotated. 
   * @member {number} cuiTransformable.rotation 
   */
  this.rotation = 0; 
  /** 
   * Scaling factor by which the object has been magnified. 
   * @member {number} cuiTransformable.scale 
   */
  this.scale = 1; 
  /** 
   * Difference in x coordinate by which the centre of the transformable has been moved relative to its
   * initial position (specified by x + 0.5 * width with the arguments of {@link cuiTransformable#process}). 
   * @member {number} cuiTransformable.translationX 
   */
  this.translationX = 0;
  /** 
   * Difference in y coordinate by which the centre of the transformable has been moved relative to its
   * initial position (specified by y + 0.5 * height with the arguments of {@link cuiTransformable#process}). 
   * @member {number} cuiTransformable.translationY 
   */
  this.translationY = 0;
  /** 
   * Flag specifying whether a mouse button or first finger is inside the object's rectangle.
   * @member {boolean} cuiTransformable.isPointerInside0
   */
  this.isPointerInside0 = false;
  /** 
   * Flag specifying whether a mouse button or first finger is pushing the object or has been
   * pushing the object and is still held down (but may have moved outside the object's    
   * rectangle). 
   * @member {boolean} cuiTransformable.isPointerDown0 
   */
  this.isPointerDown0 = false; 
  /** 
   * Flag specifying whether a second finger is pushing the object or has been
   * pushing the object and is still held down (but may have moved outside the object's    
   * rectangle). 
   * @member {boolean} cuiTransformable.isPointerDown1 
   */
  this.isPointerDown1 = false; 
  this.hasTriggeredClick = false; // click event has been triggered?
  this.hasTriggeredDoubleClick = false; // double click event has been triggered?
  this.hasTriggeredHold0 = false; // hold event has been triggered for first pointer?
  this.hasTriggeredHold1 = false; // hold event has been triggered for second pointer?
  /** 
   * Flag to specify whether to process events even if they are outside of the rectangle.
   * If true, it will consume many more events and therefore should only be used for background objects.
   * @member {boolean} cuiTransformable.isProcessingOuterEvents
   */
  this.isProcessingOuterEvents = false;
  
  this.timeDown0 = 0; // time in milliseconds after January 1, 1970 when the first pointer went down
  this.timeDown1 = 0; // time in milliseconds after January 1, 1970 when the second pointer went down
  this.identifier0 = -1; // identifier of the first touch point (-1 for mouse)
  this.identifier1 = -1; // identifier of the second touch point (-1 for mouse)
  this.translationXDown = 0; // value of translationX when the pointer went down
  this.translationYDown = 0; // value of translationX when the pointer went down
  this.rotationDown = 0; // value of rotation when the pointer went down
  this.scaleDown = 0; // value of scale when the pointer went down
  this.eventXDown0 = 0; // x coordinate of the event when the first pointer went down 
  this.eventYDown0 = 0; // y coordinate of the event when the first pointer went down 
  this.eventXDown1 = 0; // x coordinate of the event when the second pointer went down 
  this.eventYDown1 = 0; // y coordinate of the event when the second pointer went down 
  this.eventX0 = 0; // current x coordinate of the first pointer 
  this.eventY0 = 0; // current Y coordinate of the first pointer 
  this.eventX1 = 0; // current x coordinate of the second pointer 
  this.eventY1 = 0; // current y coordinate of the second pointer 
};

/**
 * Returns whether the transformable has just been clicked. 
 * @returns {boolean} True if the draggable has been clicked, false otherwise.
 */
cuiTransformable.prototype.isClicked = function() {
  return this.hasTriggeredClick;
}

/** 
 * Determine whether the button has just been double clicked. 
 * @returns {boolean} True if the button has been double clicked, false otherwise.
 */
cuiTransformable.prototype.isDoubleClicked = function() {
  return this.hasTriggeredDoubleClick;
}

/** 
 * Determine whether first pointer has just been held down longer than {@link cuiTimeUntilHold}. 
 * @returns {boolean} True if the first pointer has just been held down long enough, false otherwise.
 */
cuiTransformable.prototype.isHeldDown0 = function() {
  return this.hasTriggeredHold0;
}

/** 
 * Determine whether second pointer has just been held down longer than {@link cuiTimeUntilHold}. 
 * @returns {boolean} True if the second pointer has just been held down long enough, false otherwise.
 */
cuiTransformable.prototype.isHeldDown1 = function() {
  return this.hasTriggeredHold1;
}

/** 
 * Either process the event (if event != null) and return true if the event has been processed, 
 * or draw the appropriate image for the object state in the rectangle 
 * with a text string on top of it (if event == null) and return false.
 * This function is usually called by {@link cuiPage.process} of a {@link cuiPage}.
 * @param {Object} event - An object describing a user event by its "type", coordinates in 
 * page coordinates ("eventX" and "eventY"), an "identifier" for touch events, and optionally
 * "buttons" to specify which mouse buttons are depressed. If null, the function should
 * redraw the object.
 * @param {number} x - The x coordinate of the top, left corner of the object's rectangle.
 * @param {number} y - The y coordinate of the top, left corner of the object's rectangle.
 * @param {number} width - The width of the object's rectangle.
 * @param {number} height - The height of the object's rectangle.
 * @param {string} text - A text that is written at the center of the rectangle. (May be null).
 * @param {Object} imageNormal - An image to be drawn inside the object's rectangle if there
 * are no user interactions. (May be null.)
 * @param {Object} imageFocused - An image to be drawn inside the object's rectangle if the
 * mouse hovers over the object's rectangle or a touch point moves into it. (May be null.)
 * @param {Object} imagePressed0 - An image to be drawn inside the object's rectangle if a
 * mouse button is pushed or the object is touched once. (May be null.)
 * @param {Object} imagePressed1 - An image to be drawn inside the object's rectangle if a
 * mouse button is pushed or the object is touched twice. (May be null.)
 * @param {number} interactionBits - The forms of interaction, either {@link cuiConstants.none} 
 * or a bitwise-or of other constants in {@link cuiConstants}, e.g. 
 * cuiConstants.isDraggableWithOneFinger | cuiConstants.isTransformableWithTwoFingers.
 * @returns {boolean} True if event != null and the event has been processed (implying that 
 * no other GUI elements should process it). False otherwise.
 */ 
cuiTransformable.prototype.process = function (event, x, y, width, height, 
  text, imageNormal, imageFocused, imagePressed0, imagePressed1, interactionBits) {
 
  if (null == event) {
    // choose appropriate image
    var image = imageNormal;
    if (this.isPointerDown1) {
      image = imagePressed1;
    } 
    else if (this.isPointerDown0) {
      image = imagePressed0;
    }
    else if (this.isPointerInside0) {
      image = imageFocused;
    }

    // transform and draw object
    cuiContext.save();
    cuiContext.translate(this.translationX, this.translationY);
    cuiContext.translate(x + 0.5 * width, y + 0.5 * height);
    cuiContext.rotate(this.rotation * Math.PI / 180.0);
    cuiContext.scale(this.scale, this.scale);
    cuiContext.translate(-x - 0.5 * width, -y - 0.5 * height);
 
    if (null != text) {
      cuiContext.fillText(text, x, y, width, height);
    }
    if (null != image) {
      cuiContext.drawImage(image, x, y, width, height);
    } 
    cuiContext.restore();   

    return false;       
  }
 
  // check point of event
  var isIn = false;       
  var mappedX = event.eventX - this.translationX;
  var mappedY = event.eventY - this.translationY;
  mappedX = mappedX - x - 0.5 * width;
  mappedY = mappedY - y - 0.5 * height;
  var angle = -this.rotation * Math.PI / 180.0;
  var tempX = Math.cos(angle) * mappedX - Math.sin(angle) * mappedY;
  mappedY = Math.sin(angle) * mappedX  + Math.cos(angle) * mappedY;
  mappedX = tempX / this.scale;
  mappedY = mappedY / this.scale;
  mappedX = mappedX + x + 0.5 * width;
  mappedY = mappedY + y + 0.5 * height;
  if ((x <= mappedX && mappedX < x + width && y <= mappedY && mappedY < y + height) || 
    this.isProcessingOuterEvents) {
    isIn = true;
  }
 
  // clear event notifications (they are only set once and need to be cleared afterwards)
  this.hasTriggeredClick = false;
  this.hasTriggeredDoubleClick = false;
  this.hasTriggeredHold0 = false;
  this.hasTriggeredHold1 = false;
 
  // process double click events
  if ("dblclick" == event.type) {
    this.hasTriggeredDoubleClick = isIn;
    return isIn;
  }
  
  // process our hold events
  if ("mousehold" == event.type) {
    if (event.timeDown == this.timeDown0 && event.identifier == this.identifier0 && 
      this.isPointerDown0) {
      this.hasTriggeredHold0 = true;
      return true;
    }
    return false;
  }
  if ("mousehold" == event.type) {
    if (event.timeDown == this.timeDown1 && event.identifier == this.identifier1 && 
      this.isPointerDown1) {
      this.hasTriggeredHold1 = true;
      return true;
    }
    return false;
  }
  
  // process wheel events
  if ("wheel" == event.type || "mousewheel" == event.type) {
    if (!(cuiConstants.isUniformlyScalableWithTwoFingers & interactionBits)) {
      return false;
    }
    if (isIn) {
      // compute new x and y based on the motion of the point under the mouse
      
      // first compute the point that is mapped to the point under the mouse
      var fixpointX = event.eventX;
      var fixpointY = event.eventY;
      var mappedX = fixpointX - this.translationX - x - 0.5 * width;
      var mappedY = fixpointY - this.translationY - y - 0.5 * height;
      var angle = -this.rotation * Math.PI / 180.0;
      fixpointX = (Math.cos(angle) * mappedX - Math.sin(angle) * mappedY) / this.scale
        + x + 0.5 * width;
      fixpointY = (Math.sin(angle) * mappedX  + Math.cos(angle) * mappedY) / this.scale
        + y + 0.5 * height;
       
      // change scale 
      var delta;
      if ("mousewheel" == event.type) {
        delta = -event.wheelDelta / 30.0;
      }
      else {
        delta = event.deltaY;
      }
      this.scale = this.scale * Math.pow(2.0, -0.025 * delta); 

      // now see where this fixpoint is mapped to with the current transformation
      mappedX = fixpointX - x - 0.5 * width;
      mappedY = fixpointY - y - 0.5 * height;
      angle = this.rotation * Math.PI / 180.0;
      var tempX = this.scale * (Math.cos(angle) * mappedX - Math.sin(angle) * mappedY);
      mappedY = this.scale * (Math.sin(angle) * mappedX  + Math.cos(angle) * mappedY); 
      mappedX = tempX + x + 0.5 * width + this.translationX;
      mappedY = mappedY + y + 0.5 * height + this.translationY;
      
      // (x,y) should be at the position of the mouse; 
      // we change the transformation such that it ends up there
      
      this.translationX = this.translationX + event.eventX - mappedX;
      this.translationY = this.translationY + event.eventY - mappedY;
      
      cuiRepaint();
    }
    return isIn; 
  }

  // ignore mouse or touch points that are not the tracked point (apart from mousedown and touchstart)
  if ((this.isPointerInside0 || this.isPointerDown0)  && !this.isPointerDown1) {  
    if ("touchend" == event.type || "touchmove" == event.type || "touchcancel" == event.type) {
      if (event.identifier != this.identifier0) {
        return false; // ignore all other touch points except "touchstart" events
      }
    } 
    else if (("mousemove" == event.type || "mouseup" == event.type) && this.identifier0 >= 0) {
      return false; // ignore mouse (except mousedown) if we are tracking a touch point
    }
  }
  if (this.isPointerDown0 && this.isPointerDown1) {
    if ("touchend" == event.type || "touchmove" == event.type || "touchcancel" == event.type) {
      if (event.identifier != this.identifier0 && 
        event.identifier != this.identifier1) {
        return false; // ignore all other touch points except "touchstart" events
      }
    }
    else if (("mousemove" == event.type || "mouseup" == event.type) && 
      (this.identifier0 >= 0 && this.identifier1 >= 0)) {
      return false; // ignore mouse (except mousedown) if we are tracking a touch point    
    }
  }

  // state changes
  if (!this.isPointerInside0 && !this.isPointerDown0 && !this.isPointerDown1) { // passive object state
    if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) { // add 0th point
      this.isPointerDown0 = true;
      this.isPointerInside0 = true;
      if ("touchstart" == event.type) {
        this.identifier0 = event.identifier;
      } 
      else {
        this.identifier0 = -1; // mouse 
      }    
      this.timeDown0 = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier0, this.timeDown0); 
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale;
      this.eventXDown0 = event.eventX;
      this.eventYDown0 = event.eventY;
      this.eventX0 = event.eventX;
      this.eventY0 = event.eventY;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("mousemove" == event.type || "mouseup" == event.type || 
      "touchmove" == event.type)) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = true;
      if ("touchmove" == event.type) {
        this.identifier0 = event.identifier;
      } 
      else {
        this.identifier0 = -1; // mouse 
      }    
      cuiRepaint();
      return true;
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerInside0 && !this.isPointerDown0 && !this.isPointerDown1) { // focused object state (not pushed yet) 
    if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) { // add 0th point
      this.isPointerDown0 = true;
      this.isPointerInside0 = true;
      if ("touchstart" == event.type) {
        this.identifier0 = event.identifier;
      } 
      else {
        this.identifier0 = -1; // mouse 
      }    
      this.timeDown0 = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier0, this.timeDown0); 
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale;
      this.eventXDown0 = event.eventX;
      this.eventYDown0 = event.eventY;
      this.eventX0 = event.eventX;
      this.eventY0 = event.eventY;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("touchend" == event.type || "touchcancel" == event.type)) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = false;
      cuiRepaint();
      return true; 
    }
    else if (!isIn && ("touchmove" == event.type || "touchend" == event.type || 
      "touchcancel" == event.type || "mousemove" == event.type || "mouseup" == event.type)) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = false;
      cuiRepaint();
      return false; // none of our business
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerDown0 && !this.isPointerDown1) { // object grabbed once 
    if (isIn && this.identifier0 < 0 && "mousedown" == event.type) { 
      // replace 0th mouse point
      this.identifier0 = -1; // mouse down
      this.isPointerDown0 = true;
      this.timeDown0 = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier0, this.timeDown0); 
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale;
      this.eventXDown0 = event.eventX;
      this.eventYDown0 = event.eventY;
      this.eventX0 = event.eventX;
      this.eventY0 = event.eventY;
      cuiRepaint();
      return true;
    }
    else if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) { 
      // add 1st touch point
      this.isPointerDown1 = true;
      if ("touchstart" == event.type) {
        this.identifier1 = event.identifier;
      } 
      else {
        this.identifier1 = -1; // mouse 
      }    
      this.timeDown1 = (new Date()).getTime();
      setTimeout(cuiSendHoldEvent, cuiTimeUntilHold, event.clientX, event.clientY, this.identifier1, this.timeDown1); 
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale;
      this.eventXDown0 = this.eventX0;
      this.eventYDown0 = this.eventY0;
      this.eventXDown1 = event.eventX;
      this.eventYDown1 = event.eventY;
      this.eventX1 = event.eventX;
      this.eventY1 = event.eventY;
      cuiRepaint();
      return true;
    } 
    else if ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons)) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = isIn;
      this.identifier0 = -1; // mouse 
      if (isIn) {
        this.hasTriggeredClick = true;
      }
      cuiRepaint();
      return true; 
    }
    else if ("touchend" == event.type) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = false;
      if (isIn) {
        this.hasTriggeredClick = true;
      }
      cuiRepaint();
      return true; 
    }
    else if ("touchcancel" == event.type) { 
      this.isPointerDown0 = false;
      this.isPointerInside0 = false;
      cuiRepaint();
      return true; 
    }
    else if ("touchmove" == event.type || ("mousemove" == event.type)) {
      this.isPointerInside0 = isIn;
      this.eventX0 = event.eventX;
      this.eventY0 = event.eventY;
      if (cuiConstants.isDraggableWithOneFinger & interactionBits) {
        this.translationX = this.translationXDown + (this.eventX0 - this.eventXDown0);
        this.translationY = this.translationYDown + (this.eventY0 - this.eventYDown0);
      }
      cuiRepaint();
      return true; 
    }
    else if (!isIn && (("mousedown" == event.type && this.identifier0 < 0) ||
      ("touchstart" == event.type && this.identifier0 == event.identifier))) {
      this.isPointerDown0 = false;
      this.isPointerInside0 = false;
      cuiRepaint();
      return false; // none of our business
    }
    else {
      return false; // none of our business
    }
  }
  else if (this.isPointerDown1) { // two pointers down
    if (("mouseup" == event.type && this.identifier0 < 0) || 
      (("touchend" == event.type || "touchcancel" == event.type) && 
      event.identifier == this.identifier0)) { // 0th point goes up
       // remove 0th point, replace by 1st 
      this.isPointerDown1 = false;
      this.isPointerDown0 = true;
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale; 
      this.eventXDown0 = this.eventX1;
      this.eventYDown0 = this.eventY1;
      this.eventX0 = this.eventX1;
      this.eventY0 = this.eventY1;
      this.identifier0 = this.identifier1;
      if (isIn) {
        this.hasTriggeredClick = true;
      }
      cuiRepaint();
      return true;
    }
    else if (("mouseup" == event.type && this.identifier1 < 0) || 
      (("touchend" == event.type || "touchcancel" == event.type) && 
      event.identifier == this.identifier1)) { // 1st point goes up
      // just remove 1st point
      this.isPointerDown1 = false;
      this.isPointerDown0 = true;
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale; 
      this.eventXDown0 = this.eventX0;
      this.eventYDown0 = this.eventY0;
      if (isIn) {
        this.hasTriggeredClick = true;
      }
      cuiRepaint();
      return true;
    } 
    else if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) { 
      // remove 0th point, replace by 1st, add new as 1stby removing the 0th point, 
      // the user has a way of getting rid of ghost points
      // which are no longer tracked (but which we still assume to be active)
      this.translationXDown = this.translationX;
      this.translationYDown = this.translationY;
      this.rotationDown = this.rotation;
      this.scaleDown = this.scale;
      this.eventXDown0 = this.eventX1;
      this.eventYDown0 = this.eventY1;
      this.eventX0 = this.eventX1;
      this.eventY0 = this.eventY1;
      this.identifier0 = this.identifier1;
      this.eventXDown1 = event.eventX;
      this.eventYDown1 = event.eventY;
      this.eventX1 = event.eventX;
      this.eventY1 = event.eventY;
      if ("touchstart" == event.type) {
        this.identifier1 = event.identifier;
      } 
      else {
        this.identifier1 = -1; // mouse 
      }    
      cuiRepaint();
      return true;
    } 
    else if ("touchmove" == event.type || ("mousemove" == event.type)) {
      // update dragging
      if (("mousemove" == event.type && this.identifier0 < 0) || 
        ("touchmove" == event.type && event.identifier == this.identifier0)) { 
        this.eventX0 = event.eventX;
        this.eventY0 = event.eventY;
      }
      else if (("mousemove" == event.type && this.identifier1 < 0) || 
        ("touchmove" == event.type && event.identifier == this.identifier1)) { 
        this.eventX1 = event.eventX;
        this.eventY1 = event.eventY;
      }
      else {
        return false; // we should not have gotten this event (see above for the filtering)
      }
 
      if (cuiConstants.isRotatableWithTwoFingers & interactionBits) {
        // compute new rotation
        this.rotation = this.rotationDown + 
          (Math.atan2(this.eventY1 - this.eventY0, 
          this.eventX1 - this.eventX0) - 
          Math.atan2(this.eventYDown1 - this.eventYDown0, 
          this.eventXDown1 - this.eventXDown0)
          ) * 180.0 / Math.PI;
        while (this.rotation >= 360.0) {
          this.rotation -= 360.0;
        }
        while (this.rotation < 0.0) {
          this.rotation += 360.0;
        }
      }

      if (cuiConstants.isScalableWithTwoFingers & interactionBits) {
        // compute new scale
        var diffPointsLength = 
          Math.sqrt((this.eventX0 - this.eventX1) * 
          (this.eventX0 - this.eventX1) +  
          (this.eventY0 - this.eventY1) * 
          (this.eventY0 - this.eventY1));
        var diffPointsLengthDown = 
          Math.sqrt((this.eventXDown0 - this.eventXDown1) * 
          (this.eventXDown0 - this.eventXDown1) +  
          (this.eventYDown0 - this.eventYDown1) * 
          (this.eventYDown0 - this.eventYDown1));
        this.scale = this.scaleDown * diffPointsLength / 
           diffPointsLengthDown;  
      }

      if (cuiConstants.isDraggableWithTwoFingers & interactionBits) {
        // compute new x and y based on the motion of the center between the two points
      
        // first compute the point that was mapped to the center between the two fingers when grabbed  
        var fixpointX = 0.5 * (this.eventXDown0 + this.eventXDown1);
        var fixpointY = 0.5 * (this.eventYDown0 + this.eventYDown1);
        var mappedX = fixpointX - this.translationXDown - x - 0.5 * width;
        var mappedY = fixpointY - this.translationYDown - y - 0.5 * height;
        var angle = -this.rotationDown * Math.PI / 180.0;
        fixpointX = (Math.cos(angle) * mappedX - Math.sin(angle) * mappedY) / this.scaleDown
          + x + 0.5 * width;
        fixpointY = (Math.sin(angle) * mappedX  + Math.cos(angle) * mappedY) / this.scaleDown
          + y + 0.5 * height;
       
        // now see where this fixpoint is mapped to with the current transformation
        mappedX = fixpointX - x - 0.5 * width;
        mappedY = fixpointY - y - 0.5 * height;
        angle = this.rotation * Math.PI / 180.0;
        var tempX = this.scale * (Math.cos(angle) * mappedX - Math.sin(angle) * mappedY);
        mappedY = this.scale * (Math.sin(angle) * mappedX  + Math.cos(angle) * mappedY); 
        mappedX = tempX + x + 0.5 * width + this.translationX;
        mappedY = mappedY + y + 0.5 * height + this.translationY;
      
        // (x,y) should be at the center between the two fingers; 
        // we change the transformation such that it ends up there
      
        this.translationX = this.translationX + 
          0.5 * (this.eventX0 + this.eventX1) - mappedX;
        this.translationY = this.translationY + 
          0.5 * (this.eventY0 + this.eventY1) - mappedY;
      }
      cuiRepaint();
      return true; 
    } 
    else {
      return false;
    }
  }
  // unreachable code
  return false;
}


< Canvas 2D Web Apps

Unless stated otherwise, all example source code on this page is granted to the public domain.