Initial commit
authordsc <david.schoonover@gmail.com>
Sat, 30 Oct 2010 23:54:53 +0000 (16:54 -0700)
committerdsc <david.schoonover@gmail.com>
Sat, 30 Oct 2010 23:54:53 +0000 (16:54 -0700)
98 files changed:
css/log.css [new file with mode: 0644]
css/lttl.css [new file with mode: 0644]
css/reset.css [new file with mode: 0644]
css/test.css [new file with mode: 0644]
index.php [new file with mode: 0644]
lib/cake.js [new file with mode: 0644]
lib/excanvas.js [new file with mode: 0644]
lib/excanvas.min.js [new file with mode: 0644]
lib/functional.js [new file with mode: 0644]
lib/gury.js [new file with mode: 0644]
lib/jquery-1.4.3.js [new file with mode: 0644]
lib/jquery-1.4.3.min.js [new file with mode: 0644]
lib/jquery.hotkeys.js [new file with mode: 0644]
lib/jquery.hotkeys.min.js [new file with mode: 0644]
lib/processing-0.9.7.js [new file with mode: 0644]
lib/processing-0.9.7.min.js [new file with mode: 0644]
lib/qunit/qunit.css [new file with mode: 0644]
lib/qunit/qunit.js [new file with mode: 0644]
lib/raphael-min.js [new file with mode: 0644]
lib/raphael.js [new file with mode: 0644]
lib/uki-0.3.8.js [new file with mode: 0644]
lib/uki-0.3.8.min.js [new file with mode: 0644]
lib/uki-more-0.3.8.min.js [new file with mode: 0644]
notes.md [new file with mode: 0644]
src/Y/_intro.js [new file with mode: 0644]
src/Y/_outro.js [new file with mode: 0644]
src/Y/alias.js [new file with mode: 0644]
src/Y/core.js [new file with mode: 0644]
src/Y/modules/event.js [new file with mode: 0644]
src/Y/modules/metaclass.js [new file with mode: 0644]
src/Y/modules/y.event.js [new file with mode: 0644]
src/Y/modules/y.json.js [new file with mode: 0644]
src/Y/modules/y.kv.js [new file with mode: 0644]
src/Y/modules/y.op.js [new file with mode: 0644]
src/Y/modules/y.plugin-arch.js [new file with mode: 0644]
src/Y/modules/y.polyevent.js [new file with mode: 0644]
src/Y/type.js [new file with mode: 0644]
src/Y/y-array.js [new file with mode: 0644]
src/Y/y-class.js [new file with mode: 0644]
src/Y/y-collection.js [new file with mode: 0644]
src/Y/y-core.js [new file with mode: 0644]
src/Y/y-function.js [new file with mode: 0644]
src/Y/y-number.js [new file with mode: 0644]
src/Y/y-object.js [new file with mode: 0644]
src/Y/y-op.js [new file with mode: 0644]
src/Y/y-string.js [new file with mode: 0644]
src/Y/y.js.php [new file with mode: 0644]
src/js/_intro.js [new file with mode: 0644]
src/js/_outro.js [new file with mode: 0644]
src/js/array.js [new file with mode: 0644]
src/js/core.js [new file with mode: 0644]
src/js/function.js [new file with mode: 0644]
src/js/js.js.php [new file with mode: 0644]
src/js/number.js [new file with mode: 0644]
src/js/object.js [new file with mode: 0644]
src/js/regexp.js [new file with mode: 0644]
src/js/string.js [new file with mode: 0644]
src/js/type.js [new file with mode: 0644]
src/lessly/bitgrid.js [new file with mode: 0644]
src/lessly/draw.js [new file with mode: 0644]
src/lessly/future.js [new file with mode: 0644]
src/lessly/log.js [new file with mode: 0644]
src/lessly/log.uki.js [new file with mode: 0644]
src/lessly/viewport.js [new file with mode: 0644]
src/portal/layer.js [new file with mode: 0644]
src/portal/path.js [new file with mode: 0644]
src/portal/portal.js [new file with mode: 0644]
src/portal/shape.js [new file with mode: 0644]
src/portal/simpleclass.js [new file with mode: 0644]
src/portal/util/cooldown.js [new file with mode: 0644]
src/portal/util/eventloop.js [new file with mode: 0644]
src/portal/util/loc.js [new file with mode: 0644]
src/portal/util/pointquadtree.js [new file with mode: 0644]
src/portal/util/quadtree.js [new file with mode: 0644]
src/portal/util/rbtree.js [new file with mode: 0644]
src/simoon/ability/ability.js [new file with mode: 0644]
src/simoon/ability/laser.js [new file with mode: 0644]
src/simoon/ability/projectile.js [new file with mode: 0644]
src/simoon/game/calc.js [new file with mode: 0644]
src/simoon/game/draw.js [new file with mode: 0644]
src/simoon/game/game.js [new file with mode: 0644]
src/simoon/game/map.js [new file with mode: 0644]
src/simoon/globals.js [new file with mode: 0644]
src/simoon/grid/grid.js [new file with mode: 0644]
src/simoon/player/player.js [new file with mode: 0644]
src/simoon/player/test-player.js [new file with mode: 0644]
src/simoon/simoon.js [new file with mode: 0644]
src/simoon/ui.js [new file with mode: 0644]
src/simoon/unit/agent.js [new file with mode: 0644]
src/simoon/unit/creep.js [new file with mode: 0644]
src/simoon/unit/tower.js [new file with mode: 0644]
src/simoon/unit/unit.js [new file with mode: 0644]
src/tanks/game.js [new file with mode: 0644]
src/tanks/lttl.js [new file with mode: 0644]
src/tanks/map.js [new file with mode: 0644]
src/tanks/tank.js [new file with mode: 0644]
src/tanks/ui.js [new file with mode: 0644]
src/u.js [new file with mode: 0644]

