ui: introduce new flamegraph widget and use it for java heap dumps

This CL introduces a new flamgraph widget which is far superior to the
existing flamgraph implementation in many ways:
- supports filtering stacks/frames
- sources data from any arbitrary source, not use hardcoded use of
  experimental_flamgraph
- improves the UX for hover
- collapses linked lists (and other recursive data structures) down
  instead of arbitrarily truncating

This CL also starts using this widget in the Java heap graph details
panel as that was the use-case which is suffering the most right now.
For example, the runtime of a dominator tree flamegraph has been
cut from multiple minutes to 5s.

Also while I'm here, improve the heap profile track query as well to
be significantly better.

Change-Id: Ib8444b642e78e69b041adde716976c2f3822c92f
diff --git a/Android.bp b/Android.bp
index 7bd6f16..6de0fdc 100644
--- a/Android.bp
+++ b/Android.bp
@@ -13244,6 +13244,7 @@
         "src/trace_processor/perfetto_sql/stdlib/time/conversion.sql",
         "src/trace_processor/perfetto_sql/stdlib/v8/jit.sql",
         "src/trace_processor/perfetto_sql/stdlib/viz/core_type.sql",
+        "src/trace_processor/perfetto_sql/stdlib/viz/flamegraph.sql",
         "src/trace_processor/perfetto_sql/stdlib/viz/summary/counters.sql",
         "src/trace_processor/perfetto_sql/stdlib/viz/summary/processes.sql",
         "src/trace_processor/perfetto_sql/stdlib/viz/summary/slices.sql",
diff --git a/BUILD b/BUILD
index be9ebc9..24543ae 100644
--- a/BUILD
+++ b/BUILD
@@ -2800,6 +2800,7 @@
     name = "src_trace_processor_perfetto_sql_stdlib_viz_viz",
     srcs = [
         "src/trace_processor/perfetto_sql/stdlib/viz/core_type.sql",
+        "src/trace_processor/perfetto_sql/stdlib/viz/flamegraph.sql",
     ],
 )
 
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/graph_scan.cc b/src/trace_processor/perfetto_sql/intrinsics/functions/graph_scan.cc
index b8fe51a..586f62d 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/graph_scan.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/graph_scan.cc
@@ -405,7 +405,11 @@
     }
     if (col_names != init->column_names) {
       return sqlite::result::Error(
-          ctx, "graph_scan: column list does not match initial table list");
+          ctx, base::ErrStatus("graph_scan: column list '%s' does not match "
+                               "initial table list '%s'",
+                               base::Join(col_names, ",").c_str(),
+                               base::Join(init->column_names, ",").c_str())
+                   .c_message());
     }
 
     const auto* nodes =
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/graph_traversal.cc b/src/trace_processor/perfetto_sql/intrinsics/functions/graph_traversal.cc
index a85eb18..de6b8a9 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/graph_traversal.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/graph_traversal.cc
@@ -135,21 +135,25 @@
 
     std::vector<bool> visited(graph->size());
     base::CircularQueue<State> queue;
-    for (int64_t x : *start_ids) {
-      queue.emplace_back(State{static_cast<uint32_t>(x), std::nullopt});
+    for (int64_t raw_id : *start_ids) {
+      auto id = static_cast<uint32_t>(raw_id);
+      if (id >= graph->size() || visited[id]) {
+        continue;
+      }
+      visited[id] = true;
+      queue.emplace_back(State{id, std::nullopt});
     }
     while (!queue.empty()) {
       State state = queue.front();
       queue.pop_front();
+      table->Insert({state.id, state.parent_id});
 
       auto& node = (*graph)[state.id];
-      if (visited[state.id]) {
-        continue;
-      }
-      table->Insert({state.id, state.parent_id});
-      visited[state.id] = true;
-
       for (uint32_t n : node.outgoing_edges) {
+        if (visited[n]) {
+          continue;
+        }
+        visited[n] = true;
         queue.emplace_back(State{n, state.id});
       }
     }
diff --git a/src/trace_processor/perfetto_sql/stdlib/viz/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/viz/BUILD.gn
index 3f3b655..99b9a44 100644
--- a/src/trace_processor/perfetto_sql/stdlib/viz/BUILD.gn
+++ b/src/trace_processor/perfetto_sql/stdlib/viz/BUILD.gn
@@ -15,6 +15,9 @@
 import("../../../../../gn/perfetto_sql.gni")
 
 perfetto_sql_source_set("viz") {
-  sources = [ "core_type.sql" ]
+  sources = [
+    "core_type.sql",
+    "flamegraph.sql",
+  ]
   deps = [ "summary" ]
 }
diff --git a/src/trace_processor/perfetto_sql/stdlib/viz/flamegraph.sql b/src/trace_processor/perfetto_sql/stdlib/viz/flamegraph.sql
new file mode 100644
index 0000000..f5b08eb
--- /dev/null
+++ b/src/trace_processor/perfetto_sql/stdlib/viz/flamegraph.sql
@@ -0,0 +1,203 @@
+--
+-- Copyright 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
+--
+--     https://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.
+
+include perfetto module graphs.scan;
+
+CREATE PERFETTO MACRO _viz_flamegraph_prepare_filter(
+  tab TableOrSubquery,
+  show_frame Expr,
+  hide_frame Expr,
+  show_stack Expr,
+  hide_stack Expr,
+  impossible_stack_bits Expr
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT
+    *,
+    IIF($hide_stack, $impossible_stack_bits, $show_stack) AS stackBits,
+    $show_frame AND NOT $hide_frame AS showFrame
+  FROM $tab
+);
+
+CREATE PERFETTO MACRO _viz_flamegraph_filter_and_hash(
+  tab TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT id, hash, parentHash, depth, stackBits
+  FROM _graph_scan!(
+    (
+      SELECT parentId AS source_node_id, id AS dest_node_id
+      FROM $tab
+      WHERE parentId IS NOT NULL
+    ),
+    (
+      select
+        id,
+        IIF(showFrame, HASH(name), 0) AS hash,
+        IIF(showFrame, 0, NULL) AS parentHash,
+        IIF(showFrame, 0, -1) AS depth,
+        IIF(showFrame, stackBits, 0) AS stackBits
+      FROM $tab
+      WHERE parentId IS NULL
+    ),
+    (hash, parentHash, depth, stackBits),
+    (
+      select
+        t.id as id,
+        IIF(x.showFrame, HASH(t.hash, name), t.hash) AS hash,
+        IIF(x.showFrame, t.hash, null) AS parentHash,
+        IIF(x.showFrame, t.depth + 1, t.depth) AS depth,
+        IIF(
+          x.showFrame,
+          (t.stackBits | x.stackBits),
+          t.stackBits
+        ) AS stackBits
+      FROM $table t
+      JOIN $tab x USING (id)
+    )
+  ) g
+  WHERE parentHash IS NOT NULL
+  ORDER BY hash
+);
+
+CREATE PERFETTO MACRO _viz_flamegraph_merge_hashes(
+  tab TableOrSubquery,
+  source TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT
+    c._auto_id AS id,
+    (
+      SELECT p._auto_id
+      FROM $tab p
+      WHERE p.hash = c.parentHash
+      LIMIT 1
+    ) AS parentId,
+    c.depth,
+    c.stackBits,
+    s.name,
+    SUM(s.value) AS value
+  FROM $tab c
+  JOIN $source s USING (id)
+  GROUP BY hash
+);
+
+CREATE PERFETTO MACRO _viz_flamegraph_accumulate(
+  tab TableOrSubquery,
+  allStackBits Expr
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT id, cumulativeValue
+  FROM _graph_scan!(
+    (
+      SELECT id AS source_node_id, parentId AS dest_node_id
+      FROM $tab
+      WHERE parentId IS NOT NULL
+    ),
+    (
+      SELECT t.id AS id, t.value AS cumulativeValue
+      FROM $tab t
+      LEFT JOIN $tab c ON t.id = c.parentId
+      WHERE c.id IS NULL AND t.stackBits = $allStackBits
+    ),
+    (cumulativeValue),
+    (
+      SELECT
+        x.id,
+        x.childValue + IIF(
+          t.stackBits = $allStackBits,
+          t.value,
+          0
+        ) AS cumulativeValue
+      FROM (
+        SELECT id, SUM(cumulativeValue) AS childValue
+        FROM $table
+        GROUP BY 1
+      ) x
+      JOIN $tab t USING (id)
+    )
+  )
+  ORDER BY id
+);
+
+CREATE PERFETTO MACRO _viz_flamegraph_local_layout(
+  acc TableOrSubquery,
+  tab TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  SELECT id, xEnd - cumulativeValue as xStart, xEnd
+  FROM (
+    SELECT
+      b.id,
+      b.cumulativeValue,
+      SUM(b.cumulativeValue) OVER win AS xEnd
+    FROM $acc b
+    JOIN $tab s USING (id)
+    WINDOW win AS (
+      PARTITION BY s.parentId
+      ORDER BY b.cumulativeValue DESC
+      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
+    )
+  )
+);
+
+CREATE PERFETTO MACRO _viz_flamegraph_global_layout(
+  layout TableOrSubquery,
+  acc TableOrSubquery,
+  tab TableOrSubquery
+)
+RETURNS TableOrSubquery
+AS (
+  select
+    s.id,
+    ifnull(t.parentId, -1) as parentId,
+    t.depth,
+    t.name,
+    t.value as selfValue,
+    b.cumulativeValue,
+    s.xStart,
+    s.xEnd
+  from _graph_scan!(
+    (
+      select parentId as source_node_id, id as dest_node_id
+      from $tab
+      where parentId is not null
+    ),
+    (
+      select b.id as id, w.xStart, w.xEnd
+      from $acc b
+      join $tab t using (id)
+      join $layout w using (id)
+      where t.parentId is null
+    ),
+    (xStart, xEnd),
+    (
+      select
+        t.id,
+        t.xStart + w.xStart as xStart,
+        t.xStart + w.xEnd as xEnd
+      from $table t
+      join $layout w using (id)
+    )
+  ) s
+  join $tab t using (id)
+  join $acc b using (id)
+  order by depth, xStart
+);
diff --git a/ui/src/assets/details.scss b/ui/src/assets/details.scss
index 55a2cd9..ef1593c 100644
--- a/ui/src/assets/details.scss
+++ b/ui/src/assets/details.scss
@@ -527,16 +527,4 @@
     text-overflow: ellipsis;
     width: 200px;
   }
