Skip to content

Commit

Permalink
Show sample tooltips on sample graph hover (#5298)
Browse files Browse the repository at this point in the history
Fixes #3363.

This was requested by multiple people because sometimes it's not clear
to users what the squares in the sample graph are. For example latest
feedback we got was in #5278. They also said that a tooltip would've
helped.
  • Loading branch information
canova authored Jan 15, 2025
2 parents c2af69b + ede1005 commit 73b2d84
Show file tree
Hide file tree
Showing 5 changed files with 420 additions and 67 deletions.
240 changes: 197 additions & 43 deletions src/components/shared/thread/SampleGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { getSampleIndexClosestToCenteredTime } from 'firefox-profiler/profile-lo
import { bisectionRight } from 'firefox-profiler/utils/bisect';
import { withSize } from 'firefox-profiler/components/shared/WithSize';
import { BLUE_70, BLUE_40 } from 'photon-colors';
import {
Tooltip,
MOUSE_OFFSET,
} from 'firefox-profiler/components/tooltip/Tooltip';
import { SampleTooltipContents } from 'firefox-profiler/components/shared/SampleTooltipContents';

import './SampleGraph.css';

Expand All @@ -20,8 +25,17 @@ import type {
IndexIntoSamplesTable,
Milliseconds,
SelectedState,
CssPixels,
TimelineType,
ImplementationFilter,
} from 'firefox-profiler/types';
import type { SizeProps } from 'firefox-profiler/components/shared/WithSize';
import type { CpuRatioInTimeRange } from './ActivityGraphFills';

export type HoveredPixelState = {|
+sample: IndexIntoSamplesTable | null,
+cpuRatioInTimeRange: CpuRatioInTimeRange | null,
|};

type Props = {|
+className: string,
Expand All @@ -33,13 +47,38 @@ type Props = {|
+categories: CategoryList,
+onSampleClick: (
event: SyntheticMouseEvent<>,
sampleIndex: IndexIntoSamplesTable
sampleIndex: IndexIntoSamplesTable | null
) => void,
+trackName: string,
+timelineType: TimelineType,
+implementationFilter: ImplementationFilter,
...SizeProps,
|};

type State = {
hoveredPixelState: null | HoveredPixelState,
mouseX: CssPixels,
mouseY: CssPixels,
};

type CanvasProps = {|
+className: string,
+thread: Thread,
+samplesSelectedStates: null | SelectedState[],
+interval: Milliseconds,
+rangeStart: Milliseconds,
+rangeEnd: Milliseconds,
+categories: CategoryList,
+trackName: string,
...SizeProps,
|};

export class ThreadSampleGraphImpl extends PureComponent<Props> {
/**
* This component controls the rendering of the canvas. Every render call through
* React triggers a new canvas render. Because of this, it's important to only pass
* in the props that are needed for the canvas draw call.
*/
class ThreadSampleGraphCanvas extends React.PureComponent<CanvasProps> {
_canvas: null | HTMLCanvasElement = null;
_takeCanvasRef = (canvas: HTMLCanvasElement | null) =>
(this._canvas = canvas);
Expand Down Expand Up @@ -103,8 +142,7 @@ export class ThreadSampleGraphImpl extends PureComponent<Props> {
canvas.width = Math.round(width * devicePixelRatio);
canvas.height = Math.round(height * devicePixelRatio);
const ctx = canvas.getContext('2d');
const range = [rangeStart, rangeEnd];
const rangeLength = range[1] - range[0];
const rangeLength = rangeEnd - rangeStart;
const xPixelsPerMs = canvas.width / rangeLength;
const trueIntervalPixelWidth = interval * xPixelsPerMs;
const multiplier = trueIntervalPixelWidth < 2.0 ? 1.2 : 1.0;
Expand All @@ -114,8 +152,8 @@ export class ThreadSampleGraphImpl extends PureComponent<Props> {
);
const drawnSampleWidth = Math.min(drawnIntervalWidth, 10);

const firstDrawnSampleTime = range[0] - drawnIntervalWidth / xPixelsPerMs;
const lastDrawnSampleTime = range[1];
const firstDrawnSampleTime = rangeStart - drawnIntervalWidth / xPixelsPerMs;
const lastDrawnSampleTime = rangeEnd;

const firstDrawnSampleIndex = bisectionRight(
thread.samples.time,
Expand Down Expand Up @@ -145,7 +183,7 @@ export class ThreadSampleGraphImpl extends PureComponent<Props> {
continue;
}
const xPos =
(sampleTime - range[0]) * xPixelsPerMs - drawnSampleWidth / 2;
(sampleTime - rangeStart) * xPixelsPerMs - drawnSampleWidth / 2;
let samplesBucket;
if (
samplesSelectedStates !== null &&
Expand Down Expand Up @@ -185,48 +223,164 @@ export class ThreadSampleGraphImpl extends PureComponent<Props> {
drawSamples(idleSamples, lighterBlue);
}

_onClick = (event: SyntheticMouseEvent<>) => {
const canvas = this._canvas;
if (canvas) {
const { rangeStart, rangeEnd, thread } = this.props;
const r = canvas.getBoundingClientRect();

const x = event.pageX - r.left;
const time = rangeStart + (x / r.width) * (rangeEnd - rangeStart);

const sampleIndex = getSampleIndexClosestToCenteredTime(
thread.samples,
time
);

if (thread.samples.stack[sampleIndex] === null) {
// If the sample index refers to a null sample, that sample
// has been filtered out and means that there was no stack bar
// drawn at the place where the user clicked. Do nothing here.
return;
}
render() {
const { trackName } = this.props;

return (
<InView onChange={this._observerCallback}>
<canvas
className={classNames(
`${this.props.className}Canvas`,
'threadSampleGraphCanvas'
)}
ref={this._takeCanvasRef}
>
<h2>Stack Graph for {trackName}</h2>
<p>This graph charts the stack height of each sample.</p>
</canvas>
</InView>
);
}
}

export class ThreadSampleGraphImpl extends PureComponent<Props, State> {
state = {
hoveredPixelState: null,
mouseX: 0,
mouseY: 0,
};

_onClick = (event: SyntheticMouseEvent<HTMLCanvasElement>) => {
const hoveredSample = this._getSampleAtMouseEvent(event);
this.props.onSampleClick(event, hoveredSample?.sample ?? null);
};

_onMouseLeave = () => {
this.setState({ hoveredPixelState: null });
};

this.props.onSampleClick(event, sampleIndex);
_onMouseMove = (event: SyntheticMouseEvent<HTMLCanvasElement>) => {
const canvas = event.currentTarget;
if (!canvas) {
return;
}

const rect = canvas.getBoundingClientRect();
this.setState({
hoveredPixelState: this._getSampleAtMouseEvent(event),
mouseX: event.pageX,
// Have the tooltip align to the bottom of the track.
mouseY: rect.bottom - MOUSE_OFFSET,
});
};

_getSampleAtMouseEvent(
event: SyntheticMouseEvent<HTMLCanvasElement>
): null | HoveredPixelState {
const canvas = event.currentTarget;
if (!canvas) {
return null;
}

const { rangeStart, rangeEnd, thread, interval } = this.props;
const r = canvas.getBoundingClientRect();

const x = event.nativeEvent.offsetX;
const time = rangeStart + (x / r.width) * (rangeEnd - rangeStart);

// These values are copied from the `drawCanvas` method to compute the
// `drawnSampleWidth` instead of extracting into a new function. Extracting
// into a new function is not really idea for performance reasons since we
// need these values for other values in `drawCanvas`.
const rangeLength = rangeEnd - rangeStart;
const xPixelsPerMs = r.width / rangeLength;
const trueIntervalPixelWidth = interval * xPixelsPerMs;
const multiplier = trueIntervalPixelWidth < 2.0 ? 1.2 : 1.0;
const drawnIntervalWidth = Math.max(
0.8,
trueIntervalPixelWidth * multiplier
);
const drawnSampleWidth = Math.min(drawnIntervalWidth, 10) / 2;

const maxTimeDistance = (drawnSampleWidth / 2 / r.width) * rangeLength;

const sampleIndex = getSampleIndexClosestToCenteredTime(
thread.samples,
time,
maxTimeDistance
);

if (sampleIndex === null) {
// No sample that is close enough found. Mouse doesn't hover any of the
// sample boxes in the sample graph.
return null;
}

if (thread.samples.stack[sampleIndex] === null) {
// If the sample index refers to a null sample, that sample
// has been filtered out and means that there was no stack bar
// drawn at the place where the user clicked. Do nothing here.
return null;
}

return {
sample: sampleIndex,
cpuRatioInTimeRange: null,
};
}

render() {
const { className, trackName } = this.props;
const {
className,
trackName,
timelineType,
categories,
implementationFilter,
thread,
interval,
rangeStart,
rangeEnd,
samplesSelectedStates,
width,
height,
} = this.props;
const { hoveredPixelState, mouseX, mouseY } = this.state;

return (
<div className={className}>
<InView onChange={this._observerCallback}>
<canvas
className={classNames(
`${this.props.className}Canvas`,
'threadSampleGraphCanvas'
)}
ref={this._takeCanvasRef}
onClick={this._onClick}
>
<h2>Stack Graph for {trackName}</h2>
<p>This graph charts the stack height of each sample.</p>
</canvas>
</InView>
<div
className={className}
onMouseMove={this._onMouseMove}
onMouseLeave={this._onMouseLeave}
onClick={this._onClick}
>
<ThreadSampleGraphCanvas
className={className}
trackName={trackName}
interval={interval}
thread={thread}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
samplesSelectedStates={samplesSelectedStates}
categories={categories}
width={width}
height={height}
/>

{hoveredPixelState === null ? null : (
<Tooltip mouseX={mouseX} mouseY={mouseY}>
<SampleTooltipContents
sampleIndex={hoveredPixelState.sample}
cpuRatioInTimeRange={
timelineType === 'cpu-category'
? hoveredPixelState.cpuRatioInTimeRange
: null
}
rangeFilteredThread={thread}
categories={categories}
implementationFilter={implementationFilter}
/>
</Tooltip>
)}
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/components/timeline/TrackThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ class TimelineTrackThreadImpl extends PureComponent<Props> {
samplesSelectedStates={samplesSelectedStates}
categories={categories}
onSampleClick={this._onSampleClick}
timelineType={timelineType}
implementationFilter={implementationFilter}
/>
) : null}
{isExperimentalCPUGraphsEnabled &&
Expand Down
52 changes: 31 additions & 21 deletions src/profile-logic/profile-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -2325,41 +2325,51 @@ export function getSampleIndexClosestToStartTime(
* uses the adjusted time. In this context, adjusted time means that `time` array
* represent the "center" of the sample, and raw values represent the "start" of
* the sample.
*
* Additionally it also checks for a maxTimeDistance threshold. If the time to
* sample distance is higher than that, it just returns null, which indicates
* no sample found.
*/
export function getSampleIndexClosestToCenteredTime(
samples: SamplesTable,
time: number
): IndexIntoSamplesTable {
time: number,
maxTimeDistance: number
): IndexIntoSamplesTable | null {
// Helper function to compute the "center" of a sample
const getCenterTime = (index: number): number => {
if (samples.weight) {
return samples.time[index] + Math.abs(samples.weight[index]) / 2;
}
return samples.time[index];
};

// Bisect to find the index of the first sample after the provided time.
const index = bisectionRight(samples.time, time);

if (index === 0) {
return 0;
// Time is before the first sample
return maxTimeDistance >= Math.abs(getCenterTime(0) - time) ? 0 : null;
}

if (index === samples.length) {
return samples.length - 1;
if (index === samples.time.length) {
// Time is after the last sample
const lastIndex = samples.time.length - 1;
return maxTimeDistance >= Math.abs(getCenterTime(lastIndex) - time)
? lastIndex
: null;
}

// Check the distance between the provided time and the center of the bisected sample
// and its predecessor.
const previousIndex = index - 1;
let distanceToThis;
let distanceToLast;

if (samples.weight) {
const samplesWeight = samples.weight;
const weight = Math.abs(samplesWeight[index]);
const previousWeight = Math.abs(samplesWeight[previousIndex]);
// Calculate distances to the centered time for both the current and previous samples
const distanceToNext = Math.abs(getCenterTime(index) - time);
const distanceToPrevious = Math.abs(getCenterTime(index - 1) - time);

distanceToThis = samples.time[index] + weight / 2 - time;
distanceToLast = time - (samples.time[previousIndex] + previousWeight / 2);
} else {
distanceToThis = samples.time[index] - time;
distanceToLast = time - samples.time[previousIndex];
if (distanceToNext <= distanceToPrevious) {
// If `distanceToNext` is closer but exceeds `maxTimeDistance`, return null.
return distanceToNext <= maxTimeDistance ? index : null;
}

return distanceToThis < distanceToLast ? index : index - 1;
// Otherwise, `distanceToPrevious` is closer. Again check if it exceeds `maxTimeDistance`.
return distanceToPrevious <= maxTimeDistance ? index - 1 : null;
}

export function getFriendlyThreadName(
Expand Down
Loading

0 comments on commit 73b2d84

Please sign in to comment.