Canvas 2D Web Apps/Pages

This chapter shows how to set up multiple pages and allow the user to navigate between them with the help of responsive buttons.

The ExampleEdit

The example of this chapter (which is available online; also as downloadable version) allows users to navigate between three pages of different dimensions using four buttons. The pages are automatically scaled to fit the dimensions of the browser window or the screen of a mobile device. The following sections will discuss how to set up the pages. See the chapter on responsive buttons and previous chapters for discussions of 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
        imageNormalButton.src = "normal.png";
        imageNormalButton.onload = cuiRepaint;
        imageFocusedButton.src = "selected.png";
        imageFocusedButton.onload = cuiRepaint;
        imagePressedButton.src = "depressed.png";
        imagePressedButton.onload = cuiRepaint;
 
        // initialize and start cui2d
        cuiInit(firstPage);
      }
 
      // first page
 
      var firstPage = new cuiPage(400, 300, firstPageProcess);
      var button0 = new cuiButton();
      var imageNormalButton = new Image();
      var imageFocusedButton = new Image();
      var imagePressedButton = new Image();
 
      function firstPageProcess(event) {
        if (button0.process(event, 300, 50, 80, 50, "next",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = secondPage;
            cuiRepaint(); 
          }
          return true; 
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("First page using landcape format.", 200, 150);
          cuiContext.fillStyle = "#E0E0E0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }
        return false;  // event has not been processed
      }
 
      // second page
 
      var secondPage = new cuiPage(400, 400, secondPageProcess);
      var button1 = new cuiButton();
      var button2 = new cuiButton();
 
      function secondPageProcess(event) {
        if (button1.process(event, 20, 50, 120, 50, "previous",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = firstPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (button2.process(event, 300, 50, 80, 50, "next",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button2.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = thirdPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("Second page using square format.", 200, 200);
          cuiContext.fillStyle = "#FFF0E0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }
        return false;
      }
 
      // third page
 
      var thirdPage = new cuiPage(400, 533, thirdPageProcess);
      var button3 = new cuiButton();
 
      function thirdPageProcess(event) {
        if (button3.process(event, 20, 50, 120, 50, "previous",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button3.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = secondPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("Third page using portrait format.", 200, 266);
          cuiContext.fillStyle = "#FFE0F0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }    
        return false;
      }
 
    </script>
  </head>
 
  <body bgcolor="#000000" onload="init()" 
    style="-webkit-user-drag:none; -webkit-user-select:none; ">
    <font color="white">A canvas element cannot be displayed.</font>
  </body>
</html>

Defining Multiple PagesEdit

In order to implement multiple pages, we need to have one cuiPage object for each page. In the example, these are created this way:

      ...
      var firstPage = new cuiPage(400, 300, firstPageProcess);
      ...
      var secondPage = new cuiPage(400, 400, secondPageProcess);
      ...
      var thirdPage = new cuiPage(400, 533, thirdPageProcess);
      ...

Each constructor call defines the width and height of the page and the page's process function. The first process function looks like this:

      function firstPageProcess(event) {
        if (button0.process(event, 300, 50, 80, 50, "next",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = secondPage;
            cuiRepaint(); 
          }
          return true; 
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("First page using landcape format.", 200, 150);
          cuiContext.fillStyle = "#E0E0E0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }
        return false;  // event has not been processed
      }

It checks whether button0 has processed the event and whether the button was clicked (with button0.isClicked()). If that's the case, it sets the global variable cuiIgnoreEventEnds to the current time (in milliseconds since January 1, 1970) plus 50 milliseconds in order to ignore all events for the next 50 milliseconds. This is useful because the current user interactions should not be applied to the next page, which is set by assigning another cuiPage to the global variable cuiCurrentPage. Lastly, the new page is painted by calling cuiRepaint().

Otherwise, if the button hasn't been clicked, the page's background is rendered if event is null.

The process functions of the other two pages work similarly, except that the second page has two buttons:

      function secondPageProcess(event) {
        if (button1.process(event, 20, 50, 120, 50, "previous",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = firstPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (button2.process(event, 300, 50, 80, 50, "next",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button2.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = thirdPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("Second page using square format.", 200, 200);
          cuiContext.fillStyle = "#FFF0E0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }
        return false;
      }

And the third page with one button:

      function thirdPageProcess(event) {
        if (button3.process(event, 20, 50, 120, 50, "previous",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button3.isClicked()) {
            cuiIgnoreEventsEnd = (new Date()).getTime() + 50; 
                // ignore events for 50 milliseconds
            cuiCurrentPage = secondPage;
            cuiRepaint(); 
          }
          return true;
        }
        if (null == event) {
          // draw background
          cuiContext.fillText("Third page using portrait format.", 200, 266);
          cuiContext.fillStyle = "#FFE0F0";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }    
        return false;
      }

Implementation of cuiPageEdit

The constructor for cuiPage objects is defined as follows:

/**
 * Pages are the top-level structure of a cui2d user interface: There is always exactly one page visible. 
 * (In the future there might be a hierarchy of pages visible but then there is always one root page.)
 * @typedef cuiPage
 */
 
/**
 * Creates a new cuiPage of specified width and height with the specified process(event) function
 * to process an event (with process(event) which should return true to indicate that the event has
 * been processed and therefore to prevent the default gestures for manipulating pages) and 
 * to repaint the page (with process(null) which should always return false).
 * Each page has a coordinate system with the origin in the top, left corner and x coordinates between
 * 0 and width, and y coordinates between 0 and height. 
 * @constructor
 */
function cuiPage(width, height, process) {
  this.width = width;
  this.height = height;
  this.process = process;
  this.isDraggableWithOneFinger = true; // can be disallowed by setting it to false
  this.view = new cuiTransformable(); // the page transformed by the user (set by cuiProcess())
}

There is only one method defined for cuiPages, which is only relevant for animated transitions between pages; see the chapter on transitions.

Note that each page uses a cuiTransformable object (called view) for its transformation. This is applied in the cuiProcess function (which also calls the page's user-defined process function). The function is relatively complex since it has to scale the page optimally by taking the page's dimensions and the dimensions of the screen into account. Furthermore, it has to apply the transformation of the transformable object view. And then it has to apply the inverse transformation to event points such that they are correctly transformed.

/** 
 * Either process the event (if event != null) or repaint the canvas (if event == null). 
 */
function cuiProcess(event) {
  // ignore events if necessary
  if (null != event && cuiIgnoringEventsEnd > 0) {
    if ((new Date()).getTime() < cuiIgnoringEventsEnd) {
      return;
    }
  }
 
  // clear repaint flag
  if (null == event) {
    cuiCanvasNeedsRepaint = false;
  }
 
  // determine initial scale and position for the page to fit it into the window
  var scaleFactor = 1.0;
  var offsetX = 0.0;
  var offsetY = 0.0;
  if (window.innerWidth / cuiCurrentPage.width < window.innerHeight / cuiCurrentPage.height) {
    // required X scaling is smaller: use it
    scaleFactor = window.innerWidth / cuiCurrentPage.width;
    offsetX = 0.0; // X is scaled for full window
    offsetY = 0.5 * (window.innerHeight - cuiCurrentPage.height * scaleFactor);
      // scaling is too small for Y: offset to center content
  }
  else { // required Y scaling is smaller: use it
    scaleFactor = window.innerHeight / cuiCurrentPage.height;
    offsetX = 0.5 * (window.innerWidth - cuiCurrentPage.width * scaleFactor);
      // scaling is too small for X: offset to center content
    offsetY = 0.0;
  }
 
  // adapt coordinates of event 
  if (null != event) {
    // transformation in cuiCurrentPage.view
    var mappedX = event.clientX - cuiCurrentPage.view.translationX;
    var mappedY = event.clientY - cuiCurrentPage.view.translationY;
    mappedX = mappedX - offsetX - 0.5 * cuiCurrentPage.width * scaleFactor;
    mappedY = mappedY - offsetY - 0.5 * cuiCurrentPage.height * scaleFactor;
    var angle = -cuiCurrentPage.view.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 / cuiCurrentPage.view.scale;
    mappedY = mappedY / cuiCurrentPage.view.scale;
    mappedX = mappedX + offsetX + 0.5 * cuiCurrentPage.width * scaleFactor;
    mappedY = mappedY + offsetY + 0.5 * cuiCurrentPage.height * scaleFactor;
    // initial transformation for fitting the page into the window
    event.eventX = (mappedX - offsetX) / scaleFactor;
    event.eventY = (mappedY - offsetY) / scaleFactor;
  }
 
  // initialize drawing
  if (null == event) {
    cuiCanvas.width = window.innerWidth;
    cuiCanvas.height = window.innerHeight;
      // The following line is not necessary because we set the canvas size: 
      //   cuiContext.clearRect(0, 0, cuiCanvas.width, cuiCanvas.height);
      // Some people recommend to avoid setting the canvas size every frame, 
      // but I had trouble with rendering a transition effect on Firefox without it.
 
    // transformation in cuiCurrentPage.view
    cuiContext.setTransform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
    cuiContext.translate(cuiCurrentPage.view.translationX, cuiCurrentPage.view.translationY);
    cuiContext.translate(offsetX + 0.5 * cuiCurrentPage.width * scaleFactor, 
      offsetY + 0.5 * cuiCurrentPage.height * scaleFactor);
    cuiContext.rotate(cuiCurrentPage.view.rotation * Math.PI / 180.0);
    cuiContext.scale(cuiCurrentPage.view.scale, cuiCurrentPage.view.scale);
    cuiContext.translate(-offsetX - 0.5 * cuiCurrentPage.width * scaleFactor, 
      -offsetY - 0.5 * cuiCurrentPage.height * scaleFactor);
    // initial transformation for fitting the page into the window
    cuiContext.translate(offsetX, offsetY);
    cuiContext.scale(scaleFactor, scaleFactor);
 
    cuiContext.globalCompositeOperation = "destination-over";
    cuiContext.globalAlpha = 1.0;
    cuiContext.font = cuiDefaultFont;
    cuiContext.textAlign = cuiDefaultTextAlign;
    cuiContext.textBaseline = cuiDefaultTextBaseline;
    cuiContext.fillStyle = cuiDefaultFillStyle;
  }
 
  if (!cuiCurrentPage.process(event) && cuiCurrentPage != cuiPageForTransitions && null != event) { 
    // event hasn't been processed, not a transition, and we have an event?
    event.eventX = event.clientX; // we don't need any transformation here because the initial ...
    event.eventY = event.clientY; // ... transformation is applied to the arguments of ... 
      // ... view.process() and the transformation in view is applied internally in view.process()
    if (cuiCurrentPage.view.process(event, offsetX, offsetY, cuiCurrentPage.width * scaleFactor, 
      cuiCurrentPage.height * scaleFactor,
      null, null, null, null, null, cuiCurrentPage.isDraggableWithOneFinger)) {
      // limit translation such that users don't lose the page 
      if (cuiCurrentPage.view.translationX < -0.5 * window.innerWidth * 
        Math.max(1.0, cuiCurrentPage.view.scale)) {
        cuiCurrentPage.view.translationX = -0.5 * window.innerWidth * 
        Math.max(1.0, cuiCurrentPage.view.scale);
      }
      if (cuiCurrentPage.view.translationX > 0.5 * window.innerWidth * 
        Math.max(1.0, cuiCurrentPage.view.scale)) {
        cuiCurrentPage.view.translationX = 0.5 * window.innerWidth * 
        Math.max(1.0, cuiCurrentPage.view.scale);
      }
      if (cuiCurrentPage.view.translationY < -0.5 * window.innerHeight * 
        Math.max(1.0, cuiCurrentPage.view.scale)) {
        cuiCurrentPage.view.translationY = -0.5 * window.innerHeight * 
        Math.max(1.0, cuiCurrentPage.view.scale);
      }
      if (cuiCurrentPage.view.translationY > 0.5 * window.innerHeight * 
        Math.max(1.0, cuiCurrentPage.view.scale)) {
        cuiCurrentPage.view.translationY = 0.5 * window.innerHeight * 
        Math.max(1.0, cuiCurrentPage.view.scale);
      }
    }
  }
 
  // draw background
  if (null == event) {
    cuiContext.globalCompositeOperation = "destination-over";
    cuiContext.globalAlpha = 1.0;
    cuiContext.setTransform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
    cuiContext.fillStyle = cuiBackgroundFillStyle;
    cuiContext.fillRect(0, 0, cuiCanvas.width, cuiCanvas.height);
  }
}

< Canvas 2D Web Apps

Unless stated otherwise, all example source code on this page is granted to the public domain.
Last modified on 18 August 2013, at 18:38