-  .flamegraph-content {
-    overflow: auto;
-    height: 100%;
-
-    .loading-container {
-      font-size: larger;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      height: 100%;
-    }
-  }
 }
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 4f50c43..ca985ea 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -41,6 +41,7 @@
 @import "widgets/editor";
 @import "widgets/empty_state";
 @import "widgets/error";
+@import "widgets/flamegraph";
 @import "widgets/form";
 @import "widgets/grid_layout";
 @import "widgets/hotkey";
diff --git a/ui/src/assets/widgets/flamegraph.scss b/ui/src/assets/widgets/flamegraph.scss
new file mode 100644
index 0000000..825060b
--- /dev/null
+++ b/ui/src/assets/widgets/flamegraph.scss
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 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.
+
+.pf-flamegraph {
+  overflow: auto;
+  height: 100%;
+
+  .loading-container {
+    font-size: larger;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+  }
+
+  .filter-bar {
+    font-size: 1rem;
+    margin: 6px 8px;
+    display: flex;
+
+    .pf-tag-input {
+      display: flex;
+      flex-grow: 1;
+      margin-left: 8px;
+
+      input {
+        flex-grow: 1;
+      }
+    }
+  }
+}
+
+.pf-flamegraph-filter-bar-popup-content {
+  white-space: pre-line;
+  width: max-content;
+  font-family: "Roboto Condensed", sans-serif;
+}
diff --git a/ui/src/base/string_utils.ts b/ui/src/base/string_utils.ts
index 5c3ef9c..7f16d30 100644
--- a/ui/src/base/string_utils.ts
+++ b/ui/src/base/string_utils.ts
@@ -126,3 +126,25 @@
   // Replace non-breaking spaces with normal spaces.
   return str.replaceAll('\u00A0', ' ');
 }
+
+export function cropText(str: string, charWidth: number, rectWidth: number) {
+  let displayText = '';
+  const maxLength = Math.floor(rectWidth / charWidth) - 1;
+  if (str.length <= maxLength) {
+    displayText = str;
+  } else {
+    let limit = maxLength;
+    let maybeTripleDot = '';
+    if (maxLength > 1) {
+      limit = maxLength - 1;
+      maybeTripleDot = '\u2026';
+    }
+    // Javascript strings are UTF-16. |limit| could point in the middle of a
+    // 32-bit double-wchar codepoint (e.g., an emoji). Here we detect if the
+    // |limit|-th wchar is a leading surrogate and attach the trailing one.
+    const lastCharCode = str.charCodeAt(limit - 1);
+    limit += lastCharCode >= 55296 && lastCharCode < 56320 ? 1 : 0;
+    displayText = str.substring(0, limit) + maybeTripleDot;
+  }
+  return displayText;
+}
diff --git a/ui/src/base/string_utils_unittest.ts b/ui/src/base/string_utils_unittest.ts
index ffd2bdf..1a4baf2 100644
--- a/ui/src/base/string_utils_unittest.ts
+++ b/ui/src/base/string_utils_unittest.ts
@@ -17,6 +17,7 @@
   base64Encode,
   binaryDecode,
   binaryEncode,
+  cropText,
   sqliteString,
   utf8Decode,
   utf8Encode,
@@ -67,3 +68,29 @@
   expect(sqliteString('no quotes')).toEqual("'no quotes'");
   expect(sqliteString(`foo ' bar '`)).toEqual(`'foo '' bar '''`);
 });
+
+test('cropHelper regular text', () => {
+  const tripleDot = '\u2026';
+  const emoji = '\uD83D\uDE00';
+  expect(
+    cropText(
+      'com.android.camera [4096]',
+      /* charWidth=*/ 5,
+      /* rectWidth=*/ 2 * 5,
+    ),
+  ).toBe('c');
+  expect(cropText('com.android.camera [4096]', 5, 4 * 5 + 2)).toBe(
+    'co' + tripleDot,
+  );
+  expect(cropText('com.android.camera [4096]', 5, 5 * 5 + 2)).toBe(
+    'com' + tripleDot,
+  );
+  expect(cropText('com.android.camera [4096]', 5, 13 * 5 + 2)).toBe(
+    'com.android' + tripleDot,
+  );
+  expect(cropText('com.android.camera [4096]', 5, 26 * 5 + 2)).toBe(
+    'com.android.camera [4096]',
+  );
+  expect(cropText(emoji + 'abc', 5, 2 * 5)).toBe(emoji);
+  expect(cropText(emoji + 'abc', 5, 5 * 5)).toBe(emoji + 'a' + tripleDot);
+});
diff --git a/ui/src/common/canvas_utils.ts b/ui/src/common/canvas_utils.ts
index c2f24b2..d08619a 100644
--- a/ui/src/common/canvas_utils.ts
+++ b/ui/src/common/canvas_utils.ts
@@ -15,28 +15,6 @@
 import {Size, Vector} from '../base/geom';
 import {isString} from '../base/object_utils';
 