diff --git a/css/log.css b/css/log.css
new file mode 100644 (file)
index 0000000..897fee1
--- /dev/null
@@ -0,0 +1,12 @@
+#log {
+       position:fixed; top:0; right:0; width:50%; height:100%; overflow:hidden;
+       border-left:1px solid #bbb; background-color: #fff; color:#333; }
+#log h5 { font-size:1.25em; font-weight:normal; text-transform:uppercase; letter-spacing:0.16667em; }
+
+.log_meta { position:absolute; top:0; left:0; width:100%; z-index:2; background-color:#fff; }
+.log_meta > * { padding:0.3em; }
+       .log_menu { color:#fff; background-color:#bbb; }
+       .log_menu a { color:#fff; cursor:pointer; }
+.log_container { position:relative; width:100%; height:100%; overflow:auto; z-index:1; }
+       .log_item { padding:0.5em; font:normal normal 10pt/1 Menlo, Monospace; text-decoration:none; }
+
diff --git a/css/lttl.css b/css/lttl.css
new file mode 100644 (file)
index 0000000..d746829
--- /dev/null
@@ -0,0 +1,18 @@
+html, body { width:100%; height:100%; 
+    font-family:Geogrotesque,Helvetica; color:#fff; background-color:#3F3F3F; }
+body { font-family:Geogrotesque, Helvetica; font-size:12pt; }
+h1 { position:fixed; top:0; right:0; margin:0; padding:0; font-size:3em; color:#000; opacity:0.25; z-index:100; }
+ul, ol, li { list-style: none ! important; margin:0; padding:0; }
+
+.rounded { border-radius:1em; -moz-border-radius:1em; -webkit-border-radius:1em; }
+
+#viewport { position:relative; top:1em; width:500px; height:500px; margin:0 auto; }
+
+#howto { position:fixed; top:3em; right:1em; color:#BFBFBF; }
+
+#info { position:fixed; bottom:10px; right:10px; padding:0.5em; background-color:rgba(0,0,0, 0.1); color:#787878; }
+    #info label { display:block; float:left; width:3em; margin-right:0.5em; color:#787878; }
+    #info input { border:0; background-color:transparent; min-width:5em; width:5em; color:#5c5c5c; }
+
+#log { position:fixed; top:auto; bottom:0; left:0; width:100%; height:30%; border-top:1px solid #bbb; }
+
diff --git a/css/reset.css b/css/reset.css
new file mode 100644 (file)
index 0000000..dd5886b
--- /dev/null
@@ -0,0 +1,2 @@
+html{color:#000;background:#FFF;}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img{border:0;}address,caption,cite,code,dfn,em,strong,th,var{font-style:normal;font-weight:normal;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym {border:0;font-variant:normal;}sup {vertical-align:text-top;}sub {vertical-align:text-bottom;}input,textarea,select{font-family:inherit;font-size:inherit;font-weight:inherit;}input,textarea,select{*font-size:100%;}legend{color:#000;}
+h1{font-size:138.5%;}h2{font-size:123.1%;}h3{font-size:108%;}h1,h2,h3{margin:1em 0;}h1,h2,h3,h4,h5,h6,strong{font-weight:bold;}abbr,acronym{border-bottom:1px dotted #000;cursor:help;} em{font-style:italic;}blockquote,ul,ol,dl{margin:1em;}ol,ul,dl{margin-left:2em;}ol li{list-style:decimal outside;}ul li{list-style:disc outside;}dl dd{margin-left:1em;}th,td{padding:.5em;}th{font-weight:bold;text-align:center;}caption{margin-bottom:.5em;text-align:center;}p,fieldset,table,pre{margin-bottom:1em;}input[type=text],input[type=password],textarea{width:12.25em;*width:11.9em;}
\ No newline at end of file
diff --git a/css/test.css b/css/test.css
new file mode 100644 (file)
index 0000000..1c2d3e4
--- /dev/null
@@ -0,0 +1,4 @@
+html, body { width:100%; height:100%; 
+    font-family:Geogrotesque,Helvetica; color:#fff; background-color:#3F3F3F; }
+h1:not([id]) { position:fixed; top:-0.25em; right:-0.25em; margin:0; padding:0;
+    font-size:3em; color:#000; opacity:0.25; z-index:100; }
diff --git a/index.php b/index.php
new file mode 100644 (file)
index 0000000..8806461
--- /dev/null
+++ b/index.php
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>The Littlest Battletank</title>
+<link rel="stylesheet" href="css/reset.css" type="text/css" media="screen">
+<link rel="stylesheet" href="css/lttl.css" type="text/css" media="screen">
+</head>
+<body class="lttl tanks">
+
+
+<div id="viewport"></div>
+
+<h1>The Littlest Battletank</h1>
+
+<ul id="info" class="rounded">
+    <li id="state"></li>
+    <li><label for="fps">fps</label> <input id="fps" name="fps" value="" type="text"></li>
+    <li><label for="frame">frame</label> <input id="frame" name="frame" value="" type="text"></li>
+    <li><label for="agents">agents</label> <input id="agents" name="agents" value="" type="text"></li>
+    <li><label for="units">units</label> <input id="units" name="units" value="" type="text"></li>
+    <li><label for="bullets">bullets</label> <input id="bullets" name="bullets" value="" type="text"></li>
+</ul>
+
+<div id="log" style="display:none"></div>
+
+<div id="scripts">
+    <!--[if IE]><script type="text/javascript" src="lib/excanvas.min.js"></script><![endif]-->
+<?php
+$scripts = array(
+    "lib/jquery-1.4.3.js",
+    "lib/jquery.hotkeys.js",
+    
+    // "http://static.ukijs.org/pkg/0.3.8/uki.js",
+    // "http://static.ukijs.org/pkg/0.3.8/uki-more.js",
+    
+    "src/lessly/future.js",
+    
+    "src/Y/y.js.php",
+    "src/Y/modules/y.event.js",
+    
+    "src/portal/layer.js",
+    "src/portal/shape.js",
+    
+    // "src/portal/util/quadtree.js",
+    // "src/portal/util/rbtree.js",
+    // "src/portal/util/eventloop.js",
+    // "src/portal/util/cooldown.js",
+    // "src/portal/util/loc.js",
+    
+    "src/tanks/map.js",
+    "src/tanks/tank.js",
+    "src/tanks/game.js",
+    "src/tanks/ui.js",
+    
+    "src/tanks/lttl.js"
+);
+
+function js($src) {
+    echo "    <script src=\"$src\" type=\"text/javascript\"></script>\n";
+}
+
+foreach ($scripts as $s) js($s);
+?>
+</div>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/lib/cake.js b/lib/cake.js
new file mode 100644 (file)
index 0000000..e222e3c
--- /dev/null
@@ -0,0 +1,7657 @@
+/*
+CAKE - Canvas Animation Kit Experiment
+
+Copyright (C) 2007  Ilmari Heikkinen
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+
+/**
+  Delete the first instance of obj from the array.
+
+  @param obj The object to delete
+  @return true on success, false if array contains no instances of obj
+  @type boolean
+  @addon
+  */
+Array.prototype.deleteFirst = function(obj) {
+  for (var i=0; i<this.length; i++) {
+    if (this[i] == obj) {
+      this.splice(i,1)
+      return true
+    }
+  }
+  return false
+}
+
+Array.prototype.stableSort = function(cmp) {
+  // hack to work around Chrome's qsort
+  for(var i=0; i<this.length; i++) {
+    this[i].__arrayPos = i;
+  }
+  return this.sort(Array.__stableSorter(cmp));
+}
+Array.__stableSorter = function(cmp) {
+  return (function(c1, c2) {
+    var r = cmp(c1,c2);
+    if (!r) { // hack to work around Chrome's qsort
+      return c1.__arrayPos - c2.__arrayPos
+    }
+    return r;
+  });
+}
+
+/**
+  Compares two arrays for equality. Returns true if the arrays are equal.
+  */
+Array.prototype.equals = function(array) {
+  if (!array) return false
+  if (this.length != array.length) return false
+  for (var i=0; i<this.length; i++) {
+    var a = this[i]
+    var b = array[i]
+    if (a.equals && typeof(a.equals) == 'function') {
+      if (!a.equals(b)) return false
+    } else if (a != b) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+  Rotates the first element of an array to be the last element.
+  Rotates last element to be the first element when backToFront is true.
+
+  @param {boolean} backToFront Whether to move the last element to the front or not
+  @return The last element when backToFront is false, the first element when backToFront is true
+  @addon
+  */
+Array.prototype.rotate = function(backToFront) {
+  if (backToFront) {
+    this.unshift(this.pop())
+    return this[0]
+  } else {
+    this.push(this.shift())
+    return this[this.length-1]
+  }
+}
+/**
+  Returns a random element from the array.
+
+  @return A random element
+  @addon
+ */
+Array.prototype.pick = function() {
+  return this[Math.floor(Math.random()*this.length)]
+}
+
+Array.prototype.flatten = function() {
+  var a = []
+  for (var i=0; i<this.length; i++) {
+    var e = this[i]
+    if (e.flatten) {
+      var ef = e.flatten()
+      for (var j=0; j<ef.length; j++) {
+        a[a.length] = ef[j]
+      }
+    } else {
+      a[a.length] = e
+    }
+  }
+  return a
+}
+
+Array.prototype.take = function() {
+  var a = []
+  for (var i=0; i<this.length; i++) {
+    var e = []
+    for (var j=0; j<arguments.length; j++) {
+      e[j] = this[i][arguments[j]]
+    }
+    a[i] = e
+  }
+  return a
+}
+
+if (!Array.prototype.pluck) {
+  Array.prototype.pluck = function(key) {
+    var a = []
+    for (var i=0; i<this.length; i++) {
+      a[i] = this[i][key]
+    }
+    return a
+  }
+}
+
+Array.prototype.set = function(key, value) {
+  for (var i=0; i<this.length; i++) {
+    this[i][key] = value
+  }
+}
+
+Array.prototype.allWith = function() {
+  var a = []
+  topLoop:
+  for (var i=0; i<this.length; i++) {
+    var e = this[i]
+    for (var j=0; j<arguments.length; j++) {
+      if (!this[i][arguments[j]])
+        continue topLoop
+    }
+    a[a.length] = e
+  }
+  return a
+}
+
+// some common helper methods
+
+if (!Function.prototype.bind) {
+  /**
+    Creates a function that calls this function in the scope of the given
+    object.
+
+      var obj = { x: 'obj' }
+      var f = function() { return this.x }
+      window.x = 'window'
+      f()
+      // => 'window'
+      var g = f.bind(obj)
+      g()
+      // => 'obj'
+
+    @param object Object to bind this function to
+    @return Function bound to object
+    @addon
+    */
+  Function.prototype.bind = function(object) {
+    var t = this
+    return function() {
+      return t.apply(object, arguments)
+    }
+  }
+}
+
+if (!Array.prototype.last) {
+  /**
+    Returns the last element of the array.
+
+    @return The last element of the array
+    @addon
+    */
+  Array.prototype.last = function() {
+    return this[this.length-1]
+  }
+}
+if (!Array.prototype.indexOf) {
+  /**
+    Returns the index of obj if it is in the array.
+    Returns -1 otherwise.
+
+    @param obj The object to find from the array.
+    @return The index of obj or -1 if obj isn't in the array.
+    @addon
+    */
+  Array.prototype.indexOf = function(obj) {
+    for (var i=0; i<this.length; i++)
+      if (obj == this[i]) return i
+    return -1
+  }
+}
+if (!Array.prototype.includes) {
+  /**
+    Returns true if obj is in the array.
+    Returns false if it isn't.
+
+    @param obj The object to find from the array.
+    @return True if obj is in the array, false if it isn't
+    @addon
+    */
+  Array.prototype.includes = function(obj) {
+    return (this.indexOf(obj) >= 0);
+  }
+}
+/**
+  Iterate function f over each element of the array and return an array
+  of the return values.
+
+  @param f Function to apply to each element
+  @return An array of return values from applying f on each element of the array
+  @type Array
+  @addon
+  */
+Array.prototype.map = function(f) {
+  var na = new Array(this.length)
+  if (f)
+    for (var i=0; i<this.length; i++) na[i] = f(this[i], i, this)
+  else
+    for (var i=0; i<this.length; i++) na[i] = this[i]
+  return na
+}
+Array.prototype.forEach = function(f) {
+  for (var i=0; i<this.length; i++) f(this[i], i, this)
+}
+if (!Array.prototype.reduce) {
+  Array.prototype.reduce = function(f, s) {
+    var i = 0
+    if (arguments.length == 1) {
+      s = this[0]
+      i++
+    }
+    for(; i<this.length; i++) {
+      s = f(s, this[i], i, this)
+    }
+    return s
+  }
+}
+if (!Array.prototype.find) {
+  Array.prototype.find = function(f) {
+    for(var i=0; i<this.length; i++) {
+      if (f(this[i], i, this)) return this[i]
+    }
+  }
+}
+
+if (!String.prototype.capitalize) {
+  /**
+    Returns a copy of this string with the first character uppercased.
+
+    @return Capitalized version of the string
+    @type String
+    @addon
+    */
+  String.prototype.capitalize = function() {
+    return this.replace(/^./, this.slice(0,1).toUpperCase())
+  }
+}
+
+if (!String.prototype.escape) {
+  /**
+    Returns a version of the string that can be used as a string literal.
+
+    @return Copy of string enclosed in double-quotes, with double-quotes
+            inside string escaped.
+    @type String
+    @addon
+    */
+  String.prototype.escape = function() {
+    return '"' + this.replace(/"/g, '\\"') + '"'
+  }
+}
+if (!String.prototype.splice) {
+  String.prototype.splice = function(start, count, replacement) {
+    return this.slice(0,start) + replacement + this.slice(start+count)
+  }
+}
+if (!String.prototype.strip) {
+  /**
+    Returns a copy of the string with preceding and trailing whitespace
+    removed.
+
+    @return Copy of string sans surrounding whitespace.
+    @type String
+    @addon
+    */
+  String.prototype.strip = function() {
+    return this.replace(/^\s+|\s+$/g, '')
+  }
+}
+
+if (!window['$A']) {
+  /**
+    Creates a new array from an object with #length.
+    */
+  $A = function(obj) {
+    var a = new Array(obj.length)
+    for (var i=0; i<obj.length; i++)
+      a[i] = obj[i]
+    return a
+  }
+}
+
+if (!window['$']) {
+  $ = function(id) {
+    return document.getElementById(id)
+  }
+}
+
+if (!Math.sinh) {
+  /**
+    Returns the hyperbolic sine of x.
+
+    @param x The value for x
+    @return The hyperbolic sine of x
+    @addon
+    */
+  Math.sinh = function(x) {
+    return 0.5 * (Math.exp(x) - Math.exp(-x))
+  }
+  /**
+    Returns the inverse hyperbolic sine of x.
+
+    @param x The value for x
+    @return The inverse hyperbolic sine of x
+    @addon
+    */
+  Math.asinh = function(x) {
+    return Math.log(x + Math.sqrt(x*x + 1))
+  }
+}
+if (!Math.cosh) {
+  /**
+    Returns the hyperbolic cosine of x.
+
+    @param x The value for x
+    @return The hyperbolic cosine of x
+    @addon
+    */
+  Math.cosh = function(x) {
+    return 0.5 * (Math.exp(x) + Math.exp(-x))
+  }
+  /**
+    Returns the inverse hyperbolic cosine of x.
+
+    @param x The value for x
+    @return The inverse hyperbolic cosine of x
+    @addon
+    */
+  Math.acosh = function(x) {
+    return Math.log(x + Math.sqrt(x*x - 1))
+  }
+}
+
+/**
+  Creates and configures a DOM element.
+
+  The tag of the element is given by name.
+
+  If params is a string, it is used as the innerHTML of the created element.
+  If params is a DOM element, it is appended to the created element.
+  If params is an object, it is treated as a config object and merged
+  with the created element.
+
+  If params is a string or DOM element, the third argument is treated
+  as the config object.
+
+  Special attributes of the config object:
+    * content
+      - if content is a string, it is used as the innerHTML of the
+        created element
+      - if content is an element, it is appended to the created element
+    * style
+      - the style object is merged with the created element's style
+
+  @param {String} name The tag for the created element
+  @param params The content or config for the created element
+  @param config The config for the created element if params is content
+  @return The created DOM element
+  */
+E = function(name, params, config) {
+  var el = document.createElement(name)
+  if (params) {
+    if (typeof(params) == 'string') {
+      el.innerHTML = params
+      params = config
+    } else if (params.DOCUMENT_NODE) {
+      el.appendChild(params)
+      params = config
+    }
+    if (params) {
+      if (params.style) {
+        var style = params.style
+        params = Object.clone(params)
+        delete params.style
+        Object.forceExtend(el.style, style)
+      }
+      if (params.content) {
+        if (typeof(params.content) == 'string') {
+          el.appendChild(T(params.content))
+        } else {
+          el.appendChild(params.content)
+        }
+        params = Object.clone(params)
+        delete params.content
+      }
+      Object.forceExtend(el, params)
+    }
+  }
+  return el
+}
+E.append = function(node) {
+  for(var i=1; i<arguments.length; i++) {
+    if (typeof(arguments[i]) == 'string') {
+      node.appendChild(T(arguments[i]))
+    } else {
+      node.appendChild(arguments[i])
+    }
+  }
+}
+// Safari requires each canvas to have a unique id.
+E.lastCanvasId = 0
+/**
+  Creates and returns a canvas element with width w and height h.
+
+  @param {int} w The width for the canvas
+  @param {int} h The height for the canvas
+  @param config Optional config object to pass to E()
+  @return The created canvas element
+  */
+E.canvas = function(w,h,config) {
+  var id = 'canvas-uuid-' + E.lastCanvasId
+  E.lastCanvasId++
+  if (!config) config = {}
+  return E('canvas', Object.extend(config, {id: id, width: w, height: h}))
+}
+
+/**
+  Shortcut for document.createTextNode.
+
+  @param {String} text The text for the text node
+  @return The created text node
+  */
+T = function(text) {
+  return document.createTextNode(text)
+}
+
+/**
+  Merges the src object's attributes with the dst object, ignoring errors.
+
+  @param dst The destination object
+  @param src The source object
+  @return The dst object
+  @addon
+  */
+Object.forceExtend = function(dst, src) {
+  for (var i in src) {
+    try{ dst[i] = src[i] } catch(e) {}
+  }
+  return dst
+}
+// In case Object.extend isn't defined already, set it to Object.forceExtend.
+if (!Object.extend)
+  Object.extend = Object.forceExtend
+
+/**
+  Merges the src object's attributes with the dst object, preserving all dst
+  object's current attributes.
+
+  @param dst The destination object
+  @param src The source object
+  @return The dst object
+  @addon
+  */
+Object.conditionalExtend = function(dst, src) {
+  for (var i in src) {
+    if (dst[i] == null)
+      dst[i] = src[i]
+  }
+  return dst
+}
+
+/**
+  Creates and returns a shallow copy of the src object.
+
+  @param src The source object
+  @return A clone of the src object
+  @addon
+  */
+Object.clone = function(src) {
+  if (!src || src == true)
+    return src
+  switch (typeof(src)) {
+    case 'string':
+      return Object.extend(src+'', src)
+      break
+    case 'number':
+      return src
+      break
+    case 'function':
+      obj = eval(src.toSource())
+      return Object.extend(obj, src)
+      break
+    case 'object':
+      if (src instanceof Array) {
+        return Object.extend([], src)
+      } else {
+        return Object.extend({}, src)
+      }
+      break
+  }
+}
+
+/**
+  Creates and returns an Image object, with source URL set to src and
+  onload handler set to onload.
+
+  @param {String} src The source URL for the image
+  @param {Function} onload The onload handler for the image
+  @return The created Image object
+  @type {Image}
+  */
+Object.loadImage = function(src, onload) {
+  var img = new Image()
+  if (onload)
+    img.onload = onload
+  img.src = src
+  return img
+}
+
+/**
+  Returns true if image is fully loaded and ready for use.
+
+  @param image The image to check
+  @return Whether the image is loaded or not
+  @type {boolean}
+  @addon
+  */
+Object.isImageLoaded = function(image) {
+  if (image.tagName == 'CANVAS') return true
+  if (!image.complete) return false
+  if (image.naturalWidth == null) return true
+  return !!image.naturalWidth
+}
+
+/**
+  Sums two objects.
+  */
+Object.sum = function(a,b) {
+  if (a instanceof Array) {
+    if (b instanceof Array) {
+      var ab = []
+      for (var i=0; i<a.length; i++) {
+        ab[i] = a[i] + b[i]
+      }
+      return ab
+    } else {
+      return a.map(function(v){ return v + b })
+    }
+  } else if (b instanceof Array) {
+    return b.map(function(v){ return v + a })
+  } else {
+    return a + b
+  }
+}
+
+/**
+  Substracts b from a.
+  */
+Object.sub = function(a,b) {
+  if (a instanceof Array) {
+    if (b instanceof Array) {
+      var ab = []
+      for (var i=0; i<a.length; i++) {
+        ab[i] = a[i] - b[i]
+      }
+      return ab
+    } else {
+      return a.map(function(v){ return v - b })
+    }
+  } else if (b instanceof Array) {
+    return b.map(function(v){ return a - v })
+  } else {
+    return a - b
+  }
+}
+
+if (!window.Mouse) Mouse = {}
+/**
+  Returns the coordinates for a mouse event relative to element.
+  Element must be the target for the event.
+
+  @param element The element to compare against
+  @param event The mouse event
+  @return An object of form {x: relative_x, y: relative_y}
+  */
+Mouse.getRelativeCoords = function(element, event) {
+  var xy = {x:0, y:0}
+  var osl = 0
+  var ost = 0
+  var el = element
+  while (el) {
+    osl += el.offsetLeft
+    ost += el.offsetTop
+    el = el.offsetParent
+  }
+  xy.x = event.pageX - osl
+  xy.y = event.pageY - ost
+  return xy
+}
+
+Browser = (function(){
+  var ua = window.navigator.userAgent
+  var khtml = ua.match(/KHTML/)
+  var gecko = ua.match(/Gecko/)
+  var webkit = ua.match(/WebKit\/\d+/)
+  var ie = ua.match(/Explorer/)
+  if (khtml) return 'KHTML'
+  if (gecko) return 'Gecko'
+  if (webkit) return 'Webkit'
+  if (ie) return 'IE'
+  return 'UNKNOWN'
+})()
+
+
+Mouse.LEFT = 0
+Mouse.MIDDLE = 1
+Mouse.RIGHT = 2
+
+if (Browser == 'IE') {
+  Mouse.LEFT = 1
+  Mouse.MIDDLE = 4
+}
+
+
+/**
+  Klass is a function that returns a constructor function.
+
+  The constructor function calls #initialize with its arguments.
+
+  The parameters to Klass have their prototypes or themselves merged with the
+  constructor function's prototype.
+
+  Finally, the constructor function's prototype is merged with the constructor
+  function. So you can write Shape.getArea.call(this) instead of
+  Shape.prototype.getArea.call(this).
+
+  Shape = Klass({
+    getArea : function() {
+      raise('No area defined!')
+    }
+  })
+
+  Rectangle = Klass(Shape, {
+    initialize : function(x, y) {
+      this.x = x
+      this.y = y
+    },
+
+    getArea : function() {
+      return this.x * this.y
+    }
+  })
+
+  Square = Klass(Rectangle, {
+    initialize : function(s) {
+      Rectangle.initialize.call(this, s, s)
+    }
+  })
+
+  new Square(5).getArea()
+  //=> 25
+
+  @return Constructor object for the class
+  */
+Klass = function() {
+  var c = function() {
+    this.initialize.apply(this, arguments)
+  }
+  c.ancestors = $A(arguments)
+  c.prototype = {}
+  for(var i = 0; i<arguments.length; i++) {
+    var a = arguments[i]
+    if (a.prototype) {
+      Object.extend(c.prototype, a.prototype)
+    } else {
+      Object.extend(c.prototype, a)
+    }
+  }
+  Object.extend(c, c.prototype)
+  return c
+}
+
+
+
+Curves = {
+
+  angularDistance : function(a, b) {
+    var pi2 = Math.PI*2
+    var d = (b - a) % pi2
+    if (d > Math.PI) d -= pi2
+    if (d < -Math.PI) d += pi2
+    return d
+  },
+
+  linePoint : function(a, b, t) {
+    return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t]
+  },
+
+  quadraticPoint : function(a, b, c, t) {
+    // var d = this.linePoint(a,b,t)
+    // var e = this.linePoint(b,c,t)
+    // return this.linePoint(d,e,t)
+    var dx = a[0]+(b[0]-a[0])*t
+    var ex = b[0]+(c[0]-b[0])*t
+    var x = dx+(ex-dx)*t
+    var dy = a[1]+(b[1]-a[1])*t
+    var ey = b[1]+(c[1]-b[1])*t
+    var y = dy+(ey-dy)*t
+    return [x,y]
+  },
+
+  cubicPoint : function(a, b, c, d, t) {
+    var ax3 = a[0]*3
+    var bx3 = b[0]*3
+    var cx3 = c[0]*3
+    var ay3 = a[1]*3
+    var by3 = b[1]*3
+    var cy3 = c[1]*3
+    return [
+      a[0] + t*(bx3 - ax3 + t*(ax3-2*bx3+cx3 + t*(bx3-a[0]-cx3+d[0]))),
+      a[1] + t*(by3 - ay3 + t*(ay3-2*by3+cy3 + t*(by3-a[1]-cy3+d[1])))
+    ]
+  },
+
+  linearValue : function(a,b,t) {
+    return a + (b-a)*t
+  },
+
+  quadraticValue : function(a,b,c,t) {
+    var d = a + (b-a)*t
+    var e = b + (c-b)*t
+    return d + (e-d)*t
+  },
+
+  cubicValue : function(a,b,c,d,t) {
+    var a3 = a*3, b3 = b*3, c3 = c*3
+    return a + t*(b3 - a3 + t*(a3-2*b3+c3 + t*(b3-a-c3+d)))
+  },
+
+  catmullRomPoint : function (a,b,c,d, t) {
+    var af = ((-t+2)*t-1)*t*0.5
+    var bf = (((3*t-5)*t)*t+2)*0.5
+    var cf = ((-3*t+4)*t+1)*t*0.5
+    var df = ((t-1)*t*t)*0.5
+    return [
+      a[0]*af + b[0]*bf + c[0]*cf + d[0]*df,
+      a[1]*af + b[1]*bf + c[1]*cf + d[1]*df
+    ]
+  },
+
+  catmullRomAngle : function (a,b,c,d, t) {
+    var dx = 0.5 * (c[0] - a[0] + 2*t*(2*a[0] - 5*b[0] + 4*c[0] - d[0]) +
+             3*t*t*(3*b[0] + d[0] - a[0] - 3*c[0]))
+    var dy = 0.5 * (c[1] - a[1] + 2*t*(2*a[1] - 5*b[1] + 4*c[1] - d[1]) +
+             3*t*t*(3*b[1] + d[1] - a[1] - 3*c[1]))
+    return Math.atan2(dy, dx)
+  },
+
+  catmullRomPointAngle : function (a,b,c,d, t) {
+    var p = this.catmullRomPoint(a,b,c,d,t)
+    var a = this.catmullRomAngle(a,b,c,d,t)
+    return {point:p, angle:a}
+  },
+
+  lineAngle : function(a,b) {
+    return Math.atan2(b[1]-a[1], b[0]-a[0])
+  },
+
+  quadraticAngle : function(a,b,c,t) {
+    var d = this.linePoint(a,b,t)
+    var e = this.linePoint(b,c,t)
+    return this.lineAngle(d,e)
+  },
+
+  cubicAngle : function(a, b, c, d, t) {
+    var e = this.quadraticPoint(a,b,c,t)
+    var f = this.quadraticPoint(b,c,d,t)
+    return this.lineAngle(e,f)
+  },
+
+  lineLength : function(a,b) {
+    var x = (b[0]-a[0])
+    var y = (b[1]-a[1])
+    return Math.sqrt(x*x + y*y)
+  },
+
+  squareLineLength : function(a,b) {
+    var x = (b[0]-a[0])
+    var y = (b[1]-a[1])
+    return x*x + y*y
+  },
+
+  quadraticLength : function(a,b,c, error) {
+    var p1 = this.linePoint(a,b,2/3)
+    var p2 = this.linePoint(b,c,1/3)
+    return this.cubicLength(a,p1,p2,c, error)
+  },
+
+  cubicLength : (function() {
+    var bezsplit = function(v) {
+      var vtemp = [v.slice(0)]
+
+      for (var i=1; i < 4; i++) {
+        vtemp[i] = [[],[],[],[]]
+        for (var j=0; j < 4-i; j++) {
+          vtemp[i][j][0] = 0.5 * (vtemp[i-1][j][0] + vtemp[i-1][j+1][0])
+          vtemp[i][j][1] = 0.5 * (vtemp[i-1][j][1] + vtemp[i-1][j+1][1])
+        }
+      }
+      var left = []
+      var right = []
+      for (var j=0; j<4; j++) {
+        left[j] = vtemp[j][0]
+        right[j] = vtemp[3-j][j]
+      }
+      return [left, right]
+    }
+
+    var addifclose = function(v, error) {
+      var len = 0
+      for (var i=0; i < 3; i++) {
+        len += Curves.lineLength(v[i], v[i+1])
+      }
+      var chord = Curves.lineLength(v[0], v[3])
+      if ((len - chord) > error) {
+        var lr = bezsplit(v)
+        len = addifclose(lr[0], error) + addifclose(lr[1], error)
+      }
+      return len
+    }
+
+    return function(a,b,c,d, error) {
+      if (!error) error = 1
+      return addifclose([a,b,c,d], error)
+    }
+  })(),
+
+  quadraticLengthPointAngle : function(a,b,c,lt,error) {
+    var p1 = this.linePoint(a,b,2/3)
+    var p2 = this.linePoint(b,c,1/3)
+    return this.cubicLengthPointAngle(a,p1,p2,c, error)
+  },
+
+  cubicLengthPointAngle : function(a,b,c,d,lt,error) {
+    // this thing outright rapes the GC.
+    // how about not creating a billion arrays, hmm?
+    var len = this.cubicLength(a,b,c,d,error)
+    var point = a
+    var prevpoint = a
+    var lengths = []
+    var prevlensum = 0
+    var lensum = 0
+    var tl = lt*len
+    var segs = 20
+    var fac = 1/segs
+    for (var i=1; i<=segs; i++) { // FIXME get smarter
+      prevpoint = point
+      point = this.cubicPoint(a,b,c,d, fac*i)
+      prevlensum = lensum
+      lensum += this.lineLength(prevpoint, point)
+      if (lensum >= tl) {
+        if (lensum == prevlensum)
+          return {point: point, angle: this.lineAngle(a,b)}
+        var dl = lensum - tl
+        var dt = dl / (lensum-prevlensum)
+        return {point: this.linePoint(prevpoint, point, 1-dt),
+                angle: this.cubicAngle(a,b,c,d, fac*(i-dt)) }
+      }
+    }
+    return {point: d.slice(0), angle: this.lineAngle(c,d)}
+  }
+
+}
+
+
+
+/**
+  Color helper functions.
+  */
+Colors = {
+
+  /**
+    Converts an HSL color to its corresponding RGB color.
+
+    @param h Hue in degrees (0 .. 359)
+    @param s Saturation (0.0 .. 1.0)
+    @param l Lightness (0 .. 255)
+    @return The corresponding RGB color as [r,g,b]
+    @type Array
+    */
+  hsl2rgb : function(h,s,l) {
+    var r,g,b
+    if (s == 0) {
+      r=g=b=v
+    } else {
+      var q = (l < 0.5 ? l * (1+s) : l+s-(l*s))
+      var p = 2 * l - q
+      var hk = (h % 360) / 360
+      var tr = hk + 1/3
+      var tg = hk
+      var tb = hk - 1/3
+      if (tr < 0) tr++
+      if (tr > 1) tr--
+      if (tg < 0) tg++
+      if (tg > 1) tg--
+      if (tb < 0) tb++
+      if (tb > 1) tb--
+      if (tr < 1/6)
+        r = p + ((q-p)*6*tr)
+      else if (tr < 1/2)
+        r = q
+      else if (tr < 2/3)
+        r = p + ((q-p)*6*(2/3 - tr))
+      else
+        r = p
+
+      if (tg < 1/6)
+        g = p + ((q-p)*6*tg)
+      else if (tg < 1/2)
+        g = q
+      else if (tg < 2/3)
+        g = p + ((q-p)*6*(2/3 - tg))
+      else
+        g = p
+
+      if (tb < 1/6)
+        b = p + ((q-p)*6*tb)
+      else if (tb < 1/2)
+        b = q
+      else if (tb < 2/3)
+        b = p + ((q-p)*6*(2/3 - tb))
+      else
+        b = p
+    }
+
+    return [r,g,b]
+  },
+
+  /**
+    Converts an HSV color to its corresponding RGB color.
+
+    @param h Hue in degrees (0 .. 359)
+    @param s Saturation (0.0 .. 1.0)
+    @param v Value (0 .. 255)
+    @return The corresponding RGB color as [r,g,b]
+    @type Array
+    */
+  hsv2rgb : function(h,s,v) {
+    var r,g,b
+    if (s == 0) {
+      r=g=b=v
+    } else {
+      h = (h % 360)/60.0
+      var i = Math.floor(h)
+      var f = h-i
+      var p = v * (1-s)
+      var q = v * (1-s*f)
+      var t = v * (1-s*(1-f))
+      switch (i) {
+        case 0:
+          r = v
+          g = t
+          b = p
+          break
+        case 1:
+          r = q
+          g = v
+          b = p
+          break
+        case 2:
+          r = p
+          g = v
+          b = t
+          break
+        case 3:
+          r = p
+          g = q
+          b = v
+          break
+        case 4:
+          r = t
+          g = p
+          b = v
+          break
+        case 5:
+          r = v
+          g = p
+          b = q
+          break
+      }
+    }
+    return [r,g,b]
+  },
+
+  /**
+    Parses a color style object into one that can be used with the given
+    canvas context.
+
+    Accepted formats:
+      'white'
+      '#fff'
+      '#ffffff'
+      'rgba(255,255,255, 1.0)'
+      [255, 255, 255]
+      [255, 255, 255, 1.0]
+      new Gradient(...)
+      new Pattern(...)
+
+    @param style The color style to parse
+    @param ctx Canvas 2D context on which the style is to be used
+    @return A parsed style, ready to be used as ctx.fillStyle / strokeStyle
+    */
+  parseColorStyle : function(style, ctx) {
+    if (typeof style == 'string') {
+      return style
+    } else if (style.compiled) {
+      return style.compiled
+    } else if (style.isPattern) {
+      return style.compile(ctx)
+    } else if (style.length == 3) {
+      return 'rgba('+style.map(Math.round).join(",")+', 1)'
+    } else if (style.length == 4) {
+      return 'rgba('+
+              Math.round(style[0])+','+
+              Math.round(style[1])+','+
+              Math.round(style[2])+','+
+              style[3]+
+             ')'
+    } else { // wtf
+      throw( "Bad style: " + style )
+    }
+  }
+}
+
+
+
+/**
+  Navigating around differing implementations of canvas features.
+
+  Current issues:
+
+  isPointInPath(x,y):
+
+    Opera supports isPointInPath.
+
+    Safari doesn't have isPointInPath. So you need to keep track of the CTM and
+    do your own in-fill-checking. Which is done for circles and rectangles
+    in Circle#isPointInPath and Rectangle#isPointInPath.
+    Paths use an inaccurate bounding box test, implemented in
+    Path#isPointInPath.
+
+    Firefox 3 has isPointInPath. But it uses user-space coordinates.
+    Which can be easily navigated around because it has setTransform.
+
+    Firefox 2 has isPointInPath. But it uses user-space coordinates.
+    And there's no setTransform, so you need to keep track of the CTM and
+    multiply the mouse vector with the CTM's inverse.
+
+  Drawing text:
+
+    Rhino has ctx.drawString(x,y, text)
+
+    Firefox has ctx.mozDrawText(text)
+
+    The WhatWG spec, Safari and Opera have nothing.
+
+*/
+CanvasSupport = {
+  DEVICE_SPACE : 0, // Opera
+  USER_SPACE : 1,   // Fx2, Fx3
+  isPointInPathMode : null,
+  supportsIsPointInPath : null,
+  supportsCSSTransform : null,
+  supportsCanvas : null,
+
+  isCanvasSupported : function() {
+    if (this.supportsCanvas == null) {
+      var e = {};
+      try { e = E('canvas'); } catch(x) {}
+      this.supportsCanvas = (e.getContext != null);
+    }
+    return this.supportsCanvas;
+  },
+
+  isCSSTransformSupported : function() {
+    if (this.supportsCSSTransform == null) {
+      var e = E('div')
+      var dbs = e.style
+      var s = (dbs.webkitTransform != null || dbs.MozTransform != null)
+      this.supportsCSSTransform = (s != null)
+    }
+    return this.supportsCSSTransform
+  },
+
+  getTestContext : function() {
+    if (!this.testContext) {
+      var c = E.canvas(1,1)
+      this.testContext = c.getContext('2d')
+    }
+    return this.testContext
+  },
+
+  getSupportsAudioTag : function() {
+    var e = E('audio')
+    return !!e.play
+  },
+
+  getSupportsSoundManager : function() {
+    return (window.soundManager && soundManager.enabled)
+  },
+
+  soundId : 0,
+
+  getSoundObject : function() {
+    var e = null
+//     if (this.getSupportsAudioTag()) {
+//       e = this.getAudioTagSoundObject()
+//     } else
+    if (this.getSupportsSoundManager()) {
+      e = this.getSoundManagerSoundObject()
+    }
+    return e
+  },
+
+  getAudioTagSoundObject : function() {
+    var sid = 'sound-' + this.soundId++
+    var e = E('audio', {id: sid})
+    e.load = function(src) {
+      this.src = src
+    }
+    e.addEventListener('canplaythrough', function() {
+      if (this.onready) this.onready()
+    }, false)
+    e.setVolume = function(v){ this.volume = v }
+    e.setPan = function(v){ this.pan = v }
+    return e
+  },
+
+  getSoundManagerSoundObject : function() {
+    var sid = 'sound-' + this.soundId++
+    var e = {
+      volume: 100,
+      pan: 0,
+      sid : sid,
+      load : function(src) {
+        return soundManager.load(this.sid, {
+          url: src,
+          autoPlay: false,
+          volume: this.volume,
+          pan: this.pan
+        })
+      },
+      _onload : function() {
+        if (this.onload) this.onload()
+        if (this.onready) this.onready()
+      },
+      _onerror : function() {
+        if (this.onerror) this.onerror()
+      },
+      _onfinish : function() {
+        if (this.onfinish) this.onfinish()
+      },
+      play : function() {
+        return soundManager.play(this.sid)
+      },
+      stop : function() {
+        return soundManager.stop(this.sid)
+      },
+      pause : function() {
+        return soundManager.togglePause(this.sid)
+      },
+      setVolume : function(v) {
+        this.volume = v*100
+        return soundManager.setVolume(this.sid, v*100)
+      },
+      setPan : function(v) {
+        this.pan = v*100
+        return soundManager.setPan(this.sid, v*100)
+      }
+    }
+    soundManager.createSound(sid, 'null.mp3')
+    e.sound = soundManager.getSoundById(sid)
+    e.sound.options.onfinish = e._onfinish.bind(e)
+    e.sound.options.onload = e._onload.bind(e)
+    e.sound.options.onerror = e._onerror.bind(e)
+    return e
+  },
+
+  /**
+    Canvas context augment module that adds setters.
+    */
+  ContextSetterAugment : {
+    setFillStyle : function(fs) { this.fillStyle = fs },
+    setStrokeStyle : function(ss) { this.strokeStyle = ss },
+    setGlobalAlpha : function(ga) { this.globalAlpha = ga },
+    setLineWidth : function(lw) { this.lineWidth = lw },
+    setLineCap : function(lw) { this.lineCap = lw },
+    setLineJoin : function(lw) { this.lineJoin = lw },
+    setMiterLimit : function(lw) { this.miterLimit = lw },
+    setGlobalCompositeOperation : function(lw) {
+      this.globalCompositeOperation = lw
+    },
+    setShadowColor : function(x) { this.shadowColor = x },
+    setShadowBlur : function(x) { this.shadowBlur = x },
+    setShadowOffsetX : function(x) { this.shadowOffsetX = x },
+    setShadowOffsetY : function(x) { this.shadowOffsetY = x },
+    setMozTextStyle : function(x) { this.mozTextStyle = x },
+    setFont : function(x) { this.font = x },
+    setTextAlign : function(x) { this.textAlign = x },
+    setTextBaseline : function(x) { this.textBaseline = x }
+  },
+
+  ContextJSImplAugment : {
+    identity : function() {
+      CanvasSupport.setTransform(this, [1,0,0,1,0,0])
+    }
+  },
+
+  /**
+    Augments a canvas context with setters.
+    */
+  augment : function(ctx) {
+    Object.conditionalExtend(ctx, this.ContextSetterAugment)
+    Object.conditionalExtend(ctx, this.ContextJSImplAugment)
+    return ctx
+  },
+
+  /**
+    Gets the augmented context for canvas.
+    */
+  getContext : function(canvas, type) {
+    var ctx = canvas.getContext(type || '2d')
+    this.augment(ctx)
+    return ctx
+  },
+
+
+  /**
+    Multiplies two 3x2 affine 2D column-major transformation matrices with
+    each other and stores the result in the first matrix.
+
+    Returns the multiplied matrix m1.
+    */
+  tMatrixMultiply : function(m1, m2) {
+    var m11 = m1[0]*m2[0] + m1[2]*m2[1]
+    var m12 = m1[1]*m2[0] + m1[3]*m2[1]
+
+    var m21 = m1[0]*m2[2] + m1[2]*m2[3]
+    var m22 = m1[1]*m2[2] + m1[3]*m2[3]
+
+    var dx = m1[0]*m2[4] + m1[2]*m2[5] + m1[4]
+    var dy = m1[1]*m2[4] + m1[3]*m2[5] + m1[5]
+
+    m1[0] = m11
+    m1[1] = m12
+    m1[2] = m21
+    m1[3] = m22
+    m1[4] = dx
+    m1[5] = dy
+
+    return m1
+  },
+
+  /**
+    Multiplies the vector [x, y, 1] with the 3x2 transformation matrix m.
+    */
+  tMatrixMultiplyPoint : function(m, x, y) {
+    return [
+      x*m[0] + y*m[2] + m[4],
+      x*m[1] + y*m[3] + m[5]
+    ]
+  },
+
+  /**
+    Inverts a 3x2 affine 2D column-major transformation matrix.
+
+    Returns an inverted copy of the matrix.
+    */
+  tInvertMatrix : function(m) {
+    var d = 1 / (m[0]*m[3]-m[1]*m[2])
+    return [
+      m[3]*d, -m[1]*d,
+      -m[2]*d, m[0]*d,
+      d*(m[2]*m[5]-m[3]*m[4]), d*(m[1]*m[4]-m[0]*m[5])
+    ]
+  },
+
+  /**
+    Applies a transformation matrix m on the canvas context ctx.
+    */
+  transform : function(ctx, m) {
+    if (ctx.transform)
+      return ctx.transform.apply(ctx, m)
+    ctx.translate(m[4], m[5])
+    // scale
+    if (Math.abs(m[1]) < 1e-6 && Math.abs(m[2]) < 1e-6) {
+      ctx.scale(m[0], m[3])
+      return
+    }
+    var res = this.svdTransform({xx:m[0], xy:m[2], yx:m[1], yy:m[3], dx:m[4], dy:m[5]})
+    ctx.rotate(res.angle2)
+    ctx.scale(res.sx, res.sy)
+    ctx.rotate(res.angle1)
+    return
+  },
+
+  // broken svd...
+  brokenSvd : function(m) {
+    var mt = [m[0], m[2], m[1], m[3], 0,0]
+    var mtm = [
+      mt[0]*m[0]+mt[2]*m[1],
+      mt[1]*m[0]+mt[3]*m[1],
+      mt[0]*m[2]+mt[2]*m[3],
+      mt[1]*m[2]+mt[3]*m[3],
+      0,0
+    ]
+    // (mtm[0]-x) * (mtm[3]-x) - (mtm[1]*mtm[2]) = 0
+    // x*x - (mtm[0]+mtm[3])*x - (mtm[1]*mtm[2])+(mtm[0]*mtm[3]) = 0
+    var a = 1
+    var b = -(mtm[0]+mtm[3])
+    var c = -(mtm[1]*mtm[2])+(mtm[0]*mtm[3])
+    var d = Math.sqrt(b*b - 4*a*c)
+    var c1 = (-b + d) / (2*a)
+    var c2 = (-b - d) / (2*a)
+    if (c1 < c2)
+      var tmp = c1, c1 = c2, c2 = tmp
+    var s1 = Math.sqrt(c1)
+    var s2 = Math.sqrt(c2)
+    var i_s = [1/s1, 0, 0, 1/s2, 0,0]
+    // (mtm[0]-c1)*x1 + mtm[2]*x2 = 0
+    // mtm[1]*x1 + (mtm[3]-c1)*x2 = 0
+    // x2 = -(mtm[0]-c1)*x1 / mtm[2]
+    var e = ((mtm[0]-c1)/mtm[2])
+    var l = Math.sqrt(1 + e*e)
+    var v00 = 1 / l
+    var v10 = e / l
+    var v11 = v00
+    var v01 = -v10
+    var v = [v00, v01, v10, v11, 0,0]
+    var u = m.slice(0)
+    this.tMatrixMultiply(u,v)
+    this.tMatrixMultiply(u,i_s)
+    return [u, [s1,0,0,s2,0,0], [v00, v10, v01, v11, 0, 0]]
+  },
+
+
+  svdTransform : (function(){
+    //   Copyright (c) 2004-2005, The Dojo Foundation
+    //   All Rights Reserved
+    var m = {}
+    m.Matrix2D = function(arg){
+      // summary: a 2D matrix object
+      // description: Normalizes a 2D matrix-like object. If arrays is passed,
+      //    all objects of the array are normalized and multiplied sequentially.
+      // arg: Object
+      //    a 2D matrix-like object, a number, or an array of such objects
+      if(arg){
+        if(typeof arg == "number"){
+          this.xx = this.yy = arg;
+        }else if(arg instanceof Array){
+          if(arg.length > 0){
+            var matrix = m.normalize(arg[0]);
+            // combine matrices
+            for(var i = 1; i < arg.length; ++i){
+              var l = matrix, r = m.normalize(arg[i]);
+              matrix = new m.Matrix2D();
+              matrix.xx = l.xx * r.xx + l.xy * r.yx;
+              matrix.xy = l.xx * r.xy + l.xy * r.yy;
+              matrix.yx = l.yx * r.xx + l.yy * r.yx;
+              matrix.yy = l.yx * r.xy + l.yy * r.yy;
+              matrix.dx = l.xx * r.dx + l.xy * r.dy + l.dx;
+              matrix.dy = l.yx * r.dx + l.yy * r.dy + l.dy;
+            }
+            Object.extend(this, matrix);
+          }
+        }else{
+          Object.extend(this, arg);
+        }
+      }
+    }
+    // ensure matrix 2D conformance
+    m.normalize = function(matrix){
+        // summary: converts an object to a matrix, if necessary
+        // description: Converts any 2D matrix-like object or an array of
+        //    such objects to a valid dojox.gfx.matrix.Matrix2D object.
+        // matrix: Object: an object, which is converted to a matrix, if necessary
+        return (matrix instanceof m.Matrix2D) ? matrix : new m.Matrix2D(matrix); // dojox.gfx.matrix.Matrix2D
+    }
+    m.multiply = function(matrix){
+      // summary: combines matrices by multiplying them sequentially in the given order
+      // matrix: dojox.gfx.matrix.Matrix2D...: a 2D matrix-like object,
+      //    all subsequent arguments are matrix-like objects too
+      var M = m.normalize(matrix);
+      // combine matrices
+      for(var i = 1; i < arguments.length; ++i){
+        var l = M, r = m.normalize(arguments[i]);
+        M = new m.Matrix2D();
+        M.xx = l.xx * r.xx + l.xy * r.yx;
+        M.xy = l.xx * r.xy + l.xy * r.yy;
+        M.yx = l.yx * r.xx + l.yy * r.yx;
+        M.yy = l.yx * r.xy + l.yy * r.yy;
+        M.dx = l.xx * r.dx + l.xy * r.dy + l.dx;
+        M.dy = l.yx * r.dx + l.yy * r.dy + l.dy;
+      }
+      return M; // dojox.gfx.matrix.Matrix2D
+    }
+    m.invert = function(matrix) {
+      var M = m.normalize(matrix),
+        D = M.xx * M.yy - M.xy * M.yx,
+        M = new m.Matrix2D({
+          xx: M.yy/D, xy: -M.xy/D,
+          yx: -M.yx/D, yy: M.xx/D,
+          dx: (M.xy * M.dy - M.yy * M.dx) / D,
+          dy: (M.yx * M.dx - M.xx * M.dy) / D
+        });
+      return M; // dojox.gfx.matrix.Matrix2D
+    }
+    // the default (identity) matrix, which is used to fill in missing values
+    Object.extend(m.Matrix2D, {xx: 1, xy: 0, yx: 0, yy: 1, dx: 0, dy: 0});
+
+    var eq = function(/* Number */ a, /* Number */ b){
+      // summary: compare two FP numbers for equality
+      return Math.abs(a - b) <= 1e-6 * (Math.abs(a) + Math.abs(b)); // Boolean
+    };
+
+    var calcFromValues = function(/* Number */ s1, /* Number */ s2){
+      // summary: uses two close FP values to approximate the result
+      if(!isFinite(s1)){
+        return s2;  // Number
+      }else if(!isFinite(s2)){
+        return s1;  // Number
+      }
+      return (s1 + s2) / 2; // Number
+    };
+
+    var transpose = function(/* dojox.gfx.matrix.Matrix2D */ matrix){
+      // matrix: dojox.gfx.matrix.Matrix2D: a 2D matrix-like object
+      var M = new m.Matrix2D(matrix);
+      return Object.extend(M, {dx: 0, dy: 0, xy: M.yx, yx: M.xy}); // dojox.gfx.matrix.Matrix2D
+    };
+
+    var scaleSign = function(/* dojox.gfx.matrix.Matrix2D */ matrix){
+      return (matrix.xx * matrix.yy < 0 || matrix.xy * matrix.yx > 0) ? -1 : 1; // Number
+    };
+
+    var eigenvalueDecomposition = function(/* dojox.gfx.matrix.Matrix2D */ matrix){
+      // matrix: dojox.gfx.matrix.Matrix2D: a 2D matrix-like object
+      var M = m.normalize(matrix),
+        b = -M.xx - M.yy,
+        c = M.xx * M.yy - M.xy * M.yx,
+        d = Math.sqrt(b * b - 4 * c),
+        l1 = -(b + (b < 0 ? -d : d)) / 2,
+        l2 = c / l1,
+        vx1 = M.xy / (l1 - M.xx), vy1 = 1,
+        vx2 = M.xy / (l2 - M.xx), vy2 = 1;
+      if(eq(l1, l2)){
+        vx1 = 1, vy1 = 0, vx2 = 0, vy2 = 1;
+      }
+      if(!isFinite(vx1)){
+        vx1 = 1, vy1 = (l1 - M.xx) / M.xy;
+        if(!isFinite(vy1)){
+          vx1 = (l1 - M.yy) / M.yx, vy1 = 1;
+          if(!isFinite(vx1)){
+            vx1 = 1, vy1 = M.yx / (l1 - M.yy);
+          }
+        }
+      }
+      if(!isFinite(vx2)){
+        vx2 = 1, vy2 = (l2 - M.xx) / M.xy;
+        if(!isFinite(vy2)){
+          vx2 = (l2 - M.yy) / M.yx, vy2 = 1;
+          if(!isFinite(vx2)){
+            vx2 = 1, vy2 = M.yx / (l2 - M.yy);
+          }
+        }
+      }
+      var d1 = Math.sqrt(vx1 * vx1 + vy1 * vy1),
+        d2 = Math.sqrt(vx2 * vx2 + vy2 * vy2);
+      if(isNaN(vx1 /= d1)){ vx1 = 0; }
+      if(isNaN(vy1 /= d1)){ vy1 = 0; }
+      if(isNaN(vx2 /= d2)){ vx2 = 0; }
+      if(isNaN(vy2 /= d2)){ vy2 = 0; }
+      return {  // Object
+        value1: l1,
+        value2: l2,
+        vector1: {x: vx1, y: vy1},
+        vector2: {x: vx2, y: vy2}
+      };
+    };
+
+    var decomposeSR = function(/* dojox.gfx.matrix.Matrix2D */ M, /* Object */ result){
+      // summary: decomposes a matrix into [scale, rotate]; no checks are done.
+      var sign = scaleSign(M),
+        a = result.angle1 = (Math.atan2(M.yx, M.yy) + Math.atan2(-sign * M.xy, sign * M.xx)) / 2,
+        cos = Math.cos(a), sin = Math.sin(a);
+      result.sx = calcFromValues(M.xx / cos, -M.xy / sin);
+      result.sy = calcFromValues(M.yy / cos, M.yx / sin);
+      return result;  // Object
+    };
+
+    var decomposeRS = function(/* dojox.gfx.matrix.Matrix2D */ M, /* Object */ result){
+      // summary: decomposes a matrix into [rotate, scale]; no checks are done
+      var sign = scaleSign(M),
+        a = result.angle2 = (Math.atan2(sign * M.yx, sign * M.xx) + Math.atan2(-M.xy, M.yy)) / 2,
+        cos = Math.cos(a), sin = Math.sin(a);
+      result.sx = calcFromValues(M.xx / cos, M.yx / sin);
+      result.sy = calcFromValues(M.yy / cos, -M.xy / sin);
+      return result;  // Object
+    };
+
+    return function(matrix){
+      // summary: decompose a 2D matrix into translation, scaling, and rotation components
+      // description: this function decompose a matrix into four logical components:
+      //  translation, rotation, scaling, and one more rotation using SVD.
+      //  The components should be applied in following order:
+      //  | [translate, rotate(angle2), scale, rotate(angle1)]
+      // matrix: dojox.gfx.matrix.Matrix2D: a 2D matrix-like object
+      var M = m.normalize(matrix),
+        result = {dx: M.dx, dy: M.dy, sx: 1, sy: 1, angle1: 0, angle2: 0};
+      // detect case: [scale]
+      if(eq(M.xy, 0) && eq(M.yx, 0)){
+        return Object.extend(result, {sx: M.xx, sy: M.yy});  // Object
+      }
+      // detect case: [scale, rotate]
+      if(eq(M.xx * M.yx, -M.xy * M.yy)){
+        return decomposeSR(M, result);  // Object
+      }
+      // detect case: [rotate, scale]
+      if(eq(M.xx * M.xy, -M.yx * M.yy)){
+        return decomposeRS(M, result);  // Object
+      }
+      // do SVD
+      var MT = transpose(M),
+        u  = eigenvalueDecomposition([M, MT]),
+        v  = eigenvalueDecomposition([MT, M]),
+        U  = new m.Matrix2D({xx: u.vector1.x, xy: u.vector2.x, yx: u.vector1.y, yy: u.vector2.y}),
+        VT = new m.Matrix2D({xx: v.vector1.x, xy: v.vector1.y, yx: v.vector2.x, yy: v.vector2.y}),
+        S = new m.Matrix2D([m.invert(U), M, m.invert(VT)]);
+      decomposeSR(VT, result);
+      S.xx *= result.sx;
+      S.yy *= result.sy;
+      decomposeRS(U, result);
+      S.xx *= result.sx;
+      S.yy *= result.sy;
+      return Object.extend(result, {sx: S.xx, sy: S.yy});  // Object
+    };
+  })(),
+
+
+  /**
+    Sets the canvas context ctx's transformation matrix to m, with ctm being
+    the current transformation matrix.
+    */
+  setTransform : function(ctx, m, ctm) {
+    if (ctx.setTransform)
+      return ctx.setTransform.apply(ctx, m)
+    this.transform(ctx, this.tInvertMatrix(ctm))
+    this.transform(ctx, m)
+  },
+
+  /**
+    Skews the canvas context by angle on the x-axis.
+    */
+  skewX : function(ctx, angle) {
+    return this.transform(ctx, this.tSkewXMatrix(angle))
+  },
+
+  /**
+    Skews the canvas context by angle on the y-axis.
+    */
+  skewY : function(ctx, angle) {
+    return this.transform(ctx, this.tSkewYMatrix(angle))
+  },
+
+  /**
+    Rotates a transformation matrix by angle.
+    */
+  tRotate : function(m1, angle) {
+    // return this.tMatrixMultiply(matrix, this.tRotationMatrix(angle))
+    var c = Math.cos(angle)
+    var s = Math.sin(angle)
+    var m11 = m1[0]*c + m1[2]*s
+    var m12 = m1[1]*c + m1[3]*s
+    var m21 = m1[0]*-s + m1[2]*c
+    var m22 = m1[1]*-s + m1[3]*c
+    m1[0] = m11
+    m1[1] = m12
+    m1[2] = m21
+    m1[3] = m22
+    return m1
+  },
+
+  /**
+    Translates a transformation matrix by x and y.
+    */
+  tTranslate : function(m1, x, y) {
+    // return this.tMatrixMultiply(matrix, this.tTranslationMatrix(x,y))
+    m1[4] += m1[0]*x + m1[2]*y
+    m1[5] += m1[1]*x + m1[3]*y
+    return m1
+  },
+
+  /**
+    Scales a transformation matrix by sx and sy.
+    */
+  tScale : function(m1, sx, sy) {
+    // return this.tMatrixMultiply(matrix, this.tScalingMatrix(sx,sy))
+    m1[0] *= sx
+    m1[1] *= sx
+    m1[2] *= sy
+    m1[3] *= sy
+    return m1
+  },
+
+  /**
+    Skews a transformation matrix by angle on the x-axis.
+    */
+  tSkewX : function(m1, angle) {
+    return this.tMatrixMultiply(m1, this.tSkewXMatrix(angle))
+  },
+
+  /**
+    Skews a transformation matrix by angle on the y-axis.
+    */
+  tSkewY : function(m1, angle) {
+    return this.tMatrixMultiply(m1, this.tSkewYMatrix(angle))
+  },
+
+  /**
+    Returns a 3x2 2D column-major y-skew matrix for the angle.
+    */
+  tSkewXMatrix : function(angle) {
+    return [ 1, 0, Math.tan(angle), 1, 0, 0 ]
+  },
+
+  /**
+    Returns a 3x2 2D column-major y-skew matrix for the angle.
+    */
+  tSkewYMatrix : function(angle) {
+    return [ 1, Math.tan(angle), 0, 1, 0, 0 ]
+  },
+
+  /**
+    Returns a 3x2 2D column-major rotation matrix for the angle.
+    */
+  tRotationMatrix : function(angle) {
+    var c = Math.cos(angle)
+    var s = Math.sin(angle)
+    return [ c, s, -s, c, 0, 0 ]
+  },
+
+  /**
+    Returns a 3x2 2D column-major translation matrix for x and y.
+    */
+  tTranslationMatrix : function(x, y) {
+    return [ 1, 0, 0, 1, x, y ]
+  },
+
+  /**
+    Returns a 3x2 2D column-major scaling matrix for sx and sy.
+    */
+  tScalingMatrix : function(sx, sy) {
+    return [ sx, 0, 0, sy, 0, 0 ]
+  },
+
+  /**
+    Returns the name of the text backend to use.
+
+    Possible values are:
+      * 'MozText' for Firefox
+      * 'DrawString' for Rhino
+      * 'NONE' no text drawing
+
+    @return The text backend name
+    @type String
+    */
+  getTextBackend : function() {
+    if (this.textBackend == null)
+      this.textBackend = this.detectTextBackend()
+    return this.textBackend
+  },
+
+  /**
+    Detects the name of the text backend to use.
+
+    Possible values are:
+      * 'MozText' for Firefox
+      * 'DrawString' for Rhino
+      * 'NONE' no text drawing
+
+    @return The text backend name
+    @type String
+    */
+  detectTextBackend : function() {
+    var ctx = this.getTestContext()
+    if (ctx.fillText) {
+      return 'HTML5'
+    } else if (ctx.mozDrawText) {
+      return 'MozText'
+    } else if (ctx.drawString) {
+      return 'DrawString'
+    }
+    return 'NONE'
+  },
+
+  getSupportsPutImageData : function() {
+    if (this.supportsPutImageData == null) {
+      var ctx = this.getTestContext()
+      var support = ctx.putImageData
+      if (support) {
+        try {
+          var idata = ctx.getImageData(0,0,1,1)
+          idata[0] = 255
+          idata[1] = 0
+          idata[2] = 255
+          idata[3] = 255
+          ctx.putImageData({width: 1, height: 1, data: idata}, 0, 0)
+          var idata = ctx.getImageData(0,0,1,1)
+          support = [255, 0, 255, 255].equals(idata.data)
+        } catch(e) {
+          support = false
+        }
+      }
+      this.supportsPutImageData = support
+    }
+    return support
+  },
+
+  /**
+    Returns true if the browser can be coaxed to work with
+    {@link CanvasSupport.isPointInPath}.
+
+    @return Whether the browser supports isPointInPath or not
+    @type boolean
+    */
+  getSupportsIsPointInPath : function() {
+    if (this.supportsIsPointInPath == null)
+      this.supportsIsPointInPath = !!this.getTestContext().isPointInPath
+    return this.supportsIsPointInPath
+  },
+
+  /**
+    Returns the coordinate system in which the isPointInPath of the
+    browser operates. Possible coordinate systems are
+    CanvasSupport.DEVICE_SPACE and CanvasSupport.USER_SPACE.
+
+    @return The coordinate system for the browser's isPointInPath
+    */
+  getIsPointInPathMode : function() {
+    if (this.isPointInPathMode == null)
+      this.isPointInPathMode = this.detectIsPointInPathMode()
+    return this.isPointInPathMode
+  },
+
+  /**
+    Detects the coordinate system in which the isPointInPath of the
+    browser operates. Possible coordinate systems are
+    CanvasSupport.DEVICE_SPACE and CanvasSupport.USER_SPACE.
+
+    @return The coordinate system for the browser's isPointInPath
+    @private
+    */
+  detectIsPointInPathMode : function() {
+    var ctx = this.getTestContext()
+    var rv
+    if (!ctx.isPointInPath)
+      return this.USER_SPACE
+    ctx.save()
+    ctx.translate(1,0)
+    ctx.beginPath()
+    ctx.rect(0,0,1,1)
+    if (ctx.isPointInPath(0.3,0.3)) {
+      rv = this.USER_SPACE
+    } else {
+      rv = this.DEVICE_SPACE
+    }
+    ctx.restore()
+    return rv
+  },
+
+  /**
+    Returns true if the device-space point (x,y) is inside the fill of
+    ctx's current path.
+
+    @param ctx Canvas 2D context to query
+    @param x The distance in pixels from the left side of the canvas element
+    @param y The distance in pixels from the top side of the canvas element
+    @param matrix The current transformation matrix. Needed if the browser has
+                  no isPointInPath or the browser's isPointInPath works in
+                  user-space coordinates and the browser doesn't support
+                  setTransform.
+    @param callbackObj If the browser doesn't support isPointInPath,
+                       callbackObj.isPointInPath will be called with the
+                       x,y-coordinates transformed to user-space.
+    @param
+    @return Whether (x,y) is inside ctx's current path or not
+    @type boolean
+    */
+  isPointInPath : function(ctx, x, y, matrix, callbackObj) {
+    var rv
+    if (!ctx.isPointInPath) {
+      if (callbackObj && callbackObj.isPointInPath) {
+        var xy = this.tMatrixMultiplyPoint(this.tInvertMatrix(matrix), x, y)
+        return callbackObj.isPointInPath(xy[0], xy[1])
+      } else {
+        return false
+      }
+    } else {
+      if (this.getIsPointInPathMode() == this.USER_SPACE) {
+        if (!ctx.setTransform) {
+          var xy = this.tMatrixMultiplyPoint(this.tInvertMatrix(matrix), x, y)
+          rv = ctx.isPointInPath(xy[0], xy[1])
+        } else {
+          ctx.save()
+          ctx.setTransform(1,0,0,1,0,0)
+          rv = ctx.isPointInPath(x,y)
+          ctx.restore()
+        }
+      } else {
+        rv = ctx.isPointInPath(x,y)
+      }
+      return rv
+    }
+  }
+}
+
+
+RecordingContext = Klass({
+  objectId : 0,
+  commands : [],
+  isMockObject : true,
+
+  initialize : function(commands) {
+    this.commands = commands || []
+    Object.conditionalExtend(this, this.getMockContext())
+  },
+
+  getMockContext : function() {
+    if (!RecordingContext.MockContext) {
+      var c = E.canvas(1,1)
+      var ctx = CanvasSupport.getContext(c, '2d')
+      var obj = {}
+      for (var i in ctx) {
+        if (typeof(ctx[i]) == 'function')
+          obj[i] = this.createRecordingFunction(i)
+        else
+          obj[i] = ctx[i]
+      }
+      obj.isPointInPath = null
+      obj.transform = null
+      obj.setTransform = null
+      RecordingContext.MockContext = obj
+    }
+    return RecordingContext.MockContext
+  },
+
+  createRecordingFunction : function(name){
+    if (name.search(/^set[A-Z]/) != -1 && name != 'setTransform') {
+      var varName = name.charAt(3).toLowerCase() + name.slice(4)
+      return function(){
+        this[varName] = arguments[0]
+        this.commands.push([name, $A(arguments)])
+      }
+    } else {
+      return function(){
+        this.commands.push([name, $A(arguments)])
+      }
+    }
+  },
+
+  clear : function(){
+    this.commands = []
+  },
+
+  getRecording : function() {
+    return this.commands
+  },
+
+  serialize : function(width, height) {
+    return '(' + {
+      width: width, height: height,
+      commands: this.getRecording()
+    }.toSource() + ')'
+  },
+
+  play : function(ctx) {
+    RecordingContext.play(ctx, this.getRecording())
+  },
+
+  createLinearGradient : function() {
+    var id = this.objectId++
+    this.commands.push([id, '=', 'createLinearGradient', $A(arguments)])
+    return new MockGradient(this, id)
+  },
+
+  createRadialGradient : function() {
+    var id = this.objectId++
+    this.commands.push([id, '=', 'createRadialGradient', $A(arguments)])
+    return new this.MockGradient(this, id)
+  },
+
+  createPattern : function() {
+    var id = this.objectId++
+    this.commands.push([id, '=', 'createPattern', $A(arguments)])
+    return new this.MockGradient(this, id)
+  },
+
+  MockGradient : Klass({
+    isMockObject : true,
+
+    initialize : function(recorder, id) {
+      this.recorder = recorder
+      this.id = id
+    },
+
+    addColorStop : function() {
+      this.recorder.commands.push([this.id, 'addColorStop', $A(arguments)])
+    },
+
+    toSource : function() {
+      return {id : this.id, isMockObject : true}.toSource()
+    }
+  })
+})
+RecordingContext.play = function(ctx, commands) {
+  var dictionary = []
+  for (var i=0; i<commands.length; i++) {
+    var cmd = commands[i]
+    if (cmd.length == 2) {
+      var args = cmd[1]
+      if (args[0] && args[0].isMockObject) {
+        ctx[cmd[0]](dictionary[args[0].id])
+      } else {
+        ctx[cmd[0]].apply(ctx, cmd[1])
+      }
+    } else if (cmd.length == 3) {
+      var obj = dictionary[cmd[0]]
+      obj[cmd[1]].apply(obj, cmd[2])
+    } else if (cmd.length == 4) {
+      dictionary[cmd[0]] = ctx[cmd[2]].apply(ctx, cmd[3])
+    } else {
+      throw "Malformed command: "+cmd.toString()
+    }
+  }
+}
+
+
+
+
+Transformable = Klass({
+  needMatrixUpdate : true,
+
+  /**
+    Transforms the context state according to this node's attributes.
+
+    @param ctx Canvas 2D context
+    */
+  transform : function(ctx) {
+    var atm = this.absoluteMatrix
+    var xy = this.x || this.y
+    var rot = this.rotation
+    var sca = this.scale != null
+    var skX = this.skewX
+    var skY = this.skewY
+    var tm = this.matrix
+    var tl = this.transformList
+
+    // update the node's transformation matrix
+    if (this.needMatrixUpdate || !this.currentMatrix) {
+      if (!this.currentMatrix) this.currentMatrix = [1,0,0,1,0,0]
+      if (this.parent)
+        this.__copyMatrix(this.parent.currentMatrix)
+      else
+        this.__identityMatrix()
+      if (atm) this.__setMatrixMatrix(this.absoluteMatrix)
+      if (xy) this.__translateMatrix(this.x, this.y)
+      if (rot) this.__rotateMatrix(this.rotation)
+      if (skX) this.__skewXMatrix(this.skewX)
+      if (skY) this.__skewYMatrix(this.skewY)
+      if (sca) this.__scaleMatrix(this.scale)
+      if (tm) this.__matrixMatrix(this.matrix)
+      if (tl) {
+        for (var i=0; i<this.transformList.length; i++) {
+          var tl = this.transformList[i]
+          this['__'+tl[0]+'Matrix'](tl[1])
+        }
+      }
+      this.needMatrixUpdate = false
+    }
+
+    if (!ctx) return
+
+    // transform matrix modifiers
+    this.__setMatrix(ctx, this.currentMatrix)
+  },
+
+  distanceTo : function(node) {
+    return Curves.lineLength([this.x, this.y], [node.x, node.y])
+  },
+
+  angleTo : function(node) {
+    return Curves.lineAngle([this.x, this.y], [node.x, node.y])
+  },
+
+
+
+  __setMatrixMatrix : function(matrix) {
+    if (!this.previousMatrix) this.previousMatrix = []
+    var p = this.previousMatrix
+    var c = this.currentMatrix
+    p[0] = c[0]
+    p[1] = c[1]
+    p[2] = c[2]
+    p[3] = c[3]
+    p[4] = c[4]
+    p[5] = c[5]
+    p = this.currentMatrix
+    c = matrix
+    p[0] = c[0]
+    p[1] = c[1]
+    p[2] = c[2]
+    p[3] = c[3]
+    p[4] = c[4]
+    p[5] = c[5]
+  },
+
+  __copyMatrix : function(matrix) {
+    var p = this.currentMatrix
+    var c = matrix
+    p[0] = c[0]
+    p[1] = c[1]
+    p[2] = c[2]
+    p[3] = c[3]
+    p[4] = c[4]
+    p[5] = c[5]
+  },
+
+  __identityMatrix : function() {
+    var p = this.currentMatrix
+    p[0] = 1
+    p[1] = 0
+    p[2] = 0
+    p[3] = 1
+    p[4] = 0
+    p[5] = 0
+  },
+
+  __translateMatrix : function(x, y) {
+    if (x.length) {
+      CanvasSupport.tTranslate( this.currentMatrix, x[0], x[1] )
+    } else {
+      CanvasSupport.tTranslate( this.currentMatrix, x, y )
+    }
+  },
+
+  __rotateMatrix : function(rotation) {
+    if (rotation.length) {
+      if (rotation[0] % Math.PI*2 == 0) return
+      if (rotation[1] || rotation[2]) {
+        CanvasSupport.tTranslate( this.currentMatrix,
+                                  rotation[1], rotation[2] )
+        CanvasSupport.tRotate( this.currentMatrix, rotation[0] )
+        CanvasSupport.tTranslate( this.currentMatrix,
+                                  -rotation[1], -rotation[2] )
+      } else {
+        CanvasSupport.tRotate( this.currentMatrix, rotation[0] )
+      }
+    } else {
+      if (rotation % Math.PI*2 == 0) return
+      CanvasSupport.tRotate( this.currentMatrix, rotation )
+    }
+  },
+
+  __skewXMatrix : function(skewX) {
+    if (skewX.length && skewX[0])
+      CanvasSupport.tSkewX(this.currentMatrix, skewX[0])
+    else
+      CanvasSupport.tSkewX(this.currentMatrix, skewX)
+  },
+
+  __skewYMatrix : function(skewY) {
+    if (skewY.length && skewY[0])
+      CanvasSupport.tSkewY(this.currentMatrix, skewY[0])
+    else
+      CanvasSupport.tSkewY(this.currentMatrix, skewY)
+  },
+
+  __scaleMatrix : function(scale) {
+    if (scale.length == 2) {
+      if (scale[0] == 1 && scale[1] == 1) return
+      CanvasSupport.tScale(this.currentMatrix,
+                           scale[0], scale[1])
+    } else if (scale.length == 3) {
+      if (scale[0] == 1 || (scale[0].length && (scale[0][0] == 1 && scale[0][1] == 1)))
+        return
+      CanvasSupport.tTranslate(this.currentMatrix,
+                               scale[1], scale[2])
+      if (scale[0].length) {
+        CanvasSupport.tScale(this.currentMatrix,
+                              scale[0][0], scale[0][1])
+      } else {
+        CanvasSupport.tScale( this.currentMatrix, scale[0], scale[0] )
+      }
+      CanvasSupport.tTranslate(this.currentMatrix,
+                               -scale[1], -scale[2])
+    } else if (scale != 1) {
+      CanvasSupport.tScale( this.currentMatrix, scale, scale )
+    }
+  },
+
+  __matrixMatrix : function(matrix) {
+    CanvasSupport.tMatrixMultiply(this.currentMatrix, matrix)
+  },
+
+  __setMatrix : function(ctx, matrix) {
+    CanvasSupport.setTransform(ctx, matrix, this.previousMatrix)
+  },
+
+  __translate : function(ctx, x,y) {
+    if (x.length != null)
+      ctx.translate(x[0], x[1])
+    else
+      ctx.translate(x, y)
+  },
+
+  __rotate : function(ctx, rotation) {
+    if (rotation.length) {
+      if (rotation[1] || rotation[2]) {
+        if (rotation[0] % Math.PI*2 == 0) return
+        ctx.translate( rotation[1], rotation[2] )
+        ctx.rotate( rotation[0] )
+        ctx.translate( -rotation[1], -rotation[2] )
+      } else {
+        ctx.rotate( rotation[0] )
+      }
+    } else {
+      ctx.rotate( rotation )
+    }
+  },
+
+  __skewX : function(ctx, skewX) {
+    if (skewX.length && skewX[0])
+      CanvasSupport.skewX(ctx, skewX[0])
+    else
+      CanvasSupport.skewX(ctx, skewX)
+  },
+
+  __skewY : function(ctx, skewY) {
+    if (skewY.length && skewY[0])
+      CanvasSupport.skewY(ctx, skewY[0])
+    else
+      CanvasSupport.skewY(ctx, skewY)
+  },
+
+  __scale : function(ctx, scale) {
+    if (scale.length == 2) {
+      ctx.scale(scale[0], scale[1])
+    } else if (scale.length == 3) {
+      ctx.translate( scale[1], scale[2] )
+      if (scale[0].length) {
+        ctx.scale(scale[0][0], scale[0][1])
+      } else {
+        ctx.scale(scale[0], scale[0])
+      }
+      ctx.translate( -scale[1], -scale[2] )
+    } else {
+      ctx.scale(scale, scale)
+    }
+  },
+
+  __matrix : function(ctx, matrix) {
+    CanvasSupport.transform(ctx, matrix)
+  }
+
+})
+
+
+/**
+  Timeline is an animator that tweens between its frames.
+
+  When object.time = k.time:
+       object.state = k.state
+  When object.time > k[i-1].time and object.time < k[i].time:
+       object.state = k[i].tween(position, k[i-1].state, k[i].state)
+       where position = elapsed / duration,
+             elapsed = object.time - k[i-1].time,
+             duration = k[i].time - k[i-1].time
+  */
+Timeline = Klass({
+  startTime : null,
+  repeat : false,
+  lastAction : 0,
+
+  initialize : function(repeat, pingpong) {
+    this.repeat = repeat
+    this.keyframes = []
+  },
+
+  addKeyframe : function(time, target, tween) {
+    if (arguments.length == 1) this.keyframes.push(time)
+    else  this.keyframes.push({
+            time : time,
+            target : target,
+            tween : tween
+          })
+  },
+
+  appendKeyframe : function(timeDelta, target, tween) {
+    this.lastAction += timeDelta
+    return this.addKeyframe(this.lastAction, target, tween)
+  },
+
+  evaluate : function(object, ot, dt) {
+    if (this.startTime == null) this.startTime = ot
+    var t = ot - this.startTime
+    if (this.keyframes.length > 0) {
+      // find current keyframe
+      var currentIndex, previousFrame, currentFrame
+      for (var i=0; i<this.keyframes.length; i++) {
+        if (this.keyframes[i].time > t) {
+          currentIndex = i
+          break
+        }
+      }
+      if (currentIndex != null) {
+        previousFrame = this.keyframes[currentIndex-1]
+        currentFrame = this.keyframes[currentIndex]
+      }
+      if (!currentFrame) {
+        if (!this.keyframes.atEnd) {
+          this.keyframes.atEnd = true
+          previousFrame = this.keyframes[this.keyframes.length - 1]
+          Object.extend(object, Object.clone(previousFrame.target))
+          if (this.repeat) this.startTime = ot
+          object.changed = true
+        }
+      } else if (previousFrame) {
+        this.keyframes.atEnd = false
+        // animate towards current keyframe
+        var elapsed = t - previousFrame.time
+        var duration = currentFrame.time - previousFrame.time
+        var pos = elapsed / duration
+        for (var k in currentFrame.target) {
+          if (previousFrame.target[k] != null) {
+            object.tweenVariable(k,
+              previousFrame.target[k], currentFrame.target[k],
+              pos, currentFrame.tween)
+          }
+        }
+      }
+    }
+  }
+
+})
+
+
+Animatable = Klass({
+  tweenFunctions : {
+    linear : function(v) { return v },
+
+    set : function(v) { return Math.floor(v) },
+    discrete : function(v) { return Math.floor(v) },
+
+    sine : function(v) { return 0.5-0.5*Math.cos(v*Math.PI) },
+
+    sproing : function(v) {
+      return (0.5-0.5*Math.cos(v*3.59261946538606)) * 1.05263157894737
+      // pi + pi-acos(0.9)
+    },
+
+    square : function(v) {
+      return v*v
+    },
+
+    cube : function(v) {
+      return v*v*v
+    },
+
+    sqrt : function(v) {
+      return Math.sqrt(v)
+    },
+
+    curt : function(v) {
+      return Math.pow(v, -0.333333333333)
+    }
+  },
+
+  initialize : function() {
+    this.lastAction = 0
+    this.timeline = []
+    this.keyframes = []
+    this.pendingKeyframes = []
+    this.pendingTimelineEvents = []
+    this.timelines = []
+    this.animators = []
+    this.addFrameListener(this.updateTimelines)
+    this.addFrameListener(this.updateKeyframes)
+    this.addFrameListener(this.updateTimeline)
+    this.addFrameListener(this.updateAnimators)
+  },
+
+  updateTimelines : function(t, dt) {
+    for (var i=0; i<this.timelines.length; i++)
+      this.timelines[i].evaluate(this, t, dt)
+  },
+
+  addTimeline : function(tl) {
+    this.timelines.push(tl)
+  },
+
+  removeTimeline : function(tl) {
+    this.timelines.deleteFirst(tl)
+  },
+
+  /**
+    Tweens between keyframes (a keyframe is an object with the new values of
+    the members of this, e.g. { time: 0, target: { x: 10, y: 20 }, tween: 'square'})
+
+    Keyframes are very much like multi-variable animators, the main difference
+    is that with keyframes the start value and the duration are implicit.
+
+    While an animation from value A to B would take two keyframes instead of
+    a single animator, chaining and reordering keyframes is very easy.
+    */
+  updateKeyframes : function(t,dt) {
+    this.addPendingKeyframes(t)
+    if (this.keyframes.length > 0) {
+      // find current keyframe
+      var currentIndex, previousFrame, currentFrame
+      for (var i=0; i<this.keyframes.length; i++) {
+        if (this.keyframes[i].time > t) {
+          currentIndex = i
+          break
+        }
+      }
+      if (currentIndex != null) {
+        previousFrame = this.keyframes[currentIndex-1]
+        currentFrame = this.keyframes[currentIndex]
+      }
+      if (!currentFrame) {
+        if (!this.keyframes.atEnd) {
+          this.keyframes.atEnd = true
+          previousFrame = this.keyframes[this.keyframes.length - 1]
+          Object.extend(this, Object.clone(previousFrame.target))
+          this.changed = true
+        }
+      } else if (previousFrame) {
+        this.keyframes.atEnd = false
+        // animate towards current keyframe
+        var elapsed = t - previousFrame.time
+        var duration = currentFrame.time - previousFrame.time
+        var pos = elapsed / duration
+        for (var k in currentFrame.target) {
+          if (previousFrame.target[k] != null) {
+            this.tweenVariable(k,
+              previousFrame.target[k], currentFrame.target[k],
+              pos, currentFrame.tween)
+          }
+        }
+      }
+    }
+  },
+
+  addPendingKeyframes : function(t) {
+    if (this.pendingKeyframes.length > 0) {
+      while (this.pendingKeyframes.length > 0) {
+        var kf = this.pendingKeyframes.shift()
+        if (kf.time == null)
+          kf.time = kf.relativeTime + t
+        this.keyframes.push(kf)
+      }
+      this.keyframes.stableSort(function(a,b) { return a.time - b.time })
+    }
+  },
+
+  /**
+    Run and remove timelineEvents that have startTime <= t.
+    TimelineEvents are run in the ascending order of their startTimes.
+    */
+  updateTimeline : function(t, dt) {
+    this.addPendingTimelineEvents(t)
+    while (this.timeline[0] && this.timeline[0].startTime <= t) {
+      var keyframe = this.timeline.shift()
+      var rv = true
+      if (typeof(keyframe.action) == 'function')
+        rv = keyframe.action.call(this, t, dt, keyframe)
+      else
+        this.animators.push(keyframe.action)
+      if (keyframe.repeatEvery != null && rv != false) {
+        if (keyframe.repeatTimes != null) {
+          if (keyframe.repeatTimes <= 0) continue
+          keyframe.repeatTimes--
+        }
+        keyframe.startTime += keyframe.repeatEvery
+        this.addTimelineEvent(keyframe)
+      }
+      this.changed = true
+    }
+  },
+
+  addPendingTimelineEvents : function(t) {
+    if (this.pendingTimelineEvents.length > 0) {
+      while (this.pendingTimelineEvents.length > 0) {
+        var kf = this.pendingTimelineEvents.shift()
+        if (!kf.startTime)
+          kf.startTime = kf.relativeStartTime + t
+        this.timeline.push(kf)
+      }
+      this.timeline.stableSort(function(a,b) { return a.startTime - b.startTime })
+    }
+  },
+
+  addTimelineEvent : function(kf) {
+    this.pendingTimelineEvents.push(kf)
+  },
+
+  /**
+    Run each animator, delete ones that have their durations exceeded.
+    */
+  updateAnimators : function(t, dt) {
+    for (var i=0; i<this.animators.length; i++) {
+      var ani = this.animators[i]
+      if (!ani.startTime) ani.startTime = t
+      var elapsed = t - ani.startTime
+      var pos = elapsed / ani.duration
+      var shouldRemove = false
+      if (pos >= 1) {
+        if (!ani.repeat) {
+          pos = 1
+          shouldRemove = true
+        } else {
+          if (ani.repeat !== true) ani.repeat = Math.max(0, ani.repeat - 1)
+          if (ani.accumulate) {
+            ani.startValue = Object.clone(ani.endValue)
+            ani.endValue = Object.sum(ani.difference, ani.endValue)
+          }
+          if (ani.repeat == 0) {
+            shouldRemove = true
+            pos = 1
+          } else {
+            ani.startTime = t
+            pos = pos % 1
+          }
+        }
+      } else if (ani.repeat && ani.repeat !== true && ani.repeat <= pos) {
+        shouldRemove = true
+        pos = ani.repeat
+      }
+      this.tweenVariable(ani.variable, ani.startValue, ani.endValue, pos, ani.tween)
+      if (shouldRemove) {
+        this.animators.splice(i, 1)
+        i--
+      }
+    }
+  },
+
+  tweenVariable : function(variable, start, end, pos, tweenFunction) {
+    if (typeof(tweenFunction) != 'function') {
+      tweenFunction = this.tweenFunctions[tweenFunction] || this.tweenFunctions.linear
+    }
+    var tweened = tweenFunction(pos)
+    if (typeof(variable) != 'function') {
+      if (start instanceof Array) {
+        for (var j=0; j<start.length; j++) {
+          this[variable][j] = start[j] + tweened*(end[j]-start[j])
+        }
+      } else {
+        this[variable] = start + tweened*(end-start)
+      }
+    } else {
+      variable.call(this, tweened, start, end)
+    }
+    this.changed = true
+  },
+
+  animate : function(variable, start, end, duration, tween, config) {
+    var start = Object.clone(start)
+    var end = Object.clone(end)
+    if (!config) config = {}
+    if (config.additive) {
+      var diff = Object.sub(end, start)
+      start = Object.sum(start, this[variable])
+      end = Object.sum(end, this[variable])
+    }
+    if (typeof(variable) != 'function')
+      this[variable] = Object.clone(start)
+    var ani = {
+      id : Animatable.uid++,
+      variable : variable,
+      startValue : start,
+      endValue : end,
+      difference : diff,
+      duration : duration,
+      tween : tween,
+      repeat : config.repeat,
+      additive : config.additive,
+      accumulate : config.accumulate,
+      pingpong : config.pingpong
+    }
+    this.animators.push(ani)
+    return ani
+  },
+
+  removeAnimator : function(animator) {
+    this.animators.deleteFirst(animator)
+  },
+
+  animateTo : function(variableName, end, duration, tween, config) {
+    return this.animate(variableName, this[variableName], end, duration, tween, config)
+  },
+
+  animateFrom : function(variableName, start, duration, tween, config) {
+    return this.animate(variableName, start, this[variableName], duration, tween, config)
+  },
+
+  animateFactor : function(variableName, start, endFactor, duration, tween, config) {
+    var end
+    if (start instanceof Array) {
+      end = []
+      for (var i=0; i<start.length; i++) {
+        end[i] = start[i] * endFactor
+      }
+    } else {
+      end = start * endFactor
+    }
+    return this.animate(variableName, start, end, duration, tween, config)
+  },
+
+  animateToFactor : function(variableName, endFactor, duration, tween, config) {
+    var start = this[variableName]
+    return this.animateFactor(variableName, start, endFactor, duration, tween, config)
+  },
+
+  addKeyframe : function(time, target, tween) {
+    var kf = {
+      relativeTime: time,
+      target: target,
+      tween: tween
+    }
+    this.pendingKeyframes.push(kf)
+  },
+
+  addKeyframeAt : function(time, target, tween) {
+    var kf = {
+      time: time,
+      target: target,
+      tween: tween
+    }
+    this.pendingKeyframes.push(kf)
+  },
+
+  appendKeyframe : function(timeDelta, target, tween) {
+    this.lastAction += timeDelta
+    return this.addKeyframe(this.lastAction, target, tween)
+  },
+
+  every : function(duration, action, noFirst) {
+    var kf = {
+      action : action,
+      relativeStartTime : noFirst ? duration : 0,
+      repeatEvery : duration
+    }
+    this.addTimelineEvent(kf)
+    return kf
+  },
+
+  at : function(time, action) {
+    var kf = {
+      action : action,
+      startTime : time
+    }
+    this.addTimelineEvent(kf)
+    return kf
+  },
+
+  after : function(duration, action) {
+    var kf = {
+      action : action,
+      relativeStartTime : duration
+    }
+    this.addTimelineEvent(kf)
+    return kf
+  },
+
+  afterFrame : function(duration, callback) {
+    var elapsed = 0
+    var animator
+    animator = function(t, dt){
+      if (elapsed >= duration) {
+        callback.call(this)
+        this.removeFrameListener(animator)
+      }
+      elapsed++
+    }
+    this.addFrameListener(animator)
+    return animator
+  },
+
+  everyFrame : function(duration, callback, noFirst) {
+    var elapsed = noFirst ? 0 : duration
+    var animator
+    animator = function(t, dt){
+      if (elapsed >= duration) {
+        if (callback.call(this) == false)
+          this.removeFrameListener(animator)
+        elapsed = 0
+      }
+      elapsed++
+    }
+    this.addFrameListener(animator)
+    return animator
+  }
+})
+Animatable.uid = 0
+
+
+
+/**
+  CanvasNode is the base CAKE scenegraph node. All the other scenegraph nodes
+  derive from it. A plain CanvasNode does no drawing, but it can be used for
+  grouping other nodes and setting up the group's drawing state.
+
+  var scene = new CanvasNode({x: 10, y: 10})
+
+  The usual way to use CanvasNodes is to append them to a Canvas object:
+
+    var scene = new CanvasNode()
+    scene.append(new Rectangle(40, 40, {fill: true}))
+    var elem = E.canvas(400, 400)
+    var canvas = new Canvas(elem)
+    canvas.append(scene)
+
+  You can also use CanvasNodes to draw directly to a canvas element:
+
+    var scene = new CanvasNode()
+    scene.append(new Circle(40, {x:200, y:200, stroke: true}))
+    var elem = E.canvas(400, 400)
+    scene.handleDraw(elem.getContext('2d'))
+
+  */
+CanvasNode = Klass(Animatable, Transformable, {
+  OBJECTBOUNDINGBOX : 'objectBoundingBox',
+
+  // whether to draw the node and its childNodes or not
+  visible : true,
+
+  // whether to draw the node (doesn't affect subtree)
+  drawable : true,
+
+  // the CSS display property can be used to affect 'visible'
+  // false     => visible = visible
+  // 'none'    => visible = false
+  // otherwise => visible = true
+  display : null,
+
+  // the CSS visibility property can be used to affect 'drawable'
+  // false     => drawable = drawable
+  // 'hidden'  => drawable = false
+  // otherwise => drawable = true
+  visibility : null,
+
+  // whether this and the subtree from this register mouse hover
+  catchMouse : true,
+
+  // Whether this object registers mouse hover. Only set this to true when you
+  // have a drawable object that can be picked. Otherwise the object requires
+  // a matrix inversion on Firefox 2 and Safari, which is slow.
+  pickable : false,
+
+  // true if this node or one of its descendants is under the mouse
+  // cursor and catchMouse is true
+  underCursor : false,
+
+  // zIndex in relation to sibling nodes (note: not global)
+  zIndex : 0,
+
+  // x translation of the node
+  x : 0,
+
+  // y translation of the node
+  y : 0,
+
+  // scale factor: number for uniform scaling, [x,y] for dimension-wise
+  scale : 1,
+
+  // Rotation of the node, in radians.
+  //
+  // The rotation can also be the array [angle, cx, cy],
+  // where cx and cy define the rotation center.
+  //
+  // The array form is equivalent to
+  // translate(cx, cy); rotate(angle); translate(-cx, -cy);
+  rotation : 0,
+
+  // Transform matrix with which to multiply the current transform matrix.
+  // Applied after all other transformations.
+  matrix : null,
+
+  // Transform matrix with which to replace the current transform matrix.
+  // Applied before any other transformation.
+  absoluteMatrix : null,
+
+  // SVG-like list of transformations to apply.
+  // The different transformations are:
+  // ['translate', [x,y]]
+  // ['rotate', [angle, cx, cy]] - (optional) cx and cy are the rotation center
+  // ['scale', [x,y]]
+  // ['matrix', [m11, m12, m21, m22, dx, dy]]
+  transformList : null,
+
+  // fillStyle for the node and its descendants
+  // Possibilities:
+  //   null // use the previous
+  //   true      // use the previous but do fill
+  //   false     // use the previous but don't do fill
+  //   'none'    // use the previous but don't do fill
+  //
+  //   'white'
+  //   '#fff'
+  //   '#ffffff'
+  //   'rgba(255,255,255, 1.0)'
+  //   [255, 255, 255, 1.0]
+  //   new Gradient(...)
+  //   new Pattern(myImage, 'no-repeat')
+  fill : null,
+
+  // strokeStyle for the node and its descendants
+  // Possibilities:
+  //   null // use the previous
+  //   true      // use the previous but do stroke
+  //   false     // use the previous but don't do stroke
+  //   'none'    // use the previous but don't do stroke
+  //
+  //   'white'
+  //   '#fff'
+  //   '#ffffff'
+  //   'rgba(255,255,255, 1.0)'
+  //   [255, 255, 255, 1.0]
+  //   new Gradient(...)
+  //   new Pattern(myImage, 'no-repeat')
+  stroke : null,
+
+  // stroke line width
+  strokeWidth : null,
+
+  // stroke line cap style ('butt' | 'round' | 'square')
+  lineCap : null,
+
+  // stroke line join style ('bevel' | 'round' | 'miter')
+  lineJoin : null,
+
+  // stroke line miter limit
+  miterLimit : null,
+
+  // set globalAlpha to this value
+  absoluteOpacity : null,
+
+  // multiply globalAlpha by this value
+  opacity : null,
+
+  // fill opacity
+  fillOpacity : null,
+
+  // stroke opacity
+  strokeOpacity : null,
+
+  // set globalCompositeOperation to this value
+  // Possibilities:
+  // ( 'source-over' |
+  //   'copy' |
+  //   'lighter' |
+  //   'darker' |
+  //   'xor' |
+  //   'source-in' |
+  //   'source-out' |
+  //   'destination-over' |
+  //   'destination-atop' |
+  //   'destination-in' |
+  //   'destination-out' )
+  compositeOperation : null,
+
+  // Color for the drop shadow
+  shadowColor : null,
+
+  // Drop shadow blur radius
+  shadowBlur : null,
+
+  // Drop shadow's x-offset
+  shadowOffsetX : null,
+
+  // Drop shadow's y-offset
+  shadowOffsetY : null,
+
+  // HTML5 text API
+  font : null,
+  // horizontal position of the text origin
+  // 'left' | 'center' | 'right' | 'start' | 'end'
+  textAlign : null,
+  // vertical position of the text origin
+  // 'top' | 'hanging' | 'middle' | 'alphabetic' | 'ideographic' | 'bottom'
+  textBaseline : null,
+
+  cursor : null,
+
+  changed : true,
+
+  tagName : 'g',
+
+  getNextSibling : function(){
+    if (this.parentNode)
+      return this.parentNode.childNodes[this.parentNode.childNodes.indexOf(this)+1]
+    return null
+  },
+
+  getPreviousSibling : function(){
+    if (this.parentNode)
+      return this.parentNode.childNodes[this.parentNode.childNodes.indexOf(this)-1]
+    return null
+  },
+
+  /**
+    Initialize the CanvasNode and merge an optional config hash.
+    */
+  initialize : function(config) {
+    this.root = this
+    this.currentMatrix = [1,0,0,1,0,0]
+    this.previousMatrix = [1,0,0,1,0,0]
+    this.needMatrixUpdate = true
+    this.childNodes = []
+    this.frameListeners = []
+    this.eventListeners = {}
+    Animatable.initialize.call(this)
+    if (config)
+      Object.extend(this, config)
+  },
+
+  /**
+    Create a clone of the node and its subtree.
+    */
+  clone : function() {
+    var c = Object.clone(this)
+    c.parent = c.root = null
+    for (var i in this) {
+      if (typeof(this[i]) == 'object')
+        c[i] = Object.clone(this[i])
+    }
+    c.parent = c.root = null
+    c.childNodes = []
+    c.setRoot(null)
+    for (var i=0; i<this.childNodes.length; i++) {
+      var ch = this.childNodes[i].clone()
+      c.append(ch)
+    }
+    return c
+  },
+
+  cloneNode : function(){ return this.clone() },
+
+  /**
+    Gets node by id.
+    */
+  getElementById : function(id) {
+    if (this.id == id)
+      return this
+    for (var i=0; i<this.childNodes.length; i++) {
+      var n = this.childNodes[i].getElementById(id)
+      if (n) return n
+    }
+    return null
+  },
+
+  $ : function(id) {
+    return this.getElementById(id)
+  },
+
+  /**
+    Alias for append().
+
+    @param Node[s] to append
+    */
+  appendChild : function() {
+    return this.append.apply(this, arguments)
+  },
+
+  /**
+    Appends arguments as childNodes to the node.
+
+    Adding a child sets child.parent to be the node and calls
+    child.setRoot(node.root)
+
+    @param Node[s] to append
+    */
+  append : function(obj) {
+    var a = $A(arguments)
+    for (var i=0; i<a.length; i++) {
+      if (a[i].parent) a[i].removeSelf()
+      this.childNodes.push(a[i])
+      a[i].parent = a[i].parentNode = this
+      a[i].setRoot(this.root)
+    }
+    this.changed = true
+  },
+
+  /**
+    Removes all childNodes from the node.
+    */
+  removeAllChildren : function() {
+    this.remove.apply(this, this.childNodes)
+  },
+
+  /**
+    Alias for remove().
+
+    @param Node[s] to remove
+    */
+  removeChild : function() {
+    return this.remove.apply(this, arguments)
+  },
+
+  /**
+    Removes arguments from the node's childNodes.
+
+    Removing a child sets its parent to null and calls
+    child.setRoot(null)
+
+    @param Child node[s] to remove
+    */
+  remove : function(obj) {
+    var a = arguments
+    for (var i=0; i<a.length; i++) {
+      this.childNodes.deleteFirst(a[i])
+      delete a[i].parent
+      delete a[i].parentNode
+      a[i].setRoot(null)
+    }
+    this.changed = true
+  },
+
+  /**
+    Calls this.parent.removeChild(this) if this.parent is set.
+    */
+  removeSelf : function() {
+    if (this.parentNode) {
+      this.parentNode.remove(this)
+    }
+  },
+
+  /**
+    Returns true if this node's subtree contains obj. (I.e. obj is this or
+    obj's parent chain includes this.)
+
+    @param obj Node to look for
+    @return True if obj is in this node's subtree, false if it isn't.
+    */
+  contains : function(obj) {
+    while (obj) {
+      if (obj == this) return true
+      obj = obj.parentNode
+    }
+    return false
+  },
+
+  /**
+    Set this.root to the given value and propagate the update to childNodes.
+
+    @param root The new root node
+    @private
+    */
+  setRoot : function(root) {
+    if (!root) root = this
+    this.dispatchEvent({type: 'rootChanged', canvasTarget: this, relatedTarget: root})
+    this.root = root
+    for (var i=0; i<this.childNodes.length; i++)
+      this.childNodes[i].setRoot(root)
+  },
+
+  /**
+    Adds a callback function to be called before drawing each frame.
+
+    @param f Callback function
+    */
+  addFrameListener : function(f) {
+    this.frameListeners.push(f)
+  },
+
+  /**
+    Removes a callback function from update callbacks.
+
+    @param f Callback function
+    */
+  removeFrameListener : function(f) {
+    this.frameListeners.deleteFirst(f)
+  },
+
+  addEventListener : function(type, listener, capture) {
+    if (!this.eventListeners[type])
+      this.eventListeners[type] = {capture:[], bubble:[]}
+    this.eventListeners[type][capture ? 'capture' : 'bubble'].push(listener)
+  },
+
+  /**
+    Synonym for addEventListener.
+  */
+  when : function(type, listener, capture) {
+    this.addEventListener(type, listener, capture || false)
+  },
+
+  removeEventListener : function(type, listener, capture) {
+    if (!this.eventListeners[type]) return
+    this.eventListeners[type][capture ? 'capture' : 'bubble'].deleteFirst(listener)
+    if (this.eventListeners[type].capture.length == 0 &&
+        this.eventListeners[type].bubble.length == 0)
+      delete this.eventListeners[type]
+  },
+
+  dispatchEvent : function(event) {
+    var type = event.type
+    if (!event.canvasTarget) {
+      if (type.search(/^(key|text)/i) == 0) {
+        event.canvasTarget = this.root.focused || this.root.target
+      } else {
+        event.canvasTarget = this.root.target
+      }
+      if (!event.canvasTarget)
+        event.canvasTarget = this
+    }
+    var path = []
+    var obj = event.canvasTarget
+    while (obj && obj != this) {
+      path.push(obj)
+      obj = obj.parent
+    }
+    path.push(this)
+    event.canvasPhase = 'capture'
+    for (var i=path.length-1; i>=0; i--)
+      if (!path[i].handleEvent(event)) return false
+    event.canvasPhase = 'bubble'
+    for (var i=0; i<path.length; i++)
+      if (!path[i].handleEvent(event)) return false
+    return true
+  },
+
+  broadcastEvent : function(event) {
+    var type = event.type
+    event.canvasPhase = 'capture'
+    if (!this.handleEvent(event)) return false
+    for (var i=0; i<this.childNodes.length; i++)
+      if (!this.childNodes[i].broadcastEvent(event)) return false
+    event.canvasPhase = 'bubble'
+    if (!this.handleEvent(event)) return false
+    return true
+  },
+
+  handleEvent : function(event) {
+    var type = event.type
+    var phase = event.canvasPhase
+    if (this.cursor && phase == 'capture')
+      event.cursor = this.cursor
+    var els = this.eventListeners[type]
+    els = els && els[phase]
+    if (els) {
+      for (var i=0; i<els.length; i++) {
+        var rv = els[i].call(this, event)
+        if (rv == false || event.stopped) {
+          if (!event.stopped)
+            event.stopPropagation()
+          event.stopped = true
+          return false
+        }
+      }
+    }
+    return true
+  },
+
+  /**
+    Handle scenegraph update.
+    Called with current time before drawing each frame.
+
+    This method should be touched only if you know what you're doing.
+    If you need your own update handler, either add a frame listener or
+    overwrite {@link CanvasNode#update}.
+
+    @param time Current animation time
+    @param timeDelta Time since last frame in milliseconds
+    */
+  handleUpdate : function(time, timeDelta) {
+    this.update(time, timeDelta)
+    this.willBeDrawn = (!this.parent || this.parent.willBeDrawn) && (this.display ? this.display != 'none' : this.visible)
+    for(var i=0; i<this.childNodes.length; i++)
+      this.childNodes[i].handleUpdate(time, timeDelta)
+    // TODO propagate dirty area bbox up the scene graph
+    if (this.parent && this.changed) {
+      this.parent.changed = this.changed
+      this.changed = false
+    }
+    this.needMatrixUpdate = true
+  },
+
+  /**
+    Update this node. Calls all frame listener callbacks in the order they
+    were added.
+
+    Overwrite this with your own method if you want to do things differently.
+
+    @param time Current animation time
+    @param timeDelta Time since last frame in milliseconds
+    */
+  update : function(time, timeDelta) {
+    // need to operate on a copy, otherwise bad stuff happens
+    var fl = this.frameListeners.slice(0)
+    for(var i=0; i<fl.length; i++) {
+      if (this.frameListeners.includes(fl[i]))
+        fl[i].apply(this, arguments)
+    }
+  },
+
+  /**
+    Tests if this node or its subtree is under the mouse cursor and
+    sets this.underCursor accordingly.
+
+    If this node (and not one of its childNodes) is under the mouse cursor
+    this.root.target is set to this. This way, the topmost (== drawn last)
+    node under the mouse cursor is the root target.
+
+    To see whether a subtree node is the current target:
+
+    if (this.underCursor && this.contains(this.root.target)) {
+      // we are the target, let's roll
+    }
+
+    This method should be touched only if you know what you're doing.
+    Overwrite {@link CanvasNode#drawPickingPath} to change the way the node's
+    picking path is created.
+
+    Called after handleUpdate, but before handleDraw.
+
+    @param ctx Canvas 2D context
+    */
+  handlePick : function(ctx) {
+    // CSS display & visibility
+    if (this.display)
+      this.visible = (this.display != 'none')
+    if (this.visibility)
+      this.drawable = (this.visibility != 'hidden')
+    this.underCursor = false
+    if (this.visible && this.catchMouse && this.root.absoluteMouseX != null) {
+      ctx.save()
+      this.transform(ctx, true)
+      if (this.pickable && this.drawable) {
+        if (ctx.isPointInPath) {
+          ctx.beginPath()
+          if (this.drawPickingPath)
+            this.drawPickingPath(ctx)
+        }
+        this.underCursor = CanvasSupport.isPointInPath(
+                              this.drawPickingPath ? ctx : false,
+                              this.root.mouseX,
+                              this.root.mouseY,
+                              this.currentMatrix,
+                              this)
+        if (this.underCursor)
+          this.root.target = this
+      } else {
+        this.underCursor = false
+      }
+      var c = this.__getChildrenCopy()
+      this.__zSort(c)
+      for(var i=0; i<c.length; i++) {
+        c[i].handlePick(ctx)
+        if (!this.underCursor)
+          this.underCursor = c[i].underCursor
+      }
+      ctx.restore()
+    } else {
+      var c = this.__getChildrenCopy()
+      while (c.length > 0) {
+        var c0 = c.pop()
+        if (c0.underCursor) {
+          c0.underCursor = false
+          Array.prototype.push.apply(c, c0.childNodes)
+        }
+      }
+    }
+  },
+
+  __zSort : function(c) {
+    c.stableSort(function(c1,c2) { return c1.zIndex - c2.zIndex; });
+  },
+
+  __getChildrenCopy : function() {
+    if (this.__childNodesCopy) {
+      while (this.__childNodesCopy.length > this.childNodes.length)
+        this.__childNodesCopy.pop()
+      for (var i=0; i<this.childNodes.length; i++)
+        this.__childNodesCopy[i] = this.childNodes[i]
+    } else {
+      this.__childNodesCopy = this.childNodes.slice(0)
+    }
+    return this.__childNodesCopy
+  },
+
+  /**
+    Returns true if the point x,y is inside the path of a drawable node.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,5 will always be inside the rectangle [0, 0, 10, 10], regardless of the
+    transform on the rectangle.
+
+    Leave isPointInPath to false to avoid unnecessary matrix inversions for
+    non-drawables.
+
+    @param x X-coordinate of the point.
+    @param y Y-coordinate of the point.
+    @return Whether the point is inside the path of this node.
+    @type boolean
+    */
+  isPointInPath : false,
+
+  /**
+    Handles transforming and drawing the node and its childNodes
+    on each frame.
+
+    Pushes context state, applies state transforms and draws the node.
+    Then sorts the node's childNodes by zIndex, smallest first, and
+    calls their handleDraws in that order. Finally, pops the context state.
+
+    Called after handleUpdate and handlePick.
+
+    This method should be touched only if you know what you're doing.
+    Overwrite {@link CanvasNode#draw} when you need to draw things.
+
+    @param ctx Canvas 2D context
+    */
+  handleDraw : function(ctx) {
+    // CSS display & visibility
+    if (this.display)
+      this.visible = (this.display != 'none')
+    if (this.visibility)
+      this.drawable = (this.visibility != 'hidden')
+    if (!this.visible) return
+    ctx.save()
+    var pff = ctx.fontFamily
+    var pfs = ctx.fontSize
+    var pfo = ctx.fillOn
+    var pso = ctx.strokeOn
+    if (this.fontFamily)
+      ctx.fontFamily = this.fontFamily
+    if (this.fontSize)
+      ctx.fontSize = this.fontSize
+    this.transform(ctx)
+    if (this.clipPath) {
+      ctx.beginPath()
+      if (this.clipPath.units == this.OBJECTBOUNDINGBOX) {
+        var bb = this.getSubtreeBoundingBox(true)
+        ctx.save()
+        ctx.translate(bb[0], bb[1])
+        ctx.scale(bb[2], bb[3])
+        this.clipPath.createSubtreePath(ctx, true)
+        ctx.restore()
+        ctx.clip()
+      } else {
+        this.clipPath.createSubtreePath(ctx, true)
+        ctx.clip()
+      }
+    }
+    if (this.drawable && this.draw)
+      this.draw(ctx)
+    var c = this.__getChildrenCopy()
+    this.__zSort(c);
+    for(var i=0; i<c.length; i++) {
+      c[i].handleDraw(ctx)
+    }
+    ctx.fontFamily = pff
+    ctx.fontSize = pfs
+    ctx.fillOn = pfo
+    ctx.strokeOn = pso
+    ctx.restore()
+  },
+
+  /**
+    Transforms the context state according to this node's attributes.
+
+    @param ctx Canvas 2D context
+    @param onlyTransform If set to true, only do matrix transforms.
+    */
+  transform : function(ctx, onlyTransform) {
+    Transformable.prototype.transform.call(this, ctx)
+
+    if (onlyTransform) return
+
+    // stroke / fill modifiers
+    if (this.fill != null) {
+      if (!this.fill || this.fill == 'none') {
+        ctx.fillOn = false
+      } else {
+        ctx.fillOn = true
+        if (this.fill != true) {
+          var fillStyle = Colors.parseColorStyle(this.fill, ctx)
+          ctx.setFillStyle( fillStyle )
+        }
+      }
+    }
+    if (this.stroke != null) {
+      if (!this.stroke || this.stroke == 'none') {
+        ctx.strokeOn = false
+      } else {
+        ctx.strokeOn = true
+        if (this.stroke != true)
+          ctx.setStrokeStyle( Colors.parseColorStyle(this.stroke, ctx) )
+      }
+    }
+    if (this.strokeWidth != null)
+      ctx.setLineWidth( this.strokeWidth )
+    if (this.lineCap != null)
+      ctx.setLineCap( this.lineCap )
+    if (this.lineJoin != null)
+      ctx.setLineJoin( this.lineJoin )
+    if (this.miterLimit != null)
+      ctx.setMiterLimit( this.miterLimit )
+
+    // compositing modifiers
+    if (this.absoluteOpacity != null)
+      ctx.setGlobalAlpha( this.absoluteOpacity )
+    if (this.opacity != null)
+      ctx.setGlobalAlpha( ctx.globalAlpha * this.opacity )
+    if (this.compositeOperation != null)
+      ctx.setGlobalCompositeOperation( this.compositeOperation )
+
+    // shadow modifiers
+    if (this.shadowColor != null)
+      ctx.setShadowColor( Colors.parseColorStyle(this.shadowColor, ctx) )
+    if (this.shadowBlur != null)
+      ctx.setShadowBlur( this.shadowBlur )
+    if (this.shadowOffsetX != null)
+      ctx.setShadowOffsetX( this.shadowOffsetX )
+    if (this.shadowOffsetY != null)
+      ctx.setShadowOffsetY( this.shadowOffsetY )
+
+    // text modifiers
+    if (this.textAlign != null)
+      ctx.setTextAlign( this.textAlign )
+    if (this.textBaseline != null)
+      ctx.setTextBaseline( this.textBaseline )
+    if (this.font != null)
+      ctx.setFont( this.font )
+  },
+
+  /**
+    Draws the picking path for the node for testing if the mouse cursor
+    is inside the node.
+
+    False by default, overwrite if you need special behaviour.
+
+    @param ctx Canvas 2D context
+    */
+  drawPickingPath : false,
+
+  /**
+    Draws the node.
+
+    False by default, overwrite to actually draw something.
+
+    @param ctx Canvas 2D context
+    */
+  draw : false,
+
+  createSubtreePath : function(ctx, skipTransform) {
+    ctx.save()
+    if (!skipTransform) this.transform(ctx, true)
+    for (var i=0; i<this.childNodes.length; i++)
+      this.childNodes[i].createSubtreePath(ctx)
+    ctx.restore()
+  },
+
+  getSubtreeBoundingBox : function(identity) {
+    if (identity) {
+      var p = this.parent
+      this.parent = null
+      this.needMatrixUpdate = true
+    }
+    var bb = this.getAxisAlignedBoundingBox()
+    for (var i=0; i<this.childNodes.length; i++) {
+      var cbb = this.childNodes[i].getSubtreeBoundingBox()
+      if (!bb) {
+        bb = cbb
+      } else if (cbb) {
+        this.mergeBoundingBoxes(bb, cbb)
+      }
+    }
+    if (identity) {
+      this.parent = p
+      this.needMatrixUpdate = true
+    }
+    return bb
+  },
+
+  mergeBoundingBoxes : function(bb, bb2) {
+    if (bb[0] > bb2[0]) bb[0] = bb2[0]
+    if (bb[1] > bb2[1]) bb[1] = bb2[1]
+    if (bb[2]+bb[0] < bb2[2]+bb2[0]) bb[2] = bb2[2]+bb2[0]-bb[0]
+    if (bb[3]+bb[1] < bb2[3]+bb2[1]) bb[3] = bb2[3]+bb2[1]-bb[1]
+  },
+
+  getAxisAlignedBoundingBox : function() {
+    this.transform(null, true)
+    if (!this.getBoundingBox) return null
+    var bbox = this.getBoundingBox()
+    var xy1 = CanvasSupport.tMatrixMultiplyPoint(this.currentMatrix,
+      bbox[0], bbox[1])
+    var xy2 = CanvasSupport.tMatrixMultiplyPoint(this.currentMatrix,
+      bbox[0]+bbox[2], bbox[1]+bbox[3])
+    var xy3 = CanvasSupport.tMatrixMultiplyPoint(this.currentMatrix,
+      bbox[0], bbox[1]+bbox[3])
+    var xy4 = CanvasSupport.tMatrixMultiplyPoint(this.currentMatrix,
+      bbox[0]+bbox[2], bbox[1])
+    var x1 = Math.min(xy1[0], xy2[0], xy3[0], xy4[0])
+    var x2 = Math.max(xy1[0], xy2[0], xy3[0], xy4[0])
+    var y1 = Math.min(xy1[1], xy2[1], xy3[1], xy4[1])
+    var y2 = Math.max(xy1[1], xy2[1], xy3[1], xy4[1])
+    return [x1, y1, x2-x1, y2-y1]
+  },
+
+  makeDraggable : function() {
+    this.addEventListener('dragstart', function(ev) {
+      this.dragStartPosition = {x: this.x, y: this.y};
+      ev.stopPropagation();
+      ev.preventDefault();
+      return false;
+    }, false);
+    this.addEventListener('drag', function(ev) {
+      this.x = this.dragStartPosition.x + this.root.dragX / this.parent.currentMatrix[0];
+      this.y = this.dragStartPosition.y + this.root.dragY / this.parent.currentMatrix[3];
+      ev.stopPropagation();
+      ev.preventDefault();
+      return false;
+    }, false);
+  }
+})
+
+
+/**
+  Canvas is the canvas manager class.
+  It takes care of updating and drawing its childNodes on a canvas element.
+
+  An example with a rotating rectangle:
+
+    var c = E.canvas(500, 500)
+    var canvas = new Canvas(c)
+    var rect = new Rectangle(100, 100)
+    rect.x = 250
+    rect.y = 250
+    rect.fill = true
+    rect.fillStyle = 'green'
+    rect.addFrameListener(function(t) {
+      this.rotation = ((t / 3000) % 1) * Math.PI * 2
+    })
+    canvas.append(rect)
+    document.body.appendChild(c)
+
+
+  To use the canvas as a manually updated image:
+
+    var canvas = new Canvas(E.canvas(200,40), {
+      isPlaying : false,
+      redrawOnlyWhenChanged : true
+    })
+    var c = new Circle(20)
+    c.x = 100
+    c.y = 20
+    c.fill = true
+    c.fillStyle = 'red'
+    c.addFrameListener(function(t) {
+      if (this.root.absoluteMouseX != null) {
+        this.x = this.root.mouseX // relative to canvas surface
+        this.root.changed = true
+      }
+    })
+    canvas.append(c)
+
+
+  Or by using raw onFrame-calls:
+
+    var canvas = new Canvas(E.canvas(200,40), {
+      isPlaying : false,
+      fill : true,
+      fillStyle : 'white'
+    })
+    var c = new Circle(20)
+    c.x = 100
+    c.y = 20
+    c.fill = true
+    c.fillStyle = 'red'
+    canvas.append(c)
+    canvas.onFrame()
+
+
+  Which is also the recommended way to use a canvas inside another canvas:
+
+    var canvas = new Canvas(E.canvas(200,40), {
+      isPlaying : false
+    })
+    var c = new Circle(20, {
+      x: 100, y: 20,
+      fill: true, fillStyle: 'red'
+    })
+    canvas.append(c)
+
+    var topCanvas = new Canvas(E.canvas(500, 500))
+    var canvasImage = new ImageNode(canvas.canvas, {x: 250, y: 250})
+    topCanvas.append(canvasImage)
+    canvasImage.addFrameListener(function(t) {
+      this.rotation = (t / 3000 % 1) * Math.PI * 2
+      canvas.onFrame(t)
+    })
+
+  */
+Canvas = Klass(CanvasNode, {
+
+  clear : true,
+  frameLoop : false,
+  recording : false,
+  opacity : 1,
+  frame : 0,
+  elapsed : 0,
+  frameDuration : 30,
+  speed : 1.0,
+  time : 0,
+  fps : 0,
+  currentRealFps : 0,
+  currentFps : 0,
+  fpsFrames : 30,
+  startTime : 0,
+  realFps : 0,
+  fixedTimestep : false,
+  playOnlyWhenFocused : true,
+  isPlaying : true,
+  redrawOnlyWhenChanged : false,
+  changed : true,
+  drawBoundingBoxes : false,
+  cursor : 'default',
+
+  mouseDown : false,
+  mouseEvents : [],
+
+  // absolute pixel coordinates from canvas top-left
+  absoluteMouseX : null,
+  absoluteMouseY : null,
+
+  /*
+    Coordinates relative to the canvas's surface scale.
+    Example:
+      canvas.width
+      #=> 100
+      canvas.style.width
+      #=> '100px'
+      canvas.absoluteMouseX
+      #=> 50
+      canvas.mouseX
+      #=> 50
+
+      canvas.style.width = '200px'
+      canvas.width
+      #=> 100
+      canvas.absoluteMouseX
+      #=> 100
+      canvas.mouseX
+      #=> 50
+  */
+  mouseX : null,
+  mouseY : null,
+
+  elementNodeZIndexCounter : 0,
+
+  initialize : function(canvas, config) {
+    if (arguments.length > 2) {
+      var container = arguments[0]
+      var w = arguments[1]
+      var h = arguments[2]
+      var config = arguments[3]
+      var canvas = E.canvas(w,h)
+      var canvasContainer = E('div', canvas, {style:
+        {overflow:'hidden', width:w+'px', height:h+'px', position:'relative'}
+      })
+      this.canvasContainer = canvasContainer
+      if (container)
+        container.appendChild(canvasContainer)
+    }
+    CanvasNode.initialize.call(this, config)
+    this.mouseEventStack = []
+    this.canvas = canvas
+    canvas.canvas = this
+    this.width = this.canvas.width
+    this.height = this.canvas.height
+    var th = this
+    this.frameHandler = function() { th.onFrame() }
+    this.canvas.addEventListener('DOMNodeInserted', function(ev) {
+      if (ev.target == this)
+        th.addEventListeners()
+    }, false)
+    this.canvas.addEventListener('DOMNodeRemoved', function(ev) {
+      if (ev.target == this)
+        th.removeEventListeners()
+    }, false)
+    if (this.canvas.parentNode) this.addEventListeners()
+    this.startTime = new Date().getTime()
+    if (this.isPlaying)
+      this.play()
+  },
+
+  // FIXME
+  removeEventListeners : function() {
+  },
+
+  addEventListeners : function() {
+    var th = this
+    this.canvas.parentNode.addMouseEvent = function(e){
+      var xy = Mouse.getRelativeCoords(this, e)
+      th.absoluteMouseX = xy.x
+      th.absoluteMouseY = xy.y
+      var style = document.defaultView.getComputedStyle(th.canvas,"")
+      var w = parseFloat(style.getPropertyValue('width'))
+      var h = parseFloat(style.getPropertyValue('height'))
+      th.mouseX = th.absoluteMouseX * (w / th.canvas.width)
+      th.mouseY = th.absoluteMouseY * (h / th.canvas.height)
+      th.addMouseEvent(th.mouseX, th.mouseY, th.mouseDown)
+    }
+    this.canvas.parentNode.contains = this.contains
+
+    this.canvas.parentNode.addEventListener('mousedown', function(e) {
+      th.mouseDown = true
+      if (th.keyTarget != th.target) {
+        if (th.keyTarget)
+          th.dispatchEvent({type: 'blur', canvasTarget: th.keyTarget})
+        th.keyTarget = th.target
+        if (th.keyTarget)
+          th.dispatchEvent({type: 'focus', canvasTarget: th.keyTarget})
+      }
+      this.addMouseEvent(e)
+    }, true)
+
+    this.canvas.parentNode.addEventListener('mouseup', function(e) {
+      this.addMouseEvent(e)
+      th.mouseDown = false
+    }, true)
+
+    this.canvas.parentNode.addEventListener('mousemove', function(e) {
+      this.addMouseEvent(e)
+      if (th.prevClientX == null) {
+        th.prevClientX = e.clientX
+        th.prevClientY = e.clientY
+      }
+      if (th.dragTarget) {
+        var nev = document.createEvent('MouseEvents')
+        nev.initMouseEvent('drag', true, true, window, e.detail,
+          e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey, e.altKey,
+          e.shiftKey, e.metaKey, e.button, e.relatedTarget)
+        nev.canvasTarget = th.dragTarget
+        nev.dx = e.clientX - th.prevClientX
+        nev.dy = e.clientY - th.prevClientY
+        th.dragX += nev.dx
+        th.dragY += nev.dy
+        th.dispatchEvent(nev)
+      }
+      if (!th.mouseDown) {
+        if (th.dragTarget) {
+          var nev = document.createEvent('MouseEvents')
+          nev.initMouseEvent('dragend', true, true, window, e.detail,
+            e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey, e.altKey,
+            e.shiftKey, e.metaKey, e.button, e.relatedTarget)
+          nev.canvasTarget = th.dragTarget
+          th.dispatchEvent(nev)
+          th.dragX = th.dragY = 0
+          th.dragTarget = false
+        }
+      } else if (!th.dragTarget && th.target) {
+        th.dragTarget = th.target
+        var nev = document.createEvent('MouseEvents')
+        nev.initMouseEvent('dragstart', true, true, window, e.detail,
+          e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey, e.altKey,
+          e.shiftKey, e.metaKey, e.button, e.relatedTarget)
+        nev.canvasTarget = th.dragTarget
+        th.dragStartX = e.clientX
+        th.dragStartY = e.clientY
+        th.dragX = th.dragY = 0
+        th.dispatchEvent(nev)
+      }
+      th.prevClientX = e.clientX
+      th.prevClientY = e.clientY
+    }, true)
+
+    this.canvas.parentNode.addEventListener('mouseout', function(e) {
+      if (!CanvasNode.contains.call(this, e.relatedTarget))
+        th.absoluteMouseX = th.absoluteMouseY = th.mouseX = th.mouseY = null
+    }, true)
+
+    var dispatch = this.dispatchEvent.bind(this)
+    var types = [
+      'mousemove', 'mouseover', 'mouseout',
+      'click', 'dblclick',
+      'mousedown', 'mouseup',
+      'keypress', 'keydown', 'keyup',
+      'DOMMouseScroll', 'mousewheel', 'mousemultiwheel', 'textInput',
+      'focus', 'blur'
+    ]
+    for (var i=0; i<types.length; i++) {
+      this.canvas.parentNode.addEventListener(types[i], dispatch, false)
+    }
+    this.keys = {}
+
+    this.windowEventListeners = {
+
+      keydown : function(ev) {
+        if (th.keyTarget) {
+          th.updateKeys(ev)
+          ev.canvasTarget = th.keyTarget
+          th.dispatchEvent(ev)
+        }
+      },
+
+      keyup : function(ev) {
+        if (th.keyTarget) {
+          th.updateKeys(ev)
+          ev.canvasTarget = th.keyTarget
+          th.dispatchEvent(ev)
+        }
+      },
+
+      // do we even want to have this?
+      keypress : function(ev) {
+        if (th.keyTarget) {
+          ev.canvasTarget = th.keyTarget
+          th.dispatchEvent(ev)
+        }
+      },
+
+      blur : function(ev) {
+        th.absoluteMouseX = th.absoluteMouseY = null
+        if (th.playOnlyWhenFocused && th.isPlaying) {
+          th.stop()
+          th.__blurStop = true
+        }
+      },
+
+      focus : function(ev) {
+        if (th.__blurStop && !th.isPlaying) th.play()
+      },
+
+      mouseup : function(e) {
+        th.mouseDown = false
+        if (th.dragTarget) {
+          // TODO
+          // find the object that receives the drag (i.e. drop target)
+          var nev = document.createEvent('MouseEvents')
+          nev.initMouseEvent('dragend', true, true, window, e.detail,
+            e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey, e.altKey,
+            e.shiftKey, e.metaKey, e.button, e.relatedTarget)
+          nev.canvasTarget = th.dragTarget
+          th.dispatchEvent(nev)
+          th.dragTarget = false
+        }
+        if (!th.canvas.parentNode.contains(e.target)) {
+          var rv = th.dispatchEvent(e)
+          if (th.keyTarget) {
+            th.dispatchEvent({type: 'blur', canvasTarget: th.keyTarget})
+            th.keyTarget = null
+          }
+          return rv
+        }
+      },
+
+      mousemove : function(ev) {
+        if (th.__blurStop && !th.isPlaying) th.play()
+        if (!th.canvas.parentNode.contains(ev.target) && th.mouseDown)
+          return th.dispatchEvent(ev)
+      }
+
+    }
+
+    this.canvas.parentNode.addEventListener('DOMNodeRemoved', function(ev) {
+      if (ev.target == this)
+        th.removeWindowEventListeners()
+    }, false)
+    this.canvas.parentNode.addEventListener('DOMNodeInserted', function(ev) {
+      if (ev.target == this)
+        th.addWindowEventListeners()
+    }, false)
+    if (this.canvas.parentNode.parentNode) this.addWindowEventListeners()
+  },
+
+  updateKeys : function(ev) {
+    this.keys.shift = ev.shiftKey
+    this.keys.ctrl = ev.ctrlKey
+    this.keys.alt = ev.altKey
+    this.keys.meta = ev.metaKey
+    var state = (ev.type == 'keydown')
+    switch (ev.keyCode) {
+      case 37: this.keys.left = state; break
+      case 38: this.keys.up = state; break
+      case 39: this.keys.right = state; break
+      case 40: this.keys.down = state; break
+      case 32: this.keys.space = state; break
+      case 13: this.keys.enter = state; break
+      case 9: this.keys.tab = state; break
+      case 8: this.keys.backspace = state; break
+      case 16: this.keys.shift = state; break
+      case 17: this.keys.ctrl = state; break
+      case 18: this.keys.alt = state; break
+    }
+    this.keys[ev.keyCode] = state
+  },
+
+  addWindowEventListeners : function() {
+    for (var i in this.windowEventListeners)
+      window.addEventListener(i, this.windowEventListeners[i], false)
+  },
+
+  removeWindowEventListeners : function() {
+    for (var i in this.windowEventListeners)
+      window.removeEventListener(i, this.windowEventListeners[i], false)
+  },
+
+  addMouseEvent : function(x,y,mouseDown) {
+    var a = this.allocMouseEvent()
+    a[0] = x
+    a[1] = y
+    a[2] = mouseDown
+    this.mouseEvents.push(a)
+  },
+
+  allocMouseEvent : function() {
+    if (this.mouseEventStack.length > 0) {
+      return this.mouseEventStack.pop()
+    } else {
+      return [null, null, null]
+    }
+  },
+
+  freeMouseEvent : function(ev) {
+    this.mouseEventStack.push(ev)
+    if (this.mouseEventStack.length > 100)
+      this.mouseEventStack.splice(0,this.mouseEventStack.length)
+  },
+
+  clearMouseEvents : function() {
+    while (this.mouseEvents.length > 0)
+      this.freeMouseEvent(this.mouseEvents.pop())
+  },
+
+  /**
+    Start frame loop.
+
+    The frame loop is an interval, where #onFrame is called every
+    #frameDuration milliseconds.
+    */
+  play : function() {
+    this.stop()
+    this.realTime = new Date().getTime()
+    this.frameLoop = setInterval(this.frameHandler, this.frameDuration)
+    this.isPlaying = true
+  },
+
+  /**
+    Stop frame loop.
+    */
+  stop : function() {
+    this.__blurStop = false
+    if (this.frameLoop) {
+      clearInterval(this.frameLoop)
+      this.frameLoop = false
+    }
+    this.isPlaying = false
+  },
+
+  dispatchEvent : function(ev) {
+    var rv = CanvasNode.prototype.dispatchEvent.call(this, ev)
+    if (ev.cursor) {
+      if (this.canvas.style.cursor != ev.cursor)
+        this.canvas.style.cursor = ev.cursor
+    } else {
+      if (this.canvas.style.cursor != this.cursor)
+        this.canvas.style.cursor = this.cursor
+    }
+    return rv
+  },
+
+  /**
+    The frame loop function. Called every #frameDuration milliseconds.
+    Takes an optional external time parameter (for syncing Canvases with each
+    other, e.g. when using a Canvas as an image.)
+
+    If the time parameter is given, the second parameter is used as the frame
+    time delta (i.e. the time elapsed since last frame.)
+
+    If time or timeDelta is not given, the canvas computes its own timeDelta.
+
+    @param time The external time. Optional.
+    @param timeDelta Time since last frame in milliseconds. Optional.
+    */
+  onFrame : function(time, timeDelta) {
+    this.elementNodeZIndexCounter = 0
+    var ctx = this.getContext()
+    try {
+      var realTime = new Date().getTime()
+      this.currentRealElapsed = (realTime - this.realTime)
+      this.currentRealFps = 1000 / this.currentRealElapsed
+      var dt = this.frameDuration * this.speed
+      if (!this.fixedTimestep)
+        dt = this.currentRealElapsed * this.speed
+      this.realTime = realTime
+      if (time != null) {
+        this.time = time
+        if (timeDelta)
+          dt = timeDelta
+      } else {
+        this.time += dt
+      }
+      this.previousTarget = this.target
+      this.target = null
+      if (this.catchMouse)
+        this.handlePick(ctx)
+      if (this.previousTarget != this.target) {
+        if (this.previousTarget) {
+          var nev = document.createEvent('MouseEvents')
+          nev.initMouseEvent('mouseout', true, true, window,
+            0, 0, 0, 0, 0, false, false, false, false, 0, null)
+          nev.canvasTarget = this.previousTarget
+          this.dispatchEvent(nev)
+        }
+        if (this.target) {
+          var nev = document.createEvent('MouseEvents')
+          nev.initMouseEvent('mouseover', true, true, window,
+            0, 0, 0, 0, 0, false, false, false, false, 0, null)
+          nev.canvasTarget = this.target
+          this.dispatchEvent(nev)
+        }
+      }
+      this.handleUpdate(this.time, dt)
+      this.clearMouseEvents()
+      if (!this.redrawOnlyWhenChanged || this.changed) {
+        try {
+          this.handleDraw(ctx)
+        } catch(e) {
+          console.log(e)
+          throw(e)
+        }
+        this.changed = false
+      }
+      this.currentElapsed = (new Date().getTime() - this.realTime)
+      this.elapsed += this.currentElapsed
+      this.currentFps = 1000 / this.currentElapsed
+      this.frame++
+      if (this.frame % this.fpsFrames == 0) {
+        this.fps = this.fpsFrames*1000 / (this.elapsed)
+        this.realFps = this.fpsFrames*1000 / (new Date().getTime() - this.startTime)
+        this.elapsed = 0
+        this.startTime = new Date().getTime()
+      }
+    } catch(e) {
+      if (ctx) {
+        // screwed up, context is borked
+        try {
+          // FIXME don't be stupid
+          for (var i=0; i<1000; i++)
+            ctx.restore()
+        } catch(er) {}
+      }
+      delete this.context
+      throw(e)
+    }
+  },
+
+  /**
+    Returns the canvas drawing context object.
+
+    @return Canvas drawing context
+    */
+  getContext : function() {
+    if (this.recording)
+      return this.getRecordingContext()
+    else if (this.useMockContext)
+      return this.getMockContext()
+    else
+      return this.get2DContext()
+  },
+
+  /**
+    Gets and returns an augmented canvas 2D drawing context.
+
+    The canvas 2D context is augmented by setter functions for all
+    its instance variables, making it easier to record canvas operations in
+    a cross-browser fashion.
+    */
+  get2DContext : function() {
+    if (!this.context) {
+      var ctx = CanvasSupport.getContext(this.canvas, '2d')
+      this.context = ctx
+    }
+    return this.context
+  },
+
+  /**
+    Creates and returns a mock drawing context.
+
+    @return Mock drawing context
+    */
+  getMockContext : function() {
+    if (!this.fakeContext) {
+      var ctx = this.get2DContext()
+      this.fakeContext = {}
+      var f = function(){ return this }
+      for (var i in ctx) {
+        if (typeof(ctx[i]) == 'function')
+          this.fakeContext[i] = f
+        else
+          this.fakeContext[i] = ctx[i]
+      }
+      this.fakeContext.isMockObject = true
+      this.fakeContext.addColorStop = f
+    }
+    return this.fakeContext
+  },
+
+  getRecordingContext : function() {
+    if (!this.recordingContext)
+      this.recordingContext = new RecordingContext()
+    return this.recordingContext
+  },
+
+  /**
+    Canvas drawPickingPath uses the canvas rectangle as its path.
+
+    @param ctx Canvas drawing context
+    */
+  drawPickingPath : function(ctx) {
+    ctx.rect(0,0, this.canvas.width, this.canvas.height)
+  },
+
+  isPointInPath : function(x,y) {
+    return ((x >= 0) && (x <= this.canvas.width) && (y >= 0) && (y <= this.canvas.height))
+  },
+
+  /**
+    Sets globalAlpha to this.opacity and clears the canvas if #clear is set to
+    true. If #fill is also set to true, fills the canvas rectangle instead of
+    clearing (using #fillStyle as the color.)
+
+    @param ctx Canvas drawing context
+    */
+  draw : function(ctx) {
+    ctx.setGlobalAlpha( this.opacity )
+    if (this.clear) {
+      if (ctx.fillOn) {
+        ctx.beginPath()
+        ctx.rect(0,0, this.canvas.width, this.canvas.height)
+        ctx.fill()
+      } else {
+        ctx.clearRect(0,0, this.canvas.width, this.canvas.height)
+      }
+    }
+    // set default fill and stroke for the canvas contents
+    ctx.fillStyle = 'black'
+    ctx.strokeStyle = 'black'
+    ctx.fillOn = false
+    ctx.strokeOn = false
+  }
+})
+
+
+/**
+  Hacky link class for emulating <a>.
+
+  The correct way would be to have a real <a> under the cursor while hovering
+  this, or an imagemap polygon built from the clipped subtree path.
+
+  @param href Link href.
+  @param target Link target, defaults to _self.
+  @param config Optional config hash.
+  */
+LinkNode = Klass(CanvasNode, {
+  href : null,
+  target : '_self',
+  cursor : 'pointer',
+
+  initialize : function(href, target, config) {
+    this.href = href
+    if (target)
+      this.target = target
+    CanvasNode.initialize.call(this, config)
+    this.setupLinkEventListeners()
+  },
+
+  setupLinkEventListeners : function() {
+    this.addEventListener('click', function(ev) {
+      if (ev.button == Mouse.RIGHT) return
+      var target = this.target
+      if ((ev.ctrlKey || ev.button == Mouse.MIDDLE) && target == '_self')
+        target = '_blank'
+      window.open(this.href, target)
+    }, false)
+  }
+})
+
+
+/**
+  AudioNode is a CanvasNode used to play a sound.
+
+  */
+AudioNode = Klass(CanvasNode, {
+  ready : false,
+  autoPlay : false,
+  playing : false,
+  paused : false,
+  pan : 0,
+  volume : 1,
+  loop : false,
+
+  transformSound : false,
+
+  initialize : function(filename, params) {
+    CanvasNode.initialize.call(this, params)
+    this.filename = filename
+    this.when('load', this._autoPlaySound)
+    this.loadSound()
+  },
+
+  loadSound : function() {
+    this.sound = CanvasSupport.getSoundObject()
+    if (!this.sound) return
+    var self = this
+    this.sound.onready = function() {
+      self.ready = true
+      self.root.dispatchEvent({type: 'ready', canvasTarget: self})
+    }
+    this.sound.onload = function() {
+      self.loaded = true
+      self.root.dispatchEvent({type: 'load', canvasTarget: self})
+    }
+    this.sound.onerror = function() {
+      self.root.dispatchEvent({type: 'error', canvasTarget: self})
+    }
+    this.sound.onfinish = function() {
+      if (self.loop) self.play()
+      else self.stop()
+    }
+    this.sound.load(this.filename)
+  },
+
+  play : function() {
+    this.playing = true
+    this.needPlayUpdate = true
+  },
+
+  stop : function() {
+    this.playing = false
+    this.needPlayUpdate = true
+  },
+
+  pause : function() {
+    if (this.needPauseUpdate) {
+      this.needPauseUpdate = false
+      return
+    }
+    this.paused = !this.paused
+    this.needPauseUpdate = true
+  },
+
+  setVolume : function(v) {
+    this.volume = v
+    this.needStatusUpdate = true
+  },
+
+  setPan : function(p) {
+    this.pan = p
+    this.needStatusUpdate = true
+  },
+
+  handleUpdate : function() {
+    CanvasNode.handleUpdate.apply(this, arguments)
+    if (this.willBeDrawn) {
+      this.transform(null, true)
+      if (!this.sound) this.loadSound()
+      if (this.ready) {
+        if (this.transformSound) {
+          var x = this.currentMatrix[4]
+          var y = this.currentMatrix[5]
+          var a = this.currentMatrix[2]
+          var b = this.currentMatrix[3]
+          var c = this.currentMatrix[0]
+          var d = this.currentMatrix[1]
+          var hw = this.root.width * 0.5
+          var ys = Math.sqrt(a*a + b*b)
+          var xs = Math.sqrt(c*c + d*d)
+          this.setVolume(ys)
+          this.setPan((x - hw) / hw)
+        }
+        if (this.needPauseUpdate) {
+          this.needPauseUpdate = false
+          this._pauseSound()
+        }
+        if (this.needPlayUpdate) {
+          this.needPlayUpdate = false
+          if (this.playing) this._playSound()
+          else this._stopSound()
+        }
+        if (this.needStatusUpdate) {
+          this._setSoundVolume()
+          this._setSoundPan()
+        }
+      }
+    }
+  },
+
+  _autoPlaySound : function() {
+    if (this.autoPlay) this.play()
+  },
+
+  _setSoundVolume : function() {
+    this.sound.setVolume(this.volume)
+  },
+
+  _setSoundPan : function() {
+    this.sound.setPan(this.pan)
+  },
+
+  _playSound : function() {
+    if (this.sound.play() == false)
+      return this.playing = false
+    this.root.dispatchEvent({type: 'play', canvasTarget: this})
+  },
+
+  _stopSound : function() {
+    this.sound.stop()
+    this.root.dispatchEvent({type: 'stop', canvasTarget: this})
+  },
+
+  _pauseSound : function() {
+    this.sound.pause()
+    this.root.dispatchEvent({type: this.paused ? 'pause' : 'play', canvasTarget: this})
+  }
+})
+
+
+/**
+  ElementNode is a CanvasNode that has an HTML element as its content.
+
+  The content is added to an absolutely positioned HTML element, which is added
+  to the root node's canvases parentNode. The content element follows the
+  current transformation matrix.
+
+  The opacity of the element is set to the globalAlpha of the drawing context
+  unless #noAlpha is true.
+
+  The font-size of the element is set to the current y-scale unless #noScaling
+  is true.
+
+  Use ElementNode when you need accessible web content in your animations.
+
+    var e = new ElementNode(
+      E('h1', 'HERZLICH WILLKOMMEN IM BAHNHOF'),
+      {
+        x : 40,
+        y : 30
+      }
+    )
+    e.addFrameListener(function(t) {
+      this.scale = 1 + 0.5*Math.cos(t/1000)
+    })
+
+  @param content An HTML element or string of HTML to use as the content.
+  @param config Optional config has.
+  */
+ElementNode = Klass(CanvasNode, {
+  noScaling : false,
+  noAlpha : false,
+  inherit : 'inherit',
+  align: null, // left | center | right
+  valign: null, // top | center | bottom
+  xOffset: 0,
+  yOffset: 0,
+
+  initialize : function(content, config) {
+    CanvasNode.initialize.call(this, config)
+    this.content = content
+    this.element = E('div', content)
+    this.element.style.MozTransformOrigin =
+    this.element.style.webkitTransformOrigin = '0 0'
+    this.element.style.position = 'absolute'
+  },
+
+  clone : function() {
+    var c = CanvasNode.prototype.clone.call(this)
+    if (this.content && this.content.cloneNode)
+      c.content = this.content.cloneNode(true)
+    c.element = E('div', c.content)
+    c.element.style.position = 'absolute'
+    c.element.style.MozTransformOrigin =
+    c.element.style.webkitTransformOrigin = '0 0'
+    return c
+  },
+
+  setRoot : function(root) {
+    CanvasNode.setRoot.call(this, root)
+    if (this.element && this.element.parentNode && this.element.parentNode.removeChild)
+      this.element.parentNode.removeChild(this.element)
+  },
+
+  handleUpdate : function(t, dt) {
+    CanvasNode.handleUpdate.call(this, t, dt)
+    if (!this.willBeDrawn || !this.visible || this.display == 'none' || this.visibility == 'hidden' || !this.drawable) {
+      if (this.element.style.display != 'none')
+        this.element.style.display = 'none'
+    } else if (this.element.style.display == 'none') {
+      this.element.style.display = 'block'
+    }
+  },
+
+  addEventListener : function(event, callback, capture) {
+    var th = this
+    var ccallback = function() { callback.apply(th, arguments) }
+    return this.element.addEventListener(event, ccallback, capture||false)
+  },
+
+  removeEventListener : function(event, callback, capture) {
+    var th = this
+    var ccallback = function() { callback.apply(th, arguments) }
+    return this.element.removeEventListener(event, ccallback, capture||false)
+  },
+
+  draw : function(ctx) {
+    if (this.cursor && this.element.style.cursor != this.cursor)
+      this.element.style.cursor = this.cursor
+    if (this.element.style.zIndex != this.root.elementNodeZIndexCounter)
+      this.element.style.zIndex = this.root.elementNodeZIndexCounter
+    this.root.elementNodeZIndexCounter++
+    var baseTransform = this.currentMatrix
+    xo = this.xOffset
+    yo = this.yOffset
+    if (this.fillBoundingBox && this.parent && this.parent.getBoundingBox) {
+      var bb = this.parent.getBoundingBox()
+      xo += bb[0]
+      yo += bb[1]
+    }
+    var xy = CanvasSupport.tMatrixMultiplyPoint(baseTransform.slice(0,4).concat([0,0]),
+      xo, yo)
+    var x = this.currentMatrix[4] + xy[0]
+    var y = this.currentMatrix[5] + xy[1]
+    var a = this.currentMatrix[2]
+    var b = this.currentMatrix[3]
+    var c = this.currentMatrix[0]
+    var d = this.currentMatrix[1]
+    var ys = Math.sqrt(a*a + b*b)
+    var xs = Math.sqrt(c*c + d*d)
+    if (ctx.fontFamily != null)
+      this.element.style.fontFamily = ctx.fontFamily
+
+    var wkt = CanvasSupport.isCSSTransformSupported()
+    if (wkt && !this.noScaling) {
+      this.element.style.MozTransform =
+      this.element.style.webkitTransform = 'matrix('+baseTransform.join(",")+')'
+    } else {
+      this.element.style.MozTransform =
+      this.element.style.webkitTransform = ''
+    }
+    if (ctx.fontSize != null) {
+      if (this.noScaling || wkt) {
+        this.element.style.fontSize = ctx.fontSize + 'px'
+      } else {
+        this.element.style.fontSize = ctx.fontSize * ys + 'px'
+      }
+    } else {
+      if (this.noScaling || wkt) {
+        this.element.style.fontSize = 'inherit'
+      } else {
+        this.element.style.fontSize = 100 * ys + '%'
+      }
+    }
+    if (this.noAlpha)
+      this.element.style.opacity = 1
+    else
+      this.element.style.opacity = ctx.globalAlpha
+    if (!this.element.parentNode && this.root.canvas.parentNode) {
+      this.element.style.visibility = 'hidden'
+      this.root.canvas.parentNode.appendChild(this.element)
+      var hidden = true
+    }
+    var fs = this.color || this.fill
+    if (this.parent) {
+      if (!fs || !fs.length)
+        fs = this.parent.color
+      if (!fs || !fs.length)
+        fs = this.parent.fill
+    }
+    if (!fs || !fs.length)
+      fs = ctx.fillStyle
+    if (typeof(fs) == 'string') {
+      if (fs.search(/^rgba\(/) != -1) {
+        this.element.style.color = 'rgb(' +
+          fs.match(/\d+/g).slice(0,3).join(",") +
+          ')'
+      } else {
+        this.element.style.color = fs
+      }
+    } else if (fs.length) {
+      this.element.style.color = 'rgb(' + fs.slice(0,3).map(Math.floor).join(",") + ')'
+    }
+    var dx = 0, dy = 0
+    if (bb) {
+      this.element.style.width = Math.floor(xs * bb[2]) + 'px'
+      this.element.style.height = Math.floor(ys * bb[3]) + 'px'
+      this.eWidth = xs
+      this.eHeight = ys
+    } else {
+      this.element.style.width = ''
+      this.element.style.height = ''
+      var align = this.align || this.textAnchor
+      var origin = [0,0]
+      if (align == 'center' || align == 'middle') {
+        dx = -this.element.offsetWidth / 2
+        origin[0] = '50%'
+      } else if (align == 'right') {
+        dx = -this.element.offsetWidth
+        origin[0] = '100%'
+      }
+      var valign = this.valign
+      if (valign == 'center' || valign == 'middle') {
+        dy = -this.element.offsetHeight / 2
+        origin[1] = '50%'
+      } else if (valign == 'bottom') {
+        dy = -this.element.offsetHeight
+        origin[1] = '100%'
+      }
+      this.element.style.webkitTransformOrigin =
+      this.element.style.MozTransformOrigin = origin.join(" ")
+      this.eWidth = this.element.offsetWidth / xs
+      this.eHeight = this.element.offsetHeight / ys
+    }
+    if (wkt && !this.noScaling) {
+      this.element.style.left = Math.floor(dx) + 'px'
+      this.element.style.top = Math.floor(dy) + 'px'
+    } else {
+      this.element.style.left = Math.floor(x+dx) + 'px'
+      this.element.style.top = Math.floor(y+dy) + 'px'
+    }
+    if (hidden)
+      this.element.style.visibility = 'visible'
+  }
+})
+
+
+/**
+  A Drawable is a CanvasNode with possible fill, stroke and clip.
+
+  It draws the path by calling #drawGeometry
+  */
+Drawable = Klass(CanvasNode, {
+  pickable : true,
+  //   'inside' // clip before drawing the stroke
+  // | 'above'  // draw stroke after the fill
+  // | 'below'  // draw stroke before the fill
+  strokeMode : 'above',
+
+  ABOVE : 'above', BELOW : 'below', INSIDE : 'inside',
+
+  initialize : function(config) {
+    CanvasNode.initialize.call(this, config)
+  },
+
+  /**
+    Draws the picking path for the Drawable.
+
+    The default version begins a new path and calls drawGeometry.
+
+    @param ctx Canvas drawing context
+    */
+  drawPickingPath : function(ctx) {
+    if (!this.drawGeometry) return
+    ctx.beginPath()
+    this.drawGeometry(ctx)
+  },
+
+  /**
+    Returns true if the point x,y is inside the path of a drawable node.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,5 will always be inside the rectangle [0, 0, 10, 10], regardless of the
+    transform on the rectangle.
+
+    @param x X-coordinate of the point.
+    @param y Y-coordinate of the point.
+    @return Whether the point is inside the path of this node.
+    @type boolean
+    */
+  isPointInPath : function(x, y) {
+    return false
+  },
+
+  isVisible : function(ctx) {
+    var abb = this.getAxisAlignedBoundingBox()
+    if (!abb) return true
+    var x1 = abb[0], x2 = abb[0]+abb[2], y1 = abb[1], y2 = abb[1]+abb[3]
+    var w = this.root.width
+    var h = this.root.height
+    if (this.root.drawBoundingBoxes) {
+      ctx.save()
+        var bbox = this.getBoundingBox()
+        ctx.beginPath()
+        ctx.rect(bbox[0], bbox[1], bbox[2], bbox[3])
+        ctx.strokeStyle = 'green'
+        ctx.lineWidth = 1
+        ctx.stroke()
+      ctx.restore()
+      ctx.save()
+        CanvasSupport.setTransform(ctx, [1,0,0,1,0,0], this.currentMatrix)
+        ctx.beginPath()
+        ctx.rect(x1, y1, x2-x1, y2-y1)
+        ctx.strokeStyle = 'red'
+        ctx.lineWidth = 1.5
+        ctx.stroke()
+      ctx.restore()
+    }
+    var visible = !(x2 < 0 || x1 > w || y2 < 0 || y1 > h)
+    return visible
+  },
+
+  createSubtreePath : function(ctx, skipTransform) {
+    ctx.save()
+    if (!skipTransform) this.transform(ctx, true)
+    if (this.drawGeometry) this.drawGeometry(ctx)
+    for (var i=0; i<this.childNodes.length; i++)
+      this.childNodes[i].createSubtreePath(ctx)
+    ctx.restore()
+  },
+
+  /**
+    Draws the Drawable. Begins a path and calls this.drawGeometry, followed by
+    possibly filling, stroking and clipping the path, depending on whether
+    #fill, #stroke and #clip are set.
+
+    @param ctx Canvas drawing context
+    */
+  draw : function(ctx) {
+    if (!this.drawGeometry) return
+    // bbox checking is slower than just drawing in most cases.
+    // and caching the bboxes is hard to do correctly.
+    // plus, bboxes aren't hierarchical.
+    // so we are being glib :|
+    if (this.root.drawBoundingBoxes)
+      this.isVisible(ctx)
+    var ft = (ctx.fillStyle.transformList ||
+              ctx.fillStyle.matrix ||
+              ctx.fillStyle.scale != null ||
+              ctx.fillStyle.rotation ||
+              ctx.fillStyle.x ||
+              ctx.fillStyle.y )
+    var st = (ctx.strokeStyle.transformList ||
+              ctx.strokeStyle.matrix ||
+              ctx.strokeStyle.scale != null ||
+              ctx.strokeStyle.rotation ||
+              ctx.strokeStyle.x ||
+              ctx.strokeStyle.y )
+    ctx.beginPath()
+    this.drawGeometry(ctx)
+    if (ctx.strokeOn) {
+      switch (this.strokeMode) {
+        case this.ABOVE:
+          if (ctx.fillOn) this.doFill(ctx,ft)
+          this.doStroke(ctx, st)
+          break
+        case this.BELOW:
+          this.doStroke(ctx, st)
+          if (ctx.fillOn) this.doFill(ctx,ft)
+          break
+        case this.INSIDE:
+          if (ctx.fillOn) this.doFill(ctx,ft)
+          ctx.save()
+          var lw = ctx.lineWidth
+          ctx.setLineWidth(1)
+          this.doStroke(ctx, st)
+          ctx.setLineWidth(lw)
+          ctx.clip()
+          this.doStroke(ctx, st)
+          ctx.restore()
+          break
+      }
+    } else if (ctx.fillOn) {
+      this.doFill(ctx,ft)
+    }
+    this.drawMarkers(ctx)
+    if (this.clip) ctx.clip()
+  },
+
+  doFill : function(ctx, ft) {
+    if (ft || (this.getBoundingBox && ctx.fillStyle.units == this.OBJECTBOUNDINGBOX)) {
+      ctx.save()
+      if (this.getBoundingBox && ctx.fillStyle.units == this.OBJECTBOUNDINGBOX) {
+        var bb = this.getBoundingBox()
+        var sx = bb[2]
+        var sy = bb[3]
+        ctx.translate(bb[0],bb[1])
+        ctx.scale(sx,sy)
+      }
+      ctx.fillStyle.transform(ctx)
+    }
+    if (this.fillOpacity != null) {
+      var go = ctx.globalAlpha
+      ctx.setGlobalAlpha(go * this.fillOpacity)
+      ctx.fill()
+      ctx.globalAlpha = go
+    } else {
+      ctx.fill()
+    }
+    if (ft) ctx.restore()
+  },
+
+  doStroke : function(ctx, st) {
+    if (st || (this.getBoundingBox && ctx.strokeStyle.units == this.OBJECTBOUNDINGBOX)) {
+      ctx.save()
+      if (this.getBoundingBox && ctx.strokeStyle.units == this.OBJECTBOUNDINGBOX) {
+        var bb = this.getBoundingBox()
+        var sx = bb[2]
+        var sy = bb[3]
+        ctx.translate(bb[0],bb[1])
+        ctx.scale(sx,sy)
+      }
+      ctx.strokeStyle.needMatrixUpdate = true
+      ctx.strokeStyle.transform(ctx)
+      if (sx != null)
+        CanvasSupport.tScale(ctx.strokeStyle.currentMatrix, sx, sy)
+      var cm = ctx.strokeStyle.currentMatrix
+      // fix stroke width scale (non-uniform scales screw us up though)
+      var sw = Math.sqrt(Math.max(
+        cm[0]*cm[0] + cm[1]*cm[1],
+        cm[2]*cm[2] + cm[3]*cm[3]
+      ))
+      ctx.setLineWidth(((ctx.lineWidth == null) ? 1 : ctx.lineWidth) / sw)
+    }
+    if (this.strokeOpacity != null) {
+      var go = ctx.globalAlpha
+      ctx.setGlobalAlpha(go * this.strokeOpacity)
+      ctx.stroke()
+      ctx.globalAlpha = go
+    } else {
+      ctx.stroke()
+    }
+    if (st) ctx.restore()
+  },
+
+  drawMarkers : function(ctx) {
+    var sm = this.markerStart || this.marker
+    var em = this.markerEnd || this.marker
+    var mm = this.markerMid || this.marker
+    if (sm && this.getStartPoint) {
+      var pa = this.getStartPoint()
+      if (sm.orient != null && sm.orient != 'auto')
+        pa.angle = sm.orient
+      var scale = (sm.markerUnits == 'strokeWidth') ? ctx.lineWidth : 1
+      ctx.save()
+        ctx.translate(pa.point[0], pa.point[1])
+        ctx.scale(scale, scale)
+        ctx.rotate(pa.angle)
+        var mat = CanvasSupport.tRotate(
+          CanvasSupport.tScale(
+          CanvasSupport.tTranslate(
+            this.currentMatrix.slice(0),
+            pa.point[0], pa.point[1]
+          ), scale, scale), pa.angle)
+        sm.__copyMatrix(mat)
+        sm.handleDraw(ctx)
+      ctx.restore()
+    }
+    if (em && this.getEndPoint) {
+      var pa = this.getEndPoint()
+      if (em.orient != null && em.orient != 'auto')
+        pa.angle = em.orient
+      var scale = (em.markerUnits == 'strokeWidth') ? ctx.lineWidth : 1
+      ctx.save()
+        ctx.translate(pa.point[0], pa.point[1])
+        ctx.scale(scale, scale)
+        ctx.rotate(pa.angle)
+        var mat = CanvasSupport.tRotate(
+          CanvasSupport.tScale(
+          CanvasSupport.tTranslate(
+            this.currentMatrix.slice(0),
+            pa.point[0], pa.point[1]
+          ), scale, scale), pa.angle)
+        em.__copyMatrix(mat)
+        em.handleDraw(ctx)
+      ctx.restore()
+    }
+    if (mm && this.getMidPoints) {
+      var pas = this.getMidPoints()
+      var scale = (mm.markerUnits == 'strokeWidth') ? ctx.lineWidth : 1
+      for (var i=0; i<pas.length; i++) {
+        var pa = pas[i]
+        ctx.save()
+          ctx.translate(pa.point[0], pa.point[1])
+          ctx.scale(scale, scale)
+          if (mm.orient != null && mm.orient != 'auto')
+            pa.angle = em.orient
+          ctx.rotate(pa.angle)
+          var mat = CanvasSupport.tRotate(
+            CanvasSupport.tScale(
+            CanvasSupport.tTranslate(
+              this.currentMatrix.slice(0),
+              pa.point[0], pa.point[1]
+            ), scale, scale), pa.angle)
+          mm.__copyMatrix(mat)
+          mm.handleDraw(ctx)
+        ctx.restore()
+      }
+    }
+  },
+
+  getStartPoint : false,
+  getEndPoint : false,
+  getMidPoints : false,
+  getBoundingBox : false
+
+})
+
+
+/**
+  A Line is a line drawn from x1,y1 to x2,y2. Lines are stroked by default.
+
+  @param x1 X-coordinate of the line's first point.
+  @param y1 Y-coordinate of the line's first point.
+  @param x2 X-coordinate of the line's second point.
+  @param y2 Y-coordinate of the line's second point.
+  @param config Optional config hash.
+  */
+Line = Klass(Drawable, {
+  x1 : 0,
+  y1 : 0,
+  x2 : 0,
+  y2 : 0,
+  stroke : true,
+
+  initialize : function(x1,y1, x2,y2, config) {
+    this.x1 = x1
+    this.y1 = y1
+    this.x2 = x2
+    this.y2 = y2
+    Drawable.initialize.call(this, config)
+  },
+
+  drawGeometry : function(ctx) {
+    ctx.moveTo(this.x1, this.y1)
+    ctx.lineTo(this.x2, this.y2)
+  },
+
+  getStartPoint : function() {
+    return {
+      point: [this.x1, this.y1],
+      angle: Math.atan2(this.y2-this.y1, this.x2-this.x1)
+    }
+  },
+
+  getEndPoint : function() {
+    return {
+      point: [this.x2, this.y2],
+      angle: Math.atan2(this.y2-this.y1, this.x2-this.x1)
+    }
+  },
+
+  getBoundingBox : function() {
+    return [this.x1, this.y1, this.x2-this.x1, this.y2-this.y1]
+  },
+
+  getLength : function() {
+    return Curves.lineLength([this.x1, this.y1], [this.x2, this.y2])
+  }
+
+})
+
+
+
+/**
+  Circle is used for creating circular paths.
+
+  Uses context.arc(...).
+
+  Attributes:
+    cx, cy, radius, startAngle, endAngle, clockwise, closePath, includeCenter
+
+  @param radius Radius of the circle.
+  @param config Optional config hash.
+  */
+Circle = Klass(Drawable, {
+  cx : 0,
+  cy : 0,
+  radius : 10,
+  startAngle : 0,
+  endAngle : Math.PI * 2,
+  clockwise : false,
+  closePath : true,
+  includeCenter : false,
+
+  initialize : function(radius, config) {
+    if (radius != null) this.radius = radius
+    Drawable.initialize.call(this, config)
+  },
+
+  /**
+    Creates a circular path using ctx.arc(...).
+
+    @param ctx Canvas drawing context.
+    */
+  drawGeometry : function(ctx) {
+    if (this.radius == 0) return
+    if (this.includeCenter)
+      ctx.moveTo(this.cx, this.cy)
+    ctx.arc(this.cx, this.cy, this.radius, this.startAngle, this.endAngle, this.clockwise)
+    if (this.closePath) {
+      // firefox 2 is buggy without the endpoint
+      var x2 = Math.cos(this.endAngle)
+      var y2 = Math.sin(this.endAngle)
+      ctx.moveTo(this.cx + x2*this.radius, this.cy + y2 * this.radius)
+      ctx.closePath()
+    }
+  },
+
+  /**
+    Returns true if the point x,y is inside the radius of the circle.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,0 will always be inside a circle with radius of 10 and center at origin,
+    regardless of the transform on the circle.
+
+    @param x X-coordinate of the point.
+    @param y Y-coordinate of the point.
+    @return Whether the point is inside the radius of this circle.
+    @type boolean
+    */
+  isPointInPath : function(x,y) {
+    x -= this.cx
+    y -= this.cy
+    return (x*x + y*y) <= (this.radius*this.radius)
+  },
+
+  getBoundingBox : function() {
+    return [this.cx-this.radius, this.cy-this.radius,
+            2*this.radius, 2*this.radius]
+  }
+})
+
+
+/**
+  Ellipse is a scaled circle. Except it isn't. Because that wouldn't work in
+  Opera.
+  */
+Ellipse = Klass(Circle, {
+  radiusX : 0,
+  radiusY : 0,
+
+  initialize : function(radiusX, radiusY, config) {
+    this.radiusX = radiusX
+    this.radiusY = radiusY
+    Circle.initialize.call(this, 1, config)
+  },
+
+  drawGeometry : function(ctx) {
+    if (this.radiusX == 0 || this.radiusY == 0) return
+    var k = 0.5522847498
+    var x = this.cx
+    var y = this.cy
+    var krx = k*this.radiusX
+    var kry = k*this.radiusY
+    ctx.moveTo(x+this.radiusX, y)
+    ctx.bezierCurveTo(x+this.radiusX, y-kry, x+krx, y-this.radiusY, x, y-this.radiusY)
+    ctx.bezierCurveTo(x-krx, y-this.radiusY, x-this.radiusX, y-kry, x-this.radiusX, y)
+    ctx.bezierCurveTo(x-this.radiusX, y+kry, x-krx, y+this.radiusY, x, y+this.radiusY)
+    ctx.bezierCurveTo(x+krx, y+this.radiusY, x+this.radiusX, y+kry, x+this.radiusX, y)
+  },
+
+  isPointInPath : function(x, y) {
+    // does this work?
+    x -= this.cx
+    y -= this.cy
+    x /= this.radiusX
+    y /= this.radiusY
+    return (x*x + y*y) <= 1
+  },
+
+  getBoundingBox : function() {
+    return [this.cx-this.radiusX, this.cy-this.radiusY,
+            this.radiusX*2, this.radiusY*2]
+  }
+})
+
+
+/**
+  A Spiral is a function graph drawn in polar coordinates from startAngle to
+  endAngle. And the source of all life energy, etc.
+  */
+Spiral = Klass(Drawable, {
+  cx : 0,
+  cy : 0,
+  startRadius : 0,
+  startAngle : 0,
+  endAngle : 0,
+
+  radiusFunction : function(a) {
+    return a
+  },
+
+  initialize : function(endAngle, config) {
+    this.endAngle = endAngle
+    Drawable.initialize.call(this, config)
+  },
+
+  drawGeometry : function(ctx) {
+    var x = this.cx
+    var y = this.cy
+    var a = this.startAngle
+    var r = this.startRadius + this.radiusFunction(a)
+    ctx.moveTo(x+Math.cos(a)*r, y-Math.sin(a)*r)
+    if (this.startAngle < this.endAngle) {
+      a += 0.1
+      r = this.startRadius + this.radiusFunction(a)
+      while (a < this.endAngle) {
+        ctx.lineTo(x+Math.cos(a)*r, y-Math.sin(a)*r)
+        a += 0.1
+        r = this.startRadius + this.radiusFunction(a)
+      }
+    } else {
+      a -= 0.1
+      r = this.startRadius + this.radiusFunction(a)
+      while (a > this.endAngle) {
+        ctx.lineTo(x+Math.cos(a)*r, y-Math.sin(a)*r)
+        a -= 0.1
+        r = this.startRadius + this.radiusFunction(a)
+      }
+    }
+    a = this.endAngle
+    r = this.startRadius + this.radiusFunction(a)
+    ctx.lineTo(x+Math.cos(a)*r, y-Math.sin(a)*r)
+  },
+
+  isPointInPath : function(x, y) {
+    return false
+  }
+})
+
+
+/**
+  Rectangle is used for creating rectangular paths.
+
+  Uses context.rect(...).
+
+  Attributes:
+    cx, cy, width, height, centered, rx, ry
+
+  If centered is set to true, centers the rectangle on the origin.
+  Otherwise the top-left corner of the rectangle is on the origin.
+
+  @param width Width of the rectangle.
+  @param height Height of the rectangle.
+  @param config Optional config hash.
+  */
+Rectangle = Klass(Drawable, {
+  cx : 0,
+  cy : 0,
+  x2 : 0,
+  y2 : 0,
+  width : 0,
+  height : 0,
+  rx : 0,
+  ry : 0,
+  centered : false,
+
+  initialize : function(width, height, config) {
+    if (width != null) {
+      this.width = width
+      this.height = width
+    }
+    if (height != null) this.height = height
+    Drawable.initialize.call(this, config)
+  },
+
+  /**
+    Creates a rectangular path using ctx.rect(...).
+
+    @param ctx Canvas drawing context.
+    */
+  drawGeometry : function(ctx) {
+    var x = this.cx
+    var y = this.cy
+    var w = (this.width || (this.x2 - x))
+    var h = (this.height || (this.y2 - y))
+    if (w == 0 || h == 0) return
+    if (this.centered) {
+      x -= 0.5*w
+      y -= 0.5*h
+    }
+    if (this.rx || this.ry) {
+      // hahaa, welcome to the undocumented rounded corners path
+      // using bezier curves approximating ellipse quadrants
+      var rx = Math.min(w * 0.5, this.rx || this.ry)
+      var ry = Math.min(h * 0.5, this.ry || rx)
+      var k = 0.5522847498
+      var krx = k*rx
+      var kry = k*ry
+      ctx.moveTo(x+rx, y)
+      ctx.lineTo(x-rx+w, y)
+      ctx.bezierCurveTo(x-rx+w + krx, y, x+w, y+ry-kry, x+w, y+ry)
+      ctx.lineTo(x+w, y+h-ry)
+      ctx.bezierCurveTo(x+w, y+h-ry+kry, x-rx+w+krx, y+h, x-rx+w, y+h)
+      ctx.lineTo(x+rx, y+h)
+      ctx.bezierCurveTo(x+rx-krx, y+h, x, y+h-ry+kry, x, y+h-ry)
+      ctx.lineTo(x, y+ry)
+      ctx.bezierCurveTo(x, y+ry-kry, x+rx-krx, y, x+rx, y)
+      ctx.closePath()
+    } else {
+      if (w < 0) x += w
+      if (h < 0) y += h
+      ctx.rect(x, y, Math.abs(w), Math.abs(h))
+    }
+  },
+
+  /**
+    Returns true if the point x,y is inside this rectangle.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,5 will always be inside the rectangle [0, 0, 10, 10], regardless of the
+    transform on the rectangle.
+
+    @param x X-coordinate of the point.
+    @param y Y-coordinate of the point.
+    @return Whether the point is inside this rectangle.
+    @type boolean
+    */
+  isPointInPath : function(x,y) {
+    x -= this.cx
+    y -= this.cy
+    if (this.centered) {
+      x += this.width/2
+      y += this.height/2
+    }
+    return (x >= 0 && x <= this.width && y >= 0 && y <= this.height)
+  },
+
+  getBoundingBox : function() {
+    var x = this.cx
+    var y = this.cy
+    if (this.centered) {
+      x -= this.width/2
+      y -= this.height/2
+    }
+    return [x,y,this.width,this.height]
+  }
+})
+
+
+/**
+  Polygon is used for creating paths consisting of straight line
+  segments.
+
+  Attributes:
+    segments - The vertices of the polygon, e.g. [0,0, 1,1, 1,2, 0,1]
+    closePath - Whether to close the path, default is true.
+
+  @param segments The vertices of the polygon.
+  @param closePath Whether to close the path.
+  @param config Optional config hash.
+  */
+Polygon = Klass(Drawable, {
+  segments : [],
+  closePath : true,
+
+  initialize : function(segments, config) {
+    this.segments = segments
+    Drawable.initialize.call(this, config)
+  },
+
+  drawGeometry : function(ctx) {
+    if (!this.segments || this.segments.length < 2) return
+    var s = this.segments
+    ctx.moveTo(s[0], s[1])
+    for (var i=2; i<s.length; i+=2) {
+      ctx.lineTo(s[i], s[i+1])
+    }
+    if (this.closePath)
+      ctx.closePath()
+  },
+
+  isPointInPath : function(px,py) {
+    if (!this.segments || this.segments.length < 2) return false
+    var bbox = this.getBoundingBox()
+    return (px >= bbox[0] && px <= bbox[0]+bbox[2] &&
+            py >= bbox[1] && py <= bbox[1]+bbox[3])
+  },
+
+  getStartPoint : function() {
+    if (!this.segments || this.segments.length < 2)
+      return {point:[0,0], angle:0}
+    var a = 0
+    if (this.segments.length > 2) {
+      a = Curves.lineAngle(this.segments.slice(0,2), this.segments.slice(2,4))
+    }
+    return {point: this.segments.slice(0,2),
+            angle: a}
+  },
+
+  getEndPoint : function() {
+    if (!this.segments || this.segments.length < 2)
+      return {point:[0,0], angle:0}
+    var a = 0
+    if (this.segments.length > 2) {
+      a = Curves.lineAngle(this.segments.slice(-4,-2), this.segments.slice(-2))
+    }
+    return {point: this.segments.slice(-2),
+            angle: a}
+  },
+
+  getMidPoints : function() {
+    if (!this.segments || this.segments.length < 2)
+      return []
+    var segs = this.segments
+    var verts = []
+    for (var i=2; i<segs.length-2; i+=2) {
+      var a = segs.slice(i-2,i)
+      var b = segs.slice(i, i+2)
+      var c = segs.slice(i+2, i+4)
+      var t = 0.5 * (Curves.lineAngle(a,b) + Curves.lineAngle(b,c))
+      verts.push(
+        {point: b, angle: t}
+      )
+    }
+    return verts
+  },
+
+  getBoundingBox : function() {
+    var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+    var s = this.segments
+    for (var i=0; i<s.length; i+=2) {
+      var x = s[i], y = s[i+1]
+      if (x < minX) minX = x
+      if (x > maxX) maxX = x
+      if (y < minY) minY = y
+      if (y > maxY) maxY = y
+    }
+    return [minX, minY, maxX-minX, maxY-minY]
+  }
+})
+
+
+
+/**
+  CatmullRomSpline draws a Catmull-Rom spline, with optional looping and
+  path closing. Handy for motion paths.
+
+  @param segments Control points for the spline, as [[x,y], [x,y], ...]
+  @param config Optional config hash.
+  */
+CatmullRomSpline = Klass(Drawable, {
+  segments : [],
+  loop : false,
+  closePath : false,
+
+  initialize : function(segments, config) {
+    this.segments = segments
+    Drawable.initialize.call(this, config)
+  },
+
+  drawGeometry : function(ctx) {
+    var x1 = this.currentMatrix[0]
+    var x2 = this.currentMatrix[1]
+    var y1 = this.currentMatrix[2]
+    var y2 = this.currentMatrix[3]
+    var xs = x1*x1 + x2*x2
+    var ys = y1*y1 + y2*y2
+    var s = Math.floor(Math.sqrt(Math.max(xs, ys)))
+    var cmp = this.compiled
+    if (!cmp || cmp.scale != s) {
+      cmp = this.compile(s)
+    }
+    for (var i=0; i<cmp.length; i++) {
+      var cmd = cmp[i]
+      ctx[cmd[0]].apply(ctx, cmd[1])
+    }
+    if (this.closePath)
+      ctx.closePath()
+  },
+
+  compile : function(scale) {
+    if (!scale) scale = 1
+    var compiled = []
+    if (this.segments && this.segments.length >= (this.loop ? 1 : 4)) {
+      var segs = this.segments
+      if (this.loop) {
+        segs = segs.slice(0)
+        segs.unshift(segs[segs.length-1])
+        segs.push(segs[1])
+        segs.push(segs[2])
+      }
+      // FIXME don't be stupid
+      var point_spacing = 1 / (15 * (scale+0.5))
+      var a,b,c,d,p,pp
+      compiled.push(['moveTo', segs[1].slice(0)])
+      p = segs[1]
+      for (var j=1; j<segs.length-2; j++) {
+        a = segs[j-1]
+        b = segs[j]
+        c = segs[j+1]
+        d = segs[j+2]
+        for (var i=0; i<1; i+=point_spacing) {
+          pp = p
+          p = Curves.catmullRomPoint(a,b,c,d,i)
+          compiled.push(['lineTo', p])
+        }
+      }
+      p = Curves.catmullRomPoint(a,b,c,d,1)
+      compiled.push(['lineTo', p])
+    }
+    compiled.scale = scale
+    this.compiled = compiled
+    return compiled
+  },
+
+  getBoundingBox : function() {
+    var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+    var segments = (this.compiled ? this.compiled : this.compile())
+    for (var i=0; i<segments.length; i++) {
+      var seg = segments[i][1]
+      for (var j=0; j<seg.length; j+=2) {
+        var x = seg[j], y = seg[j+1]
+        if (x < minX) minX = x
+        if (x > maxX) maxX = x
+        if (y < minY) minY = y
+        if (y > maxY) maxY = y
+      }
+    }
+    return [minX, minY, maxX-minX, maxY-minY]
+  },
+
+  pointAt : function(t) {
+    if (!this.segments) return [0,0]
+    if (this.segments.length >= (this.loop ? 1 : 4)) {
+      var segs = this.segments
+      if (this.loop) {
+        segs = segs.slice(0)
+        segs.unshift(segs[segs.length-1])
+        segs.push(segs[1])
+        segs.push(segs[2])
+      }
+      // turn t into segment_index.segment_t
+      var rt = t * (segs.length - 3)
+      var j = Math.floor(rt)
+      var st = rt-j
+      var a = segs[j],
+          b = segs[j+1],
+          c = segs[j+2],
+          d = segs[j+3]
+      return Curves.catmullRomPoint(a,b,c,d,st)
+    } else {
+      return this.segments[0]
+    }
+  },
+
+  pointAngleAt : function(t) {
+    if (!this.segments) return {point: [0,0], angle: 0}
+    if (this.segments.length >= (this.loop ? 1 : 4)) {
+      var segs = this.segments
+      if (this.loop) {
+        segs = segs.slice(0)
+        segs.unshift(segs[segs.length-1])
+        segs.push(segs[1])
+        segs.push(segs[2])
+      }
+      // turn t into segment_index.segment_t
+      var rt = t * (segs.length - 3)
+      var j = Math.floor(rt)
+      var st = rt-j
+      var a = segs[j],
+          b = segs[j+1],
+          c = segs[j+2],
+          d = segs[j+3]
+      return Curves.catmullRomPointAngle(a,b,c,d,st)
+    } else {
+      return {point:this.segments[0] || [0,0], angle: 0}
+    }
+  }
+})
+
+
+/**
+  Path is used for creating custom paths.
+
+  Attributes: segments, closePath.
+
+    var path = new Path([
+      ['moveTo', [-50, -60]],
+      ['lineTo', [30, 50],
+      ['lineTo', [-50, 50]],
+      ['bezierCurveTo', [-50, 100, -50, 100, 0, 100]],
+      ['quadraticCurveTo', [0, 120, -20, 130]],
+      ['quadraticCurveTo', [0, 140, 0, 160]],
+      ['bezierCurveTo', [-10, 160, -20, 170, -30, 180]],
+      ['quadraticCurveTo', [10, 230, -50, 260]]
+    ])
+
+  The path segments are used as [methodName, arguments] on the canvas
+  drawing context, so the possible path segments are:
+
+    ['moveTo', [x, y]]
+    ['lineTo', [x, y]]
+    ['quadraticCurveTo', [control_point_x, control_point_y, x, y]]
+    ['bezierCurveTo', [cp1x, cp1y, cp2x, cp2y, x, y]]
+    ['arc', [x, y, radius, startAngle, endAngle, drawClockwise]]
+    ['arcTo', [x1, y1, x2, y2, radius]]
+    ['rect', [x, y, width, height]]
+
+  You can also pass an SVG path string as segments.
+
+    var path = new Path("M 100 100 L 300 100 L 200 300 z", {
+      stroke: true, strokeStyle: 'blue',
+      fill: true, fillStyle: 'red',
+      lineWidth: 3
+    })
+
+  @param segments The path segments.
+  @param config Optional config hash.
+  */
+Path = Klass(Drawable, {
+  segments : [],
+  closePath : false,
+
+  initialize : function(segments, config) {
+    this.segments = segments
+    Drawable.initialize.call(this, config)
+  },
+
+  /**
+    Creates a path on the given drawing context.
+
+    For each path segment, calls the context method named in the first element
+    of the segment with the rest of the segment elements as arguments.
+
+    SVG paths are parsed and executed.
+
+    Closes the path if closePath is true.
+
+    @param ctx Canvas drawing context.
+    */
+  drawGeometry : function(ctx) {
+    var segments = this.getSegments()
+    for (var i=0; i<segments.length; i++) {
+      var seg = segments[i]
+      ctx[seg[0]].apply(ctx, seg[1])
+    }
+    if (this.closePath)
+      ctx.closePath()
+  },
+
+  /**
+    Returns true if the point x,y is inside the path's bounding rectangle.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,5 will always be inside the rectangle [0, 0, 10, 10], regardless of the
+    transform on the rectangle.
+
+    @param px X-coordinate of the point.
+    @param py Y-coordinate of the point.
+    @return Whether the point is inside the path's bounding rectangle.
+    @type boolean
+    */
+  isPointInPath : function(px,py) {
+    var bbox = this.getBoundingBox()
+    return (px >= bbox[0] && px <= bbox[0]+bbox[2] &&
+            py >= bbox[1] && py <= bbox[1]+bbox[3])
+  },
+
+  getBoundingBox : function() {
+    if (!(this.compiled && this.compiledBoundingBox)) {
+      var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
+      var segments = this.getSegments()
+      for (var i=0; i<segments.length; i++) {
+        var seg = segments[i][1]
+        for (var j=0; j<seg.length; j+=2) {
+          var x = seg[j], y = seg[j+1]
+          if (x < minX) minX = x
+          if (x > maxX) maxX = x
+          if (y < minY) minY = y
+          if (y > maxY) maxY = y
+        }
+      }
+      this.compiledBoundingBox = [minX, minY, maxX-minX, maxY-minY]
+    }
+    return this.compiledBoundingBox
+  },
+
+  getStartPoint : function() {
+    var segs = this.getSegments()
+    if (!segs || !segs[0]) return {point: [0,0], angle: 0}
+    var fs = segs[0]
+    var c = fs[1]
+    var point = [c[c.length-2], c[c.length-1]]
+    var ss = segs[1]
+    var angle = 0
+    if (ss) {
+      c2 = ss[1]
+      angle = Curves.lineAngle(point, [c2[c2.length-2], c2[c2.length-1]])
+    }
+    return {
+      point: point,
+      angle: angle
+    }
+  },
+
+  getEndPoint : function() {
+    var segs = this.getSegments()
+    if (!segs || !segs[0]) return {point: [0,0], angle: 0}
+    var fs = segs[segs.length-1]
+    var c = fs[1]
+    var point = [c[c.length-2], c[c.length-1]]
+    var ss = segs[segs.length-2]
+    var angle = 0
+    if (ss) {
+      c2 = ss[1]
+      angle = Curves.lineAngle([c2[c2.length-2], c2[c2.length-1]], point)
+    }
+    return {
+      point: point,
+      angle: angle
+    }
+  },
+
+  getMidPoints : function() {
+    var segs = this.getSegments()
+    if (this.vertices)
+      return this.vertices.slice(1,-1)
+    var verts = []
+    for (var i=1; i<segs.length-1; i++) {
+      var b = segs[i-1][1].slice(-2)
+      var c = segs[i][1].slice(0,2)
+      if (segs[i-1].length > 2) {
+        var a = segs[i-1][1].slice(-4,-2)
+        var t = 0.5 * (Curves.lineAngle(a,b) + Curves.lineAngle(b,c))
+      } else {
+        var t = Curves.lineAngle(b,c)
+      }
+      verts.push(
+        {point: b, angle: t}
+      )
+      var id = segs[i][2]
+      if (id != null) {
+        i++
+        while (segs[i] && segs[i][2] == id) i++
+        i--
+      }
+    }
+    return verts
+  },
+
+  getSegments : function() {
+    if (typeof(this.segments) == 'string') {
+      if (!this.compiled || this.segments != this.compiledSegments) {
+        this.compiled = this.compileSVGPath(this.segments)
+        this.compiledSegments = this.segments
+      }
+    } else if (!this.compiled) {
+      this.compiled = Object.clone(this.segments)
+    }
+    return this.compiled
+  },
+
+  /**
+    Compiles an SVG path string into an array of canvas context method calls.
+
+    Returns an array of [methodName, [arg1, arg2, ...]] method call arrays.
+    */
+  compileSVGPath : function(svgPath) {
+    var segs = svgPath.split(/(?=[a-z])/i)
+    var x = 0
+    var y = 0
+    var px,py
+    var pc
+    var commands = []
+    for (var i=0; i<segs.length; i++) {
+      var seg = segs[i]
+      var cmd = seg.match(/[a-z]/i)
+      if (!cmd) return [];
+      cmd = cmd[0];
+      var coords = seg.match(/[+-]?\d+(\.\d+(e\d+(\.\d+)?)?)?/gi)
+      if (coords) coords = coords.map(parseFloat)
+      switch(cmd) {
+        case 'M':
+          x = coords[0]
+          y = coords[1]
+          px = py = null
+          commands.push(['moveTo', [x, y]])
+          break
+        case 'm':
+          x += coords[0]
+          y += coords[1]
+          px = py = null
+          commands.push(['moveTo', [x, y]])
+          break
+
+        case 'L':
+          x = coords[0]
+          y = coords[1]
+          px = py = null
+          commands.push(['lineTo', [x, y]])
+          break
+        case 'l':
+          x += coords[0]
+          y += coords[1]
+          px = py = null
+          commands.push(['lineTo', [x, y]])
+          break
+        case 'H':
+          x = coords[0]
+          px = py = null
+          commands.push(['lineTo', [x, y]])
+          break
+        case 'h':
+          x += coords[0]
+          px = py = null
+          commands.push(['lineTo', [x,y]])
+          break
+        case 'V':
+          y = coords[0]
+          px = py = null
+          commands.push(['lineTo', [x,y]])
+          break
+        case 'v':
+          y += coords[0]
+          px = py = null
+          commands.push(['lineTo', [x,y]])
+          break
+
+        case 'C':
+          x = coords[4]
+          y = coords[5]
+          px = coords[2]
+          py = coords[3]
+          commands.push(['bezierCurveTo', coords])
+          break
+        case 'c':
+          commands.push(['bezierCurveTo',[
+            coords[0] + x, coords[1] + y,
+            coords[2] + x, coords[3] + y,
+            coords[4] + x, coords[5] + y
+          ]])
+          px = x + coords[2]
+          py = y + coords[3]
+          x += coords[4]
+          y += coords[5]
+          break
+
+        case 'S':
+          if (px == null || !pc.match(/[sc]/i)) {
+            px = x
+            py = y
+          }
+          commands.push(['bezierCurveTo',[
+            x-(px-x), y-(py-y),
+            coords[0], coords[1],
+            coords[2], coords[3]
+          ]])
+          px = coords[0]
+          py = coords[1]
+          x = coords[2]
+          y = coords[3]
+          break
+        case 's':
+          if (px == null || !pc.match(/[sc]/i)) {
+            px = x
+            py = y
+          }
+          commands.push(['bezierCurveTo',[
+            x-(px-x), y-(py-y),
+            x + coords[0], y + coords[1],
+            x + coords[2], y + coords[3]
+          ]])
+          px = x + coords[0]
+          py = y + coords[1]
+          x += coords[2]
+          y += coords[3]
+          break
+
+        case 'Q':
+          px = coords[0]
+          py = coords[1]
+          x = coords[2]
+          y = coords[3]
+          commands.push(['quadraticCurveTo', coords])
+          break
+        case 'q':
+          commands.push(['quadraticCurveTo',[
+            coords[0] + x, coords[1] + y,
+            coords[2] + x, coords[3] + y
+          ]])
+          px = x + coords[0]
+          py = y + coords[1]
+          x += coords[2]
+          y += coords[3]
+          break
+
+        case 'T':
+          if (px == null || !pc.match(/[qt]/i)) {
+            px = x
+            py = y
+          } else {
+            px = x-(px-x)
+            py = y-(py-y)
+          }
+          commands.push(['quadraticCurveTo',[
+            px, py,
+            coords[0], coords[1]
+          ]])
+          px = x-(px-x)
+          py = y-(py-y)
+          x = coords[0]
+          y = coords[1]
+          break
+        case 't':
+          if (px == null || !pc.match(/[qt]/i)) {
+            px = x
+            py = y
+          } else {
+            px = x-(px-x)
+            py = y-(py-y)
+          }
+          commands.push(['quadraticCurveTo',[
+            px, py,
+            x + coords[0], y + coords[1]
+          ]])
+          x += coords[0]
+          y += coords[1]
+          break
+
+        case 'A':
+          var arc_segs = this.solveArc(x,y, coords)
+          for (var l=0; l<arc_segs.length; l++) arc_segs[l][2] = i
+          commands.push.apply(commands, arc_segs)
+          x = coords[5]
+          y = coords[6]
+          break
+        case 'a':
+          coords[5] += x
+          coords[6] += y
+          var arc_segs = this.solveArc(x,y, coords)
+          for (var l=0; l<arc_segs.length; l++) arc_segs[l][2] = i
+          commands.push.apply(commands, arc_segs)
+          x = coords[5]
+          y = coords[6]
+          break
+
+        case 'Z':
+          commands.push(['closePath', []])
+          break
+        case 'z':
+          commands.push(['closePath', []])
+          break
+      }
+      pc = cmd
+    }
+    return commands
+  },
+
+  solveArc : function(x, y, coords) {
+    var rx = coords[0]
+    var ry = coords[1]
+    var rot = coords[2]
+    var large = coords[3]
+    var sweep = coords[4]
+    var ex = coords[5]
+    var ey = coords[6]
+    var segs = this.arcToSegments(ex, ey, rx, ry, large, sweep, rot, x, y)
+    var retval = []
+    for (var i=0; i<segs.length; i++) {
+      retval.push(['bezierCurveTo', this.segmentToBezier.apply(this, segs[i])])
+    }
+    return retval
+  },
+
+
+  // Copied from Inkscape svgtopdf, thanks!
+  arcToSegments : function(x, y, rx, ry, large, sweep, rotateX, ox, oy) {
+    var th = rotateX * (Math.PI/180)
+    var sin_th = Math.sin(th)
+    var cos_th = Math.cos(th)
+    rx = Math.abs(rx)
+    ry = Math.abs(ry)
+    var px = cos_th * (ox - x) * 0.5 + sin_th * (oy - y) * 0.5
+    var py = cos_th * (oy - y) * 0.5 - sin_th * (ox - x) * 0.5
+    var pl = (px*px) / (rx*rx) + (py*py) / (ry*ry)
+    if (pl > 1) {
+      pl = Math.sqrt(pl)
+      rx *= pl
+      ry *= pl
+    }
+
+    var a00 = cos_th / rx
+    var a01 = sin_th / rx
+    var a10 = (-sin_th) / ry
+    var a11 = (cos_th) / ry
+    var x0 = a00 * ox + a01 * oy
+    var y0 = a10 * ox + a11 * oy
+    var x1 = a00 * x + a01 * y
+    var y1 = a10 * x + a11 * y
+
+    var d = (x1-x0) * (x1-x0) + (y1-y0) * (y1-y0)
+    var sfactor_sq = 1 / d - 0.25
+    if (sfactor_sq < 0) sfactor_sq = 0
+    var sfactor = Math.sqrt(sfactor_sq)
+    if (sweep == large) sfactor = -sfactor
+    var xc = 0.5 * (x0 + x1) - sfactor * (y1-y0)
+    var yc = 0.5 * (y0 + y1) + sfactor * (x1-x0)
+
+    var th0 = Math.atan2(y0-yc, x0-xc)
+    var th1 = Math.atan2(y1-yc, x1-xc)
+
+    var th_arc = th1-th0
+    if (th_arc < 0 && sweep == 1){
+      th_arc += 2*Math.PI
+    } else if (th_arc > 0 && sweep == 0) {
+      th_arc -= 2 * Math.PI
+    }
+
+    var segments = Math.ceil(Math.abs(th_arc / (Math.PI * 0.5 + 0.001)))
+    var result = []
+    for (var i=0; i<segments; i++) {
+      var th2 = th0 + i * th_arc / segments
+      var th3 = th0 + (i+1) * th_arc / segments
+      result[i] = [xc, yc, th2, th3, rx, ry, sin_th, cos_th]
+    }
+
+    return result
+  },
+
+  segmentToBezier : function(cx, cy, th0, th1, rx, ry, sin_th, cos_th) {
+    var a00 = cos_th * rx
+    var a01 = -sin_th * ry
+    var a10 = sin_th * rx
+    var a11 = cos_th * ry
+
+    var th_half = 0.5 * (th1 - th0)
+    var t = (8/3) * Math.sin(th_half * 0.5) * Math.sin(th_half * 0.5) / Math.sin(th_half)
+    var x1 = cx + Math.cos(th0) - t * Math.sin(th0)
+    var y1 = cy + Math.sin(th0) + t * Math.cos(th0)
+    var x3 = cx + Math.cos(th1)
+    var y3 = cy + Math.sin(th1)
+    var x2 = x3 + t * Math.sin(th1)
+    var y2 = y3 - t * Math.cos(th1)
+    return [
+      a00 * x1 + a01 * y1,      a10 * x1 + a11 * y1,
+      a00 * x2 + a01 * y2,      a10 * x2 + a11 * y2,
+      a00 * x3 + a01 * y3,      a10 * x3 + a11 * y3
+    ]
+  },
+
+  getLength : function() {
+    var segs = this.getSegments()
+    if (segs.arcLength == null) {
+      segs.arcLength = 0
+      var x=0, y=0
+      for (var i=0; i<segs.length; i++) {
+        var args = segs[i][1]
+        if (args.length < 2) continue
+        switch(segs[i][0]) {
+          case 'bezierCurveTo':
+            segs[i][3] = Curves.cubicLength(
+              [x, y], [args[0], args[1]], [args[2], args[3]], [args[4], args[5]])
+            break
+          case 'quadraticCurveTo':
+            segs[i][3] = Curves.quadraticLength(
+              [x, y], [args[0], args[1]], [args[2], args[3]])
+            break
+          case 'lineTo':
+            segs[i][3] = Curves.lineLength(
+              [x, y], [args[0], args[1]])
+            break
+        }
+        if (segs[i][3])
+          segs.arcLength += segs[i][3]
+        x = args[args.length-2]
+        y = args[args.length-1]
+      }
+    }
+    return segs.arcLength
+  },
+
+  pointAngleAt : function(t, config) {
+    var segments = []
+    var segs = this.getSegments()
+    var length = this.getLength()
+    var x = 0, y = 0
+    for (var i=0; i<segs.length; i++) {
+      var seg = segs[i]
+      if (seg[1].length < 2) continue
+      if (seg[0] != 'moveTo') {
+        segments.push([x, y, seg])
+      }
+      x = seg[1][seg[1].length-2]
+      y = seg[1][seg[1].length-1]
+    }
+    if (segments.length < 1)
+      return {point: [x, y], angle: 0 }
+    if (t >= 1) {
+      var rt = 1
+      var seg = segments[segments.length-1]
+    } else if (config && config.discrete) {
+      var idx = Math.floor(t * segments.length)
+      var seg = segments[idx]
+      var rt = 0
+    } else if (config && config.linear) {
+      var idx = t * segments.length
+      var rt = idx - Math.floor(idx)
+      var seg = segments[Math.floor(idx)]
+    } else {
+      var len = t * length
+      var rlen = 0, idx, rt
+      for (var i=0; i<segments.length; i++) {
+        if (rlen + segments[i][2][3] > len) {
+          idx = i
+          rt = (len - rlen) / segments[i][2][3]
+          break
+        }
+        rlen += segments[i][2][3]
+      }
+      var seg = segments[idx]
+    }
+    var angle = 0
+    var cmd = seg[2][0]
+    var args = seg[2][1]
+    switch (cmd) {
+      case 'bezierCurveTo':
+        return Curves.cubicLengthPointAngle([seg[0], seg[1]], [args[0], args[1]], [args[2], args[3]], [args[4], args[5]], rt)
+        break
+      case 'quadraticCurveTo':
+        return Curves.quadraticLengthPointAngle([seg[0], seg[1]], [args[0], args[1]], [args[2], args[3]], rt)
+        break
+      case 'lineTo':
+        x = Curves.linearValue(seg[0], args[0], rt)
+        y = Curves.linearValue(seg[1], args[1], rt)
+        angle = Curves.lineAngle([seg[0], seg[1]], [args[0], args[1]], rt)
+        break
+    }
+    return {point: [x, y], angle: angle }
+  }
+
+})
+
+
+/**
+  ImageNode is used for drawing images. Creates a rectangular path around
+  the drawn image.
+
+  Attributes:
+
+    centered - If true, image center is at the origin.
+               Otherwise image top-left is at the origin.
+    usePattern - Use a pattern fill for drawing the image (instead of
+                 drawImage.) Doesn't do sub-image drawing, and Safari doesn't
+                 like scaled image patterns.
+    sX, sY, sWidth, sHeight - Area of image to draw. Optional.
+    dX, dY - Coordinates where to draw the image. Default is 0, 0.
+    dWidth, dHeight - Size of the drawn image. Optional.
+
+  Example:
+
+    var img = new Image()
+    img.src = 'foo.jpg'
+    var imageGeo = new ImageNode(img)
+
+  @param image Image to draw.
+  @param config Optional config hash.
+  */
+ImageNode = Klass(Drawable, {
+  centered : false,
+  usePattern : false,
+
+  sX : 0,
+  sY : 0,
+  sWidth : null,
+  sHeight : null,
+
+  dX : 0,
+  dY : 0,
+  dWidth : null,
+  dHeight : null,
+
+  initialize : function(image, config) {
+    this.image = image
+    Drawable.initialize.call(this, config)
+  },
+
+  /**
+    Draws the image on the given drawing context.
+
+    Creates a rectangular path around the drawn image (for possible stroke
+    and/or fill.)
+
+    @param ctx Canvas drawing context.
+    */
+  drawGeometry : function(ctx) {
+    if (Object.isImageLoaded(this.image)) {
+      var w = this.dWidth == null ? this.image.width : this.dWidth
+      var h = this.dHeight == null ? this.image.height : this.dHeight
+      var x = this.dX + (this.centered ? -w * 0.5 : 0)
+      var y = this.dY + (this.centered ? -h * 0.5 : 0)
+      if (this.dWidth != null) {
+        if (this.sWidth != null) {
+          ctx.drawImage(this.image,
+            this.sX, this.sY, this.sWidth, this.sHeight,
+            x, y, w, h)
+        } else {
+          ctx.drawImage(this.image, x, y, w, h)
+        }
+      } else {
+        w = this.image.width
+        h = this.image.height
+        if (this.usePattern) {
+          if (!this.imagePattern)
+            this.imagePattern = new Pattern(this.image, 'repeat')
+          var fs = this.imagePattern.compiled
+          if (!fs)
+            fs = this.imagePattern.compile(ctx)
+          ctx.save()
+          ctx.beginPath()
+          ctx.rect(x, y, w, h)
+          ctx.setFillStyle(fs)
+          ctx.fill()
+          ctx.restore()
+          ctx.beginPath()
+        } else {
+          ctx.drawImage(this.image, x, y)
+        }
+      }
+    } else {
+      var w = this.dWidth
+      var h = this.dHeight
+      if (!( w && h )) return
+      var x = this.dX + (this.centered ? -w * 0.5 : 0)
+      var y = this.dY + (this.centered ? -h * 0.5 : 0)
+    }
+    ctx.rect(x, y, w, h)
+  },
+
+  /**
+    Creates a bounding rectangle path for the image on the given drawing
+    context.
+
+    @param ctx Canvas drawing context.
+    */
+  drawPickingPath : function(ctx) {
+    var x = this.dX + (this.centered ? -this.image.width * 0.5 : 0)
+    var y = this.dY + (this.centered ? -this.image.height * 0.5 : 0)
+    var w = this.dWidth
+    var h = this.dHeight
+    if (this.dWidth == null) {
+      w = this.image.width
+      h = this.image.height
+    }
+    ctx.rect(x, y, w, h)
+  },
+
+  /**
+    Returns true if the point x,y is inside the image rectangle.
+
+    The x,y point is in user-space coordinates, meaning that e.g. the point
+    5,5 will always be inside the rectangle [0, 0, 10, 10], regardless of the
+    transform on the rectangle.
+
+    @param x X-coordinate of the point.
+    @param y Y-coordinate of the point.
+    @return Whether the point is inside the image rectangle.
+    @type boolean
+    */
+  isPointInPath : function(x,y) {
+    x -= this.dX
+    y -= this.dY
+    if (this.centered) {
+      x += this.image.width * 0.5
+      y += this.image.height * 0.5
+    }
+    var w = this.dWidth
+    var h = this.dHeight
+    if (this.dWidth == null) {
+      w = this.image.width
+      h = this.image.height
+    }
+    return ((x >= 0) && (x <= w) && (y >= 0) && (y <= h))
+  },
+
+  getBoundingBox : function() {
+    x = this.dX
+    y = this.dY
+    if (this.centered) {
+      x -= this.image.width * 0.5
+      y -= this.image.height * 0.5
+    }
+    var w = this.dWidth
+    var h = this.dHeight
+    if (this.dWidth == null) {
+      w = this.image.width
+      h = this.image.height
+    }
+    return [x, y, w, h]
+  }
+})
+
+ImageNode.load = function(src) {
+  var img = new Image();
+  img.src = src;
+  var imgn = new ImageNode(img);
+  return imgn;
+}
+
+
+/**
+  TextNode is used for drawing text on a canvas.
+
+  Attributes:
+
+    text - The text string to draw.
+    align - Horizontal alignment for the text.
+            'left', 'right', 'center', 'start' or 'end'
+    baseline - Baseline used for the text.
+               'top', 'hanging', 'middle', 'alphabetic', 'ideographic' or 'bottom'
+    asPath - If true, creates a text path instead of drawing the text.
+    pathGeometry - A geometry object the path of which the text follows.
+
+  Example:
+
+    var text = new TextGeometry('The cake is a lie.')
+
+  @param text The text string to draw.
+  @param config Optional config hash.
+  */
+TextNode = Klass(Drawable, {
+  text : 'Text',
+  align : 'start', // 'left' | 'right' | 'center' | 'start' | 'end'
+  baseline : 'alphabetic', // 'top' | 'hanging' | 'middle' | 'alphabetic' |
+                           // 'ideographic' | 'bottom'
+  accuratePicking : false,
+  asPath : false,
+  pathGeometry : null,
+  maxWidth : null,
+  width : 0,
+  height : 20,
+  cx : 0,
+  cy : 0,
+
+  __drawMethodName : 'draw' + CanvasSupport.getTextBackend(),
+  __pickingMethodName : 'drawPickingPath' + CanvasSupport.getTextBackend(),
+
+  initialize : function(text, config) {
+    this.lastText = this.text
+    this.text = text
+    Drawable.initialize.call(this, config)
+  },
+
+  drawGeometry : function(ctx) {
+    this.drawUsing(ctx, this.__drawMethodName)
+  },
+
+  drawPickingPath : function(ctx) {
+    this.drawUsing(ctx, this.__pickingMethodName)
+  },
+
+  drawUsing : function(ctx, methodName) {
+    if (!this.text || this.text.length == 0)
+      return
+    if (this.lastText != this.text || this.lastStyle != ctx.font) {
+      this.dimensions = this.measureText(ctx)
+      this.lastText = this.text
+      this.lastStyle = ctx.font
+    }
+    if (this[methodName])
+      this[methodName](ctx)
+  },
+
+  measureText : function(ctx) {
+    var mn = 'measureText' + CanvasSupport.getTextBackend().capitalize()
+    if (this[mn]) {
+      return this[mn](ctx)
+    } else {
+      return {width: 0, height: 0}
+    }
+  },
+
+  computeXForAlign : function() {
+    if (this.align == 'left') // most hit branch
+      return 0
+    else if (this.align == 'right')
+      return -this.dimensions.width
+    else if (this.align == 'center')
+      return  -this.dimensions.width * 0.5
+  },
+
+  measureTextHTML5 : function(ctx) {
+    // FIXME measureText is retarded
+    return {width: ctx.measureText(this.text).width, height: 20}
+  },
+
+  drawHTML5 : function(ctx) {
+    ctx.fillText(this.text, this.cx, this.cy, this.maxWidth)
+  },
+
+  drawPickingPathHTML5 : function(ctx) {
+    var ascender = 15 // this.dimensions.ascender
+    var ry = this.cy - ascender
+    ctx.rect(this.cx, ry, this.dimensions.width, this.dimensions.height)
+  },
+
+  measureTextMozText : function(ctx) {
+    return {width: ctx.mozMeasureText(this.text), height: 20}
+  },
+
+  drawMozText : function(ctx) {
+    var x = this.cx + this.computeXForAlign()
+    var y = this.cy + 0
+    if (this.pathGeometry) {
+      this.pathGeometry.draw(ctx)
+      ctx.mozDrawTextAlongPath(this.text, this.path)
+    } else {
+      ctx.save()
+      ctx.translate(x,y)
+      if (this.asPath) {
+        ctx.mozPathText(this.text)
+      } else {
+        ctx.mozDrawText(this.text)
+      }
+      ctx.restore()
+    }
+  },
+
+  drawPickingPathMozText : function(ctx) {
+    var x = this.cx + this.computeXForAlign()
+    var y = this.cy + 0
+    if (this.pathGeometry) { // FIXME how to draw a text path along path?
+        this.pathGeometry.draw(ctx)
+        // ctx.mozDrawTextAlongPath(this.text, this.path)
+    } else if (!this.accuratePicking) {
+      var ascender = 15 // this.dimensions.ascender
+      var ry = y - ascender
+      ctx.rect(x, ry, this.dimensions.width, this.dimensions.height)
+    } else {
+      ctx.save()
+      ctx.translate(x,y)
+      ctx.mozPathText(this.text)
+      ctx.restore()
+    }
+  },
+
+  drawDrawString : function(ctx) {
+    var x = this.cx + this.computeXForAlign()
+    var y = this.cy + 0
+    ctx.drawString(x,y, this.text)
+  },
+
+  measureTextPerfectWorld : function(ctx) {
+    return ctx.measureText(this.text)
+  },
+
+  drawPerfectWorld : function(ctx) {
+    if (this.pathGeometry) {
+      this.pathGeometry.draw(ctx)
+      if (this.asPath)
+        ctx.pathTextAlongPath(this.text)
+      else
+        ctx.drawTextAlongPath(this.text)
+    } else if (this.asPath) {
+      ctx.pathText(this.text)
+    } else {
+      ctx.drawText(this.text)
+    }
+  },
+
+  drawPickingPathPerfectWorld : function(ctx) {
+    if (this.accuratePicking) {
+      if (this.pathGeometry) {
+        ctx.pathTextAlongPath(this.text)
+      } else {
+        ctx.pathText(this.text)
+      }
+    } else { // creates a path of text bounding box
+      if (this.pathGeometry) {
+        ctx.textRectAlongPath(this.text)
+      } else {
+        ctx.textRect(this.text)
+      }
+    }
+  }
+})
+
+
+/**
+  Gradient is a linear or radial color gradient that can be used as a
+  strokeStyle or fillStyle.
+
+  Attributes:
+
+    type - Type of the gradient. 'linear' or 'radial'
+    startX, startY - Coordinates for the starting point of the gradient.
+                     Center of the starting circle of a radial gradient.
+                     Default is 0, 0.
+    endX, endY - Coordinates for the ending point of the gradient.
+                 Center of the ending circle of a radial gradient.
+                 Default is 0, 0.
+    startRadius - The radius of the starting circle of a radial gradient.
+                  Default is 0.
+    endRadius - The radius of the ending circle of a radial gradient.
+                Default is 100.
+    colorStops - The color stops for the gradient. The format for the color
+                 stops is: [[position_1, color_1], [position_2, color_2], ...].
+                 The possible color formats are: 'red', '#000', '#000000',
+                 'rgba(0,0,0, 0.2)', [0,0,0] and [0,0,0, 0.2].
+                 Default color stops are [[0, '#000000'], [1, '#FFFFFF']].
+
+  Example:
+
+    var g = new Gradient({
+      type : 'radial',
+      endRadius : 40,
+      colorStops : [
+        [0, '#000'],
+        [0.2, '#ffffff'],
+        [0.5, [255, 0, 0]],
+        [0.8, [0, 255, 255, 0.5]],
+        [1.0, 'rgba(255, 0, 255, 0.8)']
+      ]
+    })
+
+  @param config Optional config hash.
+  */
+Gradient = Klass({
+  type : 'linear',
+  isPattern : true,
+  startX : 0,
+  startY : 0,
+  endX : 1,
+  endY : 0,
+  startRadius : 0,
+  endRadius : 1,
+  colorStops : [],
+
+  initialize : function(config) {
+    this.colorStops = [[0, '#000000'], [1, '#FFFFFF']]
+    if (config) Object.extend(this, config)
+  },
+
+  /**
+    Compiles the gradient using the given drawing context.
+    Returns a gradient object that can be used as drawing context
+    fill/strokeStyle.
+
+    @param ctx Drawing context to compile pattern on.
+    @return Gradient object.
+    */
+  compile : function(ctx) {
+    if (this.type == 'linear') {
+      var go = ctx.createLinearGradient(
+                    this.startX, this.startY,
+                    this.endX, this.endY)
+    } else {
+      var go = ctx.createRadialGradient(
+                    this.startX, this.startY, this.startRadius,
+                    this.endX, this.endY, this.endRadius)
+    }
+    for(var i=0; i<this.colorStops.length; i++) {
+      var cs = this.colorStops[i]
+      if (typeof(cs[1]) == 'string') {
+        go.addColorStop(cs[0], cs[1])
+      } else {
+        var ca = cs[1]
+        var a = (ca.length == 3) ? 1 : ca[3]
+        var g = 'rgba('+ca.slice(0,3).map(Math.round).join(",")+', '+a+')'
+        go.addColorStop(cs[0], g)
+      }
+    }
+    Object.extend(go, Transformable.prototype)
+    go.transformList = this.transformList
+    go.scale = this.scale
+    go.x = this.x
+    go.y = this.y
+    go.matrix = this.matrix
+    go.rotation = this.rotation
+    go.units = this.units
+    if (!go.isMockObject)
+      this.compiled = go
+    return go
+  }
+})
+
+
+/**
+  Pattern is a possibly repeating image that can be used as a strokeStyle or
+  fillStyle.
+
+    var image = new Image()
+    image.src = 'foo.jpg'
+    var pattern = new Pattern(image, 'no-repeat')
+    var rect = new Rectangle(200, 200, {fill: true, fillStyle: pattern})
+
+  @param image The image object for the pattern. IMG and CANVAS elements, and
+               Image objects all work.
+  @param repeat The repeat mode of the pattern. One of 'repeat', 'repeat-y',
+                'repeat-x' and 'no-repeat'. The default is 'repeat'.
+  */
+Pattern = Klass({
+  isPattern : true,
+  repeat: 'repeat',
+
+  initialize : function(image, repeat) {
+    this.image = image
+    if (repeat)
+      this.repeat = repeat
+  },
+
+  /**
+    Compiles the pattern using the given drawing context.
+    Returns a pattern object that can be used as drawing context
+    fill/strokeStyle.
+
+    @param ctx Drawing context to compile pattern on.
+    @return Pattern object.
+    */
+  compile : function(ctx) {
+    var pat = ctx.createPattern(this.image, this.repeat)
+    Object.extend(pat, Transformable.prototype)
+    pat.transformList = this.transformList
+    pat.scale = this.scale
+    pat.x = this.x
+    pat.y = this.y
+    pat.matrix = this.matrix
+    pat.rotation = this.rotation
+    pat.units = this.units
+    if (!pat.isMockObject)
+      this.compiled = pat
+    return pat
+  }
+})
+
+
+
+
+
+
+
+
+
+
+
+
+/**
+  SVG parser for simple documents. Converts SVG DOM to CAKE scenegraph.
+  Emphasis on graphical images, not on the "HTML-killer" features of SVG.
+
+  var svgNode = SVGParser.parse(
+    svgRootElement, filename, containerWidth, containerHeight, fontSize
+  )
+
+  Features:
+    * <svg> width and height, viewBox clipping.
+    * Clipping (objectBoundingBox clipping too)
+    * Paths, rectangles, ellipses, circles, lines, polylines and polygons
+    * Simple untransformed text using HTML
+    * Nested transforms
+    * Transform lists (transform="rotate(30) translate(2,2) scale(4)")
+    * Gradient and pattern transforms
+    * Strokes with miter, joins and caps
+    * Flat fills and gradient fills, ditto for strokes
+    * Parsing simple stylesheets (tag, class or id)
+    * Images
+    * Non-pixel units (cm, mm, in, pt, pc, em, ex, %)
+    * <use>-tags
+    * preserveAspectRatio
+    * Dynamic gradient sizes (objectBoundingBox, etc.)
+    * Markers (though buggy)
+
+  Some of the several missing features:
+    * Masks
+    * Patterns
+    * viewBox clipping for elements other than <marker> and <svg>
+    * Text styling
+    * tspan, tref, textPath, many things text
+    * Fancy style rules (tag .class + #foo > bob#alice { ... })
+    * Filters
+    * Animation
+    * Dashed strokes
+  */
+SVGParser = {
+  /**
+    Loads an SVG document using XMLHttpRequest and calls the given onSuccess
+    callback with the parsed document. If loading fails, calls onFailure
+    instead if one is given. When loading and an onLoading callback is given,
+    calls onLoading every time xhr.readyState is 3.
+
+    The callbacks will be called with the following parameters:
+
+    onSuccess(svgNode, xmlHttpRequest,
+      filename, config)
+
+    onFailure(xmlHttpRequest, possibleException,
+      filename, config)
+
+    onLoading(xmlHttpRequest,
+      filename, config)
+
+    Config hash parameters:
+      filename: Filename for the SVG document. Used for parsing image paths.
+      width: Width of the bounding box to fit the SVG in.
+      height: Height of the bounding box to fit the SVG in.
+      fontSize: Default font size for the SVG document.
+      onSuccess: Function to call on successful load. Required.
+      onFailure: Function to call on failed load.
+      onLoading: Function to call while loading.
+
+    @param config The config hash.
+    @param filename The URL of the SVG document to load. Must conform to SOP.
+    */
+  load : function(filename, config) {
+    if (!config.onSuccess) throw("Need to provide an onSuccess function.")
+    if (!config.filename)
+      config.filename = filename
+    var xhr = window.XMLHttpRequest ? new XMLHttpRequest() :
+                                      new ActiveXObject("MSXML2.XMLHTTP.3.0")
+    xhr.open('GET', filename, true)
+    xhr.overrideMimeType('text/xml')
+    var failureFired = false
+    xhr.onreadystatechange = function() {
+      if (xhr.readyState == 4) {
+        if (xhr.status == 200 || xhr.status == 0) {
+          try {
+            var svg = xhr.responseXML
+            var svgNode = SVGParser.parse(svg, config)
+            svgNode.svgRootElement = svg
+          } catch(e) {
+            if (config.onFailure)
+              config.onFailure(xhr, e, filename, config)
+            return
+          }
+          config.onSuccess(svgNode, xhr, filename, config)
+        } else {
+          if (config.onFailure && !failureFired)
+            config.onFailure(xhr, null, filename, config)
+        }
+      } else if (xhr.readyState == 3) {
+        if (config.onLoading)
+          config.onLoading(xhr, filename, config)
+      }
+    }
+    try {
+      xhr.send(null)
+    } catch(e) {
+      if (config.onFailure) {
+        config.onFailure(xhr, e, filename, config)
+        failureFired = true
+      }
+      xhr.abort()
+    }
+  },
+
+  /**
+    Parses an SVG DOM into CAKE scenegraph.
+
+    Config hash parameters:
+      filename: Filename for the SVG document. Used for parsing image paths.
+      width: Width of the bounding box to fit the SVG in.
+      height: Height of the bounding box to fit the SVG in.
+      fontSize: Default font size for the SVG document.
+      currentColor: HTML text color of the containing element.
+
+    @param svgRootElement The root element of the SVG DOM.
+    @param config The config hash.
+    @returns The root CanvasNode of the scenegraph created from the SVG document.
+    @type CanvasNode
+    */
+  parse : function(svgRootElement, config) {
+    var n = new CanvasNode()
+    var w = config.width, h = config.height, fs = config.fontSize
+    n.innerWidth = w || window.innerWidth
+    n.innerHeight = h || window.innerHeight
+    n.innerSize = Math.sqrt(w*w + h*h) / Math.sqrt(2)
+    n.fontSize = fs || 12
+    n.filename = config.filename || '.'
+    var defs = {}
+    var style = { ids : {}, classes : {}, tags : {} }
+    n.color = config.currentColor || 'black'
+    this.parseChildren(svgRootElement, n, defs, style)
+    n.defs = defs
+    n.style = style
+    return n
+  },
+
+  parsePreserveAspectRatio : function(aspect, w, h, vpw, vph) {
+    var aspect = aspect || ""
+    var aspa = aspect.split(/\s+/)
+    var defer = (aspa[0] == 'defer')
+    if (defer) aspa.shift()
+    var align = (aspa[0] || 'xMidYMid')
+    var meet = (aspa[1] || 'meet')
+    var wf = w / vpw
+    var hf = h / vph
+    var xywh = {x:0, y:0, w:wf, h:hf}
+    if (align == 'none') return xywh
+    xywh.w = xywh.h = (meet == 'meet' ? Math.min : Math.max)(wf, hf)
+    var xa = align.slice(1, 4).toLowerCase()
+    var ya = align.slice(5, 8).toLowerCase()
+    var xf = (this.SVGAlignMap[xa] || 0)
+    var yf = (this.SVGAlignMap[ya] || 0)
+    xywh.x = xf * (w-vpw*xywh.w)
+    xywh.y = yf * (h-vph*xywh.h)
+    return xywh
+  },
+
+  SVGAlignMap : {
+    min : 0,
+    mid : 0.5,
+    max : 1
+  },
+
+  SVGTagMapping : {
+    svg : function(c, cn, defs, style) {
+      var p = new Rectangle()
+      p.width = 0
+      p.height = 0
+      p.doFill = function(){}
+      p.doStroke = function(){}
+      p.drawMarkers = function(){}
+      p.fill = 'black'
+      p.stroke = 'none'
+      var vb = c.getAttribute('viewBox')
+      var w = c.getAttribute('width')
+      var h = c.getAttribute('height')
+      if (!w) w = h
+      else if (!h) h = w
+      if (w) {
+        var wpx = this.parseUnit(w, cn, 'x')
+        var hpx = this.parseUnit(h, cn, 'y')
+      }
+      if (vb) {
+        xywh = vb.match(/[-+]?\d+/g).map(parseFloat)
+        p.cx = xywh[0]
+        p.cy = xywh[1]
+        p.width = xywh[2]
+        p.height = xywh[3]
+        var iw = cn.innerWidth = p.width
+        var ih = cn.innerHeight = p.height
+        cn.innerSize = Math.sqrt(iw*iw + ih*ih) / Math.sqrt(2)
+        if (c.getAttribute('overflow') != 'visible')
+          p.clip = true
+      }
+      if (w) {
+        if (vb) { // nuts, let's parse the alignment :|
+          var aspect = c.getAttribute('preserveAspectRatio')
+          var align = this.parsePreserveAspectRatio(aspect,
+            wpx, hpx,
+            p.width, p.height)
+          p.cx -= align.x / align.w
+          p.cy -= align.y / align.h
+          p.width = wpx / align.w
+          p.height = hpx / align.h
+          p.x += align.x
+          p.y += align.y
+          p.scale = [align.w, align.h]
+        }
+        // wrong place!
+        cn.docWidth = wpx
+        cn.docHeight = hpx
+      }
+      return p
+    },
+
+    marker : function(c, cn) {
+      var p = new CanvasNode()
+      p.draw = function(ctx) {
+        if (this.overflow != 'hidden' && this.viewBox) return
+        ctx.beginPath()
+        ctx.rect(
+          this.viewBox[0],this.viewBox[1],
+          this.viewBox[2],this.viewBox[3]
+        )
+        ctx.clip()
+      }
+      var x = -this.parseUnit(c.getAttribute('refX'), cn, 'x') || 0
+      var y = -this.parseUnit(c.getAttribute('refY'), cn, 'y') || 0
+      p.transformList = [['translate', [x,y]]]
+      p.markerUnits = c.getAttribute('markerUnits') || 'strokeWidth'
+      p.markerWidth = this.parseUnit(c.getAttribute('markerWidth'), cn, 'x') || 3
+      p.markerHeight = this.parseUnit(
+                          c.getAttribute('markerHeight'), cn, 'y') ||
+                          3
+      p.overflow = c.getAttribute('overflow') || 'hidden'
+      p.viewBox = c.getAttribute('viewBox')
+      p.orient = c.getAttribute('orient') || 0
+      if (p.orient && p.orient != 'auto')
+        p.orient = parseFloat(p.orient)*SVGMapping.DEG_TO_RAD_FACTOR
+      if (p.viewBox) {
+        p.viewBox = p.viewBox.strip().split(/[\s,]+/g).map(parseFloat)
+        var vbw = p.viewBox[2] - p.viewBox[0]
+        var vbh = p.viewBox[3] - p.viewBox[1]
+        if (p.markerWidth) {
+          var sx = sy = Math.min(
+            p.markerWidth / vbw,
+            p.markerHeight / vbh
+          )
+          p.transformList.unshift(['scale', [sx,sy]])
+        }
+      }
+      return p
+    },
+
+    clipPath : function(c,cn) {
+      var p = new CanvasNode()
+      p.units = c.getAttribute('clipPathUnits')
+      return p
+    },
+
+    title : function(c, canvasNode) {
+      canvasNode.root.title = c.textContent
+    },
+
+    desc : function(c,cn) {
+      cn.root.description = c.textContent
+    },
+
+    metadata : function(c, cn) {
+      cn.root.metadata = c
+    },
+
+
+
+
+
+
+
+
+    parseAnimateTag : function(c, cn) {
+      var after = SVGParser.SVGTagMapping.parseTime(c.getAttribute('begin'))
+      var dur = SVGParser.SVGTagMapping.parseTime(c.getAttribute('dur'))
+      var end = SVGParser.SVGTagMapping.parseTime(c.getAttribute('end'))
+      if (dur == null) dur = end-after
+      dur = isNaN(dur) ? 0 : dur
+      var variable = c.getAttribute('attributeName')
+      var fill = c.getAttribute('fill')
+      if (cn.tagName == 'rect') {
+        if (variable == 'x') variable = 'cx'
+        if (variable == 'y') variable = 'cy'
+      }
+      var accum = c.getAttribute('accumulate') == 'sum'
+      var additive = c.getAttribute('additive')
+      if (additive) additive = additive == 'sum'
+      else additive = accum
+      var repeat = c.getAttribute('repeatCount')
+      if (repeat == 'indefinite') repeat = true
+      else repeat = parseFloat(repeat)
+      if (!repeat && dur > 0) {
+        var repeatDur = c.getAttribute('repeatDur')
+        if (repeatDur == 'indefinite') repeat = true
+        else repeat = SVGParser.SVGTagMapping.parseTime(repeatDur) / dur
+      }
+      return {
+        after: isNaN(after) ? 0 : after,
+        duration: dur,
+        restart: c.getAttribute('restart'),
+        calcMode : c.getAttribute('calcMode'),
+        additive : additive,
+        accumulate : accum,
+        repeat : repeat,
+        variable: variable,
+        fill: fill
+      }
+    },
+
+    parseTime : function(value) {
+      if (!value) return null
+      if (value.match(/[0-9]$/)) {
+        var hms = value.split(":")
+        var s = hms[hms.length-1] || 0
+        var m = hms[hms.length-2] || 0
+        var h = hms[hms.length-3] || 0
+        return (parseFloat(h)*3600 + parseFloat(m)*60 + parseFloat(s)) * 1000
+      } else {
+        var fac = 60
+        if (value.match(/s$/i)) fac = 1
+        else if (value.match(/h$/i)) fac = 3600
+        return parseFloat(value) * fac * 1000
+      }
+    },
+
+
+
+
+
+
+    animate : function(c, cn) {
+      var from = this.parseUnit(c.getAttribute('from'), cn, 'x')
+      var to = this.parseUnit(c.getAttribute('to'), cn, 'x')
+      var by = this.parseUnit(c.getAttribute('by'), cn, 'x')
+      var o = SVGParser.SVGTagMapping.parseAnimateTag(c, cn)
+      if (c.getAttribute('values')) {
+        var self = this
+        var vals = c.getAttribute('values')
+        vals = vals.split(";").map(function(v) {
+          var xy = v.split(/[, ]+/)
+          if (xy.length > 2) {
+            return xy.map(function(x){ return self.parseUnit(x, cn, 'x') })
+          } else if (xy.length > 1) {
+            return [
+              self.parseUnit(xy[0], cn, 'x'),
+              self.parseUnit(xy[1], cn, 'y')
+            ]
+          } else {
+            return self.parseUnit(v, cn, 'x')
+          }
+        })
+      } else {
+        if (to == null) to = from + by
+      }
+      cn.after(o.after, function() {
+        if (o.fill == 'remove') {
+          var orig = Object.clone(this[o.variable])
+          this.after(o.duration, function(){ this[o.variable] = orig })
+        }
+        if (vals) {
+          if (o.additive) {
+            var ov = this[o.variable]
+            vals = vals.map(function(v){
+              return Object.sum(v, ov)
+            })
+          }
+          var length = 0
+          var lens = []
+          if (vals[0] instanceof Array) {
+            for (var i=1; i<vals.length; i++) {
+              var diff = Object.sub(vals[i] - vals[i-1])
+              var sl = Math.sqrt(diff.reduce(function(s, i) { return s + i*i }, 0))
+              lens.push(sl)
+              length += sl
+            }
+          } else {
+            for (var i=1; i<vals.length; i++) {
+              var sl = Math.abs(vals[i] - vals[i-1])
+              lens.push(sl)
+              length += sl
+            }
+          }
+          var animator = function(pos) {
+            if (pos == 1) {
+              this[o.variable] = vals[vals.length-1]
+            } else {
+              if (o.calcMode == 'paced') {
+                var len = pos * length
+                var rlen = 0, idx, rt
+                for (var i=0; i<lens.length; i++) {
+                  if (rlen + lens[i] > len) {
+                    idx = i
+                    rt = (len - rlen) / lens[i]
+                    break
+                  }
+                  rlen += lens[i]
+                }
+                var v0 = idx
+                var v1 = v0 + 1
+              } else {
+                var idx = pos * (vals.length-1)
+                var v0 = Math.floor(idx)
+                var rt = idx - v0
+                var v1 = v0 + 1
+              }
+              this.tweenVariable(o.variable, vals[v0], vals[v1], rt, o.calcMode)
+            }
+          }
+          this.animate(animator, from, to, o.duration, 'linear', {
+            repeat: o.repeat,
+            additive: o.additive,
+            accumulate: o.accumulate
+          })
+        } else {
+          if (from == null) {
+            from = this[o.variable]
+            if (by != null) to = from + by
+          }
+          if (o.additive) {
+            from = Object.sum(from, this[o.variable])
+            to = Object.sum(to, this[o.variable])
+          }
+          this.animate(o.variable, from, to, o.duration, o.calcMode, {
+            repeat: o.repeat,
+            additive: o.additive,
+            accumulate: o.accumulate
+          })
+        }
+      })
+    },
+
+    set : function(c, cn) {
+      var to = c.getAttribute('to')
+      var o = SVGParser.SVGTagMapping.parseAnimateTag(c, cn)
+      cn.after(o.after, function() {
+        if (o.fill == 'remove') {
+          var orig = Object.clone(this[o.variable])
+          this.after(o.duration, function(){ this[o.variable] = orig })
+        }
+        this[o.variable] = to
+      })
+    },
+
+    animateMotion : function(c,cn) {
+      var path
+      if (c.getAttribute('path')) {
+        path = new Path(c.getAttribute('path'))
+      } else if (c.getAttribute('values')) {
+        var vals = c.getAttribute('values')
+        path = new Path("M" + vals.split(";").join("L"))
+      } else if (c.getAttribute('from') || c.getAttribute('to') || c.getAttribute('by')) {
+        var from = c.getAttribute('from')
+        var to = c.getAttribute('to')
+        var by = c.getAttribute('by')
+        if (!from) from = "0,0"
+        if (!to) to = "l" + by
+        else to = "L" + to
+        path = new Path("M" + from + to)
+      }
+      var p = new CanvasNode()
+      p.__motionPath = path
+      var rotate = c.getAttribute('rotate')
+      var o = SVGParser.SVGTagMapping.parseAnimateTag(c, cn)
+      cn.after(o.after, function() {
+        if (o.fill == 'remove') {
+          var ox = this.x, oy = this.y
+          this.after(o.duration, function(){ this.x = ox; this.y = oy})
+        }
+        var motion = function(pos) {
+          var pa = p.__motionPath.pointAngleAt(pos, {
+            discrete: o.calcMode == 'discrete',
+            linear : o.calcMode == 'linear'
+          })
+          this.x = pa.point[0]
+          this.y = pa.point[1]
+          if (rotate == 'auto') {
+            this.rotation = pa.angle
+          } else if (rotate == 'auto-reverse') {
+            this.rotation = pa.angle + Math.PI
+          }
+        }
+        this.animate(motion, 0, 1, o.duration, 'linear', {
+          repeat: o.repeat,
+          additive: o.additive,
+          accumulate: o.accumulate
+        })
+      })
+      return p
+    },
+
+    mpath : function(c,cn, defs) {
+      var href = c.getAttribute('xlink:href')
+      href = href.replace(/^#/,'')
+      this.getDef(defs, href, function(obj) {
+        cn.__motionPath = obj
+      })
+    },
+
+    animateColor : function(c, cn, defs) {
+      var from = c.getAttribute('from')
+      var to = c.getAttribute('to')
+      from = SVGParser.SVGMapping.__parseStyle(from, null, defs)
+      to = SVGParser.SVGMapping.__parseStyle(to, null, defs)
+      var o = SVGParser.SVGTagMapping.parseAnimateTag(c, cn)
+      cn.after(o.after, function() {
+        if (o.fill == 'remove') {
+          var orig = Object.clone(this[o.variable])
+          this.after(o.duration, function(){ this[o.variable] = orig })
+        }
+        this.animate(o.variable, from, to, o.duration, 'linear', {
+          repeat: o.repeat,
+          additive: o.additive,
+          accumulate: o.accumulate
+        })
+      })
+    },
+
+    animateTransform : function(c, cn) {
+      var from = c.getAttribute('from')
+      var to = c.getAttribute('to')
+      var by = c.getAttribute('by')
+      var o = SVGParser.SVGTagMapping.parseAnimateTag(c, cn)
+      if (from) from = from.split(/[ ,]+/).map(parseFloat)
+      if (to) to = to.split(/[ ,]+/).map(parseFloat)
+      if (by) by = by.split(/[ ,]+/).map(parseFloat)
+      o.variable = c.getAttribute('type')
+      if (o.variable == 'rotate') {
+        o.variable = 'rotation'
+        if (from) from = from.map(function(v) { return v * Math.PI/180 })
+        if (to) to = to.map(function(v) { return v * Math.PI/180 })
+        if (by) by = by.map(function(v) { return v * Math.PI/180 })
+      } else if (o.variable.match(/^skew/)) {
+        if (from) from = from.map(function(v) { return v * Math.PI/180 })
+        if (to) to = to.map(function(v) { return v * Math.PI/180 })
+        if (by) by = by.map(function(v) { return v * Math.PI/180 })
+      }
+      if (to == null) to = Object.sum(from, by)
+      cn.after(o.after, function() {
+        if (o.variable == 'translate') {
+          if (from == null) {
+            from = [this.x, this.y]
+            if (by != null) to = Object.sum(from, by)
+          }
+          if (o.fill == 'remove') {
+            var ox = this.x
+            var oy = this.y
+            this.after(o.duration, function(){ this.x = ox; this.y = oy })
+          }
+          this.animate('x', from[0], to[0], o.duration, 'linear', {
+            repeat: o.repeat,
+            additive: o.additive,
+            accumulate: o.accumulate
+          })
+          if (from[1] != null) {
+            this.animate('y', from[1], to[1], o.duration, 'linear', {
+              repeat: o.repeat,
+              additive: o.additive,
+              accumulate: o.accumulate
+            })
+          }
+        } else {
+          if (from) {
+            if (from.length == 1) from = from[0]
+          }
+          if (to) {
+            if (to.length == 1) to = to[0]
+          }
+          if (by) {
+            if (by.length == 1) by = by[0]
+          }
+          if (from == null) {
+            from = this[o.variable]
+            if (by != null) to = Object.sum(from, by)
+          }
+          if (o.variable == 'scale' && o.additive) {
+            // +1 in SMIL's additive scale means *1, welcome to brokenville
+            o.additive = false
+          }
+          if (o.fill == 'remove') {
+            var orig = Object.clone(this[o.variable])
+            this.after(o.duration, function(){ this[o.variable] = orig })
+          }
+          this.animate(o.variable, from, to, o.duration, 'linear', {
+            repeat: o.repeat,
+            additive: o.additive,
+            accumulate: o.accumulate
+          })
+        }
+      })
+    },
+
+    a : function(c, cn) {
+      var href = c.getAttribute('xlink:href') ||
+                 c.getAttribute('href')
+      var target = c.getAttribute('target')
+      var p = new LinkNode(href, target)
+      return p
+    },
+
+    use : function(c, cn, defs, style) {
+      var id = c.getAttribute('xlink:href') ||
+                c.getAttribute('href')
+      var p = new CanvasNode()
+      if (id) {
+        id = id.replace(/^#/,'')
+        this.getDef(defs, id, function(obj) {
+          var oc = obj.clone()
+          var par = p.parent
+          if (par) {
+            if (p.stroke) oc.stroke = p.stroke
+            if (p.fill) oc.fill = p.fill
+            p.append(oc)
+          } else {
+            p = oc
+          }
+        })
+      }
+      return p
+    },
+
+    image : function(c, cn, defs, style) {
+      var src = c.getAttribute('xlink:href') ||
+                c.getAttribute('href')
+      if (src && src.search(/^[a-z]+:/i) != 0) {
+        src = cn.root.filename.split("/").slice(0,-1).join("/") + "/" + src
+      }
+      var p = new ImageNode(src ? Object.loadImage(src) : null)
+      p.fill = 'none'
+      p.dX = this.parseUnit(c.getAttribute('x'), cn, 'x') || 0
+      p.dY = this.parseUnit(c.getAttribute('y'), cn, 'y') || 0
+      p.srcWidth = this.parseUnit(c.getAttribute('width'), cn, 'x')
+      p.srcHeight = this.parseUnit(c.getAttribute('height'), cn, 'y')
+      return p
+    },
+
+    path : function(c) {
+      return new Path(c.getAttribute("d"))
+    },
+
+    polygon : function(c) {
+      return new Polygon(c.getAttribute("points").toString().strip()
+                          .split(/[\s,]+/).map(parseFloat))
+    },
+
+    polyline : function(c) {
+      return new Polygon(c.getAttribute("points").toString().strip()
+                          .split(/[\s,]+/).map(parseFloat), {closePath:false})
+    },
+
+    rect : function(c, cn) {
+      var p = new Rectangle(
+        this.parseUnit(c.getAttribute('width'), cn, 'x'),
+        this.parseUnit(c.getAttribute('height'), cn, 'y')
+      )
+      p.cx = this.parseUnit(c.getAttribute('x'), cn, 'x') || 0
+      p.cy = this.parseUnit(c.getAttribute('y'), cn, 'y') || 0
+      p.rx = this.parseUnit(c.getAttribute('rx'), cn, 'x') || 0
+      p.ry = this.parseUnit(c.getAttribute('ry'), cn, 'y') || 0
+      return p
+    },
+
+    line : function(c, cn) {
+      var x1 = this.parseUnit(c.getAttribute('x1'), cn, 'x') || 0
+      var y1 = this.parseUnit(c.getAttribute('y1'), cn, 'y') || 0
+      var x2 = this.parseUnit(c.getAttribute('x2'), cn, 'x') || 0
+      var y2 = this.parseUnit(c.getAttribute('y2'), cn, 'y') || 0
+      var p = new Line(x1,y1, x2,y2)
+      return p
+    },
+
+    circle : function(c, cn) {
+      var p = new Circle(this.parseUnit(c.getAttribute('r'), cn) || 0)
+      p.cx = this.parseUnit(c.getAttribute('cx'), cn, 'x') || 0
+      p.cy = this.parseUnit(c.getAttribute('cy'), cn, 'y') || 0
+      return p
+    },
+
+    ellipse : function(c, cn) {
+      var p = new Ellipse(
+        this.parseUnit(c.getAttribute('rx'), cn, 'x') || 0,
+        this.parseUnit(c.getAttribute('ry'), cn, 'y') || 0
+      )
+      p.cx = this.parseUnit(c.getAttribute('cx'), cn, 'x') || 0
+      p.cy = this.parseUnit(c.getAttribute('cy'), cn, 'y') || 0
+      return p
+    },
+
+    text : function(c, cn) {
+      if (false) {
+        var p = new TextNode(c.textContent.strip())
+        p.setAsPath(true)
+        p.cx = this.parseUnit(c.getAttribute('x'),cn, 'x') || 0
+        p.cy = this.parseUnit(c.getAttribute('y'),cn, 'y') || 0
+        return p
+      } else {
+        var e = E('div', c.textContent.strip())
+        e.style.marginTop = '-1em'
+        e.style.whiteSpace = 'nowrap'
+        var p = new ElementNode(e)
+        p.xOffset = this.parseUnit(c.getAttribute('x'),cn, 'x') || 0
+        p.yOffset = this.parseUnit(c.getAttribute('y'),cn, 'y') || 0
+        return p
+      }
+    },
+
+    style : function(c, cn, defs, style) {
+      this.parseStyle(c, style)
+    },
+
+    defs : function(c, cn, defs, style) {
+      return new CanvasNode({visible: false})
+    },
+
+    linearGradient : function(c, cn,defs,style) {
+      var g = new Gradient({type:'linear'})
+      g.color = cn.color
+      if (c.getAttribute('color')) {
+        SVGParser.SVGMapping.color(g, c.getAttribute('color'), defs, style)
+      }
+      g.svgNode = c
+      var x1 = c.getAttribute('x1')
+      var y1 = c.getAttribute('y1')
+      var x2 = c.getAttribute('x2')
+      var y2 = c.getAttribute('y2')
+      var transform = c.getAttribute('gradientTransform')
+      g.units = c.getAttribute('gradientUnits') || "objectBoundingBox"
+      if (x1) g.startX = parseFloat(x1) * (x1.charAt(x1.length-1) == '%' ? 0.01 : 1)
+      if (y1) g.startY = parseFloat(y1) * (y1.charAt(y1.length-1) == '%' ? 0.01 : 1)
+      if (x2) g.endX = parseFloat(x2) * (x2.charAt(x2.length-1) == '%' ? 0.01 : 1)
+      if (y2) g.endY = parseFloat(y2) * (y2.charAt(y2.length-1) == '%' ? 0.01 : 1)
+      if (transform) this.applySVGTransform(g, transform, defs, style)
+      this.parseStops(g, c, defs, style)
+      return g
+    },
+
+    radialGradient : function(c, cn, defs, style) {
+      var g = new Gradient({type:'radial'})
+      g.color = cn.color
+      if (c.getAttribute('color')) {
+        SVGParser.SVGMapping.color(g, c.getAttribute('color'), defs, style)
+      }
+      g.svgNode = c
+      var r = c.getAttribute('r')
+      var fx = c.getAttribute('fx')
+      var fy = c.getAttribute('fy')
+      var cx = c.getAttribute('cx')
+      var cy = c.getAttribute('cy')
+      var transform = c.getAttribute('gradientTransform')
+      g.units = c.getAttribute('gradientUnits') || "objectBoundingBox"
+      if (r) g.endRadius = parseFloat(r) * (r.charAt(r.length-1) == '%' ? 0.01 : 1)
+      if (fx) g.startX = parseFloat(fx) * (fx.charAt(fx.length-1) == '%' ? 0.01 : 1)
+      if (fy) g.startY = parseFloat(fy) * (fy.charAt(fy.length-1) == '%' ? 0.01 : 1)
+      if (cx) g.endX = parseFloat(cx) * (cx.charAt(cx.length-1) == '%' ? 0.01 : 1)
+      if (cy) g.endY = parseFloat(cy) * (cy.charAt(cy.length-1) == '%' ? 0.01 : 1)
+      if (transform) this.applySVGTransform(g, transform, defs, style)
+      this.parseStops(g, c, defs, style)
+      return g
+    }
+  },
+
+  parseChildren : function(node, canvasNode, defs, style) {
+    var childNodes = []
+    var cn = canvasNode
+    for (var i=0; i<node.childNodes.length; i++) {
+      var c = node.childNodes[i]
+      var p = false // argh, remember to initialize vars inside loops
+      if (c.childNodes) {
+        if (c.tagName) {
+          if (this.SVGTagMapping[c.tagName]) {
+            p = this.SVGTagMapping[c.tagName].call(
+                  this, c, canvasNode, defs, style)
+          } else {
+            p = new CanvasNode()
+          }
+          if (p) {
+            p.root = canvasNode.root
+            p.fontSize = cn.fontSize
+            p.strokeWidth = cn.strokeWidth
+            if (c.attributes) {
+              for (var j=0; j<c.attributes.length; j++) {
+                var attr = c.attributes[j]
+                if (this.SVGMapping[attr.nodeName])
+                  this.SVGMapping[attr.nodeName](p, attr.nodeValue, defs, style)
+              }
+            }
+            if (p.id) {
+              this.setDef(defs, p.id, p)
+            }
+            p.tagName = c.tagName
+            this.applySVGTransform(p, c.getAttribute("transform"), defs, style)
+            this.applySVGStyle(p, c.getAttribute("style"), defs, style)
+            if (p.tagName && style.tags[p.tagName])
+              this.applySVGStyle(p, style.tags[p.tagName], defs, style)
+            if (p.className && style.classes[p.className])
+              this.applySVGStyle(p, style.classes[p.className], defs, style)
+            if (p.id && style.ids[p.id])
+              this.applySVGStyle(p, style.ids[p.id], defs, style)
+            if (!p.marker) p.marker = cn.marker
+            if (!p.markerStart) p.markerStart = cn.markerStart
+            if (!p.markerEnd) p.markerEnd = cn.markerEnd
+            if (!p.markerMid) p.markerMid = cn.markerMid
+          }
+        }
+        if (p && p.setRoot) {
+          p.zIndex = i
+          canvasNode.append(p)
+          this.parseChildren(c, p, defs, style)
+        }
+      }
+    }
+  },
+
+  parseStyle : function(node, style) {
+    var text = node.textContent
+    var segs = text.split(/\}/m)
+    for (var i=0; i<segs.length; i++) {
+      var seg = segs[i]
+      var kv = seg.split(/\{/m)
+      if (kv.length < 2) continue
+      var key = kv[0].strip()
+      var value = kv[1].strip()
+      switch (key.charAt(0)) {
+        case '.':
+          style.classes[key.slice(1)] = value
+          break;
+        case '#':
+          style.ids[key.slice(1)] = value
+          break;
+        default:
+          style.tags[key] = value
+          break;
+      }
+    }
+  },
+
+  parseStops : function(g, node, defs, style) {
+    var href = node.getAttribute('xlink:href')
+    g.colorStops = []
+    if (href) {
+      href = href.replace(/^#/,'')
+      this.getDef(defs, href, function(g2) {
+        if (g.colorStops.length == 0)
+          g.colorStops = g2.colorStops
+      })
+    }
+    var stops = []
+    for (var i=0; i<node.childNodes.length; i++) {
+      var c = node.childNodes[i]
+      if (c.tagName == 'stop') {
+        var offset = parseFloat(c.getAttribute('offset'))
+        if (c.getAttribute('offset').search(/%/) != -1)
+          offset *= 0.01
+        var stop = [offset]
+        stop.color = g.color
+        for (var j=0; j<c.attributes.length; j++) {
+          var attr = c.attributes[j]
+          if (this.SVGMapping[attr.nodeName])
+            this.SVGMapping[attr.nodeName](stop, attr.nodeValue, defs, style)
+        }
+        this.applySVGStyle(stop, c.getAttribute('style'), defs, style)
+        var id = c.getAttribute('id')
+        if (id) this.setDef(defs, id, stop)
+        stops.push(stop)
+      }
+    }
+    if (stops.length > 0)
+      g.colorStops = stops
+  },
+
+  applySVGTransform : function(node, transform, defs, style) {
+    if (!transform) return
+    node.transformList = []
+    var segs = transform.match(/[a-z]+\s*\([^)]*\)/ig)
+    for (var i=0; i<segs.length; i++) {
+      var kv = segs[i].split("(")
+      var k = kv[0].strip()
+      if (this.SVGMapping[k]) {
+        var v = kv[1].strip().slice(0,-1)
+        this.SVGMapping[k](node, v, defs, style)
+      }
+    }
+    this.breakDownTransformList(node)
+  },
+
+  breakDownTransformList : function(node) {
+    var tl = node.transformList
+    if (node.transformList.length == 1) {
+      var tr = tl[0]
+      if (tr[0] == 'translate') {
+        node.x = tr[1][0]
+        node.y = tr[1][1]
+      } else if (tr[0] == 'scale') {
+        node.scale = tr[1]
+      } else if (tr[0] == 'rotate') {
+        node.rotation = tr[1]
+      } else if (tr[0] == 'matrix') {
+        node.matrix = tr[1]
+      } else if (tr[0] == 'skewX') {
+        node.skewX = tr[1][0]
+      } else if (tr[0] == 'skewY') {
+        node.skewY = tr[1][0]
+      } else {
+        return
+      }
+      node.transformList = null
+    }
+  },
+
+  applySVGStyle : function(node, style, defs, st) {
+    if (!style) return
+    var segs = style.split(";")
+    for (var i=0; i<segs.length; i++) {
+      var kv = segs[i].split(":")
+      var k = kv[0].strip()
+      if (this.SVGMapping[k]) {
+        var v = kv[1].strip()
+        this.SVGMapping[k](node, v, defs, st)
+      }
+    }
+  },
+
+  getDef : function(defs, id, f) {
+    if (defs[id] && defs[id] instanceof Array) {
+      defs[id].push(f)
+    } else if (defs[id]) {
+      f(defs[id])
+    } else {
+      defs[id] = [f]
+    }
+  },
+
+  setDef : function(defs, id, obj) {
+    if (defs[id] && defs[id] instanceof Array) {
+      for (var i=0; i<defs[id].length; i++) {
+        defs[id][i](obj)
+      }
+    }
+    defs[id] = obj
+  },
+
+  parseUnit : function(v, parent, dir) {
+    if (v == null) {
+      return null
+    } else {
+      return this.parseUnitMultiplier(v, parent, dir) * parseFloat(v.strip())
+    }
+  },
+
+  parseUnitMultiplier : function(str, parent, dir) {
+    var cm = this.getCmInPixels()
+    if (str.search(/cm$/i) != -1)
+      return cm
+    else if (str.search(/mm$/i) != -1)
+      return 0.1 * cm
+    else if (str.search(/pt$/i) != -1)
+      return 0.0352777778 * cm
+    else if (str.search(/pc$/i) != -1)
+      return 0.4233333333 * cm
+    else if (str.search(/in$/i) != -1)
+      return 2.54 * cm
+    else if (str.search(/em$/i) != -1)
+      return parent.fontSize
+    else if (str.search(/ex$/i) != -1)
+      return parent.fontSize / 2
+    else if (str.search(/%$/i) != -1)
+      if (dir == 'x')
+        return parent.root.innerWidth * 0.01
+      else if (dir == 'y')
+        return parent.root.innerHeight * 0.01
+      else
+        return parent.root.innerSize * 0.01
+    else
+      return 1
+  },
+
+  getCmInPixels : function() {
+    if (!this.cmInPixels) {
+      var e = E('div',{ style: {
+        margin: '0px',
+        padding: '0px',
+        width: '1cm',
+        height: '1cm',
+        position: 'absolute',
+        visibility: 'hidden'
+      }})
+      document.body.appendChild(e)
+      var cm = e.offsetWidth
+      document.body.removeChild(e)
+      this.cmInPixels = cm || 38
+    }
+    return this.cmInPixels
+  },
+
+  getEmInPixels : function() {
+    if (!this.emInPixels) {
+      var e = E('div',{ style: {
+        margin: '0px',
+        padding: '0px',
+        width: '1em',
+        height: '1em',
+        position: 'absolute',
+        visibility: 'hidden'
+      }})
+      document.body.appendChild(e)
+      var em = e.offsetWidth
+      document.body.removeChild(e)
+      this.emInPixels = em || 12
+    }
+    return this.emInPixels
+  },
+
+  getExInPixels : function() {
+    if (!this.exInPixels) {
+      var e = E('div',{ style: {
+        margin: '0px',
+        padding: '0px',
+        width: '1ex',
+        height: '1ex',
+        position: 'absolute',
+        visibility: 'hidden'
+      }})
+      document.body.appendChild(e)
+      var ex = e.offsetWidth
+      document.body.removeChild(e)
+      this.exInPixels = ex || 6
+    }
+    return this.exInPixels
+  },
+
+  SVGMapping : {
+    DEG_TO_RAD_FACTOR : Math.PI / 180,
+    RAD_TO_DEG_FACTOR : 180 / Math.PI,
+
+    parseUnit : function(v, cn, dir) {
+      return SVGParser.parseUnit(v, cn, dir)
+    },
+
+    "class" : function(node, v) {
+      node.className = v
+    },
+
+    marker : function(node, v, defs) {
+      SVGParser.getDef(defs, v.replace(/^url\(#|\)$/g, ''), function(g) {
+        node.marker = g
+      })
+    },
+
+    "marker-start" : function(node, v, defs) {
+      SVGParser.getDef(defs, v.replace(/^url\(#|\)$/g, ''), function(g) {
+        node.markerStart = g
+      })
+    },
+
+    "marker-end" : function(node, v, defs) {
+      SVGParser.getDef(defs, v.replace(/^url\(#|\)$/g, ''), function(g) {
+        node.markerEnd = g
+      })
+    },
+
+    "marker-mid" : function(node, v, defs) {
+      SVGParser.getDef(defs, v.replace(/^url\(#|\)$/g, ''), function(g) {
+        node.markerMid = g
+      })
+    },
+
+    "clip-path" : function(node, v, defs) {
+      SVGParser.getDef(defs, v.replace(/^url\(#|\)$/g, ''), function(g) {
+        node.clipPath = g
+      })
+    },
+
+    id : function(node, v) {
+      node.id = v
+    },
+
+    translate : function(node, v) {
+      var xy = v.split(/[\s,]+/).map(parseFloat)
+      node.transformList.push(['translate', [xy[0], xy[1] || 0]])
+    },
+
+    rotate : function(node, v) {
+      if (v == 'auto' || v == 'auto-reverse') return
+      var rot = v.split(/[\s,]+/).map(parseFloat)
+      var angle = rot[0] * this.DEG_TO_RAD_FACTOR
+      if (rot.length > 1)
+        node.transformList.push(['rotate', [angle, rot[1], rot[2] || 0]])
+      else
+        node.transformList.push(['rotate', [angle]])
+    },
+
+    scale : function(node, v) {
+      var xy = v.split(/[\s,]+/).map(parseFloat)
+      var trans = ['scale']
+      if (xy.length > 1)
+        trans[1] = [xy[0], xy[1]]
+      else
+        trans[1] = [xy[0], xy[0]]
+      node.transformList.push(trans)
+    },
+
+    matrix : function(node, v) {
+      var mat = v.split(/[\s,]+/).map(parseFloat)
+      node.transformList.push(['matrix', mat])
+    },
+
+    skewX : function(node, v) {
+      var angle = parseFloat(v)*this.DEG_TO_RAD_FACTOR
+      node.transformList.push(['skewX', [angle]])
+    },
+
+    skewY : function(node, v) {
+      var angle = parseFloat(v)*this.DEG_TO_RAD_FACTOR
+      node.transformList.push(['skewY', [angle]])
+    },
+
+    opacity : function(node, v) {
+      node.opacity = parseFloat(v)
+    },
+
+    display : function (node, v) {
+      node.display = v
+    },
+
+    visibility : function (node, v) {
+      node.visibility = v
+    },
+
+    'stroke-miterlimit' : function(node, v) {
+      node.miterLimit = parseFloat(v)
+    },
+
+    'stroke-linecap' : function(node, v) {
+      node.lineCap = v
+    },
+
+    'stroke-linejoin' : function(node, v) {
+      node.lineJoin = v
+    },
+
+    'stroke-width' : function(node, v) {
+      node.strokeWidth = this.parseUnit(v, node)
+    },
+
+    fill : function(node, v, defs, style) {
+      node.fill = this.__parseStyle(v, node.fill, defs, node.color)
+    },
+
+    stroke : function(node, v, defs, style) {
+      node.stroke = this.__parseStyle(v, node.stroke, defs, node.color)
+    },
+
+    color : function(node, v, defs, style) {
+      if (v == 'inherit') return
+      node.color = this.__parseStyle(v, false, defs, node.color)
+    },
+
+    'stop-color' : function(node, v, defs, style) {
+      if (v == 'none') {
+        node[1] = [0,0,0,0]
+      } else {
+        node[1] = this.__parseStyle(v, node[1], defs, node.color)
+      }
+    },
+
+    'fill-opacity' : function(node, v) {
+      node.fillOpacity = Math.min(1,Math.max(0,parseFloat(v)))
+    },
+
+    'stroke-opacity' : function(node, v) {
+      node.strokeOpacity = Math.min(1,Math.max(0,parseFloat(v)))
+    },
+
+    'stop-opacity' : function(node, v) {
+      node[1] = node[1] || [0,0,0]
+      node[1][3] = Math.min(1,Math.max(0,parseFloat(v)))
+    },
+
+    'text-anchor' : function(node, v) {
+      node.textAnchor = v
+      if (node.setAlign) {
+        if (v == 'middle')
+          node.setAlign('center')
+        else
+          node.setAlign(v)
+      }
+    },
+
+    'font-family' : function(node, v) {
+      node.fontFamily = v
+    },
+
+    'font-size' : function(node, v) {
+      node.fontSize = this.parseUnit(v, node)
+    },
+
+    __parseStyle : function(v, currentStyle, defs, currentColor) {
+
+      if (v.charAt(0) == '#') {
+        if (v.length == 4)
+          v = v.replace(/([^#])/g, '$1$1')
+        var a = v.slice(1).match(/../g).map(
+          function(i) { return parseInt(i, 16) })
+        return a
+
+      } else if (v.search(/^rgb\(/) != -1) {
+        var a = v.slice(4,-1).split(",")
+        for (var i=0; i<a.length; i++) {
+          var c = a[i].strip()
+          if (c.charAt(c.length-1) == '%')
+            a[i] = Math.round(parseFloat(c.slice(0,-1)) * 2.55)
+          else
+            a[i] = parseInt(c)
+        }
+        return a
+
+      } else if (v.search(/^rgba\(/) != -1) {
+        var a = v.slice(5,-1).split(",")
+        for (var i=0; i<3; i++) {
+          var c = a[i].strip()
+          if (c.charAt(c.length-1) == '%')
+            a[i] = Math.round(parseFloat(c.slice(0,-1)) * 2.55)
+          else
+            a[i] = parseInt(c)
+        }
+        var c = a[3].strip()
+        if (c.charAt(c.length-1) == '%')
+          a[3] = Math.round(parseFloat(c.slice(0,-1)) * 0.01)
+        else
+          a[3] = Math.max(0, Math.min(1, parseFloat(c)))
+        return a
+
+      } else if (v.search(/^url\(/) != -1) {
+        var id = v.match(/\([^)]+\)/)[0].slice(1,-1).replace(/^#/, '')
+        if (defs[id]) {
+          return defs[id]
+        } else { // missing defs, let's make it known that we're screwed
+          return 'rgba(255,0,255,1)'
+        }
+
+      } else if (v == 'currentColor') {
+        return currentColor
+
+      } else if (v == 'none') {
+        return 'none'
+
+      } else if (v == 'freeze') { // SMIL is evil, but so are we
+        return null
+
+      } else if (v == 'remove') {
+        return null
+
+      } else { // unknown value, maybe it's an ICC color
+        return v
+      }
+    }
+  }
+}
diff --git a/lib/excanvas.js b/lib/excanvas.js
new file mode 100644 (file)
index 0000000..367764b
--- /dev/null
@@ -0,0 +1,924 @@
+// Copyright 2006 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+// Known Issues:
+//
+// * Patterns are not implemented.
+// * Radial gradient are not implemented. The VML version of these look very
+//   different from the canvas one.
+// * Clipping paths are not implemented.
+// * Coordsize. The width and height attribute have higher priority than the
+//   width and height style values which isn't correct.
+// * Painting mode isn't implemented.
+// * Canvas width/height should is using content-box by default. IE in
+//   Quirks mode will draw the canvas using border-box. Either change your
+//   doctype to HTML5
+//   (http://www.whatwg.org/specs/web-apps/current-work/#the-doctype)
+//   or use Box Sizing Behavior from WebFX
+//   (http://webfx.eae.net/dhtml/boxsizing/boxsizing.html)
+// * Non uniform scaling does not correctly scale strokes.
+// * Optimize. There is always room for speed improvements.
+
+// Only add this code if we do not already have a canvas implementation
+if (!document.createElement('canvas').getContext) {
+
+(function() {
+
+  // alias some functions to make (compiled) code shorter
+  var m = Math;
+  var mr = m.round;
+  var ms = m.sin;
+  var mc = m.cos;
+  var abs = m.abs;
+  var sqrt = m.sqrt;
+
+  // this is used for sub pixel precision
+  var Z = 10;
+  var Z2 = Z / 2;
+
+  /**
+   * This funtion is assigned to the <canvas> elements as element.getContext().
+   * @this {HTMLElement}
+   * @return {CanvasRenderingContext2D_}
+   */
+  function getContext() {
+    return this.context_ ||
+        (this.context_ = new CanvasRenderingContext2D_(this));
+  }
+
+  var slice = Array.prototype.slice;
+
+  /**
+   * Binds a function to an object. The returned function will always use the
+   * passed in {@code obj} as {@code this}.
+   *
+   * Example:
+   *
+   *   g = bind(f, obj, a, b)
+   *   g(c, d) // will do f.call(obj, a, b, c, d)
+   *
+   * @param {Function} f The function to bind the object to
+   * @param {Object} obj The object that should act as this when the function
+   *     is called
+   * @param {*} var_args Rest arguments that will be used as the initial
+   *     arguments when the function is called
+   * @return {Function} A new function that has bound this
+   */
+  function bind(f, obj, var_args) {
+    var a = slice.call(arguments, 2);
+    return function() {
+      return f.apply(obj, a.concat(slice.call(arguments)));
+    };
+  }
+
+  var G_vmlCanvasManager_ = {
+    init: function(opt_doc) {
+      if (/MSIE/.test(navigator.userAgent) && !window.opera) {
+        var doc = opt_doc || document;
+        // Create a dummy element so that IE will allow canvas elements to be
+        // recognized.
+        doc.createElement('canvas');
+        doc.attachEvent('onreadystatechange', bind(this.init_, this, doc));
+      }
+    },
+
+    init_: function(doc) {
+      // create xmlns
+      if (!doc.namespaces['g_vml_']) {
+        doc.namespaces.add('g_vml_', 'urn:schemas-microsoft-com:vml',
+                           '#default#VML');
+
+      }
+      if (!doc.namespaces['g_o_']) {
+        doc.namespaces.add('g_o_', 'urn:schemas-microsoft-com:office:office',
+                           '#default#VML');
+      }
+
+      // Setup default CSS.  Only add one style sheet per document
+      if (!doc.styleSheets['ex_canvas_']) {
+        var ss = doc.createStyleSheet();
+        ss.owningElement.id = 'ex_canvas_';
+        ss.cssText = 'canvas{display:inline-block;overflow:hidden;' +
+            // default size is 300x150 in Gecko and Opera
+            'text-align:left;width:300px;height:150px}' +
+            'g_vml_\\:*{behavior:url(#default#VML)}' +
+            'g_o_\\:*{behavior:url(#default#VML)}';
+
+      }
+
+      // find all canvas elements
+      var els = doc.getElementsByTagName('canvas');
+      for (var i = 0; i < els.length; i++) {
+        this.initElement(els[i]);
+      }
+    },
+
+    /**
+     * Public initializes a canvas element so that it can be used as canvas
+     * element from now on. This is called automatically before the page is
+     * loaded but if you are creating elements using createElement you need to
+     * make sure this is called on the element.
+     * @param {HTMLElement} el The c