From 303e9f9c9768772db8c532d9887f28565cc4d9a8 Mon Sep 17 00:00:00 2001 From: andjsrk Date: Sat, 14 Mar 2026 21:42:54 +0900 Subject: [PATCH 1/3] Support zoom by click in flame graph --- src/components/flame-graph/Canvas.tsx | 142 ++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 8 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 1c163512b2..b6a4fb6be4 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -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, @@ -116,6 +117,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( + 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 selected call node. + * If there is no selected call node, it defaults to the root call node. + */ +function getSelectedOrRootCallNodeTiming( + flameGraphTiming: FlameGraphTiming, + callNodeInfo: CallNodeInfo, + selectedCallNodeIndex: IndexIntoCallNodeTable | null, +): { start: number; end: number; } { + if (selectedCallNodeIndex === null) { + return { start: 0, end: 1 }; + } + + const depth = callNodeInfo.depthForNode(selectedCallNodeIndex); + const stackTiming = flameGraphTiming[depth]; + if (!stackTiming) { + return { start: 0, end: 1 }; + } + + const posInStackTiming = stackTiming.callNode.indexOf(selectedCallNodeIndex); + return { + start: stackTiming.start[posInStackTiming], + end: stackTiming.end[posInStackTiming], + }; +} + class FlameGraphCanvasImpl extends React.PureComponent { _textMeasurement: TextMeasurement | null = null; _textMeasurementCssToDeviceScale: number = 1; @@ -241,6 +292,28 @@ class FlameGraphCanvasImpl extends React.PureComponent { maxStackDepthPlusOne - viewportTop / stackFrameHeight ); + const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( + flameGraphTiming, + callNodeInfo, + selectedCallNodeIndex, + ); + // Indicates how much selected call node has "grown" by zooming in, + // compared to its original size. + // It is 1 when there is no selected call node. + const selectedCallNodeGrownRatio = + 1 / (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); + + const selectedCallNodeInclusivePrefixes: IndexIntoCallNodeTable[] | null = + selectedCallNodeIndex !== null ? [] : null; + if (selectedCallNodeInclusivePrefixes !== null) { + let cni = selectedCallNodeIndex!; + do { + selectedCallNodeInclusivePrefixes.push(cni); + cni = callNodeInfo.prefixForNode(cni); + } while (cni !== -1); + selectedCallNodeInclusivePrefixes.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++) { @@ -263,7 +336,23 @@ class FlameGraphCanvasImpl extends React.PureComponent { const deviceTextTop = deviceRowTop + snap(TEXT_OFFSET_TOP * cssToDeviceScale); - for (let i = 0; i < stackTiming.length; i++) { + const shouldDrawFullWidthBox = + selectedCallNodeIndex !== null + && depth <= callNodeInfo.depthForNode(selectedCallNodeIndex); + const startIndex = + shouldDrawFullWidthBox + ? stackTiming.callNode.indexOf(selectedCallNodeInclusivePrefixes![depth]) + : stackTiming.end.findIndex(x => x > selectedOrRootCallNodeTiming.start); + const endIndex = + shouldDrawFullWidthBox + ? startIndex + 1 + : findLastIndex(stackTiming.start, x => x < selectedOrRootCallNodeTiming.end) + 1; + if (startIndex === -1 || endIndex === 0) { + // There is no box related to the selected 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. @@ -273,10 +362,22 @@ class FlameGraphCanvasImpl extends React.PureComponent { // 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 - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, + 0, + 1, + ); + const rawBoxRightFraction = stackTiming.end[i]; + const zoomedInBoxRightFraction = + clamp( + (rawBoxRightFraction - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, + 0, + 1, + ); + const deviceBoxLeftUnsnapped = zoomedInBoxLeftFraction * deviceContainerWidth; + const deviceBoxRightUnsnapped = zoomedInBoxRightFraction * deviceContainerWidth; const deviceBoxLeft: DevicePixels = snapValueToMultipleOf( deviceBoxLeftUnsnapped, @@ -469,8 +570,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { _hitTest = (x: CssPixels, y: CssPixels): HoveredStackTiming | null => { const { + callNodeInfo, flameGraphTiming, maxStackDepthPlusOne, + selectedCallNodeIndex, viewport: { viewportTop, containerWidth }, } = this.props; const pos = x / containerWidth; @@ -483,10 +586,33 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } + const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( + flameGraphTiming, + callNodeInfo, + selectedCallNodeIndex, + ); + // Indicates how much selected call node has "grown" by zooming in, + // compared to its original size. + // It is 1 when there is no selected call node. + const selectedCallNodeGrownRatio = + 1 / (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.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 - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, + 0, + 1, + ); + const zoomedInEnd = + clamp( + (rawEnd - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, + 0, + 1, + ); + if (zoomedInStart < pos && pos < zoomedInEnd) { return { depth, flameGraphTimingIndex: i }; } } From a3cead6f81c4f77dad075cb4a65b585841453cbf Mon Sep 17 00:00:00 2001 From: andjsrk Date: Thu, 19 Mar 2026 16:36:05 +0900 Subject: [PATCH 2/3] format --- src/components/flame-graph/Canvas.tsx | 101 ++++++++++++++------------ 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index b6a4fb6be4..7a478bb448 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -125,13 +125,13 @@ function snapValueToMultipleOf( */ function findLastIndex( array: T[], - predicate: (value: T, index: number) => boolean, + predicate: (value: T, index: number) => boolean ): number { if ( - 'findLastIndex' in Array.prototype - && typeof Array.prototype.findLastIndex === 'function' + 'findLastIndex' in Array.prototype && + typeof Array.prototype.findLastIndex === 'function' ) { - return Array.prototype.findLastIndex.call(array, predicate) + return Array.prototype.findLastIndex.call(array, predicate); } for (let i = array.length - 1; i >= 0; i--) { @@ -148,8 +148,8 @@ function findLastIndex( function getSelectedOrRootCallNodeTiming( flameGraphTiming: FlameGraphTiming, callNodeInfo: CallNodeInfo, - selectedCallNodeIndex: IndexIntoCallNodeTable | null, -): { start: number; end: number; } { + selectedCallNodeIndex: IndexIntoCallNodeTable | null +): { start: number; end: number } { if (selectedCallNodeIndex === null) { return { start: 0, end: 1 }; } @@ -295,13 +295,14 @@ class FlameGraphCanvasImpl extends React.PureComponent { const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( flameGraphTiming, callNodeInfo, - selectedCallNodeIndex, + selectedCallNodeIndex ); // Indicates how much selected call node has "grown" by zooming in, // compared to its original size. // It is 1 when there is no selected call node. const selectedCallNodeGrownRatio = - 1 / (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); + 1 / + (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); const selectedCallNodeInclusivePrefixes: IndexIntoCallNodeTable[] | null = selectedCallNodeIndex !== null ? [] : null; @@ -337,16 +338,21 @@ class FlameGraphCanvasImpl extends React.PureComponent { deviceRowTop + snap(TEXT_OFFSET_TOP * cssToDeviceScale); const shouldDrawFullWidthBox = - selectedCallNodeIndex !== null - && depth <= callNodeInfo.depthForNode(selectedCallNodeIndex); - const startIndex = - shouldDrawFullWidthBox - ? stackTiming.callNode.indexOf(selectedCallNodeInclusivePrefixes![depth]) - : stackTiming.end.findIndex(x => x > selectedOrRootCallNodeTiming.start); - const endIndex = - shouldDrawFullWidthBox - ? startIndex + 1 - : findLastIndex(stackTiming.start, x => x < selectedOrRootCallNodeTiming.end) + 1; + selectedCallNodeIndex !== null && + depth <= callNodeInfo.depthForNode(selectedCallNodeIndex); + const startIndex = shouldDrawFullWidthBox + ? stackTiming.callNode.indexOf( + selectedCallNodeInclusivePrefixes![depth] + ) + : stackTiming.end.findIndex( + (x) => x > selectedOrRootCallNodeTiming.start + ); + const endIndex = shouldDrawFullWidthBox + ? startIndex + 1 + : findLastIndex( + stackTiming.start, + (x) => x < selectedOrRootCallNodeTiming.end + ) + 1; if (startIndex === -1 || endIndex === 0) { // There is no box related to the selected one. Skip. continue; @@ -363,21 +369,23 @@ class FlameGraphCanvasImpl extends React.PureComponent { // 20%. const rawBoxLeftFraction = stackTiming.start[i]; - const zoomedInBoxLeftFraction = - clamp( - (rawBoxLeftFraction - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, - 0, - 1, - ); + const zoomedInBoxLeftFraction = clamp( + (rawBoxLeftFraction - selectedOrRootCallNodeTiming.start) * + selectedCallNodeGrownRatio, + 0, + 1 + ); const rawBoxRightFraction = stackTiming.end[i]; - const zoomedInBoxRightFraction = - clamp( - (rawBoxRightFraction - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, - 0, - 1, - ); - const deviceBoxLeftUnsnapped = zoomedInBoxLeftFraction * deviceContainerWidth; - const deviceBoxRightUnsnapped = zoomedInBoxRightFraction * deviceContainerWidth; + const zoomedInBoxRightFraction = clamp( + (rawBoxRightFraction - selectedOrRootCallNodeTiming.start) * + selectedCallNodeGrownRatio, + 0, + 1 + ); + const deviceBoxLeftUnsnapped = + zoomedInBoxLeftFraction * deviceContainerWidth; + const deviceBoxRightUnsnapped = + zoomedInBoxRightFraction * deviceContainerWidth; const deviceBoxLeft: DevicePixels = snapValueToMultipleOf( deviceBoxLeftUnsnapped, @@ -589,29 +597,30 @@ class FlameGraphCanvasImpl extends React.PureComponent { const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( flameGraphTiming, callNodeInfo, - selectedCallNodeIndex, + selectedCallNodeIndex ); // Indicates how much selected call node has "grown" by zooming in, // compared to its original size. // It is 1 when there is no selected call node. const selectedCallNodeGrownRatio = - 1 / (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); + 1 / + (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); for (let i = 0; i < stackTiming.length; i++) { const rawStart = stackTiming.start[i]; const rawEnd = stackTiming.end[i]; - const zoomedInStart = - clamp( - (rawStart - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, - 0, - 1, - ); - const zoomedInEnd = - clamp( - (rawEnd - selectedOrRootCallNodeTiming.start) * selectedCallNodeGrownRatio, - 0, - 1, - ); + const zoomedInStart = clamp( + (rawStart - selectedOrRootCallNodeTiming.start) * + selectedCallNodeGrownRatio, + 0, + 1 + ); + const zoomedInEnd = clamp( + (rawEnd - selectedOrRootCallNodeTiming.start) * + selectedCallNodeGrownRatio, + 0, + 1 + ); if (zoomedInStart < pos && pos < zoomedInEnd) { return { depth, flameGraphTimingIndex: i }; } From 1cdca9030ce1e34b235eabfd0c724611d38ca3c8 Mon Sep 17 00:00:00 2001 From: andjsrk Date: Sun, 22 Mar 2026 20:28:53 +0900 Subject: [PATCH 3/3] Make zoomed in node state separate and include it in URL --- src/actions/profile-view.ts | 13 ++++ src/app-logic/url-handling.ts | 14 ++++ src/components/flame-graph/Canvas.tsx | 82 ++++++++++++----------- src/components/flame-graph/FlameGraph.tsx | 18 ++++- src/reducers/url-state.ts | 14 ++++ src/selectors/per-thread/stack-sample.ts | 19 ++++++ src/selectors/url-state.ts | 3 + src/types/actions.ts | 4 ++ src/types/state.ts | 1 + 9 files changed, 127 insertions(+), 41 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..f9e71a0b6c 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -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 diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index f2b1196339..31e23e7403 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -217,6 +217,9 @@ type Query = BaseQuery & { sourceViewIndex?: number; assemblyView?: string; + // FlameGraph specific + zoomedInNode?: string; + // StackChart specific showUserTimings?: null | undefined; sameWidths?: null | undefined; @@ -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] @@ -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))) { @@ -614,6 +627,7 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + zoomedInCallNodePath, }, }; } diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 7a478bb448..7993dce57f 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -63,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; @@ -142,25 +143,25 @@ function findLastIndex( } /** - * Get the timing information of the selected call node. - * If there is no selected call node, it defaults to the root call node. + * 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 getSelectedOrRootCallNodeTiming( +function getZoomedInOrRootCallNodeTiming( flameGraphTiming: FlameGraphTiming, callNodeInfo: CallNodeInfo, - selectedCallNodeIndex: IndexIntoCallNodeTable | null + zoomedInCallNodeIndex: IndexIntoCallNodeTable | null ): { start: number; end: number } { - if (selectedCallNodeIndex === null) { + if (zoomedInCallNodeIndex === null) { return { start: 0, end: 1 }; } - const depth = callNodeInfo.depthForNode(selectedCallNodeIndex); + const depth = callNodeInfo.depthForNode(zoomedInCallNodeIndex); const stackTiming = flameGraphTiming[depth]; if (!stackTiming) { return { start: 0, end: 1 }; } - const posInStackTiming = stackTiming.callNode.indexOf(selectedCallNodeIndex); + const posInStackTiming = stackTiming.callNode.indexOf(zoomedInCallNodeIndex); return { start: stackTiming.start[posInStackTiming], end: stackTiming.end[posInStackTiming], @@ -236,6 +237,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { maxStackDepthPlusOne, rightClickedCallNodeIndex, selectedCallNodeIndex, + zoomedInCallNodeIndex, categories, viewport: { containerWidth, @@ -292,27 +294,27 @@ class FlameGraphCanvasImpl extends React.PureComponent { maxStackDepthPlusOne - viewportTop / stackFrameHeight ); - const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( + const zoomedInOrRootCallNodeTiming = getZoomedInOrRootCallNodeTiming( flameGraphTiming, callNodeInfo, - selectedCallNodeIndex + zoomedInCallNodeIndex ); - // Indicates how much selected call node has "grown" by zooming in, + // Indicates how much zoomed in call node has "grown" by zooming in, // compared to its original size. - // It is 1 when there is no selected call node. - const selectedCallNodeGrownRatio = + // It is 1 when there is no zoomed in call node. + const zoomedInCallNodeGrownRatio = 1 / - (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); + (zoomedInOrRootCallNodeTiming.end - zoomedInOrRootCallNodeTiming.start); - const selectedCallNodeInclusivePrefixes: IndexIntoCallNodeTable[] | null = - selectedCallNodeIndex !== null ? [] : null; - if (selectedCallNodeInclusivePrefixes !== null) { - let cni = selectedCallNodeIndex!; + const zoomedInCallNodeInclusivePrefixes: IndexIntoCallNodeTable[] | null = + zoomedInCallNodeIndex !== null ? [] : null; + if (zoomedInCallNodeInclusivePrefixes !== null) { + let cni = zoomedInCallNodeIndex!; do { - selectedCallNodeInclusivePrefixes.push(cni); + zoomedInCallNodeInclusivePrefixes.push(cni); cni = callNodeInfo.prefixForNode(cni); } while (cni !== -1); - selectedCallNodeInclusivePrefixes.reverse(); + zoomedInCallNodeInclusivePrefixes.reverse(); } // Only draw the stack frames that are vertically within view. @@ -338,23 +340,23 @@ class FlameGraphCanvasImpl extends React.PureComponent { deviceRowTop + snap(TEXT_OFFSET_TOP * cssToDeviceScale); const shouldDrawFullWidthBox = - selectedCallNodeIndex !== null && - depth <= callNodeInfo.depthForNode(selectedCallNodeIndex); + zoomedInCallNodeIndex !== null && + depth <= callNodeInfo.depthForNode(zoomedInCallNodeIndex); const startIndex = shouldDrawFullWidthBox ? stackTiming.callNode.indexOf( - selectedCallNodeInclusivePrefixes![depth] + zoomedInCallNodeInclusivePrefixes![depth] ) : stackTiming.end.findIndex( - (x) => x > selectedOrRootCallNodeTiming.start + (x) => x > zoomedInOrRootCallNodeTiming.start ); const endIndex = shouldDrawFullWidthBox ? startIndex + 1 : findLastIndex( stackTiming.start, - (x) => x < selectedOrRootCallNodeTiming.end + (x) => x < zoomedInOrRootCallNodeTiming.end ) + 1; if (startIndex === -1 || endIndex === 0) { - // There is no box related to the selected one. Skip. + // There is no box related to the zoomed in one. Skip. continue; } @@ -370,15 +372,15 @@ class FlameGraphCanvasImpl extends React.PureComponent { const rawBoxLeftFraction = stackTiming.start[i]; const zoomedInBoxLeftFraction = clamp( - (rawBoxLeftFraction - selectedOrRootCallNodeTiming.start) * - selectedCallNodeGrownRatio, + (rawBoxLeftFraction - zoomedInOrRootCallNodeTiming.start) * + zoomedInCallNodeGrownRatio, 0, 1 ); const rawBoxRightFraction = stackTiming.end[i]; const zoomedInBoxRightFraction = clamp( - (rawBoxRightFraction - selectedOrRootCallNodeTiming.start) * - selectedCallNodeGrownRatio, + (rawBoxRightFraction - zoomedInOrRootCallNodeTiming.start) * + zoomedInCallNodeGrownRatio, 0, 1 ); @@ -581,7 +583,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { callNodeInfo, flameGraphTiming, maxStackDepthPlusOne, - selectedCallNodeIndex, + zoomedInCallNodeIndex, viewport: { viewportTop, containerWidth }, } = this.props; const pos = x / containerWidth; @@ -594,30 +596,30 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const selectedOrRootCallNodeTiming = getSelectedOrRootCallNodeTiming( + const zoomedInOrRootCallNodeTiming = getZoomedInOrRootCallNodeTiming( flameGraphTiming, callNodeInfo, - selectedCallNodeIndex + zoomedInCallNodeIndex ); - // Indicates how much selected call node has "grown" by zooming in, + // Indicates how much zoomed in call node has "grown" by zooming in, // compared to its original size. - // It is 1 when there is no selected call node. - const selectedCallNodeGrownRatio = + // It is 1 when there is no zoomed in call node. + const zoomedInCallNodeGrownRatio = 1 / - (selectedOrRootCallNodeTiming.end - selectedOrRootCallNodeTiming.start); + (zoomedInOrRootCallNodeTiming.end - zoomedInOrRootCallNodeTiming.start); for (let i = 0; i < stackTiming.length; i++) { const rawStart = stackTiming.start[i]; const rawEnd = stackTiming.end[i]; const zoomedInStart = clamp( - (rawStart - selectedOrRootCallNodeTiming.start) * - selectedCallNodeGrownRatio, + (rawStart - zoomedInOrRootCallNodeTiming.start) * + zoomedInCallNodeGrownRatio, 0, 1 ); const zoomedInEnd = clamp( - (rawEnd - selectedOrRootCallNodeTiming.start) * - selectedCallNodeGrownRatio, + (rawEnd - zoomedInOrRootCallNodeTiming.start) * + zoomedInCallNodeGrownRatio, 0, 1 ); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..580475ac3e 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -23,6 +23,7 @@ import { import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; import { changeSelectedCallNode, + changeZoomedInCallNode, changeRightClickedCallNode, handleCallNodeTransformShortcut, updateBottomBoxContentsAndMaybeOpen, @@ -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; @@ -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; @@ -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 = ( @@ -332,6 +343,7 @@ class FlameGraphImpl previewSelection, rightClickedCallNodeIndex, selectedCallNodeIndex, + zoomedInCallNodeIndex, scrollToSelectionGeneration, callTreeSummaryStrategy, categories, @@ -394,6 +406,7 @@ class FlameGraphImpl callNodeInfo, categories, selectedCallNodeIndex, + zoomedInCallNodeIndex, rightClickedCallNodeIndex, scrollToSelectionGeneration, callTreeSummaryStrategy, @@ -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), @@ -464,6 +479,7 @@ export const FlameGraph = explicitConnectWithForwardRef< }), mapDispatchToProps: { changeSelectedCallNode, + changeZoomedInCallNode, changeRightClickedCallNode, handleCallNodeTransformShortcut, updateBottomBoxContentsAndMaybeOpen, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 0d7a5e5c08..441395705e 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,7 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + CallNodePath, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -732,6 +733,18 @@ const selectedMarkers: Reducer = ( } }; +const zoomedInCallNodePath: Reducer = ( + 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. */ @@ -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 diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 11f1a282a5..df15f3af78 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -220,6 +220,23 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getZoomedInCallNodePath: Selector = createSelector( + UrlState.getZoomedInCallNodePath, + (state) => state + ); + + const getZoomedInCallNodeIndex: Selector = + createSelector( + getCallNodeInfo, + getZoomedInCallNodePath, + (callNodeInfo, zoomedInCallNodePath) => { + if (zoomedInCallNodePath === null) { + return null; + } + return callNodeInfo.getCallNodeIndexFromPath(zoomedInCallNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -501,6 +518,8 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getZoomedInCallNodePath, + getZoomedInCallNodeIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index c3ff9e27e6..c7f462c486 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,7 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + CallNodePath, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -124,6 +125,8 @@ export const getSelectedTab: Selector = (state) => export const getInvertCallstack: Selector = (state) => getSelectedTab(state) === 'calltree' && getProfileSpecificState(state).invertCallstack; +export const getZoomedInCallNodePath: Selector = (state) => + getProfileSpecificState(state).zoomedInCallNodePath; export const getSelectedThreadIndexesOrNull: Selector< Set | null diff --git a/src/types/actions.ts b/src/types/actions.ts index b3dccd0f99..91b64027b2 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -184,6 +184,10 @@ type ProfileAction = readonly optionalExpandedToCallNodePath: CallNodePath | undefined; readonly context: SelectionContext; } + | { + readonly type: 'CHANGE_ZOOMED_IN_CALL_NODE'; + readonly zoomedInCallNodePath: CallNodePath | null; + } | { readonly type: 'UPDATE_TRACK_THREAD_HEIGHT'; readonly height: CssPixels; diff --git a/src/types/state.ts b/src/types/state.ts index 6e18bb2630..2e0acfcd26 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -377,6 +377,7 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + zoomedInCallNodePath: CallNodePath | null; }; export type UrlState = {