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 { 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 (
) } }