diff --git a/src/d3/3d/D33dAxis.js b/src/d3/3d/D33dAxis.js
new file mode 100644
index 0000000000000000000000000000000000000000..96c89b7d17a8aa25904924ce76c5d6687f4ac55b
--- /dev/null
+++ b/src/d3/3d/D33dAxis.js
@@ -0,0 +1,240 @@
+'use strict';
+
+var D33dGroup = require('./D33dGroup'),
+  D33dPath = require('./D33dPath'),
+  D33dText = require('./D33dText'),
+  Util = require('../../util/Util'),
+  Vector = require('../../math/Vector');
+
+var _DEFAULTS = {
+  className: 'D33dAxis',
+  extent: [
+    [0, 0, 0],
+    [1, 0, 0],
+  ],
+  format: function (coord) {
+    return '' + coord;
+  },
+  labelAnchor: 'middle',
+  labelDirection: null,
+  labelVector: null,
+  padding: 0.05,
+  scale: null,
+  tickVector: [0, 0, 1],
+  ticks: 10,
+  title: 'Axis',
+  titleAnchor: 'middle',
+  titleDirection: null,
+  titleVector: null,
+};
+
+/**
+ * A 3d axis.
+ *
+ * @param options {Object}
+ * @param options.extent {Array<Array<Number>>}
+ *        default [[0, 0, 0], [1, 0, 0]].
+ *        axis extent.
+ * @param options.format {Function(Array<Number>)}
+ *        format one coordinate (for ticks).
+ * @param options.labelAnchor {'start'|'middle'|'end'}
+ *        default 'middle'.
+ *        svg text-anchor property for tick labels.
+ * @param options.labelDirection {Array<Number>}
+ *        default null.
+ *        rotate label text to be parallel to this vector.
+ * @param options.labelVector {Array<Number>}
+ *        default tickVector.multiply(2).
+ *        placement of tick labels
+ * @param options.padding {Number}
+ *        default 0.05
+ *        padding outside extent.  0.05 would be 5% padding based on extent.
+ * @param options.tickVector {Array<Number>}
+ *        direction and length of tick marks.
+ * @param options.ticks {Number}
+ *        number of ticks to create.
+ * @param options.title {String}
+ *        axis title.
+ * @param options.titleAnchor {'start'|'middle'|'end'}
+ *        default 'middle'.
+ *        svg text-anchor property for title.
+ * @param options.titleDirection {Array<Number>}
+ *        default null.
+ *        rotate title to be parallel to this vector.
+ * @param options.titleVector {Array<Number>}
+ *        default Vector(tickVector).multiply(5).
+ *        direction and distance to title from center of axis extent.
+ */
+var D3Axis = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _extent,
+    _ticks,
+    _title,
+    // methods
+    _createTicks;
+
+  _this = D33dGroup(options);
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.model.set(
+      {
+        extent: options.extent,
+        format: options.format,
+        labelAnchor: options.labelAnchor,
+        labelDirection: options.labelDirection,
+        labelVector: options.labelVector,
+        padding: options.padding,
+        tickVector: options.tickVector,
+        ticks: options.ticks,
+        title: options.title,
+        titleAnchor: options.titleAnchor,
+        titleDirection: options.titleDirection,
+        titleVector: options.titleVector,
+      },
+      { silent: true }
+    );
+
+    _extent = D33dPath({
+      className: 'axis-extent',
+    });
+    _title = D33dText({
+      className: 'axis-title',
+    });
+    _ticks = [];
+
+    _createTicks();
+    _this.model.on('change', _createTicks);
+  };
+
+  /**
+   * Update the _ticks array and model "items" property.
+   */
+  _createTicks = function () {
+    var extent,
+      format,
+      i,
+      labelAnchor,
+      labelDirection,
+      labelVector,
+      padding,
+      tickEnd,
+      tick,
+      tickStart,
+      tickUnit,
+      tickVector,
+      ticks,
+      title,
+      titleAnchor,
+      titleDirection,
+      titleVector;
+
+    _ticks.forEach(function (tick) {
+      tick.destroy();
+    });
+    _ticks = [];
+
+    extent = _this.model.get('extent');
+    format = _this.model.get('format');
+    labelAnchor = _this.model.get('labelAnchor');
+    labelDirection = _this.model.get('labelDirection');
+    labelVector = _this.model.get('labelVector');
+    padding = _this.model.get('padding');
+    tickVector = _this.model.get('tickVector');
+    ticks = _this.model.get('ticks');
+    title = _this.model.get('title');
+    titleAnchor = _this.model.get('titleAnchor');
+    titleDirection = _this.model.get('titleDirection');
+    titleVector = _this.model.get('titleVector');
+
+    tickEnd = Vector(extent[1]);
+    tickStart = Vector(extent[0]);
+    tickVector = Vector(tickVector);
+    titleVector = titleVector ? Vector(titleVector) : tickVector.multiply(5);
+
+    // update extent
+    if (padding !== 0) {
+      padding = tickEnd.subtract(tickStart).multiply(padding);
+      extent[0] = Vector(extent[0]).subtract(padding).data();
+      extent[1] = Vector(extent[1]).add(padding).data();
+    }
+    _extent.model.set({
+      coords: extent,
+    });
+    _title.model.set({
+      coords: Vector(extent[0])
+        // center within axis
+        .add(tickEnd.subtract(tickStart).multiply(0.5))
+        // offset
+        .add(titleVector)
+        .data(),
+      direction: titleDirection,
+      text: title,
+      textAnchor: titleAnchor,
+    });
+
+    // create ticks
+    tickUnit = tickEnd.subtract(tickStart).multiply(1 / ticks);
+    labelVector = labelVector ? Vector(labelVector) : tickVector.multiply(2);
+    for (i = 0; i <= ticks; i++) {
+      tick = tickStart.add(tickUnit.multiply(i));
+
+      _ticks.push(
+        D33dGroup({
+          className: 'axis-tick',
+          items: [
+            D33dPath({
+              coords: [tick.data(), tick.add(tickVector).data()],
+            }),
+            D33dText({
+              coords: tick.add(labelVector).data(),
+              direction: labelDirection,
+              text: format(tick.data()),
+              textAnchor: labelAnchor,
+            }),
+          ],
+        })
+      );
+    }
+
+    _this.model.set(
+      {
+        items: [_extent, _title].concat(_ticks),
+      },
+      { silent: true }
+    );
+  };
+
+  /**
+   * Free references.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      return;
+    }
+
+    _this.model.off('change', _createTicks);
+
+    _extent.destroy();
+    _title.destroy();
+    _ticks.forEach(function (tick) {
+      tick.destroy();
+    });
+
+    _createTicks = null;
+    _extent = null;
+    _initialize = null;
+    _this = null;
+    _ticks = null;
+    _title = null;
+  }, _this.destroy);
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D3Axis;
diff --git a/src/d3/3d/D33dCuboid.js b/src/d3/3d/D33dCuboid.js
new file mode 100644
index 0000000000000000000000000000000000000000..88f2e17821959cdd4a31c969a34e5a5ddafd6443
--- /dev/null
+++ b/src/d3/3d/D33dCuboid.js
@@ -0,0 +1,156 @@
+'use strict';
+
+var D33dGroup = require('./D33dGroup'),
+  D33dPath = require('./D33dPath'),
+  Util = require('../../util/Util');
+
+var _DEFAULTS = {
+  className: 'D33dCuboid',
+  x0: 0,
+  x1: 1,
+  y0: 0,
+  y1: 1,
+  z0: 0,
+  z1: 1,
+};
+
+/**
+ * A 3d cuboid.
+ */
+var D33dCuboid = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _x0,
+    _x1,
+    _y0,
+    _y1,
+    _z0,
+    _z1,
+    // methods
+    _updateCoordinates;
+
+  _this = D33dGroup(options);
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    // create sides as paths
+    _x0 = D33dPath({
+      className: 'side-x0',
+    });
+    _x1 = D33dPath({
+      className: 'side-x1',
+    });
+    _y0 = D33dPath({
+      className: 'side-y0',
+    });
+    _y1 = D33dPath({
+      className: 'side-y1',
+    });
+    _z0 = D33dPath({
+      className: 'side-z0',
+    });
+    _z1 = D33dPath({
+      className: 'side-z1',
+    });
+
+    // update model for D33dGroup
+    _this.model.set(
+      {
+        className: options.className,
+        items: [_x0, _x1, _y0, _y1, _z0, _z1],
+        x0: options.x0,
+        x1: options.x1,
+        y0: options.y0,
+        y1: options.y1,
+        z0: options.z0,
+        z1: options.z1,
+      },
+      { silent: true }
+    );
+
+    // set coordinates
+    _updateCoordinates();
+    _this.model.on('change', _updateCoordinates);
+  };
+
+  /**
+   * Update the coordinates of each side of the cuboid.
+   */
+  _updateCoordinates = function () {
+    var p000, p001, p010, p011, p100, p101, p110, p111, x0, x1, y0, y1, z0, z1;
+
+    // raw coordinates
+    x0 = _this.model.get('x0');
+    x1 = _this.model.get('x1');
+    y0 = _this.model.get('y0');
+    y1 = _this.model.get('y1');
+    z0 = _this.model.get('z0');
+    z1 = _this.model.get('z1');
+
+    // points on cuboid
+    p000 = [x0, y0, z0];
+    p001 = [x0, y0, z1];
+    p010 = [x0, y1, z0];
+    p011 = [x0, y1, z1];
+    p100 = [x1, y0, z0];
+    p101 = [x1, y0, z1];
+    p110 = [x1, y1, z0];
+    p111 = [x1, y1, z1];
+
+    // update sides
+    _x0.model.set({
+      coords: [p000, p010, p011, p001],
+    });
+    _x1.model.set({
+      coords: [p100, p101, p111, p110],
+    });
+    _y0.model.set({
+      coords: [p000, p001, p101, p100],
+    });
+    _y1.model.set({
+      coords: [p010, p011, p111, p110],
+    });
+    _z0.model.set({
+      coords: [p000, p010, p110, p100],
+    });
+    _z1.model.set({
+      coords: [p001, p011, p111, p101],
+    });
+  };
+
+  /**
+   * Unbind listeners, free references.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      return _this;
+    }
+
+    _this.model.off('change', _updateCoordinates);
+
+    _x0.destroy();
+    _x1.destroy();
+    _y0.destroy();
+    _y1.destroy();
+    _z0.destroy();
+    _z1.destroy();
+
+    _initialize = null;
+    _this = null;
+    _updateCoordinates = null;
+    _x0 = null;
+    _x1 = null;
+    _y0 = null;
+    _y1 = null;
+    _z0 = null;
+    _z1 = null;
+  }, _this.destroy);
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dCuboid;
diff --git a/src/d3/3d/D33dGroup.js b/src/d3/3d/D33dGroup.js
new file mode 100644
index 0000000000000000000000000000000000000000..001d6d766f90a5fea183df3584945b1925af83bd
--- /dev/null
+++ b/src/d3/3d/D33dGroup.js
@@ -0,0 +1,70 @@
+'use strict';
+
+var D33dSubView = require('../view/D33dSubView'),
+  Util = require('../../util/Util');
+
+var _DEFAULTS = {
+  className: 'D33dGroup',
+  coords: [0, 0, 0],
+  items: [],
+};
+
+/**
+ * Text to be plotted at a point in the view.
+ *
+ * @param options {Object}
+ * @param options.className {String}
+ *        default 'D33dGroup'.
+ *        element class attribute value (may include spaces).
+ * @param options.coords {Array<Number>}
+ *        3d coordinates where text should be positioned.
+ * @param options.items {Array<Object>}
+ *        nested items.
+ */
+var D33dGroup = function (options) {
+  var _this, _initialize;
+
+  _this = D33dSubView(options);
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.model.set(
+      {
+        className: options.className,
+        coords: options.coords,
+        items: options.items,
+      },
+      { silent: true }
+    );
+  };
+
+  /**
+   * @return data for D33dView plotting.
+   */
+  _this.getData = function () {
+    return {
+      el: _this.el,
+      items: _this.model.get('items').map(function (item) {
+        return item.getData();
+      }),
+    };
+  };
+
+  /**
+   * Render everything except position.
+   */
+  _this.render = function (view) {
+    _this.el.setAttribute('class', _this.model.get('className'));
+    // render nested views
+    _this.model.get('items').forEach(function (item) {
+      item.render(view);
+    });
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dGroup;
diff --git a/src/d3/3d/D33dPath.js b/src/d3/3d/D33dPath.js
new file mode 100644
index 0000000000000000000000000000000000000000..f1fdbbae369525be29b9c8eaf8dbba8e19045912
--- /dev/null
+++ b/src/d3/3d/D33dPath.js
@@ -0,0 +1,71 @@
+'use strict';
+
+var D33dSubView = require('../view/D33dSubView'),
+  Util = require('../../util/Util');
+
+var _DEFAULTS = {
+  className: 'D33dPath',
+  close: true,
+  coords: [],
+};
+
+/**
+ * Class info and constructor parameters.
+ */
+var D33dPath = function (options) {
+  var _this, _initialize;
+
+  _this = D33dSubView(
+    Util.extend(
+      {
+        element: 'path',
+      },
+      options
+    )
+  );
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.model.set(
+      {
+        className: options.className,
+        close: options.close,
+        coords: options.coords,
+      },
+      { silent: true }
+    );
+  };
+
+  /**
+   * Free references.
+   */
+  _this.destroy = Util.compose(function () {
+    _initialize = null;
+    _this = null;
+  }, _this.destroy);
+
+  /**
+   * @return data for D33dView plotting.
+   */
+  _this.getData = function () {
+    return {
+      close: _this.model.get('close'),
+      coords: _this.model.get('coords'),
+      el: _this.el,
+    };
+  };
+
+  /**
+   * Render everything except coordinates.
+   */
+  _this.render = function (/*view*/) {
+    _this.el.setAttribute('class', _this.model.get('className'));
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dPath;
diff --git a/src/d3/3d/D33dText.js b/src/d3/3d/D33dText.js
new file mode 100644
index 0000000000000000000000000000000000000000..5b46bf89ac59f9e06b42903685139f2afe7b2899
--- /dev/null
+++ b/src/d3/3d/D33dText.js
@@ -0,0 +1,89 @@
+'use strict';
+
+var D33dSubView = require('../view/D33dSubView'),
+  Util = require('../../util/Util');
+
+var _DEFAULTS = {
+  className: 'D33dText',
+  coords: [0, 0, 0],
+  direction: null,
+  text: '',
+  textAnchor: 'start',
+};
+
+/**
+ * Text to be plotted at a point in the view.
+ *
+ * @param options {Object}
+ * @param options.className {String}
+ *        default 'D33dText'.
+ *        element class attribute value (may include spaces).
+ * @param options.coords {Array<Number>}
+ *        3d coordinates where text should be positioned.
+ * @param options.text {String}
+ *        text content.
+ * @param options.textAnchor {String}
+ *        default 'start'.
+ *        svg text anchor.
+ */
+var D33dText = function (options) {
+  var _this, _initialize;
+
+  _this = D33dSubView(
+    Util.extend(
+      {
+        element: 'text',
+      },
+      options
+    )
+  );
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.model.set(
+      {
+        className: options.className,
+        coords: options.coords,
+        direction: options.direction,
+        text: options.text,
+        textAnchor: options.textAnchor,
+      },
+      { silent: true }
+    );
+  };
+
+  /**
+   * Free references.
+   */
+  _this.destroy = Util.compose(function () {
+    _initialize = null;
+    _this = null;
+  }, _this.destroy);
+
+  /**
+   * @return data for D33dView plotting.
+   */
+  _this.getData = function () {
+    return {
+      coords: _this.model.get('coords'),
+      direction: _this.model.get('direction'),
+      el: _this.el,
+    };
+  };
+
+  /**
+   * Render everything except position.
+   */
+  _this.render = function (/*view*/) {
+    _this.el.textContent = _this.model.get('text');
+    _this.el.setAttribute('class', _this.model.get('className'));
+    _this.el.setAttribute('text-anchor', _this.model.get('textAnchor'));
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dText;
diff --git a/src/d3/index.d.ts b/src/d3/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5e0e908cc9c8e69217b2d3f65aa75b6bf1edff7c
--- /dev/null
+++ b/src/d3/index.d.ts
@@ -0,0 +1 @@
+export * from '../../types/d3';
diff --git a/src/d3/index.js b/src/d3/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c74ac0c40fc5ef1a753bcfcedbc542ee73c326a7
--- /dev/null
+++ b/src/d3/index.js
@@ -0,0 +1,16 @@
+module.exports = {
+  ClassList: require('./util/ClassList'),
+  D3Util: require('./util/D3Util'),
+
+  D3BaseView: require('./view/D3BaseView'),
+  D3SubView: require('./view/D3SubView'),
+  D3View: require('./view/D3View'),
+  D33dSubView: require('./view/D33dSubView'),
+  D33dView: require('./view/D33dView'),
+
+  D33dAxis: require('./3d/D33dAxis'),
+  D33dCuboid: require('./3d/D33dCuboid'),
+  D33dGroup: require('./3d/D33dGroup'),
+  D33dPath: require('./3d/D33dPath'),
+  D33dText: require('./3d/D33dText'),
+};
diff --git a/src/d3/util/ClassList.js b/src/d3/util/ClassList.js
new file mode 100644
index 0000000000000000000000000000000000000000..710f4bc2e5f7fef85fd0860db59cb515127feba9
--- /dev/null
+++ b/src/d3/util/ClassList.js
@@ -0,0 +1,163 @@
+'use strict';
+
+/**
+ * Simulates a class list.
+ *
+ * If changes are made outside this object, resync using synchronize().
+ */
+var ClassList = function (el) {
+  var _this,
+    _initialize,
+    // variables
+    _classList,
+    _syncValue,
+    // methods
+    _sync;
+
+  _this = {};
+
+  /**
+   * Initialize ClassList.
+   */
+  _initialize = function () {
+    _syncValue = null;
+    _classList = [];
+    _this.length = 0;
+
+    _sync(true);
+  };
+
+  /**
+   * Synchronize with element state.
+   *
+   * @param load {Boolean}
+   *        when true, read state from element.
+   *        otherwise, set element state.
+   */
+  _sync = function (load) {
+    var value;
+
+    if (load) {
+      // read from element
+      value = el.getAttribute('class');
+      if (value === null) {
+        _classList = [];
+        _this.length = 0;
+      } else {
+        value = '' + value;
+        _classList = value.split(' ');
+        _this.length = _classList.length;
+      }
+    } else {
+      // update element
+      value = _classList.join(' ');
+      el.setAttribute('class', value);
+    }
+    _syncValue = value;
+  };
+
+  /**
+   * Add a class.
+   *
+   * @param className {String}
+   *        class to add.
+   */
+  _this.add = function (className) {
+    var pos;
+    // load from element
+    _sync(true);
+    pos = _classList.indexOf(className);
+    if (pos === -1) {
+      _classList.push(className);
+      _this.length++;
+      // update element
+      _sync(false);
+    }
+  };
+
+  /**
+   * Check if element has a class.
+   *
+   * @param className {String}
+   *        class to add.
+   * @return {Boolean}
+   *         true if element list includes class.
+   */
+  _this.contains = function (className) {
+    var pos;
+    // load from element
+    _sync(true);
+    pos = _classList.indexOf(className);
+    return pos !== -1;
+  };
+
+  /**
+   * Access a class.
+   *
+   * @param pos {String}
+   *        index in list [0,ClassList.length-1].
+   * @return className in list, or null if out of range.
+   */
+  _this.item = function (pos) {
+    // load from element
+    _sync(true);
+    if (pos < 0 || pos >= _classList.length) {
+      return null;
+    }
+    return _classList[pos];
+  };
+
+  /**
+   * Remove a class.
+   *
+   * @param className {String}
+   *        class to remove.
+   */
+  _this.remove = function (className) {
+    var pos;
+    // load from element
+    _sync(true);
+    pos = _classList.indexOf(className);
+    if (pos !== -1) {
+      _classList.splice(pos, 1);
+      _this.length--;
+      // update element
+      _sync(false);
+    }
+  };
+
+  /**
+   * Toggle a class.
+   *
+   * Add is not in list, otherwise remove.
+   *
+   * @param className {String}
+   *        class to add.
+   */
+  _this.toggle = function (className) {
+    if (_this.has(className)) {
+      _this.remove(className);
+    } else {
+      _this.add(className);
+    }
+  };
+
+  _initialize();
+  return _this;
+};
+
+/**
+ * Add classList if element doesn't natively support classList.
+ *
+ * Some SVG implementations do not support classList.
+ *
+ * @param el {Element}
+ *        element to polyfill.
+ */
+ClassList.polyfill = function (el) {
+  if (!el.classList) {
+    el.classList = ClassList(el);
+  }
+};
+
+module.exports = ClassList;
diff --git a/src/d3/util/D3Util.js b/src/d3/util/D3Util.js
new file mode 100644
index 0000000000000000000000000000000000000000..73665da0d2b489bb8f35cd6871ab84820007320b
--- /dev/null
+++ b/src/d3/util/D3Util.js
@@ -0,0 +1,133 @@
+'use strict';
+
+/**
+ * Format text content.
+ *
+ * @param el {D3Element}
+ *        tooltip container element.
+ * @param data {Array<Object|Array>}
+ *        data passed to showTooltip.
+ *        this implementation expects objects (or arrays of objects):
+ *        obj.class {String} class attribute for text|tspan.
+ *        obj.text {String} content for text|tspan.
+ */
+var _formatText = function (el, data) {
+  var y;
+
+  // add content to tooltip
+  data = data.map(function (line) {
+    var text = el.append('text');
+    if (typeof line.forEach === 'function') {
+      // array of components:
+      line.forEach(function (l) {
+        text
+          .append('tspan')
+          .attr('class', l.class || '')
+          .text(l.text);
+      });
+    } else {
+      text.attr('class', line.class || '').text(line.text);
+    }
+    return text;
+  });
+  // position lines in tooltip
+  y = 0;
+  data.forEach(function (line) {
+    var bbox = line.node().getBBox();
+    y += bbox.height;
+    line.attr('y', y);
+  });
+};
+
+/**
+ * Persistently tries to get the bounding box for the given element.
+ *
+ * @param element {SVGText}
+ *      The element for which to get the bounding box.
+ * @return {Object}
+ *      A bounding box object with x, y, width, height attributes
+ */
+var _getBBox = function (element) {
+  var bbox;
+
+  try {
+    bbox = element.getBBox();
+  } catch (e) {
+    // Ignore
+  }
+
+  if (!bbox) {
+    try {
+      bbox = element.getBoundingClientRect();
+    } catch (e) {
+      // Ignore
+    }
+  }
+
+  if (!bbox) {
+    bbox = { x: 0, y: 0, width: 0, height: 0 };
+  }
+
+  return bbox;
+};
+
+/**
+ * Pad an extent.
+ *
+ * @param extent {Array<Number>}
+ *        first entry should be minimum.
+ *        last entry should be maximum.
+ * @param amount {Number}
+ *        percentage of range to pad.
+ *        For example: 0.05 = +/- 5% of range.
+ * @return {Array<Number>}
+ *         padded extent.
+ */
+var _padExtent = function (extent, amount) {
+  var start = extent[0],
+    end = extent[extent.length - 1],
+    range = end - start,
+    pad = range * amount;
+
+  // Deal with case where there is only one value for the extents.
+  if (pad === 0) {
+    pad = amount;
+  }
+
+  return [start - pad, end + pad];
+};
+
+/**
+ * Pad a log based extent.
+ *
+ * Similar to _padExtent(), but padding occurs in log space.
+ *
+ * @param extent {Array<Number>}
+ *        first entry should be minimum.
+ *        last entry should be maximum.
+ * @param amount {Number}
+ *        percentage of range to pad.
+ *        For example: 0.05 = +/- 5% of range.
+ * @return {Array<Number>}
+ *         padded extent.
+ */
+var _padLogExtent = function (extent, amount) {
+  var base, baseLog, end, start;
+
+  // convert min/max to base 10
+  base = 10;
+  baseLog = Math.log(base);
+  start = Math.log(extent[0]) / baseLog;
+  end = Math.log(extent[extent.length - 1]) / baseLog;
+  extent = _padExtent([start, end], amount);
+  return [Math.pow(base, extent[0]), Math.pow(base, extent[extent.length - 1])];
+};
+
+var D3Util = {
+  formatText: _formatText,
+  getBBox: _getBBox,
+  padExtent: _padExtent,
+  padLogExtent: _padLogExtent,
+};
+
+module.exports = D3Util;
diff --git a/src/d3/view/D33dSubView.js b/src/d3/view/D33dSubView.js
new file mode 100644
index 0000000000000000000000000000000000000000..436a82f76af386d23b83114b0edc9855c4d32980
--- /dev/null
+++ b/src/d3/view/D33dSubView.js
@@ -0,0 +1,157 @@
+'use strict';
+
+var d3 = require('d3'),
+  ClassList = require('../util/ClassList'),
+  Util = require('../../util/Util'),
+  View = require('../../mvc/View');
+
+var _DEFAULTS = {
+  element: 'g',
+  elementNamespace: 'http://www.w3.org/2000/svg',
+  id: null,
+};
+
+var _ID_SEQUENCE = 0;
+
+/**
+ * Sub view for a D3 plot.
+ *
+ * Manages mouseover, mouseout, click events for view.
+ * mouseover and mouseout toggle a "mouseover" class on view.
+ * click triggers "click" event.
+ *
+ * When added to a D3BaseView, "click" event triggers "select" in collection.
+ * D3BaseView calls onSelect, onDeselect methods when collection selection
+ * changes.
+ *
+ * @param options {Object}
+ *        all options are passed to View.
+ */
+var D33dSubView = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _el;
+
+  _this = View(
+    Util.extend(
+      {
+        // View will not create an element if any "el" property is specified.
+        // Do this so an element is only created if not configured.
+        el: null,
+      },
+      options
+    )
+  );
+
+  /**
+   * Initialize view.
+   */
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    // ensure views have a unique id
+    _this.id = options.id || _ID_SEQUENCE++;
+
+    if (_this.el === null) {
+      _this.el = document.createElementNS(options.elementNamespace, options.element);
+    }
+
+    // reference to view from element
+    _this.el.view = _this;
+
+    ClassList.polyfill(_this.el);
+    _el = d3.select(_this.el);
+    _el.on('click', _this.onClick);
+    _el.on('mouseout', _this.onMouseOut);
+    _el.on('mouseover', _this.onMouseOver);
+  };
+
+  /**
+   * Destroy view.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      // already destroyed
+      return;
+    }
+
+    if (_el) {
+      _el.on('click', null);
+      _el.on('mouseout', null);
+      _el.on('mouseover', null);
+      _el = null;
+    }
+
+    _this.el.view = null;
+    _this = null;
+  }, _this.destroy);
+
+  /**
+   * Click event handler.
+   */
+  _this.onClick = function () {
+    _this.trigger('click');
+  };
+
+  /**
+   * Deselect event handler.
+   */
+  _this.onDeselect = function () {
+    _this.el.classList.remove('selected');
+  };
+
+  /**
+   * Mouseout event handler.
+   */
+  _this.onMouseOut = function () {
+    _this.el.classList.remove('mouseover');
+  };
+
+  /**
+   * Mouseover event handler.
+   */
+  _this.onMouseOver = function () {
+    _this.el.classList.add('mouseover');
+  };
+
+  /**
+   * Select event handler.
+   */
+  _this.onSelect = function () {
+    _this.el.classList.add('selected');
+  };
+
+  /**
+   * Get plotting data for D33dView.
+   *
+   * @return {Object}
+   *         el - view element
+   *         when el is a SVG "g" element
+   *             items {Array<Object>}
+   *                 items within group
+   *         when el is a SVG "text" element
+   *             coords {Array<Number>}
+   *                 coordinates for text anchor point.
+   *         when el is a SVG "path" element
+   *             close {Boolean}
+   *                 default true
+   *             coords {Array<Array<Number>>}
+   *                 array of path coordinates.
+   */
+  _this.getData = function () {
+    throw new Error('D33dSubView.getData not implemented');
+  };
+
+  /**
+   * Render sub view.
+   * Update all view attributes except coordinates (updated by D33dView).
+   */
+  _this.render = function () {};
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dSubView;
diff --git a/src/d3/view/D33dView.js b/src/d3/view/D33dView.js
new file mode 100644
index 0000000000000000000000000000000000000000..15279a450f799135bb88fcd84777cea7ef01281a
--- /dev/null
+++ b/src/d3/view/D33dView.js
@@ -0,0 +1,283 @@
+'use strict';
+
+var Camera = require('../../math/Camera'),
+  D3BaseView = require('./D3BaseView'),
+  Util = require('../../util/Util'),
+  Vector = require('../../math/Vector');
+
+var _DEFAULTS = {
+  lookAt: [0, 0, 0],
+  origin: [100, 100, 100],
+  up: [0, 0, 1],
+  zoom: 10,
+};
+
+/**
+ * Simulate a 3D view.
+ *
+ * Add objects to the D33dView.views collection to make them plot:
+ * - D33dGroup
+ *   - D33dAxis
+ *   - D33dCuboid
+ * - D33dPath
+ * - D33dText
+ *
+ * The properties lookAt, origin, up, and zoom, can be modified by updating the
+ * corresponding D33dView.model properties.
+ *
+ * @param options {Object}
+ * @param options.lookAt {Array<x, y, z>}
+ *        default [0, 0, 0].
+ *        where view looks at.
+ * @param options.origin {Array<x, y, z>}
+ *        default [100, 100, 100].
+ *        where view looks from.
+ * @param options.up {Array<x, y, z>}
+ *        default [0, 0, 1] (z-axis up).
+ *        up vector.
+ * @param options.zoom {Number}
+ *        default 10.
+ *        scaling factor for plot.
+ */
+var D33dView = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _camera,
+    _centerX,
+    _centerY,
+    _zoom,
+    // methods
+    _createCamera,
+    _projectObject,
+    _renderObject;
+
+  _this = D3BaseView(options);
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _centerX = 0;
+    _centerY = 0;
+    _zoom = options.zoom;
+
+    _createCamera();
+
+    _this.model.on('change:lookAt', _createCamera);
+    _this.model.on('change:origin', _createCamera);
+    _this.model.on('change:up', _createCamera);
+  };
+
+  /**
+   * Create the camera object used for projection.
+   */
+  _createCamera = function () {
+    var lookAt, origin, up;
+
+    lookAt = _this.model.get('lookAt');
+    origin = _this.model.get('origin');
+    up = _this.model.get('up');
+    _zoom = _this.model.get('zoom');
+
+    _camera = Camera({
+      lookAt: lookAt,
+      origin: origin,
+      up: up,
+    });
+  };
+
+  /**
+   * Project object coordinates.
+   *
+   * Sets the object properties `_projected` and `_z`.
+   */
+  _projectObject = function (obj) {
+    var type;
+
+    type = obj.el.nodeName;
+    if (type === 'g') {
+      obj.items.forEach(function (obj) {
+        _projectObject(obj);
+      });
+      obj._z = Math.max.apply(
+        Math,
+        obj.items.map(function (p) {
+          return p._z;
+        })
+      );
+    } else if (type === 'text') {
+      obj._projected = _this.project(obj.coords);
+      obj._z = obj._projected[2];
+      if (obj.direction) {
+        obj._direction = obj.direction.map(_this.project);
+      }
+    } else if (type === 'path') {
+      obj._projected = obj.coords.map(_this.project);
+      obj._z = Math.max.apply(
+        Math,
+        obj._projected.map(function (c) {
+          return c[2];
+        })
+      );
+    } else {
+      throw new Error('unexpected element type "' + type + '"');
+    }
+  };
+
+  /**
+   * Render object coordinates.
+   *
+   * Ignores objects that are behind the camera (z' <= 0).
+   *
+   * @param obj {Object}
+   *        object to render.
+   * @param el {SVGElement}
+   *        Element where object should be appended.
+   */
+  _renderObject = function (obj, el) {
+    var angle, direction, end, start, type, x, y;
+
+    if (obj._z <= 0) {
+      // behind camera
+      return;
+    }
+
+    // attach element
+    el.appendChild(obj.el);
+    el = obj.el;
+    type = el.nodeName;
+    if (type === 'g') {
+      // empty group element
+      Util.empty(el);
+      // sort to plot further objects first
+      obj.items.sort(function (a, b) {
+        return b._z - a._z;
+      });
+      // plot nested objects
+      obj.items.forEach(function (item) {
+        _renderObject(item, obj.el);
+      });
+    } else if (type === 'text') {
+      x = obj._projected[0];
+      y = obj._projected[1];
+      el.setAttribute('x', x);
+      el.setAttribute('y', y);
+      if (obj._direction) {
+        // direction representa a vector that should be parallel to the text
+        end = Vector(obj._direction[1]);
+        start = Vector(obj._direction[0]);
+        direction = end.subtract(start);
+        // check angle in camera plane
+        direction.z(0);
+        angle = (direction.azimuth() * 180) / Math.PI;
+        // subtract azimuth from 90, screen y axis is in opposite direction
+        angle = 90 - angle;
+        el.setAttribute('transform', 'rotate(' + [angle, x, y].join(' ') + ')');
+      }
+    } else if (type === 'path') {
+      el.setAttribute(
+        'd',
+        'M' +
+          obj._projected
+            .map(function (c) {
+              return c[0] + ',' + c[1];
+            })
+            .join('L') +
+          (obj.close !== false ? 'Z' : '')
+      );
+    } else {
+      throw new Error('unexpected element type "' + type + '"');
+    }
+  };
+
+  /**
+   * Free references.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      return;
+    }
+
+    _this.model.off('change:lookAt', _createCamera);
+    _this.model.off('change:origin', _createCamera);
+    _this.model.off('change:up', _createCamera);
+
+    _centerX = null;
+    _centerY = null;
+    _createCamera = null;
+    _initialize = null;
+    _projectObject = null;
+    _renderObject = null;
+    _this = null;
+    _zoom = null;
+  }, _this.destroy);
+
+  /**
+   * Project a single coordinate into the view x/y plane.
+   *
+   * @param coords {Array<x, y, z>}
+   * @return {Array<x', y', z'>} projected coordinates.
+   *         z' represents the distance from the camera plane to the object.
+   */
+  _this.project = function (coords) {
+    var projected, x, y, z;
+
+    projected = _camera.project(coords);
+    x = projected[0];
+    y = projected[1];
+    z = projected[2];
+
+    // zoom and center
+    x = x * _zoom + _centerX;
+    y = -y * _zoom + _centerY;
+
+    return [x, y, z];
+  };
+
+  /**
+   * Render all views.
+   *
+   * @param info {Object}
+   *        plot information.
+   */
+  _this.renderViews = function (info) {
+    var plotArea, torender;
+
+    // update projection
+    _centerX = info.innerHeight / 2;
+    _centerY = info.innerWidth / 2;
+
+    // clear plot
+    plotArea = info.el;
+    Util.empty(plotArea);
+
+    // get raw coordinates
+    torender = [];
+    _this.views.data().forEach(function (view) {
+      torender.push(view.getData());
+    });
+    // compute position
+    torender.forEach(function (r) {
+      _projectObject(r);
+    });
+    // stack to plot closest elements last
+    torender.sort(function (a, b) {
+      return b._z - a._z;
+    });
+    // position elements
+    torender.forEach(function (r) {
+      _renderObject(r, plotArea);
+    });
+
+    // now that elements are positioned, have views update rendering
+    _this.views.data().forEach(function (view) {
+      view.render(_this);
+    });
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dView;
diff --git a/src/d3/view/D3BaseView.js b/src/d3/view/D3BaseView.js
new file mode 100644
index 0000000000000000000000000000000000000000..d4f925b0c2c582a338cec3eb1a31606c9e042048
--- /dev/null
+++ b/src/d3/view/D3BaseView.js
@@ -0,0 +1,536 @@
+'use strict';
+
+var d3 = require('d3'),
+  Collection = require('../../mvc/Collection'),
+  D3Util = require('../util/D3Util'),
+  Util = require('../../util/Util'),
+  View = require('../../mvc/View');
+
+var _DEFAULTS = {
+  clickToSelect: true,
+  height: 480,
+  legendPosition: 'topright',
+  legendOffset: 20,
+  marginBottom: 0,
+  marginLeft: 0,
+  marginRight: 0,
+  marginTop: 0,
+  paddingBottom: 80,
+  paddingLeft: 80,
+  paddingRight: 20,
+  paddingTop: 50,
+  pointRadius: 3,
+  title: '',
+  tooltipOffset: 10,
+  tooltipPadding: 5,
+  width: 640,
+};
+
+/**
+ * View for a D3 plot.
+ *
+ * @param options {Object}
+ *        options are passed to View.
+ * @param options.clickToSelect {Boolean}
+ *        default true.
+ *        when true, clicking a view causes it to be selected in the
+ *        views collection.
+ * @param options.height {Number}
+ *        default 480.
+ *        overall (viewbox) height of svg element.
+ * @param options.legendPosition {String}
+ *        default 'topleft'.
+ *        one of (topright|topleft|bottomright|bottomleft).
+ *        position of legend element.
+ * @param options.marginBottom {Number}
+ *        default 0.
+ * @param options.marginLeft {Number}
+ *        default 0.
+ * @param options.marginRight {Number}
+ *        default 0.
+ * @param options.marginTop {Number}
+ *        default 0.
+ * @param options.paddingBottom {Number}
+ *        default 80.
+ * @param options.paddingLeft {Number}
+ *        default 80.
+ * @param options.paddingRight {Number}
+ *        default 20.
+ * @param options.paddingTop {Number}
+ *        default 50.
+ * @param options.title {String}
+ *        title for plot.
+ * @param options.tooltipOffset {Number}
+ *        default 10.
+ *        x/y distance from tooltip coordinate.
+ * @param options.tooltipPadding {Number}
+ *        default 5.
+ *        padding around tooltip content.
+ * @param options.width {Number}
+ *        default 640.
+ *        width of svg viewBox.
+ */
+var D3BaseView = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _axes,
+    _firstRender,
+    _innerFrame,
+    _legend,
+    _margin,
+    _outerFrame,
+    _padding,
+    _plotArea,
+    _plotAreaClip,
+    _plotTitle,
+    _svg,
+    _tooltip;
+
+  _this = View(options);
+
+  /**
+   * Initialize view.
+   */
+  _initialize = function (options) {
+    var el;
+
+    options = options || {};
+    _firstRender = true;
+    _this.model.set(Util.extend({}, _DEFAULTS, options), { silent: true });
+
+    el = _this.el;
+    el.classList.add('D3BaseView');
+    el.innerHTML =
+      '<svg xmlns="http://www.w3.org/2000/svg">' +
+      '<defs>' +
+      '<clipPath id="plotAreaClip">' +
+      '<rect x="0" y="0"></rect>' +
+      '</clipPath>' +
+      '</defs>' +
+      '<g class="margin">' +
+      '<rect class="outer-frame"></rect>' +
+      '<text class="plot-title" text-anchor="middle"></text>' +
+      '<g class="padding">' +
+      '<rect class="inner-frame"></rect>' +
+      '<g class="legend"></g>' +
+      '<g class="plot"></g>' +
+      '<g class="tooltip"></g>' +
+      '</g>' +
+      '</g>' +
+      '</svg>';
+
+    _svg = el.querySelector('svg');
+    _plotAreaClip = _svg.querySelector('#plotAreaClip > rect');
+    _outerFrame = _svg.querySelector('.outer-frame');
+    _innerFrame = _svg.querySelector('.inner-frame');
+    _margin = _svg.querySelector('.margin');
+    _plotTitle = _margin.querySelector('.plot-title');
+    _padding = _margin.querySelector('.padding');
+    _legend = _padding.querySelector('.legend');
+    _axes = _padding.querySelector('.axes');
+    _plotArea = _padding.querySelector('.plot');
+    _tooltip = _padding.querySelector('.tooltip');
+
+    _this.views = Collection([]);
+    _this.views.on('add', _this.onAdd);
+    _this.views.on('deselect', _this.onDeselect);
+    _this.views.on('remove', _this.onRemove);
+    _this.views.on('reset', _this.onReset);
+    _this.views.on('select', _this.onSelect);
+  };
+
+  /**
+   * Destroy view.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      // already destroyed
+      return;
+    }
+
+    _this.views.off();
+    _this.views.destroy();
+
+    _this.views = null;
+    _innerFrame = null;
+    _legend = null;
+    _margin = null;
+    _outerFrame = null;
+    _padding = null;
+    _plotArea = null;
+    _plotAreaClip = null;
+    _plotTitle = null;
+    _svg = null;
+    _tooltip = null;
+    _this = null;
+  }, _this.destroy);
+
+  /**
+   * Views collection add handler.
+   *
+   * @param views {Array<D3SubView>}
+   *        views that were added.
+   */
+  _this.onAdd = function (views, dontrender) {
+    views.forEach(function (view) {
+      view._d3view_onclick = function () {
+        _this.onClick(view);
+      };
+      view.on('click', view._d3view_onclick);
+    });
+    if (!dontrender) {
+      _this.render();
+    }
+  };
+
+  /**
+   * Called when a view is clicked.
+   *
+   * @param view {D3SubView}
+   *        view that was clicked.
+   */
+  _this.onClick = function (view) {
+    if (_this.model.get('clickToSelect')) {
+      _this.views.select(view);
+    }
+  };
+
+  /**
+   * Views collection select handler.
+   *
+   * @param view {D3SubView}
+   *        view that was selected.
+   */
+  _this.onDeselect = function (view) {
+    view.onDeselect();
+  };
+
+  /**
+   * Views collection remove handler.
+   *
+   * @param views {Array<D3SubView>}
+   *        views that were removed.
+   */
+  _this.onRemove = function (views, dontrender) {
+    views.forEach(function (view) {
+      view.off('click', view._d3view_onclick);
+      view._d3view_onclick = null;
+    });
+    if (!dontrender) {
+      _this.render();
+    }
+  };
+
+  /**
+   * Views collection reset handler.
+   */
+  _this.onReset = function () {
+    var el,
+      toRemove = [];
+    // call onRemove for all existing views.
+    while (_plotArea.firstChild) {
+      // detach view
+      el = _plotArea.firstChild;
+      _plotArea.removeChild(el);
+      // call remove to clean up
+      toRemove.push(el.view);
+    }
+    _this.onRemove(toRemove);
+    // call onAdd for all views
+    _this.onAdd(_this.views.data());
+  };
+
+  /**
+   * Views collection select handler.
+   *
+   * @param view {D3SubView}
+   *        view that was selected.
+   */
+  _this.onSelect = function (view) {
+    view.onSelect();
+  };
+
+  /**
+   * Project data coordinates to plot coordinates.
+   *
+   * @param coords {Array<Number>}
+   *        data coordinate.
+   * @return {Array<Number>}
+   *         plot coordinate.
+   */
+  _this.project = function (coords) {
+    return coords;
+  };
+
+  /**
+   * Render view.
+   *
+   * @param changed {Object}
+   *        default is _this.model.get.
+   *        list of properties that have changed.
+   */
+  _this.render = function (changed) {
+    var height,
+      innerWidth,
+      innerHeight,
+      legendPosition,
+      legendX,
+      legendY,
+      marginBottom,
+      marginLeft,
+      marginRight,
+      marginTop,
+      options,
+      outerHeight,
+      outerWidth,
+      paddingBottom,
+      paddingLeft,
+      paddingRight,
+      paddingTop,
+      width;
+
+    options = _this.model.get();
+    if (_firstRender || !changed) {
+      changed = options;
+      _firstRender = false;
+    }
+
+    // these are used for label positioning
+    paddingBottom = options.paddingBottom;
+    paddingLeft = options.paddingLeft;
+    width = options.width;
+    height = options.height;
+    marginBottom = options.marginBottom;
+    marginLeft = options.marginLeft;
+    marginRight = options.marginRight;
+    marginTop = options.marginTop;
+    paddingRight = options.paddingRight;
+    paddingTop = options.paddingTop;
+    // adjust based on margin/padding
+    outerWidth = width - marginLeft - marginRight;
+    outerHeight = height - marginTop - marginBottom;
+    innerWidth = outerWidth - paddingLeft - paddingRight;
+    innerHeight = outerHeight - paddingTop - paddingBottom;
+
+    if (changed.hasOwnProperty('title')) {
+      _plotTitle.textContent = options.title;
+      _plotTitle.setAttribute('y', D3Util.getBBox(_plotTitle).height);
+    }
+
+    if (
+      changed.hasOwnProperty('width') ||
+      changed.hasOwnProperty('height') ||
+      changed.hasOwnProperty('legendPosition') ||
+      changed.hasOwnProperty('marginBottom') ||
+      changed.hasOwnProperty('marginLeft') ||
+      changed.hasOwnProperty('marginRight') ||
+      changed.hasOwnProperty('marginTop') ||
+      changed.hasOwnProperty('paddingBottom') ||
+      changed.hasOwnProperty('paddingLeft') ||
+      changed.hasOwnProperty('paddingRight') ||
+      changed.hasOwnProperty('paddingTop')
+    ) {
+      // update elements
+      _this.el.style.paddingBottom = (100 * height) / width + '%';
+      _svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
+      _svg.setAttribute('preserveAspectRatio', 'xMinYMin meet');
+      _plotAreaClip.setAttribute('width', innerWidth);
+      _plotAreaClip.setAttribute('height', innerHeight);
+      _margin.setAttribute('transform', 'translate(' + marginLeft + ',' + marginTop + ')');
+      _outerFrame.setAttribute('height', outerHeight);
+      _outerFrame.setAttribute('width', outerWidth);
+      _plotTitle.setAttribute('x', outerWidth / 2);
+      _padding.setAttribute('transform', 'translate(' + paddingLeft + ',' + paddingTop + ')');
+      _innerFrame.setAttribute('width', innerWidth);
+      _innerFrame.setAttribute('height', innerHeight);
+
+      legendPosition = options.legendPosition;
+      legendX = 0;
+      legendY = 0;
+      if (legendPosition === 'topright') {
+        legendX = innerWidth;
+      } else if (legendPosition === 'bottomleft') {
+        legendY = innerHeight;
+      } else if (legendPosition === 'bottomright') {
+        legendX = innerWidth;
+        legendY = innerHeight;
+      } // else 'topleft'
+      _legend.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')');
+    }
+
+    _this.renderViews({
+      el: _plotArea,
+      height: height,
+      innerHeight: innerHeight,
+      innerWidth: innerWidth,
+      marginBottom: marginBottom,
+      marginLeft: marginLeft,
+      marginRight: marginRight,
+      marginTop: marginTop,
+      outerWidth: outerWidth,
+      outerHeight: outerHeight,
+    });
+
+    _this.renderLegend({
+      el: _legend,
+    });
+
+    /**
+      work around suggested by
+      https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3723601/
+     */
+    _this.el.classList.add('ms-render-fix');
+    setTimeout(function () {
+      if (_this) {
+        _this.el.classList.remove('ms-render-fix');
+      }
+    }, 50);
+  };
+
+  /**
+   * Re-render sub-views.
+   *
+   * @param info {Object}
+   *        plot information.
+   * @param info.el {SVGElement}
+   *        plot area element.
+   * @param info.height {Number}
+   *        total plot height.
+   * @param info.innerHeight {Number}
+   *        plot area height (height - margin/padding).
+   * @param info.innerWidth {Number}
+   *        plot area width (width - margin/padding).
+   * @param info.marginBottom {Number}
+   *        bottom margin.
+   * @param info.marginLeft {Number}
+   *        left margin.
+   * @param info.marginRight {Number}
+   *        right margin.
+   * @param info.marginTop {Number}
+   *        top margin.
+   * @param info.outerHeight {Number}
+   *        height outside padding (height - margin).
+   * @param info.outerWidth {Number}
+   *        width outside padding (width - margin).
+   */
+  _this.renderViews = function (info) {
+    var plotArea = info.el;
+
+    _this.views.data().forEach(function (view, index) {
+      // add elements
+      plotArea.appendChild(view.el);
+      view.el.setAttribute('data-index', index);
+      view.render(_this);
+    });
+  };
+
+  /**
+   * Re-render legend.
+   *
+   * @param info {Object}
+   *        plot information.
+   * @param info.el {SVGElement}
+   *        legend area element.
+   */
+  _this.renderLegend = function (info) {
+    var bbox, legend, legendContent, legendOffset, legendPosition, legendX, legendY;
+
+    legend = info.el;
+    // clear legend area
+    Util.empty(legend);
+    legendContent = d3.select(legend).append('g').attr('class', 'legend-content').node();
+
+    // add views to plot area
+    legendY = 0;
+    _this.views.data().forEach(function (view, index) {
+      if (view.legend) {
+        legendContent.appendChild(view.legend);
+        view.legend.setAttribute('data-index', index);
+        if (typeof view.renderLegend === 'function') {
+          // render legend after attached
+          view.renderLegend();
+        }
+        // position legend
+        bbox = D3Util.getBBox(view.legend);
+        legendY += bbox.height;
+        view.legend.setAttribute('transform', 'translate(0,' + legendY + ')');
+      }
+    });
+
+    // position legend content.
+    bbox = D3Util.getBBox(legendContent);
+    legendOffset = _this.model.get('legendOffset');
+    legendPosition = _this.model.get('legendPosition');
+    legendX = legendOffset;
+    legendY = legendOffset;
+    if (legendPosition === 'topright') {
+      legendX = -(legendOffset + bbox.width);
+    } else if (legendPosition === 'bottomleft') {
+      legendY = -(legendOffset + bbox.height);
+    } else if (legendPosition === 'bottomright') {
+      legendX = -(legendOffset + bbox.width);
+      legendY = -(legendOffset + bbox.height);
+    } // else 'topleft'
+    legendContent.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')');
+  };
+
+  /**
+   * Show a tooltip on the graph.
+   *
+   * @param coords {Array<x, y>}
+   *        plot coordinates for origin of tooltip, should be pre-projected.
+   * @param data {Array<Object|Array>}
+   *        tooltip content, passed to formatTooltip.
+   */
+  _this.showTooltip = function (coords, data) {
+    var bbox, content, offset, options, outline, padding, tooltip, tooltipBbox, x, y;
+
+    tooltip = d3.select(_tooltip);
+    // clear tooltip
+    tooltip.selectAll('*').remove();
+    if (!coords || !data) {
+      return;
+    }
+
+    options = _this.model.get();
+    offset = options.tooltipOffset;
+    padding = options.tooltipPadding;
+    // create tooltip content
+    outline = tooltip.append('rect').attr('class', 'tooltip-outline');
+    content = tooltip.append('g').attr('class', 'tooltip-content');
+    D3Util.formatText(content, data);
+    // position tooltip outline
+    bbox = D3Util.getBBox(tooltip.node());
+    outline.attr('width', bbox.width + 2 * padding).attr('height', bbox.height + 2 * padding);
+    content.attr('transform', 'translate(' + padding + ',0)');
+
+    // center of point
+    x = coords[0];
+    y = coords[1];
+
+    // box rendering inside
+    bbox = D3Util.getBBox(_innerFrame);
+    // box being rendered
+    tooltipBbox = D3Util.getBBox(_tooltip);
+    // keep tooltip in graph area
+    if (x + tooltipBbox.width > bbox.width) {
+      x = x - tooltipBbox.width - offset;
+    } else {
+      x = x + offset;
+    }
+    if (y + tooltipBbox.height > bbox.height) {
+      y = y - tooltipBbox.height - offset;
+    } else {
+      y = y + offset;
+    }
+    // set position
+    _tooltip.setAttribute('transform', 'translate(' + x + ',' + y + ')');
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D3BaseView;
diff --git a/src/d3/view/D3SubView.js b/src/d3/view/D3SubView.js
new file mode 100644
index 0000000000000000000000000000000000000000..599f04037f18abd0205e9bad43031cad59375b7c
--- /dev/null
+++ b/src/d3/view/D3SubView.js
@@ -0,0 +1,195 @@
+'use strict';
+
+var d3 = require('d3'),
+  ClassList = require('../util/ClassList'),
+  Util = require('../../util/Util'),
+  View = require('../../mvc/View');
+
+var ID_SEQUENCE = 0;
+
+/**
+ * Sub view for a D3 plot.
+ *
+ * Manages mouseover, mouseout, click events for view.
+ * mouseover and mouseout toggle a "mouseover" class on view.
+ * click triggers "click" event.
+ *
+ * When added to a D3View, "click" event triggers "select" in collection.
+ * D3View calls onSelect, onDeselect methods when collection selection changes.
+ *
+ * Subclasses should override at least getXExtent(), getYExtent(), render(view).
+ *
+ * @param options {Object}
+ *        all options are passed to View.
+ * @param options.el {SVGElement}
+ *        default svg:g.
+ * @param options.legend {SVGElement}
+ *        default svg:g.
+ *        set to null for no legend.
+ * @param options.className {String}
+ *        default null.
+ *        class added to el and legend.
+ */
+var D3SubView = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _el,
+    _legend;
+
+  _this = View(
+    Util.extend(
+      {
+        el: document.createElementNS('http://www.w3.org/2000/svg', 'g'),
+      },
+      options
+    )
+  );
+
+  /**
+   * Initialize view.
+   */
+  _initialize = function (options) {
+    options = Util.extend(
+      {
+        className: null,
+        legend: document.createElementNS('http://www.w3.org/2000/svg', 'g'),
+      },
+      options
+    );
+
+    // ensure views have a unique id
+    _this.id = options.id || ID_SEQUENCE++;
+    _this.view = options.view;
+
+    // reference to view from element
+    _this.el.view = _this;
+
+    ClassList.polyfill(_this.el);
+    _el = d3.select(_this.el);
+    _el.on('click', _this.onClick);
+    _el.on('mouseout', _this.onMouseOut);
+    _el.on('mouseover', _this.onMouseOver);
+
+    _this.legend = options.legend;
+    if (_this.legend) {
+      ClassList.polyfill(_this.legend);
+      _legend = d3.select(_this.legend);
+      _legend.on('click', _this.onClick);
+      _legend.on('mouseout', _this.onMouseOut);
+      _legend.on('mouseover', _this.onMouseOver);
+    }
+
+    if (options.className) {
+      _this.el.classList.add(options.className);
+      if (_this.legend) {
+        _this.legend.classList.add(options.className);
+      }
+    }
+  };
+
+  /**
+   * Destroy view.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      // already destroyed
+      return;
+    }
+
+    if (_el) {
+      _el.on('click', null);
+      _el.on('mouseout', null);
+      _el.on('mouseover', null);
+      _el = null;
+    }
+    if (_this.legend) {
+      _legend.on('click', null);
+      _legend.on('mouseout', null);
+      _legend.on('mouseover', null);
+      _legend = null;
+    }
+
+    _this.el.view = null;
+    _this = null;
+  }, _this.destroy);
+
+  /**
+   * X extent for view.
+   *
+   * @return {Array<Number>}
+   *         x extent for view.
+   */
+  _this.getXExtent = function () {
+    return [];
+  };
+
+  /**
+   * Y extent for view.
+   *
+   * @return {Array<Number>}
+   *         y extent for view.
+   */
+  _this.getYExtent = function () {
+    return [];
+  };
+
+  /**
+   * Click event handler.
+   */
+  _this.onClick = function () {
+    _this.trigger('click');
+  };
+
+  /**
+   * Deselect event handler.
+   */
+  _this.onDeselect = function () {
+    _this.el.classList.remove('selected');
+    if (_this.legend) {
+      _this.legend.classList.remove('selected');
+    }
+  };
+
+  /**
+   * Mouseout event handler.
+   */
+  _this.onMouseOut = function () {
+    _this.el.classList.remove('mouseover');
+    if (_this.legend) {
+      _this.legend.classList.remove('mouseover');
+    }
+  };
+
+  /**
+   * Mouseover event handler.
+   */
+  _this.onMouseOver = function () {
+    _this.el.classList.add('mouseover');
+    if (_this.legend) {
+      _this.legend.classList.add('mouseover');
+    }
+  };
+
+  /**
+   * Select event handler.
+   */
+  _this.onSelect = function () {
+    _this.el.classList.add('selected');
+    if (_this.legend) {
+      _this.legend.classList.add('selected');
+    }
+  };
+
+  /**
+   * Render sub view.
+   * Element has already been attached to view.
+   */
+  _this.render = function () {};
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D3SubView;
diff --git a/src/d3/view/D3View.js b/src/d3/view/D3View.js
new file mode 100644
index 0000000000000000000000000000000000000000..22ba5a2b352bb9fe05c39b1b0499e9151f79c06d
--- /dev/null
+++ b/src/d3/view/D3View.js
@@ -0,0 +1,696 @@
+'use strict';
+
+var d3 = require('d3'),
+  Collection = require('../../mvc/Collection'),
+  D3Util = require('../util/D3Util'),
+  Util = require('../../util/Util'),
+  View = require('../../mvc/View');
+
+/**
+ * View for a D3 plot.
+ *
+ * @param options {Object}
+ *        options are passed to View.
+ * @param options.clickToSelect {Boolean}
+ *        default true.
+ *        when true, clicking a view causes it to be selected in the
+ *        views collection.
+ * @param options.height {Number}
+ *        default 480.
+ *        overall (viewbox) height of svg element.
+ * @param options.legendPosition {String}
+ *        default 'topleft'.
+ *        one of (topright|topleft|bottomright|bottomleft).
+ *        position of legend element.
+ * @param options.marginBottom {Number}
+ *        default 0.
+ * @param options.marginLeft {Number}
+ *        default 0.
+ * @param options.marginRight {Number}
+ *        default 0.
+ * @param options.marginTop {Number}
+ *        default 0.
+ * @param options.paddingBottom {Number}
+ *        default 80.
+ * @param options.paddingLeft {Number}
+ *        default 80.
+ * @param options.paddingRight {Number}
+ *        default 20.
+ * @param options.paddingTop {Number}
+ *        default 50.
+ * @param options.title {String}
+ *        title for plot.
+ * @param options.tooltipOffset {Number}
+ *        default 10.
+ *        x/y distance from tooltip coordinate.
+ * @param options.tooltipPadding {Number}
+ *        default 5.
+ *        padding around tooltip content.
+ * @param options.width {Number}
+ *        default 640.
+ *        width of svg viewBox.
+ * @param options.xAxisFormat {Function|String}
+ *        default null.
+ *        x axis tickFormat.
+ * @param options.xAxisPadding {Number}
+ *        default 0.05.
+ *        pad extents by this ratio.
+ *        For example:  0.05 pads the x axis extent by 5% of the range.
+ * @param options.xAxisScale {d3.scale}
+ *        default d3.scale.linear().
+ * @param options.xAxisTicks {Function(extent)|Array<Number>}
+ *        default null.
+ *        x axis tick values.
+ * @param optoins.xExtent {Array<Number>}
+ *        default null.
+ *        explicit x extent for graph, default is auto.
+ * @param options.xLabel {String}
+ *        label for x axis.
+ * @param options.yAxisFormat {Function|String}
+ *        default null.
+ *        y axis tickFormat.
+ * @param options.yAxisPadding {Number}
+ *        default 0.05.
+ *        pad extents by this ratio.
+ *        For example:  0.05 pads the y axis extent by 5% of the range.
+ * @param options.yAxisScale {d3.scale}
+ *        default d3.scale.linear().
+ * @param options.yAxisTicks {Function(extent)|Array<Number>}
+ *        default null.
+ *        y axis tick values.
+ * @param optoins.yExtent {Array<Number>}
+ *        default null.
+ *        explicit y extent for graph, default is auto.
+ * @param options.yLabel {String}
+ *        label for y axis.
+ */
+var D3View = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _firstRender,
+    _innerFrame,
+    _legend,
+    _margin,
+    _outerFrame,
+    _padding,
+    _plotArea,
+    _plotAreaClip,
+    _plotTitle,
+    _svg,
+    _tooltip,
+    _xAxis,
+    _xAxisEl,
+    _xAxisLabel,
+    _xEl,
+    _yAxis,
+    _yAxisEl,
+    _yAxisLabel,
+    _yEl;
+
+  _this = View(options);
+
+  /**
+   * Initialize view.
+   */
+  _initialize = function (options) {
+    var el;
+
+    options = options || {};
+    _firstRender = true;
+
+    _this.model.set(
+      Util.extend(
+        {
+          clickToSelect: true,
+          height: 480,
+          legendPosition: 'topright',
+          legendOffset: 20,
+          marginBottom: 0,
+          marginLeft: 0,
+          marginRight: 0,
+          marginTop: 0,
+          paddingBottom: 80,
+          paddingLeft: 80,
+          paddingRight: 20,
+          paddingTop: 50,
+          pointRadius: 3,
+          title: '',
+          tooltipOffset: 10,
+          tooltipPadding: 5,
+          width: 640,
+          xAxisFormat: null,
+          xAxisPadding: 0.05,
+          xAxisScale: d3.scale.linear(),
+          xAxisTicks: null,
+          xExtent: null,
+          xLabel: '',
+          yAxisFormat: null,
+          yAxisPadding: 0.05,
+          yAxisScale: d3.scale.linear(),
+          yAxisTicks: null,
+          yExtent: null,
+          yLabel: '',
+        },
+        options
+      ),
+      { silent: true }
+    );
+
+    el = _this.el;
+    el.classList.add('D3View');
+    el.innerHTML =
+      '<svg xmlns="http://www.w3.org/2000/svg">' +
+      '<defs>' +
+      '<clipPath id="plotAreaClip">' +
+      '<rect x="0" y="0"></rect>' +
+      '</clipPath>' +
+      '</defs>' +
+      '<g class="margin">' +
+      '<rect class="outer-frame"></rect>' +
+      '<text class="plot-title" text-anchor="middle"></text>' +
+      '<g class="padding">' +
+      '<rect class="inner-frame"></rect>' +
+      '<g class="legend"></g>' +
+      '<g class="x">' +
+      '<g class="axis"></g>' +
+      '<text class="label" text-anchor="middle"></text>' +
+      '</g>' +
+      '<g class="y">' +
+      '<g class="axis"></g>' +
+      '<text class="label" text-anchor="middle"' +
+      ' transform="rotate(-90)"></text>' +
+      '</g>' +
+      '<g class="plot"></g>' +
+      '<g class="tooltip"></g>' +
+      '</g>' +
+      '</g>' +
+      '</svg>';
+
+    _svg = el.querySelector('svg');
+    _plotAreaClip = _svg.querySelector('#plotAreaClip > rect');
+    _outerFrame = _svg.querySelector('.outer-frame');
+    _innerFrame = _svg.querySelector('.inner-frame');
+    _margin = _svg.querySelector('.margin');
+    _plotTitle = _margin.querySelector('.plot-title');
+    _padding = _margin.querySelector('.padding');
+    _legend = _padding.querySelector('.legend');
+    _xEl = _padding.querySelector('.x');
+    _xAxisEl = _xEl.querySelector('.axis');
+    _xAxisLabel = _xEl.querySelector('.label');
+    _yEl = _padding.querySelector('.y');
+    _yAxisEl = _yEl.querySelector('.axis');
+    _yAxisLabel = _yEl.querySelector('.label');
+    _plotArea = _padding.querySelector('.plot');
+    _tooltip = _padding.querySelector('.tooltip');
+
+    _this.views = Collection([]);
+    _this.views.on('add', _this.onAdd);
+    _this.views.on('deselect', _this.onDeselect);
+    _this.views.on('remove', _this.onRemove);
+    _this.views.on('reset', _this.onReset);
+    _this.views.on('select', _this.onSelect);
+
+    _xAxis = d3.svg.axis().orient('bottom').outerTickSize(0);
+    _yAxis = d3.svg.axis().orient('left').outerTickSize(0);
+  };
+
+  /**
+   * Destroy view.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      // already destroyed
+      return;
+    }
+
+    _this.views.off();
+    _this.views.destroy();
+
+    _this.views = null;
+    _innerFrame = null;
+    _legend = null;
+    _margin = null;
+    _outerFrame = null;
+    _padding = null;
+    _plotArea = null;
+    _plotAreaClip = null;
+    _plotTitle = null;
+    _svg = null;
+    _tooltip = null;
+    _xAxis = null;
+    _xAxisEl = null;
+    _xAxisLabel = null;
+    _xEl = null;
+    _yAxis = null;
+    _yAxisEl = null;
+    _yAxisLabel = null;
+    _yEl = null;
+    _this = null;
+  }, _this.destroy);
+
+  _this.getLegendClass = function (/*data, index, scope*/) {
+    return 'legend-content';
+  };
+
+  /**
+   * Views collection add handler.
+   *
+   * @param views {Array<D3SubView>}
+   *        views that were added.
+   */
+  _this.onAdd = function (views, dontrender) {
+    views.forEach(function (view) {
+      view._d3view_onclick = function () {
+        _this.onClick(view);
+      };
+      view.on('click', view._d3view_onclick);
+    });
+    if (!dontrender) {
+      _this.render();
+    }
+  };
+
+  /**
+   * Called when a view is clicked.
+   *
+   * @param view {D3SubView}
+   *        view that was clicked.
+   */
+  _this.onClick = function (view) {
+    if (_this.model.get('clickToSelect')) {
+      _this.views.select(view);
+    }
+  };
+
+  /**
+   * Views collection select handler.
+   *
+   * @param view {D3SubView}
+   *        view that was selected.
+   */
+  _this.onDeselect = function (view) {
+    view.onDeselect();
+  };
+
+  /**
+   * Views collection remove handler.
+   *
+   * @param views {Array<D3SubView>}
+   *        views that were removed.
+   */
+  _this.onRemove = function (views, dontrender) {
+    views.forEach(function (view) {
+      view.off('click', view._d3view_onclick);
+      view._d3view_onclick = null;
+    });
+    if (!dontrender) {
+      _this.render();
+    }
+  };
+
+  /**
+   * Views collection reset handler.
+   */
+  _this.onReset = function () {
+    var el,
+      toRemove = [];
+    // call onRemove for all existing views.
+    while (_plotArea.firstChild) {
+      // detach view
+      el = _plotArea.firstChild;
+      _plotArea.removeChild(el);
+      // call remove to clean up
+      toRemove.push(el.view);
+    }
+    _this.onRemove(toRemove);
+    // call onAdd for all views
+    _this.onAdd(_this.views.data());
+  };
+
+  /**
+   * Views collection select handler.
+   *
+   * @param view {D3SubView}
+   *        view that was selected.
+   */
+  _this.onSelect = function (view) {
+    view.onSelect();
+  };
+
+  /**
+   * Render view.
+   *
+   * @param changed {Object}
+   *        default is _this.model.get.
+   *        list of properties that have changed.
+   */
+  _this.render = function (changed) {
+    var height,
+      innerWidth,
+      innerHeight,
+      legendPosition,
+      legendX,
+      legendY,
+      marginBottom,
+      marginLeft,
+      marginRight,
+      marginTop,
+      options,
+      outerHeight,
+      outerWidth,
+      paddingBottom,
+      paddingLeft,
+      paddingRight,
+      paddingTop,
+      width,
+      xAxisScale,
+      xAxisTicks,
+      xExtent,
+      xPlotExtent,
+      yAxisScale,
+      yAxisTicks,
+      yPlotExtent;
+
+    options = _this.model.get();
+    if (_firstRender || !changed) {
+      changed = options;
+      _firstRender = false;
+    }
+
+    // all options
+    xAxisScale = options.xAxisScale;
+    yAxisScale = options.yAxisScale;
+    // these are used for label positioning
+    paddingBottom = options.paddingBottom;
+    paddingLeft = options.paddingLeft;
+
+    if (changed.hasOwnProperty('title')) {
+      _plotTitle.textContent = options.title;
+      _plotTitle.setAttribute('y', D3Util.getBBox(_plotTitle).height);
+    }
+    if (changed.hasOwnProperty('xLabel')) {
+      _xAxisLabel.textContent = options.xLabel;
+    }
+    if (changed.hasOwnProperty('yLabel')) {
+      _yAxisLabel.textContent = options.yLabel;
+    }
+
+    if (
+      changed.hasOwnProperty('width') ||
+      changed.hasOwnProperty('height') ||
+      changed.hasOwnProperty('legendPosition') ||
+      changed.hasOwnProperty('marginBottom') ||
+      changed.hasOwnProperty('marginLeft') ||
+      changed.hasOwnProperty('marginRight') ||
+      changed.hasOwnProperty('marginTop') ||
+      changed.hasOwnProperty('paddingBottom') ||
+      changed.hasOwnProperty('paddingLeft') ||
+      changed.hasOwnProperty('paddingRight') ||
+      changed.hasOwnProperty('paddingTop')
+    ) {
+      width = options.width;
+      height = options.height;
+      marginBottom = options.marginBottom;
+      marginLeft = options.marginLeft;
+      marginRight = options.marginRight;
+      marginTop = options.marginTop;
+      paddingRight = options.paddingRight;
+      paddingTop = options.paddingTop;
+      // adjust based on margin/padding
+      outerWidth = width - marginLeft - marginRight;
+      outerHeight = height - marginTop - marginBottom;
+      innerWidth = outerWidth - paddingLeft - paddingRight;
+      innerHeight = outerHeight - paddingTop - paddingBottom;
+      // update elements
+      _this.el.style.paddingBottom = (100 * height) / width + '%';
+      _svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
+      _svg.setAttribute('preserveAspectRatio', 'xMinYMin meet');
+      _plotAreaClip.setAttribute('width', innerWidth);
+      _plotAreaClip.setAttribute('height', innerHeight);
+      _margin.setAttribute('transform', 'translate(' + marginLeft + ',' + marginTop + ')');
+      _outerFrame.setAttribute('height', outerHeight);
+      _outerFrame.setAttribute('width', outerWidth);
+      _plotTitle.setAttribute('x', outerWidth / 2);
+      _padding.setAttribute('transform', 'translate(' + paddingLeft + ',' + paddingTop + ')');
+      _innerFrame.setAttribute('width', innerWidth);
+      _innerFrame.setAttribute('height', innerHeight);
+      _xEl.setAttribute('transform', 'translate(0,' + innerHeight + ')');
+      // update axes range and position
+      xAxisScale.range([0, innerWidth]);
+      yAxisScale.range([innerHeight, 0]);
+      _xAxisLabel.setAttribute('x', innerWidth / 2);
+      _yAxisLabel.setAttribute('x', -innerHeight / 2);
+
+      legendPosition = options.legendPosition;
+      legendX = 0;
+      legendY = 0;
+      if (legendPosition === 'topright') {
+        legendX = innerWidth;
+      } else if (legendPosition === 'bottomleft') {
+        legendY = innerHeight;
+      } else if (legendPosition === 'bottomright') {
+        legendX = innerWidth;
+        legendY = innerHeight;
+      } // else 'topleft'
+      _legend.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')');
+    }
+
+    // update axes extent
+    xExtent = _this.getXExtent();
+    xPlotExtent = _this.getPlotXExtent(xExtent);
+    xAxisScale.domain(xPlotExtent);
+    yPlotExtent = _this.getPlotYExtent(xPlotExtent);
+    yAxisScale.domain(yPlotExtent);
+
+    // redraw axes
+    _xAxis.scale(xAxisScale);
+    _xAxis.tickFormat(options.xAxisFormat);
+    xAxisTicks = options.xAxisTicks;
+    if (typeof xAxisTicks === 'function') {
+      xAxisTicks = xAxisTicks(xExtent);
+    }
+    _xAxis.tickValues(xAxisTicks);
+
+    _yAxis.scale(yAxisScale);
+    _yAxis.tickFormat(options.yAxisFormat);
+    yAxisTicks = options.yAxisTicks;
+    if (typeof yAxisTicks === 'function') {
+      yAxisTicks = yAxisTicks(yPlotExtent);
+    }
+    _yAxis.tickValues(yAxisTicks);
+
+    d3.select(_xAxisEl).call(_xAxis);
+    d3.select(_yAxisEl).call(_yAxis);
+
+    // update label positions based on axes size
+    _xAxisLabel.setAttribute('y', paddingBottom - D3Util.getBBox(_xAxisLabel).height);
+    _yAxisLabel.setAttribute('y', D3Util.getBBox(_yAxisLabel).height - paddingLeft);
+
+    // now render views
+    _this.renderViews();
+
+    /**
+      work around suggested by
+      https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/3723601/
+     */
+    _this.el.classList.add('ms-render-fix');
+    setTimeout(function () {
+      if (_this) {
+        _this.el.classList.remove('ms-render-fix');
+      }
+    }, 50);
+  };
+
+  /**
+   * Re-render sub-views.
+   */
+  _this.renderViews = function () {
+    var bbox, legendContent, legendOffset, legendPosition, legendX, legendY;
+
+    // clear plot area
+    Util.empty(_plotArea);
+    Util.empty(_legend);
+    legendContent = d3.select(_legend).append('g').attr('class', _this.getLegendClass).node();
+
+    // add views to plot area
+    legendY = 0;
+    _this.views.data().forEach(function (view, index) {
+      // add elements
+      _plotArea.appendChild(view.el);
+      view.el.setAttribute('data-index', index);
+      if (view.legend) {
+        legendContent.appendChild(view.legend);
+        view.legend.setAttribute('data-index', index);
+      }
+      // render elements
+      view.render(_this);
+      // position legend
+      if (view.legend) {
+        bbox = D3Util.getBBox(view.legend);
+        legendY += bbox.height;
+        view.legend.setAttribute('transform', 'translate(0,' + legendY + ')');
+      }
+    });
+
+    // position legend content.
+    bbox = D3Util.getBBox(legendContent);
+    legendOffset = _this.model.get('legendOffset');
+    legendPosition = _this.model.get('legendPosition');
+    legendX = legendOffset;
+    legendY = legendOffset;
+    if (legendPosition === 'topright') {
+      legendX = -(legendOffset + bbox.width);
+    } else if (legendPosition === 'bottomleft') {
+      legendY = -(legendOffset + bbox.height);
+    } else if (legendPosition === 'bottomright') {
+      legendX = -(legendOffset + bbox.width);
+      legendY = -(legendOffset + bbox.height);
+    } // else 'topleft'
+    legendContent.setAttribute('transform', 'translate(' + legendX + ',' + legendY + ')');
+  };
+
+  /**
+   * Get the plot x extent, including padding.
+   *
+   * @return {Array<Number>} x extents.
+   */
+  _this.getPlotXExtent = function (xExtent) {
+    var xAxisPadding, xAxisScale;
+
+    xAxisPadding = _this.model.get('xAxisPadding');
+    if (xAxisPadding) {
+      xAxisScale = _this.model.get('xAxisScale');
+      xExtent = (typeof xAxisScale.base === 'function' ? D3Util.padLogExtent : D3Util.padExtent)(
+        xExtent,
+        xAxisPadding
+      );
+    }
+
+    return xExtent;
+  };
+
+  /**
+   * Get the plot y extent, including padding.
+   *
+   * @param xExtent {Array<Number>}
+   *        xExtent is passed to _this.getYExtent().
+   * @return {Array<Number>} y extents.
+   */
+  _this.getPlotYExtent = function (xExtent) {
+    var yAxisPadding, yAxisScale, yExtent;
+
+    yExtent = _this.getYExtent(xExtent);
+    yAxisPadding = _this.model.get('yAxisPadding');
+    if (yAxisPadding) {
+      yAxisScale = _this.model.get('yAxisScale');
+      yExtent = (typeof yAxisScale.base === 'function' ? D3Util.padLogExtent : D3Util.padExtent)(
+        yExtent,
+        yAxisPadding
+      );
+    }
+
+    return yExtent;
+  };
+
+  /**
+   * Get the data x extent.
+   *
+   * @return {Array<Number>} x extents.
+   */
+  _this.getXExtent = function () {
+    var xExtent;
+
+    xExtent = _this.model.get('xExtent');
+    if (xExtent === null) {
+      xExtent = [];
+      _this.views.data().forEach(function (view) {
+        xExtent = xExtent.concat(view.getXExtent());
+      });
+      xExtent = d3.extent(xExtent);
+    }
+
+    return xExtent;
+  };
+
+  /**
+   * Get the data y extent, including padding.
+   *
+   * @param xExtent {Array<Number>}
+   *        x extent, in case y extent is filtered based on x extent.
+   * @return {Array<Number>} x extents.
+   */
+  _this.getYExtent = function (/* xExtent */) {
+    var yExtent;
+
+    yExtent = _this.model.get('yExtent');
+    if (yExtent === null) {
+      yExtent = [];
+      _this.views.data().forEach(function (view) {
+        yExtent = yExtent.concat(view.getYExtent());
+      });
+      yExtent = d3.extent(yExtent);
+    }
+
+    return yExtent;
+  };
+
+  /**
+   * Show a tooltip on the graph.
+   *
+   * @param coords {Array<x, y>}
+   *        coordinate for origin of tooltip.
+   * @param data {Array<Object|Array>}
+   *        tooltip content, passed to formatTooltip.
+   */
+  _this.showTooltip = function (coords, data) {
+    var bbox, content, offset, options, outline, padding, tooltip, tooltipBbox, x, y;
+
+    tooltip = d3.select(_tooltip);
+    // clear tooltip
+    tooltip.selectAll('*').remove();
+    if (!coords || !data) {
+      return;
+    }
+
+    options = _this.model.get();
+    offset = options.tooltipOffset;
+    padding = options.tooltipPadding;
+    // create tooltip content
+    outline = tooltip.append('rect').attr('class', 'tooltip-outline');
+    content = tooltip.append('g').attr('class', 'tooltip-content');
+    D3Util.formatText(content, data);
+    // position tooltip outline
+    bbox = D3Util.getBBox(tooltip.node());
+    outline.attr('width', bbox.width + 2 * padding).attr('height', bbox.height + 2 * padding);
+    content.attr('transform', 'translate(' + padding + ',0)');
+
+    // position tooltip on graph
+    // center of point
+    x = options.xAxisScale(coords[0]);
+    y = options.yAxisScale(coords[1]);
+    // box rendering inside
+    bbox = D3Util.getBBox(_innerFrame);
+    // box being rendered
+    tooltipBbox = D3Util.getBBox(_tooltip);
+    // keep tooltip in graph area
+    if (x + tooltipBbox.width > bbox.width) {
+      x = x - tooltipBbox.width - offset;
+    } else {
+      x = x + offset;
+    }
+    if (y + tooltipBbox.height > bbox.height) {
+      y = y - tooltipBbox.height - offset;
+    } else {
+      y = y + offset;
+    }
+    // set position
+    _tooltip.setAttribute('transform', 'translate(' + x + ',' + y + ')');
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D3View;
diff --git a/src/disagg/D33dDeaggregationBin.js b/src/disagg/D33dDeaggregationBin.js
new file mode 100644
index 0000000000000000000000000000000000000000..d61416974c6ec4a4f85e66f2bae2588275c293c1
--- /dev/null
+++ b/src/disagg/D33dDeaggregationBin.js
@@ -0,0 +1,113 @@
+'use strict';
+
+var D33dCuboid = require('../d3/3d/D33dCuboid'),
+  D33dGroup = require('../d3/3d/D33dGroup'),
+  Util = require('../util/Util');
+
+var _DEFAULTS;
+
+_DEFAULTS = {
+  size: 1,
+  xScale: 1,
+  yScale: 1,
+  zScale: 1,
+};
+
+/**
+ * Represents one vertical bar on a deaggregation plot.
+ *
+ * @param options {Object}
+ * @param options.bin {Object}
+ *        one magnitude/distance bin object.
+ * @param options.bin.m {Number}
+ *        magnitude.
+ * @param options.bin.r {Number}
+ *        distance.
+ * @param options.bin.εdata {Array<Object>}
+ *        epsilon bin percent contributions.
+ * @param options.size {Number}
+ *        default 1.
+ *        size of bin around distance/magnitude point on graph.
+ * @param options.xScale {Number}
+ *        default 1.
+ *        multiplier for raw x values.
+ * @param options.yScale {Number}
+ *        default 1.
+ *        multiplier for raw y values.
+ * @param options.zScale {Number}
+ *        default 1.
+ *        multiplier for raw z values.
+ */
+var D33dDeaggregationBin = function (options) {
+  var _this, _initialize;
+
+  _this = D33dGroup({
+    className: 'D33dDeaggregationBin',
+  });
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.createEpsilonCuboids(options);
+  };
+
+  /**
+   * Create all epsilon bins within this magnitude/distance bin.
+   */
+  _this.createEpsilonCuboids = function (options) {
+    var bin, items, m, r, size, total, x, xScale, y, yScale, z, zScale, εbinIds, εbins, εdata;
+
+    bin = options.bin;
+    εbins = options.εbins;
+    size = options.size / 2;
+    xScale = options.xScale;
+    yScale = options.yScale;
+    zScale = options.zScale;
+    m = bin.m;
+    r = bin.r;
+    εdata = bin.εdata;
+    εbinIds = εbins.getIds();
+
+    items = [];
+    // represents total contribution after all bins are processed.
+    total = 0;
+    εdata.forEach(function (bin) {
+      var item, value, εbin;
+
+      value = bin.value;
+      εbin = bin.εbin;
+
+      x = r * xScale;
+      y = m * yScale;
+      z = total * zScale;
+
+      // cuboid for this epsilon bin contribution
+      item = D33dCuboid({
+        x0: x - size,
+        x1: x + size,
+        y0: y - size,
+        y1: y + size,
+        z0: z,
+        z1: z + value * zScale,
+      });
+      item.el.setAttribute('data-bin-index', εbinIds[εbin]);
+      items.push(item);
+
+      // increase with each epsilon bin contribution
+      total += value;
+    });
+
+    _this.model.set(
+      {
+        items: items,
+      },
+      { silent: true }
+    );
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = D33dDeaggregationBin;
diff --git a/src/disagg/DeaggResponse.js b/src/disagg/DeaggResponse.js
new file mode 100644
index 0000000000000000000000000000000000000000..0e5145e56a9881cba6fb0b0d3d00486627c7f62f
--- /dev/null
+++ b/src/disagg/DeaggResponse.js
@@ -0,0 +1,83 @@
+'use strict';
+
+var Deaggregation = require('./Deaggregation'),
+  Collection = require('../mvc/Collection'),
+  Model = require('../mvc/Model'),
+  Util = require('../util/Util');
+
+// Default values to be used by constructor
+var _DEFAULTS = {
+  metadata: {
+    imt: { value: 'Unknown' },
+    rlabel: 'Distance',
+    mlabel: 'Magnitude',
+    εlabel: 'Contribution',
+    εbins: [],
+  },
+  data: [],
+};
+
+var _DEAGG_ID = 0;
+
+/**
+ * Class: DeaggResponse
+ *
+ * @param params {Object}
+ *      Configuration options. See _DEFAULTS for more details.
+ */
+var DeaggResponse = function (params) {
+  var _this, _initialize;
+
+  // Inherit from parent class
+  _this = Model();
+
+  /**
+   * @constructor
+   *
+   */
+  _initialize = function (params) {
+    var attributes, deaggs, metadata;
+
+    params = Util.extend({}, _DEFAULTS, { id: 'deagg-response-' + _DEAGG_ID++ }, params);
+
+    metadata = params.metadata;
+    deaggs = params.data.map(function (deagg) {
+      return Deaggregation(
+        Util.extend(
+          {
+            metadata: metadata,
+          },
+          deagg
+        )
+      );
+    });
+
+    attributes = {
+      imt: metadata.imt.value,
+      rlabel: metadata.rlabel,
+      mlabel: metadata.mlabel,
+      εlabel: metadata.εlabel,
+      εbins: metadata.εbins,
+      deaggregations: Collection(deaggs),
+    };
+
+    // Should not have listeners yet, but silent anyway to short-circuit check
+    _this.set(attributes, { silent: true });
+  };
+
+  _this.destroy = Util.compose(function () {
+    var deaggs;
+
+    deaggs = _this.get('deaggregations');
+    deaggs.destroy();
+
+    _initialize = null;
+    _this = null;
+  }, _this.destroy);
+
+  _initialize(params);
+  params = null;
+  return _this;
+};
+
+module.exports = DeaggResponse;
diff --git a/src/disagg/Deaggregation.js b/src/disagg/Deaggregation.js
new file mode 100644
index 0000000000000000000000000000000000000000..3868ce7f0da7e03a74333322daf4e608b18b44f9
--- /dev/null
+++ b/src/disagg/Deaggregation.js
@@ -0,0 +1,31 @@
+'use strict';
+
+var Model = require('../mvc/Model'),
+  Util = require('../util/Util');
+
+// Default values to be used by constructor
+var _DEFAULTS = {
+  component: '', // String - GMPE Name or "Total"
+  data: [], // Array - Hazard contirbution bins
+  //         Each bin has "m", "r", and "εdata" attributes
+};
+
+var _DEAGG_ID = 0;
+
+/**
+ * Class: Deaggregation
+ *
+ * @param params {Object}
+ *      Configuration options. See _DEFAULTS for more details.
+ */
+var Deaggregation = function (params) {
+  var _this;
+
+  params = Util.extend({}, _DEFAULTS, { id: 'deagg-' + _DEAGG_ID++ }, params);
+  _this = Model(params);
+
+  params = null;
+  return _this;
+};
+
+module.exports = Deaggregation;
diff --git a/src/disagg/DeaggregationGraphView.js b/src/disagg/DeaggregationGraphView.js
new file mode 100644
index 0000000000000000000000000000000000000000..2ed45902e4bae0dc575d4dbaad0c779034ec99fc
--- /dev/null
+++ b/src/disagg/DeaggregationGraphView.js
@@ -0,0 +1,656 @@
+'use strict';
+
+var Collection = require('../mvc/Collection'),
+  d3 = require('d3'),
+  D33dAxis = require('../d3/3d/D33dAxis'),
+  D33dDeaggregationBin = require('./D33dDeaggregationBin'),
+  D33dPath = require('../d3/3d/D33dPath'),
+  D33dView = require('../d3/view/D33dView'),
+  D3Util = require('../d3/util/D3Util'),
+  SelectedCollectionView = require('../mvc/SelectedCollectionView'),
+  Util = require('../util/Util');
+
+var _DEFAULTS;
+
+_DEFAULTS = {};
+
+var __calculateBounds;
+
+/**
+ * Calculate deaggregation data bounds.
+ *
+ * @param bindata {Array<Bin>}
+ *     array of deaggregation bins
+ *     where Bin is an Object:
+ *       bin.r {Number}
+ *             distance to bin (x-axis)
+ *       bin.m {Number}
+ *             magnitude of bin (y-axis)
+ *       bin.εdata {Array<Object>}
+ *             array of epsilon data for bin (z-axis)
+ *             Each Object has properties:
+ *               εdata.value {Number}
+ *                           % contribution to hazard
+ *               εdata.εbin {Number}
+ *                          id of epsilon bin.
+ *
+ * @return {Array<Array<x0,y0,z0>, Array<x1,y1,z1>}
+ *         bounds of deaggregation data.
+ *         Array contains two sub arrays,
+ *         containing minimum and maximum values for each axis.
+ */
+__calculateBounds = function (bindata) {
+  var x0, x1, y0, y1, z0, z1;
+
+  // start with values that will always be smaller/larger than actual values
+  x0 = y0 = z0 = Number.POSITIVE_INFINITY;
+  x1 = y1 = z1 = Number.NEGATIVE_INFINITY;
+
+  bindata.forEach(function (bin) {
+    var binx, biny, binz;
+
+    binx = bin.r;
+    biny = bin.m;
+    // sum values for z
+    binz = 0;
+    bin.εdata.forEach(function (εval) {
+      binz = binz + εval.value;
+    });
+
+    // track min/max
+    if (binx < x0) {
+      x0 = binx;
+    }
+    if (binx > x1) {
+      x1 = binx;
+    }
+    if (biny < y0) {
+      y0 = biny;
+    }
+    if (biny > y1) {
+      y1 = biny;
+    }
+    if (binz < z0) {
+      z0 = binz;
+    }
+    if (binz > z1) {
+      z1 = binz;
+    }
+  });
+
+  // return bounds
+  return [
+    [x0, y0, z0],
+    [x1, y1, z1],
+  ];
+};
+
+/**
+ * A deaggregation distance/magnitude plot.
+ *
+ * @param options {Object}
+ *        options are passed to SelectedCollectionView.
+ * @param options.bounds {Array<Array>}
+ *        default null.
+ *        plotting bounds.
+ *        can be changed later by setting VIEW.bounds and calling VIEW.render
+ * @param options.collection {Collection<Deaggregation>}
+ *        collection with data to display.
+ *        selected Deaggregation object is displayed.
+ */
+var DeaggregationGraphView = function (options) {
+  var _this, _initialize;
+
+  _this = SelectedCollectionView(options);
+
+  _initialize = function (options) {
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _this.el.innerHTML = '<div class="DeaggregationGraphView"></div>';
+
+    _this.bounds = options.bounds || null;
+
+    _this.axes = [];
+    _this.bins = [];
+
+    _this.xScale = 2;
+    _this.yScale = 25;
+    _this.zScale = 2;
+
+    _this.xTickSpacing = 5;
+    _this.yTickSpacing = 0.5;
+    _this.zTickSpacing = 5;
+
+    _this.d33d = D33dView({
+      el: _this.el.querySelector('.DeaggregationGraphView'),
+      lookAt: [60, 125, 10],
+      origin: [280, -150, 180],
+      up: [0, 0, 1],
+      zoom: 3.5,
+    });
+    _this.d33d.renderLegend = _this.renderLegend;
+
+    _this.render();
+  };
+
+  /**
+   * Use data extents to set view lookAt, origin, and zoom so plot is
+   * roughly centered within view plot area.
+   */
+  _this.centerView = function () {
+    var bounds,
+      lookAt,
+      origin,
+      projectedBounds,
+      projectedWidth,
+      projectedHeight,
+      x0,
+      x1,
+      y0,
+      y1,
+      z0,
+      z1,
+      zoom;
+
+    // get current view
+    lookAt = _this.d33d.model.get('lookAt');
+    origin = _this.d33d.model.get('origin');
+    zoom = _this.d33d.model.get('zoom');
+
+    // compute projected bounds
+    bounds = _this.getBounds();
+    _this.xScale = 100 / (bounds[1][0] - bounds[0][0]);
+    _this.yScale = 70 / (bounds[1][1] - bounds[0][1]);
+    _this.zScale = 50 / (bounds[1][2] - bounds[0][2]);
+    x0 = bounds[0][0] * _this.xScale;
+    x1 = bounds[1][0] * _this.xScale;
+    y0 = bounds[0][1] * _this.yScale;
+    y1 = bounds[1][1] * _this.yScale;
+    z0 = bounds[0][2] * _this.zScale;
+    z1 = bounds[1][2] * _this.zScale;
+
+    projectedBounds = [
+      [x0, y0, z0],
+      [x0, y1, z0],
+      [x1, y1, z0],
+      [x1, y0, z0],
+      [x0, y0, z1],
+      [x0, y1, z1],
+      [x1, y1, z1],
+      [x1, y0, z1],
+    ]
+      .map(_this.d33d.project)
+      .reduce(
+        function (bounds, point) {
+          var x, y, z;
+
+          x = point[0];
+          y = point[1];
+          z = point[2];
+
+          if (x < bounds[0][0]) {
+            bounds[0][0] = x;
+          }
+          if (x > bounds[1][0]) {
+            bounds[1][0] = x;
+          }
+          if (y < bounds[0][1]) {
+            bounds[0][1] = y;
+          }
+          if (y > bounds[1][1]) {
+            bounds[1][1] = y;
+          }
+          if (z < bounds[0][2]) {
+            bounds[0][2] = z;
+          }
+          if (z > bounds[1][2]) {
+            bounds[1][2] = z;
+          }
+
+          return bounds;
+        },
+        [
+          [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
+          [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
+        ]
+      );
+
+    projectedWidth = projectedBounds[1][0] - projectedBounds[0][0];
+    projectedHeight = projectedBounds[1][1] - projectedBounds[0][1];
+
+    // look at x/y center at z=z0
+    lookAt = [(0.95 * (x1 + x0)) / 2, (0.95 * (y1 + y0)) / 2, -5 * _this.zScale];
+    origin = [lookAt[0] + 1000, lookAt[1] - 1000, lookAt[2] + 1000];
+    zoom = Math.min((480 * zoom) / projectedWidth, (480 * zoom) / projectedHeight);
+
+    _this.d33d.model.set({
+      lookAt: lookAt,
+      origin: origin,
+      zoom: zoom,
+    });
+  };
+
+  /**
+   * Create axes to be plotted.
+   *
+   * TODO: eliminate "magic" numbers.
+   * Extents and tick, label, and title, vectors arre hard coded and work for
+   * the current lookAt, origin, and zoom, combination.
+   */
+  _this.createAxes = function () {
+    var bounds,
+      extent,
+      gridSpacing,
+      metadata,
+      x,
+      x0,
+      x1,
+      xLabel,
+      xTicks,
+      y,
+      y0,
+      y1,
+      yLabel,
+      yTicks,
+      z0,
+      z1,
+      zLabel,
+      zTicks;
+
+    bounds = _this.getBounds();
+    x0 = bounds[0][0];
+    x1 = bounds[1][0];
+    y0 = bounds[0][1];
+    y1 = bounds[1][1];
+    z0 = bounds[0][2];
+    z1 = bounds[1][2];
+
+    if (_this.model) {
+      metadata = _this.model.get('metadata');
+    } else {
+      metadata = {};
+    }
+
+    //_this.yScale = 100 / (y1 - y0);
+    //_this.zScale = 100 / (z1 - z0);
+
+    xLabel = metadata.rlabel;
+    xTicks = (x1 - x0) / _this.xTickSpacing;
+    while (xTicks > 10) {
+      xTicks = xTicks / 2;
+    }
+
+    yLabel = metadata.mlabel;
+    yTicks = (y1 - y0) / _this.yTickSpacing;
+    zLabel = metadata.εlabel;
+    zTicks = (z1 - z0) / _this.zTickSpacing;
+
+    x0 = x0 * _this.xScale;
+    x1 = x1 * _this.xScale;
+    y0 = y0 * _this.yScale;
+    y1 = y1 * _this.yScale;
+    z0 = z0 * _this.zScale;
+    z1 = z1 * _this.zScale;
+
+    // x axis at y0
+    extent = [
+      [x0, y0, z0],
+      [x1, y0, z0],
+    ];
+    _this.axes.push(
+      D33dAxis({
+        className: 'axis x-axis',
+        extent: extent,
+        format: _this.formatX,
+        labelAnchor: 'middle',
+        labelDirection: extent,
+        labelVector: [1, -5, 0],
+        padding: 0,
+        tickVector: [0, -1, 0],
+        ticks: xTicks,
+        title: xLabel,
+        titleAnchor: 'middle',
+        titleDirection: extent,
+        titleVector: [2, -10, 0],
+      })
+    );
+
+    // x axis at y1
+    extent = [
+      [x0, y1, z0],
+      [x1, y1, z0],
+    ];
+    _this.axes.push(
+      D33dAxis({
+        className: 'axis x-axis',
+        extent: extent,
+        format: _this.formatX,
+        labelAnchor: 'middle',
+        labelDirection: extent,
+        labelVector: [0, 2, 0],
+        padding: 0,
+        tickVector: [0, 1, 0],
+        ticks: xTicks,
+        title: xLabel,
+        titleAnchor: 'middle',
+        titleDirection: extent,
+        titleVector: [0, 8, 0],
+      })
+    );
+
+    // y axis at x0
+    extent = [
+      [x0, y0, z0],
+      [x0, y1, z0],
+    ];
+    _this.axes.push(
+      D33dAxis({
+        className: 'axis y-axis',
+        extent: extent,
+        format: _this.formatY,
+        labelAnchor: 'middle',
+        labelDirection: extent,
+        labelVector: [-2, 0, 0],
+        padding: 0,
+        tickVector: [-1, 0, 0],
+        ticks: yTicks,
+        title: yLabel,
+        titleAnchor: 'middle',
+        titleDirection: extent,
+        titleVector: [-7, 2, 0],
+      })
+    );
+
+    // y axis at x1
+    extent = [
+      [x1, y0, z0],
+      [x1, y1, z0],
+    ];
+    _this.axes.push(
+      D33dAxis({
+        className: 'axis y-axis',
+        extent: extent,
+        format: _this.formatY,
+        labelAnchor: 'middle',
+        labelDirection: extent,
+        labelVector: [5, -2, 0],
+        padding: 0,
+        tickVector: [1, 0, 0],
+        ticks: yTicks,
+        title: yLabel,
+        titleAnchor: 'middle',
+        titleDirection: extent,
+        titleVector: [9, -2, 0],
+      })
+    );
+
+    // z axis
+    extent = [
+      [x0, y0, z0],
+      [x0, y0, z1],
+    ];
+    _this.axes.push(
+      D33dAxis({
+        className: 'axis z-axis',
+        extent: extent,
+        format: _this.formatZ,
+        labelAnchor: 'middle',
+        labelDirection: extent,
+        labelVector: [-1.5, 0, 0],
+        padding: 0,
+        tickVector: [-1, 0, 0],
+        ticks: zTicks,
+        title: zLabel,
+        titleAnchor: 'middle',
+        titleDirection: extent,
+        titleVector: [-5, 0, 0],
+      })
+    );
+
+    // grid lines
+    gridSpacing = _this.xTickSpacing * _this.xScale;
+    while ((x1 - x0) / gridSpacing > 10) {
+      gridSpacing = gridSpacing * 2;
+    }
+
+    for (x = x0 + gridSpacing; x < x1; x += gridSpacing) {
+      _this.axes.push(
+        D33dPath({
+          className: 'grid-line',
+          close: false,
+          coords: [
+            [x, y0, z0],
+            [x, y1, z0],
+          ],
+        })
+      );
+    }
+
+    gridSpacing = _this.yTickSpacing * _this.yScale;
+    for (y = y0 + gridSpacing; y < y1; y += gridSpacing) {
+      _this.axes.push(
+        D33dPath({
+          className: 'grid-line',
+          close: false,
+          coords: [
+            [x0, y, z0],
+            [x1, y, z0],
+          ],
+        })
+      );
+    }
+  };
+
+  /**
+   * Format x-axis {distance} labels.
+   *
+   * @param c {Array<Number>}
+   *        tick coordinate.
+   * @return {String}
+   *         formatted coordinate.
+   */
+  _this.formatX = function (c) {
+    var x;
+    x = c[0] / _this.xScale;
+    if (x === 0) {
+      return '';
+    }
+    return '' + Number(x.toPrecision(3));
+  };
+
+  /**
+   * Format y-axis (magnitude) labels.
+   *
+   * @param c {Array<Number>}
+   *        tick coordinate.
+   * @return {String}
+   *         formatted coordinate.
+   */
+  _this.formatY = function (c) {
+    var y;
+    y = c[1] / _this.yScale;
+    if (y === 0) {
+      return '';
+    }
+    return '' + Number(y.toPrecision(3));
+  };
+
+  /**
+   * Format z-axis (percent contribution) labels.
+   *
+   * @param c {Array<Number>}
+   *        tick coordinate.
+   * @return {String}
+   *         formatted coordinate.
+   */
+  _this.formatZ = function (c) {
+    var z;
+    z = c[2] / _this.zScale;
+    if (z === 0) {
+      return '';
+    }
+    return '' + Number(z.toPrecision(3));
+  };
+
+  /**
+   * Get data bounds for plotting.
+   *
+   * @return {Array<Array<x0,y0,z0>, Array<x1,y1,z1>}
+   *         bounds of deaggregation data.
+   *         Array contains two sub arrays,
+   *         containing minimum and maximum values for each axis.
+   */
+  _this.getBounds = function () {
+    var bounds, x0, x1, y0, y1, z0, z1;
+
+    // default bounds
+    x0 = 0;
+    x1 = 100;
+    y0 = 5;
+    y1 = 8;
+    z0 = 0;
+    z1 = 35;
+
+    if (_this.model) {
+      bounds = _this.bounds;
+      if (!bounds) {
+        bounds = __calculateBounds(_this.model.get('data'));
+      }
+
+      x0 = bounds[0][0];
+      x1 = bounds[1][0];
+      y0 = bounds[0][1];
+      y1 = bounds[1][1];
+      z0 = bounds[0][2];
+      z1 = bounds[1][2];
+    }
+
+    // round min/max down/up to an increment of 10
+    x0 = _this.xTickSpacing * Math.floor(x0 / _this.xTickSpacing) - _this.xTickSpacing;
+    x1 = _this.xTickSpacing * Math.ceil(x1 / _this.xTickSpacing) + _this.xTickSpacing;
+
+    // round min/max down/up to next whole unit
+    y0 = _this.yTickSpacing * Math.floor(y0 / _this.yTickSpacing) - _this.yTickSpacing;
+    y1 = _this.yTickSpacing * Math.ceil(y1 / _this.yTickSpacing) + _this.yTickSpacing;
+
+    // always use 0
+    z0 = 0;
+    // round up to increment of 10
+    z1 = _this.zTickSpacing * Math.ceil(z1 / _this.zTickSpacing) + _this.zTickSpacing;
+
+    return [
+      [x0, y0, z0],
+      [x1, y1, z1],
+    ];
+  };
+
+  /**
+   * Update displayed data.
+   *
+   * When a model is selected, bins are plotted on the graph.
+   * Otherwise, any existing bins are destroyed and removed from the graph.
+   */
+  _this.render = function () {
+    var oldAxes, oldBins, εbins;
+
+    oldBins = _this.bins;
+    oldAxes = _this.axes;
+
+    _this.centerView();
+
+    // create new views
+    _this.axes = [];
+    _this.createAxes();
+
+    _this.bins = [];
+    if (_this.model) {
+      εbins = Collection(_this.model.get('metadata').εbins);
+      _this.model.get('data').forEach(function (bin) {
+        var view;
+        view = D33dDeaggregationBin({
+          bin: bin,
+          xScale: _this.xScale,
+          yScale: _this.yScale,
+          zScale: _this.zScale,
+          εbins: εbins,
+        });
+        _this.bins.push(view);
+      });
+    }
+
+    // update plot
+    _this.d33d.views.reset([].concat(_this.axes).concat(_this.bins));
+
+    // clean up old views
+    oldAxes.forEach(function (a) {
+      a.destroy();
+    });
+
+    oldBins.forEach(function (v) {
+      v.destroy();
+    });
+
+    oldAxes = null;
+    oldBins = null;
+  };
+
+  /**
+   * Custom legend rendering function for wrapped D33dView.
+   *
+   * @param info {Object}
+   *        rendering information.
+   * @param info.el {SVGElement}
+   *        element where legend should be rendered.
+   */
+  _this.renderLegend = function (info) {
+    var bbox, el, legendEl, metadata, z, εbins;
+
+    legendEl = info.el;
+
+    // clear legend
+    Util.empty(legendEl);
+    if (!_this.model) {
+      return;
+    }
+
+    // plot epsilon bins
+    z = 0;
+    el = d3
+      .select(legendEl)
+      .append('g')
+      .attr('class', 'legend-content')
+      .attr('class', 'D33dDeaggregationBin');
+
+    metadata = _this.model.get('metadata');
+    εbins = metadata.εbins;
+    εbins.forEach(function (bin, index) {
+      var binEl, text, textEl;
+
+      text =
+        'ε = ' +
+        (bin.min === null ? '(-∞' : '[' + bin.min) +
+        ' .. ' +
+        (bin.max === null ? '+∞' : bin.max) +
+        ')';
+
+      binEl = el
+        .append('g')
+        .attr('data-bin-index', index)
+        .attr('transform', 'translate(0 ' + z + ')');
+      binEl.append('path').attr('d', 'M0,2' + 'L10,2' + 'L10,-8' + 'L0,-8' + 'Z');
+      textEl = binEl.append('text').attr('x', 15).text(text);
+      z += D3Util.getBBox(binEl.node()).height;
+    });
+
+    bbox = D3Util.getBBox(el.node());
+    el.attr('transform', 'translate(' + -bbox.width + ' 0)');
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+DeaggregationGraphView.calculateBounds = __calculateBounds;
+
+module.exports = DeaggregationGraphView;
diff --git a/src/disagg/index.d.ts b/src/disagg/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb52eedb022f26073b7ff476466ee59e16eab5e0
--- /dev/null
+++ b/src/disagg/index.d.ts
@@ -0,0 +1 @@
+export * from '../../types/disagg';
diff --git a/src/disagg/index.js b/src/disagg/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..4f9fbbc8526762e3cd0585e0912c9d35ad81a50f
--- /dev/null
+++ b/src/disagg/index.js
@@ -0,0 +1,6 @@
+module.exports = {
+  D33dDeaggregationBin: require('./D33dDeaggregationBin'),
+  Deaggregation: require('./Deaggregation'),
+  DeaggregationGraphView: require('./DeaggregationGraphView'),
+  DeaggResponse: require('./DeaggResponse'),
+};
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..158be18e7c1476f68822eb81b6e0e5096af844ea
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,7 @@
+module.exports = {
+  d3: require('./d3'),
+  disagg: require('./disagg'),
+  math: require('./math'),
+  mvc: require('./mvc'),
+  util: require('./util'),
+};
diff --git a/src/math/Camera.js b/src/math/Camera.js
new file mode 100644
index 0000000000000000000000000000000000000000..b82d8069ff3035c93a8d70397f83946fd74f3a4f
--- /dev/null
+++ b/src/math/Camera.js
@@ -0,0 +1,103 @@
+'use strict';
+
+var Matrix = require('./Matrix'),
+  Vector = require('./Vector'),
+  Util = require('../util/Util');
+
+var _DEFAULTS = {
+  lookAt: [0, 0, 0],
+  origin: [100, 100, 100],
+  up: [0, 0, 1],
+};
+
+/**
+ * Camera defines a coordinate translation from World coordinates (X, Y, Z)
+ * to Camera coordinates (x, y, z).
+ *
+ * After projection:
+ *     +z is to lookAt from camera
+ *     +x is right from camera
+ *     +y is up from camera
+ *
+ * @param options {Object}
+ * @param options.origin {Array<Number>}
+ *        default [100, 100, 100].
+ *        position of camera in world coordinates.
+ * @param options.lookAt {Array<Number>}
+ *        default [0, 0, 0].
+ *        position for camera to look at in world coordinates.
+ * @param options.up {Array<Number>}
+ *        default [0, 0, 1].
+ *        vector pointing up in world coordinates.
+ */
+var Camera = function (options) {
+  var _this,
+    _initialize,
+    // variables
+    _lookAt,
+    _origin,
+    _up,
+    _worldToCamera;
+
+  _this = {};
+
+  _initialize = function (options) {
+    var rotate, translate, x, y, z;
+
+    options = Util.extend({}, _DEFAULTS, options);
+
+    _lookAt = Vector(options.lookAt);
+    _origin = Vector(options.origin);
+    _up = Vector(options.up);
+
+    // camera axes using world coordinates
+    // +z is from origin through look at
+    z = _lookAt.subtract(_origin).unit();
+    // +x is right
+    x = z.cross(_up).unit();
+    // +y is up
+    y = x.cross(z).unit();
+
+    rotate = Matrix(
+      [x.x(), x.y(), x.z(), 0, y.x(), y.y(), y.z(), 0, z.x(), z.y(), z.z(), 0, 0, 0, 0, 1],
+      4,
+      4
+    );
+
+    translate = Matrix(
+      [1, 0, 0, -_origin.x(), 0, 1, 0, -_origin.y(), 0, 0, 1, -_origin.z(), 0, 0, 0, 1],
+      4,
+      4
+    );
+
+    _worldToCamera = rotate.multiply(translate).data();
+  };
+
+  /**
+   * Project a point from world coordinates to camera coordinates.
+   *
+   * @param world {Array<Number>}
+   *        x, y, z world coordinates.
+   * @return {Array<Number>}
+   *        x, y, z, camera coordinates.
+   */
+  _this.project = function (world) {
+    var projected, x, xp, y, yp, z, zp;
+
+    x = world[0];
+    y = world[1];
+    z = world[2];
+    projected = Matrix.multiply(_worldToCamera, 4, 4, [x, y, z, 1], 4, 1);
+
+    xp = projected[0];
+    yp = projected[1];
+    zp = projected[2];
+    return [xp, yp, zp];
+  };
+
+  _initialize(options);
+  options = null;
+  return _this;
+};
+
+module.exports = Camera;
diff --git a/src/math/Matrix.js b/src/math/Matrix.js
new file mode 100644
index 0000000000000000000000000000000000000000..b5b3e9372e5bc0c9e705925f302278fb8e775b3b
--- /dev/null
+++ b/src/math/Matrix.js
@@ -0,0 +1,655 @@
+'use strict';
+
+var Vector = require('./Vector');
+
+// static methods that operate on arrays
+var __col,
+  __diagonal,
+  __get,
+  __identity,
+  __index,
+  __jacobi,
+  __multiply,
+  __row,
+  __set,
+  __stringify,
+  __transpose;
+
+/**
+ * Extract a column from this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param col {Number}
+ *        index of column, in range [0,n)
+ * @throws Error if column out of range.
+ * @return {Array<Number>} column elements.
+ */
+__col = function (data, m, n, col) {
+  var row,
+    values = [];
+  if (col < 0 || col >= n) {
+    throw new Error('column ' + col + ' out of range [0,' + n + ')');
+  }
+  if (n === 1) {
+    // only one column in matrix
+    return data;
+  }
+  values = [];
+  for (row = 0; row < m; row++) {
+    values.push(data[__index(m, n, row, col)]);
+  }
+  return values;
+};
+
+/**
+ * Get array of elements on the diagonal.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @return {Array<Number>} elements on the diagonal.
+ */
+__diagonal = function (data, m, n) {
+  var len = Math.min(m, n),
+    diag = [],
+    i;
+  for (i = 0; i < len; i++) {
+    diag.push(data[__index(m, n, i, i)]);
+  }
+  return diag;
+};
+
+/**
+ * Get the value of an element of this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param row {Number}
+ *        row of element, in range [0,m)
+ * @param col {Number}
+ *        column of element, in range [0,n)
+ * @throws Error if row or col are out of range.
+ * @return {Number} value.
+ */
+__get = function (data, m, n, row, col) {
+  return data[__index(m, n, row, col)];
+};
+
+/**
+ * Create an identity Matrix.
+ *
+ * @param n {Number}
+ *        number of rows and columns.
+ * @return identity matrix of size n.
+ */
+__identity = function (n) {
+  var values = [],
+    row,
+    col;
+  for (row = 0; row < n; row++) {
+    for (col = 0; col < n; col++) {
+      values.push(row === col ? 1 : 0);
+    }
+  }
+  return values;
+};
+
+/**
+ * Get the index of an element of this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param row {Number}
+ *        row of element, in range [0,m)
+ * @param col {Number}
+ *        column of element, in range [0,n)
+ * @return {Number} index.
+ */
+__index = function (m, n, row, col) {
+  return n * row + col;
+};
+
+/**
+ * Jacobi eigenvalue algorithm.
+ *
+ * Ported from:
+ *     http://users-phys.au.dk/fedorov/nucltheo/Numeric/now/eigen.pdf
+ *
+ * An iterative method for eigenvalues and eigenvectors,
+ * only works on symmetric matrices.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param maxRotations {Number}
+ *        maximum number of rotations.
+ *        Optional, default 100.
+ * @return {Array<Vector>} array of eigenvectors, magnitude is eigenvalue.
+ */
+__jacobi = function (data, m, n, maxRotations) {
+  var a,
+    aip,
+    aiq,
+    api,
+    app,
+    app1,
+    apq,
+    aqi,
+    aqq,
+    aqq1,
+    c,
+    changed,
+    e,
+    i,
+    ip,
+    iq,
+    p,
+    phi,
+    pi,
+    q,
+    qi,
+    rotations,
+    s,
+    v,
+    vector,
+    vectors,
+    vip,
+    viq;
+
+  if (m !== n) {
+    throw new Error('Jacobi only works on symmetric, square matrices');
+  }
+
+  // set a default max
+  maxRotations = maxRotations || 100;
+  a = data.slice(0);
+  e = __diagonal(data, m, n);
+  v = __identity(n);
+  rotations = 0;
+
+  do {
+    changed = false;
+
+    for (p = 0; p < n; p++) {
+      for (q = p + 1; q < n; q++) {
+        app = e[p];
+        aqq = e[q];
+        apq = a[n * p + q];
+        phi = 0.5 * Math.atan2(2 * apq, aqq - app);
+        c = Math.cos(phi);
+        s = Math.sin(phi);
+        app1 = c * c * app - 2 * s * c * apq + s * s * aqq;
+        aqq1 = s * s * app + 2 * s * c * apq + c * c * aqq;
+
+        if (app1 !== app || aqq1 !== aqq) {
+          changed = true;
+          rotations++;
+
+          e[p] = app1;
+          e[q] = aqq1;
+          a[n * p + q] = 0;
+
+          for (i = 0; i < p; i++) {
+            ip = n * i + p;
+            iq = n * i + q;
+            aip = a[ip];
+            aiq = a[iq];
+            a[ip] = c * aip - s * aiq;
+            a[iq] = c * aiq + s * aip;
+          }
+          for (i = p + 1; i < q; i++) {
+            pi = n * p + i;
+            iq = n * i + q;
+            api = a[pi];
+            aiq = a[iq];
+            a[pi] = c * api - s * aiq;
+            a[iq] = c * aiq + s * api;
+          }
+          for (i = q + 1; i < n; i++) {
+            pi = n * p + i;
+            qi = n * q + i;
+            api = a[pi];
+            aqi = a[qi];
+            a[pi] = c * api - s * aqi;
+            a[qi] = c * aqi + s * api;
+          }
+          for (i = 0; i < n; i++) {
+            ip = n * i + p;
+            iq = n * i + q;
+            vip = v[ip];
+            viq = v[iq];
+            v[ip] = c * vip - s * viq;
+            v[iq] = c * viq + s * vip;
+          }
+        }
+      }
+    }
+  } while (changed && rotations < maxRotations);
+
+  if (changed) {
+    throw new Error('failed to converge');
+  }
+
+  vectors = [];
+  for (i = 0; i < n; i++) {
+    // i-th vector is i-th column
+    vector = Vector(__col(v, m, n, i));
+    vector.eigenvalue = e[i];
+    vectors.push(vector);
+  }
+
+  return vectors;
+};
+
+/**
+ * Multiply this matrix by another matrix.
+ *
+ * @param data1 {Array<Number>}
+ *        first matrix data.
+ * @param m1 {Number}
+ *        number of rows in first matrix.
+ * @param n1 {Number}
+ *        number of columns in first matrix.
+ * @param data2 {Array<Number>}
+ *        second matrix data.
+ * @param m2 {Number}
+ *        number of rows in second matrix.
+ * @param n2 {Number}
+ *        number of columns in second matrix.
+ * @throws Error if n1 !== m2
+ * @return result of multiplication (original matrix is unchanged).
+ */
+__multiply = function (data1, m1, n1, data2, m2, n2) {
+  var col, col2, row, row1, values;
+
+  if (n1 !== m2) {
+    throw new Error('wrong combination of rows and cols');
+  }
+  values = [];
+  for (row = 0; row < m1; row++) {
+    row1 = __row(data1, m1, n1, row);
+    for (col = 0; col < n2; col++) {
+      col2 = __col(data2, m2, n2, col);
+      // result is dot product
+      values.push(Vector.dot(row1, col2));
+    }
+  }
+  return values;
+};
+
+/**
+ * Extract a row from this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param row {Number}
+ *        index of row, in range [0,m)
+ * @throws Error if row out of range.
+ * @return {Array<Number>} row elements.
+ */
+__row = function (data, m, n, row) {
+  var col, values;
+  if (row < 0 || row >= m) {
+    throw new Error('row ' + row + ' out of range [0,' + m + ')');
+  }
+  values = [];
+  for (col = 0; col < n; col++) {
+    values.push(data[__index(m, n, row, col)]);
+  }
+  return values;
+};
+
+/**
+ * Set the value of an element of this matrix.
+ *
+ * NOTE: this method modifies the contents of this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @param row {Number}
+ *        row of element, in range [0,m)
+ * @param col {Number}
+ *        column of element, in range [0,n)
+ * @param value {Number}
+ *        value to set.
+ * @throws Error if row or col are out of range.
+ */
+__set = function (data, m, n, row, col, value) {
+  data[__index(m, n, row, col)] = value;
+};
+
+/**
+ * Display matrix as a string.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @return {String} formatted matrix.
+ */
+__stringify = function (data, m, n) {
+  var lastRow = m - 1,
+    lastCol = n - 1,
+    buf = [],
+    row,
+    col;
+
+  buf.push('[');
+  for (row = 0; row < m; row++) {
+    for (col = 0; col < n; col++) {
+      buf.push(data[n * row + col], col !== lastCol || row !== lastRow ? ', ' : '');
+    }
+    if (row !== lastRow) {
+      buf.push('\n ');
+    }
+  }
+  buf.push(']');
+  return buf.join('');
+};
+
+/**
+ * Transpose this matrix.
+ *
+ * @param data {Array<Number>}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ * @return transposed matrix (original matrix is unchanged).
+ */
+__transpose = function (data, m, n) {
+  var values = [],
+    row,
+    col;
+  for (col = 0; col < n; col++) {
+    for (row = 0; row < m; row++) {
+      values.push(data[__index(m, n, row, col)]);
+    }
+  }
+  return values;
+};
+
+/**
+ * Construct a new Matrix object.
+ *
+ * If m and n are omitted, Matrix is assumed to be square and
+ * data length is used to compute size.
+ *
+ * If m or n are omitted, data length is used to compute omitted value.
+ *
+ * @param data {Array}
+ *        matrix data.
+ * @param m {Number}
+ *        number of rows.
+ * @param n {Number}
+ *        number of columns.
+ */
+var Matrix = function (data, m, n) {
+  var _this,
+    _initialize,
+    // variables
+    _data,
+    _m,
+    _n;
+
+  _this = {};
+
+  _initialize = function (data, m, n) {
+    _data = data;
+    _m = m;
+    _n = n;
+
+    if (m && n) {
+      // done
+      return;
+    }
+
+    // try to compute size based on data
+    if (!m && !n) {
+      var side = Math.sqrt(data.length);
+      if (side !== parseInt(side, 10)) {
+        throw new Error('matrix m,n unspecified, and matrix not square');
+      }
+      _m = side;
+      _n = side;
+    } else if (!m) {
+      _m = data.length / n;
+      if (_m !== parseInt(_m, 10)) {
+        throw new Error('wrong number of data elements');
+      }
+    } else if (!n) {
+      _n = data.length / m;
+      if (_n !== parseInt(_n, 10)) {
+        throw new Error('wrong number of data elements');
+      }
+    }
+  };
+
+  /**
+   * Add matrices.
+   *
+   * @param that {Matrix}
+   *        matrix to add.
+   * @throws Error if dimensions do not match.
+   * @return result of addition (original matrix is unchanged).
+   */
+  _this.add = function (that) {
+    if (_m !== that.m() || n !== that.n()) {
+      throw new Error('matrices must be same size');
+    }
+    return Matrix(Vector.add(_data, that.data()), _m, _n);
+  };
+
+  /**
+   * Get a column from this matrix.
+   *
+   * @param col {Number}
+   *        zero-based column index.
+   * @return {Array<Number>} array containing elements from column.
+   */
+  _this.col = function (col) {
+    return __col(_data, _m, _n, col);
+  };
+
+  /**
+   * Access the wrapped array.
+   */
+  _this.data = function () {
+    return _data;
+  };
+
+  /**
+   * Get the diagonal from this matrix.
+   *
+   * @return {Array<Number>} array containing elements from diagonal.
+   */
+  _this.diagonal = function () {
+    return __diagonal(_data, _m, _n);
+  };
+
+  /**
+   * Get a value from this matrix.
+   *
+   * @param row {Number}
+   *        zero-based index of row.
+   * @param col {Number}
+   *        zero-based index of column.
+   * @return {Number} value at (row, col).
+   */
+  _this.get = function (row, col) {
+    return __get(_data, _m, _n, row, col);
+  };
+
+  /**
+   * Compute the eigenvectors of this matrix.
+   *
+   * NOTE: Matrix should be 3x3 and symmetric.
+   *
+   * @param maxRotations {Number}
+   *        default 100.
+   *        maximum number of iterations.
+   * @return {Array<Vector>} eigenvectors.
+   *         Magnitude of each vector is eigenvalue.
+   */
+  _this.jacobi = function (maxRotations) {
+    return __jacobi(_data, _m, _n, maxRotations);
+  };
+
+  /**
+   * Get the number of rows in matrix.
+   *
+   * @return {Number}
+   *         number of rows.
+   */
+  _this.m = function () {
+    return _m;
+  };
+
+  /**
+   * Multiply matrices.
+   *
+   * @param that {Matrix}
+   *        matrix to multiply.
+   * @return {Matrix} result of multiplication.
+   */
+  _this.multiply = function (that) {
+    return Matrix(
+      __multiply(_data, _m, _n, that.data(), that.m(), that.n()),
+      // use that.N
+      _m,
+      that.n()
+    );
+  };
+
+  /**
+   * Get number of columns in matrix.
+   *
+   * @return {Number} number of columns.
+   */
+  _this.n = function () {
+    return _n;
+  };
+
+  /**
+   * Multiply each element by -1.
+   *
+   * @return {Matrix} result of negation.
+   */
+  _this.negative = function () {
+    return Matrix(Vector.multiply(_data, -1), _m, _n);
+  };
+
+  /**
+   * Get a row from this matrix.
+   *
+   * @param row {Number}
+   *        zero-based index of row.
+   * @return {Array<Number>} elements from row.
+   */
+  _this.row = function (row) {
+    return __row(_data, _m, _n, row);
+  };
+
+  /**
+   * Set a value in this matrix.
+   *
+   * @param row {Number}
+   *        zero-based row index.
+   * @param col {Number}
+   *        zero-based column index.
+   * @param value {Number}
+   *        value to set.
+   */
+  _this.set = function (row, col, value) {
+    __set(_data, _m, _n, row, col, value);
+  };
+
+  /**
+   * Subtract another matrix from this matrix.
+   *
+   * @param that {Matrix}
+   *        matrix to subtract.
+   * @throws Error if dimensions do not match.
+   * @return result of subtraction (original matrix is unchanged).
+   */
+  _this.subtract = function (that) {
+    if (_m !== that.m() || n !== that.n()) {
+      throw new Error('matrices must be same size');
+    }
+    return Matrix(Vector.subtract(_data, that.data()), _m, _n);
+  };
+
+  /**
+   * Display matrix as a string.
+   *
+   * @return {String} formatted matrix.
+   */
+  _this.toString = function () {
+    return __stringify(_data, _m, _n);
+  };
+
+  /**
+   * Transpose matrix.
+   *
+   * Columns become rows, and rows become columns.
+   *
+   * @return {Matrix} result of transpose.
+   */
+  _this.transpose = function () {
+    return Matrix(
+      __transpose(_data, _m, _n),
+      // swap M and N
+      _n,
+      _m
+    );
+  };
+
+  _initialize(data, m, n);
+  data = null;
+  return _this;
+};
+
+// expose static methods.
+Matrix.col = __col;
+Matrix.diagonal = __diagonal;
+Matrix.get = __get;
+Matrix.identity = __identity;
+Matrix.index = __index;
+Matrix.jacobi = __jacobi;
+Matrix.multiply = __multiply;
+Matrix.row = __row;
+Matrix.set = __set;
+Matrix.stringify = __stringify;
+Matrix.transpose = __transpose;
+
+module.exports = Matrix;
diff --git a/src/math/Vector.js b/src/math/Vector.js
new file mode 100644
index 0000000000000000000000000000000000000000..edda42b171ff9253a5dd5e2e4cb9ed730feea63d
--- /dev/null
+++ b/src/math/Vector.js
@@ -0,0 +1,644 @@
+'use strict';
+
+// static methods that operate on arrays
+var __add,
+  __angle,
+  __azimuth,
+  __cross,
+  __dot,
+  __equals,
+  __magnitude,
+  __multiply,
+  __plunge,
+  __unit,
+  __rotate,
+  __subtract,
+  __x,
+  __y,
+  __z;
+
+/**
+ * Add two vectors.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Array<Number>}
+ *         result of addition.
+ * @throws {Error} when vectors are different lengths.
+ */
+__add = function (v1, v2) {
+  var i, v;
+  if (v1.length !== v2.length) {
+    throw new Error('vectors must be same length');
+  }
+  v = [];
+  for (i = 0; i < v1.length; i++) {
+    v.push(v1[i] + v2[i]);
+  }
+  return v;
+};
+
+/**
+ * Compute the angle between two vectors.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Number}
+ *         angle between vectors in radians.
+ */
+__angle = function (v1, v2) {
+  return Math.acos(__dot(v1, v2) / (__magnitude(v1) * __magnitude(v2)));
+};
+
+/**
+ * Compute the azimuth of a vector.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Number}
+ *         angle between vectors in radians.
+ */
+__azimuth = function (v1) {
+  if (v1.length < 2) {
+    throw new Error('azimuth requires at least 2 dimensions');
+  }
+  if (v1[0] === 0 && v1[1] === 0) {
+    // if vector is zero, or vertical, azimuth is zero.
+    return 0;
+  }
+  return Math.PI / 2 - Math.atan2(v1[1], v1[0]);
+};
+
+/**
+ * Compute vector cross product.
+ *
+ * Note: only computes cross product in 3 dimensions.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Array<Number>}
+ *         the 3 dimensional cross product.
+ *         the resulting vector follows the right-hand rule: if the fingers on
+ *         your right hand point to v1, and you close your hand to get to v2,
+ *         the resulting vector points in the direction of your thumb.
+ */
+__cross = function (v1, v2) {
+  if (v1.length !== v2.length || v1.length < 3) {
+    throw new Error('cross product requires at least 3 dimensions');
+  }
+  return [
+    v1[1] * v2[2] - v2[1] * v1[2],
+    v1[2] * v2[0] - v2[2] * v1[0],
+    v1[0] * v2[1] - v2[0] * v1[1],
+  ];
+};
+
+/**
+ * Compute vector dot product.
+ *
+ * @param v1 {Array<Number}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Number}
+ *         the dot product.
+ */
+__dot = function (v1, v2) {
+  var i, sum;
+  sum = 0;
+  for (i = 0; i < v1.length; i++) {
+    sum += v1[i] * v2[i];
+  }
+  return sum;
+};
+
+/**
+ * Check if two vectors are equal.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the second vector.
+ * @return {Boolean}
+ *         true if vectors are same length and all elements are equal.
+ */
+__equals = function (v1, v2) {
+  var i;
+  if (v1.length !== v2.length) {
+    return false;
+  }
+  for (i = 0; i < v1.length; i++) {
+    if (v1[i] !== v2[i]) {
+      return false;
+    }
+  }
+  return true;
+};
+
+/**
+ * Compute length of vector.
+ *
+ * @param v1 {Array<Number>}
+ *        vector.
+ * @return {Number}
+ *         magnitude of vector.
+ */
+__magnitude = function (v1) {
+  var i, sum;
+  sum = 0;
+  for (i = 0; i < v1.length; i++) {
+    sum += v1[i] * v1[i];
+  }
+  return Math.sqrt(sum);
+};
+
+/**
+ * Multiply vector by a constant.
+ *
+ * @param v1 {Array<Number>}
+ *        vector to multiply.
+ * @param n {Number}
+ *        number to multiply by.
+ * @return {Array<Number}
+ *         result of multiplication.
+ */
+__multiply = function (v1, n) {
+  var i, v;
+
+  v = [];
+  for (i = 0; i < v1.length; i++) {
+    v.push(v1[i] * n);
+  }
+  return v;
+};
+
+/**
+ * Compute angle from plane z=0 to vector.
+ *
+ * @param v {Array<Number>}
+ *        the vector.
+ * @return {Number}
+ *         angle from plane z=0 to vector.
+ *         angle is positive when z > 0, negative when z < 0.
+ */
+__plunge = function (v) {
+  if (v.length < 3) {
+    throw new Error('__azimuth: vector must have at least 3 dimensions');
+  }
+  return Math.asin(v[2] / __magnitude(v));
+};
+
+/**
+ * Rotate a vector around an axis.
+ *
+ * From "6.2 The normalized matrix for rotation about an arbitrary line",
+ *      http://inside.mines.edu/~gmurray/ArbitraryAxisRotation/
+ *
+ * @param v1 {Array<Number>}
+ *        the "point" to rotate.
+ * @param axis {Array<Number>}
+ *        direction vector of rotation axis.
+ * @param theta {Number}
+ *        angle of rotation in radians.
+ * @param origin {Array<Number>}
+ *        default [0, 0, 0].
+ *        origin of axis of rotation.
+ */
+__rotate = function (v1, axis, theta, origin) {
+  var a,
+    au,
+    av,
+    aw,
+    b,
+    bu,
+    bv,
+    bw,
+    c,
+    cu,
+    cv,
+    cw,
+    cosT,
+    sinT,
+    u,
+    uu,
+    ux,
+    uy,
+    uz,
+    v,
+    vv,
+    vx,
+    vy,
+    vz,
+    w,
+    ww,
+    wx,
+    wy,
+    wz,
+    x,
+    y,
+    z;
+
+  origin = origin || [0, 0, 0];
+  a = origin[0];
+  b = origin[1];
+  c = origin[2];
+  u = axis[0];
+  v = axis[1];
+  w = axis[2];
+  x = v1[0];
+  y = v1[1];
+  z = v1[2];
+
+  cosT = Math.cos(theta);
+  sinT = Math.sin(theta);
+  au = a * u;
+  av = a * v;
+  aw = a * w;
+  bu = b * u;
+  bv = b * v;
+  bw = b * w;
+  cu = c * u;
+  cv = c * v;
+  cw = c * w;
+  uu = u * u;
+  ux = u * x;
+  uy = u * y;
+  uz = u * z;
+  vv = v * v;
+  vx = v * x;
+  vy = v * y;
+  vz = v * z;
+  ww = w * w;
+  wx = w * x;
+  wy = w * y;
+  wz = w * z;
+
+  return [
+    (a * (vv + ww) - u * (bv + cw - ux - vy - wz)) * (1 - cosT) +
+      x * cosT +
+      (-cv + bw - wy + vz) * sinT,
+    (b * (uu + ww) - v * (au + cw - ux - vy - wz)) * (1 - cosT) +
+      y * cosT +
+      (cu - aw + wx - uz) * sinT,
+    (c * (uu + vv) - w * (au + bv - ux - vy - wz)) * (1 - cosT) +
+      z * cosT +
+      (-bu + av - vx + uy) * sinT,
+  ];
+};
+
+/**
+ * Subtract two vectors.
+ *
+ * @param v1 {Array<Number>}
+ *        the first vector.
+ * @param v2 {Array<Number>}
+ *        the vector to subtract.
+ * @return {Array<Number>}
+ *         result of subtraction.
+ * @throws {Error} when vectors are different lengths.
+ */
+__subtract = function (v1, v2) {
+  var i, v;
+
+  if (v1.length !== v2.length) {
+    throw new Error('__subtract: vectors must be same length');
+  }
+  v = [];
+  for (i = 0; i < v1.length; i++) {
+    v.push(v1[i] - v2[i]);
+  }
+  return v;
+};
+
+/**
+ * Convert vector to length 1.
+ *
+ * Same as __multiply(v1, 1 / __magnitude(v1))
+ *
+ * @param v1 {Array<Number>}
+ *        the vector.
+ * @return {Array<Number>}
+ *         vector converted to length 1.
+ * @throws {Error} if vector magnitude is 0.
+ */
+__unit = function (v1) {
+  var mag = __magnitude(v1);
+  if (mag === 0) {
+    throw new Error('__unit: cannot convert zero vector to unit vector');
+  }
+  return __multiply(v1, 1 / mag);
+};
+
+/**
+ * Get, and optionally set, the x component of a vector.
+ *
+ * @param v {Array<Number>}
+ *        the vector.
+ * @param value {Number}
+ *        default undefined.
+ *        when defined, set x component.
+ * @return {Number}
+ *         the x component.
+ */
+__x = function (v, value) {
+  if (typeof value === 'number') {
+    v[0] = value;
+  }
+  return v[0];
+};
+
+/**
+ * Get, and optionally set, the y component of a vector.
+ *
+ * @param v {Array<Number>}
+ *        the vector.
+ * @param value {Number}
+ *        default undefined.
+ *        when defined, set y component.
+ * @return {Number}
+ *         the y component.
+ */
+__y = function (v, value) {
+  if (typeof value === 'number') {
+    v[1] = value;
+  }
+  return v[1];
+};
+
+/**
+ * Get, and optionally set, the z component of a vector.
+ *
+ * @param v {Array<Number>}
+ *        the vector.
+ * @param value {Number}
+ *        default undefined.
+ *        when defined, set z component.
+ * @return {Number}
+ *         the z component.
+ */
+__z = function (v, value) {
+  if (typeof value === 'number') {
+    v[2] = value;
+  }
+  return v[2];
+};
+
+/**
+ * A vector object that wraps an array.
+ *
+ * This is a convenience object to call the static methods on the wrapped array.
+ * Only the methods x(), y(), and z() modify data; other methods return new
+ * Vector objects without modifying the existing object.
+ *
+ * @param data {Array<Number>}
+ *        array to wrap.
+ */
+var Vector = function (data) {
+  var _this,
+    _initialize,
+    // variables
+    _data;
+
+  if (data && typeof data.data === 'function') {
+    // copy existing object
+    data = data.data().slice(0);
+  }
+
+  _this = {
+    _isa_vector: true,
+  };
+
+  _initialize = function (data) {
+    _data = data;
+  };
+
+  /**
+   * Add two vectors.
+   *
+   * @param that {Vector|Array<Number>}
+   *        vector to add.
+   * @return {Vector}
+   *         result of addition.
+   */
+  _this.add = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return Vector(__add(_data, that));
+  };
+
+  /**
+   * Compute angle between vectors.
+   *
+   * @param that {Vector|Array<Number>}
+   *        vector to compute angle between.
+   * @return {Number} angle between vectors in radians.
+   */
+  _this.angle = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return __angle(_data, that);
+  };
+
+  /**
+   * Compute azimuth of this vector.
+   *
+   * @return {Number} azimuth of this vector in radians.
+   */
+  _this.azimuth = function () {
+    return __azimuth(_data);
+  };
+
+  /**
+   * Compute the cross product between vectors.
+   *
+   * @param that {Vector|Array<Number>}
+   *        the vector to cross.
+   * @return {Vector} result of the cross product.
+   */
+  _this.cross = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return Vector(__cross(_data, that));
+  };
+
+  /**
+   * Access the wrapped array.
+   *
+   * @return {Array<Number>}
+   *         the wrapped array.
+   */
+  _this.data = function () {
+    return _data;
+  };
+
+  /**
+   * Compute dot product between vectors.
+   *
+   * @param that {Vector|Array<Number>}
+   *        vector to dot.
+   * @return {Number} result of dot product.
+   */
+  _this.dot = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return __dot(_data, that);
+  };
+
+  /**
+   * Check if two vectors are equal.
+   *
+   * @param that {Vector|Array<Number>}
+   *        vector to compare.
+   * @return {Boolean} true if equal, false otherwise.
+   */
+  _this.equals = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return __equals(_data, that);
+  };
+
+  /**
+   * Compute length of this vector.
+   *
+   * @return {Number} length of vector.
+   *         Square root of the sum of squares of all components.
+   */
+  _this.magnitude = function () {
+    return __magnitude(_data);
+  };
+
+  /**
+   * Multiply this vector by a number.
+   *
+   * @param n {Number}
+   *        number to multiply.
+   * @return {Vector} result of multiplication.
+   */
+  _this.multiply = function (n) {
+    return Vector(__multiply(_data, n));
+  };
+
+  /**
+   * Same as multiply(-1).
+   */
+  _this.negative = function () {
+    return _this.multiply(-1);
+  };
+
+  /**
+   * Compute plunge of this vector.
+   *
+   * Plunge is the angle between this vector and the plane z=0.
+   *
+   * @return {Number} plunge in radians.
+   *         positive when z>0, negative when z<0.
+   */
+  _this.plunge = function () {
+    return __plunge(_data);
+  };
+
+  /**
+   * Rotate this vector around an arbitrary axis.
+   *
+   * @param axis {Vector|Array<Number>}
+   *        direction of axis of rotation.
+   * @param theta {Number}
+   *        angle of rotation in radians.
+   * @param origin {Vector|Array<Number>}
+   *        origin of axis of rotation.
+   * @return {Vector} result of rotation.
+   */
+  _this.rotate = function (axis, theta, origin) {
+    axis = axis._isa_vector ? axis.data() : axis;
+    origin = origin && origin._isa_vector ? origin.data() : origin;
+    return Vector(__rotate(_data, axis, theta, origin));
+  };
+
+  /**
+   * Subtract another vector.
+   *
+   * @param that {Vector|Array<Number>}
+   *        vector to subtract.
+   * @return {Vector} result of subtraction.
+   */
+  _this.subtract = function (that) {
+    that = that._isa_vector ? that.data() : that;
+    return Vector(__subtract(_data, that));
+  };
+
+  /**
+   * Convert vector to string.
+   *
+   * @return {String} wrapped array converted to string.
+   */
+  _this.toString = function () {
+    return '' + _data;
+  };
+
+  /**
+   * Convert this vector to length 1.
+   *
+   * @return {Vector} vector / |vector|.
+   */
+  _this.unit = function () {
+    return Vector(__unit(_data));
+  };
+
+  /**
+   * Get or set x component.
+   *
+   * @param value {Number}
+   *        when defined, set x component to value.
+   * @return {Number} x component value.
+   */
+  _this.x = function (value) {
+    return __x(_data, value);
+  };
+
+  /**
+   * Get or set y component.
+   *
+   * @param value {Number}
+   *        when defined, set y component to value.
+   * @return {Number} y component value.
+   */
+  _this.y = function (value) {
+    return __y(_data, value);
+  };
+
+  /**
+   * Get or set z component.
+   *
+   * @param value {Number}
+   *        when defined, set z component to value.
+   * @return {Number} z component value.
+   */
+  _this.z = function (value) {
+    return __z(_data, value);
+  };
+
+  _initialize(data);
+  data = null;
+  return _this;
+};
+
+// expose static methods
+Vector.add = __add;
+Vector.angle = __angle;
+Vector.azimuth = __azimuth;
+Vector.cross = __cross;
+Vector.dot = __dot;
+Vector.magnitude = __magnitude;
+Vector.multiply = __multiply;
+Vector.plunge = __plunge;
+Vector.rotate = __rotate;
+Vector.subtract = __subtract;
+Vector.unit = __unit;
+Vector.x = __x;
+Vector.y = __y;
+Vector.z = __z;
+
+module.exports = Vector;
diff --git a/src/math/index.d.ts b/src/math/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6d948efd57f278bff7430031b422278131ee2436
--- /dev/null
+++ b/src/math/index.d.ts
@@ -0,0 +1 @@
+export * from '../../types/math';
diff --git a/src/math/index.js b/src/math/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..9c8b8af5528b7fb1b8668a7e43459c9766041382
--- /dev/null
+++ b/src/math/index.js
@@ -0,0 +1,5 @@
+module.exports = {
+  Camera: require('./Camera'),
+  Matrix: require('./Matrix'),
+  Vector: require('./Vector'),
+};
diff --git a/src/mvc/Collection.js b/src/mvc/Collection.js
new file mode 100644
index 0000000000000000000000000000000000000000..d1098a673b57db954275f0f17556a2379d7c7b2c
--- /dev/null
+++ b/src/mvc/Collection.js
@@ -0,0 +1,360 @@
+'use strict';
+/**
+ * A Lightweight collection, inspired by backbone.
+ *
+ * Lazily builds indexes to avoid overhead until needed.
+ */
+
+var Events = require('../util/Events'),
+  Util = require('../util/Util');
+
+/**
+ * Create a new Collection.
+ *
+ * @param data {Array}
+ *      When omitted a new array is created.
+ */
+var Collection = function (data) {
+  var _this, _initialize, _data, _ids, _selected, _isSilent;
+
+  _this = Events();
+
+  _initialize = function () {
+    _data = data || [];
+    _ids = null;
+    _selected = null;
+
+    data = null;
+  };
+
+  /**
+   * Whether "silent" option is true.
+   *
+   * @param options {Object}
+   * @param options.silent {Boolean}
+   *        default false.
+   * @return {Boolean} true if options.silent is true.
+   */
+  _isSilent = function (options) {
+    return options && options.silent === true;
+  };
+
+  /**
+   * Add objects to the collection.
+   *
+   * Calls wrapped array.push, and clears the id cache.
+   *
+   * @param {Object…}
+   *      a variable number of objects to append to the collection.
+   * @deprecated see #addAll()
+   */
+  _this.add = function () {
+    _this.addAll(Array.prototype.slice.call(arguments, 0));
+  };
+
+  /**
+   * Add objects to the collection.
+   *
+   * Calls wrapped array.push, and clears the id cache.
+   *
+   * @param toadd {Array<Object>}
+   *        objects to be added to the collection.
+   */
+  _this.addAll = function (toadd, options) {
+    _data.push.apply(_data, toadd);
+    _ids = null;
+    if (!_isSilent(options)) {
+      _this.trigger('add', toadd);
+    }
+  };
+
+  /**
+   * Get the wrapped array.
+   *
+   * @return
+   *      the wrapped array.
+   */
+  _this.data = function () {
+    return _data;
+  };
+
+  /**
+   * Deselect current selection.
+   */
+  _this.deselect = function (options) {
+    if (_selected !== null) {
+      var oldSelected = _selected;
+      _selected = null;
+      if (!_isSilent(options)) {
+        _this.trigger('deselect', oldSelected);
+      }
+    }
+  };
+
+  /**
+   * Free the array and id cache.
+   *
+   * @param options {Object}
+   *        passed to #deselect().
+   */
+  _this.destroy = Util.compose(function (options) {
+    _data = null;
+    _ids = null;
+    _selected = null;
+    if (!_isSilent(options)) {
+      _this.trigger('destroy');
+    }
+    return options;
+  }, _this.destroy);
+
+  /**
+   * Get an object in the collection by ID.
+   *
+   * Uses getIds(), so builds map of ID to INDEX on first access O(N).
+   * Subsequent access should be O(1).
+   *
+   * @param id {Any}
+   *      if the collection contains more than one object with the same id,
+   *      the last element with that id is returned.
+   */
+  _this.get = function (id) {
+    var ids = _this.getIds();
+
+    if (ids.hasOwnProperty(id)) {
+      // use cached index
+      return _data[ids[id]];
+    } else {
+      return null;
+    }
+  };
+
+  /**
+   * Get a map from ID to INDEX.
+   *
+   * @param force {Boolean}
+   *      rebuild the map even if it exists.
+   */
+  _this.getIds = function (force) {
+    var i = 0,
+      len = _data.length,
+      id;
+
+    if (force || _ids === null) {
+      // build up ids first time through
+      _ids = {};
+
+      for (; i < len; i++) {
+        id = _data[i].id;
+
+        if (_ids.hasOwnProperty(id)) {
+          throw 'model with duplicate id "' + id + '" found in collection';
+        }
+
+        _ids[id] = i;
+      }
+    }
+
+    return _ids;
+  };
+
+  /**
+   * Get the currently selected object.
+   */
+  _this.getSelected = function () {
+    return _selected;
+  };
+
+  /**
+   * Remove objects from the collection.
+   *
+   * This method calls array.splice to remove item from array.
+   * Reset would be faster if modifying large chunks of the array.
+   *
+   * @param o {Object}
+   *      object to remove.
+   * @deprecated see #removeAll()
+   */
+  _this.remove = function (/* o */) {
+    // trigger remove event
+    _this.removeAll(Array.prototype.slice.call(arguments, 0));
+  };
+
+  /**
+   * Remove objects from the collection.
+   *
+   * Reset is faster if modifying large chunks of the array.
+   *
+   * @param toremove {Array<Object>}
+   *        objects to remove.
+   * @param options {Object}
+   * @param options.silent {Boolean}
+   *        default false.
+   *        whether to trigger events (false), or not (true).
+   */
+  _this.removeAll = function (toremove, options) {
+    var i,
+      len = toremove.length,
+      indexes = [],
+      ids = _this.getIds(),
+      o;
+
+    // select indexes to be removed
+    for (i = 0; i < len; i++) {
+      o = toremove[i];
+
+      // clear current selection if being removed
+      if (o === _selected) {
+        _this.deselect();
+      }
+
+      // add to list to be removed
+      if (ids.hasOwnProperty(o.id)) {
+        indexes.push(ids[o.id]);
+      } else {
+        throw 'removing object not in collection';
+      }
+    }
+
+    // remove in descending index order
+    indexes.sort(function (a, b) {
+      return a - b;
+    });
+
+    for (i = indexes.length - 1; i >= 0; i--) {
+      _data.splice(indexes[i], 1);
+    }
+
+    // reset id cache
+    _ids = null;
+
+    if (!_isSilent(options)) {
+      // trigger remove event
+      _this.trigger('remove', toremove);
+    }
+  };
+
+  /**
+   * Replace the wrapped array with a new one.
+   */
+  _this.reset = function (data, options) {
+    // check for existing selection
+    var selectedId = null;
+    if (_selected !== null) {
+      selectedId = _selected.id;
+    }
+
+    // free array and id cache
+    _data = null;
+    _ids = null;
+    _selected = null;
+
+    // set new array
+    _data = data || [];
+
+    // notify listeners
+    if (!options || options.silent !== true) {
+      _this.trigger('reset', data);
+    }
+
+    // reselect if there was a previous selection
+    if (selectedId !== null) {
+      var selected = _this.get(selectedId);
+      if (selected !== null) {
+        options = Util.extend({}, options, { reset: true });
+        _this.select(selected, options);
+      }
+    }
+  };
+
+  /**
+   * Select an object in the collection.
+   *
+   * @param obj {Object}
+   *      object in the collection to select.
+   * @throws exception
+   *      if obj not in collection.
+   */
+  _this.select = function (obj, options) {
+    // no selection
+    if (obj === null) {
+      _this.deselect();
+      return;
+    }
+    // already selected
+    if (obj === _selected) {
+      return;
+    }
+    // deselect previous selection
+    if (_selected !== null) {
+      _this.deselect(options);
+    }
+
+    if (obj === _this.get(obj.id)) {
+      // make sure it's part of this collection…
+      _selected = obj;
+      if (!options || options.silent !== true) {
+        _this.trigger('select', _selected, options);
+      }
+    } else {
+      throw 'selecting object not in collection';
+    }
+  };
+
+  /**
+   * Utility method to select collection item using its id.
+   *
+   * Selects matching item if it exists, otherwise clears any selection.
+   *
+   * @param id {?}
+   *        id of item to select.
+   * @param options {Object}
+   *        options passed to #select() or #deselect().
+   */
+  _this.selectById = function (id, options) {
+    var obj = _this.get(id);
+    if (obj !== null) {
+      _this.select(obj, options);
+    } else {
+      _this.deselect(options);
+    }
+  };
+
+  /**
+   * Sorts the data.
+   *
+   * @param method {Function}
+   *        javascript sort method.
+   * @param options {Object}
+   *        passed to #reset()
+   */
+  _this.sort = function (method, options) {
+    _data.sort(method);
+
+    // "reset" to new sort order
+    _this.reset(_data, options);
+  };
+
+  /**
+   * Override toJSON method to serialize only collection data.
+   */
+  _this.toJSON = function () {
+    var json = _data.slice(0),
+      item,
+      i,
+      len;
+
+    for (i = 0, len = json.length; i < len; i++) {
+      item = json[i];
+      if (typeof item === 'object' && item !== null && typeof item.toJSON === 'function') {
+        json[i] = item.toJSON();
+      }
+    }
+
+    return json;
+  };
+
+  _initialize();
+  return _this;
+};
+
+module.exports = Collection;
diff --git a/src/mvc/Model.js b/src/mvc/Model.js
new file mode 100644
index 0000000000000000000000000000000000000000..78f7b3825357492ceb9edaec8d80a955cdc80224
--- /dev/null
+++ b/src/mvc/Model.js
@@ -0,0 +1,122 @@
+'use strict';
+
+var Events = require('../util/Events'),
+  Util = require('../util/Util');
+
+/**
+ * Constructor
+ *
+ * @param data {Object}
+ *      key/value attributes of this model.
+ */
+var Model = function (data) {
+  var _this, _initialize, _model;
+
+  _this = Events();
+
+  _initialize = function () {
+    _model = Util.extend({}, data);
+
+    // track id at top level
+    if (data && data.hasOwnProperty('id')) {
+      _this.id = data.id;
+    }
+
+    data = null;
+  };
+
+  /**
+   * Get one or more values.
+   *
+   * @param key {String}
+   *      the value to get; when key is undefined, returns the object with all
+   *      values.
+   * @return
+   *      - if key is specified, the value or null if no value exists.
+   *      - when key is not specified, the underlying object is returned.
+   *        (Any changes to this underlying object will not trigger events!!!)
+   */
+  _this.get = function (key) {
+    if (typeof key === 'undefined') {
+      return _model;
+    }
+
+    if (_model.hasOwnProperty(key)) {
+      return _model[key];
+    }
+
+    return null;
+  };
+
+  /**
+   * Update one or more values.
+   *
+   * @param data {Object}
+   *      the keys and values to update.
+   * @param options {Object}
+   *      options for this method.
+   * @param options.silent {Boolean}
+   *      default false. true to suppress any events that would otherwise be
+   *      triggered.
+   */
+  _this.set = function (data, options) {
+    // detect changes
+    var changed = {},
+      anyChanged = false,
+      c;
+
+    for (c in data) {
+      if (!_model.hasOwnProperty(c) || _model[c] !== data[c]) {
+        changed[c] = data[c];
+        anyChanged = true;
+      }
+    }
+
+    // persist changes
+    _model = Util.extend(_model, data);
+
+    // if id is changing, update the model id
+    if (data && data.hasOwnProperty('id')) {
+      _this.id = data.id;
+    }
+
+    if (options && options.hasOwnProperty('silent') && options.silent) {
+      // don't trigger any events
+      return;
+    }
+
+    // trigger events based on changes
+    if (anyChanged || (options && options.hasOwnProperty('force') && options.force)) {
+      for (c in changed) {
+        // events specific to a property
+        _this.trigger('change:' + c, changed[c]);
+      }
+      // generic event for any change
+      _this.trigger('change', changed);
+    }
+  };
+
+  /**
+   * Override toJSON method to serialize only model data.
+   */
+  _this.toJSON = function () {
+    var json = Util.extend({}, _model),
+      key,
+      value;
+
+    for (key in json) {
+      value = json[key];
+
+      if (typeof value === 'object' && value !== null && typeof value.toJSON === 'function') {
+        json[key] = value.toJSON();
+      }
+    }
+
+    return json;
+  };
+
+  _initialize();
+  return _this;
+};
+
+module.exports = Model;
diff --git a/src/mvc/SelectedCollectionView.js b/src/mvc/SelectedCollectionView.js
new file mode 100644
index 0000000000000000000000000000000000000000..1a0679842cbc0a7e3104c82d265ff4e1f458282b
--- /dev/null
+++ b/src/mvc/SelectedCollectionView.js
@@ -0,0 +1,101 @@
+'use strict';
+
+var Collection = require('./Collection'),
+  Events = require('../util/Events');
+
+/** create a new view based on a collection of models. */
+var SelectedCollectionView = function (params) {
+  var _this, _initialize, _destroyCollection;
+
+  _this = Events();
+
+  /**
+   * @constructor
+   *
+   */
+  _initialize = function (params) {
+    params = params || {};
+
+    // Element where this view is rendered
+    _this.el = params.hasOwnProperty('el') ? params.el : document.createElement('div');
+
+    _this.collection = params.collection;
+
+    if (!_this.collection) {
+      _this.collection = Collection();
+      _destroyCollection = true;
+    }
+
+    if (_this.collection.getSelected()) {
+      _this.onCollectionSelect();
+    }
+
+    _this.collection.on('deselect', 'onCollectionDeselect', _this);
+    _this.collection.on('reset', 'onCollectionReset', _this);
+    _this.collection.on('select', 'onCollectionSelect', _this);
+  };
+
+  /**
+   * clean up the view
+   */
+  _this.destroy = function () {
+    // undo event bindings
+    if (_this.model) {
+      _this.onCollectionDeselect();
+    }
+    _this.collection.off('deselect', 'onCollectionDeselect', _this);
+    _this.collection.off('reset', 'onCollectionReset', _this);
+    _this.collection.off('select', 'onCollectionSelect', _this);
+
+    if (_destroyCollection) {
+      _this.collection.destroy();
+    }
+
+    _destroyCollection = null;
+
+    _this = null;
+    _initialize = null;
+  };
+
+  /**
+   * unset the event bindings for the collection
+   */
+  _this.onCollectionDeselect = function () {
+    if (_this.model) {
+      _this.model.off('change', 'render', _this);
+      _this.model = null;
+    }
+    _this.render({ model: _this.model });
+  };
+
+  /**
+   * unset event bindings for the collection, if set.
+   */
+  _this.onCollectionReset = function () {
+    if (_this.model) {
+      _this.model.off('change', 'render', _this);
+      _this.model = null;
+    }
+    _this.render({ model: _this.model });
+  };
+
+  /**
+   * set event bindings for the collection
+   */
+  _this.onCollectionSelect = function () {
+    _this.model = _this.collection.getSelected();
+    _this.model.on('change', 'render', _this);
+    _this.render({ model: _this.model });
+  };
+
+  /**
+   * render the selected model in the view
+   */
+  _this.render = function () {};
+
+  _initialize(params);
+  params = null;
+  return _this;
+};
+
+module.exports = SelectedCollectionView;
diff --git a/src/mvc/View.js b/src/mvc/View.js
new file mode 100644
index 0000000000000000000000000000000000000000..bd328c212942d9c58ef901d23eaaa5de424ec196
--- /dev/null
+++ b/src/mvc/View.js
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * A lightweight view class.
+ *
+ * Primarily manages an element where a view can render its data.
+ */
+
+var Model = require('./Model'),
+  Events = require('../util/Events'),
+  Util = require('../util/Util');
+
+var _DEFAULTS = {};
+
+/** create a new view. */
+var View = function (params) {
+  var _this, _initialize, _destroyModel;
+
+  _this = Events();
+
+  /**
+   * @constructor
+   *
+   */
+  _initialize = function (params) {
+    params = Util.extend({}, _DEFAULTS, params);
+
+    // Element where this view is rendered
+    _this.el = params && params.hasOwnProperty('el') ? params.el : document.createElement('div');
+
+    _this.model = params.model;
+
+    if (!_this.model) {
+      _this.model = Model({});
+      _destroyModel = true;
+    }
+
+    _this.model.on('change', 'render', _this);
+  };
+
+  /**
+   * API Method
+   *
+   * Renders the view
+   */
+  _this.render = function () {
+    // Impelementations should update the view based on the current
+    // model properties.
+  };
+
+  /**
+   * API Method
+   *
+   * Cleans up resources allocated by the view. Should be called before
+   * discarding a view.
+   */
+  _this.destroy = Util.compose(function () {
+    if (_this === null) {
+      return; // already destroyed
+    }
+
+    _this.model.off('change', 'render', _this);
+
+    if (_destroyModel) {
+      _this.model.destroy();
+    }
+
+    _destroyModel = null;
+
+    _this.model = null;
+    _this.el = null;
+
+    _initialize = null;
+    _this = null;
+  }, _this.destroy);
+
+  _initialize(params);
+  params = null;
+  return _this;
+};
+
+module.exports = View;
diff --git a/src/mvc/index.d.ts b/src/mvc/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d543e04ce7122e0181519c9efaf966f773a18015
--- /dev/null
+++ b/src/mvc/index.d.ts
@@ -0,0 +1 @@
+export * from '../../types/mvc';
diff --git a/src/mvc/index.js b/src/mvc/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..663590a03d674bb97a335a4bc2baa0c362dec98c
--- /dev/null
+++ b/src/mvc/index.js
@@ -0,0 +1,6 @@
+module.exports = {
+  Collection: require('./Collection'),
+  Model: require('./Model'),
+  SelectedCollectionView: require('./SelectedCollectionView'),
+  View: require('./View'),
+};
diff --git a/src/util/Events.js b/src/util/Events.js
new file mode 100644
index 0000000000000000000000000000000000000000..b13f05938d2ebed7e6ce0a3bea24b15b7ce2942b
--- /dev/null
+++ b/src/util/Events.js
@@ -0,0 +1,183 @@
+'use strict';
+
+var __INSTANCE__ = null;
+
+var __is_string = function (obj) {
+  return typeof obj === 'string' || obj instanceof String;
+};
+
+var Events = function () {
+  var _this, _initialize, _listeners;
+
+  _this = {};
+
+  _initialize = function () {
+    // map of listeners by event type
+    _listeners = {};
+  };
+
+  /**
+   * Free all references.
+   */
+  _this.destroy = function () {
+    _initialize = null;
+    _listeners = null;
+    _this = null;
+  };
+
+  /**
+   * Remove an event listener
+   *
+   * Omitting callback clears all listeners for given event.
+   * Omitting event clears all listeners for all events.
+   *
+   * @param event {String}
+   *      event name to unbind.
+   * @param callback {Function}
+   *      callback to unbind.
+   * @param context {Object}
+   *      context for "this" when callback is called
+   */
+  _this.off = function (evt, callback, context) {
+    var i;
+
+    if (typeof evt === 'undefined') {
+      // removing all listeners on this object
+      _listeners = null;
+      _listeners = {};
+    } else if (!_listeners.hasOwnProperty(evt)) {
+      // no listeners, nothing to do
+      return;
+    } else if (typeof callback === 'undefined') {
+      // removing all listeners for this event
+      delete _listeners[evt];
+    } else {
+      var listener = null;
+
+      // search for callback to remove
+      for (i = _listeners[evt].length - 1; i >= 0; i--) {
+        listener = _listeners[evt][i];
+
+        if (listener.callback === callback && (!context || listener.context === context)) {
+          // found callback, remove
+          _listeners[evt].splice(i, 1);
+
+          if (context) {
+            // found callback with context, stop searching
+            break;
+          }
+        }
+      }
+
+      // cleanup if last callback of this type
+      if (_listeners[evt].length === 0) {
+        delete _listeners[evt];
+      }
+
+      listener = null;
+    }
+  };
+
+  /**
+   * Add an event listener
+   *
+   * @param event {String}
+   *      event name (singular).  E.g. 'reset'
+   * @param callback {Function}
+   *      function to call when event is triggered.
+   * @param context {Object}
+   *      context for "this" when callback is called
+   */
+  _this.on = function (event, callback, context) {
+    if (
+      !(
+        callback ||
+        !callback.apply ||
+        (context && __is_string(callback) && context[callback].apply)
+      )
+    ) {
+      throw new Error('Callback parameter is not callable.');
+    }
+
+    if (!_listeners.hasOwnProperty(event)) {
+      // first listener for event type
+      _listeners[event] = [];
+    }
+
+    // add listener
+    _listeners[event].push({
+      callback: callback,
+      context: context,
+    });
+  };
+
+  /**
+   * Trigger an event
+   *
+   * @param event {String}
+   *      event name.
+   * @param args {…}
+   *      variable length arguments after event are passed to listeners.
+   */
+  _this.trigger = function (event) {
+    var args, i, len, listener, listeners;
+
+    if (_listeners.hasOwnProperty(event)) {
+      args = Array.prototype.slice.call(arguments, 1);
+      listeners = _listeners[event].slice(0);
+
+      for (i = 0, len = listeners.length; i < len; i++) {
+        listener = listeners[i];
+
+        // NOTE: if listener throws exception, this will stop...
+        if (__is_string(listener.callback)) {
+          listener.context[listener.callback].apply(listener.context, args);
+        } else {
+          listener.callback.apply(listener.context, args);
+        }
+      }
+    }
+  };
+
+  _initialize();
+  return _this;
+};
+
+// make Events a global event source
+__INSTANCE__ = Events();
+Events.on = function _events_on() {
+  return __INSTANCE__.on.apply(__INSTANCE__, arguments);
+};
+Events.off = function _events_off() {
+  return __INSTANCE__.off.apply(__INSTANCE__, arguments);
+};
+Events.trigger = function _events_trigger() {
+  return __INSTANCE__.trigger.apply(__INSTANCE__, arguments);
+};
+
+// intercept window.onhashchange events, or simulate if browser doesn't
+// support, and send to global Events object
+var _onHashChange = function (e) {
+  Events.trigger('hashchange', e);
+};
+
+// courtesy of:
+// http://stackoverflow.com/questions/9339865/get-the-hashchange-event-to-work-in-all-browsers-including-ie7
+if (!('onhashchange' in window)) {
+  var oldHref = document.location.hash;
+
+  setInterval(function () {
+    if (oldHref !== document.location.hash) {
+      oldHref = document.location.hash;
+      _onHashChange({
+        type: 'hashchange',
+        newURL: document.location.hash,
+        oldURL: oldHref,
+      });
+    }
+  }, 300);
+} else if (window.addEventListener) {
+  window.addEventListener('hashchange', _onHashChange, false);
+}
+
+module.exports = Events;
diff --git a/src/util/Util.js b/src/util/Util.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ea74d59ab5b4912cd27aa016e3679bad8d51edb
--- /dev/null
+++ b/src/util/Util.js
@@ -0,0 +1,73 @@
+'use strict';
+
+// static object with utility methods
+var Util = function () {};
+
+/**
+ * Merge properties from a series of objects.
+ *
+ * @param dst {Object}
+ *      target where merged properties are copied to.
+ * @param <variable> {Object}
+ *      source objects for properties. When a source is non null, it's
+ *      properties are copied to the dst object. Properties are copied in
+ *      the order of arguments: a property on a later argument overrides a
+ *      property on an earlier argument.
+ */
+Util.extend = function (dst) {
+  var i, len, src, prop;
+
+  // iterate over sources where properties are read
+  for (i = 1, len = arguments.length; i < len; i++) {
+    src = arguments[i];
+    if (src) {
+      for (prop in src) {
+        dst[prop] = src[prop];
+      }
+    }
+  }
+
+  // return updated object
+  return dst;
+};
+
+// remove an elements child nodes
+Util.empty = function (el) {
+  while (el.firstChild) {
+    el.removeChild(el.firstChild);
+  }
+};
+
+/**
+ * Creates a function that is a composition of other functions.
+ *
+ * For example:
+ *      a(b(c(x))) === compose(c, b, a)(x);
+ *
+ * Each function should accept as an argument, the result of the previous
+ * function call in the chain. It is allowable for all functions to have no
+ * return value as well.
+ *
+ * @param ... {Function} A variable set of functions to call, in order.
+ *
+ * @return {Function} The composition of the functions provided as arguments.
+ */
+Util.compose = function () {
+  var fns = arguments;
+
+  return function (result) {
+    var i, fn, len;
+
+    for (i = 0, len = fns.length; i < len; i++) {
+      fn = fns[i];
+
+      if (fn && fn.call) {
+        result = fn.call(this, result);
+      }
+    }
+
+    return result;
+  };
+};
+
+module.exports = Util;
diff --git a/src/util/index.d.ts b/src/util/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1fcea8e188be71f823e6fe74792840bdb2b5dbdd
--- /dev/null
+++ b/src/util/index.d.ts
@@ -0,0 +1 @@
+export * from '../../types/util';
diff --git a/src/util/index.js b/src/util/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..7a10fdd9cc42ff113d87287b55620ece59332732
--- /dev/null
+++ b/src/util/index.js
@@ -0,0 +1,4 @@
+module.exports = {
+  Events: require('./Events'),
+  Util: require('./Util'),
+};