You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mesos.apache.org by be...@apache.org on 2012/05/15 21:12:19 UTC

svn commit: r1338838 [9/11] - in /incubator/mesos/trunk/src: local/ master/ webui/master/static/ webui/master/static/bootstrap/ webui/master/static/bootstrap/css/ webui/master/static/bootstrap/ico/ webui/master/static/bootstrap/img/ webui/master/static...

Added: incubator/mesos/trunk/src/webui/master/static/controllers.js
URL: http://svn.apache.org/viewvc/incubator/mesos/trunk/src/webui/master/static/controllers.js?rev=1338838&view=auto
==============================================================================
--- incubator/mesos/trunk/src/webui/master/static/controllers.js (added)
+++ incubator/mesos/trunk/src/webui/master/static/controllers.js Tue May 15 19:12:15 2012
@@ -0,0 +1,240 @@
+'use strict';
+
+
+// Update the outermost scope with the new state.
+function update($scope, data) {
+  $scope.state = data;
+
+  $scope.total_cpus = 0;
+  $scope.total_mem = 0;
+  $scope.used_cpus = 0;
+  $scope.used_mem = 0;
+  $scope.offered_cpus = 0;
+  $scope.offered_mem = 0;
+
+  $scope.slaves = {};
+
+  _.each($scope.state.slaves, function(slave) {
+    $scope.total_cpus += slave.resources.cpus;
+    $scope.total_mem += slave.resources.mem;
+
+    $scope.slaves[slave.id] = slave;
+  });
+
+  $scope.frameworks = {};
+  $scope.offers = {};
+
+  _.each($scope.state.frameworks, function(framework) {
+      $scope.used_cpus += framework.resources.cpus;
+      $scope.used_mem += framework.resources.mem;
+
+      _.each(framework.offers, function(offer) {
+        $scope.offered_cpus = offer.resources.cpus;
+        $scope.offered_mem = offer.resources.mem;
+
+        $scope.offers[offer.id] = offer;
+      });
+
+      $scope.frameworks[framework.id] = framework;
+
+      if ($scope.total_cpus > 0) {
+        $scope.frameworks[framework.id].cpus_share =
+          framework.resources.cpus / $scope.total_cpus;
+      } else {
+        $scope.frameworks[framework.id].cpus_share = 0;
+      }
+
+      if ($scope.total_mem > 0) {
+        $scope.frameworks[framework.id].mem_share =
+          framework.resources.mem / $scope.total_mem;
+      } else {
+        $scope.frameworks[framework.id].mem_share = 0;
+      }
+
+      $scope.frameworks[framework.id].max_share =
+        Math.max($scope.frameworks[framework.id].cpus_share,
+                 $scope.frameworks[framework.id].mem_share);
+  });
+
+  $scope.used_cpus -= $scope.offered_cpus;
+  $scope.used_mem -= $scope.offered_mem;
+
+  $scope.idle_cpus = $scope.total_cpus - ($scope.offered_cpus + $scope.used_cpus);
+  $scope.idle_mem = $scope.total_mem - ($scope.offered_mem + $scope.used_mem);
+
+  $scope.completed_frameworks = {};
+
+  _.each($scope.state.completed_frameworks, function(framework) {
+      $scope.completed_frameworks[framework.id] = framework;
+  });
+
+  $.event.trigger('state_updated');
+}
+
+// Main controller that can be used to handle "global" events. E.g.,:
+//     $scope.$on('$afterRouteChange', function() { ...; });
+//
+// In addition, the MainCntl encapsulates the "view", allowing the
+// active controller/view to easily access anything in scope (e.g.,
+// the state).
+function MainCntl($scope, $http, $route, $routeParams, $location, $defer) {
+
+  $scope.$location = $location;
+  $scope.delay = 2000;
+  $scope.retry = 0;
+
+  var poll = function() {
+    $http.get('master/state.json')
+      .success(function(data) {
+        update($scope, data);
+        $scope.delay = 2000;
+        $defer(poll, $scope.delay);
+      })
+      .error(function(data) {
+        if ($scope.delay >= 32000) {
+          $scope.delay = 2000;
+        } else {
+          $scope.delay = $scope.delay * 2;
+        }
+        $scope.retry = $scope.delay;
+        function countdown() {
+          if ($scope.retry == 0) {
+            $('#error-modal').modal('hide');
+          } else {
+            $scope.retry = $scope.retry - 1000;
+            $scope.countdown = $defer(countdown, 1000);
+          }
+        }
+        countdown();
+        $('#error-modal').modal('show');
+      });
+  }
+
+  // Make it such that everytime we hide the error-modal, we stop the
+  // countdown and restart the polling.
+  $('#error-modal').on('hidden', function () {
+    if ($scope.countdown != undefined) {
+      if ($defer.cancel($scope.countdown)) {
+        $scope.delay = 2000; // Restart since they cancelled the countdown.
+      }
+    }
+
+    // Start polling again, but do it asynchronously (and wait at
+    // least a second because otherwise the error-modal won't get
+    // properly shown).
+    $defer(poll, 1000);
+  });
+
+  poll();
+}
+
+function HomeCtrl($scope) {
+  setNavbarActiveTab('home');
+}
+
+var offset = -1;
+var log = '';
+
+function LogCtrl($scope, $http, $defer) {
+  setNavbarActiveTab('log');
+
+  $('#log').html(log);
+
+  var deferred = undefined;
+
+  var tail = function() {
+    $http.get('master/log.json?offset=' + offset)
+      .success(function(data) {
+        // Get the last "page" of data if this was the first time.
+        if (offset == -1) {
+          // TODO(benh): Define a "page" size.
+          if (data.offset > 1000) {
+            offset = data.offset - 1000;
+          } else {
+            offset = 0;
+          }
+          deferred = $defer(tail, 0);
+          return;
+        } else {
+          offset = data.offset + data.length;
+        }
+
+        if (data.length > 0) {
+          // Truncate to the first newline if this is the first time
+          // (and we aren't reading from the beginning of the log).
+          if (log == '' && data.offset != 0) {
+            log = data.data.substring(data.data.indexOf("\n") + 1);
+            $('#log').append(log);
+          } else {
+            log += data.data;
+            $('#log').append(data.data);
+          }
+        }
+
+        deferred = $defer(tail, 1000);
+      })
+      .error(function(data, status) {
+        if (status == 404) {
+          $('#log-not-found-alert').show();
+        } else {
+          deferred = $defer(tail, 1000);
+        }
+      });
+  }
+
+  tail();
+
+  $scope.$on('$beforeRouteChange', function() { $defer.cancel(deferred); });
+}
+
+function DashboardCtrl($scope) {
+  setNavbarActiveTab('dashboard');
+  
+  var context = cubism.context()
+    .step(1000)
+    .size(1440);
+
+  // Create a "cpus" horizon.
+  horizons.create(context, "cpus", random(context, "cpus"), [0, 10], "cpus");
+
+  // Create a "mem" horizon.
+  horizons.create(context, "mem", random(context, "mem"), [0, 10], "mb");
+
+  // Do any cleanup before we change the route.
+  $scope.$on('$beforeRouteChange', function() { context.stop(); });
+}
+
+function FrameworksCtrl($scope) {
+  setNavbarActiveTab('frameworks');
+  
+}
+
+function FrameworkCtrl($scope, $routeParams) {
+  setNavbarActiveTab('frameworks');
+
+  var update = function() {
+    if ($routeParams.id in $scope.completed_frameworks) {
+      $scope.framework = $scope.completed_frameworks[$routeParams.id];
+      $('#terminated-alert').show();
+      $('#framework').show();
+    } else if ($routeParams.id in $scope.frameworks) {
+      $scope.framework = $scope.frameworks[$routeParams.id];
+      $('#framework').show();
+    } else {
+      $('#missing-alert').show();
+    }
+  }
+
+  if ($scope.state) {
+    update();
+  }
+
+  $(document).on('state_updated', update);
+  $scope.$on('$beforeRouteChange', function() {
+    $(document).off('state_updated', update);
+  });
+}
+
+function SlavesCtrl($scope) {
+  setNavbarActiveTab('slaves');
+}