-export function cropText(str: string, charWidth: number, rectWidth: number) {
-  let displayText = '';
-  const maxLength = Math.floor(rectWidth / charWidth) - 1;
-  if (str.length <= maxLength) {
-    displayText = str;
-  } else {
-    let limit = maxLength;
-    let maybeTripleDot = '';
-    if (maxLength > 1) {
-      limit = maxLength - 1;
-      maybeTripleDot = '\u2026';
-    }
-    // Javascript strings are UTF-16. |limit| could point in the middle of a
-    // 32-bit double-wchar codepoint (e.g., an emoji). Here we detect if the
-    // |limit|-th wchar is a leading surrogate and attach the trailing one.
-    const lastCharCode = str.charCodeAt(limit - 1);
-    limit += lastCharCode >= 0xd800 && lastCharCode < 0xdc00 ? 1 : 0;
-    displayText = str.substring(0, limit) + maybeTripleDot;
-  }
-  return displayText;
-}
-
 export function drawDoubleHeadedArrow(
   ctx: CanvasRenderingContext2D,
   x: number,
diff --git a/ui/src/common/canvas_utils_unittest.ts b/ui/src/common/canvas_utils_unittest.ts
deleted file mode 100644
index 36909dd0..0000000
--- a/ui/src/common/canvas_utils_unittest.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2020 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 {cropText} from './canvas_utils';
-
-test('cropHelper regular text', () => {
-  const tripleDot = '\u2026';
-  const emoji = '\uD83D\uDE00';
-  expect(
-    cropText(
-      'com.android.camera [4096]',
-      /* charWidth=*/ 5,
-      /* rectWidth=*/ 2 * 5,
-    ),
-  ).toBe('c');
-  expect(cropText('com.android.camera [4096]', 5, 4 * 5 + 2)).toBe(
-    'co' + tripleDot,
-  );
-  expect(cropText('com.android.camera [4096]', 5, 5 * 5 + 2)).toBe(
-    'com' + tripleDot,
-  );
-  expect(cropText('com.android.camera [4096]', 5, 13 * 5 + 2)).toBe(
-    'com.android' + tripleDot,
-  );
-  expect(cropText('com.android.camera [4096]', 5, 26 * 5 + 2)).toBe(
-    'com.android.camera [4096]',
-  );
-  expect(cropText(emoji + 'abc', 5, 2 * 5)).toBe(emoji);
-  expect(cropText(emoji + 'abc', 5, 5 * 5)).toBe(emoji + 'a' + tripleDot);
-});
diff --git a/ui/src/core/flamegraph_query_utils.ts b/ui/src/core/flamegraph_query_utils.ts
new file mode 100644
index 0000000..ba95b28
--- /dev/null
+++ b/ui/src/core/flamegraph_query_utils.ts
@@ -0,0 +1,160 @@
+// 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 {AsyncDisposableStack} from '../base/disposable';
+
+import {Engine} from '../trace_processor/engine';
+import {NUM, STR} from '../trace_processor/query_result';
+import {createPerfettoTable} from '../trace_processor/sql_utils';
+
+export async function computeFlamegraphTree(
+  engine: Engine,
+  dependencySql: string,
+  sql: string,
+  {
+    showStack,
+    hideStack,
+    showFrame,
+    hideFrame,
+  }: {
+    readonly showStack: ReadonlyArray<string>;
+    readonly hideStack: ReadonlyArray<string>;
+    readonly showFrame: ReadonlyArray<string>;
+    readonly hideFrame: ReadonlyArray<string>;
+  },
+) {
+  const allStackBits = (1 << showStack.length) - 1;
+  const showStackFilter =
+    showStack.length === 0
+      ? '0'
+      : showStack.map((x, i) => `((name like '%${x}%') << ${i})`).join(' | ');
+  const hideStackFilter =
+    hideStack.length === 0
+      ? 'false'
+      : hideStack.map((x) => `name like '%${x}%'`).join(' OR ');
+  const showFrameFilter =
+    showFrame.length === 0
+      ? 'true'
+      : showFrame.map((x) => `name like '%${x}%'`).join(' OR ');
+  const hideFrameFilter =
+    hideFrame.length === 0
+      ? 'false'
+      : hideFrame.map((x) => `name like '%${x}%'`).join(' OR ');
+
+  await engine.query(dependencySql);
+  await engine.query(`include perfetto module viz.flamegraph;`);
+
+  const disposable = new AsyncDisposableStack();
+  try {
+    disposable.use(
+      await createPerfettoTable(
+        engine,
+        '_flamegraph_source',
+        `
+        select *
+        from _viz_flamegraph_prepare_filter!(
+          (${sql}),
+          (${showFrameFilter}),
+          (${hideFrameFilter}),
+          (${showStackFilter}),
+          (${hideStackFilter}),
+          ${1 << showStack.length}
+        )
+      `,
+      ),
+    );
+    disposable.use(
+      await createPerfettoTable(
+        engine,
+        '_flamegraph_raw_top_down',
+        `select * from _viz_flamegraph_filter_and_hash!(_flamegraph_source)`,
+      ),
+    );
+    disposable.use(
+      await createPerfettoTable(
+        engine,
+        '_flamegraph_top_down',
+        `
+        select * from _viz_flamegraph_merge_hashes!(
+          _flamegraph_raw_top_down,
+          _flamegraph_source
+        )
+      `,
+      ),
+    );
+    disposable.use(
+      await createPerfettoTable(
+        engine,
+        '_flamegraph_raw_bottom_up',
+        `
+        select *
+        from _viz_flamegraph_accumulate!(_flamegraph_top_down, ${allStackBits})
+      `,
+      ),
+    );
+    disposable.use(
+      await createPerfettoTable(
+        engine,
+        '_flamegraph_windowed',
+        `
+        select *
+        from _viz_flamegraph_local_layout!(
+          _flamegraph_raw_bottom_up,
+          _flamegraph_top_down
+        );
+      `,
+      ),
+    );
+    const res = await engine.query(`
+      select *
+      from _viz_flamegraph_global_layout!(
+        _flamegraph_windowed,
+        _flamegraph_raw_bottom_up,
+        _flamegraph_top_down
+      )
+    `);
+    const it = res.iter({
+      id: NUM,
+      parentId: NUM,
+      depth: NUM,
+      name: STR,
+      selfValue: NUM,
+      cumulativeValue: NUM,
+      xStart: NUM,
+      xEnd: NUM,
+    });
+    let allRootsCumulativeValue = 0;
+    let maxDepth = 0;
+    const nodes = [];
+    for (; it.valid(); it.next()) {
+      nodes.push({
+        id: it.id,
+        parentId: it.parentId,
+        depth: it.depth,
+        name: it.name,
+        selfValue: it.selfValue,
+        cumulativeValue: it.cumulativeValue,
+        xStart: it.xStart,
+        xEnd: it.xEnd,
+      });
+      if (it.parentId === -1) {
+        allRootsCumulativeValue += it.cumulativeValue;
+      }
+      maxDepth = Math.max(maxDepth, it.depth);
+    }
+    return {nodes, allRootsCumulativeValue, maxDepth};
+  } finally {
+    await disposable.disposeAsync();
+  }
+}
diff --git a/ui/src/core_plugins/cpu_slices/cpu_slice_track.ts b/ui/src/core_plugins/cpu_slices/cpu_slice_track.ts
index 0ea7326..d2fd10f 100644
--- a/ui/src/core_plugins/cpu_slices/cpu_slice_track.ts
+++ b/ui/src/core_plugins/cpu_slices/cpu_slice_track.ts
@@ -19,11 +19,11 @@
 import {Actions} from '../../common/actions';
 import {getLegacySelection} from '../../common/state';
 import {
-  cropText,
   drawDoubleHeadedArrow,
   drawIncompleteSlice,
   drawTrackHoverTooltip,
 } from '../../common/canvas_utils';
+import {cropText} from '../../base/string_utils';
 import {Color} from '../../core/color';
 import {colorForThread} from '../../core/colorizer';
 import {TrackData} from '../../common/track_data';
diff --git a/ui/src/core_plugins/heap_profile/heap_profile_track.ts b/ui/src/core_plugins/heap_profile/heap_profile_track.ts
index 3cecba1..b420236 100644
--- a/ui/src/core_plugins/heap_profile/heap_profile_track.ts
+++ b/ui/src/core_plugins/heap_profile/heap_profile_track.ts
@@ -12,9 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {profileType} from '../../frontend/legacy_flamegraph_panel';
 import {Actions} from '../../common/actions';
-import {ProfileType, LegacySelection} from '../../common/state';
+import {LegacySelection, ProfileType} from '../../common/state';
 import {
   BASE_ROW,
   BaseSliceTrack,
@@ -22,9 +21,11 @@
   OnSliceOverArgs,
 } from '../../frontend/base_slice_track';
 import {globals} from '../../frontend/globals';
+import {profileType} from '../../frontend/legacy_flamegraph_panel';
 import {NewTrackArgs} from '../../frontend/track';
 import {Slice} from '../../public';
 import {STR} from '../../trace_processor/query_result';
+import {createPerfettoTable} from '../../trace_processor/sql_utils';
 
 export const HEAP_PROFILE_TRACK_KIND = 'HeapProfileTrack';
 
@@ -48,28 +49,50 @@
     this.upid = upid;
   }
 
