diff --git a/src/common/types/profile-derived.js b/src/common/types/profile-derived.js index 074efb6f15..cd9ef6bca7 100644 --- a/src/common/types/profile-derived.js +++ b/src/common/types/profile-derived.js @@ -1,5 +1,6 @@ // @flow import type { Milliseconds } from './units'; +import type { MarkerPayload } from './profile'; export type IndexIntoFuncStackTable = number; @@ -20,8 +21,11 @@ export type TracingMarker = { dur: Milliseconds, name: string, title: string|null, + data: MarkerPayload, }; +export type IndexIntoTracingMarkers = number; + export type Node = { totalTime: string, totalTimePercent: string, @@ -31,3 +35,17 @@ export type Node = { dim: boolean, icon: string | null, }; + +export type IndexIntoMarkerTiming = number; + +export type MarkerTiming = { + // Start time in milliseconds. + start: number[], + // End time in milliseconds. + end: number[], + index: IndexIntoTracingMarkers[], + label: string[], + name: string, + length: number, +}; +export type MarkerTimingRows = Array diff --git a/src/common/types/profile.js b/src/common/types/profile.js index 87d78ab435..9203394cbc 100644 --- a/src/common/types/profile.js +++ b/src/common/types/profile.js @@ -97,6 +97,28 @@ export type ProfilerMarkerTracing = { // TODO - Add more markers. ); +/** + * The payload for the UserTimings API. These are added through performance.measure() + * and performance.mark(). https://developer.mozilla.org/en-US/docs/Web/API/Performance + */ +export type UserTimingMarkerPayload = { + type: "UserTiming", + startTime: Milliseconds, + endTime: Milliseconds, + name: string, + entryType: "measure" | "mark", +} + +/** + * The union of all the different marker payloads that perf.html knows about, this is + * not guaranteed to be all the payloads that we actually get from the profiler. + */ +export type MarkerPayload = + GPUMarkerPayload | + ProfilerMarkerTracing | + UserTimingMarkerPayload | + null; + /** * Markers represent arbitrary events that happen within the browser. They have a * name, time, and potentially a JSON data payload. These can come from all over the @@ -105,13 +127,7 @@ export type ProfilerMarkerTracing = { * perf.html to instrument their code. */ export type MarkersTable = { - data: ( - GPUMarkerPayload | - ProfilerMarkerTracing | - Object | - null | - void - )[], + data: MarkerPayload[], name: IndexIntoStringTable[], time: number[], length: number, diff --git a/src/common/types/units.js b/src/common/types/units.js index bdfe1e9c81..955065cb7d 100644 --- a/src/common/types/units.js +++ b/src/common/types/units.js @@ -29,3 +29,8 @@ export type HorizontalViewport = { } export type StartEndRange = { start: Milliseconds, end: Milliseconds }; + +/** + * This is not really a unit, but doesn't warrant a separate file. + */ +export type NonNull = number | string | () => mixed | Object | Array; diff --git a/src/content/actions/timeline.js b/src/content/actions/timeline.js index 17b322d50f..7aa2890552 100644 --- a/src/content/actions/timeline.js +++ b/src/content/actions/timeline.js @@ -18,8 +18,13 @@ export function changeFlameChartLabelingStrategy(getLabel: GetLabel): Action { }; } -export function changeTimelineExpandedThread(threadIndex: ThreadIndex, isExpanded: boolean): Action { - const type = 'CHANGE_TIMELINE_EXPANDED_THREAD'; +export function changeTimelineFlameChartExpandedThread(threadIndex: ThreadIndex, isExpanded: boolean): Action { + const type = 'CHANGE_TIMELINE_FLAME_CHART_EXPANDED_THREAD'; + return { type, threadIndex, isExpanded }; +} + +export function changeTimelineMarkersExpandedThread(threadIndex: ThreadIndex, isExpanded: boolean): Action { + const type = 'CHANGE_TIMELINE_MARKERS_EXPANDED_THREAD'; return { type, threadIndex, isExpanded }; } diff --git a/src/content/actions/types.js b/src/content/actions/types.js index 4337a8033a..c62542c2dc 100644 --- a/src/content/actions/types.js +++ b/src/content/actions/types.js @@ -78,7 +78,8 @@ type ReceiveProfileAction = type TimelineAction = { type: 'CHANGE_FLAME_CHART_COLOR_STRATEGY', getCategory: GetCategory } | { type: 'CHANGE_FLAME_CHART_LABELING_STRATEGY', getLabel: GetLabel } | - { type: 'CHANGE_TIMELINE_EXPANDED_THREAD', threadIndex: ThreadIndex, isExpanded: boolean }; + { type: 'CHANGE_TIMELINE_FLAME_CHART_EXPANDED_THREAD', threadIndex: ThreadIndex, isExpanded: boolean } | + { type: 'CHANGE_TIMELINE_MARKERS_EXPANDED_THREAD', threadIndex: ThreadIndex, isExpanded: boolean }; type URLEnhancerAction = { type: "@@urlenhancer/urlSetupDone" } | diff --git a/src/content/components/FlameChartCanvas.js b/src/content/components/FlameChartCanvas.js index a7f4c22dfb..d2821f0858 100644 --- a/src/content/components/FlameChartCanvas.js +++ b/src/content/components/FlameChartCanvas.js @@ -1,14 +1,15 @@ // @flow import React, { PureComponent } from 'react'; -import { timeCode } from '../../common/time-code'; import TextMeasurement from '../../common/text-measurement'; import withTimelineViewport from './TimelineViewport'; +import TimelineCanvas from './TimelineCanvas'; import type { Thread } from '../../common/types/profile'; -import type { Milliseconds, CssPixels, UnitIntervalOfProfileRange, DevicePixels } from '../../common/types/units'; -import type { StackTimingByDepth } from '../stack-timing'; +import type { Milliseconds, CssPixels, UnitIntervalOfProfileRange } from '../../common/types/units'; +import type { StackTimingByDepth, StackTimingDepth, IndexIntoStackTiming } from '../stack-timing'; import type { GetCategory } from '../color-categories'; import type { GetLabel } from '../labeling-strategies'; +import type { Action, ProfileSelection } from '../actions/types'; type Props = { thread: Thread, @@ -25,6 +26,12 @@ type Props = { stackFrameHeight: CssPixels, getCategory: GetCategory, getLabel: GetLabel, + updateProfileSelection: ProfileSelection => Action, +}; + +type HoveredStackTiming = { + depth: StackTimingDepth, + stackTableIndex: IndexIntoStackTiming, }; require('./FlameChartCanvas.css'); @@ -35,62 +42,16 @@ const TEXT_OFFSET_TOP = 11; class FlameChartCanvas extends PureComponent { - _requestedAnimationFrame: boolean - _devicePixelRatio: number - _textMeasurement: null|TextMeasurement - _ctx: null|CanvasRenderingContext2D + _textMeasurement: null | TextMeasurement; - props: Props + props: Props; constructor(props: Props) { super(props); - this._requestedAnimationFrame = false; - this._devicePixelRatio = 1; - this._textMeasurement = null; - } - - _scheduleDraw() { - if (!this._requestedAnimationFrame) { - this._requestedAnimationFrame = true; - window.requestAnimationFrame(() => { - this._requestedAnimationFrame = false; - if (this.refs.canvas) { - timeCode('FlameChartCanvas render', () => { - this.drawCanvas(); - }); - } - }); - } - } - - componentDidMount() { - this._textMeasurement = new TextMeasurement(this.refs.canvas.getContext('2d')); - } - - _prepCanvas() { - const {canvas} = this.refs; - const {containerWidth, containerHeight} = this.props; - const {devicePixelRatio} = window; - const pixelWidth: DevicePixels = containerWidth * devicePixelRatio; - const pixelHeight: DevicePixels = containerHeight * devicePixelRatio; - if (!this._ctx) { - this._ctx = canvas.getContext('2d'); - } - if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { - canvas.width = pixelWidth; - canvas.height = pixelHeight; - canvas.style.width = containerWidth + 'px'; - canvas.style.height = containerHeight + 'px'; - this._ctx.scale(this._devicePixelRatio, this._devicePixelRatio); - } - if (this._devicePixelRatio !== devicePixelRatio) { - // Make sure and multiply by the inverse of the previous ratio, as the scaling - // operates off of the previous set scale. - const scale = (1 / this._devicePixelRatio) * devicePixelRatio; - this._ctx.scale(scale, scale); - this._devicePixelRatio = devicePixelRatio; - } - return this._ctx; + (this: any)._onDoubleClickStack = this._onDoubleClickStack.bind(this); + (this: any)._getHoveredStackInfo = this._getHoveredStackInfo.bind(this); + (this: any)._drawCanvas = this._drawCanvas.bind(this); + (this: any)._hitTest = this._hitTest.bind(this); } /** @@ -100,15 +61,22 @@ class FlameChartCanvas extends PureComponent { * 0 - 1. This was done to make the calculations easier for computing various zoomed * and translated views independent of any particular scale. See TimelineViewport.js * for a diagram detailing the various components of this set-up. - * @param {HTMLCanvasElement} canvas - The current canvas. - * @returns {undefined} */ - drawCanvas() { + _drawCanvas( + ctx: CanvasRenderingContext2D, + hoveredItem: HoveredStackTiming | null + ) { const { thread, rangeStart, rangeEnd, containerWidth, getLabel, containerHeight, stackTimingByDepth, stackFrameHeight, getCategory, viewportLeft, viewportRight, viewportTop, viewportBottom } = this.props; - const ctx = this._prepCanvas(); + // Ensure the text measurement tool is created, since this is the first time + // this class has access to a ctx. + if (!this._textMeasurement) { + this._textMeasurement = new TextMeasurement(ctx); + } + const textMeasurement = this._textMeasurement; + ctx.clearRect(0, 0, containerWidth, containerHeight); const rangeLength: Milliseconds = rangeEnd - rangeStart; @@ -158,8 +126,9 @@ class FlameChartCanvas extends PureComponent { const frameIndex = thread.stackTable.frame[stackIndex]; const text = getLabel(thread, stackIndex); const category = getCategory(thread, frameIndex); + const isHovered = hoveredItem && depth === hoveredItem.depth && i === hoveredItem.stackTableIndex; - ctx.fillStyle = category.color; + ctx.fillStyle = isHovered ? 'Highlight' : category.color; ctx.fillRect(x, y, w, h); // Ensure spacing between blocks. ctx.clearRect(x, y, 1, h); @@ -169,10 +138,10 @@ class FlameChartCanvas extends PureComponent { const x2: CssPixels = Math.max(x, 0) + TEXT_OFFSET_START; const w2: CssPixels = Math.max(0, w - (x2 - x)); - if (this._textMeasurement !== null && w2 > this._textMeasurement.minWidth) { - const fittedText = this._textMeasurement.getFittedText(text, w2); + if (w2 > textMeasurement.minWidth) { + const fittedText = textMeasurement.getFittedText(text, w2); if (fittedText) { - ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillStyle = isHovered ? 'HighlightText' : '#000000'; ctx.fillText(fittedText, x2, y + TEXT_OFFSET_TOP); } } @@ -181,9 +150,77 @@ class FlameChartCanvas extends PureComponent { } } + _getHoveredStackInfo( + {depth, stackTableIndex}: HoveredStackTiming + ): string { + const { thread, getLabel, stackTimingByDepth } = this.props; + const label = getLabel(thread, stackTimingByDepth[depth].stack[stackTableIndex]); + + const duration = stackTimingByDepth[depth].end[stackTableIndex] - + stackTimingByDepth[depth].start[stackTableIndex]; + let durationString; + if (duration >= 10) { + durationString = duration.toFixed(0); + } else if (duration >= 1) { + durationString = duration.toFixed(1); + } else if (duration >= 0.1) { + durationString = duration.toFixed(2); + } else { + durationString = duration.toFixed(3); + } + + return `${durationString}ms - ${label}`; + } + + _onDoubleClickStack({depth, stackTableIndex}: HoveredStackTiming) { + const { stackTimingByDepth, updateProfileSelection } = this.props; + updateProfileSelection({ + hasSelection: true, + isModifying: false, + selectionStart: stackTimingByDepth[depth].start[stackTableIndex], + selectionEnd: stackTimingByDepth[depth].end[stackTableIndex], + }); + } + + _hitTest(x: CssPixels, y: CssPixels): HoveredStackTiming | null { + const { + rangeStart, rangeEnd, viewportLeft, viewportRight, viewportTop, + containerWidth, stackTimingByDepth, + } = this.props; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = viewportRight - viewportLeft; + const unitIntervalTime: UnitIntervalOfProfileRange = viewportLeft + viewportLength * (x / containerWidth); + const time: Milliseconds = rangeStart + unitIntervalTime * rangeLength; + const depth = Math.floor((y + viewportTop) / ROW_HEIGHT); + const stackTiming = stackTimingByDepth[depth]; + + if (!stackTiming) { + return null; + } + + for (let i = 0; i < stackTiming.length; i++) { + const start = stackTiming.start[i]; + const end = stackTiming.end[i]; + if (start < time && end > time) { + return { depth, stackTableIndex: i }; + } + } + + return null; + } + + render() { - this._scheduleDraw(); - return ; + const { containerWidth, containerHeight } = this.props; + + return ; } } diff --git a/src/content/components/TimelineCanvas.css b/src/content/components/TimelineCanvas.css new file mode 100644 index 0000000000..94b014b399 --- /dev/null +++ b/src/content/components/TimelineCanvas.css @@ -0,0 +1,9 @@ +.timelineCanvas { + position: absolute; + top: 0; + left: 0; +} + +.timelineCanvas.hover { + cursor: default; +} diff --git a/src/content/components/TimelineCanvas.js b/src/content/components/TimelineCanvas.js new file mode 100644 index 0000000000..2d2f2eec49 --- /dev/null +++ b/src/content/components/TimelineCanvas.js @@ -0,0 +1,177 @@ +// @flow +import React, { Component } from 'react'; +import { timeCode } from '../../common/time-code'; +import classNames from 'classnames'; + +import type { CssPixels, DevicePixels, NonNull } from '../../common/types/units'; + +type HoveredItem = NonNull; + +type Props = { + containerWidth: CssPixels, + containerHeight: CssPixels, + className: string, + onDoubleClickItem: HoveredItem => void, + getHoveredItemInfo: HoveredItem => string, + drawCanvas: (CanvasRenderingContext2D, HoveredItem) => void, + hitTest: (x: CssPixels, y: CssPixels) => null | HoveredItem, +}; + +require('./TimelineCanvas.css'); + +export default class TimelineCanvas extends Component { + + props: Props; + _requestedAnimationFrame: boolean; + _devicePixelRatio: 1; + _ctx: CanvasRenderingContext2D; + state: { + hoveredItem: null | HoveredItem; + }; + + constructor(props: Props) { + super(props); + this._requestedAnimationFrame = false; + this._devicePixelRatio = 1; + this.state = { hoveredItem: null }; + + (this: any)._onMouseMove = this._onMouseMove.bind(this); + (this: any)._onMouseOut = this._onMouseOut.bind(this); + (this: any)._onDoubleClick = this._onDoubleClick.bind(this); + (this: any)._getHoveredItemInfo = this._getHoveredItemInfo.bind(this); + } + + shouldComponentUpdate() { + // If the parent updates, always re-render. + return true; + } + + _scheduleDraw() { + const { className, drawCanvas } = this.props; + if (!this._requestedAnimationFrame) { + this._requestedAnimationFrame = true; + window.requestAnimationFrame(() => { + this._requestedAnimationFrame = false; + if (this.refs.canvas) { + timeCode(`${className} render`, () => { + this._prepCanvas(); + drawCanvas(this._ctx, this.state.hoveredItem); + }); + } + }); + } + } + + _prepCanvas() { + const {canvas} = this.refs; + const {containerWidth, containerHeight} = this.props; + const {devicePixelRatio} = window; + const pixelWidth: DevicePixels = containerWidth * devicePixelRatio; + const pixelHeight: DevicePixels = containerHeight * devicePixelRatio; + // Satisfy the null check for Flow. + if (!this._ctx) { + this._ctx = canvas.getContext('2d'); + } + if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { + canvas.width = pixelWidth; + canvas.height = pixelHeight; + canvas.style.width = containerWidth + 'px'; + canvas.style.height = containerHeight + 'px'; + this._ctx.scale(this._devicePixelRatio, this._devicePixelRatio); + } + if (this._devicePixelRatio !== devicePixelRatio) { + // Make sure and multiply by the inverse of the previous ratio, as the scaling + // operates off of the previous set scale. + const scale = (1 / this._devicePixelRatio) * devicePixelRatio; + this._ctx.scale(scale, scale); + this._devicePixelRatio = devicePixelRatio; + } + return this._ctx; + } + + _onMouseMove(event: SyntheticMouseEvent) { + const { canvas } = this.refs; + if (!canvas) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const x: CssPixels = event.pageX - rect.left; + const y: CssPixels = event.pageY - rect.top; + + const maybeHoveredItem = this.props.hitTest(x, y); + if (!hoveredItemsAreEqual(maybeHoveredItem, this.state.hoveredItem)) { + this.setState({ hoveredItem: maybeHoveredItem }); + } + } + + _onMouseOut() { + if (this.state.hoveredItem !== null) { + this.setState({ hoveredItem: null }); + } + } + + _onDoubleClick() { + const { hoveredItem } = this.state; + if (hoveredItem === null) { + return; + } + this.props.onDoubleClickItem(hoveredItem); + } + + _getHoveredItemInfo(): null | string { + const { hoveredItem } = this.state; + if (hoveredItem === null) { + return null; + } + return this.props.getHoveredItemInfo(hoveredItem); + } + + render() { + const { hoveredItem } = this.state; + this._scheduleDraw(); + + const className = classNames({ + timelineCanvas: true, + [this.props.className]: true, + hover: hoveredItem !== null, + }); + + return ; + } +} + +/** + * Check for shallow equality for objects, and strict equality for everything else. + */ +function hoveredItemsAreEqual(a: any, b: any) { + if (a && b && typeof a === 'object' && typeof b === 'object') { + if (a.length !== b.length) { + return false; + } + let hasAllKeys = true; + for (const aKey in a) { + let hasKey = false; + for (const bKey in b) { + if (aKey === bKey) { + if (a[aKey] !== b[bKey]) { + return false; + } + hasKey = true; + break; + } + } + hasAllKeys = hasAllKeys && hasKey; + if (!hasAllKeys) { + return false; + } + } + return true; + } + return a === b; +} diff --git a/src/content/components/TimelineMarkerCanvas.js b/src/content/components/TimelineMarkerCanvas.js new file mode 100644 index 0000000000..b3755b147e --- /dev/null +++ b/src/content/components/TimelineMarkerCanvas.js @@ -0,0 +1,274 @@ +// @flow +import React, { PureComponent } from 'react'; +import withTimelineViewport from './TimelineViewport'; +import TimelineCanvas from './TimelineCanvas'; +import TextMeasurement from '../../common/text-measurement'; + +import type { Milliseconds, CssPixels, UnitIntervalOfProfileRange } from '../../common/types/units'; +import type { TracingMarker, MarkerTimingRows, IndexIntoMarkerTiming } from '../../common/types/profile-derived'; +import type { Action, ProfileSelection } from '../actions/types'; + +type Props = { + interval: Milliseconds, + rangeStart: Milliseconds, + rangeEnd: Milliseconds, + containerWidth: CssPixels, + containerHeight: CssPixels, + viewportLeft: UnitIntervalOfProfileRange, + viewportRight: UnitIntervalOfProfileRange, + viewportTop: CssPixels, + viewportBottom: CssPixels, + markerTimingRows: MarkerTimingRows, + rowHeight: CssPixels, + markers: TracingMarker[], + updateProfileSelection: ProfileSelection => Action, +}; + +const ROW_HEIGHT = 16; +const TEXT_OFFSET_TOP = 11; +const TWO_PI = Math.PI * 2; +const MARKER_DOT_RADIUS = 0.25; +const TEXT_OFFSET_START = 3; + +class TimelineMarkerCanvas extends PureComponent { + + _requestedAnimationFrame: boolean; + _devicePixelRatio: number; + _ctx: null | CanvasRenderingContext2D; + _textMeasurement: null | TextMeasurement; + + props: Props; + + state: { + hoveredItem: null | number, + }; + + constructor(props: Props) { + super(props); + (this: any).onDoubleClickMarker = this.onDoubleClickMarker.bind(this); + (this: any).getHoveredMarkerInfo = this.getHoveredMarkerInfo.bind(this); + (this: any).drawCanvas = this.drawCanvas.bind(this); + (this: any).hitTest = this.hitTest.bind(this); + } + + drawCanvas(ctx: CanvasRenderingContext2D, hoveredItem: IndexIntoMarkerTiming) { + const { + viewportTop, viewportBottom, rowHeight, containerWidth, containerHeight, markerTimingRows, + } = this.props; + // Convert CssPixels to Stack Depth + const startRow = Math.floor(viewportTop / rowHeight); + const endRow = Math.min(Math.ceil(viewportBottom / rowHeight), markerTimingRows.length); + + ctx.clearRect(0, 0, containerWidth, containerHeight); + + this.drawMarkers(ctx, hoveredItem, startRow, endRow); + this.drawSeparatorsAndLabels(ctx, startRow, endRow); + } + + drawMarkers( + ctx: CanvasRenderingContext2D, + hoveredItem: IndexIntoMarkerTiming, + startRow: number, + endRow: number + ) { + const { + rangeStart, rangeEnd, containerWidth, markerTimingRows, viewportLeft, + viewportRight, viewportTop, + } = this.props; + + // Ensure the text measurement tool is created, since this is the first time + // this class has access to a ctx. + if (!this._textMeasurement) { + this._textMeasurement = new TextMeasurement(ctx); + } + const textMeasurement = this._textMeasurement; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = viewportRight - viewportLeft; + + // Only draw the stack frames that are vertically within view. + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // Get the timing information for a row of stack frames. + const markerTiming = markerTimingRows[rowIndex]; + + if (!markerTiming) { + continue; + } + + // Decide which samples to actually draw + const timeAtViewportLeft: Milliseconds = rangeStart + rangeLength * viewportLeft; + const timeAtViewportRight: Milliseconds = rangeStart + rangeLength * viewportRight; + + ctx.lineWidth = 1; + for (let i = 0; i < markerTiming.length; i++) { + // Only draw samples that are in bounds. + if (markerTiming.end[i] > timeAtViewportLeft && markerTiming.start[i] < timeAtViewportRight) { + const startTime: UnitIntervalOfProfileRange = (markerTiming.start[i] - rangeStart) / rangeLength; + const endTime: UnitIntervalOfProfileRange = (markerTiming.end[i] - rangeStart) / rangeLength; + + const x: CssPixels = ((startTime - viewportLeft) * containerWidth / viewportLength); + const y: CssPixels = rowIndex * ROW_HEIGHT - viewportTop; + const w: CssPixels = Math.max(10, ((endTime - startTime) * containerWidth / viewportLength)); + const h: CssPixels = ROW_HEIGHT - 1; + + if (w < 2) { + // Skip sending draw calls for sufficiently small boxes. + continue; + } + + const tracingMarkerIndex = markerTiming.index[i]; + const isHovered = hoveredItem === tracingMarkerIndex; + ctx.fillStyle = isHovered ? 'Highlight' : '#8296cb'; + + if (w >= h) { + this.drawRoundedRect(ctx, x, y + 1, w, h - 1, 1); + + const text = markerTiming.label[i]; + // Draw the text label + // TODO - L10N RTL. + // Constrain the x coordinate to the leftmost area. + const x2: CssPixels = Math.max(x, 0) + TEXT_OFFSET_START; + const w2: CssPixels = Math.max(0, w - (x2 - x)); + + if (w2 > textMeasurement.minWidth) { + const fittedText = textMeasurement.getFittedText(text, w2); + if (fittedText) { + ctx.fillStyle = isHovered ? 'HighlightText' : '#ffffff'; + ctx.fillText(fittedText, x2, y + TEXT_OFFSET_TOP); + } + } + } else { + ctx.beginPath(); + ctx.arc( + x + w / 2, // x + y + h / 2, // y + h * MARKER_DOT_RADIUS, // radius + 0, // arc start + TWO_PI // arc end + ); + ctx.fill(); + } + } + } + } + } + + drawSeparatorsAndLabels(ctx: CanvasRenderingContext2D, startRow: number, endRow: number) { + const { markerTimingRows, rowHeight, viewportTop, containerWidth } = this.props; + + // Draw separators + ctx.fillStyle = '#eee'; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + const y = (rowIndex + 1) * rowHeight - viewportTop; + ctx.fillRect(0, y, containerWidth, 1); + } + + // Fill in behind text + const gradient = ctx.createLinearGradient(0, 0, 150, 0); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0.8)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0.0)'); + ctx.fillStyle = gradient; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // Get the timing information for a row of stack frames. + const y = rowIndex * rowHeight - viewportTop; + ctx.fillRect(0, y, 150, rowHeight); + } + + // Draw the text + ctx.fillStyle = '#000000'; + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + // Get the timing information for a row of stack frames. + const { name } = markerTimingRows[rowIndex]; + if (rowIndex > 0 && name === markerTimingRows[rowIndex - 1].name) { + continue; + } + const y = rowIndex * rowHeight - viewportTop; + ctx.fillText(name, 5, y + TEXT_OFFSET_TOP); + } + } + + hitTest(x: CssPixels, y: CssPixels): IndexIntoMarkerTiming | null { + const { + rangeStart, rangeEnd, markerTimingRows, viewportLeft, viewportRight, viewportTop, + containerWidth, rowHeight, + } = this.props; + + const rangeLength: Milliseconds = rangeEnd - rangeStart; + const viewportLength: UnitIntervalOfProfileRange = viewportRight - viewportLeft; + const unitIntervalTime: UnitIntervalOfProfileRange = viewportLeft + viewportLength * (x / containerWidth); + const time: Milliseconds = rangeStart + unitIntervalTime * rangeLength; + const rowIndex = Math.floor((y + viewportTop) / rowHeight); + const minDuration = rangeLength * viewportLength * (rowHeight * 2 * MARKER_DOT_RADIUS / containerWidth); + const markerTiming = markerTimingRows[rowIndex]; + + if (!markerTiming) { + return null; + } + + for (let i = 0; i < markerTiming.length; i++) { + const start = markerTiming.start[i]; + // Ensure that really small markers are hoverable with a minDuration. + const end = Math.max(start + minDuration, markerTiming.end[i]); + if (start < time && end > time) { + return markerTiming.index[i]; + } + } + return null; + } + + onDoubleClickMarker(markerIndex: IndexIntoMarkerTiming) { + const { markers, updateProfileSelection } = this.props; + const marker = markers[markerIndex]; + updateProfileSelection({ + hasSelection: true, + isModifying: false, + selectionStart: marker.start, + selectionEnd: marker.start + marker.dur, + }); + } + + drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: CssPixels, + y: CssPixels, + width: CssPixels, + height: CssPixels, + cornerSize: CssPixels + ) { + // Cut out c x c -sized squares in the corners. + const c = Math.min(width / 2, Math.min(height / 2, cornerSize)); + const bottom = y + height; + ctx.fillRect(x + c, y, width - 2 * c, c); + ctx.fillRect(x, y + c, width, height - 2 * c); + ctx.fillRect(x + c, bottom - c, width - 2 * c, c); + } + + getHoveredMarkerInfo(hoveredItem: IndexIntoMarkerTiming): string { + const { name, dur } = this.props.markers[hoveredItem]; + let duration; + if (dur >= 10) { + duration = dur.toFixed(0); + } else if (dur >= 1) { + duration = dur.toFixed(1); + } else if (dur >= 0.1) { + duration = dur.toFixed(2); + } else { + duration = dur.toFixed(3); + } + return `${name} - ${duration}ms`; + } + + render() { + const { containerWidth, containerHeight } = this.props; + + return ; + } +} + +export default withTimelineViewport(TimelineMarkerCanvas); diff --git a/src/content/components/TimelineViewport.js b/src/content/components/TimelineViewport.js index bca5cf5172..7db78ded70 100644 --- a/src/content/components/TimelineViewport.js +++ b/src/content/components/TimelineViewport.js @@ -85,7 +85,6 @@ export default function withTimelineViewport(WrappedComponent: ReactClass) constructor(props: Props) { super(props); - (this: any)._mouseWheelListener = this._mouseWheelListener.bind(this); (this: any)._mouseDownListener = this._mouseDownListener.bind(this); (this: any)._mouseMoveListener = this._mouseMoveListener.bind(this); @@ -163,6 +162,7 @@ export default function withTimelineViewport(WrappedComponent: ReactClass) componentWillReceiveProps(newProps: Props) { if (this.props.isRowExpanded !== newProps.isRowExpanded) { this.setState(this.getDefaultState(newProps)); + this._setSizeNextFrame(); return; } if ( diff --git a/src/content/containers/TimelineFlameChart.js b/src/content/containers/TimelineFlameChart.js index 94c588fe65..ea2d2e6d50 100644 --- a/src/content/containers/TimelineFlameChart.js +++ b/src/content/containers/TimelineFlameChart.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import FlameChartCanvas from '../components/FlameChartCanvas'; import { selectorsForThread, getDisplayRange, getProfileInterval, getProfileViewOptions } from '../reducers/profile-view'; import { getCategoryColorStrategy, getLabelingStrategy } from '../reducers/flame-chart'; -import { getIsThreadExpanded } from '../reducers/timeline-view'; +import { getIsFlameChartExpanded } from '../reducers/timeline-view'; import actions from '../actions'; import { getImplementationName } from '../labeling-strategies'; import classNames from 'classnames'; @@ -34,7 +34,7 @@ type Props = { interval: Milliseconds, getCategory: GetCategory, getLabel: GetLabel, - changeTimelineExpandedThread: (number, boolean) => {}, + changeTimelineFlameChartExpandedThread: (number, boolean) => {}, updateProfileSelection: UpdateProfileSelection, viewHeight: CssPixels, getScrollElement: () => HTMLElement, @@ -53,8 +53,8 @@ class TimelineFlameChart extends PureComponent { } toggleThreadCollapse() { - const { changeTimelineExpandedThread, threadIndex, isRowExpanded } = this.props; - changeTimelineExpandedThread(threadIndex, !isRowExpanded); + const { changeTimelineFlameChartExpandedThread, threadIndex, isRowExpanded } = this.props; + changeTimelineFlameChartExpandedThread(threadIndex, !isRowExpanded); } /** @@ -123,9 +123,7 @@ class TimelineFlameChart extends PureComponent { maximumZoom={this.getMaximumZoom()} selection={selection} updateProfileSelection={updateProfileSelection} - viewportNeedsUpdate={(prevProps, newProps) => { - return prevProps.stackTimingByDepth !== newProps.stackTimingByDepth; - }} + viewportNeedsUpdate={viewportNeedsUpdate} // FlameChartCanvas props interval={interval} @@ -145,7 +143,7 @@ class TimelineFlameChart extends PureComponent { export default connect((state, ownProps) => { const { threadIndex } = ownProps; const threadSelectors = selectorsForThread(threadIndex); - const isRowExpanded = getIsThreadExpanded(state, threadIndex); + const isRowExpanded = getIsFlameChartExpanded(state, threadIndex); const stackTimingByDepth = isRowExpanded ? threadSelectors.getStackTimingByDepthForFlameChart(state) : threadSelectors.getLeafCategoryStackTimingForFlameChart(state); @@ -166,3 +164,7 @@ export default connect((state, ownProps) => { processDetails: threadSelectors.getThreadProcessDetails(state), }; }, (actions: Object))(TimelineFlameChart); + +function viewportNeedsUpdate(prevProps, newProps) { + return prevProps.stackTimingByDepth !== newProps.stackTimingByDepth; +} diff --git a/src/content/containers/TimelineMarkers.css b/src/content/containers/TimelineMarkers.css new file mode 100644 index 0000000000..24d9805cba --- /dev/null +++ b/src/content/containers/TimelineMarkers.css @@ -0,0 +1,54 @@ +.timelineMarkersLabels { + width: 135px; + display: flex; + padding: 9px 0 9px 14px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.timelineMarkersLabelsName { + flex: 1; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.timelineMarkers { + display: flex; + flex-direction: row; +} + +.timelineMarkersCollapseButton { + position: relative; + width: 17px; + height: 22px; + margin: 0; + padding: 0; + border: none; + margin: -4px 0px; + cursor: pointer; + transition: transform 100ms; + background: transparent; +} + +.timelineMarkersCollapseButton.expanded { + transform: rotate(90deg); +} + +.timelineMarkersCollapseButton::after { + position: absolute; + top: 5px; + left: 5px; + width: 0; + height: 0; + margin: 0; + padding: 0; + content: ""; + border-style: solid; + border-width: 6px 0 6px 8px; + border-color: transparent transparent transparent #888; + background: transparent; +} diff --git a/src/content/containers/TimelineMarkers.js b/src/content/containers/TimelineMarkers.js new file mode 100644 index 0000000000..e942057708 --- /dev/null +++ b/src/content/containers/TimelineMarkers.js @@ -0,0 +1,169 @@ +// @flow +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import TimelineMarkerCanvas from '../components/TimelineMarkerCanvas'; +import { selectorsForThread, getDisplayRange, getProfileInterval, getProfileViewOptions } from '../reducers/profile-view'; +import { getCategoryColorStrategy, getLabelingStrategy } from '../reducers/flame-chart'; +import { getAreMarkersExpanded } from '../reducers/timeline-view'; +import actions from '../actions'; +import { getImplementationName } from '../labeling-strategies'; +import classNames from 'classnames'; + +import type { Thread } from '../../common/types/profile'; +import type { TracingMarker, MarkerTimingRows } from '../../common/types/profile-derived'; +import type { Milliseconds, CssPixels, UnitIntervalOfProfileRange } from '../../common/types/units'; +import type { GetCategory } from '../color-categories'; +import type { GetLabel } from '../labeling-strategies'; +import type { UpdateProfileSelection } from '../actions/profile-view'; +import type { ProfileSelection } from '../actions/types'; + + +require('./TimelineMarkers.css'); + +const ROW_HEIGHT = 16; +const TIMELINE_ROW_HEIGHT = 34; + +type Props = { + thread: Thread, + isRowExpanded: boolean, + maxMarkerRows: number, + isSelected: boolean, + timeRange: { start: Milliseconds, end: Milliseconds }, + threadIndex: number, + interval: Milliseconds, + getCategory: GetCategory, + getLabel: GetLabel, + changeTimelineMarkersExpandedThread: (number, boolean) => {}, + updateProfileSelection: UpdateProfileSelection, + viewHeight: CssPixels, + getScrollElement: () => HTMLElement, + selection: ProfileSelection, + threadName: string, + processDetails: string, + markerTimingRows: MarkerTimingRows, + markers: TracingMarker[], +}; + +class TimelineMarkers extends Component { + + props: Props; + + constructor(props) { + super(props); + (this: any)._toggleThreadCollapse = this._toggleThreadCollapse.bind(this); + } + + _toggleThreadCollapse() { + const { changeTimelineMarkersExpandedThread, threadIndex, isRowExpanded } = this.props; + changeTimelineMarkersExpandedThread(threadIndex, !isRowExpanded); + } + + /** + * Expanding view sizing strategy: + * + * EXACT SIZE: Try and set it exactly to the size of the flame chart. + * SMALL GRAPH: The smallest it can be is 1.5 times the row height, giving a visual cue + * to the user that this row is expanded, even if it's super shallow. + * LARGE GRAPH: If the flame chart is too large, only expand out to most of the + * available space, leaving some margin to show the other rows. + */ + getViewHeight(maxViewportHeight: number): number { + const { viewHeight, isRowExpanded } = this.props; + const exactSize = isRowExpanded ? maxViewportHeight * 1.5 : maxViewportHeight; + const largeGraph = viewHeight - TIMELINE_ROW_HEIGHT * 2; + const smallGraph = TIMELINE_ROW_HEIGHT; + return Math.max(smallGraph, Math.min(exactSize, largeGraph)); + } + + /** + * Determine + */ + getMaximumZoom(): UnitIntervalOfProfileRange { + const { + timeRange: { start, end }, + interval, + } = this.props; + return interval / (end - start); + } + + render() { + const { + thread, isRowExpanded, maxMarkerRows, isSelected, timeRange, + threadIndex, interval, getCategory, getLabel, markerTimingRows, markers, + updateProfileSelection, selection, threadName, processDetails, getScrollElement, + } = this.props; + + // The viewport needs to know about the height of what it's drawing, calculate + // that here at the top level component. + const maxViewportHeight = maxMarkerRows * ROW_HEIGHT; + const height = this.getViewHeight(maxViewportHeight); + const buttonClass = classNames('timelineMarkersCollapseButton', { + expanded: isRowExpanded, + collapsed: !isRowExpanded, + }); + + return ( +
+
+ {threadName} +
+ +
+ ); + } +} + +function viewportNeedsUpdate(prevProps, newProps) { + return prevProps.markerTimingRows !== newProps.markerTimingRows; +} + + +export default connect((state, ownProps) => { + const { threadIndex } = ownProps; + const threadSelectors = selectorsForThread(threadIndex); + const isRowExpanded = getAreMarkersExpanded(state, threadIndex); + + const markers = threadSelectors.getTracingMarkers(state); + const markerTimingRows = isRowExpanded + ? threadSelectors.getMarkerTiming(state) + : []; + + return { + thread: threadSelectors.getFilteredThreadForFlameChart(state), + isRowExpanded, + markers, + markerTimingRows, + maxMarkerRows: markerTimingRows.length, + isSelected: true, + timeRange: getDisplayRange(state), + interval: getProfileInterval(state), + getCategory: getCategoryColorStrategy(state), + getLabel: isRowExpanded ? getLabelingStrategy(state) : getImplementationName, + threadIndex, + selection: getProfileViewOptions(state).selection, + threadName: threadSelectors.getFriendlyThreadName(state), + processDetails: threadSelectors.getThreadProcessDetails(state), + }; +}, (actions: Object))(TimelineMarkers); diff --git a/src/content/containers/TimelineView.css b/src/content/containers/TimelineView.css index 63a8f6152e..e737f255e9 100644 --- a/src/content/containers/TimelineView.css +++ b/src/content/containers/TimelineView.css @@ -46,6 +46,12 @@ background-color:#d6d6d6; } -.timelineViewRow { - /*padding-bottom: 5px;*/ +.timelineViewDivider { + background-color: #f9f9f9; + border: solid #d6d6d6; + border-width: 1px 0; + height: 25px; + line-height: 25px; + padding-left: 14px; + z-index: 1; } diff --git a/src/content/containers/TimelineView.js b/src/content/containers/TimelineView.js index 6bf09eb5e4..7733d3a704 100644 --- a/src/content/containers/TimelineView.js +++ b/src/content/containers/TimelineView.js @@ -5,6 +5,7 @@ import { getThreads, getThreadOrder } from '../reducers/profile-view'; import actions from '../actions'; import FlameChartSettings from '../components/FlameChartSettings'; import TimelineFlameChart from './TimelineFlameChart'; +import TimelineMarkers from './TimelineMarkers'; import Reorderable from '../components/Reorderable'; import { withSize } from '../with-size'; @@ -34,16 +35,17 @@ class TimlineViewTimelinesImpl extends PureComponent { render() { const { threads, threadOrder, changeThreadOrder, height } = this.props; - const className = 'timelineViewTimelines'; - return (
{ this.scrollElement = element; }}> +
+ Sample based callstacks +
@@ -55,6 +57,18 @@ class TimlineViewTimelinesImpl extends PureComponent {
))} +
+ Marker Events +
+
+ {threads.map((thread, threadIndex) => ( +
+ +
+ ))} +
); diff --git a/src/content/marker-timing.js b/src/content/marker-timing.js new file mode 100644 index 0000000000..261dc79ce2 --- /dev/null +++ b/src/content/marker-timing.js @@ -0,0 +1,128 @@ +// @flow +import type { UserTimingMarkerPayload, MarkerPayload } from '../common/types/profile'; +import type { TracingMarker, MarkerTiming, MarkerTimingRows } from '../common/types/profile-derived'; + +// Arbitrarily set an upper limit for adding marker depths, avoiding an infinite loop. +const MAX_STACKING_DEPTH = 300; + +/** + * This function computes the timing information for laying out the markers in the + * TimelineMarkers component. Each marker is put into a single row based on its name. + * + * e.g. An array of 15 markers named either "A", "B", or "C" would be translated into + * something that looks like: + * + * [ + * { + * name: "A", + * start: [0, 23, 35, 65, 75], + * end: [1, 25, 37, 67, 77], + * index: [0, 2, 5, 6, 8], + * label: ["Aye", "Aye", "Aye", "Aye", "Aye"] + * } + * { + * name: "B", + * start: [1, 28, 39, 69, 70], + * end: [2, 29, 49, 70, 77], + * index: [1, 3, 7, 9, 10], + * label: ["Bee", "Bee", "Bee", "Bee", "Bee"] + * } + * { + * name: "C", + * start: [10, 33, 45, 75, 85], + * end: [11, 35, 47, 77, 87], + * index: [4, 11, 12, 13, 14], + * label: ["Sea", "Sea", "Sea", "Sea", "Sea"] + * } + * ] + * + * If a marker of a name has timings that overlap in a single row, then it is broken + * out into multiple rows, with the overlapping timings going in the next rows. The + * getMarkerTiming tests show the behavior of how this works in practice. + * + * This structure allows the markers to easily be laid out like this example below: + * ____________________________________________ + * | GC | *--* *--* *--* | + * | | | + * | Scripts | *---------------------* | + * | | | + * | User Timings | *----------------* | + * | User Timings | *------------* | + * | User Timings | *--* *---* | + * |______________|_____________________________| + */ +export function getMarkerTiming( + tracingMarkers: TracingMarker[] +): MarkerTimingRows { + // Each marker type will have it's own timing information, later collapse these into + // a single array. + const markerTimingsMap: Map = new Map(); + + // Go through all of the markers. + for (let tracingMarkerIndex = 0; tracingMarkerIndex < tracingMarkers.length; tracingMarkerIndex++) { + const marker = tracingMarkers[tracingMarkerIndex]; + let markerTimingsByName = markerTimingsMap.get(marker.name); + if (markerTimingsByName === undefined) { + markerTimingsByName = []; + markerTimingsMap.set(marker.name, markerTimingsByName); + } + + // Place the marker in the closest row that is empty. + for (let i = 0; i < MAX_STACKING_DEPTH; i++) { + // Get or create a row for marker timings. + let markerTimingsRow = markerTimingsByName[i]; + if (!markerTimingsRow) { + markerTimingsRow = { + start: [], + end: [], + index: [], + label: [], + name: marker.name, + length: 0, + }; + markerTimingsByName.push(markerTimingsRow); + } + + let continueSearching = false; + + // Search for a spot not already taken up by another marker of this type. + for (let j = 0; j < markerTimingsRow.length; j++) { + const otherStart = markerTimingsRow.start[j]; + const otherEnd = markerTimingsRow.end[j]; + if (otherStart > marker.start + marker.dur) { + break; + } + if (otherEnd > marker.start) { + continueSearching = true; + break; + } + } + + if (!continueSearching) { + // An empty spot was found, fill the values in the table. + markerTimingsRow.start.push(marker.start); + markerTimingsRow.end.push(marker.start + marker.dur); + markerTimingsRow.label.push(computeMarkerLabel(marker.data)); + markerTimingsRow.index.push(tracingMarkerIndex); + markerTimingsRow.length++; + break; + } + } + } + + // Flatten out the map into a single array. + return [].concat(...markerTimingsMap.values()); +} + +function computeMarkerLabel(data: MarkerPayload): string { + // Satisfy flow's type checker. + if (data !== null && typeof data === 'object') { + // Handle different marker payloads. + switch (data.type) { + case 'UserTiming': + return (data: UserTimingMarkerPayload).name; + } + } + + return ''; +} diff --git a/src/content/profile-data.js b/src/content/profile-data.js index d075ced167..76a1ce5f4d 100644 --- a/src/content/profile-data.js +++ b/src/content/profile-data.js @@ -698,6 +698,7 @@ export function getJankInstances(samples: SamplesTable, processType: string, thr dur: lastResponsiveness, title: `${lastResponsiveness.toFixed(2)}ms event processing delay on ${processType} thread`, name: 'Jank', + data: null, }); } } @@ -710,6 +711,7 @@ export function getJankInstances(samples: SamplesTable, processType: string, thr dur: lastResponsiveness, title: `${lastResponsiveness.toFixed(2)}ms event processing delay on ${processType} thread`, name: 'Jank', + data: null, }); } return jankInstances; @@ -733,6 +735,7 @@ export function getTracingMarkers(thread: Thread): TracingMarker[] { name: stringTable.getString(nameStringIndex), dur: 0, title: null, + data, }); } else if (data.interval === 'end') { const marker = openMarkers.get(nameStringIndex); @@ -757,6 +760,7 @@ export function getTracingMarkers(thread: Thread): TracingMarker[] { start: startTime, dur: duration, name, + data, title: `${name} for ${duration.toFixed(2)}ms`, }); } diff --git a/src/content/reducers/profile-view.js b/src/content/reducers/profile-view.js index ade0fc96e1..37c2c3594b 100644 --- a/src/content/reducers/profile-view.js +++ b/src/content/reducers/profile-view.js @@ -6,6 +6,7 @@ import * as CallTreeFilters from '../call-tree-filters'; import * as URLState from './url-state'; import * as ProfileData from '../profile-data'; import * as StackTiming from '../stack-timing'; +import * as MarkerTiming from '../marker-timing'; import * as ProfileTree from '../profile-tree'; import * as TaskTracerTools from '../task-tracer'; import { getCategoryColorStrategy } from './flame-chart'; @@ -18,7 +19,12 @@ import type { SamplesTable, TaskTracer, } from '../../common/types/profile'; -import type { TracingMarker, FuncStackInfo, IndexIntoFuncStackTable } from '../../common/types/profile-derived'; +import type { + TracingMarker, + FuncStackInfo, + IndexIntoFuncStackTable, + MarkerTimingRows, +} from '../../common/types/profile-derived'; import type { Milliseconds, StartEndRange } from '../../common/types/units'; import type { Action, CallTreeFilter, ProfileSelection } from '../actions/types'; import type { @@ -357,6 +363,7 @@ export type SelectorsForThread = { getRangeFilteredThread: State => Thread, getJankInstances: State => TracingMarker[], getTracingMarkers: State => TracingMarker[], + getMarkerTiming: State => MarkerTimingRows, getRangeSelectionFilteredTracingMarkers: State => TracingMarker[], getFilteredThread: State => Thread, getRangeSelectionFilteredThread: State => Thread, @@ -414,6 +421,10 @@ export const selectorsForThread = (threadIndex: ThreadIndex): SelectorsForThread getThread, ProfileData.getTracingMarkers ); + const getMarkerTiming = createSelector( + getTracingMarkers, + MarkerTiming.getMarkerTiming + ); const getRangeSelectionFilteredTracingMarkers = createSelector( getTracingMarkers, getDisplayRange, @@ -568,6 +579,7 @@ export const selectorsForThread = (threadIndex: ThreadIndex): SelectorsForThread getRangeFilteredThread, getJankInstances, getTracingMarkers, + getMarkerTiming, getRangeSelectionFilteredTracingMarkers, getFilteredThread, getRangeSelectionFilteredThread, diff --git a/src/content/reducers/timeline-view.js b/src/content/reducers/timeline-view.js index fbc3183735..e378a64999 100644 --- a/src/content/reducers/timeline-view.js +++ b/src/content/reducers/timeline-view.js @@ -5,13 +5,14 @@ import type { Action } from '../actions/types'; type IsThreadExpandedMap = Map; type TimelineViewState = { - isThreadExpanded: IsThreadExpandedMap, + isFlameChartExpanded: IsThreadExpandedMap, + areMarkersExpanded: IsThreadExpandedMap, hasZoomedViaMousewheel: boolean, } -function isThreadExpanded(state: IsThreadExpandedMap = new Map(), action: Action) { +function isFlameChartExpanded(state: IsThreadExpandedMap = new Map(), action: Action) { switch (action.type) { - case 'CHANGE_TIMELINE_EXPANDED_THREAD': { + case 'CHANGE_TIMELINE_FLAME_CHART_EXPANDED_THREAD': { const newState = new Map(state); // For now only allow one thread to be open at a time, evaluate whether or not do // more than one. @@ -29,6 +30,17 @@ function isThreadExpanded(state: IsThreadExpandedMap = new Map(), action: Action return state; } +function areMarkersExpanded(state: IsThreadExpandedMap = new Map(), action: Action) { + switch (action.type) { + case 'CHANGE_TIMELINE_MARKERS_EXPANDED_THREAD': { + const newState = new Map(state); + newState.set(action.threadIndex, action.isExpanded); + return newState; + } + } + return state; +} + function hasZoomedViaMousewheel(state: boolean = false, action: Action) { switch (action.type) { case 'HAS_ZOOMED_VIA_MOUSEWHEEL': { @@ -38,11 +50,15 @@ function hasZoomedViaMousewheel(state: boolean = false, action: Action) { return state; } -export default combineReducers({ isThreadExpanded, hasZoomedViaMousewheel }); +export default combineReducers({ isFlameChartExpanded, areMarkersExpanded, hasZoomedViaMousewheel }); export const getTimelineView = (state: Object): TimelineViewState => state.timelineView; -export const getIsThreadExpanded = (state: Object, threadIndex: ThreadIndex) => { - return Boolean(getTimelineView(state).isThreadExpanded.get(threadIndex)); +export const getIsFlameChartExpanded = (state: Object, threadIndex: ThreadIndex) => { + return Boolean(getTimelineView(state).isFlameChartExpanded.get(threadIndex)); +}; +export const getAreMarkersExpanded = (state: Object, threadIndex: ThreadIndex) => { + // Default to being expanded by checking if not equal to false. + return getTimelineView(state).areMarkersExpanded.get(threadIndex) !== false; }; export const getHasZoomedViaMousewheel = (state: Object): boolean => { return getTimelineView(state).hasZoomedViaMousewheel; diff --git a/src/content/stack-timing.js b/src/content/stack-timing.js index 84720196a1..34e1976000 100644 --- a/src/content/stack-timing.js +++ b/src/content/stack-timing.js @@ -1,5 +1,6 @@ // @flow import type { IndexIntoStackTable, IndexIntoFrameTable, Thread, StackTable } from '../common/types/profile'; +import type { Milliseconds } from '../common/types/units'; import type { FuncStackInfo } from '../common/types/profile-derived'; import type { GetCategory } from './color-categories'; /** @@ -36,11 +37,13 @@ import type { GetCategory } from './color-categories'; * {start: [25, 45], end: [35, 55], stack: [123, 159]} * ] */ + +export type StackTimingDepth = number; +export type IndexIntoStackTiming = number; + export type StackTimingByDepth = Array<{ - // Start time of stack in milliseconds. - start: number[], - // End time of stack in milliseconds. - end: number[], + start: Milliseconds[], + end: Milliseconds[], stack: IndexIntoStackTable[], length: number, }> diff --git a/src/test/store/actions.js b/src/test/store/actions.js index c49ce29947..e2c59cc676 100644 --- a/src/test/store/actions.js +++ b/src/test/store/actions.js @@ -3,7 +3,7 @@ import { storeWithProfile } from '../fixtures/stores'; import * as ProfileViewSelectors from '../../content/reducers/profile-view'; import * as TimelineSelectors from '../../content/reducers/timeline-view'; import * as UrlStateSelectors from '../../content/reducers/url-state'; -import { getProfileWithNamedThreads } from './fixtures/profiles'; +import { getProfileWithNamedThreads, getProfileWithMarkers } from './fixtures/profiles'; import { changeCallTreeSearchString, @@ -18,7 +18,8 @@ import { } from '../../content/actions/profile-view'; import { changeFlameChartColorStrategy, - changeTimelineExpandedThread, + changeTimelineMarkersExpandedThread, + changeTimelineFlameChartExpandedThread, } from '../../content/actions/timeline'; import { getCategoryByImplementation } from '../../content/color-categories'; @@ -190,28 +191,50 @@ describe('selectors/getLeafCategoryStackTimingForFlameChart', function () { }); }); -describe('actions/changeTimelineExpandedThread', function () { - it('can set one timeline thread as expanded', function () { +describe('actions/changeTimelineFlameChartExpandedThread', function () { + it('can set one timeline flame chart thread as expanded', function () { const store = storeWithProfile(); const threads = ProfileViewSelectors.getThreads(store.getState()); function isExpanded(thread, threadIndex) { - return TimelineSelectors.getIsThreadExpanded(store.getState(), threadIndex); + return TimelineSelectors.getIsFlameChartExpanded(store.getState(), threadIndex); } assert.deepEqual(threads.map(isExpanded), [false, false, false]); - store.dispatch(changeTimelineExpandedThread(1, true)); + store.dispatch(changeTimelineFlameChartExpandedThread(1, true)); assert.deepEqual(threads.map(isExpanded), [false, true, false]); - store.dispatch(changeTimelineExpandedThread(2, true)); + store.dispatch(changeTimelineFlameChartExpandedThread(2, true)); assert.deepEqual(threads.map(isExpanded), [false, false, true]); - store.dispatch(changeTimelineExpandedThread(2, false)); + store.dispatch(changeTimelineFlameChartExpandedThread(2, false)); assert.deepEqual(threads.map(isExpanded), [false, false, false]); }); }); +describe('actions/changeTimelineMarkersExpandedThread', function () { + it('can set one timeline markers thread as expanded', function () { + const store = storeWithProfile(); + const threads = ProfileViewSelectors.getThreads(store.getState()); + + function isExpanded(thread, threadIndex) { + return TimelineSelectors.getAreMarkersExpanded(store.getState(), threadIndex); + } + // Timeline markers are open by default. + assert.deepEqual(threads.map(isExpanded), [true, true, true]); + + store.dispatch(changeTimelineMarkersExpandedThread(1, false)); + assert.deepEqual(threads.map(isExpanded), [true, false, true]); + + store.dispatch(changeTimelineMarkersExpandedThread(2, false)); + assert.deepEqual(threads.map(isExpanded), [true, false, false]); + + store.dispatch(changeTimelineMarkersExpandedThread(2, true)); + assert.deepEqual(threads.map(isExpanded), [true, false, true]); + }); +}); + describe('actions/changeImplementationFilter', function () { const store = storeWithProfile(); @@ -317,3 +340,75 @@ describe('thread ordering and toggling', function () { }); }); }); + +describe('selectors/getMarkerTiming', function () { + function getMarkerTiming(testMarkers) { + const profile = getProfileWithMarkers(testMarkers); + const { getState } = storeWithProfile(profile); + return selectedThreadSelectors.getMarkerTiming(getState()); + } + + it('has no marker timing if no markers are present', function () { + assert.deepEqual(getMarkerTiming([]), []); + }); + + describe('markers of the same name', function () { + it('puts markers of the same time in two rows', function () { + // The timing should look like this: + // 'Marker Name': *------* + // : *------* + const markerTiming = getMarkerTiming([ + ['Marker Name', 0, {startTime: 0, endTime: 10}], + ['Marker Name', 0, {startTime: 0, endTime: 10}], + ]); + assert.lengthOf(markerTiming, 2); + }); + + it('puts markers of disjoint times in one row', function () { + // The timing should look like this: + // 'Marker Name': *------* *------* + const markerTiming = getMarkerTiming([ + ['Marker Name', 0, {startTime: 0, endTime: 10}], + ['Marker Name', 0, {startTime: 15, endTime: 25}], + ]); + assert.lengthOf(markerTiming, 1); + }); + + it('puts markers of overlapping times in two rows', function () { + // The timing should look like this: + // 'Marker Name': *------* + // : *------* + const markerTiming = getMarkerTiming([ + ['Marker Name', 0, {startTime: 0, endTime: 10}], + ['Marker Name', 0, {startTime: 5, endTime: 15}], + ]); + assert.lengthOf(markerTiming, 2); + }); + + it('puts markers of inclusive overlapping times in two rows', function () { + // The timing should look like this: + // 'Marker Name': *--------* + // : *---* + const markerTiming = getMarkerTiming([ + ['Marker Name', 0, {startTime: 0, endTime: 20}], + ['Marker Name', 0, {startTime: 5, endTime: 15}], + ]); + assert.lengthOf(markerTiming, 2); + }); + }); + + describe('markers of the different names', function () { + it('puts them in different rows', function () { + // The timing should look like this: + // 'Marker Name A': *------* + // 'Marker Name B': *------* + const markerTiming = getMarkerTiming([ + ['Marker Name A', 0, {startTime: 0, endTime: 10}], + ['Marker Name B', 0, {startTime: 20, endTime: 30}], + ]); + assert.lengthOf(markerTiming, 2); + assert.equal(markerTiming[0].name, 'Marker Name A'); + assert.equal(markerTiming[1].name, 'Marker Name B'); + }); + }); +}); diff --git a/src/test/store/fixtures/profiles.js b/src/test/store/fixtures/profiles.js index 09129e21d5..c691f56b13 100644 --- a/src/test/store/fixtures/profiles.js +++ b/src/test/store/fixtures/profiles.js @@ -1,7 +1,34 @@ // @flow import { getEmptyProfile } from '../../../content/profile-data'; import { UniqueStringArray } from '../../../content/unique-string-array'; -import type { Profile, Thread } from '../../../common/types/profile'; +import type { Profile, Thread, MarkersTable } from '../../../common/types/profile'; +import type { Milliseconds } from '../../../common/types/units'; + +// Array<[MarkerName, Milliseconds, Data]> +type MarkerName = string; +type MarkerTime = Milliseconds; +type DataPayload = Object; +type TestDefinedMarkers = Array<[MarkerName, MarkerTime, DataPayload]>; + +export function getProfileWithMarkers(markers: TestDefinedMarkers): Profile { + const profile = getEmptyProfile(); + const thread = getEmptyThread(); + const stringTable = thread.stringTable; + const markersTable: MarkersTable = { + name: [], + time: [], + data: [], + length: 0, + }; + markers.map(([name, time, data]) => { + markersTable.name.push(stringTable.indexForString(name)); + markersTable.time.push(time); + markersTable.data.push(data); + markersTable.length++; + }); + profile.threads.push(Object.assign({}, thread, { markers: markersTable })); + return profile; +} export function getProfileWithNamedThreads(threadNames: string[]): Profile { const profile = getEmptyProfile(); @@ -9,7 +36,7 @@ export function getProfileWithNamedThreads(threadNames: string[]): Profile { return profile; } -export function getEmptyThread(overrides: Object): Thread { +export function getEmptyThread(overrides: ?Object): Thread { return Object.assign({ processType: 'default', name: 'Empty', diff --git a/src/test/unit/profile-data.js b/src/test/unit/profile-data.js index e592baaf48..d999ca87b0 100644 --- a/src/test/unit/profile-data.js +++ b/src/test/unit/profile-data.js @@ -220,30 +220,24 @@ describe('profile-data', function () { const tracingMarkers = getTracingMarkers(thread); it('should fold the two reflow markers into one tracing marker', function () { assert.equal(tracingMarkers.length, 3); - assert.deepEqual(tracingMarkers[0], { - start: 2, - name: 'Reflow', - dur: 6, - title: 'Reflow for 6.00ms', - }); + assert.equal(tracingMarkers[0].start, 2); + assert.equal(tracingMarkers[0].name, 'Reflow'); + assert.equal(tracingMarkers[0].dur, 6); + assert.equal(tracingMarkers[0].title, 'Reflow for 6.00ms'); }); it('should fold the two Rasterize markers into one tracing marker, after the reflow tracing marker', function () { assert.equal(tracingMarkers.length, 3); - assert.deepEqual(tracingMarkers[1], { - start: 4, - name: 'Rasterize', - dur: 1, - title: 'Rasterize for 1.00ms', - }); + assert.equal(tracingMarkers[1].start, 4); + assert.equal(tracingMarkers[1].name, 'Rasterize'); + assert.equal(tracingMarkers[1].dur, 1); + assert.equal(tracingMarkers[1].title, 'Rasterize for 1.00ms'); }); it('should create a tracing marker for the MinorGC startTime/endTime marker', function () { assert.equal(tracingMarkers.length, 3); - assert.deepEqual(tracingMarkers[2], { - start: 11, - name: 'MinorGC', - dur: 1, - title: 'MinorGC for 1.00ms', - }); + assert.equal(tracingMarkers[2].start, 11); + assert.equal(tracingMarkers[2].name, 'MinorGC'); + assert.equal(tracingMarkers[2].dur, 1); + assert.equal(tracingMarkers[2].title, 'MinorGC for 1.00ms'); }); }); });