Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/actions/profile-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ export function changeSelectedCallNode(
};
}

/**
* Zoom in a call node. This action is used when the user clicks on a call node in
* the flame chart panel.
*/
export function changeZoomedInCallNode(
zoomedInCallNodePath: CallNodePath | null
): Action {
return {
type: 'CHANGE_ZOOMED_IN_CALL_NODE',
zoomedInCallNodePath,
};
}

/**
* This action is used when the user right clicks on a call node (in panels such
* as the call tree, the flame chart, or the stack chart). It's especially used
Expand Down
14 changes: 14 additions & 0 deletions src/app-logic/url-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ type Query = BaseQuery & {
sourceViewIndex?: number;
assemblyView?: string;

// FlameGraph specific
zoomedInNode?: string;

// StackChart specific
showUserTimings?: null | undefined;
sameWidths?: null | undefined;
Expand Down Expand Up @@ -338,6 +341,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
query.invertCallstack = urlState.profileSpecific.invertCallstack
? null
: undefined;
if (urlState.profileSpecific.zoomedInCallNodePath !== null) {
query.zoomedInNode = encodeUintArrayForUrlComponent(
urlState.profileSpecific.zoomedInCallNodePath
);
}
if (
selectedThreadsKey !== null &&
urlState.profileSpecific.transforms[selectedThreadsKey]
Expand Down Expand Up @@ -525,6 +533,11 @@ export function stateFromLocation(
}
}

const zoomedInCallNodePath: CallNodePath | null =
selectedThreadsKey !== null && query.zoomedInNode !== undefined
? decodeUintArrayFromUrlComponent(query.zoomedInNode)
: null;

// tabID is used for the tab selector that we have in our full view.
let tabID = null;
if (query.tabID && Number.isInteger(Number(query.tabID))) {
Expand Down Expand Up @@ -614,6 +627,7 @@ export function stateFromLocation(
? query.hiddenThreads.split('-').map((index) => Number(index))
: null,
selectedMarkers,
zoomedInCallNodePath,
},
};
}
Expand Down
153 changes: 145 additions & 8 deletions src/components/flame-graph/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { TooltipCallNode } from 'firefox-profiler/components/tooltip/CallNode';
import { getTimingsForCallNodeIndex } from 'firefox-profiler/profile-logic/profile-data';
import MixedTupleMap from 'mixedtuplemap';
import clamp from 'clamp';

import type {
Thread,
Expand Down Expand Up @@ -62,6 +63,7 @@ export type OwnProps = {
readonly callTree: CallTree;
readonly stackFrameHeight: CssPixels;
readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null;
readonly zoomedInCallNodeIndex: IndexIntoCallNodeTable | null;
readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
readonly onSelectionChange: (param: IndexIntoCallNodeTable | null) => void;
readonly onRightClick: (param: IndexIntoCallNodeTable | null) => void;
Expand Down Expand Up @@ -116,6 +118,56 @@ function snapValueToMultipleOf(
return snap(floatDeviceValue / integerFactor) * integerFactor;
}

/**
* A polyfill of `Array.prototype.findLastIndex`.
*
* FIXME: replace this with native `findLastIndex` once we are allowed to
* use ES2023 things.
*/
function findLastIndex<T>(
array: T[],
predicate: (value: T, index: number) => boolean
): number {
if (
'findLastIndex' in Array.prototype &&
typeof Array.prototype.findLastIndex === 'function'
) {
return Array.prototype.findLastIndex.call(array, predicate);
}

for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i], i)) return i;
}

return -1;
}

/**
* Get the timing information of the zoomed in call node.
* If there is no zoomed in call node, it defaults to the root call node.
*/
function getZoomedInOrRootCallNodeTiming(
flameGraphTiming: FlameGraphTiming,
callNodeInfo: CallNodeInfo,
zoomedInCallNodeIndex: IndexIntoCallNodeTable | null
): { start: number; end: number } {
if (zoomedInCallNodeIndex === null) {
return { start: 0, end: 1 };
}

const depth = callNodeInfo.depthForNode(zoomedInCallNodeIndex);
const stackTiming = flameGraphTiming[depth];
if (!stackTiming) {
return { start: 0, end: 1 };
}

const posInStackTiming = stackTiming.callNode.indexOf(zoomedInCallNodeIndex);
return {
start: stackTiming.start[posInStackTiming],
end: stackTiming.end[posInStackTiming],
};
}

