From caba525465820d5e037a96ad984af2e13c3886c0 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Tue, 17 Apr 2018 00:04:24 -0700 Subject: [PATCH] Add a detail view to view aggregate statistics & file details (#23) ![image](https://user-images.githubusercontent.com/150329/38852391-9706fb4e-41ce-11e8-8296-a236928820c6.png) This introduces a view for showing detailed information about a selected frame in the flamegraph. This provides information about self time & aggregate times across all instances of the function, regardless of where it is in the flamegraph. This also shows a full stack with file & line information. Fixes #20 --- application.tsx | 6 +- color.ts | 56 +-------- flamechart-style.ts | 67 ++++++++++ flamechart-view.tsx | 299 +++++++++++++++++++++++++++++++++++++++----- style.ts | 3 +- 5 files changed, 347 insertions(+), 84 deletions(-) diff --git a/application.tsx b/application.tsx index 150aad5..d45a93b 100644 --- a/application.tsx +++ b/application.tsx @@ -592,7 +592,7 @@ const style = StyleSheet.create({ cursor: 'pointer', }, link: { - color: Colors.LIGHT_BLUE, + color: Colors.BRIGHT_BLUE, cursor: 'pointer', textDecoration: 'none', }, @@ -639,9 +639,9 @@ const style = StyleSheet.create({ }, }, toolbarTabActive: { - background: Colors.LIGHT_BLUE, + background: Colors.BRIGHT_BLUE, ':hover': { - background: Colors.LIGHT_BLUE, + background: Colors.BRIGHT_BLUE, }, }, noLinkStyle: { diff --git a/color.ts b/color.ts index 1ab169d..f859d31 100644 --- a/color.ts +++ b/color.ts @@ -1,5 +1,3 @@ -import {Frame} from './profile' - export class Color { constructor( readonly r: number = 0, @@ -30,56 +28,10 @@ export class Color { return new Color(R1 + m, G1 + m, B1 + m, 1.0) } -} -function fract(x: number) { - return x - Math.floor(x) -} - -// TODO(jlfwong): Can probably delete this? -export class FrameColorGenerator { - private frameToColor = new Map() - - constructor(frames: Frame[]) { - // Make a copy so we can mutate it - frames = [...frames] - - function key(f: Frame) { - return (f.file || '') + f.name - } - - function compare(a: Frame, b: Frame) { - return key(a) > key(b) ? 1 : -1 - } - - frames.sort(compare) - - const cumulativeScores: number[] = [] - let lastScore = 0 - for (let i = 0; i < frames.length; i++) { - const score = lastScore + Math.abs(compare(frames[i], frames[(i + 1) % frames.length])) - cumulativeScores.push(score) - lastScore = score - } - - // We now have a sorted list of frames s.t. frames with similar - // file paths and method names are clustered together. - // - // Now, to assign them colors, we map normalized cumulative - // score values onto the full range of hue values. - const totalScore = cumulativeScores[cumulativeScores.length - 1] || 1 - for (let i = 0; i < cumulativeScores.length; i++) { - const ratio = cumulativeScores[i] / totalScore - const x = 2 * fract(100.0 * ratio) - 1 - - const L = 0.85 - 0.1 * x - const C = 0.2 + 0.1 * x - const H = 360 * ratio - this.frameToColor.set(frames[i], Color.fromLumaChromaHue(L, C, H)) - } - } - - getColorForFrame(f: Frame) { - return this.frameToColor.get(f) || new Color() + toCSS(): string { + return `rgba(${(255 * this.r).toFixed()}, ${(255 * this.g).toFixed()}, ${( + 255 * this.b + ).toFixed()}, ${this.a.toFixed(2)})` } } diff --git a/flamechart-style.ts b/flamechart-style.ts index cac617c..788ac63 100644 --- a/flamechart-style.ts +++ b/flamechart-style.ts @@ -5,6 +5,7 @@ const HOVERTIP_PADDING = 2 export namespace Sizes { export const MINIMAP_HEIGHT = 100 + export const DETAIL_VIEW_HEIGHT = 150 export const TOOLTIP_WIDTH_MAX = 300 export const TOOLTIP_HEIGHT_MAX = 75 export const SEPARATOR_HEIGHT = 2 @@ -57,4 +58,70 @@ export const style = StyleSheet.create({ panZoomView: { flex: 1, }, + + detailView: { + display: 'grid', + height: Sizes.DETAIL_VIEW_HEIGHT, + overflow: 'hidden', + gridTemplateColumns: '120px 120px 1fr', + gridTemplateRows: 'repeat(4, 1fr)', + borderTop: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.MEDIUM_GRAY}`, + fontSize: FontSize.LABEL, + position: 'absolute', + background: 'white', + width: '100vw', + bottom: 0, + }, + stackTraceView: { + height: Sizes.DETAIL_VIEW_HEIGHT, + overflow: 'auto', + lineHeight: `${FontSize.LABEL + 2}px`, + padding: 5, + }, + stackChit: { + display: 'inline-block', + verticalAlign: 'bottom', + marginRight: '0.5em', + border: `1px solid ${Colors.MEDIUM_GRAY}`, + width: FontSize.LABEL - 2, + height: FontSize.LABEL - 2, + }, + stackLine: { + whiteSpace: 'nowrap', + }, + stackFileLine: { + color: Colors.MEDIUM_GRAY, + }, + statsTable: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gridTemplateRows: `repeat(3, ${FontSize.LABEL + 10}px)`, + gridGap: '1px 1px', + textAlign: 'center', + paddingRight: 1, + }, + statsTableHeader: { + gridColumn: '1 / 3', + }, + statsTableCell: { + position: 'relative', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + thisInstanceCell: { + background: Colors.DARK_BLUE, + color: 'white', + }, + allInstancesCell: { + background: Colors.PALE_DARK_BLUE, + color: 'white', + }, + barDisplay: { + position: 'absolute', + top: 0, + left: 0, + background: 'rgba(0, 0, 0, 0.2)', + width: '100%', + }, }) diff --git a/flamechart-view.tsx b/flamechart-view.tsx index fe3aeb0..8df2649 100644 --- a/flamechart-view.tsx +++ b/flamechart-view.tsx @@ -1,8 +1,8 @@ import {h} from 'preact' -import {css} from 'aphrodite' +import {css, StyleDeclarationValue} from 'aphrodite' import {ReloadableComponent} from './reloadable' -import {CallTreeNode} from './profile' +import {CallTreeNode, Frame} from './profile' import {Flamechart, FlamechartFrame} from './flamechart' import {Rect, Vec2, AffineTransform, clamp} from './math' @@ -13,6 +13,7 @@ import {style, Sizes} from './flamechart-style' import {FontSize, FontFamily, Colors} from './style' import {CanvasContext} from './canvas-context' import {FlamechartRenderer} from './flamechart-renderer' +import {Color} from './color' interface FlamechartFrameLabel { configSpaceBounds: Rect @@ -83,8 +84,10 @@ interface FlamechartPanZoomViewProps { canvasContext: CanvasContext flamechartRenderer: FlamechartRenderer + selectedNode: CallTreeNode | null setNodeHover: (node: CallTreeNode | null, logicalViewSpaceMouse: Vec2) => void + setSelectedNode: (node: CallTreeNode | null) => void configSpaceViewportRect: Rect transformViewport: (transform: AffineTransform) => void setConfigSpaceViewportRect: (rect: Rect) => void @@ -184,16 +187,23 @@ export class FlamechartPanZoomView extends ReloadableComponent { + if (!this.props.selectedNode) return + const width = frame.end - frame.start + const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1)) + + if (width < minConfigSpaceWidthToRenderOutline) return + if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return + if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return + if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return + + if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) { + const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds) + + if (frame.node.frame === this.props.selectedNode.frame) { + if (frame.node === this.props.selectedNode) { + if (ctx.strokeStyle !== Colors.DARK_BLUE) { + ctx.stroke() + ctx.beginPath() + ctx.strokeStyle = Colors.DARK_BLUE + } + } else { + if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) { + ctx.stroke() + ctx.beginPath() + ctx.strokeStyle = Colors.PALE_DARK_BLUE + } + } + + // Identify the flamechart frames with a function that matches the + // selected flamechart frame. + ctx.rect( + Math.floor(physicalRectBounds.left() + 1 + frameOutlineWidth / 2), + Math.floor(physicalRectBounds.top() + 1 + frameOutlineWidth / 2), + Math.floor(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)), + Math.floor(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)), + ) + } + } + + for (let child of frame.children) { + renderIndirectlySelectedFrameOutlines(child, depth + 1) + } + } + + ctx.beginPath() + for (let frame of this.props.flamechart.getLayers()[0] || []) { + renderIndirectlySelectedFrameOutlines(frame) + } + ctx.stroke() + + this.renderTimeIndicators() + } + + private renderTimeIndicators() { + const ctx = this.overlayCtx + if (!ctx) return + + const physicalViewSpaceFrameHeight = this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * DEVICE_PIXEL_RATIO + const physicalViewSize = this.physicalViewSize() + const configToPhysical = this.configSpaceToPhysicalViewSpace() + const left = this.props.configSpaceViewportRect.left() const right = this.props.configSpaceViewportRect.right() - // We want about 10 gridlines to be visible, and want the unit to be // 1eN, 2eN, or 5eN for some N - // Ideally, we want an interval every 100 logical screen pixels const logicalToConfig = ( this.configSpaceToPhysicalViewSpace().inverted() || new AffineTransform() ).times(this.logicalToPhysicalViewSpace()) const targetInterval = logicalToConfig.transformVector(new Vec2(200, 1)).x - const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval))) let interval = minInterval - if (targetInterval / interval > 5) { interval *= 5 } else if (targetInterval / interval > 2) { @@ -279,14 +352,12 @@ export class FlamechartPanZoomView extends ReloadableComponent { - this.updateConfigSpaceViewport(true) + this.updateConfigSpaceViewport() this.onBeforeFrame() } @@ -386,6 +457,10 @@ export class FlamechartPanZoomView extends ReloadableComponent { + if (this.hoveredLabel) { + this.props.setSelectedNode(this.hoveredLabel.node) + this.renderCanvas() + } else { + this.props.setSelectedNode(null) + } + } + private updateCursor() { if (this.lastDragPos) { document.body.style.cursor = 'grabbing' @@ -555,6 +639,9 @@ export class FlamechartPanZoomView extends ReloadableComponent string +} + +class StatisticsTable extends ReloadableComponent { + render() { + const total = this.props.formatter(this.props.selectedTotal) + const self = this.props.formatter(this.props.selectedSelf) + const totalPerc = 100.0 * this.props.selectedTotal / this.props.grandTotal + const selfPerc = 100.0 * this.props.selectedSelf / this.props.grandTotal + + return ( +
+
+ {this.props.title} +
+ +
Total
+
Self
+ +
{total}
+
{self}
+ +
+ {formatPercent(totalPerc)} +
+
+
+ {formatPercent(selfPerc)} +
+
+
+ ) + } +} + +interface StackTraceViewProps { + getFrameColor: (frame: Frame) => string + node: CallTreeNode +} +class StackTraceView extends ReloadableComponent { + render() { + const rows: JSX.Element[] = [] + let node: CallTreeNode | null = this.props.node + for (; node; node = node.parent) { + const row: (JSX.Element | string)[] = [] + const {frame} = node + + row.push( + , + ) + + if (rows.length) { + row.push(> ) + } + row.push(frame.name) + + if (frame.file) { + let pos = frame.file + if (frame.line) { + pos += `:${frame.line}` + if (frame.col) { + pos += `:${frame.col}` + } + } + row.push( ({pos})) + } + rows.push(
{row}
) + } + return
{rows}
+ } +} + +interface FlamechartDetailViewProps { + flamechart: Flamechart + selectedNode: CallTreeNode +} + +function fract(x: number) { + return x - Math.floor(x) +} + +function triangle(x: number) { + return 2.0 * Math.abs(fract(x) - 0.5) - 1.0 +} + +class FlamechartDetailView extends ReloadableComponent { + getFrameColor = (frame: Frame): string => { + const t = this.props.flamechart.getColorBucketForFrame(frame) / 255 + + const x = triangle(30.0 * t) + const H = 360.0 * (0.9 * t) + const C = 0.25 + 0.2 * x + const L = 0.8 - 0.15 * x + return Color.fromLumaChromaHue(L, C, H).toCSS() + } + + render() { + const {flamechart, selectedNode} = this.props + const {frame} = selectedNode + + return ( +
+ + + +
+ ) + } +} + interface FlamechartViewProps { flamechart: Flamechart canvasContext: CanvasContext @@ -604,6 +828,7 @@ interface FlamechartViewProps { interface FlamechartViewState { hoveredNode: CallTreeNode | null + selectedNode: CallTreeNode | null configSpaceViewportRect: Rect logicalSpaceMouse: Vec2 } @@ -615,6 +840,7 @@ export class FlamechartView extends ReloadableComponent { + onNodeHover = (node: CallTreeNode | null, logicalSpaceMouse: Vec2) => { this.setState({ - hoveredNode, + hoveredNode: node, logicalSpaceMouse: logicalSpaceMouse.plus(new Vec2(0, Sizes.MINIMAP_HEIGHT)), }) } + onNodeClick = (node: CallTreeNode | null) => { + this.setState({ + selectedNode: node, + }) + } + formatValue(weight: number) { const totalWeight = this.props.flamechart.getTotalWeight() const percent = 100 * weight / totalWeight - let formattedPercent = `${percent.toFixed(0)}%` - if (percent === 100) formattedPercent = '100%' - else if (percent > 99) formattedPercent = '>99%' - else if (percent < 0.01) formattedPercent = '<0.01%' - else if (percent < 1) formattedPercent = `${percent.toFixed(2)}%` - else if (percent < 10) formattedPercent = `${percent.toFixed(1)}%` - + const formattedPercent = formatPercent(percent) return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})` } @@ -749,12 +975,29 @@ export class FlamechartView extends ReloadableComponent + {this.state.selectedNode && ( + + )} {this.renderTooltip()}
) } } +function formatPercent(percent: number) { + let formattedPercent = `${percent.toFixed(0)}%` + if (percent === 100) formattedPercent = '100%' + else if (percent > 99) formattedPercent = '>99%' + else if (percent < 0.01) formattedPercent = '<0.01%' + else if (percent < 1) formattedPercent = `${percent.toFixed(2)}%` + else if (percent < 10) formattedPercent = `${percent.toFixed(1)}%` + return formattedPercent +} diff --git a/style.ts b/style.ts index 47ef6a8..2374b30 100644 --- a/style.ts +++ b/style.ts @@ -13,6 +13,7 @@ export enum Colors { MEDIUM_GRAY = '#BDBDBD', GRAY = '#666666', DARK_GRAY = '#222222', - LIGHT_BLUE = '#56CCF2', + BRIGHT_BLUE = '#56CCF2', DARK_BLUE = '#2F80ED', + PALE_DARK_BLUE = '#8EB7ED', }