speedscope/flamechart-minimap-view.tsx
Jamie Wong 0e654801b5
Upgrade the "Table view" to a "Sandwich" view (#73)
![image](https://user-images.githubusercontent.com/150329/41837387-25417bae-7812-11e8-83cb-d3e6782b734e.png)

This provides information about the caller & callees of individual functions selected in the table view.
2018-06-29 12:06:19 -07:00

454 lines
15 KiB
TypeScript

import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {Flamechart} from './flamechart'
import {Rect, Vec2, AffineTransform, clamp} from './math'
import {FlamechartRenderer} from './flamechart-renderer'
import {style} from './flamechart-style'
import {FontFamily, FontSize, Colors, Sizes, commonStyle} from './style'
import {CanvasContext} from './canvas-context'
import {TextureCachedRenderer} from './texture-cached-renderer'
import {cachedMeasureTextWidth} from './text-utils'
interface FlamechartMinimapViewProps {
flamechart: Flamechart
configSpaceViewportRect: Rect
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
transformViewport: (transform: AffineTransform) => void
setConfigSpaceViewportRect: (rect: Rect) => void
}
enum DraggingMode {
DRAW_NEW_VIEWPORT,
TRANSLATE_VIEWPORT,
}
export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps, {}> {
container: Element | null = null
containerRef = (element?: Element) => {
this.container = element || null
}
overlayCanvas: HTMLCanvasElement | null = null
overlayCtx: CanvasRenderingContext2D | null = null
private physicalViewSize() {
return new Vec2(
this.overlayCanvas ? this.overlayCanvas.width : 0,
this.overlayCanvas ? this.overlayCanvas.height : 0,
)
}
private minimapOrigin() {
return new Vec2(0, Sizes.FRAME_HEIGHT * window.devicePixelRatio)
}
private configSpaceSize() {
return new Vec2(
this.props.flamechart.getTotalWeight(),
this.props.flamechart.getLayers().length,
)
}
private configSpaceToPhysicalViewSpace() {
const minimapOrigin = this.minimapOrigin()
return AffineTransform.betweenRects(
new Rect(new Vec2(0, 0), this.configSpaceSize()),
new Rect(minimapOrigin, this.physicalViewSize().minus(minimapOrigin)),
)
}
private logicalToPhysicalViewSpace() {
return AffineTransform.withScale(new Vec2(window.devicePixelRatio, window.devicePixelRatio))
}
private windowToLogicalViewSpace() {
if (!this.container) return new AffineTransform()
const bounds = this.container.getBoundingClientRect()
return AffineTransform.withTranslation(new Vec2(-bounds.left, -bounds.top))
}
private cachedRenderer: TextureCachedRenderer<{
physicalSize: Vec2
}> | null = null
private renderRects() {
if (!this.container) return
// Hasn't resized yet -- no point in rendering yet
if (this.physicalViewSize().x < 2) return
if (!this.cachedRenderer) {
this.cachedRenderer = this.props.canvasContext.createTextureCachedRenderer({
shouldUpdate(oldProps, newProps) {
if (!oldProps.physicalSize.equals(newProps.physicalSize)) {
return true
}
return false
},
render: props => {
this.props.flamechartRenderer.render({
physicalSpaceDstRect: new Rect(
this.minimapOrigin(),
this.physicalViewSize().minus(this.minimapOrigin()),
),
configSpaceSrcRect: new Rect(new Vec2(0, 0), this.configSpaceSize()),
renderOutlines: false,
})
},
})
}
this.props.canvasContext.renderInto(this.container, context => {
// TODO(jlfwong): Switch back to the texture cached renderer once I figure out
// how to resize a framebuffer while another framebuffer is active. It seems
// to crash regl. I should submit a reduced repro case and hopefully get it fixed?
/*
this.cachedRenderer!.render({
physicalSize: this.physicalViewSize()
})
*/
this.props.flamechartRenderer.render({
configSpaceSrcRect: new Rect(new Vec2(0, 0), this.configSpaceSize()),
physicalSpaceDstRect: new Rect(
this.minimapOrigin(),
this.physicalViewSize().minus(this.minimapOrigin()),
),
renderOutlines: false,
})
this.props.canvasContext.drawViewportRectangle({
configSpaceViewportRect: this.props.configSpaceViewportRect,
configSpaceToPhysicalViewSpace: this.configSpaceToPhysicalViewSpace(),
})
})
}
private renderOverlays() {
const ctx = this.overlayCtx
if (!ctx) return
const physicalViewSize = this.physicalViewSize()
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
this.resizeOverlayCanvasIfNeeded()
const configToPhysical = this.configSpaceToPhysicalViewSpace()
const left = 0
const right = this.configSpaceSize().x
// TODO(jlfwong): There's a huge amount of code duplication here between
// this and the FlamechartView.renderOverlays(). Consolidate.
// We want about 10 gridlines to be visible, and want the unit to be
// 1eN, 2eN, or 5eN for some N
// Ideally, we want an interval every 100 logical screen pixels
const logicalToConfig = (
this.configSpaceToPhysicalViewSpace().inverted() || new AffineTransform()
).times(this.logicalToPhysicalViewSpace())
const targetInterval = logicalToConfig.transformVector(new Vec2(200, 1)).x
const physicalViewSpaceFrameHeight = Sizes.FRAME_HEIGHT * window.devicePixelRatio
const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
FontFamily.MONOSPACE
}`
ctx.textBaseline = 'top'
const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
let interval = minInterval
if (targetInterval / interval > 5) {
interval *= 5
} else if (targetInterval / interval > 2) {
interval *= 2
}
{
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight)
ctx.textBaseline = 'top'
ctx.fillStyle = Colors.DARK_GRAY
for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) {
// TODO(jlfwong): Ensure that labels do not overlap
const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x)
const labelText = this.props.flamechart.formatValue(x)
const textWidth = Math.ceil(cachedMeasureTextWidth(ctx, labelText))
ctx.fillText(labelText, pos - textWidth - labelPaddingPx, labelPaddingPx)
ctx.fillRect(pos, 0, 1, physicalViewSize.y)
}
}
}
onWindowResize = () => {
this.onBeforeFrame()
}
componentWillReceiveProps(nextProps: FlamechartMinimapViewProps) {
if (this.props.flamechart !== nextProps.flamechart) {
if (this.cachedRenderer) {
this.cachedRenderer.setDirty()
}
this.renderCanvas()
} else if (this.props.configSpaceViewportRect != nextProps.configSpaceViewportRect) {
this.renderCanvas()
}
}
componentDidMount() {
window.addEventListener('resize', this.onWindowResize)
this.props.canvasContext.addBeforeFrameHandler(this.onBeforeFrame)
}
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResize)
this.props.canvasContext.removeBeforeFrameHandler(this.onBeforeFrame)
}
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 onBeforeFrame = () => {
this.maybeClearInteractionLock()
this.renderRects()
this.renderOverlays()
}
private renderCanvas = () => {
this.props.canvasContext.requestFrame()
}
// 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 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(multiplier: number) {
this.interactionLock = 'zoom'
const configSpaceViewport = this.props.configSpaceViewportRect
const configSpaceCenter = configSpaceViewport.origin.plus(configSpaceViewport.size.times(1 / 2))
if (!configSpaceCenter) return
const zoomTransform = AffineTransform.withTranslation(configSpaceCenter.times(-1))
.scaledBy(new Vec2(multiplier, 1))
.translatedBy(configSpaceCenter)
this.props.transformViewport(zoomTransform)
}
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(multiplier)
} else if (this.interactionLock !== 'zoom') {
this.pan(new Vec2(ev.deltaX, ev.deltaY))
}
this.renderCanvas()
}
private configSpaceMouse(ev: MouseEvent): Vec2 | null {
const logicalSpaceMouse = this.windowToLogicalViewSpace().transformPosition(
new Vec2(ev.clientX, ev.clientY),
)
const physicalSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(
logicalSpaceMouse,
)
return this.configSpaceToPhysicalViewSpace().inverseTransformPosition(physicalSpaceMouse)
}
private dragStartConfigSpaceMouse: Vec2 | null = null
private dragConfigSpaceViewportOffset: Vec2 | null = null
private draggingMode: DraggingMode | null = null
private onMouseDown = (ev: MouseEvent) => {
const configSpaceMouse = this.configSpaceMouse(ev)
if (configSpaceMouse) {
if (this.props.configSpaceViewportRect.contains(configSpaceMouse)) {
// If dragging starting inside the viewport rectangle,
// we'll move the existing viewport
this.draggingMode = DraggingMode.TRANSLATE_VIEWPORT
this.dragConfigSpaceViewportOffset = configSpaceMouse.minus(
this.props.configSpaceViewportRect.origin,
)
} else {
// If dragging starts outside the the viewport rectangle,
// we'll start drawing a new viewport
this.draggingMode = DraggingMode.DRAW_NEW_VIEWPORT
}
this.dragStartConfigSpaceMouse = configSpaceMouse
window.addEventListener('mousemove', this.onWindowMouseMove)
window.addEventListener('mouseup', this.onWindowMouseUp)
this.updateCursor(configSpaceMouse)
}
}
private onWindowMouseMove = (ev: MouseEvent) => {
if (!this.dragStartConfigSpaceMouse) return
let configSpaceMouse = this.configSpaceMouse(ev)
if (!configSpaceMouse) return
this.updateCursor(configSpaceMouse)
// Clamp the mouse position to avoid weird behavior when outside the canvas bounds
configSpaceMouse = new Rect(new Vec2(0, 0), this.configSpaceSize()).closestPointTo(
configSpaceMouse,
)
if (this.draggingMode === DraggingMode.DRAW_NEW_VIEWPORT) {
const configStart = this.dragStartConfigSpaceMouse
let configEnd = configSpaceMouse
if (!configStart || !configEnd) return
const left = Math.min(configStart.x, configEnd.x)
const right = Math.max(configStart.x, configEnd.x)
const width = right - left
const height = this.props.configSpaceViewportRect.height()
this.props.setConfigSpaceViewportRect(
new Rect(new Vec2(left, configEnd.y - height / 2), new Vec2(width, height)),
)
} else if (this.draggingMode === DraggingMode.TRANSLATE_VIEWPORT) {
if (!this.dragConfigSpaceViewportOffset) return
const newOrigin = configSpaceMouse.minus(this.dragConfigSpaceViewportOffset)
this.props.setConfigSpaceViewportRect(
this.props.configSpaceViewportRect.withOrigin(newOrigin),
)
}
}
private updateCursor = (configSpaceMouse: Vec2) => {
if (this.draggingMode === DraggingMode.TRANSLATE_VIEWPORT) {
document.body.style.cursor = 'grabbing'
document.body.style.cursor = '-webkit-grabbing'
} else if (this.draggingMode === DraggingMode.DRAW_NEW_VIEWPORT) {
document.body.style.cursor = 'col-resize'
} else if (this.props.configSpaceViewportRect.contains(configSpaceMouse)) {
document.body.style.cursor = 'grab'
document.body.style.cursor = '-webkit-grab'
} else {
document.body.style.cursor = 'col-resize'
}
}
private onMouseMove = (ev: MouseEvent) => {
const configSpaceMouse = this.configSpaceMouse(ev)
if (!configSpaceMouse) return
this.updateCursor(configSpaceMouse)
}
private onWindowMouseUp = (ev: MouseEvent) => {
this.draggingMode = null
window.removeEventListener('mousemove', this.onWindowMouseMove)
window.removeEventListener('mouseup', this.onWindowMouseUp)
const configSpaceMouse = this.configSpaceMouse(ev)
if (!configSpaceMouse) return
this.updateCursor(configSpaceMouse)
}
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
}
}
render() {
return (
<div
ref={this.containerRef}
onWheel={this.onWheel}
onMouseDown={this.onMouseDown}
onMouseMove={this.onMouseMove}
className={css(style.minimap, commonStyle.vbox)}
>
<canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
</div>
)
}
}