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