Upgrade the "Table view" to a "Sandwich" view (#73)

![image](https://user-images.githubusercontent.com/150329/41837387-25417bae-7812-11e8-83cb-d3e6782b734e.png)

This provides information about the caller & callees of individual functions selected in the table view.
This commit is contained in:
Jamie Wong
2018-06-29 12:06:19 -07:00
committed by GitHub
parent 7f7f5eeefb
commit 0e654801b5
17 changed files with 1670 additions and 971 deletions

View File

@@ -49,21 +49,27 @@ To load a specific profile by URL, you can append a hash fragment like `#profile
## Views
Both of the main views of the applications display flame graphs.
In this view, the horizontal axis represents the "weight" of each stack (most commonly CPU time), and the vertical axis shows you the stack active at the time of the sample.
### 🕰Time Order
In the "Time Order" view (the default), the stacks are ordered left-to-right in the same order as the occurred in the input file, which is usually going to be the chronological order they were recorded in. This view is most helpful for understand the behavior of an application over time, e.g. "first the data is fetched from the database, then the data is prepared for serialization, then the data is serialized to JSON". This is the only flame graph order supported by Chrome developer tools.
In all flamegraph views, the horizontal axis represents the "weight" of each stack (most commonly CPU time), and the vertical axis shows you the stack active at the time of the sample.
If you click on one of the frames, you'll be able to see summary statistics about it.
![Detail View](https://user-images.githubusercontent.com/150329/42108613-e6ef6d3a-7b8f-11e8-93d4-541b2cb93fe5.png)
### ⬅Left Heavy
In the "Left Heavy" view, identical stacks are grouped together, regardless of whether they were recorded sequentially. Then, the stacks are sorted so that the heaviest stack for each parent is on the left -- hence "left heavy". This view is useful for understanding where all the time is going in situations where there are hundreds or thousands of function calls interleaved between other call stacks.
### 📒Table View
### 🥪 Sandwich
In this "Table View", you can find a list of all functions an their associated times. You can sort by self time or total time.
The Sandwich view is a table view in which you can find a list of all functions an their associated times. You can sort by self time or total time.
It's called "Sandwich" view because if you select one of the rows in the table, you can see flamegraphs for all the callers and callees of the selected
row.
![Sandwich View](https://user-images.githubusercontent.com/150329/42108467-76a57baa-7b8f-11e8-815f-1df7b6ac3ede.png)
## Navigation
@@ -80,6 +86,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
* Pinch to zoom
* Hold Cmd+Scroll to zoom
* Double click on a frame to fit the viewport to it
* Click on a frame to view summary statistics about it
### Keyboard Navigation
@@ -89,5 +96,5 @@ Once a profile has loaded, the main view is split into two: the top area is the
* `w`/`a`/`s`/`d` or arrow keys: pan around the profile
* `1`: Switch to the "Time Order" view
* `2`: Switch to the "Left Heavy" view
* `3`: Switch to the table view
* `3`: Switch to the "Sandwich" view
* `r`: Collapse recursion in the flamegraphs

View File

@@ -21,11 +21,12 @@ import {Flamechart} from './flamechart'
import {FlamechartView} from './flamechart-view'
import {FontFamily, FontSize, Colors, Sizes} from './style'
import {getHashParams, HashParams} from './hash-params'
import {ProfileTableView, SortMethod, SortField, SortDirection} from './profile-table-view'
import {SortMethod, SortField, SortDirection} from './profile-table-view'
import {triangle} from './utils'
import {Color} from './color'
import {RowAtlas} from './row-atlas'
import {importAsmJsSymbolMap} from './asm-js'
import {SandwichView} from './sandwich-view'
declare function require(x: string): any
const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
@@ -33,7 +34,7 @@ const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-st
const enum ViewMode {
CHRONO_FLAME_CHART,
LEFT_HEAVY_FLAME_GRAPH,
TABLE_VIEW,
SANDWICH_VIEW,
}
interface ApplicationState {
@@ -130,8 +131,8 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
}
setTableView = () => {
this.props.setViewMode(ViewMode.TABLE_VIEW)
setSandwichView = () => {
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
}
render() {
@@ -179,11 +180,11 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
<div
className={css(
style.toolbarTab,
this.props.viewMode === ViewMode.TABLE_VIEW && style.toolbarTabActive,
this.props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
)}
onClick={this.setTableView}
onClick={this.setSandwichView}
>
<span className={css(style.emoji)}>📒</span>Table View
<span className={css(style.emoji)}>🥪</span>Sandwich
</div>
{help}
</div>
@@ -480,7 +481,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
ev.preventDefault()
}
onWindowKeyPress = (ev: KeyboardEvent) => {
onWindowKeyPress = async (ev: KeyboardEvent) => {
if (ev.key === '1') {
this.setState({
viewMode: ViewMode.CHRONO_FLAME_CHART,
@@ -491,16 +492,16 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
})
} else if (ev.key === '3') {
this.setState({
viewMode: ViewMode.TABLE_VIEW,
viewMode: ViewMode.SANDWICH_VIEW,
})
} else if (ev.key === 'r') {
const {flattenRecursion, profile} = this.state
if (!profile) return
if (flattenRecursion) {
this.setActiveProfile(profile)
await this.setActiveProfile(profile)
this.setState({flattenRecursion: false})
} else {
this.setActiveProfile(profile.flattenRecursion())
await this.setActiveProfile(profile.getProfileWithRecursionFlattened())
this.setState({flattenRecursion: true})
}
}
@@ -649,6 +650,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
}
getColorBucketForFrame = (frame: Frame): number => {
const {chronoFlamechart} = this.state
if (!chronoFlamechart) return 0
return chronoFlamechart.getColorBucketForFrame(frame)
}
getCSSColorForFrame = (frame: Frame): string => {
const {chronoFlamechart} = this.state
if (!chronoFlamechart) return '#FFFFFF'
@@ -710,13 +717,18 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
/>
)
}
case ViewMode.TABLE_VIEW: {
case ViewMode.SANDWICH_VIEW: {
if (!this.rowAtlas || !this.state.profile) return null
return (
<ProfileTableView
profile={this.state.activeProfile}
<SandwichView
profile={this.state.profile}
flattenRecursion={this.state.flattenRecursion}
getColorBucketForFrame={this.getColorBucketForFrame}
getCSSColorForFrame={this.getCSSColorForFrame}
sortMethod={this.state.tableSortMethod}
setSortMethod={this.setTableSortMethod}
canvasContext={this.canvasContext}
rowAtlas={this.rowAtlas}
/>
)
}

123
flamechart-detail-view.tsx Normal file
View File

@@ -0,0 +1,123 @@
import {StyleDeclarationValue, css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {h} from 'preact'
import {style} from './flamechart-style'
import {formatPercent} from './utils'
import {Frame, CallTreeNode} from './profile'
import {ColorChit} from './color-chit'
import {Flamechart} from './flamechart'
interface StatisticsTableProps {
title: string
grandTotal: number
selectedTotal: number
selectedSelf: number
cellStyle: StyleDeclarationValue
formatter: (v: number) => string
}
class StatisticsTable extends ReloadableComponent<StatisticsTableProps, {}> {
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 (
<div className={css(style.statsTable)}>
<div className={css(this.props.cellStyle, style.statsTableCell, style.statsTableHeader)}>
{this.props.title}
</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>Total</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>Self</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>{total}</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>{self}</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>
{formatPercent(totalPerc)}
<div className={css(style.barDisplay)} style={{height: `${totalPerc}%`}} />
</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>
{formatPercent(selfPerc)}
<div className={css(style.barDisplay)} style={{height: `${selfPerc}%`}} />
</div>
</div>
)
}
}
interface StackTraceViewProps {
getFrameColor: (frame: Frame) => string
node: CallTreeNode
}
class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
render() {
const rows: JSX.Element[] = []
let node: CallTreeNode | null = this.props.node
for (; node && !node.isRoot(); node = node.parent) {
const row: (JSX.Element | string)[] = []
const {frame} = node
row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
if (rows.length) {
row.push(<span className={css(style.stackFileLine)}>> </span>)
}
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(<span className={css(style.stackFileLine)}> ({pos})</span>)
}
rows.push(<div className={css(style.stackLine)}>{row}</div>)
}
return (
<div className={css(style.stackTraceView)}>
<div className={css(style.stackTraceViewPadding)}>{rows}</div>
</div>
)
}
}
interface FlamechartDetailViewProps {
flamechart: Flamechart
getCSSColorForFrame: (frame: Frame) => string
selectedNode: CallTreeNode
}
export class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
render() {
const {flamechart, selectedNode} = this.props
const {frame} = selectedNode
return (
<div className={css(style.detailView)}>
<StatisticsTable
title={'This Instance'}
cellStyle={style.thisInstanceCell}
grandTotal={flamechart.getTotalWeight()}
selectedTotal={selectedNode.getTotalWeight()}
selectedSelf={selectedNode.getSelfWeight()}
formatter={flamechart.formatValue.bind(flamechart)}
/>
<StatisticsTable
title={'All Instances'}
cellStyle={style.allInstancesCell}
grandTotal={flamechart.getTotalWeight()}
selectedTotal={frame.getTotalWeight()}
selectedSelf={frame.getSelfWeight()}
formatter={flamechart.formatValue.bind(flamechart)}
/>
<StackTraceView node={selectedNode} getFrameColor={this.props.getCSSColorForFrame} />
</div>
)
}
}

View File

@@ -3,11 +3,11 @@ import {css} from 'aphrodite'
import {Flamechart} from './flamechart'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {FlamechartRenderer} from './flamechart-renderer'
import {cachedMeasureTextWidth} from './utils'
import {style} from './flamechart-style'
import {FontFamily, FontSize, Colors, Sizes} from './style'
import {FontFamily, FontSize, Colors, Sizes, commonStyle} from './style'
import {CanvasContext} from './canvas-context'
import {TextureCachedRenderer} from './texture-cached-renderer'
import {cachedMeasureTextWidth} from './text-utils'
interface FlamechartMinimapViewProps {
flamechart: Flamechart
@@ -444,7 +444,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
onWheel={this.onWheel}
onMouseDown={this.onMouseDown}
onMouseMove={this.onMouseMove}
className={css(style.minimap, style.vbox)}
className={css(style.minimap, commonStyle.vbox)}
>
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
</div>

View File

@@ -0,0 +1,697 @@
import {Rect, AffineTransform, Vec2, clamp} from './math'
import {CallTreeNode} from './profile'
import {Flamechart, FlamechartFrame} from './flamechart'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer} from './flamechart-renderer'
import {ReloadableComponent} from './reloadable'
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from './text-utils'
import {style} from './flamechart-style'
import {h} from 'preact'
import {css} from 'aphrodite'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
node: CallTreeNode
}
/**
* Component to visualize a Flamechart and interact with it via hovering,
* zooming, and panning.
*
* There are 3 vector spaces involved:
* - Configuration Space: In this space, the horizontal unit is ms, and the
* vertical unit is stack depth. Each stack frame is one unit high.
* - Logical view space: Origin is top-left, with +y downwards. This represents
* the coordinate space of the view as specified in CSS: horizontal and vertical
* units are both "logical" pixels.
* - Physical view space: Origin is top-left, with +y downwards. This represents
* the coordinate space of the view as specified in hardware pixels: horizontal
* and vertical units are both "physical" pixels.
*
* We use two canvases to draw the flamechart itself: one for the rectangles,
* which we render via WebGL, and one for the labels, which we render via 2D
* canvas primitives.
*/
export interface FlamechartPanZoomViewProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
selectedNode: CallTreeNode | null
onNodeHover: (hover: {node: CallTreeNode; event: MouseEvent} | null) => void
onNodeSelect: (node: CallTreeNode | null) => void
configSpaceViewportRect: Rect
transformViewport: (transform: AffineTransform) => void
setConfigSpaceViewportRect: (rect: Rect) => void
}
export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoomViewProps, {}> {
private container: Element | null = null
private containerRef = (element?: Element) => {
this.container = element || null
}
private overlayCanvas: HTMLCanvasElement | null = null
private overlayCtx: CanvasRenderingContext2D | null = null
private hoveredLabel: FlamechartFrameLabel | null = null
private setConfigSpaceViewportRect(r: Rect) {
this.props.setConfigSpaceViewportRect(r)
}
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
}
}
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
this.props.flamechart.getLayers().length,
)
}
private physicalViewSize() {
return new Vec2(
this.overlayCanvas ? this.overlayCanvas.width : 0,
this.overlayCanvas ? this.overlayCanvas.height : 0,
)
}
private physicalBounds(): Rect {
if (this.props.renderInverted) {
// If we're rendering inverted and the flamegraph won't fill the viewport,
// we want to stick the flamegraph to the bottom of the viewport, not the top.
const physicalViewportHeight = this.physicalViewSize().y
const physicalFlamegraphHeight =
(this.configSpaceSize().y + 1) *
this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT *
window.devicePixelRatio
if (physicalFlamegraphHeight < physicalViewportHeight) {
return new Rect(
new Vec2(0, physicalViewportHeight - physicalFlamegraphHeight),
this.physicalViewSize(),
)
}
}
return new Rect(new Vec2(0, 0), this.physicalViewSize())
}
private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = Sizes.FRAME_HEIGHT
private configSpaceToPhysicalViewSpace() {
return AffineTransform.betweenRects(this.props.configSpaceViewportRect, this.physicalBounds())
}
private logicalToPhysicalViewSpace() {
return AffineTransform.withScale(new Vec2(window.devicePixelRatio, window.devicePixelRatio))
}
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 * window.devicePixelRatio
const scaledHeight = height * window.devicePixelRatio
if (scaledWidth === this.overlayCanvas.width && scaledHeight === this.overlayCanvas.height)
return
this.overlayCanvas.width = scaledWidth
this.overlayCanvas.height = scaledHeight
}
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
this.resizeOverlayCanvasIfNeeded()
if (this.props.configSpaceViewportRect.isEmpty()) return
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const physicalViewSpaceFrameHeight =
this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
if (this.hoveredLabel) {
let color = Colors.DARK_GRAY
if (this.props.selectedNode === this.hoveredLabel.node) {
color = Colors.DARK_BLUE
}
ctx.lineWidth = 2 * devicePixelRatio
ctx.strokeStyle = color
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
ctx.strokeRect(
Math.round(physicalViewBounds.left()),
Math.round(physicalViewBounds.top()),
Math.round(Math.max(0, physicalViewBounds.width())),
Math.round(Math.max(0, physicalViewBounds.height())),
)
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = Colors.DARK_GRAY
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
const minConfigSpaceWidthToRender = (
configToPhysical.inverseTransformVector(new Vec2(minWidthToRender, 0)) || new Vec2(0, 0)
).x
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
if (width < minConfigSpaceWidthToRender) return
if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return
if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return
if (this.props.renderInverted) {
if (configSpaceBounds.bottom() < this.props.configSpaceViewportRect.top()) return
} else {
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
}
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
let physicalLabelBounds = configToPhysical.transformRect(configSpaceBounds)
if (physicalLabelBounds.left() < 0) {
physicalLabelBounds = physicalLabelBounds
.withOrigin(physicalLabelBounds.origin.withX(0))
.withSize(
physicalLabelBounds.size.withX(
physicalLabelBounds.size.x + physicalLabelBounds.left(),
),
)
}
if (physicalLabelBounds.right() > physicalViewSize.x) {
physicalLabelBounds = physicalLabelBounds.withSize(
physicalLabelBounds.size.withX(physicalViewSize.x - physicalLabelBounds.left()),
)
}
const trimmedText = trimTextMid(
ctx,
frame.node.frame.name,
physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
)
// Note that this is specifying the position of the starting text
// baseline.
ctx.fillText(
trimmedText,
physicalLabelBounds.left() + LABEL_PADDING_PX,
Math.round(
physicalLabelBounds.bottom() -
(physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
),
)
}
for (let child of frame.children) {
renderFrameLabelAndChildren(child, depth + 1)
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderFrameLabelAndChildren(frame)
}
const frameOutlineWidth = 2 * window.devicePixelRatio
ctx.strokeStyle = Colors.PALE_DARK_BLUE
ctx.lineWidth = frameOutlineWidth
const minConfigSpaceWidthToRenderOutline = (
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
).x
const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
if (!this.props.selectedNode) return
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
const configSpaceBounds = new Rect(new Vec2(frame.start, y), 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.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
Math.round(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 * window.devicePixelRatio
const physicalViewSize = this.physicalViewSize()
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
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
}
{
const y = this.props.renderInverted ? physicalViewSize.y - physicalViewSpaceFrameHeight : 0
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillRect(0, y, physicalViewSize.x, physicalViewSpaceFrameHeight)
ctx.fillStyle = Colors.DARK_GRAY
ctx.textBaseline = 'top'
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 - labelPaddingPx, y + labelPaddingPx)
ctx.fillRect(pos, 0, 1, physicalViewSize.y)
}
}
}
private lastBounds: ClientRect | null = null
private updateConfigSpaceViewport() {
if (!this.container) return
const bounds = this.container.getBoundingClientRect()
const {width, height} = bounds
// Still initializing: don't resize yet
if (width < 2 || height < 2) return
if (this.lastBounds == null) {
const configSpaceViewportHeight = height / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
if (this.props.renderInverted) {
this.setConfigSpaceViewportRect(
new Rect(
new Vec2(0, this.configSpaceSize().y - configSpaceViewportHeight + 1),
new Vec2(this.configSpaceSize().x, configSpaceViewportHeight),
),
)
} else {
this.setConfigSpaceViewportRect(
new Rect(new Vec2(0, -1), new Vec2(this.configSpaceSize().x, configSpaceViewportHeight)),
)
}
} else if (this.lastBounds.width !== width || this.lastBounds.height !== height) {
// Resize the viewport rectangle to match the window size aspect
// ratio.
this.setConfigSpaceViewportRect(
this.props.configSpaceViewportRect.withSize(
this.props.configSpaceViewportRect.size.timesPointwise(
new Vec2(width / this.lastBounds.width, height / this.lastBounds.height),
),
),
)
}
this.lastBounds = bounds
}
onWindowResize = () => {
this.updateConfigSpaceViewport()
this.onBeforeFrame()
}
private renderRects() {
if (!this.container) return
this.updateConfigSpaceViewport()
if (this.props.configSpaceViewportRect.isEmpty()) return
this.props.canvasContext.renderInto(this.container, context => {
this.props.flamechartRenderer.render({
physicalSpaceDstRect: this.physicalBounds(),
configSpaceSrcRect: this.props.configSpaceViewportRect,
renderOutlines: true,
})
})
}
// Inertial scrolling introduces tricky interaction problems.
// Namely, if you start panning, and hit the edge of the scrollable
// area, the browser continues to receive WheelEvents from inertial
// scrolling. If we start zooming by holding Cmd + scrolling, then
// release the Cmd key, this can cause us to interpret the incoming
// inertial scrolling events as panning. To prevent this, we introduce
// a concept of an "Interaction Lock". Once a certain interaction has
// begun, we don't allow the other type of interaction to begin until
// we've received two frames with no inertial wheel events. This
// prevents us from accidentally switching between panning & zooming.
private frameHadWheelEvent = false
private framesWithoutWheelEvents = 0
private interactionLock: 'pan' | 'zoom' | null = null
private maybeClearInteractionLock = () => {
if (this.interactionLock) {
if (!this.frameHadWheelEvent) {
this.framesWithoutWheelEvents++
if (this.framesWithoutWheelEvents >= 2) {
this.interactionLock = null
this.framesWithoutWheelEvents = 0
}
}
this.props.canvasContext.requestFrame()
}
this.frameHadWheelEvent = false
}
private onBeforeFrame = () => {
this.renderRects()
this.renderOverlays()
this.maybeClearInteractionLock()
}
private renderCanvas = () => {
this.props.canvasContext.requestFrame()
}
private pan(logicalViewSpaceDelta: Vec2) {
this.interactionLock = 'pan'
const physicalDelta = this.logicalToPhysicalViewSpace().transformVector(logicalViewSpaceDelta)
const configDelta = this.configSpaceToPhysicalViewSpace().inverseTransformVector(physicalDelta)
if (this.hoveredLabel) {
this.props.onNodeHover(null)
}
if (!configDelta) return
this.props.transformViewport(AffineTransform.withTranslation(configDelta))
}
private zoom(logicalViewSpaceCenter: Vec2, multiplier: number) {
this.interactionLock = 'zoom'
const physicalCenter = this.logicalToPhysicalViewSpace().transformPosition(
logicalViewSpaceCenter,
)
const configSpaceCenter = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
physicalCenter,
)
if (!configSpaceCenter) return
const zoomTransform = AffineTransform.withTranslation(configSpaceCenter.times(-1))
.scaledBy(new Vec2(multiplier, 1))
.translatedBy(configSpaceCenter)
this.props.transformViewport(zoomTransform)
}
private lastDragPos: Vec2 | null = null
private onMouseDown = (ev: MouseEvent) => {
this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
this.updateCursor()
window.addEventListener('mouseup', this.onWindowMouseUp)
}
private onMouseDrag = (ev: MouseEvent) => {
if (!this.lastDragPos) return
const logicalMousePos = new Vec2(ev.offsetX, ev.offsetY)
this.pan(this.lastDragPos.minus(logicalMousePos))
this.lastDragPos = logicalMousePos
// When panning by scrolling, the element under
// the cursor will change, so clear the hovered label.
if (this.hoveredLabel) {
this.props.onNodeHover(null)
}
}
private onDblClick = (ev: MouseEvent) => {
if (this.hoveredLabel) {
const hoveredBounds = this.hoveredLabel.configSpaceBounds
const viewportRect = new Rect(
hoveredBounds.origin.minus(new Vec2(0, 1)),
hoveredBounds.size.withY(this.props.configSpaceViewportRect.height()),
)
this.props.setConfigSpaceViewportRect(viewportRect)
}
}
private onClick = (ev: MouseEvent) => {
if (this.hoveredLabel) {
this.props.onNodeSelect(this.hoveredLabel.node)
this.renderCanvas()
} else {
this.props.onNodeSelect(null)
}
}
private updateCursor() {
if (this.lastDragPos) {
document.body.style.cursor = 'grabbing'
document.body.style.cursor = '-webkit-grabbing'
} else {
document.body.style.cursor = 'default'
}
}
private onWindowMouseUp = (ev: MouseEvent) => {
this.lastDragPos = null
this.updateCursor()
window.removeEventListener('mouseup', this.onWindowMouseUp)
}
private onMouseMove = (ev: MouseEvent) => {
this.updateCursor()
if (this.lastDragPos) {
ev.preventDefault()
this.onMouseDrag(ev)
return
}
this.hoveredLabel = null
const logicalViewSpaceMouse = new Vec2(ev.offsetX, ev.offsetY)
const physicalViewSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(
logicalViewSpaceMouse,
)
const configSpaceMouse = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
physicalViewSpaceMouse,
)
if (!configSpaceMouse) return
const setHoveredLabel = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
if (configSpaceMouse.x < configSpaceBounds.left()) return null
if (configSpaceMouse.x > configSpaceBounds.right()) return null
if (configSpaceBounds.contains(configSpaceMouse)) {
this.hoveredLabel = {
configSpaceBounds,
node: frame.node,
}
}
for (let child of frame.children) {
setHoveredLabel(child, depth + 1)
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
setHoveredLabel(frame)
}
if (this.hoveredLabel) {
this.props.onNodeHover({node: this.hoveredLabel!.node, event: ev})
} else {
this.props.onNodeHover(null)
}
this.renderCanvas()
}
private onMouseLeave = (ev: MouseEvent) => {
this.hoveredLabel = null
this.props.onNodeHover(null)
this.renderCanvas()
}
private onWheel = (ev: WheelEvent) => {
ev.preventDefault()
this.frameHadWheelEvent = true
const isZoom = ev.metaKey || ev.ctrlKey
let deltaY = ev.deltaY
let deltaX = ev.deltaX
if (ev.deltaMode === ev.DOM_DELTA_LINE) {
deltaY *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
deltaX *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
}
if (isZoom && this.interactionLock !== 'pan') {
let multiplier = 1 + deltaY / 100
// On Chrome & Firefox, pinch-to-zoom maps to
// WheelEvent + Ctrl Key. We'll accelerate it in
// this case, since it feels a bit sluggish otherwise.
if (ev.ctrlKey) {
multiplier = 1 + deltaY / 40
}
multiplier = clamp(multiplier, 0.1, 10.0)
this.zoom(new Vec2(ev.offsetX, ev.offsetY), multiplier)
} else if (this.interactionLock !== 'zoom') {
this.pan(new Vec2(deltaX, deltaY))
}
this.renderCanvas()
}
onWindowKeyPress = (ev: KeyboardEvent) => {
if (!this.container) return
const {width, height} = this.container.getBoundingClientRect()
if (ev.key === '=' || ev.key === '+') {
this.zoom(new Vec2(width / 2, height / 2), 0.5)
ev.preventDefault()
} else if (ev.key === '-' || ev.key === '_') {
this.zoom(new Vec2(width / 2, height / 2), 2)
ev.preventDefault()
} else if (ev.key === '0') {
this.zoom(new Vec2(width / 2, height / 2), 1e9)
} else if (ev.key === 'ArrowRight' || ev.key === 'd') {
this.pan(new Vec2(100, 0))
} else if (ev.key === 'ArrowLeft' || ev.key === 'a') {
this.pan(new Vec2(-100, 0))
} else if (ev.key === 'ArrowUp' || ev.key === 'w') {
this.pan(new Vec2(0, -100))
} else if (ev.key === 'ArrowDown' || ev.key === 's') {
this.pan(new Vec2(0, 100))
} else if (ev.key === 'Escape') {
this.props.onNodeSelect(null)
this.renderCanvas()
}
}
shouldComponentUpdate() {
return false
}
componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
if (this.props.flamechart !== nextProps.flamechart) {
this.hoveredLabel = null
this.lastBounds = null
this.renderCanvas()
} else if (this.props.selectedNode !== nextProps.selectedNode) {
this.renderCanvas()
} else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
this.renderCanvas()
}
}
componentDidMount() {
this.props.canvasContext.addBeforeFrameHandler(this.onBeforeFrame)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
this.props.canvasContext.removeBeforeFrameHandler(this.onBeforeFrame)
window.removeEventListener('resize', this.onWindowResize)
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
return (
<div
className={css(style.panZoomView, commonStyle.vbox)}
onMouseDown={this.onMouseDown}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
onClick={this.onClick}
onDblClick={this.onDblClick}
onWheel={this.onWheel}
ref={this.containerRef}
>
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
</div>
)
}
}

View File

@@ -121,6 +121,10 @@ export class FlamechartRowAtlasKey {
}
}
interface RendererOptions {
inverted: boolean
}
export class FlamechartRenderer {
private layers: RangeTreeNode[] = []
private rectInfoTexture: regl.Texture
@@ -130,11 +134,12 @@ export class FlamechartRenderer {
private canvasContext: CanvasContext,
private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
private flamechart: Flamechart,
private options: RendererOptions = {inverted: false},
) {
const nLayers = flamechart.getLayers().length
for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
const leafNodes: RangeTreeLeafNode[] = []
const y = stackDepth
const y = options.inverted ? nLayers - 1 - stackDepth : stackDepth
let minLeft = Infinity
let maxRight = -Infinity
@@ -150,7 +155,7 @@ export class FlamechartRenderer {
leafNodes.push(
new RangeTreeLeafNode(
batch,
new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
rectCount,
),
)
@@ -183,7 +188,7 @@ export class FlamechartRenderer {
leafNodes.push(
new RangeTreeLeafNode(
batch,
new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
rectCount,
),
)
@@ -207,7 +212,9 @@ export class FlamechartRenderer {
const width = configSpaceContentWidth / Math.pow(2, zoomLevel)
return new Rect(new Vec2(width * index, stackDepth), new Vec2(width, 1))
const nLayers = this.flamechart.getLayers().length
const y = this.options.inverted ? nLayers - 1 - stackDepth : stackDepth
return new Rect(new Vec2(width * index, y), new Vec2(width, 1))
}
render(props: FlamechartRendererProps) {
@@ -251,8 +258,11 @@ export class FlamechartRenderer {
numAtlasEntriesPerLayer * configSpaceSrcRect.right() / configSpaceContentWidth,
)
for (let stackDepth = top; stackDepth < bottom; stackDepth++) {
const nLayers = this.flamechart.getLayers().length
for (let y = top; y < bottom; y++) {
for (let index = left; index <= right; index++) {
const stackDepth = this.options.inverted ? nLayers - 1 - y : y
const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, {
stackDepth,
zoomLevel,

View File

@@ -1,40 +1,10 @@
import {StyleSheet} from 'aphrodite'
import {FontFamily, FontSize, Colors, Sizes} from './style'
const HOVERTIP_PADDING = 2
import {FontSize, Colors, Sizes} from './style'
export const style = StyleSheet.create({
hoverTip: {
position: 'absolute',
background: Colors.WHITE,
border: '1px solid black',
maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
paddingTop: HOVERTIP_PADDING,
paddingBottom: HOVERTIP_PADDING,
pointerEvents: 'none',
userSelect: 'none',
fontSize: FontSize.LABEL,
fontFamily: FontFamily.MONOSPACE,
},
hoverTipRow: {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflowX: 'hidden',
paddingLeft: HOVERTIP_PADDING,
paddingRight: HOVERTIP_PADDING,
maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
},
hoverCount: {
color: Colors.GREEN,
},
clip: {
overflow: 'hidden',
},
vbox: {
display: 'flex',
flexDirection: 'column',
position: 'relative',
},
fill: {
width: '100%',
height: '100%',

View File

@@ -1,822 +1,21 @@
import {h} from 'preact'
import {css, StyleDeclarationValue} from 'aphrodite'
import {css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {CallTreeNode, Frame} from './profile'
import {Flamechart, FlamechartFrame} from './flamechart'
import {Flamechart} from './flamechart'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {cachedMeasureTextWidth, formatPercent} from './utils'
import {Rect, Vec2, AffineTransform} from './math'
import {formatPercent} from './utils'
import {FlamechartMinimapView} from './flamechart-minimap-view'
import {style} from './flamechart-style'
import {FontSize, FontFamily, Colors, Sizes} from './style'
import {Sizes, commonStyle} from './style'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer} from './flamechart-renderer'
import {ColorChit} from './color-chit'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
node: CallTreeNode
}
function binarySearch(
lo: number,
hi: number,
f: (val: number) => number,
target: number,
targetRangeSize = 1,
): [number, number] {
console.assert(!isNaN(targetRangeSize) && !isNaN(target))
while (true) {
if (hi - lo <= targetRangeSize) return [lo, hi]
const mid = (hi + lo) / 2
const val = f(mid)
if (val < target) lo = mid
else hi = mid
}
}
const ELLIPSIS = '\u2026'
function buildTrimmedText(text: string, length: number) {
const prefixLength = Math.floor(length / 2)
const prefix = text.substr(0, prefixLength)
const suffix = text.substr(text.length - prefixLength, prefixLength)
return prefix + ELLIPSIS + suffix
}
function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
const [lo] = binarySearch(
0,
text.length,
n => {
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
},
maxWidth,
)
return buildTrimmedText(text, lo)
}
/**
* Component to visualize a Flamechart and interact with it via hovering,
* zooming, and panning.
*
* There are 3 vector spaces involved:
* - Configuration Space: In this space, the horizontal unit is ms, and the
* vertical unit is stack depth. Each stack frame is one unit high.
* - Logical view space: Origin is top-left, with +y downwards. This represents
* the coordinate space of the view as specified in CSS: horizontal and vertical
* units are both "logical" pixels.
* - Physical view space: Origin is top-left, with +y downwards. This represents
* the coordinate space of the view as specified in hardware pixels: horizontal
* and vertical units are both "physical" pixels.
*
* We use two canvases to draw the flamechart itself: one for the rectangles,
* which we render via WebGL, and one for the labels, which we render via 2D
* canvas primitives.
*/
interface FlamechartPanZoomViewProps {
flamechart: Flamechart
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
}
export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoomViewProps, {}> {
private container: Element | null = null
private containerRef = (element?: Element) => {
this.container = element || null
}
private overlayCanvas: HTMLCanvasElement | null = null
private overlayCtx: CanvasRenderingContext2D | null = null
private hoveredLabel: FlamechartFrameLabel | null = null
private setConfigSpaceViewportRect(r: Rect) {
this.props.setConfigSpaceViewportRect(r)
}
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
}
}
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
this.props.flamechart.getLayers().length,
)
}
private physicalViewSize() {
return new Vec2(
this.overlayCanvas ? this.overlayCanvas.width : 0,
this.overlayCanvas ? this.overlayCanvas.height : 0,
)
}
private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = Sizes.FRAME_HEIGHT
private configSpaceToPhysicalViewSpace() {
return AffineTransform.betweenRects(
this.props.configSpaceViewportRect,
new Rect(new Vec2(0, 0), this.physicalViewSize()),
)
}
private logicalToPhysicalViewSpace() {
return AffineTransform.withScale(new Vec2(window.devicePixelRatio, window.devicePixelRatio))
}
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 * window.devicePixelRatio
const scaledHeight = height * window.devicePixelRatio
if (scaledWidth === this.overlayCanvas.width && scaledHeight === this.overlayCanvas.height)
return
this.overlayCanvas.width = scaledWidth
this.overlayCanvas.height = scaledHeight
}
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
this.resizeOverlayCanvasIfNeeded()
if (this.props.configSpaceViewportRect.isEmpty()) return
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const physicalViewSpaceFrameHeight =
this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
if (this.hoveredLabel) {
let color = Colors.DARK_GRAY
if (this.props.selectedNode === this.hoveredLabel.node) {
color = Colors.DARK_BLUE
}
ctx.lineWidth = 2 * devicePixelRatio
ctx.strokeStyle = color
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
ctx.strokeRect(
Math.round(physicalViewBounds.left()),
Math.round(physicalViewBounds.top()),
Math.round(Math.max(0, physicalViewBounds.width())),
Math.round(Math.max(0, physicalViewBounds.height())),
)
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = Colors.DARK_GRAY
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
const minConfigSpaceWidthToRender = (
configToPhysical.inverseTransformVector(new Vec2(minWidthToRender, 0)) || new Vec2(0, 0)
).x
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1))
if (width < minConfigSpaceWidthToRender) 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)) {
let physicalLabelBounds = configToPhysical.transformRect(configSpaceBounds)
if (physicalLabelBounds.left() < 0) {
physicalLabelBounds = physicalLabelBounds
.withOrigin(physicalLabelBounds.origin.withX(0))
.withSize(
physicalLabelBounds.size.withX(
physicalLabelBounds.size.x + physicalLabelBounds.left(),
),
)
}
if (physicalLabelBounds.right() > physicalViewSize.x) {
physicalLabelBounds = physicalLabelBounds.withSize(
physicalLabelBounds.size.withX(physicalViewSize.x - physicalLabelBounds.left()),
)
}
const trimmedText = trimTextMid(
ctx,
frame.node.frame.name,
physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
)
// Note that this is specifying the position of the starting text
// baseline.
ctx.fillText(
trimmedText,
physicalLabelBounds.left() + LABEL_PADDING_PX,
Math.round(
physicalLabelBounds.bottom() -
(physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
),
)
}
for (let child of frame.children) {
renderFrameLabelAndChildren(child, depth + 1)
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderFrameLabelAndChildren(frame)
}
const frameOutlineWidth = 2 * window.devicePixelRatio
ctx.strokeStyle = Colors.PALE_DARK_BLUE
ctx.lineWidth = frameOutlineWidth
const minConfigSpaceWidthToRenderOutline = (
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
).x
const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
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.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
Math.round(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 * window.devicePixelRatio
const physicalViewSize = this.physicalViewSize()
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
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.DARK_GRAY
ctx.textBaseline = 'top'
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 - labelPaddingPx, labelPaddingPx)
ctx.fillRect(pos, 0, 1, physicalViewSize.y)
}
}
}
private lastBounds: ClientRect | null = null
private updateConfigSpaceViewport() {
if (!this.container) return
const bounds = this.container.getBoundingClientRect()
const {width, height} = bounds
// Still initializing: don't resize yet
if (width < 2 || height < 2) return
if (this.lastBounds == null) {
this.setConfigSpaceViewportRect(
new Rect(
new Vec2(0, -1),
new Vec2(this.configSpaceSize().x, height / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT),
),
)
} else if (this.lastBounds.width !== width || this.lastBounds.height !== height) {
// Resize the viewport rectangle to match the window size aspect
// ratio.
this.setConfigSpaceViewportRect(
this.props.configSpaceViewportRect.withSize(
this.props.configSpaceViewportRect.size.timesPointwise(
new Vec2(width / this.lastBounds.width, height / this.lastBounds.height),
),
),
)
}
this.lastBounds = bounds
}
onWindowResize = () => {
this.updateConfigSpaceViewport()
this.onBeforeFrame()
}
private renderRects() {
if (!this.container) return
this.updateConfigSpaceViewport()
if (this.props.configSpaceViewportRect.isEmpty()) return
this.props.canvasContext.renderInto(this.container, context => {
this.props.flamechartRenderer.render({
physicalSpaceDstRect: new Rect(Vec2.zero, this.physicalViewSize()),
configSpaceSrcRect: this.props.configSpaceViewportRect,
renderOutlines: true,
})
})
}
// Inertial scrolling introduces tricky interaction problems.
// Namely, if you start panning, and hit the edge of the scrollable
// area, the browser continues to receive WheelEvents from inertial
// scrolling. If we start zooming by holding Cmd + scrolling, then
// release the Cmd key, this can cause us to interpret the incoming
// inertial scrolling events as panning. To prevent this, we introduce
// a concept of an "Interaction Lock". Once a certain interaction has
// begun, we don't allow the other type of interaction to begin until
// we've received two frames with no inertial wheel events. This
// prevents us from accidentally switching between panning & zooming.
private frameHadWheelEvent = false
private framesWithoutWheelEvents = 0
private interactionLock: 'pan' | 'zoom' | null = null
private maybeClearInteractionLock = () => {
if (this.interactionLock) {
if (!this.frameHadWheelEvent) {
this.framesWithoutWheelEvents++
if (this.framesWithoutWheelEvents >= 2) {
this.interactionLock = null
this.framesWithoutWheelEvents = 0
}
}
this.props.canvasContext.requestFrame()
}
this.frameHadWheelEvent = false
}
private onBeforeFrame = () => {
this.renderRects()
this.renderOverlays()
this.maybeClearInteractionLock()
}
private renderCanvas = () => {
this.props.canvasContext.requestFrame()
}
private pan(logicalViewSpaceDelta: Vec2) {
this.interactionLock = 'pan'
const physicalDelta = this.logicalToPhysicalViewSpace().transformVector(logicalViewSpaceDelta)
const configDelta = this.configSpaceToPhysicalViewSpace().inverseTransformVector(physicalDelta)
if (this.hoveredLabel) {
this.props.setNodeHover(null, Vec2.zero)
}
if (!configDelta) return
this.props.transformViewport(AffineTransform.withTranslation(configDelta))
}
private zoom(logicalViewSpaceCenter: Vec2, multiplier: number) {
this.interactionLock = 'zoom'
const physicalCenter = this.logicalToPhysicalViewSpace().transformPosition(
logicalViewSpaceCenter,
)
const configSpaceCenter = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
physicalCenter,
)
if (!configSpaceCenter) return
const zoomTransform = AffineTransform.withTranslation(configSpaceCenter.times(-1))
.scaledBy(new Vec2(multiplier, 1))
.translatedBy(configSpaceCenter)
this.props.transformViewport(zoomTransform)
}
private lastDragPos: Vec2 | null = null
private onMouseDown = (ev: MouseEvent) => {
this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
this.updateCursor()
window.addEventListener('mouseup', this.onWindowMouseUp)
}
private onMouseDrag = (ev: MouseEvent) => {
if (!this.lastDragPos) return
const logicalMousePos = new Vec2(ev.offsetX, ev.offsetY)
this.pan(this.lastDragPos.minus(logicalMousePos))
this.lastDragPos = logicalMousePos
// When panning by scrolling, the element under
// the cursor will change, so clear the hovered label.
if (this.hoveredLabel) {
this.props.setNodeHover(null, logicalMousePos)
}
}
private onDblClick = (ev: MouseEvent) => {
if (this.hoveredLabel) {
const hoveredBounds = this.hoveredLabel.configSpaceBounds
const viewportRect = new Rect(
hoveredBounds.origin.minus(new Vec2(0, 1)),
hoveredBounds.size.withY(this.props.configSpaceViewportRect.height()),
)
this.props.setConfigSpaceViewportRect(viewportRect)
}
}
private onClick = (ev: MouseEvent) => {
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'
document.body.style.cursor = '-webkit-grabbing'
} else {
document.body.style.cursor = 'default'
}
}
private onWindowMouseUp = (ev: MouseEvent) => {
this.lastDragPos = null
this.updateCursor()
window.removeEventListener('mouseup', this.onWindowMouseUp)
}
private onMouseMove = (ev: MouseEvent) => {
this.updateCursor()
if (this.lastDragPos) {
ev.preventDefault()
this.onMouseDrag(ev)
return
}
this.hoveredLabel = null
const logicalViewSpaceMouse = new Vec2(ev.offsetX, ev.offsetY)
const physicalViewSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(
logicalViewSpaceMouse,
)
const configSpaceMouse = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
physicalViewSpaceMouse,
)
if (!configSpaceMouse) return
const setHoveredLabel = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1))
if (configSpaceMouse.x < configSpaceBounds.left()) return null
if (configSpaceMouse.x > configSpaceBounds.right()) return null
if (configSpaceBounds.contains(configSpaceMouse)) {
this.hoveredLabel = {
configSpaceBounds,
node: frame.node,
}
}
for (let child of frame.children) {
setHoveredLabel(child, depth + 1)
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
setHoveredLabel(frame)
}
if (this.hoveredLabel) {
this.props.setNodeHover(this.hoveredLabel!.node, logicalViewSpaceMouse)
} else {
this.props.setNodeHover(null, logicalViewSpaceMouse)
}
this.renderCanvas()
}
private onMouseLeave = (ev: MouseEvent) => {
this.hoveredLabel = null
this.props.setNodeHover(null, Vec2.zero)
this.renderCanvas()
}
private onWheel = (ev: WheelEvent) => {
ev.preventDefault()
this.frameHadWheelEvent = true
const isZoom = ev.metaKey || ev.ctrlKey
let deltaY = ev.deltaY
let deltaX = ev.deltaX
if (ev.deltaMode === ev.DOM_DELTA_LINE) {
deltaY *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
deltaX *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
}
if (isZoom && this.interactionLock !== 'pan') {
let multiplier = 1 + deltaY / 100
// On Chrome & Firefox, pinch-to-zoom maps to
// WheelEvent + Ctrl Key. We'll accelerate it in
// this case, since it feels a bit sluggish otherwise.
if (ev.ctrlKey) {
multiplier = 1 + deltaY / 40
}
multiplier = clamp(multiplier, 0.1, 10.0)
this.zoom(new Vec2(ev.offsetX, ev.offsetY), multiplier)
} else if (this.interactionLock !== 'zoom') {
this.pan(new Vec2(deltaX, deltaY))
}
this.renderCanvas()
}
onWindowKeyPress = (ev: KeyboardEvent) => {
if (!this.container) return
const {width, height} = this.container.getBoundingClientRect()
if (ev.key === '=' || ev.key === '+') {
this.zoom(new Vec2(width / 2, height / 2), 0.5)
ev.preventDefault()
} else if (ev.key === '-' || ev.key === '_') {
this.zoom(new Vec2(width / 2, height / 2), 2)
ev.preventDefault()
} else if (ev.key === '0') {
this.zoom(new Vec2(width / 2, height / 2), 1e9)
} else if (ev.key === 'ArrowRight' || ev.key === 'd') {
this.pan(new Vec2(100, 0))
} else if (ev.key === 'ArrowLeft' || ev.key === 'a') {
this.pan(new Vec2(-100, 0))
} else if (ev.key === 'ArrowUp' || ev.key === 'w') {
this.pan(new Vec2(0, -100))
} else if (ev.key === 'ArrowDown' || ev.key === 's') {
this.pan(new Vec2(0, 100))
} else if (ev.key === 'Escape') {
this.props.setSelectedNode(null)
this.renderCanvas()
}
}
shouldComponentUpdate() {
return false
}
componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
if (this.props.flamechart !== nextProps.flamechart) {
this.hoveredLabel = null
this.renderCanvas()
} else if (this.props.selectedNode !== nextProps.selectedNode) {
this.renderCanvas()
} else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
this.renderCanvas()
}
}
componentDidMount() {
this.props.canvasContext.addBeforeFrameHandler(this.onBeforeFrame)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
this.props.canvasContext.removeBeforeFrameHandler(this.onBeforeFrame)
window.removeEventListener('resize', this.onWindowResize)
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
return (
<div
className={css(style.panZoomView, style.vbox)}
onMouseDown={this.onMouseDown}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
onClick={this.onClick}
onDblClick={this.onDblClick}
onWheel={this.onWheel}
ref={this.containerRef}
>
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
</div>
)
}
}
interface StatisticsTableProps {
title: string
grandTotal: number
selectedTotal: number
selectedSelf: number
cellStyle: StyleDeclarationValue
formatter: (v: number) => string
}
class StatisticsTable extends ReloadableComponent<StatisticsTableProps, {}> {
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 (
<div className={css(style.statsTable)}>
<div className={css(this.props.cellStyle, style.statsTableCell, style.statsTableHeader)}>
{this.props.title}
</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>Total</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>Self</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>{total}</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>{self}</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>
{formatPercent(totalPerc)}
<div className={css(style.barDisplay)} style={{height: `${totalPerc}%`}} />
</div>
<div className={css(this.props.cellStyle, style.statsTableCell)}>
{formatPercent(selfPerc)}
<div className={css(style.barDisplay)} style={{height: `${selfPerc}%`}} />
</div>
</div>
)
}
}
interface StackTraceViewProps {
getFrameColor: (frame: Frame) => string
node: CallTreeNode
}
class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
render() {
const rows: JSX.Element[] = []
let node: CallTreeNode | null = this.props.node
for (; node && !node.isRoot(); node = node.parent) {
const row: (JSX.Element | string)[] = []
const {frame} = node
row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
if (rows.length) {
row.push(<span className={css(style.stackFileLine)}>> </span>)
}
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(<span className={css(style.stackFileLine)}> ({pos})</span>)
}
rows.push(<div className={css(style.stackLine)}>{row}</div>)
}
return (
<div className={css(style.stackTraceView)}>
<div className={css(style.stackTraceViewPadding)}>{rows}</div>
</div>
)
}
}
interface FlamechartDetailViewProps {
flamechart: Flamechart
getCSSColorForFrame: (frame: Frame) => string
selectedNode: CallTreeNode
}
class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
render() {
const {flamechart, selectedNode} = this.props
const {frame} = selectedNode
return (
<div className={css(style.detailView)}>
<StatisticsTable
title={'This Instance'}
cellStyle={style.thisInstanceCell}
grandTotal={flamechart.getTotalWeight()}
selectedTotal={selectedNode.getTotalWeight()}
selectedSelf={selectedNode.getSelfWeight()}
formatter={flamechart.formatValue.bind(flamechart)}
/>
<StatisticsTable
title={'All Instances'}
cellStyle={style.allInstancesCell}
grandTotal={flamechart.getTotalWeight()}
selectedTotal={frame.getTotalWeight()}
selectedSelf={frame.getSelfWeight()}
formatter={flamechart.formatValue.bind(flamechart)}
/>
<StackTraceView node={selectedNode} getFrameColor={this.props.getCSSColorForFrame} />
</div>
)
}
}
import {FlamechartDetailView} from './flamechart-detail-view'
import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {Hovertip} from './hovertip'
interface FlamechartViewProps {
flamechart: Flamechart
@@ -826,22 +25,21 @@ interface FlamechartViewProps {
}
interface FlamechartViewState {
hoveredNode: CallTreeNode | null
hover: {
node: CallTreeNode
event: MouseEvent
} | null
selectedNode: CallTreeNode | null
configSpaceViewportRect: Rect
logicalSpaceMouse: Vec2
}
export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
container: HTMLDivElement | null = null
constructor() {
super()
this.state = {
hoveredNode: null,
hover: null,
selectedNode: null,
configSpaceViewportRect: Rect.empty,
logicalSpaceMouse: Vec2.zero,
}
}
@@ -890,11 +88,8 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
this.setConfigSpaceViewportRect(viewportRect)
}
onNodeHover = (node: CallTreeNode | null, logicalSpaceMouse: Vec2) => {
this.setState({
hoveredNode: node,
logicalSpaceMouse: logicalSpaceMouse.plus(new Vec2(0, Sizes.MINIMAP_HEIGHT)),
})
onNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
this.setState({hover})
}
onNodeClick = (node: CallTreeNode | null) => {
@@ -913,43 +108,22 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
renderTooltip() {
if (!this.container) return null
const {hoveredNode, logicalSpaceMouse} = this.state
if (!hoveredNode) return null
const {width, height} = this.container.getBoundingClientRect()
const positionStyle: {
left?: number
right?: number
top?: number
bottom?: number
} = {}
const OFFSET_FROM_MOUSE = 7
if (logicalSpaceMouse.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
positionStyle.left = logicalSpaceMouse.x + OFFSET_FROM_MOUSE
} else {
positionStyle.right = width - logicalSpaceMouse.x + 1
}
if (logicalSpaceMouse.y + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_HEIGHT_MAX < height) {
positionStyle.top = logicalSpaceMouse.y + OFFSET_FROM_MOUSE
} else {
positionStyle.bottom = height - logicalSpaceMouse.y + 1
}
const {hover} = this.state
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
return (
<div className={css(style.hoverTip)} style={positionStyle}>
<div className={css(style.hoverTipRow)}>
<span className={css(style.hoverCount)}>
{this.formatValue(hoveredNode.getTotalWeight())}
</span>{' '}
{hoveredNode.frame.name}
</div>
</div>
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
<span className={css(style.hoverCount)}>
{this.formatValue(hover.node.getTotalWeight())}
</span>{' '}
{hover.node.frame.name}
</Hovertip>
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
@@ -966,7 +140,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
render() {
return (
<div className={css(style.fill, style.clip, style.vbox)} ref={this.containerRef}>
<div className={css(style.fill, commonStyle.vbox)} ref={this.containerRef}>
<FlamechartMinimapView
configSpaceViewportRect={this.state.configSpaceViewportRect}
transformViewport={this.transformViewport}
@@ -980,13 +154,15 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
canvasContext={this.props.canvasContext}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
setNodeHover={this.onNodeHover}
setSelectedNode={this.onNodeClick}
renderInverted={false}
onNodeHover={this.onNodeHover}
onNodeSelect={this.onNodeClick}
selectedNode={this.state.selectedNode}
transformViewport={this.transformViewport}
configSpaceViewportRect={this.state.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
{this.renderTooltip()}
{this.state.selectedNode && (
<FlamechartDetailView
flamechart={this.props.flamechart}
@@ -994,7 +170,6 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
selectedNode={this.state.selectedNode}
/>
)}
{this.renderTooltip()}
</div>
)
}

70
hovertip.tsx Normal file
View File

@@ -0,0 +1,70 @@
import {ReloadableComponent} from './reloadable'
import {Vec2} from './math'
import {Sizes, Colors, FontSize, FontFamily, ZIndex} from './style'
import {css, StyleSheet} from 'aphrodite'
import {h} from 'preact'
interface HovertipProps {
containerSize: Vec2
offset: Vec2
}
export class Hovertip extends ReloadableComponent<HovertipProps, {}> {
render() {
const {containerSize, offset} = this.props
const width = containerSize.x
const height = containerSize.y
const positionStyle: {
left?: number
right?: number
top?: number
bottom?: number
} = {}
const OFFSET_FROM_MOUSE = 7
if (offset.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
positionStyle.left = offset.x + OFFSET_FROM_MOUSE
} else {
positionStyle.right = width - offset.x + 1
}
if (offset.y + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_HEIGHT_MAX < height) {
positionStyle.top = offset.y + OFFSET_FROM_MOUSE
} else {
positionStyle.bottom = height - offset.y + 1
}
return (
<div className={css(style.hoverTip)} style={positionStyle}>
<div className={css(style.hoverTipRow)}>{this.props.children}</div>
</div>
)
}
}
const HOVERTIP_PADDING = 2
const style = StyleSheet.create({
hoverTip: {
position: 'absolute',
background: Colors.WHITE,
border: '1px solid black',
maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
paddingTop: HOVERTIP_PADDING,
paddingBottom: HOVERTIP_PADDING,
pointerEvents: 'none',
userSelect: 'none',
fontSize: FontSize.LABEL,
fontFamily: FontFamily.MONOSPACE,
zIndex: ZIndex.HOVERTIP,
},
hoverTipRow: {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflowX: 'hidden',
paddingLeft: HOVERTIP_PADDING,
paddingRight: HOVERTIP_PADDING,
maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
},
})

View File

@@ -3,7 +3,7 @@ import {StyleSheet, css} from 'aphrodite'
import {ReloadableComponent} from './reloadable'
import {Profile, Frame} from './profile'
import {sortBy, formatPercent} from './utils'
import {FontSize, Colors, Sizes} from './style'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ScrollableListView, ListItem} from './scrollable-list-view'
@@ -23,13 +23,6 @@ export interface SortMethod {
direction: SortDirection
}
interface ProfileTableViewProps {
profile: Profile
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
}
interface HBarProps {
perc: number
}
@@ -70,19 +63,42 @@ class SortIcon extends Component<SortIconProps, {}> {
}
}
interface ProfileTableViewProps {
profile: Profile
selectedFrame: Frame | null
setSelectedFrame: (frame: Frame | null) => void
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
}
export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps, void> {
setSelectedFrame = (frame: Frame | null) => {
this.props.setSelectedFrame(frame)
}
renderRow(frame: Frame, index: number) {
const {profile} = this.props
const {profile, selectedFrame} = this.props
const totalWeight = frame.getTotalWeight()
const selfWeight = frame.getSelfWeight()
const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
const selfPerc = 100.0 * selfWeight / profile.getTotalNonIdleWeight()
const selected = frame === selectedFrame
// We intentionally use index rather than frame.key here as the tr key
// in order to re-use rows when sorting rather than creating all new elements.
return (
<tr key={`${index}`} className={css(style.tableRow, index % 2 == 0 && style.tableRowEven)}>
<tr
key={`${index}`}
onClick={this.setSelectedFrame.bind(null, frame)}
className={css(
style.tableRow,
index % 2 == 0 && style.tableRowEven,
selected && style.tableRowSelected,
)}
>
<td className={css(style.numericCell)}>
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
<HBarDisplay perc={totalPerc} />
@@ -172,7 +188,7 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
return (
<div className={css(style.vbox, style.profileTableView)}>
<div className={css(commonStyle.vbox, style.profileTableView)}>
<table className={css(style.tableView)}>
<thead className={css(style.tableHeader)}>
<tr>
@@ -227,11 +243,6 @@ const style = StyleSheet.create({
background: Colors.WHITE,
height: '100%',
},
vbox: {
display: 'flex',
flexDirection: 'column',
position: 'relative',
},
scrollView: {
overflowY: 'auto',
overflowX: 'hidden',
@@ -259,6 +270,10 @@ const style = StyleSheet.create({
tableRowEven: {
background: Colors.OFF_WHITE,
},
tableRowSelected: {
background: Colors.DARK_BLUE,
color: Colors.WHITE,
},
numericCell: {
textOverflow: 'ellipsis',
overflow: 'hidden',

View File

@@ -21,12 +21,7 @@ const fc = getFrameInfo('c')
const fd = getFrameInfo('d')
const fe = getFrameInfo('e')
function verifyProfile(profile: Profile) {
const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
const framesInProfile = new Set<string | number>()
profile.forEachFrame(f => framesInProfile.add(f.key))
expect(allFrameKeys).toEqual(framesInProfile)
function toStackList(profile: Profile, grouped: boolean): string[] {
let stackList: string[] = []
const curStack: (number | string)[] = []
let lastValue = 0
@@ -47,8 +42,21 @@ function verifyProfile(profile: Profile) {
curStack.pop()
}
profile.forEachCall(openFrame, closeFrame)
expect(stackList).toEqual([
if (grouped) {
profile.forEachCallGrouped(openFrame, closeFrame)
} else {
profile.forEachCall(openFrame, closeFrame)
}
return stackList
}
function verifyProfile(profile: Profile) {
const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
const framesInProfile = new Set<string | number>()
profile.forEachFrame(f => framesInProfile.add(f.key))
expect(allFrameKeys).toEqual(framesInProfile)
expect(toStackList(profile, false)).toEqual([
// prettier-ignore
'a',
'a;b',
@@ -62,10 +70,7 @@ function verifyProfile(profile: Profile) {
'a',
])
lastValue = 0
stackList = []
profile.forEachCallGrouped(openFrame, closeFrame)
expect(stackList).toEqual([
expect(toStackList(profile, true)).toEqual([
// prettier-ignore
'a;b;e',
'a;b;b',
@@ -75,12 +80,8 @@ function verifyProfile(profile: Profile) {
'a',
])
const flattened = profile.flattenRecursion()
lastValue = 0
stackList = []
flattened.forEachCall(openFrame, closeFrame)
expect(stackList).toEqual([
const flattened = profile.getProfileWithRecursionFlattened()
expect(toStackList(flattened, false)).toEqual([
// prettier-ignore
'a',
'a;b',
@@ -93,10 +94,7 @@ function verifyProfile(profile: Profile) {
'a',
])
lastValue = 0
stackList = []
flattened.forEachCallGrouped(openFrame, closeFrame)
expect(stackList).toEqual([
expect(toStackList(flattened, true)).toEqual([
// prettier-ignore
'a;b;e',
'a;b;c',
@@ -168,3 +166,97 @@ test('CallTreeProfileBuilder', () => {
const profile = b.build()
verifyProfile(profile)
})
test('getInvertedProfileForCallersOf', () => {
const b = new StackListProfileBuilder()
const samples = [
// prettier-ignore
[fb],
[fa, fb],
[fa, fb, fc],
[fa],
[fa, fb, fd],
[fa],
[fd, fb],
]
samples.forEach(stack => {
b.appendSample(stack, 1)
})
const profile = b.build()
const inverted = profile.getInvertedProfileForCallersOf(fb)
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'b',
'b;a',
'b;d',
])
})
test('getProfileForCalleesOf', () => {
const b = new StackListProfileBuilder()
const samples = [
// prettier-ignore
[fb],
[fa, fb],
[fa, fb, fc],
[fa],
[fa, fb, fd],
[fa],
[fd, fb],
]
samples.forEach(stack => {
b.appendSample(stack, 1)
})
const profile = b.build()
const inverted = profile.getProfileForCalleesOf(fb)
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'b',
'b;c',
'b;d',
'b',
])
})
test('getProfileWithRecursionFlattened', () => {
const b = new StackListProfileBuilder()
const samples = [
// prettier-ignore
[fa],
[fa, fb, fa],
[fa, fb, fa, fb, fa],
[fa, fb, fa],
]
samples.forEach(stack => {
b.appendSample(stack, 1)
})
const profile = b.build()
const inverted = profile.getProfileWithRecursionFlattened()
expect(toStackList(inverted, false)).toEqual([
// prettier-ignore
'a',
'a;b',
])
const framesInProfile = new Set<string | number>()
inverted.forEachFrame(f => {
if (f.key === fa.key) {
expect(f.getSelfWeight()).toEqual(4)
}
if (f.key === fb.key) {
expect(f.getSelfWeight()).toEqual(0)
}
framesInProfile.add(f.key)
})
const allFrameKeys = new Set([fa, fb].map(f => f.key))
expect(allFrameKeys).toEqual(framesInProfile)
})

View File

@@ -38,6 +38,11 @@ export class HasWeights {
addToSelfWeight(delta: number) {
this.selfWeight += delta
}
overwriteWeightWith(other: HasWeights) {
this.selfWeight = other.selfWeight
this.totalWeight = other.totalWeight
}
}
export class Frame extends HasWeights {
@@ -218,7 +223,7 @@ export class Profile {
this.frames.forEach(fn)
}
flattenRecursion(): Profile {
getProfileWithRecursionFlattened(): Profile {
const builder = new CallTreeProfileBuilder()
const stack: (CallTreeNode | null)[] = []
@@ -246,9 +251,105 @@ export class Profile {
const flattenedProfile = builder.build()
flattenedProfile.name = this.name
flattenedProfile.valueFormatter = this.valueFormatter
// When constructing a profile with recursion flattened,
// counter-intuitive things can happen to "self time" measurements
// for functions.
// For example, given the following list of stacks w/ weights:
//
// a 1
// a;b;a 1
// a;b;a;b;a 1
// a;b;a 1
//
// The resulting profile with recursion flattened out will look like this:
//
// a 1
// a;b 3
//
// Which is useful to view, but it's counter-intuitive to move self-time
// for frames around, since analyzing the self-time of functions is an important
// thing to be able to do accurately, and we don't want this to change when recursion
// is flattened. To work around that, we'll just copy the weights directly from the
// un-flattened profile.
this.forEachFrame(f => {
flattenedProfile.frames.getOrInsert(f).overwriteWeightWith(f)
})
return flattenedProfile
}
getInvertedProfileForCallersOf(focalFrameInfo: FrameInfo): Profile {
const focalFrame = Frame.getOrInsert(this.frames, focalFrameInfo)
const builder = new StackListProfileBuilder()
// TODO(jlfwong): Could construct this at profile
// construction time rather than on demand.
const nodes: CallTreeNode[] = []
function visit(node: CallTreeNode) {
if (node.frame === focalFrame) {
nodes.push(node)
} else {
for (let child of node.children) {
visit(child)
}
}
}
visit(this.appendOrderCalltreeRoot)
for (let node of nodes) {
const stack: FrameInfo[] = []
for (let n: CallTreeNode | null = node; n != null && n.frame !== Frame.root; n = n.parent) {
stack.push(n.frame)
}
builder.appendSample(stack, node.getTotalWeight())
}
const ret = builder.build()
ret.name = this.name
ret.valueFormatter = this.valueFormatter
return ret
}
getProfileForCalleesOf(focalFrameInfo: FrameInfo): Profile {
const focalFrame = Frame.getOrInsert(this.frames, focalFrameInfo)
const builder = new StackListProfileBuilder()
function recordSubtree(focalFrameNode: CallTreeNode) {
const stack: FrameInfo[] = []
function visit(node: CallTreeNode) {
stack.push(node.frame)
builder.appendSample(stack, node.getSelfWeight())
for (let child of node.children) {
visit(child)
}
stack.pop()
}
visit(focalFrameNode)
}
function findCalls(node: CallTreeNode) {
if (node.frame === focalFrame) {
recordSubtree(node)
} else {
for (let child of node.children) {
findCalls(child)
}
}
}
findCalls(this.appendOrderCalltreeRoot)
const ret = builder.build()
ret.name = this.name
ret.valueFormatter = this.valueFormatter
return ret
}
// Demangle symbols for readability
async demangle() {
let demangleCpp: ((name: string) => string) | null = null

350
sandwich-view.tsx Normal file
View File

@@ -0,0 +1,350 @@
import {ReloadableComponent} from './reloadable'
import {Profile, Frame, CallTreeNode} from './profile'
import {StyleSheet, css} from 'aphrodite'
import {SortMethod, ProfileTableView} from './profile-table-view'
import {h} from 'preact'
import {commonStyle, Sizes, Colors, FontSize} from './style'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer'
import {Flamechart} from './flamechart'
import {RowAtlas} from './row-atlas'
import {Rect, AffineTransform, Vec2} from './math'
import {FlamechartPanZoomView, FlamechartPanZoomViewProps} from './flamechart-pan-zoom-view'
import {noop, formatPercent} from './utils'
import {Hovertip} from './hovertip'
interface FlamechartWrapperProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
}
interface FlamechartWrapperState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
configSpaceViewportRect: Rect
}
export class FlamechartWrapper extends ReloadableComponent<
FlamechartWrapperProps,
FlamechartWrapperState
> {
constructor(props: FlamechartWrapperProps) {
super(props)
this.state = {
hover: null,
configSpaceViewportRect: Rect.empty,
}
}
private clampViewportToFlamegraph(viewportRect: Rect, flamegraph: Flamechart, inverted: boolean) {
const configSpaceSize = new Vec2(flamegraph.getTotalWeight(), flamegraph.getLayers().length)
let configSpaceOriginBounds = new Rect(
new Vec2(0, inverted ? 0 : -1),
Vec2.max(new Vec2(0, 0), configSpaceSize.minus(viewportRect.size).plus(new Vec2(0, 1))),
)
const minConfigSpaceViewportRectWidth = Math.min(
flamegraph.getTotalWeight(),
3 * flamegraph.getMinFrameWidth(),
)
const configSpaceSizeBounds = new Rect(
new Vec2(minConfigSpaceViewportRectWidth, viewportRect.height()),
new Vec2(configSpaceSize.x, viewportRect.height()),
)
return new Rect(
configSpaceOriginBounds.closestPointTo(viewportRect.origin),
configSpaceSizeBounds.closestPointTo(viewportRect.size),
)
}
private setConfigSpaceViewportRect = (viewportRect: Rect) => {
this.setState({
configSpaceViewportRect: this.clampViewportToFlamegraph(
viewportRect,
this.props.flamechart,
this.props.renderInverted,
),
})
}
private transformViewport = (transform: AffineTransform) => {
this.setConfigSpaceViewportRect(transform.transformRect(this.state.configSpaceViewportRect))
}
private formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
private renderTooltip() {
if (!this.container) return null
const {hover} = this.state
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
return (
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
<span className={css(style.hoverCount)}>
{this.formatValue(hover.node.getTotalWeight())}
</span>{' '}
Hello
{hover.node.frame.name}
</Hovertip>
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
private setNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
this.setState({hover})
}
render() {
const props: FlamechartPanZoomViewProps = {
...(this.props as FlamechartWrapperProps),
selectedNode: null,
onNodeHover: this.setNodeHover,
onNodeSelect: noop,
configSpaceViewportRect: this.state.configSpaceViewportRect,
setConfigSpaceViewportRect: this.setConfigSpaceViewportRect,
transformViewport: this.transformViewport,
}
return (
<div
className={css(commonStyle.fillY, commonStyle.fillX, commonStyle.vbox)}
ref={this.containerRef}
>
<FlamechartPanZoomView {...props} />
{this.renderTooltip()}
</div>
)
}
}
interface SandwichViewProps {
profile: Profile
flattenRecursion: boolean
// TODO(jlfwong): It's kind of awkward requiring both of these
getColorBucketForFrame: (frame: Frame) => number
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
canvasContext: CanvasContext
rowAtlas: RowAtlas<FlamechartRowAtlasKey>
}
interface CallerCalleeState {
selectedFrame: Frame
invertedCallerFlamegraph: Flamechart
invertedCallerFlamegraphRenderer: FlamechartRenderer
calleeFlamegraph: Flamechart
calleeFlamegraphRenderer: FlamechartRenderer
}
interface SandwichViewState {
callerCallee: CallerCalleeState | null
}
export class SandwichView extends ReloadableComponent<SandwichViewProps, SandwichViewState> {
constructor(props: SandwichViewProps) {
super(props)
this.state = {
callerCallee: null,
}
}
private setSelectedFrame = (
selectedFrame: Frame | null,
props: SandwichViewProps = this.props,
) => {
const {profile, canvasContext, rowAtlas, getColorBucketForFrame, flattenRecursion} = props
if (!selectedFrame) {
this.setState({callerCallee: null})
return
}
let invertedCallerProfile = profile.getInvertedProfileForCallersOf(selectedFrame)
if (flattenRecursion) {
invertedCallerProfile = invertedCallerProfile.getProfileWithRecursionFlattened()
}
const invertedCallerFlamegraph = new Flamechart({
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
getColorBucketForFrame,
})
const invertedCallerFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
invertedCallerFlamegraph,
{inverted: true},
)
let calleeProfile = profile.getProfileForCalleesOf(selectedFrame)
if (flattenRecursion) {
calleeProfile = calleeProfile.getProfileWithRecursionFlattened()
}
const calleeFlamegraph = new Flamechart({
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
formatValue: calleeProfile.formatValue.bind(calleeProfile),
getColorBucketForFrame,
})
const calleeFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
calleeFlamegraph,
)
this.setState({
callerCallee: {
selectedFrame,
invertedCallerFlamegraph,
invertedCallerFlamegraphRenderer,
calleeFlamegraph,
calleeFlamegraphRenderer,
},
})
}
onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
this.setState({callerCallee: null})
}
}
componentWillReceiveProps(nextProps: SandwichViewProps) {
if (this.props.flattenRecursion !== nextProps.flattenRecursion) {
if (this.state.callerCallee) {
this.setSelectedFrame(this.state.callerCallee.selectedFrame, nextProps)
}
}
}
componentDidMount() {
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
const {canvasContext} = this.props
const {callerCallee} = this.state
let selectedFrame: Frame | null = null
let flamegraphViews: JSX.Element | null = null
if (callerCallee) {
selectedFrame = callerCallee.selectedFrame
flamegraphViews = (
<div className={css(commonStyle.fillY, style.callersAndCallees, commonStyle.vbox)}>
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent)}>
<div className={css(style.flamechartLabel)}>Callers</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.invertedCallerFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.invertedCallerFlamegraphRenderer}
renderInverted={true}
/>
</div>
<div className={css(style.divider)} />
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent, style.flamechartLabelParentBottom)}>
<div className={css(style.flamechartLabel, style.flamechartLabelBottom)}>Callees</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.calleeFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.calleeFlamegraphRenderer}
renderInverted={false}
/>
</div>
</div>
)
}
return (
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableView
selectedFrame={selectedFrame}
setSelectedFrame={this.setSelectedFrame}
profile={this.props.profile}
getCSSColorForFrame={this.props.getCSSColorForFrame}
sortMethod={this.props.sortMethod}
setSortMethod={this.props.setSortMethod}
/>
</div>
{flamegraphViews}
</div>
)
}
}
const style = StyleSheet.create({
tableView: {
flex: 1,
},
panZoomViewWraper: {
flex: 1,
},
flamechartLabelParent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'flex-start',
fontSize: FontSize.TITLE,
width: FontSize.TITLE * 1.2,
borderRight: `1px solid ${Colors.LIGHT_GRAY}`,
},
flamechartLabelParentBottom: {
justifyContent: 'flex-start',
},
flamechartLabel: {
transform: 'rotate(-90deg)',
transformOrigin: '50% 50% 0',
width: FontSize.TITLE * 1.2,
flexShrink: 1,
},
flamechartLabelBottom: {
transform: 'rotate(-90deg)',
display: 'flex',
justifyContent: 'flex-end',
},
callersAndCallees: {
flex: 1,
borderLeft: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
},
divider: {
height: 2,
background: Colors.LIGHT_GRAY,
},
hoverCount: {
color: Colors.GREEN,
},
})

View File

@@ -1,3 +1,5 @@
import {StyleSheet} from 'aphrodite'
export enum FontFamily {
MONOSPACE = '"Source Code Pro", Courier, monospace',
}
@@ -32,3 +34,28 @@ export enum Sizes {
TOOLBAR_HEIGHT = 20,
TOOLBAR_TAB_HEIGHT = TOOLBAR_HEIGHT - SEPARATOR_HEIGHT,
}
export enum ZIndex {
HOVERTIP = 1,
}
export const commonStyle = StyleSheet.create({
fillY: {
height: '100%',
},
fillX: {
width: '100%',
},
hbox: {
display: 'flex',
flexDirection: 'row',
position: 'relative',
overflow: 'hidden',
},
vbox: {
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
},
})

39
text-utils.ts Normal file
View File

@@ -0,0 +1,39 @@
import {binarySearch} from './utils'
export const ELLIPSIS = '\u2026'
// NOTE: This blindly assumes the same result across contexts.
const measureTextCache = new Map<string, number>()
let lastDevicePixelRatio = -1
export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number {
if (window.devicePixelRatio !== lastDevicePixelRatio) {
// This cache is no longer valid!
measureTextCache.clear()
lastDevicePixelRatio = window.devicePixelRatio
}
if (!measureTextCache.has(text)) {
measureTextCache.set(text, ctx.measureText(text).width)
}
return measureTextCache.get(text)!
}
function buildTrimmedText(text: string, length: number) {
const prefixLength = Math.floor(length / 2)
const prefix = text.substr(0, prefixLength)
const suffix = text.substr(text.length - prefixLength, prefixLength)
return prefix + ELLIPSIS + suffix
}
export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
const [lo] = binarySearch(
0,
text.length,
n => {
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
},
maxWidth,
)
return buildTrimmedText(text, lo)
}

View File

@@ -9,6 +9,7 @@ import {
zeroPad,
formatPercent,
KeyedSet,
binarySearch,
} from './utils'
test('sortBy', () => {
@@ -101,3 +102,10 @@ test('formatPercent', () => {
expect(formatPercent(99.9)).toBe('>99%')
expect(formatPercent(100)).toBe('100%')
})
test('binarySearch', () => {
const [lo, hi] = binarySearch(0, 10, n => Math.log(n), 1, 0.0001)
expect(lo).toBeCloseTo(Math.E, 4)
expect(lo).toBeLessThan(Math.E)
expect(hi).toBeGreaterThan(Math.E)
})

View File

@@ -85,22 +85,6 @@ export function zeroPad(s: string, width: number) {
return new Array(Math.max(width - s.length, 0) + 1).join('0') + s
}
// NOTE: This blindly assumes the same result across contexts.
const measureTextCache = new Map<string, number>()
let lastDevicePixelRatio = -1
export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number {
if (window.devicePixelRatio !== lastDevicePixelRatio) {
// This cache is no longer valid!
measureTextCache.clear()
lastDevicePixelRatio = window.devicePixelRatio
}
if (!measureTextCache.has(text)) {
measureTextCache.set(text, ctx.measureText(text).width)
}
return measureTextCache.get(text)!
}
export function formatPercent(percent: number) {
let formattedPercent = `${percent.toFixed(0)}%`
if (percent === 100) formattedPercent = '100%'
@@ -118,3 +102,22 @@ export function fract(x: number) {
export function triangle(x: number) {
return 2.0 * Math.abs(fract(x) - 0.5) - 1.0
}
export function binarySearch(
lo: number,
hi: number,
f: (val: number) => number,
target: number,
targetRangeSize = 1,
): [number, number] {
console.assert(!isNaN(targetRangeSize) && !isNaN(target))
while (true) {
if (hi - lo <= targetRangeSize) return [lo, hi]
const mid = (hi + lo) / 2
const val = f(mid)
if (val < target) lo = mid
else hi = mid
}
}
export function noop(...args: any[]) {}