Added: incubator/mesos/trunk/src/webui/master/static/cubism.v1.js
URL: http://svn.apache.org/viewvc/incubator/mesos/trunk/src/webui/master/static/cubism.v1.js?rev=1338838&view=auto
==============================================================================
--- incubator/mesos/trunk/src/webui/master/static/cubism.v1.js (added)
+++ incubator/mesos/trunk/src/webui/master/static/cubism.v1.js Tue May 15 19:12:15 2012
@@ -0,0 +1,975 @@
+(function(exports){
+var cubism = exports.cubism = {version: "1.0.0"};
+var cubism_id = 0;
+function cubism_identity(d) { return d; }
+cubism.option = function(name, value) {
+  var options = location.search.substring(1).split("&"),
+      i = -1,
+      n = options.length,
+      o;
+  while (++i < n) {
+    if ((o = options[i].split("="))[0] == name) {
+      return decodeURIComponent(o[1]);
+    }
+  }
+  return value;
+};
+cubism.context = function() {
+  var context = new cubism_context,
+      step = 1e4, // ten seconds, in milliseconds
+      size = 1440, // four hours at ten seconds, in pixels
+      start0, stop0, // the start and stop for the previous change event
+      start1, stop1, // the start and stop for the next prepare event
+      serverDelay = 5e3,
+      clientDelay = 5e3,
+      event = d3.dispatch("prepare", "beforechange", "change", "focus"),
+      scale = context.scale = d3.time.scale().range([0, size]),
+      timeout,
+      focus;
+
+  function update() {
+    var now = Date.now();
+    stop0 = new Date(Math.floor((now - serverDelay - clientDelay) / step) * step);
+    start0 = new Date(stop0 - size * step);
+    stop1 = new Date(Math.floor((now - serverDelay) / step) * step);
+    start1 = new Date(stop1 - size * step);
+    scale.domain([start0, stop0]);
+    return context;
+  }
+
+  context.start = function() {
+    if (timeout) clearTimeout(timeout);
+    var delay = +stop1 + serverDelay - Date.now();
+
+    // If we're too late for the first prepare event, skip it.
+    if (delay < clientDelay) delay += step;
+
+    timeout = setTimeout(function prepare() {
+      stop1 = new Date(Math.floor((Date.now() - serverDelay) / step) * step);
+      start1 = new Date(stop1 - size * step);
+      event.prepare.call(context, start1, stop1);
+
+      setTimeout(function() {
+        scale.domain([start0 = start1, stop0 = stop1]);
+        event.beforechange.call(context, start1, stop1);
+        event.change.call(context, start1, stop1);
+        event.focus.call(context, focus);
+      }, clientDelay);
+
+      timeout = setTimeout(prepare, step);
+    }, delay);
+    return context;
+  };
+
+  context.stop = function() {
+    timeout = clearTimeout(timeout);
+    return context;
+  };
+
+  timeout = setTimeout(context.start, 10);
+
+  // Set or get the step interval in milliseconds.
+  // Defaults to ten seconds.
+  context.step = function(_) {
+    if (!arguments.length) return step;
+    step = +_;
+    return update();
+  };
+
+  // Set or get the context size (the count of metric values).
+  // Defaults to 1440 (four hours at ten seconds).
+  context.size = function(_) {
+    if (!arguments.length) return size;
+    scale.range([0, size = +_]);
+    return update();
+  };
+
+  // The server delay is the amount of time we wait for the server to compute a
+  // metric. This delay may result from clock skew or from delays collecting
+  // metrics from various hosts. Defaults to 4 seconds.
+  context.serverDelay = function(_) {
+    if (!arguments.length) return serverDelay;
+    serverDelay = +_;
+    return update();
+  };
+
+  // The client delay is the amount of additional time we wait to fetch those
+  // metrics from the server. The client and server delay combined represent the
+  // age of the most recent displayed metric. Defaults to 1 second.
+  context.clientDelay = function(_) {
+    if (!arguments.length) return clientDelay;
+    clientDelay = +_;
+    return update();
+  };
+
+  // Sets the focus to the specified index, and dispatches a "focus" event.
+  context.focus = function(i) {
+    event.focus.call(context, focus = i);
+    return context;
+  };
+
+  // Add, remove or get listeners for events.
+  context.on = function(type, listener) {
+    if (arguments.length < 2) return event.on(type);
+
+    event.on(type, listener);
+
+    // Notify the listener of the current start and stop time, as appropriate.
+    // This way, metrics can make requests for data immediately,
+    // and likewise the axis can display itself synchronously.
+    if (listener != null) {
+      if (/^prepare(\.|$)/.test(type)) listener.call(context, start1, stop1);
+      if (/^beforechange(\.|$)/.test(type)) listener.call(context, start0, stop0);
+      if (/^change(\.|$)/.test(type)) listener.call(context, start0, stop0);
+      if (/^focus(\.|$)/.test(type)) listener.call(context, focus);
+    }
+
+    return context;
+  };
+
+  d3.select(window).on("keydown.context-" + ++cubism_id, function() {
+    switch (!d3.event.metaKey && d3.event.keyCode) {
+      case 37: // left
+        if (focus == null) focus = size - 1;
+        if (focus > 0) context.focus(--focus);
+        break;
+      case 39: // right
+        if (focus == null) focus = size - 2;
+        if (focus < size - 1) context.focus(++focus);
+        break;
+      default: return;
+    }
+    d3.event.preventDefault();
+  });
+
+  return update();
+};
+
+function cubism_context() {}
+
+var cubism_contextPrototype = cubism_context.prototype;
+
+cubism_contextPrototype.constant = function(value) {
+  return new cubism_metricConstant(this, +value);
+};
+cubism_contextPrototype.cube = function(host) {
+  if (!arguments.length) host = "";
+  var source = {},
+      context = this;
+
+  source.metric = function(expression) {
+    return context.metric(function(start, stop, step, callback) {
+      d3.json(host + "/1.0/metric"
+          + "?expression=" + encodeURIComponent(expression)
+          + "&start=" + cubism_cubeFormatDate(start)
+          + "&stop=" + cubism_cubeFormatDate(stop)
+          + "&step=" + step, function(data) {
+        if (!data) return callback(new Error("unable to load data"));
+        callback(null, data.map(function(d) { return d.value; }));
+      });
+    }, expression += "");
+  };
+
+  // Returns the Cube host.
+  source.toString = function() {
+    return host;
+  };
+
+  return source;
+};
+
+var cubism_cubeFormatDate = d3.time.format.iso;
+cubism_contextPrototype.graphite = function(host) {
+  if (!arguments.length) host = "";
+  var source = {},
+      context = this;
+
+  source.metric = function(expression) {
+    return context.metric(function(start, stop, step, callback) {
+      d3.text(host + "/render?format=raw"
+          + "&target=" + encodeURIComponent("alias(" + expression + ",'')")
+          + "&from=" + cubism_graphiteFormatDate(start - 2 * step) // off-by-two?
+          + "&until=" + cubism_graphiteFormatDate(stop - 1000), function(text) {
+        if (!text) return callback(new Error("unable to load data"));
+        callback(null, cubism_graphiteParse(text));
+      });
+    }, expression += "");
+  };
+
+  source.find = function(pattern, callback) {
+    d3.json(host + "/metrics/find?format=completer"
+        + "&query=" + encodeURIComponent(pattern), function(result) {
+      if (!result) return callback(new Error("unable to find metrics"));
+      callback(null, result.metrics.map(function(d) { return d.path; }));
+    });
+  };
+
+  // Returns the graphite host.
+  source.toString = function() {
+    return host;
+  };
+
+  return source;
+};
+
+// Graphite understands seconds since UNIX epoch.
+function cubism_graphiteFormatDate(time) {
+  return Math.floor(time / 1000);
+}
+
+// Helper method for parsing graphite's raw format.
+function cubism_graphiteParse(text) {
+  var i = text.indexOf("|"),
+      meta = text.substring(0, i),
+      c = meta.lastIndexOf(","),
+      b = meta.lastIndexOf(",", c - 1),
+      a = meta.lastIndexOf(",", b - 1),
+      start = meta.substring(a + 1, b) * 1000,
+      step = meta.substring(c + 1) * 1000;
+  return text
+      .substring(i + 1)
+      .split(",")
+      .slice(1) // the first value is always None?
+      .map(function(d) { return +d; });
+}
+function cubism_metric(context) {
+  if (!(context instanceof cubism_context)) throw new Error("invalid context");
+  this.context = context;
+}
+
+var cubism_metricPrototype = cubism_metric.prototype;
+
+cubism_metricPrototype.valueAt = function() {
+  return NaN;
+};
+
+cubism_metricPrototype.extent = function() {
+  var i = 0,
+      n = this.context.size(),
+      value,
+      min = Infinity,
+      max = -Infinity;
+  while (++i < n) {
+    value = this.valueAt(i);
+    if (value < min) min = value;
+    if (value > max) max = value;
+  }
+  return [min, max];
+};
+
+cubism_metricPrototype.on = function(type, listener) {
+  return arguments.length < 2 ? null : this;
+};
+
+cubism_metricPrototype.shift = function() {
+  return this;
+};
+
+cubism_metricPrototype.on = function() {
+  return arguments.length < 2 ? null : this;
+};
+
+cubism_contextPrototype.metric = function(request, name) {
+  var context = this,
+      metric = new cubism_metric(context),
+      id = ".metric-" + ++cubism_id,
+      start = -Infinity,
+      stop,
+      step = context.step(),
+      size = context.size(),
+      values = [],
+      event = d3.dispatch("change"),
+      listening = 0,
+      fetching;
+
+  // Prefetch new data into a temporary array.
+  function prepare(start1, stop) {
+    var steps = Math.min(size, Math.round((start1 - start) / step));
+    if (!steps || fetching) return; // already fetched, or fetching!
+    fetching = true;
+    steps = Math.min(size, steps + cubism_metricOverlap);
+    var start0 = new Date(stop - steps * step);
+    request(start0, stop, step, function(error, data) {
+      fetching = false;
+      if (error) return console.warn(error);
+      var i = isFinite(start) ? Math.round((start0 - start) / step) : 0;
+      for (var j = 0, m = data.length; j < m; ++j) values[j + i] = data[j];
+      event.change.call(metric, start, stop);
+    });
+  }
+
+  // When the context changes, switch to the new data, ready-or-not!
+  function beforechange(start1, stop1) {
+    if (!isFinite(start)) start = start1;
+    values.splice(0, Math.max(0, Math.min(size, Math.round((start1 - start) / step))));
+    start = start1;
+    stop = stop1;
+  }
+
+  //
+  metric.valueAt = function(i) {
+    return values[i];
+  };
+
+  //
+  metric.shift = function(offset) {
+    return context.metric(cubism_metricShift(request, +offset));
+  };
+
+  //
+  metric.on = function(type, listener) {
+    if (!arguments.length) return event.on(type);
+
+    // If there are no listeners, then stop listening to the context,
+    // and avoid unnecessary fetches.
+    if (listener == null) {
+      if (event.on(type) != null && --listening == 0) {
+        context.on("prepare" + id, null).on("beforechange" + id, null);
+      }
+    } else {
+      if (event.on(type) == null && ++listening == 1) {
+        context.on("prepare" + id, prepare).on("beforechange" + id, beforechange);
+      }
+    }
+
+    event.on(type, listener);
+
+    // Notify the listener of the current start and stop time, as appropriate.
+    // This way, charts can display synchronous metrics immediately.
+    if (listener != null) {
+      if (/^change(\.|$)/.test(type)) listener.call(context, start, stop);
+    }
+
+    return metric;
+  };
+
+  //
+  if (arguments.length > 1) metric.toString = function() {
+    return name;
+  };
+
+  return metric;
+};
+
+// Number of metric to refetch each period, in case of lag.
+var cubism_metricOverlap = 6;
+
+// Wraps the specified request implementation, and shifts time by the given offset.
+function cubism_metricShift(request, offset) {
+  return function(start, stop, step, callback) {
+    request(new Date(+start + offset), new Date(+stop + offset), step, callback);
+  };
+}
+function cubism_metricConstant(context, value) {
+  cubism_metric.call(this, context);
+  value = +value;
+  var name = value + "";
+  this.valueOf = function() { return value; };
+  this.toString = function() { return name; };
+}
+
+var cubism_metricConstantPrototype = cubism_metricConstant.prototype = Object.create(cubism_metric.prototype);
+
+cubism_metricConstantPrototype.valueAt = function() {
+  return +this;
+};
+
+cubism_metricConstantPrototype.extent = function() {
+  return [+this, +this];
+};
+function cubism_metricOperator(name, operate) {
+
+  function cubism_metricOperator(left, right) {
+    if (!(right instanceof cubism_metric)) right = new cubism_metricConstant(left.context, right);
+    else if (left.context !== right.context) throw new Error("mismatch context");
+    cubism_metric.call(this, left.context);
+    this.left = left;
+    this.right = right;
+    this.toString = function() { return left + " " + name + " " + right; };
+  }
+
+  var cubism_metricOperatorPrototype = cubism_metricOperator.prototype = Object.create(cubism_metric.prototype);
+
+  cubism_metricOperatorPrototype.valueAt = function(i) {
+    return operate(this.left.valueAt(i), this.right.valueAt(i));
+  };
+
+  cubism_metricOperatorPrototype.shift = function(offset) {
+    return new cubism_metricOperator(this.left.shift(offset), this.right.shift(offset));
+  };
+
+  cubism_metricOperatorPrototype.on = function(type, listener) {
+    if (arguments.length < 2) return this.left.on(type);
+    this.left.on(type, listener);
+    this.right.on(type, listener);
+    return this;
+  };
+
+  return function(right) {
+    return new cubism_metricOperator(this, right);
+  };
+}
+
+cubism_metricPrototype.add = cubism_metricOperator("+", function(left, right) {
+  return left + right;
+});
+
+cubism_metricPrototype.subtract = cubism_metricOperator("-", function(left, right) {
+  return left - right;
+});
+
+cubism_metricPrototype.multiply = cubism_metricOperator("*", function(left, right) {
+  return left * right;
+});
+
+cubism_metricPrototype.divide = cubism_metricOperator("/", function(left, right) {
+  return left / right;
+});
+cubism_contextPrototype.horizon = function() {
+  var context = this,
+      mode = "offset",
+      buffer = document.createElement("canvas"),
+      width = buffer.width = context.size(),
+      height = buffer.height = 30,
+      scale = d3.scale.linear().interpolate(d3.interpolateRound),
+      metric = cubism_identity,
+      extent = null,
+      title = cubism_identity,
+      format = d3.format(".2s"),
+      colors = ["#08519c","#3182bd","#6baed6","#bdd7e7","#bae4b3","#74c476","#31a354","#006d2c"];
+
+  function horizon(selection) {
+
+    selection
+        .on("mousemove.horizon", function() { context.focus(d3.mouse(this)[0]); })
+        .on("mouseout.horizon", function() { context.focus(null); });
+
+    selection.append("canvas")
+        .attr("width", width)
+        .attr("height", height);
+
+    selection.append("span")
+        .attr("class", "title")
+        .text(title);
+
+    selection.append("span")
+        .attr("class", "value");
+
+    selection.each(function(d, i) {
+      var that = this,
+          id = ++cubism_id,
+          metric_ = typeof metric === "function" ? metric.call(that, d, i) : metric,
+          colors_ = typeof colors === "function" ? colors.call(that, d, i) : colors,
+          extent_ = typeof extent === "function" ? extent.call(that, d, i) : extent,
+          start = -Infinity,
+          step = context.step(),
+          canvas = d3.select(that).select("canvas"),
+          span = d3.select(that).select(".value"),
+          max_,
+          m = colors_.length >> 1,
+          ready;
+
+      canvas.datum({id: id, metric: metric_});
+      canvas = canvas.node().getContext("2d");
+
+      function change(start1, stop) {
+        canvas.save();
+
+        // compute the new extent and ready flag
+        var extent = metric_.extent();
+        ready = extent.every(isFinite);
+        if (extent_ != null) extent = extent_;
+
+        // if this is an update (with no extent change), copy old values!
+        var i0 = 0, max = Math.max(-extent[0], extent[1]);
+        if (this === context) {
+          if (max == max_) {
+            i0 = width - cubism_metricOverlap;
+            var dx = (start1 - start) / step;
+            if (dx < width) {
+              var canvas0 = buffer.getContext("2d");
+              canvas0.clearRect(0, 0, width, height);
+              canvas0.drawImage(canvas.canvas, dx, 0, width - dx, height, 0, 0, width - dx, height);
+              canvas.clearRect(0, 0, width, height);
+              canvas.drawImage(canvas0.canvas, 0, 0);
+            }
+          }
+          start = start1;
+        }
+
+        // update the domain
+        scale.domain([0, max_ = max]);
+
+        // clear for the new data
+        canvas.clearRect(i0, 0, width - i0, height);
+
+        // record whether there are negative values to display
+        var negative;
+
+        // positive bands
+        for (var j = 0; j < m; ++j) {
+          canvas.fillStyle = colors_[m + j];
+
+          // Adjust the range based on the current band index.
+          var y0 = (j - m + 1) * height;
+          scale.range([m * height + y0, y0]);
+          y0 = scale(0);
+
+          for (var i = i0, n = width, y1; i < n; ++i) {
+            y1 = metric_.valueAt(i);
+            if (y1 <= 0) { negative = true; continue; }
+            canvas.fillRect(i, y1 = scale(y1), 1, y0 - y1);
+          }
+        }
+
+        if (negative) {
+          // enable offset mode
+          if (mode === "offset") {
+            canvas.translate(0, height);
+            canvas.scale(1, -1);
+          }
+
+          // negative bands
+          for (var j = 0; j < m; ++j) {
+            canvas.fillStyle = colors_[m - 1 - j];
+
+            // Adjust the range based on the current band index.
+            var y0 = (j - m + 1) * height;
+            scale.range([m * height + y0, y0]);
+            y0 = scale(0);
+
+            for (var i = i0, n = width, y1; i < n; ++i) {
+              y1 = metric_.valueAt(i);
+              if (y1 >= 0) continue;
+              canvas.fillRect(i, scale(-y1), 1, y0 - scale(-y1));
+            }
+          }
+        }
+
+        canvas.restore();
+      }
+
+      function focus(i) {
+        if (i == null) i = width - 1;
+        var value = metric_.valueAt(i);
+        span.datum(value).text(isNaN(value) ? null : format);
+      }
+
+      // Update the chart when the context changes.
+      context.on("change.horizon-" + id, change);
+      context.on("focus.horizon-" + id, focus);
+
+      // Display the first metric change immediately,
+      // but defer subsequent updates to the canvas change.
+      // Note that someone still needs to listen to the metric,
+      // so that it continues to update automatically.
+      metric_.on("change.horizon-" + id, function(start, stop) {
+        change(start, stop), focus();
+        if (ready) metric_.on("change.horizon-" + id, cubism_identity);
+      });
+    });
+  }
+
+  horizon.remove = function(selection) {
+
+    selection
+        .on("mousemove.horizon", null)
+        .on("mouseout.horizon", null);
+
+    selection.selectAll("canvas")
+        .each(remove)
+        .remove();
+
+    selection.selectAll(".title,.value")
+        .remove();
+
+    function remove(d) {
+      d.metric.on("change.horizon-" + d.id, null);
+      context.on("change.horizon-" + d.id, null);
+      context.on("focus.horizon-" + d.id, null);
+    }
+  };
+
+  horizon.mode = function(_) {
+    if (!arguments.length) return mode;
+    mode = _ + "";
+    return horizon;
+  };
+
+  horizon.height = function(_) {
+    if (!arguments.length) return height;
+    buffer.height = height = +_;
+    return horizon;
+  };
+
+  horizon.metric = function(_) {
+    if (!arguments.length) return metric;
+    metric = _;
+    return horizon;
+  };
+
+  horizon.scale = function(_) {
+    if (!arguments.length) return scale;
+    scale = _;
+    return horizon;
+  };
+
+  horizon.extent = function(_) {
+    if (!arguments.length) return extent;
+    extent = _;
+    return horizon;
+  };
+
+  horizon.title = function(_) {
+    if (!arguments.length) return title;
+    title = _;
+    return horizon;
+  };
+
+  horizon.format = function(_) {
+    if (!arguments.length) return format;
+    format = _;
+    return horizon;
+  };
+
+  horizon.colors = function(_) {
+    if (!arguments.length) return colors;
+    colors = _;
+    return horizon;
+  };
+
+  return horizon;
+};
+cubism_contextPrototype.comparison = function() {
+  var context = this,
+      width = context.size(),
+      height = 120,
+      scale = d3.scale.linear().interpolate(d3.interpolateRound),
+      primary = function(d) { return d[0]; },
+      secondary = function(d) { return d[1]; },
+      extent = null,
+      title = cubism_identity,
+      formatPrimary = cubism_comparisonPrimaryFormat,
+      formatChange = cubism_comparisonChangeFormat,
+      colors = ["#9ecae1", "#225b84", "#a1d99b", "#22723a"],
+      strokeWidth = 1.5;
+
+  function comparison(selection) {
+
+    selection
+        .on("mousemove.comparison", function() { context.focus(d3.mouse(this)[0]); })
+        .on("mouseout.comparison", function() { context.focus(null); });
+
+    selection.append("canvas")
+        .attr("width", width)
+        .attr("height", height);
+
+    selection.append("span")
+        .attr("class", "title")
+        .text(title);
+
+    selection.append("span")
+        .attr("class", "value primary");
+
+    selection.append("span")
+        .attr("class", "value change");
+
+    selection.each(function(d, i) {
+      var that = this,
+          id = ++cubism_id,
+          primary_ = typeof primary === "function" ? primary.call(that, d, i) : primary,
+          secondary_ = typeof secondary === "function" ? secondary.call(that, d, i) : secondary,
+          extent_ = typeof extent === "function" ? extent.call(that, d, i) : extent,
+          div = d3.select(that),
+          canvas = div.select("canvas"),
+          spanPrimary = div.select(".value.primary"),
+          spanChange = div.select(".value.change"),
+          ready;
+
+      canvas.datum({id: id, primary: primary_, secondary: secondary_});
+      canvas = canvas.node().getContext("2d");
+
+      function change(start, stop) {
+        canvas.save();
+        canvas.clearRect(0, 0, width, height);
+
+        // update the scale
+        var primaryExtent = primary_.extent(),
+            secondaryExtent = secondary_.extent(),
+            extent = extent_ == null ? primaryExtent : extent_;
+        scale.domain(extent).range([height, 0]);
+        ready = primaryExtent.concat(secondaryExtent).every(isFinite);
+
+        // consistent overplotting
+        var round = start / context.step() & 1
+            ? cubism_comparisonRoundOdd
+            : cubism_comparisonRoundEven;
+
+        // positive changes
+        canvas.fillStyle = colors[2];
+        for (var i = 0, n = width; i < n; ++i) {
+          var y0 = scale(primary_.valueAt(i)),
+              y1 = scale(secondary_.valueAt(i));
+          if (y0 < y1) canvas.fillRect(round(i), y0, 1, y1 - y0);
+        }
+
+        // negative changes
+        canvas.fillStyle = colors[0];
+        for (i = 0; i < n; ++i) {
+          var y0 = scale(primary_.valueAt(i)),
+              y1 = scale(secondary_.valueAt(i));
+          if (y0 > y1) canvas.fillRect(round(i), y1, 1, y0 - y1);
+        }
+
+        // positive values
+        canvas.fillStyle = colors[3];
+        for (i = 0; i < n; ++i) {
+          var y0 = scale(primary_.valueAt(i)),
+              y1 = scale(secondary_.valueAt(i));
+          if (y0 <= y1) canvas.fillRect(round(i), y0, 1, strokeWidth);
+        }
+
+        // negative values
+        canvas.fillStyle = colors[1];
+        for (i = 0; i < n; ++i) {
+          var y0 = scale(primary_.valueAt(i)),
+              y1 = scale(secondary_.valueAt(i));
+          if (y0 > y1) canvas.fillRect(round(i), y0 - strokeWidth, 1, strokeWidth);
+        }
+
+        canvas.restore();
+      }
+
+      function focus(i) {
+        if (i == null) i = width - 1;
+        var valuePrimary = primary_.valueAt(i),
+            valueSecondary = secondary_.valueAt(i),
+            valueChange = (valuePrimary - valueSecondary) / valueSecondary;
+
+        spanPrimary
+            .datum(valuePrimary)
+            .text(isNaN(valuePrimary) ? null : formatPrimary);
+
+        spanChange
+            .datum(valueChange)
+            .text(isNaN(valueChange) ? null : formatChange)
+            .attr("class", "value change " + (valueChange > 0 ? "positive" : valueChange < 0 ? "negative" : ""));
+      }
+
+      // Display the first primary change immediately,
+      // but defer subsequent updates to the context change.
+      // Note that someone still needs to listen to the metric,
+      // so that it continues to update automatically.
+      primary_.on("change.comparison-" + id, firstChange);
+      secondary_.on("change.comparison-" + id, firstChange);
+      function firstChange(start, stop) {
+        change(start, stop), focus();
+        if (ready) {
+          primary_.on("change.comparison-" + id, cubism_identity);
+          secondary_.on("change.comparison-" + id, cubism_identity);
+        }
+      }
+
+      // Update the chart when the context changes.
+      context.on("change.comparison-" + id, change);
+      context.on("focus.comparison-" + id, focus);
+    });
+  }
+
+  comparison.remove = function(selection) {
+
+    selection
+        .on("mousemove.comparison", null)
+        .on("mouseout.comparison", null);
+
+    selection.selectAll("canvas")
+        .each(remove)
+        .remove();
+
+    selection.selectAll(".title,.value")
+        .remove();
+
+    function remove(d) {
+      d.primary.on("change.comparison-" + d.id, null);
+      d.secondary.on("change.comparison-" + d.id, null);
+      context.on("change.comparison-" + d.id, null);
+      context.on("focus.comparison-" + d.id, null);
+    }
+  };
+
+  comparison.height = function(_) {
+    if (!arguments.length) return height;
+    height = +_;
+    return comparison;
+  };
+
+  comparison.primary = function(_) {
+    if (!arguments.length) return primary;
+    primary = _;
+    return comparison;
+  };
+
+  comparison.secondary = function(_) {
+    if (!arguments.length) return secondary;
+    secondary = _;
+    return comparison;
+  };
+
+  comparison.scale = function(_) {
+    if (!arguments.length) return scale;
+    scale = _;
+    return comparison;
+  };
+
+  comparison.extent = function(_) {
+    if (!arguments.length) return extent;
+    extent = _;
+    return comparison;
+  };
+
+  comparison.title = function(_) {
+    if (!arguments.length) return title;
+    title = _;
+    return comparison;
+  };
+
+  comparison.formatPrimary = function(_) {
+    if (!arguments.length) return formatPrimary;
+    formatPrimary = _;
+    return comparison;
+  };
+
+  comparison.formatChange = function(_) {
+    if (!arguments.length) return formatChange;
+    formatChange = _;
+    return comparison;
+  };
+
+  comparison.colors = function(_) {
+    if (!arguments.length) return colors;
+    colors = _;
+    return comparison;
+  };
+
+  comparison.strokeWidth = function(_) {
+    if (!arguments.length) return strokeWidth;
+    strokeWidth = _;
+    return comparison;
+  };
+
+  return comparison;
+};
+
+var cubism_comparisonPrimaryFormat = d3.format(".2s"),
+    cubism_comparisonChangeFormat = d3.format("+.0%");
+
+function cubism_comparisonRoundEven(i) {
+  return i & 0xfffffe;
+}
+
+function cubism_comparisonRoundOdd(i) {
+  return ((i + 1) & 0xfffffe) - 1;
+}
+cubism_contextPrototype.axis = function() {
+  var context = this,
+      scale = context.scale,
+      axis_ = d3.svg.axis().scale(scale),
+      format = context.step() < 6e4 ? cubism_axisFormatSeconds : cubism_axisFormatMinutes;
+
+  function axis(selection) {
+    var id = ++cubism_id,
+        tick;
+
+    var g = selection.append("svg")
+        .datum({id: id})
+        .attr("width", context.size())
+        .attr("height", Math.max(28, -axis.tickSize()))
+      .append("g")
+        .attr("transform", "translate(0," + (axis_.orient() === "top" ? 27 : 4) + ")")
+        .call(axis_);
+
+    context.on("change.axis-" + id, function() {
+      g.call(axis_);
+      if (!tick) tick = cloneTick();
+    });
+
+    context.on("focus.axis-" + id, function(i) {
+      if (tick) {
+        if (i == null) {
+          tick.style("display", "none");
+          g.selectAll("text").style("fill-opacity", null);
+        } else {
+          tick.style("display", null).attr("x", i).text(format(scale.invert(i)));
+          var dx = tick.node().getComputedTextLength() + 6;
+          g.selectAll("text").style("fill-opacity", function(d) { return Math.abs(scale(d) - i) < dx ? 0 : 1; });
+        }
+      }
+    });
+
+    function cloneTick() {
+      return g.select(function() { return this.appendChild(g.select("text").node().cloneNode(true)); })
+          .style("display", "none")
+          .text(null);
+    }
+  }
+
+  axis.remove = function(selection) {
+
+    selection.selectAll("svg")
+        .each(remove)
+        .remove();
+
+    function remove(d) {
+      context.on("change.axis-" + d.id, null);
+      context.on("focus.axis-" + d.id, null);
+    }
+  };
+
+  return d3.rebind(axis, axis_,
+      "orient",
+      "ticks",
+      "tickSubdivide",
+      "tickSize",
+      "tickPadding",
+      "tickFormat");
+};
+
+var cubism_axisFormatSeconds = d3.time.format("%I:%M:%S %p"),
+    cubism_axisFormatMinutes = d3.time.format("%I:%M %p");
+cubism_contextPrototype.rule = function() {
+  var context = this;
+
+  function rule(selection) {
+    var id = ++cubism_id;
+
+    var line = selection.append("div")
+        .datum({id: id})
+        .attr("class", "line")
+        .style("position", "fixed")
+        .style("top", 0)
+        .style("right", 0)
+        .style("bottom", 0)
+        .style("width", "1px")
+        .style("pointer-events", "none");
+
+    context.on("focus.rule-" + id, function(i) {
+      line
+          .style("display", i == null ? "none" : null)
+          .style("left", function() { return this.parentNode.getBoundingClientRect().left + i + "px"; });
+    });
+  }
+
+  rule.remove = function(selection) {
+
+    selection.selectAll(".line")
+        .each(remove)
+        .remove();
+
+    function remove(d) {
+      context.on("focus.rule-" + d.id, null);
+    }
+  };
+
+  return rule;
+};
+})(this);