lib/stackprof/flamegraph/flamegraph.js in stackprof-0.2.10 vs lib/stackprof/flamegraph/flamegraph.js in stackprof-0.2.11

- old
+ new

@@ -1,357 +1,983 @@ -var guessGem = function(frame) { - var split = frame.split('/gems/'); - if(split.length == 1) { - split = frame.split('/app/'); - if(split.length == 1) { - split = frame.split('/lib/'); - } else { - return split[split.length-1].split('/')[0] +if (typeof Element.prototype.matches !== 'function') { + Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.webkitMatchesSelector || function matches(selector) { + var element = this + var elements = (element.document || element.ownerDocument).querySelectorAll(selector) + var index = 0 + + while (elements[index] && elements[index] !== element) { + ++index } - split = split[Math.max(split.length-2,0)].split('/'); - return split[split.length-1].split(':')[0]; + return Boolean(elements[index]) } - else - { - return split[split.length -1].split('/')[0].split('-', 2)[0]; - } } -var color = function() { - var r = parseInt(205 + Math.random() * 50); - var g = parseInt(Math.random() * 230); - var b = parseInt(Math.random() * 55); - return "rgb(" + r + "," + g + "," + b + ")"; +if (typeof Element.prototype.closest !== 'function') { + Element.prototype.closest = function closest(selector) { + var element = this + + while (element && element.nodeType === 1) { + if (element.matches(selector)) { + return element + } + + element = element.parentNode + } + + return null + } } -// http://stackoverflow.com/a/7419630 -var rainbow = function(numOfSteps, step) { - // This function generates vibrant, "evenly spaced" colours (i.e. no clustering). This is ideal for creating easily distiguishable vibrant markers in Google Maps and other apps. - // Adam Cole, 2011-Sept-14 - // HSV to RBG adapted from: http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript - var r, g, b; - var h = step / numOfSteps; - var i = ~~(h * 6); - var f = h * 6 - i; - var q = 1 - f; - switch(i % 6){ - case 0: r = 1, g = f, b = 0; break; - case 1: r = q, g = 1, b = 0; break; - case 2: r = 0, g = 1, b = f; break; - case 3: r = 0, g = q, b = 1; break; - case 4: r = f, g = 0, b = 1; break; - case 5: r = 1, g = 0, b = q; break; +if (typeof Object.assign !== 'function') { + (function() { + Object.assign = function(target) { + 'use strict' + // We must check against these specific cases. + if (target === undefined || target === null) { + throw new TypeError('Cannot convert undefined or null to object') + } + + var output = Object(target) + for (var index = 1; index < arguments.length; index++) { + var source = arguments[index] + if (source !== undefined && source !== null) { + for (var nextKey in source) { + if (source.hasOwnProperty(nextKey)) { + output[nextKey] = source[nextKey] + } + } + } + } + return output } - var c = "#" + ("00" + (~ ~(r * 255)).toString(16)).slice(-2) + ("00" + (~ ~(g * 255)).toString(16)).slice(-2) + ("00" + (~ ~(b * 255)).toString(16)).slice(-2); - return (c); + })() } -// http://stackoverflow.com/questions/1960473/unique-values-in-an-array -var getUnique = function(orig) { - var o = {}, a = [] - for (var i = 0; i < orig.length; i++) o[orig[i]] = 1 - for (var e in o) a.push(e) - return a +function EventSource() { + var self = this + + self.eventListeners = {} } -function flamegraph(data) { - var maxX = 0; - var maxY = 0; - var minY = 10000; - $.each(data, function(){ - maxX = Math.max(maxX, this.x + this.width); - maxY = Math.max(maxY, this.y); - minY = Math.min(minY, this.y); - }); +EventSource.prototype.on = function(name, callback) { + var self = this - // normalize Y - if (minY > 0) { - $.each(data, function(){ - this.y -= minY - }) - maxY -= minY - minY = 0 - } + var listeners = self.eventListeners[name] + if (!listeners) + listeners = self.eventListeners[name] = [] + listeners.push(callback) +} - var margin = {top: 10, right: 10, bottom: 10, left: 10} - var width = $(window).width() - 200 - margin.left - margin.right; - var height = $(window).height() * 0.70 - margin.top - margin.bottom; - var height2 = $(window).height() * 0.30 - 60 - margin.top - margin.bottom; +EventSource.prototype.dispatch = function(name, data) { + var self = this - $('.flamegraph').width(width + margin.left + margin.right).height(height + margin.top + margin.bottom); - $('.zoom').width(width + margin.left + margin.right).height(height2 + margin.top + margin.bottom); + var listeners = self.eventListeners[name] || [] + listeners.forEach(function(c) { + requestAnimationFrame(function() { c(data) }) + }) +} - var xScale = d3.scale.linear() - .domain([0, maxX]) - .range([0, width]); +function CanvasView(canvas) { + var self = this - var xScale2 = d3.scale.linear() - .domain([0, maxX]) - .range([0, width]) + self.canvas = canvas +} - var yScale = d3.scale.linear() - .domain([0, maxY]) - .range([0,height]); +CanvasView.prototype.setDimensions = function(width, height) { + var self = this - var yScale2 = d3.scale.linear() - .domain([0, maxY]) - .range([0,height2]); + if (self.resizeRequestID) + cancelAnimationFrame(self.resizeRequestID) - var zoomXRatio = 1 - var zoomed = function() { - svg.attr("transform", "translate(" + d3.event.translate + ")" + " scale(" + (zoomXRatio*d3.event.scale) + "," + d3.event.scale + ")"); + self.resizeRequestID = requestAnimationFrame(self.setDimensionsNow.bind(self, width, height)) +} - var x = xScale.domain(), y = yScale.domain() - brush.extent([ [x[0]/zoomXRatio, y[0]], [x[1]/zoomXRatio, y[1]] ]) - if (x[1] == maxX && y[1] == maxY) - brush.clear() - svg2.select('g.brush').call(brush) +CanvasView.prototype.setDimensionsNow = function(width, height) { + var self = this + + if (width === self.width && height === self.height) + return + + self.width = width + self.height = height + + self.canvas.style.width = width + self.canvas.style.height = height + + var ratio = window.devicePixelRatio || 1 + self.canvas.width = width * ratio + self.canvas.height = height * ratio + + var ctx = self.canvas.getContext('2d') + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.scale(ratio, ratio) + + self.repaintNow() +} + +CanvasView.prototype.paint = function() { +} + +CanvasView.prototype.scheduleRepaint = function() { + var self = this + + if (self.repaintRequestID) + return + + self.repaintRequestID = requestAnimationFrame(function() { + self.repaintRequestID = null + self.repaintNow() + }) +} + +CanvasView.prototype.repaintNow = function() { + var self = this + + self.canvas.getContext('2d').clearRect(0, 0, self.width, self.height) + self.paint() + + if (self.repaintRequestID) { + cancelAnimationFrame(self.repaintRequestID) + self.repaintRequestID = null } +} - var zoom = d3.behavior.zoom().x(xScale).y(yScale).scaleExtent([1, 14]).on('zoom', zoomed) +function Flamechart(canvas, data, dataRange, info) { + var self = this - var svg2 = d3.select('.zoom').append('svg').attr('width', '100%').attr('height', '100%').append('svg:g') - .attr("transform", "translate(" + margin.left + "," + margin.top + ")") - .append('g').attr('class', 'graph') + CanvasView.call(self, canvas) + EventSource.call(self) - var svg = d3.select(".flamegraph") - .append("svg") - .attr("width", "100%") - .attr("height", "100%") - .attr("pointer-events", "all") - .append('svg:g') - .attr("transform", "translate(" + margin.left + "," + margin.top + ")") - .call(zoom) - .append('svg:g').attr('class', 'graph'); + self.canvas = canvas + self.data = data + self.dataRange = dataRange + self.info = info - // so zoom works everywhere - svg.append("rect") - .attr("x",function(d) { return xScale(0); }) - .attr("y",function(d) { return yScale(0);}) - .attr("width", function(d){return xScale(maxX);}) - .attr("height", yScale(maxY)) - .attr("fill", "white"); + self.viewport = { + x: dataRange.minX, + y: dataRange.minY, + width: dataRange.maxX - dataRange.minX, + height: dataRange.maxY - dataRange.minY, + } +} - var samplePercentRaw = function(samples, exclusive) { - var ret = [samples, ((samples / maxX) * 100).toFixed(2)] - if (exclusive) - ret = ret.concat([exclusive, ((exclusive / maxX) * 100).toFixed(2)]) - return ret; +Flamechart.prototype = Object.create(CanvasView.prototype) +Flamechart.prototype.constructor = Flamechart +Object.assign(Flamechart.prototype, EventSource.prototype) + +Flamechart.prototype.xScale = function(x) { + var self = this + return self.widthScale(x - self.viewport.x) +} + +Flamechart.prototype.yScale = function(y) { + var self = this + return self.heightScale(y - self.viewport.y) +} + +Flamechart.prototype.widthScale = function(width) { + var self = this + return width * self.width / self.viewport.width +} + +Flamechart.prototype.heightScale = function(height) { + var self = this + return height * self.height / self.viewport.height +} + +Flamechart.prototype.frameRect = function(f) { + return { + x: f.x, + y: f.y, + width: f.width, + height: 1, } +} - var samplePercent = function(samples, exclusive) { - var info = samplePercentRaw(samples, exclusive) - var samplesPct = info[1], exclusivePct = info[3] - var ret = " (" + samples + " sample" + (samples == 1 ? "" : "s") + " - " + samplesPct + "%) "; - if (exclusive) - ret += " (" + exclusive + " exclusive - " + exclusivePct + "%) "; - return ret; +Flamechart.prototype.dataToCanvas = function(r) { + var self = this + + return { + x: self.xScale(r.x), + y: self.yScale(r.y), + width: self.widthScale(r.width), + height: self.heightScale(r.height), } +} - var info = {}; +Flamechart.prototype.setViewport = function(viewport) { + var self = this - var mouseover = function(d) { - var i = info[d.frame_id]; - var shortFile = d.file.replace(/^.+\/(gems|app|lib|config|jobs)/, '$1') - var data = samplePercentRaw(i.samples.length, d.topFrame ? d.topFrame.exclusiveCount : 0) + if (self.viewport.x === viewport.x && + self.viewport.y === viewport.y && + self.viewport.width === viewport.width && + self.viewport.height === viewport.height) + return - $('.info') - .css('background-color', i.color) - .find('.frame').text(d.frame).end() - .find('.file').text(shortFile).end() - .find('.samples').text(data[0] + ' samples ('+data[1]+'%)').end() - .find('.exclusive').text('') + self.viewport = viewport - if (data[3]) - $('.info .exclusive').text(data[2] + ' exclusive ('+data[3]+'%)') + self.scheduleRepaint() - d3.selectAll(i.nodes) - .attr('opacity',0.5); - }; + self.dispatch('viewportchanged', { current: viewport }) +} - var mouseout = function(d) { - var i = info[d.frame_id]; - $('.info').css('background-color', 'none').find('.frame, .file, .samples, .exclusive').text('') +Flamechart.prototype.paint = function(opacity, frames, gemName) { + var self = this - d3.selectAll(i.nodes) - .attr('opacity',1); - }; + var ctx = self.canvas.getContext('2d') - // assign some colors, analyze samples per gem - var gemStats = {} - var topFrames = {} - var lastFrame = {frame: 'd52e04d-df28-41ed-a215-b6ec840a8ea5', x: -1} + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)' - $.each(data, function(){ - var gem = guessGem(this.file); - var stat = gemStats[gem]; - this.gemName = gem + if (self.showLabels) { + ctx.textBaseline = 'middle' + ctx.font = '11px ' + getComputedStyle(this.canvas).fontFamily + // W tends to be one of the widest characters (and if the font is truly + // fixed-width then any character will do). + var characterWidth = ctx.measureText('WWWW').width / 4 + } - if(!stat) { - gemStats[gem] = stat = {name: gem, samples: [], frames: [], nodes:[]}; - } + if (typeof opacity === 'undefined') + opacity = 1 - stat.frames.push(this.frame_id); - for(var j=0; j < this.width; j++){ - stat.samples.push(this.x + j); + frames = frames || self.data + + var blocksByColor = {} + + frames.forEach(function(f) { + if (gemName && f.gemName !== gemName) + return + + var r = self.dataToCanvas(self.frameRect(f)) + + if (r.x >= self.width || + r.y >= self.height || + (r.x + r.width) <= 0 || + (r.y + r.height) <= 0) { + return } - // This assumes the traversal is in order - if (lastFrame.x != this.x) { - var topFrame = topFrames[lastFrame.frame_id] - if (!topFrame) { - topFrames[lastFrame.frame_id] = topFrame = {exclusiveCount: 0} - } - topFrame.exclusiveCount += 1; - lastFrame.topFrame = topFrame; - } - lastFrame = this; - }); + var i = self.info[f.frame_id] + var color = colorString(i.color, opacity) + var colorBlocks = blocksByColor[color] + if (!colorBlocks) + colorBlocks = blocksByColor[color] = [] + colorBlocks.push({ rect: r, text: f.frame }) + }) - var topFrame = topFrames[lastFrame.frame_id] - if (!topFrame) { - topFrames[lastFrame.frame_id] = topFrame = {exclusiveCount: 0} + var textBlocks = [] + + Object.keys(blocksByColor).forEach(function(color) { + ctx.fillStyle = color + + blocksByColor[color].forEach(function(block) { + if (opacity < 1) + ctx.clearRect(block.rect.x, block.rect.y, block.rect.width, block.rect.height) + + ctx.fillRect(block.rect.x, block.rect.y, block.rect.width, block.rect.height) + + if (block.rect.width > 4 && block.rect.height > 4) + ctx.strokeRect(block.rect.x, block.rect.y, block.rect.width, block.rect.height) + + if (!self.showLabels || block.rect.width / characterWidth < 4) + return + + textBlocks.push(block) + }) + }) + + ctx.fillStyle = '#000' + textBlocks.forEach(function(block) { + var text = block.text + var textRect = Object.assign({}, block.rect) + textRect.x += 1 + textRect.width -= 2 + if (textRect.width < text.length * characterWidth * 0.75) + text = centerTruncate(block.text, Math.floor(textRect.width / characterWidth)) + ctx.fillText(text, textRect.x, textRect.y + textRect.height / 2, textRect.width) + }) +} + +Flamechart.prototype.frameAtPoint = function(x, y) { + var self = this + + return self.data.find(function(d) { + var r = self.dataToCanvas(self.frameRect(d)) + + return r.x <= x + && r.x + r.width >= x + && r.y <= y + && r.y + r.height >= y + }) +} + +function MainFlamechart(canvas, data, dataRange, info) { + var self = this + + Flamechart.call(self, canvas, data, dataRange, info) + + self.showLabels = true + + self.canvas.addEventListener('mousedown', self.onMouseDown.bind(self)) + self.canvas.addEventListener('mousemove', self.onMouseMove.bind(self)) + self.canvas.addEventListener('mouseout', self.onMouseOut.bind(self)) + self.canvas.addEventListener('wheel', self.onWheel.bind(self)) +} + +MainFlamechart.prototype = Object.create(Flamechart.prototype) + +MainFlamechart.prototype.setDimensionsNow = function(width, height) { + var self = this + + var viewport = Object.assign({}, self.viewport) + viewport.height = height / 16 + self.setViewport(viewport) + + CanvasView.prototype.setDimensionsNow.call(self, width, height) +} + +MainFlamechart.prototype.onMouseDown = function(e) { + var self = this + + if (e.button !== 0) + return + + captureMouse({ + mouseup: self.onMouseUp.bind(self), + mousemove: self.onMouseMove.bind(self), + }) + + var clientRect = self.canvas.getBoundingClientRect() + var currentX = e.clientX - clientRect.left + var currentY = e.clientY - clientRect.top + + self.dragging = true + self.dragInfo = { + mouse: { x: currentX, y: currentY }, + viewport: { x: self.viewport.x, y: self.viewport.y }, } - topFrame.exclusiveCount += 1; - lastFrame.topFrame = topFrame; - var totalGems = 0; - $.each(gemStats, function(k,stat){ - totalGems++; - stat.samples = getUnique(stat.samples); - }); + e.preventDefault() +} - var gemsSorted = $.map(gemStats, function(v, k){ return v }) - gemsSorted.sort(function(a, b){ return b.samples.length - a.samples.length }) +MainFlamechart.prototype.onMouseUp = function(e) { + var self = this - var currentIndex = 0; - $.each(gemsSorted, function(k,stat){ - stat.color = rainbow(totalGems, currentIndex); - currentIndex += 1; + if (!self.dragging) + return - for(var x=0; x < stat.frames.length; x++) { - info[stat.frames[x]] = {nodes: [], samples: [], color: stat.color}; + releaseCapture() + + self.dragging = false + e.preventDefault() +} + +MainFlamechart.prototype.onMouseMove = function(e) { + var self = this + + var clientRect = self.canvas.getBoundingClientRect() + var currentX = e.clientX - clientRect.left + var currentY = e.clientY - clientRect.top + + if (self.dragging) { + var viewport = Object.assign({}, self.viewport) + viewport.x = self.dragInfo.viewport.x - (currentX - self.dragInfo.mouse.x) * viewport.width / self.width + viewport.y = self.dragInfo.viewport.y - (currentY - self.dragInfo.mouse.y) * viewport.height / self.height + viewport.x = Math.min(self.dataRange.maxX - viewport.width, Math.max(self.dataRange.minX, viewport.x)) + viewport.y = Math.min(self.dataRange.maxY - viewport.height, Math.max(self.dataRange.minY, viewport.y)) + self.setViewport(viewport) + return + } + + var frame = self.frameAtPoint(currentX, currentY) + self.setHoveredFrame(frame) +} + +MainFlamechart.prototype.onMouseOut = function() { + var self = this + + if (self.dragging) + return + + self.setHoveredFrame(null) +} + +MainFlamechart.prototype.onWheel = function(e) { + var self = this + + var deltaX = e.deltaX + var deltaY = e.deltaY + + if (e.deltaMode == WheelEvent.prototype.DOM_DELTA_LINE) { + deltaX *= 11 + deltaY *= 11 + } + + if (e.shiftKey) { + if ('webkitDirectionInvertedFromDevice' in e) { + if (e.webkitDirectionInvertedFromDevice) + deltaY *= -1 + } else if (/Mac OS X/.test(navigator.userAgent)) { + // Assume that most Mac users have "Scroll direction: Natural" enabled. + deltaY *= -1 } - }); - function drawData(svg, data, xScale, yScale, mini) { - svg.selectAll("g.flames") - .data(data) - .enter() - .append("g") - .attr('class', 'flames') - .each(function(d){ - gemStats[d.gemName].nodes.push(this) + var mouseWheelZoomSpeed = 1 / 120 + self.handleZoomGesture(Math.pow(1.2, -(deltaY || deltaX) * mouseWheelZoomSpeed), e.offsetX) + e.preventDefault() + return + } - var r = d3.select(this) - .append("rect") - .attr("x",function(d) { return xScale(d.x); }) - .attr("y",function(d) { return yScale(maxY - d.y);}) - .attr("width", function(d){return xScale(d.width);}) - .attr("height", yScale(1)) - .attr("fill", function(d){ - var i = info[d.frame_id]; - if(!i) { - info[d.frame_id] = i = {nodes: [], samples: [], color: color()}; - } - i.nodes.push(this); - if (!mini) - for(var j=0; j < d.width; j++){ - i.samples.push(d.x + j); - } - return i.color; - }) + var viewport = Object.assign({}, self.viewport) + viewport.x += deltaX * viewport.width / (self.dataRange.maxX - self.dataRange.minX) + viewport.x = Math.min(self.dataRange.maxX - viewport.width, Math.max(self.dataRange.minX, viewport.x)) + viewport.y += (deltaY / 8) * viewport.height / (self.dataRange.maxY - self.dataRange.minY) + viewport.y = Math.min(self.dataRange.maxY - viewport.height, Math.max(self.dataRange.minY, viewport.y)) + self.setViewport(viewport) + e.preventDefault() +} - if (!mini) - r - .on("mouseover", mouseover) - .on("mouseout", mouseout); +MainFlamechart.prototype.handleZoomGesture = function(zoom, originX) { + var self = this - if (!mini) - d3.select(this) - .append('foreignObject') - .classed('label-body', true) - .attr("x",function(d) { return xScale(d.x); }) - .attr("y",function(d) { return yScale(maxY - d.y);}) - .attr("width", function(d){return xScale(d.width);}) - .attr("height", yScale(1)) - .attr("line-height", yScale(1)) - .attr("font-size", yScale(0.42) + 'px') - .attr('pointer-events', 'none') - .append('xhtml:span') - .style("height", yScale(1)) - .classed('label', true) - .text(function(d){ return d.frame }) - }); + var viewport = Object.assign({}, self.viewport) + var ratioX = originX / self.width + + var newWidth = Math.min(viewport.width / zoom, self.dataRange.maxX - self.dataRange.minX) + viewport.x = Math.max(self.dataRange.minX, viewport.x + (viewport.width - newWidth) * ratioX) + viewport.width = Math.min(newWidth, self.dataRange.maxX - viewport.x) + + self.setViewport(viewport) +} + +MainFlamechart.prototype.setHoveredFrame = function(frame) { + var self = this + + if (frame === self.hoveredFrame) + return + + var previous = self.hoveredFrame + self.hoveredFrame = frame + + self.dispatch('hoveredframechanged', { previous: previous, current: self.hoveredFrame }) +} + +function OverviewFlamechart(container, viewportOverlay, data, dataRange, info) { + var self = this + + Flamechart.call(self, container.querySelector('.overview'), data, dataRange, info) + + self.container = container + + self.showLabels = false + + self.viewportOverlay = viewportOverlay + + self.canvas.addEventListener('mousedown', self.onMouseDown.bind(self)) + self.viewportOverlay.addEventListener('mousedown', self.onOverlayMouseDown.bind(self)) +} + +OverviewFlamechart.prototype = Object.create(Flamechart.prototype) + +OverviewFlamechart.prototype.setViewportOverlayRect = function(r) { + var self = this + + self.viewportOverlayRect = r + + r = self.dataToCanvas(r) + r.width = Math.max(2, r.width) + r.height = Math.max(2, r.height) + + if ('transform' in self.viewportOverlay.style) { + self.viewportOverlay.style.transform = 'translate(' + r.x + 'px, ' + r.y + 'px) scale(' + r.width + ', ' + r.height + ')' + } else { + self.viewportOverlay.style.left = r.x + self.viewportOverlay.style.top = r.y + self.viewportOverlay.style.width = r.width + self.viewportOverlay.style.height = r.height } +} - drawData(svg, data, xScale, yScale, 0) - drawData(svg2, data, xScale2, yScale2, 1) +OverviewFlamechart.prototype.onMouseDown = function(e) { + var self = this - var brushed = function(){ - if (brush.empty()) { - svg.attr('transform', '') - zoomXRatio = 1 - zoom.scale(1).translate([0,0]) - svg.selectAll('.label-body') - .attr('transform', 'scale(1,1)') - .attr("x",function(d) { return xScale(d.x)*zoomXRatio; }) - .attr("width", function(d){return xScale(d.width)*zoomXRatio;}) - } else { - var e = brush.extent() - var x = [e[0][0],e[1][0]], y = [e[0][1],e[1][1]] + captureMouse({ + mouseup: self.onMouseUp.bind(self), + mousemove: self.onMouseMove.bind(self), + }) - xScale.domain([0, maxX]) - yScale.domain([0, maxY]) + self.dragging = true + self.dragStartX = e.clientX - self.canvas.getBoundingClientRect().left - var w = width, h = height2 - var dx = xScale2(1.0*x[1]-x[0]), dy = yScale2(1.0*y[1]-y[0]) - var sx = w/dx, sy = h/dy - var trlx = -xScale(x[0])*sx, trly = -yScale(y[0])*sy - var transform = "translate(" + trlx + ',' + trly + ")" + " scale(" + sx + ',' + sy + ")" + self.handleDragGesture(e) - zoomXRatio = sx/sy + e.preventDefault() +} - svg.selectAll('.label-body') - .attr("x",function(d) { return xScale(d.x)*zoomXRatio; }) - .attr("width", function(d){return xScale(d.width)*zoomXRatio;}) - .attr('transform', function(d){ - var x = xScale(d.x) - return "scale("+(1.0/zoomXRatio)+",1)" - }) +OverviewFlamechart.prototype.onMouseUp = function(e) { + var self = this - svg.attr("transform", transform) - zoom.translate([trlx, trly]).scale(sy) + if (!self.dragging) + return + + releaseCapture() + + self.dragging = false + + self.handleDragGesture(e) + + e.preventDefault() +} + +OverviewFlamechart.prototype.onMouseMove = function(e) { + var self = this + + if (!self.dragging) + return + + self.handleDragGesture(e) + + e.preventDefault() +} + +OverviewFlamechart.prototype.handleDragGesture = function(e) { + var self = this + + var clientRect = self.canvas.getBoundingClientRect() + var currentX = e.clientX - clientRect.left + var currentY = e.clientY - clientRect.top + + if (self.dragCurrentX === currentX) + return + + self.dragCurrentX = currentX + + var minX = Math.min(self.dragStartX, self.dragCurrentX) + var maxX = Math.max(self.dragStartX, self.dragCurrentX) + + var rect = Object.assign({}, self.viewportOverlayRect) + rect.x = minX / self.width * self.viewport.width + self.viewport.x + rect.width = Math.max(self.viewport.width / 1000, (maxX - minX) / self.width * self.viewport.width) + + rect.y = Math.max(self.viewport.y, Math.min(self.viewport.height - self.viewport.y, currentY / self.height * self.viewport.height + self.viewport.y - rect.height / 2)) + + self.setViewportOverlayRect(rect) + self.dispatch('overlaychanged', { current: self.viewportOverlayRect }) +} + +OverviewFlamechart.prototype.onOverlayMouseDown = function(e) { + var self = this + + captureMouse({ + mouseup: self.onOverlayMouseUp.bind(self), + mousemove: self.onOverlayMouseMove.bind(self), + }) + + self.overlayDragging = true + self.overlayDragInfo = { + mouse: { x: e.clientX, y: e.clientY }, + rect: Object.assign({}, self.viewportOverlayRect), + } + self.viewportOverlay.classList.add('moving') + + self.handleOverlayDragGesture(e) + + e.preventDefault() +} + +OverviewFlamechart.prototype.onOverlayMouseUp = function(e) { + var self = this + + if (!self.overlayDragging) + return + + releaseCapture() + + self.overlayDragging = false + self.viewportOverlay.classList.remove('moving') + + self.handleOverlayDragGesture(e) + + e.preventDefault() +} + +OverviewFlamechart.prototype.onOverlayMouseMove = function(e) { + var self = this + + if (!self.overlayDragging) + return + + self.handleOverlayDragGesture(e) + + e.preventDefault() +} + +OverviewFlamechart.prototype.handleOverlayDragGesture = function(e) { + var self = this + + var deltaX = (e.clientX - self.overlayDragInfo.mouse.x) / self.width * self.viewport.width + var deltaY = (e.clientY - self.overlayDragInfo.mouse.y) / self.height * self.viewport.height + + var rect = Object.assign({}, self.overlayDragInfo.rect) + rect.x += deltaX + rect.y += deltaY + rect.x = Math.max(self.viewport.x, Math.min(self.viewport.x + self.viewport.width - rect.width, rect.x)) + rect.y = Math.max(self.viewport.y, Math.min(self.viewport.y + self.viewport.height - rect.height, rect.y)) + + self.setViewportOverlayRect(rect) + self.dispatch('overlaychanged', { current: self.viewportOverlayRect }) +} + +function FlamegraphView(data, info, sortedGems) { + var self = this + + self.data = data + self.info = info + + self.dataRange = self.computeDataRange() + + self.mainChart = new MainFlamechart(document.querySelector('.flamegraph'), data, self.dataRange, info) + self.overview = new OverviewFlamechart(document.querySelector('.overview-container'), document.querySelector('.overview-viewport-overlay'), data, self.dataRange, info) + self.infoElement = document.querySelector('.info') + + self.mainChart.on('hoveredframechanged', self.onHoveredFrameChanged.bind(self)) + self.mainChart.on('viewportchanged', self.onViewportChanged.bind(self)) + self.overview.on('overlaychanged', self.onOverlayChanged.bind(self)) + + var legend = document.querySelector('.legend') + self.renderLegend(legend, sortedGems) + + legend.addEventListener('mousemove', self.onLegendMouseMove.bind(self)) + legend.addEventListener('mouseout', self.onLegendMouseOut.bind(self)) + + window.addEventListener('resize', self.updateDimensions.bind(self)) + + self.updateDimensions() +} + +FlamegraphView.prototype.updateDimensions = function() { + var self = this + + var margin = {top: 10, right: 10, bottom: 10, left: 10} + var width = window.innerWidth - 200 - margin.left - margin.right + var mainChartHeight = Math.ceil(window.innerHeight * 0.80) - margin.top - margin.bottom + var overviewHeight = Math.floor(window.innerHeight * 0.20) - 60 - margin.top - margin.bottom + + self.mainChart.setDimensions(width + margin.left + margin.right, mainChartHeight + margin.top + margin.bottom) + self.overview.setDimensions(width + margin.left + margin.right, overviewHeight + margin.top + margin.bottom) + self.overview.setViewportOverlayRect(self.mainChart.viewport) +} + +FlamegraphView.prototype.computeDataRange = function() { + var self = this + + var range = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } + self.data.forEach(function(d) { + range.minX = Math.min(range.minX, d.x) + range.minY = Math.min(range.minY, d.y) + range.maxX = Math.max(range.maxX, d.x + d.width) + range.maxY = Math.max(range.maxY, d.y + 1) + }) + + return range +} + +FlamegraphView.prototype.onHoveredFrameChanged = function(data) { + var self = this + + self.updateInfo(data.current) + + if (data.previous) + self.repaintFrames(1, self.info[data.previous.frame_id].frames) + + if (data.current) + self.repaintFrames(0.5, self.info[data.current.frame_id].frames) +} + +FlamegraphView.prototype.repaintFrames = function(opacity, frames) { + var self = this + + self.mainChart.paint(opacity, frames) + self.overview.paint(opacity, frames) +} + +FlamegraphView.prototype.updateInfo = function(frame) { + var self = this + + if (!frame) { + self.infoElement.style.backgroundColor = '' + self.infoElement.querySelector('.frame').textContent = '' + self.infoElement.querySelector('.file').textContent = '' + self.infoElement.querySelector('.samples').textContent = '' + self.infoElement.querySelector('.exclusive').textContent = '' + return + } + + var i = self.info[frame.frame_id] + var shortFile = frame.file.replace(/^.+\/(gems|app|lib|config|jobs)/, '$1') + var sData = self.samplePercentRaw(i.samples.length, frame.topFrame ? frame.topFrame.exclusiveCount : 0) + + self.infoElement.style.backgroundColor = colorString(i.color, 1) + self.infoElement.querySelector('.frame').textContent = frame.frame + self.infoElement.querySelector('.file').textContent = shortFile + self.infoElement.querySelector('.samples').textContent = sData[0] + ' samples (' + sData[1] + '%)' + if (sData[3]) + self.infoElement.querySelector('.exclusive').textContent = sData[2] + ' exclusive (' + sData[3] + '%)' + else + self.infoElement.querySelector('.exclusive').textContent = '' +} + +FlamegraphView.prototype.samplePercentRaw = function(samples, exclusive) { + var self = this + + var ret = [samples, ((samples / self.dataRange.maxX) * 100).toFixed(2)] + if (exclusive) + ret = ret.concat([exclusive, ((exclusive / self.dataRange.maxX) * 100).toFixed(2)]) + return ret +} + +FlamegraphView.prototype.onViewportChanged = function(data) { + var self = this + + self.overview.setViewportOverlayRect(data.current) +} + +FlamegraphView.prototype.onOverlayChanged = function(data) { + var self = this + + self.mainChart.setViewport(data.current) +} + +FlamegraphView.prototype.renderLegend = function(element, sortedGems) { + var self = this + + var fragment = document.createDocumentFragment() + + sortedGems.forEach(function(gem) { + var sData = self.samplePercentRaw(gem.samples.length) + var node = document.createElement('div') + node.className = 'legend-gem' + node.setAttribute('data-gem-name', gem.name) + node.style.backgroundColor = colorString(gem.color, 1) + + var span = document.createElement('span') + span.style.float = 'right' + span.textContent = sData[0] + 'x' + span.appendChild(document.createElement('br')) + span.appendChild(document.createTextNode(sData[1] + '%')) + node.appendChild(span) + + var name = document.createElement('div') + name.className = 'name' + name.textContent = gem.name + name.appendChild(document.createElement('br')) + name.appendChild(document.createTextNode('\u00a0')) + node.appendChild(name) + + fragment.appendChild(node) + }) + + element.appendChild(fragment) +} + +FlamegraphView.prototype.onLegendMouseMove = function(e) { + var self = this + + var gemElement = e.target.closest('.legend-gem') + var gemName = gemElement.getAttribute('data-gem-name') + + if (self.hoveredGemName === gemName) + return + + if (self.hoveredGemName) { + self.mainChart.paint(1, null, self.hoveredGemName) + self.overview.paint(1, null, self.hoveredGemName) + } + + self.hoveredGemName = gemName + + self.mainChart.paint(0.5, null, self.hoveredGemName) + self.overview.paint(0.5, null, self.hoveredGemName) +} + +FlamegraphView.prototype.onLegendMouseOut = function() { + var self = this + + if (!self.hoveredGemName) + return + + self.mainChart.paint(1, null, self.hoveredGemName) + self.overview.paint(1, null, self.hoveredGemName) + self.hoveredGemName = null +} + +var capturingListeners = null +function captureMouse(listeners) { + if (capturingListeners) + releaseCapture() + + for (var name in listeners) + document.addEventListener(name, listeners[name], true) + capturingListeners = listeners +} + +function releaseCapture() { + if (!capturingListeners) + return + + for (var name in capturingListeners) + document.removeEventListener(name, capturingListeners[name], true) + capturingListeners = null +} + +function guessGem(frame) { + var split = frame.split('/gems/') + if (split.length === 1) { + split = frame.split('/app/') + if (split.length === 1) { + split = frame.split('/lib/') + } else { + return split[split.length - 1].split('/')[0] } + + split = split[Math.max(split.length - 2, 0)].split('/') + return split[split.length - 1].split(':')[0] } + else + { + return split[split.length - 1].split('/')[0].split('-', 2)[0] + } +} - var brush = d3.svg.brush() - .x(xScale2) - .y(yScale2) - .on("brush", brushed); +function color() { + var r = parseInt(205 + Math.random() * 50) + var g = parseInt(Math.random() * 230) + var b = parseInt(Math.random() * 55) + return [r, g, b] +} - svg2.append("g") - .attr("class", "brush") - .call(brush) +// http://stackoverflow.com/a/7419630 +function rainbow(numOfSteps, step) { + // This function generates vibrant, "evenly spaced" colours (i.e. no clustering). This is ideal for creating easily distiguishable vibrant markers in Google Maps and other apps. + // Adam Cole, 2011-Sept-14 + // HSV to RBG adapted from: http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript + var r, g, b + var h = step / numOfSteps + var i = ~~(h * 6) + var f = h * 6 - i + var q = 1 - f + switch (i % 6) { + case 0: r = 1, g = f, b = 0; break + case 1: r = q, g = 1, b = 0; break + case 2: r = 0, g = 1, b = f; break + case 3: r = 0, g = q, b = 1; break + case 4: r = f, g = 0, b = 1; break + case 5: r = 1, g = 0, b = q; break + } + return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)] +} +function colorString(color, opacity) { + if (typeof opacity === 'undefined') + opacity = 1 + return 'rgba(' + color.join(',') + ',' + opacity + ')' +} + +// http://stackoverflow.com/questions/1960473/unique-values-in-an-array +function getUnique(orig) { + var o = {} + for (var i = 0; i < orig.length; i++) o[orig[i]] = 1 + return Object.keys(o) +} + +function centerTruncate(text, maxLength) { + var charactersToKeep = maxLength - 1 + if (charactersToKeep <= 0) + return '' + if (text.length <= charactersToKeep) + return text + + var prefixLength = Math.ceil(charactersToKeep / 2) + var suffixLength = charactersToKeep - prefixLength + var prefix = text.substr(0, prefixLength) + var suffix = suffixLength > 0 ? text.substr(-suffixLength) : '' + + return [prefix, '\u2026', suffix].join('') +} + +function flamegraph(data) { + var info = {} + data.forEach(function(d) { + var i = info[d.frame_id] + if (!i) + info[d.frame_id] = i = {frames: [], samples: [], color: color()} + i.frames.push(d) + for (var j = 0; j < d.width; j++) { + i.samples.push(d.x + j) + } + }) + // Samples may overlap on the same line for (var r in info) { if (info[r].samples) { - info[r].samples = getUnique(info[r].samples); + info[r].samples = getUnique(info[r].samples) } - }; + } - // render the legend - $.each(gemsSorted, function(k,gem){ - var data = samplePercentRaw(gem.samples.length) - var node = $("<div class='"+gem.name+"'></div>") - .css("background-color", gem.color) - .html("<span style='float: right'>" + data[0] + 'x<br>' + data[1] + '%' + '</span>' + '<div class="name">'+gem.name+'<br>&nbsp;</div>'); + // assign some colors, analyze samples per gem + var gemStats = {} + var topFrames = {} + var lastFrame = {frame: 'd52e04d-df28-41ed-a215-b6ec840a8ea5', x: -1} - node.on('mouseenter mouseleave', function(e){ - d3.selectAll(gemStats[gem.name].nodes).classed('highlighted', e.type == 'mouseenter') - }) + data.forEach(function(d) { + var gem = guessGem(d.file) + var stat = gemStats[gem] + d.gemName = gem - $('.legend').append(node); - }); + if (!stat) { + gemStats[gem] = stat = {name: gem, samples: [], frames: []} + } + + stat.frames.push(d.frame_id) + for (var j = 0; j < d.width; j++) { + stat.samples.push(d.x + j) + } + // This assumes the traversal is in order + if (lastFrame.x !== d.x) { + var topFrame = topFrames[lastFrame.frame_id] + if (!topFrame) { + topFrames[lastFrame.frame_id] = topFrame = {exclusiveCount: 0} + } + topFrame.exclusiveCount += 1 + lastFrame.topFrame = topFrame + } + lastFrame = d + }) + + var topFrame = topFrames[lastFrame.frame_id] + if (!topFrame) { + topFrames[lastFrame.frame_id] = topFrame = {exclusiveCount: 0} + } + topFrame.exclusiveCount += 1 + lastFrame.topFrame = topFrame + + var totalGems = 0 + for (var k in gemStats) { + totalGems++ + gemStats[k].samples = getUnique(gemStats[k].samples) + } + + var gemsSorted = Object.keys(gemStats).map(function(k) { return gemStats[k] }) + gemsSorted.sort(function(a, b) { return b.samples.length - a.samples.length }) + + var currentIndex = 0 + gemsSorted.forEach(function(stat) { + stat.color = rainbow(totalGems, currentIndex) + currentIndex += 1 + + for (var x = 0; x < stat.frames.length; x++) { + info[stat.frames[x]].color = stat.color + } + }) + + new FlamegraphView(data, info, gemsSorted) }