From 4ea1486a358d1c97d599b372f7453204e275820e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 9 Dec 2024 14:48:20 -0500 Subject: [PATCH] Draw the marker chart using device pixels. --- src/components/marker-chart/Canvas.js | 1038 ++++++++++++----------- src/components/shared/chart/Viewport.js | 8 + src/profile-logic/marker-timing.js | 15 + src/types/profile-derived.js | 1 + 4 files changed, 579 insertions(+), 483 deletions(-) diff --git a/src/components/marker-chart/Canvas.js b/src/components/marker-chart/Canvas.js index 8ecf638e07..4baf6230f6 100644 --- a/src/components/marker-chart/Canvas.js +++ b/src/components/marker-chart/Canvas.js @@ -14,17 +14,16 @@ import { ChartCanvas } from 'firefox-profiler/components/shared/chart/Canvas'; import { TooltipMarker } from 'firefox-profiler/components/tooltip/Marker'; import TextMeasurement from 'firefox-profiler/utils/text-measurement'; import { bisectionRight } from 'firefox-profiler/utils/bisect'; -import memoize from 'memoize-immutable'; import { typeof updatePreviewSelection as UpdatePreviewSelection, typeof changeRightClickedMarker as ChangeRightClickedMarker, typeof changeMouseTimePosition as ChangeMouseTimePosition, typeof changeSelectedMarker as ChangeSelectedMarker, } from 'firefox-profiler/actions/profile-view'; -import { TIMELINE_MARGIN_LEFT } from 'firefox-profiler/app-logic/constants'; import type { Milliseconds, CssPixels, + DevicePixels, UnitIntervalOfProfileRange, ThreadsKey, Marker, @@ -42,15 +41,6 @@ import type { import type { WrapFunctionInDispatch } from 'firefox-profiler/utils/connect'; -type MarkerDrawingInformation = {| - +x: CssPixels, - +y: CssPixels, - +w: CssPixels, - +h: CssPixels, - +isInstantMarker: boolean, - +markerIndex: MarkerIndex, -|}; - // We can hover over multiple items with Marker chart when we are in the active // tab view. Usually on other charts, we only have one selected item at a time. // But in here, we can hover over both markers and marker labels. @@ -68,10 +58,16 @@ type MarkerDrawingInformation = {| // also see the hovered labels. 4th case is not used. We use primitive `null` // instead when both of the states are null, because that's what our shared // canvas component require. -type IndexIntoHoveredLabelRow = number; +type RowIndex = number; + +type MarkerLocationInChart = {| + rowIndex: RowIndex, + markerIndex: MarkerIndex, +|}; + type HoveredMarkerChartItems = {| - markerIndex: MarkerIndex | null, - rowIndexOfLabel: IndexIntoHoveredLabelRow | null, + marker: MarkerLocationInChart | null, + labelRow: RowIndex | null, |}; type OwnProps = {| @@ -101,276 +97,505 @@ type Props = {| +viewport: Viewport, |}; -const TEXT_OFFSET_TOP = 11; -const TEXT_OFFSET_START = 3; -const MARKER_DOT_RADIUS = 0.25; -const LABEL_PADDING = 5; +const TEXT_OFFSET_TOP_CSS = 11; +const TEXT_OFFSET_START_CSS = 3; +const MARKER_DOT_RADIUS_CSS = 0.25; +const LABEL_PADDING_CSS = 5; const MARKER_BORDER_COLOR = '#2c77d1'; +const FONT_SIZE_CSS = 10; + +type ViewportCoordinates = {| + outerWidthDevPx: DevicePixels, + outerHeightDevPx: DevicePixels, + + cssToDeviceScale: number, + + insetLeftTimestamp: Milliseconds, + insetLeftDevPx: DevicePixels, + devPxPerMs: number, + outerRightTimestamp: Milliseconds, + viewportRightDevPx: DevicePixels, + + viewportTopDevPx: DevicePixels, + rowHeightFractDevPx: DevicePixels, + + visibleRowsStart: RowIndex, + visibleRowsEnd: RowIndex, +|}; + +type RowCoordinates = {| + rowTopDevPx: DevicePixels, + rowBottomDevPx: DevicePixels, + rowHeightDevPx: DevicePixels, + rowContentHeightDevPx: DevicePixels, +|}; + +// type DeviceRect = {| +// left: number, +// top: number, +// right: number, +// bottom: number, +// width: number, +// height: number, +// |}; + +/** + * Round the given value to integers, consistently rounding x.5 towards positive infinity. + * This is different from Math.round: Math.round rounds 0.5 to the right (to 1), and -0.5 + * to the left (to -1). + * snap should be preferred over Math.round for rounding coordinates which might + * be negative, so that there is no discontinuity when a box moves past zero. + */ +function snap(floatDeviceValue: DevicePixels): DevicePixels { + return Math.floor(floatDeviceValue + 0.5); +} + +/** + * Round the given value to a multiple of `integerFactor`. + */ +function snapValueToMultipleOf( + floatDeviceValue: DevicePixels, + integerFactor: number +): DevicePixels { + return snap(floatDeviceValue / integerFactor) * integerFactor; +} class MarkerChartCanvasImpl extends React.PureComponent { _textMeasurement: null | TextMeasurement; + _textMeasurementCssToDeviceScale: number = 1; - drawCanvas = ( - ctx: CanvasRenderingContext2D, - scale: ChartCanvasScale, + _computeDirtyRows( + coordinates: ViewportCoordinates, hoverInfo: ChartCanvasHoverInfo - ) => { - const { - rowHeight, - markerTimingAndBuckets, - rightClickedMarkerIndex, - timelineTrackOrganization, - viewport: { - viewportTop, - viewportBottom, - containerWidth, - containerHeight, - }, - } = this.props; - let hoveredMarker = null; - let hoveredLabel = null; - let prevHoveredMarker = null; - let prevHoveredLabel = null; - + ): Set | null { const { hoveredItem: hoveredItems, prevHoveredItem: prevHoveredItems, isHoveredOnlyDifferent, } = hoverInfo; - if (hoveredItems) { - hoveredMarker = hoveredItems.markerIndex; - hoveredLabel = hoveredItems.rowIndexOfLabel; + // const { rowHeightFractDevPx, viewportTopDevPx, outerWidthDevPx, outerHeightDevPx } = coordinates; + + if (!isHoveredOnlyDifferent) { + return null; + // const allRows = new Set(); + // for () + // const width = outerWidthDevPx; + // const right = width; + // const height = outerHeightDevPx; + // const bottom = height; + // return [{ left: 0, top: 0, right, bottom, width, height }]; } - if (prevHoveredItems) { - prevHoveredMarker = prevHoveredItems.markerIndex; - prevHoveredLabel = prevHoveredItems.rowIndexOfLabel; + + const invalidRows = new Set(); + + const hoveredMarker = hoveredItems ? hoveredItems.marker : null; + const hoveredMarkerIndex = hoveredMarker ? hoveredMarker.markerIndex : null; + const prevHoveredMarker = prevHoveredItems ? prevHoveredItems.marker : null; + const prevHoveredMarkerIndex = prevHoveredMarker + ? prevHoveredMarker.markerIndex + : null; + + if (hoveredMarkerIndex !== prevHoveredMarkerIndex) { + invalidRows.add(hoveredMarker ? hoveredMarker.rowIndex : null); + invalidRows.add(prevHoveredMarker ? prevHoveredMarker.rowIndex : null); } - const { cssToUserScale } = scale; - if (cssToUserScale !== 1) { + const hoveredLabelRow = hoveredItems ? hoveredItems.labelRow : null; + const prevHoveredLabelRow = prevHoveredItems + ? prevHoveredItems.labelRow + : null; + if (hoveredLabelRow !== prevHoveredLabelRow) { + invalidRows.add(hoveredLabelRow); + invalidRows.add(prevHoveredLabelRow); + } + + return invalidRows; + + // const invalidRects = []; + // for (const rowIndex of invalidRows) { + // if (rowIndex === null) { + // continue; + // } + + // const top = Math.round(rowIndex * rowHeightFractDevPx - viewportTopDevPx); + // const bottom = Math.round((rowIndex + 1) * rowHeightFractDevPx - viewportTopDevPx); + // const height = bottom - top; + // const left = 0; + // const width = outerWidthDevPx; + // const right = width; + // invalidRects.push({ left, top, right, bottom, width, height }); + // } + + // return invalidRects; + } + + _getViewportCoordinates(cssToDeviceScale: number): ViewportCoordinates { + const { + marginLeft: insetLeftCssPx, + marginRight: insetRightCssPx, + rangeStart: committedRangeStartTimestamp, + rangeEnd: committedRangeEndTimestamp, + rowHeight: rowHeightCssPx, + viewport: { + containerWidth: outerWidthCssPx, + containerHeight: outerHeightCssPx, + viewportLeft: viewportLeftFraction, + viewportRight: viewportRightFraction, + viewportTop: viewportTopCssPx, + }, + } = this.props; + + const outerWidthDevPx = Math.round(outerWidthCssPx * cssToDeviceScale); + const insetLeftDevPx = Math.round(insetLeftCssPx * cssToDeviceScale); + const insetRightDevPx = Math.round(insetRightCssPx * cssToDeviceScale); + const viewportWidthDevPx = + outerWidthDevPx - insetLeftDevPx - insetRightDevPx; + + const committedRangeTimeDuration = + committedRangeEndTimestamp - committedRangeStartTimestamp; + const viewportWidthFraction = viewportRightFraction - viewportLeftFraction; + const viewportWidthTimeDuration = + committedRangeTimeDuration * viewportWidthFraction; + + const insetLeftTimestamp = + committedRangeStartTimestamp + + viewportLeftFraction * committedRangeTimeDuration; + const devPxPerMs = + viewportWidthTimeDuration !== 0 + ? viewportWidthDevPx / viewportWidthTimeDuration + : 0; + const outerRightTimestamp = + insetLeftTimestamp + + (devPxPerMs !== 0 + ? (viewportWidthDevPx + insetRightDevPx) / devPxPerMs + : 0); + const viewportRightDevPx = insetLeftDevPx + viewportWidthDevPx; + + const viewportTopDevPx = Math.round(viewportTopCssPx * cssToDeviceScale); + const outerHeightDevPx = Math.round(outerHeightCssPx * cssToDeviceScale); + const rowHeightFractDevPx = rowHeightCssPx * cssToDeviceScale; + + const rowCount = this.props.markerTimingAndBuckets.length; + + const visibleRowsStart = Math.floor(viewportTopDevPx / rowHeightFractDevPx); + const visibleRowsEnd = Math.min( + Math.ceil((viewportTopDevPx + outerHeightDevPx) / rowHeightFractDevPx) + + 1, + rowCount + ); + + return { + outerWidthDevPx, + outerHeightDevPx, + + cssToDeviceScale, + + insetLeftTimestamp, + insetLeftDevPx, + devPxPerMs, + outerRightTimestamp, + viewportRightDevPx, + + viewportTopDevPx, + rowHeightFractDevPx, + + visibleRowsStart, + visibleRowsEnd, + }; + } + + drawCanvas = ( + ctx: CanvasRenderingContext2D, + scale: ChartCanvasScale, + hoverInfo: ChartCanvasHoverInfo + ) => { + const { cssToDeviceScale, cssToUserScale } = scale; + if (cssToDeviceScale !== cssToUserScale) { throw new Error( - 'StackChartCanvasImpl sets scaleCtxToCssPixels={true}, so canvas user space units should be equal to CSS pixels.' + 'MarkerChartCanvasImpl sets scaleCtxToCssPixels={false}, so canvas user space units should be equal to device pixels.' ); } + // Set the font before creating the text renderer. The font property resets + // automatically whenever the canvas size is changed, so we set it on every + // call. + ctx.font = `${FONT_SIZE_CSS * cssToDeviceScale}px sans-serif`; + + const coordinates = this._getViewportCoordinates(cssToDeviceScale); + const dirtyRows = this._computeDirtyRows(coordinates, hoverInfo); + // Convert CssPixels to Stack Depth - const startRow = Math.floor(viewportTop / rowHeight); - const endRow = Math.min( - Math.ceil(viewportBottom / rowHeight), - markerTimingAndBuckets.length - ); - const markerIndexToTimingRow = this._getMarkerIndexToTimingRow( - markerTimingAndBuckets - ); - const rightClickedRow: number | void = - rightClickedMarkerIndex === null - ? undefined - : markerIndexToTimingRow[rightClickedMarkerIndex]; - let newRow: number | void = - hoveredMarker === null - ? undefined - : markerIndexToTimingRow[hoveredMarker]; - if ( - timelineTrackOrganization.type === 'active-tab' && - newRow === undefined && - hoveredLabel !== null - ) { - // If it's active tab view and we don't know the row yet, assign - // `hoveredLabel` if it's non-null. This is needed because we can hover - // the label and not the marker. That way we are making sure that we - // select the correct row. - newRow = hoveredLabel; - } + const startRow = coordinates.visibleRowsStart; + const endRow = coordinates.visibleRowsEnd; // Common properties that won't be changed later. - ctx.lineWidth = 1; - - if (isHoveredOnlyDifferent) { - // Only re-draw the rows that have been updated if only the hovering information - // is different. - let oldRow: number | void = - prevHoveredMarker === null - ? undefined - : markerIndexToTimingRow[prevHoveredMarker]; - if ( - timelineTrackOrganization.type === 'active-tab' && - oldRow === undefined && - prevHoveredLabel !== null - ) { - // If it's active tab view and we don't know the row yet, assign - // `prevHoveredLabel` if it's non-null. This is needed because we can - // hover the label and not the marker. That way we are making sure that - // previous hovered row is correct. - oldRow = prevHoveredLabel; - } + ctx.lineWidth = 1 * cssToDeviceScale; - if (newRow !== undefined) { - this.clearRow(ctx, newRow); - this.highlightRow(ctx, newRow); - this.drawMarkers(ctx, hoveredMarker, newRow, newRow + 1); - if (hoveredLabel === null) { - this.drawSeparatorsAndLabels(ctx, newRow, newRow + 1, true); - } - } - if (oldRow !== undefined && oldRow !== newRow) { - if (oldRow !== rightClickedRow) { - this.clearRow(ctx, oldRow); - } - this.drawMarkers(ctx, hoveredMarker, oldRow, oldRow + 1); - this.drawSeparatorsAndLabels(ctx, oldRow, oldRow + 1); - } - } else { + if (dirtyRows === null) { + // All rows need to be redrawn. + const { outerWidthDevPx, outerHeightDevPx } = coordinates; ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, containerWidth, containerHeight); - if (rightClickedRow !== undefined) { - this.highlightRow(ctx, rightClickedRow); - } else if (newRow !== undefined) { - this.highlightRow(ctx, newRow); + ctx.fillRect(0, 0, outerWidthDevPx, outerHeightDevPx); + } + + const { hoveredItem } = hoverInfo; + const hoveredMarker = hoveredItem ? hoveredItem.marker : null; + const hoveredMarkerIndex = hoveredMarker ? hoveredMarker.markerIndex : null; + const hoveredMarkerRow = hoveredMarker ? hoveredMarker.rowIndex : null; + + for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { + if (dirtyRows !== null && !dirtyRows.has(rowIndex)) { + continue; } - this.drawMarkers(ctx, hoveredMarker, startRow, endRow); - this.drawSeparatorsAndLabels(ctx, startRow, endRow); + const hasHoveredMarker = rowIndex === hoveredMarkerRow; + this.drawRow( + ctx, + coordinates, + rowIndex, + hasHoveredMarker ? hoveredMarkerIndex : null + ); } }; - highlightRow = (ctx, row) => { + drawRow( + ctx: CanvasRenderingContext2D, + coordinates: ViewportCoordinates, + rowIndex: RowIndex, + hoveredMarkerIndex: MarkerIndex | null + ) { const { - rowHeight, - viewport: { viewportTop, containerWidth }, - } = this.props; + outerWidthDevPx, + cssToDeviceScale, + insetLeftDevPx, + viewportRightDevPx, + viewportTopDevPx, + rowHeightFractDevPx, + } = coordinates; + + const separatorThicknessDevPx = Math.round(1 * cssToDeviceScale); + + const rowTopDevPx = Math.round( + rowIndex * rowHeightFractDevPx - viewportTopDevPx + ); + const rowBottomDevPx = Math.round( + (rowIndex + 1) * rowHeightFractDevPx - viewportTopDevPx + ); + const rowHeightDevPx = rowBottomDevPx - rowTopDevPx; + const rowContentHeightDevPx = rowHeightDevPx - separatorThicknessDevPx; + const bottomSeparatorY = rowTopDevPx + rowContentHeightDevPx; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, rowTopDevPx, outerWidthDevPx, rowHeightDevPx); + if (hoveredMarkerIndex !== null) { + ctx.fillStyle = 'rgba(40, 122, 169, 0.2)'; + ctx.fillRect(0, rowTopDevPx, outerWidthDevPx, rowHeightDevPx); + } + + const rowCoordinates = { + rowTopDevPx, + rowBottomDevPx, + rowHeightDevPx, + rowContentHeightDevPx, + }; + + const row = this.props.markerTimingAndBuckets[rowIndex]; + + if (typeof row === 'string') { + this._drawBucketHeader(ctx, coordinates, rowCoordinates, row); + return; + } - ctx.fillStyle = 'rgba(40, 122, 169, 0.2)'; + if (row.isFirstRowOfName) { + let markerCount = null; + if (hoveredMarkerIndex !== null) { + // This row is hovered. Draw the marker count. + markerCount = this.countMarkersInBucketStartingAtRow(rowIndex); + } + this._drawRowLabel(ctx, coordinates, rowCoordinates, row, markerCount); + } + + // Draw separators + ctx.fillStyle = GREY_20; + // draw vertical separator + // if (timelineTrackOrganization.type !== 'active-tab') { + // Don't draw the separator on the right side if we are in the active tab. ctx.fillRect( - 0, // To include the labels also - row * rowHeight - viewportTop, - containerWidth, - rowHeight - 1 // Subtract 1 for borders. + insetLeftDevPx - separatorThicknessDevPx, + rowTopDevPx, + separatorThicknessDevPx, + rowHeightDevPx ); - }; + // } - /** - * When re-drawing markers, it's helpful to isolate the operations to a single row - * in order to make the drawing faster. This memoized function computes the map - * of a marker index to its row in the marker timing. - */ - _getMarkerIndexToTimingRow = memoize( - ( - markerTimingAndBuckets: MarkerTimingAndBuckets - ): Uint32Array /* like Map */ => { - const markerIndexToTimingRow = new Uint32Array( - this.props.markerListLength + // draw bottom border + ctx.fillRect( + 0, + bottomSeparatorY, + viewportRightDevPx, + separatorThicknessDevPx + ); + + // draw markers + + // The clip operation forbids drawing in the label zone. + ctx.save(); + ctx.beginPath(); + ctx.rect( + insetLeftDevPx, + rowTopDevPx, + outerWidthDevPx - insetLeftDevPx, + rowHeightDevPx + ); + ctx.clip(); + + if (row.instantOnly) { + this._drawInstantMarkersInRow( + ctx, + coordinates, + rowCoordinates, + row, + hoveredMarkerIndex ); - for ( - let rowIndex = 0; - rowIndex < markerTimingAndBuckets.length; - rowIndex++ - ) { - const markerTiming = markerTimingAndBuckets[rowIndex]; - if (typeof markerTiming === 'string') { - continue; - } - for ( - let timingIndex = 0; - timingIndex < markerTiming.length; - timingIndex++ - ) { - markerIndexToTimingRow[markerTiming.index[timingIndex]] = rowIndex; - } - } - return markerIndexToTimingRow; - }, - { cache: new WeakMap() } - ); - - // Note: we used a long argument list instead of an object parameter on - // purpose, to reduce GC pressure while drawing. - drawOneMarker( - ctx: CanvasRenderingContext2D, - x: CssPixels, - y: CssPixels, - w: CssPixels, - h: CssPixels, - isInstantMarker: boolean, - markerIndex: MarkerIndex, - isHighlighted: boolean = false - ) { - if (isInstantMarker) { - this.drawOneInstantMarker(ctx, x, y, h, isHighlighted); } else { - this.drawOneIntervalMarker(ctx, x, y, w, h, markerIndex, isHighlighted); + this._drawIntervalMarkersInRow( + ctx, + coordinates, + rowCoordinates, + row, + hoveredMarkerIndex + ); } + + ctx.restore(); } - drawOneIntervalMarker( + _drawIntervalMarkersInRow( ctx: CanvasRenderingContext2D, - x: CssPixels, - y: CssPixels, - w: CssPixels, - h: CssPixels, - markerIndex: MarkerIndex, - isHighlighted: boolean + coordinates: ViewportCoordinates, + rowCoordinates: RowCoordinates, + row: MarkerTiming, + hoveredMarkerIndex: MarkerIndex | null ) { - const { marginLeft, getMarkerLabel } = this.props; - - if (w <= 2) { - // This is an interval marker small enough that if we drew it as a - // rectangle, we wouldn't see any inside part. With a width of 2 pixels, - // the rectangle-with-borders would only be borders. With less than 2 - // pixels, the borders would collapse. - // So let's draw it directly as a rect. - ctx.fillStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; - - // w is rounded in the caller, but let's make sure it's at least 1. - w = Math.max(w, 1); - ctx.fillRect(x, y + 1, w, h - 2); - } else { - // This is a bigger interval marker. - const textMeasurement = this._getTextMeasurement(ctx); + const { rightClickedMarkerIndex, selectedMarkerIndex, getMarkerLabel } = this.props; + const { + insetLeftTimestamp, + devPxPerMs, + outerRightTimestamp, + cssToDeviceScale, + insetLeftDevPx, + } = coordinates; + const { rowTopDevPx: y, rowContentHeightDevPx: h } = rowCoordinates; + + const textOffsetXDevPx = Math.round( + TEXT_OFFSET_START_CSS * cssToDeviceScale + ); + const textOffsetYDevPx = Math.round(TEXT_OFFSET_TOP_CSS * cssToDeviceScale); + const textMeasurement = this._getTextMeasurement(ctx, cssToDeviceScale); + + let prevMarkerRightDevPx = 0; + for (let i = 0; i < row.length; i++) { + const startTimestamp = row.start[i]; + const endTimestamp = row.end[i]; + if ( + endTimestamp <= insetLeftTimestamp || + startTimestamp >= outerRightTimestamp + ) { + continue; + } + + const boxLeftFractDevPx = + insetLeftDevPx + (startTimestamp - insetLeftTimestamp) * devPxPerMs; + const boxRightFractDevPx = + insetLeftDevPx + (endTimestamp - insetLeftTimestamp) * devPxPerMs; + let boxLeftDevPx = snapValueToMultipleOf(boxLeftFractDevPx, 2); + let boxRightDevPx = snapValueToMultipleOf(boxRightFractDevPx, 2); + + if (boxRightDevPx === boxLeftDevPx) { + boxRightDevPx = boxLeftDevPx + 2; + } + + if (boxLeftDevPx < prevMarkerRightDevPx) { + boxLeftDevPx = prevMarkerRightDevPx + } + if (boxRightDevPx <= prevMarkerRightDevPx) { + continue; + } + prevMarkerRightDevPx = boxRightDevPx; + + const markerIndex = row.index[i]; + const isHighlighted = + markerIndex === hoveredMarkerIndex || + markerIndex === selectedMarkerIndex || + markerIndex === rightClickedMarkerIndex; + + const boxWidthDevPx = boxRightDevPx - boxLeftDevPx; + const x = boxLeftDevPx; + const w = boxWidthDevPx - 0.8; ctx.fillStyle = isHighlighted ? BLUE_60 : '#8ac4ff'; - ctx.strokeStyle = isHighlighted ? BLUE_80 : MARKER_BORDER_COLOR; - - ctx.beginPath(); - - // We want the rectangle to have a clear margin, that's why we increment y - // and decrement h (twice, for both margins). - // We also add "0.5" more so that the stroke is properly on a pixel. - // Indeed strokes are drawn on both sides equally, so half a pixel on each - // side in this case. - ctx.rect( - x + 0.5, // + 0.5 for the stroke - y + 1 + 0.5, // + 1 for the top margin, + 0.5 for the stroke - w - 1, // - 1 to account for left and right strokes. - h - 2 - 1 // + 2 accounts for top and bottom margins, + 1 accounts for top and bottom strokes - ); - ctx.fill(); - ctx.stroke(); + ctx.fillRect(x, y, w, h); - // Draw the text label - // TODO - L10N RTL. - // Constrain the x coordinate to the leftmost area. - const x2: CssPixels = - x < marginLeft ? marginLeft + TEXT_OFFSET_START : x + TEXT_OFFSET_START; - const visibleWidth = x < marginLeft ? w - marginLeft + x : w; - const w2: CssPixels = visibleWidth - 2 * TEXT_OFFSET_START; + const visibleX = Math.max(x, insetLeftDevPx); + const visibleWidth = boxRightDevPx - visibleX; + const x2 = x + textOffsetXDevPx; + const w2 = visibleWidth - 2 * textOffsetXDevPx; if (w2 > textMeasurement.minWidth) { - const fittedText = textMeasurement.getFittedText( - getMarkerLabel(markerIndex), - w2 - ); + const label = getMarkerLabel(markerIndex); + const fittedText = textMeasurement.getFittedText(label, w2); if (fittedText) { ctx.fillStyle = isHighlighted ? 'white' : 'black'; - ctx.fillText(fittedText, x2, y + TEXT_OFFSET_TOP); + ctx.fillText(fittedText, x2, y + textOffsetYDevPx); } } } } + _drawInstantMarkersInRow( + ctx: CanvasRenderingContext2D, + coordinates: ViewportCoordinates, + rowCoordinates: RowCoordinates, + row: MarkerTiming, + hoveredMarkerIndex: MarkerIndex | null + ) { + const { rightClickedMarkerIndex, selectedMarkerIndex } = this.props; + const { + insetLeftTimestamp, + devPxPerMs, + outerRightTimestamp, + insetLeftDevPx, + } = coordinates; + const { rowTopDevPx: y, rowContentHeightDevPx: h } = rowCoordinates; + + const highlightedMarkers = []; + for (let i = 0; i < row.length; i++) { + const timestamp = row.start[i]; + if (timestamp < insetLeftTimestamp || timestamp >= outerRightTimestamp) { + continue; + } + + const markerIndex = row.index[i]; + const isHighlighted = + markerIndex === hoveredMarkerIndex || + markerIndex === selectedMarkerIndex || + markerIndex === rightClickedMarkerIndex; + const x = insetLeftDevPx + (timestamp - insetLeftTimestamp) * devPxPerMs; + if (isHighlighted) { + highlightedMarkers.push(x); + } else { + this._drawOneInstantMarker(ctx, x, y, h, false); + } + } + for (const x of highlightedMarkers) { + this._drawOneInstantMarker(ctx, x, y, h, true); + } + } + // x indicates the center of this marker // y indicates the top of the row // h indicates the available height in the row - drawOneInstantMarker( + _drawOneInstantMarker( ctx: CanvasRenderingContext2D, x: CssPixels, y: CssPixels, @@ -391,168 +616,25 @@ class MarkerChartCanvasImpl extends React.PureComponent { ctx.stroke(); } - drawMarkers( - ctx: CanvasRenderingContext2D, - hoveredItem: MarkerIndex | null, - startRow: number, - endRow: number - ) { - const { - rangeStart, - rangeEnd, - markerTimingAndBuckets, - rowHeight, - marginLeft, - marginRight, - rightClickedMarkerIndex, - selectedMarkerIndex, - viewport: { - containerWidth, - containerHeight, - viewportLeft, - viewportRight, - viewportTop, - }, - } = this.props; - - const { devicePixelRatio } = window; - const markerContainerWidth = containerWidth - marginLeft - marginRight; - - const rangeLength: Milliseconds = rangeEnd - rangeStart; - const viewportLength: UnitIntervalOfProfileRange = - viewportRight - viewportLeft; - - // Decide which samples to actually draw - const timeAtViewportLeft: Milliseconds = - rangeStart + rangeLength * viewportLeft; - const timeAtViewportRightPlusMargin: Milliseconds = - rangeStart + - rangeLength * viewportRight + - // This represents the amount of seconds in the right margin: - marginRight * ((viewportLength * rangeLength) / markerContainerWidth); - - const highlightedMarkers: MarkerDrawingInformation[] = []; - - // We'll restore the context at the end, so that the clip region will be - // removed. - ctx.save(); - // The clip operation forbids drawing in the label zone. - ctx.beginPath(); - ctx.rect(marginLeft, 0, markerContainerWidth, containerHeight); - ctx.clip(); - - // 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 = markerTimingAndBuckets[rowIndex]; - - if (!markerTiming || typeof markerTiming === 'string') { - // This marker timing either didn't exist, or was a bucket. - continue; - } - - // Track the last drawn marker X position, so that we can avoid overdrawing. - let previousMarkerDrawnAtX: number | null = null; - - for (let i = 0; i < markerTiming.length; i++) { - const startTimestamp = markerTiming.start[i]; - const endTimestamp = markerTiming.end[i]; - const isInstantMarker = startTimestamp === endTimestamp; - - // Only draw samples that are in bounds. - if ( - endTimestamp >= timeAtViewportLeft && - startTimestamp < timeAtViewportRightPlusMargin - ) { - const startTime: UnitIntervalOfProfileRange = - (startTimestamp - rangeStart) / rangeLength; - const endTime: UnitIntervalOfProfileRange = - (endTimestamp - rangeStart) / rangeLength; - - let x: CssPixels = - ((startTime - viewportLeft) * markerContainerWidth) / - viewportLength + - marginLeft; - const y: CssPixels = rowIndex * rowHeight - viewportTop; - let w: CssPixels = - ((endTime - startTime) * markerContainerWidth) / viewportLength; - const h: CssPixels = rowHeight - 1; - - x = Math.round(x * devicePixelRatio) / devicePixelRatio; - w = Math.round(w * devicePixelRatio) / devicePixelRatio; - - const markerIndex = markerTiming.index[i]; - - const isHighlighted = - rightClickedMarkerIndex === markerIndex || - hoveredItem === markerIndex || - selectedMarkerIndex === markerIndex; - - if (isHighlighted) { - highlightedMarkers.push({ - x, - y, - w, - h, - isInstantMarker, - markerIndex, - }); - } else if ( - // Always render non-dot markers and markers that are larger than - // one pixel. - w > 1 || - // Do not render dot markers that occupy the same pixel, as this can take - // a lot of time, and not change the visual display of the chart. - x !== previousMarkerDrawnAtX - ) { - previousMarkerDrawnAtX = x; - this.drawOneMarker(ctx, x, y, w, h, isInstantMarker, markerIndex); - } - } - } - } - - // We draw highlighted markers after the normal markers so that they stand - // out more. - highlightedMarkers.forEach((highlightedMarker) => { - this.drawOneMarker( - ctx, - highlightedMarker.x, - highlightedMarker.y, - highlightedMarker.w, - highlightedMarker.h, - highlightedMarker.isInstantMarker, - highlightedMarker.markerIndex, - true /* isHighlighted */ - ); - }); - - ctx.restore(); - } - - clearRow(ctx: CanvasRenderingContext2D, rowIndex: number) { - const { - rowHeight, - viewport: { viewportTop, containerWidth }, - } = this.props; - - ctx.fillStyle = '#fff'; - ctx.fillRect( - 0, - rowIndex * rowHeight - viewportTop, - containerWidth, - rowHeight - 1 // Subtract 1 for borders. - ); - } - /** * Lazily create the text measurement tool, as a valid 2d rendering context must * exist before it is created. */ - _getTextMeasurement(ctx: CanvasRenderingContext2D): TextMeasurement { - if (!this._textMeasurement) { + _getTextMeasurement( + ctx: CanvasRenderingContext2D, + cssToDeviceScale: number + ): TextMeasurement { + // Ensure the text measurement tool is created, since this is the first time + // this class has access to a ctx. We also need to recreate it when the scale + // changes because we are working with device coordinates. + if ( + !this._textMeasurement || + this._textMeasurementCssToDeviceScale !== cssToDeviceScale + ) { this._textMeasurement = new TextMeasurement(ctx); + this._textMeasurementCssToDeviceScale = cssToDeviceScale; } + return this._textMeasurement; } @@ -608,100 +690,84 @@ class MarkerChartCanvasImpl extends React.PureComponent { return count; } - drawSeparatorsAndLabels( + _drawBucketHeader( ctx: CanvasRenderingContext2D, - startRow: number, - endRow: number, - drawMarkerCount: boolean = false + coordinates: ViewportCoordinates, + rowCoordinates: RowCoordinates, + bucketName: string ) { - const { - markerTimingAndBuckets, - rowHeight, - marginLeft, - marginRight, - timelineTrackOrganization, - viewport: { viewportTop, containerWidth, containerHeight }, - } = this.props; - - const usefulContainerWidth = containerWidth - marginRight; - - // Draw separators + const { viewportRightDevPx, insetLeftDevPx, cssToDeviceScale } = + coordinates; + const { rowTopDevPx, rowBottomDevPx, rowHeightDevPx } = rowCoordinates; + const separatorThicknessDevPx = Math.round(1 * cssToDeviceScale); + // Draw the backgound. ctx.fillStyle = GREY_20; - if (timelineTrackOrganization.type !== 'active-tab') { - // Don't draw the separator on the right side if we are in the active tab. - ctx.fillRect(marginLeft - 1, 0, 1, containerHeight); - } - for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { - // `- 1` at the end, because the top separator is not drawn in the canvas, - // it's drawn using CSS' border property. And canvas positioning is 0-based. - const y = (rowIndex + 1) * rowHeight - viewportTop - 1; - ctx.fillRect(0, y, usefulContainerWidth, 1); - } + ctx.fillRect(0, rowTopDevPx, viewportRightDevPx, rowHeightDevPx); - const textMeasurement = this._getTextMeasurement(ctx); + // Draw the borders. + ctx.fillStyle = GREY_30; + const bottomSeparatorY = rowBottomDevPx - separatorThicknessDevPx; + const prevRowBottomSeparatorY = rowTopDevPx - separatorThicknessDevPx; + ctx.fillRect( + 0, + prevRowBottomSeparatorY, + viewportRightDevPx, + separatorThicknessDevPx + ); + ctx.fillRect( + 0, + bottomSeparatorY, + viewportRightDevPx, + separatorThicknessDevPx + ); - // Draw the marker names in the left margin. + // Draw the text. ctx.fillStyle = '#000000'; - for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { - const markerTiming = markerTimingAndBuckets[rowIndex]; - if (typeof markerTiming === 'string') { - continue; - } - // Draw the marker name. - const { name } = markerTiming; - if (rowIndex > 0 && name === markerTimingAndBuckets[rowIndex - 1].name) { - continue; - } - - const y = rowIndex * rowHeight - viewportTop; + const labelPaddingDevPx = Math.round(LABEL_PADDING_CSS * cssToDeviceScale); + const textOffsetYDevPx = Math.round(TEXT_OFFSET_TOP_CSS * cssToDeviceScale); + ctx.fillText( + bucketName, + insetLeftDevPx + labelPaddingDevPx, + rowTopDevPx + textOffsetYDevPx + ); + } - const countString = drawMarkerCount - ? ` (${this.countMarkersInBucketStartingAtRow(rowIndex)})` + _drawRowLabel( + ctx: CanvasRenderingContext2D, + coordinates: ViewportCoordinates, + rowCoordinates: RowCoordinates, + row: MarkerTiming, + markerCountIfShouldBeDrawn: number | null + ) { + const { insetLeftDevPx, cssToDeviceScale } = coordinates; + const { rowTopDevPx: y } = rowCoordinates; + const countString = + markerCountIfShouldBeDrawn !== null + ? ` (${markerCountIfShouldBeDrawn})` : ''; - // Even when it's on active tab view, have a hard cap on the text length. - const fittedText = - textMeasurement.getFittedText( - name, - TIMELINE_MARGIN_LEFT - - LABEL_PADDING - - (countString ? textMeasurement.getTextWidth(countString) : 0) - ) + countString; - - if (timelineTrackOrganization.type === 'active-tab') { - // Draw the text backgound for active tab. - ctx.fillStyle = '#ffffffbf'; // white with 75% opacity - const textWidth = textMeasurement.getTextWidth(fittedText); - ctx.fillRect(0, y, textWidth + LABEL_PADDING * 2, rowHeight); - - // Set the fill style back for text. - ctx.fillStyle = '#000000'; - } - - ctx.fillText(fittedText, LABEL_PADDING, y + TEXT_OFFSET_TOP); - } - - // Draw the bucket names. - for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) { - // Get the timing information for a row of stack frames. - const bucketName = markerTimingAndBuckets[rowIndex]; - if (typeof bucketName !== 'string') { - continue; - } - const y = rowIndex * rowHeight - viewportTop; - - // Draw the backgound. - ctx.fillStyle = GREY_20; - ctx.fillRect(0, y - 1, usefulContainerWidth, rowHeight); + const textMeasurement = this._getTextMeasurement(ctx, cssToDeviceScale); + const countStringWidthDevPx = countString + ? textMeasurement.getTextWidth(countString) + : 0; + const labelPaddingDevPx = Math.round(LABEL_PADDING_CSS * cssToDeviceScale); + const textOffsetYDevPx = Math.round(TEXT_OFFSET_TOP_CSS * cssToDeviceScale); + + const name = row.name; + const fittedLabel = textMeasurement.getFittedText( + name, + insetLeftDevPx - labelPaddingDevPx - countStringWidthDevPx + ); + const fittedLabelAndCount = fittedLabel + countString; - // Draw the borders./* - ctx.fillStyle = GREY_30; - ctx.fillRect(0, y - 1, usefulContainerWidth, 1); - ctx.fillRect(0, y + rowHeight - 1, usefulContainerWidth, 1); + // if (timelineTrackOrganization.type === 'active-tab') { + // // Draw the text backgound for active tab. + // ctx.fillStyle = '#ffffffbf'; // white with 75% opacity + // const textWidth = textMeasurement.getTextWidth(fittedText); + // ctx.fillRect(0, y, textWidth + LABEL_PADDING * 2, rowHeight); + // } - // Draw the text. - ctx.fillStyle = '#000000'; - ctx.fillText(bucketName, LABEL_PADDING + marginLeft, y + TEXT_OFFSET_TOP); - } + ctx.fillStyle = '#000000'; + ctx.fillText(fittedLabelAndCount, labelPaddingDevPx, y + textOffsetYDevPx); } hitTest = (x: CssPixels, y: CssPixels): HoveredMarkerChartItems | null => { @@ -717,13 +783,13 @@ class MarkerChartCanvasImpl extends React.PureComponent { } = this.props; // Note: we may want to increase this value to hit markers that are farther. - const dotRadius: CssPixels = MARKER_DOT_RADIUS * rowHeight; + const dotRadius: CssPixels = MARKER_DOT_RADIUS_CSS * rowHeight; if (x < marginLeft - dotRadius) { return null; } let markerIndex = null; - let rowIndexOfLabel = null; + let labelRow = null; const markerContainerWidth = containerWidth - marginLeft - marginRight; const rangeLength: Milliseconds = rangeEnd - rangeStart; @@ -818,24 +884,26 @@ class MarkerChartCanvasImpl extends React.PureComponent { this._textMeasurement ) { const textWidth = this._textMeasurement.getTextWidth(markerTiming.name); - if (x < textWidth + LABEL_PADDING * 2) { - rowIndexOfLabel = rowIndex; + if (x < textWidth + LABEL_PADDING_CSS * 2) { + labelRow = rowIndex; } } } - if (markerIndex === null && rowIndexOfLabel === null) { + if (markerIndex === null && labelRow === null) { // If both of them are null, return a null instead of `[null, null]`. // That's because shared canvas component only understands that. return null; } + const marker = markerIndex !== null ? { markerIndex, rowIndex } : null; + // Yes, we are returning a new object all the time when we do the hit testing. // I can hear you say "How does equality check work for old and new hovered // items then?". Well, on the shared canvas component we have a function // called `hoveredItemsAreEqual` that shallowly checks for equality of // objects and arrays. So it's safe to return a new object all the time. - return { markerIndex, rowIndexOfLabel }; + return { marker, labelRow }; }; onMouseMove = (event: { nativeEvent: MouseEvent }) => { @@ -870,7 +938,8 @@ class MarkerChartCanvasImpl extends React.PureComponent { }; onDoubleClickMarker = (hoveredItems: HoveredMarkerChartItems | null) => { - const markerIndex = hoveredItems === null ? null : hoveredItems.markerIndex; + const markerIndex = + hoveredItems === null ? null : (hoveredItems.marker?.markerIndex ?? null); if (markerIndex === null) { return; } @@ -892,24 +961,27 @@ class MarkerChartCanvasImpl extends React.PureComponent { }; onSelectItem = (hoveredItems: HoveredMarkerChartItems | null) => { - const markerIndex = hoveredItems === null ? null : hoveredItems.markerIndex; + const markerIndex = + hoveredItems === null ? null : (hoveredItems.marker?.markerIndex ?? null); const { changeSelectedMarker, threadsKey } = this.props; changeSelectedMarker(threadsKey, markerIndex, { source: 'pointer' }); }; onRightClickMarker = (hoveredItems: HoveredMarkerChartItems | null) => { - const markerIndex = hoveredItems === null ? null : hoveredItems.markerIndex; + const markerIndex = + hoveredItems === null ? null : (hoveredItems.marker?.markerIndex ?? null); const { changeRightClickedMarker, threadsKey } = this.props; changeRightClickedMarker(threadsKey, markerIndex); }; - getHoveredMarkerInfo = ({ - markerIndex, - }: HoveredMarkerChartItems): React.Node => { - if (!this.props.shouldDisplayTooltips() || markerIndex === null) { + getHoveredMarkerInfo = ( + hoveredItems: HoveredMarkerChartItems + ): React.Node => { + if (!this.props.shouldDisplayTooltips() || hoveredItems.marker === null) { return null; } + const markerIndex = hoveredItems.marker.markerIndex; const marker = this.props.getMarker(markerIndex); return ( { containerWidth={containerWidth} containerHeight={containerHeight} isDragging={isDragging} - scaleCtxToCssPixels={true} + scaleCtxToCssPixels={false} onSelectItem={this.onSelectItem} onDoubleClickItem={this.onDoubleClickMarker} onRightClick={this.onRightClickMarker} diff --git a/src/components/shared/chart/Viewport.js b/src/components/shared/chart/Viewport.js index b4269bf92e..354ee41c6b 100644 --- a/src/components/shared/chart/Viewport.js +++ b/src/components/shared/chart/Viewport.js @@ -125,11 +125,19 @@ const CTRL_KEYMAP: { [string]: NavigationKey } = { // These viewport values (most of which are computed dynamically by // the HOC) are passed into the props of the wrapped component. export type Viewport = {| + // The outer width. marginLeft and marginRight are inside of the outer width. +containerWidth: CssPixels, + // The viewport height. +containerHeight: CssPixels, + // When fully zoomed out, this is 0.0. + // Corresponds to what's drawn at marginLeft from the left edge of containerWidth. +viewportLeft: UnitIntervalOfProfileRange, + // When fully zoomed out, this is 1.0. + // Corresponds to what's drawn at marginRight from the right edge of containerWidth. +viewportRight: UnitIntervalOfProfileRange, + // The vertical scroll position. +viewportTop: CssPixels, + // This is viewportTop + containerHeight. +viewportBottom: CssPixels, +isDragging: boolean, +moveViewport: (CssPixels, CssPixels) => void, diff --git a/src/profile-logic/marker-timing.js b/src/profile-logic/marker-timing.js index 9bf6b13f0d..beee92ec65 100644 --- a/src/profile-logic/marker-timing.js +++ b/src/profile-logic/marker-timing.js @@ -126,6 +126,7 @@ export function getMarkerTiming( name: markerLineName, bucket: bucketName, instantOnly, + isFirstRowOfName: false, length: 0, }); @@ -230,6 +231,20 @@ export function getMarkerTiming( return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1; }); + // Compute isFirstRowOfName for all rows. + let prevRowName = null; + for (let i = 0; i < allMarkerTimings.length; i++) { + const markerTiming = allMarkerTimings[i]; + if (typeof markerTiming === 'string') { + prevRowName = null; + continue; + } + + const rowName = markerTiming.name; + markerTiming.isFirstRowOfName = rowName !== prevRowName; + prevRowName = rowName; + } + return allMarkerTimings; } diff --git a/src/types/profile-derived.js b/src/types/profile-derived.js index c06774e777..601239c875 100644 --- a/src/types/profile-derived.js +++ b/src/types/profile-derived.js @@ -405,6 +405,7 @@ export type MarkerTiming = {| bucket: string, // True if this marker timing contains only instant markers. instantOnly: boolean, + isFirstRowOfName: boolean, length: number, |};