From b536f79addbc98126a8b9e72fdd5034d6c9bbb05 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 1/2] Use callNodeInfo.fieldForNode(i) instead of callNodeTable.field[i] in flame graph code. For the non-inverted call node info, the two are equivalent. But if we want to use the flame graph on an inverted call node info, such as for the "called by" view in the function list, the old code would not work because the callNodeTable is always the non-inverted call node table. So this change removes one barrier for inverted flame graphs. --- src/components/flame-graph/Canvas.tsx | 9 +++------ src/components/flame-graph/FlameGraph.tsx | 12 ++++-------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 73dd9394da..252add681a 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -157,8 +157,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { return; } - const callNodeTable = callNodeInfo.getCallNodeTable(); - const depth = callNodeTable.depth[selectedCallNodeIndex]; + const depth = callNodeInfo.depthForNode(selectedCallNodeIndex); const y = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT; if (y < this.props.viewport.viewportTop) { @@ -231,8 +230,6 @@ class FlameGraphCanvasImpl extends React.PureComponent { fastFillStyle.set(getBackgroundColor()); ctx.fillRect(0, 0, deviceContainerWidth, deviceContainerHeight); - const callNodeTable = callNodeInfo.getCallNodeTable(); - const startDepth = Math.floor( maxStackDepthPlusOne - viewportBottom / stackFrameHeight ); @@ -299,7 +296,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { i === hoveredItem.flameGraphTimingIndex; const isHighlighted = isSelected || isRightClicked || isHovered; - const categoryIndex = callNodeTable.category[callNodeIndex]; + const categoryIndex = callNodeInfo.categoryForNode(callNodeIndex); const category = categories[categoryIndex]; const colorStyles = mapCategoryColorNameToStackChartStyles( category.color @@ -321,7 +318,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { deviceBoxLeft + deviceHorizontalPadding; const deviceTextWidth: DevicePixels = deviceBoxRight - deviceTextLeft; if (deviceTextWidth > textMeasurement.minWidth) { - const funcIndex = callNodeTable.func[callNodeIndex]; + const funcIndex = callNodeInfo.funcForNode(callNodeIndex); const funcName = thread.stringTable.getString( thread.funcTable.name[funcIndex] ); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 159aaa0ea5..b241b973e1 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -118,8 +118,7 @@ export class FlameGraph _wideEnough = (callNodeIndex: IndexIntoCallNodeTable): boolean => { const { flameGraphTiming, callNodeInfo } = this.props; - const callNodeTable = callNodeInfo.getCallNodeTable(); - const depth = callNodeTable.depth[callNodeIndex]; + const depth = callNodeInfo.depthForNode(callNodeIndex); const row = flameGraphTiming.getRow(depth); const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; @@ -143,8 +142,7 @@ export class FlameGraph let callNodeIndex = startingCallNodeIndex; - const callNodeTable = callNodeInfo.getCallNodeTable(); - const depth = callNodeTable.depth[callNodeIndex]; + const depth = callNodeInfo.depthForNode(callNodeIndex); const row = flameGraphTiming.getRow(depth); let columnIndex = row.callNode.indexOf(callNodeIndex); @@ -174,7 +172,6 @@ export class FlameGraph onCallNodeEnterOrDoubleClick, onKeyboardTransformShortcut, } = this.props; - const callNodeTable = callNodeInfo.getCallNodeTable(); if ( // Please do not forget to update the switch/case below if changing the array to allow more keys. @@ -188,7 +185,7 @@ export class FlameGraph switch (event.key) { case 'ArrowDown': { - const prefix = callNodeTable.prefix[selectedCallNodeIndex]; + const prefix = callNodeInfo.prefixForNode(selectedCallNodeIndex); if (prefix !== -1) { onSelectedCallNodeChange(prefix); } @@ -248,9 +245,8 @@ export class FlameGraph if (document.activeElement === this._viewport) { event.preventDefault(); const { callNodeInfo, selectedCallNodeIndex, thread } = this.props; - const callNodeTable = callNodeInfo.getCallNodeTable(); if (selectedCallNodeIndex !== null) { - const funcIndex = callNodeTable.func[selectedCallNodeIndex]; + const funcIndex = callNodeInfo.funcForNode(selectedCallNodeIndex); const funcName = thread.stringTable.getString( thread.funcTable.name[funcIndex] ); From 1d18f2a14e6022d937da403cc76fd2d94927ac51 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 2/2] Add a startsAtBottom prop to FlameGraph. startsAtBottom={true} is the regular flame graph layout. startsAtBottom={false} can be used for icicle-style flame graphs. --- src/components/flame-graph/Canvas.tsx | 52 ++++++++----- .../flame-graph/ConnectedFlameGraph.tsx | 1 + src/components/flame-graph/FlameGraph.tsx | 76 ++++++++++--------- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 252add681a..7d40744d9b 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -70,6 +70,7 @@ export type OwnProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; + readonly startsAtBottom: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -123,8 +124,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { // If the stack depth changes (say, when changing the time range // selection or applying a transform), move the viewport // vertically so that its offset from the base of the flame graph - // is maintained. - if (prevProps.maxStackDepthPlusOne !== this.props.maxStackDepthPlusOne) { + // is maintained. In top-down layout the base is at the top, so no + // adjustment is needed when depth grows or shrinks. + if ( + this.props.startsAtBottom && + prevProps.maxStackDepthPlusOne !== this.props.maxStackDepthPlusOne + ) { this.props.viewport.moveViewport( 0, (prevProps.maxStackDepthPlusOne - this.props.maxStackDepthPlusOne) * @@ -150,15 +155,21 @@ class FlameGraphCanvasImpl extends React.PureComponent { } _scrollSelectionIntoView = () => { - const { selectedCallNodeIndex, maxStackDepthPlusOne, callNodeInfo } = - this.props; + const { + selectedCallNodeIndex, + maxStackDepthPlusOne, + callNodeInfo, + startsAtBottom, + } = this.props; if (selectedCallNodeIndex === null) { return; } const depth = callNodeInfo.depthForNode(selectedCallNodeIndex); - const y = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT; + const y = startsAtBottom + ? (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT + : depth * ROW_HEIGHT; if (y < this.props.viewport.viewportTop) { this.props.viewport.moveViewport(0, this.props.viewport.viewportTop - y); @@ -190,6 +201,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { viewportTop, viewportBottom, }, + startsAtBottom, } = this.props; const { hoveredItem } = hoverInfo; @@ -230,12 +242,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { fastFillStyle.set(getBackgroundColor()); ctx.fillRect(0, 0, deviceContainerWidth, deviceContainerHeight); - const startDepth = Math.floor( - maxStackDepthPlusOne - viewportBottom / stackFrameHeight - ); - const endDepth = Math.ceil( - maxStackDepthPlusOne - viewportTop / stackFrameHeight - ); + const startDepth = startsAtBottom + ? Math.floor(maxStackDepthPlusOne - viewportBottom / stackFrameHeight) + : Math.floor(viewportTop / stackFrameHeight); + const endDepth = startsAtBottom + ? Math.ceil(maxStackDepthPlusOne - viewportTop / stackFrameHeight) + : Math.ceil(viewportBottom / stackFrameHeight); // Only draw the stack frames that are vertically within view. // The graph is drawn from bottom to top, in order of increasing depth. @@ -247,10 +259,12 @@ class FlameGraphCanvasImpl extends React.PureComponent { // Get the timing information for a row of stack frames. const stackTiming = flameGraphTiming.getRow(depth); - const cssRowTop: CssPixels = - (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop; - const cssRowBottom: CssPixels = - (maxStackDepthPlusOne - depth) * ROW_HEIGHT - viewportTop; + const cssRowTop: CssPixels = startsAtBottom + ? (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop + : depth * ROW_HEIGHT - viewportTop; + const cssRowBottom: CssPixels = startsAtBottom + ? (maxStackDepthPlusOne - depth) * ROW_HEIGHT - viewportTop + : (depth + 1) * ROW_HEIGHT - viewportTop; const deviceRowTop: DevicePixels = snap(cssRowTop * cssToDeviceScale); const deviceRowBottom: DevicePixels = snap(cssRowBottom * cssToDeviceScale) - 1; @@ -469,11 +483,13 @@ class FlameGraphCanvasImpl extends React.PureComponent { flameGraphTiming, maxStackDepthPlusOne, viewport: { viewportTop, containerWidth }, + startsAtBottom, } = this.props; const pos = x / containerWidth; - const depth = Math.floor( - maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT - ); + const depth = startsAtBottom + ? Math.floor(maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT) + : Math.floor((y + viewportTop) / ROW_HEIGHT); + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx index 8895b8812b..d2dcd36f63 100644 --- a/src/components/flame-graph/ConnectedFlameGraph.tsx +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -177,6 +177,7 @@ class ConnectedFlameGraphImpl scrollToSelectionGeneration={scrollToSelectionGeneration} categories={categories} interval={interval} + startsAtBottom={true} callTreeSummaryStrategy={callTreeSummaryStrategy} ctssSamples={ctssSamples} ctssSampleCategoriesAndSubcategories={ diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index b241b973e1..dd6d888341 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -59,6 +59,7 @@ export type Props = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; + readonly startsAtBottom: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -171,6 +172,7 @@ export class FlameGraph onSelectedCallNodeChange, onCallNodeEnterOrDoubleClick, onKeyboardTransformShortcut, + startsAtBottom, } = this.props; if ( @@ -183,43 +185,45 @@ export class FlameGraph return; } - switch (event.key) { - case 'ArrowDown': { - const prefix = callNodeInfo.prefixForNode(selectedCallNodeIndex); - if (prefix !== -1) { - onSelectedCallNodeChange(prefix); - } - break; + // In top-down layout the parent is visually above the selected box, so + // ArrowUp navigates to the parent and ArrowDown to the first child. + // In bottom-up layout it's the other way around. + const isGoToParent = startsAtBottom + ? event.key === 'ArrowDown' + : event.key === 'ArrowUp'; + const isGoToChild = startsAtBottom + ? event.key === 'ArrowUp' + : event.key === 'ArrowDown'; + + if (isGoToParent) { + const prefix = callNodeInfo.prefixForNode(selectedCallNodeIndex); + if (prefix !== -1) { + onSelectedCallNodeChange(prefix); } - case 'ArrowUp': { - const [callNodeIndex] = callTree.getChildren(selectedCallNodeIndex); - // The call nodes returned from getChildren are sorted by - // total time in descending order. The first one in the - // array, which is the one we pick, has the longest time and - // thus the widest box. - - if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - onSelectedCallNodeChange(callNodeIndex); - } - break; + } else if (isGoToChild) { + const [callNodeIndex] = callTree.getChildren(selectedCallNodeIndex); + // The call nodes returned from getChildren are sorted by + // total time in descending order. The first one in the + // array, which is the one we pick, has the longest time and + // thus the widest box. + + if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { + onSelectedCallNodeChange(callNodeIndex); } - case 'ArrowLeft': - case 'ArrowRight': { - const callNodeIndex = this._nextSelectableInRow( - selectedCallNodeIndex, - event.key === 'ArrowLeft' ? -1 : 1 - ); - - if (callNodeIndex !== undefined) { - onSelectedCallNodeChange(callNodeIndex); - } - break; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const callNodeIndex = this._nextSelectableInRow( + selectedCallNodeIndex, + event.key === 'ArrowLeft' ? -1 : 1 + ); + + if (callNodeIndex !== undefined) { + onSelectedCallNodeChange(callNodeIndex); } - default: - // We shouldn't arrive here, thanks to the if block at the top. - console.error( - `An unknown key "${event.key}" was pressed, this shouldn't happen.` - ); + } else { + // We shouldn't arrive here, thanks to the if block at the top. + console.error( + `An unknown key "${event.key}" was pressed, this shouldn't happen.` + ); } return; } @@ -271,6 +275,7 @@ export class FlameGraph callTreeSummaryStrategy, categories, interval, + startsAtBottom, innerWindowIDToPageMap, weightType, ctssSamples, @@ -314,7 +319,7 @@ export class FlameGraph maxViewportHeight, maximumZoom: 1, previewSelection, - startsAtBottom: true, + startsAtBottom, disableHorizontalMovement: true, viewportNeedsUpdate, marginLeft: 0, @@ -341,6 +346,7 @@ export class FlameGraph onDoubleClick: onCallNodeEnterOrDoubleClick, shouldDisplayTooltips: this._shouldDisplayTooltips, interval, + startsAtBottom, ctssSamples, ctssSampleCategoriesAndSubcategories, tracedTiming: tracedTimingNonInverted,