class FlameGraphCanvasImpl extends React.PureComponent<Props> {
_textMeasurement: TextMeasurement | null = null;
_textMeasurementCssToDeviceScale: number = 1;
Expand Down Expand Up @@ -185,6 +237,7 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {
maxStackDepthPlusOne,
rightClickedCallNodeIndex,
selectedCallNodeIndex,
zoomedInCallNodeIndex,
categories,
viewport: {
containerWidth,
Expand Down Expand Up @@ -241,6 +294,29 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {
maxStackDepthPlusOne - viewportTop / stackFrameHeight
);

const zoomedInOrRootCallNodeTiming = getZoomedInOrRootCallNodeTiming(
flameGraphTiming,
callNodeInfo,
zoomedInCallNodeIndex
);
// Indicates how much zoomed in call node has "grown" by zooming in,
// compared to its original size.
// It is 1 when there is no zoomed in call node.
const zoomedInCallNodeGrownRatio =
1 /
(zoomedInOrRootCallNodeTiming.end - zoomedInOrRootCallNodeTiming.start);

const zoomedInCallNodeInclusivePrefixes: IndexIntoCallNodeTable[] | null =
zoomedInCallNodeIndex !== null ? [] : null;
if (zoomedInCallNodeInclusivePrefixes !== null) {
let cni = zoomedInCallNodeIndex!;
do {
zoomedInCallNodeInclusivePrefixes.push(cni);
cni = callNodeInfo.prefixForNode(cni);
} while (cni !== -1);
zoomedInCallNodeInclusivePrefixes.reverse();
}

// Only draw the stack frames that are vertically within view.
// The graph is drawn from bottom to top, in order of increasing depth.
for (let depth = startDepth; depth < endDepth; depth++) {
Expand All @@ -263,7 +339,28 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {
const deviceTextTop =
deviceRowTop + snap(TEXT_OFFSET_TOP * cssToDeviceScale);

for (let i = 0; i < stackTiming.length; i++) {
const shouldDrawFullWidthBox =
zoomedInCallNodeIndex !== null &&
depth <= callNodeInfo.depthForNode(zoomedInCallNodeIndex);
const startIndex = shouldDrawFullWidthBox
? stackTiming.callNode.indexOf(
zoomedInCallNodeInclusivePrefixes![depth]
)
: stackTiming.end.findIndex(
(x) => x > zoomedInOrRootCallNodeTiming.start
);
const endIndex = shouldDrawFullWidthBox
? startIndex + 1
: findLastIndex(
stackTiming.start,
(x) => x < zoomedInOrRootCallNodeTiming.end
) + 1;
if (startIndex === -1 || endIndex === 0) {
// There is no box related to the zoomed in one. Skip.
continue;
}

for (let i = startIndex; i < endIndex; i++) {
// For each box, snap the left and right edges to the nearest multiple
// of two device pixels. If both edges snap to the same value, the box
// becomes empty and is not drawn.
Expand All @@ -273,10 +370,24 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {
// left by 0.8 device pixels, so that this gap pixel column is filled to
// 20%.

const boxLeftFraction = stackTiming.start[i];
const boxRightFraction = stackTiming.end[i];
const deviceBoxLeftUnsnapped = boxLeftFraction * deviceContainerWidth;
const deviceBoxRightUnsnapped = boxRightFraction * deviceContainerWidth;
const rawBoxLeftFraction = stackTiming.start[i];
const zoomedInBoxLeftFraction = clamp(
(rawBoxLeftFraction - zoomedInOrRootCallNodeTiming.start) *
zoomedInCallNodeGrownRatio,
0,
1
);
const rawBoxRightFraction = stackTiming.end[i];
const zoomedInBoxRightFraction = clamp(
(rawBoxRightFraction - zoomedInOrRootCallNodeTiming.start) *
zoomedInCallNodeGrownRatio,
0,
1
);
const deviceBoxLeftUnsnapped =
zoomedInBoxLeftFraction * deviceContainerWidth;
const deviceBoxRightUnsnapped =
zoomedInBoxRightFraction * deviceContainerWidth;

const deviceBoxLeft: DevicePixels = snapValueToMultipleOf(
deviceBoxLeftUnsnapped,
Expand Down Expand Up @@ -469,8 +580,10 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {

_hitTest = (x: CssPixels, y: CssPixels): HoveredStackTiming | null => {
const {
callNodeInfo,
flameGraphTiming,
maxStackDepthPlusOne,
zoomedInCallNodeIndex,
viewport: { viewportTop, containerWidth },
} = this.props;
const pos = x / containerWidth;
Expand All @@ -483,10 +596,34 @@ class FlameGraphCanvasImpl extends React.PureComponent<Props> {
return null;
}

const zoomedInOrRootCallNodeTiming = getZoomedInOrRootCallNodeTiming(
flameGraphTiming,
callNodeInfo,
zoomedInCallNodeIndex
);
// Indicates how much zoomed in call node has "grown" by zooming in,
// compared to its original size.
// It is 1 when there is no zoomed in call node.
const zoomedInCallNodeGrownRatio =
1 /
(zoomedInOrRootCallNodeTiming.end - zoomedInOrRootCallNodeTiming.start);

for (let i = 0; i < stackTiming.length; i++) {
const start = stackTiming.start[i];
const end = stackTiming.end[i];
if (start < pos && end > pos) {
const rawStart = stackTiming.start[i];
const rawEnd = stackTiming.end[i];
const zoomedInStart = clamp(
(rawStart - zoomedInOrRootCallNodeTiming.start) *
zoomedInCallNodeGrownRatio,
0,
1
);
const zoomedInEnd = clamp(
(rawEnd - zoomedInOrRootCallNodeTiming.start) *
zoomedInCallNodeGrownRatio,
0,
1
);
if (zoomedInStart < pos && pos < zoomedInEnd) {
return { depth, flameGraphTimingIndex: i };
}
}
Expand Down
18 changes: 17 additions & 1 deletion src/components/flame-graph/FlameGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger';
import {
changeSelectedCallNode,
changeZoomedInCallNode,
changeRightClickedCallNode,
handleCallNodeTransformShortcut,
updateBottomBoxContentsAndMaybeOpen,
Expand Down Expand Up @@ -79,6 +80,7 @@ type StateProps = {
readonly callNodeInfo: CallNodeInfo;
readonly threadsKey: ThreadsKey;
readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null;
readonly zoomedInCallNodeIndex: IndexIntoCallNodeTable | null;
readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null;
readonly scrollToSelectionGeneration: number;
readonly categories: CategoryList;
Expand All @@ -92,6 +94,7 @@ type StateProps = {
};
type DispatchProps = {
readonly changeSelectedCallNode: typeof changeSelectedCallNode;
readonly changeZoomedInCallNode: typeof changeZoomedInCallNode;
readonly changeRightClickedCallNode: typeof changeRightClickedCallNode;
readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut;
readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen;
Expand Down Expand Up @@ -119,11 +122,19 @@ class FlameGraphImpl
_onSelectedCallNodeChange = (
callNodeIndex: IndexIntoCallNodeTable | null
) => {
const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props;
const {
callNodeInfo,
threadsKey,
changeSelectedCallNode,
changeZoomedInCallNode,
} = this.props;
changeSelectedCallNode(
threadsKey,
callNodeInfo.getCallNodePathFromIndex(callNodeIndex)
);
changeZoomedInCallNode(
callNodeInfo.getCallNodePathFromIndex(callNodeIndex)
);
};

_onRightClickedCallNodeChange = (
Expand Down Expand Up @@ -332,6 +343,7 @@ class FlameGraphImpl
previewSelection,
rightClickedCallNodeIndex,
selectedCallNodeIndex,
zoomedInCallNodeIndex,
scrollToSelectionGeneration,
callTreeSummaryStrategy,
categories,
Expand Down Expand Up @@ -394,6 +406,7 @@ class FlameGraphImpl
callNodeInfo,
categories,
selectedCallNodeIndex,
zoomedInCallNodeIndex,
rightClickedCallNodeIndex,
scrollToSelectionGeneration,
callTreeSummaryStrategy,
Expand Down Expand Up @@ -446,6 +459,8 @@ export const FlameGraph = explicitConnectWithForwardRef<
threadsKey: getSelectedThreadsKey(state),
selectedCallNodeIndex:
selectedThreadSelectors.getSelectedCallNodeIndex(state),
zoomedInCallNodeIndex:
selectedThreadSelectors.getZoomedInCallNodeIndex(state),
rightClickedCallNodeIndex:
selectedThreadSelectors.getRightClickedCallNodeIndex(state),
scrollToSelectionGeneration: getScrollToSelectionGeneration(state),
Expand All @@ -464,6 +479,7 @@ export const FlameGraph = explicitConnectWithForwardRef<
}),
mapDispatchToProps: {
changeSelectedCallNode,
changeZoomedInCallNode,
changeRightClickedCallNode,
handleCallNodeTransformShortcut,
updateBottomBoxContentsAndMaybeOpen,
Expand Down
14 changes: 14 additions & 0 deletions src/reducers/url-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
IsOpenPerPanelState,
TabID,
SelectedMarkersPerThread,
CallNodePath,
} from 'firefox-profiler/types';

import type { TabSlug } from '../app-logic/tabs-handling';
Expand Down Expand Up @@ -732,6 +733,18 @@ const selectedMarkers: Reducer<SelectedMarkersPerThread> = (
}
};

const zoomedInCallNodePath: Reducer<CallNodePath | null> = (
state = null,
action
): CallNodePath | null => {
switch (action.type) {
case 'CHANGE_ZOOMED_IN_CALL_NODE':
return action.zoomedInCallNodePath;
default:
return state;
}
};

/**
* These values are specific to an individual profile.
*/
Expand Down Expand Up @@ -759,6 +772,7 @@ const profileSpecific = combineReducers({
showJsTracerSummary,
tabFilter,
selectedMarkers,
zoomedInCallNodePath,
// The timeline tracks used to be hidden and sorted by thread indexes, rather than
// track indexes. The only way to migrate this information to tracks-based data is to
// first retrieve the profile, so they can't be upgraded by the normal url upgrading
Expand Down
Loading