speedscope/flamechart-view.tsx
Alan Pierce 1bcb88670b Set up Prettier and run it on the whole codebase
* Install prettier, set up the config file, and run it on all ts and tsx files.
* Install eslint and configure it with just eslint-plugin-prettier to check to
  make sure that prettier has been run.
* Add a basic .travis.yml that runs eslint.

There are other style things that might be nice to enforce with ESLint/TSLint,
like using const, import order, etc, but this commit just focuses on prettier,
which gets most of the way there.

One annoying issue for now is that typescript-eslint-parser gives a big warning
message since they haven't updated to officially support TypeScript 2.8 yet. We
aren't even using any ESLint rules that need the parser, but if we don't include
it, ESLint will crash. TS2.8 support is hopefully coming really soon, though:
https://github.com/eslint/typescript-eslint-parser/pull/454

As for the prettier config specifically, see https://prettier.io/docs/en/options.html
for the available options.

Config settings that seem non-controversial:

Semicolons: You don't use semicolons. (I prefer semicolons, but either way is fine.)

Quote style: Looks like you consistently use single quotes outside JSX and double
quotes in JSX, which is the `singleQuote: true` option.

Config settings worth discussion:

Line width: You don't have a specific max. I put 100 since I think it's a good number
for people (like both of us, probably) who find 80 a bit cramped. (At Benchling we use
110.) Prettier has a big red warning box recommending 80, but I still prefer 100ish.

Bracket spacing: This is `{foo}` vs `{ foo }` for imports, exports, object literals,
and destructuring. Looks like you're inconsistent but lean toward spaces. I personally
really dislike bracket spacing (it feels inconsistent with arrays and function calls),
but I'm certainly fine with it and Prettier has it enabled by default, so I kept it
enabled.

Trailing comma style: Options are "no trailing commas", "trailing commas for
everything exception function calls and parameter lists", and "trailing commas
everywhere". TypeScript can handle trailing commas everywhere, so there isn't a
concern with tooling. You're inconsistent, and it looks like you tend to not have
trailing commas, but I think it's probably best to just have them everywhere, so I
enabled them.

JSX Brackets: You're inconsistent about this, I think. I'd prefer to just keep the
default and wrap the `>` to the next line.

Arrow function parens: I only found two cases of arrow functions with one param
(both `c => c.frame === frame`), and both omitted the parens, so I kept the
default of omitting parens. This makes it mildly more annoying to add a typescript
type or additional param, which is a possible reason for always requiring parens.

Everything else is non-configurable, although it's possible some places would be
better with a `// prettier-ignore` comment (but I usually try to avoid those).
2018-04-14 08:40:06 -07:00

761 lines
25 KiB
TypeScript

