blob: dc36789308e40646adb99467d2fea666a7106482 [file] [edit]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import './flamegraph.scss';
import m from 'mithril';
import {ensureExists, assertTrue, assertUnreachable} from '../base/assert';
import {Monitor} from '../base/monitor';
import {Button, ButtonBar} from './button';
import {copyToClipboard} from '../base/clipboard';
import {EmptyState} from './empty_state';
import type {ExportFormat} from './export_button';
import {
formatAsTSV,
formatAsJSON,
formatAsMarkdown,
} from '../base/export_formatters';
import {Popup, PopupPosition} from './popup';
import {Spinner} from './spinner';
import type {Rect2D, Size2D} from '../base/geom';
import {
VirtualOverlayCanvas,
type VirtualOverlayCanvasApi,
} from './virtual_overlay_canvas';
import {MenuItem, type MenuItemAttrs, PopupMenu} from './menu';
import {type Color, HSLColor} from '../base/color';
import {hash} from '../base/hash';
import {escapeRegex} from './flamegraph_regex';
import type {MithrilEvent} from '../base/mithril_utils';
import {Icons} from '../base/semantic_icons';
import {
type ActionCategory,
FILTER_TYPES,
type FilterType,
type TreeExplorerData,
type TreeExplorerFilter,
type TreeExplorerMetric,
type TreeExplorerNode,
type TreeExplorerOptionalAction,
type TreeExplorerPropertyDefinition,
type TreeExplorerState,
addFilter,
displayPercentage,
displaySize,
getUnitDisplayName,
metricId,
} from './tree_explorer';
const LABEL_FONT_STYLE = '12px Roboto';
const NODE_HEIGHT = 20;
const MIN_PIXEL_DISPLAYED = 3;
const LABEL_PADDING_PX = 5;
const LABEL_MIN_WIDTH_FOR_TEXT_PX = 5;
const PADDING_NODE_COUNT = 8;
interface BaseSource {
readonly queryXStart: number;
readonly queryXEnd: number;
readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT';
}
interface MergedSource extends BaseSource {
readonly kind: 'MERGED';
}
interface RootSource extends BaseSource {
readonly kind: 'ROOT';
}
interface NodeSource extends BaseSource {
readonly kind: 'NODE';
readonly queryIdx: number;
}
type Source = MergedSource | NodeSource | RootSource;
interface RenderNode {
readonly x: number;
readonly y: number;
readonly width: number;
readonly source: Source;
readonly state: 'NORMAL' | 'PARTIAL' | 'SELECTED';
}
interface ZoomRegion {
readonly queryXStart: number;
readonly queryXEnd: number;
readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT';
}
const CATEGORY_LABELS: Record<ActionCategory, string> = {
FOCUS: 'Focus',
FILTER: 'Filter',
DRILL: 'Drill down',
COPY: 'Copy',
};
const CATEGORY_ORDER: ReadonlyArray<ActionCategory> = [
'FOCUS',
'FILTER',
'DRILL',
'COPY',
];
const CATEGORY_ICONS: Record<ActionCategory, string> = {
FOCUS: 'center_focus_weak',
FILTER: 'filter_list',
DRILL: 'open_in_new',
COPY: 'content_copy',
};
interface NodeAction {
readonly label: string;
readonly icon: string;
readonly description?: m.Children;
readonly category: ActionCategory;
execute(): void;
}
export interface FlamegraphAttrs {
readonly metrics: ReadonlyArray<TreeExplorerMetric>;
readonly state: TreeExplorerState;
readonly data: TreeExplorerData | undefined;
// Nodes whose name matches are drawn with a highlighted border. Owned by
// the caller so the (separate) filter bar can drive it.
readonly highlightRegex?: RegExp;
readonly onStateChange: (state: TreeExplorerState) => void;
}
/*
* Widget visualizing "tree-like" data structures using an interactive
* flamegraph visualization: just the canvas and its tooltip. The filtering
* bar lives in the separate TreeExplorerFilterBar widget; most callers should
* use TreeExplorerPanel (in components/) which composes the two and fetches
* the data.
*
* Note that it's valid to pass "undefined" as the data: this will cause a
* loading container to be shown.
*/
export class Flamegraph implements m.ClassComponent<FlamegraphAttrs> {
private attrs: FlamegraphAttrs;
private dataChangeMonitor = new Monitor([() => this.attrs.data]);
private zoomRegion?: ZoomRegion;
private canvasApi?: VirtualOverlayCanvasApi;
private pendingScrollToY?: number;
private renderNodesMonitor = new Monitor([
() => this.attrs.data,
() => this.canvasWidth,
() => this.zoomRegion,
]);
private renderNodes?: ReadonlyArray<RenderNode>;
private tooltipPos?: {
x: number;
y: number;
source: Source;
state: 'HOVER' | 'CLICK' | 'DECLICK';
};
private lastClickedNode?: RenderNode;
// Track hovered node by index to avoid redraws when mouse moves within same node.
// We also keep hoveredX/Y so we can re-find the node after render nodes change.
private hoveredNodeIdx?: number;
private hoveredX?: number;
private hoveredY?: number;
private canvasWidth = 0;
private labelCharWidth = 0;
private viewportRect?: Rect2D;
private lastPopupVisible = false;
constructor({attrs}: m.Vnode<FlamegraphAttrs, {}>) {
this.attrs = attrs;
}
oncreate() {
this.flushPendingScroll();
}
onupdate() {
this.flushPendingScroll();
}
private flushPendingScroll() {
if (this.pendingScrollToY === undefined || this.canvasApi === undefined) {
return;
}
const requestedY = this.pendingScrollToY;
const {y: appliedY} = this.canvasApi.scrollTo({y: requestedY});
if (requestedY !== Number.MAX_SAFE_INTEGER && appliedY !== requestedY) {
// Keep shallow graphs aligned to whole rows when the target is unreachable.
this.canvasApi.scrollTo({y: 0});
}
this.pendingScrollToY = undefined;
}
view({attrs}: m.Vnode<FlamegraphAttrs, this>): void | m.Children {
this.attrs = attrs;
if (this.dataChangeMonitor.ifStateChanged()) {
this.zoomRegion = undefined;
this.lastClickedNode = undefined;
this.tooltipPos = undefined;
// Auto-scroll so the root (depth 0) is visible. In TOP_DOWN the root
// sits at the top of the canvas; in BOTTOM_UP it sits near the bottom
// (only callers/leaves above it); in PIVOT it sits somewhere in the
// middle with callers above and callees below.
if (attrs.data !== undefined) {
if (attrs.state.view.kind === 'BOTTOM_UP') {
// Large value — the browser clamps to scrollHeight - clientHeight.
this.pendingScrollToY = Number.MAX_SAFE_INTEGER;
} else {
const rootY = -attrs.data.minDepth * NODE_HEIGHT;
this.pendingScrollToY = Math.max(0, rootY - NODE_HEIGHT);
}
}
}
if (attrs.data === undefined) {
this.canvasApi = undefined;
return m(
'.pf-flamegraph',
m(
'.loading-container',
m(
EmptyState,
{
icon: 'bar_chart',
title: 'Computing graph ...',
className: 'flamegraph-loading',
},
m(Spinner, {easing: true}),
),
),
);
}
const {minDepth, maxDepth} = attrs.data;
const canvasHeight =
Math.max(maxDepth - minDepth + PADDING_NODE_COUNT, PADDING_NODE_COUNT) *
NODE_HEIGHT;
const hoveredNode =
this.hoveredNodeIdx !== undefined
? this.renderNodes?.[this.hoveredNodeIdx]
: undefined;
return m(
'.pf-flamegraph',
m(
VirtualOverlayCanvas,
{
className: 'pf-virtual-canvas',
overflowX: 'hidden',
overflowY: 'auto',
onMount: (api) => {
this.canvasApi = api;
this.flushPendingScroll();
},
onscroll: (e: MithrilEvent<Event>) => {
// Only redraw if popup visibility would change
if (!this.tooltipPos) {
e.redraw = false;
return;
}
const target = e.target as HTMLElement;
const scrollTop = target.scrollTop;
const clientHeight = target.clientHeight;
const tooltipY = this.tooltipPos.y;
const nowVisible =
tooltipY >= scrollTop && tooltipY <= scrollTop + clientHeight;
if (nowVisible === this.lastPopupVisible) {
e.redraw = false;
}
},
onCanvasRedraw: ({
ctx,
virtualCanvasSize,
canvasRect,
viewportRect,
}) => {
this.drawCanvas(ctx, virtualCanvasSize, canvasRect, viewportRect);
},
},
m(
'div',
{
style: {
height: `${canvasHeight}px`,
cursor: hoveredNode === undefined ? 'default' : 'pointer',
},
onmousemove: (e: MithrilEvent<MouseEvent>) => {
const {offsetX, offsetY} = e;
this.hoveredX = offsetX;
this.hoveredY = offsetY;
const nodeIdx = this.renderNodes?.findIndex((n) =>
isIntersecting(offsetX, offsetY, n),
);
const newHoveredIdx =
nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined;
if (newHoveredIdx === this.hoveredNodeIdx) {
e.redraw = false;
return;
}
this.hoveredNodeIdx = newHoveredIdx;
if (this.tooltipPos?.state === 'CLICK') {
return;
}
if (newHoveredIdx === undefined) {
this.tooltipPos = undefined;
return;
}
const renderNode = this.renderNodes![newHoveredIdx];
this.tooltipPos = {
x: offsetX,
y: renderNode.y,
source: renderNode.source,
state: 'HOVER',
};
},
onmouseout: () => {
this.hoveredNodeIdx = undefined;
this.hoveredX = undefined;
this.hoveredY = undefined;
if (
this.tooltipPos?.state === 'HOVER' ||
this.tooltipPos?.state === 'DECLICK'
) {
this.tooltipPos = undefined;
}
},
onclick: ({offsetX, offsetY}: MouseEvent) => {
const renderNode = this.renderNodes?.find((n) =>
isIntersecting(offsetX, offsetY, n),
);
this.lastClickedNode = renderNode;
if (renderNode === undefined) {
this.tooltipPos = undefined;
} else if (
isIntersecting(
this.tooltipPos?.x,
this.tooltipPos?.y,
renderNode,
)
) {
this.tooltipPos!.state =
this.tooltipPos?.state === 'CLICK' ? 'DECLICK' : 'CLICK';
} else {
this.tooltipPos = {
x: offsetX,
y: renderNode.y,
source: renderNode.source,
state: 'CLICK',
};
}
},
ondblclick: ({offsetX, offsetY}: MouseEvent) => {
const renderNode = this.renderNodes?.find((n) =>
isIntersecting(offsetX, offsetY, n),
);
if (renderNode?.source.kind === 'MERGED') {
return;
}
this.zoomRegion = renderNode?.source;
},
},
(() => {
const popupVisible =
this.isPopupAnchorVisible() &&
(this.tooltipPos?.state === 'HOVER' ||
this.tooltipPos?.state === 'CLICK');
this.lastPopupVisible = popupVisible;
return m(
Popup,
{
trigger: m('.popup-anchor', {
style: {
left: this.tooltipPos?.x + 'px',
top: this.tooltipPos?.y + 'px',
},
}),
fitContent: true,
position: PopupPosition.Right,
isOpen: popupVisible,
className: 'pf-flamegraph-tooltip-popup',
offset: NODE_HEIGHT,
},
this.renderTooltip(),
);
})(),
),
),
);
}
private drawCanvas(
ctx: CanvasRenderingContext2D,
size: Size2D,
canvasRect: Rect2D,
viewportRect: Rect2D,
) {
this.viewportRect = viewportRect;
this.canvasWidth = size.width;
if (this.renderNodesMonitor.ifStateChanged()) {
if (this.attrs.data === undefined) {
this.renderNodes = undefined;
this.lastClickedNode = undefined;
} else {
this.renderNodes = computeRenderNodes(
this.attrs.data,
this.zoomRegion ?? {
queryXStart: 0,
queryXEnd: this.attrs.data.allRootsCumulativeValue,
type: 'ROOT',
},
size.width,
);
this.lastClickedNode = this.renderNodes?.find((n) =>
isIntersecting(this.lastClickedNode?.x, this.lastClickedNode?.y, n),
);
}
this.tooltipPos = undefined;
// Re-find hovered node using stored coordinates
const nodeIdx = this.renderNodes?.findIndex((n) =>
isIntersecting(this.hoveredX, this.hoveredY, n),
);
this.hoveredNodeIdx =
nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined;
}
if (this.attrs.data === undefined || this.renderNodes === undefined) {
return;
}
const yStart = canvasRect.top;
const yEnd = canvasRect.bottom;
const {allRootsCumulativeValue, unfilteredCumulativeValue, nodes} =
this.attrs.data;
const unit = ensureExists(this.selectedMetric).unit;
ctx.font = LABEL_FONT_STYLE;
ctx.textBaseline = 'middle';
ctx.strokeStyle = 'white';
ctx.lineWidth = 0.5;
if (this.labelCharWidth === 0) {
this.labelCharWidth = ctx.measureText('_').width;
}
const highlightColor = getComputedStyle(ctx.canvas)
.getPropertyValue('--pf-color-accent')
.trim();
for (let i = 0; i < this.renderNodes.length; i++) {
const node = this.renderNodes[i];
const {x, y, width: width, source, state} = node;
if (y + NODE_HEIGHT <= yStart || y >= yEnd) {
continue;
}
const hover = isIntersecting(this.hoveredX, this.hoveredY, node);
let name: string;
let colorScheme;
if (source.kind === 'ROOT') {
const val = displaySize(allRootsCumulativeValue, unit);
const percent = displayPercentage(
allRootsCumulativeValue,
unfilteredCumulativeValue,
);
name = `root: ${val} (${percent})`;
colorScheme = getFlamegraphColorScheme('root', state === 'PARTIAL');
} else if (source.kind === 'MERGED') {
name = '(merged)';
colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
} else {
name = nodes[source.queryIdx].name;
colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
}
const highlighted =
source.kind === 'NODE' &&
this.attrs.highlightRegex?.test(name) === true;
const bgColor = hover ? colorScheme.variant : colorScheme.base;
const textColor = hover ? colorScheme.textVariant : colorScheme.textBase;
ctx.fillStyle = bgColor.cssString;
ctx.fillRect(x, y, width - 1, NODE_HEIGHT - 1);
// Render marker
const MARKER_SIZE = 3;
const MARKER_LEFT_MARGIN = 2;
const MIN_WIDTH_FOR_MARKER = 15; // Don't show marker on very small nodes
const hasMarker =
source.kind === 'NODE' &&
nodes[source.queryIdx].marker !== undefined &&
width >= MIN_WIDTH_FOR_MARKER;
if (hasMarker) {
ctx.fillStyle = textColor.cssString;
const markerX = x + MARKER_LEFT_MARGIN;
const markerY = y + 2; // Position at top of node with small margin
ctx.fillRect(markerX, markerY, MARKER_SIZE, MARKER_SIZE);
}
// Text positioning - no need to reserve space since marker is in top corner
const widthNoPadding = width - LABEL_PADDING_PX * 2;
if (widthNoPadding >= LABEL_MIN_WIDTH_FOR_TEXT_PX) {
ctx.fillStyle = textColor.cssString;
ctx.fillText(
name.substring(0, widthNoPadding / this.labelCharWidth),
x + LABEL_PADDING_PX,
y + (NODE_HEIGHT - 1) / 2,
widthNoPadding,
);
}
if (highlighted) {
const highlightWidth = Math.max(0, width - 3);
const highlightHeight = NODE_HEIGHT - 3;
ctx.fillStyle = highlightColor || textColor.cssString;
ctx.fillRect(x + 1, y + 1, highlightWidth, 2);
ctx.fillRect(x + 1, y + NODE_HEIGHT - 3, highlightWidth, 2);
ctx.fillRect(x + 1, y + 1, 2, highlightHeight);
ctx.fillRect(x + width - 3, y + 1, 2, highlightHeight);
}
if (this.lastClickedNode?.x === x && this.lastClickedNode?.y === y) {
ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + width, y);
ctx.lineTo(x + width, y + NODE_HEIGHT - 1);
ctx.lineTo(x, y + NODE_HEIGHT - 1);
ctx.lineTo(x, y);
ctx.stroke();
ctx.strokeStyle = 'white';
ctx.lineWidth = 0.5;
}
}
}
private isPopupAnchorVisible(): boolean {
if (!this.tooltipPos || !this.viewportRect) {
return false;
}
const {y} = this.tooltipPos;
return y >= this.viewportRect.top && y <= this.viewportRect.bottom;
}
private renderTooltip() {
if (this.tooltipPos === undefined) {
return undefined;
}
const {source} = this.tooltipPos;
if (source.kind === 'MERGED') {
return m(
'div',
m('.tooltip-bold-text', '(merged)'),
m('.tooltip-text', 'Nodes too small to show, please use filters'),
);
}
const {
nodes,
allRootsCumulativeValue,
unfilteredCumulativeValue,
nodeActions,
rootActions,
} = ensureExists(this.attrs.data);
const {unit, nameColumnLabel} = ensureExists(this.selectedMetric);
if (source.kind === 'ROOT') {
const val = displaySize(allRootsCumulativeValue, unit);
const percent = displayPercentage(
allRootsCumulativeValue,
unfilteredCumulativeValue,
);
return m(
'div',
m('.tooltip-bold-text', 'root'),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Cumulative:'),
m('.tooltip-text', `${val}, ${percent}`),
this.renderActionsMenu(rootActions, new Map()),
),
);
}
const {queryIdx} = source;
const {
name,
cumulativeValue,
selfValue,
parentCumulativeValue,
properties,
marker,
} = nodes[queryIdx];
const percent = displayPercentage(
cumulativeValue,
unfilteredCumulativeValue,
);
const selfPercent = displayPercentage(selfValue, unfilteredCumulativeValue);
let percentText = `all: ${percent}`;
let selfPercentText = `all: ${selfPercent}`;
if (parentCumulativeValue !== undefined) {
const parentPercent = displayPercentage(
cumulativeValue,
parentCumulativeValue,
);
percentText += `, parent: ${parentPercent}`;
const parentSelfPercent = displayPercentage(
selfValue,
parentCumulativeValue,
);
selfPercentText += `, parent: ${parentSelfPercent}`;
}
const nameLabel = nameColumnLabel ?? 'Name';
return m(
'div',
// Show marker at the top of the tooltip
marker &&
m('.tooltip-text-line', m('.tooltip-marker-text', `â–  ${marker}`)),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', `${nameLabel}:`),
m('.tooltip-text', name),
),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Cumulative:'),
m(
'.tooltip-text',
`${displaySize(cumulativeValue, unit)} (${percentText})`,
),
),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Self:'),
m(
'.tooltip-text',
`${displaySize(selfValue, unit)} (${selfPercentText})`,
),
),
Array.from(properties, ([_, value]) => {
if (value.isVisible) {
return m(
'.tooltip-text-line',
m('.tooltip-bold-text', value.displayName + ':'),
m('.tooltip-text', value.value),
);
}
return null;
}),
this.renderNodeActionBar(source, name, nodes[queryIdx], nodeActions),
);
}
// One dropdown per category. Built-ins come from buildNodeActions; flat
// embedder actions slot into their declared category, nested/disabled ones
// keep the renderMenuItem path under Drill down.
private renderNodeActionBar(
source: NodeSource,
name: string,
node: TreeExplorerNode,
nodeActions: ReadonlyArray<TreeExplorerOptionalAction>,
) {
const {properties} = node;
const builtIn = this.buildNodeActions(source, name, node);
const isFlat = (a: TreeExplorerOptionalAction) =>
a.execute !== undefined &&
(a.subActions === undefined || a.subActions.length === 0);
const embedderFlat: NodeAction[] = nodeActions.filter(isFlat).map((a) => ({
label: a.name,
icon: a.icon ?? 'open_in_new',
description: a.description,
category: a.category ?? 'DRILL',
execute: () => {
a.execute!({
properties: this.createReducedProperties(properties),
node,
});
this.tooltipPos = undefined;
},
}));
const embedderComplex = nodeActions
.filter((a) => !isFlat(a))
.map((a) => this.renderMenuItem(a, properties, node));
const actions = [...builtIn, ...embedderFlat];
return m(
ButtonBar,
{className: 'pf-flamegraph-action-bar'},
CATEGORY_ORDER.map((cat) => {
const items = actions.filter((a) => a.category === cat);
const extra = cat === 'DRILL' ? embedderComplex : [];
if (items.length === 0 && extra.length === 0) return null;
return m(
PopupMenu,
{
trigger: m(Button, {
label: CATEGORY_LABELS[cat],
icon: CATEGORY_ICONS[cat],
rightIcon: 'arrow_drop_down',
compact: true,
}),
position: PopupPosition.Bottom,
className: 'pf-popup-menu pf-flamegraph-action-menu',
},
items.map((a) => this.renderNodeActionItem(a)),
extra,
);
}),
);
}
private renderNodeActionItem(a: NodeAction): m.Children {
return m(MenuItem, {
icon: a.icon,
label: this.actionItemLabel(a.label, a.description),
onclick: a.execute,
});
}
// Two-line menu label: name on top, muted description beneath. A name with no
// description renders as a plain single line.
private actionItemLabel(
label: m.Children,
description?: m.Children,
): m.Children {
if (description == null) return label;
return m(
'.pf-flamegraph-action',
m('.pf-flamegraph-action__title', label),
m('.pf-flamegraph-action__desc', description),
);
}
private buildNodeActions(
source: NodeSource,
name: string,
node: TreeExplorerNode,
): NodeAction[] {
const applyState = (state: TreeExplorerState) => {
this.attrs.onStateChange(state);
this.tooltipPos = undefined;
};
const addF = (kind: TreeExplorerFilter['kind'], filter: string) =>
applyState(addFilter(this.attrs.state, {kind, filter}));
// Match this exact name: an anchored regex over the escaped literal name,
// wrapped in `/…/` so the filter bar reads it as a regex.
const exactNameRegex = `/^${escapeRegex(name)}$/`;
const ft = (v: FilterType) =>
ensureExists(FILTER_TYPES.find((o) => o.value === v));
const filterAction = (v: FilterType, execute: () => void): NodeAction => {
const o = ft(v);
return {
label: o.friendlyLabel,
icon: o.icon,
category: o.category,
description: [
o.description,
o.aka !== undefined && [
' ',
m(
'span.pf-flamegraph-action__aka',
`Also called "${o.aka}" in other profilers.`,
),
],
],
execute,
};
};
return [
{
label: 'Zoom in',
icon: 'zoom_in',
category: 'FOCUS',
description:
'Enlarge this branch. Nothing is removed, so you can zoom out anytime.',
execute: () => {
this.zoomRegion = source;
},
},
{
label: 'Show from this frame',
icon: 'center_focus_strong',
category: 'FOCUS',
description:
'Re-root at this frame and show its descendants, dropping ancestors.',
execute: () =>
applyState({
...this.attrs.state,
view: {
kind: 'FROM_FRAME',
pattern: exactNameRegex,
displayLabel: name,
},
}),
},
{
label: 'Pivot on this frame',
icon: 'account_tree',
category: 'FOCUS',
description:
'Re-root at this frame with callers above and callees below.',
execute: () =>
applyState({
...this.attrs.state,
view: {
kind: 'PIVOT',
pivot: exactNameRegex,
displayLabel: name,
},
}),
},
filterAction('SHOW_STACK', () => addF('SHOW_STACK', exactNameRegex)),
filterAction('HIDE_STACK', () => addF('HIDE_STACK', exactNameRegex)),
filterAction('HIDE_FRAME', () => addF('HIDE_FRAME', exactNameRegex)),
{
label: 'Copy stack',
icon: Icons.Copy,
category: 'COPY',
description: 'Copy this stack as text.',
execute: () => copyToClipboard(this.buildStackString(node, false)),
},
{
label: 'Copy stack with details',
icon: Icons.Copy,
category: 'COPY',
description: 'Copy this stack with per-frame metrics and columns.',
execute: () => copyToClipboard(this.buildStackString(node, true)),
},
];
}
private get selectedMetric() {
return this.attrs.metrics.find(
(x) => metricId(x) === this.attrs.state.selectedMetricId,
);
}
// Root-only actions menu; node actions go through renderNodeActionBar.
private renderActionsMenu(
actions: ReadonlyArray<TreeExplorerOptionalAction>,
properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>,
) {
if (actions.length === 0) {
return null;
}
return m(
PopupMenu,
{
trigger: m(Button, {
icon: 'menu',
compact: true,
}),
position: PopupPosition.Bottom,
},
actions.map((action) => this.renderMenuItem(action, properties)),
);
}
private renderMenuItem(
action: TreeExplorerOptionalAction,
properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>,
node?: TreeExplorerNode,
): m.Vnode<MenuItemAttrs> {
if (action.subActions !== undefined && action.subActions.length > 0) {
return this.renderParentMenuItem(
action,
action.subActions,
properties,
node,
);
} else if (action.execute) {
return this.renderExecutableMenuItem(action, properties, node);
} else {
return this.renderDisabledMenuItem(action);
}
}
private renderParentMenuItem(
action: TreeExplorerOptionalAction,
subActions: TreeExplorerOptionalAction[],
properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>,
node?: TreeExplorerNode,
): m.Vnode<MenuItemAttrs> {
return m(
MenuItem,
{
label: this.actionItemLabel(action.name, action.description),
icon: action.icon,
// No onclick handler for parent menu items
},
// Directly render sub-actions as children of the MenuItem
subActions.map((subAction) =>
this.renderMenuItem(subAction, properties, node),
),
);
}
private renderExecutableMenuItem(
action: TreeExplorerOptionalAction,
properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>,
node?: TreeExplorerNode,
): m.Vnode<MenuItemAttrs> {
return m(MenuItem, {
label: this.actionItemLabel(action.name, action.description),
icon: action.icon,
onclick: () => {
action.execute!({
properties: this.createReducedProperties(properties),
node,
});
this.tooltipPos = undefined; // Close tooltip after action
},
});
}
private renderDisabledMenuItem(
action: TreeExplorerOptionalAction,
): m.Vnode<MenuItemAttrs> {
return m(MenuItem, {
label: this.actionItemLabel(action.name, action.description),
icon: action.icon,
disabled: true,
});
}
private buildStackString(
node: TreeExplorerNode,
withDetails: boolean,
): string {
const {nodes, unfilteredCumulativeValue} = ensureExists(this.attrs.data);
const metric = ensureExists(this.selectedMetric);
const view = this.attrs.state.view;
// Walk via parentId for all modes. Reverse for TOP_DOWN and PIVOT below.
const stack: TreeExplorerNode[] = [];
let currentId = node.id;
while (currentId !== -1) {
const current = ensureExists(nodes.find((n) => n.id === currentId));
stack.push(current);
currentId = current.parentId;
}
const shouldReverse =
view.kind === 'TOP_DOWN' ||
view.kind === 'FROM_FRAME' ||
(view.kind === 'PIVOT' && node.depth > 0);
if (shouldReverse) {
stack.reverse();
}
if (!withDetails) {
return stack.map((n) => n.name).join('\n');
}
// Collect all unique property keys, separated by aggregatable status
const unaggKeys: string[] = [];
const aggKeys: string[] = [];
for (const entry of stack) {
for (const [key, prop] of entry.properties) {
if (prop.isAggregatable) {
if (!aggKeys.includes(key)) {
aggKeys.push(key);
}
} else {
if (!unaggKeys.includes(key)) {
unaggKeys.push(key);
}
}
}
}
// Helper to get display name for a property key
const getDisplayName = (key: string): string => {
for (const entry of stack) {
const prop = entry.properties.get(key);
if (prop !== undefined) {
return prop.displayName;
}
}
return key;
};
// Build header: Name | Non-agg props | Cumulative | Self | Agg props
const nameLabel = metric.nameColumnLabel ?? 'Name';
const unitDisplay = getUnitDisplayName(metric.unit);
const headers = [nameLabel];
for (const key of unaggKeys) {
headers.push(getDisplayName(key));
}
headers.push(
`Cumulative ${metric.name} (${unitDisplay})`,
`Self ${metric.name} (${unitDisplay})`,
);
for (const key of aggKeys) {
headers.push(getDisplayName(key));
}
// Format as markdown table
const lines: string[] = [];
lines.push('| ' + headers.join(' | ') + ' |');
lines.push('|' + headers.map(() => '------').join('|') + '|');
for (const entry of stack) {
const cumulative = displaySize(entry.cumulativeValue, metric.unit);
const cumulativePercent = displayPercentage(
entry.cumulativeValue,
unfilteredCumulativeValue,
);
const self = displaySize(entry.selfValue, metric.unit);
const selfPercent = displayPercentage(
entry.selfValue,
unfilteredCumulativeValue,
);
const cols = [entry.name];
for (const key of unaggKeys) {
cols.push(entry.properties.get(key)?.value ?? '');
}
cols.push(
`${cumulative} (${cumulativePercent})`,
`${self} (${selfPercent})`,
);
for (const key of aggKeys) {
cols.push(entry.properties.get(key)?.value ?? '');
}
lines.push('| ' + cols.join(' | ') + ' |');
}
return lines.join('\n');
}
private createReducedProperties(
properties: ReadonlyMap<string, TreeExplorerPropertyDefinition>,
): ReadonlyMap<string, string> {
return new Map([...properties].map(([key, {value}]) => [key, value]));
}
}
// Builds a table of all currently displayed nodes (i.e. with filters
// applied) in the given format. Metric values are raw numbers in the
// metric's unit so they can be aggregated in spreadsheets; the tree
// structure is preserved via the id/parentId columns.
export function buildFlamegraphExportString(
data: TreeExplorerData,
metric: TreeExplorerMetric,
format: ExportFormat,
): string {
const {nodes} = data;
const unitDisplay = getUnitDisplayName(metric.unit);
const unaggKeys: string[] = [];
const aggKeys: string[] = [];
const propDisplayNames = new Map<string, string>();
for (const node of nodes) {
for (const [key, prop] of node.properties) {
const keys = prop.isAggregatable ? aggKeys : unaggKeys;
if (!keys.includes(key)) {
keys.push(key);
propDisplayNames.set(key, prop.displayName);
}
}
}
const columns = [
'id',
'parentId',
'depth',
'name',
...unaggKeys,
'cumulativeValue',
'selfValue',
...aggKeys,
];
const columnNames: Record<string, string> = {
...Object.fromEntries(propDisplayNames),
id: 'Id',
parentId: 'Parent Id',
depth: 'Depth',
name: metric.nameColumnLabel ?? 'Name',
cumulativeValue: `Cumulative ${metric.name} (${unitDisplay})`,
selfValue: `Self ${metric.name} (${unitDisplay})`,
};
const rows = nodes.map((n) => {
const row: Record<string, string> = {
id: n.id.toString(),
parentId: n.parentId.toString(),
depth: n.depth.toString(),
name: n.name,
cumulativeValue: n.cumulativeValue.toString(),
selfValue: n.selfValue.toString(),
};
for (const key of [...unaggKeys, ...aggKeys]) {
row[key] = n.properties.get(key)?.value ?? '';
}
return row;
});
switch (format) {
case 'tsv':
return formatAsTSV(columns, columnNames, rows);
case 'json':
return formatAsJSON(columns, columnNames, rows);
case 'markdown':
return formatAsMarkdown(columns, columnNames, rows);
default:
assertUnreachable(format);
}
}
function computeRenderNodes(
{nodes, allRootsCumulativeValue, minDepth}: TreeExplorerData,
zoomRegion: ZoomRegion,
canvasWidth: number,
): ReadonlyArray<RenderNode> {
const renderNodes: RenderNode[] = [];
const mergedKeyToX = new Map<string, number>();
const keyToChildMergedIdx = new Map<string, number>();
renderNodes.push({
x: 0,
y: -minDepth * NODE_HEIGHT,
width: canvasWidth,
source: {
kind: 'ROOT',
queryXStart: 0,
queryXEnd: allRootsCumulativeValue,
type: 'ROOT',
},
state:
zoomRegion.queryXStart === 0 &&
zoomRegion.queryXEnd === allRootsCumulativeValue
? 'NORMAL'
: 'PARTIAL',
});
const zoomQueryWidth = zoomRegion.queryXEnd - zoomRegion.queryXStart;
for (let i = 0; i < nodes.length; i++) {
const {id, parentId, depth, xStart: qXStart, xEnd: qXEnd} = nodes[i];
assertTrue(depth !== 0);
const depthMatchingZoom = isDepthMatchingZoom(depth, zoomRegion);
if (
depthMatchingZoom &&
(qXEnd <= zoomRegion.queryXStart || qXStart >= zoomRegion.queryXEnd)
) {
continue;
}
const queryXPerPx = depthMatchingZoom
? zoomQueryWidth / canvasWidth
: allRootsCumulativeValue / canvasWidth;
const relativeXStart = depthMatchingZoom
? qXStart - zoomRegion.queryXStart
: qXStart;
const relativeXEnd = depthMatchingZoom
? qXEnd - zoomRegion.queryXStart
: qXEnd;
const relativeWidth = relativeXEnd - relativeXStart;
const x = Math.max(0, relativeXStart) / queryXPerPx;
const y = NODE_HEIGHT * (depth - minDepth);
const width = depthMatchingZoom
? Math.min(relativeWidth, zoomQueryWidth) / queryXPerPx
: relativeWidth / queryXPerPx;
const state = computeState(qXStart, qXEnd, zoomRegion, depthMatchingZoom);
if (width < MIN_PIXEL_DISPLAYED) {
// Check if parent was merged - if so, use x-position-based key so that
// children of different parents that were merged together also merge.
// This enables recursive merging: if parents A and B merged into the
// same visual node, their children should also merge together.
const parentMergedX = mergedKeyToX.get(`${parentId}_${depth}`);
const parentChildMergeKey =
parentMergedX !== undefined
? `x_${Math.round(parentMergedX)}_${depth}`
: `p_${parentId}_${depth}`;
const mergedXKey = `${id}_${depth > 0 ? depth + 1 : depth - 1}`;
const childMergedIdx = keyToChildMergedIdx.get(parentChildMergeKey);
if (childMergedIdx !== undefined) {
const r = renderNodes[childMergedIdx];
const mergedWidth = isDepthMatchingZoom(depth, zoomRegion)
? Math.min(qXEnd - r.source.queryXStart, zoomQueryWidth) / queryXPerPx
: (qXEnd - r.source.queryXStart) / queryXPerPx;
renderNodes[childMergedIdx] = {
...r,
width: Math.max(mergedWidth, MIN_PIXEL_DISPLAYED),
source: {
...(r.source as MergedSource),
queryXEnd: qXEnd,
},
};
mergedKeyToX.set(mergedXKey, r.x);
continue;
}
const mergedX = parentMergedX ?? x;
renderNodes.push({
x: mergedX,
y,
width: Math.max(width, MIN_PIXEL_DISPLAYED),
source: {
kind: 'MERGED',
queryXStart: qXStart,
queryXEnd: qXEnd,
type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT',
},
state,
});
keyToChildMergedIdx.set(parentChildMergeKey, renderNodes.length - 1);
mergedKeyToX.set(mergedXKey, mergedX);
continue;
}
renderNodes.push({
x,
y,
width,
source: {
kind: 'NODE',
queryXStart: qXStart,
queryXEnd: qXEnd,
queryIdx: i,
type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT',
},
state,
});
}
return renderNodes;
}
function isDepthMatchingZoom(depth: number, zoomRegion: ZoomRegion): boolean {
assertTrue(
depth !== 0,
'Handling zooming root not possible in this function',
);
return (
(depth > 0 && zoomRegion.type === 'BELOW_ROOT') ||
(depth < 0 && zoomRegion.type === 'ABOVE_ROOT')
);
}
function computeState(
qXStart: number,
qXEnd: number,
zoomRegion: ZoomRegion,
isDepthMatchingZoom: boolean,
) {
if (!isDepthMatchingZoom) {
return 'NORMAL';
}
if (qXStart === zoomRegion.queryXStart && qXEnd === zoomRegion.queryXEnd) {
return 'SELECTED';
}
if (qXStart < zoomRegion.queryXStart || qXEnd > zoomRegion.queryXEnd) {
return 'PARTIAL';
}
return 'NORMAL';
}
function isIntersecting(
needleX: number | undefined,
needleY: number | undefined,
{x, y, width}: RenderNode,
) {
if (needleX === undefined || needleY === undefined) {
return false;
}
return (
needleX >= x &&
needleX < x + width &&
needleY >= y &&
needleY < y + NODE_HEIGHT
);
}
// Unfortunately, widgets *cannot* depend on components so we cannot use the
// colorizer code. Since we need very little of that code anyway, just inline
// what we need here.
const PERCEIVED_BRIGHTNESS_LIMIT = 180;
const WHITE_COLOR = new HSLColor([0, 0, 100]);
const BLACK_COLOR = new HSLColor([0, 0, 0]);
// Lightness 85 ensures even darken(10) stays above brightness threshold for black text
const GRAY_VARIANT_COLOR = new HSLColor([0, 0, 85]);
interface ColorScheme {
readonly base: Color;
readonly variant: Color;
readonly textBase: Color;
readonly textVariant: Color;
}
function makeColorScheme(base: Color, variant: Color): ColorScheme {
// Use the same text color for both base and variant to prevent text color
// switching on hover. The text color is determined by the base color only.
const textColor =
base.perceivedBrightness >= PERCEIVED_BRIGHTNESS_LIMIT
? BLACK_COLOR
: WHITE_COLOR;
return {
base,
variant,
textBase: textColor,
textVariant: textColor,
};
}
// Pre-computed color schemes for special cases
const GREYED_COLOR_SCHEME = makeColorScheme(
GRAY_VARIANT_COLOR,
GRAY_VARIANT_COLOR.darken(5),
);
const ROOT_COLOR_SCHEME = makeColorScheme(
GRAY_VARIANT_COLOR.darken(10),
GRAY_VARIANT_COLOR.darken(15),
);
// Cache for computed color schemes by name
const colorSchemeCache = new Map<string, ColorScheme>();
function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme {
if (greyed) {
return GREYED_COLOR_SCHEME;
}
if (name === 'unknown' || name === 'root') {
return ROOT_COLOR_SCHEME;
}
// Check cache first
let scheme = colorSchemeCache.get(name);
if (scheme !== undefined) {
return scheme;
}
// Hash the name to get a predictable hue, then create color with fixed
// saturation and lightness values to match what pprof web UI does.
const hue = hash(name, 360);
const base = new HSLColor({h: hue, s: 46, l: 80});
scheme = makeColorScheme(base, base.darken(15).saturate(15));
colorSchemeCache.set(name, scheme);
return scheme;
}