diff --git a/src/App.js b/src/App.js index a8a9952..00c9172 100644 --- a/src/App.js +++ b/src/App.js @@ -1,12 +1,10 @@ // @flow -import type {FlamechartData, ReactLane, ReactProfilerData} from './types'; +import type {FlamechartData, ReactProfilerData} from './types'; import React, {useState, useCallback} from 'react'; import {unstable_batchedUpdates} from 'react-dom'; -import {getLaneHeight} from './canvas/canvasUtils'; -import {REACT_TOTAL_NUM_LANES} from './constants'; import ImportPage from './ImportPage'; import CanvasPage from './CanvasPage'; @@ -15,7 +13,6 @@ export default function App() { null, ); const [flamechart, setFlamechart] = useState(null); - const [schedulerCanvasHeight, setSchedulerCanvasHeight] = useState(0); const handleDataImported = useCallback( ( @@ -25,27 +22,11 @@ export default function App() { unstable_batchedUpdates(() => { setProfilerData(importedProfilerData); setFlamechart(importedFlamechart); - - const lanesToRender: ReactLane[] = Array.from( - Array(REACT_TOTAL_NUM_LANES).keys(), - ); - // TODO: Figure out if this is necessary - setSchedulerCanvasHeight( - lanesToRender.reduce((height, lane) => { - return height + getLaneHeight(importedProfilerData, lane); - }, 0), - ); }); }, ); if (profilerData && flamechart) { - return ( - - ); + return ; } else { return ; } diff --git a/src/CanvasPage.js b/src/CanvasPage.js index 74eaf1f..c89719f 100644 --- a/src/CanvasPage.js +++ b/src/CanvasPage.js @@ -1,25 +1,33 @@ // @flow -import type {PanAndZoomState} from './util/usePanAndZoom'; +import type {Point} from './layout'; import {copy} from 'clipboard-js'; -import React, {Fragment, useLayoutEffect, useRef, useState} from 'react'; -import usePanAndZoom from './util/usePanAndZoom'; +import React, { + Fragment, + useEffect, + useLayoutEffect, + useRef, + useState, + useCallback, +} from 'react'; -import {getHoveredEvent} from './canvas/getHoveredEvent'; -import {renderCanvas} from './canvas/renderCanvas'; +import { + HorizontalPanAndZoomView, + VerticalScrollView, + Surface, + StaticLayoutView, + layeredLayout, + zeroPoint, + verticallyStackedLayout, +} from './layout'; import prettyMilliseconds from 'pretty-ms'; import {getBatchRange} from './util/getBatchRange'; import EventTooltip from './EventTooltip'; import styles from './CanvasPage.css'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { - COLORS, - FLAMECHART_FRAME_HEIGHT, - LABEL_FIXED_WIDTH, - HEADER_HEIGHT_FIXED, -} from './canvas/constants'; +import {COLORS} from './canvas/constants'; import {ContextMenu, ContextMenuItem, useContextMenu} from './context'; @@ -30,32 +38,36 @@ import type { ReactHoverContextInfo, ReactProfilerData, } from './types'; +import {useCanvasInteraction} from './useCanvasInteraction'; +import { + FlamegraphView, + ReactEventsView, + ReactMeasuresView, + TimeAxisMarkersView, +} from './canvas/views'; type ContextMenuContextData = {| data: ReactProfilerData, flamechart: FlamechartData, hoveredEvent: ReactHoverContextInfo | null, - state: PanAndZoomState, |}; type Props = {| profilerData: ReactProfilerData, flamechart: FlamechartData, - schedulerCanvasHeight: number, |}; -function CanvasPage({profilerData, flamechart, schedulerCanvasHeight}: Props) { +function CanvasPage({profilerData, flamechart}: Props) { return (
+ style={{backgroundColor: COLORS.BACKGROUND}}> {({height, width}: {height: number, width: number}) => ( )} @@ -79,21 +91,21 @@ const copySummary = (data, measure) => { ); }; -const zoomToBatch = (data, measure, state) => { - const {zoomTo} = state; - if (!zoomTo) { - return; - } - const {batchUID} = measure; - const [startTime, stopTime] = getBatchRange(batchUID, data); - zoomTo(startTime, stopTime); -}; +// TODO: Migrate zoomToBatch to new views architecture +// const zoomToBatch = (data, measure, state) => { +// const {zoomTo} = state; +// if (!zoomTo) { +// return; +// } +// const {batchUID} = measure; +// const [startTime, stopTime] = getBatchRange(batchUID, data); +// zoomTo(startTime, stopTime); +// }; type AutoSizedCanvasProps = {| data: ReactProfilerData, flamechart: FlamechartData, height: number, - schedulerCanvasHeight: number, width: number, |}; @@ -101,55 +113,212 @@ function AutoSizedCanvas({ data, flamechart, height, - schedulerCanvasHeight, width, }: AutoSizedCanvasProps) { const canvasRef = useRef(null); - const state = usePanAndZoom({ - canvasRef, - canvasHeight: height, - canvasWidth: width, - fixedColumnWidth: LABEL_FIXED_WIDTH, - fixedHeaderHeight: HEADER_HEIGHT_FIXED, - unscaledContentWidth: data.duration, - unscaledContentHeight: - schedulerCanvasHeight + - flamechart.layers.length * FLAMECHART_FRAME_HEIGHT, - }); + const [isContextMenuShown, setIsContextMenuShown] = useState(false); + const [mouseLocation, setMouseLocation] = useState(zeroPoint); // DOM coordinates + const [ + hoveredEvent, + setHoveredEvent, + ] = useState(null); + + const surfaceRef = useRef(new Surface()); + const flamegraphViewRef = useRef(null); + const axisMarkersViewRef = useRef(null); + const reactEventsViewRef = useRef(null); + const reactMeasuresViewRef = useRef(null); + const rootViewRef = useRef(null); + + useLayoutEffect(() => { + const axisMarkersView = new TimeAxisMarkersView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + data.duration, + ); + axisMarkersViewRef.current = axisMarkersView; + + const reactEventsView = new ReactEventsView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + data, + ); + reactEventsViewRef.current = reactEventsView; + + const reactMeasuresView = new ReactMeasuresView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + data, + ); + reactMeasuresViewRef.current = reactMeasuresView; + + const flamegraphView = new FlamegraphView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + flamechart, + data, + ); + flamegraphViewRef.current = flamegraphView; + const flamegraphVScrollWrapper = new VerticalScrollView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + flamegraphView, + flamegraphView.intrinsicSize.height, + ); + + const stackedZoomables = new StaticLayoutView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + verticallyStackedLayout, + [ + axisMarkersView, + reactEventsView, + reactMeasuresView, + flamegraphVScrollWrapper, + ], + ); + + const contentZoomWrapper = new HorizontalPanAndZoomView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + stackedZoomables, + flamegraphView.intrinsicSize.width, + ); + + rootViewRef.current = new StaticLayoutView( + surfaceRef.current, + {origin: zeroPoint, size: {width, height}}, + layeredLayout, + [contentZoomWrapper], + ); + + surfaceRef.current.rootView = rootViewRef.current; + }, [data, flamechart, setHoveredEvent]); - const hoveredEvent = getHoveredEvent( - schedulerCanvasHeight, - data, - flamechart, - state, + useLayoutEffect(() => { + if (canvasRef.current) { + surfaceRef.current.setCanvas(canvasRef.current, {width, height}); + } + }, [surfaceRef, canvasRef, width, height]); + + const interactor = useCallback( + interaction => { + if ( + hoveredEvent && + (hoveredEvent.event || + hoveredEvent.measure || + hoveredEvent.flamechartNode) + ) { + setMouseLocation({ + x: interaction.payload.event.x, + y: interaction.payload.event.y, + }); + } + if (canvasRef.current === null) { + return; + } + surfaceRef.current.handleInteraction(interaction); + surfaceRef.current.displayIfNeeded(); + }, + [surfaceRef, hoveredEvent, setMouseLocation], ); - const [isContextMenuShown, setIsContextMenuShown] = useState(false); + + useCanvasInteraction(canvasRef, interactor); useContextMenu({ data: { data, flamechart, hoveredEvent, - state, }, id: CONTEXT_MENU_ID, onChange: setIsContextMenuShown, ref: canvasRef, }); + useEffect(() => { + const {current: reactEventsView} = reactEventsViewRef; + if (reactEventsView) { + reactEventsView.onHover = event => { + if (!hoveredEvent || hoveredEvent.event !== event) { + setHoveredEvent({ + event, + flamechartNode: null, + measure: null, + data, + }); + } + }; + } + + const {current: reactMeasuresView} = reactMeasuresViewRef; + if (reactMeasuresView) { + reactMeasuresView.onHover = measure => { + if (!hoveredEvent || hoveredEvent.measure !== measure) { + setHoveredEvent({ + event: null, + flamechartNode: null, + measure, + data, + }); + } + }; + } + + const {current: flamegraphView} = flamegraphViewRef; + if (flamegraphView) { + flamegraphView.onHover = flamechartNode => { + if (!hoveredEvent || hoveredEvent.flamechartNode !== flamechartNode) { + setHoveredEvent({ + event: null, + flamechartNode, + measure: null, + data, + }); + } + }; + } + }, [ + reactEventsViewRef, + reactMeasuresViewRef, + flamegraphViewRef, + hoveredEvent, + setHoveredEvent, + ]); + useLayoutEffect(() => { - if (canvasRef.current !== null) { - renderCanvas( - data, - flamechart, - canvasRef.current, - width, - height, - state, - hoveredEvent, + const {current: reactEventsView} = reactEventsViewRef; + if (reactEventsView) { + reactEventsView.setHoveredEvent(hoveredEvent ? hoveredEvent.event : null); + } + + const {current: reactMeasuresView} = reactMeasuresViewRef; + if (reactMeasuresView) { + reactMeasuresView.setHoveredMeasure( + hoveredEvent ? hoveredEvent.measure : null, + ); + } + + const {current: flamegraphView} = flamegraphViewRef; + if (flamegraphView) { + flamegraphView.setHoveredFlamechartNode( + hoveredEvent ? hoveredEvent.flamechartNode : null, ); } + }, [ + reactEventsViewRef, + reactMeasuresViewRef, + flamegraphViewRef, + hoveredEvent, + ]); + + // When React component renders, rerender surface. + // TODO: See if displaying on rAF would make more sense since we're somewhat + // decoupled from React and we don't want to render canvas multiple times per + // frame. + useLayoutEffect(() => { + surfaceRef.current.displayIfNeeded(); }); return ( @@ -177,13 +346,13 @@ function AutoSizedCanvas({ Copy component stack )} - {measure !== null && ( + {/* {measure !== null && ( zoomToBatch(contextData.data, measure, state)} title="Zoom to batch"> Zoom to batch - )} + )} */} {measure !== null && ( copySummary(contextData.data, measure)} @@ -202,7 +371,8 @@ function AutoSizedCanvas({ copy( - `line ${flamechartNode.node.frame.line}, column ${flamechartNode.node.frame.col}`, + `line ${flamechartNode.node.frame.line || + ''}, column ${flamechartNode.node.frame.col || ''}`, ) } title="Copy location"> @@ -214,7 +384,11 @@ function AutoSizedCanvas({ }} {!isContextMenuShown && ( - + )} ); diff --git a/src/EventTooltip.js b/src/EventTooltip.js index 9b0a0e6..3ab48ac 100644 --- a/src/EventTooltip.js +++ b/src/EventTooltip.js @@ -1,6 +1,6 @@ // @flow -import type {PanAndZoomState} from './util/usePanAndZoom'; +import type {Point} from './layout'; import type {FlamechartFrame} from '@elg/speedscope'; import type { ReactEvent, @@ -20,7 +20,7 @@ import styles from './EventTooltip.css'; type Props = {| data: ReactProfilerData, hoveredEvent: ReactHoverContextInfo | null, - state: PanAndZoomState, + origin: Point, |}; function formatTimestamp(ms) { @@ -38,12 +38,10 @@ function trimComponentName(name) { return name; } -export default function EventTooltip({data, hoveredEvent, state}: Props) { - const {canvasMouseY, canvasMouseX} = state; - +export default function EventTooltip({data, hoveredEvent, origin}: Props) { const tooltipRef = useSmartTooltip({ - mouseX: canvasMouseX, - mouseY: canvasMouseY, + mouseX: origin.x, + mouseY: origin.y, }); if (hoveredEvent === null) { diff --git a/src/canvas/canvasUtils.js b/src/canvas/canvasUtils.js index ae76961..96508a6 100644 --- a/src/canvas/canvasUtils.js +++ b/src/canvas/canvasUtils.js @@ -1,16 +1,10 @@ // @flow // Contains helper functions for rendering canvas elements -import type {ReactLane, ReactProfilerData} from '../types'; +import type {Rect} from '../layout'; import memoize from 'memoize-one'; -import { - INTERVAL_TIMES, - MAX_INTERVAL_SIZE_PX, - REACT_GUTTER_SIZE, - REACT_WORK_SIZE, - REACT_WORK_BORDER_SIZE, -} from './constants'; +import {INTERVAL_TIMES, MAX_INTERVAL_SIZE_PX} from './constants'; // hidpi canvas: https://www.html5rocks.com/en/tutorials/canvas/hidpi/ function configureRetinaCanvas(canvas, height, width) { @@ -39,20 +33,6 @@ export const getCanvasContext = memoize( }, ); -export function getCanvasMousePos( - canvas: HTMLCanvasElement, - mouseEvent: MouseEvent, -) { - const rect = - canvas instanceof HTMLCanvasElement - ? canvas.getBoundingClientRect() - : {left: 0, top: 0}; - const canvasMouseX = mouseEvent.clientX - rect.left; - const canvasMouseY = mouseEvent.clientY - rect.top; - - return {canvasMouseX, canvasMouseY}; -} - // Time mark intervals vary based on the current zoom range and the time it represents. // In Chrome, these seem to range from 70-140 pixels wide. // Time wise, they represent intervals of e.g. 1s, 500ms, 200ms, 100ms, 50ms, 20ms. @@ -92,15 +72,33 @@ export const trimFlamegraphText = ( return null; }; -export const getLaneHeight = ( - data: $ReadOnly, - lane: ReactLane, -): number => { - // TODO: Return 0 if data has no data for lane - return ( - REACT_GUTTER_SIZE + - REACT_WORK_SIZE + - REACT_GUTTER_SIZE + - REACT_WORK_BORDER_SIZE - ); -}; +export function positioningScaleFactor( + intrinsicWidth: number, + frame: Rect, +): number { + return frame.size.width / intrinsicWidth; +} + +export function timestampToPosition( + timestamp: number, + scaleFactor: number, + frame: Rect, +): number { + return frame.origin.x + timestamp * scaleFactor; +} + +export function positionToTimestamp( + position: number, + scaleFactor: number, + frame: Rect, +): number { + return (position - frame.origin.x) / scaleFactor; +} + +export function durationToWidth(duration: number, scaleFactor: number): number { + return duration * scaleFactor; +} + +export function widthToDuration(width: number, scaleFactor: number): number { + return width / scaleFactor; +} diff --git a/src/canvas/constants.js b/src/canvas/constants.js index 09b58a5..7aaebf9 100644 --- a/src/canvas/constants.js +++ b/src/canvas/constants.js @@ -10,7 +10,6 @@ export const MARKER_TEXT_PADDING = 8; export const BAR_HEIGHT = 16; export const BAR_HORIZONTAL_SPACING = 1; export const BAR_SPACER_SIZE = 6; -export const EVENT_SIZE = 6; // TODO: What's the difference between this and REACT_EVENT_SIZE? export const MIN_BAR_WIDTH = 1; export const SECTION_GUTTER_SIZE = 4; diff --git a/src/canvas/getHoveredEvent.js b/src/canvas/getHoveredEvent.js deleted file mode 100644 index c23d563..0000000 --- a/src/canvas/getHoveredEvent.js +++ /dev/null @@ -1,241 +0,0 @@ -// @flow - -import type { - FlamechartData, - ReactHoverContextInfo, - ReactLane, - ReactProfilerData, -} from '../types'; -import type {PanAndZoomState} from '../util/usePanAndZoom'; - -import { - durationToWidth, - positionToTimestamp, - timestampToPosition, -} from '../util/usePanAndZoom'; -import {REACT_TOTAL_NUM_LANES} from '../constants'; - -import {getLaneHeight} from './canvasUtils'; -import { - HEADER_HEIGHT_FIXED, - REACT_EVENT_SIZE, - FLAMECHART_FRAME_HEIGHT, - EVENT_ROW_HEIGHT_FIXED, -} from './constants'; - -/** - * Returns a hover context info object containing the `ReactEvent` currently - * being hovered over. - * - * NOTE: Assumes that the events are all in a row, and that the cursor is - * already known to be in this row; this function only compares the X positions - * of the cursor and events. - */ -function getHoveredReactEvent( - data: $ReadOnly, - panAndZoomState: PanAndZoomState, -): ReactHoverContextInfo | null { - const {canvasMouseX} = panAndZoomState; - const {events} = data; - - // Because data ranges may overlap, we want to find the last intersecting item. - // This will always be the one on "top" (the one the user is hovering over). - for (let index = events.length - 1; index >= 0; index--) { - const event = events[index]; - const {timestamp} = event; - - const eventX = timestampToPosition(timestamp, panAndZoomState); - const startX = eventX - REACT_EVENT_SIZE / 2; - const stopX = eventX + REACT_EVENT_SIZE / 2; - if (canvasMouseX >= startX && canvasMouseX <= stopX) { - return { - event, - flamechartNode: null, - measure: null, - lane: null, - data, - }; - } - } - - return null; -} - -/** - * Returns a hover context info object containing the `ReactMeasure` currently - * being hovered over. - */ -function getHoveredReactMeasure( - data: $ReadOnly, - panAndZoomState: PanAndZoomState, - stackSectionBaseY: number, -): ReactHoverContextInfo | null { - const {canvasMouseX, canvasMouseY, offsetY} = panAndZoomState; - - // Identify the lane being hovered over - const adjustedCanvasMouseY = canvasMouseY - stackSectionBaseY + offsetY; - let laneMinY = 0; - let lane = null; - for ( - let laneIndex: ReactLane = 0; - laneIndex < REACT_TOTAL_NUM_LANES; - laneIndex++ - ) { - const laneHeight = getLaneHeight(data, laneIndex); - if ( - adjustedCanvasMouseY >= laneMinY && - adjustedCanvasMouseY <= laneMinY + laneHeight - ) { - lane = laneIndex; - break; - } - laneMinY += laneHeight; - } - - if (lane === null) { - return null; - } - - // Find the measure in `lane` being hovered over. - // - // Because data ranges may overlap, we want to find the last intersecting item. - // This will always be the one on "top" (the one the user is hovering over). - const {measures} = data; - for (let index = measures.length - 1; index >= 0; index--) { - const measure = measures[index]; - if (!measure.lanes.includes(lane)) { - continue; - } - - const {duration, timestamp} = measure; - const pointerTime = positionToTimestamp(canvasMouseX, panAndZoomState); - - if (pointerTime >= timestamp && pointerTime <= timestamp + duration) { - return { - event: null, - flamechartNode: null, - measure, - lane, - data, - }; - } - } - - return null; -} - -/** - * Returns a hover context info object containing the `FlamechartFrame` - * currently being hovered over. - */ -function getHoveredFlamechartEvent( - data: $ReadOnly, - flamechart: $ReadOnly, - panAndZoomState: PanAndZoomState, - stackSectionBaseY: number, -): ReactHoverContextInfo | null { - const {canvasMouseX, canvasMouseY, offsetY} = panAndZoomState; - - const layerIndex = Math.floor( - (canvasMouseY + offsetY - stackSectionBaseY) / FLAMECHART_FRAME_HEIGHT, - ); - const layer = flamechart.layers[layerIndex]; - - if (!layer) { - return null; - } - - let startIndex = 0; - let stopIndex = layer.length - 1; - while (startIndex <= stopIndex) { - const currentIndex = Math.floor((startIndex + stopIndex) / 2); - const flamechartNode = layer[currentIndex]; - - const {end, start} = flamechartNode; - - const width = durationToWidth((end - start) / 1000, panAndZoomState); - const x = Math.floor(timestampToPosition(start / 1000, panAndZoomState)); - - if (x <= canvasMouseX && x + width >= canvasMouseX) { - return { - event: null, - flamechartNode, - measure: null, - lane: null, - data, - }; - } - - if (x > canvasMouseX) { - stopIndex = currentIndex - 1; - } else { - startIndex = currentIndex + 1; - } - } - - return null; -} - -/** - * Returns a hover context object if the cursor is hovering over a React - * event/measure or a Flamechart node, otherwise returns null. - */ -export function getHoveredEvent( - schedulerCanvasHeight: number, - data: $ReadOnly, - flamechart: $ReadOnly, - panAndZoomState: PanAndZoomState, -): ReactHoverContextInfo | null { - const {canvasMouseY, offsetY} = panAndZoomState; - - // These variables keep track of the current vertical stack sections' base and - // max Y coordinates. For example, if we're at the React event row, these are - // what the values represent: - // ┌----------------------------------- - // | t⁰ t¹ t² ... - // ├---------------------------------- <- stackSectionBaseY - // | - // | - // | - // ├---------------------------------- <- stackSectionMaxY - // | - // | - // | - // ├---------------------------------- - // | - // | - // | - // └---------------------------------- - let stackSectionBaseY: number; - let stackSectionMaxY: number = 0; - - // Header section: do nothing - stackSectionBaseY = stackSectionMaxY; - stackSectionMaxY += HEADER_HEIGHT_FIXED; - if (canvasMouseY < stackSectionMaxY) { - return null; - } - - // ReactEvent row - stackSectionBaseY = stackSectionMaxY; - stackSectionMaxY += EVENT_ROW_HEIGHT_FIXED; - if (canvasMouseY + offsetY < stackSectionMaxY) { - return getHoveredReactEvent(data, panAndZoomState); - } - - // ReactMeasure lanes - stackSectionBaseY = stackSectionMaxY; - stackSectionMaxY += schedulerCanvasHeight; - if (canvasMouseY + offsetY < stackSectionMaxY) { - return getHoveredReactMeasure(data, panAndZoomState, stackSectionBaseY); - } - - // Flamechart area - stackSectionBaseY = stackSectionMaxY; - return getHoveredFlamechartEvent( - data, - flamechart, - panAndZoomState, - stackSectionBaseY, - ); -} diff --git a/src/canvas/renderCanvas.js b/src/canvas/renderCanvas.js deleted file mode 100644 index 3737c1c..0000000 --- a/src/canvas/renderCanvas.js +++ /dev/null @@ -1,563 +0,0 @@ -// @flow - -import type { - FlamechartData, - ReactHoverContextInfo, - ReactLane, - ReactProfilerData, -} from '../types'; -import type {PanAndZoomState} from '../util/usePanAndZoom'; - -import memoize from 'memoize-one'; - -import { - durationToWidth, - positionToTimestamp, - timestampToPosition, -} from '../util/usePanAndZoom'; - -import { - getCanvasContext, - getTimeTickInterval, - trimFlamegraphText, - getLaneHeight, -} from './canvasUtils'; - -import { - COLORS, - MARKER_FONT_SIZE, - MARKER_TEXT_PADDING, - MARKER_HEIGHT, - MARKER_TICK_HEIGHT, - REACT_GUTTER_SIZE, - REACT_WORK_SIZE, - REACT_WORK_BORDER_SIZE, - FLAMECHART_FONT_SIZE, - FLAMECHART_FRAME_HEIGHT, - FLAMECHART_TEXT_PADDING, - LABEL_FIXED_WIDTH, - HEADER_HEIGHT_FIXED, - REACT_EVENT_SIZE, - EVENT_SIZE, - REACT_EVENT_ROW_PADDING, - EVENT_ROW_HEIGHT_FIXED, -} from './constants'; -import {REACT_TOTAL_NUM_LANES} from '../constants'; - -// The canvas we're rendering looks a little like the outline below. -// Left labels mark different scheduler REACT_PRIORITIES, -// and top labels mark different times (based on how long the data runs and how zoomed in we are). -// The content in the bottom right area is scrollable, but the top/left labels are fixed. -// -// ┌----------------------------------- -// | t⁰ t¹ t² ... -// ├-------------┬--------------------- -// | unscheduled ┋ -// ├-------------┼--------------------- -// | high ┋ -// ├-------------┼--------------------- -// | normal ┋ -// ├-------------┼--------------------- -// | low ┋ -// ├-------------┼--------------------- -// | ┋ -// | ┋ -// | ┋ -// └-------------┴--------------------- -// -// Because everything we draw on a canvas is drawn on top of what was already there, -// we render the graph in several passes, each pass creating a layer: -// ,──────── -// axis labels → / -// ,──/ -// profiling data → / '─────────── -// ,─ / -// axis marker lines → / '──────────────── -// ,─ / -// background fills → / '───────────────────── -// / -// '────────────────────────── -// - -// TODO: (windowing, optimization) We can avoid rendering offscreen data in many -// of the render* functions in this file. - -function renderBackgroundFills(context, canvasWidth, canvasHeight) { - // Fill the canvas with the background color - context.fillStyle = COLORS.BACKGROUND; - context.fillRect(0, 0, canvasWidth, canvasHeight); -} - -/** - * Render React events from `data` in a single row. - * - * The React events will be rendered into the canvas `context` of dimensions - * `canvasWidth`x`canvasHeight`, starting at `canvasStartY`. The events will be - * offset by pan and zoom `state`. Optionally with a highlighted - * `hoveredEvent`. - * - * @see renderSingleReactEvent - */ -function renderReactEventRow( - context, - events, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - canvasStartY, -): number { - const {offsetY} = state; - - // Draw events - const baseY = canvasStartY + REACT_EVENT_ROW_PADDING; - events.forEach(event => { - const showHoverHighlight = hoveredEvent && hoveredEvent.event === event; - renderSingleReactEvent( - context, - state, - event, - canvasWidth, - baseY, - offsetY, - showHoverHighlight, - ); - }); - - // Draw the hovered and/or selected items on top so they stand out. - // This is helpful if there are multiple (overlapping) items close to each other. - if (hoveredEvent !== null && hoveredEvent.event !== null) { - renderSingleReactEvent( - context, - state, - hoveredEvent.event, - canvasWidth, - baseY, - offsetY, - true, - ); - } - - // Render bottom border - context.fillStyle = COLORS.PRIORITY_BORDER; - context.fillRect( - 0, - Math.floor( - canvasStartY + EVENT_ROW_HEIGHT_FIXED - offsetY - REACT_WORK_BORDER_SIZE, - ), - canvasWidth, - REACT_WORK_BORDER_SIZE, - ); - - return canvasStartY + EVENT_ROW_HEIGHT_FIXED; -} - -/** - * Render a single `ReactEvent` as a circle in the canvas. - * - * @see renderReactEventRow - */ -function renderSingleReactEvent( - context, - state, - event, - canvasWidth, - baseY, - panOffsetY, - showHoverHighlight, -) { - const {timestamp, type} = event; - - const x = timestampToPosition(timestamp, state); - if (x + EVENT_SIZE / 2 < 0 || canvasWidth < x) { - return; // Not in view - } - - let fillStyle = null; - - switch (type) { - case 'schedule-render': - case 'schedule-state-update': - case 'schedule-force-update': - if (event.isCascading) { - fillStyle = showHoverHighlight - ? COLORS.REACT_SCHEDULE_CASCADING_HOVER - : COLORS.REACT_SCHEDULE_CASCADING; - } else { - fillStyle = showHoverHighlight - ? COLORS.REACT_SCHEDULE_HOVER - : COLORS.REACT_SCHEDULE; - } - break; - case 'suspense-suspend': - case 'suspense-resolved': - case 'suspense-rejected': - fillStyle = showHoverHighlight - ? COLORS.REACT_SUSPEND_HOVER - : COLORS.REACT_SUSPEND; - break; - default: - console.warn(`Unexpected event type "${type}"`); - break; - } - - if (fillStyle !== null) { - const circumference = REACT_EVENT_SIZE; - const y = baseY + REACT_EVENT_SIZE / 2 - panOffsetY; - - context.beginPath(); - context.fillStyle = fillStyle; - context.arc(x, y, circumference / 2, 0, 2 * Math.PI); - context.fill(); - } -} - -/** - * Render React measures from `data` in parallel lanes. - * - * The React measures will be rendered into the canvas `context` of dimensions - * `canvasWidth`x`canvasHeight`, starting at `canvasStartY`. The measures will - * be offset by pan and zoom `state`. Optionally with a highlighted - * `hoveredEvent`. - * - * @see renderSingleReactMeasure - */ -function renderReactMeasures( - context, - data, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - canvasStartY, -): number { - const {offsetY} = state; - - // TODO: Compute lanes to render from data? Or just use getLaneHeight to skip lanes - const lanesToRender: ReactLane[] = Array.from( - Array(REACT_TOTAL_NUM_LANES).keys(), - ); - - let laneMinY = canvasStartY; - - // Render lanes background. - // TODO: Figure out a way not to compute total height twice - const schedulerAreaHeight = lanesToRender.reduce( - (height, lane) => height + getLaneHeight(data, lane), - 0, - ); - context.fillStyle = COLORS.PRIORITY_BACKGROUND; - context.fillRect( - 0, - Math.floor(canvasStartY - offsetY), - canvasWidth, - schedulerAreaHeight, - ); - - lanesToRender.forEach(lane => { - const baseY = laneMinY + REACT_GUTTER_SIZE; - - data.measures - // TODO: Optimization: precompute this so that we don't filter this array |lanesToRender| times - .filter(measure => measure.lanes.includes(lane)) - .forEach(measure => { - const showHoverHighlight = - hoveredEvent && hoveredEvent.measure === measure; - const showGroupHighlight = - hoveredEvent && - hoveredEvent.measure && - hoveredEvent.measure.batchUID === measure.batchUID; - renderSingleReactMeasure( - context, - state, - measure, - canvasWidth, - baseY, - showGroupHighlight, - showHoverHighlight, - ); - }); - - laneMinY += getLaneHeight(data, lane); - - // Render bottom border - context.fillStyle = COLORS.PRIORITY_BORDER; - context.fillRect( - 0, - Math.floor(laneMinY - offsetY - REACT_WORK_BORDER_SIZE), - canvasWidth, - REACT_WORK_BORDER_SIZE, - ); - }); - - return laneMinY; -} - -/** - * Render a single `ReactMeasure` as a bar in the canvas. - * - * @see renderReactMeasures - */ -function renderSingleReactMeasure( - context, - state, - measure, - canvasWidth, - baseY, - showGroupHighlight, - showHoverHighlight, -) { - const {timestamp, type, duration} = measure; - const {offsetY} = state; - - let fillStyle = null; - let hoveredFillStyle = null; - let groupSelectedFillStyle = null; - - // We could change the max to 0 and just skip over rendering anything that small, - // but this has the effect of making the chart look very empty when zoomed out. - // So long as perf is okay- it might be best to err on the side of showing things. - const width = durationToWidth(duration, state); - if (width <= 0) { - return; // Too small to render at this zoom level - } - - const x = timestampToPosition(timestamp, state); - if (x + width < 0 || canvasWidth < x) { - return; // Not in view - } - - switch (type) { - case 'commit': - fillStyle = COLORS.REACT_COMMIT; - hoveredFillStyle = COLORS.REACT_COMMIT_HOVER; - groupSelectedFillStyle = COLORS.REACT_COMMIT_SELECTED; - break; - case 'render-idle': - // We could render idle time as diagonal hashes. - // This looks nicer when zoomed in, but not so nice when zoomed out. - // color = context.createPattern(getIdlePattern(), 'repeat'); - fillStyle = COLORS.REACT_IDLE; - hoveredFillStyle = COLORS.REACT_IDLE_HOVER; - groupSelectedFillStyle = COLORS.REACT_IDLE_SELECTED; - break; - case 'render': - fillStyle = COLORS.REACT_RENDER; - hoveredFillStyle = COLORS.REACT_RENDER_HOVER; - groupSelectedFillStyle = COLORS.REACT_RENDER_SELECTED; - break; - case 'layout-effects': - fillStyle = COLORS.REACT_LAYOUT_EFFECTS; - hoveredFillStyle = COLORS.REACT_LAYOUT_EFFECTS_HOVER; - groupSelectedFillStyle = COLORS.REACT_LAYOUT_EFFECTS_SELECTED; - break; - case 'passive-effects': - fillStyle = COLORS.REACT_PASSIVE_EFFECTS; - hoveredFillStyle = COLORS.REACT_PASSIVE_EFFECTS_HOVER; - groupSelectedFillStyle = COLORS.REACT_PASSIVE_EFFECTS_SELECTED; - break; - default: - throw new Error(`Unexpected measure type "${type}"`); - } - - const y = baseY - offsetY; - - context.fillStyle = showHoverHighlight - ? hoveredFillStyle - : showGroupHighlight - ? groupSelectedFillStyle - : fillStyle; - context.fillRect( - Math.floor(x), - Math.floor(y), - Math.floor(width), - REACT_WORK_SIZE, - ); -} - -function renderFlamechart( - context, - flamechart, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - /** y coord on canvas to start painting at */ - canvasStartY, -) { - context.textAlign = 'left'; - context.textBaseline = 'middle'; - context.font = `${FLAMECHART_FONT_SIZE}px sans-serif`; - - for (let i = 0; i < flamechart.layers.length; i++) { - const nodes = flamechart.layers[i]; - - const layerY = Math.floor(canvasStartY + i * FLAMECHART_FRAME_HEIGHT); - if ( - layerY + FLAMECHART_FRAME_HEIGHT < HEADER_HEIGHT_FIXED || - canvasHeight < layerY - ) { - continue; // Not in view - } - - for (let j = 0; j < nodes.length; j++) { - const {end, node, start} = nodes[j]; - const {name} = node.frame; - - const showHoverHighlight = - hoveredEvent && hoveredEvent.flamechartNode === nodes[j]; - - const width = durationToWidth((end - start) / 1000, state); - if (width <= 0) { - continue; // Too small to render at this zoom level - } - - const x = Math.floor(timestampToPosition(start / 1000, state)); - if (x + width < 0 || canvasWidth < x) { - continue; // Not in view - } - - context.fillStyle = showHoverHighlight - ? COLORS.FLAME_GRAPH_HOVER - : COLORS.FLAME_GRAPH; - - context.fillRect( - x, - layerY, - Math.floor(width - REACT_WORK_BORDER_SIZE), - Math.floor(FLAMECHART_FRAME_HEIGHT - REACT_WORK_BORDER_SIZE), - ); - - if (width > FLAMECHART_TEXT_PADDING * 2) { - const trimmedName = trimFlamegraphText( - context, - name, - width - FLAMECHART_TEXT_PADDING * 2 + (x < 0 ? x : 0), - ); - if (trimmedName !== null) { - context.fillStyle = COLORS.PRIORITY_LABEL; - context.fillText( - trimmedName, - x + FLAMECHART_TEXT_PADDING - (x < 0 ? x : 0), - layerY + FLAMECHART_FRAME_HEIGHT / 2, - ); - } - } - } - } -} - -function renderAxisMarkers( - context, - state, - canvasWidth, - panOffsetX, - panZoomLevel, -) { - context.fillStyle = COLORS.BACKGROUND; - context.fillRect(0, 0, canvasWidth, HEADER_HEIGHT_FIXED); - - context.fillStyle = COLORS.PRIORITY_BORDER; - context.fillRect(0, MARKER_HEIGHT, canvasWidth, REACT_WORK_BORDER_SIZE); - - // Charting data renders within this region of pixels as "scrollable" content. - // Time markers (top) and priority labels (left) are fixed content. - const scrollableCanvasWidth = canvasWidth - LABEL_FIXED_WIDTH; - - const interval = getTimeTickInterval(panZoomLevel); - const intervalSize = interval * panZoomLevel; - const firstIntervalPosition = - 0 - panOffsetX + Math.floor(panOffsetX / intervalSize) * intervalSize; - - for ( - let i = firstIntervalPosition; - i < scrollableCanvasWidth; - i += intervalSize - ) { - if (i > 0) { - const markerTimestamp = positionToTimestamp(i + LABEL_FIXED_WIDTH, state); - const markerLabel = Math.round(markerTimestamp); - - const x = LABEL_FIXED_WIDTH + i; - - context.fillStyle = COLORS.PRIORITY_BORDER; - context.fillRect( - x, - MARKER_HEIGHT - MARKER_TICK_HEIGHT, - REACT_WORK_BORDER_SIZE, - MARKER_TICK_HEIGHT, - ); - - context.fillStyle = COLORS.TIME_MARKER_LABEL; - context.textAlign = 'right'; - context.textBaseline = 'middle'; - context.font = `${MARKER_FONT_SIZE}px sans-serif`; - context.fillText( - `${markerLabel}ms`, - x - MARKER_TEXT_PADDING, - MARKER_HEIGHT / 2, - ); - } - } -} - -// TODO Passing "state" directly breaks memoization for e.g. mouse moves -export const renderCanvas = memoize( - ( - data: $ReadOnly, - flamechart: $ReadOnly, - canvas: HTMLCanvasElement, - canvasWidth: number, - canvasHeight: number, - state: $ReadOnly, - hoveredEvent: $ReadOnly | null, - ) => { - const {offsetX, offsetY, zoomLevel} = state; - - const context = getCanvasContext(canvas, canvasHeight, canvasWidth, true); - - renderBackgroundFills(context, canvasWidth, canvasHeight); - - let schedulerAreaEndY = HEADER_HEIGHT_FIXED; - - schedulerAreaEndY = renderReactEventRow( - context, - data.events, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - schedulerAreaEndY, - ); - - schedulerAreaEndY = renderReactMeasures( - context, - data, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - // Time markers do not scroll off screen; they are always rendered at a - // fixed vertical position. - schedulerAreaEndY, - ); - - // Flame graph data renders below the prioritized React data. - // TODO Timestamp alignment is off by a few hundred me from our user timing marks; why? - renderFlamechart( - context, - flamechart, - state, - hoveredEvent, - canvasWidth, - canvasHeight, - schedulerAreaEndY - offsetY, - ); - - // TOP: Time markers - // Time markers do not scroll off screen; they are always rendered at a fixed vertical position. - // Render them last, on top of everything else, to account for things scrolled beneath them. - // Draw time marker text on top of the priority groupings - renderAxisMarkers(context, state, canvasWidth, offsetX, zoomLevel); - }, -); diff --git a/src/canvas/views/FlamegraphView.js b/src/canvas/views/FlamegraphView.js new file mode 100644 index 0000000..603bc1b --- /dev/null +++ b/src/canvas/views/FlamegraphView.js @@ -0,0 +1,246 @@ +// @flow + +import type {FlamechartFrame} from '@elg/speedscope'; +import type {Interaction, HoverInteraction} from '../../useCanvasInteraction'; +import type {FlamechartData, ReactProfilerData} from '../../types'; +import type {Rect, Size} from '../../layout'; + +import { + View, + Surface, + rectContainsPoint, + rectEqualToRect, + rectIntersectsRect, + rectIntersectionWithRect, +} from '../../layout'; +import { + durationToWidth, + positioningScaleFactor, + timestampToPosition, + trimFlamegraphText, +} from '../canvasUtils'; +import { + COLORS, + FLAMECHART_FONT_SIZE, + FLAMECHART_FRAME_HEIGHT, + FLAMECHART_TEXT_PADDING, + REACT_WORK_BORDER_SIZE, +} from '../constants'; + +export class FlamegraphView extends View { + flamechart: FlamechartData; + profilerData: ReactProfilerData; + intrinsicSize: Size; + + hoveredFlamechartNode: FlamechartFrame | null = null; + onHover: ((node: FlamechartFrame | null) => void) | null = null; + + constructor( + surface: Surface, + frame: Rect, + flamechart: FlamechartData, + profilerData: ReactProfilerData, + ) { + super(surface, frame); + this.flamechart = flamechart; + this.profilerData = profilerData; + this.intrinsicSize = { + width: this.profilerData.duration, + height: this.flamechart.getLayers().length * FLAMECHART_FRAME_HEIGHT, + }; + } + + desiredSize() { + return this.intrinsicSize; + } + + setHoveredFlamechartNode(hoveredFlamechartNode: FlamechartFrame | null) { + if (this.hoveredFlamechartNode === hoveredFlamechartNode) { + return; + } + this.hoveredFlamechartNode = hoveredFlamechartNode; + this.setNeedsDisplay(); + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + flamechart, + hoveredFlamechartNode, + intrinsicSize, + visibleArea, + } = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + context.textAlign = 'left'; + context.textBaseline = 'middle'; + context.font = `${FLAMECHART_FONT_SIZE}px sans-serif`; + + const scaleFactor = positioningScaleFactor(intrinsicSize.width, frame); + + for (let i = 0; i < flamechart.getLayers().length; i++) { + const nodes = flamechart.getLayers()[i]; + + const layerY = Math.floor(frame.origin.y + i * FLAMECHART_FRAME_HEIGHT); + if ( + layerY + FLAMECHART_FRAME_HEIGHT < visibleArea.origin.y || + visibleArea.origin.y + visibleArea.size.height < layerY + ) { + continue; // Not in view + } + + for (let j = 0; j < nodes.length; j++) { + const {end, node, start} = nodes[j]; + const {name} = node.frame; + + const width = durationToWidth((end - start) / 1000, scaleFactor); + if (width < 1) { + continue; // Too small to render at this zoom level + } + + const x = Math.floor( + timestampToPosition(start / 1000, scaleFactor, frame), + ); + const nodeRect: Rect = { + origin: {x, y: layerY}, + size: { + width: Math.floor(width - REACT_WORK_BORDER_SIZE), + height: Math.floor( + FLAMECHART_FRAME_HEIGHT - REACT_WORK_BORDER_SIZE, + ), + }, + }; + if (!rectIntersectsRect(nodeRect, visibleArea)) { + continue; // Not in view + } + + const showHoverHighlight = hoveredFlamechartNode === nodes[j]; + context.fillStyle = showHoverHighlight + ? COLORS.FLAME_GRAPH_HOVER + : COLORS.FLAME_GRAPH; + + const drawableRect = rectIntersectionWithRect(nodeRect, visibleArea); + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + + if (width > FLAMECHART_TEXT_PADDING * 2) { + const trimmedName = trimFlamegraphText( + context, + name, + width - FLAMECHART_TEXT_PADDING * 2 + (x < 0 ? x : 0), + ); + + if (trimmedName !== null) { + context.fillStyle = COLORS.PRIORITY_LABEL; + + // Prevent text from being drawn outside `viewableArea` + const textOverflowsViewableArea = !rectEqualToRect( + drawableRect, + nodeRect, + ); + if (textOverflowsViewableArea) { + context.save(); + context.rect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + context.clip(); + } + + context.fillText( + trimmedName, + x + FLAMECHART_TEXT_PADDING - (x < 0 ? x : 0), + layerY + FLAMECHART_FRAME_HEIGHT / 2, + ); + + if (textOverflowsViewableArea) { + context.restore(); + } + } + } + } + } + } + + /** + * @private + */ + handleHover(interaction: HoverInteraction) { + const {flamechart, frame, intrinsicSize, onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + // Identify the layer being hovered over + const adjustedCanvasMouseY = location.y - frame.origin.y; + const layerIndex = Math.floor( + adjustedCanvasMouseY / FLAMECHART_FRAME_HEIGHT, + ); + if (layerIndex < 0 || layerIndex >= flamechart.getLayers().length) { + onHover(null); + return; + } + const layer = flamechart.getLayers()[layerIndex]; + + if (!layer) { + return null; + } + + // Find the node being hovered over. + const scaleFactor = positioningScaleFactor(intrinsicSize.width, frame); + let startIndex = 0; + let stopIndex = layer.length - 1; + while (startIndex <= stopIndex) { + const currentIndex = Math.floor((startIndex + stopIndex) / 2); + const flamechartNode = layer[currentIndex]; + + const {end, start} = flamechartNode; + + const width = durationToWidth((end - start) / 1000, scaleFactor); + + const x = Math.floor( + timestampToPosition(start / 1000, scaleFactor, frame), + ); + + if (x <= location.x && x + width >= location.x) { + onHover(flamechartNode); + return; + } + + if (x > location.x) { + stopIndex = currentIndex - 1; + } else { + startIndex = currentIndex + 1; + } + } + + onHover(null); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + switch (interaction.type) { + case 'hover': + this.handleHover(interaction); + break; + } + } +} diff --git a/src/canvas/views/ReactEventsView.js b/src/canvas/views/ReactEventsView.js new file mode 100644 index 0000000..9eff893 --- /dev/null +++ b/src/canvas/views/ReactEventsView.js @@ -0,0 +1,246 @@ +// @flow + +import type {Interaction, HoverInteraction} from '../../useCanvasInteraction'; +import type {ReactEvent, ReactProfilerData} from '../../types'; +import type {Rect, Size} from '../../layout'; + +import { + positioningScaleFactor, + timestampToPosition, + positionToTimestamp, + widthToDuration, +} from '../canvasUtils'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + rectIntersectionWithRect, +} from '../../layout'; +import { + COLORS, + EVENT_ROW_HEIGHT_FIXED, + REACT_EVENT_ROW_PADDING, + REACT_EVENT_SIZE, + REACT_WORK_BORDER_SIZE, +} from '../constants'; + +export class ReactEventsView extends View { + profilerData: ReactProfilerData; + intrinsicSize: Size; + + hoveredEvent: ReactEvent | null = null; + onHover: ((event: ReactEvent | null) => void) | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + this.profilerData = profilerData; + + this.intrinsicSize = { + width: this.profilerData.duration, + height: EVENT_ROW_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this.intrinsicSize; + } + + setHoveredEvent(hoveredEvent: ReactEvent | null) { + if (this.hoveredEvent === hoveredEvent) { + return; + } + this.hoveredEvent = hoveredEvent; + this.setNeedsDisplay(); + } + + /** + * Draw a single `ReactEvent` as a circle in the canvas. + */ + drawSingleReactEvent( + context: CanvasRenderingContext2D, + rect: Rect, + event: ReactEvent, + baseY: number, + scaleFactor: number, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp, type} = event; + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const radius = REACT_EVENT_SIZE / 2; + const eventRect: Rect = { + origin: { + x: x - radius, + y: baseY, + }, + size: {width: REACT_EVENT_SIZE, height: REACT_EVENT_SIZE}, + }; + if (!rectIntersectsRect(eventRect, rect)) { + return; // Not in view + } + + let fillStyle = null; + + switch (type) { + case 'schedule-render': + case 'schedule-state-update': + case 'schedule-force-update': + if (event.isCascading) { + fillStyle = showHoverHighlight + ? COLORS.REACT_SCHEDULE_CASCADING_HOVER + : COLORS.REACT_SCHEDULE_CASCADING; + } else { + fillStyle = showHoverHighlight + ? COLORS.REACT_SCHEDULE_HOVER + : COLORS.REACT_SCHEDULE; + } + break; + case 'suspense-suspend': + case 'suspense-resolved': + case 'suspense-rejected': + fillStyle = showHoverHighlight + ? COLORS.REACT_SUSPEND_HOVER + : COLORS.REACT_SUSPEND; + break; + default: + console.warn(`Unexpected event type "${type}"`); + break; + } + + if (fillStyle !== null) { + const y = eventRect.origin.y + radius; + + context.beginPath(); + context.fillStyle = fillStyle; + context.arc(x, y, radius, 0, 2 * Math.PI); + context.fill(); + } + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + profilerData: {events}, + hoveredEvent, + visibleArea, + } = this; + + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + // Draw events + const baseY = frame.origin.y + REACT_EVENT_ROW_PADDING; + const scaleFactor = positioningScaleFactor(this.intrinsicSize.width, frame); + + events.forEach(event => { + if (event === hoveredEvent) { + return; + } + this.drawSingleReactEvent( + context, + visibleArea, + event, + baseY, + scaleFactor, + false, + ); + }); + + // Draw the hovered and/or selected items on top so they stand out. + // This is helpful if there are multiple (overlapping) items close to each other. + if (hoveredEvent !== null) { + this.drawSingleReactEvent( + context, + visibleArea, + hoveredEvent, + baseY, + scaleFactor, + true, + ); + } + + // Render bottom border. + // Propose border rect, check if intersects with `rect`, draw intersection. + const borderFrame: Rect = { + origin: { + x: frame.origin.x, + y: frame.origin.y + EVENT_ROW_HEIGHT_FIXED - REACT_WORK_BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: REACT_WORK_BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = rectIntersectionWithRect( + borderFrame, + visibleArea, + ); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + + /** + * @private + */ + handleHover(interaction: HoverInteraction) { + const {frame, onHover, visibleArea} = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + const { + profilerData: {events}, + } = this; + const scaleFactor = positioningScaleFactor(this.intrinsicSize.width, frame); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const eventTimestampAllowance = widthToDuration( + REACT_EVENT_SIZE / 2, + scaleFactor, + ); + + // Because data ranges may overlap, we want to find the last intersecting item. + // This will always be the one on "top" (the one the user is hovering over). + for (let index = events.length - 1; index >= 0; index--) { + const event = events[index]; + const {timestamp} = event; + + if ( + timestamp - eventTimestampAllowance <= hoverTimestamp && + hoverTimestamp <= timestamp + eventTimestampAllowance + ) { + onHover(event); + return; + } + } + + onHover(null); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + switch (interaction.type) { + case 'hover': + this.handleHover(interaction); + break; + } + } +} diff --git a/src/canvas/views/ReactMeasuresView.js b/src/canvas/views/ReactMeasuresView.js new file mode 100644 index 0000000..a77d9c4 --- /dev/null +++ b/src/canvas/views/ReactMeasuresView.js @@ -0,0 +1,304 @@ +// @flow + +import type {Interaction, HoverInteraction} from '../../useCanvasInteraction'; +import type {ReactLane, ReactMeasure, ReactProfilerData} from '../../types'; +import type {Rect, Size} from '../../layout'; + +import { + durationToWidth, + positioningScaleFactor, + positionToTimestamp, + timestampToPosition, +} from '../canvasUtils'; +import { + View, + Surface, + rectContainsPoint, + rectIntersectsRect, + rectIntersectionWithRect, +} from '../../layout'; + +import {COLORS, REACT_WORK_BORDER_SIZE, REACT_WORK_SIZE} from '../constants'; +import {REACT_TOTAL_NUM_LANES} from '../../constants'; + +const REACT_LANE_HEIGHT = REACT_WORK_SIZE + REACT_WORK_BORDER_SIZE; + +export class ReactMeasuresView extends View { + profilerData: ReactProfilerData; + intrinsicSize: Size; + + lanesToRender: ReactLane[]; + laneToMeasures: Map; + + hoveredMeasure: ReactMeasure | null = null; + onHover: ((measure: ReactMeasure | null) => void) | null = null; + + constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) { + super(surface, frame); + this.profilerData = profilerData; + this.performPreflightComputations(); + } + + performPreflightComputations() { + this.lanesToRender = []; + this.laneToMeasures = new Map(); + + for (let lane: ReactLane = 0; lane < REACT_TOTAL_NUM_LANES; lane++) { + // Hide lanes without any measures + const measuresForLane = this.profilerData.measures.filter(measure => + measure.lanes.includes(lane), + ); + if (measuresForLane.length) { + this.lanesToRender.push(lane); + this.laneToMeasures.set(lane, measuresForLane); + } + } + + this.intrinsicSize = { + width: this.profilerData.duration, + height: this.lanesToRender.length * REACT_LANE_HEIGHT, + }; + } + + desiredSize() { + return this.intrinsicSize; + } + + setHoveredMeasure(hoveredMeasure: ReactMeasure | null) { + if (this.hoveredMeasure === hoveredMeasure) { + return; + } + this.hoveredMeasure = hoveredMeasure; + this.setNeedsDisplay(); + } + + /** + * Draw a single `ReactMeasure` as a bar in the canvas. + */ + drawSingleReactMeasure( + context: CanvasRenderingContext2D, + rect: Rect, + measure: ReactMeasure, + baseY: number, + scaleFactor: number, + showGroupHighlight: boolean, + showHoverHighlight: boolean, + ) { + const {frame} = this; + const {timestamp, type, duration} = measure; + + let fillStyle = null; + let hoveredFillStyle = null; + let groupSelectedFillStyle = null; + + // We could change the max to 0 and just skip over rendering anything that small, + // but this has the effect of making the chart look very empty when zoomed out. + // So long as perf is okay- it might be best to err on the side of showing things. + const width = durationToWidth(duration, scaleFactor); + if (width <= 0) { + return; // Too small to render at this zoom level + } + + const x = timestampToPosition(timestamp, scaleFactor, frame); + const measureRect: Rect = { + origin: {x, y: baseY}, + size: {width, height: REACT_WORK_SIZE}, + }; + if (!rectIntersectsRect(measureRect, rect)) { + return; // Not in view + } + + switch (type) { + case 'commit': + fillStyle = COLORS.REACT_COMMIT; + hoveredFillStyle = COLORS.REACT_COMMIT_HOVER; + groupSelectedFillStyle = COLORS.REACT_COMMIT_SELECTED; + break; + case 'render-idle': + // We could render idle time as diagonal hashes. + // This looks nicer when zoomed in, but not so nice when zoomed out. + // color = context.createPattern(getIdlePattern(), 'repeat'); + fillStyle = COLORS.REACT_IDLE; + hoveredFillStyle = COLORS.REACT_IDLE_HOVER; + groupSelectedFillStyle = COLORS.REACT_IDLE_SELECTED; + break; + case 'render': + fillStyle = COLORS.REACT_RENDER; + hoveredFillStyle = COLORS.REACT_RENDER_HOVER; + groupSelectedFillStyle = COLORS.REACT_RENDER_SELECTED; + break; + case 'layout-effects': + fillStyle = COLORS.REACT_LAYOUT_EFFECTS; + hoveredFillStyle = COLORS.REACT_LAYOUT_EFFECTS_HOVER; + groupSelectedFillStyle = COLORS.REACT_LAYOUT_EFFECTS_SELECTED; + break; + case 'passive-effects': + fillStyle = COLORS.REACT_PASSIVE_EFFECTS; + hoveredFillStyle = COLORS.REACT_PASSIVE_EFFECTS_HOVER; + groupSelectedFillStyle = COLORS.REACT_PASSIVE_EFFECTS_SELECTED; + break; + default: + throw new Error(`Unexpected measure type "${type}"`); + } + + const drawableRect = rectIntersectionWithRect(measureRect, rect); + context.fillStyle = showHoverHighlight + ? hoveredFillStyle + : showGroupHighlight + ? groupSelectedFillStyle + : fillStyle; + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + } + + draw(context: CanvasRenderingContext2D) { + const { + frame, + hoveredMeasure, + lanesToRender, + laneToMeasures, + visibleArea, + } = this; + + context.fillStyle = COLORS.PRIORITY_BACKGROUND; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y, + visibleArea.size.width, + visibleArea.size.height, + ); + + const scaleFactor = positioningScaleFactor(this.intrinsicSize.width, frame); + + for (let i = 0; i < lanesToRender.length; i++) { + const lane = lanesToRender[i]; + const baseY = frame.origin.y + i * REACT_LANE_HEIGHT; + const measuresForLane = laneToMeasures.get(lane); + + if (!measuresForLane) { + throw new Error( + 'No measures found for a React lane! This is a bug in this profiler tool. Please file an issue.', + ); + } + + // Draw measures + for (let j = 0; j < measuresForLane.length; j++) { + const measure = measuresForLane[j]; + const showHoverHighlight = hoveredMeasure === measure; + const showGroupHighlight = + !!hoveredMeasure && hoveredMeasure.batchUID === measure.batchUID; + + this.drawSingleReactMeasure( + context, + visibleArea, + measure, + baseY, + scaleFactor, + showGroupHighlight, + showHoverHighlight, + ); + } + + // Render bottom border + const borderFrame: Rect = { + origin: { + x: frame.origin.x, + y: + frame.origin.y + + (i + 1) * REACT_LANE_HEIGHT - + REACT_WORK_BORDER_SIZE, + }, + size: { + width: frame.size.width, + height: REACT_WORK_BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = rectIntersectionWithRect( + borderFrame, + visibleArea, + ); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } + } + + /** + * @private + */ + handleHover(interaction: HoverInteraction) { + const { + frame, + intrinsicSize, + lanesToRender, + laneToMeasures, + onHover, + visibleArea, + } = this; + if (!onHover) { + return; + } + + const {location} = interaction.payload; + if (!rectContainsPoint(location, visibleArea)) { + onHover(null); + return; + } + + // Identify the lane being hovered over + const adjustedCanvasMouseY = location.y - frame.origin.y; + const renderedLaneIndex = Math.floor( + adjustedCanvasMouseY / REACT_LANE_HEIGHT, + ); + if (renderedLaneIndex < 0 || renderedLaneIndex >= lanesToRender.length) { + onHover(null); + return; + } + const lane = lanesToRender[renderedLaneIndex]; + + // Find the measure in `lane` being hovered over. + // + // Because data ranges may overlap, we want to find the last intersecting item. + // This will always be the one on "top" (the one the user is hovering over). + const scaleFactor = positioningScaleFactor(intrinsicSize.width, frame); + const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame); + const measures = laneToMeasures.get(lane); + if (!measures) { + onHover(null); + return; + } + + for (let index = measures.length - 1; index >= 0; index--) { + const measure = measures[index]; + const {duration, timestamp} = measure; + + if ( + hoverTimestamp >= timestamp && + hoverTimestamp <= timestamp + duration + ) { + onHover(measure); + return; + } + } + + onHover(null); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + switch (interaction.type) { + case 'hover': + this.handleHover(interaction); + break; + } + } +} diff --git a/src/canvas/views/TimeAxisMarkersView.js b/src/canvas/views/TimeAxisMarkersView.js new file mode 100644 index 0000000..6d97ab1 --- /dev/null +++ b/src/canvas/views/TimeAxisMarkersView.js @@ -0,0 +1,160 @@ +// @flow + +import type {Rect, Size} from '../../layout'; + +import { + durationToWidth, + positioningScaleFactor, + positionToTimestamp, + timestampToPosition, +} from '../canvasUtils'; +import { + View, + Surface, + rectIntersectsRect, + rectIntersectionWithRect, +} from '../../layout'; +import { + COLORS, + HEADER_HEIGHT_FIXED, + INTERVAL_TIMES, + LABEL_FIXED_WIDTH, + MARKER_FONT_SIZE, + MARKER_HEIGHT, + MARKER_TEXT_PADDING, + MARKER_TICK_HEIGHT, + MIN_INTERVAL_SIZE_PX, + REACT_WORK_BORDER_SIZE, +} from '../constants'; + +export class TimeAxisMarkersView extends View { + totalDuration: number; + intrinsicSize: Size; + + constructor(surface: Surface, frame: Rect, totalDuration: number) { + super(surface, frame); + this.totalDuration = totalDuration; + this.intrinsicSize = { + width: this.totalDuration, + height: HEADER_HEIGHT_FIXED, + }; + } + + desiredSize() { + return this.intrinsicSize; + } + + // Time mark intervals vary based on the current zoom range and the time it represents. + // In Chrome, these seem to range from 70-140 pixels wide. + // Time wise, they represent intervals of e.g. 1s, 500ms, 200ms, 100ms, 50ms, 20ms. + // Based on zoom, we should determine which amount to actually show. + getTimeTickInterval(scaleFactor: number): number { + for (let i = 0; i < INTERVAL_TIMES.length; i++) { + const currentInterval = INTERVAL_TIMES[i]; + const intervalWidth = durationToWidth(currentInterval, scaleFactor); + if (intervalWidth > MIN_INTERVAL_SIZE_PX) { + return currentInterval; + } + } + return INTERVAL_TIMES[0]; + } + + draw(context: CanvasRenderingContext2D) { + const {frame, intrinsicSize, visibleArea} = this; + const clippedFrame = { + origin: frame.origin, + size: { + width: frame.size.width, + height: intrinsicSize.height, + }, + }; + const drawableRect = rectIntersectionWithRect(clippedFrame, visibleArea); + + // Clear background + context.fillStyle = COLORS.BACKGROUND; + context.fillRect( + drawableRect.origin.x, + drawableRect.origin.y, + drawableRect.size.width, + drawableRect.size.height, + ); + + const scaleFactor = positioningScaleFactor( + intrinsicSize.width, + clippedFrame, + ); + const interval = this.getTimeTickInterval(scaleFactor); + const firstIntervalTimestamp = + Math.ceil( + positionToTimestamp( + drawableRect.origin.x - LABEL_FIXED_WIDTH, + scaleFactor, + clippedFrame, + ) / interval, + ) * interval; + + for ( + let markerTimestamp = firstIntervalTimestamp; + true; + markerTimestamp += interval + ) { + if (markerTimestamp <= 0) { + continue; // Timestamps < are probably a bug; markers at 0 are ugly. + } + + const x = timestampToPosition(markerTimestamp, scaleFactor, clippedFrame); + if (x > drawableRect.origin.x + drawableRect.size.width) { + break; // Not in view + } + + const markerLabel = Math.round(markerTimestamp); + + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + x, + drawableRect.origin.y + MARKER_HEIGHT - MARKER_TICK_HEIGHT, + REACT_WORK_BORDER_SIZE, + MARKER_TICK_HEIGHT, + ); + + context.fillStyle = COLORS.TIME_MARKER_LABEL; + context.textAlign = 'right'; + context.textBaseline = 'middle'; + context.font = `${MARKER_FONT_SIZE}px sans-serif`; + context.fillText( + `${markerLabel}ms`, + x - MARKER_TEXT_PADDING, + MARKER_HEIGHT / 2, + ); + } + + // Render bottom border. + // Propose border rect, check if intersects with `rect`, draw intersection. + const borderFrame: Rect = { + origin: { + x: clippedFrame.origin.x, + y: + clippedFrame.origin.y + + clippedFrame.size.height - + REACT_WORK_BORDER_SIZE, + }, + size: { + width: clippedFrame.size.width, + height: REACT_WORK_BORDER_SIZE, + }, + }; + if (rectIntersectsRect(borderFrame, visibleArea)) { + const borderDrawableRect = rectIntersectionWithRect( + borderFrame, + visibleArea, + ); + context.fillStyle = COLORS.PRIORITY_BORDER; + context.fillRect( + borderDrawableRect.origin.x, + borderDrawableRect.origin.y, + borderDrawableRect.size.width, + borderDrawableRect.size.height, + ); + } + } +} diff --git a/src/canvas/views/index.js b/src/canvas/views/index.js new file mode 100644 index 0000000..04dfba8 --- /dev/null +++ b/src/canvas/views/index.js @@ -0,0 +1,6 @@ +// @flow + +export * from './FlamegraphView'; +export * from './ReactEventsView'; +export * from './ReactMeasuresView'; +export * from './TimeAxisMarkersView'; diff --git a/src/layout/HorizontalPanAndZoomView.js b/src/layout/HorizontalPanAndZoomView.js new file mode 100644 index 0000000..23dd463 --- /dev/null +++ b/src/layout/HorizontalPanAndZoomView.js @@ -0,0 +1,299 @@ +// @flow + +import type { + Interaction, + HorizontalPanStartInteraction, + HorizontalPanMoveInteraction, + HorizontalPanEndInteraction, + WheelPlainInteraction, + WheelWithShiftInteraction, + WheelWithControlInteraction, + WheelWithMetaInteraction, +} from '../useCanvasInteraction'; +import type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; +import {rectContainsPoint} from './geometry'; +import { + MIN_ZOOM_LEVEL, + MAX_ZOOM_LEVEL, + MOVE_WHEEL_DELTA_THRESHOLD, +} from '../canvas/constants'; // TODO: Remove external dependency + +type HorizontalPanAndZoomState = {| + /** Horizontal offset; positive in the left direction */ + offsetX: number, + zoomLevel: number, +|}; + +function panAndZoomStatesAreEqual( + state1: HorizontalPanAndZoomState, + state2: HorizontalPanAndZoomState, +): boolean { + return ( + state1.offsetX === state2.offsetX && state1.zoomLevel === state2.zoomLevel + ); +} + +function clamp(min: number, max: number, value: number): number { + if (Number.isNaN(min) || Number.isNaN(max) || Number.isNaN(value)) { + throw new Error( + `Clamp was called with NaN. Args: min: ${min}, max: ${max}, value: ${value}.`, + ); + } + return Math.min(max, Math.max(min, value)); +} + +function zoomLevelAndIntrinsicWidthToFrameWidth( + zoomLevel: number, + intrinsicWidth: number, +): number { + return intrinsicWidth * zoomLevel; +} + +export class HorizontalPanAndZoomView extends View { + contentView: View; + intrinsicContentWidth: number; + + panAndZoomState: HorizontalPanAndZoomState = { + offsetX: 0, + zoomLevel: 0.25, + }; + + stateDeriver: ( + state: HorizontalPanAndZoomState, + ) => HorizontalPanAndZoomState = state => state; + + onStateChange: (state: HorizontalPanAndZoomState) => void = () => {}; + + constructor( + surface: Surface, + frame: Rect, + contentView: View, + intrinsicContentWidth: number, + stateDeriver?: ( + state: HorizontalPanAndZoomState, + ) => HorizontalPanAndZoomState, + onStateChange?: (state: HorizontalPanAndZoomState) => void, + ) { + super(surface, frame); + this.contentView = contentView; + contentView.superview = this; + this.intrinsicContentWidth = intrinsicContentWidth; + if (stateDeriver) this.stateDeriver = stateDeriver; + if (onStateChange) this.onStateChange = onStateChange; + } + + setNeedsDisplay() { + super.setNeedsDisplay(); + this.contentView.setNeedsDisplay(); + } + + setFrame(newFrame: Rect) { + super.setFrame(newFrame); + + // Revalidate panAndZoomState + this.updateState(this.panAndZoomState); + } + + layoutSubviews() { + const {offsetX, zoomLevel} = this.panAndZoomState; + const proposedFrame = { + origin: { + x: this.frame.origin.x + offsetX, + y: this.frame.origin.y, + }, + size: { + width: zoomLevelAndIntrinsicWidthToFrameWidth( + zoomLevel, + this.intrinsicContentWidth, + ), + height: this.frame.size.height, + }, + }; + this.contentView.setFrame(proposedFrame); + this.contentView.setVisibleArea(this.visibleArea); + } + + draw(context: CanvasRenderingContext2D) { + this.contentView.displayIfNeeded(context); + } + + isPanning = false; + + handleHorizontalPanStart(interaction: HorizontalPanStartInteraction) { + if (rectContainsPoint(interaction.payload.location, this.frame)) { + this.isPanning = true; + } + } + + handleHorizontalPanMove(interaction: HorizontalPanMoveInteraction) { + if (!this.isPanning) { + return; + } + const {offsetX} = this.panAndZoomState; + const {movementX} = interaction.payload.event; + this.updateState({ + ...this.panAndZoomState, + offsetX: offsetX + movementX, + }); + } + + handleHorizontalPanEnd(interaction: HorizontalPanEndInteraction) { + if (this.isPanning) { + this.isPanning = false; + } + } + + handleWheelPlain(interaction: WheelPlainInteraction) { + const { + location, + event: {deltaX, deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + if (absDeltaY > absDeltaX) { + return; // Scrolling vertically + } + + if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + this.updateState({ + ...this.panAndZoomState, + offsetX: this.panAndZoomState.offsetX - deltaX, + }); + } + + handleWheelZoom( + interaction: + | WheelWithShiftInteraction + | WheelWithControlInteraction + | WheelWithMetaInteraction, + ) { + const { + location, + event: {deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaY = Math.abs(deltaY); + if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + const zoomClampedState = this.clampedProposedStateZoomLevel({ + ...this.panAndZoomState, + zoomLevel: this.panAndZoomState.zoomLevel * (1 + 0.005 * -deltaY), + }); + + // Determine where the mouse is, and adjust the offset so that point stays + // centered after zooming. + const oldMouseXInFrame = location.x - zoomClampedState.offsetX; + const fractionalMouseX = + oldMouseXInFrame / this.contentView.frame.size.width; + const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth( + zoomClampedState.zoomLevel, + this.intrinsicContentWidth, + ); + const newMouseXInFrame = fractionalMouseX * newContentWidth; + + const offsetAdjustedState = this.clampedProposedStateOffsetX({ + ...zoomClampedState, + offsetX: location.x - newMouseXInFrame, + }); + + this.updateState(offsetAdjustedState); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + switch (interaction.type) { + case 'horizontal-pan-start': + this.handleHorizontalPanStart(interaction); + break; + case 'horizontal-pan-move': + this.handleHorizontalPanMove(interaction); + break; + case 'horizontal-pan-end': + this.handleHorizontalPanEnd(interaction); + break; + case 'wheel-plain': + this.handleWheelPlain(interaction); + break; + case 'wheel-shift': + case 'wheel-control': + case 'wheel-meta': + this.handleWheelZoom(interaction); + break; + } + this.contentView.handleInteractionAndPropagateToSubviews(interaction); + } + + /** + * @private + */ + updateState(proposedState: HorizontalPanAndZoomState) { + const clampedState = this.stateDeriver( + this.clampedProposedState(proposedState), + ); + if (!panAndZoomStatesAreEqual(clampedState, this.panAndZoomState)) { + this.panAndZoomState = clampedState; + this.onStateChange(this.panAndZoomState); + this.setNeedsDisplay(); + } + } + + /** + * @private + */ + clampedProposedStateZoomLevel( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + // Content-based min zoom level to ensure that contentView's width >= our width. + const minContentBasedZoomLevel = + this.frame.size.width / this.intrinsicContentWidth; + const minZoomLevel = Math.max(MIN_ZOOM_LEVEL, minContentBasedZoomLevel); + return { + ...proposedState, + zoomLevel: clamp(minZoomLevel, MAX_ZOOM_LEVEL, proposedState.zoomLevel), + }; + } + + /** + * @private + */ + clampedProposedStateOffsetX( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth( + proposedState.zoomLevel, + this.intrinsicContentWidth, + ); + return { + ...proposedState, + offsetX: clamp( + -(newContentWidth - this.frame.size.width), + 0, + proposedState.offsetX, + ), + }; + } + + /** + * @private + */ + clampedProposedState( + proposedState: HorizontalPanAndZoomState, + ): HorizontalPanAndZoomState { + const zoomClampedState = this.clampedProposedStateZoomLevel(proposedState); + return this.clampedProposedStateOffsetX(zoomClampedState); + } +} diff --git a/src/layout/StaticLayoutView.js b/src/layout/StaticLayoutView.js new file mode 100644 index 0000000..3d00912 --- /dev/null +++ b/src/layout/StaticLayoutView.js @@ -0,0 +1,90 @@ +// @flow + +import type {Interaction} from '../useCanvasInteraction'; +import type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; +import { + rectIntersectsRect, + rectIntersectionWithRect, + zeroRect, +} from './geometry'; + +export type Layouter = (views: View[], containingFrame: Rect) => void; + +export const layeredLayout: Layouter = (views, frame) => + views.forEach(subview => { + subview.setFrame(frame); + }); + +export const verticallyStackedLayout: Layouter = (views, frame) => { + let currentY = frame.origin.y; + views.forEach(view => { + const desiredSize = view.desiredSize(); + const height = desiredSize + ? desiredSize.height + : frame.size.height - currentY; + const proposedFrame = { + origin: {x: frame.origin.x, y: currentY}, + size: {width: frame.size.width, height}, + }; + view.setFrame(proposedFrame); + currentY += height; + }); +}; + +export class StaticLayoutView extends View { + subviews: View[] = []; + layouter: Layouter; + + constructor( + surface: Surface, + frame: Rect, + layouter: Layouter, + subviews: View[], + ) { + super(surface, frame); + this.layouter = layouter; + subviews.forEach(subview => this.addSubview(subview)); + } + + setNeedsDisplay() { + super.setNeedsDisplay(); + this.subviews.forEach(subview => subview.setNeedsDisplay()); + } + + addSubview(view: View) { + this.subviews.push(view); + view.superview = this; + } + + layoutSubviews() { + const {frame, layouter, subviews, visibleArea} = this; + layouter(subviews, frame); + subviews.forEach(subview => { + if (rectIntersectsRect(visibleArea, subview.frame)) { + subview.setVisibleArea( + rectIntersectionWithRect(visibleArea, subview.frame), + ); + } else { + subview.setVisibleArea(zeroRect); + } + }); + } + + draw(context: CanvasRenderingContext2D) { + const {subviews, visibleArea} = this; + subviews.forEach(subview => { + if (rectIntersectsRect(visibleArea, subview.visibleArea)) { + subview.displayIfNeeded(context); + } + }); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + this.subviews.forEach(subview => + subview.handleInteractionAndPropagateToSubviews(interaction), + ); + } +} diff --git a/src/layout/Surface.js b/src/layout/Surface.js new file mode 100644 index 0000000..0df492d --- /dev/null +++ b/src/layout/Surface.js @@ -0,0 +1,51 @@ +// @flow + +import type {Interaction} from '../useCanvasInteraction'; +import type {Size} from './geometry'; + +import {getCanvasContext} from '../canvas/canvasUtils'; + +import {View} from './View'; +import {zeroPoint} from './geometry'; + +export class Surface { + rootView: ?View; + context: ?CanvasRenderingContext2D; + canvasSize: ?Size; + + setCanvas(canvas: HTMLCanvasElement, canvasSize: Size) { + this.context = getCanvasContext( + canvas, + canvasSize.height, + canvasSize.width, + ); + this.canvasSize = canvasSize; + + if (this.rootView) { + this.rootView.setNeedsDisplay(); + } + } + + displayIfNeeded() { + const {rootView, canvasSize, context} = this; + if (!rootView || !context || !canvasSize) { + return; + } + rootView.setFrame({ + origin: zeroPoint, + size: canvasSize, + }); + rootView.setVisibleArea({ + origin: zeroPoint, + size: canvasSize, + }); + rootView.displayIfNeeded(context); + } + + handleInteraction(interaction: Interaction) { + if (!this.rootView) { + return; + } + this.rootView.handleInteractionAndPropagateToSubviews(interaction); + } +} diff --git a/src/layout/VerticalScrollView.js b/src/layout/VerticalScrollView.js new file mode 100644 index 0000000..906e7f3 --- /dev/null +++ b/src/layout/VerticalScrollView.js @@ -0,0 +1,196 @@ +// @flow + +import type { + Interaction, + VerticalPanStartInteraction, + VerticalPanMoveInteraction, + VerticalPanEndInteraction, + WheelPlainInteraction, +} from '../useCanvasInteraction'; +import type {Rect} from './geometry'; + +import {Surface} from './Surface'; +import {View} from './View'; +import {rectContainsPoint} from './geometry'; +import {MOVE_WHEEL_DELTA_THRESHOLD} from '../canvas/constants'; // TODO: Remove external dependency + +type VerticalScrollState = {| + offsetY: number, +|}; + +function scrollStatesAreEqual( + state1: VerticalScrollState, + state2: VerticalScrollState, +): boolean { + return state1.offsetY === state2.offsetY; +} + +// TODO: Deduplicate +function clamp(min: number, max: number, value: number): number { + if (Number.isNaN(min) || Number.isNaN(max) || Number.isNaN(value)) { + throw new Error( + `Clamp was called with NaN. Args: min: ${min}, max: ${max}, value: ${value}.`, + ); + } + return Math.min(max, Math.max(min, value)); +} + +export class VerticalScrollView extends View { + contentView: View; + intrinsicContentHeight: number; + + scrollState: VerticalScrollState = { + offsetY: 0, + }; + + stateDeriver: (state: VerticalScrollState) => VerticalScrollState = state => + state; + + onStateChange: (state: VerticalScrollState) => void = () => {}; + + constructor( + surface: Surface, + frame: Rect, + contentView: View, + intrinsicContentHeight: number, + stateDeriver?: (state: VerticalScrollState) => VerticalScrollState, + onStateChange?: (state: VerticalScrollState) => void, + ) { + super(surface, frame); + this.contentView = contentView; + contentView.superview = this; + this.intrinsicContentHeight = intrinsicContentHeight; + if (stateDeriver) this.stateDeriver = stateDeriver; + if (onStateChange) this.onStateChange = onStateChange; + } + + setNeedsDisplay() { + super.setNeedsDisplay(); + this.contentView.setNeedsDisplay(); + } + + setFrame(newFrame: Rect) { + super.setFrame(newFrame); + + // Revalidate scrollState + this.updateState(this.scrollState); + } + + layoutSubviews() { + const {offsetY} = this.scrollState; + const proposedFrame = { + origin: { + x: this.frame.origin.x, + y: this.frame.origin.y + offsetY, + }, + size: { + width: this.frame.size.width, + height: this.intrinsicContentHeight, + }, + }; + this.contentView.setFrame(proposedFrame); + this.contentView.setVisibleArea(this.visibleArea); + } + + draw(context: CanvasRenderingContext2D) { + this.contentView.displayIfNeeded(context); + } + + isPanning = false; + + handleVerticalPanStart(interaction: VerticalPanStartInteraction) { + if (rectContainsPoint(interaction.payload.location, this.frame)) { + this.isPanning = true; + } + } + + handleVerticalPanMove(interaction: VerticalPanMoveInteraction) { + if (!this.isPanning) { + return; + } + const {offsetY} = this.scrollState; + const {movementY} = interaction.payload.event; + this.updateState({ + ...this.scrollState, + offsetY: offsetY + movementY, + }); + } + + handleVerticalPanEnd(interaction: VerticalPanEndInteraction) { + if (this.isPanning) { + this.isPanning = false; + } + } + + handleWheelPlain(interaction: WheelPlainInteraction) { + const { + location, + event: {deltaX, deltaY}, + } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { + return; // Not scrolling on view + } + + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + if (absDeltaX > absDeltaY) { + return; // Scrolling horizontally + } + + if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { + return; + } + + this.updateState({ + ...this.scrollState, + offsetY: this.scrollState.offsetY - deltaY, + }); + } + + handleInteractionAndPropagateToSubviews(interaction: Interaction) { + switch (interaction.type) { + case 'vertical-pan-start': + this.handleVerticalPanStart(interaction); + break; + case 'vertical-pan-move': + this.handleVerticalPanMove(interaction); + break; + case 'vertical-pan-end': + this.handleVerticalPanEnd(interaction); + break; + case 'wheel-plain': + this.handleWheelPlain(interaction); + break; + } + this.contentView.handleInteractionAndPropagateToSubviews(interaction); + } + + /** + * @private + */ + updateState(proposedState: VerticalScrollState) { + const clampedState = this.stateDeriver( + this.clampedProposedState(proposedState), + ); + if (!scrollStatesAreEqual(clampedState, this.scrollState)) { + this.scrollState = clampedState; + this.onStateChange(this.scrollState); + this.setNeedsDisplay(); + } + } + + /** + * @private + */ + clampedProposedState( + proposedState: VerticalScrollState, + ): VerticalScrollState { + return { + offsetY: clamp( + -(this.contentView.frame.size.height - this.frame.size.height), + 0, + proposedState.offsetY, + ), + }; + } +} diff --git a/src/layout/View.js b/src/layout/View.js new file mode 100644 index 0000000..d34c80a --- /dev/null +++ b/src/layout/View.js @@ -0,0 +1,113 @@ +// @flow + +import type {Interaction} from '../useCanvasInteraction'; +import type {Rect, Size} from './geometry'; + +import {Surface} from './Surface'; +import { + rectIntersectsRect, + rectEqualToRect, + sizeIsEmpty, + sizeIsValid, + zeroRect, +} from './geometry'; + +export class View { + surface: Surface; + + frame: Rect; + visibleArea: Rect; + + superview: ?View; + + /** Whether this view needs to be drawn. */ + needsDisplay = true; + /** Whether the heirarchy below this view has subviews that need display. */ + subviewsNeedDisplay = false; + + constructor(surface: Surface, frame: Rect, visibleArea: Rect = frame) { + this.surface = surface; + this.frame = frame; + this.visibleArea = visibleArea; + } + + /** + * Invalidates view's contents. + * + * Downward propagating; once called, all subviews of this view should also + * be invalidated. + * + * Subclasses with subviews should override this method and call + * `setNeedsDisplay` on its subviews. + */ + setNeedsDisplay() { + this.needsDisplay = true; + if (this.superview) { + this.superview.setSubviewsNeedDisplay(); + } + } + + /** + * Informs superview that it has subviews that need to be drawn. + * + * Upward propagating; once called, all superviews of this view should also + * have `subviewsNeedDisplay` = true. + */ + setSubviewsNeedDisplay() { + this.subviewsNeedDisplay = true; + if (this.superview) { + this.superview.setSubviewsNeedDisplay(); + } + } + + setFrame(newFrame: Rect) { + if (!rectEqualToRect(this.frame, newFrame)) { + this.frame = newFrame; + if (sizeIsValid(newFrame.size)) { + this.frame = newFrame; + } else { + this.frame = zeroRect; + } + this.setNeedsDisplay(); + } + } + + setVisibleArea(newVisibleArea: Rect) { + if (!rectEqualToRect(this.visibleArea, newVisibleArea)) { + if (sizeIsValid(newVisibleArea.size)) { + this.visibleArea = newVisibleArea; + } else { + this.visibleArea = zeroRect; + } + this.setNeedsDisplay(); + } + } + + desiredSize(): ?Size {} + + /** + * Layout self and subviews. + * + * Call `setNeedsDisplay` if we are to redraw. + * + * To be overwritten by subclasses. + */ + layoutSubviews() {} + + displayIfNeeded(context: CanvasRenderingContext2D) { + if ( + (this.needsDisplay || this.subviewsNeedDisplay) && + rectIntersectsRect(this.frame, this.visibleArea) && + !sizeIsEmpty(this.visibleArea.size) + ) { + this.layoutSubviews(); + if (this.needsDisplay) this.needsDisplay = false; + if (this.subviewsNeedDisplay) this.subviewsNeedDisplay = false; + this.draw(context); + } + } + + draw(context: CanvasRenderingContext2D) {} + + handleInteractionAndPropagateToSubviews(interaction: Interaction): ?boolean {} +} diff --git a/src/layout/geometry.js b/src/layout/geometry.js new file mode 100644 index 0000000..5762227 --- /dev/null +++ b/src/layout/geometry.js @@ -0,0 +1,88 @@ +// @flow + +type MutablePoint = {x: number, y: number}; +type MutableSize = {width: number, height: number}; + +export type Point = $ReadOnly; +export type Size = $ReadOnly; +export type Rect = $ReadOnly<{origin: Point, size: Size}>; + +export const zeroPoint: Point = Object.freeze({x: 0, y: 0}); +export const zeroSize: Size = Object.freeze({width: 0, height: 0}); +export const zeroRect: Rect = Object.freeze({ + origin: zeroPoint, + size: zeroSize, +}); + +export function pointEqualToPoint(point1: Point, point2: Point): boolean { + return point1.x === point2.x && point1.y === point2.y; +} + +export function sizeEqualToSize(size1: Size, size2: Size): boolean { + return size1.width === size2.width && size1.height === size2.height; +} + +export function rectEqualToRect(rect1: Rect, rect2: Rect): boolean { + return ( + pointEqualToPoint(rect1.origin, rect2.origin) && + sizeEqualToSize(rect1.size, rect2.size) + ); +} + +export function sizeIsValid({width, height}: Size): boolean { + return width >= 0 && height >= 0; +} + +export function sizeIsEmpty({width, height}: Size): boolean { + return width <= 0 || height <= 0; +} + +function rectToBoundaryCoordinates( + rect: Rect, +): [number, number, number, number] { + const top = rect.origin.y; + const right = rect.origin.x + rect.size.width; + const bottom = rect.origin.y + rect.size.height; + const left = rect.origin.x; + return [top, right, bottom, left]; +} + +export function rectIntersectsRect(rect1: Rect, rect2: Rect): boolean { + const [top1, right1, bottom1, left1] = rectToBoundaryCoordinates(rect1); + const [top2, right2, bottom2, left2] = rectToBoundaryCoordinates(rect2); + return !( + right1 < left2 || + right2 < left1 || + bottom1 < top2 || + bottom2 < top1 + ); +} + +/** + * Prerequisite: rect1 must intersect with rect2. + */ +export function rectIntersectionWithRect(rect1: Rect, rect2: Rect): Rect { + const [top1, right1, bottom1, left1] = rectToBoundaryCoordinates(rect1); + const [top2, right2, bottom2, left2] = rectToBoundaryCoordinates(rect2); + + const intersectleft = Math.max(left1, left2); + const intersectRight = Math.min(right1, right2); + const intersectTop = Math.max(top1, top2); + const intersectBottom = Math.min(bottom1, bottom2); + + return { + origin: { + x: intersectleft, + y: intersectTop, + }, + size: { + width: intersectRight - intersectleft, + height: intersectBottom - intersectTop, + }, + }; +} + +export function rectContainsPoint({x, y}: Point, rect: Rect): boolean { + const [top, right, bottom, left] = rectToBoundaryCoordinates(rect); + return left <= x && x <= right && top <= y && y <= bottom; +} diff --git a/src/layout/index.js b/src/layout/index.js new file mode 100644 index 0000000..df9f5d6 --- /dev/null +++ b/src/layout/index.js @@ -0,0 +1,8 @@ +// @flow + +export * from './geometry'; +export * from './HorizontalPanAndZoomView'; +export * from './StaticLayoutView'; +export * from './Surface'; +export * from './VerticalScrollView'; +export * from './View'; diff --git a/src/types.js b/src/types.js index f3444ba..4a52abe 100644 --- a/src/types.js +++ b/src/types.js @@ -97,7 +97,6 @@ export type ReactProfilerData = {| export type ReactHoverContextInfo = {| event: ReactEvent | null, measure: ReactMeasure | null, - lane: ReactLane | null, data: $ReadOnly | null, flamechartNode: FlamechartFrame | null, |}; diff --git a/src/useCanvasInteraction.js b/src/useCanvasInteraction.js new file mode 100644 index 0000000..0b2136f --- /dev/null +++ b/src/useCanvasInteraction.js @@ -0,0 +1,234 @@ +// @flow + +import type {Point} from './layout'; + +import {useEffect} from 'react'; + +export type VerticalPanStartInteraction = {| + type: 'vertical-pan-start', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type VerticalPanMoveInteraction = {| + type: 'vertical-pan-move', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type VerticalPanEndInteraction = {| + type: 'vertical-pan-end', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type HorizontalPanStartInteraction = {| + type: 'horizontal-pan-start', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type HorizontalPanMoveInteraction = {| + type: 'horizontal-pan-move', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type HorizontalPanEndInteraction = {| + type: 'horizontal-pan-end', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type HoverInteraction = {| + type: 'hover', + payload: {| + event: MouseEvent, + location: Point, + |}, +|}; +export type WheelPlainInteraction = {| + type: 'wheel-plain', + payload: {| + event: WheelEvent, + location: Point, + |}, +|}; +export type WheelWithShiftInteraction = {| + type: 'wheel-shift', + payload: {| + event: WheelEvent, + location: Point, + |}, +|}; +export type WheelWithControlInteraction = {| + type: 'wheel-control', + payload: {| + event: WheelEvent, + location: Point, + |}, +|}; +export type WheelWithMetaInteraction = {| + type: 'wheel-meta', + payload: {| + event: WheelEvent, + location: Point, + |}, +|}; + +export type Interaction = + | VerticalPanStartInteraction + | VerticalPanMoveInteraction + | VerticalPanEndInteraction + | HorizontalPanStartInteraction + | HorizontalPanMoveInteraction + | HorizontalPanEndInteraction + | HoverInteraction + | WheelPlainInteraction + | WheelWithShiftInteraction + | WheelWithControlInteraction + | WheelWithMetaInteraction; + +export function useCanvasInteraction( + canvasRef: {|current: HTMLCanvasElement | null|}, + interactor: (interaction: Interaction) => void, +) { + useEffect(() => { + const canvas = canvasRef.current; + + function localToCanvasCoordinates(localCoordinates: Point): Point { + if (!canvas) { + return localCoordinates; + } + const canvasRect = canvas.getBoundingClientRect(); + return { + x: localCoordinates.x - canvasRect.left, + y: localCoordinates.y - canvasRect.top, + }; + } + + if (!(canvas instanceof HTMLCanvasElement)) { + console.error('canvas is not a HTMLCanvasElement!', canvas); + return; + } + + const onCanvasMouseDown: MouseEventHandler = event => { + interactor({ + type: 'horizontal-pan-start', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + interactor({ + type: 'vertical-pan-start', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onCanvasMouseMove: MouseEventHandler = event => { + interactor({ + type: 'horizontal-pan-move', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + interactor({ + type: 'vertical-pan-move', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + interactor({ + type: 'hover', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onDocumentMouseUp: MouseEventHandler = event => { + interactor({ + type: 'horizontal-pan-end', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + interactor({ + type: 'vertical-pan-end', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + }; + + const onCanvasWheel: WheelEventHandler = event => { + event.preventDefault(); + event.stopPropagation(); + + if (event.shiftKey) { + interactor({ + type: 'wheel-shift', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + } else if (event.ctrlKey) { + interactor({ + type: 'wheel-control', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + } else if (event.metaKey) { + interactor({ + type: 'wheel-meta', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + } else { + interactor({ + type: 'wheel-plain', + payload: { + event, + location: localToCanvasCoordinates({x: event.x, y: event.y}), + }, + }); + } + + return false; + }; + + document.addEventListener('mouseup', onDocumentMouseUp); + + canvas.addEventListener('wheel', onCanvasWheel); + canvas.addEventListener('mousedown', onCanvasMouseDown); + canvas.addEventListener('mousemove', onCanvasMouseMove); + + return () => { + document.removeEventListener('mouseup', onDocumentMouseUp); + + canvas.removeEventListener('wheel', onCanvasWheel); + canvas.removeEventListener('mousedown', onCanvasMouseDown); + canvas.removeEventListener('mousemove', onCanvasMouseMove); + }; + }, [canvasRef, interactor]); +} diff --git a/src/util/useInteractiveEvents.js b/src/util/useInteractiveEvents.js deleted file mode 100644 index e4f2c5e..0000000 --- a/src/util/useInteractiveEvents.js +++ /dev/null @@ -1,132 +0,0 @@ -// @flow - -import {useEffect, useRef, useState} from 'react'; -import {durationToWidth, timestampToPosition} from './usePanAndZoom'; -import {EVENT_SIZE} from '../constants'; - -function doesEventIntersectPosition(position, state, event) { - const {duration, timestamp} = event; - - // Although it would be intuitive to search by time, - // that can result in a confusing user experience for events of 0ms duration- - // because we may choose to render these anyway. - // The best way to compare is to convert event times to pixel coordinates and compare them. - let startX = timestampToPosition(timestamp, state); - let stopX = startX + durationToWidth(duration, state); - - if (duration !== undefined) { - if (position >= startX && position <= stopX) { - return true; - } - } else { - startX -= EVENT_SIZE / 2; - stopX = startX + EVENT_SIZE; - - if (position >= startX && position <= stopX) { - return true; - } - } - - return false; -} - -export default function useInteractiveEvents({ - canvasHeight, - canvasRef, - canvasWidth, - eventQueue, - state, -}) { - const { - canvasMouseX, - canvasMouseY, - fixedColumnWidth, - fixedHeaderHeight, - } = state; - - let hoveredEvent = null; - - const [selectedEvent, setSelectedEvent] = useState(null); - - const lastResultRef = useRef({ - eventQueue, - hoveredEvent, - selectedEvent, - }); - - useEffect(() => { - lastResultRef.current = { - eventQueue, - hoveredEvent, - selectedEvent, - }; - }); - - useEffect(() => { - const onClick = () => { - setSelectedEvent( - lastResultRef.current.hoveredEvent === - lastResultRef.current.selectedEvent - ? null - : lastResultRef.current.hoveredEvent, - ); - }; - const canvas = canvasRef.current; - canvas.addEventListener('click', onClick); - return () => { - canvas.removeEventListener('click', onClick); - }; - }, [canvasRef]); - - if (eventQueue == null) { - return [null, selectedEvent]; - } - - // Ignore mouse events that happen outside of the canvas. - if ( - canvasMouseX >= fixedColumnWidth && - canvasMouseX < canvasWidth && - canvasMouseY >= fixedHeaderHeight && - canvasMouseY < canvasHeight - ) { - // Small mouse movements won't change the hovered event, - // So always start by checking the last hovered event to see if we can avoid doing more work. - const lastEvents = lastResultRef.current.eventQueue; - const lastHoveredEvent = lastResultRef.current.hoveredEvent; - - if (lastHoveredEvent !== null && lastEvents === eventQueue) { - if (doesEventIntersectPosition(canvasMouseX, state, lastHoveredEvent)) { - hoveredEvent = lastHoveredEvent; - return [hoveredEvent, selectedEvent]; - } - } - - // TODO I think we coulsed use a binary search if we just looked at start times only! - // - // Since event data is sorted, it would be nice to use a binary search for faster comparison. - // A simple binary search would not work here though, because of overlapping intervals. - // For example, imagine an event sequence A-E, with overlapping events B and C, as shown below. - // - // AAAA BBBBBBBBBBBB DDD EE - // CCC - // (X) - // - // Given the cursor position X, it should match event B- - // but if it happens to be compared to C first, it would next be compared to D, - // and would ultimately fail to match any results. - // - // Eventually we should create a data structure like an interval tree while pre-processing, - // so that we could more efficiently search it. - // For now though we'll just do a brute force search since this is just a prototype. :) - - for (let i = 0; i < eventQueue.length; i++) { - const event = eventQueue[i]; - if (doesEventIntersectPosition(canvasMouseX, state, event)) { - hoveredEvent = event; - return [hoveredEvent, selectedEvent]; - } - } - } - - return [hoveredEvent, selectedEvent]; -} diff --git a/src/util/usePanAndZoom.js b/src/util/usePanAndZoom.js deleted file mode 100644 index c14df88..0000000 --- a/src/util/usePanAndZoom.js +++ /dev/null @@ -1,403 +0,0 @@ -// @flow - -import {useEffect, useReducer} from 'react'; -import {getCanvasMousePos} from '../canvas/canvasUtils'; -import { - BAR_HORIZONTAL_SPACING, - MAX_ZOOM_LEVEL, - MIN_BAR_WIDTH, - MIN_ZOOM_LEVEL, - MOVE_WHEEL_DELTA_THRESHOLD, - ZOOM_WHEEL_DELTA_THRESHOLD, -} from '../canvas/constants'; - -export type PanAndZoomState = {| - canvasHeight: number, - canvasWidth: number, - canvasMouseX: number, - canvasMouseY: number, - fixedColumnWidth: number, - fixedHeaderHeight: number, - isDragging: boolean, - minZoomLevel: number, - offsetX: number, - offsetY: number, - unscaledContentHeight: number, - unscaledContentWidth: number, - zoomLevel: number, - zoomTo: null | ((startTime: number, endTime: number) => void), -|}; - -const initialState: PanAndZoomState = { - canvasHeight: 0, - canvasWidth: 0, - canvasMouseX: 0, - canvasMouseY: 0, - fixedColumnWidth: 0, - fixedHeaderHeight: 0, - isDragging: false, - minZoomLevel: 1, - offsetX: 0, - offsetY: 0, - unscaledContentHeight: 0, - unscaledContentWidth: 0, - zoomLevel: 1, - zoomTo: null, -}; - -// TODO Account for fixed label width -export function positionToTimestamp( - position: number, - state: $ReadOnly, -) { - return (position - state.fixedColumnWidth + state.offsetX) / state.zoomLevel; -} - -// TODO Account for fixed label width -export function timestampToPosition( - timestamp: number, - state: $ReadOnly, -) { - return timestamp * state.zoomLevel + state.fixedColumnWidth - state.offsetX; -} - -export function durationToWidth( - duration: number, - state: $ReadOnly, -) { - return Math.max( - duration * state.zoomLevel - BAR_HORIZONTAL_SPACING, - MIN_BAR_WIDTH, - ); -} - -function getMaxOffsetX(state: $ReadOnly) { - return ( - state.unscaledContentWidth * state.zoomLevel - - state.canvasWidth + - state.fixedColumnWidth - ); -} - -function getMaxOffsetY(state: $ReadOnly) { - return ( - state.unscaledContentHeight - state.canvasHeight + state.fixedHeaderHeight - ); -} - -type InitializeAction = {| - type: 'initialize', - payload: $Shape, -|}; -type MouseDownAction = {| - type: 'mouse-down', -|}; -type MouseMoveAction = {| - type: 'mouse-move', - payload: {| - canvas: HTMLCanvasElement, - event: MouseEvent, - |}, -|}; -type MouseUpAction = {| - type: 'mouse-up', -|}; -type WheelAction = {| - type: 'wheel', - payload: {| - canvas: HTMLCanvasElement, - event: WheelEvent, - |}, -|}; -type ZoomToAction = {| - type: 'zoom-to', - payload: {| - startTime: number, - stopTime: number, - |}, -|}; - -function reducer( - state: PanAndZoomState, - action: - | InitializeAction - | MouseDownAction - | MouseMoveAction - | MouseUpAction - | WheelAction - | ZoomToAction, -): PanAndZoomState { - switch (action.type) { - case 'initialize': { - const {payload} = action; - return ({ - ...state, - canvasHeight: payload.canvasHeight, - canvasWidth: payload.canvasWidth, - fixedColumnWidth: payload.fixedColumnWidth, - fixedHeaderHeight: payload.fixedHeaderHeight, - minZoomLevel: payload.minZoomLevel, - unscaledContentHeight: payload.unscaledContentHeight, - unscaledContentWidth: payload.unscaledContentWidth, - zoomLevel: payload.zoomLevel, - offsetX: clamp(0, getMaxOffsetX(state), state.offsetX), - offsetY: clamp(0, getMaxOffsetY(state), state.offsetY), - }: PanAndZoomState); - } - case 'mouse-down': { - return { - ...state, - isDragging: true, - }; - } - case 'mouse-move': { - const {payload} = action; - const {canvasMouseX, canvasMouseY} = getCanvasMousePos( - payload.canvas, - payload.event, - ); - - if (state.isDragging) { - return { - ...state, - canvasMouseX, - canvasMouseY, - offsetX: clamp( - 0, - getMaxOffsetX(state), - state.offsetX - payload.event.movementX, - ), - offsetY: clamp( - 0, - getMaxOffsetY(state), - state.offsetY + payload.event.movementY, - ), - }; - } else { - return { - ...state, - canvasMouseX, - canvasMouseY, - }; - } - } - case 'mouse-up': { - return { - ...state, - isDragging: false, - }; - } - case 'wheel': { - const {payload} = action; - const {canvas, event} = payload; - const {deltaX, deltaY} = event; - const {minZoomLevel, offsetX, offsetY, zoomLevel} = state; - - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - if (absDeltaX > absDeltaY) { - if (absDeltaX > MOVE_WHEEL_DELTA_THRESHOLD) { - return { - ...state, - offsetX: clamp(0, getMaxOffsetX(state), offsetX + deltaX), - }; - } - } else { - if (event.shiftKey || event.ctrlKey || event.metaKey) { - if (absDeltaY > ZOOM_WHEEL_DELTA_THRESHOLD) { - const {canvasMouseX} = getCanvasMousePos(canvas, event); - - const nextState: PanAndZoomState = { - ...state, - zoomLevel: clamp( - minZoomLevel, - MAX_ZOOM_LEVEL, - zoomLevel * (1 + 0.005 * -deltaY), - ), - }; - - // Determine what point in time the mouse is currently centered over, - // and adjust the offset so that point stays centered after zooming. - const timestampAtCurrentZoomLevel = positionToTimestamp( - canvasMouseX, - state, - ); - const positionAtNewZoom = timestampToPosition( - timestampAtCurrentZoomLevel, - nextState, - ); - - nextState.offsetX = clamp( - 0, - getMaxOffsetX(nextState), - offsetX + positionAtNewZoom - canvasMouseX, - ); - - if (nextState.zoomLevel !== zoomLevel) { - return nextState; - } - } - } else { - if (absDeltaY > MOVE_WHEEL_DELTA_THRESHOLD) { - return { - ...state, - offsetY: clamp(0, getMaxOffsetY(state), offsetY + deltaY), - }; - } - } - } - break; - } - case 'zoom-to': { - const {payload} = action; - const {startTime, stopTime} = payload; - const {canvasWidth, fixedColumnWidth} = state; - - const availableWidth = canvasWidth - fixedColumnWidth; - const newZoomLevel = availableWidth / (stopTime - startTime); - - return { - ...state, - offsetX: newZoomLevel * startTime, - zoomLevel: newZoomLevel, - }; - } - default: - throw Error(`Unexpected type "${action.type}"`); - } - - return state; -} - -function clamp(min: number, max: number, value: number): number { - if (Number.isNaN(min) || Number.isNaN(max) || Number.isNaN(value)) { - throw new Error( - `Clamp was called with NaN. Args: min: ${min}, max: ${max}, value: ${value}.`, - ); - } - return Math.max(min, Math.min(max, value)); -} - -type Props = {| - canvasRef: {|current: HTMLCanvasElement | null|}, - canvasHeight: number, - canvasWidth: number, - fixedColumnWidth: number, - fixedHeaderHeight: number, - unscaledContentWidth: number, - unscaledContentHeight: number, -|}; - -// Inspired by https://github.com/jsdf/flamechart -export default function usePanAndZoom({ - canvasRef, - canvasHeight, - canvasWidth, - fixedColumnWidth, - fixedHeaderHeight, - unscaledContentWidth, - unscaledContentHeight, -}: Props) { - const [state, dispatch] = useReducer(reducer, { - ...initialState, - zoomTo: (startTime: number, stopTime: number) => - dispatch({ - type: 'zoom-to', - payload: { - startTime, - stopTime, - }, - }), - }); - - // TODO This effect should run any time width or unscaledContentWidth changes - useEffect(() => { - const width = canvasWidth; - - const initialZoomLevel = clamp( - MIN_ZOOM_LEVEL, - MAX_ZOOM_LEVEL, - (width - fixedColumnWidth) / unscaledContentWidth, - ); - - dispatch({ - type: 'initialize', - payload: { - canvasHeight, - canvasWidth, - fixedColumnWidth, - fixedHeaderHeight, - minZoomLevel: initialZoomLevel, - unscaledContentHeight, - unscaledContentWidth, - zoomLevel: initialZoomLevel, - }, - }); - }, [ - canvasHeight, - canvasWidth, - fixedHeaderHeight, - fixedColumnWidth, - unscaledContentHeight, - unscaledContentWidth, - ]); - - useEffect(() => { - const canvas = canvasRef.current; - - if (!(canvas instanceof HTMLCanvasElement)) { - console.error('canvas is not a HTMLCanvasElement!', canvas); - return; - } - - const onCanvasMouseDown: MouseEventHandler = event => { - dispatch({type: 'mouse-down'}); - }; - - const onCanvasMouseMove: MouseEventHandler = event => { - dispatch({ - type: 'mouse-move', - payload: { - canvas, - event, - }, - }); - }; - - const onDocumentMouseUp: MouseEventHandler = event => { - dispatch({type: 'mouse-up'}); - }; - - const onCanvasWheel: WheelEventHandler = event => { - event.preventDefault(); - event.stopPropagation(); - - dispatch({ - type: 'wheel', - payload: { - canvas, - event, - }, - }); - - return false; - }; - - document.addEventListener('mouseup', onDocumentMouseUp); - - canvas.addEventListener('wheel', onCanvasWheel); - canvas.addEventListener('mousedown', onCanvasMouseDown); - canvas.addEventListener('mousemove', onCanvasMouseMove); - - return () => { - document.removeEventListener('mouseup', onDocumentMouseUp); - - canvas.removeEventListener('wheel', onCanvasWheel); - canvas.removeEventListener('mousedown', onCanvasMouseDown); - canvas.removeEventListener('mousemove', onCanvasMouseMove); - }; - }, [canvasRef]); - - return state; -}