diff --git a/application.tsx b/application.tsx index f82fc41..9e8fbfb 100644 --- a/application.tsx +++ b/application.tsx @@ -60,7 +60,8 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { const sortedFlamechart = new Flamechart({ getTotalWeight: profile.getTotalNonIdleWeight.bind(profile), forEachCall: profile.forEachCallGrouped.bind(profile), - forEachFrame: profile.forEachFrame.bind(profile) + formatValue: profile.formatValue.bind(profile), + forEachFrame: profile.forEachFrame.bind(profile), }) this.setState({ profile, flamechart, sortedFlamechart }, () => { console.timeEnd('import') diff --git a/flamechart-minimap-view.tsx b/flamechart-minimap-view.tsx index c376949..76074a0 100644 --- a/flamechart-minimap-view.tsx +++ b/flamechart-minimap-view.tsx @@ -5,8 +5,9 @@ import { css } from 'aphrodite' import { Flamechart } from './flamechart' import { Rect, Vec2, AffineTransform } from './math' import { rectangleBatchRenderer, RectangleBatchRendererProps } from "./rectangle-batch-renderer" -import { atMostOnceAFrame } from "./utils"; -import { style } from "./flamechart-style"; +import { atMostOnceAFrame, cachedMeasureTextWidth } from "./utils"; +import { style, Sizes } from "./flamechart-style"; +import { FontFamily, FontSize, Colors } from "./style" const DEVICE_PIXEL_RATIO = window.devicePixelRatio @@ -18,12 +19,15 @@ interface FlamechartMinimapViewProps { export class FlamechartMinimapView extends Component { renderer: ReglCommand | null = null - overlayRenderer: ReglCommand | null = null + viewportRectRenderer: ReglCommand | null = null ctx: WebGLRenderingContext | null = null regl: regl.ReglCommandConstructor | null = null canvas: HTMLCanvasElement | null = null + overlayCanvas: HTMLCanvasElement | null = null + overlayCtx: CanvasRenderingContext2D | null = null + private physicalViewSize() { return new Vec2( this.canvas ? this.canvas.width : 0, @@ -39,9 +43,11 @@ export class FlamechartMinimapView extends Component 5) { + interval *= 5 + } else if (targetInterval / interval > 2) { + interval *= 2 + } + + { + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' + ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight) + + ctx.fillStyle = Colors.GRAY + for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) { + // TODO(jlfwong): Ensure that labels do not overlap + const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x) + const labelText = this.props.flamechart.formatValue(x) + const textWidth = Math.ceil(cachedMeasureTextWidth(ctx, labelText)) + + ctx.fillText(labelText, pos - textWidth - LABEL_PADDING_PX, LABEL_PADDING_PX) + ctx.fillRect(pos, 0, 1, physicalViewSize.y) + } + } + } + componentWillReceiveProps(nextProps: FlamechartMinimapViewProps) { if (this.props.flamechart !== nextProps.flamechart) { this.renderer = null @@ -101,6 +160,30 @@ export class FlamechartMinimapView extends Component { if (!this.canvas || this.canvas.getBoundingClientRect().width < 2) { // If the canvas is still tiny, it means browser layout hasn't had @@ -115,6 +198,7 @@ export class FlamechartMinimapView extends Component { + if (element) { + this.overlayCanvas = element as HTMLCanvasElement + this.overlayCtx = this.overlayCanvas.getContext('2d') + this.renderCanvas() + } else { + this.overlayCanvas = null + this.overlayCtx = null + } } componentDidMount() { @@ -258,6 +353,10 @@ export class FlamechartMinimapView extends Component + ) } @@ -269,7 +368,7 @@ export interface OverlayRectangleRendererProps { physicalSize: Vec2 } -export const overlayRectangleRenderer = (regl: regl.ReglCommandConstructor) => { +export const viewportRectangleRenderer = (regl: regl.ReglCommandConstructor) => { return regl({ vert: ` attribute vec2 position; diff --git a/flamechart-view.tsx b/flamechart-view.tsx index e781940..c6f9626 100644 --- a/flamechart-view.tsx +++ b/flamechart-view.tsx @@ -9,12 +9,12 @@ import * as regl from 'regl' import { vec3, ReglCommand, ReglCommandConstructor } from 'regl' import { Rect, Vec2, AffineTransform, clamp } from './math' -import { atMostOnceAFrame } from "./utils"; +import { atMostOnceAFrame, cachedMeasureTextWidth } from "./utils"; import { rectangleBatchRenderer, RectangleBatchRendererProps } from "./rectangle-batch-renderer" import { FlamechartMinimapView } from "./flamechart-minimap-view" import { style, Sizes } from './flamechart-style' -import { FontSize, FontFamily } from './style' +import { FontSize, FontFamily, Colors } from './style' interface FlamechartFrameLabel { configSpaceBounds: Rect @@ -41,14 +41,6 @@ function buildTrimmedText(text: string, length: number) { return prefix + ELLIPSIS + suffix } -const measureTextCache = new Map() -function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number { - if (!measureTextCache.has(text)) { - measureTextCache.set(text, ctx.measureText(text).width) - } - return measureTextCache.get(text)! -} - function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text const [lo,] = binarySearch(0, text.length, (n) => { @@ -117,7 +109,7 @@ export class FlamechartPanZoomView extends ReloadableComponent 5) { + interval *= 5 + } else if (targetInterval / interval > 2) { + interval *= 2 + } + + { + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)' + ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight) + + ctx.fillStyle = Colors.GRAY + for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) { + // TODO(jlfwong): Ensure that labels do not overlap + const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x) + const labelText = this.props.flamechart.formatValue(x) + const textWidth = cachedMeasureTextWidth(ctx, labelText) + + ctx.fillText(labelText, pos - textWidth - 5, 2) + ctx.fillRect(pos, 0, 1, physicalViewSize.y) + } + } } private resizeCanvasIfNeeded(windowResized = false) { @@ -372,7 +399,7 @@ export class FlamechartPanZoomView extends ReloadableComponent
- {this.formatTime(hoveredNode.getTotalTime())}{' '} + {this.formatValue(hoveredNode.getTotalWeight())}{' '} {hoveredNode.frame.name}
diff --git a/flamechart.ts b/flamechart.ts index 000d4c6..6ca94f3 100644 --- a/flamechart.ts +++ b/flamechart.ts @@ -15,6 +15,8 @@ type StackLayer = FlamechartFrame[] interface FlamechartDataSource { getTotalWeight(): number + formatValue(v: number): string + forEachCall( openFrame: (node: CallTreeNode, value: number) => void, closeFrame: (value: number) => void @@ -35,6 +37,7 @@ export class Flamechart { getLayers() { return this.layers } getFrameColors() { return this.frameColors } getMinFrameWidth() { return this.minFrameWidth } + formatValue(v: number) { return this.source.formatValue(v) } // TODO(jlfwong): Move this a color generation file private selectFrameColors(source: FlamechartDataSource) { @@ -132,6 +135,7 @@ export class Flamechart { console.assert(stack.length > 0) const stackTop = stack.pop()! stackTop.end = value + if (stackTop.end - stackTop.start === 0) return const layerIndex = stack.length while (this.layers.length <= layerIndex) this.layers.push([]) this.layers[layerIndex].push(stackTop) diff --git a/import/chrome.ts b/import/chrome.ts index 8661d46..d12e6ea 100644 --- a/import/chrome.ts +++ b/import/chrome.ts @@ -1,4 +1,4 @@ -import {Profile, FrameInfo} from '../profile' +import {Profile, TimeFormatter, FrameInfo} from '../profile' interface TimelineEvent { pid: number, @@ -106,5 +106,7 @@ export function importFromChrome(events: TimelineEvent[]) { profile.appendSample(stack, timeDelta) } + + profile.setValueFormatter(new TimeFormatter('us')) return profile } \ No newline at end of file diff --git a/import/stackprof.ts b/import/stackprof.ts index d8fc7c5..509ac0c 100644 --- a/import/stackprof.ts +++ b/import/stackprof.ts @@ -1,6 +1,6 @@ // https://github.com/tmm1/stackprof -import {Profile, FrameInfo} from '../profile' +import {Profile, TimeFormatter, FrameInfo} from '../profile' interface StackprofFrame { name: string @@ -41,5 +41,6 @@ export function importFromStackprof(stackprofProfile: StackprofProfile): Profile profile.appendSample(stack, sampleDuration) } + profile.setValueFormatter(new TimeFormatter('us')) return profile } \ No newline at end of file diff --git a/profile.ts b/profile.ts index e616472..c4af00e 100644 --- a/profile.ts +++ b/profile.ts @@ -21,8 +21,8 @@ export interface FrameInfo { export class HasWeights { private selfWeight = 0 private totalWeight = 0 - getSelfTime() { return this.selfWeight } - getTotalTime() { return this.totalWeight } + getSelfWeight() { return this.selfWeight } + getTotalWeight() { return this.totalWeight } addToTotalWeight(delta: number) { this.totalWeight += delta } addToSelfWeight(delta: number) { this.selfWeight += delta } } @@ -71,6 +71,36 @@ const rootFrame = new Frame({ name: '(speedscope root)', }) +export interface ValueFormatter { + format(v: number): string +} + +export class RawValueFormatter implements ValueFormatter { + format(v: number) { + return v.toLocaleString() + } +} + +export class TimeFormatter implements ValueFormatter { + private multiplier : number + + constructor(unit: 'ns' | 'us' | 'ms' | 's' = 'ns') { + if (unit === 'ns') this.multiplier = 1e-9 + else if (unit === 'us') this.multiplier = 1e-6 + else if (unit === 'ms') this.multiplier = 1e-3 + else this.multiplier = 1 + } + + format(v: number) { + const s = v * this.multiplier + + if (s / 1e0 > 1) return `${s.toFixed(2)}s` + if (s / 1e-3 > 1) return `${(s / 1e-3).toFixed(2)}ms` + if (s / 1e-6 > 1) return `${(s / 1e-6).toFixed(2)}us` + else return `${(s / 1e-9).toFixed(2)}ms` + } +} + export class Profile { private totalWeight: number @@ -83,13 +113,18 @@ export class Profile { private samples: CallTreeNode[] = [] private weights: number[] = [] + private valueFormatter: ValueFormatter = new RawValueFormatter() + constructor(totalWeight: number) { this.totalWeight = totalWeight } + formatValue(v: number) { return this.valueFormatter.format(v) } + setValueFormatter(f: ValueFormatter) { this.valueFormatter = f } + getTotalWeight() { return this.totalWeight } getTotalNonIdleWeight() { - return this.groupedCalltreeRoot.children.reduce((n, c) => n + c.getTotalTime(), 0) + return this.groupedCalltreeRoot.children.reduce((n, c) => n + c.getTotalWeight(), 0) } forEachCallGrouped( @@ -104,15 +139,15 @@ export class Profile { let childTime = 0 const children = [...node.children] - children.sort((a, b) => a.getTotalTime() > b.getTotalTime() ? -1 : 1) + children.sort((a, b) => a.getTotalWeight() > b.getTotalWeight() ? -1 : 1) children.forEach(function (child) { visit(child, start + childTime) - childTime += child.getTotalTime() + childTime += child.getTotalWeight() }) if (node.frame !== rootFrame) { - closeFrame(start + node.getTotalTime()) + closeFrame(start + node.getTotalWeight()) } } visit(this.groupedCalltreeRoot, 0) diff --git a/style.ts b/style.ts index 40c575f..42c4377 100644 --- a/style.ts +++ b/style.ts @@ -8,6 +8,7 @@ export enum FontSize { } export enum Colors { + LIGHT_GRAY = "#C4C4C4", MEDIUM_GRAY = "#BDBDBD", - LIGHT_GRAY = "#C4C4C4" + GRAY = '#666666' } diff --git a/utils.ts b/utils.ts index 5ddfc22..db2dc1b 100644 --- a/utils.ts +++ b/utils.ts @@ -13,4 +13,13 @@ export function atMostOnceAFrame(fn: F) { export function lastOf(ts: T[]): T | null { return ts[ts.length-1] || null +} + +// NOTE: This blindly assumes the same result across contexts. +const measureTextCache = new Map() +export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number { + if (!measureTextCache.has(text)) { + measureTextCache.set(text, ctx.measureText(text).width) + } + return measureTextCache.get(text)! } \ No newline at end of file