ui: add a table view to flamegraphs

Add a Flame/Table toggle to the flamegraph filter bar. Table mode shows
the same tree as the canvas — one expandable row per node with self and
cumulative values and the share of the root total — so metric selection,
filters and the top-down/bottom-up/pivot views apply to both modes.

The table is a DataGrid in id-based tree mode. QueryFlamegraph backs it
with a source over the nodes the canvas already holds, so nothing extra
is loaded: expanding walks only the visible part of the tree and the
grid virtualises the DOM. Callers without a resident node array can pass
a SQL data source instead, which fetches one level per expansion. The
Flamegraph widget delegates the rendering to a renderTableView hook,
keeping the widgets layer free of DataGrid dependencies.

Change-Id: Idb509db6acd2fb22cefd51d7775628251aeb56b8
diff --git a/ui/src/components/flamegraph_table.ts b/ui/src/components/flamegraph_table.ts
new file mode 100644
index 0000000..9a9733a
--- /dev/null
+++ b/ui/src/components/flamegraph_table.ts
@@ -0,0 +1,177 @@
+// Copyright (C) 2026 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 m from 'mithril';
+
+import {displaySize} from '../widgets/flamegraph';
+import type {FlamegraphNode, FlamegraphQueryData} from '../widgets/flamegraph';
+import {DataGrid} from './widgets/datagrid/datagrid';
+import {InMemoryDataSource} from './widgets/datagrid/in_memory_data_source';
+import type {
+  DataSource,
+  DataSourceModel,
+  DataSourceRows,
+} from './widgets/datagrid/data_source';
+import type {IdBasedTree} from './widgets/datagrid/model';
+import type {Row, SqlValue} from '../trace_processor/query_result';
+
+const DEFAULT_TREE: IdBasedTree = {
+  idField: 'id',
+  parentIdField: 'parentId',
+  treeColumn: 'name',
+  expandedIds: new Set<bigint>(),
+};
+
+export interface FlamegraphTableAttrs {
+  // Tree-capable DataGrid source: FlamegraphTreeDataSource over resident
+  // nodes, or a SQL source that fetches one level per expansion.
+  readonly source: DataSource;
+  readonly unit: string;
+  readonly tree: IdBasedTree | undefined;
+  readonly onTreeChanged: (tree: IdBasedTree | undefined) => void;
+}
+
+// Tree table over a flamegraph: one expandable row per node with self and
+// cumulative values and share of the root total. DataGrid id-based tree mode
+// keeps expansion O(visible rows) and the DOM virtualised.
+export class FlamegraphTable implements m.ClassComponent<FlamegraphTableAttrs> {
+  view({attrs}: m.CVnode<FlamegraphTableAttrs>): m.Children {
+    const {unit} = attrs;
+    const fmtValue = (value: SqlValue) =>
+      typeof value === 'number' ? displaySize(value, unit) : '';
+    return m(DataGrid, {
+      className: 'pf-flamegraph-table__grid',
+      fillHeight: true,
+      // The flamegraph's own filter bar is the filtering surface; row-level
+      // grid filters have no meaning on a tree walk.
+      disableFilterControls: true,
+      schema: {
+        name: {title: 'Name', columnType: 'text'},
+        total: {
+          title: 'Total',
+          columnType: 'quantitative',
+          cellRenderer: fmtValue,
+        },
+        self: {
+          title: 'Self',
+          columnType: 'quantitative',
+          cellRenderer: fmtValue,
+        },
+        percent: {
+          title: '% of total',
+          columnType: 'quantitative',
+          cellRenderer: (value: SqlValue) =>
+            typeof value === 'number' ? `${value.toFixed(1)}%` : '',
+        },
+      },
+      data: attrs.source,
+      initialColumns: [
+        {id: 'name', field: 'name'},
+        {id: 'total', field: 'total', sort: 'DESC'},
+        {id: 'self', field: 'self'},
+        {id: 'percent', field: 'percent'},
+      ],
+      tree: attrs.tree ?? DEFAULT_TREE,
+      onTreeChanged: attrs.onTreeChanged,
+    });
+  }
+}
+
+// Tree-mode source over the flamegraph nodes the canvas already holds.
+// Children are indexed once; useRows() walks only the expanded rows, sorting
+// siblings by the requested column (total descending by default).
+export class FlamegraphTreeDataSource extends InMemoryDataSource {
+  private readonly children = new Map<number, FlamegraphNode[]>();
+  private readonly roots: FlamegraphNode[] = [];
+  private readonly total: number;
+
+  constructor(data: FlamegraphQueryData) {
+    super([]);
+    this.total = data.allRootsCumulativeValue;
+    const ids = new Set(data.nodes.map((n) => n.id));
+    for (const n of data.nodes) {
+      if (ids.has(n.parentId)) {
+        let siblings = this.children.get(n.parentId);
+        if (siblings === undefined) {
+          siblings = [];
+          this.children.set(n.parentId, siblings);
+        }
+        siblings.push(n);
+      } else {
+        this.roots.push(n);
+      }
+    }
+  }
+
+  useRows(model: DataSourceModel): DataSourceRows {
+    if (model.mode !== 'tree') {
+      return super.useRows(model);
+    }
+    const rows = this.visibleRows(model);
+    return {rows, totalRows: rows.length, isPending: false};
+  }
+
+  exportData(model: DataSourceModel): Promise<readonly Row[]> {
+    if (model.mode !== 'tree') {
+      return super.exportData(model);
+    }
+    return Promise.resolve(this.visibleRows(model));
+  }
+
+  private visibleRows(model: DataSourceModel & {mode: 'tree'}): readonly Row[] {
+    const {expandedIds, collapsedIds} = model.tree;
+    const isExpanded = (id: number) =>
+      collapsedIds !== undefined
+        ? !collapsedIds.has(BigInt(id))
+        : (expandedIds?.has(BigInt(id)) ?? false);
+    const cmp = this.comparator(model.sort);
+    const rows: Row[] = [];
+    const visit = (nodes: FlamegraphNode[], depth: number) => {
+      for (const n of [...nodes].sort(cmp)) {
+        const children = this.children.get(n.id);
+        rows.push({
+          id: n.id,
+          parentId: n.parentId,
+          name: n.name,
+          total: n.cumulativeValue,
+          self: n.selfValue,
+          percent:
+            this.total === 0 ? 0 : (n.cumulativeValue / this.total) * 100,
+          __id: n.id,
+          __depth: depth,
+          __has_children: children === undefined ? 0 : 1,
+        });
+        if (children !== undefined && isExpanded(n.id)) {
+          visit(children, depth + 1);
+        }
+      }
+    };
+    visit(this.roots, 0);
+    return rows;
+  }
+
+  private comparator(
+    sort: {alias: string; direction: 'ASC' | 'DESC'} | undefined,
+  ): (a: FlamegraphNode, b: FlamegraphNode) => number {
+    const dir = sort?.direction === 'ASC' ? 1 : -1;
+    switch (sort?.alias) {
+      case 'name':
+        return (a, b) => dir * a.name.localeCompare(b.name);
+      case 'self':
+        return (a, b) => dir * (a.selfValue - b.selfValue);
+      default: // 'total', 'percent' and the initial (unsorted) state.
+        return (a, b) => dir * (a.cumulativeValue - b.cumulativeValue);
+    }
+  }
+}
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts
index bf895fc..8c571ed 100644
--- a/ui/src/components/query_flamegraph.ts
+++ b/ui/src/components/query_flamegraph.ts
@@ -40,6 +40,8 @@
   type FlamegraphOptionalMarker,
 } from '../widgets/flamegraph';
 import type {Trace} from '../public/trace';