-  getSqlSource(): string {
-    return `
+  async onInit() {
+    return createPerfettoTable(
+      this.engine,
+      `_heap_profile_track_${this.trackUuid}`,
+      `
+      with
+        heaps as (select group_concat(distinct heap_name) h from heap_profile_allocation where upid = ${this.upid}),
+        allocation_tses as (select distinct ts from heap_profile_allocation where upid = ${this.upid}),
+        graph_tses as (select distinct graph_sample_ts from heap_graph_object where upid = ${this.upid})
       select
         *,
         0 AS dur,
         0 AS depth
       from (
-        select distinct
-          id,
+        select
+          (
+            select a.id
+            from heap_profile_allocation a
+            where a.ts = t.ts
+            order by a.id
+            limit 1
+          ) as id,
           ts,
-          'heap_profile:' || (select group_concat(distinct heap_name) from heap_profile_allocation where upid = ${this.upid}) AS type
-        from heap_profile_allocation
-        where upid = ${this.upid}
-        union
-        select distinct
-          id,
+          'heap_profile:' || (select h from heaps) AS type
+        from allocation_tses t
+        union all
+        select
+          (
+            select o.id
+            from heap_graph_object o
+            where o.graph_sample_ts = g.graph_sample_ts
+            order by o.id
+            limit 1
+          ) as id,
           graph_sample_ts AS ts,
           'graph' AS type
-        from heap_graph_object
-        where upid = ${this.upid}
+        from graph_tses g
       )
-    `;
+    `,
+    );
+  }
+
+  getSqlSource(): string {
+    return `_heap_profile_track_${this.trackUuid}`;
   }
 
   getRowSpec(): HeapProfileRow {
diff --git a/ui/src/core_plugins/heap_profile/index.ts b/ui/src/core_plugins/heap_profile/index.ts
index 0c0c269..97c973a 100644
--- a/ui/src/core_plugins/heap_profile/index.ts
+++ b/ui/src/core_plugins/heap_profile/index.ts
@@ -14,13 +14,38 @@
 
 import m from 'mithril';
 
+import {AsyncLimiter} from '../../base/async_limiter';
+import {assertExists} from '../../base/logging';
+import {Monitor} from '../../base/monitor';
+import {time} from '../../base/time';
+import {featureFlags} from '../../core/feature_flags';
 import {LegacyFlamegraphCache} from '../../core/legacy_flamegraph_cache';
 import {
+  HeapProfileSelection,
+  LegacySelection,
+  ProfileType,
+} from '../../core/selection_manager';
+import {
   LegacyFlamegraphDetailsPanel,
   profileType,
 } from '../../frontend/legacy_flamegraph_panel';
-import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
+import {Timestamp} from '../../frontend/widgets/timestamp';
+import {
+  Engine,
+  LegacyDetailsPanel,
+  Plugin,
+  PluginContextTrace,
+  PluginDescriptor,
+} from '../../public';
+import {computeFlamegraphTree} from '../../core/flamegraph_query_utils';
 import {NUM} from '../../trace_processor/query_result';
+import {DetailsShell} from '../../widgets/details_shell';
+import {
+  Flamegraph,
+  FlamegraphFilters,
+  FlamegraphQueryData,
+} from '../../widgets/flamegraph';
+
 import {HeapProfileTrack} from './heap_profile_track';
 
 export const HEAP_PROFILE_TRACK_KIND = 'HeapProfileTrack';
@@ -50,24 +75,172 @@
         },
       });
     }
+    ctx.registerDetailsPanel(new HeapProfileFlamegraphDetailsPanel(ctx.engine));
+  }
+}
 
