Basic horizontal axis labelling

This commit is contained in:
Jamie Wong 2018-01-06 19:37:36 -05:00
parent cf36b8e630
commit 2188025165
9 changed files with 217 additions and 38 deletions

View File

@ -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')

View File

@ -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<FlamechartMinimapViewProps, {}> {
renderer: ReglCommand<RectangleBatchRendererProps> | null = null
overlayRenderer: ReglCommand<OverlayRectangleRendererProps> | null = null
viewportRectRenderer: ReglCommand<OverlayRectangleRendererProps> | 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<FlamechartMinimapViewProps,
}
private configSpaceToPhysicalViewSpace() {
const minimapOrigin = new Vec2(0, Sizes.FRAME_HEIGHT * DEVICE_PIXEL_RATIO)
return AffineTransform.betweenRects(
new Rect(new Vec2(0, 0), this.configSpaceSize()),
new Rect(new Vec2(0, 0), this.physicalViewSize())
new Rect(minimapOrigin, this.physicalViewSize().minus(minimapOrigin))
)
}
@ -55,7 +61,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
}
private renderRects() {
if (!this.renderer || !this.canvas || !this.overlayRenderer) return
if (!this.renderer || !this.canvas || !this.viewportRectRenderer) return
this.resizeCanvasIfNeeded()
const configSpaceToNDC = this.physicalViewSpaceToNDC().times(this.configSpaceToPhysicalViewSpace())
@ -65,13 +71,66 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
physicalSize: this.physicalViewSize()
})
this.overlayRenderer({
this.viewportRectRenderer({
configSpaceViewportRect: this.props.configSpaceViewportRect,
configSpaceToPhysicalViewSpace: this.configSpaceToPhysicalViewSpace(),
physicalSize: this.physicalViewSize()
})
}
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
this.resizeOverlayCanvasIfNeeded()
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const left = 0
const right = this.configSpaceSize().x
// 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 physicalViewSpaceFrameHeight = Sizes.FRAME_HEIGHT * DEVICE_PIXEL_RATIO
const physicalViewSpaceFontSize = FontSize.LABEL * DEVICE_PIXEL_RATIO
const LABEL_PADDING_PX = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.textBaseline = 'top'
const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
let interval = minInterval
if (targetInterval / interval > 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<FlamechartMinimapViewProps,
this.ctx.viewport(0, 0, width, height)
}
private resizeOverlayCanvasIfNeeded() {
if (!this.overlayCanvas) return
let {width, height} = this.overlayCanvas.getBoundingClientRect()
{/*
We render text at a higher resolution then scale down to
ensure we're rendering at 1:1 device pixel ratio.
This ensures our text is rendered crisply.
*/}
width = Math.floor(width)
height = Math.floor(height)
// Still initializing: don't resize yet
if (width === 0 || height === 0) return
const scaledWidth = width * DEVICE_PIXEL_RATIO
const scaledHeight = height * DEVICE_PIXEL_RATIO
if (scaledWidth === this.overlayCanvas.width &&
scaledHeight === this.overlayCanvas.height) return
this.overlayCanvas.width = scaledWidth
this.overlayCanvas.height = scaledHeight
}
private renderCanvas = atMostOnceAFrame(() => {
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<FlamechartMinimapViewProps,
depth: 1
})
this.renderRects()
this.renderOverlays()
}
})
@ -236,7 +320,18 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
}
this.renderer = rectangleBatchRenderer(this.regl, configSpaceRects, colors, 0)
this.overlayRenderer = overlayRectangleRenderer(this.regl);
this.viewportRectRenderer = viewportRectangleRenderer(this.regl);
}
private overlayCanvasRef = (element?: Element) => {
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<FlamechartMinimapViewProps,
width={1} height={1}
ref={this.canvasRef}
className={css(style.fill)} />
<canvas
width={1} height={1}
ref={this.overlayCanvasRef}
className={css(style.fill)} />
</div>
)
}
@ -269,7 +368,7 @@ export interface OverlayRectangleRendererProps {
physicalSize: Vec2
}
export const overlayRectangleRenderer = (regl: regl.ReglCommandConstructor) => {
export const viewportRectangleRenderer = (regl: regl.ReglCommandConstructor) => {
return regl<OverlayRectangleRendererProps>({
vert: `
attribute vec2 position;

View File

@ -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<string, number>()
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<FlamechartPanZoom
const layer = layers[i]
for (let flamechartFrame of layer) {
const configSpaceBounds = new Rect(
new Vec2(flamechartFrame.start, i),
new Vec2(flamechartFrame.start, i+1),
new Vec2(flamechartFrame.end - flamechartFrame.start, 1)
)
configSpaceRects.push(configSpaceBounds)
@ -214,10 +206,9 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
this.overlayCanvas.width = scaledWidth
this.overlayCanvas.height = scaledHeight
}
private renderLabels() {
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
this.resizeOverlayCanvasIfNeeded()
@ -245,12 +236,13 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.fillStyle = 'rgba(80, 70, 70, 1)'
ctx.fillStyle = Colors.GRAY
ctx.textBaseline = 'top'
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
const LABEL_PADDING_PX = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
for (let label of this.labels) {
const LABEL_PADDING_PX = 2 * DEVICE_PIXEL_RATIO
let physicalLabelBounds = configToPhysical.transformRect(label.configSpaceBounds)
physicalLabelBounds = physicalLabelBounds
@ -275,6 +267,41 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
const trimmedText = trimTextMid(ctx, label.node.frame.name, physicalLabelBounds.width())
ctx.fillText(trimmedText, physicalLabelBounds.left(), physicalLabelBounds.top())
}
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) {
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<FlamechartPanZoom
} else {
if (!this.renderer) this.preprocess(this.props.flamechart)
this.renderRects()
this.renderLabels()
this.renderOverlays()
}
})
@ -581,9 +608,9 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
});
}
formatTime(timeInNs: number) {
const totalTimeNs = this.props.flamechart.getTotalWeight()
return `${(timeInNs / 1000).toFixed(2)}ms (${(100 * timeInNs/totalTimeNs).toFixed()}%)`
formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
return `${this.props.flamechart.formatValue(weight)} (${(100 * weight/totalWeight).toFixed()}%)`
}
renderTooltip() {
@ -617,7 +644,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
return (
<div className={css(style.hoverTip)} style={positionStyle}>
<div className={css(style.hoverTipRow)}>
<span className={css(style.hoverCount)}>{this.formatTime(hoveredNode.getTotalTime())}</span>{' '}
<span className={css(style.hoverCount)}>{this.formatValue(hoveredNode.getTotalWeight())}</span>{' '}
{hoveredNode.frame.name}
</div>
</div>

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -8,6 +8,7 @@ export enum FontSize {
}
export enum Colors {
LIGHT_GRAY = "#C4C4C4",
MEDIUM_GRAY = "#BDBDBD",
LIGHT_GRAY = "#C4C4C4"
GRAY = '#666666'
}

View File

@ -13,4 +13,13 @@ export function atMostOnceAFrame<F extends Function>(fn: F) {
export function lastOf<T>(ts: T[]): T | null {
return ts[ts.length-1] || null
}
// NOTE: This blindly assumes the same result across contexts.
const measureTextCache = new Map<string, number>()
export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number {
if (!measureTextCache.has(text)) {
measureTextCache.set(text, ctx.measureText(text).width)
}
return measureTextCache.get(text)!
}