import { h } from 'preact'
import { css } from 'aphrodite'
import { ReloadableComponent } from './reloadable'
import { CallTreeNode } from './profile'
import { Flamechart, FlamechartFrame } from './flamechart'
import { Rect, Vec2, AffineTransform, clamp } from './math'
import { cachedMeasureTextWidth } from './utils'
import { FlamechartMinimapView } from './flamechart-minimap-view'
import { style, Sizes } from './flamechart-style'
import { FontSize, FontFamily, Colors } from './style'
import { CanvasContext } from './canvas-context'
import { FlamechartRenderer } from './flamechart-renderer'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
node: CallTreeNode
}
function binarySearch(
lo: number,
hi: number,
f: (val: number) => number,
target: number,
targetRangeSize = 1,
): [number, number] {
console.assert(!isNaN(targetRangeSize) && !isNaN(target))
while (true) {
if (hi - lo <= targetRangeSize) return [lo, hi]
const mid = (hi + lo) / 2
const val = f(mid)
if (val < target) lo = mid
else hi = mid
}
}
const ELLIPSIS = '\u2026'
function buildTrimmedText(text: string, length: number) {
const prefixLength = Math.floor(length / 2)
const prefix = text.substr(0, prefixLength)
const suffix = text.substr(text.length - prefixLength, prefixLength)
return prefix + ELLIPSIS + suffix
}
function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
const [lo] = binarySearch(
0,
text.length,
n => {
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
},
maxWidth,
)
return buildTrimmedText(text, lo)
}
const DEVICE_PIXEL_RATIO = window.devicePixelRatio
/**
* 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
setNodeHover: (node: CallTreeNode | null, logicalViewSpaceMouse: Vec2) => 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 = 16
private configSpaceToPhysicalViewSpace() {
return AffineTransform.betweenRects(
this.props.configSpaceViewportRect,
new Rect(new Vec2(0, 0), this.physicalViewSize()),
)
}
private logicalToPhysicalViewSpace() {
return AffineTransform.withScale(new Vec2(DEVICE_PIXEL_RATIO, DEVICE_PIXEL_RATIO))
}
private resizeOverlayCanvasIfNeeded() {
if (!this.overlayCanvas) return
let { width, height } = this.overlayCanvas.getBoundingClientRect()
{
/*
We render text at a higher resolution then scale down to
ensure we're rendering at 1:1 device pixel ratio.
This ensures our text is rendered crisply.
*/
}
width = Math.floor(width)
height = Math.floor(height)
// Still initializing: don't resize yet
if (width === 0 || height === 0) return
const scaledWidth = width * DEVICE_PIXEL_RATIO
const scaledHeight = height * DEVICE_PIXEL_RATIO
if (scaledWidth === this.overlayCanvas.width && scaledHeight === this.overlayCanvas.height)
return
this.overlayCanvas.width = scaledWidth
this.overlayCanvas.height = scaledHeight
}
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
this.resizeOverlayCanvasIfNeeded()
if (this.props.configSpaceViewportRect.isEmpty()) return
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const physicalViewSpaceFontSize = FontSize.LABEL * DEVICE_PIXEL_RATIO
const physicalViewSpaceFrameHeight = this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * DEVICE_PIXEL_RATIO
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
ctx.strokeStyle = 'rgba(15, 10, 5, 0.5)'
ctx.lineWidth = 2
if (this.hoveredLabel) {
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
ctx.strokeRect(
Math.floor(physicalViewBounds.left()),
Math.floor(physicalViewBounds.top()),
Math.floor(physicalViewBounds.width()),
Math.floor(physicalViewBounds.height()),
)
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.fillStyle = Colors.DARK_GRAY
ctx.textBaseline = 'top'
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
const minConfigSpaceWidthToRender = (
configToPhysical.inverseTransformVector(new Vec2(minWidthToRender, 0)) || new Vec2(0, 0)
).x
const LABEL_PADDING_PX = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
const PADDING_OFFSET = new Vec2(LABEL_PADDING_PX, LABEL_PADDING_PX)
const SIZE_OFFSET = new Vec2(2 * LABEL_PADDING_PX, 2 * LABEL_PADDING_PX)
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()),
)
}
physicalLabelBounds = physicalLabelBounds
.withOrigin(physicalLabelBounds.origin.plus(PADDING_OFFSET))
.withSize(physicalLabelBounds.size.minus(SIZE_OFFSET))
const trimmedText = trimTextMid(ctx, frame.node.frame.name, physicalLabelBounds.width())
ctx.fillText(trimmedText, physicalLabelBounds.left(), physicalLabelBounds.top())
}
for (let child of frame.children) {
renderFrameLabelAndChildren(child, depth + 1)
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderFrameLabelAndChildren(frame)
}
const left = this.props.configSpaceViewportRect.left()
const right = this.props.configSpaceViewportRect.right()
// We want about 10 gridlines to be visible, and want the unit to be
// 1eN, 2eN, or 5eN for some N
// Ideally, we want an interval every 100 logical screen pixels
const logicalToConfig = (
this.configSpaceToPhysicalViewSpace().inverted() || new AffineTransform()
).times(this.logicalToPhysicalViewSpace())
const targetInterval = logicalToConfig.transformVector(new Vec2(200, 1)).x
const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
let interval = minInterval
if (targetInterval / interval > 5) {
interval *= 5
} else if (targetInterval / interval > 2) {
interval *= 2
}
{
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight)
ctx.fillStyle = Colors.GRAY
for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) {
// TODO(jlfwong): Ensure that labels do not overlap
const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x)
const labelText = this.props.flamechart.formatValue(x)
const textWidth = cachedMeasureTextWidth(ctx, labelText)
ctx.fillText(labelText, pos - textWidth - 5, 2)
ctx.fillRect(pos, 0, 1, physicalViewSize.y)
}
}
}
private lastBounds: ClientRect | null = null
private updateConfigSpaceViewport(windowResized = false) {
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 (windowResized) {
// 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(true)
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 (!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(this.hoveredLabel.node, 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 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
if (isZoom && this.interactionLock !== 'pan') {
let multiplier = 1 + ev.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 + ev.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(ev.deltaX, ev.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))
}
}
shouldComponentUpdate() {
return false
}
componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
if (this.props.flamechart !== nextProps.flamechart) {
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}
onDblClick={this.onDblClick}
onWheel={this.onWheel}
ref={this.containerRef}
>
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
</div>
)
}
}
interface FlamechartViewProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
}
interface FlamechartViewState {
hoveredNode: CallTreeNode | null
configSpaceViewportRect: Rect
logicalSpaceMouse: Vec2
}
export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
container: HTMLDivElement | null = null
constructor() {
super()
this.state = {
hoveredNode: null,
configSpaceViewportRect: Rect.empty,
logicalSpaceMouse: Vec2.zero,
}
}
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
this.props.flamechart.getLayers().length,
)
}
private minConfigSpaceViewportRectWidth() {
return Math.min(
this.props.flamechart.getTotalWeight(),
3 * this.props.flamechart.getMinFrameWidth(),
)
}
private setConfigSpaceViewportRect = (viewportRect: Rect): void => {
const configSpaceOriginBounds = new Rect(
new Vec2(0, -1),
Vec2.max(new Vec2(0, 0), this.configSpaceSize().minus(viewportRect.size)),
)
const configSpaceSizeBounds = new Rect(
new Vec2(this.minConfigSpaceViewportRectWidth(), viewportRect.height()),
new Vec2(this.configSpaceSize().x, viewportRect.height()),
)
this.setState({
configSpaceViewportRect: new Rect(
configSpaceOriginBounds.closestPointTo(viewportRect.origin),
configSpaceSizeBounds.closestPointTo(viewportRect.size),
),
})
}
private transformViewport = (transform: AffineTransform): void => {
const viewportRect = transform.transformRect(this.state.configSpaceViewportRect)
this.setConfigSpaceViewportRect(viewportRect)
}
onNodeHover = (hoveredNode: CallTreeNode | null, logicalSpaceMouse: Vec2) => {
this.setState({
hoveredNode,
logicalSpaceMouse: logicalSpaceMouse.plus(new Vec2(0, Sizes.MINIMAP_HEIGHT)),
})
}
formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
let formattedPercent = `${percent.toFixed(0)}%`
if (percent === 100) formattedPercent = '100%'
else if (percent > 99) formattedPercent = '>99%'
else if (percent < 0.01) formattedPercent = '<0.01%'
else if (percent < 1) formattedPercent = `${percent.toFixed(2)}%`
else if (percent < 10) formattedPercent = `${percent.toFixed(1)}%`
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
renderTooltip() {
if (!this.container) return null
const { hoveredNode, logicalSpaceMouse } = this.state
if (!hoveredNode) return null
const { width, height } = this.container.getBoundingClientRect()
const positionStyle: {
left?: number
right?: number
top?: number
bottom?: number
} = {}
const OFFSET_FROM_MOUSE = 7
if (logicalSpaceMouse.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
positionStyle.left = logicalSpaceMouse.x + OFFSET_FROM_MOUSE
} else {
positionStyle.right = width - logicalSpaceMouse.x + 1
}
if (logicalSpaceMouse.y + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_HEIGHT_MAX < height) {
positionStyle.top = logicalSpaceMouse.y + OFFSET_FROM_MOUSE
} else {
positionStyle.bottom = height - logicalSpaceMouse.y + 1
}
return (
<div className={css(style.hoverTip)} style={positionStyle}>
<div className={css(style.hoverTipRow)}>
<span className={css(style.hoverCount)}>
{this.formatValue(hoveredNode.getTotalWeight())}
</span>{' '}
{hoveredNode.frame.name}
</div>
</div>
)
}
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
panZoomView: FlamechartPanZoomView | null = null
panZoomRef = (view: FlamechartPanZoomView | null) => {
this.panZoomView = view
}
subcomponents() {
return {
panZoom: this.panZoomView,
}
}
render() {
return (
<div className={css(style.fill, style.clip, style.vbox)} ref={this.containerRef}>
<FlamechartMinimapView
configSpaceViewportRect={this.state.configSpaceViewportRect}
transformViewport={this.transformViewport}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
canvasContext={this.props.canvasContext}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
<FlamechartPanZoomView
ref={this.panZoomRef}
canvasContext={this.props.canvasContext}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
setNodeHover={this.onNodeHover}
transformViewport={this.transformViewport}
configSpaceViewportRect={this.state.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
{this.renderTooltip()}
</div>
)
}
}