-    const cache = new LegacyFlamegraphCache('heap_profile');
-    ctx.registerDetailsPanel({
-      render: (sel) => {
-        if (sel.kind === 'HEAP_PROFILE') {
-          return m(LegacyFlamegraphDetailsPanel, {
-            cache,
-            selection: {
-              profileType: profileType(sel.type),
-              start: sel.ts,
-              end: sel.ts,
-              upids: [sel.upid],
-            },
-          });
-        } else {
-          return undefined;
-        }
-      },
+const FLAMEGRAPH_METRICS = [
+  {
+    name: 'Object Size',
+    unit: 'B',
+    dependencySql: `
+      include perfetto module android.memory.heap_graph.class_tree;
+    `,
+    sqlFn: (ts: time, upid: number) => `
+      select id, parent_id as parentId, name, self_size as value
+      from _heap_graph_class_tree
+      where graph_sample_ts = ${ts} and upid = ${upid}
+    `,
+  },
+  {
+    name: 'Object Count',
+    unit: '',
+    dependencySql: `
+      include perfetto module android.memory.heap_graph.class_tree;
+    `,
+    sqlFn: (ts: time, upid: number) => `
+      select id, parent_id as parentId, name, self_count as value
+      from _heap_graph_class_tree
+      where graph_sample_ts = ${ts} and upid = ${upid}
+    `,
+  },
+  {
+    name: 'Dominated Object Size',
+    unit: 'B',
+    dependencySql: `
+      include perfetto module android.memory.heap_graph.dominator_class_tree;
+    `,
+    sqlFn: (ts: time, upid: number) => `
+      select id, parent_id as parentId, name, self_size as value
+      from _heap_graph_dominator_class_tree
+      where graph_sample_ts = ${ts} and upid = ${upid}
+    `,
+  },
+  {
+    name: 'Dominated Object Count',
+    unit: '',
+    dependencySql: `
+      include perfetto module android.memory.heap_graph.dominator_class_tree;
+    `,
+    sqlFn: (ts: time, upid: number) => `
+      select id, parent_id as parentId, name, self_count as value
+      from _heap_graph_dominator_class_tree
+      where graph_sample_ts = ${ts} and upid = ${upid}
+    `,
+  },
+];
+const DEFAULT_SELECTED_METRIC_NAME = 'Object Size';
+
+const USE_NEW_FLAMEGRAPH_IMPL = featureFlags.register({
+  id: 'useNewFlamegraphImpl',
+  name: 'Use new flamegraph implementation',
+  description: 'Use new flamgraph implementation in details panels.',
+  defaultValue: true,
+});
+
+class HeapProfileFlamegraphDetailsPanel implements LegacyDetailsPanel {
+  private sel?: HeapProfileSelection;
+  private selMonitor = new Monitor([
+    () => this.sel?.ts,
+    () => this.sel?.upid,
+    () => this.sel?.type,
+  ]);
+  private cache = new LegacyFlamegraphCache('heap_profile');
+  private queryLimiter = new AsyncLimiter();
+
+  private selectedMetricName = DEFAULT_SELECTED_METRIC_NAME;
+  private data?: FlamegraphQueryData;
+  private filters: FlamegraphFilters = {
+    showStack: [],
+    hideStack: [],
+    showFrame: [],
+    hideFrame: [],
+  };
+
+  constructor(private engine: Engine) {}
+
+  render(sel: LegacySelection) {
+    if (sel.kind !== 'HEAP_PROFILE') {
+      this.sel = undefined;
+      return undefined;
+    }
+    if (
+      sel.type !== ProfileType.JAVA_HEAP_GRAPH &&
+      !USE_NEW_FLAMEGRAPH_IMPL.get()
+    ) {
+      this.sel = undefined;
+      return m(LegacyFlamegraphDetailsPanel, {
+        cache: this.cache,
+        selection: {
+          profileType: profileType(sel.type),
+          start: sel.ts,
+          end: sel.ts,
+          upids: [sel.upid],
+        },
+      });
+    }
+
+    this.sel = sel;
+    if (this.selMonitor.ifStateChanged()) {
+      this.selectedMetricName = DEFAULT_SELECTED_METRIC_NAME;
+      this.data = undefined;
+      this.fetchData();
+    }
+    return m(
+      '.flamegraph-profile',
+      m(
+        DetailsShell,
+        {
+          fillParent: true,
+          title: m('div.title', 'Java Heap Graph'),
+          description: [],
+          buttons: [
+            m(
+              'div.time',
+              `Snapshot time: `,
+              m(Timestamp, {
+                ts: sel.ts,
+              }),
+            ),
+          ],
+        },
+        m(Flamegraph, {
+          metrics: FLAMEGRAPH_METRICS,
+          selectedMetricName: this.selectedMetricName,
+          data: this.data,
+          onMetricChange: (name) => {
+            this.selectedMetricName = name;
+            this.data = undefined;
+            this.fetchData();
+          },
+          onFiltersChanged: (filters) => {
+            this.filters = filters;
+            this.data = undefined;
+            this.fetchData();
+          },
+        }),
+      ),
+    );
+  }
+
+  private async fetchData() {
+    if (this.sel === undefined) {
+      return;
+    }
+    const {ts, upid} = this.sel;
+    const selectedMetricName = this.selectedMetricName;
+    const filters = this.filters;
+    this.queryLimiter.schedule(async () => {
+      const {sqlFn, dependencySql} = assertExists(
+        FLAMEGRAPH_METRICS.find((metric) => metric.name === selectedMetricName),
+      );
+      const sql = sqlFn(ts, upid);
+      this.data = await computeFlamegraphTree(
+        this.engine,
+        dependencySql,
+        sql,
+        filters,
+      );
     });
   }
 }
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index 7fdc836..b3f0d49 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -19,10 +19,10 @@
 import {exists} from '../base/utils';
 import {Actions} from '../common/actions';
 import {
-  cropText,
   drawIncompleteSlice,
   drawTrackHoverTooltip,
 } from '../common/canvas_utils';
+import {cropText} from '../base/string_utils';
 import {colorCompare} from '../core/color';
 import {UNEXPECTED_PINK} from '../core/colorizer';
 import {
diff --git a/ui/src/frontend/legacy_flamegraph.ts b/ui/src/frontend/legacy_flamegraph.ts
index 19db899..caa5e3e 100644
--- a/ui/src/frontend/legacy_flamegraph.ts
+++ b/ui/src/frontend/legacy_flamegraph.ts
@@ -14,7 +14,7 @@
 
 import {CallsiteInfo} from '../common/legacy_flamegraph_util';
 import {searchSegment} from '../base/binary_search';
-import {cropText} from '../common/canvas_utils';
+import {cropText} from '../base/string_utils';
 
 interface Node {
   width: number;
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts
new file mode 100644
index 0000000..f46e74a
--- /dev/null
+++ b/ui/src/widgets/flamegraph.ts
@@ -0,0 +1,666 @@
+// 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 m from 'mithril';
+
+import {findRef} from '../base/dom_utils';
+import {assertExists, assertTrue} from '../base/logging';
+import {Monitor} from '../base/monitor';
+import {cropText} from '../base/string_utils';
+
+import {EmptyState} from './empty_state';
+import {Popup, PopupPosition} from './popup';
+import {Select} from './select';
+import {Spinner} from './spinner';
+import {TagInput} from './tag_input';
+import {scheduleFullRedraw} from './raf';
+
+const ROLLOVER_FONT_STYLE = '12px Roboto Condensed';
+const LABEL_FONT_STYLE = '12px Roboto Mono';
+const NODE_HEIGHT = 18;
+const MIN_PIXEL_DISPLAYED = 1;
+const TOOLTOP_PADDING_PX = 8;
+const TOOLTIP_OFFSET_PX = 4;
+const FILTER_COMMON_TEXT = `
+- "Show Frame: foo" or "SF: foo" to show only frames containing "foo"
+- "Hide Frame: foo" or "HF: foo" to hide all frames containing "foo"
+- "Show Stack: foo" or "SS: foo" to show only stacks containing "foo"
+- "Hide Stack: foo" or "HS: foo" to hide all stacks containing "foo"
+Frame filters are always evaluated before stack filters.
+`;
+const FILTER_EMPTY_TEXT = `
+Available filters:${FILTER_COMMON_TEXT}
+`;
+const FILTER_INVALID_TEXT = `
+Invalid filter. Please use the following options:${FILTER_COMMON_TEXT}
+`;
+
+interface BaseSource {
+  readonly queryXStart: number;
+  readonly queryXEnd: number;
+}
+
+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;
+}
+
+export interface FlamegraphQueryData {
+  readonly nodes: ReadonlyArray<{
+    readonly id: number;
+    readonly parentId: number;
+    readonly depth: number;
+    readonly name: string;
+    readonly selfValue: number;
+    readonly cumulativeValue: number;
+    readonly xStart: number;
+    readonly xEnd: number;
+  }>;
+  readonly allRootsCumulativeValue: number;
+  readonly maxDepth: number;
+}
+
+export interface FlamegraphFilters {
+  readonly showStack: ReadonlyArray<string>;
+  readonly hideStack: ReadonlyArray<string>;
+  readonly showFrame: ReadonlyArray<string>;
+  readonly hideFrame: ReadonlyArray<string>;
+}
+
+export interface FlamegraphAttrs {
+  readonly metrics: ReadonlyArray<{
+    readonly name: string;
+    readonly unit: string;
+  }>;
+  readonly selectedMetricName: string;
+  readonly data: FlamegraphQueryData | undefined;
+
+  readonly onMetricChange: (metricName: string) => void;
+  readonly onFiltersChanged: (filters: FlamegraphFilters) => void;
+}
+
+/*
+ * Widget for visualizing "tree-like" data structures using an interactive
+ * flamegraph visualization.
+ *
+ * To use this widget, provide an array of "metrics", which correspond to
+ * different properties of the tree to switch between (e.g. object size
+ * and object count) and the data which should be displayed.
+ *
+ * Note that it's valid to pass "undefined" as the data: this will cause a
+ * loading container to be shown.
+ *
+ * Example:
+ *
+ * ```
+ * const metrics = [...];
+ * const selectedMetricName = ...;
+ * const filters = ...;
+ * const data = ...;
+ *
+ * m(Flamegraph, {
+ *   metrics,
+ *   selectedMetricName,
+ *   onMetricChange: (metricName) => {
+ *     selectedMetricName = metricName;
+ *     data = undefined;
+ *     fetchData();
+ *   },
+ *   data,
+ *   onFiltersChanged: (showStack, hideStack, showFrame, hideFrame) => {
+ *     updateFilters(showStack, hideStack, showFrame, hideFrame);
+ *     data = undefined;
+ *     fetchData();
+ *   },
+ * });
+ * ```
+ */
+export class Flamegraph implements m.ClassComponent<FlamegraphAttrs> {
+  private attrs: FlamegraphAttrs;
+
+  private rawFilterText: string = '';
+  private rawFilters: ReadonlyArray<string> = [];
+  private filterFocus: boolean = false;
+  private filterChangeFail: boolean = false;
+
+  private zoomRegionMonitor = new Monitor([() => this.attrs.data]);
+  private zoomRegion?: ZoomRegion;
+
+  private renderNodesMonitor = new Monitor([
+    () => this.attrs.data,
+    () => this.canvasWidth,
+    () => this.zoomRegion,
+  ]);
+  private renderNodes?: ReadonlyArray<RenderNode>;
+
+  private hoveredX?: number;
+  private hoveredY?: number;
+
+  private canvasWidth = 0;
+  private labelCharWidth = 0;
+
+  constructor({attrs}: m.Vnode<FlamegraphAttrs, {}>) {
+    this.attrs = attrs;
+  }
+
+  view({attrs}: m.Vnode<FlamegraphAttrs, this>): void | m.Children {
+    this.attrs = attrs;
+    if (attrs.data === undefined) {
+      return m(
+        '.pf-flamegraph',
+        this.renderFilterBar(attrs),
+        m(
+          '.loading-container',
+          m(
+            EmptyState,
+            {
+              icon: 'bar_chart',
+              title: 'Computing graph ...',
+              className: 'flamegraph-loading',
+            },
+            m(Spinner, {easing: true}),
+          ),
+        ),
+      );
+    }
+    const {maxDepth} = attrs.data;
+    const canvasHeight = Math.max(maxDepth + 2, 8) * NODE_HEIGHT;
+    return m(
+      '.pf-flamegraph',
+      this.renderFilterBar(attrs),
+      m(`canvas[ref=canvas]`, {
+        style: `height:${canvasHeight}px; width:100%`,
+        onmousemove: ({offsetX, offsetY}: MouseEvent) => {
+          this.hoveredX = offsetX;
+          this.hoveredY = offsetY;
+          scheduleFullRedraw();
+        },
+        onmouseout: () => {
+          this.hoveredX = undefined;
+          this.hoveredY = undefined;
+          document.body.style.cursor = 'default';
+          scheduleFullRedraw();
+        },
+        onclick: ({offsetX, offsetY}: MouseEvent) => {
+          const renderNode = this.renderNodes?.find((n) =>
+            isHovered(offsetX, offsetY, n),
+          );
+          // TODO(lalitm): ignore merged nodes for now as we haven't quite
+          // figured out the UX for this.
+          if (renderNode?.source.kind === 'MERGED') {
+            return;
+          }
+          this.zoomRegion = renderNode?.source;
+          scheduleFullRedraw();
+        },
+      }),
+    );
+  }
+
+  oncreate({dom}: m.VnodeDOM<FlamegraphAttrs, this>) {
+    this.renderCanvas(dom);
+  }
+
+  onupdate({dom}: m.VnodeDOM<FlamegraphAttrs, this>) {
+    this.renderCanvas(dom);
+  }
+
+  private renderCanvas(dom: Element) {
+    const canvas = findRef(dom, 'canvas');
+    if (canvas === null || !(canvas instanceof HTMLCanvasElement)) {
+      return;
+    }
+    const ctx = canvas.getContext('2d');
+    if (ctx === null) {
+      return;
+    }
+    canvas.width = canvas.offsetWidth * devicePixelRatio;
+    canvas.height = canvas.offsetHeight * devicePixelRatio;
+    this.canvasWidth = canvas.offsetWidth;
+
+    if (this.zoomRegionMonitor.ifStateChanged()) {
+      this.zoomRegion = undefined;
+    }
+    if (this.renderNodesMonitor.ifStateChanged()) {
+      this.renderNodes =
+        this.attrs.data === undefined
+          ? undefined
+          : computeRenderNodes(
+              this.attrs.data,
+              this.zoomRegion ?? {
+                queryXStart: 0,
+                queryXEnd: this.attrs.data.allRootsCumulativeValue,
+              },
+              canvas.offsetWidth,
+            );
+    }
+    if (this.attrs.data === undefined || this.renderNodes === undefined) {
+      return;
+    }
+
+    const {allRootsCumulativeValue, nodes} = this.attrs.data;
+    const unit = assertExists(this.selectedMetric).unit;
+
+    ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);
+    ctx.save();
+    ctx.scale(devicePixelRatio, devicePixelRatio);
+
+    ctx.font = LABEL_FONT_STYLE;
+    ctx.textBaseline = 'middle';
+
+    ctx.strokeStyle = 'white';
+    ctx.lineWidth = 0.5;
+
+    if (this.labelCharWidth === 0) {
+      this.labelCharWidth = ctx.measureText('_').width;
+    }
+
+    let hoveredNode: RenderNode | undefined = undefined;
+    for (let i = 0; i < this.renderNodes.length; i++) {
+      const node = this.renderNodes[i];
+      const {x, y, width: width, source, state} = node;
+      const hover = isHovered(this.hoveredX, this.hoveredY, node);
+      hoveredNode = hover ? node : hoveredNode;
+      let name: string;
+      if (source.kind === 'ROOT') {
+        name = `root: ${displaySize(allRootsCumulativeValue, unit)}`;
+        ctx.fillStyle = generateColor('root', state === 'PARTIAL', hover);
+      } else if (source.kind === 'MERGED') {
+        name = '(merged)';
+        ctx.fillStyle = generateColor(name, state === 'PARTIAL', false);
+      } else {
+        name = nodes[source.queryIdx].name;
+        ctx.fillStyle = generateColor(name, state === 'PARTIAL', hover);
+      }
+      ctx.fillRect(x, y, width, NODE_HEIGHT - 1);
+      const labelPaddingPx = 5;
+      const maxLabelWidth = width - labelPaddingPx * 2;
+      ctx.fillStyle = 'black';
+      ctx.fillText(
+        cropText(name, this.labelCharWidth, maxLabelWidth),
+        x + labelPaddingPx,
+        y + (NODE_HEIGHT - 1) / 2,
+        maxLabelWidth,
+      );
+      ctx.beginPath();
+      ctx.moveTo(x + width, y);
+      ctx.lineTo(x + width, y + NODE_HEIGHT);
+      ctx.stroke();
+    }
+    if (hoveredNode !== undefined) {
+      this.drawTooltip(
+        ctx,
+        canvas.offsetWidth,
+        canvas.offsetHeight,
+        hoveredNode,
+      );
+    }
+    const kind = hoveredNode?.source.kind;
+    if (kind === 'ROOT' || kind === 'NODE') {
+      canvas.style.cursor = 'pointer';
+    } else {
+      canvas.style.cursor = 'default';
+    }
+    ctx.restore();
+  }
+
+  private drawTooltip(
+    ctx: CanvasRenderingContext2D,
+    canvasWidth: number,
+    canvasHeight: number,
+    node: RenderNode,
+  ) {
+    ctx.font = ROLLOVER_FONT_STYLE;
+    ctx.textBaseline = 'top';
+
+    const {unit} = assertExists(this.selectedMetric);
+    const {nodes, allRootsCumulativeValue} = assertExists(this.attrs.data);
+    const nodeSource = node.source;
+    let lines: string[];
+    if (nodeSource.kind === 'NODE') {
+      const {name, cumulativeValue, selfValue} = nodes[nodeSource.queryIdx];
+      const cdisp = displaySize(cumulativeValue, unit);
+      const cpercentage = (cumulativeValue / allRootsCumulativeValue) * 100;
+      const sdisp = displaySize(selfValue, unit);
+      const spercentage = (selfValue / allRootsCumulativeValue) * 100;
+      lines = [
+        name,
+        `Cumulative: ${cdisp} (${cpercentage.toFixed(2)}%)`,
+        `Self: ${sdisp} (${spercentage.toFixed(2)}%)`,
+      ];
+    } else if (nodeSource.kind === 'ROOT') {
+      lines = [
+        'root',
+        `Cumulative: ${allRootsCumulativeValue} (100%)`,
+        'Self: 0',
+      ];
+    } else {
+      lines = ['(merged)', 'Too small to show, use filters'];
+    }
+    const measured = ctx.measureText(lines.join('\n'));
+
+    const heightSample = ctx.measureText(
+      'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
+    );
+    const lineHeight = Math.round(heightSample.actualBoundingBoxDescent * 1.5);
+
+    const rectWidth = measured.width + 2 * TOOLTOP_PADDING_PX;
+    const rectHeight = lineHeight * lines.length + 2 * TOOLTOP_PADDING_PX;
+
+    let rectXStart = assertExists(this.hoveredX) + TOOLTIP_OFFSET_PX;
+    let rectYStart = assertExists(this.hoveredY) + TOOLTIP_OFFSET_PX;
+    if (rectXStart + rectWidth > canvasWidth) {
+      rectXStart = canvasWidth - rectWidth;
+    }
+    if (rectYStart + rectHeight > canvasHeight) {
+      rectYStart = canvasHeight - rectHeight;
+    }
+    ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
+    ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight);
+    ctx.fillStyle = 'hsl(200, 50%, 40%)';
+    ctx.textAlign = 'left';
+    for (let i = 0; i < lines.length; i++) {
+      ctx.fillText(
+        lines[i],
+        rectXStart + TOOLTOP_PADDING_PX,
+        rectYStart + TOOLTOP_PADDING_PX + i * lineHeight,
+      );
+    }
+  }
+
+  private renderFilterBar(attrs: FlamegraphAttrs) {
+    const self = this;
+    return m(
+      '.filter-bar',
+      m(
+        Select,
+        {
+          value: attrs.selectedMetricName,
+          onchange: (e: Event) => {
+            const el = e.target as HTMLSelectElement;
+            attrs.onMetricChange(el.value);
+            scheduleFullRedraw();
+          },
+        },
+        attrs.metrics.map((x) => {
+          return m('option', {value: x.name}, x.name);
+        }),
+      ),
+      m(
+        Popup,
+        {
+          trigger: m(TagInput, {
+            tags: this.rawFilters,
+            value: this.rawFilterText,
+            onChange: (value: string) => {
+              self.rawFilterText = value;
+              self.filterChangeFail = false;
+              scheduleFullRedraw();
+            },
+            onTagAdd: (tag: string) => {
+              const filter = normalizeFilter(tag);
+              if (filter === undefined) {
+                self.filterChangeFail = true;
+              } else {
+                self.rawFilters = [...self.rawFilters, filter];
+                self.rawFilterText = '';
+                self.attrs.onFiltersChanged(computeFilters(self.rawFilters));
+              }
+              scheduleFullRedraw();
+            },
+            onTagRemove(index: number) {
+              const filters = Array.from(self.rawFilters);
+              filters.splice(index, 1);
+              self.rawFilters = filters;
+              self.attrs.onFiltersChanged(computeFilters(self.rawFilters));
+              self.filterChangeFail = false;
+              scheduleFullRedraw();
+            },
+            onfocus() {
+              self.filterFocus = true;
+              self.filterChangeFail = false;
+            },
+            onblur() {
+              self.filterFocus = false;
+              self.filterChangeFail = false;
+            },
+            placeholder: 'Add filter...',
+          }),
+          isOpen:
+            self.filterFocus &&
+            (this.rawFilterText.length === 0 || self.filterChangeFail),
+          position: PopupPosition.Bottom,
+        },
+        m(
+          '.pf-flamegraph-filter-bar-popup-content',
+          (self.rawFilterText === ''
+            ? FILTER_EMPTY_TEXT
+            : FILTER_INVALID_TEXT
+          ).trim(),
+        ),
+      ),
+    );
+  }
+
+  private get selectedMetric() {
+    return this.attrs.metrics.find(
+      (x) => x.name === this.attrs.selectedMetricName,
+    );
+  }
+}
+
+function computeRenderNodes(
+  {nodes, allRootsCumulativeValue}: FlamegraphQueryData,
+  zoomRegion: ZoomRegion,
+  canvasWidth: number,
+): ReadonlyArray<RenderNode> {
+  const renderNodes: RenderNode[] = [];
+
+  const idToIdx = new Map<number, number>();
+  const idxToChildMergedIdx = new Map<number, number>();
+  renderNodes.push({
+    x: 0,
+    y: 0,
+    width: canvasWidth,
+    source: {kind: 'ROOT', queryXStart: 0, queryXEnd: allRootsCumulativeValue},
+    state:
+      zoomRegion.queryXStart === 0 &&
+      zoomRegion.queryXEnd === allRootsCumulativeValue
+        ? 'NORMAL'
+        : 'PARTIAL',
+  });
+  idToIdx.set(-1, renderNodes.length - 1);
+
+  const zoomQueryWidth = zoomRegion.queryXEnd - zoomRegion.queryXStart;
+  const queryXPerPx = zoomQueryWidth / canvasWidth;
+  for (let i = 0; i < nodes.length; i++) {
+    const {id, parentId, depth, xStart: qXStart, xEnd: qXEnd} = nodes[i];
+    if (qXEnd <= zoomRegion.queryXStart || qXStart >= zoomRegion.queryXEnd) {
+      continue;
+    }
+    const relativeXStart = qXStart - zoomRegion.queryXStart;
+    const relativeXEnd = qXEnd - zoomRegion.queryXStart;
+    const relativeWidth = relativeXEnd - relativeXStart;
+
+    const x = Math.max(0, relativeXStart) / queryXPerPx;
+    const y = NODE_HEIGHT * (depth + 1);
+    const width = Math.min(relativeWidth, zoomQueryWidth) / queryXPerPx;
+    const state = computeState(qXStart, qXEnd, zoomRegion);
+
+    if (width < MIN_PIXEL_DISPLAYED) {
+      const parentIdx = assertExists(idToIdx.get(parentId));
+      const childMergedIdx = idxToChildMergedIdx.get(parentIdx);
+      if (childMergedIdx !== undefined) {
+        const r = renderNodes[childMergedIdx];
+        const mergedWidth =
+          Math.min(qXEnd - r.source.queryXStart, zoomQueryWidth) / queryXPerPx;
+        renderNodes[childMergedIdx] = {
+          ...r,
+          width: Math.max(mergedWidth, MIN_PIXEL_DISPLAYED),
+          source: {
+            ...(r.source as MergedSource),
+            queryXEnd: qXEnd,
+          },
+        };
+        idToIdx.set(id, childMergedIdx);
+        continue;
+      }
+      const parentNode = renderNodes[parentIdx];
+      renderNodes.push({
+        x: parentNode.source.kind === 'MERGED' ? parentNode.x : x,
+        y,
+        width: Math.max(width, MIN_PIXEL_DISPLAYED),
+        source: {kind: 'MERGED', queryXStart: qXStart, queryXEnd: qXEnd},
+        state,
+      });
+      idToIdx.set(id, renderNodes.length - 1);
+      idxToChildMergedIdx.set(parentIdx, renderNodes.length - 1);
+      continue;
+    }
+    renderNodes.push({
+      x,
+      y,
+      width,
+      source: {
+        kind: 'NODE',
+        queryXStart: qXStart,
+        queryXEnd: qXEnd,
+        queryIdx: i,
+      },
+      state,
+    });
+    idToIdx.set(id, renderNodes.length - 1);
+  }
+  return renderNodes;
+}
+
+function computeState(qXStart: number, qXEnd: number, zoomRegion: ZoomRegion) {
+  if (qXStart === zoomRegion.queryXStart && qXEnd === zoomRegion.queryXEnd) {
+    return 'SELECTED';
+  }
+  if (qXStart < zoomRegion.queryXStart || qXEnd > zoomRegion.queryXEnd) {
+    return 'PARTIAL';
+  }
+  return 'NORMAL';
+}
+
+function isHovered(
+  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
+  );
+}
+
+function displaySize(totalSize: number, unit: string): string {
+  if (unit === '') return totalSize.toLocaleString();
+  if (totalSize === 0) return `0 ${unit}`;
+  const step = unit === 'B' ? 1024 : 1000;
+  const units = [
+    ['', 1],
+    ['K', step],
+    ['M', Math.pow(step, 2)],
+    ['G', Math.pow(step, 3)],
+  ];
+  let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step));
+  unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex;
+  const result = totalSize / +units[unitsIndex][1];
+  const resultString =
+    totalSize % +units[unitsIndex][1] === 0
+      ? result.toString()
+      : result.toFixed(2);
+  return `${resultString} ${units[unitsIndex][0]}${unit}`;
+}
+
+function normalizeFilter(filter: string) {
+  const lower = filter.toLowerCase();
+  if (lower.startsWith('ss: ') || lower.startsWith('show stack: ')) {
+    return 'Show Stack: ' + filter.split(': ', 2)[1];
+  } else if (lower.startsWith('hs: ') || lower.startsWith('hide stack: ')) {
+    return 'Hide Stack: ' + filter.split(': ', 2)[1];
+  } else if (lower.startsWith('sf: ') || lower.startsWith('show frame: ')) {
+    return 'Show Frame: ' + filter.split(': ', 2)[1];
+  } else if (lower.startsWith('hf: ') || lower.startsWith('hide frame: ')) {
+    return 'Hide Frame: ' + filter.split(': ', 2)[1];
+  }
+  return undefined;
+}
+
+function computeFilters(rawFilters: readonly string[]): FlamegraphFilters {
+  const showStack = rawFilters
+    .filter((x) => x.startsWith('Show Stack: '))
+    .map((x) => x.split(': ', 2)[1]);
+
+  assertTrue(
+    showStack.length < 32,
+    'More than 32 show stack filters is not supported',
+  );
+  return {
+    showStack,
+    hideStack: rawFilters
+      .filter((x) => x.startsWith('Hide Stack: '))
+      .map((x) => x.split(': ', 2)[1]),
+    showFrame: rawFilters
+      .filter((x) => x.startsWith('Show Frame: '))
+      .map((x) => x.split(': ', 2)[1]),
+    hideFrame: rawFilters
+      .filter((x) => x.startsWith('Hide Frame: '))
+      .map((x) => x.split(': ', 2)[1]),
+  };
+}
+
+function generateColor(name: string, greyed: boolean, hovered: boolean) {
+  if (greyed) {
+    return `hsl(0deg, 0%, ${hovered ? 85 : 80}%)`;
+  }
+  if (name === 'unknown' || name === 'root') {
+    return `hsl(0deg, 0%, ${hovered ? 78 : 73}%)`;
+  }
+  let x = 0;
+  for (let i = 0; i < name.length; ++i) {
+    x += name.charCodeAt(i) % 64;
+  }
+  return `hsl(${x % 360}deg, 45%, ${hovered ? 78 : 73}%)`;
+}
diff --git a/ui/src/widgets/tag_input.ts b/ui/src/widgets/tag_input.ts
index f13a36f..816228c 100644
--- a/ui/src/widgets/tag_input.ts
+++ b/ui/src/widgets/tag_input.ts
@@ -21,9 +21,11 @@
 export interface TagInputAttrs extends HTMLAttrs {
   value?: string;
   onChange?: (text: string) => void;
-  tags: string[];
+  tags: ReadonlyArray<string>;
   onTagAdd: (text: string) => void;
   onTagRemove: (index: number) => void;
+  onfocus?: () => void;
+  onblur?: () => void;
   placeholder?: string;
 }
 
@@ -87,6 +89,8 @@
       tags,
       onTagAdd,
       onTagRemove,
+      onfocus,
+      onblur,
       placeholder,
       ...htmlAttrs
     } = attrs;
@@ -132,6 +136,8 @@
           const el = ev.target as HTMLInputElement;
           onChange?.(el.value);
         },
+        onfocus,
+        onblur,
       }),
     );
   }