Javascript  |  180行  |  5.16 KB

var DEFAULT_RESOLUTION = 5;
var OUTLIER_THRESHOLD = 0.10;
var MARGIN_SIZE = 0.10;

Heatmap = function(canvas, resolutionSlider, data, colors) {
  this.canvas = canvas.get(0);
  this.context = this.canvas.getContext('2d');
  this.scaleCanvas();

  this.colors = colors;
  this.resolution = this.h / DEFAULT_RESOLUTION;

  this.calculate(data);
  this.installInputHandlers(canvas, resolutionSlider);

  this.draw();
};

Heatmap.prototype.calculate = function(data) {
  this.cleanUpData(data);
  this.calculateBounds();
  this.calculateHeatmap();

  this.drawTraces = [];
  for (var i = 0; i < data.length; ++i)
    this.drawTraces.push(false);
};

Heatmap.prototype.cleanUpData = function(data) {
  // Data, indexed by revision and trace.
  this.traces = {};

  for (var trace = 0; trace < data.length; ++trace) {
    for (var t = 0; t < data[trace].data.length; ++t) {
      var revision = data[trace].data[t][0];
      var value = data[trace].data[t][1];
      if (revision > 1000000)
        continue;
      if (value == null)
        continue;

      if (!this.traces[revision])
        this.traces[revision] = {};
      this.traces[revision][trace] = value;
    }
  }

  this.revisions = Object.keys(this.traces).sort();
};

Heatmap.prototype.calculateBounds = function() {
  var values = [];
  for (var revision in this.traces)
    for (var trace in this.traces[revision])
      values.push(this.traces[revision][trace]);

  // Exclude OUTLIER_THRESHOLD% of the points.
  values.sort(function(a, b) {return a - b});
  this.min = percentile(values, OUTLIER_THRESHOLD / 2);
  this.max = percentile(values, -OUTLIER_THRESHOLD / 2);

  // Ease bounds by adding margins.
  var margin = (this.max - this.min) * MARGIN_SIZE * 2;
  this.min -= margin;
  this.max += margin;
};

Heatmap.prototype.calculateHeatmap = function() {
  this.data = {};
  for (var revision in this.traces) {
    for (var trace in this.traces[revision]) {
      var value = this.traces[revision][trace];
      y = Math.floor(mapRange(value, this.min, this.max, 0, this.resolution));
      y = constrain(y, 0, this.resolution - 1);

      if (this.data[revision] == null)
        this.data[revision] = {};
      if (this.data[revision][y] == null)
        this.data[revision][y] = [];
      this.data[revision][y].push(trace);
    }
  }
};

Heatmap.prototype.draw = function() {
  this.drawHeatmap();
  for (var i = 0; i < this.drawTraces.length; ++i)
    if (this.drawTraces[i])
      this.drawTrace(i, 1);
};

Heatmap.prototype.drawHeatmap = function() {
  this.context.clearRect(0, 0, this.w, this.h);

  this.context.save();
  this.context.scale(this.w / this.revisions.length, this.h / this.resolution);

  var counts = [];
  for (var t = 0; t < this.revisions.length; ++t) {
    var revision = this.revisions[t];
    for (var y in this.data[revision]) {
      counts.push(this.data[revision][y].length);
    }
  }
  counts.sort(function(a, b) {return a - b});
  var cutoff = percentile(counts, 0.9);
  if (cutoff < 2)
    cutoff = 2;

  for (var t = 0; t < this.revisions.length; ++t) {
    var revision = this.revisions[t];
    for (var y in this.data[revision]) {
      var count = this.data[revision][y].length;

      // Calculate average color across all traces in bucket.
      var r = 0, g = 0, b = 0;
      for (var i = 0; i < this.data[revision][y].length; ++i) {
        var trace = this.data[revision][y][i];
        r += this.colors[trace][0];
        g += this.colors[trace][1];
        b += this.colors[trace][2];
      }
      r /= count, g /= count, b /= count;
      var brightness = mapRange(count / cutoff, 0, 1, 2, 0);

      // Draw!
      this.context.fillStyle = calculateColor(r, g, b, 1, brightness);
      this.context.fillRect(t, y, 1, 1);
    }
  }

  this.context.restore();
};

Heatmap.prototype.drawTrace = function(trace, opacity) {
  this.drawTraceLine(trace, 4, 'rgba(255, 255, 255, ' + opacity + ')');
  var color = 'rgba(' + this.colors[trace][0] + ',' + this.colors[trace][1] + ',' + this.colors[trace][2] + ',' + opacity + ')';
  this.drawTraceLine(trace, 2, color);
};

Heatmap.prototype.drawTraceLine = function(trace, width, color) {
  var revisionWidth = this.w / this.revisions.length;

  this.context.save();
  this.context.lineJoin = 'round';
  this.context.lineWidth = width;
  this.context.strokeStyle = color;
  this.context.translate(revisionWidth / 2, 0);
  this.context.beginPath();

  var started = false;
  for (var t = 0; t < this.revisions.length; ++t) {
    var value = this.traces[this.revisions[t]][trace];
    if (value == null)
      continue;
    var y = mapRange(value, this.min, this.max, 0, this.h);
    if (started) {
      this.context.lineTo(revisionWidth * t, y);
    } else {
      this.context.moveTo(revisionWidth * t, y);
      started = true;
    }
  }

  this.context.stroke();
  this.context.restore();
}

Heatmap.prototype.scaleCanvas = function() {
  this.canvas.width = this.canvas.clientWidth * window.devicePixelRatio;
  this.canvas.height = this.canvas.clientHeight * window.devicePixelRatio;
  this.context.scale(window.devicePixelRatio, window.devicePixelRatio);

  this.w = this.canvas.clientWidth, this.h = this.canvas.clientHeight;

  // Flip canvas.
  this.context.scale(1, -1);
  this.context.translate(0, -this.h);
};