Upgrade the "Table view" to a "Sandwich" view (#73)
 This provides information about the caller & callees of individual functions selected in the table view.
This commit is contained in:
21
README.md
21
README.md
@@ -49,21 +49,27 @@ To load a specific profile by URL, you can append a hash fragment like `#profile
|
|||||||
|
|
||||||
## Views
|
## 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
|
### 🕰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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### ⬅️Left Heavy
|
### ⬅️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.
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Navigation
|
## 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
|
* Pinch to zoom
|
||||||
* Hold Cmd+Scroll to zoom
|
* Hold Cmd+Scroll to zoom
|
||||||
* Double click on a frame to fit the viewport to it
|
* Double click on a frame to fit the viewport to it
|
||||||
|
* Click on a frame to view summary statistics about it
|
||||||
|
|
||||||
### Keyboard Navigation
|
### 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
|
* `w`/`a`/`s`/`d` or arrow keys: pan around the profile
|
||||||
* `1`: Switch to the "Time Order" view
|
* `1`: Switch to the "Time Order" view
|
||||||
* `2`: Switch to the "Left Heavy" 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
|
* `r`: Collapse recursion in the flamegraphs
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ import {Flamechart} from './flamechart'
|
|||||||
import {FlamechartView} from './flamechart-view'
|
import {FlamechartView} from './flamechart-view'
|
||||||
import {FontFamily, FontSize, Colors, Sizes} from './style'
|
import {FontFamily, FontSize, Colors, Sizes} from './style'
|
||||||
import {getHashParams, HashParams} from './hash-params'
|
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 {triangle} from './utils'
|
||||||
import {Color} from './color'
|
import {Color} from './color'
|
||||||
import {RowAtlas} from './row-atlas'
|
import {RowAtlas} from './row-atlas'
|
||||||
import {importAsmJsSymbolMap} from './asm-js'
|
import {importAsmJsSymbolMap} from './asm-js'
|
||||||
|
import {SandwichView} from './sandwich-view'
|
||||||
|
|
||||||
declare function require(x: string): any
|
declare function require(x: string): any
|
||||||
const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
|
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 {
|
const enum ViewMode {
|
||||||
CHRONO_FLAME_CHART,
|
CHRONO_FLAME_CHART,
|
||||||
LEFT_HEAVY_FLAME_GRAPH,
|
LEFT_HEAVY_FLAME_GRAPH,
|
||||||
TABLE_VIEW,
|
SANDWICH_VIEW,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplicationState {
|
interface ApplicationState {
|
||||||
@@ -130,8 +131,8 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
|||||||
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTableView = () => {
|
setSandwichView = () => {
|
||||||
this.props.setViewMode(ViewMode.TABLE_VIEW)
|
this.props.setViewMode(ViewMode.SANDWICH_VIEW)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -179,11 +180,11 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
|||||||
<div
|
<div
|
||||||
className={css(
|
className={css(
|
||||||
style.toolbarTab,
|
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>
|
</div>
|
||||||
{help}
|
{help}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +481,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
|||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
onWindowKeyPress = (ev: KeyboardEvent) => {
|
onWindowKeyPress = async (ev: KeyboardEvent) => {
|
||||||
if (ev.key === '1') {
|
if (ev.key === '1') {
|
||||||
this.setState({
|
this.setState({
|
||||||
viewMode: ViewMode.CHRONO_FLAME_CHART,
|
viewMode: ViewMode.CHRONO_FLAME_CHART,
|
||||||
@@ -491,16 +492,16 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
|||||||
})
|
})
|
||||||
} else if (ev.key === '3') {
|
} else if (ev.key === '3') {
|
||||||
this.setState({
|
this.setState({
|
||||||
viewMode: ViewMode.TABLE_VIEW,
|
viewMode: ViewMode.SANDWICH_VIEW,
|
||||||
})
|
})
|
||||||
} else if (ev.key === 'r') {
|
} else if (ev.key === 'r') {
|
||||||
const {flattenRecursion, profile} = this.state
|
const {flattenRecursion, profile} = this.state
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
if (flattenRecursion) {
|
if (flattenRecursion) {
|
||||||
this.setActiveProfile(profile)
|
await this.setActiveProfile(profile)
|
||||||
this.setState({flattenRecursion: false})
|
this.setState({flattenRecursion: false})
|
||||||
} else {
|
} else {
|
||||||
this.setActiveProfile(profile.flattenRecursion())
|
await this.setActiveProfile(profile.getProfileWithRecursionFlattened())
|
||||||
this.setState({flattenRecursion: true})
|
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 => {
|
getCSSColorForFrame = (frame: Frame): string => {
|
||||||
const {chronoFlamechart} = this.state
|
const {chronoFlamechart} = this.state
|
||||||
if (!chronoFlamechart) return '#FFFFFF'
|
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 (
|
return (
|
||||||
<ProfileTableView
|
<SandwichView
|
||||||
profile={this.state.activeProfile}
|
profile={this.state.profile}
|
||||||
|
flattenRecursion={this.state.flattenRecursion}
|
||||||
|
getColorBucketForFrame={this.getColorBucketForFrame}
|
||||||
getCSSColorForFrame={this.getCSSColorForFrame}
|
getCSSColorForFrame={this.getCSSColorForFrame}
|
||||||
sortMethod={this.state.tableSortMethod}
|
sortMethod={this.state.tableSortMethod}
|
||||||
setSortMethod={this.setTableSortMethod}
|
setSortMethod={this.setTableSortMethod}
|
||||||
|
canvasContext={this.canvasContext}
|
||||||
|
rowAtlas={this.rowAtlas}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
123
flamechart-detail-view.tsx
Normal file
123
flamechart-detail-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ import {css} from 'aphrodite'
|
|||||||
import {Flamechart} from './flamechart'
|
import {Flamechart} from './flamechart'
|
||||||
import {Rect, Vec2, AffineTransform, clamp} from './math'
|
import {Rect, Vec2, AffineTransform, clamp} from './math'
|
||||||
import {FlamechartRenderer} from './flamechart-renderer'
|
import {FlamechartRenderer} from './flamechart-renderer'
|
||||||
import {cachedMeasureTextWidth} from './utils'
|
|
||||||
import {style} from './flamechart-style'
|
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 {CanvasContext} from './canvas-context'
|
||||||
import {TextureCachedRenderer} from './texture-cached-renderer'
|
import {TextureCachedRenderer} from './texture-cached-renderer'
|
||||||
|
import {cachedMeasureTextWidth} from './text-utils'
|
||||||
|
|
||||||
interface FlamechartMinimapViewProps {
|
interface FlamechartMinimapViewProps {
|
||||||
flamechart: Flamechart
|
flamechart: Flamechart
|
||||||
@@ -444,7 +444,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
|
|||||||
onWheel={this.onWheel}
|
onWheel={this.onWheel}
|
||||||
onMouseDown={this.onMouseDown}
|
onMouseDown={this.onMouseDown}
|
||||||
onMouseMove={this.onMouseMove}
|
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)} />
|
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
697
flamechart-pan-zoom-view.tsx
Normal file
697
flamechart-pan-zoom-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,10 @@ export class FlamechartRowAtlasKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RendererOptions {
|
||||||
|
inverted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class FlamechartRenderer {
|
export class FlamechartRenderer {
|
||||||
private layers: RangeTreeNode[] = []
|
private layers: RangeTreeNode[] = []
|
||||||
private rectInfoTexture: regl.Texture
|
private rectInfoTexture: regl.Texture
|
||||||
@@ -130,11 +134,12 @@ export class FlamechartRenderer {
|
|||||||
private canvasContext: CanvasContext,
|
private canvasContext: CanvasContext,
|
||||||
private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
|
private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
|
||||||
private flamechart: Flamechart,
|
private flamechart: Flamechart,
|
||||||
|
private options: RendererOptions = {inverted: false},
|
||||||
) {
|
) {
|
||||||
const nLayers = flamechart.getLayers().length
|
const nLayers = flamechart.getLayers().length
|
||||||
for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
|
for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
|
||||||
const leafNodes: RangeTreeLeafNode[] = []
|
const leafNodes: RangeTreeLeafNode[] = []
|
||||||
const y = stackDepth
|
const y = options.inverted ? nLayers - 1 - stackDepth : stackDepth
|
||||||
|
|
||||||
let minLeft = Infinity
|
let minLeft = Infinity
|
||||||
let maxRight = -Infinity
|
let maxRight = -Infinity
|
||||||
@@ -150,7 +155,7 @@ export class FlamechartRenderer {
|
|||||||
leafNodes.push(
|
leafNodes.push(
|
||||||
new RangeTreeLeafNode(
|
new RangeTreeLeafNode(
|
||||||
batch,
|
batch,
|
||||||
new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
|
new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
|
||||||
rectCount,
|
rectCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -183,7 +188,7 @@ export class FlamechartRenderer {
|
|||||||
leafNodes.push(
|
leafNodes.push(
|
||||||
new RangeTreeLeafNode(
|
new RangeTreeLeafNode(
|
||||||
batch,
|
batch,
|
||||||
new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
|
new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
|
||||||
rectCount,
|
rectCount,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,7 +212,9 @@ export class FlamechartRenderer {
|
|||||||
|
|
||||||
const width = configSpaceContentWidth / Math.pow(2, zoomLevel)
|
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) {
|
render(props: FlamechartRendererProps) {
|
||||||
@@ -251,8 +258,11 @@ export class FlamechartRenderer {
|
|||||||
numAtlasEntriesPerLayer * configSpaceSrcRect.right() / configSpaceContentWidth,
|
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++) {
|
for (let index = left; index <= right; index++) {
|
||||||
|
const stackDepth = this.options.inverted ? nLayers - 1 - y : y
|
||||||
const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, {
|
const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, {
|
||||||
stackDepth,
|
stackDepth,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|||||||
@@ -1,40 +1,10 @@
|
|||||||
import {StyleSheet} from 'aphrodite'
|
import {StyleSheet} from 'aphrodite'
|
||||||
import {FontFamily, FontSize, Colors, Sizes} from './style'
|
import {FontSize, Colors, Sizes} from './style'
|
||||||
|
|
||||||
const HOVERTIP_PADDING = 2
|
|
||||||
|
|
||||||
export const style = StyleSheet.create({
|
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: {
|
hoverCount: {
|
||||||
color: Colors.GREEN,
|
color: Colors.GREEN,
|
||||||
},
|
},
|
||||||
clip: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
vbox: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
fill: {
|
fill: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|||||||
@@ -1,822 +1,21 @@
|
|||||||
import {h} from 'preact'
|
import {h} from 'preact'
|
||||||
import {css, StyleDeclarationValue} from 'aphrodite'
|
import {css} from 'aphrodite'
|
||||||
import {ReloadableComponent} from './reloadable'
|
import {ReloadableComponent} from './reloadable'
|
||||||
|
|
||||||
import {CallTreeNode, Frame} from './profile'
|
import {CallTreeNode, Frame} from './profile'
|
||||||
import {Flamechart, FlamechartFrame} from './flamechart'
|
import {Flamechart} from './flamechart'
|
||||||
|
|
||||||
import {Rect, Vec2, AffineTransform, clamp} from './math'
|
import {Rect, Vec2, AffineTransform} from './math'
|
||||||
import {cachedMeasureTextWidth, formatPercent} from './utils'
|
import {formatPercent} from './utils'
|
||||||
import {FlamechartMinimapView} from './flamechart-minimap-view'
|
import {FlamechartMinimapView} from './flamechart-minimap-view'
|
||||||
|
|
||||||
import {style} from './flamechart-style'
|
import {style} from './flamechart-style'
|
||||||
import {FontSize, FontFamily, Colors, Sizes} from './style'
|
import {Sizes, commonStyle} from './style'
|
||||||
import {CanvasContext} from './canvas-context'
|
import {CanvasContext} from './canvas-context'
|
||||||
import {FlamechartRenderer} from './flamechart-renderer'
|
import {FlamechartRenderer} from './flamechart-renderer'
|
||||||
import {ColorChit} from './color-chit'
|
import {FlamechartDetailView} from './flamechart-detail-view'
|
||||||
|
import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
|
||||||
interface FlamechartFrameLabel {
|
import {Hovertip} from './hovertip'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlamechartViewProps {
|
interface FlamechartViewProps {
|
||||||
flamechart: Flamechart
|
flamechart: Flamechart
|
||||||
@@ -826,22 +25,21 @@ interface FlamechartViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FlamechartViewState {
|
interface FlamechartViewState {
|
||||||
hoveredNode: CallTreeNode | null
|
hover: {
|
||||||
|
node: CallTreeNode
|
||||||
|
event: MouseEvent
|
||||||
|
} | null
|
||||||
selectedNode: CallTreeNode | null
|
selectedNode: CallTreeNode | null
|
||||||
configSpaceViewportRect: Rect
|
configSpaceViewportRect: Rect
|
||||||
logicalSpaceMouse: Vec2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
|
export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
|
||||||
container: HTMLDivElement | null = null
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.state = {
|
this.state = {
|
||||||
hoveredNode: null,
|
hover: null,
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
configSpaceViewportRect: Rect.empty,
|
configSpaceViewportRect: Rect.empty,
|
||||||
logicalSpaceMouse: Vec2.zero,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,11 +88,8 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
|
|||||||
this.setConfigSpaceViewportRect(viewportRect)
|
this.setConfigSpaceViewportRect(viewportRect)
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodeHover = (node: CallTreeNode | null, logicalSpaceMouse: Vec2) => {
|
onNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
|
||||||
this.setState({
|
this.setState({hover})
|
||||||
hoveredNode: node,
|
|
||||||
logicalSpaceMouse: logicalSpaceMouse.plus(new Vec2(0, Sizes.MINIMAP_HEIGHT)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodeClick = (node: CallTreeNode | null) => {
|
onNodeClick = (node: CallTreeNode | null) => {
|
||||||
@@ -913,43 +108,22 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
|
|||||||
renderTooltip() {
|
renderTooltip() {
|
||||||
if (!this.container) return null
|
if (!this.container) return null
|
||||||
|
|
||||||
const {hoveredNode, logicalSpaceMouse} = this.state
|
const {hover} = this.state
|
||||||
if (!hoveredNode) return null
|
if (!hover) return null
|
||||||
|
const {width, height, left, top} = this.container.getBoundingClientRect()
|
||||||
const {width, height} = this.container.getBoundingClientRect()
|
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css(style.hoverTip)} style={positionStyle}>
|
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
|
||||||
<div className={css(style.hoverTipRow)}>
|
<span className={css(style.hoverCount)}>
|
||||||
<span className={css(style.hoverCount)}>
|
{this.formatValue(hover.node.getTotalWeight())}
|
||||||
{this.formatValue(hoveredNode.getTotalWeight())}
|
</span>{' '}
|
||||||
</span>{' '}
|
{hover.node.frame.name}
|
||||||
{hoveredNode.frame.name}
|
</Hovertip>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
container: HTMLDivElement | null = null
|
||||||
containerRef = (container?: Element) => {
|
containerRef = (container?: Element) => {
|
||||||
this.container = (container as HTMLDivElement) || null
|
this.container = (container as HTMLDivElement) || null
|
||||||
}
|
}
|
||||||
@@ -966,7 +140,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className={css(style.fill, style.clip, style.vbox)} ref={this.containerRef}>
|
<div className={css(style.fill, commonStyle.vbox)} ref={this.containerRef}>
|
||||||
<FlamechartMinimapView
|
<FlamechartMinimapView
|
||||||
configSpaceViewportRect={this.state.configSpaceViewportRect}
|
configSpaceViewportRect={this.state.configSpaceViewportRect}
|
||||||
transformViewport={this.transformViewport}
|
transformViewport={this.transformViewport}
|
||||||
@@ -980,13 +154,15 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
|
|||||||
canvasContext={this.props.canvasContext}
|
canvasContext={this.props.canvasContext}
|
||||||
flamechart={this.props.flamechart}
|
flamechart={this.props.flamechart}
|
||||||
flamechartRenderer={this.props.flamechartRenderer}
|
flamechartRenderer={this.props.flamechartRenderer}
|
||||||
setNodeHover={this.onNodeHover}
|
renderInverted={false}
|
||||||
setSelectedNode={this.onNodeClick}
|
onNodeHover={this.onNodeHover}
|
||||||
|
onNodeSelect={this.onNodeClick}
|
||||||
selectedNode={this.state.selectedNode}
|
selectedNode={this.state.selectedNode}
|
||||||
transformViewport={this.transformViewport}
|
transformViewport={this.transformViewport}
|
||||||
configSpaceViewportRect={this.state.configSpaceViewportRect}
|
configSpaceViewportRect={this.state.configSpaceViewportRect}
|
||||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||||
/>
|
/>
|
||||||
|
{this.renderTooltip()}
|
||||||
{this.state.selectedNode && (
|
{this.state.selectedNode && (
|
||||||
<FlamechartDetailView
|
<FlamechartDetailView
|
||||||
flamechart={this.props.flamechart}
|
flamechart={this.props.flamechart}
|
||||||
@@ -994,7 +170,6 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
|
|||||||
selectedNode={this.state.selectedNode}
|
selectedNode={this.state.selectedNode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.renderTooltip()}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
70
hovertip.tsx
Normal file
70
hovertip.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import {StyleSheet, css} from 'aphrodite'
|
|||||||
import {ReloadableComponent} from './reloadable'
|
import {ReloadableComponent} from './reloadable'
|
||||||
import {Profile, Frame} from './profile'
|
import {Profile, Frame} from './profile'
|
||||||
import {sortBy, formatPercent} from './utils'
|
import {sortBy, formatPercent} from './utils'
|
||||||
import {FontSize, Colors, Sizes} from './style'
|
import {FontSize, Colors, Sizes, commonStyle} from './style'
|
||||||
import {ColorChit} from './color-chit'
|
import {ColorChit} from './color-chit'
|
||||||
import {ScrollableListView, ListItem} from './scrollable-list-view'
|
import {ScrollableListView, ListItem} from './scrollable-list-view'
|
||||||
|
|
||||||
@@ -23,13 +23,6 @@ export interface SortMethod {
|
|||||||
direction: SortDirection
|
direction: SortDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileTableViewProps {
|
|
||||||
profile: Profile
|
|
||||||
getCSSColorForFrame: (frame: Frame) => string
|
|
||||||
sortMethod: SortMethod
|
|
||||||
setSortMethod: (sortMethod: SortMethod) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HBarProps {
|
interface HBarProps {
|
||||||
perc: number
|
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> {
|
export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps, void> {
|
||||||
|
setSelectedFrame = (frame: Frame | null) => {
|
||||||
|
this.props.setSelectedFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
renderRow(frame: Frame, index: number) {
|
renderRow(frame: Frame, index: number) {
|
||||||
const {profile} = this.props
|
const {profile, selectedFrame} = this.props
|
||||||
|
|
||||||
const totalWeight = frame.getTotalWeight()
|
const totalWeight = frame.getTotalWeight()
|
||||||
const selfWeight = frame.getSelfWeight()
|
const selfWeight = frame.getSelfWeight()
|
||||||
const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
|
const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
|
||||||
const selfPerc = 100.0 * selfWeight / 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
|
// 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.
|
// in order to re-use rows when sorting rather than creating all new elements.
|
||||||
return (
|
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)}>
|
<td className={css(style.numericCell)}>
|
||||||
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
|
{profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
|
||||||
<HBarDisplay perc={totalPerc} />
|
<HBarDisplay perc={totalPerc} />
|
||||||
@@ -172,7 +188,7 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
|
|||||||
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
|
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css(style.vbox, style.profileTableView)}>
|
<div className={css(commonStyle.vbox, style.profileTableView)}>
|
||||||
<table className={css(style.tableView)}>
|
<table className={css(style.tableView)}>
|
||||||
<thead className={css(style.tableHeader)}>
|
<thead className={css(style.tableHeader)}>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -227,11 +243,6 @@ const style = StyleSheet.create({
|
|||||||
background: Colors.WHITE,
|
background: Colors.WHITE,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
vbox: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
scrollView: {
|
scrollView: {
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
@@ -259,6 +270,10 @@ const style = StyleSheet.create({
|
|||||||
tableRowEven: {
|
tableRowEven: {
|
||||||
background: Colors.OFF_WHITE,
|
background: Colors.OFF_WHITE,
|
||||||
},
|
},
|
||||||
|
tableRowSelected: {
|
||||||
|
background: Colors.DARK_BLUE,
|
||||||
|
color: Colors.WHITE,
|
||||||
|
},
|
||||||
numericCell: {
|
numericCell: {
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|||||||
136
profile.test.ts
136
profile.test.ts
@@ -21,12 +21,7 @@ const fc = getFrameInfo('c')
|
|||||||
const fd = getFrameInfo('d')
|
const fd = getFrameInfo('d')
|
||||||
const fe = getFrameInfo('e')
|
const fe = getFrameInfo('e')
|
||||||
|
|
||||||
function verifyProfile(profile: Profile) {
|
function toStackList(profile: Profile, grouped: boolean): string[] {
|
||||||
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)
|
|
||||||
|
|
||||||
let stackList: string[] = []
|
let stackList: string[] = []
|
||||||
const curStack: (number | string)[] = []
|
const curStack: (number | string)[] = []
|
||||||
let lastValue = 0
|
let lastValue = 0
|
||||||
@@ -47,8 +42,21 @@ function verifyProfile(profile: Profile) {
|
|||||||
curStack.pop()
|
curStack.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.forEachCall(openFrame, closeFrame)
|
if (grouped) {
|
||||||
expect(stackList).toEqual([
|
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
|
// prettier-ignore
|
||||||
'a',
|
'a',
|
||||||
'a;b',
|
'a;b',
|
||||||
@@ -62,10 +70,7 @@ function verifyProfile(profile: Profile) {
|
|||||||
'a',
|
'a',
|
||||||
])
|
])
|
||||||
|
|
||||||
lastValue = 0
|
expect(toStackList(profile, true)).toEqual([
|
||||||
stackList = []
|
|
||||||
profile.forEachCallGrouped(openFrame, closeFrame)
|
|
||||||
expect(stackList).toEqual([
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
'a;b;e',
|
'a;b;e',
|
||||||
'a;b;b',
|
'a;b;b',
|
||||||
@@ -75,12 +80,8 @@ function verifyProfile(profile: Profile) {
|
|||||||
'a',
|
'a',
|
||||||
])
|
])
|
||||||
|
|
||||||
const flattened = profile.flattenRecursion()
|
const flattened = profile.getProfileWithRecursionFlattened()
|
||||||
|
expect(toStackList(flattened, false)).toEqual([
|
||||||
lastValue = 0
|
|
||||||
stackList = []
|
|
||||||
flattened.forEachCall(openFrame, closeFrame)
|
|
||||||
expect(stackList).toEqual([
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
'a',
|
'a',
|
||||||
'a;b',
|
'a;b',
|
||||||
@@ -93,10 +94,7 @@ function verifyProfile(profile: Profile) {
|
|||||||
'a',
|
'a',
|
||||||
])
|
])
|
||||||
|
|
||||||
lastValue = 0
|
expect(toStackList(flattened, true)).toEqual([
|
||||||
stackList = []
|
|
||||||
flattened.forEachCallGrouped(openFrame, closeFrame)
|
|
||||||
expect(stackList).toEqual([
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
'a;b;e',
|
'a;b;e',
|
||||||
'a;b;c',
|
'a;b;c',
|
||||||
@@ -168,3 +166,97 @@ test('CallTreeProfileBuilder', () => {
|
|||||||
const profile = b.build()
|
const profile = b.build()
|
||||||
verifyProfile(profile)
|
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)
|
||||||
|
})
|
||||||
|
|||||||
103
profile.ts
103
profile.ts
@@ -38,6 +38,11 @@ export class HasWeights {
|
|||||||
addToSelfWeight(delta: number) {
|
addToSelfWeight(delta: number) {
|
||||||
this.selfWeight += delta
|
this.selfWeight += delta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overwriteWeightWith(other: HasWeights) {
|
||||||
|
this.selfWeight = other.selfWeight
|
||||||
|
this.totalWeight = other.totalWeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Frame extends HasWeights {
|
export class Frame extends HasWeights {
|
||||||
@@ -218,7 +223,7 @@ export class Profile {
|
|||||||
this.frames.forEach(fn)
|
this.frames.forEach(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
flattenRecursion(): Profile {
|
getProfileWithRecursionFlattened(): Profile {
|
||||||
const builder = new CallTreeProfileBuilder()
|
const builder = new CallTreeProfileBuilder()
|
||||||
|
|
||||||
const stack: (CallTreeNode | null)[] = []
|
const stack: (CallTreeNode | null)[] = []
|
||||||
@@ -246,9 +251,105 @@ export class Profile {
|
|||||||
const flattenedProfile = builder.build()
|
const flattenedProfile = builder.build()
|
||||||
flattenedProfile.name = this.name
|
flattenedProfile.name = this.name
|
||||||
flattenedProfile.valueFormatter = this.valueFormatter
|
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
|
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
|
// Demangle symbols for readability
|
||||||
async demangle() {
|
async demangle() {
|
||||||
let demangleCpp: ((name: string) => string) | null = null
|
let demangleCpp: ((name: string) => string) | null = null
|
||||||
|
|||||||
350
sandwich-view.tsx
Normal file
350
sandwich-view.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
27
style.ts
27
style.ts
@@ -1,3 +1,5 @@
|
|||||||
|
import {StyleSheet} from 'aphrodite'
|
||||||
|
|
||||||
export enum FontFamily {
|
export enum FontFamily {
|
||||||
MONOSPACE = '"Source Code Pro", Courier, monospace',
|
MONOSPACE = '"Source Code Pro", Courier, monospace',
|
||||||
}
|
}
|
||||||
@@ -32,3 +34,28 @@ export enum Sizes {
|
|||||||
TOOLBAR_HEIGHT = 20,
|
TOOLBAR_HEIGHT = 20,
|
||||||
TOOLBAR_TAB_HEIGHT = TOOLBAR_HEIGHT - SEPARATOR_HEIGHT,
|
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
39
text-utils.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
zeroPad,
|
zeroPad,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
KeyedSet,
|
KeyedSet,
|
||||||
|
binarySearch,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
test('sortBy', () => {
|
test('sortBy', () => {
|
||||||
@@ -101,3 +102,10 @@ test('formatPercent', () => {
|
|||||||
expect(formatPercent(99.9)).toBe('>99%')
|
expect(formatPercent(99.9)).toBe('>99%')
|
||||||
expect(formatPercent(100)).toBe('100%')
|
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)
|
||||||
|
})
|
||||||
|
|||||||
35
utils.ts
35
utils.ts
@@ -85,22 +85,6 @@ export function zeroPad(s: string, width: number) {
|
|||||||
return new Array(Math.max(width - s.length, 0) + 1).join('0') + s
|
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) {
|
export function formatPercent(percent: number) {
|
||||||
let formattedPercent = `${percent.toFixed(0)}%`
|
let formattedPercent = `${percent.toFixed(0)}%`
|
||||||
if (percent === 100) formattedPercent = '100%'
|
if (percent === 100) formattedPercent = '100%'
|
||||||
@@ -118,3 +102,22 @@ export function fract(x: number) {
|
|||||||
export function triangle(x: number) {
|
export function triangle(x: number) {
|
||||||
return 2.0 * Math.abs(fract(x) - 0.5) - 1.0
|
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[]) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user