Merge "perfetto: extract out lifecycle management from the window operator" into main
diff --git a/include/perfetto/tracing/track.h b/include/perfetto/tracing/track.h
index 0d5a364..c12f231 100644
--- a/include/perfetto/tracing/track.h
+++ b/include/perfetto/tracing/track.h
@@ -204,7 +204,7 @@
   using CounterType =
       perfetto::protos::gen::CounterDescriptor::BuiltinCounterType;
 
-  // |name| must be a string with static lifetime.
+  // |name| must outlive this object.
   constexpr explicit CounterTrack(const char* name,
                                   Track parent = MakeProcessTrack())
       : Track(internal::Fnv1a(name) ^ kCounterMagic, parent),
@@ -212,7 +212,7 @@
         category_(nullptr) {}
 
   // |unit_name| is a free-form description of the unit used by this counter. It
-  // must have static lifetime.
+  // must outlive this object.
   constexpr CounterTrack(const char* name,
                          const char* unit_name,
                          Track parent = MakeProcessTrack())
diff --git a/src/trace_redaction/main.cc b/src/trace_redaction/main.cc
index 767e90e..ea728bc 100644
--- a/src/trace_redaction/main.cc
+++ b/src/trace_redaction/main.cc
@@ -39,21 +39,21 @@
   TraceRedactor redactor;
 
   // Add all collectors.
-  redactor.collectors()->emplace_back(new FindPackageUid());
-  redactor.collectors()->emplace_back(new BuildTimeline());
+  redactor.emplace_collect<FindPackageUid>();
+  redactor.emplace_collect<BuildTimeline>();
 
   // Add all builders.
-  redactor.builders()->emplace_back(new PopulateAllowlists());
-  redactor.builders()->emplace_back(new OptimizeTimeline());
+  redactor.emplace_build<PopulateAllowlists>();
+  redactor.emplace_build<OptimizeTimeline>();
 
   // Add all transforms.
-  redactor.transformers()->emplace_back(new PrunePackageList());
-  redactor.transformers()->emplace_back(new ScrubTracePacket());
-  redactor.transformers()->emplace_back(new ScrubFtraceEvents());
-  redactor.transformers()->emplace_back(new ScrubProcessTrees());
-  redactor.transformers()->emplace_back(new ScrubTaskRename());
-  redactor.transformers()->emplace_back(new RedactSchedSwitch());
-  redactor.transformers()->emplace_back(new RedactSchedWaking());
+  redactor.emplace_transform<PrunePackageList>();
+  redactor.emplace_transform<ScrubTracePacket>();
+  redactor.emplace_transform<ScrubFtraceEvents>();
+  redactor.emplace_transform<ScrubProcessTrees>();
+  redactor.emplace_transform<ScrubTaskRename>();
+  redactor.emplace_transform<RedactSchedSwitch>();
+  redactor.emplace_transform<RedactSchedWaking>();
 
   Context context;
   context.package_name = package_name;
