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);
};