+import {FlamegraphTable, FlamegraphTreeDataSource} from './flamegraph_table';
+import type {IdBasedTree} from './widgets/datagrid/model';
 import {sqliteString} from '../base/string_utils';
 import {userFilterToRegex} from '../widgets/flamegraph_regex';
 import {SharedAsyncDisposable} from '../base/shared_disposable';
@@ -195,14 +197,21 @@
 // data for the widget by querying an `Engine`.
 export class QueryFlamegraph implements AsyncDisposable {
   private data?: FlamegraphQueryData;
+  private tableTree?: IdBasedTree;
+  private tableSource?: FlamegraphTreeDataSource;
+  private tableSourceData?: FlamegraphQueryData;
   private readonly queryLimiter = new AsyncLimiter();
   private readonly dependencies: ReadonlyArray<
     SharedAsyncDisposable<AsyncDisposable>
   >;
   private lastAttrs?: QueryFlamegraphAttrs;
+  // displayMode is excluded: toggling Flame/Table must not refetch.
   private monitor = new Monitor([
     () => this.lastAttrs?.metrics,
-    () => this.lastAttrs?.state,
+    () => this.lastAttrs?.state?.filters,
+    () => this.lastAttrs?.state?.view,
+    () => this.lastAttrs?.state?.selectedMetricId,
+    () => this.lastAttrs?.state?.addedMetricIds,
   ]);
 
   constructor(
@@ -227,6 +236,9 @@
         this.fetchData(metrics, state);
       }
     }
+    const unit =
+      metrics?.find((x) => state?.selectedMetricId === (x.id ?? x.name))
+        ?.unit ?? '';
     return m(Flamegraph, {
       metrics: metrics ?? [],
       data: this.data,
@@ -234,10 +246,26 @@
         view: {kind: 'TOP_DOWN'},
         selectedMetricId: '',
         addedMetricIds: [],
+        displayMode: 'flamegraph',
         filters: [],
       },
       addableMetrics,
       onAddMetric,
+      renderTableView: (data) => {
+        if (this.tableSource === undefined || this.tableSourceData !== data) {
+          this.tableSourceData = data;
+          this.tableSource = new FlamegraphTreeDataSource(data);
+          this.tableTree = undefined;
+        }
+        return m(FlamegraphTable, {
+          source: this.tableSource,
+          unit,
+          tree: this.tableTree,
+          onTreeChanged: (tree) => {
+            this.tableTree = tree;
+          },
+        });
+      },
       onStateChange,
     });
   }