diff --git a/src/trace_redaction/redact_sched_switch_integrationtest.cc b/src/trace_redaction/redact_sched_switch_integrationtest.cc
index 7cb516b..26dd400 100644
--- a/src/trace_redaction/redact_sched_switch_integrationtest.cc
+++ b/src/trace_redaction/redact_sched_switch_integrationtest.cc
@@ -50,10 +50,10 @@
 class RedactSchedSwitchIntegrationTest : public testing::Test {
  protected:
   void SetUp() override {
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.collectors()->emplace_back(new BuildTimeline());
-    redactor_.builders()->emplace_back(new OptimizeTimeline());
-    redactor_.transformers()->emplace_back(new RedactSchedSwitch());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_collect<BuildTimeline>();
+    redactor_.emplace_build<OptimizeTimeline>();
+    redactor_.emplace_transform<RedactSchedSwitch>();
 
     context_.package_name = kPackageName;
 
diff --git a/src/trace_redaction/redact_sched_waking_integrationtest.cc b/src/trace_redaction/redact_sched_waking_integrationtest.cc
index 6b6a990..b83f7e9 100644
--- a/src/trace_redaction/redact_sched_waking_integrationtest.cc
+++ b/src/trace_redaction/redact_sched_waking_integrationtest.cc
@@ -50,10 +50,10 @@
 class RedactSchedWakingIntegrationTest : public testing::Test {
  protected:
   void SetUp() override {
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.collectors()->emplace_back(new BuildTimeline());
-    redactor_.builders()->emplace_back(new OptimizeTimeline());
-    redactor_.transformers()->emplace_back(new RedactSchedWaking());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_collect<BuildTimeline>();
+    redactor_.emplace_build<OptimizeTimeline>();
+    redactor_.emplace_transform<RedactSchedWaking>();
 
     context_.package_name = kPackageName;
 
diff --git a/src/trace_redaction/scrub_process_trees_integrationtest.cc b/src/trace_redaction/scrub_process_trees_integrationtest.cc
index c1ca7b8..61a7bcd 100644
--- a/src/trace_redaction/scrub_process_trees_integrationtest.cc
+++ b/src/trace_redaction/scrub_process_trees_integrationtest.cc
@@ -58,10 +58,10 @@
     // BuildTimeline depends on.... nothing
     // FindPackageUid depends on... nothing
 
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.collectors()->emplace_back(new BuildTimeline());
-    redactor_.builders()->emplace_back(new OptimizeTimeline());
-    redactor_.transformers()->emplace_back(new ScrubProcessTrees());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_collect<BuildTimeline>();
+    redactor_.emplace_build<OptimizeTimeline>();
+    redactor_.emplace_transform<ScrubProcessTrees>();
 
     // In this case, the process and package have the same name.
     context_.package_name = kProcessName;
diff --git a/src/trace_redaction/scrub_task_rename_integrationtest.cc b/src/trace_redaction/scrub_task_rename_integrationtest.cc
index eb75208..dee77e6 100644
--- a/src/trace_redaction/scrub_task_rename_integrationtest.cc
+++ b/src/trace_redaction/scrub_task_rename_integrationtest.cc
@@ -55,10 +55,10 @@
   void SetUp() override {
     // In order for ScrubTaskRename to work, it needs the timeline. All
     // registered primitives are there to generate the timeline.
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.collectors()->emplace_back(new BuildTimeline());
-    redactor_.builders()->emplace_back(new OptimizeTimeline());
-    redactor_.transformers()->emplace_back(new ScrubTaskRename());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_collect<BuildTimeline>();
+    redactor_.emplace_build<OptimizeTimeline>();
+    redactor_.emplace_transform<ScrubTaskRename>();
 
     context_.package_name = kPackageName;
 
diff --git a/src/trace_redaction/trace_redactor.h b/src/trace_redaction/trace_redactor.h
index 82ec371..7da6b3d 100644
--- a/src/trace_redaction/trace_redactor.h
+++ b/src/trace_redaction/trace_redactor.h
@@ -45,16 +45,31 @@
                       std::string_view dest_filename,
                       Context* context) const;
 
-  std::vector<std::unique_ptr<CollectPrimitive>>* collectors() {
-    return &collectors_;
+  // T must be derived from trace_redaction::CollectPrimitive.
+  template <typename T>
+  T* emplace_collect() {
+    auto uptr = std::make_unique<T>();
+    auto* ptr = uptr.get();
+    collectors_.push_back(std::move(uptr));
+    return ptr;
   }
 
-  std::vector<std::unique_ptr<BuildPrimitive>>* builders() {
-    return &builders_;
+  // T must be derived from trace_redaction::BuildPrimitive.
+  template <typename T>
+  T* emplace_build() {
+    auto uptr = std::make_unique<T>();
+    auto* ptr = uptr.get();
+    builders_.push_back(std::move(uptr));
+    return ptr;
   }
 
-  std::vector<std::unique_ptr<TransformPrimitive>>* transformers() {
-    return &transformers_;
+  // T must be derived from trace_redaction::TransformPrimitive.
+  template <typename T>
+  T* emplace_transform() {
+    auto uptr = std::make_unique<T>();
+    auto* ptr = uptr.get();
+    transformers_.push_back(std::move(uptr));
+    return ptr;
   }
 
  private:
diff --git a/src/trace_redaction/trace_redactor_integrationtest.cc b/src/trace_redaction/trace_redactor_integrationtest.cc
index 3ee73da..db3e23f 100644
--- a/src/trace_redaction/trace_redactor_integrationtest.cc
+++ b/src/trace_redaction/trace_redactor_integrationtest.cc
@@ -89,8 +89,8 @@
   void SetUp() override {
     TraceRedactorIntegrationTest::SetUp();
 
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.transformers()->emplace_back(new PrunePackageList());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_transform<PrunePackageList>();
   }
 
   std::vector<protozero::ConstBytes> GetPackageInfos(
@@ -210,10 +210,10 @@
   void SetUp() override {
     TraceRedactorIntegrationTest::SetUp();
 
-    redactor_.collectors()->emplace_back(new FindPackageUid());
-    redactor_.builders()->emplace_back(new PopulateAllowlists());
-    redactor_.transformers()->emplace_back(new ScrubTracePacket());
-    redactor_.transformers()->emplace_back(new ScrubFtraceEvents());
+    redactor_.emplace_collect<FindPackageUid>();
+    redactor_.emplace_build<PopulateAllowlists>();
+    redactor_.emplace_transform<ScrubTracePacket>();
+    redactor_.emplace_transform<ScrubFtraceEvents>();
   }
 
   static base::StatusOr<protozero::ConstBytes> FindFirstFtraceEvents(
diff --git a/test/data/chrome/chrome_input_with_frame_view.pftrace.sha256 b/test/data/chrome/chrome_input_with_frame_view.pftrace.sha256
index ea5a606..d0943a8 100644
--- a/test/data/chrome/chrome_input_with_frame_view.pftrace.sha256
+++ b/test/data/chrome/chrome_input_with_frame_view.pftrace.sha256
@@ -1 +1 @@
-1e4e1b7098c3c1b900d31fa6d6791e7b022e85ecebbb560123ce7139b3f82231
\ No newline at end of file
+a93548822e481508c728ccc5da3ad34afcd0aec02ca7a7a4dad84ff340ee5975
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
index e21997c..e82ef5c 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_expand_camera.png.sha256
@@ -1 +1 @@
-8e2f7043d233187a8e2229e3bfd64c3a21522e52036bf4135641c4b22ff2a40d
\ No newline at end of file
+c40361fe7a34f3506e0b84d585ea15eb07d111bf0fa49d3976f40738e3448c7a
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256 b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
index 22796d2..260ea4f 100644
--- a/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
+++ b/test/data/ui-screenshots/ui-android_trace_30s_load.png.sha256
@@ -1 +1 @@
-804eb26f54e147bc536d14354cc1694f748e598b7baaaa50cfd41e137781cbac
\ No newline at end of file
+0954283d6fc7beb554ffbaee5afe354aed896a7eeefe2475e14c1ad64327a6f7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
index 95e467d..880b363 100644
--- a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
@@ -1 +1 @@
-8948301117c29da08b8b616501462a3012d3afd58e94bc8fad46eb8ed3199d23
\ No newline at end of file
+8d0cb7b3d4794c4f036fb851dd8a4726e7e6c90f15657b86c06a1fabe2c7984c
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index 2e11179..e327bd1 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-cafa4721383e4b0d4425ec5a1b0f738a91d665196957b70e535df0ccc40e54b8
\ No newline at end of file
+9e84ad0881a56d3731066604e973b6297dee7b9a602e6b35013560fd0bc8fd39
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
index 8b12476..1d3de51 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
@@ -1 +1 @@
-09f18560b7c44f2fb3fde256b6de77cf3e7688dd2f7213a2df73534b29cc6104
\ No newline at end of file
+eb92c26c12d039aac315e3514bc24ebfcf392914309b28bc83a172127fe60250
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
index 32ead5f..11dfc47 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_select_slice_with_flows.png.sha256
@@ -1 +1 @@
-5f4d43c74cc565e94dc9e77c522dbe0d84c14a1f1c7d123a7e1c6b4d338c25cc
\ No newline at end of file
+1d80ba6fc24a1db8252e24e0d4250787bf7da4f6f3f7efe4e71b63a2b42c6e23
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
index f394224..ac18e72 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
@@ -1 +1 @@
-e885a74d6e5eaa497a79f018bb60bbd6ddc664e22ff7c62730032916ff58f610
\ No newline at end of file
+99c00e663442fd3ca7913b5ce7bea350af82f5ec8422d16f49175c91d484b52a
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
index 0669589..ff1df49 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
@@ -1 +1 @@
-7e0810f73b905ac2586f579efe0e45f9997da071329a55cabd05fb6e7b9fd26b
\ No newline at end of file
+f345cfd5f9f2c5922d15c691dbe76d5d50ccfecdf35f2fd0033c1462299b0d17
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
index 20f9f33..97c82b1 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_open_trace_from_url.png.sha256
@@ -1 +1 @@
-c006f2550408db44cf35f8e2b18cae1592568fe7d8a5fcdc1d454eb40a7cf34e
\ No newline at end of file
+46d1d64e852be22cb86a7eb6868aea3007ce04c663e9667ae55b71d5a0fad6b7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256 b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
index 6bc94e4..f60a4ff 100644
--- a/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_invalid_trace_from_blank_page.png.sha256
@@ -1 +1 @@
-8374cbf36adb6de6371002ca3af76e53186cf6138eed82bdf14902fe874dd7dc
\ No newline at end of file
+cd2fafc00d2f2ab761a7c17a0bdc9e606e3996fe20c17ff7e0b61a0e11c55199
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
index 72a3447..c417604 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_access_subpage_then_go_back.png.sha256
@@ -1 +1 @@
-f050c13da6a113631d22b24fcf839026cdb848815119e46f10d2472ce522a3a2
\ No newline at end of file
+049301241729848f47474868766daf513e7f739efddbfc3dc3f96200f9b63bc6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
index 20f9f33..97c82b1 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_first_trace_from_url.png.sha256
@@ -1 +1 @@
-c006f2550408db44cf35f8e2b18cae1592568fe7d8a5fcdc1d454eb40a7cf34e
\ No newline at end of file
+46d1d64e852be22cb86a7eb6868aea3007ce04c663e9667ae55b71d5a0fad6b7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256 b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
index 72a3447..c417604 100644
--- a/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_open_two_traces_then_go_back_open_second_trace_from_url.png.sha256
@@ -1 +1 @@
-f050c13da6a113631d22b24fcf839026cdb848815119e46f10d2472ce522a3a2
\ No newline at end of file
+049301241729848f47474868766daf513e7f739efddbfc3dc3f96200f9b63bc6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
index 72a3447..c417604 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_go_back_to_first_trace.png.sha256
@@ -1 +1 @@
-f050c13da6a113631d22b24fcf839026cdb848815119e46f10d2472ce522a3a2
\ No newline at end of file
+049301241729848f47474868766daf513e7f739efddbfc3dc3f96200f9b63bc6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
index a8a5277..c69fd14 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_invalid_trace.png.sha256
@@ -1 +1 @@
-e37a6224d98083ee764f849bc02af11ebbd09574b3ef0dc51837057e0ec9324a
\ No newline at end of file
+4b0c253d17c45c7e787e7b9cc484a9c0af38bcf77a92645266f15879af92d8e2
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
index 20f9f33..97c82b1 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_second_trace.png.sha256
@@ -1 +1 @@
-c006f2550408db44cf35f8e2b18cae1592568fe7d8a5fcdc1d454eb40a7cf34e
\ No newline at end of file
+46d1d64e852be22cb86a7eb6868aea3007ce04c663e9667ae55b71d5a0fad6b7
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
index 72a3447..c417604 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_open_trace_.png.sha256
@@ -1 +1 @@
-f050c13da6a113631d22b24fcf839026cdb848815119e46f10d2472ce522a3a2
\ No newline at end of file
+049301241729848f47474868766daf513e7f739efddbfc3dc3f96200f9b63bc6
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256 b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
index 72a3447..c417604 100644
--- a/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_start_from_no_trace_refresh.png.sha256
@@ -1 +1 @@
-f050c13da6a113631d22b24fcf839026cdb848815119e46f10d2472ce522a3a2
\ No newline at end of file
+049301241729848f47474868766daf513e7f739efddbfc3dc3f96200f9b63bc6
\ No newline at end of file
diff --git a/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3.out b/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3.out
index f3d91cd..15270ab 100644
--- a/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3.out
+++ b/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3.out
@@ -1,4 +1,2 @@
 "cause_of_jank","sub_cause_of_jank","delay_since_last_frame","vsync_interval"
-"RendererCompositorQueueingDelay","[NULL]",33.462000,16.368000
-"RendererCompositorFinishedToBeginImplFrame","[NULL]",100.274000,16.368000
-"RendererCompositorQueueingDelay","[NULL]",33.404000,16.368000
+"RendererCompositorQueueingDelay","[NULL]",22.331000,10.483000
diff --git a/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3_percentage.out b/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3_percentage.out
index 2081e06..f070675 100644
--- a/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3_percentage.out
+++ b/test/trace_processor/diff_tests/stdlib/chrome/scroll_jank_v3_percentage.out
@@ -1,2 +1,2 @@
 "delayed_frame_percentage"
-1.030928
+0.282486
diff --git a/test/trace_processor/diff_tests/stdlib/chrome/tests_scroll_jank.py b/test/trace_processor/diff_tests/stdlib/chrome/tests_scroll_jank.py
index 80b679c..ae641d4 100755
--- a/test/trace_processor/diff_tests/stdlib/chrome/tests_scroll_jank.py
+++ b/test/trace_processor/diff_tests/stdlib/chrome/tests_scroll_jank.py
@@ -112,7 +112,6 @@
         INCLUDE PERFETTO MODULE chrome.scroll_jank.scroll_jank_intervals;
 
         SELECT
-          id,
           ts,
           dur,
           track_id,
@@ -122,14 +121,11 @@
           delayed_frame_count,
           frame_jank_ts,
           frame_jank_dur
-        FROM chrome_janky_event_latencies_v3
-        ORDER by id;
+        FROM chrome_janky_event_latencies_v3;
         """,
         out=Csv("""
-        "id","ts","dur","track_id","name","cause_of_jank","sub_cause_of_jank","delayed_frame_count","frame_jank_ts","frame_jank_dur"
-        29926,174795897267797,48088000,1431,"EventLatency","RendererCompositorQueueingDelay","[NULL]",1,174795928261797,17094000
-        38463,174796315541797,131289000,2163,"EventLatency","RendererCompositorFinishedToBeginImplFrame","[NULL]",5,174796362924797,83906000
-        88876,174799556245797,49856000,4329,"EventLatency","RendererCompositorQueueingDelay","[NULL]",1,174799589065797,17036000
+        "ts","dur","track_id","name","cause_of_jank","sub_cause_of_jank","delayed_frame_count","frame_jank_ts","frame_jank_dur"
+        1035869386651926,60311000,2314,"EventLatency","RendererCompositorQueueingDelay","[NULL]",1,1035869435114926,11847999
         """))
 
   def test_chrome_janky_frame_presentation_intervals(self):
@@ -144,16 +140,13 @@
           dur,
           cause_of_jank,
           sub_cause_of_jank,
-          delayed_frame_count,
-          event_latency_id
+          delayed_frame_count
         FROM chrome_janky_frame_presentation_intervals
         ORDER by id;
         """,
         out=Csv("""
-        "id","ts","dur","cause_of_jank","sub_cause_of_jank","delayed_frame_count","event_latency_id"
-        1,174795928261797,17094000,"RendererCompositorQueueingDelay","[NULL]",1,29926
-        2,174796362924797,83906000,"RendererCompositorFinishedToBeginImplFrame","[NULL]",5,38463
-        3,174799589065797,17036000,"RendererCompositorQueueingDelay","[NULL]",1,88876
+        "id","ts","dur","cause_of_jank","sub_cause_of_jank","delayed_frame_count"
+        1,1035869435114926,11847999,"RendererCompositorQueueingDelay","[NULL]",1
         """))
 
   def test_chrome_scroll_stats(self):
@@ -174,9 +167,10 @@
         """,
         out=Csv("""
         "scroll_id","missed_vsyncs","frame_count","presented_frame_count","janky_frame_count","janky_frame_percent"
-        1186,6,110,105,2,1.900000
-        1889,"[NULL]",101,102,0,0.000000
-        2506,1,84,84,1,1.190000
+        4328,"[NULL]",109,110,0,0.000000
+        4471,"[NULL]",117,118,0,0.000000
+        4620,"[NULL]",5,4,0,0.000000
+        4652,1,122,122,1,0.820000
         """))
 
   def test_chrome_scroll_jank_intervals_v3(self):
@@ -194,9 +188,7 @@
         """,
         out=Csv("""
         "id","ts","dur"
-        1,174795928261797,17094000
-        2,174796362924797,83906000
-        3,174799589065797,17036000
+        1,1035869435114926,11847999
         """))
   def test_chrome_presented_scroll_offsets(self):
     return DiffTestBlueprint(
diff --git a/tools/install-build-deps b/tools/install-build-deps
index ab15251..a3befb0 100755
--- a/tools/install-build-deps
+++ b/tools/install-build-deps
@@ -165,13 +165,13 @@
     # tools/clang/scripts/update.py.
     Dependency(
         'buildtools/linux64/clang.tgz',
-        'https://commondatastorage.googleapis.com/chromium-browser-clang/Linux_x64/clang-llvmorg-19-init-2941-ga0b3dbaf-22.tar.xz',
-        '7b33138d8592199f97d132242d7b3e10f460c5c9655d49a3ad3767218fba7a77',
+        'https://commondatastorage.googleapis.com/chromium-browser-clang/Linux_x64/clang-llvmorg-19-init-2941-ga0b3dbaf-22.tgz',
+        '6741cc1083f935795330b6e04617ac891a7b5d2b5647b664c5b0fccc354adb43',
         'linux', 'x64'),
     Dependency(
         'buildtools/win/clang.tgz',
-        'https://commondatastorage.googleapis.com/chromium-browser-clang/Win/clang-llvmorg-19-init-2941-ga0b3dbaf-22.tar.xz',
-        'c8e1c41eb36aef6e63d65755d4746f68688c2fcefca44777a205d412c83d25a1',
+        'https://commondatastorage.googleapis.com/chromium-browser-clang/Win/clang-llvmorg-19-init-2941-ga0b3dbaf-22.tgz',
+        'f627080ed53d4c156f089323e04fa3690c8bb459110b62cd1952b0e1f0755987',
         'windows', 'x64'),
 ]
 
diff --git a/ui/release/channels.json b/ui/release/channels.json
index 5d29857..5f4dd4d 100644
--- a/ui/release/channels.json
+++ b/ui/release/channels.json
@@ -6,7 +6,7 @@
     },
     {
       "name": "canary",
-      "rev": "5457e2afae21f167ef490c7e76a5e3fc6c53e04a"
+      "rev": "58439240cbad83689a1fbafd8234a379a56be7c1"
     },
     {
       "name": "autopush",
diff --git a/ui/src/assets/panel_container.scss b/ui/src/assets/panel_container.scss
index 6163353..2356ff8 100644
--- a/ui/src/assets/panel_container.scss
+++ b/ui/src/assets/panel_container.scss
@@ -20,24 +20,13 @@
   // instead.
   user-select: none;
 
-  .pf-panels {
-    // Make this a positioned element so .pf-scroll-limiter is positioned
-    // relative to this element.
-    position: relative;
+  .pf-panel-stack {
+    position: relative; // Position overlay relative to this element
 
-    // In the scrolling case, since the canvas is overdrawn and continuously
-    // repositioned, we need the canvas to be in a div with overflow hidden and
-    // height equalling the total height of the content to prevent scrolling
-    // height from growing.
-    .pf-scroll-limiter {
+    .pf-overlay {
       position: absolute;
-      top: 0;
-      left: 0;
-      bottom: 0;
-      overflow: hidden;
-
-      // Make this overlay invisible to pointer events.
-      pointer-events: none;
+      inset: 0; // Shorthand for [top, left, right, bottom]: 0
+      pointer-events: none; // Make this overlay invisible to pointer events
     }
 
     .pf-panel {
diff --git a/ui/src/assets/viewer_page.scss b/ui/src/assets/viewer_page.scss
index 3fdd737..2ccc9eb 100644
--- a/ui/src/assets/viewer_page.scss
+++ b/ui/src/assets/viewer_page.scss
@@ -24,13 +24,15 @@
     z-index: 1;
     flex-grow: 0;
     flex-shrink: 0;
-    overflow: hidden;
+    overflow-x: hidden;
     overflow-y: auto;
+    scrollbar-gutter: stable;
   }
 
   .scrolling-panel-container {
     overflow-x: hidden;
     overflow-y: auto;
+    scrollbar-gutter: stable;
     flex: 1 1 auto;
     will-change: transform; // Force layer creation.
   }
@@ -40,10 +42,21 @@
     overflow: auto;
   }
 
-  .header-panel-container {
-    overflow: visible;
+  .header {
+    display: flex;
+    flex-direction: row;
     box-shadow: 1px 3px 15px rgba(23, 32, 44, 0.3);
     z-index: 2;
+
+    .header-panel-container {
+      flex-grow: 1;
+    }
+
+    .scrollbar-spacer-vertical {
+      scrollbar-gutter: stable;
+      overflow-y: scroll;
+      visibility: hidden;
+    }
   }
 
   .pan-and-zoom-content {
diff --git a/ui/src/base/dom_utils.ts b/ui/src/base/dom_utils.ts
index 9dca373..3c39e9c 100644
--- a/ui/src/base/dom_utils.ts
+++ b/ui/src/base/dom_utils.ts
@@ -82,25 +82,3 @@
 
   return {x: e.offsetX, y: e.offsetY};
 }
-
-function calculateScrollbarWidth() {
-  const outer = document.createElement('div');
-  outer.style.overflowY = 'scroll';
-  const inner = document.createElement('div');
-  outer.appendChild(inner);
-  document.body.appendChild(outer);
-  const width =
-    outer.getBoundingClientRect().width - inner.getBoundingClientRect().width;
-  document.body.removeChild(outer);
-  return width;
-}
-
-let cachedScrollBarWidth: number | undefined = undefined;
-
-// Calculate the space a scrollbar takes up.
-export function getScrollbarWidth() {
-  if (cachedScrollBarWidth === undefined) {
-    cachedScrollBarWidth = calculateScrollbarWidth();
-  }
-  return cachedScrollBarWidth;
-}
diff --git a/ui/src/base/geom.ts b/ui/src/base/geom.ts
new file mode 100644
index 0000000..f569400
--- /dev/null
+++ b/ui/src/base/geom.ts
@@ -0,0 +1,59 @@
+// 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.
+
+export interface Rect {
+  readonly left: number;
+  readonly top: number;
+  readonly right: number;
+  readonly bottom: number;
+}
+
+export interface Size {
+  readonly width: number;
+  readonly height: number;
+}
+
+export function intersectRects(a: Rect, b: Rect): Rect {
+  return {
+    top: Math.max(a.top, b.top),
+    left: Math.max(a.left, b.left),
+    bottom: Math.min(a.bottom, b.bottom),
+    right: Math.min(a.right, b.right),
+  };
+}
+
+export function expandRect(r: Rect, amount: number): Rect {
+  return {
+    top: r.top - amount,
+    left: r.left - amount,
+    bottom: r.bottom + amount,
+    right: r.right + amount,
+  };
+}
+
+export function rebaseRect(r: Rect, x: number, y: number): Rect {
+  return {
+    left: r.left - x,
+    right: r.right - x,
+    top: r.top - y,
+    bottom: r.bottom - y,
+  };
+}
+
+export function rectSize(r: Rect): Size {
+  return {
+    width: r.right - r.left,
+    height: r.bottom - r.top,
+  };
+}
diff --git a/ui/src/base/geom_unittest.ts b/ui/src/base/geom_unittest.ts
new file mode 100644
index 0000000..628d053
--- /dev/null
+++ b/ui/src/base/geom_unittest.ts
@@ -0,0 +1,52 @@
+// 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 {intersectRects, expandRect, rebaseRect, rectSize, Rect} from './geom';
+
+describe('intersectRects', () => {
+  it('should correctly intersect two overlapping rects', () => {
+    const a: Rect = {left: 1, top: 1, right: 4, bottom: 4};
+    const b: Rect = {left: 2, top: 2, right: 5, bottom: 5};
+    const result = intersectRects(a, b);
+    expect(result).toEqual({left: 2, top: 2, right: 4, bottom: 4});
+  });
+  // Note: Non-overlapping rects are not supported and thus not tested
+});
+
+describe('expandRect', () => {
+  it('should correctly expand a rect by a given amount', () => {
+    const rect: Rect = {left: 1, top: 1, right: 3, bottom: 3};
+    const amount = 1;
+    const result = expandRect(rect, amount);
+    expect(result).toEqual({left: 0, top: 0, right: 4, bottom: 4});
+  });
+});
+
+describe('rebaseRect', () => {
+  it('should correctly rebase a rect', () => {
+    const rect: Rect = {left: 2, top: 2, right: 5, bottom: 5};
+    const x = 1;
+    const y = 1;
+    const result = rebaseRect(rect, x, y);
+    expect(result).toEqual({left: 1, top: 1, right: 4, bottom: 4});
+  });
+});
+
+describe('rectSize', () => {
+  it('should correctly calculate the size of a rect', () => {
+    const rect: Rect = {left: 1, top: 1, right: 4, bottom: 3};
+    const result = rectSize(rect);
+    expect(result).toEqual({width: 3, height: 2});
+  });
+});
diff --git a/ui/src/frontend/base_counter_track.ts b/ui/src/frontend/base_counter_track.ts
index 46a7f27..98730e0 100644
--- a/ui/src/frontend/base_counter_track.ts
+++ b/ui/src/frontend/base_counter_track.ts
@@ -42,7 +42,7 @@
 function roundAway(n: number): number {
   const exp = Math.ceil(Math.log10(Math.max(Math.abs(n), 1)));
   const pow10 = Math.pow(10, exp);
-  return Math.sign(n) * (Math.ceil(Math.abs(n) / (pow10 / 4)) * (pow10 / 4));
+  return Math.sign(n) * (Math.ceil(Math.abs(n) / (pow10 / 20)) * (pow10 / 20));
 }
 
 function toLabel(n: number): string {
@@ -752,8 +752,13 @@
     }
 
     if (options.yRangeRounding === 'human_readable') {
-      yMax = roundAway(yMax);
-      yMin = roundAway(yMin);
+      if (options.yDisplay === 'log') {
+        yMax = Math.log(roundAway(Math.exp(yMax)));
+        yMin = Math.log(roundAway(Math.exp(yMin)));
+      } else {
+        yMax = roundAway(yMax);
+        yMin = roundAway(yMin);
+      }
     }
 
     const sharer = RangeSharer.get();
diff --git a/ui/src/frontend/debug_tracks.ts b/ui/src/frontend/debug_tracks.ts
index 6ff6297..0a39612 100644
--- a/ui/src/frontend/debug_tracks.ts
+++ b/ui/src/frontend/debug_tracks.ts
@@ -23,11 +23,13 @@
 export const DEBUG_SLICE_TRACK_URI = 'perfetto.DebugSlices';
 export const DEBUG_COUNTER_TRACK_URI = 'perfetto.DebugCounter';
 
-// Names of the columns of the underlying view to be used as ts / dur / name.
+// Names of the columns of the underlying view to be used as
+// ts / dur / name / pivot.
 export interface SliceColumns {
   ts: string;
   dur: string;
   name: string;
+  pivot?: string;
 }
 
 export interface DebugTrackV2CreateConfig {
@@ -87,6 +89,43 @@
   return actions;
 }
 
+export async function addPivotDebugSliceTracks(
+  engine: EngineProxy,
+  data: SqlDataSource,
+  trackName: string,
+  sliceColumns: SliceColumns,
+  argColumns: string[],
+  config?: DebugTrackV2CreateConfig,
+) {
+  if (sliceColumns.pivot) {
+    // Get distinct values to group by
+    const pivotValues = await engine.query(`
+      with all_vals as (${data.sqlSource})
+      select DISTINCT ${sliceColumns.pivot} from all_vals;`);
+
+    const iter = pivotValues.iter({});
+
+    for (; iter.valid(); iter.next()) {
+      const pivotDataSource: SqlDataSource = {
+        sqlSource: `select * from
+        (${data.sqlSource})
+        where ${sliceColumns.pivot} = '${iter.get(sliceColumns.pivot)}'`,
+      };
+
+      const actions = await createDebugSliceTrackActions(
+        engine,
+        pivotDataSource,
+        `${trackName.trim() || 'Pivot Track'}: ${iter.get(sliceColumns.pivot)}`,
+        sliceColumns,
+        argColumns,
+        config,
+      );
+
+      globals.dispatchMultiple(actions);
+    }
+  }
+}
+
 // Adds a debug track immediately. Use createDebugSliceTrackActions() if you
 // want to create many tracks at once.
 export async function addDebugSliceTrack(
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 0e13781..517ae4b 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -15,21 +15,22 @@
 import m from 'mithril';
 
 import {Trash} from '../base/disposable';
-import {findRef, getScrollbarWidth} from '../base/dom_utils';
+import {findRef, toHTMLElement} from '../base/dom_utils';
 import {assertExists, assertFalse} from '../base/logging';
-import {SimpleResizeObserver} from '../base/resize_observer';
 import {time} from '../base/time';
 import {
+  PerfStatsSource,
+  RunningStatistics,
   debugNow,
   perfDebug,
   perfDisplay,
-  PerfStatsSource,
-  RunningStatistics,
   runningStatStr,
 } from '../core/perf';
 import {raf} from '../core/raf_scheduler';
 import {SliceRect} from '../public';
 
+import {SimpleResizeObserver} from '../base/resize_observer';
+import {canvasClip} from '../common/canvas_utils';
 import {
   SELECTION_STROKE_COLOR,
   TOPBAR_HEIGHT,
@@ -41,11 +42,9 @@
 } from './flow_events_renderer';
 import {globals} from './globals';
 import {PanelSize} from './panel';
-import {canvasClip} from '../common/canvas_utils';
+import {VirtualCanvas} from './virtual_canvas';
 
-// If the panel container scrolls, the backing canvas height is
-// SCROLLING_CANVAS_OVERDRAW_FACTOR * parent container height.
-const SCROLLING_CANVAS_OVERDRAW_FACTOR = 1.2;
+const CANVAS_OVERDRAW_PX = 100;
 
 export interface Panel {
   kind: 'panel';
@@ -62,7 +61,7 @@
   kind: 'group';
   collapsed: boolean;
   header: Panel;
-  childTracks: Panel[];
+  childPanels: Panel[];
   trackGroupId: string;
 }
 
@@ -70,9 +69,8 @@
 
 export interface PanelContainerAttrs {
   panels: PanelOrGroup[];
-  doesScroll: boolean;
-  kind: 'TRACKS' | 'OVERVIEW';
   className?: string;
+  onPanelStackResize?: (width: number, height: number) => void;
 }
 
 interface PanelInfo {
@@ -80,25 +78,23 @@
   panel: Panel;
   height: number;
   width: number;
-  x: number;
-  y: number;
+  clientX: number;
+  clientY: number;
 }
 
 export class PanelContainer
   implements m.ClassComponent<PanelContainerAttrs>, PerfStatsSource
 {
   // These values are updated with proper values in oncreate.
-  private parentWidth = 0;
-  private parentHeight = 0;
-  private scrollTop = 0;
-  private panelInfos: PanelInfo[] = [];
+  // Y position of the panel container w.r.t. the client
   private panelContainerTop = 0;
   private panelContainerHeight = 0;
-  private panelByKey = new Map<string, Panel>();
-  private totalPanelHeight = 0;
-  private canvasHeight = 0;
 
-  private flowEventsRenderer: FlowEventsRenderer;
+  // Updated every render cycle in the view() hook
+  private panelByKey = new Map<string, Panel>();
+
+  // Updated every render cycle in the oncreate/onupdate hook
+  private panelInfos: PanelInfo[] = [];
 
   private panelPerfStats = new WeakMap<Panel, RunningStatistics>();
   private perfStats = {
@@ -107,22 +103,12 @@
     renderStats: new RunningStatistics(10),
   };
 
-  // Attrs received in the most recent mithril redraw. We receive a new vnode
-  // with new attrs on every redraw, and we cache it here so that resize
-  // listeners and canvas redraw callbacks can access it.
-  private attrs: PanelContainerAttrs;
-
   private ctx?: CanvasRenderingContext2D;
 
-  private trash: Trash;
+  private readonly trash = new Trash();
 
-  private readonly SCROLL_LIMITER_REF = 'scroll-limiter';
-  private readonly PANELS_REF = 'panels';
-  private readonly OVERLAY_CANVAS_REF = 'canvas';
-
-  get canvasOverdrawFactor() {
-    return this.attrs.doesScroll ? SCROLLING_CANVAS_OVERDRAW_FACTOR : 1;
-  }
+  private readonly OVERLAY_REF = 'overlay';
+  private readonly PANEL_STACK_REF = 'panel-stack';
 
   getPanelsInRegion(
     startX: number,
@@ -137,12 +123,12 @@
     const panels: Panel[] = [];
     for (let i = 0; i < this.panelInfos.length; i++) {
       const pos = this.panelInfos[i];
-      const realPosX = pos.x - TRACK_SHELL_WIDTH;
+      const realPosX = pos.clientX - TRACK_SHELL_WIDTH;
       if (
         realPosX + pos.width >= minX &&
         realPosX <= maxX &&
-        pos.y + pos.height >= minY &&
-        pos.y <= maxY &&
+        pos.clientY + pos.height >= minY &&
+        pos.clientY <= maxY &&
         pos.panel.selectable
       ) {
         panels.push(pos.panel);
@@ -165,9 +151,9 @@
     }
     // Only get panels from the current panel container if the selection began
     // in this container.
-    const panelContainerTop = this.panelInfos[0].y;
+    const panelContainerTop = this.panelInfos[0].clientY;
     const panelContainerBottom =
-      this.panelInfos[this.panelInfos.length - 1].y +
+      this.panelInfos[this.panelInfos.length - 1].clientY +
       this.panelInfos[this.panelInfos.length - 1].height;
     if (
       globals.timeline.areaY.start + TOPBAR_HEIGHT < panelContainerTop ||
@@ -207,11 +193,7 @@
     globals.timeline.selectArea(area.start, area.end, tracks);
   }
 
-  constructor(vnode: m.CVnode<PanelContainerAttrs>) {
-    this.attrs = vnode.attrs;
-    this.flowEventsRenderer = new FlowEventsRenderer();
-    this.trash = new Trash();
-
+  constructor() {
     const onRedraw = () => this.renderCanvas();
     raf.addRedrawCallback(onRedraw);
     this.trash.addCallback(() => {
@@ -224,45 +206,52 @@
     });
   }
 
-  oncreate({dom}: m.CVnodeDOM<PanelContainerAttrs>) {
-    // Save the canvas context in the state.
-    const canvas = findRef(dom, this.OVERLAY_CANVAS_REF) as HTMLCanvasElement;
-    const ctx = canvas.getContext('2d');
+  private virtualCanvas?: VirtualCanvas;
+
+  oncreate(vnode: m.CVnodeDOM<PanelContainerAttrs>) {
+    const {dom, attrs} = vnode;
+
+    const overlayElement = toHTMLElement(
+      assertExists(findRef(dom, this.OVERLAY_REF)),
+    );
+
+    const virtualCanvas = new VirtualCanvas(overlayElement, dom, {
+      overdrawPx: CANVAS_OVERDRAW_PX,
+    });
+    this.trash.add(virtualCanvas);
+    this.virtualCanvas = virtualCanvas;
+
+    const ctx = virtualCanvas.canvasElement.getContext('2d');
     if (!ctx) {
       throw Error('Cannot create canvas context');
     }
     this.ctx = ctx;
 
-    this.readParentSizeFromDom(dom);
-    this.readPanelHeightsFromDom(dom);
+    virtualCanvas.setCanvasResizeListener((canvas, width, height) => {
+      const dpr = window.devicePixelRatio;
+      canvas.width = width * dpr;
+      canvas.height = height * dpr;
+    });
 
-    this.updateCanvasDimensions();
-    this.repositionCanvas();
+    virtualCanvas.setLayoutShiftListener(() => {
+      this.renderCanvas();
+    });
 
-    const scrollLimiter = assertExists(findRef(dom, this.SCROLL_LIMITER_REF));
-    this.trash.add(
-      new SimpleResizeObserver(scrollLimiter, () => {
-        const parentSizeChanged = this.readParentSizeFromDom(dom);
-        if (parentSizeChanged) {
-          this.updateCanvasDimensions();
-          this.repositionCanvas();
-          this.renderCanvas();
-        }
-      }),
+    this.onupdate(vnode);
+
+    const panelStackElement = toHTMLElement(
+      assertExists(findRef(dom, this.PANEL_STACK_REF)),
     );
 
-    // TODO(dproy): Handle change in doesScroll attribute.
-    if (this.attrs.doesScroll) {
-      const parentOnScroll = () => {
-        this.scrollTop = dom.scrollTop;
-        this.repositionCanvas();
-        raf.scheduleRedraw();
-      };
-      dom.addEventListener('scroll', parentOnScroll, {passive: true});
-      this.trash.addCallback(() => {
-        dom.removeEventListener('scroll', parentOnScroll);
-      });
-    }
+    // Listen for when the panel stack changes size
+    this.trash.add(
+      new SimpleResizeObserver(panelStackElement, () => {
+        attrs.onPanelStackResize?.(
+          panelStackElement.clientWidth,
+          panelStackElement.clientHeight,
+        );
+      }),
+    );
   }
 
   onremove() {
@@ -288,7 +277,7 @@
           `${path}-header`,
           node.collapsed ? '' : '.pf-sticky',
         ),
-        ...node.childTracks.map((child, index) =>
+        ...node.childPanels.map((child, index) =>
           this.renderTree(child, `${path}-${index}`),
         ),
       );
@@ -297,7 +286,6 @@
   }
 
   view({attrs}: m.CVnode<PanelContainerAttrs>) {
-    this.attrs = attrs;
     this.panelByKey.clear();
     const children = attrs.panels.map((panel, index) =>
       this.renderTree(panel, `track-tree-${index}`),
@@ -307,91 +295,22 @@
       '.pf-panel-container',
       {className: attrs.className},
       m(
-        '.pf-panels',
-        {ref: this.PANELS_REF},
-        m(
-          '.pf-scroll-limiter',
-          {ref: this.SCROLL_LIMITER_REF},
-          m('canvas.pf-overlay-canvas', {ref: this.OVERLAY_CANVAS_REF}),
-        ),
+        '.pf-panel-stack',
+        {ref: this.PANEL_STACK_REF},
+        m('.pf-overlay', {ref: this.OVERLAY_REF}),
         children,
       ),
     );
   }
 
   onupdate({dom}: m.CVnodeDOM<PanelContainerAttrs>) {
-    const totalPanelHeightChanged = this.readPanelHeightsFromDom(dom);
-    const parentSizeChanged = this.readParentSizeFromDom(dom);
-    const canvasSizeShouldChange =
-      parentSizeChanged || (!this.attrs.doesScroll && totalPanelHeightChanged);
-    if (canvasSizeShouldChange) {
-      this.updateCanvasDimensions();
-      this.repositionCanvas();
-      if (this.attrs.kind === 'TRACKS') {
-        globals.timeline.updateLocalLimits(
-          0,
-          this.parentWidth - TRACK_SHELL_WIDTH,
-        );
-      }
-      this.renderCanvas();
-    }
+    this.readPanelRectsFromDom(dom);
   }
 
-  private updateCanvasDimensions() {
-    this.canvasHeight = Math.floor(
-      this.attrs.doesScroll
-        ? this.parentHeight * this.canvasOverdrawFactor
-        : this.totalPanelHeight,
-    );
-    const ctx = assertExists(this.ctx);
-    const canvas = assertExists(ctx.canvas);
-    canvas.style.height = `${this.canvasHeight}px`;
-
-    // If're we're non-scrolling canvas and the scroll-limiter should always
-    // have the same height. Enforce this by explicitly setting the height.
-    if (!this.attrs.doesScroll) {
-      const scrollLimiter = canvas.parentElement;
-      if (scrollLimiter) {
-        scrollLimiter.style.height = `${this.canvasHeight}px`;
-      }
-    }
-
-    const dpr = window.devicePixelRatio;
-    ctx.canvas.width = this.parentWidth * dpr;
-    ctx.canvas.height = this.canvasHeight * dpr;
-    ctx.scale(dpr, dpr);
-  }
-
-  private repositionCanvas() {
-    const canvas = assertExists(assertExists(this.ctx).canvas);
-    const canvasYStart = Math.floor(
-      this.scrollTop - this.getCanvasOverdrawHeightPerSide(),
-    );
-    canvas.style.transform = `translateY(${canvasYStart}px)`;
-  }
-
-  // Reads dimensions of parent node. Returns true if read dimensions are
-  // different from what was cached in the state.
-  private readParentSizeFromDom(dom: Element): boolean {
-    const oldWidth = this.parentWidth;
-    const oldHeight = this.parentHeight;
-    const clientRect = dom.getBoundingClientRect();
-    // On non-MacOS if there is a solid scroll bar it can cover important
-    // pixels, reduce the size of the canvas so it doesn't overlap with
-    // the scroll bar.
-    this.parentWidth = clientRect.width - getScrollbarWidth();
-    this.parentHeight = clientRect.height;
-    return this.parentHeight !== oldHeight || this.parentWidth !== oldWidth;
-  }
-
-  // Reads dimensions of panels. Returns true if total panel height is different
-  // from what was cached in state.
-  private readPanelHeightsFromDom(dom: Element): boolean {
-    const prevHeight = this.totalPanelHeight;
+  private readPanelRectsFromDom(dom: Element): void {
     this.panelInfos = [];
-    this.totalPanelHeight = 0;
 
-    const panels = assertExists(findRef(dom, this.PANELS_REF));
+    const panels = assertExists(findRef(dom, this.PANEL_STACK_REF));
     const domRect = panels.getBoundingClientRect();
     this.panelContainerTop = domRect.y;
     this.panelContainerHeight = domRect.height;
@@ -407,80 +326,105 @@
         id,
         height: rect.height,
         width: rect.width,
-        x: rect.x,
-        y: rect.y,
+        clientX: rect.x,
+        clientY: rect.y,
         panel,
       });
-      this.totalPanelHeight += rect.height;
     });
-
-    return this.totalPanelHeight !== prevHeight;
-  }
-
-  private overlapsCanvas(yStart: number, yEnd: number) {
-    return yEnd > 0 && yStart < this.canvasHeight;
   }
 
   private renderCanvas() {
-    const redrawStart = debugNow();
     if (!this.ctx) return;
-    this.ctx.clearRect(0, 0, this.parentWidth, this.canvasHeight);
-    const canvasYStart = Math.floor(
-      this.scrollTop - this.getCanvasOverdrawHeightPerSide(),
-    );
+    if (!this.virtualCanvas) return;
+
+    const ctx = this.ctx;
+    const vc = this.virtualCanvas;
+    const redrawStart = debugNow();
+
+    ctx.resetTransform();
+    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+
+    const dpr = window.devicePixelRatio;
+    ctx.scale(dpr, dpr);
+    ctx.translate(-vc.canvasRect.left, -vc.canvasRect.top);
 
     this.handleAreaSelection();
 
-    let panelYStart = 0;
-    let totalOnCanvas = 0;
-    const flowEventsRendererArgs = new FlowEventsRendererArgs(
-      this.parentWidth,
-      this.canvasHeight,
-    );
-    for (let i = 0; i < this.panelInfos.length; i++) {
-      const panel = this.panelInfos[i].panel;
-      const panelHeight = this.panelInfos[i].height;
-      const yStartOnCanvas = panelYStart - canvasYStart;
+    const totalRenderedPanels = this.renderPanels(ctx, vc);
 
-      flowEventsRendererArgs.registerPanel(panel, yStartOnCanvas, panelHeight);
+    this.drawTopLayerOnCanvas(ctx, vc);
 
-      if (!this.overlapsCanvas(yStartOnCanvas, yStartOnCanvas + panelHeight)) {
-        panelYStart += panelHeight;
-        continue;
-      }
-
-      totalOnCanvas++;
-
-      this.ctx.save();
-      this.ctx.translate(0, yStartOnCanvas);
-      const clipRect = new Path2D();
-      const size = {width: this.parentWidth, height: panelHeight};
-      clipRect.rect(0, 0, size.width, size.height);
-      this.ctx.clip(clipRect);
-      const beforeRender = debugNow();
-      panel.renderCanvas(this.ctx, size);
-      this.updatePanelStats(
-        i,
-        panel,
-        debugNow() - beforeRender,
-        this.ctx,
-        size,
-      );
-      this.ctx.restore();
-      panelYStart += panelHeight;
-    }
-
-    this.drawTopLayerOnCanvas();
-    this.flowEventsRenderer.render(this.ctx, flowEventsRendererArgs);
     // Collect performance as the last thing we do.
     const redrawDur = debugNow() - redrawStart;
-    this.updatePerfStats(redrawDur, this.panelInfos.length, totalOnCanvas);
+    this.updatePerfStats(
+      redrawDur,
+      this.panelInfos.length,
+      totalRenderedPanels,
+    );
+  }
+
+  private renderPanels(
+    ctx: CanvasRenderingContext2D,
+    vc: VirtualCanvas,
+  ): number {
+    let panelTop = 0;
+    let totalOnCanvas = 0;
+
+    const flowEventsRendererArgs = new FlowEventsRendererArgs(
+      vc.size.width,
+      vc.size.height,
+    );
+
+    for (let i = 0; i < this.panelInfos.length; i++) {
+      const {
+        panel,
+        width: panelWidth,
+        height: panelHeight,
+      } = this.panelInfos[i];
+
+      const panelRect = {
+        left: 0,
+        top: panelTop,
+        bottom: panelTop + panelHeight,
+        right: panelWidth,
+      };
+      const panelSize = {width: panelWidth, height: panelHeight};
+
+      flowEventsRendererArgs.registerPanel(panel, panelTop, panelHeight);
+
+      if (vc.overlapsCanvas(panelRect)) {
+        totalOnCanvas++;
+
+        ctx.save();
+        ctx.translate(0, panelTop);
+        canvasClip(ctx, 0, 0, panelWidth, panelHeight);
+        const beforeRender = debugNow();
+        panel.renderCanvas(ctx, panelSize);
+        this.updatePanelStats(
+          i,
+          panel,
+          debugNow() - beforeRender,
+          ctx,
+          panelSize,
+        );
+        ctx.restore();
+      }
+
+      panelTop += panelHeight;
+    }
+
+    const flowEventsRenderer = new FlowEventsRenderer();
+    flowEventsRenderer.render(ctx, flowEventsRendererArgs);
+
+    return totalOnCanvas;
   }
 
   // The panels each draw on the canvas but some details need to be drawn across
   // the whole canvas rather than per panel.
-  private drawTopLayerOnCanvas() {
-    if (!this.ctx) return;
+  private drawTopLayerOnCanvas(
+    ctx: CanvasRenderingContext2D,
+    vc: VirtualCanvas,
+  ): void {
     const area = globals.timeline.selectedArea;
     if (
       area === undefined ||
@@ -498,10 +442,13 @@
     for (let i = 0; i < this.panelInfos.length; i++) {
       if (area.tracks.includes(this.panelInfos[i].id)) {
         trackFromCurrentContainerSelected = true;
-        selectedTracksMinY = Math.min(selectedTracksMinY, this.panelInfos[i].y);
+        selectedTracksMinY = Math.min(
+          selectedTracksMinY,
+          this.panelInfos[i].clientY,
+        );
         selectedTracksMaxY = Math.max(
           selectedTracksMaxY,
-          this.panelInfos[i].y + this.panelInfos[i].height,
+          this.panelInfos[i].clientY + this.panelInfos[i].height,
         );
       }
     }
@@ -518,30 +465,22 @@
     // To align with where to draw on the canvas subtract the first panel Y.
     selectedTracksMinY -= this.panelContainerTop;
     selectedTracksMaxY -= this.panelContainerTop;
-    this.ctx.save();
-    this.ctx.strokeStyle = SELECTION_STROKE_COLOR;
-    this.ctx.lineWidth = 1;
-    const canvasYStart = Math.floor(
-      this.scrollTop - this.getCanvasOverdrawHeightPerSide(),
-    );
-    this.ctx.translate(TRACK_SHELL_WIDTH, -canvasYStart);
+    ctx.save();
+    ctx.strokeStyle = SELECTION_STROKE_COLOR;
+    ctx.lineWidth = 1;
+
+    ctx.translate(TRACK_SHELL_WIDTH, 0);
 
     // Clip off any drawing happening outside the bounds of the timeline area
-    canvasClip(
-      this.ctx,
-      0,
-      0,
-      this.parentWidth - TRACK_SHELL_WIDTH,
-      this.totalPanelHeight,
-    );
+    canvasClip(ctx, 0, 0, vc.size.width - TRACK_SHELL_WIDTH, vc.size.height);
 
-    this.ctx.strokeRect(
+    ctx.strokeRect(
       startX,
       selectedTracksMaxY,
       endX - startX,
       selectedTracksMinY - selectedTracksMaxY,
     );
-    this.ctx.restore();
+    ctx.restore();
   }
 
   private updatePanelStats(
@@ -599,9 +538,4 @@
       m('div', runningStatStr(this.perfStats.renderStats)),
     ];
   }
-
-  private getCanvasOverdrawHeightPerSide() {
-    const overdrawHeight = (this.canvasOverdrawFactor - 1) * this.parentHeight;
-    return overdrawHeight / 2;
-  }
 }
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 719705e..34d1752 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 
-import {findRef, getScrollbarWidth, toHTMLElement} from '../base/dom_utils';
+import {findRef, toHTMLElement} from '../base/dom_utils';
 import {clamp} from '../base/math_utils';
 import {Time} from '../base/time';
 import {Actions} from '../common/actions';
@@ -78,7 +78,6 @@
  * panels, and everything else that's part of the main trace viewer page.
  */
 class TraceViewer implements m.ClassComponent {
-  private onResize: () => void = () => {};
   private zoomContent?: PanAndZoomHandler;
   // Used to prevent global deselection if a pan/drag select occurred.
   private keepCurrentSelection = false;
@@ -93,25 +92,6 @@
 
   oncreate(vnode: m.CVnodeDOM) {
     const timeline = globals.timeline;
-    const updateDimensions = () => {
-      const rect = vnode.dom.getBoundingClientRect();
-      timeline.updateLocalLimits(
-        0,
-        rect.width - TRACK_SHELL_WIDTH - getScrollbarWidth(),
-      );
-    };
-
-    updateDimensions();
-
-    // TODO: Do resize handling better.
-    this.onResize = () => {
-      updateDimensions();
-      raf.scheduleFullRedraw();
-    };
-
-    // Once ResizeObservers are out, we can stop accessing the window here.
-    window.addEventListener('resize', this.onResize);
-
     const panZoomElRaw = findRef(vnode.dom, this.PAN_ZOOM_CONTENT_REF);
     const panZoomEl = toHTMLElement(assertExists(panZoomElRaw));
 
@@ -228,7 +208,6 @@
   }
 
   onremove() {
-    window.removeEventListener('resize', this.onResize);
     if (this.zoomContent) this.zoomContent.dispose();
   }
 
@@ -280,7 +259,7 @@
       scrollingPanels.push({
         kind: 'group',
         collapsed: group.collapsed,
-        childTracks,
+        childPanels: childTracks,
         header: headerPanel,
         trackGroupId: group.id,
       });
@@ -306,21 +285,22 @@
             globals.clearSelection();
           },
         },
-        m(PanelContainer, {
-          className: 'header-panel-container',
-          doesScroll: false,
-          panels: [
-            ...overviewPanel,
-            this.timeAxisPanel,
-            this.timeSelectionPanel,
-            this.notesPanel,
-            this.tickmarkPanel,
-          ],
-          kind: 'OVERVIEW',
-        }),
+        m(
+          '.header',
+          m(PanelContainer, {
+            className: 'header-panel-container',
+            panels: [
+              ...overviewPanel,
+              this.timeAxisPanel,
+              this.timeSelectionPanel,
+              this.notesPanel,
+              this.tickmarkPanel,
+            ],
+          }),
+          m('.scrollbar-spacer-vertical'),
+        ),
         m(PanelContainer, {
           className: 'pinned-panel-container',
-          doesScroll: true,
           panels: globals.state.pinnedTracks.map((key) => {
             const trackBundle = this.resolveTrack(key);
             return new TrackPanel({
@@ -332,13 +312,14 @@
               closeable: trackBundle.closeable,
             });
           }),
-          kind: 'TRACKS',
         }),
         m(PanelContainer, {
           className: 'scrolling-panel-container',
-          doesScroll: true,
           panels: scrollingPanels,
-          kind: 'TRACKS',
+          onPanelStackResize: (width) => {
+            const timelineWidth = width - TRACK_SHELL_WIDTH;
+            globals.timeline.updateLocalLimits(0, timelineWidth);
+          },
         }),
       ),
       this.renderTabPanel(),
diff --git a/ui/src/frontend/virtual_canvas.ts b/ui/src/frontend/virtual_canvas.ts
new file mode 100644
index 0000000..4035614
--- /dev/null
+++ b/ui/src/frontend/virtual_canvas.ts
@@ -0,0 +1,269 @@
+// 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.
+
+/**
+ * Canvases have limits on their maximum size (which is determined by the
+ * system). Usually, this limit is fairly large, but can be as small as
+ * 4096x4096px on some machines.
+ *
+ * If we need a super large canvas, we need to use a different approach.
+ *
+ * Unless the user has a huge monitor, most of the time any sufficiently large
+ * canvas will overflow it's container, so we assume this container is set to
+ * scroll so that the user can actually see all of the canvas. We can take
+ * advantage of the fact that users may only see a small portion of the canvas
+ * at a time. So, if we position a small floating canvas element over the
+ * viewport of the scrolling container, we can approximate a huge canvas using a
+ * much smaller one.
+ *
+ * Given a target element and it's scrolling container, VirtualCanvas turns an
+ * empty HTML element into a "virtual" canvas with virtually unlimited size
+ * using the "floating" canvas technique described above.
+ */
+
+import {Disposable, Trash} from '../base/disposable';
+import {
+  Rect,
+  Size,
+  expandRect,
+  intersectRects,
+  rebaseRect,
+  rectSize,
+} from '../base/geom';
+
+export type LayoutShiftListener = (
+  canvas: HTMLCanvasElement,
+  rect: Rect,
+) => void;
+
+export type CanvasResizeListener = (
+  canvas: HTMLCanvasElement,
+  width: number,
+  height: number,
+) => void;
+
+export interface VirtualCanvasOpts {
+  // How much buffer to add above and below the visible window.
+  overdrawPx: number;
+
+  // If true, the canvas will remain within the bounds on the target element at
+  // all times.
+  //
+  // If false, the canvas is allowed to overflow the bounds of the target
+  // element to avoid resizing unnecessarily.
+  avoidOverflowingContainer: boolean;
+}
+
+export class VirtualCanvas implements Disposable {
+  private readonly _trash = new Trash();
+  private readonly _canvasElement: HTMLCanvasElement;
+  private readonly _targetElement: HTMLElement;
+
+  // Describes the offset of the canvas w.r.t. the "target" container
+  private _canvasRect: Rect;
+  private _layoutShiftListener?: LayoutShiftListener;
+  private _canvasResizeListener?: CanvasResizeListener;
+
+  /**
+   * @param targetElement The element to turn into a virtual canvas. The
+   * dimensions of this element are used to size the canvas, so ensure this
+   * element is sized appropriately.
+   * @param containerElement The scrolling container to be used for determining
+   * the size and position of the canvas. The targetElement should be a child of
+   * this element.
+   * @param opts Setup options for the VirtualCanvas.
+   */
+  constructor(
+    targetElement: HTMLElement,
+    containerElement: Element,
+    opts?: Partial<VirtualCanvasOpts>,
+  ) {
+    const {overdrawPx = 100, avoidOverflowingContainer} = opts ?? {};
+
+    // Returns what the canvas rect should look like
+    const getCanvasRect = () => {
+      const containerRect = containerElement.getBoundingClientRect();
+      const targetElementRect = targetElement.getBoundingClientRect();
+
+      // Calculate the intersection of the container's viewport and the target
+      const intersection = intersectRects(containerRect, targetElementRect);
+
+      // Pad the intersection by the overdraw amount
+      const intersectionExpanded = expandRect(intersection, overdrawPx);
+
+      // Intersect with the original target rect unless we want to avoid resizes
+      const canvasTargetRect = avoidOverflowingContainer
+        ? intersectRects(intersectionExpanded, targetElementRect)
+        : intersectionExpanded;
+
+      return rebaseRect(
+        canvasTargetRect,
+        targetElementRect.x,
+        targetElementRect.y,
+      );
+    };
+
+    const updateCanvas = () => {
+      let repaintRequired = false;
+
+      const canvasRect = getCanvasRect();
+      const canvasRectSize = rectSize(canvasRect);
+      const canvasRectPrev = this._canvasRect;
+      const canvasRectPrevSize = rectSize(canvasRectPrev);
+      this._canvasRect = canvasRect;
+
+      if (
+        canvasRectPrevSize.width !== canvasRectSize.width ||
+        canvasRectPrevSize.height !== canvasRectSize.height
+      ) {
+        // Canvas needs to change size, update its size
+        canvas.style.width = `${canvasRectSize.width}px`;
+        canvas.style.height = `${canvasRectSize.height}px`;
+        this._canvasResizeListener?.(
+          canvas,
+          canvasRectSize.width,
+          canvasRectSize.height,
+        );
+        repaintRequired = true;
+      }
+
+      if (
+        canvasRectPrev.left !== canvasRect.left ||
+        canvasRectPrev.top !== canvasRect.top
+      ) {
+        // Canvas needs to move, update the transform
+        canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
+        repaintRequired = true;
+      }
+
+      repaintRequired && this._layoutShiftListener?.(canvas, canvasRect);
+    };
+
+    containerElement.addEventListener('scroll', updateCanvas, {
+      passive: true,
+    });
+    this._trash.addCallback(() =>
+      containerElement.removeEventListener('scroll', updateCanvas),
+    );
+
+    // Resize observer callbacks are called once immediately
+    const resizeObserver = new ResizeObserver(() => {
+      updateCanvas();
+    });
+
+    resizeObserver.observe(containerElement);
+    resizeObserver.observe(targetElement);
+    this._trash.addCallback(() => {
+      resizeObserver.disconnect();
+    });
+
+    // Ensures the canvas doesn't change the size of the target element
+    targetElement.style.overflow = 'hidden';
+
+    const canvas = document.createElement('canvas');
+    canvas.style.position = 'absolute';
+    targetElement.appendChild(canvas);
+    this._trash.addCallback(() => {
+      targetElement.removeChild(canvas);
+    });
+
+    this._canvasElement = canvas;
+    this._targetElement = targetElement;
+    this._canvasRect = {
+      left: 0,
+      top: 0,
+      bottom: 0,
+      right: 0,
+    };
+  }
+
+  /**
+   * Set the callback that gets called when the canvas element is moved or
+   * resized, thus, invalidating the contents, and should be re-painted.
+   *
+   * @param cb The new callback.
+   */
+  setLayoutShiftListener(cb: LayoutShiftListener) {
+    this._layoutShiftListener = cb;
+  }
+
+  /**
+   * Set the callback that gets called when the canvas element is resized. This
+   * might be a good opportunity to update the size of the canvas' draw buffer.
+   *
+   * @param cb The new callback.
+   */
+  setCanvasResizeListener(cb: CanvasResizeListener) {
+    this._canvasResizeListener = cb;
+  }
+
+  /**
+   * The floating canvas element.
+   */
+  get canvasElement(): HTMLCanvasElement {
+    return this._canvasElement;
+  }
+
+  /**
+   * The target element, i.e. the one passed to our constructor.
+   */
+  get targetElement(): HTMLElement {
+    return this._targetElement;
+  }
+
+  /**
+   * The size of the target element, aka the size of the virtual canvas.
+   */
+  get size(): Size {
+    return {
+      width: this._targetElement.clientWidth,
+      height: this._targetElement.clientHeight,
+    };
+  }
+
+  /**
+   * Returns the rect of the floating canvas with respect to the target element.
+   * This will need to be subtracted from any drawing operations to get the
+   * right alignment within the virtual canvas.
+   */
+  get canvasRect(): Rect {
+    return this._canvasRect;
+  }
+
+  /**
+   * The size of the floating canvas.
+   */
+  get canvasSize(): Size {
+    return rectSize(this._canvasRect);
+  }
+
+  /**
+   * Stop listening to DOM events.
+   */
+  dispose(): void {
+    this._trash.dispose();
+  }
+
+  /**
+   * Return true if a rect overlaps the floating canvas.
+   * @param rect The rect to test.
+   * @returns true if rect overlaps, false otherwise.
+   */
+  overlapsCanvas(rect: Rect): boolean {
+    const c = this._canvasRect;
+    const y = rect.top < c.bottom && rect.bottom > c.top;
+    const x = rect.left < c.right && rect.right > c.left;
+    return x && y;
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
index a1a2842..c022dba 100644
--- a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
@@ -448,32 +448,21 @@
       wakelock_dur
     from step2
     where wakelock_dur is not null
-      and wakelock_dur > 0
-      and count >= 0
-  ),
-  step4 as (
-    select
-      ts,
-      ts_end,
-      suspended_dur,
-      wakelock_name,
-      count,
-      1.0 * wakelock_dur / (ts_end - ts - suspended_dur) as ratio,
-      wakelock_dur
-    from step3
+      and wakelock_dur >= 0
   )
   select
     ts,
-    min(ratio, 1) * (ts_end - ts) as dur,
+    ts_end - ts as dur,
     wakelock_name,
-    cast (100.0 * ratio as int) || '% (+' || count || ')' as name
-    from step4
-  where cast (100.0 * wakelock_dur / (ts_end - ts - suspended_dur) as int) > 1`;
+    min(100.0 * wakelock_dur / (ts_end - ts - suspended_dur), 100) as value
+  from step3`;
 
 const KERNEL_WAKELOCKS_SUMMARY = `
-  select distinct wakelock_name
+  select wakelock_name, max(value) as max_value
   from kernel_wakelocks
   where wakelock_name not in ('PowerManager.SuspendLockout', 'PowerManagerService.Display')
+  group by 1
+  having max_value > 1
   order by 1;`;
 
 const HIGH_CPU = `
@@ -1079,12 +1068,12 @@
     bluetooth_stack_state & 0x00000F00 >> 8 as acl_ble_count,
     bluetooth_stack_state & 0x0000F000 >> 12 as advertising_count,
     case bluetooth_stack_state & 0x000F0000 >> 16
-      when 0 then 'disabled'
-      when 1 then '<5%'
-      when 2 then '5% to 10%'
-      when 3 then '10% to 25%'
-      when 4 then '25% to 100%'
-      else 'invalid'
+      when 0 then 0
+      when 1 then 5
+      when 2 then 10
+      when 3 then 25
+      when 4 then 100
+      else -1
     end as le_scan_duty_cycle,
     bluetooth_stack_state & 0x00100000 >> 20 as inquiry_active,
     bluetooth_stack_state & 0x00200000 >> 21 as sco_active,
@@ -1436,11 +1425,12 @@
     const result = await e.query(KERNEL_WAKELOCKS_SUMMARY);
     const it = result.iter({wakelock_name: 'str'});
     for (; it.valid(); it.next()) {
-      this.addSliceTrack(
+      this.addCounterTrack(
         ctx,
         it.wakelock_name,
-        `select ts, dur, name from kernel_wakelocks where wakelock_name = "${it.wakelock_name}"`,
+        `select ts, dur, value from kernel_wakelocks where wakelock_name = "${it.wakelock_name}"`,
         groupName,
+        {yRangeSharingKey: 'kernel_wakelock', unit: '%'},
       );
     }
   }
@@ -1602,11 +1592,12 @@
       'select ts, dur, advertising_count as value from bt_activity',
       groupName,
     );
-    this.addSliceTrack(
+    this.addCounterTrack(
       ctx,
-      'LE Scan Duty Cycle',
-      'select ts, dur, le_scan_duty_cycle as name from bt_activity',
+      'LE Scan Duty Cycle Maximum',
+      'select ts, dur, le_scan_duty_cycle as value from bt_activity',
       groupName,
+      {unit: '%'},
     );
     this.addSliceTrack(
       ctx,
diff --git a/ui/src/test/ui_integrationtest.ts b/ui/src/test/ui_integrationtest.ts
index 946f6cd..442c5ee 100644
--- a/ui/src/test/ui_integrationtest.ts
+++ b/ui/src/test/ui_integrationtest.ts
@@ -86,7 +86,7 @@
   });
 
   test('expand_camera', async () => {
-    await page.click('.pf-overlay-canvas');
+    await page.click('.pf-overlay');
     await page.click('h1[title="com.google.android.GoogleCamera 5506"]');
     await page.evaluate(() => {
       document.querySelector('.scrolling-panel-container')!.scrollTo(0, 400);
@@ -114,7 +114,7 @@
 
   test('expand_browser_proc', async () => {
     const page = await getPage();
-    await page.click('.pf-overlay-canvas');
+    await page.click('.pf-overlay');
     await page.click('h1[title="Browser 12685"]');
     await waitForPerfettoIdle(page);
   });
diff --git a/ui/src/tracks/debug/add_debug_track_menu.ts b/ui/src/tracks/debug/add_debug_track_menu.ts
index 8f0cd37..e03c9f2 100644
--- a/ui/src/tracks/debug/add_debug_track_menu.ts
+++ b/ui/src/tracks/debug/add_debug_track_menu.ts
@@ -25,6 +25,7 @@
   SqlDataSource,
   addDebugCounterTrack,
   addDebugSliceTrack,
+  addPivotDebugSliceTracks,
 } from '../../frontend/debug_tracks';
 
 export const ARG_PREFIX = 'arg_';
@@ -52,7 +53,13 @@
   // 'value' for slice and 'name' for counter) and then just don't the values
   // which don't match the currently selected track type (so changing track type
   // from A to B and back to A is a no-op).
-  renderParams: {ts: string; dur: string; name: string; value: string};
+  renderParams: {
+    ts: string;
+    dur: string;
+    name: string;
+    value: string;
+    pivot: string;
+  };
 
   constructor(vnode: m.Vnode<AddDebugTrackMenuAttrs>) {
     this.columns = [...vnode.attrs.dataSource.columns];
@@ -77,6 +84,7 @@
       dur: chooseDefaultOption('dur'),
       name: chooseDefaultOption('name'),
       value: chooseDefaultOption('value'),
+      pivot: '',
     };
   }
 
@@ -124,15 +132,23 @@
   }
 
   view(vnode: m.Vnode<AddDebugTrackMenuAttrs>) {
-    const renderSelect = (name: 'ts' | 'dur' | 'name' | 'value') => {
+    const renderSelect = (name: 'ts' | 'dur' | 'name' | 'value' | 'pivot') => {
       const options = [];
+
+      if (name === 'pivot') {
+        options.push(
+          m(
+            'option',
+            {selected: this.renderParams[name] === '' ? true : undefined},
+            m('i', ''),
+          ),
+        );
+      }
       for (const column of this.columns) {
         options.push(
           m(
             'option',
-            {
-              selected: this.renderParams[name] === column ? true : undefined,
-            },
+            {selected: this.renderParams[name] === column ? true : undefined},
             column,
           ),
         );
@@ -161,23 +177,39 @@
         ),
       ];
     };
+
     return m(
       Form,
       {
         onSubmit: () => {
           switch (this.trackType) {
             case 'slice':
-              addDebugSliceTrack(
-                vnode.attrs.engine,
-                vnode.attrs.dataSource,
-                this.name,
-                {
-                  ts: this.renderParams.ts,
-                  dur: this.renderParams.dur,
-                  name: this.renderParams.name,
-                },
-                this.columns,
-              );
+              if (this.renderParams.pivot === '') {
+                addDebugSliceTrack(
+                  vnode.attrs.engine,
+                  vnode.attrs.dataSource,
+                  this.name,
+                  {
+                    ts: this.renderParams.ts,
+                    dur: this.renderParams.dur,
+                    name: this.renderParams.name,
+                  },
+                  this.columns,
+                );
+              } else {
+                addPivotDebugSliceTracks(
+                  vnode.attrs.engine,
+                  vnode.attrs.dataSource,
+                  this.name,
+                  {
+                    ts: this.renderParams.ts,
+                    dur: this.renderParams.dur,
+                    name: this.renderParams.name,
+                    pivot: this.renderParams.pivot,
+                  },
+                  this.columns,
+                );
+              }
               break;
             case 'counter':
               addDebugCounterTrack(vnode.attrs.dataSource, this.name, {
@@ -207,6 +239,7 @@
       renderSelect('ts'),
       this.trackType === 'slice' && renderSelect('dur'),
       this.trackType === 'slice' && renderSelect('name'),
+      this.trackType === 'slice' && renderSelect('pivot'),
       this.trackType === 'counter' && renderSelect('value'),
     );
   }