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 1c163512b2..7993dce57f 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, @@ -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; @@ -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( + 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 { _textMeasurement: TextMeasurement | null = null; _textMeasurementCssToDeviceScale: number = 1; @@ -185,6 +237,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { maxStackDepthPlusOne, rightClickedCallNodeIndex, selectedCallNodeIndex, + zoomedInCallNodeIndex, categories, viewport: { containerWidth, @@ -241,6 +294,29 @@ class FlameGraphCanvasImpl extends React.PureComponent { 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++) { @@ -263,7 +339,28 @@ class FlameGraphCanvasImpl extends React.PureComponent { 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. @@ -273,10 +370,24 @@ 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 - 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, @@ -469,8 +580,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { _hitTest = (x: CssPixels, y: CssPixels): HoveredStackTiming | null => { const { + callNodeInfo, flameGraphTiming, maxStackDepthPlusOne, + zoomedInCallNodeIndex, viewport: { viewportTop, containerWidth }, } = this.props; const pos = x / containerWidth; @@ -483,10 +596,34 @@ class FlameGraphCanvasImpl extends React.PureComponent { 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 }; } } 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 = {