diff --git a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
index 4506dbb..92dd98a 100644
--- a/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
+++ b/ui/src/plugins/com.android.HeapDumpExplorer/session.ts
@@ -385,6 +385,7 @@
         ? METRIC_DOMINATED_OBJECT_SIZE
         : METRIC_OBJECT_SIZE,
       addedMetricIds: [],
+      displayMode: 'flamegraph',
       filters: [],
       view: {
         kind: 'PIVOT',
diff --git a/ui/src/widgets/flamegraph.scss b/ui/src/widgets/flamegraph.scss
index 99afd0d..9bce5a6 100644
--- a/ui/src/widgets/flamegraph.scss
+++ b/ui/src/widgets/flamegraph.scss
@@ -96,6 +96,11 @@
   .pf-virtual-canvas {
     height: 100%;
   }
+
+  .pf-flamegraph-table {
+    height: 100%;
+    overflow: hidden;
+  }
 }
 
 .pf-flamegraph-tooltip-popup {
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts
index 72cb93a..af36739 100644
--- a/ui/src/widgets/flamegraph.ts
+++ b/ui/src/widgets/flamegraph.ts
@@ -221,6 +221,7 @@
   .object({
     selectedMetricId: z.string().readonly(),
     addedMetricIds: z.array(z.string()).default([]),
+    displayMode: z.enum(['flamegraph', 'table']).default('flamegraph'),
     filters: z.array(FLAMEGRAPH_FILTER_SCHEMA),
     view: FLAMEGRAPH_VIEW_SCHEMA,
   })
@@ -252,6 +253,10 @@
   readonly data: FlamegraphQueryData | undefined;
   readonly addableMetrics?: ReadonlyArray<FlamegraphAddableMetric>;
 
+  // When set, a Flame/Table toggle appears and 'table' mode renders this
+  // instead of the canvas. Both modes consume the same post-filter tree.
+  readonly renderTableView?: (data: FlamegraphQueryData) => m.Children;
+
   readonly onStateChange: (filters: FlamegraphState) => void;
   readonly onAddMetric?: (metric: FlamegraphAddableMetric) => void;
 }
@@ -556,6 +561,13 @@
         ),
       );
     }
+    if (attrs.state.displayMode === 'table' && attrs.renderTableView) {
+      return m(
+        '.pf-flamegraph',
+        this.renderFilterBar(attrs),
+        m('.pf-flamegraph-table', attrs.renderTableView(attrs.data)),
+      );
+    }
     const {minDepth, maxDepth} = attrs.data;
     const canvasHeight =
       Math.max(maxDepth - minDepth + PADDING_NODE_COUNT, PADDING_NODE_COUNT) *
@@ -721,6 +733,7 @@
     return {
       selectedMetricId: metricId(metrics[0]),
       addedMetricIds: [],
+      displayMode: 'flamegraph',
       filters: [],
       view: {kind: 'TOP_DOWN'},
     };
@@ -754,6 +767,7 @@
       filters: state.filters,
       view: state.view,
       addedMetricIds: state.addedMetricIds,
+      displayMode: state.displayMode,
       selectedMetricId: metricId(metrics[0]),
     };
   }
@@ -1076,6 +1090,25 @@
           m(RadioGroup.Button, {value: 'bottom-up'}, 'Bottom Up'),
         ],
       ),
+      attrs.renderTableView !== undefined && [
+        m('.pf-flamegraph-filter-bar-separator'),
+        m(
+          RadioGroup,
+          {
+            selectedValue: this.attrs.state.displayMode,
+            onValueChange: (value) => {
+              this.attrs.onStateChange({
+                ...this.attrs.state,
+                displayMode: value as 'flamegraph' | 'table',
+              });
+            },
+          },
+          [
+            m(RadioGroup.Button, {value: 'flamegraph'}, 'Flame'),
+            m(RadioGroup.Button, {value: 'table'}, 'Table'),
+          ],
+        ),
+      ],
       attrs.data !== undefined &&
         attrs.data.nodes.length > 0 && [
           m('.pf-flamegraph-filter-bar-separator'),
@@ -1864,7 +1897,10 @@
   }
 }
 
-function displaySize(totalSize: number, unit: string): string {
+// Formats a value in the metric's unit ('B' and 'ns' are human-scaled with
+// 1024/1000 steps, 'count' and '' verbatim, anything else K/M/G-prefixed).
+// Exported so other views showing flamegraph metrics can format identically.
+export function displaySize(totalSize: number, unit: string): string {
   if (unit === '' || unit === 'count') return totalSize.toLocaleString();
   if (totalSize === 0) return `0 ${unit}`;
   let step: number;