ui: add dev.perfetto.ScatterPlot plugin and __intrinsic_scatter_mipmap operator

Adds a GPU-accelerated 2D scatter plot page plugin (dev.perfetto.ScatterPlot)
over any trace processor table or SQL query, backed by a new trace processor
2D level-of-detail virtual table operator (__intrinsic_scatter_mipmap).

Trace processor (src/trace_processor/plugins/scatter_mipmap_operator):
- Builds a 2D Morton-ordered spatial index (2-pass 11-bit radix sort + level-T
  prefix table, 24 B/point SoA) once on CREATE VIRTUAL TABLE and answers
  viewport queries (cols x rows cells) in O(cells) via aligned prefix-table
  lookup, with a quadtree DFS fallback for arbitrary unaligned viewports.
- Returns one representative point per non-empty cell (id, x, y, c, count),
  plus hidden aggregate columns (out_count, out_x_min/max, out_y_min/max,
  out_c_min/max) so dataset bounds are read without a separate SQL scan.
- Direct dataframe column ingestion for Int64, Double, Uint32, Int32 and
  StringPool::Id columns, with a SQL projection fallback for views and
  subqueries.
- Optional integer-N 6th argument computes top-N categorical frequencies in
  pass 1 (mapping top N to 0..N-1, remaining non-null values to N 'Other',
  and NULL to -1), with a cols=0, rows=0 listing mode returning
  (out_cat_idx, out_cat_value, out_cat_count) for the legend.

UI plugin (ui/src/plugins/dev.perfetto.ScatterPlot):
- WebGL2 renderer with hi/lo float32 splitting for nanosecond-scale timestamps
  and a 1D RGBA8 color LUT texture (viridis continuous + 20-category palette).
- Power-of-two aligned viewport quantization with 15% padding so pans within
  the padded region do not re-query trace processor.
- Cursor-anchored smooth wheel zoom, drag pan, Shift+drag box selection with
  lazy SQL DataGrid aggregation and query-page handoff, Popper-backed
  CursorTooltip, and a collapsible DrawerPanel embedding ThreadSliceDetailsPanel
  for pinned slices and Tree/Timestamp/DurationWidget for generic tables.
diff --git a/Android.bp b/Android.bp
index 3187269..7afcab2 100644
--- a/Android.bp
+++ b/Android.bp
@@ -3212,6 +3212,7 @@
         ":perfetto_src_trace_processor_plugins_perf_text_perf_text",
         ":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -20508,6 +20509,15 @@
     ],
 }
 
+// GN: //src/trace_processor/plugins/scatter_mipmap_operator:scatter_mipmap_operator
+filegroup {
+    name: "perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
+    srcs: [
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.cc",
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.cc",
+    ],
+}
+
 // GN: //src/trace_processor/plugins/slice_mipmap_operator:slice_mipmap_operator
 filegroup {
     name: "perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
@@ -21574,6 +21584,7 @@
         ":perfetto_src_trace_processor_plugins_perf_text_perf_text",
         ":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -24617,6 +24628,7 @@
         ":perfetto_src_trace_processor_plugins_perf_text_perf_text",
         ":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_unittests",
@@ -25763,6 +25775,7 @@
         ":perfetto_src_trace_processor_plugins_perf_text_perf_text",
         ":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -26481,6 +26494,7 @@
         ":perfetto_src_trace_processor_plugins_perf_text_perf_text",
         ":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":perfetto_src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
diff --git a/BUILD b/BUILD
index 32a2f86..1e37112 100644
--- a/BUILD
+++ b/BUILD
@@ -533,6 +533,7 @@
         ":src_trace_processor_plugins_perf_text_perf_text",
         ":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -858,6 +859,7 @@
         ":src_trace_processor_plugins_perf_text_perf_text",
         ":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -5148,6 +5150,17 @@
     ],
 )
 
+# GN target: //src/trace_processor/plugins/scatter_mipmap_operator:scatter_mipmap_operator
+perfetto_filegroup(
+    name = "src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
+    srcs = [
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.cc",
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h",
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.cc",
+        "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h",
+    ],
+)
+
 # GN target: //src/trace_processor/plugins/slice_mipmap_operator:slice_mipmap_operator
 perfetto_filegroup(
     name = "src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
@@ -11936,6 +11949,7 @@
         ":src_trace_processor_plugins_perf_text_perf_text",
         ":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -12292,6 +12306,7 @@
         ":src_trace_processor_plugins_perf_text_perf_text",
         ":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
         ":src_trace_processor_plugins_pprof_functions_pprof_functions",
+        ":src_trace_processor_plugins_scatter_mipmap_operator_scatter_mipmap_operator",
         ":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
         ":src_trace_processor_plugins_span_join_operator_span_join_operator",
         ":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
diff --git a/src/trace_processor/BUILD.gn b/src/trace_processor/BUILD.gn
index 79237bb..efc2443 100644
--- a/src/trace_processor/BUILD.gn
+++ b/src/trace_processor/BUILD.gn
@@ -262,6 +262,7 @@
       "plugins/perf_text",
       "plugins/perfetto_manifest",
       "plugins/pprof_functions",
+      "plugins/scatter_mipmap_operator",
       "plugins/slice_mipmap_operator",
       "plugins/span_join_operator",
       "plugins/sql_stats_table",
diff --git a/src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h b/src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h
index ca1927b..04e378a 100644
--- a/src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h
+++ b/src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h
@@ -304,6 +304,9 @@
 
   SqliteConnection* sqlite_connection() { return connection_.get(); }
 
+  // Returns the StringPool backing this connection.
+  StringPool* string_pool() const { return pool_; }
+
   // Test-only accessor for the |PerfettoSqlDatabase| backing this connection.
   PerfettoSqlDatabase* database_for_testing() { return database_.get(); }
 
diff --git a/src/trace_processor/plugins/scatter_mipmap_operator/BUILD.gn b/src/trace_processor/plugins/scatter_mipmap_operator/BUILD.gn
new file mode 100644
index 0000000..3d6ba50
--- /dev/null
+++ b/src/trace_processor/plugins/scatter_mipmap_operator/BUILD.gn
@@ -0,0 +1,37 @@
+# Copyright (C) 2026 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import("../../../../gn/perfetto.gni")
+
+assert(enable_perfetto_trace_processor_sqlite)
+
+source_set("scatter_mipmap_operator") {
+  sources = [
+    "scatter_mipmap_index.cc",
+    "scatter_mipmap_index.h",
+    "scatter_mipmap_operator.cc",
+    "scatter_mipmap_operator.h",
+  ]
+  deps = [
+    "../../../../gn:default_deps",
+    "../../../../gn:sqlite",
+    "../../../base",
+    "../../containers",
+    "../../core/dataframe",
+    "../../core/plugin",
+    "../../core/util",
+    "../../perfetto_sql/engine",
+    "../../sqlite",
+  ]
+}
diff --git a/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.cc b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.cc
new file mode 100644
index 0000000..bfa912e
--- /dev/null
+++ b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.cc
@@ -0,0 +1,970 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "perfetto/base/compiler.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/flat_hash_map.h"
+#include "perfetto/ext/base/string_view.h"
+
+namespace perfetto::trace_processor::scatter_mipmap_operator {
+namespace {
+
+constexpr int64_t kMaxCells = 16'000'000;
+
+inline float SafeDoubleToFloat(double d) {
+  if (!std::isfinite(d)) {
+    return std::numeric_limits<float>::quiet_NaN();
+  }
+  constexpr double kMaxFloat =
+      static_cast<double>(std::numeric_limits<float>::max());
+  if (d > kMaxFloat) {
+    return std::numeric_limits<float>::max();
+  }
+  if (d < -kMaxFloat) {
+    return -std::numeric_limits<float>::max();
+  }
+  return static_cast<float>(d);
+}
+
+void AppendOtherAndNull(std::vector<CategoryEntry>& categories,
+                        uint32_t target_n,
+                        uint64_t other_count,
+                        uint32_t null_count) {
+  if (other_count > 0) {
+    CategoryEntry entry;
+    entry.idx = static_cast<int32_t>(target_n);
+    entry.count = other_count;
+    entry.is_other = true;
+    entry.str_val = "Other";
+    categories.push_back(std::move(entry));
+  }
+  if (null_count > 0) {
+    CategoryEntry entry;
+    entry.idx = -1;
+    entry.count = null_count;
+    entry.is_null = true;
+    categories.push_back(std::move(entry));
+  }
+}
+
+}  // namespace
+
+void CategoryMapping::PopulateTopN(
+    const base::FlatHashMap<uint32_t, uint32_t>& str_counts,
+    const base::FlatHashMap<uint64_t, uint32_t>& num_counts,
+    uint32_t null_count,
+    uint64_t other_mismatched_count,
+    StringPool* pool) {
+  uint32_t target_n = auto_top_n;
+  categories.clear();
+  str_pool_id_to_idx.Clear();
+  num_to_idx.Clear();
+  uint64_t other_count = other_mismatched_count;
+
+  if (type == Type::kString) {
+    struct Cand {
+      uint32_t pool_id;
+      uint32_t count;
+      base::StringView str;
+    };
+    std::vector<Cand> cands;
+    cands.reserve(str_counts.size());
+    for (auto it = str_counts.GetIterator(); it; ++it) {
+      base::StringView sv = (pool && it.key() != 0)
+                                ? pool->Get(StringPool::Id::Raw(it.key()))
+                                : base::StringView();
+      cands.push_back({it.key(), it.value(), sv});
+    }
+    std::sort(cands.begin(), cands.end(), [](const Cand& a, const Cand& b) {
+      return a.count != b.count ? a.count > b.count : a.str < b.str;
+    });
+    size_t top_count = std::min(static_cast<size_t>(target_n), cands.size());
+    for (size_t i = 0; i < top_count; ++i) {
+      int32_t idx = static_cast<int32_t>(i);
+      str_pool_id_to_idx.Insert(cands[i].pool_id, idx);
+      CategoryEntry entry;
+      entry.idx = idx;
+      entry.count = cands[i].count;
+      entry.str_val = std::string(cands[i].str.data(), cands[i].str.size());
+      categories.push_back(std::move(entry));
+    }
+    for (size_t i = top_count; i < cands.size(); ++i) {
+      other_count += cands[i].count;
+    }
+  } else {
+    struct NumCand {
+      uint64_t key;
+      int64_t iv;
+      double dv;
+      uint32_t count;
+    };
+    std::vector<NumCand> cands;
+    cands.reserve(num_counts.size());
+    bool is_int = (type == Type::kInt);
+    for (auto it = num_counts.GetIterator(); it; ++it) {
+      cands.push_back({it.key(), static_cast<int64_t>(it.key()),
+                       is_int ? 0.0 : KeyToDouble(it.key()), it.value()});
+    }
+    std::sort(cands.begin(), cands.end(),
+              [is_int](const NumCand& a, const NumCand& b) {
+                if (a.count != b.count)
+                  return a.count > b.count;
+                return is_int ? (a.iv < b.iv) : (a.dv < b.dv);
+              });
+    size_t top_count = std::min(static_cast<size_t>(target_n), cands.size());
+    for (size_t i = 0; i < top_count; ++i) {
+      int32_t idx = static_cast<int32_t>(i);
+      num_to_idx.Insert(cands[i].key, idx);
+      CategoryEntry entry;
+      entry.idx = idx;
+      entry.count = cands[i].count;
+      entry.int_val = cands[i].iv;
+      entry.double_val = cands[i].dv;
+      categories.push_back(std::move(entry));
+    }
+    for (size_t i = top_count; i < cands.size(); ++i) {
+      other_count += cands[i].count;
+    }
+  }
+  AppendOtherAndNull(categories, target_n, other_count, null_count);
+}
+
+template <typename PointAccessor>
+void ScatterMipmapIndex::BuildCore(uint32_t n,
+                                   double x_min,
+                                   double x_max,
+                                   double y_min,
+                                   double y_max,
+                                   double c_min,
+                                   double c_max,
+                                   bool has_c,
+                                   int64_t min_id,
+                                   int64_t max_id,
+                                   PointAccessor& accessor) {
+  if (n == 0) {
+    x_min_data_ = x_max_data_ = y_min_data_ = y_max_data_ = 0.0;
+    c_min_data_ = c_max_data_ = 0.0;
+    has_c_ = false;
+    level_t_ = 8;
+    level_t_offsets_.assign(65537, 0);
+    return;
+  }
+
+  x_min_data_ = x_min;
+  x_max_data_ = x_max;
+  y_min_data_ = y_min;
+  y_max_data_ = y_max;
+  c_min_data_ = c_min;
+  c_max_data_ = c_max;
+  has_c_ = has_c;
+  min_id_ = min_id;
+  max_id_ = max_id;
+
+  constexpr uint32_t kMaxQuant = (1u << 20) - 1;
+  double x_span = x_max_data_ - x_min_data_;
+  double y_span = y_max_data_ - y_min_data_;
+  double inv_x_span =
+      (x_span > 0.0) ? (static_cast<double>(1u << 20) / x_span) : 0.0;
+  double inv_y_span =
+      (y_span > 0.0) ? (static_cast<double>(1u << 20) / y_span) : 0.0;
+
+  double log4_n = std::log(static_cast<double>(n)) / std::log(4.0);
+  level_t_ = std::clamp(static_cast<uint32_t>(std::ceil(log4_n)) + 1, 8u, 11u);
+  size_t table_size = (1ULL << (2 * level_t_)) + 1;
+  uint32_t t_shift = 40 - 2 * level_t_;
+
+  ids_are_32_ = (min_id_ >= 0 && max_id_ <= 0xFFFFFFFFLL);
+
+  std::unique_ptr<uint64_t[]> pairs(new uint64_t[n]);
+  std::unique_ptr<uint64_t[]> temp(new uint64_t[n]);
+  uint32_t counts[2][2048] = {{0}};
+
+  for (size_t i = 0; i < n; ++i) {
+    double x = accessor.GetX(static_cast<uint32_t>(i));
+    double y = accessor.GetY(static_cast<uint32_t>(i));
+
+    uint32_t qx = 0;
+    if (x_span > 0.0) {
+      if (PERFETTO_UNLIKELY(x >= x_max_data_)) {
+        qx = kMaxQuant;
+      } else if (PERFETTO_LIKELY(x > x_min_data_)) {
+        qx = std::min(kMaxQuant,
+                      static_cast<uint32_t>((x - x_min_data_) * inv_x_span));
+      }
+    }
+
+    uint32_t qy = 0;
+    if (y_span > 0.0) {
+      if (PERFETTO_UNLIKELY(y >= y_max_data_)) {
+        qy = kMaxQuant;
+      } else if (PERFETTO_LIKELY(y > y_min_data_)) {
+        qy = std::min(kMaxQuant,
+                      static_cast<uint32_t>((y - y_min_data_) * inv_y_span));
+      }
+    }
+
+    uint32_t leaf_key =
+        static_cast<uint32_t>(EncodeMorton2D(qx, qy) >> t_shift);
+    pairs[i] =
+        (static_cast<uint64_t>(leaf_key) << 32) | static_cast<uint32_t>(i);
+    counts[0][leaf_key & 0x7FF]++;
+    counts[1][(leaf_key >> 11) & 0x7FF]++;
+  }
+
+  uint32_t offsets[2048];
+  offsets[0] = 0;
+  for (size_t d = 1; d < 2048; ++d) {
+    offsets[d] = offsets[d - 1] + counts[0][d - 1];
+  }
+  for (size_t i = 0; i < n; ++i) {
+    temp[offsets[(pairs[i] >> 32) & 0x7FF]++] = pairs[i];
+  }
+
+  offsets[0] = 0;
+  for (size_t d = 1; d < 2048; ++d) {
+    offsets[d] = offsets[d - 1] + counts[1][d - 1];
+  }
+  for (size_t i = 0; i < n; ++i) {
+    pairs[offsets[(temp[i] >> 43) & 0x7FF]++] = temp[i];
+  }
+  temp.reset();
+
+  xs_.resize(n);
+  ys_.resize(n);
+  if (ids_are_32_) {
+    ids32_.resize(n);
+    ids64_.clear();
+  } else {
+    ids64_.resize(n);
+    ids32_.clear();
+  }
+  if (has_c_) {
+    cs_.resize(n);
+  } else {
+    cs_.clear();
+  }
+  level_t_offsets_.resize(table_size);
+  uint32_t offset_idx = 0;
+
+  for (size_t i = 0; i < n; ++i) {
+    uint32_t orig = static_cast<uint32_t>(pairs[i]);
+    uint32_t leaf_key = static_cast<uint32_t>(pairs[i] >> 32);
+    xs_[i] = accessor.GetX(orig);
+    ys_[i] = accessor.GetY(orig);
+    int64_t pt_id = accessor.GetId(orig);
+    if (ids_are_32_) {
+      ids32_[i] = static_cast<uint32_t>(pt_id);
+    } else {
+      ids64_[i] = pt_id;
+    }
+    if (has_c_) {
+      cs_[i] = accessor.GetC(orig);
+    }
+    while (offset_idx <= leaf_key) {
+      level_t_offsets_[offset_idx++] = static_cast<uint32_t>(i);
+    }
+  }
+  while (offset_idx < table_size) {
+    level_t_offsets_[offset_idx++] = static_cast<uint32_t>(n);
+  }
+}
+
+void ScatterMipmapIndex::IngestAndBuildDataframe(
+    uint32_t n,
+    const DataframeColumnReader* reader_id,
+    const DataframeColumnReader& reader_x,
+    const DataframeColumnReader& reader_y,
+    const DataframeColumnReader* reader_c,
+    CategoryMapping* categories,
+    StringPool* pool) {
+  base::FlatHashMap<uint32_t, uint32_t> auto_str_counts;
+  base::FlatHashMap<uint64_t, uint32_t> auto_num_counts;
+  uint32_t auto_null_count = 0;
+  bool is_auto_cat = (categories && categories->auto_top_n > 0 && reader_c);
+
+  double x_min = 0.0, x_max = 0.0, y_min = 0.0, y_max = 0.0;
+  int64_t min_id = 0, max_id = 0;
+  double c_min = 0.0, c_max = 0.0;
+  bool has_c = false;
+  uint32_t valid_count = 0;
+  bool has_invalid = false;
+
+  for (uint32_t i = 0; i < n; ++i) {
+    double x = 0.0, y = 0.0;
+    if (!reader_x.GetDouble(i, x) || !reader_y.GetDouble(i, y)) {
+      has_invalid = true;
+      continue;
+    }
+
+    int64_t id_val = static_cast<int64_t>(i);
+    if (reader_id) {
+      reader_id->GetInt64(i, id_val);
+    }
+
+    if (valid_count == 0) {
+      x_min = x_max = x;
+      y_min = y_max = y;
+      min_id = max_id = id_val;
+    } else {
+      x_min = std::min(x_min, x);
+      x_max = std::max(x_max, x);
+      y_min = std::min(y_min, y);
+      y_max = std::max(y_max, y);
+      min_id = std::min(min_id, id_val);
+      max_id = std::max(max_id, id_val);
+    }
+
+    if (is_auto_cat) {
+      if (categories->type == CategoryMapping::Type::kString) {
+        StringPool::Id s_id;
+        if (reader_c->GetStringPoolId(i, s_id) && !s_id.is_null()) {
+          auto_str_counts[s_id.raw_id()]++;
+        } else {
+          auto_null_count++;
+        }
+      } else if (categories->type == CategoryMapping::Type::kInt) {
+        int64_t iv;
+        if (reader_c->GetInt64(i, iv)) {
+          auto_num_counts[static_cast<uint64_t>(iv)]++;
+        } else {
+          auto_null_count++;
+        }
+      } else {
+        double dv;
+        if (reader_c->GetDouble(i, dv) && std::isfinite(dv)) {
+          auto_num_counts[DoubleToKey(dv)]++;
+        } else {
+          auto_null_count++;
+        }
+      }
+    } else if (reader_c) {
+      double c_val = 0.0;
+      if (reader_c->GetDouble(i, c_val) && std::isfinite(c_val)) {
+        if (!has_c) {
+          c_min = c_max = c_val;
+          has_c = true;
+        } else {
+          c_min = std::min(c_min, c_val);
+          c_max = std::max(c_max, c_val);
+        }
+      }
+    }
+    valid_count++;
+  }
+
+  if (is_auto_cat && valid_count > 0) {
+    categories->PopulateTopN(auto_str_counts, auto_num_counts, auto_null_count,
+                             0, pool);
+    has_c = true;
+    c_min = (auto_null_count > 0) ? -1.0 : 0.0;
+    c_max = static_cast<double>(categories->auto_top_n);
+  }
+
+  std::vector<uint32_t> valid_indices;
+  if (has_invalid && valid_count > 0) {
+    valid_indices.reserve(valid_count);
+    for (uint32_t i = 0; i < n; ++i) {
+      double x = 0.0, y = 0.0;
+      if (reader_x.GetDouble(i, x) && reader_y.GetDouble(i, y)) {
+        valid_indices.push_back(i);
+      }
+    }
+  }
+
+  struct DirectAccessor {
+    const DataframeColumnReader* reader_id;
+    const DataframeColumnReader& reader_x;
+    const DataframeColumnReader& reader_y;
+    const DataframeColumnReader* reader_c;
+    const CategoryMapping* categories;
+
+    PERFETTO_ALWAYS_INLINE double GetX(uint32_t i) const {
+      double v = 0.0;
+      reader_x.GetDouble(i, v);
+      return v;
+    }
+    PERFETTO_ALWAYS_INLINE double GetY(uint32_t i) const {
+      double v = 0.0;
+      reader_y.GetDouble(i, v);
+      return v;
+    }
+    PERFETTO_ALWAYS_INLINE int64_t GetId(uint32_t i) const {
+      int64_t v = 0;
+      return (reader_id && reader_id->GetInt64(i, v)) ? v
+                                                      : static_cast<int64_t>(i);
+    }
+    PERFETTO_ALWAYS_INLINE float GetC(uint32_t i) const {
+      if (categories) {
+        if (categories->type == CategoryMapping::Type::kString) {
+          StringPool::Id s_id;
+          return (reader_c && reader_c->GetStringPoolId(i, s_id) &&
+                  !s_id.is_null())
+                     ? static_cast<float>(categories->MapString(s_id.raw_id()))
+                     : -1.0f;
+        }
+        if (categories->type == CategoryMapping::Type::kInt) {
+          int64_t iv = 0;
+          return (reader_c && reader_c->GetInt64(i, iv))
+                     ? static_cast<float>(categories->MapInt(iv))
+                     : -1.0f;
+        }
+        double dv = 0.0;
+        return (reader_c && reader_c->GetDouble(i, dv) && std::isfinite(dv))
+                   ? static_cast<float>(categories->MapDouble(dv))
+                   : -1.0f;
+      }
+      double v = 0.0;
+      return (reader_c && reader_c->GetDouble(i, v))
+                 ? SafeDoubleToFloat(v)
+                 : std::numeric_limits<float>::quiet_NaN();
+    }
+  } direct{reader_id, reader_x, reader_y, reader_c, categories};
+
+  if (!has_invalid) {
+    BuildCore(valid_count, x_min, x_max, y_min, y_max, c_min, c_max, has_c,
+              min_id, max_id, direct);
+  } else {
+    struct IndirectAccessor {
+      const std::vector<uint32_t>& valid_indices;
+      DirectAccessor& direct;
+      double GetX(uint32_t i) const { return direct.GetX(valid_indices[i]); }
+      double GetY(uint32_t i) const { return direct.GetY(valid_indices[i]); }
+      int64_t GetId(uint32_t i) const { return direct.GetId(valid_indices[i]); }
+      float GetC(uint32_t i) const { return direct.GetC(valid_indices[i]); }
+    } indirect{valid_indices, direct};
+    BuildCore(valid_count, x_min, x_max, y_min, y_max, c_min, c_max, has_c,
+              min_id, max_id, indirect);
+  }
+
+  categorical_ = categories != nullptr;
+  if (categories) {
+    categories_ = categories->categories;
+    categories_type_ = categories->type;
+  }
+  BuildCategoryMasks();
+}
+
+void ScatterMipmapIndex::IngestAndBuildSqlRows(std::vector<SqlRow>& rows,
+                                               CategoryMapping* categories,
+                                               StringPool* pool) {
+  rows.erase(std::remove_if(rows.begin(), rows.end(),
+                            [](const SqlRow& r) {
+                              return !std::isfinite(r.x) || !std::isfinite(r.y);
+                            }),
+             rows.end());
+
+  struct SqlAccessor {
+    const std::vector<SqlRow>& rows;
+    double GetX(uint32_t i) const { return rows[i].x; }
+    double GetY(uint32_t i) const { return rows[i].y; }
+    int64_t GetId(uint32_t i) const { return rows[i].id; }
+    float GetC(uint32_t i) const { return SafeDoubleToFloat(rows[i].c); }
+  } acc{rows};
+
+  if (rows.empty()) {
+    BuildCore(0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false, 0, 0, acc);
+    return;
+  }
+
+  uint32_t n = static_cast<uint32_t>(rows.size());
+  double x_min = rows[0].x, x_max = rows[0].x;
+  double y_min = rows[0].y, y_max = rows[0].y;
+  int64_t min_id = rows[0].id, max_id = rows[0].id;
+  double c_min = 0.0, c_max = 0.0;
+  bool has_c = false;
+
+  base::FlatHashMap<uint32_t, uint32_t> auto_str_counts;
+  base::FlatHashMap<uint64_t, uint32_t> auto_num_counts;
+  uint32_t auto_null_count = 0;
+  uint64_t other_mismatched_count = 0;
+  bool is_auto_cat = (categories && categories->auto_top_n > 0);
+
+  for (size_t i = 0; i < n; ++i) {
+    const auto& r = rows[i];
+    x_min = std::min(x_min, r.x);
+    x_max = std::max(x_max, r.x);
+    y_min = std::min(y_min, r.y);
+    y_max = std::max(y_max, r.y);
+    min_id = std::min(min_id, r.id);
+    max_id = std::max(max_id, r.id);
+
+    if (is_auto_cat) {
+      if (r.c_is_null) {
+        auto_null_count++;
+      } else if (categories->type == CategoryMapping::Type::kString &&
+                 r.c_is_string) {
+        auto_str_counts[r.c_str_id]++;
+      } else if (categories->type == CategoryMapping::Type::kInt &&
+                 r.c_is_int) {
+        auto_num_counts[static_cast<uint64_t>(r.c_int)]++;
+      } else if (categories->type == CategoryMapping::Type::kDouble &&
+                 r.c_is_double && std::isfinite(r.c_double)) {
+        auto_num_counts[DoubleToKey(r.c_double)]++;
+      } else {
+        other_mismatched_count++;
+      }
+    } else if (r.has_c && std::isfinite(r.c)) {
+      if (!has_c) {
+        c_min = c_max = r.c;
+        has_c = true;
+      } else {
+        c_min = std::min(c_min, r.c);
+        c_max = std::max(c_max, r.c);
+      }
+    }
+  }
+
+  if (is_auto_cat) {
+    categories->PopulateTopN(auto_str_counts, auto_num_counts, auto_null_count,
+                             other_mismatched_count, pool);
+    has_c = true;
+    c_min = (auto_null_count > 0) ? -1.0 : 0.0;
+    c_max = static_cast<double>(categories->auto_top_n);
+
+    for (size_t i = 0; i < n; ++i) {
+      auto& r = rows[i];
+      if (r.c_is_null) {
+        r.c = -1.0;
+      } else if (categories->type == CategoryMapping::Type::kString) {
+        r.c = r.c_is_string
+                  ? static_cast<double>(categories->MapString(r.c_str_id))
+                  : static_cast<double>(categories->auto_top_n);
+      } else if (categories->type == CategoryMapping::Type::kInt) {
+        r.c = r.c_is_int ? static_cast<double>(categories->MapInt(r.c_int))
+                         : static_cast<double>(categories->auto_top_n);
+      } else {
+        r.c = (r.c_is_double && std::isfinite(r.c_double))
+                  ? static_cast<double>(categories->MapDouble(r.c_double))
+                  : static_cast<double>(categories->auto_top_n);
+      }
+    }
+  }
+
+  BuildCore(n, x_min, x_max, y_min, y_max, c_min, c_max, has_c, min_id, max_id,
+            acc);
+
+  categorical_ = categories != nullptr;
+  if (categories) {
+    categories_ = categories->categories;
+    categories_type_ = categories->type;
+  }
+  BuildCategoryMasks();
+  std::vector<SqlRow>().swap(rows);
+}
+
+bool ScatterMipmapIndex::TryQueryAligned(
+    double x_min,
+    double x_max,
+    double y_min,
+    double y_max,
+    int64_t cols,
+    int64_t rows,
+    std::vector<Result>& out_results) const {
+  double x_span = x_max_data_ - x_min_data_;
+  double y_span = y_max_data_ - y_min_data_;
+  double span_x_query = x_max - x_min;
+  double span_y_query = y_max - y_min;
+  if (x_span <= 0.0 || y_span <= 0.0 || span_x_query <= 0.0 ||
+      span_y_query <= 0.0) {
+    return false;
+  }
+
+  double rx_d = (x_span * static_cast<double>(cols)) / span_x_query;
+  double ry_d = (y_span * static_cast<double>(rows)) / span_y_query;
+  if (!std::isfinite(rx_d) || !std::isfinite(ry_d) || rx_d < 0.9999 ||
+      ry_d < 0.9999 || !(rx_d <= 0x1p20) || !(ry_d <= 0x1p20)) {
+    return false;
+  }
+
+  uint32_t lx = static_cast<uint32_t>(std::round(std::log2(rx_d)));
+  uint32_t ly = static_cast<uint32_t>(std::round(std::log2(ry_d)));
+  uint32_t l_max = std::max(lx, ly);
+  if (l_max > level_t_) {
+    return false;
+  }
+
+  double target_rx = std::ldexp(1.0, static_cast<int>(lx));
+  double target_ry = std::ldexp(1.0, static_cast<int>(ly));
+  if (std::abs(rx_d - target_rx) > 1e-4 * target_rx ||
+      std::abs(ry_d - target_ry) > 1e-4 * target_ry) {
+    return false;
+  }
+
+  double kx0 = (x_min - x_min_data_) / (x_span / target_rx);
+  double ky0 = (y_min - y_min_data_) / (y_span / target_ry);
+  double rx0 = std::round(kx0);
+  double ry0 = std::round(ky0);
+  if (!std::isfinite(rx0) || !std::isfinite(ry0) ||
+      std::abs(kx0 - rx0) > 1e-4 || std::abs(ky0 - ry0) > 1e-4 || rx0 < 0.0 ||
+      ry0 < 0.0 || rx0 + static_cast<double>(cols) > target_rx ||
+      ry0 + static_cast<double>(rows) > target_ry) {
+    return false;
+  }
+
+  int64_t ix0 = static_cast<int64_t>(rx0);
+  int64_t iy0 = static_cast<int64_t>(ry0);
+  out_results.clear();
+  out_results.reserve(
+      std::min<size_t>(size(), static_cast<size_t>(cols * rows)));
+
+  uint32_t shift_to_t = 2 * (level_t_ - l_max);
+  uint32_t dx = (ly > lx) ? (ly - lx) : 0;
+  uint32_t dy = (lx > ly) ? (lx - ly) : 0;
+  uint32_t subs_x = 1u << dx;
+  uint32_t subs_y = 1u << dy;
+
+  for (int64_t r = 0; r < rows; ++r) {
+    uint32_t y_base = static_cast<uint32_t>((iy0 + r) << dy);
+    for (int64_t c = 0; c < cols; ++c) {
+      uint32_t x_base = static_cast<uint32_t>((ix0 + c) << dx);
+      uint32_t total_count = 0;
+      uint32_t best_rep = 0;
+      uint32_t max_sub_count = 0;
+      for (uint32_t sy = 0; sy < subs_y; ++sy) {
+        for (uint32_t sx = 0; sx < subs_x; ++sx) {
+          uint64_t morton = EncodeMorton2D(x_base + sx, y_base + sy);
+          uint32_t lo = level_t_offsets_[morton << shift_to_t];
+          uint32_t hi = level_t_offsets_[(morton + 1) << shift_to_t];
+          uint32_t sub_count = hi - lo;
+          if (sub_count > 0) {
+            total_count += sub_count;
+            if (sub_count > max_sub_count) {
+              max_sub_count = sub_count;
+              best_rep = lo + sub_count / 2;
+            }
+          }
+        }
+      }
+      if (total_count > 0) {
+        out_results.push_back({best_rep, total_count});
+      }
+    }
+  }
+  return true;
+}
+
+bool ScatterMipmapIndex::Query(double x_min,
+                               double x_max,
+                               double y_min,
+                               double y_max,
+                               int64_t cols,
+                               int64_t rows,
+                               QueryBuffers& buffers,
+                               std::vector<Result>& out_results,
+                               const CategoryFilter* filter) const {
+  out_results.clear();
+  if (empty() || !std::isfinite(x_min) || !std::isfinite(x_max) ||
+      !std::isfinite(y_min) || !std::isfinite(y_max) || x_max <= x_min ||
+      y_max <= y_min || cols < 1 || rows < 1 || cols > kMaxCells ||
+      rows > kMaxCells || cols > kMaxCells / rows) {
+    return false;
+  }
+  bool filtered =
+      filter != nullptr && has_c_ &&
+      (filter->hide_null ||
+       (categorical_ && std::find(filter->hidden.begin(), filter->hidden.end(),
+                                  true) != filter->hidden.end()));
+  if (!filtered) {
+    if (TryQueryAligned(x_min, x_max, y_min, y_max, cols, rows, out_results)) {
+      return true;
+    }
+    filter = nullptr;
+  }
+  QueryDfs(x_min, x_max, y_min, y_max, cols, rows, buffers, out_results,
+           filter);
+  return true;
+}
+
+uint32_t ScatterMipmapIndex::CategoryBit(uint32_t idx) const {
+  float c = cs_[idx];
+  if (std::isnan(c)) {
+    return kNullBit;
+  }
+  if (!categorical_) {
+    return kOverflowBit;
+  }
+  if (c < 0.0f) {
+    return kNullBit;
+  }
+  return c < static_cast<float>(kOverflowBit) ? static_cast<uint32_t>(c)
+                                              : kOverflowBit;
+}
+
+bool ScatterMipmapIndex::IsPointHidden(uint32_t idx,
+                                       const CategoryFilter& filter) const {
+  float c = cs_[idx];
+  if (std::isnan(c) || (categorical_ && c < 0.0f)) {
+    return filter.hide_null;
+  }
+  if (!categorical_) {
+    return false;
+  }
+  auto k = static_cast<size_t>(c);
+  return k < filter.hidden.size() && filter.hidden[k];
+}
+
+void ScatterMipmapIndex::BuildCategoryMasks() {
+  cat_masks_.clear();
+  mask_level_ = 0;
+  if (!has_c_ || empty()) {
+    return;
+  }
+  mask_level_ = std::min(level_t_, kMaxMaskLevel);
+  cat_masks_.assign(MaskLevelOffset(mask_level_ + 1), 0);
+
+  // Leaf masks at `mask_level_` from the Morton-ordered point ranges.
+  const uint32_t shift = 2 * (level_t_ - mask_level_);
+  const size_t leaf_count = size_t{1} << (2 * mask_level_);
+  uint64_t* leaves = &cat_masks_[MaskLevelOffset(mask_level_)];
+  for (size_t p = 0; p < leaf_count; ++p) {
+    uint32_t lo = level_t_offsets_[p << shift];
+    uint32_t hi = level_t_offsets_[(p + 1) << shift];
+    uint64_t mask = 0;
+    for (uint32_t i = lo; i < hi; ++i) {
+      mask |= uint64_t{1} << CategoryBit(i);
+    }
+    leaves[p] = mask;
+  }
+
+  // Reduce upwards: a parent's mask is the OR of its four children.
+  for (uint32_t level = mask_level_; level > 0; --level) {
+    const uint64_t* child = &cat_masks_[MaskLevelOffset(level)];
+    uint64_t* parent = &cat_masks_[MaskLevelOffset(level - 1)];
+    const size_t parent_count = size_t{1} << (2 * (level - 1));
+    for (size_t p = 0; p < parent_count; ++p) {
+      parent[p] =
+          child[4 * p] | child[4 * p + 1] | child[4 * p + 2] | child[4 * p + 3];
+    }
+  }
+}
+
+void ScatterMipmapIndex::QueryDfs(double x_min,
+                                  double x_max,
+                                  double y_min,
+                                  double y_max,
+                                  int64_t cols,
+                                  int64_t rows,
+                                  QueryBuffers& buffers,
+                                  std::vector<Result>& out_results,
+                                  const CategoryFilter* filter) const {
+  // Bits of the categories that are hidden / possibly visible (see
+  // CategoryBit()). The overflow bit is shared by several categories so it is
+  // always considered possibly visible.
+  uint64_t hidden_bits = 0;
+  if (filter != nullptr) {
+    if (categorical_) {
+      for (size_t k = 0; k < filter->hidden.size(); ++k) {
+        if (filter->hidden[k]) {
+          hidden_bits |= uint64_t{1}
+                         << std::min<size_t>(k, size_t{kOverflowBit});
+        }
+      }
+    }
+    if (filter->hide_null) {
+      hidden_bits |= uint64_t{1} << kNullBit;
+    }
+  }
+  const uint64_t visible_bits = ~hidden_bits | (uint64_t{1} << kOverflowBit);
+
+  uint64_t total_cells_u64 =
+      static_cast<uint64_t>(cols) * static_cast<uint64_t>(rows);
+  size_t total_cells = static_cast<size_t>(total_cells_u64);
+  bool use_dense = total_cells <= 4'000'000;
+
+  if (use_dense) {
+    if (buffers.dense_slots.size() < total_cells) {
+      buffers.dense_slots.resize(total_cells);
+    }
+    buffers.occupied_cells.clear();
+  } else {
+    buffers.sparse_slots.Clear();
+  }
+
+  auto add_points = [&](uint64_t cell_idx, uint32_t count, uint32_t rep_idx) {
+    if (use_dense) {
+      DenseSlot& slot = buffers.dense_slots[cell_idx];
+      if (slot.count == 0) {
+        buffers.occupied_cells.push_back(static_cast<uint32_t>(cell_idx));
+        slot.rep_index = rep_idx;
+      }
+      slot.count += count;
+    } else if (auto* slot = buffers.sparse_slots.Find(cell_idx)) {
+      slot->count += count;
+    } else {
+      buffers.sparse_slots.Insert(cell_idx, DenseSlot{count, rep_idx});
+    }
+  };
+
+  double inv_cell_x = static_cast<double>(cols) / (x_max - x_min);
+  double inv_cell_y = static_cast<double>(rows) / (y_max - y_min);
+  double eps_x = 1e-9 * ((x_max - x_min) / static_cast<double>(cols));
+  double eps_y = 1e-9 * ((y_max - y_min) / static_cast<double>(rows));
+
+  auto fast_cell = [](double v, double v_min, double v_max, double inv,
+                      int64_t limit) -> int64_t {
+    if (PERFETTO_UNLIKELY(v >= v_max))
+      return limit - 1;
+    if (PERFETTO_UNLIKELY(v <= v_min))
+      return 0;
+    return std::clamp(static_cast<int64_t>((v - v_min) * inv), int64_t{0},
+                      limit - 1);
+  };
+
+  struct DfsFrame {
+    uint32_t level;
+    uint64_t prefix;
+    uint32_t lo;
+    uint32_t hi;
+    double x_min;
+    double x_max;
+    double y_min;
+    double y_max;
+    // True if the node is known to contain no hidden points.
+    bool clean;
+  };
+
+  DfsFrame stack[128];
+  int stack_ptr = 0;
+  stack[stack_ptr++] = {
+      0,
+      0,
+      0,
+      static_cast<uint32_t>(size()),
+      x_min_data_,
+      x_max_data_,
+      y_min_data_,
+      y_max_data_,
+      hidden_bits == 0,
+  };
+
+  while (stack_ptr > 0) {
+    DfsFrame curr = stack[--stack_ptr];
+    if (curr.lo >= curr.hi || curr.x_max < x_min || curr.x_min > x_max ||
+        curr.y_max < y_min || curr.y_min > y_max) {
+      continue;
+    }
+
+    if (!curr.clean && curr.level <= mask_level_) {
+      uint64_t mask = cat_masks_[MaskLevelOffset(curr.level) + curr.prefix];
+      if ((mask & visible_bits) == 0) {
+        continue;
+      }
+      curr.clean = (mask & hidden_bits) == 0;
+    }
+
+    if (curr.clean && curr.x_min >= x_min && curr.x_max <= x_max &&
+        curr.y_min >= y_min && curr.y_max <= y_max) {
+      int64_t cx1 = fast_cell(curr.x_min, x_min, x_max, inv_cell_x, cols);
+      int64_t cx2 = fast_cell(std::max(curr.x_min, curr.x_max - eps_x), x_min,
+                              x_max, inv_cell_x, cols);
+      int64_t cy1 = fast_cell(curr.y_min, y_min, y_max, inv_cell_y, rows);
+      int64_t cy2 = fast_cell(std::max(curr.y_min, curr.y_max - eps_y), y_min,
+                              y_max, inv_cell_y, rows);
+      bool x_fits = (cx1 == cx2) && (curr.x_max < x_max_data_ ||
+                                     fast_cell(curr.x_max, x_min, x_max,
+                                               inv_cell_x, cols) == cx1);
+      bool y_fits = (cy1 == cy2) && (curr.y_max < y_max_data_ ||
+                                     fast_cell(curr.y_max, y_min, y_max,
+                                               inv_cell_y, rows) == cy1);
+      if (x_fits && y_fits) {
+        uint64_t cell_idx =
+            static_cast<uint64_t>(cy1) * static_cast<uint64_t>(cols) +
+            static_cast<uint64_t>(cx1);
+        add_points(cell_idx, curr.hi - curr.lo,
+                   curr.lo + (curr.hi - curr.lo) / 2);
+        continue;
+      }
+    }
+
+    if (curr.hi - curr.lo <= 32 || curr.level >= level_t_) {
+      for (uint32_t i = curr.lo; i < curr.hi; ++i) {
+        if (!curr.clean && IsPointHidden(i, *filter)) {
+          continue;
+        }
+        double px = xs_[i];
+        double py = ys_[i];
+        if (px >= x_min && px <= x_max && py >= y_min && py <= y_max) {
+          int64_t cx = fast_cell(px, x_min, x_max, inv_cell_x, cols);
+          int64_t cy = fast_cell(py, y_min, y_max, inv_cell_y, rows);
+          add_points(static_cast<uint64_t>(cy) * static_cast<uint64_t>(cols) +
+                         static_cast<uint64_t>(cx),
+                     1, i);
+        }
+      }
+      continue;
+    }
+
+    uint32_t next_level = curr.level + 1;
+    double x_mid = 0.5 * (curr.x_min + curr.x_max);
+    double y_mid = 0.5 * (curr.y_min + curr.y_max);
+    uint32_t shift_bits = 2 * (level_t_ - next_level);
+
+    for (int c = 3; c >= 0; --c) {
+      uint64_t child_prefix = (curr.prefix << 2) | static_cast<uint64_t>(c);
+      uint32_t lo = level_t_offsets_[child_prefix << shift_bits];
+      uint32_t hi = level_t_offsets_[(child_prefix + 1) << shift_bits];
+      if (hi > lo) {
+        stack[stack_ptr++] = {next_level,
+                              child_prefix,
+                              lo,
+                              hi,
+                              (c & 1) ? x_mid : curr.x_min,
+                              (c & 1) ? curr.x_max : x_mid,
+                              (c & 2) ? y_mid : curr.y_min,
+                              (c & 2) ? curr.y_max : y_mid,
+                              curr.clean};
+      }
+    }
+  }
+
+  if (use_dense) {
+    std::sort(buffers.occupied_cells.begin(), buffers.occupied_cells.end());
+    out_results.reserve(buffers.occupied_cells.size());
+    for (uint32_t cell_idx : buffers.occupied_cells) {
+      const DenseSlot& slot = buffers.dense_slots[cell_idx];
+      out_results.push_back({slot.rep_index, slot.count});
+      buffers.dense_slots[cell_idx] = {0, 0};
+    }
+    buffers.occupied_cells.clear();
+  } else {
+    std::vector<std::pair<uint64_t, DenseSlot>> sorted_cells;
+    sorted_cells.reserve(buffers.sparse_slots.size());
+    for (auto it = buffers.sparse_slots.GetIterator(); it; ++it) {
+      sorted_cells.emplace_back(it.key(), it.value());
+    }
+    std::sort(sorted_cells.begin(), sorted_cells.end(),
+              [](const auto& a, const auto& b) { return a.first < b.first; });
+    out_results.reserve(sorted_cells.size());
+    for (const auto& entry : sorted_cells) {
+      out_results.push_back({entry.second.rep_index, entry.second.count});
+    }
+    buffers.sparse_slots.Clear();
+  }
+}
+
+}  // namespace perfetto::trace_processor::scatter_mipmap_operator
diff --git a/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h
new file mode 100644
index 0000000..cf4656c
--- /dev/null
+++ b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h
@@ -0,0 +1,466 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_INDEX_H_
+#define SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_INDEX_H_
+
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/compiler.h"
+#include "perfetto/ext/base/flat_hash_map.h"
+#include "src/trace_processor/containers/string_pool.h"
+#include "src/trace_processor/core/dataframe/dataframe.h"
+#include "src/trace_processor/core/util/bit_vector.h"
+
+#if PERFETTO_BUILDFLAG(PERFETTO_X64_CPU_OPT)
+#include <immintrin.h>
+#endif
+
+namespace perfetto::trace_processor::scatter_mipmap_operator {
+
+inline uint64_t Dilate20To40(uint32_t v) {
+  uint64_t x = v & 0xFFFFF;
+  x = (x | (x << 16)) & 0x0000FFFF0000FFFFULL;
+  x = (x | (x << 8)) & 0x00FF00FF00FF00FFULL;
+  x = (x | (x << 4)) & 0x0F0F0F0F0F0F0F0FULL;
+  x = (x | (x << 2)) & 0x3333333333333333ULL;
+  x = (x | (x << 1)) & 0x5555555555555555ULL;
+  return x;
+}
+
+#if PERFETTO_BUILDFLAG(PERFETTO_X64_CPU_OPT) && \
+    (defined(__x86_64__) || defined(_M_X64))
+inline uint64_t EncodeMorton2D(uint32_t qx, uint32_t qy) {
+  return _pdep_u64(qx, 0x5555555555555555ULL) |
+         (_pdep_u64(qy, 0x5555555555555555ULL) << 1);
+}
+#else
+inline uint64_t EncodeMorton2D(uint32_t qx, uint32_t qy) {
+  return Dilate20To40(qx) | (Dilate20To40(qy) << 1);
+}
+#endif
+
+inline uint64_t DoubleToKey(double d) {
+  if (d == 0.0)
+    return 0;
+  uint64_t u = 0;
+  std::memcpy(&u, &d, sizeof(u));
+  return u;
+}
+
+inline double KeyToDouble(uint64_t u) {
+  double d = 0.0;
+  std::memcpy(&d, &u, sizeof(d));
+  return d;
+}
+
+struct CategoryEntry {
+  int32_t idx = 0;
+  uint64_t count = 0;
+  bool is_null = false;
+  bool is_other = false;
+  std::string str_val;
+  int64_t int_val = 0;
+  double double_val = 0.0;
+};
+
+struct CategoryMapping {
+  enum class Type { kString, kInt, kDouble };
+  Type type = Type::kString;
+  uint32_t auto_top_n = 0;
+
+  base::FlatHashMap<uint32_t, int32_t> str_pool_id_to_idx;
+  base::FlatHashMap<uint64_t, int32_t> num_to_idx;
+  std::vector<CategoryEntry> categories;
+
+  void PopulateTopN(const base::FlatHashMap<uint32_t, uint32_t>& str_counts,
+                    const base::FlatHashMap<uint64_t, uint32_t>& num_counts,
+                    uint32_t null_count,
+                    uint64_t other_mismatched_count,
+                    StringPool* pool);
+
+  int32_t MapString(uint32_t pool_id) const {
+    if (const int32_t* it = str_pool_id_to_idx.Find(pool_id)) {
+      return *it;
+    }
+    return static_cast<int32_t>(auto_top_n);
+  }
+
+  int32_t MapInt(int64_t val) const {
+    if (const int32_t* it = num_to_idx.Find(static_cast<uint64_t>(val))) {
+      return *it;
+    }
+    return static_cast<int32_t>(auto_top_n);
+  }
+
+  int32_t MapDouble(double val) const {
+    if (const int32_t* it = num_to_idx.Find(DoubleToKey(val))) {
+      return *it;
+    }
+    return static_cast<int32_t>(auto_top_n);
+  }
+};
+
+struct DataframeColumnReader {
+  enum StorageKind { kId, kDouble, kInt64, kUint32, kInt32, kString, kUnknown };
+  StorageKind kind = kUnknown;
+  const double* double_data = nullptr;
+  const int64_t* int64_data = nullptr;
+  const uint32_t* uint32_data = nullptr;
+  const int32_t* int32_data = nullptr;
+  const StringPool::Id* string_data = nullptr;
+  const core::BitVector* null_bv = nullptr;
+  bool is_sparse_null = false;
+  const uint32_t* sparse_prefix_popcount = nullptr;
+
+  bool Init(const dataframe::Column& col) {
+    const dataframe::Nullability nullability = col.null_storage.nullability();
+    switch (nullability.index()) {
+      case dataframe::Nullability::GetTypeIndex<dataframe::NonNull>():
+        null_bv = nullptr;
+        break;
+      case dataframe::Nullability::GetTypeIndex<dataframe::DenseNull>():
+        null_bv =
+            &col.null_storage.unchecked_get<dataframe::DenseNull>().bit_vector;
+        break;
+      case dataframe::Nullability::GetTypeIndex<dataframe::SparseNull>():
+      case dataframe::Nullability::GetTypeIndex<
+          dataframe::SparseNullWithPopcountAlways>():
+      case dataframe::Nullability::GetTypeIndex<
+          dataframe::SparseNullWithPopcountUntilFinalization>(): {
+        const auto& sparse =
+            col.null_storage.unchecked_get<dataframe::SparseNull>();
+        null_bv = &sparse.bit_vector;
+        is_sparse_null = true;
+        if (sparse.prefix_popcount_for_cell_get.empty()) {
+          return false;
+        }
+        sparse_prefix_popcount = sparse.prefix_popcount_for_cell_get.data();
+        break;
+      }
+      default:
+        return false;
+    }
+
+    auto data_ptr = col.storage.data();
+    switch (data_ptr.index()) {
+      case dataframe::StorageType::GetTypeIndex<dataframe::Id>():
+        kind = kId;
+        break;
+      case dataframe::StorageType::GetTypeIndex<dataframe::Double>():
+        kind = kDouble;
+        double_data =
+            dataframe::Storage::CastDataPtr<dataframe::Double>(data_ptr);
+        break;
+      case dataframe::StorageType::GetTypeIndex<dataframe::Int64>():
+        kind = kInt64;
+        int64_data =
+            dataframe::Storage::CastDataPtr<dataframe::Int64>(data_ptr);
+        break;
+      case dataframe::StorageType::GetTypeIndex<dataframe::Uint32>():
+        kind = kUint32;
+        uint32_data =
+            dataframe::Storage::CastDataPtr<dataframe::Uint32>(data_ptr);
+        break;
+      case dataframe::StorageType::GetTypeIndex<dataframe::Int32>():
+        kind = kInt32;
+        int32_data =
+            dataframe::Storage::CastDataPtr<dataframe::Int32>(data_ptr);
+        break;
+      case dataframe::StorageType::GetTypeIndex<core::String>():
+        kind = kString;
+        string_data = dataframe::Storage::CastDataPtr<core::String>(data_ptr);
+        break;
+      default:
+        return false;
+    }
+    return true;
+  }
+
+  PERFETTO_ALWAYS_INLINE bool is_integer() const {
+    return kind == kId || kind == kInt64 || kind == kUint32 || kind == kInt32;
+  }
+  PERFETTO_ALWAYS_INLINE bool is_numeric() const {
+    return is_integer() || kind == kDouble;
+  }
+
+  PERFETTO_ALWAYS_INLINE bool ResolveIndex(uint32_t row, uint32_t& idx) const {
+    idx = row;
+    if (PERFETTO_UNLIKELY(null_bv)) {
+      if (!null_bv->is_set(row)) {
+        return false;
+      }
+      if (is_sparse_null) {
+        idx = static_cast<uint32_t>(sparse_prefix_popcount[row / 64] +
+                                    null_bv->count_set_bits_until_in_word(row));
+      }
+    }
+    return true;
+  }
+
+  PERFETTO_ALWAYS_INLINE bool GetStringPoolId(uint32_t row,
+                                              StringPool::Id& out) const {
+    uint32_t idx = 0;
+    if (kind != kString || !ResolveIndex(row, idx)) {
+      return false;
+    }
+    out = string_data[idx];
+    return !out.is_null();
+  }
+
+  PERFETTO_ALWAYS_INLINE bool GetDouble(uint32_t row, double& out) const {
+    uint32_t idx = 0;
+    if (!ResolveIndex(row, idx)) {
+      return false;
+    }
+    switch (kind) {
+      case kDouble:
+        out = double_data[idx];
+        return std::isfinite(out);
+      case kInt64:
+        out = static_cast<double>(int64_data[idx]);
+        return true;
+      case kUint32:
+        out = static_cast<double>(uint32_data[idx]);
+        return true;
+      case kInt32:
+        out = static_cast<double>(int32_data[idx]);
+        return true;
+      case kId:
+        out = static_cast<double>(idx);
+        return true;
+      case kString:
+      case kUnknown:
+        return false;
+    }
+    return false;
+  }
+
+  PERFETTO_ALWAYS_INLINE bool GetInt64(uint32_t row, int64_t& out) const {
+    uint32_t idx = 0;
+    if (!ResolveIndex(row, idx)) {
+      return false;
+    }
+    switch (kind) {
+      case kInt64:
+        out = int64_data[idx];
+        return true;
+      case kId:
+        out = static_cast<int64_t>(idx);
+        return true;
+      case kUint32:
+        out = static_cast<int64_t>(uint32_data[idx]);
+        return true;
+      case kInt32:
+        out = static_cast<int64_t>(int32_data[idx]);
+        return true;
+      case kDouble: {
+        double d = double_data[idx];
+        if (!std::isfinite(d) ||
+            d < static_cast<double>(std::numeric_limits<int64_t>::min()) ||
+            d > static_cast<double>(std::numeric_limits<int64_t>::max())) {
+          return false;
+        }
+        out = static_cast<int64_t>(d);
+        return true;
+      }
+      case kString:
+      case kUnknown:
+        return false;
+    }
+    return false;
+  }
+};
+
+struct SqlRow {
+  int64_t id = 0;
+  double x = 0.0;
+  double y = 0.0;
+  bool has_c = false;
+  double c = std::numeric_limits<double>::quiet_NaN();
+  bool c_is_null = true;
+  bool c_is_string = false;
+  bool c_is_int = false;
+  bool c_is_double = false;
+  uint32_t c_str_id = 0;
+  int64_t c_int = 0;
+  double c_double = 0.0;
+};
+
+class ScatterMipmapIndex {
+ public:
+  struct Result {
+    uint32_t rep_index;
+    uint32_t count;
+  };
+
+  struct DenseSlot {
+    uint32_t count = 0;
+    uint32_t rep_index = 0;
+  };
+
+  // Excludes points by category from a query. Hidden points are neither
+  // counted nor chosen as representatives, so cells that only contain hidden
+  // points are omitted and mixed cells report only their visible points.
+  struct CategoryFilter {
+    // hidden[k] is true if category index k (>= 0) is hidden. Only applies to
+    // indexes built with categories (top-N mode).
+    std::vector<bool> hidden;
+    // Hides points whose c is NULL (category -1 / NaN).
+    bool hide_null = false;
+  };
+
+  struct QueryBuffers {
+    std::vector<DenseSlot> dense_slots;
+    std::vector<uint32_t> occupied_cells;
+    base::FlatHashMap<uint64_t, DenseSlot> sparse_slots;
+  };
+
+  ScatterMipmapIndex() = default;
+
+  void IngestAndBuildDataframe(uint32_t n,
+                               const DataframeColumnReader* reader_id,
+                               const DataframeColumnReader& reader_x,
+                               const DataframeColumnReader& reader_y,
+                               const DataframeColumnReader* reader_c,
+                               CategoryMapping* categories = nullptr,
+                               StringPool* pool = nullptr);
+
+  void IngestAndBuildSqlRows(std::vector<SqlRow>& rows,
+                             CategoryMapping* categories = nullptr,
+                             StringPool* pool = nullptr);
+
+  bool Query(double x_min,
+             double x_max,
+             double y_min,
+             double y_max,
+             int64_t cols,
+             int64_t rows,
+             QueryBuffers& buffers,
+             std::vector<Result>& out_results,
+             const CategoryFilter* filter = nullptr) const;
+
+  const std::vector<CategoryEntry>& categories() const { return categories_; }
+  CategoryMapping::Type categories_type() const { return categories_type_; }
+
+  size_t size() const { return ids_are_32_ ? ids32_.size() : ids64_.size(); }
+  bool empty() const { return size() == 0; }
+
+  int64_t id(uint32_t idx) const {
+    return ids_are_32_ ? static_cast<int64_t>(ids32_[idx]) : ids64_[idx];
+  }
+  double x(uint32_t idx) const { return xs_[idx]; }
+  double y(uint32_t idx) const { return ys_[idx]; }
+  double c(uint32_t idx) const {
+    return has_c_ ? static_cast<double>(cs_[idx])
+                  : std::numeric_limits<double>::quiet_NaN();
+  }
+
+  double x_min_data() const { return x_min_data_; }
+  double x_max_data() const { return x_max_data_; }
+  double y_min_data() const { return y_min_data_; }
+  double y_max_data() const { return y_max_data_; }
+  double c_min_data() const { return c_min_data_; }
+  double c_max_data() const { return c_max_data_; }
+  bool has_c() const { return has_c_; }
+
+ private:
+  template <typename PointAccessor>
+  void BuildCore(uint32_t n,
+                 double x_min,
+                 double x_max,
+                 double y_min,
+                 double y_max,
+                 double c_min,
+                 double c_max,
+                 bool has_c,
+                 int64_t min_id,
+                 int64_t max_id,
+                 PointAccessor& accessor);
+
+  bool TryQueryAligned(double x_min,
+                       double x_max,
+                       double y_min,
+                       double y_max,
+                       int64_t cols,
+                       int64_t rows,
+                       std::vector<Result>& out_results) const;
+
+  void QueryDfs(double x_min,
+                double x_max,
+                double y_min,
+                double y_max,
+                int64_t cols,
+                int64_t rows,
+                QueryBuffers& buffers,
+                std::vector<Result>& out_results,
+                const CategoryFilter* filter) const;
+
+  // Category presence masks: each point maps to one of 64 bits (see
+  // CategoryBit()) and every quadtree node up to `mask_level_` stores the OR
+  // of its points' bits. This lets filtered queries skip nodes with no
+  // visible points and take the counting fast path for nodes with no hidden
+  // points.
+  static constexpr uint32_t kMaxMaskLevel = 8;
+  static constexpr uint32_t kOverflowBit = 62;  // Category >= 62 / numeric c.
+  static constexpr uint32_t kNullBit = 63;
+  static constexpr size_t MaskLevelOffset(uint32_t level) {
+    return ((size_t{1} << (2 * level)) - 1) / 3;
+  }
+  uint32_t CategoryBit(uint32_t idx) const;
+  bool IsPointHidden(uint32_t idx, const CategoryFilter& filter) const;
+  void BuildCategoryMasks();
+
+  int64_t min_id_ = 0;
+  int64_t max_id_ = 0;
+
+  bool ids_are_32_ = true;
+  std::vector<uint32_t> ids32_;
+  std::vector<int64_t> ids64_;
+  std::vector<double> xs_;
+  std::vector<double> ys_;
+  std::vector<float> cs_;
+
+  uint32_t level_t_ = 8;
+  std::vector<uint32_t> level_t_offsets_;
+
+  double x_min_data_ = 0.0;
+  double x_max_data_ = 0.0;
+  double y_min_data_ = 0.0;
+  double y_max_data_ = 0.0;
+  double c_min_data_ = 0.0;
+  double c_max_data_ = 0.0;
+  bool has_c_ = false;
+
+  std::vector<CategoryEntry> categories_;
+  CategoryMapping::Type categories_type_ = CategoryMapping::Type::kString;
+  bool categorical_ = false;
+
+  uint32_t mask_level_ = 0;
+  std::vector<uint64_t> cat_masks_;
+};
+
+}  // namespace perfetto::trace_processor::scatter_mipmap_operator
+
+#endif  // SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_INDEX_H_
diff --git a/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.cc b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.cc
new file mode 100644
index 0000000..cc69091
--- /dev/null
+++ b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.cc
@@ -0,0 +1,858 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h"
+
+#include <sqlite3.h>
+#include <array>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "perfetto/base/compiler.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/base/status.h"
+#include "perfetto/ext/base/string_utils.h"
+#include "perfetto/ext/base/string_view.h"
+#include "src/trace_processor/core/dataframe/dataframe.h"
+#include "src/trace_processor/core/dataframe/types.h"
+#include "src/trace_processor/core/plugin/plugin.h"
+#include "src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
+#include "src/trace_processor/sqlite/module_state_manager.h"
+#include "src/trace_processor/sqlite/sql_source.h"
+#include "src/trace_processor/sqlite/sqlite_utils.h"
+
+namespace perfetto::trace_processor::scatter_mipmap_operator {
+namespace {
+
+constexpr char kSchema[] = R"(
+  CREATE TABLE x(
+    in_x_min DOUBLE HIDDEN,
+    in_x_max DOUBLE HIDDEN,
+    in_y_min DOUBLE HIDDEN,
+    in_y_max DOUBLE HIDDEN,
+    in_cols INTEGER HIDDEN,
+    in_rows INTEGER HIDDEN,
+    in_hidden TEXT HIDDEN,
+    out_count INTEGER HIDDEN,
+    out_x_min DOUBLE HIDDEN,
+    out_x_max DOUBLE HIDDEN,
+    out_y_min DOUBLE HIDDEN,
+    out_y_max DOUBLE HIDDEN,
+    out_c_min DOUBLE HIDDEN,
+    out_c_max DOUBLE HIDDEN,
+    out_cat_idx INTEGER HIDDEN,
+    out_cat_value HIDDEN,
+    out_cat_count INTEGER HIDDEN,
+    id BIGINT,
+    x DOUBLE,
+    y DOUBLE,
+    c DOUBLE,
+    count INTEGER,
+    PRIMARY KEY(id)
+  ) WITHOUT ROWID
+)";
+
+enum ColumnIndex : size_t {
+  kInXMin = 0,
+  kInXMax,
+  kInYMin,
+  kInYMax,
+  kInCols,
+  kInRows,
+  // Optional: comma-separated category indexes to exclude (-1 = NULL).
+  kInHidden,
+
+  kOutCount,
+  kOutXMin,
+  kOutXMax,
+  kOutYMin,
+  kOutYMax,
+  kOutCMin,
+  kOutCMax,
+  kOutCatIdx,
+  kOutCatValue,
+  kOutCatCount,
+
+  kId,
+  kX,
+  kY,
+  kC,
+  kCount,
+};
+
+constexpr size_t kRequiredArgCount = kInRows + 1;
+constexpr size_t kArgCount = kInHidden + 1;
+
+// BestIndex idxNum flag: the optional `in_hidden` argument is present.
+constexpr int kHasHiddenArg = 1;
+
+bool IsArgColumn(size_t index) {
+  return index < kArgCount;
+}
+
+std::string TrimAndUnquote(std::string_view s) {
+  while (!s.empty() && (s.front() == ' ' || s.front() == '\t' ||
+                        s.front() == '\n' || s.front() == '\r')) {
+    s.remove_prefix(1);
+  }
+  while (!s.empty() && (s.back() == ' ' || s.back() == '\t' ||
+                        s.back() == '\n' || s.back() == '\r')) {
+    s.remove_suffix(1);
+  }
+  if (s.size() >= 2 && ((s.front() == '\'' && s.back() == '\'') ||
+                        (s.front() == '"' && s.back() == '"'))) {
+    s.remove_prefix(1);
+    s.remove_suffix(1);
+  }
+  return std::string(s);
+}
+
+std::string QuoteIdentifier(std::string_view name) {
+  std::string res = "\"";
+  for (char ch : name) {
+    if (ch == '"') {
+      res += "\"\"";
+    } else {
+      res += ch;
+    }
+  }
+  res += "\"";
+  return res;
+}
+
+}  // namespace
+
+int ScatterMipmapOperator::Create(sqlite3* db,
+                                  void* raw_ctx,
+                                  int argc,
+                                  const char* const* argv,
+                                  sqlite3_vtab** vtab,
+                                  char** zErr) {
+  if (argc != 4 && argc != 7 && argc != 8 && argc != 9) {
+    *zErr = sqlite3_mprintf(
+        "scatter_mipmap: expected 1 table/query arg, or 4-6 args (table, id, "
+        "x, y, [c], [categories_top_n])");
+    return SQLITE_ERROR;
+  }
+
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
+  }
+
+  auto* ctx = GetContext(raw_ctx);
+  auto state = std::make_unique<State>();
+
+  std::string table_name;
+  std::string id_col = "id";
+  std::string x_col = "x";
+  std::string y_col = "y";
+  std::string c_col;
+  uint32_t auto_category_top_n = 0;
+  bool is_table_mode = false;
+  bool id_omitted = false;
+
+  if (argc == 7 || argc == 8 || argc == 9) {
+    table_name = TrimAndUnquote(argv[3]);
+    id_col = TrimAndUnquote(argv[4]);
+    if (id_col.empty() || id_col == "NULL" || id_col == "null") {
+      id_omitted = true;
+    }
+    x_col = TrimAndUnquote(argv[5]);
+    y_col = TrimAndUnquote(argv[6]);
+    c_col = (argc >= 8) ? TrimAndUnquote(argv[7]) : "";
+    if (c_col == "NULL" || c_col == "null") {
+      c_col = "";
+    }
+    if (argc == 9) {
+      std::string arg6 = TrimAndUnquote(argv[8]);
+      if (c_col.empty()) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: categories specified but color column 'c' is "
+            "missing");
+        return SQLITE_ERROR;
+      }
+      if (arg6.empty()) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: categories argument cannot be empty");
+        return SQLITE_ERROR;
+      }
+      std::optional<int32_t> top_n = base::StringToInt32(arg6);
+      if (!top_n.has_value() || *top_n <= 0) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: categories N must be positive, got: %s",
+            arg6.c_str());
+        return SQLITE_ERROR;
+      }
+      auto_category_top_n = static_cast<uint32_t>(*top_n);
+    }
+    is_table_mode = true;
+  } else {
+    std::string arg = TrimAndUnquote(argv[3]);
+    if (!arg.empty() && arg.front() != '(') {
+      table_name = arg;
+      id_col = "id";
+      x_col = "x";
+      y_col = "y";
+      c_col = "c";
+      is_table_mode = true;
+    }
+  }
+
+  CategoryMapping cat_mapping;
+  if (auto_category_top_n > 0) {
+    cat_mapping.auto_top_n = auto_category_top_n;
+  }
+
+  bool ingested_from_dataframe = false;
+
+  if (is_table_mode) {
+    const auto* df = ctx->connection->GetDataframeOrNull(table_name);
+    if (df) {
+      const auto& col_names = df->column_names();
+      int id_idx = -1, x_idx = -1, y_idx = -1, c_idx = -1;
+      for (uint32_t i = 0; i < col_names.size(); ++i) {
+        if (!id_omitted && col_names[i] == id_col)
+          id_idx = static_cast<int>(i);
+        if (col_names[i] == x_col)
+          x_idx = static_cast<int>(i);
+        if (col_names[i] == y_col)
+          y_idx = static_cast<int>(i);
+        if (!c_col.empty() && col_names[i] == c_col)
+          c_idx = static_cast<int>(i);
+      }
+      if (!id_omitted && id_idx < 0) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: column '%s' does not exist in table '%s'",
+            id_col.c_str(), table_name.c_str());
+        return SQLITE_ERROR;
+      }
+      if (x_idx < 0) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: column '%s' does not exist in table '%s'",
+            x_col.c_str(), table_name.c_str());
+        return SQLITE_ERROR;
+      }
+      if (y_idx < 0) {
+        *zErr = sqlite3_mprintf(
+            "scatter_mipmap: column '%s' does not exist in table '%s'",
+            y_col.c_str(), table_name.c_str());
+        return SQLITE_ERROR;
+      }
+      if (!c_col.empty() && c_idx < 0) {
+        if (argc == 4) {
+          // For bare name form, c is optional.
+          c_col = "";
+        } else {
+          *zErr = sqlite3_mprintf(
+              "scatter_mipmap: column '%s' does not exist in table '%s'",
+              c_col.c_str(), table_name.c_str());
+          return SQLITE_ERROR;
+        }
+      }
+
+      DataframeColumnReader reader_id, reader_x, reader_y, reader_c;
+      bool id_ok = id_omitted ||
+                   reader_id.Init(df->column(static_cast<uint32_t>(id_idx)));
+      if (id_ok && reader_x.Init(df->column(static_cast<uint32_t>(x_idx))) &&
+          reader_y.Init(df->column(static_cast<uint32_t>(y_idx))) &&
+          (c_idx < 0 ||
+           reader_c.Init(df->column(static_cast<uint32_t>(c_idx))))) {
+        if (!id_omitted && !reader_id.is_integer()) {
+          *zErr = sqlite3_mprintf(
+              "scatter_mipmap: id column '%s' must be an integer",
+              id_col.c_str());
+          return SQLITE_ERROR;
+        }
+        if (!reader_x.is_numeric()) {
+          *zErr = sqlite3_mprintf(
+              "scatter_mipmap: x column '%s' must be numeric", x_col.c_str());
+          return SQLITE_ERROR;
+        }
+        if (!reader_y.is_numeric()) {
+          *zErr = sqlite3_mprintf(
+              "scatter_mipmap: y column '%s' must be numeric", y_col.c_str());
+          return SQLITE_ERROR;
+        }
+        if (auto_category_top_n > 0) {
+          if (reader_c.kind == DataframeColumnReader::kString) {
+            cat_mapping.type = CategoryMapping::Type::kString;
+          } else if (reader_c.kind == DataframeColumnReader::kDouble) {
+            cat_mapping.type = CategoryMapping::Type::kDouble;
+          } else if (reader_c.is_integer()) {
+            cat_mapping.type = CategoryMapping::Type::kInt;
+          } else {
+            *zErr = sqlite3_mprintf(
+                "scatter_mipmap: unsupported category column type");
+            return SQLITE_ERROR;
+          }
+        } else if (c_idx >= 0 && !reader_c.is_numeric()) {
+          *zErr = sqlite3_mprintf(
+              "scatter_mipmap: color column '%s' must be numeric when "
+              "categories are not used",
+              c_col.c_str());
+          return SQLITE_ERROR;
+        }
+
+        state->index.IngestAndBuildDataframe(
+            df->row_count(), (id_omitted ? nullptr : &reader_id), reader_x,
+            reader_y, (c_idx >= 0 ? &reader_c : nullptr),
+            (auto_category_top_n > 0 ? &cat_mapping : nullptr),
+            ctx->connection->string_pool());
+        ingested_from_dataframe = true;
+      }
+    }
+  }
+
+  if (!ingested_from_dataframe) {
+    std::string sql;
+    if (is_table_mode) {
+      sql = "SELECT ";
+      if (id_omitted) {
+        sql += "NULL AS id, ";
+      } else {
+        sql += QuoteIdentifier(id_col) + " AS id, ";
+      }
+      sql += QuoteIdentifier(x_col) + " AS x, ";
+      sql += QuoteIdentifier(y_col) + " AS y, ";
+      if (c_col.empty()) {
+        sql += "NULL AS c ";
+      } else {
+        sql += QuoteIdentifier(c_col) + " AS c ";
+      }
+      sql += "FROM " + QuoteIdentifier(table_name);
+    } else {
+      sql = "SELECT * FROM ";
+      sql.append(argv[3]);
+    }
+
+    auto res = ctx->connection->ExecuteUntilLastStatement(
+        SqlSource::FromTraceProcessorImplementation(std::move(sql)));
+    if (!res.ok()) {
+      *zErr = sqlite3_mprintf("%s", res.status().c_message());
+      return SQLITE_ERROR;
+    }
+
+    sqlite3_stmt* stmt = res->stmt.sqlite_stmt();
+    int num_cols = sqlite3_column_count(stmt);
+    if (!is_table_mode && (num_cols < 2 || num_cols > 4)) {
+      *zErr = sqlite3_mprintf(
+          "scatter_mipmap: query must return between 2 and 4 columns, got %d",
+          num_cols);
+      return SQLITE_ERROR;
+    }
+
+    int col_id_idx = -1;
+    int col_x_idx = 0;
+    int col_y_idx = 1;
+    int col_c_idx = -1;
+
+    if (is_table_mode || num_cols == 4) {
+      col_id_idx = 0;
+      col_x_idx = 1;
+      col_y_idx = 2;
+      col_c_idx = 3;
+    } else if (num_cols == 3) {
+      col_id_idx = -1;
+      col_x_idx = 0;
+      col_y_idx = 1;
+      col_c_idx = 2;
+    } else {
+      col_id_idx = -1;
+      col_x_idx = 0;
+      col_y_idx = 1;
+      col_c_idx = -1;
+    }
+
+    std::vector<SqlRow> rows;
+    StringPool* pool = ctx->connection->string_pool();
+    bool type_determined = false;
+    int64_t ordinal = 0;
+
+    for (bool more = !res->stmt.IsDone(); more; more = res->stmt.Step()) {
+      int t_x = sqlite3_column_type(stmt, col_x_idx);
+      int t_y = sqlite3_column_type(stmt, col_y_idx);
+      if (t_x == SQLITE_NULL || t_y == SQLITE_NULL) {
+        ordinal++;
+        continue;
+      }
+      if (t_x == SQLITE_TEXT || t_y == SQLITE_TEXT || t_x == SQLITE_BLOB ||
+          t_y == SQLITE_BLOB) {
+        *zErr =
+            sqlite3_mprintf("scatter_mipmap: x and y columns must be numeric");
+        return SQLITE_ERROR;
+      }
+      double x = sqlite3_column_double(stmt, col_x_idx);
+      double y = sqlite3_column_double(stmt, col_y_idx);
+      if (!std::isfinite(x) || !std::isfinite(y)) {
+        ordinal++;
+        continue;
+      }
+
+      int64_t id = ordinal;
+      if (col_id_idx >= 0) {
+        int t_id = sqlite3_column_type(stmt, col_id_idx);
+        if (t_id != SQLITE_NULL) {
+          if (t_id != SQLITE_INTEGER) {
+            *zErr =
+                sqlite3_mprintf("scatter_mipmap: id column must be an integer");
+            return SQLITE_ERROR;
+          }
+          id = sqlite3_column_int64(stmt, col_id_idx);
+        }
+      }
+      ordinal++;
+
+      SqlRow row;
+      row.id = id;
+      row.x = x;
+      row.y = y;
+
+      if (col_c_idx >= 0) {
+        int t_c = sqlite3_column_type(stmt, col_c_idx);
+        if (t_c != SQLITE_NULL) {
+          row.has_c = true;
+          row.c_is_null = false;
+          if (auto_category_top_n > 0) {
+            if (t_c == SQLITE_TEXT) {
+              const char* txt = reinterpret_cast<const char*>(
+                  sqlite3_column_text(stmt, col_c_idx));
+              StringPool::Id s_id = pool->InternString(base::StringView(txt));
+              row.c_is_string = true;
+              row.c_str_id = s_id.raw_id();
+              if (!type_determined) {
+                cat_mapping.type = CategoryMapping::Type::kString;
+                type_determined = true;
+              }
+            } else if (t_c == SQLITE_INTEGER) {
+              row.c_is_int = true;
+              row.c_int = sqlite3_column_int64(stmt, col_c_idx);
+              if (!type_determined) {
+                cat_mapping.type = CategoryMapping::Type::kInt;
+                type_determined = true;
+              }
+            } else if (t_c == SQLITE_FLOAT) {
+              row.c_is_double = true;
+              row.c_double = sqlite3_column_double(stmt, col_c_idx);
+              if (!type_determined) {
+                cat_mapping.type = CategoryMapping::Type::kDouble;
+                type_determined = true;
+              }
+            }
+          } else {
+            if (t_c == SQLITE_INTEGER || t_c == SQLITE_FLOAT) {
+              row.c = sqlite3_column_double(stmt, col_c_idx);
+            } else {
+              row.c = std::numeric_limits<double>::quiet_NaN();
+            }
+          }
+        }
+      }
+      rows.push_back(std::move(row));
+    }
+
+    if (!res->stmt.status().ok()) {
+      *zErr = sqlite3_mprintf("%s", res->stmt.status().c_message());
+      return SQLITE_ERROR;
+    }
+
+    state->index.IngestAndBuildSqlRows(
+        rows, (auto_category_top_n > 0 ? &cat_mapping : nullptr), pool);
+  }
+
+  std::unique_ptr<Vtab> vtab_res = std::make_unique<Vtab>();
+  vtab_res->state = ctx->OnCreate(argc, argv, std::move(state));
+  *vtab = vtab_res.release();
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Destroy(sqlite3_vtab* vtab) {
+  std::unique_ptr<Vtab> tab(GetVtab(vtab));
+  sqlite::ModuleStateManager<ScatterMipmapOperator>::OnDestroy(tab->state);
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Connect(sqlite3* db,
+                                   void* raw_ctx,
+                                   int argc,
+                                   const char* const* argv,
+                                   sqlite3_vtab** vtab,
+                                   char**) {
+  if (argc != 4 && argc != 7 && argc != 8 && argc != 9) {
+    return SQLITE_ERROR;
+  }
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
+  }
+  auto* ctx = GetContext(raw_ctx);
+  std::unique_ptr<Vtab> res = std::make_unique<Vtab>();
+  res->state = ctx->OnConnect(argc, argv);
+  *vtab = res.release();
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Disconnect(sqlite3_vtab* vtab) {
+  std::unique_ptr<Vtab> tab(GetVtab(vtab));
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::BestIndex(sqlite3_vtab*, sqlite3_index_info* info) {
+  std::array<bool, kArgCount> seen_args{};
+  size_t seen_count = 0;
+
+  for (int i = 0; i < info->nConstraint; ++i) {
+    auto& usage = info->aConstraintUsage[i];
+    usage.argvIndex = 0;
+    usage.omit = false;
+
+    const auto& c = info->aConstraint[i];
+    if (!c.usable) {
+      continue;
+    }
+    if (c.op == SQLITE_INDEX_CONSTRAINT_LIMIT ||
+        c.op == SQLITE_INDEX_CONSTRAINT_OFFSET) {
+      continue;
+    }
+    if (c.iColumn < 0) {
+      continue;
+    }
+    size_t col = static_cast<size_t>(c.iColumn);
+    if (!IsArgColumn(col)) {
+      continue;
+    }
+    if (c.op != SQLITE_INDEX_CONSTRAINT_EQ) {
+      continue;
+    }
+    if (seen_args[col]) {
+      return SQLITE_CONSTRAINT;
+    }
+    seen_args[col] = true;
+    ++seen_count;
+    usage.argvIndex = static_cast<int>(col + 1);
+    usage.omit = true;
+  }
+
+  for (size_t i = 0; i < kRequiredArgCount; ++i) {
+    if (!seen_args[i]) {
+      return SQLITE_CONSTRAINT;
+    }
+  }
+  PERFETTO_DCHECK(seen_count == kRequiredArgCount || seen_count == kArgCount);
+  info->idxNum = seen_args[kInHidden] ? kHasHiddenArg : 0;
+
+  info->estimatedCost = 100.0;
+  info->estimatedRows = 1000;
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Open(sqlite3_vtab*, sqlite3_vtab_cursor** cursor) {
+  std::unique_ptr<Cursor> c = std::make_unique<Cursor>();
+  *cursor = c.release();
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Close(sqlite3_vtab_cursor* cursor) {
+  std::unique_ptr<Cursor> c(GetCursor(cursor));
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Filter(sqlite3_vtab_cursor* cursor,
+                                  int idx_num,
+                                  const char*,
+                                  int argc,
+                                  sqlite3_value** argv) {
+  auto* c = GetCursor(cursor);
+  auto* t = GetVtab(c->pVtab);
+  auto* state =
+      sqlite::ModuleStateManager<ScatterMipmapOperator>::GetState(t->state);
+  const bool has_hidden_arg = (idx_num & kHasHiddenArg) != 0;
+  PERFETTO_CHECK(
+      argc == static_cast<int>(has_hidden_arg ? kArgCount : kRequiredArgCount));
+
+  c->results.clear();
+  c->current_index = 0;
+  c->is_listing_mode = false;
+
+  int64_t cols = sqlite3_value_int64(argv[4]);
+  int64_t rows = sqlite3_value_int64(argv[5]);
+
+  if (cols == 0 && rows == 0) {
+    c->is_listing_mode = true;
+    return SQLITE_OK;
+  }
+
+  if (state->index.empty()) {
+    return SQLITE_OK;
+  }
+
+  bool x_min_null = (sqlite3_value_type(argv[0]) == SQLITE_NULL);
+  bool x_max_null = (sqlite3_value_type(argv[1]) == SQLITE_NULL);
+  bool y_min_null = (sqlite3_value_type(argv[2]) == SQLITE_NULL);
+  bool y_max_null = (sqlite3_value_type(argv[3]) == SQLITE_NULL);
+
+  double x_min =
+      x_min_null ? state->index.x_min_data() : sqlite3_value_double(argv[0]);
+  double x_max =
+      x_max_null ? state->index.x_max_data() : sqlite3_value_double(argv[1]);
+  double y_min =
+      y_min_null ? state->index.y_min_data() : sqlite3_value_double(argv[2]);
+  double y_max =
+      y_max_null ? state->index.y_max_data() : sqlite3_value_double(argv[3]);
+
+  if (!std::isfinite(x_min) || !std::isfinite(x_max) || !std::isfinite(y_min) ||
+      !std::isfinite(y_max)) {
+    return sqlite::utils::SetError(t, "scatter_mipmap: non-finite bounds");
+  }
+  if (x_max_null && x_max <= x_min) {
+    x_min -= 0.5;
+    x_max += 0.5;
+  }
+  if (y_max_null && y_max <= y_min) {
+    y_min -= 0.5;
+    y_max += 0.5;
+  }
+  if (x_max <= x_min) {
+    return sqlite::utils::SetError(
+        t, "scatter_mipmap: x_max must be greater than x_min");
+  }
+  if (y_max <= y_min) {
+    return sqlite::utils::SetError(
+        t, "scatter_mipmap: y_max must be greater than y_min");
+  }
+  constexpr int64_t kMaxCells = 16'000'000;
+  if (cols < 1 || rows < 1) {
+    return sqlite::utils::SetError(
+        t, "scatter_mipmap: cols and rows must be at least 1");
+  }
+  if (cols > kMaxCells || rows > kMaxCells || cols > kMaxCells / rows) {
+    return sqlite::utils::SetError(
+        t, "scatter_mipmap: cols * rows must not exceed 16M");
+  }
+
+  ScatterMipmapIndex::CategoryFilter filter;
+  bool has_filter = false;
+  if (has_hidden_arg && sqlite3_value_type(argv[kInHidden]) != SQLITE_NULL) {
+    const char* text =
+        reinterpret_cast<const char*>(sqlite3_value_text(argv[kInHidden]));
+    for (const std::string& token :
+         base::SplitString(text != nullptr ? text : "", ",")) {
+      std::string trimmed = base::TrimWhitespace(token);
+      if (trimmed.empty()) {
+        continue;
+      }
+      std::optional<int32_t> idx = base::StringToInt32(trimmed);
+      if (!idx.has_value() || *idx < -1) {
+        return sqlite::utils::SetError(
+            t,
+            "scatter_mipmap: hidden must be a comma-separated list of "
+            "category indexes >= -1");
+      }
+      if (*idx == -1) {
+        filter.hide_null = true;
+      } else {
+        auto k = static_cast<size_t>(*idx);
+        if (k > state->index.categories().size()) {
+          // No such category (indexes never exceed top-N, i.e. "Other").
+          continue;
+        }
+        if (k >= filter.hidden.size()) {
+          filter.hidden.resize(k + 1, false);
+        }
+        filter.hidden[k] = true;
+      }
+      has_filter = true;
+    }
+  }
+
+  state->index.Query(x_min, x_max, y_min, y_max, cols, rows, c->buffers,
+                     c->results, has_filter ? &filter : nullptr);
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Next(sqlite3_vtab_cursor* cursor) {
+  GetCursor(cursor)->current_index++;
+  return SQLITE_OK;
+}
+
+int ScatterMipmapOperator::Eof(sqlite3_vtab_cursor* cursor) {
+  auto* c = GetCursor(cursor);
+  auto* t = GetVtab(c->pVtab);
+  auto* state =
+      sqlite::ModuleStateManager<ScatterMipmapOperator>::GetState(t->state);
+  if (c->is_listing_mode) {
+    return c->current_index >= state->index.categories().size();
+  }
+  return c->current_index >= c->results.size();
+}
+
+int ScatterMipmapOperator::Column(sqlite3_vtab_cursor* cursor,
+                                  sqlite3_context* ctx,
+                                  int N) {
+  auto* t = GetVtab(cursor->pVtab);
+  auto* c = GetCursor(cursor);
+  auto* state =
+      sqlite::ModuleStateManager<ScatterMipmapOperator>::GetState(t->state);
+
+  if (c->is_listing_mode) {
+    if (c->current_index >= state->index.categories().size()) {
+      sqlite::result::Null(ctx);
+      return SQLITE_OK;
+    }
+    const auto& cat = state->index.categories()[c->current_index];
+    switch (N) {
+      case ColumnIndex::kOutCatIdx:
+        sqlite::result::Long(ctx, cat.idx);
+        return SQLITE_OK;
+      case ColumnIndex::kOutCatValue:
+        if (cat.is_null) {
+          sqlite::result::Null(ctx);
+        } else if (cat.is_other) {
+          sqlite::result::StaticString(ctx, "Other");
+        } else if (state->index.categories_type() ==
+                   CategoryMapping::Type::kString) {
+          sqlite::result::TransientString(ctx, cat.str_val.c_str());
+        } else if (state->index.categories_type() ==
+                   CategoryMapping::Type::kInt) {
+          sqlite::result::Long(ctx, cat.int_val);
+        } else {
+          sqlite::result::Double(ctx, cat.double_val);
+        }
+        return SQLITE_OK;
+      case ColumnIndex::kOutCatCount:
+        sqlite::result::Long(ctx, static_cast<int64_t>(cat.count));
+        return SQLITE_OK;
+      case ColumnIndex::kId:
+        sqlite::result::Long(ctx, static_cast<int64_t>(c->current_index));
+        return SQLITE_OK;
+      default:
+        sqlite::result::Null(ctx);
+        return SQLITE_OK;
+    }
+  }
+
+  const auto& res = c->results[c->current_index];
+  uint32_t rep_idx = res.rep_index;
+
+  switch (N) {
+    case ColumnIndex::kInXMin:
+    case ColumnIndex::kInXMax:
+    case ColumnIndex::kInYMin:
+    case ColumnIndex::kInYMax:
+    case ColumnIndex::kInCols:
+    case ColumnIndex::kInRows:
+    case ColumnIndex::kInHidden:
+    case ColumnIndex::kOutCatIdx:
+    case ColumnIndex::kOutCatValue:
+    case ColumnIndex::kOutCatCount:
+      sqlite::result::Null(ctx);
+      return SQLITE_OK;
+    case ColumnIndex::kOutCount:
+      sqlite::result::Long(ctx, static_cast<int64_t>(state->index.size()));
+      return SQLITE_OK;
+    case ColumnIndex::kOutXMin:
+      sqlite::result::Double(ctx, state->index.x_min_data());
+      return SQLITE_OK;
+    case ColumnIndex::kOutXMax:
+      sqlite::result::Double(ctx, state->index.x_max_data());
+      return SQLITE_OK;
+    case ColumnIndex::kOutYMin:
+      sqlite::result::Double(ctx, state->index.y_min_data());
+      return SQLITE_OK;
+    case ColumnIndex::kOutYMax:
+      sqlite::result::Double(ctx, state->index.y_max_data());
+      return SQLITE_OK;
+    case ColumnIndex::kOutCMin:
+      if (state->index.has_c()) {
+        sqlite::result::Double(ctx, state->index.c_min_data());
+      } else {
+        sqlite::result::Null(ctx);
+      }
+      return SQLITE_OK;
+    case ColumnIndex::kOutCMax:
+      if (state->index.has_c()) {
+        sqlite::result::Double(ctx, state->index.c_max_data());
+      } else {
+        sqlite::result::Null(ctx);
+      }
+      return SQLITE_OK;
+    case ColumnIndex::kId:
+      sqlite::result::Long(ctx, state->index.id(rep_idx));
+      return SQLITE_OK;
+    case ColumnIndex::kX:
+      sqlite::result::Double(ctx, state->index.x(rep_idx));
+      return SQLITE_OK;
+    case ColumnIndex::kY:
+      sqlite::result::Double(ctx, state->index.y(rep_idx));
+      return SQLITE_OK;
+    case ColumnIndex::kC:
+      if (std::isnan(state->index.c(rep_idx))) {
+        sqlite::result::Null(ctx);
+      } else {
+        sqlite::result::Double(ctx, state->index.c(rep_idx));
+      }
+      return SQLITE_OK;
+    case ColumnIndex::kCount:
+      sqlite::result::Long(ctx, static_cast<int64_t>(res.count));
+      return SQLITE_OK;
+    default:
+      return sqlite::utils::SetError(t, "Bad column");
+  }
+  PERFETTO_FATAL("For GCC");
+}
+
+int ScatterMipmapOperator::Rowid(sqlite3_vtab_cursor*, sqlite_int64*) {
+  return SQLITE_ERROR;
+}
+
+namespace {
+
+class ScatterMipmapOperatorPlugin : public Plugin<ScatterMipmapOperatorPlugin> {
+ public:
+  ~ScatterMipmapOperatorPlugin() override;
+
+  void RegisterSqliteModules(
+      PerfettoSqlConnection* connection,
+      std::vector<SqliteModuleRegistration>& out) override {
+    out.push_back(MakeSqliteModule<ScatterMipmapOperator>(
+        "__intrinsic_scatter_mipmap",
+        std::make_unique<ScatterMipmapOperator::Context>(connection)));
+  }
+};
+
+ScatterMipmapOperatorPlugin::~ScatterMipmapOperatorPlugin() = default;
+
+}  // namespace
+
+void RegisterPlugin() {
+  static PluginRegistration reg(
+      []() -> std::unique_ptr<PluginBase> {
+        return std::make_unique<ScatterMipmapOperatorPlugin>();
+      },
+      ScatterMipmapOperatorPlugin::kPluginId,
+      ScatterMipmapOperatorPlugin::kDepIds.data(),
+      ScatterMipmapOperatorPlugin::kDepIds.size());
+  base::ignore_result(reg);
+}
+
+}  // namespace perfetto::trace_processor::scatter_mipmap_operator
diff --git a/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h
new file mode 100644
index 0000000..1e474df
--- /dev/null
+++ b/src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_OPERATOR_H_
+#define SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_OPERATOR_H_
+
+#include <sqlite3.h>
+#include <cstdint>
+#include <vector>
+
+#include "src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h"
+#include "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_index.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_module.h"
+#include "src/trace_processor/sqlite/module_state_manager.h"
+
+namespace perfetto::trace_processor::scatter_mipmap_operator {
+
+// ScatterMipmapOperator provides 2D spatial downsampling / mipmapping.
+//
+// Supported declaration forms:
+// 1. Table form:
+//    CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap(
+//        table, id, x, y [, c [, categories_top_n]]);
+//    - If `id` is '' or 'NULL', `id` defaults to the 0-based ordinal of the
+//    row.
+//    - If `table` is a dataframe or views an __intrinsic_* dataframe, fast
+//      direct streaming is used without SQL execution.
+//    - If `categories_top_n` is provided, it must be an integer N > 0. Top-N
+//      categories by frequency are assigned indices 0..N-1, other non-null
+//      values are mapped to N ("Other"), and NULL values are mapped to -1.
+// 2. Subquery form:
+//    CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap((SELECT ...));
+//    - 2 columns: (x, y). `id` = 0-based ordinal, `c` = NULL / NaN.
+//    - 3 columns: (x, y, c). `id` = 0-based ordinal.
+//    - 4 columns: (id, x, y, c). If `id` is NULL, defaults to 0-based ordinal.
+//
+// Accepted types for x and y include any numeric types (INTEGER, REAL).
+// Column c can be numeric or string/TEXT when categories_top_n is provided.
+// Rows with NULL/non-finite x or y are skipped. NULL or non-finite c is stored
+// as NaN (or -1 if categorical) and returned as NULL in query results.
+//
+// Querying:
+//    SELECT id, x, y, c, count
+//    FROM t(x_min, x_max, y_min, y_max, cols, rows [, hidden]);
+//    - Returns one representative point per non-empty cell of a cols x rows
+//      grid over the given bounds, with the exact number of points in it.
+//    - `hidden` (optional TEXT) is a comma-separated list of category indexes
+//      to exclude, e.g. '0,3,-1' (-1 = NULL c). Excluded points are not
+//      counted and never chosen as representatives.
+//    - cols = rows = 0 lists the categories:
+//      SELECT out_cat_idx, out_cat_value, out_cat_count FROM t(...,0,0).
+struct ScatterMipmapOperator : sqlite::Module<ScatterMipmapOperator> {
+  struct State {
+    ScatterMipmapIndex index;
+  };
+  struct Context : sqlite::ModuleStateManager<ScatterMipmapOperator> {
+    explicit Context(PerfettoSqlConnection* _connection)
+        : sqlite::ModuleStateManager<ScatterMipmapOperator>(
+              owned_committed_store_),
+          connection(_connection) {}
+    PerfettoSqlConnection* connection;
+
+   private:
+    sqlite::CommittedStateManager owned_committed_store_;
+  };
+  struct Vtab : sqlite::Module<ScatterMipmapOperator>::Vtab {
+    sqlite::ModuleStateManager<ScatterMipmapOperator>::PerVtabState* state;
+  };
+  struct Cursor : sqlite::Module<ScatterMipmapOperator>::Cursor {
+    std::vector<ScatterMipmapIndex::Result> results;
+    uint32_t current_index = 0;
+    ScatterMipmapIndex::QueryBuffers buffers;
+    bool is_listing_mode = false;
+  };
+
+  static constexpr auto kType = kCreateOnly;
+  static constexpr bool kSupportsWrites = false;
+  static constexpr bool kDoesOverloadFunctions = false;
+
+  static int Create(sqlite3*,
+                    void*,
+                    int,
+                    const char* const*,
+                    sqlite3_vtab**,
+                    char**);
+  static int Destroy(sqlite3_vtab*);
+
+  static int Connect(sqlite3*,
+                     void*,
+                     int,
+                     const char* const*,
+                     sqlite3_vtab**,
+                     char**);
+  static int Disconnect(sqlite3_vtab*);
+
+  static int BestIndex(sqlite3_vtab*, sqlite3_index_info*);
+
+  static int Open(sqlite3_vtab*, sqlite3_vtab_cursor**);
+  static int Close(sqlite3_vtab_cursor*);
+
+  static int Filter(sqlite3_vtab_cursor*,
+                    int,
+                    const char*,
+                    int,
+                    sqlite3_value**);
+  static int Next(sqlite3_vtab_cursor*);
+  static int Eof(sqlite3_vtab_cursor*);
+  static int Column(sqlite3_vtab_cursor*, sqlite3_context*, int);
+  static int Rowid(sqlite3_vtab_cursor*, sqlite_int64*);
+
+  static int Begin(sqlite3_vtab*) { return SQLITE_OK; }
+  static int Sync(sqlite3_vtab*) { return SQLITE_OK; }
+  static int Commit(sqlite3_vtab*) { return SQLITE_OK; }
+  static int Rollback(sqlite3_vtab*) { return SQLITE_OK; }
+  static int Savepoint(sqlite3_vtab* t, int r) {
+    ScatterMipmapOperator::Vtab* vtab = GetVtab(t);
+    sqlite::ModuleStateManager<ScatterMipmapOperator>::OnSavepoint(vtab->state,
+                                                                   r);
+    return SQLITE_OK;
+  }
+  static int Release(sqlite3_vtab* t, int r) {
+    ScatterMipmapOperator::Vtab* vtab = GetVtab(t);
+    sqlite::ModuleStateManager<ScatterMipmapOperator>::OnRelease(vtab->state,
+                                                                 r);
+    return SQLITE_OK;
+  }
+  static int RollbackTo(sqlite3_vtab* t, int r) {
+    ScatterMipmapOperator::Vtab* vtab = GetVtab(t);
+    sqlite::ModuleStateManager<ScatterMipmapOperator>::OnRollbackTo(vtab->state,
+                                                                    r);
+    return SQLITE_OK;
+  }
+
+  static constexpr sqlite3_module kModule = CreateModule();
+};
+
+void RegisterPlugin();
+
+}  // namespace perfetto::trace_processor::scatter_mipmap_operator
+
+#endif  // SRC_TRACE_PROCESSOR_PLUGINS_SCATTER_MIPMAP_OPERATOR_SCATTER_MIPMAP_OPERATOR_H_
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index d0bba8f..177806c 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -127,6 +127,7 @@
 #include "src/trace_processor/plugins/perf_text/perf_text.h"
 #include "src/trace_processor/plugins/perfetto_manifest/perfetto_manifest.h"
 #include "src/trace_processor/plugins/pprof_functions/pprof_functions.h"
+#include "src/trace_processor/plugins/scatter_mipmap_operator/scatter_mipmap_operator.h"
 #include "src/trace_processor/plugins/slice_mipmap_operator/slice_mipmap_operator.h"
 #include "src/trace_processor/plugins/span_join_operator/span_join_operator.h"
 #include "src/trace_processor/plugins/sql_stats_table/sql_stats_table.h"
@@ -380,6 +381,7 @@
   perf_text_importer::RegisterPlugin();
   perfetto_manifest::RegisterPlugin();
   pprof_functions::RegisterPlugin();
+  scatter_mipmap_operator::RegisterPlugin();
   slice_mipmap_operator::RegisterPlugin();
   span_join_operator::RegisterPlugin();
   sql_stats_table::RegisterPlugin();
diff --git a/test/trace_processor/diff_tests/include_index.py b/test/trace_processor/diff_tests/include_index.py
index ad86ec7..aa6772f 100644
--- a/test/trace_processor/diff_tests/include_index.py
+++ b/test/trace_processor/diff_tests/include_index.py
@@ -207,6 +207,7 @@
 from diff_tests.syntax.stdlib_docs_tests import StdlibDocs
 from diff_tests.syntax.structured_query_tests import StructuredQueryTests
 from diff_tests.syntax.pipeline_tests import PerfettoPipeline
+from diff_tests.syntax.scatter_mipmap_tests import PerfettoScatterMipmap
 from diff_tests.syntax.table_function_tests import PerfettoTableFunction
 from diff_tests.syntax.table_tests import PerfettoTable
 from diff_tests.syntax.view_tests import PerfettoView
@@ -409,6 +410,7 @@
       PerfettoMacro,
       PerfettoMultiStatement,
       PerfettoPipeline,
+      PerfettoScatterMipmap,
       PerfettoTable,
       PerfettoTableFunction,
       PerfettoView,
diff --git a/test/trace_processor/diff_tests/syntax/scatter_mipmap_tests.py b/test/trace_processor/diff_tests/syntax/scatter_mipmap_tests.py
new file mode 100644
index 0000000..a161e77
--- /dev/null
+++ b/test/trace_processor/diff_tests/syntax/scatter_mipmap_tests.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+# Copyright (C) 2026 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from python.generators.diff_tests.testing import Csv, TextProto
+from python.generators.diff_tests.testing import DiffTestBlueprint, ExpectedError
+from python.generators.diff_tests.testing import TestSuite
+
+
+class PerfettoScatterMipmap(TestSuite):
+
+  def test_scatter_mipmap_basic_and_stats(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 10.0 AS c
+            UNION ALL SELECT 2, 2.0, 2.0, 20.0
+            UNION ALL SELECT 3, 8.0, 8.0, NULL
+            UNION ALL SELECT 4, 9.0, 9.0, 40.0;
+
+          CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap(pts);
+
+          SELECT
+            count,
+            out_count,
+            out_x_min,
+            out_x_max,
+            out_y_min,
+            out_y_max,
+            out_c_min,
+            out_c_max
+          FROM t(0.0, 10.0, 0.0, 10.0, 2, 2);
+        """,
+        out=Csv("""
+          "count","out_count","out_x_min","out_x_max","out_y_min","out_y_max","out_c_min","out_c_max"
+          2,4,1.000000,9.000000,1.000000,9.000000,10.000000,40.000000
+          2,4,1.000000,9.000000,1.000000,9.000000,10.000000,40.000000
+        """))
+
+  def test_scatter_mipmap_omitted_id_and_null_skipping(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE VIRTUAL TABLE t_sub USING __intrinsic_scatter_mipmap((
+            SELECT NULL AS id, CAST(NULL AS REAL) AS x, 1.0 AS y, 10 AS c
+            UNION ALL SELECT NULL, 2.0, 3.0, 20
+            UNION ALL SELECT NULL, CAST(NULL AS REAL), 4.0, 30
+            UNION ALL SELECT NULL, 7.0, 8.0, NULL
+          ));
+
+          SELECT id, x, y, c, count FROM t_sub(0.0, 10.0, 0.0, 10.0, 2, 2) ORDER BY id;
+        """,
+        out=Csv("""
+          "id","x","y","c","count"
+          1,2.000000,3.000000,20.000000,1
+          3,7.000000,8.000000,"[NULL]",1
+        """))
+
+  def test_scatter_mipmap_auto_categories_listing_mode(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 'A' AS name
+            UNION ALL SELECT 2, 2.0, 2.0, 'A'
+            UNION ALL SELECT 3, 3.0, 3.0, 'A'
+            UNION ALL SELECT 4, 4.0, 4.0, 'B'
+            UNION ALL SELECT 5, 5.0, 5.0, 'B'
+            UNION ALL SELECT 6, 6.0, 6.0, 'C'
+            UNION ALL SELECT 7, 7.0, 7.0, 'D'
+            UNION ALL SELECT 8, 8.0, 8.0, CAST(NULL AS STRING);
+
+          CREATE VIRTUAL TABLE t_auto USING __intrinsic_scatter_mipmap(
+            pts, id, x, y, name, 2
+          );
+
+          SELECT out_cat_idx, out_cat_value, out_cat_count FROM t_auto(NULL, NULL, NULL, NULL, 0, 0);
+        """,
+        out=Csv("""
+          "out_cat_idx","out_cat_value","out_cat_count"
+          0,"A",3
+          1,"B",2
+          2,"Other",2
+          -1,"[NULL]",1
+        """))
+
+  def test_scatter_mipmap_slice_dataframe_fast_path(self):
+    return DiffTestBlueprint(
+        trace=TextProto("""
+          packet {
+            ftrace_events {
+              cpu: 0
+              event { timestamp: 1000 pid: 1 print { buf: "B|1|slice_A\\n" } }
+              event { timestamp: 2000 pid: 1 print { buf: "E|1\\n" } }
+              event { timestamp: 3000 pid: 1 print { buf: "B|1|slice_A\\n" } }
+              event { timestamp: 4000 pid: 1 print { buf: "E|1\\n" } }
+              event { timestamp: 5000 pid: 1 print { buf: "B|1|slice_B\\n" } }
+              event { timestamp: 6000 pid: 1 print { buf: "E|1\\n" } }
+              event { timestamp: 7000 pid: 1 print { buf: "B|1|slice_C\\n" } }
+              event { timestamp: 8000 pid: 1 print { buf: "E|1\\n" } }
+            }
+          }
+        """),
+        query="""
+          CREATE VIRTUAL TABLE t_slice USING __intrinsic_scatter_mipmap(
+            slice, id, ts, dur, name, 1
+          );
+
+          SELECT out_cat_idx, out_cat_value, out_cat_count FROM t_slice(NULL, NULL, NULL, NULL, 0, 0);
+        """,
+        out=Csv("""
+          "out_cat_idx","out_cat_value","out_cat_count"
+          0,"slice_A",2
+          1,"Other",2
+        """))
+
+  def test_scatter_mipmap_invalid_bounds(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts_err AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 1.0 AS c;
+
+          CREATE VIRTUAL TABLE t_err USING __intrinsic_scatter_mipmap(pts_err);
+
+          SELECT * FROM t_err(10.0, 5.0, 0.0, 10.0, 2, 2);
+        """,
+        out=ExpectedError("scatter_mipmap: x_max must be greater than x_min\n"))
+
+  def test_scatter_mipmap_hidden_categories_small(self):
+    # Cell (0,0) mixes A, B and NULL; cell (1,1) only has B.
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 'A' AS name
+            UNION ALL SELECT 2, 2.0, 2.0, 'A'
+            UNION ALL SELECT 3, 3.0, 3.0, 'A'
+            UNION ALL SELECT 4, 4.0, 4.0, 'B'
+            UNION ALL SELECT 5, 4.5, 4.5, NULL
+            UNION ALL SELECT 6, 8.0, 8.0, 'B';
+
+          CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap(
+            pts, id, x, y, name, 2);
+
+          SELECT 'none' AS hidden, id, c, count
+          FROM t(0.0, 10.0, 0.0, 10.0, 2, 2)
+          UNION ALL
+          SELECT 'A', id, c, count FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, '0')
+          UNION ALL
+          SELECT 'B,NULL', id, c, count
+          FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, ' 1, -1 ')
+          UNION ALL
+          SELECT 'all', id, c, count
+          FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, '0,1,-1')
+          UNION ALL
+          SELECT 'empty', id, c, count FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, '')
+          UNION ALL
+          SELECT 'unknown', id, c, count
+          FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, '99');
+        """,
+        out=Csv("""
+          "hidden","id","c","count"
+          "none",1,0.000000,5
+          "none",6,1.000000,1
+          "A",4,1.000000,2
+          "A",6,1.000000,1
+          "B,NULL",1,0.000000,3
+          "empty",1,0.000000,5
+          "empty",6,1.000000,1
+          "unknown",1,0.000000,5
+          "unknown",6,1.000000,1
+        """))
+
+  def test_scatter_mipmap_hidden_categories_brute_force(self):
+    # 20k points with 7 string categories (+ NULLs) reduced to top-3 + Other.
+    # Filtered per-cell counts must match a brute force GROUP BY and no
+    # representative may belong to a hidden category.
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts AS
+          WITH RECURSIVE r(i) AS (
+            SELECT 0 UNION ALL SELECT i + 1 FROM r WHERE i < 19999
+          )
+          SELECT
+            i AS id,
+            (i * 7919) % 1000 AS x,
+            (i * 104729) % 997 AS y,
+            IIF(i % 11 = 0, NULL, 'k' || ((i * i) % 7)) AS name
+          FROM r;
+
+          CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap(
+            pts, id, x, y, name, 3);
+
+          CREATE PERFETTO TABLE pt AS
+          WITH cats AS (
+            SELECT out_cat_idx AS idx, out_cat_value AS v
+            FROM t(0, 0, 0, 0, 0, 0)
+          )
+          SELECT
+            p.id,
+            MIN(36, CAST(p.x * 37 / 1000.0 AS INT)) AS cx,
+            MIN(22, CAST(p.y * 23 / 1000.0 AS INT)) AS cy,
+            IFNULL((SELECT idx FROM cats WHERE v = p.name),
+                   IIF(p.name IS NULL, -1, 3)) AS idx
+          FROM pts p;
+
+          CREATE PERFETTO TABLE bf AS
+          SELECT cx, cy, COUNT(*) AS n
+          FROM pt
+          WHERE idx NOT IN (0, 3, -1)
+          GROUP BY cx, cy;
+
+          CREATE PERFETTO TABLE op AS
+          SELECT
+            id,
+            MIN(36, CAST(x * 37 / 1000.0 AS INT)) AS cx,
+            MIN(22, CAST(y * 23 / 1000.0 AS INT)) AS cy,
+            count AS n
+          FROM t(0, 1000, 0, 1000, 37, 23, '0,3,-1');
+
+          SELECT
+            (SELECT COUNT(*) FROM op) AS op_cells,
+            (SELECT COUNT(*) FROM bf) AS bf_cells,
+            (SELECT SUM(n) FROM op) AS op_total,
+            (SELECT SUM(n) FROM bf) AS bf_total,
+            (SELECT COUNT(*) FROM bf LEFT JOIN op USING (cx, cy)
+             WHERE op.n IS NULL OR op.n != bf.n)
+            + (SELECT COUNT(*) FROM op LEFT JOIN bf USING (cx, cy)
+               WHERE bf.n IS NULL) AS mismatched_cells,
+            (SELECT COUNT(*) FROM op JOIN pt USING (id)
+             WHERE pt.idx IN (0, 3, -1)) AS hidden_reps;
+        """,
+        out=Csv("""
+          "op_cells","bf_cells","op_total","bf_total","mismatched_cells","hidden_reps"
+          851,851,10388,10388,0,0
+        """))
+
+  def test_scatter_mipmap_hidden_null_numeric(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 10.0 AS c
+            UNION ALL SELECT 2, 2.0, 2.0, NULL
+            UNION ALL SELECT 3, 8.0, 8.0, NULL;
+
+          CREATE VIRTUAL TABLE t USING __intrinsic_scatter_mipmap(pts);
+
+          SELECT id, c, count FROM t(0.0, 10.0, 0.0, 10.0, 2, 2, '-1');
+        """,
+        out=Csv("""
+          "id","c","count"
+          1,10.000000,1
+        """))
+
+  def test_scatter_mipmap_hidden_invalid(self):
+    return DiffTestBlueprint(
+        trace=TextProto(""),
+        query="""
+          CREATE PERFETTO TABLE pts_err AS
+            SELECT 1 AS id, 1.0 AS x, 1.0 AS y, 1.0 AS c;
+
+          CREATE VIRTUAL TABLE t_err USING __intrinsic_scatter_mipmap(pts_err);
+
+          SELECT * FROM t_err(0.0, 10.0, 0.0, 10.0, 2, 2, '1,x');
+        """,
+        out=ExpectedError(
+            "scatter_mipmap: hidden must be a comma-separated list of "
+            "category indexes >= -1\n"))
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/axis.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/axis.ts
new file mode 100644
index 0000000..3018824
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/axis.ts
@@ -0,0 +1,211 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {clamp} from '../../base/math_utils';
+
+const SI_PREFIXES: ReadonlyArray<readonly [number, string]> = [
+  [1e12, 'T'],
+  [1e9, 'G'],
+  [1e6, 'M'],
+  [1e3, 'K'],
+  [1, ''],
+  [1e-3, 'm'],
+  [1e-6, 'µ'],
+  [1e-9, 'n'],
+  [1e-12, 'p'],
+];
+
+/**
+ * Computes nice tick marks within [min, max] using 1/2/5 x 10^k steps.
+ */
+export function niceTicks(
+  min: number,
+  max: number,
+  targetCount: number,
+): number[] {
+  if (!Number.isFinite(min) || !Number.isFinite(max) || targetCount <= 0) {
+    return [];
+  }
+  const lo = Math.min(min, max);
+  const hi = Math.max(min, max);
+  const span = hi - lo;
+  if (span <= 0) return [lo];
+
+  const target = clamp(Math.round(targetCount), 1, 100);
+  const rawStep = span / target;
+  if (!Number.isFinite(rawStep) || rawStep <= 0) return [lo];
+
+  const exp = Math.floor(Math.log10(rawStep));
+  const frac = rawStep / Math.pow(10, exp);
+  const mult = frac > 7.5 ? 10 : frac > 3.5 ? 5 : frac > 1.5 ? 2 : 1;
+  const step = mult * Math.pow(10, exp);
+  if (!Number.isFinite(step) || step <= 0 || lo + step === lo) return [lo];
+
+  const rLo = lo / step;
+  const kMin = Math.ceil(
+    rLo - Math.max(1, Math.abs(rLo)) * Number.EPSILON * 64,
+  );
+  const rHi = hi / step;
+  const kMax = Math.floor(
+    rHi + Math.max(1, Math.abs(rHi)) * Number.EPSILON * 64,
+  );
+  if (kMin > kMax) return [];
+
+  const count = Math.min(kMax - kMin + 1, Math.max(100, target * 4));
+  const ticks: number[] = [];
+  const stepDecimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
+  const epsLo = Math.max(1, Math.abs(lo)) * Number.EPSILON * 64;
+  const epsHi = Math.max(1, Math.abs(hi)) * Number.EPSILON * 64;
+
+  for (let i = 0; i < count; i++) {
+    let val = (kMin + i) * step;
+    if (stepDecimals > 0 && stepDecimals <= 14) {
+      val = Number(val.toFixed(stepDecimals));
+    }
+    if (val === 0) val = 0;
+    if (val < lo) {
+      if (Math.abs(val - lo) <= epsLo) val = lo;
+      else continue;
+    }
+    if (val > hi) {
+      if (Math.abs(val - hi) <= epsHi) val = hi;
+      else continue;
+    }
+    ticks.push(val);
+  }
+  return ticks;
+}
+
+/**
+ * Formats a single tick value compactly given the tick step size.
+ */
+export function formatTick(value: number, step: number): string {
+  if (!Number.isFinite(value)) return String(value);
+  if (value === 0) return '0';
+
+  const absVal = Math.abs(value);
+  let chosenMultiplier = 1;
+  let chosenPrefix = '';
+  for (const [multiplier, prefix] of SI_PREFIXES) {
+    if (absVal >= multiplier * 0.9999) {
+      chosenMultiplier = multiplier;
+      chosenPrefix = prefix;
+      break;
+    }
+  }
+
+  const scaledStep = step / chosenMultiplier;
+  if (scaledStep >= 0.00099) {
+    const decimals = clamp(-Math.floor(Math.log10(scaledStep) + 1e-9), 0, 6);
+    const s = (value / chosenMultiplier).toFixed(decimals);
+    const formatted = decimals > 0 ? parseFloat(s).toString() : s;
+    return chosenPrefix.length > 0 ? `${formatted} ${chosenPrefix}` : formatted;
+  }
+
+  const stepDecimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
+  return stepDecimals === 0
+    ? Math.round(value).toString()
+    : value.toFixed(Math.min(20, stepDecimals));
+}
+
+/**
+ * Formats a value for detailed inspection / tooltips with full precision.
+ */
+export function formatValue(v: number): string {
+  if (!Number.isFinite(v)) return String(v);
+  if (v === 0) return '0';
+  if (Number.isInteger(v)) return v.toLocaleString('en-US');
+
+  const s = String(v);
+  if (s.includes('e') || s.includes('E')) return s;
+  const parts = s.split('.');
+  return parts.length === 2
+    ? `${parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${parts[1]}`
+    : s;
+}
+
+export interface AxisTicks {
+  readonly ticks: number[];
+  readonly step: number;
+  readonly offset: number;
+  readonly labels: string[];
+}
+
+export function computeAxisTicks(
+  min: number,
+  max: number,
+  targetCount: number,
+): AxisTicks {
+  if (!Number.isFinite(min) || !Number.isFinite(max) || targetCount <= 0) {
+    return {ticks: [], step: 0, offset: 0, labels: []};
+  }
+  const lo = Math.min(min, max);
+  const hi = Math.max(min, max);
+  const span = hi - lo;
+  const ticks = niceTicks(lo, hi, targetCount);
+  if (ticks.length === 0) {
+    return {ticks: [], step: span > 0 ? span : 1, offset: 0, labels: []};
+  }
+
+  let step = ticks.length >= 2 ? ticks[1] - ticks[0] : span > 0 ? span : 1;
+  const stepDecimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
+  if (stepDecimals > 0 && stepDecimals <= 14) {
+    step = Number(step.toFixed(stepDecimals));
+  }
+
+  const maxAbs = Math.max(Math.abs(lo), Math.abs(hi));
+  let offset = 0;
+  if (span > 0 && maxAbs / step >= 1e4) {
+    const P = Math.pow(10, Math.ceil(Math.log10(span)));
+    offset = Math.floor(lo / P) * P;
+  }
+
+  const labels = ticks.map((t) => {
+    if (offset === 0) return formatTick(t, step);
+    let relVal = t - offset;
+    if (stepDecimals > 0 && stepDecimals <= 14) {
+      relVal = Number(relVal.toFixed(stepDecimals));
+    }
+    return formatTick(relVal === 0 ? 0 : relVal, step);
+  });
+
+  return {ticks, step, offset, labels};
+}
+
+export function formatAxisOffset(offset: number): string {
+  return formatValue(offset);
+}
+
+export function fitTickCount(
+  min: number,
+  max: number,
+  maxTargetCount: number,
+  maxLabelPx: (labels: string[]) => number,
+  availablePx: number,
+  minGapPx = 8,
+): AxisTicks {
+  let target = Math.max(2, Math.round(maxTargetCount));
+  let result = computeAxisTicks(min, max, target);
+  while (target > 2) {
+    if (
+      result.ticks.length * (maxLabelPx(result.labels) + minGapPx) <=
+      availablePx
+    ) {
+      return result;
+    }
+    target--;
+    result = computeAxisTicks(min, max, target);
+  }
+  return result;
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/color_scale.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/color_scale.ts
new file mode 100644
index 0000000..4d64777
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/color_scale.ts
@@ -0,0 +1,286 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {hslToRgb, rgbToHsl} from '../../base/color';
+import {clamp} from '../../base/math_utils';
+import {getChartThemeColors} from '../../components/widgets/charts/chart_theme';
+import {
+  type ColorEncoding,
+  type ColorMode,
+  NULL_CATEGORY,
+  NUMERIC_LUT_SIZE,
+  TOP_N_CATEGORIES,
+} from './types';
+
+export interface ScatterTheme {
+  readonly palette: ReadonlyArray<Uint8Array>;
+  readonly uniform: Uint8Array;
+  readonly nullColor: Uint8Array;
+  readonly background: Uint8Array;
+  readonly text: string;
+  readonly grid: string;
+}
+
+export interface LegendEntry {
+  readonly label: string;
+  readonly css: string;
+  readonly index: number;
+  // Number of rows (full dataset, not just the viewport) in this entry.
+  readonly count: number;
+}
+
+const DEFAULT_PALETTE_HEX = [
+  '#4285f4',
+  '#ea4335',
+  '#fbbc04',
+  '#34a853',
+  '#ff6d01',
+  '#46bdc6',
+  '#9334e6',
+  '#185abc',
+];
+
+export function parseCssColor(
+  input: string,
+  fallback: Uint8Array = new Uint8Array([0, 0, 0, 255]),
+): Uint8Array {
+  const str = input.trim();
+  if (str.startsWith('#')) {
+    const hex = str.slice(1);
+    if (hex.length === 3 || hex.length === 4) {
+      const r = parseInt(hex[0] + hex[0], 16);
+      const g = parseInt(hex[1] + hex[1], 16);
+      const b = parseInt(hex[2] + hex[2], 16);
+      const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) : 255;
+      if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
+        return new Uint8Array([r, g, b, a]);
+      }
+    } else if (hex.length === 6 || hex.length === 8) {
+      const r = parseInt(hex.slice(0, 2), 16);
+      const g = parseInt(hex.slice(2, 4), 16);
+      const b = parseInt(hex.slice(4, 6), 16);
+      const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
+      if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {
+        return new Uint8Array([r, g, b, a]);
+      }
+    }
+  }
+
+  const rgbMatch = str.match(
+    /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
+  );
+  if (rgbMatch !== null) {
+    const r = clamp(Math.round(parseFloat(rgbMatch[1])), 0, 255);
+    const g = clamp(Math.round(parseFloat(rgbMatch[2])), 0, 255);
+    const b = clamp(Math.round(parseFloat(rgbMatch[3])), 0, 255);
+    const a =
+      rgbMatch[4] !== undefined
+        ? clamp(Math.round(parseFloat(rgbMatch[4]) * 255), 0, 255)
+        : 255;
+    return new Uint8Array([r, g, b, a]);
+  }
+
+  const lower = str.toLowerCase();
+  if (lower === 'white') return new Uint8Array([255, 255, 255, 255]);
+  if (lower === 'black') return new Uint8Array([0, 0, 0, 255]);
+  if (lower === 'transparent') return new Uint8Array([0, 0, 0, 0]);
+  if (lower === 'gray' || lower === 'grey') {
+    return new Uint8Array([128, 128, 128, 255]);
+  }
+  return new Uint8Array(fallback);
+}
+
+export function readScatterTheme(el: Element): ScatterTheme {
+  let chartTheme: ReturnType<typeof getChartThemeColors> | undefined;
+  let neutralCss = '';
+  try {
+    if (
+      typeof window !== 'undefined' &&
+      typeof window.getComputedStyle === 'function'
+    ) {
+      chartTheme = getChartThemeColors(el);
+      const style = window.getComputedStyle(el);
+      neutralCss =
+        style.getPropertyValue('--pf-chart-color-neutral').trim() ||
+        style.getPropertyValue('--pf-color-text-hint').trim();
+    }
+  } catch {
+    // Fallback defaults used in non-DOM environments.
+  }
+
+  const palette = DEFAULT_PALETTE_HEX.map((hex, i) => {
+    const def = parseCssColor(hex);
+    const cssVal = chartTheme?.chartColors[i] ?? '';
+    return cssVal.length > 0 ? parseCssColor(cssVal, def) : def;
+  });
+
+  const resolve = (val: string | undefined, hexFallback: string) => {
+    const def = parseCssColor(hexFallback);
+    return val && val.length > 0 ? parseCssColor(val, def) : def;
+  };
+
+  return {
+    palette,
+    uniform: resolve(chartTheme?.accentColor, '#2667e7'),
+    nullColor: resolve(neutralCss, '#9e9e9e'),
+    background: resolve(chartTheme?.backgroundColor, '#ffffff'),
+    text: chartTheme?.textColor || '#333333',
+    grid: chartTheme?.borderColor || '#cccccc',
+  };
+}
+
+const VIRIDIS_CONTROL_POINTS: ReadonlyArray<[number, number, number, number]> =
+  [
+    [0.0, 68, 1, 84],
+    [0.125, 72, 35, 116],
+    [0.25, 64, 67, 135],
+    [0.375, 52, 94, 141],
+    [0.5, 42, 120, 142],
+    [0.625, 33, 145, 140],
+    [0.75, 53, 183, 121],
+    [0.875, 144, 215, 67],
+    [1.0, 253, 231, 37],
+  ];
+
+export function createViridisLut(): Uint8Array {
+  const lut = new Uint8Array(NUMERIC_LUT_SIZE * 4);
+  const nPoints = VIRIDIS_CONTROL_POINTS.length;
+  for (let i = 0; i < NUMERIC_LUT_SIZE; i++) {
+    const t = i / (NUMERIC_LUT_SIZE - 1);
+    let idx = 0;
+    while (idx < nPoints - 2 && VIRIDIS_CONTROL_POINTS[idx + 1][0] < t) idx++;
+    const p0 = VIRIDIS_CONTROL_POINTS[idx];
+    const p1 = VIRIDIS_CONTROL_POINTS[idx + 1];
+    const f = p1[0] > p0[0] ? (t - p0[0]) / (p1[0] - p0[0]) : 0;
+    lut[i * 4 + 0] = clamp(Math.round(p0[1] + f * (p1[1] - p0[1])), 0, 255);
+    lut[i * 4 + 1] = clamp(Math.round(p0[2] + f * (p1[2] - p0[2])), 0, 255);
+    lut[i * 4 + 2] = clamp(Math.round(p0[3] + f * (p1[3] - p0[3])), 0, 255);
+    lut[i * 4 + 3] = 255;
+  }
+  return lut;
+}
+
+let cachedViridisLut: Uint8Array | undefined;
+function getCachedViridisLut(): Uint8Array {
+  if (cachedViridisLut === undefined) cachedViridisLut = createViridisLut();
+  return cachedViridisLut;
+}
+
+export function getCategoryColor(
+  index: number,
+  theme: ScatterTheme,
+): Uint8Array {
+  if (index >= TOP_N_CATEGORIES) return new Uint8Array([158, 158, 158, 255]);
+  const palette = theme.palette;
+  const baseCount = palette.length;
+  if (index < baseCount) return new Uint8Array(palette[index]);
+
+  const base = palette[index % baseCount];
+  const [h, s, l] = rgbToHsl([base[0], base[1], base[2]]);
+  if (index < baseCount * 2) {
+    const newL = clamp(l > 50 ? l - 15 : l + 20, 25, 85);
+    const [r, g, b] = hslToRgb((h + 35) % 360, s, newL);
+    return new Uint8Array([r, g, b, 255]);
+  }
+  const newL = clamp(l + 10, 30, 80);
+  const [r, g, b] = hslToRgb((h + 70) % 360, s, newL);
+  return new Uint8Array([r, g, b, 255]);
+}
+
+export function buildColorEncoding(
+  mode: ColorMode,
+  theme: ScatterTheme,
+  hidden: ReadonlySet<number>,
+): ColorEncoding {
+  const nullColor = new Uint8Array(theme.nullColor);
+  if (hidden.has(NULL_CATEGORY)) nullColor[3] = 0;
+
+  if (mode.kind === 'none') {
+    return {
+      kind: 'uniform',
+      lut: new Uint8Array(theme.uniform),
+      nullColor,
+      min: 0,
+      max: 0,
+    };
+  }
+  if (mode.kind === 'numeric') {
+    return {
+      kind: 'numeric',
+      lut: new Uint8Array(getCachedViridisLut()),
+      nullColor,
+      min: mode.min,
+      max: mode.max,
+    };
+  }
+
+  const catCount = mode.categories.length + (mode.hasOther ? 1 : 0);
+  const lut = new Uint8Array(catCount * 4);
+  for (let i = 0; i < catCount; i++) {
+    const isOther = mode.hasOther && i === mode.categories.length;
+    const color = getCategoryColor(isOther ? TOP_N_CATEGORIES : i, theme);
+    lut[i * 4 + 0] = color[0];
+    lut[i * 4 + 1] = color[1];
+    lut[i * 4 + 2] = color[2];
+    lut[i * 4 + 3] = hidden.has(i) ? 0 : color[3];
+  }
+  return {kind: 'categorical', lut, nullColor, min: 0, max: 0};
+}
+
+export function legendEntries(
+  mode: ColorMode,
+  encoding: ColorEncoding,
+): LegendEntry[] {
+  if (mode.kind !== 'categorical') return [];
+  const lutCss = (i: number) =>
+    `rgb(${encoding.lut[i * 4]}, ${encoding.lut[i * 4 + 1]}, ${encoding.lut[i * 4 + 2]})`;
+  // Real categories sorted by count desc (ties keep colour order). The "Other"
+  // and NULL buckets aren't categories, so they stay pinned at the bottom.
+  const entries: LegendEntry[] = mode.categories
+    .map((label, i) => ({
+      label,
+      css: lutCss(i),
+      index: i,
+      count: mode.counts[i] ?? 0,
+    }))
+    .sort((a, b) => b.count - a.count || a.index - b.index);
+  if (mode.hasOther) {
+    const idx = mode.categories.length;
+    entries.push({
+      label: 'Other',
+      css: lutCss(idx),
+      index: idx,
+      count: mode.otherCount,
+    });
+  }
+  if (mode.nullCount > 0) {
+    const [nr, ng, nb] = encoding.nullColor;
+    entries.push({
+      label: 'NULL',
+      css: `rgb(${nr}, ${ng}, ${nb})`,
+      index: NULL_CATEGORY,
+      count: mode.nullCount,
+    });
+  }
+  return entries;
+}
+
+export function gradientCss(): string {
+  const lut = getCachedViridisLut();
+  const stops = [0, 64, 128, 192, 255].map((idx) => {
+    const pct = Math.round((idx / 255) * 100);
+    return `rgb(${lut[idx * 4]}, ${lut[idx * 4 + 1]}, ${lut[idx * 4 + 2]}) ${pct}%`;
+  });
+  return `linear-gradient(to right, ${stops.join(', ')})`;
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/index.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/index.ts
new file mode 100644
index 0000000..c672b63
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/index.ts
@@ -0,0 +1,82 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './styles.scss';
+import m from 'mithril';
+import type {PerfettoPlugin} from '../../public/plugin';
+import type {Trace} from '../../public/trace';
+import SqlModulesPlugin from '../dev.perfetto.SqlModules';
+import {ScatterController} from './scatter_controller';
+import {ScatterPage} from './scatter_page';
+import {openCustomQueryModal} from './source_picker';
+
+let controllerUidCounter = 0;
+
+export default class ScatterPlotPlugin implements PerfettoPlugin {
+  static readonly id = 'dev.perfetto.ScatterPlot';
+  static readonly description =
+    'Interactive, GPU-accelerated scatter plot for Perfetto tables and SQL queries.';
+  static readonly dependencies = [SqlModulesPlugin];
+
+  private controller?: ScatterController;
+
+  async onTraceLoad(trace: Trace): Promise<void> {
+    const uid = `scatter_${++controllerUidCounter}`;
+    const controller = new ScatterController({
+      engine: trace.engine,
+      uid,
+      onChange: () => trace.raf.scheduleFullRedraw(),
+    });
+    this.controller = controller;
+
+    trace.trash.defer(() => {
+      void controller[Symbol.asyncDispose]();
+      if (this.controller === controller) {
+        this.controller = undefined;
+      }
+    });
+
+    trace.pages.registerPage({
+      route: '/scatterplot',
+      render: () => m(ScatterPage, {trace, controller}),
+    });
+
+    trace.sidebar.addMenuItem({
+      section: 'current_trace',
+      text: 'Scatter plot',
+      icon: 'scatter_plot',
+      href: '#!/scatterplot',
+      sortOrder: 22,
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.ScatterPlot#Open',
+      name: 'Scatter plot: Open',
+      callback: () => {
+        trace.navigate('#!/scatterplot');
+      },
+    });
+
+    trace.commands.registerCommand({
+      id: 'dev.perfetto.ScatterPlot#PlotQuery',
+      name: 'Scatter plot: Plot SQL query',
+      callback: () => {
+        openCustomQueryModal('', (spec) => {
+          trace.navigate('#!/scatterplot');
+          void controller.setSource(spec);
+        });
+      },
+    });
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/point_set.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/point_set.ts
new file mode 100644
index 0000000..4c5ecf5
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/point_set.ts
@@ -0,0 +1,83 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type {DataBounds, PointColumns, PointSet} from './types';
+
+let nextPointSetId = 1;
+
+export interface NearestResult {
+  readonly index: number;
+  readonly distPx: number;
+}
+
+/**
+ * Wraps 2D LOD operator viewport columns into an immutable PointSet.
+ */
+export function buildOperatorPointSet(
+  cols: PointColumns,
+  bounds: DataBounds,
+): PointSet {
+  return {
+    id: nextPointSetId++,
+    count: cols.x.length,
+    bounds,
+    x: cols.x,
+    y: cols.y,
+    row: cols.row,
+    color: cols.color,
+    weight: cols.weight,
+  };
+}
+
+/**
+ * Finds the nearest point to a cursor position within a pixel radius.
+ * Ties prefer larger indices (drawn later / on top). Points for which `skip`
+ * returns true (e.g. hidden categories) are ignored.
+ */
+export function nearest(
+  set: PointSet,
+  cursorX: number,
+  cursorY: number,
+  scaleX: number,
+  scaleY: number,
+  radiusPx: number,
+  skip?: (index: number) => boolean,
+): NearestResult | undefined {
+  if (radiusPx < 0 || scaleX <= 0 || scaleY <= 0 || set.count === 0) {
+    return undefined;
+  }
+
+  const maxDistSq = radiusPx * radiusPx;
+  let bestDistSq = maxDistSq;
+  let bestIdx = -1;
+  const {x, y, count} = set;
+
+  for (let i = 0; i < count; i++) {
+    const dPx = (x[i] - cursorX) * scaleX;
+    const dPy = (y[i] - cursorY) * scaleY;
+    const distSq = dPx * dPx + dPy * dPy;
+    if (
+      distSq <= maxDistSq &&
+      (distSq < bestDistSq || (distSq === bestDistSq && i > bestIdx)) &&
+      (skip === undefined || !skip(i))
+    ) {
+      bestDistSq = distSq;
+      bestIdx = i;
+    }
+  }
+
+  return bestIdx === -1
+    ? undefined
+    : {index: bestIdx, distPx: Math.sqrt(bestDistSq)};
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_controller.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_controller.ts
new file mode 100644
index 0000000..9fbd134
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_controller.ts
@@ -0,0 +1,940 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {clamp} from '../../base/math_utils';
+import type {Engine} from '../../trace_processor/engine';
+import type {SqlValue} from '../../trace_processor/query_result';
+import {buildOperatorPointSet, nearest} from './point_set';
+import {
+  ScatterSource,
+  type PreparedSource,
+  type ViewportRequest,
+} from './scatter_source';
+import type {
+  ColorMode,
+  ColumnInfo,
+  DataBounds,
+  PlotSpec,
+  PointColumns,
+  PointSet,
+  SourceSpec,
+  ViewRange,
+} from './types';
+import {NULL_CATEGORY} from './types';
+import {clampView, fitView, viewsEqual} from './view';
+
+export type LoadState =
+  | {readonly kind: 'empty'}
+  | {readonly kind: 'describing'}
+  | {readonly kind: 'needs_axes'}
+  | {readonly kind: 'preparing'}
+  | {readonly kind: 'loading'; readonly total?: number}
+  | {readonly kind: 'ready'}
+  | {readonly kind: 'error'; readonly message: string};
+
+export interface PointRef {
+  readonly points: PointSet;
+  readonly index: number;
+}
+
+export interface RowDetails {
+  readonly row: number;
+  readonly cells: ReadonlyArray<{
+    readonly name: string;
+    readonly value: SqlValue;
+  }>;
+}
+
+export interface BrushState {
+  readonly rect: DataBounds;
+  readonly count: number | undefined;
+}
+
+export interface ScatterSourceLike extends AsyncDisposable {
+  describe(spec: SourceSpec): Promise<ReadonlyArray<ColumnInfo>>;
+  prepare(spec: SourceSpec, plot: PlotSpec): Promise<PreparedSource>;
+  dispose(): Promise<void>;
+}
+
+export interface ScatterControllerOptions {
+  readonly engine: Engine;
+  readonly uid: string;
+  readonly onChange: () => void;
+  readonly viewportDebounceMs?: number;
+  readonly sourceFactory?: (engine: Engine, uid: string) => ScatterSourceLike;
+}
+
+const DEFAULT_VIEWPORT_DEBOUNCE_MS = 20;
+const HOVER_DEBOUNCE_MS = 150;
+
+export interface InternalViewportRequest extends ViewportRequest {
+  readonly cellX: number;
+  readonly cellY: number;
+  readonly lx: number;
+  readonly ly: number;
+}
+
+export interface AlignedAxis {
+  readonly min: number;
+  readonly max: number;
+  readonly count: number;
+  readonly cell: number;
+  readonly l: number;
+}
+
+export function computeAlignedAxis(
+  v0: number,
+  v1: number,
+  dMin: number,
+  dMax: number,
+  plotPx: number,
+  pointSizePx: number,
+): AlignedAxis {
+  const span = dMax - dMin;
+  if (span <= 0 || !Number.isFinite(span)) {
+    return {min: dMin - 0.5, max: dMax + 0.5, count: 1, cell: 1, l: 0};
+  }
+
+  const viewSpan = Math.max(1e-9, v1 - v0);
+  const desiredCellData =
+    (viewSpan / Math.max(1, plotPx)) * Math.max(1, pointSizePx);
+  const rawL = Math.round(Math.log2(span / desiredCellData));
+  const l = clamp(Number.isFinite(rawL) ? rawL : 0, 0, 20);
+  const numCells = Math.pow(2, l);
+  const cell = span / numCells;
+  const pad = viewSpan * 0.15;
+
+  let kMin = Math.floor((v0 - pad - dMin) / cell);
+  let kMax = Math.ceil((v1 + pad - dMin) / cell);
+  kMin = clamp(kMin, 0, numCells - 1);
+  kMax = clamp(kMax, kMin + 1, numCells);
+
+  return {
+    min: dMin + kMin * cell,
+    max: dMin + kMax * cell,
+    count: kMax - kMin,
+    cell,
+    l,
+  };
+}
+
+export function computeViewportRequest(
+  view: ViewRange,
+  bounds: DataBounds,
+  plotW: number,
+  plotH: number,
+  pointSize: number,
+): InternalViewportRequest {
+  const ax = computeAlignedAxis(
+    view.x0,
+    view.x1,
+    bounds.xMin,
+    bounds.xMax,
+    plotW,
+    pointSize,
+  );
+  const ay = computeAlignedAxis(
+    view.y0,
+    view.y1,
+    bounds.yMin,
+    bounds.yMax,
+    plotH,
+    pointSize,
+  );
+  return {
+    xMin: ax.min,
+    xMax: ax.max,
+    yMin: ay.min,
+    yMax: ay.max,
+    cols: ax.count,
+    rows: ay.count,
+    cellX: ax.cell,
+    cellY: ay.cell,
+    lx: ax.l,
+    ly: ay.l,
+  };
+}
+
+export function shouldRequery(
+  view: ViewRange,
+  bounds: DataBounds,
+  needed: {readonly lx: number; readonly ly: number},
+  lastFetched?: {
+    readonly xMin: number;
+    readonly xMax: number;
+    readonly yMin: number;
+    readonly yMax: number;
+    readonly lx: number;
+    readonly ly: number;
+  },
+): boolean {
+  if (lastFetched === undefined) return true;
+  if (needed.lx !== lastFetched.lx || needed.ly !== lastFetched.ly) return true;
+
+  const x0 = Math.max(view.x0, bounds.xMin);
+  const x1 = Math.min(view.x1, bounds.xMax);
+  const y0 = Math.max(view.y0, bounds.yMin);
+  const y1 = Math.min(view.y1, bounds.yMax);
+
+  if (x0 <= x1 && (x0 < lastFetched.xMin || x1 > lastFetched.xMax)) return true;
+  if (y0 <= y1 && (y0 < lastFetched.yMin || y1 > lastFetched.yMax)) return true;
+  return false;
+}
+
+export class ScatterController implements AsyncDisposable {
+  private readonly viewportDebounceMs: number;
+  private readonly sourceInstance: ScatterSourceLike;
+
+  private disposed = false;
+  private generation = 0;
+  private loadAbortController?: AbortController;
+
+  private loadState: LoadState = {kind: 'empty'};
+  private currentSource?: SourceSpec;
+  private columnsList: ReadonlyArray<ColumnInfo> = [];
+  private currentPlot?: PlotSpec;
+  private totalRowCount?: number;
+  private currentDataBounds?: DataBounds;
+  private currentColorMode: ColorMode = {kind: 'none'};
+  private readonly hiddenCats = new Set<number>();
+  private currentView?: ViewRange;
+
+  private plotWidthPx = 800;
+  private plotHeightPx = 600;
+  private pointSizePx = 3;
+
+  private lastFetchedRegion?: InternalViewportRequest;
+  // hiddenKey() of the categories excluded from the last fetched viewport.
+  private lastFetchedHiddenKey = '';
+  private viewportToken = 0;
+  private viewportTimer?: ReturnType<typeof setTimeout>;
+
+  private updating = false;
+  private queryDurationMs?: number;
+  private resultRowCount?: number;
+
+  private baseSet?: PointSet;
+  private preparedSource?: PreparedSource;
+
+  private hoverPoint?: PointRef;
+  private hoverDetailsData?: RowDetails;
+  private hoverTimer?: ReturnType<typeof setTimeout>;
+  private hoverToken = 0;
+
+  private pinnedPoint?: PointRef;
+  private pinnedDetailsData?: RowDetails;
+  private pinToken = 0;
+
+  private brushState?: BrushState;
+  private brushToken = 0;
+
+  private updatingTimer?: ReturnType<typeof setTimeout>;
+  private lastViewEmitMs = 0;
+  private viewEmitTimer?: ReturnType<typeof setTimeout>;
+
+  constructor(private readonly opts: ScatterControllerOptions) {
+    this.viewportDebounceMs =
+      opts.viewportDebounceMs ?? DEFAULT_VIEWPORT_DEBOUNCE_MS;
+    const factory =
+      opts.sourceFactory ??
+      ((engine: Engine, uid: string) => new ScatterSource(engine, uid));
+    this.sourceInstance = factory(opts.engine, opts.uid);
+  }
+
+  get state(): LoadState {
+    return this.loadState;
+  }
+  get source(): SourceSpec | undefined {
+    return this.currentSource;
+  }
+  get columns(): ReadonlyArray<ColumnInfo> {
+    return this.columnsList;
+  }
+  get plot(): PlotSpec | undefined {
+    return this.currentPlot;
+  }
+  get rowCount(): number | undefined {
+    return this.totalRowCount;
+  }
+  get dataBounds(): DataBounds | undefined {
+    return this.currentDataBounds;
+  }
+  get colorMode(): ColorMode {
+    return this.currentColorMode;
+  }
+  get hiddenCategories(): ReadonlySet<number> {
+    return this.hiddenCats;
+  }
+  get view(): ViewRange | undefined {
+    return this.currentView;
+  }
+  get hover(): PointRef | undefined {
+    return this.hoverPoint;
+  }
+  get pinned(): PointRef | undefined {
+    return this.pinnedPoint;
+  }
+  get pinnedDetails(): RowDetails | undefined {
+    return this.pinnedDetailsData;
+  }
+  get hoverDetails(): RowDetails | undefined {
+    return this.hoverDetailsData;
+  }
+  get brush(): BrushState | undefined {
+    return this.brushState;
+  }
+  get isUpdating(): boolean {
+    return this.updating;
+  }
+  get lastQueryMs(): number | undefined {
+    return this.queryDurationMs;
+  }
+  get lastResultRows(): number | undefined {
+    return this.resultRowCount;
+  }
+  get points(): PointSet | undefined {
+    return this.baseSet;
+  }
+
+  setPlotSize(widthPx: number, heightPx: number, pointSizePx: number): void {
+    const w = Math.max(1, Math.round(widthPx));
+    const h = Math.max(1, Math.round(heightPx));
+    const pt = Math.max(1, pointSizePx);
+    if (
+      this.plotWidthPx === w &&
+      this.plotHeightPx === h &&
+      this.pointSizePx === pt
+    ) {
+      return;
+    }
+    this.plotWidthPx = w;
+    this.plotHeightPx = h;
+    this.pointSizePx = pt;
+    if (this.currentView !== undefined && this.preparedSource !== undefined) {
+      this.requestViewport();
+    }
+  }
+
+  async setSource(spec: SourceSpec): Promise<void> {
+    if (this.disposed) return;
+    const gen = ++this.generation;
+    this.abortOngoingWork();
+    this.clearInteractions();
+    this.currentSource = spec;
+    this.loadState = {kind: 'describing'};
+    this.emitChange();
+
+    const loadAbort = new AbortController();
+    this.loadAbortController = loadAbort;
+
+    try {
+      const cols = await this.sourceInstance.describe(spec);
+      if (
+        this.disposed ||
+        gen !== this.generation ||
+        loadAbort.signal.aborted
+      ) {
+        return;
+      }
+      this.columnsList = cols;
+      const plot = this.resolvePlotSpec(cols);
+      if (plot === undefined) {
+        // Drop the previous source's points/view so stale data isn't shown.
+        this.viewportToken++;
+        this.preparedSource = undefined;
+        this.baseSet = undefined;
+        this.currentView = undefined;
+        this.currentDataBounds = undefined;
+        this.totalRowCount = undefined;
+        this.resultRowCount = undefined;
+        this.queryDurationMs = undefined;
+        this.currentColorMode = {kind: 'none'};
+        this.loadState = {kind: 'needs_axes'};
+        this.currentPlot = undefined;
+        this.emitChange();
+        return;
+      }
+      this.currentPlot = plot;
+      await this.executeLoad(gen, spec, plot, true, loadAbort.signal);
+    } catch (err: unknown) {
+      if (
+        this.disposed ||
+        gen !== this.generation ||
+        loadAbort.signal.aborted
+      ) {
+        return;
+      }
+      this.loadState = {
+        kind: 'error',
+        message: err instanceof Error ? err.message : String(err),
+      };
+      this.emitChange();
+    }
+  }
+
+  async setPlot(plot: PlotSpec): Promise<void> {
+    if (this.disposed) return;
+    const gen = ++this.generation;
+    this.abortOngoingWork();
+    this.clearInteractions();
+
+    const axesChanged =
+      this.currentPlot === undefined ||
+      this.currentPlot.x !== plot.x ||
+      this.currentPlot.y !== plot.y;
+    this.currentPlot = plot;
+    if (this.currentSource === undefined) return;
+
+    const loadAbort = new AbortController();
+    this.loadAbortController = loadAbort;
+
+    try {
+      await this.executeLoad(
+        gen,
+        this.currentSource,
+        plot,
+        axesChanged,
+        loadAbort.signal,
+      );
+    } catch (err: unknown) {
+      if (
+        this.disposed ||
+        gen !== this.generation ||
+        loadAbort.signal.aborted
+      ) {
+        return;
+      }
+      this.loadState = {
+        kind: 'error',
+        message: err instanceof Error ? err.message : String(err),
+      };
+      this.emitChange();
+    }
+  }
+
+  setView(view: ViewRange): void {
+    if (this.currentDataBounds === undefined) return;
+    const clamped = clampView(view, this.currentDataBounds);
+    if (
+      this.currentView !== undefined &&
+      viewsEqual(this.currentView, clamped)
+    ) {
+      return;
+    }
+    this.currentView = clamped;
+    this.emitChangeThrottled();
+    this.requestViewport();
+  }
+
+  resetView(): void {
+    if (this.currentDataBounds !== undefined) {
+      this.setView(fitView(this.currentDataBounds));
+    }
+  }
+
+  toggleCategory(index: number): void {
+    if (this.hiddenCats.has(index)) {
+      this.hiddenCats.delete(index);
+    } else {
+      this.hiddenCats.add(index);
+    }
+    this.onCategoriesChanged();
+  }
+
+  /**
+   * Shows only `index` out of `all` legend indices. If every other entry is
+   * already hidden (i.e. `index` is already isolated) this restores all, so
+   * repeating the gesture toggles back.
+   */
+  isolateCategory(index: number, all: ReadonlyArray<number>): void {
+    const alreadyIsolated = all.every(
+      (i) => i === index || this.hiddenCats.has(i),
+    );
+    this.hiddenCats.clear();
+    if (!alreadyIsolated) {
+      for (const i of all) {
+        if (i !== index) this.hiddenCats.add(i);
+      }
+    }
+    this.onCategoriesChanged();
+  }
+
+  showAllCategories(): void {
+    if (this.hiddenCats.size === 0) return;
+    this.hiddenCats.clear();
+    this.onCategoriesChanged();
+  }
+
+  private onCategoriesChanged(): void {
+    // Hidden points can't be hovered/pinned, so drop any that just vanished.
+    if (this.hoverPoint !== undefined && this.isHidden(this.hoverPoint)) {
+      this.setHover(undefined);
+    }
+    if (this.pinnedPoint !== undefined && this.isHidden(this.pinnedPoint)) {
+      this.pin(undefined);
+    }
+    this.emitChange();
+    // The operator returns one representative per cell, so client-side hiding
+    // alone would also drop visible points that share a cell with a hidden
+    // representative. Re-query with the hidden set excluded server-side; the
+    // renderer hides affected points immediately in the meantime.
+    this.refetchViewport();
+  }
+
+  private hiddenKey(): string {
+    return [...this.hiddenCats].sort((a, b) => a - b).join(',');
+  }
+
+  private refetchViewport(): void {
+    if (
+      this.disposed ||
+      this.currentView === undefined ||
+      this.preparedSource === undefined ||
+      this.currentDataBounds === undefined ||
+      this.hiddenKey() === this.lastFetchedHiddenKey
+    ) {
+      return;
+    }
+    this.clearViewportTimer();
+    void this.executeQuery(
+      computeViewportRequest(
+        this.currentView,
+        this.currentDataBounds,
+        this.plotWidthPx,
+        this.plotHeightPx,
+        this.pointSizePx,
+      ),
+    );
+  }
+
+  private isHiddenIndex(points: PointSet, i: number): boolean {
+    if (this.hiddenCats.size === 0) return false;
+    const mode = this.currentColorMode;
+    if (mode.kind === 'categorical') {
+      return this.hiddenCats.has(Math.round(points.color[i]));
+    }
+    // Only NULL can be hidden for uniform/numeric colouring.
+    return (
+      this.hiddenCats.has(NULL_CATEGORY) &&
+      mode.kind === 'numeric' &&
+      Number.isNaN(points.color[i])
+    );
+  }
+
+  private isHidden(ref: PointRef): boolean {
+    return this.isHiddenIndex(ref.points, ref.index);
+  }
+
+  setHover(ref: PointRef | undefined): void {
+    if (
+      this.hoverPoint?.points === ref?.points &&
+      this.hoverPoint?.index === ref?.index
+    ) {
+      return;
+    }
+    this.hoverPoint = ref;
+    this.hoverDetailsData = undefined;
+    this.clearHoverTimer();
+    const token = ++this.hoverToken;
+    this.emitChange();
+
+    const prepared = this.preparedSource;
+    if (ref === undefined || prepared === undefined) return;
+
+    const rowId = ref.points.row[ref.index];
+    this.hoverTimer = setTimeout(async () => {
+      this.hoverTimer = undefined;
+      if (this.disposed || token !== this.hoverToken) return;
+      try {
+        const cells = await prepared.fetchRow(rowId);
+        if (this.disposed || token !== this.hoverToken) return;
+        this.hoverDetailsData = {row: rowId, cells};
+        this.emitChange();
+      } catch {
+        // Ignored.
+      }
+    }, HOVER_DEBOUNCE_MS);
+  }
+
+  pin(ref: PointRef | undefined): void {
+    if (
+      this.pinnedPoint?.points === ref?.points &&
+      this.pinnedPoint?.index === ref?.index
+    ) {
+      return;
+    }
+    this.pinnedPoint = ref;
+    this.pinnedDetailsData = undefined;
+    const token = ++this.pinToken;
+    this.emitChange();
+
+    const prepared = this.preparedSource;
+    if (ref === undefined || prepared === undefined) return;
+
+    const rowId = ref.points.row[ref.index];
+    void (async () => {
+      try {
+        const cells = await prepared.fetchRow(rowId);
+        if (this.disposed || token !== this.pinToken) return;
+        this.pinnedDetailsData = {row: rowId, cells};
+        this.emitChange();
+      } catch {
+        // Ignored.
+      }
+    })();
+  }
+
+  setBrush(rect: DataBounds | undefined): void {
+    if (rect === undefined) {
+      if (this.brushState === undefined) return;
+      this.brushState = undefined;
+      this.brushToken++;
+      this.emitChange();
+      return;
+    }
+
+    this.brushState = {rect, count: undefined};
+    const token = ++this.brushToken;
+    this.emitChange();
+
+    const prepared = this.preparedSource;
+    if (prepared === undefined) return;
+
+    void (async () => {
+      try {
+        const count = await prepared.countInRect(rect);
+        if (this.disposed || token !== this.brushToken) return;
+        this.brushState = {rect, count};
+        this.emitChange();
+      } catch {
+        // Ignored.
+      }
+    })();
+  }
+
+  sqlForBrush(): string | undefined {
+    if (this.brushState === undefined || this.preparedSource === undefined) {
+      return undefined;
+    }
+    return this.preparedSource.sqlForRect(this.brushState.rect);
+  }
+
+  pick(
+    x: number,
+    y: number,
+    scaleX: number,
+    scaleY: number,
+    radiusPx: number,
+  ): PointRef | undefined {
+    if (this.baseSet === undefined) return undefined;
+    const set = this.baseSet;
+    const skip =
+      this.hiddenCats.size > 0
+        ? (i: number) => this.isHiddenIndex(set, i)
+        : undefined;
+    const hit = nearest(set, x, y, scaleX, scaleY, radiusPx, skip);
+    return hit !== undefined ? {points: set, index: hit.index} : undefined;
+  }
+
+  async dispose(): Promise<void> {
+    await this[Symbol.asyncDispose]();
+  }
+
+  async [Symbol.asyncDispose](): Promise<void> {
+    if (this.disposed) return;
+    this.disposed = true;
+    this.abortOngoingWork();
+    await this.sourceInstance.dispose();
+  }
+
+  private resolvePlotSpec(
+    cols: ReadonlyArray<ColumnInfo>,
+  ): PlotSpec | undefined {
+    if (this.currentPlot !== undefined) {
+      const {x, y, color} = this.currentPlot;
+      const hasX = cols.some((c) => c.name === x && c.kind === 'numeric');
+      const hasY = cols.some((c) => c.name === y && c.kind === 'numeric');
+      const hasColor =
+        color === undefined || cols.some((c) => c.name === color);
+      if (hasX && hasY && hasColor) return this.currentPlot;
+    }
+
+    const numericCols = cols.filter((c) => c.kind === 'numeric');
+    const hasTs = numericCols.some((c) => c.name === 'ts');
+    const hasDur = numericCols.some((c) => c.name === 'dur');
+    if (hasTs && hasDur) return {x: 'ts', y: 'dur'};
+    if (numericCols.length >= 2) {
+      return {x: numericCols[0].name, y: numericCols[1].name};
+    }
+    return undefined;
+  }
+
+  private async executeLoad(
+    gen: number,
+    spec: SourceSpec,
+    plot: PlotSpec,
+    resetViewToFit: boolean,
+    signal: AbortSignal,
+  ): Promise<void> {
+    this.preparedSource = undefined;
+    this.baseSet = undefined;
+    this.viewportToken++;
+    this.hiddenCats.clear();
+    this.loadState = {kind: 'preparing'};
+    this.emitChange();
+
+    this.lastFetchedRegion = undefined;
+    this.lastFetchedHiddenKey = '';
+    this.queryDurationMs = undefined;
+    this.resultRowCount = undefined;
+
+    const prepared = await this.sourceInstance.prepare(spec, plot);
+    if (this.disposed || gen !== this.generation || signal.aborted) return;
+
+    this.preparedSource = prepared;
+    this.totalRowCount = prepared.count;
+    this.currentDataBounds = prepared.bounds;
+    this.currentColorMode = prepared.colorMode;
+
+    if (resetViewToFit || this.currentView === undefined) {
+      this.currentView = fitView(prepared.bounds);
+    }
+
+    if (prepared.count === 0) {
+      this.baseSet = buildOperatorPointSet(
+        {
+          x: new Float64Array(0),
+          y: new Float64Array(0),
+          row: new Float64Array(0),
+          color: new Float32Array(0),
+        },
+        prepared.bounds,
+      );
+      this.loadState = {kind: 'ready'};
+      this.resultRowCount = 0;
+      this.emitChange();
+      return;
+    }
+
+    this.loadState = {kind: 'loading', total: prepared.count};
+    this.emitChange();
+
+    await this.executeQuery(
+      computeViewportRequest(
+        this.currentView,
+        prepared.bounds,
+        this.plotWidthPx,
+        this.plotHeightPx,
+        this.pointSizePx,
+      ),
+    );
+  }
+
+  private requestViewport(): void {
+    if (
+      this.disposed ||
+      this.currentView === undefined ||
+      this.preparedSource === undefined ||
+      this.currentDataBounds === undefined
+    ) {
+      return;
+    }
+
+    const req = computeViewportRequest(
+      this.currentView,
+      this.currentDataBounds,
+      this.plotWidthPx,
+      this.plotHeightPx,
+      this.pointSizePx,
+    );
+    if (
+      !shouldRequery(
+        this.currentView,
+        this.currentDataBounds,
+        req,
+        this.lastFetchedRegion,
+      ) &&
+      this.hiddenKey() === this.lastFetchedHiddenKey
+    ) {
+      return;
+    }
+
+    this.clearViewportTimer();
+    if (this.viewportDebounceMs === 0 || this.lastFetchedRegion === undefined) {
+      void this.executeQuery(req);
+    } else {
+      this.viewportTimer = setTimeout(() => {
+        this.viewportTimer = undefined;
+        void this.executeQuery(req);
+      }, this.viewportDebounceMs);
+    }
+  }
+
+  flushViewport(): void {
+    if (this.viewportTimer === undefined) return;
+    this.clearViewportTimer();
+    if (
+      this.disposed ||
+      this.currentView === undefined ||
+      this.preparedSource === undefined ||
+      this.currentDataBounds === undefined
+    ) {
+      return;
+    }
+    void this.executeQuery(
+      computeViewportRequest(
+        this.currentView,
+        this.currentDataBounds,
+        this.plotWidthPx,
+        this.plotHeightPx,
+        this.pointSizePx,
+      ),
+    );
+  }
+
+  private async executeQuery(req: InternalViewportRequest): Promise<void> {
+    if (this.disposed || this.preparedSource === undefined) return;
+
+    const token = ++this.viewportToken;
+    this.updating = true;
+    this.clearUpdatingTimer();
+    this.updatingTimer = setTimeout(() => {
+      this.updatingTimer = undefined;
+      if (this.updating && !this.disposed && token === this.viewportToken) {
+        this.emitChange();
+      }
+    }, 150);
+
+    const prepared = this.preparedSource;
+    const startMs = performance.now();
+    const hiddenKey = this.hiddenKey();
+    const hidden = [...this.hiddenCats].sort((a, b) => a - b);
+
+    try {
+      const res = await prepared.fetchViewport({...req, hidden});
+      if (this.disposed || token !== this.viewportToken) return;
+
+      this.queryDurationMs = Math.round(performance.now() - startMs);
+      this.resultRowCount = res.x.length;
+      this.lastFetchedRegion = req;
+      this.lastFetchedHiddenKey = hiddenKey;
+
+      const n = res.count.length;
+      const weight = new Float32Array(n);
+      for (let i = 0; i < n; i++) {
+        weight[i] = Math.min(16_777_216, res.count[i]);
+      }
+      const colsWithWeight: PointColumns = {
+        x: res.x,
+        y: res.y,
+        row: res.row,
+        color: res.color,
+        weight,
+      };
+      const newSet = buildOperatorPointSet(colsWithWeight, {
+        xMin: req.xMin,
+        xMax: req.xMax,
+        yMin: req.yMin,
+        yMax: req.yMax,
+      });
+
+      this.clearUpdatingTimer();
+      this.baseSet = newSet;
+      this.updating = false;
+      if (
+        this.loadState.kind === 'loading' ||
+        this.loadState.kind === 'preparing'
+      ) {
+        this.loadState = {kind: 'ready'};
+      }
+      this.emitChange();
+    } catch (err: unknown) {
+      if (this.disposed || token !== this.viewportToken) return;
+      this.clearUpdatingTimer();
+      this.updating = false;
+      this.loadState = {
+        kind: 'error',
+        message: err instanceof Error ? err.message : String(err),
+      };
+      this.emitChange();
+    }
+  }
+
+  private abortOngoingWork(): void {
+    this.viewportToken++;
+    this.loadAbortController?.abort();
+    this.loadAbortController = undefined;
+    this.clearViewportTimer();
+    this.clearHoverTimer();
+    this.clearUpdatingTimer();
+    this.clearViewEmitTimer();
+  }
+
+  private clearInteractions(): void {
+    this.hoverPoint = undefined;
+    this.hoverDetailsData = undefined;
+    this.pinnedPoint = undefined;
+    this.pinnedDetailsData = undefined;
+    this.brushState = undefined;
+    this.hoverToken++;
+    this.pinToken++;
+    this.brushToken++;
+  }
+
+  private clearViewportTimer(): void {
+    if (this.viewportTimer !== undefined) {
+      clearTimeout(this.viewportTimer);
+      this.viewportTimer = undefined;
+    }
+  }
+  private clearHoverTimer(): void {
+    if (this.hoverTimer !== undefined) {
+      clearTimeout(this.hoverTimer);
+      this.hoverTimer = undefined;
+    }
+  }
+  private clearUpdatingTimer(): void {
+    if (this.updatingTimer !== undefined) {
+      clearTimeout(this.updatingTimer);
+      this.updatingTimer = undefined;
+    }
+  }
+  private clearViewEmitTimer(): void {
+    if (this.viewEmitTimer !== undefined) {
+      clearTimeout(this.viewEmitTimer);
+      this.viewEmitTimer = undefined;
+    }
+  }
+
+  private emitChangeThrottled(): void {
+    if (this.disposed) return;
+    const now = performance.now();
+    const elapsed = now - this.lastViewEmitMs;
+    if (elapsed >= 250) {
+      this.lastViewEmitMs = now;
+      this.clearViewEmitTimer();
+      this.emitChange();
+    } else if (this.viewEmitTimer === undefined) {
+      this.viewEmitTimer = setTimeout(() => {
+        this.viewEmitTimer = undefined;
+        this.lastViewEmitMs = performance.now();
+        this.emitChange();
+      }, 250 - elapsed);
+    }
+  }
+
+  private emitChange(): void {
+    if (!this.disposed) this.opts.onChange();
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_page.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_page.ts
new file mode 100644
index 0000000..d6f81b0
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_page.ts
@@ -0,0 +1,997 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {copyToClipboard} from '../../base/clipboard';
+import {Icons} from '../../base/semantic_icons';
+import {Duration, Time, type time} from '../../base/time';
+import {formatDurationValue} from '../../components/aggregation_panel';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import type {
+  AggregateColumn,
+  Pivot,
+} from '../../components/widgets/datagrid/model';
+import type {
+  ColumnSchema,
+  ColumnType,
+} from '../../components/widgets/datagrid/datagrid_schema';
+import {SQLDataSource} from '../../components/widgets/datagrid/sql_data_source';
+import {DurationWidget} from '../../components/widgets/duration';
+import {Timestamp} from '../../components/widgets/timestamp';
+import type {TrackEventDetailsPanel} from '../../public/details_panel';
+import type {Selection, TrackEventSelection} from '../../public/selection';
+import type {Trace} from '../../public/trace';
+import type {SqlValue} from '../../trace_processor/query_result';
+import {sqlValueToReadableString} from '../../trace_processor/sql_utils';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {Callout} from '../../widgets/callout';
+import {Intent} from '../../widgets/common';
+import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button';
+import {DetailsShell} from '../../widgets/details_shell';
+import {EmptyState} from '../../widgets/empty_state';
+import {Keycap} from '../../widgets/hotkey_glyphs';
+import {LinearProgress} from '../../widgets/linear_progress';
+import {MenuDivider, MenuItem, MenuTitle, PopupMenu} from '../../widgets/menu';
+import {Section} from '../../widgets/section';
+import {Select} from '../../widgets/select';
+import {SqlRef} from '../../widgets/sql_ref';
+import {Tree, TreeNode} from '../../widgets/tree';
+import {
+  DrawerPanel,
+  DrawerPanelVisibility,
+  type DrawerTab,
+} from '../../widgets/drawer_panel';
+import type {
+  BrushState,
+  PointRef,
+  ScatterController,
+} from './scatter_controller';
+import {ScatterPlotView} from './scatter_plot_view';
+import {SourcePicker} from './source_picker';
+import type {DataBounds, PointStyle} from './types';
+import {fitView} from './view';
+
+export interface ScatterPageAttrs {
+  readonly trace: Trace;
+  readonly controller: ScatterController;
+}
+
+const COMMON_TABLE_SUGGESTIONS = ['slice', 'thread_state', 'sched', 'counter'];
+const DEFAULT_DRAWER_HEIGHT = 280;
+
+export class ScatterPage implements m.ClassComponent<ScatterPageAttrs> {
+  private static savedDrawerVisibility = DrawerPanelVisibility.COLLAPSED;
+  private static savedTabKey = 'current_selection';
+
+  private pointStyle: PointStyle = {
+    sizePx: 3,
+    opacity: 0.7,
+  };
+
+  private get drawerVisibility(): DrawerPanelVisibility {
+    return ScatterPage.savedDrawerVisibility;
+  }
+  private set drawerVisibility(v: DrawerPanelVisibility) {
+    ScatterPage.savedDrawerVisibility = v;
+  }
+
+  private get activeTabKey(): string {
+    return ScatterPage.savedTabKey;
+  }
+  private set activeTabKey(k: string) {
+    ScatterPage.savedTabKey = k;
+  }
+
+  private lastPinned?: PointRef;
+  private lastBrush?: BrushState;
+
+  private brushDataSource?: SQLDataSource;
+  private lastBrushRect?: DataBounds;
+  private activeTimelinePanel?: TrackEventDetailsPanel;
+  private panelToken = 0;
+  private panelLoading = false;
+  private lastResolvedId?: number;
+  private lastResolvedTable?: string;
+  private lastObservedSelection?: Selection;
+  private isDisposed = false;
+
+  oninit() {
+    this.isDisposed = false;
+  }
+
+  onremove() {
+    this.isDisposed = true;
+    this.disposeBrushDataSource();
+    this.clearPinnedPanel();
+  }
+
+  private disposeBrushDataSource(): void {
+    if (this.brushDataSource !== undefined) {
+      try {
+        this.brushDataSource.dispose();
+      } catch {
+        // Ignored.
+      }
+      this.brushDataSource = undefined;
+      this.lastBrushRect = undefined;
+    }
+  }
+
+  private clearPinnedPanel(): void {
+    this.panelToken++;
+    this.activeTimelinePanel = undefined;
+    this.panelLoading = false;
+    this.lastResolvedTable = undefined;
+    this.lastResolvedId = undefined;
+  }
+
+  private loadTimelinePanel(
+    trace: Trace,
+    tableName: string,
+    targetId: number,
+  ): void {
+    this.lastResolvedTable = tableName;
+    this.lastResolvedId = targetId;
+    this.panelLoading = true;
+    this.activeTimelinePanel = undefined;
+    const token = ++this.panelToken;
+
+    void (async () => {
+      try {
+        const events = await trace.selection.resolveSqlEvents(tableName, [
+          targetId,
+        ]);
+        if (this.isDisposed || token !== this.panelToken) return;
+        if (events.length > 0) {
+          const ev = events[0];
+          const track = trace.tracks.getTrack(ev.trackUri);
+          const d = await track?.renderer.getSelectionDetails?.(ev.eventId);
+          if (this.isDisposed || token !== this.panelToken) return;
+          const sel: TrackEventSelection = {
+            ...d,
+            ts: d?.ts ?? Time.fromRaw(0n),
+            kind: 'track_event',
+            trackUri: ev.trackUri,
+            eventId: ev.eventId,
+          };
+          const panel = track?.renderer.detailsPanel?.(sel);
+          if (panel !== undefined) {
+            await panel.load?.(sel);
+            if (this.isDisposed || token !== this.panelToken) return;
+            this.activeTimelinePanel = panel;
+            this.panelLoading = false;
+            trace.raf.scheduleFullRedraw();
+            return;
+          }
+        }
+      } catch {
+        // Fallback to generic details
+      }
+      if (this.isDisposed || token !== this.panelToken) return;
+      this.activeTimelinePanel = undefined;
+      this.panelLoading = false;
+      trace.raf.scheduleFullRedraw();
+    })();
+  }
+
+  view({attrs}: m.CVnode<ScatterPageAttrs>) {
+    const {trace, controller} = attrs;
+    const state = controller.state;
+
+    // Navigate to timeline if selection was changed by user actions in the details panel
+    const curSelection = trace.selection.selection;
+    if (
+      this.lastObservedSelection !== undefined &&
+      this.lastObservedSelection !== curSelection &&
+      curSelection.kind !== 'empty'
+    ) {
+      this.lastObservedSelection = curSelection;
+      trace.navigate('#!/viewer');
+    } else {
+      this.lastObservedSelection = curSelection;
+    }
+
+    // Auto-open drawer when point pinned or brush selection made
+    if (controller.pinned !== this.lastPinned) {
+      this.lastPinned = controller.pinned;
+      if (controller.pinned !== undefined) {
+        this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
+        this.activeTabKey = 'current_selection';
+      } else {
+        this.clearPinnedPanel();
+      }
+    }
+    if (controller.brush !== this.lastBrush) {
+      this.lastBrush = controller.brush;
+      if (controller.brush !== undefined) {
+        this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
+        this.activeTabKey = 'selection';
+      } else {
+        this.disposeBrushDataSource();
+      }
+    }
+
+    let brushTabTitle = 'Selection';
+    if (controller.source?.kind === 'table') {
+      if (controller.source.table === 'slice') {
+        brushTabTitle = 'Slices';
+      } else if (controller.source.table === 'thread_state') {
+        brushTabTitle = 'Thread States';
+      } else if (controller.source.table === 'sched') {
+        brushTabTitle = 'CPU by Thread';
+      }
+    }
+
+    const tabs: DrawerTab[] = [
+      {
+        key: 'current_selection',
+        title: 'Current Selection',
+        content: this.renderPinnedTab(controller, trace),
+        closable: controller.pinned !== undefined,
+      },
+      {
+        key: 'selection',
+        title: brushTabTitle,
+        content: this.renderSelectionTab(controller, trace),
+        closable: controller.brush !== undefined,
+      },
+    ];
+
+    return m(
+      '.pf-scatter-page',
+      this.renderToolbar(controller, trace),
+      this.renderProgressBar(controller),
+      m(
+        '.pf-scatter-page__main',
+        state.kind === 'empty'
+          ? this.renderEmptyState(controller)
+          : m(
+              '.pf-scatter-page__content',
+              // The plot always fills the whole area; the drawer floats on
+              // top of it so opening/resizing the drawer never resizes the
+              // plot.
+              m(ScatterPlotView, {
+                controller,
+                pointStyle: this.pointStyle,
+              }),
+              m(DrawerPanel, {
+                className: 'pf-scatter-page__drawer',
+                visibility: this.drawerVisibility,
+                onVisibilityChange: (visibility: DrawerPanelVisibility) => {
+                  this.drawerVisibility = visibility;
+                },
+                activeTabKey: this.activeTabKey,
+                onTabChange: (key: string) => {
+                  this.activeTabKey = key;
+                  if (
+                    this.drawerVisibility === DrawerPanelVisibility.COLLAPSED
+                  ) {
+                    this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
+                  }
+                },
+                onTabClose: (key: string) => {
+                  if (key === 'current_selection') {
+                    controller.pin(undefined);
+                  } else if (key === 'selection') {
+                    controller.setBrush(undefined);
+                  }
+                },
+                startingHeight: DEFAULT_DRAWER_HEIGHT,
+                leftHandleContent: this.renderDrawerMenu(
+                  controller,
+                  brushTabTitle,
+                ),
+                mainContent: this.renderStateCallout(controller),
+                tabs,
+              }),
+            ),
+      ),
+    );
+  }
+
+  private renderStateCallout(controller: ScatterController): m.Children {
+    const state = controller.state;
+    let message: string | undefined;
+    let intent = Intent.None;
+    if (state.kind === 'error') {
+      message = state.message;
+      intent = Intent.Danger;
+    } else if (state.kind === 'needs_axes') {
+      message =
+        'This source has fewer than two numeric columns (or no rows), so ' +
+        'there is nothing to plot. Pick another table or write a query.';
+      intent = Intent.Warning;
+    } else if (state.kind === 'ready' && controller.rowCount === 0) {
+      message = 'No rows with non-NULL values for the selected X and Y.';
+      intent = Intent.Warning;
+    }
+    if (message === undefined) return null;
+    return m(Callout, {intent, className: 'pf-scatter-page__error'}, message);
+  }
+
+  private renderProgressBar(controller: ScatterController): m.Children {
+    const state = controller.state;
+    const isLoading =
+      state.kind === 'loading' ||
+      state.kind === 'preparing' ||
+      state.kind === 'describing';
+
+    return m(LinearProgress, {
+      className: 'pf-scatter-page__progress',
+      state: isLoading ? 'indeterminate' : 'none',
+    });
+  }
+
+  private renderToolbar(
+    controller: ScatterController,
+    trace: Trace,
+  ): m.Children {
+    const source = controller.source;
+    let sourceLabel = 'Select table / query...';
+    if (source?.kind === 'table') {
+      sourceLabel = `Table: ${source.table}`;
+    } else if (source?.kind === 'query') {
+      sourceLabel = 'Custom SQL query';
+    }
+
+    const numericCols = controller.columns.filter((c) => c.kind === 'numeric');
+    const plot = controller.plot;
+    const state = controller.state;
+
+    return m(
+      '.pf-scatter-page__toolbar',
+      // Source picker button
+      m(
+        PopupMenu,
+        {
+          trigger: m(Button, {
+            label: sourceLabel,
+            icon: 'table_chart',
+            rightIcon: 'arrow_drop_down',
+          }),
+        },
+        m(SourcePicker, {
+          trace,
+          currentSource: controller.source,
+          onSelect: (spec) => {
+            void controller.setSource(spec);
+          },
+        }),
+      ),
+
+      // X Axis selector
+      m(
+        '.pf-scatter-page__control-group',
+        m('.pf-scatter-page__control-label', 'X:'),
+        m(
+          Select,
+          {
+            value: plot?.x ?? '',
+            disabled: numericCols.length === 0,
+            onchange: (e: Event) => {
+              const val = (e.target as HTMLSelectElement).value;
+              if (plot !== undefined && val.length > 0) {
+                void controller.setPlot({...plot, x: val});
+              }
+            },
+          },
+          numericCols.map((c) =>
+            m('option', {value: c.name, selected: c.name === plot?.x}, c.name),
+          ),
+        ),
+      ),
+
+      // Y Axis selector
+      m(
+        '.pf-scatter-page__control-group',
+        m('.pf-scatter-page__control-label', 'Y:'),
+        m(
+          Select,
+          {
+            value: plot?.y ?? '',
+            disabled: numericCols.length === 0,
+            onchange: (e: Event) => {
+              const val = (e.target as HTMLSelectElement).value;
+              if (plot !== undefined && val.length > 0) {
+                void controller.setPlot({...plot, y: val});
+              }
+            },
+          },
+          numericCols.map((c) =>
+            m('option', {value: c.name, selected: c.name === plot?.y}, c.name),
+          ),
+        ),
+      ),
+
+      // Color selector
+      m(
+        '.pf-scatter-page__control-group',
+        m('.pf-scatter-page__control-label', 'Color:'),
+        m(
+          Select,
+          {
+            value: plot?.color ?? '',
+            disabled: controller.columns.length === 0,
+            onchange: (e: Event) => {
+              const val = (e.target as HTMLSelectElement).value;
+              if (plot !== undefined) {
+                void controller.setPlot({
+                  ...plot,
+                  color: val.length > 0 ? val : undefined,
+                });
+              }
+            },
+          },
+          m('option', {value: ''}, 'None'),
+          controller.columns.map((c) =>
+            m(
+              'option',
+              {value: c.name, selected: c.name === plot?.color},
+              c.name,
+            ),
+          ),
+        ),
+      ),
+
+      // Reset view button
+      m(Button, {
+        label: 'Reset view',
+        icon: 'fit_screen',
+        variant: ButtonVariant.Outlined,
+        disabled: controller.view === undefined,
+        onclick: () => controller.resetView(),
+      }),
+
+      // Settings menu
+      m(
+        PopupMenu,
+        {
+          trigger: m(Button, {
+            icon: 'settings',
+            title: 'Settings',
+            variant: ButtonVariant.Minimal,
+          }),
+        },
+        m(MenuTitle, {label: 'Point Size'}),
+        [1, 2, 3, 4, 6].map((sz) =>
+          m(MenuItem, {
+            label: `${sz} px`,
+            active: this.pointStyle.sizePx === sz,
+            onclick: () => {
+              this.pointStyle = {...this.pointStyle, sizePx: sz};
+            },
+          }),
+        ),
+        m(MenuDivider),
+        m(MenuTitle, {label: 'Opacity'}),
+        [0.1, 0.25, 0.5, 0.75, 1.0].map((op) =>
+          m(MenuItem, {
+            label: `${Math.round(op * 100)}%`,
+            active: this.pointStyle.opacity === op,
+            onclick: () => {
+              this.pointStyle = {...this.pointStyle, opacity: op};
+            },
+          }),
+        ),
+      ),
+
+      // Help shortcuts menu
+      m(
+        PopupMenu,
+        {
+          trigger: m(Button, {
+            icon: 'help',
+            title: 'Keyboard & mouse shortcuts',
+            variant: ButtonVariant.Minimal,
+          }),
+        },
+        this.renderHelpMenu(),
+      ),
+
+      // Status text
+      m(
+        '.pf-scatter-page__status',
+        state.kind === 'loading' &&
+          (state.total !== undefined
+            ? `Loading ${state.total.toLocaleString()} rows...`
+            : 'Loading...'),
+        state.kind === 'ready' &&
+          (() => {
+            const parts: string[] = [];
+            if (controller.rowCount !== undefined) {
+              parts.push(`${controller.rowCount.toLocaleString()} rows`);
+            }
+            if (controller.lastResultRows !== undefined) {
+              parts.push(
+                `${controller.lastResultRows.toLocaleString()} points in view`,
+              );
+            }
+            if (controller.lastQueryMs !== undefined) {
+              parts.push(`${controller.lastQueryMs} ms`);
+            }
+            let text = parts.join(' · ');
+            if (controller.isUpdating) {
+              text += ' (updating...)';
+            }
+            return text;
+          })(),
+      ),
+    );
+  }
+
+  private renderEmptyState(controller: ScatterController): m.Children {
+    return m(
+      '.pf-scatter-page__empty-container',
+      m(
+        EmptyState,
+        {
+          icon: 'scatter_plot',
+          title: 'Scatter Plot',
+          fillHeight: false,
+        },
+        m(
+          'p',
+          'Explore millions of trace events with hardware-accelerated pan and zoom.',
+        ),
+        m(
+          '.pf-scatter-page__suggestions',
+          COMMON_TABLE_SUGGESTIONS.map((tbl) =>
+            m(Button, {
+              label: `Plot ${tbl}`,
+              variant: ButtonVariant.Outlined,
+              onclick: () => {
+                void controller.setSource({kind: 'table', table: tbl});
+              },
+            }),
+          ),
+        ),
+      ),
+    );
+  }
+
+  private renderPinnedTab(
+    controller: ScatterController,
+    trace: Trace,
+  ): m.Children {
+    const pinned = controller.pinned;
+    const details = controller.pinnedDetails;
+
+    if (pinned === undefined) {
+      this.clearPinnedPanel();
+      return m(
+        '.pf-scatter-drawer__empty',
+        'Click any point in the plot to pin and view its attributes.',
+      );
+    }
+
+    const cells = details?.cells ?? [];
+    let tsVal: number | bigint | undefined;
+    let durVal: number | bigint | undefined;
+    let idVal: number | bigint | undefined;
+
+    for (const cell of cells) {
+      if (
+        cell.name === 'id' &&
+        (typeof cell.value === 'number' || typeof cell.value === 'bigint')
+      ) {
+        idVal = cell.value;
+      }
+      if (
+        (cell.name === 'ts' || cell.name.endsWith('_ts')) &&
+        (typeof cell.value === 'number' || typeof cell.value === 'bigint')
+      ) {
+        tsVal = cell.value;
+      }
+      if (
+        (cell.name === 'dur' || cell.name.endsWith('_dur')) &&
+        (typeof cell.value === 'number' || typeof cell.value === 'bigint')
+      ) {
+        durVal = cell.value;
+      }
+    }
+
+    const isTableSource = controller.source?.kind === 'table';
+    const tableName = isTableSource ? controller.source.table : undefined;
+    const targetId = idVal !== undefined ? Number(idVal) : undefined;
+
+    // Trigger async load of timeline details panel when table and id exist
+    if (tableName !== undefined && targetId !== undefined) {
+      if (
+        tableName !== this.lastResolvedTable ||
+        targetId !== this.lastResolvedId
+      ) {
+        this.loadTimelinePanel(trace, tableName, targetId);
+      }
+    }
+
+    const renderActions = () =>
+      m(
+        '.pf-scatter-drawer__actions',
+        tsVal !== undefined &&
+          m(Button, {
+            label: 'Go to timeline',
+            icon: 'schedule',
+            variant: ButtonVariant.Filled,
+            intent: Intent.Primary,
+            onclick: () => {
+              const start = Time.fromRaw(BigInt(tsVal));
+              let end: time | undefined;
+              if (durVal !== undefined && durVal > 0n) {
+                end = Time.fromRaw(BigInt(tsVal) + BigInt(durVal));
+              }
+              trace.scrollTo({
+                time: {
+                  start,
+                  end,
+                  behavior: 'focus',
+                },
+              });
+              trace.navigate('#!/viewer');
+            },
+          }),
+        isTableSource &&
+          targetId !== undefined &&
+          m(Button, {
+            label: 'Select in timeline',
+            icon: 'target',
+            variant: ButtonVariant.Outlined,
+            onclick: () => {
+              if (controller.source?.kind === 'table') {
+                try {
+                  trace.selection.selectSqlEvent(
+                    controller.source.table,
+                    targetId,
+                    {
+                      switchToCurrentSelectionTab: true,
+                      scrollToSelection: true,
+                    },
+                  );
+                  trace.navigate('#!/viewer');
+                } catch {
+                  // Fallback
+                }
+              }
+            },
+          }),
+        m(Button, {
+          icon: 'close',
+          variant: ButtonVariant.Minimal,
+          title: 'Unpin point',
+          onclick: () => controller.pin(undefined),
+        }),
+      );
+
+    // If the real timeline details panel was resolved, render it
+    if (this.activeTimelinePanel !== undefined) {
+      return m(
+        '.pf-scatter-drawer__pane.pf-scatter-drawer__pane--details',
+        m(
+          '.pf-scatter-drawer__details-toolbar',
+          m('.pf-scatter-drawer__title', 'Current Selection'),
+          renderActions(),
+        ),
+        this.panelLoading && m(LinearProgress),
+        m('.pf-scatter-drawer__real-panel', this.activeTimelinePanel.render()),
+      );
+    }
+
+    // Fallback: Tree-based generic details panel with formatted widgets
+    return m(
+      '.pf-scatter-drawer__pane.pf-scatter-drawer__pane--details',
+      (details === undefined || this.panelLoading) && m(LinearProgress),
+      cells.length > 0
+        ? m(
+            '.pf-scatter-drawer__real-panel',
+            m(
+              DetailsShell,
+              {
+                title: 'Point details',
+                buttons: renderActions(),
+              },
+              m(
+                Section,
+                {title: 'Attributes'},
+                m(
+                  Tree,
+                  cells.map((cell) => {
+                    const valStr =
+                      cell.value === null ? 'NULL' : String(cell.value);
+                    let rightWidget: m.Children;
+                    if (
+                      cell.name === 'id' &&
+                      isTableSource &&
+                      targetId !== undefined
+                    ) {
+                      rightWidget = m(SqlRef, {
+                        table: controller.source!.table,
+                        id: targetId,
+                      });
+                    } else if (
+                      (cell.name === 'ts' || cell.name.endsWith('_ts')) &&
+                      (typeof cell.value === 'number' ||
+                        typeof cell.value === 'bigint')
+                    ) {
+                      rightWidget = m(Timestamp, {
+                        trace,
+                        ts: Time.fromRaw(
+                          BigInt(Math.round(Number(cell.value))),
+                        ),
+                      });
+                    } else if (
+                      (cell.name === 'dur' || cell.name.endsWith('_dur')) &&
+                      (typeof cell.value === 'number' ||
+                        typeof cell.value === 'bigint')
+                    ) {
+                      rightWidget = m(DurationWidget, {
+                        trace,
+                        dur: Duration.fromRaw(
+                          BigInt(Math.round(Number(cell.value))),
+                        ),
+                      });
+                    } else {
+                      rightWidget = m(
+                        'span.pf-scatter-drawer__cell-val',
+                        {
+                          title: `${valStr} (click to copy)`,
+                          onclick: () => {
+                            copyToClipboard(valStr);
+                          },
+                        },
+                        sqlValueToReadableString(cell.value),
+                      );
+                    }
+                    return m(TreeNode, {
+                      left: cell.name,
+                      right: rightWidget,
+                    });
+                  }),
+                ),
+              ),
+            ),
+          )
+        : m(
+            '.pf-scatter-drawer__details-toolbar',
+            m('.pf-scatter-drawer__title', 'Point details'),
+            renderActions(),
+          ),
+    );
+  }
+
+  private renderSelectionTab(
+    controller: ScatterController,
+    trace: Trace,
+  ): m.Children {
+    const brush = controller.brush;
+    if (brush === undefined) {
+      this.disposeBrushDataSource();
+      return m(
+        '.pf-scatter-drawer__empty',
+        'Shift + Drag on the plot to select a rectangular region.',
+      );
+    }
+
+    const countText =
+      brush.count !== undefined
+        ? `${brush.count.toLocaleString()} rows selected`
+        : 'Counting selected rows...';
+
+    // Memoize and create brush SQLDataSource
+    const r = brush.rect;
+    if (
+      this.brushDataSource === undefined ||
+      this.lastBrushRect === undefined ||
+      this.lastBrushRect.xMin !== r.xMin ||
+      this.lastBrushRect.xMax !== r.xMax ||
+      this.lastBrushRect.yMin !== r.yMin ||
+      this.lastBrushRect.yMax !== r.yMax
+    ) {
+      this.disposeBrushDataSource();
+      const rawSql = controller.sqlForBrush();
+      if (rawSql !== undefined) {
+        const cleanSql = rawSql.trim().replace(/;+$/, '');
+        this.brushDataSource = new SQLDataSource({
+          engine: trace.engine,
+          tableOrSubquery: cleanSql,
+        });
+        this.lastBrushRect = {...r};
+      }
+    }
+
+    // Build ColumnSchema from controller columns
+    const schema: ColumnSchema = {};
+    for (const col of controller.columns) {
+      let colType: ColumnType = 'text';
+      if (col.kind === 'numeric') {
+        colType = col.name === 'id' ? 'identifier' : 'quantitative';
+      }
+      const isTs = col.name === 'ts' || col.name.endsWith('_ts');
+      const isDur = col.name === 'dur' || col.name.endsWith('_dur');
+
+      schema[col.name] = {
+        title: col.name,
+        columnType: colType,
+        cellRenderer: isTs
+          ? (val: SqlValue) => {
+              if (typeof val === 'bigint') {
+                return m(Timestamp, {trace, ts: Time.fromRaw(val)});
+              } else if (typeof val === 'number') {
+                return m(Timestamp, {
+                  trace,
+                  ts: Time.fromRaw(BigInt(Math.round(val))),
+                });
+              }
+              return String(val ?? '');
+            }
+          : isDur
+            ? (val: SqlValue) => {
+                if (typeof val === 'bigint') {
+                  return formatDurationValue(val);
+                } else if (typeof val === 'number') {
+                  return formatDurationValue(BigInt(Math.round(val)));
+                }
+                return String(val ?? '');
+              }
+            : undefined,
+      };
+    }
+
+    const initialColumns = controller.columns.map((c) => ({
+      id: c.name,
+      field: c.name,
+    }));
+
+    const hasNameCol = controller.columns.some((c) => c.name === 'name');
+    const hasDurCol = controller.columns.some((c) => c.name === 'dur');
+
+    let initialPivot: Pivot | undefined = undefined;
+    if (hasNameCol) {
+      const aggregates: AggregateColumn[] = [{id: 'count', function: 'COUNT'}];
+      if (hasDurCol) {
+        aggregates.push(
+          {id: 'dur_sum', field: 'dur', function: 'SUM', sort: 'DESC'},
+          {id: 'dur_avg', field: 'dur', function: 'AVG'},
+        );
+      }
+      initialPivot = {
+        groupBy: [{id: 'name', field: 'name'}],
+        aggregates,
+      };
+    }
+
+    const leftToolbar = m(
+      '.pf-scatter-drawer__toolbar-left',
+      m('.pf-scatter-drawer__title', 'Box Selection'),
+      m('.pf-scatter-drawer__count', countText),
+    );
+
+    const rightToolbar = m(
+      '.pf-scatter-drawer__action-row',
+      m(Button, {
+        label: 'Zoom to selection',
+        icon: 'zoom_in',
+        compact: true,
+        variant: ButtonVariant.Outlined,
+        onclick: () => {
+          controller.setView(fitView(brush.rect));
+        },
+      }),
+      m(CopyToClipboardButton, {
+        label: 'Copy SQL',
+        compact: true,
+        variant: ButtonVariant.Outlined,
+        textToCopy: () => controller.sqlForBrush() ?? '',
+      }),
+      m(Button, {
+        icon: Icons.Close,
+        compact: true,
+        variant: ButtonVariant.Minimal,
+        title: 'Clear selection',
+        onclick: () => controller.setBrush(undefined),
+      }),
+    );
+
+    return m(
+      '.pf-scatter-drawer__pane.pf-scatter-drawer__pane--selection',
+      this.brushDataSource === undefined &&
+        m('.pf-scatter-drawer__selection-header', leftToolbar, rightToolbar),
+      m(
+        '.pf-scatter-drawer__grid-container',
+        this.brushDataSource !== undefined &&
+          m(DataGrid, {
+            fillHeight: true,
+            schema,
+            data: this.brushDataSource,
+            initialColumns,
+            initialPivot,
+            showExportButton: true,
+            toolbarItemsLeft: leftToolbar,
+            toolbarItemsRight: rightToolbar,
+          }),
+      ),
+    );
+  }
+
+  private renderHelpMenu(): m.Children {
+    const shortcuts = [
+      {key: 'Drag', desc: 'Pan view'},
+      {key: 'Shift + Drag', desc: 'Box select'},
+      {key: 'Wheel', desc: 'Zoom at cursor'},
+      {key: 'Shift + Wheel', desc: 'Zoom X only'},
+      {key: 'Alt + Wheel', desc: 'Zoom Y only'},
+      {key: 'W / S, + / −', desc: 'Zoom in / out at cursor'},
+      {key: 'A / D', desc: 'Pan left / right'},
+      {key: 'Arrows', desc: 'Pan in any direction'},
+      {key: 'Double click', desc: 'Reset view'},
+      {key: 'Escape', desc: 'Clear selection / pin'},
+      {key: 'Legend click', desc: 'Hide / show category'},
+      {key: 'Legend double click', desc: 'Show only that category'},
+    ];
+
+    return m(
+      '.pf-scatter-help-menu',
+      m(MenuTitle, {label: 'Shortcuts'}),
+      m(
+        '.pf-scatter-help-menu__list',
+        shortcuts.map((s) =>
+          m(
+            '.pf-scatter-help-menu__item',
+            m(Keycap, s.key),
+            m('span.pf-scatter-help-menu__desc', s.desc),
+          ),
+        ),
+      ),
+    );
+  }
+
+  private renderDrawerMenu(
+    _controller: ScatterController,
+    brushTitle: string,
+  ): m.Child {
+    return m(
+      PopupMenu,
+      {
+        trigger: m(Button, {
+          icon: Icons.ContextMenuAlt,
+          title: 'Drawer Options',
+        }),
+      },
+      m(MenuItem, {
+        label: 'Current Selection',
+        icon:
+          this.activeTabKey === 'current_selection'
+            ? Icons.Checkbox
+            : Icons.BlankCheckbox,
+        onclick: () => {
+          this.activeTabKey = 'current_selection';
+          if (this.drawerVisibility === DrawerPanelVisibility.COLLAPSED) {
+            this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
+          }
+        },
+      }),
+      m(MenuItem, {
+        label: brushTitle,
+        icon:
+          this.activeTabKey === 'selection'
+            ? Icons.Checkbox
+            : Icons.BlankCheckbox,
+        onclick: () => {
+          this.activeTabKey = 'selection';
+          if (this.drawerVisibility === DrawerPanelVisibility.COLLAPSED) {
+            this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
+          }
+        },
+      }),
+    );
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_unittest.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_unittest.ts
new file mode 100644
index 0000000..2cea90d
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_unittest.ts
@@ -0,0 +1,325 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {describe, expect, test, vi} from 'vitest';
+import type {Engine} from '../../trace_processor/engine';
+import {computeAxisTicks, formatTick, niceTicks} from './axis';
+import {
+  buildColorEncoding,
+  legendEntries,
+  parseCssColor,
+  type ScatterTheme,
+} from './color_scale';
+import {buildOperatorPointSet, nearest} from './point_set';
+import {
+  computeViewportRequest,
+  ScatterController,
+  shouldRequery,
+} from './scatter_controller';
+import {
+  scatterMipmapSubquerySql,
+  scatterMipmapVirtualTableSql,
+  sqlForRect,
+  viewportQuerySql,
+} from './sql_utils';
+import type {ColorMode} from './types';
+import {clampView, fitView, panView, zoomView} from './view';
+
+describe('ScatterPlot core utilities and controller', () => {
+  test('sql_utils builds escaped virtual table and viewport queries', () => {
+    expect(
+      scatterMipmapVirtualTableSql('vt_1', 'slice', 'id', 'ts', 'dur', 'depth'),
+    ).toBe(
+      'CREATE VIRTUAL TABLE "vt_1" USING __intrinsic_scatter_mipmap("slice", "id", "ts", "dur", "depth");',
+    );
+    expect(
+      scatterMipmapSubquerySql(
+        'vt_2',
+        {kind: 'query', sql: 'SELECT ts, dur FROM slice;'},
+        {x: 'ts', y: 'dur'},
+      ),
+    ).toBe(
+      'CREATE VIRTUAL TABLE "vt_2" USING __intrinsic_scatter_mipmap((SELECT NULL AS id, "ts" AS x, "dur" AS y, NULL AS c FROM (SELECT ts, dur FROM slice)));',
+    );
+    expect(
+      viewportQuerySql('vt_1', {
+        xMin: 0,
+        xMax: 100,
+        yMin: 10,
+        yMax: 50,
+        cols: 64,
+        rows: 32,
+      }),
+    ).toBe('SELECT id, x, y, c, count FROM "vt_1"(0, 100, 10, 50, 64, 32);');
+    expect(
+      viewportQuerySql('vt_1', {
+        xMin: 0,
+        xMax: 100,
+        yMin: 10,
+        yMax: 50,
+        cols: 64,
+        rows: 32,
+        hidden: [-1, 2, 5],
+      }),
+    ).toBe(
+      `SELECT id, x, y, c, count FROM "vt_1"(0, 100, 10, 50, 64, 32, '-1,2,5');`,
+    );
+    expect(
+      sqlForRect(
+        {kind: 'table', table: 'slice'},
+        {x: 'ts', y: 'dur'},
+        {xMin: 10, xMax: 20, yMin: 5, yMax: 15},
+      ),
+    ).toBe(
+      'SELECT * FROM "slice" WHERE "ts" BETWEEN 10 AND 20 AND "dur" BETWEEN 5 AND 15;',
+    );
+  });
+
+  test('view math: fitView, zoomView, panView, clampView', () => {
+    const bounds = {xMin: 100, xMax: 300, yMin: 0, yMax: 100};
+    const fitted = fitView(bounds, 0.05);
+    expect(fitted).toEqual({x0: 90, x1: 310, y0: -5, y1: 105});
+
+    const zoomed = zoomView({x0: 0, x1: 100, y0: 0, y1: 100}, 2, 2, 50, 50);
+    expect(zoomed).toEqual({x0: 25, x1: 75, y0: 25, y1: 75});
+
+    const panned = panView(zoomed, 10, -5);
+    expect(panned).toEqual({x0: 35, x1: 85, y0: 20, y1: 70});
+
+    const clamped = clampView({x0: 150, x1: 150, y0: 50, y1: 50}, bounds, 0.01);
+    expect(clamped.x1 - clamped.x0).toBeCloseTo(2);
+    expect(clamped.y1 - clamped.y0).toBeCloseTo(1);
+  });
+
+  test('axis ticks and formatting', () => {
+    const ticks = niceTicks(0, 100, 5);
+    expect(ticks).toEqual([0, 20, 40, 60, 80, 100]);
+    expect(formatTick(1500, 500)).toBe('1.5 K');
+
+    const huge = computeAxisTicks(1e12 + 10, 1e12 + 50, 4);
+    expect(huge.offset).toBe(1e12);
+    expect(huge.labels[0]).toBe('10');
+  });
+
+  test('point_set nearest picking and color encoding', () => {
+    const ps = buildOperatorPointSet(
+      {
+        x: new Float64Array([10, 50, 90]),
+        y: new Float64Array([20, 60, 80]),
+        row: new Float64Array([1, 2, 3]),
+        color: new Float32Array([0, 1, -1]),
+        weight: new Float32Array([1, 5, 2]),
+      },
+      {xMin: 0, xMax: 100, yMin: 0, yMax: 100},
+    );
+    const hit = nearest(ps, 51, 59, 1, 1, 5);
+    expect(hit?.index).toBe(1);
+    expect(nearest(ps, 0, 0, 1, 1, 5)).toBeUndefined();
+
+    expect(Array.from(parseCssColor('#4285f4'))).toEqual([66, 133, 244, 255]);
+    const enc = buildColorEncoding(
+      {
+        kind: 'categorical',
+        column: 'cat',
+        categories: ['A', 'B'],
+        counts: [10, 20],
+        hasOther: true,
+        otherCount: 5,
+        nullCount: 0,
+      },
+      {
+        palette: [new Uint8Array([255, 0, 0, 255])],
+        uniform: new Uint8Array([0, 0, 255, 255]),
+        nullColor: new Uint8Array([128, 128, 128, 255]),
+        background: new Uint8Array([255, 255, 255, 255]),
+        text: '#000',
+        grid: '#ccc',
+      },
+      new Set([1]),
+    );
+    expect(enc.lut[3]).toBe(255); // Category 0 visible
+    expect(enc.lut[7]).toBe(0); // Category 1 hidden
+  });
+
+  test('controller loads source, quantizes viewport, and skips redundant queries', async () => {
+    const fetchViewport = vi.fn(async () => ({
+      x: new Float64Array([10, 20]),
+      y: new Float64Array([30, 40]),
+      row: new Float64Array([100, 200]),
+      color: new Float32Array([0, 0]),
+      count: new Float64Array([1, 3]),
+    }));
+
+    const bounds = {xMin: 0, xMax: 1000, yMin: 0, yMax: 500};
+    const ctrl = new ScatterController({
+      engine: {} as Engine,
+      uid: 't',
+      onChange: () => {},
+      viewportDebounceMs: 0,
+      sourceFactory: () => ({
+        describe: async () => [
+          {name: 'id', kind: 'numeric'},
+          {name: 'ts', kind: 'numeric'},
+          {name: 'dur', kind: 'numeric'},
+        ],
+        prepare: async () => ({
+          count: 2,
+          bounds,
+          colorMode: {kind: 'none'},
+          fetchViewport,
+          fetchRow: async (id: number) => [{name: 'id', value: id}],
+          countInRect: async () => 2,
+          sqlForRect: () => 'SELECT * FROM "slice";',
+        }),
+        dispose: async () => {},
+        [Symbol.asyncDispose]: async () => {},
+      }),
+    });
+
+    await ctrl.setSource({kind: 'table', table: 'slice'});
+    expect(ctrl.state.kind).toBe('ready');
+    expect(ctrl.points?.count).toBe(2);
+    expect(fetchViewport).toHaveBeenCalledTimes(1);
+
+    // Small pan inside padded region at same LOD level should not re-query
+    const req = computeViewportRequest(ctrl.view!, bounds, 800, 600, 3);
+    expect(shouldRequery(ctrl.view!, bounds, req, req)).toBe(false);
+
+    // Hiding a category re-queries with it excluded server-side (cells whose
+    // representative was hidden may still contain visible points); showing it
+    // again re-queries without the filter.
+    const lastHidden = () => {
+      const calls = fetchViewport.mock.calls as unknown as Array<
+        [{hidden?: ReadonlyArray<number>}]
+      >;
+      return calls[calls.length - 1][0].hidden;
+    };
+    ctrl.toggleCategory(2);
+    await Promise.resolve();
+    expect(fetchViewport).toHaveBeenCalledTimes(2);
+    expect(lastHidden()).toEqual([2]);
+    ctrl.isolateCategory(0, [0, 1, 2]);
+    await Promise.resolve();
+    expect(fetchViewport).toHaveBeenCalledTimes(3);
+    expect(lastHidden()).toEqual([1, 2]);
+    ctrl.showAllCategories();
+    await Promise.resolve();
+    expect(fetchViewport).toHaveBeenCalledTimes(4);
+    expect(lastHidden()).toEqual([]);
+
+    await ctrl.dispose();
+  });
+
+  test('legend entries are sorted by count with Other/NULL last', () => {
+    const theme: ScatterTheme = {
+      palette: [new Uint8Array([255, 0, 0, 255])],
+      uniform: new Uint8Array([0, 0, 255, 255]),
+      nullColor: new Uint8Array([128, 128, 128, 255]),
+      background: new Uint8Array([255, 255, 255, 255]),
+      text: '#000',
+      grid: '#ccc',
+    };
+    const mode: ColorMode = {
+      kind: 'categorical',
+      column: 'name',
+      categories: ['a', 'b', 'c', 'd'],
+      counts: [5, 50, 5, 7],
+      hasOther: true,
+      otherCount: 100,
+      nullCount: 3,
+    };
+    const entries = legendEntries(
+      mode,
+      buildColorEncoding(mode, theme, new Set()),
+    );
+    expect(entries.map((e) => [e.label, e.count])).toEqual([
+      ['b', 50],
+      ['d', 7],
+      ['a', 5],
+      ['c', 5],
+      ['Other', 100],
+      ['NULL', 3],
+    ]);
+    expect(entries[4].index).toBe(4);
+    expect(entries[5].index).toBe(-1);
+
+    // No NULL rows -> no NULL entry; no Other -> no Other entry.
+    const noNull: ColorMode = {...mode, hasOther: false, nullCount: 0};
+    expect(
+      legendEntries(noNull, buildColorEncoding(noNull, theme, new Set())).map(
+        (e) => e.label,
+      ),
+    ).toEqual(['b', 'd', 'a', 'c']);
+  });
+
+  test('nearest skips hidden points', () => {
+    const ps = buildOperatorPointSet(
+      {
+        x: new Float64Array([50, 52]),
+        y: new Float64Array([50, 50]),
+        row: new Float64Array([1, 2]),
+        color: new Float32Array([0, 1]),
+      },
+      {xMin: 0, xMax: 100, yMin: 0, yMax: 100},
+    );
+    expect(nearest(ps, 50, 50, 1, 1, 5)?.index).toBe(0);
+    expect(nearest(ps, 50, 50, 1, 1, 5, (i) => i === 0)?.index).toBe(1);
+    expect(nearest(ps, 50, 50, 1, 1, 5, () => true)).toBeUndefined();
+  });
+
+  test('legend toggle / isolate / show all', () => {
+    let changes = 0;
+    const ctrl = new ScatterController({
+      engine: {} as Engine,
+      uid: 't',
+      onChange: () => changes++,
+    });
+    const all = [1, 0, 2, 3, -1];
+    const hidden = () => [...ctrl.hiddenCategories].sort((a, b) => a - b);
+
+    ctrl.toggleCategory(2);
+    expect(hidden()).toEqual([2]);
+    ctrl.toggleCategory(2);
+    expect(hidden()).toEqual([]);
+
+    // A double-click is delivered as click(detail=1) + click(detail=2): the
+    // view toggles on the first and isolates on the second. Net effect:
+    // only the clicked entry stays visible.
+    ctrl.toggleCategory(0);
+    ctrl.isolateCategory(0, all);
+    expect(hidden()).toEqual([-1, 1, 2, 3]);
+
+    // Double-clicking the isolated entry again restores everything.
+    ctrl.toggleCategory(0);
+    ctrl.isolateCategory(0, all);
+    expect(hidden()).toEqual([]);
+
+    // Double-clicking a hidden entry isolates it too.
+    ctrl.toggleCategory(3);
+    ctrl.toggleCategory(3);
+    ctrl.isolateCategory(3, all);
+    expect(hidden()).toEqual([-1, 0, 1, 2]);
+
+    // Double-clicking a different entry while one is isolated switches.
+    ctrl.toggleCategory(1);
+    ctrl.isolateCategory(1, all);
+    expect(hidden()).toEqual([-1, 0, 2, 3]);
+
+    ctrl.showAllCategories();
+    expect(hidden()).toEqual([]);
+    const before = changes;
+    ctrl.showAllCategories(); // no-op
+    expect(changes).toBe(before);
+  });
+});
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_view.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_view.ts
new file mode 100644
index 0000000..241db1b
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_plot_view.ts
@@ -0,0 +1,1275 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {DisposableStack} from '../../base/disposable_stack';
+import {bindEventListener} from '../../base/dom_utils';
+import {clamp} from '../../base/math_utils';
+import {SimpleResizeObserver} from '../../base/resize_observer';
+import {Icons} from '../../base/semantic_icons';
+import {KeyMapping} from '../../base/wasd_key_mapping';
+import {fitTickCount, formatAxisOffset, formatValue} from './axis';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {CursorTooltip} from '../../widgets/cursor_tooltip';
+import {
+  buildColorEncoding,
+  gradientCss,
+  legendEntries,
+  readScatterTheme,
+  type ScatterTheme,
+} from './color_scale';
+import type {PointRef, ScatterController} from './scatter_controller';
+import type {ColorEncoding, PointStyle, ViewRange} from './types';
+import {
+  clampView,
+  dataToScreenX,
+  dataToScreenY,
+  panView,
+  screenToDataX,
+  screenToDataY,
+  zoomView,
+} from './view';
+import {type HighlightPoint, WebGLScatterRenderer} from './webgl_renderer';
+
+export interface ScatterPlotViewAttrs {
+  readonly controller: ScatterController;
+  readonly pointStyle?: PointStyle;
+}
+
+const MARGIN_LEFT = 64;
+const MARGIN_BOTTOM = 36;
+const MARGIN_TOP = 12;
+const MARGIN_RIGHT = 12;
+
+// Zoom factor per W/S (or +/-) key press; key repeat accumulates smoothly.
+const KEY_ZOOM_FACTOR = 1.3;
+// Fraction of the visible span panned per A/D/arrow key press.
+const KEY_PAN_FRACTION = 0.1;
+
+const DEFAULT_STYLE: PointStyle = {
+  sizePx: 3,
+  opacity: 0.7,
+};
+
+interface DragState {
+  readonly startX: number;
+  readonly startY: number;
+  readonly startView: ViewRange;
+  readonly isShift: boolean;
+  currentX: number;
+  currentY: number;
+  hasMoved: boolean;
+}
+
+/**
+ * Interactive WebGL2 scatter plot component with Canvas2D overlay.
+ */
+export class ScatterPlotView implements m.ClassComponent<ScatterPlotViewAttrs> {
+  private containerDom?: HTMLElement;
+  private plotCanvas?: HTMLCanvasElement;
+  private overlayCanvas?: HTMLCanvasElement;
+  private renderer?: WebGLScatterRenderer;
+
+  private readonly trash = new DisposableStack();
+  private rafId?: number;
+  private isDirty = true;
+
+  private containerWidth = 0;
+  private containerHeight = 0;
+  private dpr = 1;
+
+  private cachedRect?: DOMRect;
+  private legendCollapsed = false;
+
+  private drag?: DragState;
+  private pendingHoverPointer?: {clientX: number; clientY: number};
+
+  private targetView?: ViewRange;
+  private animRafId?: number;
+  private lastAnimTime?: number;
+  private lastWheelCursor?: {x: number; y: number};
+  // Last pointer position in plot-relative px while the pointer is over the
+  // plot area; used as the keyboard zoom anchor.
+  private lastPointerPx?: {x: number; y: number};
+  private pointerInside = false;
+
+  private cachedEncoding?: ColorEncoding;
+  private lastColorModeJson = '';
+  private lastHiddenKey = '';
+  private lastEncodingTheme?: ScatterTheme;
+
+  private cachedHighlightRgba?: Uint8Array;
+  private lastBgLum = -1;
+
+  private cachedTheme?: ScatterTheme;
+  private cachedThemeClass = '';
+
+  private currentController?: ScatterController;
+  private currentPointStyle: PointStyle = DEFAULT_STYLE;
+
+  private getTheme(): ScatterTheme {
+    const rootClass =
+      (typeof document !== 'undefined' && document.documentElement.className) ||
+      '';
+    if (this.cachedTheme === undefined || this.cachedThemeClass !== rootClass) {
+      this.cachedThemeClass = rootClass;
+      this.cachedTheme = readScatterTheme(
+        this.containerDom ?? document.documentElement,
+      );
+    }
+    return this.cachedTheme;
+  }
+
+  oncreate(vnode: m.CVnodeDOM<ScatterPlotViewAttrs>) {
+    this.containerDom = vnode.dom as HTMLElement;
+    this.currentController = vnode.attrs.controller;
+    this.currentPointStyle = vnode.attrs.pointStyle ?? DEFAULT_STYLE;
+
+    this.trash.use(
+      bindEventListener(
+        window,
+        'scroll',
+        () => {
+          if (this.containerDom !== undefined) {
+            this.cachedRect = this.containerDom.getBoundingClientRect();
+          }
+        },
+        {passive: true, capture: true},
+      ),
+    );
+
+    this.trash.use(
+      bindEventListener(document, 'keydown', (e: KeyboardEvent) =>
+        this.onDocumentKeyDown(e),
+      ),
+    );
+
+    this.trash.use(
+      new SimpleResizeObserver(this.containerDom, () => {
+        this.handleResize();
+      }),
+    );
+    this.handleResize();
+    this.syncDomDataAttributes();
+    this.scheduleRedraw();
+  }
+
+  onupdate(vnode: m.CVnodeDOM<ScatterPlotViewAttrs>) {
+    if (vnode.dom instanceof HTMLElement) {
+      this.containerDom = vnode.dom;
+    }
+    this.currentController = vnode.attrs.controller;
+    this.currentPointStyle = vnode.attrs.pointStyle ?? DEFAULT_STYLE;
+    this.scheduleRedraw();
+  }
+
+  onremove() {
+    if (this.animRafId !== undefined) {
+      cancelAnimationFrame(this.animRafId);
+      this.animRafId = undefined;
+    }
+    if (this.rafId !== undefined) {
+      cancelAnimationFrame(this.rafId);
+      this.rafId = undefined;
+    }
+    this.trash.dispose();
+    this.renderer?.[Symbol.dispose]();
+    this.renderer = undefined;
+  }
+
+  private getContainerRect(forceRefresh = false): DOMRect {
+    if (this.cachedRect === undefined || forceRefresh) {
+      if (this.containerDom !== undefined) {
+        this.cachedRect = this.containerDom.getBoundingClientRect();
+      }
+    }
+    return (
+      this.cachedRect ??
+      new DOMRect(0, 0, this.containerWidth, this.containerHeight)
+    );
+  }
+
+  private handleResize(): void {
+    if (this.containerDom === undefined) {
+      return;
+    }
+    const rect = this.getContainerRect(true);
+    const newWidth = Math.max(0, Math.floor(rect.width));
+    const newHeight = Math.max(0, Math.floor(rect.height));
+    const newDpr = window.devicePixelRatio || 1;
+
+    if (
+      newWidth === this.containerWidth &&
+      newHeight === this.containerHeight &&
+      newDpr === this.dpr
+    ) {
+      return;
+    }
+
+    this.containerWidth = newWidth;
+    this.containerHeight = newHeight;
+    this.dpr = newDpr;
+
+    const plotW = Math.max(1, this.containerWidth - MARGIN_LEFT - MARGIN_RIGHT);
+    const plotH = Math.max(
+      1,
+      this.containerHeight - MARGIN_TOP - MARGIN_BOTTOM,
+    );
+    if (this.plotCanvas !== undefined) {
+      this.plotCanvas.style.width = `${plotW}px`;
+      this.plotCanvas.style.height = `${plotH}px`;
+    }
+
+    if (this.renderer !== undefined) {
+      this.renderer.resize(plotW, plotH, this.dpr);
+    }
+
+    if (this.overlayCanvas !== undefined) {
+      this.overlayCanvas.style.width = `${this.containerWidth}px`;
+      this.overlayCanvas.style.height = `${this.containerHeight}px`;
+      const targetW = Math.round(this.containerWidth * this.dpr);
+      const targetH = Math.round(this.containerHeight * this.dpr);
+      if (
+        this.overlayCanvas.width !== targetW ||
+        this.overlayCanvas.height !== targetH
+      ) {
+        this.overlayCanvas.width = targetW;
+        this.overlayCanvas.height = targetH;
+      }
+    }
+
+    this.currentController?.setPlotSize(
+      plotW,
+      plotH,
+      this.currentPointStyle.sizePx,
+    );
+
+    this.isDirty = true;
+    this.scheduleRedraw();
+    m.redraw();
+  }
+
+  private initRenderer(canvas: HTMLCanvasElement): void {
+    this.renderer?.[Symbol.dispose]();
+    this.renderer = WebGLScatterRenderer.create(canvas);
+    if (this.renderer !== undefined) {
+      this.renderer.onContextRestoredCallback = () => {
+        this.scheduleRedraw();
+      };
+      const plotW = Math.max(
+        1,
+        this.containerWidth - MARGIN_LEFT - MARGIN_RIGHT,
+      );
+      const plotH = Math.max(
+        1,
+        this.containerHeight - MARGIN_TOP - MARGIN_BOTTOM,
+      );
+      this.renderer.resize(plotW, plotH, this.dpr);
+    }
+  }
+
+  private scheduleRedraw(): void {
+    this.isDirty = true;
+    if (this.rafId !== undefined) {
+      return;
+    }
+    this.rafId = requestAnimationFrame(() => {
+      this.rafId = undefined;
+      if (this.isDirty) {
+        this.isDirty = false;
+        this.drawFrame();
+      }
+    });
+  }
+
+  private getPlotArea(): {
+    plotWidth: number;
+    plotHeight: number;
+  } {
+    return {
+      plotWidth: Math.max(1, this.containerWidth - MARGIN_LEFT - MARGIN_RIGHT),
+      plotHeight: Math.max(
+        1,
+        this.containerHeight - MARGIN_TOP - MARGIN_BOTTOM,
+      ),
+    };
+  }
+
+  private syncDomDataAttributes(): void {
+    if (this.containerDom === undefined) {
+      return;
+    }
+    this.containerDom.dataset.renderedPoints = String(
+      this.renderer?.lastDrawnPoints ?? 0,
+    );
+    this.containerDom.dataset.renderer = this.renderer?.kind ?? 'none';
+    const v = this.currentController?.view;
+    if (v !== undefined) {
+      this.containerDom.dataset.view = `${v.x0},${v.x1},${v.y0},${v.y1}`;
+    } else {
+      delete this.containerDom.dataset.view;
+    }
+  }
+
+  private drawFrame(): void {
+    if (
+      this.containerDom === undefined ||
+      this.overlayCanvas === undefined ||
+      this.plotCanvas === undefined
+    ) {
+      return;
+    }
+
+    const {plotWidth, plotHeight} = this.getPlotArea();
+    if (plotWidth <= 0 || plotHeight <= 0) {
+      return;
+    }
+
+    if (this.renderer === undefined) {
+      this.initRenderer(this.plotCanvas);
+    }
+    if (this.renderer?.isContextLost()) {
+      return;
+    }
+
+    const theme = this.getTheme();
+
+    if (
+      this.currentController !== undefined &&
+      this.currentController.view !== undefined
+    ) {
+      const ctrl = this.currentController;
+      const view = ctrl.view;
+      if (view !== undefined) {
+        // Throttled hover picking in rAF
+        if (this.drag === undefined && this.pendingHoverPointer !== undefined) {
+          const rect = this.containerDom.getBoundingClientRect();
+          const px = this.pendingHoverPointer.clientX - rect.left - MARGIN_LEFT;
+          const py = this.pendingHoverPointer.clientY - rect.top - MARGIN_TOP;
+
+          if (px >= 0 && px <= plotWidth && py >= 0 && py <= plotHeight) {
+            const dataX = screenToDataX(px, view, plotWidth);
+            const dataY = screenToDataY(py, view, plotHeight);
+            const scaleX = plotWidth / (view.x1 - view.x0);
+            const scaleY = plotHeight / (view.y1 - view.y0);
+            const ref = ctrl.pick(dataX, dataY, scaleX, scaleY, 8);
+            ctrl.setHover(ref);
+          } else {
+            ctrl.setHover(undefined);
+          }
+        }
+
+        // Update color encoding if the mode, hidden set or theme changed.
+        const modeJson = JSON.stringify(ctrl.colorMode);
+        const hiddenKey = Array.from(ctrl.hiddenCategories)
+          .sort((a, b) => a - b)
+          .join(',');
+        if (
+          this.cachedEncoding === undefined ||
+          this.lastColorModeJson !== modeJson ||
+          this.lastHiddenKey !== hiddenKey ||
+          this.lastEncodingTheme !== theme
+        ) {
+          this.cachedEncoding = buildColorEncoding(
+            ctrl.colorMode,
+            theme,
+            ctrl.hiddenCategories,
+          );
+          this.lastColorModeJson = modeJson;
+          this.lastHiddenKey = hiddenKey;
+          this.lastEncodingTheme = theme;
+        }
+
+        // Cache highlight ring color based on background luminance
+        const [br, bg, bb] = theme.background;
+        const lum = (0.299 * br + 0.587 * bg + 0.114 * bb) / 255;
+        if (this.cachedHighlightRgba === undefined || this.lastBgLum !== lum) {
+          this.cachedHighlightRgba =
+            lum > 0.5
+              ? new Uint8Array([20, 20, 20, 255])
+              : new Uint8Array([245, 245, 245, 255]);
+          this.lastBgLum = lum;
+        }
+
+        let highlight: HighlightPoint | undefined;
+        const pinOrHover = ctrl.pinned ?? ctrl.hover;
+        if (pinOrHover !== undefined) {
+          highlight = {
+            x: pinOrHover.points.x[pinOrHover.index],
+            y: pinOrHover.points.y[pinOrHover.index],
+            rgba: this.cachedHighlightRgba,
+          };
+        }
+
+        this.renderer?.render({
+          view,
+          points: ctrl.points,
+          style: this.currentPointStyle,
+          color: this.cachedEncoding,
+          background: theme.background,
+          highlight,
+        });
+
+        this.drawOverlay(view, ctrl, theme, plotWidth, plotHeight);
+      }
+    } else {
+      // Nothing to plot (e.g. source without numeric axes): wipe the previous
+      // frame so stale points and axes don't linger.
+      this.renderer?.clear(theme.background);
+      const ctx = this.overlayCanvas.getContext('2d');
+      ctx?.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
+    }
+
+    this.syncDomDataAttributes();
+  }
+
+  private drawOverlay(
+    view: ViewRange,
+    ctrl: ScatterController,
+    theme: ScatterTheme,
+    plotWidth: number,
+    plotHeight: number,
+  ): void {
+    const canvas = this.overlayCanvas;
+    if (canvas === null || canvas === undefined) {
+      return;
+    }
+    const targetW = Math.max(1, Math.round(this.containerWidth * this.dpr));
+    const targetH = Math.max(1, Math.round(this.containerHeight * this.dpr));
+    if (canvas.width !== targetW || canvas.height !== targetH) {
+      canvas.width = targetW;
+      canvas.height = targetH;
+    }
+    const ctx = canvas.getContext('2d');
+    if (ctx === null) {
+      return;
+    }
+
+    ctx.save();
+    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+    ctx.clearRect(0, 0, this.containerWidth, this.containerHeight);
+
+    // Compute fitted ticks and offsets
+    const maxTargetX = Math.max(2, Math.min(12, Math.floor(plotWidth / 60)));
+    const measureLabelsX = (labels: string[]): number => {
+      let maxW = 0;
+      for (const l of labels) {
+        maxW = Math.max(maxW, ctx.measureText(l).width);
+      }
+      return maxW;
+    };
+    const axisTicksX = fitTickCount(
+      view.x0,
+      view.x1,
+      maxTargetX,
+      measureLabelsX,
+      plotWidth,
+      12,
+    );
+
+    const maxTargetY = Math.max(2, Math.min(10, Math.floor(plotHeight / 40)));
+    const axisTicksY = fitTickCount(
+      view.y0,
+      view.y1,
+      maxTargetY,
+      () => 14,
+      plotHeight,
+      8,
+    );
+
+    // Clip grid lines to plot area
+    ctx.save();
+    ctx.beginPath();
+    ctx.rect(MARGIN_LEFT, MARGIN_TOP, plotWidth, plotHeight);
+    ctx.clip();
+
+    ctx.strokeStyle = theme.grid;
+    ctx.lineWidth = 1;
+
+    // Vertical grid lines
+    for (const tick of axisTicksX.ticks) {
+      const sx = MARGIN_LEFT + dataToScreenX(tick, view, plotWidth);
+      ctx.beginPath();
+      ctx.moveTo(Math.round(sx) + 0.5, MARGIN_TOP);
+      ctx.lineTo(Math.round(sx) + 0.5, MARGIN_TOP + plotHeight);
+      ctx.stroke();
+    }
+
+    // Horizontal grid lines
+    for (const tick of axisTicksY.ticks) {
+      const sy = MARGIN_TOP + dataToScreenY(tick, view, plotHeight);
+      ctx.beginPath();
+      ctx.moveTo(MARGIN_LEFT, Math.round(sy) + 0.5);
+      ctx.lineTo(MARGIN_LEFT + plotWidth, Math.round(sy) + 0.5);
+      ctx.stroke();
+    }
+
+    // Brush rectangle inside plot area
+    const brush = ctrl.brush?.rect;
+    if (this.drag !== undefined && this.drag.isShift && this.drag.hasMoved) {
+      const x1 = Math.min(this.drag.startX, this.drag.currentX);
+      const x2 = Math.max(this.drag.startX, this.drag.currentX);
+      const y1 = Math.min(this.drag.startY, this.drag.currentY);
+      const y2 = Math.max(this.drag.startY, this.drag.currentY);
+      ctx.fillStyle = 'rgba(66, 133, 244, 0.2)';
+      ctx.strokeStyle = 'rgba(66, 133, 244, 0.8)';
+      ctx.lineWidth = 1.5;
+      ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
+      ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
+    } else if (brush !== undefined) {
+      const bx0 = MARGIN_LEFT + dataToScreenX(brush.xMin, view, plotWidth);
+      const bx1 = MARGIN_LEFT + dataToScreenX(brush.xMax, view, plotWidth);
+      const by0 = MARGIN_TOP + dataToScreenY(brush.yMax, view, plotHeight);
+      const by1 = MARGIN_TOP + dataToScreenY(brush.yMin, view, plotHeight);
+      const rx = Math.min(bx0, bx1);
+      const ry = Math.min(by0, by1);
+      const rw = Math.abs(bx1 - bx0);
+      const rh = Math.abs(by1 - by0);
+      ctx.fillStyle = 'rgba(66, 133, 244, 0.15)';
+      ctx.strokeStyle = 'rgba(66, 133, 244, 0.8)';
+      ctx.lineWidth = 1.5;
+      ctx.fillRect(rx, ry, rw, rh);
+      ctx.strokeRect(rx, ry, rw, rh);
+    }
+
+    ctx.restore(); // Exit clip
+
+    // 2. Plot boundary
+    ctx.strokeStyle = theme.grid;
+    ctx.lineWidth = 1;
+    ctx.strokeRect(MARGIN_LEFT + 0.5, MARGIN_TOP + 0.5, plotWidth, plotHeight);
+
+    // 3. Tick Labels and Axis Titles
+    ctx.font = '10px Roboto, sans-serif';
+    ctx.fillStyle = theme.text;
+
+    // X Tick Labels
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'top';
+    for (let i = 0; i < axisTicksX.ticks.length; i++) {
+      const tick = axisTicksX.ticks[i];
+      const label = axisTicksX.labels[i];
+      const sx = MARGIN_LEFT + dataToScreenX(tick, view, plotWidth);
+      if (sx >= MARGIN_LEFT - 10 && sx <= MARGIN_LEFT + plotWidth + 10) {
+        ctx.fillText(label, sx, MARGIN_TOP + plotHeight + 6);
+      }
+    }
+
+    // Y Tick Labels
+    ctx.textAlign = 'right';
+    ctx.textBaseline = 'middle';
+    const maxAllowedLabelW = MARGIN_LEFT - 10;
+    for (let i = 0; i < axisTicksY.ticks.length; i++) {
+      const tick = axisTicksY.ticks[i];
+      let label = axisTicksY.labels[i];
+      const sy = MARGIN_TOP + dataToScreenY(tick, view, plotHeight);
+      if (sy >= MARGIN_TOP - 10 && sy <= MARGIN_TOP + plotHeight + 10) {
+        if (ctx.measureText(label).width > maxAllowedLabelW) {
+          while (
+            label.length > 3 &&
+            ctx.measureText(label + '…').width > maxAllowedLabelW
+          ) {
+            label = label.slice(0, -1);
+          }
+          label = label + '…';
+        }
+        ctx.fillText(label, MARGIN_LEFT - 8, sy);
+      }
+    }
+
+    // Axis titles with offset notation if present
+    ctx.font = '11px Roboto, sans-serif';
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'bottom';
+    const baseColX = ctrl.plot?.x ?? 'X';
+    const xTitle =
+      axisTicksX.offset !== 0
+        ? `${baseColX} (+${formatAxisOffset(axisTicksX.offset)})`
+        : baseColX;
+    ctx.fillText(
+      xTitle,
+      MARGIN_LEFT + plotWidth * 0.5,
+      this.containerHeight - 2,
+    );
+
+    ctx.save();
+    ctx.translate(14, MARGIN_TOP + plotHeight * 0.5);
+    ctx.rotate(-Math.PI / 2);
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'top';
+    const baseColY = ctrl.plot?.y ?? 'Y';
+    const yTitle =
+      axisTicksY.offset !== 0
+        ? `${baseColY} (+${formatAxisOffset(axisTicksY.offset)})`
+        : baseColY;
+    ctx.fillText(yTitle, 0, 0);
+    ctx.restore();
+
+    ctx.restore();
+  }
+
+  private cancelZoomAnimation(): void {
+    if (this.animRafId !== undefined) {
+      cancelAnimationFrame(this.animRafId);
+      this.animRafId = undefined;
+    }
+    this.targetView = undefined;
+    this.lastWheelCursor = undefined;
+  }
+
+  private startZoomAnimation(ctrl: ScatterController): void {
+    if (this.animRafId !== undefined) {
+      return;
+    }
+    this.lastAnimTime = performance.now();
+    const step = (now: number) => {
+      this.animRafId = undefined;
+      if (this.targetView === undefined || ctrl.view === undefined) {
+        return;
+      }
+      const dt = Math.max(1, Math.min(100, now - (this.lastAnimTime ?? now)));
+      this.lastAnimTime = now;
+      const alpha = 1 - Math.exp(-dt / 70);
+
+      const curr = ctrl.view;
+      const target = this.targetView;
+
+      const {plotWidth, plotHeight} = this.getPlotArea();
+      const spanX = curr.x1 - curr.x0;
+      const spanY = curr.y1 - curr.y0;
+      const pxDiffX0 = (Math.abs(target.x0 - curr.x0) / spanX) * plotWidth;
+      const pxDiffX1 = (Math.abs(target.x1 - curr.x1) / spanX) * plotWidth;
+      const pxDiffY0 = (Math.abs(target.y0 - curr.y0) / spanY) * plotHeight;
+      const pxDiffY1 = (Math.abs(target.y1 - curr.y1) / spanY) * plotHeight;
+      const maxPxDiff = Math.max(pxDiffX0, pxDiffX1, pxDiffY0, pxDiffY1);
+
+      if (maxPxDiff < 0.5 || alpha >= 0.99) {
+        ctrl.setView(target);
+        this.targetView = undefined;
+        this.lastWheelCursor = undefined;
+        ctrl.flushViewport();
+        this.scheduleRedraw();
+        return;
+      }
+
+      const nextView: ViewRange = {
+        x0: curr.x0 + alpha * (target.x0 - curr.x0),
+        x1: curr.x1 + alpha * (target.x1 - curr.x1),
+        y0: curr.y0 + alpha * (target.y0 - curr.y0),
+        y1: curr.y1 + alpha * (target.y1 - curr.y1),
+      };
+
+      ctrl.setView(nextView);
+      this.scheduleRedraw();
+      this.animRafId = requestAnimationFrame(step);
+    };
+    this.animRafId = requestAnimationFrame(step);
+  }
+
+  private onWheel(e: WheelEvent, ctrl: ScatterController): void {
+    e.preventDefault();
+    if (
+      this.containerDom === undefined ||
+      ctrl.view === undefined ||
+      ctrl.dataBounds === undefined
+    ) {
+      return;
+    }
+
+    const rect = this.containerDom.getBoundingClientRect();
+    const cursorX = e.clientX - rect.left - MARGIN_LEFT;
+    const cursorY = e.clientY - rect.top - MARGIN_TOP;
+    const {plotWidth, plotHeight} = this.getPlotArea();
+
+    if (
+      cursorX < 0 ||
+      cursorX > plotWidth ||
+      cursorY < 0 ||
+      cursorY > plotHeight
+    ) {
+      return;
+    }
+
+    if (
+      this.lastWheelCursor === undefined ||
+      Math.hypot(
+        cursorX - this.lastWheelCursor.x,
+        cursorY - this.lastWheelCursor.y,
+      ) > 1
+    ) {
+      this.targetView = undefined;
+      this.lastWheelCursor = {x: cursorX, y: cursorY};
+    }
+
+    let delta = e.deltaY;
+    if (e.deltaMode === 1) {
+      delta *= 20;
+    } else if (e.deltaMode === 2) {
+      delta *= 500;
+    }
+
+    const rawFactor = Math.pow(2, -delta * 0.002);
+    let factorX = rawFactor;
+    let factorY = rawFactor;
+    if (e.shiftKey && !e.altKey) {
+      factorY = 1;
+    } else if (e.altKey && !e.shiftKey) {
+      factorX = 1;
+    }
+
+    const isTrackpad = e.ctrlKey || (Math.abs(delta) < 15 && e.deltaMode === 0);
+    this.zoomAt(ctrl, factorX, factorY, {x: cursorX, y: cursorY}, !isTrackpad);
+  }
+
+  // Zooms by (factorX, factorY) (> 1 zooms in) keeping the data point under
+  // `anchorPx` (plot-relative pixels) fixed on screen. Falls back to the view
+  // centre when no anchor is given. When `animate` is set, the zoom is
+  // accumulated into `targetView` and eased towards; otherwise it is applied
+  // immediately.
+  private zoomAt(
+    ctrl: ScatterController,
+    factorX: number,
+    factorY: number,
+    anchorPx: {x: number; y: number} | undefined,
+    animate: boolean,
+  ): void {
+    if (ctrl.view === undefined || ctrl.dataBounds === undefined) {
+      return;
+    }
+    const base = this.targetView ?? ctrl.view;
+    const bounds = ctrl.dataBounds;
+    const totalSpanX = Math.max(1e-9, bounds.xMax - bounds.xMin);
+    const totalSpanY = Math.max(1e-9, bounds.yMax - bounds.yMin);
+    const minSpanX = totalSpanX * (1 / 1048576);
+    const maxSpanX = totalSpanX * 4;
+    const minSpanY = totalSpanY * (1 / 1048576);
+    const maxSpanY = totalSpanY * 4;
+    const curSpanX = base.x1 - base.x0;
+    const curSpanY = base.y1 - base.y0;
+
+    let fx = factorX;
+    let fy = factorY;
+    if (fx !== 1) {
+      fx = clamp(fx, curSpanX / maxSpanX, curSpanX / minSpanX);
+    }
+    if (fy !== 1) {
+      fy = clamp(fy, curSpanY / maxSpanY, curSpanY / minSpanY);
+    }
+    if (Math.abs(fx - 1) < 1e-7 && Math.abs(fy - 1) < 1e-7) {
+      return;
+    }
+
+    this.pendingHoverPointer = undefined;
+    ctrl.setHover(undefined);
+
+    const {plotWidth, plotHeight} = this.getPlotArea();
+    const anchorX =
+      anchorPx !== undefined
+        ? screenToDataX(anchorPx.x, base, plotWidth)
+        : (base.x0 + base.x1) * 0.5;
+    const anchorY =
+      anchorPx !== undefined
+        ? screenToDataY(anchorPx.y, base, plotHeight)
+        : (base.y0 + base.y1) * 0.5;
+
+    const next = clampView(zoomView(base, fx, fy, anchorX, anchorY), bounds);
+    if (animate) {
+      this.targetView = next;
+      this.startZoomAnimation(ctrl);
+    } else {
+      this.cancelZoomAnimation();
+      ctrl.setView(next);
+      this.scheduleRedraw();
+    }
+  }
+
+  private onPointerDown(e: PointerEvent, ctrl: ScatterController): void {
+    if (this.containerDom === undefined || ctrl.view === undefined) {
+      return;
+    }
+
+    this.cancelZoomAnimation();
+
+    this.containerDom.setPointerCapture(e.pointerId);
+    const rect = this.containerDom.getBoundingClientRect();
+    const {plotWidth, plotHeight} = this.getPlotArea();
+    const containerX = Math.max(
+      MARGIN_LEFT,
+      Math.min(MARGIN_LEFT + plotWidth, e.clientX - rect.left),
+    );
+    const containerY = Math.max(
+      MARGIN_TOP,
+      Math.min(MARGIN_TOP + plotHeight, e.clientY - rect.top),
+    );
+
+    this.drag = {
+      startX: containerX,
+      startY: containerY,
+      currentX: containerX,
+      currentY: containerY,
+      startView: ctrl.view,
+      isShift: e.shiftKey,
+      hasMoved: false,
+    };
+    this.pendingHoverPointer = undefined;
+  }
+
+  private onPointerMove(e: PointerEvent, ctrl: ScatterController): void {
+    if (this.containerDom === undefined || ctrl.view === undefined) {
+      return;
+    }
+    const rect = this.containerDom.getBoundingClientRect();
+    const {plotWidth, plotHeight} = this.getPlotArea();
+    const containerX = e.clientX - rect.left;
+    const containerY = e.clientY - rect.top;
+    const plotX = containerX - MARGIN_LEFT;
+    const plotY = containerY - MARGIN_TOP;
+    const inPlot =
+      plotX >= 0 && plotX <= plotWidth && plotY >= 0 && plotY <= plotHeight;
+    this.pointerInside = true;
+    this.lastPointerPx = inPlot ? {x: plotX, y: plotY} : undefined;
+
+    if (this.drag !== undefined) {
+      const dx = containerX - this.drag.startX;
+      const dy = containerY - this.drag.startY;
+      if (!this.drag.hasMoved && Math.hypot(dx, dy) > 3) {
+        this.drag.hasMoved = true;
+        this.pendingHoverPointer = undefined;
+        ctrl.setHover(undefined);
+      }
+
+      if (this.drag.hasMoved) {
+        if (this.drag.isShift) {
+          // Brush box selection drag clamped to plot area
+          this.drag.currentX = clamp(
+            containerX,
+            MARGIN_LEFT,
+            MARGIN_LEFT + plotWidth,
+          );
+          this.drag.currentY = clamp(
+            containerY,
+            MARGIN_TOP,
+            MARGIN_TOP + plotHeight,
+          );
+          this.scheduleRedraw();
+        } else {
+          // Pan
+          const spanX = this.drag.startView.x1 - this.drag.startView.x0;
+          const spanY = this.drag.startView.y1 - this.drag.startView.y0;
+          const dxData = (-dx / plotWidth) * spanX;
+          const dyData = (dy / plotHeight) * spanY;
+          ctrl.setView(panView(this.drag.startView, dxData, dyData));
+          this.scheduleRedraw();
+        }
+      }
+    } else {
+      // Store pending hover pointer; pick inside drawFrame()
+      this.pendingHoverPointer = {
+        clientX: e.clientX,
+        clientY: e.clientY,
+      };
+      this.scheduleRedraw();
+    }
+  }
+
+  private onPointerUp(e: PointerEvent, ctrl: ScatterController): void {
+    if (this.containerDom === undefined) {
+      return;
+    }
+    try {
+      this.containerDom.releasePointerCapture(e.pointerId);
+    } catch {
+      // Ignore if not captured
+    }
+
+    if (this.drag !== undefined) {
+      if (this.drag.isShift && this.drag.hasMoved && ctrl.view !== undefined) {
+        const {plotWidth, plotHeight} = this.getPlotArea();
+
+        const xMinPx =
+          Math.min(this.drag.startX, this.drag.currentX) - MARGIN_LEFT;
+        const xMaxPx =
+          Math.max(this.drag.startX, this.drag.currentX) - MARGIN_LEFT;
+        const yMinPx =
+          Math.min(this.drag.startY, this.drag.currentY) - MARGIN_TOP;
+        const yMaxPx =
+          Math.max(this.drag.startY, this.drag.currentY) - MARGIN_TOP;
+
+        const x0 = screenToDataX(xMinPx, ctrl.view, plotWidth);
+        const x1 = screenToDataX(xMaxPx, ctrl.view, plotWidth);
+        const y1 = screenToDataY(yMinPx, ctrl.view, plotHeight);
+        const y0 = screenToDataY(yMaxPx, ctrl.view, plotHeight);
+
+        ctrl.setBrush({
+          xMin: Math.min(x0, x1),
+          xMax: Math.max(x0, x1),
+          yMin: Math.min(y0, y1),
+          yMax: Math.max(y0, y1),
+        });
+      } else if (!this.drag.hasMoved) {
+        // Plain click pins point
+        ctrl.pin(ctrl.hover);
+      }
+      this.drag = undefined;
+      this.scheduleRedraw();
+    }
+  }
+
+  // Document-level keydown handler. Shortcuts apply while the pointer is over
+  // the plot or the plot has keyboard focus, and never while typing.
+  private onDocumentKeyDown(e: KeyboardEvent): void {
+    const ctrl = this.currentController;
+    if (ctrl === undefined || this.containerDom === undefined) {
+      return;
+    }
+    if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) {
+      return;
+    }
+    const target = e.target;
+    if (
+      target instanceof HTMLElement &&
+      (target.isContentEditable ||
+        target instanceof HTMLInputElement ||
+        target instanceof HTMLTextAreaElement ||
+        target instanceof HTMLSelectElement)
+    ) {
+      return;
+    }
+    const focused = this.containerDom.contains(document.activeElement);
+    if (!this.pointerInside && !focused) {
+      return;
+    }
+    if (this.onKeyDown(e, ctrl)) {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  // Keyboard navigation, mirroring the timeline: W/S (and +/-) zoom at the
+  // mouse cursor, A/D pan horizontally, arrow keys pan in all directions.
+  // Returns true if the key was handled.
+  private onKeyDown(e: KeyboardEvent, ctrl: ScatterController): boolean {
+    if (e.key === 'Escape') {
+      ctrl.setBrush(undefined);
+      ctrl.pin(undefined);
+      this.scheduleRedraw();
+      return true;
+    }
+    if (ctrl.view === undefined || ctrl.dataBounds === undefined) {
+      return false;
+    }
+
+    const anchor = this.pointerInside ? this.lastPointerPx : undefined;
+    if (e.code === KeyMapping.KEY_ZOOM_IN || e.key === '+' || e.key === '=') {
+      this.zoomAt(ctrl, KEY_ZOOM_FACTOR, KEY_ZOOM_FACTOR, anchor, true);
+      return true;
+    }
+    if (e.code === KeyMapping.KEY_ZOOM_OUT || e.key === '-' || e.key === '_') {
+      this.zoomAt(ctrl, 1 / KEY_ZOOM_FACTOR, 1 / KEY_ZOOM_FACTOR, anchor, true);
+      return true;
+    }
+
+    let dx = 0;
+    let dy = 0;
+    if (e.code === KeyMapping.KEY_PAN_LEFT || e.key === 'ArrowLeft') {
+      dx = -KEY_PAN_FRACTION;
+    } else if (e.code === KeyMapping.KEY_PAN_RIGHT || e.key === 'ArrowRight') {
+      dx = KEY_PAN_FRACTION;
+    } else if (e.key === 'ArrowUp') {
+      dy = KEY_PAN_FRACTION;
+    } else if (e.key === 'ArrowDown') {
+      dy = -KEY_PAN_FRACTION;
+    } else {
+      return false;
+    }
+    this.cancelZoomAnimation();
+    const view = ctrl.view;
+    ctrl.setView(
+      panView(view, dx * (view.x1 - view.x0), dy * (view.y1 - view.y0)),
+    );
+    this.scheduleRedraw();
+    return true;
+  }
+
+  private renderTooltip(ctrl: ScatterController, hover: PointRef): m.Children {
+    const xVal = hover.points.x[hover.index];
+    const yVal = hover.points.y[hover.index];
+    const colorVal = hover.points.color[hover.index];
+    const hoverDetails = ctrl.hoverDetails;
+
+    let colorDisplay: string | undefined;
+    if (ctrl.colorMode.kind === 'numeric') {
+      colorDisplay = Number.isNaN(colorVal) ? 'NULL' : formatValue(colorVal);
+    } else if (ctrl.colorMode.kind === 'categorical') {
+      const catIdx = Math.round(colorVal);
+      if (catIdx === -1) {
+        colorDisplay = 'NULL';
+      } else if (catIdx < ctrl.colorMode.categories.length) {
+        colorDisplay = ctrl.colorMode.categories[catIdx];
+      } else {
+        const colorCol = ctrl.colorMode.column;
+        const colorCell = hoverDetails?.cells.find((c) => c.name === colorCol);
+        colorDisplay =
+          colorCell !== undefined && colorCell.value !== null
+            ? String(colorCell.value)
+            : 'Other';
+      }
+    }
+
+    let tooltipTitle: string | undefined;
+    if (hoverDetails !== undefined) {
+      const nameCell = hoverDetails.cells.find((c) => c.name === 'name');
+      const idCell = hoverDetails.cells.find((c) => c.name === 'id');
+      if (nameCell !== undefined && nameCell.value !== null) {
+        tooltipTitle = String(nameCell.value);
+      } else if (idCell !== undefined && idCell.value !== null) {
+        tooltipTitle = `id: ${idCell.value}`;
+      }
+    }
+
+    const ptCount = hover.points.weight?.[hover.index];
+
+    return m(
+      CursorTooltip,
+      {
+        className: 'pf-scatter-plot-view__tooltip',
+      },
+      tooltipTitle !== undefined &&
+        m('.pf-scatter-plot-view__tooltip-title', tooltipTitle),
+      m(
+        '.pf-scatter-plot-view__tooltip-grid',
+        m('.pf-scatter-plot-view__tooltip-key', ctrl.plot?.x ?? 'x'),
+        m('.pf-scatter-plot-view__tooltip-val', formatValue(xVal)),
+        m('.pf-scatter-plot-view__tooltip-key', ctrl.plot?.y ?? 'y'),
+        m('.pf-scatter-plot-view__tooltip-val', formatValue(yVal)),
+        colorDisplay !== undefined &&
+          ctrl.colorMode.kind !== 'none' && [
+            m('.pf-scatter-plot-view__tooltip-key', ctrl.colorMode.column),
+            m('.pf-scatter-plot-view__tooltip-val', colorDisplay),
+          ],
+        ptCount !== undefined &&
+          ptCount > 1 && [
+            m('.pf-scatter-plot-view__tooltip-key', 'points'),
+            m(
+              '.pf-scatter-plot-view__tooltip-val',
+              ptCount.toLocaleString('en-US'),
+            ),
+          ],
+      ),
+    );
+  }
+
+  private renderLegendOverlay(ctrl: ScatterController): m.Children {
+    const colorMode = ctrl.colorMode;
+    if (colorMode.kind === 'none') {
+      return null;
+    }
+
+    const theme = this.getTheme();
+    const encoding =
+      this.cachedEncoding ??
+      buildColorEncoding(colorMode, theme, ctrl.hiddenCategories);
+
+    if (this.legendCollapsed) {
+      return m(
+        '.pf-scatter-legend.pf-scatter-legend--collapsed',
+        {
+          onpointerdown: (e: PointerEvent) => e.stopPropagation(),
+          onwheel: (e: WheelEvent) => e.stopPropagation(),
+        },
+        m(Button, {
+          icon: 'palette',
+          label: `Legend: ${colorMode.column}`,
+          compact: true,
+          variant: ButtonVariant.Outlined,
+          rightIcon: Icons.ExpandDown,
+          onclick: (e: MouseEvent) => {
+            e.stopPropagation();
+            this.legendCollapsed = false;
+            m.redraw();
+          },
+        }),
+      );
+    }
+
+    const entries =
+      colorMode.kind === 'categorical'
+        ? legendEntries(colorMode, encoding)
+        : [];
+    const allIndices = entries.map((entry) => entry.index);
+    const hiddenCount = ctrl.hiddenCategories.size;
+
+    return m(
+      '.pf-scatter-legend',
+      {
+        onpointerdown: (e: PointerEvent) => e.stopPropagation(),
+        onwheel: (e: WheelEvent) => e.stopPropagation(),
+        // Prevent the plot's double-click "reset view" from firing when
+        // double-clicking legend entries.
+        ondblclick: (e: MouseEvent) => e.stopPropagation(),
+      },
+      m(
+        '.pf-scatter-legend__header',
+        m('.pf-scatter-legend__title', `Legend (${colorMode.column})`),
+        hiddenCount > 0 &&
+          m(Button, {
+            label: 'Show all',
+            compact: true,
+            variant: ButtonVariant.Minimal,
+            title: `Show all categories (${hiddenCount} hidden)`,
+            onclick: (e: MouseEvent) => {
+              e.stopPropagation();
+              ctrl.showAllCategories();
+            },
+          }),
+        m(Button, {
+          icon: Icons.ExpandUp,
+          compact: true,
+          variant: ButtonVariant.Minimal,
+          title: 'Collapse legend',
+          onclick: (e: MouseEvent) => {
+            e.stopPropagation();
+            this.legendCollapsed = true;
+            m.redraw();
+          },
+        }),
+      ),
+      colorMode.kind === 'categorical' &&
+        m(
+          '.pf-scatter-legend__list',
+          entries.map((entry) => {
+            const isHidden = ctrl.hiddenCategories.has(entry.index);
+            return m(
+              '.pf-scatter-legend__item',
+              {
+                key: entry.index,
+                className: isHidden ? 'pf-scatter-legend__item--hidden' : '',
+                title:
+                  `${entry.label}: ${entry.count.toLocaleString('en-US')}` +
+                  ' points\nClick to hide/show, double-click to show only this',
+                onclick: (e: MouseEvent) => {
+                  e.stopPropagation();
+                  // The first click of a double-click toggles; the second
+                  // isolates. isolateCategory() is defined so that the net
+                  // effect of the pair is "show only this" (or, when this was
+                  // already the only visible entry, "show all").
+                  if (e.detail === 2) {
+                    ctrl.isolateCategory(entry.index, allIndices);
+                  } else if (e.detail <= 1) {
+                    ctrl.toggleCategory(entry.index);
+                  }
+                },
+              },
+              m('.pf-scatter-legend__swatch', {
+                style: {backgroundColor: entry.css},
+              }),
+              m('.pf-scatter-legend__label', entry.label),
+              m(
+                '.pf-scatter-legend__count',
+                entry.count.toLocaleString('en-US'),
+              ),
+            );
+          }),
+        ),
+      colorMode.kind === 'numeric' &&
+        m(
+          '.pf-scatter-legend__gradient-container',
+          m('.pf-scatter-legend__gradient-bar', {
+            style: {background: gradientCss()},
+          }),
+          m(
+            '.pf-scatter-legend__gradient-labels',
+            m('span', colorMode.min.toLocaleString()),
+            m('span', colorMode.max.toLocaleString()),
+          ),
+        ),
+    );
+  }
+
+  view({attrs}: m.CVnode<ScatterPlotViewAttrs>) {
+    this.currentController = attrs.controller;
+    this.currentPointStyle = attrs.pointStyle ?? DEFAULT_STYLE;
+
+    const {plotWidth, plotHeight} = this.getPlotArea();
+    this.currentController?.setPlotSize(
+      plotWidth,
+      plotHeight,
+      this.currentPointStyle.sizePx,
+    );
+
+    const hover = attrs.controller.hover;
+
+    return m(
+      '.pf-scatter-plot-view',
+      {
+        tabIndex: 0,
+        onwheel: (e: WheelEvent) => this.onWheel(e, attrs.controller),
+        onpointerdown: (e: PointerEvent) =>
+          this.onPointerDown(e, attrs.controller),
+        onpointermove: (e: PointerEvent) =>
+          this.onPointerMove(e, attrs.controller),
+        onpointerup: (e: PointerEvent) => this.onPointerUp(e, attrs.controller),
+        onpointerleave: () => {
+          this.pointerInside = false;
+          this.lastPointerPx = undefined;
+          this.pendingHoverPointer = undefined;
+          attrs.controller.setHover(undefined);
+          this.scheduleRedraw();
+        },
+        ondblclick: () => attrs.controller.resetView(),
+      },
+      m(
+        '.pf-scatter-plot-view__canvas-wrapper',
+        m('canvas.pf-scatter-plot-view__canvas', {
+          style: {
+            left: `${MARGIN_LEFT}px`,
+            top: `${MARGIN_TOP}px`,
+            width: `${plotWidth}px`,
+            height: `${plotHeight}px`,
+          },
+          oncreate: (vnode: m.VnodeDOM) => {
+            this.plotCanvas = vnode.dom as HTMLCanvasElement;
+            this.initRenderer(this.plotCanvas);
+            this.scheduleRedraw();
+          },
+        }),
+      ),
+      m('canvas.pf-scatter-plot-view__overlay', {
+        style: {
+          left: '0px',
+          top: '0px',
+          width: `${this.containerWidth}px`,
+          height: `${this.containerHeight}px`,
+        },
+        oncreate: (vnode: m.VnodeDOM) => {
+          this.overlayCanvas = vnode.dom as HTMLCanvasElement;
+          this.overlayCanvas.width = Math.max(
+            1,
+            Math.round(this.containerWidth * this.dpr),
+          );
+          this.overlayCanvas.height = Math.max(
+            1,
+            Math.round(this.containerHeight * this.dpr),
+          );
+          this.scheduleRedraw();
+        },
+        onupdate: (vnode: m.VnodeDOM) => {
+          this.overlayCanvas = vnode.dom as HTMLCanvasElement;
+        },
+      }),
+      this.renderLegendOverlay(attrs.controller),
+      hover !== undefined ? this.renderTooltip(attrs.controller, hover) : null,
+    );
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_source.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_source.ts
new file mode 100644
index 0000000..6bedea4
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/scatter_source.ts
@@ -0,0 +1,463 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {AsyncDisposableStack} from '../../base/disposable_stack';
+import type {Engine} from '../../trace_processor/engine';
+import {
+  NUM,
+  NUM_NULL,
+  type QueryResult,
+  type SqlValue,
+} from '../../trace_processor/query_result';
+import {
+  createView,
+  createVirtualTable,
+  fromNumNull,
+} from '../../trace_processor/sql_utils';
+import {
+  type ColumnKind,
+  type ColumnInfo,
+  type ColorMode,
+  type DataBounds,
+  NULL_CATEGORY,
+  type PlotSpec,
+  type PointColumns,
+  type SourceSpec,
+  TOP_N_CATEGORIES,
+} from './types';
+import {
+  countInRectSql,
+  includeModuleSql,
+  rowFetchByOffsetSql,
+  rowFetchSql,
+  scatterMipmapSubqueryArgs,
+  scatterMipmapTableArgs,
+  sourceSubquery,
+  sqlForRect,
+  viewportQuerySql,
+  vtableCategoriesQuerySql,
+  vtableStatsQuerySql,
+} from './sql_utils';
+
+export interface ViewportRequest {
+  readonly xMin: number;
+  readonly xMax: number;
+  readonly yMin: number;
+  readonly yMax: number;
+  readonly cols: number;
+  readonly rows: number;
+  /**
+   * Category indexes to exclude (NULL_CATEGORY for NULL). Excluded points are
+   * neither counted nor returned as cell representatives.
+   */
+  readonly hidden?: ReadonlyArray<number>;
+}
+
+export interface PreparedSource {
+  readonly count: number;
+  readonly bounds: DataBounds;
+  readonly colorMode: ColorMode;
+  fetchViewport(
+    req: ViewportRequest,
+  ): Promise<PointColumns & {readonly count: Float64Array}>;
+  fetchRow(
+    id: number,
+  ): Promise<ReadonlyArray<{readonly name: string; readonly value: SqlValue}>>;
+  countInRect(rect: DataBounds): Promise<number>;
+  sqlForRect(rect: DataBounds): string;
+}
+
+function decodeViewportColumns(
+  result: QueryResult,
+  colorMode: ColorMode,
+): PointColumns & {readonly count: Float64Array} {
+  const nullValue = colorMode.kind === 'categorical' ? -1 : NaN;
+
+  if (typeof result.decodeColumns === 'function') {
+    const decoded = result.decodeColumns({
+      id: NUM,
+      x: NUM,
+      y: NUM,
+      c: NUM_NULL,
+      count: NUM,
+    });
+    const n = decoded.x.length;
+    const color = new Float32Array(n);
+    const c = decoded.c;
+    for (let i = 0; i < n; ++i) {
+      color[i] = fromNumNull(c[i]) ?? nullValue;
+    }
+    return {
+      x: decoded.x,
+      y: decoded.y,
+      row: decoded.id,
+      color,
+      count: decoded.count,
+    };
+  }
+
+  const n = result.numRows();
+  const x = new Float64Array(n);
+  const y = new Float64Array(n);
+  const row = new Float64Array(n);
+  const color = new Float32Array(n);
+  const count = new Float64Array(n);
+  let i = 0;
+  for (
+    const it = result.iter({
+      id: NUM,
+      x: NUM,
+      y: NUM,
+      c: NUM_NULL,
+      count: NUM,
+    });
+    it.valid();
+    it.next(), i++
+  ) {
+    row[i] = it.id;
+    x[i] = it.x;
+    y[i] = it.y;
+    color[i] = fromNumNull(it.c) ?? nullValue;
+    count[i] = it.count;
+  }
+  return {x, y, row, color, count};
+}
+
+class PreparedSourceImpl implements PreparedSource {
+  constructor(
+    private readonly engine: Engine,
+    private readonly spec: SourceSpec,
+    private readonly plot: PlotSpec,
+    private readonly vtTable: string,
+    private readonly hasId: boolean,
+    readonly count: number,
+    readonly bounds: DataBounds,
+    readonly colorMode: ColorMode,
+  ) {}
+
+  async fetchViewport(
+    req: ViewportRequest,
+  ): Promise<PointColumns & {readonly count: Float64Array}> {
+    if (this.count === 0 || req.cols <= 0 || req.rows <= 0) {
+      return {
+        x: new Float64Array(0),
+        y: new Float64Array(0),
+        row: new Float64Array(0),
+        color: new Float32Array(0),
+        count: new Float64Array(0),
+      };
+    }
+    const result = await this.engine.query(viewportQuerySql(this.vtTable, req));
+    return decodeViewportColumns(result, this.colorMode);
+  }
+
+  async fetchRow(
+    id: number,
+  ): Promise<ReadonlyArray<{readonly name: string; readonly value: SqlValue}>> {
+    const sql =
+      this.spec.kind === 'table' && this.hasId
+        ? rowFetchSql(this.spec.table, id, 'id')
+        : rowFetchByOffsetSql(sourceSubquery(this.spec), id);
+    const result = await this.engine.query(sql);
+    const it = result.iter({});
+    if (!it.valid()) return [];
+    return result.columns().map((name) => ({name, value: it.get(name)}));
+  }
+
+  async countInRect(rect: DataBounds): Promise<number> {
+    const sql = countInRectSql(sourceSubquery(this.spec), this.plot, rect);
+    const result = await this.engine.query(sql);
+    return result.firstRow({count: NUM}).count;
+  }
+
+  sqlForRect(rect: DataBounds): string {
+    return sqlForRect(this.spec, this.plot, rect);
+  }
+}
+
+export class ScatterSource implements AsyncDisposable {
+  private nextGen = 0;
+  private latestSuccessfulGen = 0;
+  private activeResources = new AsyncDisposableStack();
+  private disposed = false;
+
+  constructor(
+    private readonly engine: Engine,
+    private readonly uid: string,
+  ) {}
+
+  async describe(spec: SourceSpec): Promise<ColumnInfo[]> {
+    if (spec.kind === 'table' && spec.module) {
+      await this.engine.query(includeModuleSql(spec.module));
+    }
+    const result = await this.engine.query(
+      `SELECT * FROM ${sourceSubquery(spec)} LIMIT 1000;`,
+    );
+    const colNames = result.columns();
+    if (colNames.length === 0) return [];
+
+    const stats = new Map(
+      colNames.map((name) => [
+        name,
+        {
+          hasNonNull: false,
+          hasString: false,
+          hasNumeric: false,
+          hasOther: false,
+        },
+      ]),
+    );
+
+    for (const it = result.iter({}); it.valid(); it.next()) {
+      for (const name of colNames) {
+        const val = it.get(name);
+        if (val === null) continue;
+        const st = stats.get(name);
+        if (st === undefined) continue;
+        st.hasNonNull = true;
+        if (typeof val === 'string') {
+          st.hasString = true;
+        } else if (typeof val === 'number' || typeof val === 'bigint') {
+          st.hasNumeric = true;
+        } else {
+          st.hasOther = true;
+        }
+      }
+    }
+
+    return colNames.map((name) => {
+      const st = stats.get(name);
+      let kind: ColumnKind = 'other';
+      if (st !== undefined) {
+        if (st.hasString) {
+          kind = 'string';
+        } else if (st.hasNonNull && st.hasNumeric && !st.hasOther) {
+          kind = 'numeric';
+        }
+      }
+      return {name, kind};
+    });
+  }
+
+  async prepare(spec: SourceSpec, plot: PlotSpec): Promise<PreparedSource> {
+    if (this.disposed) throw new Error('ScatterSource is disposed');
+    const gen = ++this.nextGen;
+    const vtTable = `__scatter_vt_${this.uid}_${gen}`;
+    const genResources = new AsyncDisposableStack();
+
+    try {
+      if (spec.kind === 'table' && spec.module) {
+        await this.engine.query(includeModuleSql(spec.module));
+      }
+
+      const cols = await this.describe(spec);
+      const colMap = new Map(cols.map((c) => [c.name, c.kind]));
+      for (const [axis, col] of [
+        ['X', plot.x],
+        ['Y', plot.y],
+      ] as const) {
+        if (!colMap.has(col)) {
+          throw new Error(`${axis} column "${col}" not found in source`);
+        }
+        if (colMap.get(col) !== 'numeric') {
+          throw new Error(
+            `${axis} column "${col}" must be numeric, got "${colMap.get(col)}"`,
+          );
+        }
+      }
+
+      const hasId = spec.kind === 'table' && colMap.get('id') === 'numeric';
+      let colorMode: ColorMode;
+      let isCategorical = false;
+      let isDirectColor = false;
+
+      if (plot.color === undefined || plot.color === '') {
+        colorMode = {kind: 'none'};
+        isDirectColor = true;
+      } else {
+        if (!colMap.has(plot.color)) {
+          throw new Error(`Color column "${plot.color}" not found in source`);
+        }
+        if (colMap.get(plot.color) === 'numeric') {
+          isDirectColor = true;
+          colorMode = {kind: 'numeric', column: plot.color, min: 0, max: 0};
+        } else {
+          isCategorical = true;
+          colorMode = {
+            kind: 'categorical',
+            column: plot.color,
+            categories: [],
+            counts: [],
+            hasOther: false,
+            otherCount: 0,
+            nullCount: 0,
+          };
+        }
+      }
+
+      let usingArgs: string;
+      if (isCategorical) {
+        const colorCol = plot.color ?? '';
+        let sourceTable: string;
+        if (spec.kind === 'table') {
+          sourceTable = spec.table;
+        } else {
+          const tempView = `_scatter_view_${this.uid}_${gen}`;
+          genResources.use(
+            await createView({
+              engine: this.engine,
+              name: tempView,
+              as: `SELECT * FROM ${sourceSubquery(spec)}`,
+            }),
+          );
+          sourceTable = tempView;
+        }
+        usingArgs = scatterMipmapTableArgs(
+          sourceTable,
+          hasId ? 'id' : '',
+          plot.x,
+          plot.y,
+          colorCol,
+          TOP_N_CATEGORIES,
+        );
+      } else {
+        const cCol = colorMode.kind === 'numeric' ? plot.color : undefined;
+        usingArgs =
+          spec.kind === 'table' && hasId && isDirectColor
+            ? scatterMipmapTableArgs(spec.table, 'id', plot.x, plot.y, cCol)
+            : scatterMipmapSubqueryArgs(spec, plot, cCol);
+      }
+
+      genResources.use(
+        await createVirtualTable({
+          engine: this.engine,
+          name: vtTable,
+          using: `__intrinsic_scatter_mipmap${usingArgs}`,
+        }),
+      );
+
+      const [statsRes, catRes] = await Promise.all([
+        this.engine.query(vtableStatsQuerySql(vtTable)),
+        isCategorical
+          ? this.engine.query(vtableCategoriesQuerySql(vtTable))
+          : Promise.resolve(undefined),
+      ]);
+
+      if (isCategorical && catRes !== undefined) {
+        const catRows: Array<{idx: number; value: string; count: number}> = [];
+        let hasOther = false;
+        let otherCount = 0;
+        let nullCount = 0;
+        for (
+          const it = catRes.iter({out_cat_idx: NUM, out_cat_count: NUM});
+          it.valid();
+          it.next()
+        ) {
+          const idx = it.out_cat_idx;
+          if (idx === TOP_N_CATEGORIES) {
+            hasOther = true;
+            otherCount = it.out_cat_count;
+          } else if (idx === NULL_CATEGORY) {
+            nullCount = it.out_cat_count;
+          } else if (idx >= 0 && idx < TOP_N_CATEGORIES) {
+            const rawVal = it.get('out_cat_value');
+            catRows.push({
+              idx,
+              value: rawVal === null ? '' : String(rawVal),
+              count: it.out_cat_count,
+            });
+          }
+        }
+        catRows.sort((a, b) => a.idx - b.idx);
+        colorMode = {
+          kind: 'categorical',
+          column: plot.color ?? '',
+          categories: catRows.map((r) => r.value),
+          counts: catRows.map((r) => r.count),
+          hasOther,
+          otherCount,
+          nullCount,
+        };
+      }
+
+      let count = 0;
+      let bounds: DataBounds = {xMin: 0, xMax: 0, yMin: 0, yMax: 0};
+      let cMin = 0;
+      let cMax = 0;
+
+      const statsIt = statsRes.iter({
+        out_count: NUM,
+        out_x_min: NUM_NULL,
+        out_x_max: NUM_NULL,
+        out_y_min: NUM_NULL,
+        out_y_max: NUM_NULL,
+        out_c_min: NUM_NULL,
+        out_c_max: NUM_NULL,
+      });
+      if (statsIt.valid()) {
+        count = statsIt.out_count;
+        bounds = {
+          xMin: fromNumNull(statsIt.out_x_min) ?? 0,
+          xMax: fromNumNull(statsIt.out_x_max) ?? 0,
+          yMin: fromNumNull(statsIt.out_y_min) ?? 0,
+          yMax: fromNumNull(statsIt.out_y_max) ?? 0,
+        };
+        cMin = fromNumNull(statsIt.out_c_min) ?? 0;
+        cMax = fromNumNull(statsIt.out_c_max) ?? 0;
+      }
+
+      if (colorMode.kind === 'numeric' && plot.color !== undefined) {
+        colorMode = {kind: 'numeric', column: plot.color, min: cMin, max: cMax};
+      }
+
+      if (this.disposed || gen < this.latestSuccessfulGen) {
+        await genResources.asyncDispose();
+        throw new Error(
+          this.disposed
+            ? 'ScatterSource was disposed during prepare'
+            : `Prepare generation ${gen} superseded by generation ${this.latestSuccessfulGen}`,
+        );
+      }
+
+      const oldResources = this.activeResources;
+      this.activeResources = genResources.move();
+      this.latestSuccessfulGen = gen;
+      await oldResources.asyncDispose();
+
+      return new PreparedSourceImpl(
+        this.engine,
+        spec,
+        plot,
+        vtTable,
+        hasId,
+        count,
+        bounds,
+        colorMode,
+      );
+    } catch (err) {
+      await genResources.asyncDispose();
+      throw err;
+    }
+  }
+
+  async dispose(): Promise<void> {
+    if (this.disposed) return;
+    this.disposed = true;
+    await this.activeResources.asyncDispose();
+  }
+
+  async [Symbol.asyncDispose](): Promise<void> {
+    await this.dispose();
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/source_picker.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/source_picker.ts
new file mode 100644
index 0000000..3469be5
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/source_picker.ts
@@ -0,0 +1,255 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import type {Trace} from '../../public/trace';
+import {Button, ButtonVariant} from '../../widgets/button';
+import {closeModal, showModal} from '../../widgets/modal';
+import {Popup} from '../../widgets/popup';
+import {Editor} from '../../widgets/editor';
+import {TextInput} from '../../widgets/text_input';
+import {Spinner} from '../../widgets/spinner';
+import {STR} from '../../trace_processor/query_result';
+import SqlModulesPlugin from '../dev.perfetto.SqlModules';
+import type {SourceSpec} from './types';
+
+export interface TableItem {
+  readonly name: string;
+  readonly module?: string;
+  readonly type?: string;
+}
+
+export interface SourcePickerAttrs {
+  readonly trace: Trace;
+  readonly currentSource?: SourceSpec;
+  readonly onSelect: (spec: SourceSpec) => void;
+}
+
+/**
+ * Loads available tables from trace processor and SqlModules.
+ */
+export async function loadAvailableTables(trace: Trace): Promise<TableItem[]> {
+  const tableMap = new Map<string, TableItem>();
+
+  // 1. Fetch tables and views from sqlite_master
+  try {
+    const res = await trace.engine.query(
+      `SELECT name, type FROM sqlite_master
+       WHERE type IN ('table', 'view')
+         AND name NOT LIKE 'sqlite_%'
+         AND name NOT LIKE '\\_%' ESCAPE '\\'
+       ORDER BY name`,
+    );
+    const it = res.iter({name: STR, type: STR});
+    for (; it.valid(); it.next()) {
+      tableMap.set(it.name, {
+        name: it.name,
+        type: it.type,
+      });
+    }
+  } catch {
+    // Ignore query failure
+  }
+
+  // 2. Try fetching from perfetto_tables if available
+  try {
+    const res = await trace.engine.query(
+      `SELECT name FROM perfetto_tables
+       WHERE name NOT LIKE '\\_%' ESCAPE '\\'
+       ORDER BY name`,
+    );
+    const it = res.iter({name: STR});
+    for (; it.valid(); it.next()) {
+      if (!tableMap.has(it.name)) {
+        tableMap.set(it.name, {
+          name: it.name,
+          type: 'table',
+        });
+      }
+    }
+  } catch {
+    // Ignore if table does not exist
+  }
+
+  // 3. Add stdlib tables from SqlModulesPlugin
+  try {
+    const sqlPlugin = trace.plugins.getPlugin(SqlModulesPlugin);
+    const sqlModules = sqlPlugin?.getSqlModules();
+    if (sqlModules !== undefined) {
+      for (const table of sqlModules.listTables()) {
+        tableMap.set(table.name, {
+          name: table.name,
+          module: table.includeKey,
+          type: table.type,
+        });
+      }
+    }
+  } catch {
+    // Ignore plugin error
+  }
+
+  return Array.from(tableMap.values()).sort((a, b) =>
+    a.name.localeCompare(b.name),
+  );
+}
+
+/**
+ * Opens a full-screen modal allowing the user to enter and execute a custom SQL query.
+ */
+export function openCustomQueryModal(
+  initialSql: string = '',
+  onSelect: (spec: SourceSpec) => void,
+): void {
+  let queryText = initialSql;
+
+  showModal({
+    title: 'Plot Custom SQL Query',
+    buttons: [
+      {
+        text: 'Plot',
+        primary: true,
+        action: () => {
+          const sql = queryText.trim();
+          if (sql.length > 0) {
+            onSelect({kind: 'query', sql});
+          }
+        },
+      },
+      {
+        text: 'Cancel',
+      },
+    ],
+    content: () =>
+      m(
+        '.pf-scatter-query-modal',
+        m(
+          '.pf-scatter-query-modal__editor-container',
+          m(Editor, {
+            text: queryText,
+            language: 'perfetto-sql',
+            fillHeight: true,
+            autofocus: true,
+            onUpdate: (text) => {
+              queryText = text;
+            },
+            onExecute: (text) => {
+              const sql = text.trim();
+              if (sql.length > 0) {
+                closeModal();
+                onSelect({kind: 'query', sql});
+              }
+            },
+          }),
+        ),
+      ),
+  });
+}
+
+/**
+ * SourcePicker component providing fuzzy search over tables plus custom query entry.
+ */
+export class SourcePicker implements m.ClassComponent<SourcePickerAttrs> {
+  private filterText = '';
+  private tables: TableItem[] = [];
+  private isLoading = true;
+
+  oninit({attrs}: m.CVnode<SourcePickerAttrs>) {
+    this.isLoading = true;
+    loadAvailableTables(attrs.trace)
+      .then((items) => {
+        this.tables = items;
+        this.isLoading = false;
+        m.redraw();
+      })
+      .catch(() => {
+        this.tables = [];
+        this.isLoading = false;
+        m.redraw();
+      });
+  }
+
+  view({attrs}: m.CVnode<SourcePickerAttrs>) {
+    const filter = this.filterText.trim().toLowerCase();
+    const filteredTables =
+      filter.length === 0
+        ? this.tables
+        : this.tables.filter((t) => {
+            return (
+              t.name.toLowerCase().includes(filter) ||
+              (t.module !== undefined &&
+                t.module.toLowerCase().includes(filter))
+            );
+          });
+
+    return m(
+      '.pf-scatter-source-picker',
+      m(TextInput, {
+        className: 'pf-scatter-source-picker__search',
+        placeholder: 'Search tables...',
+        leftIcon: 'search',
+        autofocus: true,
+        value: this.filterText,
+        onInput: (val: string) => {
+          this.filterText = val;
+        },
+      }),
+      m(
+        '.pf-scatter-source-picker__list',
+        this.isLoading && m(Spinner),
+        !this.isLoading &&
+          filteredTables.length === 0 &&
+          m('.pf-scatter-source-picker__empty', 'No matching tables'),
+        !this.isLoading &&
+          filteredTables.map((table) => {
+            const badge = table.module ?? table.type;
+
+            return m(
+              'button.pf-scatter-source-picker__item',
+              {
+                key: table.name,
+                // Close the enclosing PopupMenu once a table is picked.
+                className: Popup.DISMISS_POPUP_GROUP_CLASS,
+                onclick: () => {
+                  attrs.onSelect({
+                    kind: 'table',
+                    table: table.name,
+                    module: table.module,
+                  });
+                },
+              },
+              m('.pf-scatter-source-picker__item-name', table.name),
+              badge !== undefined &&
+                m('.pf-scatter-source-picker__item-badge', badge),
+            );
+          }),
+      ),
+      m(
+        '.pf-scatter-source-picker__custom-btn',
+        m(Button, {
+          label: 'Custom SQL query...',
+          icon: 'terminal',
+          variant: ButtonVariant.Minimal,
+          className: Popup.DISMISS_POPUP_GROUP_CLASS,
+          onclick: () => {
+            const initial =
+              attrs.currentSource?.kind === 'query'
+                ? attrs.currentSource.sql
+                : '';
+            openCustomQueryModal(initial, attrs.onSelect);
+          },
+        }),
+      ),
+    );
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/sql_utils.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/sql_utils.ts
new file mode 100644
index 0000000..48c4703
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/sql_utils.ts
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {stripTrailingSemicolons} from '../../trace_processor/sql_utils';
+import type {DataBounds, PlotSpec, SourceSpec} from './types';
+
+export function quoteIdentifier(name: string): string {
+  return `"${name.replace(/"/g, '""')}"`;
+}
+
+export function formatNumber(n: number): string {
+  if (!Number.isFinite(n)) {
+    throw new Error(`Non-finite number cannot be formatted for SQL: ${n}`);
+  }
+  return String(n);
+}
+
+export function sourceSubquery(spec: SourceSpec): string {
+  if (spec.kind === 'table') {
+    if (spec.table.trim().length === 0) {
+      throw new Error('Table name cannot be empty');
+    }
+    return quoteIdentifier(spec.table);
+  }
+  const sql = stripTrailingSemicolons(spec.sql).trim();
+  if (sql.length === 0) {
+    throw new Error('SQL query cannot be empty');
+  }
+  return `(${sql})`;
+}
+
+export function includeModuleSql(module: string): string {
+  const trimmed = module.trim();
+  if (trimmed.length === 0) {
+    throw new Error('Module name cannot be empty');
+  }
+  return `INCLUDE PERFETTO MODULE ${trimmed};`;
+}
+
+export function scatterMipmapSubqueryArgs(
+  spec: SourceSpec,
+  plot: PlotSpec,
+  colorCol?: string,
+): string {
+  const fromClause = sourceSubquery(spec);
+  const xCol = quoteIdentifier(plot.x);
+  const yCol = quoteIdentifier(plot.y);
+  const cExpr =
+    colorCol !== undefined && colorCol !== ''
+      ? quoteIdentifier(colorCol)
+      : 'NULL';
+  return `((SELECT NULL AS id, ${xCol} AS x, ${yCol} AS y, ${cExpr} AS c FROM ${fromClause}))`;
+}
+
+export function scatterMipmapTableArgs(
+  tableName: string,
+  idCol: string,
+  xCol: string,
+  yCol: string,
+  cCol?: string,
+  topN?: number,
+): string {
+  const idArg = idCol ? quoteIdentifier(idCol) : "''";
+  const cArg =
+    cCol !== undefined && cCol !== '' ? `, ${quoteIdentifier(cCol)}` : '';
+  const catArg = topN !== undefined ? `, ${formatNumber(topN)}` : '';
+  return `(${quoteIdentifier(tableName)}, ${idArg}, ${quoteIdentifier(xCol)}, ${quoteIdentifier(yCol)}${cArg}${catArg})`;
+}
+
+export function scatterMipmapSubquerySql(
+  vtTable: string,
+  spec: SourceSpec,
+  plot: PlotSpec,
+  colorCol?: string,
+): string {
+  return `CREATE VIRTUAL TABLE ${quoteIdentifier(vtTable)} USING __intrinsic_scatter_mipmap${scatterMipmapSubqueryArgs(spec, plot, colorCol)};`;
+}
+
+export function scatterMipmapVirtualTableSql(
+  vtTable: string,
+  tableName: string,
+  idCol: string,
+  xCol: string,
+  yCol: string,
+  cCol?: string,
+  topN?: number,
+): string {
+  return `CREATE VIRTUAL TABLE ${quoteIdentifier(vtTable)} USING __intrinsic_scatter_mipmap${scatterMipmapTableArgs(tableName, idCol, xCol, yCol, cCol, topN)};`;
+}
+
+export function vtableStatsQuerySql(vtTable: string): string {
+  return (
+    `SELECT out_count, out_x_min, out_x_max, out_y_min, out_y_max, out_c_min, out_c_max ` +
+    `FROM ${quoteIdentifier(vtTable)}(NULL, NULL, NULL, NULL, 1, 1) LIMIT 1;`
+  );
+}
+
+export function vtableCategoriesQuerySql(vtTable: string): string {
+  return (
+    `SELECT out_cat_idx, out_cat_value, out_cat_count ` +
+    `FROM ${quoteIdentifier(vtTable)}(NULL, NULL, NULL, NULL, 0, 0);`
+  );
+}
+
+export function viewportQuerySql(
+  vtTable: string,
+  req: {
+    readonly xMin: number;
+    readonly xMax: number;
+    readonly yMin: number;
+    readonly yMax: number;
+    readonly cols: number;
+    readonly rows: number;
+    readonly hidden?: ReadonlyArray<number>;
+  },
+): string {
+  const hidden = (req.hidden ?? []).filter((i) => Number.isInteger(i));
+  const hiddenArg = hidden.length > 0 ? `, '${hidden.join(',')}'` : '';
+  return (
+    `SELECT id, x, y, c, count FROM ${quoteIdentifier(vtTable)}(` +
+    `${formatNumber(req.xMin)}, ${formatNumber(req.xMax)}, ` +
+    `${formatNumber(req.yMin)}, ${formatNumber(req.yMax)}, ` +
+    `${formatNumber(Math.round(req.cols))}, ${formatNumber(Math.round(req.rows))}` +
+    `${hiddenArg});`
+  );
+}
+
+export function rowFetchSql(
+  tableName: string,
+  id: number,
+  idCol = 'id',
+): string {
+  return `SELECT * FROM ${quoteIdentifier(tableName)} WHERE ${quoteIdentifier(idCol)} = ${formatNumber(id)};`;
+}
+
+export function rowFetchByOffsetSql(
+  fromSource: string,
+  offset: number,
+): string {
+  return `SELECT * FROM ${fromSource} LIMIT 1 OFFSET ${formatNumber(offset)};`;
+}
+
+function rectWhereClause(plot: PlotSpec, rect: DataBounds): string {
+  const xCol = quoteIdentifier(plot.x);
+  const yCol = quoteIdentifier(plot.y);
+  const xMin = formatNumber(Math.min(rect.xMin, rect.xMax));
+  const xMax = formatNumber(Math.max(rect.xMin, rect.xMax));
+  const yMin = formatNumber(Math.min(rect.yMin, rect.yMax));
+  const yMax = formatNumber(Math.max(rect.yMin, rect.yMax));
+  return `WHERE ${xCol} BETWEEN ${xMin} AND ${xMax} AND ${yCol} BETWEEN ${yMin} AND ${yMax}`;
+}
+
+export function countInRectSql(
+  fromSource: string,
+  plot: PlotSpec,
+  rect: DataBounds,
+): string {
+  return `SELECT COUNT(*) AS count FROM ${fromSource} ${rectWhereClause(plot, rect)};`;
+}
+
+export function sqlForRect(
+  source: SourceSpec,
+  plot: PlotSpec,
+  rect: DataBounds,
+): string {
+  return `SELECT * FROM ${sourceSubquery(source)} ${rectWhereClause(plot, rect)};`;
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/styles.scss b/ui/src/plugins/dev.perfetto.ScatterPlot/styles.scss
new file mode 100644
index 0000000..da6cbad
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/styles.scss
@@ -0,0 +1,546 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+.pf-scatter-page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+  background-color: var(--pf-color-background);
+  color: var(--pf-color-text);
+  font-family: inherit;
+
+  &__toolbar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 12px;
+    border-bottom: 1px solid var(--pf-color-border);
+    background-color: var(--pf-color-background-secondary);
+    user-select: none;
+    flex-shrink: 0;
+  }
+
+  &__control-group {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    font-weight: 500;
+  }
+
+  &__control-label {
+    color: var(--pf-color-text-muted);
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+  }
+
+  &__status {
+    margin-left: auto;
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    font-size: 12px;
+    color: var(--pf-color-text-muted);
+  }
+
+  &__progress {
+    width: 100%;
+    height: 3px;
+  }
+
+  &__main {
+    flex: 1 1 0;
+    min-height: 0;
+    display: flex;
+    position: relative;
+    overflow: hidden;
+  }
+
+  &__content {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    > .pf-scatter-plot-view {
+      position: absolute;
+      inset: 0;
+    }
+  }
+
+  // DrawerPanel overlaid on the plot: its main area is transparent and lets
+  // pointer events through to the plot; only the handle and drawer capture.
+  &__drawer {
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+
+    .pf-drawer-panel__main {
+      overflow: hidden;
+      align-items: center;
+    }
+
+    .pf-drawer-panel__handle,
+    .pf-drawer-panel__drawer,
+    .pf-scatter-page__error {
+      pointer-events: auto;
+    }
+
+    .pf-drawer-panel__drawer {
+      background-color: var(--pf-color-background);
+    }
+  }
+
+  &__empty-container {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    padding: 24px;
+    overflow-y: auto;
+  }
+
+  &__suggestions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 12px;
+    justify-content: center;
+  }
+
+  &__error {
+    margin: 16px;
+    background-color: var(--pf-color-background);
+    box-shadow: 0 2px 8px var(--pf-color-box-shadow);
+  }
+}
+
+.pf-scatter-plot-view {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  flex: 1 1 0;
+  min-height: 0;
+  max-height: 100%;
+  overflow: hidden;
+  outline: none;
+  cursor: crosshair;
+  user-select: none;
+  background-color: var(--pf-color-background);
+
+  &__canvas {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+  }
+
+  &__overlay {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+  }
+
+  &__tooltip {
+    padding: 6px 10px;
+    font-family: var(--pf-font-compact);
+    font-size: 11px;
+    line-height: 1.4;
+    white-space: nowrap;
+    max-width: 280px;
+    color: var(--pf-color-text);
+  }
+
+  &__tooltip-title {
+    font-weight: 600;
+    margin-bottom: 4px;
+    padding-bottom: 4px;
+    border-bottom: 1px solid var(--pf-color-border);
+    color: var(--pf-color-text);
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  // Two-column grid so keys and values line up across every row.
+  &__tooltip-grid {
+    display: grid;
+    grid-template-columns: max-content minmax(0, 1fr);
+    column-gap: 12px;
+    row-gap: 2px;
+    align-items: baseline;
+  }
+
+  &__tooltip-key {
+    color: var(--pf-color-text-muted);
+  }
+
+  &__tooltip-val {
+    font-family: var(--pf-font-monospace, monospace);
+    font-variant-numeric: tabular-nums;
+    text-align: right;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.pf-scatter-legend {
+  position: absolute;
+  top: 14px;
+  right: 14px;
+  z-index: 10;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-width: 260px;
+  min-width: 170px;
+  padding: 8px 10px;
+  background: color-mix(
+    in srgb,
+    var(--pf-color-background-secondary) 85%,
+    transparent
+  );
+  border: 1px solid var(--pf-color-border);
+  border-radius: 6px;
+  box-shadow: 0 2px 8px var(--pf-color-box-shadow);
+  backdrop-filter: blur(4px);
+  user-select: none;
+  font-size: 11px;
+
+  &--collapsed {
+    min-width: auto;
+    max-width: none;
+    padding: 0;
+    background: transparent;
+    border: none;
+    box-shadow: none;
+  }
+
+  &__header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 6px;
+  }
+
+  &__title {
+    flex: 1;
+    min-width: 0;
+    font-weight: 600;
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    color: var(--pf-color-text);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__list {
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    max-height: 220px;
+    overflow-y: auto;
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    cursor: pointer;
+    padding: 2px 4px;
+    border-radius: 3px;
+
+    &:hover {
+      background-color: var(--pf-color-background);
+    }
+
+    &--hidden {
+      opacity: 0.35;
+    }
+
+    &--hidden .pf-scatter-legend__label {
+      text-decoration: line-through;
+    }
+  }
+
+  &__swatch {
+    width: 10px;
+    height: 10px;
+    border-radius: 2px;
+    flex-shrink: 0;
+    border: 1px solid var(--pf-color-border-secondary);
+  }
+
+  &__label {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 11px;
+  }
+
+  &__count {
+    flex-shrink: 0;
+    padding-left: 8px;
+    color: var(--pf-color-text-muted);
+    font-size: 10px;
+    font-variant-numeric: tabular-nums;
+  }
+
+  &__gradient-container {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+  }
+
+  &__gradient-bar {
+    width: 100%;
+    height: 10px;
+    border-radius: 2px;
+    border: 1px solid var(--pf-color-border-secondary);
+  }
+
+  &__gradient-labels {
+    display: flex;
+    justify-content: space-between;
+    font-size: 9px;
+    color: var(--pf-color-text-muted);
+    font-family: monospace;
+  }
+}
+
+.pf-scatter-drawer {
+  &__pane {
+    padding: 10px 14px;
+    height: 100%;
+    box-sizing: border-box;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    font-size: 12px;
+
+    &--details,
+    &--selection {
+      padding: 0;
+      overflow: hidden;
+    }
+  }
+
+  &__empty {
+    padding: 16px;
+    color: var(--pf-color-text-muted);
+    font-size: 12px;
+    font-style: italic;
+  }
+
+  &__details-toolbar,
+  &__selection-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 6px 12px;
+    border-bottom: 1px solid var(--pf-color-border);
+    background: var(--pf-color-background-secondary);
+    flex-shrink: 0;
+    gap: 8px;
+    flex-wrap: wrap;
+  }
+
+  &__real-panel {
+    flex: 1 1 0;
+    min-height: 0;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+
+    .pf-details-shell {
+      height: 100%;
+      min-height: 0;
+    }
+  }
+
+  &__grid-container {
+    flex: 1 1 0;
+    min-height: 0;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+
+    .pf-data-grid {
+      height: 100%;
+      width: 100%;
+    }
+  }
+
+  &__toolbar-left {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 0 4px;
+  }
+
+  &__title {
+    font-weight: 600;
+    font-size: 12px;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+  }
+
+  &__count {
+    font-size: 12px;
+    color: var(--pf-color-text-muted);
+  }
+
+  &__action-row {
+    display: flex;
+    gap: 6px;
+    flex-wrap: wrap;
+    align-items: center;
+  }
+
+  &__actions {
+    display: flex;
+    gap: 8px;
+    flex-wrap: wrap;
+    align-items: center;
+  }
+
+  &__cell-val {
+    font-family: monospace;
+    cursor: pointer;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.pf-scatter-help-menu {
+  padding: 8px 12px;
+  min-width: 220px;
+  font-size: 12px;
+
+  &__list {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    margin-top: 6px;
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+  }
+
+  &__desc {
+    color: var(--pf-color-text-muted);
+    font-size: 11px;
+    text-align: right;
+  }
+}
+
+.pf-scatter-source-picker {
+  display: flex;
+  flex-direction: column;
+  max-height: 400px;
+  width: 320px;
+  padding: 8px;
+  gap: 8px;
+
+  &__search {
+    width: 100%;
+  }
+
+  &__list {
+    flex: 1 1 auto;
+    overflow-y: auto;
+    max-height: 280px;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+  }
+
+  &__item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 6px 8px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-size: 12px;
+    border: none;
+    background: transparent;
+    text-align: left;
+    color: var(--pf-color-text);
+
+    &:hover {
+      background-color: var(--pf-color-background-secondary);
+    }
+  }
+
+  &__item-name {
+    font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__item-badge {
+    font-size: 10px;
+    color: var(--pf-color-text-muted);
+    background-color: var(--pf-color-background-tertiary);
+    padding: 1px 4px;
+    border-radius: 3px;
+    margin-left: 6px;
+    flex-shrink: 0;
+  }
+
+  &__custom-btn {
+    border-top: 1px solid var(--pf-color-border);
+    padding-top: 8px;
+  }
+}
+
+.pf-scatter-query-modal {
+  display: flex;
+  flex-direction: column;
+  width: 600px;
+  height: 360px;
+  gap: 12px;
+
+  &__editor-container {
+    flex: 1 1 auto;
+    min-height: 240px;
+    border: 1px solid var(--pf-color-border);
+    border-radius: 4px;
+    overflow: hidden;
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/types.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/types.ts
new file mode 100644
index 0000000..4ba64ea
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/types.ts
@@ -0,0 +1,105 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export interface DataBounds {
+  readonly xMin: number;
+  readonly xMax: number;
+  readonly yMin: number;
+  readonly yMax: number;
+}
+
+export interface ViewRange {
+  readonly x0: number;
+  readonly x1: number;
+  readonly y0: number;
+  readonly y1: number;
+}
+
+export interface PointColumns {
+  readonly x: Float64Array;
+  readonly y: Float64Array;
+  readonly row: Float64Array;
+  readonly color: Float32Array;
+  readonly weight?: Float32Array;
+}
+
+export interface PointSet {
+  readonly id: number;
+  readonly count: number;
+  readonly bounds: DataBounds;
+  readonly x: Float64Array;
+  readonly y: Float64Array;
+  readonly row: Float64Array;
+  readonly color: Float32Array;
+  readonly weight?: Float32Array;
+}
+
+export const NULL_CATEGORY = -1;
+export const TOP_N_CATEGORIES = 20;
+export const NUMERIC_LUT_SIZE = 256;
+
+export type ColorMode =
+  | {readonly kind: 'none'}
+  | {
+      readonly kind: 'numeric';
+      readonly column: string;
+      readonly min: number;
+      readonly max: number;
+    }
+  | {
+      readonly kind: 'categorical';
+      readonly column: string;
+      // Top-N category values; index i is the colour index i.
+      readonly categories: ReadonlyArray<string>;
+      // Row count for each entry in `categories` (same order).
+      readonly counts: ReadonlyArray<number>;
+      readonly hasOther: boolean;
+      // Rows bucketed into "Other" / with a NULL colour value.
+      readonly otherCount: number;
+      readonly nullCount: number;
+    };
+
+export interface PointStyle {
+  readonly sizePx: number;
+  readonly opacity: number;
+}
+
+export interface ColorEncoding {
+  readonly kind: 'uniform' | 'numeric' | 'categorical';
+  readonly lut: Uint8Array;
+  readonly nullColor: Uint8Array;
+  readonly min: number;
+  readonly max: number;
+}
+
+export type ColumnKind = 'numeric' | 'string' | 'other';
+
+export interface ColumnInfo {
+  readonly name: string;
+  readonly kind: ColumnKind;
+}
+
+export type SourceSpec =
+  | {
+      readonly kind: 'table';
+      readonly table: string;
+      readonly module?: string;
+    }
+  | {readonly kind: 'query'; readonly sql: string};
+
+export interface PlotSpec {
+  readonly x: string;
+  readonly y: string;
+  readonly color?: string;
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/view.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/view.ts
new file mode 100644
index 0000000..43ed192
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/view.ts
@@ -0,0 +1,177 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {clamp} from '../../base/math_utils';
+import type {DataBounds, ViewRange} from './types';
+
+function fitAxis(
+  min: number,
+  max: number,
+  pad: number,
+): {readonly v0: number; readonly v1: number} {
+  const span = max - min;
+  if (span <= 0) {
+    const s = Math.max(Math.abs(min) * 0.1, 1);
+    return {v0: min - s * 0.5, v1: max + s * 0.5};
+  }
+  const p = span * pad;
+  return {v0: min - p, v1: max + p};
+}
+
+export function fitView(bounds: DataBounds, paddingFraction = 0.05): ViewRange {
+  const pad = Math.max(0, paddingFraction);
+  const {v0: x0, v1: x1} = fitAxis(bounds.xMin, bounds.xMax, pad);
+  const {v0: y0, v1: y1} = fitAxis(bounds.yMin, bounds.yMax, pad);
+  return {x0, x1, y0, y1};
+}
+
+export function zoomView(
+  view: ViewRange,
+  factorX: number,
+  factorY: number,
+  anchorX: number,
+  anchorY: number,
+): ViewRange {
+  const fx = Number.isFinite(factorX) && factorX > 0 ? factorX : 1;
+  const fy = Number.isFinite(factorY) && factorY > 0 ? factorY : 1;
+  const ax = Number.isFinite(anchorX) ? anchorX : (view.x0 + view.x1) * 0.5;
+  const ay = Number.isFinite(anchorY) ? anchorY : (view.y0 + view.y1) * 0.5;
+  return {
+    x0: ax - (ax - view.x0) / fx,
+    x1: ax + (view.x1 - ax) / fx,
+    y0: ay - (ay - view.y0) / fy,
+    y1: ay + (view.y1 - ay) / fy,
+  };
+}
+
+export function panView(
+  view: ViewRange,
+  dxData: number,
+  dyData: number,
+): ViewRange {
+  const dx = Number.isFinite(dxData) ? dxData : 0;
+  const dy = Number.isFinite(dyData) ? dyData : 0;
+  return {
+    x0: view.x0 + dx,
+    x1: view.x1 + dx,
+    y0: view.y0 + dy,
+    y1: view.y1 + dy,
+  };
+}
+
+function clampAxis(
+  v0: number,
+  v1: number,
+  bMin: number,
+  bMax: number,
+  minSpanFraction: number,
+): {readonly lo: number; readonly hi: number} {
+  const dataSpan =
+    bMax - bMin > 0 ? bMax - bMin : Math.max(Math.abs(bMin) * 0.1, 1);
+  let lo = Math.min(v0, v1);
+  let hi = Math.max(v0, v1);
+  const maxAbs = Math.max(Math.abs(lo), Math.abs(hi));
+  const minSpan = Math.max(
+    minSpanFraction * dataSpan,
+    Number.EPSILON * maxAbs * 64,
+  );
+  const maxSpan = Math.max(10 * dataSpan, minSpan);
+
+  const span = clamp(hi - lo, minSpan, maxSpan);
+  const margin = 10 * dataSpan;
+  const c = clamp((lo + hi) * 0.5, bMin - margin, bMax + margin);
+  lo = c - span * 0.5;
+  hi = c + span * 0.5;
+  return {lo, hi};
+}
+
+export function clampView(
+  view: ViewRange,
+  bounds: DataBounds,
+  minSpanFraction = 1 / 1048576,
+): ViewRange {
+  if (
+    !Number.isFinite(view.x0) ||
+    !Number.isFinite(view.x1) ||
+    !Number.isFinite(view.y0) ||
+    !Number.isFinite(view.y1)
+  ) {
+    return fitView(bounds);
+  }
+  const {lo: x0, hi: x1} = clampAxis(
+    view.x0,
+    view.x1,
+    bounds.xMin,
+    bounds.xMax,
+    minSpanFraction,
+  );
+  const {lo: y0, hi: y1} = clampAxis(
+    view.y0,
+    view.y1,
+    bounds.yMin,
+    bounds.yMax,
+    minSpanFraction,
+  );
+  if (
+    !Number.isFinite(x0) ||
+    !Number.isFinite(x1) ||
+    !Number.isFinite(y0) ||
+    !Number.isFinite(y1)
+  ) {
+    return fitView(bounds);
+  }
+  return {x0, x1, y0, y1};
+}
+
+export function dataToScreenX(
+  x: number,
+  view: ViewRange,
+  plotWidthPx: number,
+): number {
+  const span = view.x1 - view.x0;
+  return span === 0 ? 0 : ((x - view.x0) / span) * plotWidthPx;
+}
+
+export function dataToScreenY(
+  y: number,
+  view: ViewRange,
+  plotHeightPx: number,
+): number {
+  const span = view.y1 - view.y0;
+  return span === 0 ? 0 : ((view.y1 - y) / span) * plotHeightPx;
+}
+
+export function screenToDataX(
+  px: number,
+  view: ViewRange,
+  plotWidthPx: number,
+): number {
+  return plotWidthPx === 0
+    ? view.x0
+    : view.x0 + (px / plotWidthPx) * (view.x1 - view.x0);
+}
+
+export function screenToDataY(
+  py: number,
+  view: ViewRange,
+  plotHeightPx: number,
+): number {
+  return plotHeightPx === 0
+    ? view.y1
+    : view.y1 - (py / plotHeightPx) * (view.y1 - view.y0);
+}
+
+export function viewsEqual(a: ViewRange, b: ViewRange): boolean {
+  return a.x0 === b.x0 && a.x1 === b.x1 && a.y0 === b.y0 && a.y1 === b.y1;
+}
diff --git a/ui/src/plugins/dev.perfetto.ScatterPlot/webgl_renderer.ts b/ui/src/plugins/dev.perfetto.ScatterPlot/webgl_renderer.ts
new file mode 100644
index 0000000..9d6b73f
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.ScatterPlot/webgl_renderer.ts
@@ -0,0 +1,635 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {ensureExists} from '../../base/assert';
+import {
+  createProgram,
+  getAttribLocation,
+  getUniformLocation,
+} from '../../base/gl/gl';
+import {clamp} from '../../base/math_utils';
+import type {ColorEncoding, PointSet, PointStyle, ViewRange} from './types';
+
+export interface HighlightPoint {
+  readonly x: number;
+  readonly y: number;
+  readonly rgba: Uint8Array;
+}
+
+export interface RenderFrame {
+  readonly view: ViewRange;
+  readonly points?: PointSet;
+  readonly style: PointStyle;
+  readonly color: ColorEncoding;
+  readonly background: Uint8Array;
+  readonly highlight?: HighlightPoint;
+}
+
+interface CachedGpuSet {
+  readonly vao: WebGLVertexArrayObject;
+  readonly bufX: WebGLBuffer;
+  readonly bufY: WebGLBuffer;
+  readonly bufC: WebGLBuffer;
+  readonly bufW?: WebGLBuffer;
+  readonly originX: number;
+  readonly originY: number;
+}
+
+const MAX_CACHED_SETS = 2;
+
+export function splitHiLo(
+  values: Float64Array,
+  origin: number,
+  count: number,
+): Float32Array {
+  const out = new Float32Array(count * 2);
+  for (let i = 0; i < count; i++) {
+    const d = values[i] - origin;
+    const hi = Math.fround(d);
+    out[i * 2] = hi;
+    out[i * 2 + 1] = Math.fround(d - hi);
+  }
+  return out;
+}
+
+const MAIN_VS_SOURCE = `#version 300 es
+precision highp float;
+
+in vec2 a_x;
+in vec2 a_y;
+in float a_c;
+in float a_w;
+
+uniform vec2 u_vx;
+uniform vec2 u_vy;
+uniform vec2 u_scale;
+uniform float u_pointSize;
+
+uniform sampler2D u_lut;
+uniform int u_lutSize;
+uniform int u_colorMode;
+uniform vec2 u_numericRange;
+uniform vec4 u_nullColor;
+
+out vec4 v_color;
+out float v_weight;
+
+void main() {
+  float dx = (a_x.x - u_vx.x) + (a_x.y - u_vx.y);
+  float dy = (a_y.x - u_vy.x) + (a_y.y - u_vy.y);
+
+  gl_Position = vec4(dx * u_scale.x * 2.0 - 1.0, dy * u_scale.y * 2.0 - 1.0, 0.0, 1.0);
+
+  float w = max(1.0, a_w);
+  v_weight = w;
+  gl_PointSize = u_pointSize + clamp(log2(w) * 0.35, 0.0, 2.0);
+
+  vec4 color;
+  if (u_colorMode == 0) {
+    color = texelFetch(u_lut, ivec2(0, 0), 0);
+  } else if (u_colorMode == 1) {
+    if (a_c < -1.0e38) {
+      color = u_nullColor;
+    } else {
+      float t = clamp((a_c - u_numericRange.x) * u_numericRange.y, 0.0, 1.0);
+      int idx = clamp(int(floor(t * float(u_lutSize - 1) + 0.5)), 0, u_lutSize - 1);
+      color = texelFetch(u_lut, ivec2(idx, 0), 0);
+    }
+  } else {
+    if (a_c < -0.5) {
+      color = u_nullColor;
+    } else {
+      int catIdx = clamp(int(round(a_c)), 0, u_lutSize - 1);
+      color = texelFetch(u_lut, ivec2(catIdx, 0), 0);
+    }
+  }
+
+  v_color = color;
+  if (color.a <= 0.0) {
+    gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
+    gl_PointSize = 0.0;
+  }
+}
+`;
+
+const MAIN_FS_SOURCE = `#version 300 es
+precision highp float;
+
+in vec4 v_color;
+in float v_weight;
+uniform float u_opacity;
+
+out vec4 outColor;
+
+void main() {
+  if (v_color.a <= 0.0) discard;
+  vec2 coord = gl_PointCoord - vec2(0.5);
+  float dist = length(coord);
+  if (dist > 0.5) discard;
+  float delta = clamp(fwidth(dist), 0.001, 0.2);
+  float alpha = 1.0 - smoothstep(0.5 - delta, 0.5, dist);
+  if (alpha <= 0.0) discard;
+  float weightAlphaBoost = clamp(log2(max(1.0, v_weight)) * 0.04, 0.0, 0.2);
+  float finalAlpha = v_color.a * min(1.0, u_opacity + weightAlphaBoost) * alpha;
+  outColor = vec4(v_color.rgb * finalAlpha, finalAlpha);
+}
+`;
+
+const HIGHLIGHT_VS_SOURCE = `#version 300 es
+precision highp float;
+uniform vec2 u_clipPos;
+uniform float u_ringSize;
+void main() {
+  gl_Position = vec4(u_clipPos, 0.0, 1.0);
+  gl_PointSize = u_ringSize;
+}
+`;
+
+const HIGHLIGHT_FS_SOURCE = `#version 300 es
+precision highp float;
+uniform vec4 u_ringColor;
+uniform float u_ringSize;
+out vec4 outColor;
+void main() {
+  vec2 coord = gl_PointCoord - vec2(0.5);
+  float d = abs(length(coord) * u_ringSize - (u_ringSize * 0.5 - 1.5));
+  if (d > 2.0) discard;
+  float alpha = 1.0 - smoothstep(0.5, 2.0, d);
+  if (alpha <= 0.0) discard;
+  float finalAlpha = u_ringColor.a * alpha;
+  outColor = vec4(u_ringColor.rgb * finalAlpha, finalAlpha);
+}
+`;
+
+export class WebGLScatterRenderer implements Disposable {
+  readonly kind = 'webgl' as const;
+
+  private readonly canvas: HTMLCanvasElement;
+  private readonly gl: WebGL2RenderingContext;
+  private width = 0;
+  private height = 0;
+  private dpr = 1;
+  private isLost = false;
+  private isDisposed = false;
+  private _lastDrawnPoints = 0;
+
+  private mainProgram: WebGLProgram | null = null;
+  private aXLoc = -1;
+  private aYLoc = -1;
+  private aCLoc = -1;
+  private aWLoc = -1;
+  private uVxLoc: WebGLUniformLocation | null = null;
+  private uVyLoc: WebGLUniformLocation | null = null;
+  private uScaleLoc: WebGLUniformLocation | null = null;
+  private uPointSizeLoc: WebGLUniformLocation | null = null;
+  private uOpacityLoc: WebGLUniformLocation | null = null;
+  private uLutLoc: WebGLUniformLocation | null = null;
+  private uLutSizeLoc: WebGLUniformLocation | null = null;
+  private uColorModeLoc: WebGLUniformLocation | null = null;
+  private uNumericRangeLoc: WebGLUniformLocation | null = null;
+  private uNullColorLoc: WebGLUniformLocation | null = null;
+
+  private highlightProgram: WebGLProgram | null = null;
+  private uHighlightClipPosLoc: WebGLUniformLocation | null = null;
+  private uHighlightRingSizeLoc: WebGLUniformLocation | null = null;
+  private uHighlightRingColorLoc: WebGLUniformLocation | null = null;
+  private highlightVao: WebGLVertexArrayObject | null = null;
+
+  private lutTexture: WebGLTexture | null = null;
+  private lastLutBuffer: Uint8Array | null = null;
+  private readonly gpuCache = new Map<number, CachedGpuSet>();
+
+  private readonly uVxScratch = new Float32Array(2);
+  private readonly uVyScratch = new Float32Array(2);
+  private readonly uScaleScratch = new Float32Array(2);
+  private readonly uNumericRangeScratch = new Float32Array(2);
+  private readonly uNullColorScratch = new Float32Array(4);
+  private readonly uHighlightClipScratch = new Float32Array(2);
+  private readonly uHighlightColorScratch = new Float32Array(4);
+
+  private minPointSize = 1;
+  private maxPointSize = 100;
+
+  private readonly onContextLost = (e: Event): void => {
+    e.preventDefault();
+    this.isLost = true;
+    this.dropCaches();
+  };
+
+  public onContextRestoredCallback?: () => void;
+
+  private readonly onContextRestored = (): void => {
+    this.isLost = false;
+    this.initGL();
+    this.onContextRestoredCallback?.();
+  };
+
+  private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
+    this.canvas = canvas;
+    this.gl = gl;
+    canvas.addEventListener('webglcontextlost', this.onContextLost, false);
+    canvas.addEventListener(
+      'webglcontextrestored',
+      this.onContextRestored,
+      false,
+    );
+    this.initGL();
+  }
+
+  static create(canvas: HTMLCanvasElement): WebGLScatterRenderer | undefined {
+    try {
+      const gl = canvas.getContext('webgl2', {
+        premultipliedAlpha: true,
+        antialias: false,
+        preserveDrawingBuffer: false,
+      });
+      return gl !== null ? new WebGLScatterRenderer(canvas, gl) : undefined;
+    } catch {
+      return undefined;
+    }
+  }
+
+  private initGL(): void {
+    const gl = this.gl;
+    if (gl.isContextLost()) return;
+
+    const sizeRange = gl.getParameter(
+      gl.ALIASED_POINT_SIZE_RANGE,
+    ) as Float32Array;
+    if (sizeRange.length >= 2) {
+      this.minPointSize = sizeRange[0];
+      this.maxPointSize = sizeRange[1];
+    }
+
+    this.mainProgram = createProgram(gl, MAIN_VS_SOURCE, MAIN_FS_SOURCE);
+    this.aXLoc = getAttribLocation(gl, this.mainProgram, 'a_x');
+    this.aYLoc = getAttribLocation(gl, this.mainProgram, 'a_y');
+    this.aCLoc = getAttribLocation(gl, this.mainProgram, 'a_c');
+    this.aWLoc = getAttribLocation(gl, this.mainProgram, 'a_w');
+
+    this.uVxLoc = getUniformLocation(gl, this.mainProgram, 'u_vx');
+    this.uVyLoc = getUniformLocation(gl, this.mainProgram, 'u_vy');
+    this.uScaleLoc = getUniformLocation(gl, this.mainProgram, 'u_scale');
+    this.uPointSizeLoc = getUniformLocation(
+      gl,
+      this.mainProgram,
+      'u_pointSize',
+    );
+    this.uOpacityLoc = getUniformLocation(gl, this.mainProgram, 'u_opacity');
+    this.uLutLoc = getUniformLocation(gl, this.mainProgram, 'u_lut');
+    this.uLutSizeLoc = getUniformLocation(gl, this.mainProgram, 'u_lutSize');
+    this.uColorModeLoc = getUniformLocation(
+      gl,
+      this.mainProgram,
+      'u_colorMode',
+    );
+    this.uNumericRangeLoc = getUniformLocation(
+      gl,
+      this.mainProgram,
+      'u_numericRange',
+    );
+    this.uNullColorLoc = getUniformLocation(
+      gl,
+      this.mainProgram,
+      'u_nullColor',
+    );
+
+    this.highlightProgram = createProgram(
+      gl,
+      HIGHLIGHT_VS_SOURCE,
+      HIGHLIGHT_FS_SOURCE,
+    );
+    this.uHighlightClipPosLoc = getUniformLocation(
+      gl,
+      this.highlightProgram,
+      'u_clipPos',
+    );
+    this.uHighlightRingSizeLoc = getUniformLocation(
+      gl,
+      this.highlightProgram,
+      'u_ringSize',
+    );
+    this.uHighlightRingColorLoc = getUniformLocation(
+      gl,
+      this.highlightProgram,
+      'u_ringColor',
+    );
+    this.highlightVao = gl.createVertexArray();
+
+    this.lutTexture = gl.createTexture();
+    gl.bindTexture(gl.TEXTURE_2D, this.lutTexture);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    this.lastLutBuffer = null;
+  }
+
+  private dropCaches(): void {
+    this.gpuCache.clear();
+    this.mainProgram = null;
+    this.highlightProgram = null;
+    this.highlightVao = null;
+    this.lutTexture = null;
+    this.lastLutBuffer = null;
+  }
+
+  private deleteCachedSet(id: number): void {
+    const entry = this.gpuCache.get(id);
+    if (entry === undefined) return;
+    const gl = this.gl;
+    gl.deleteVertexArray(entry.vao);
+    gl.deleteBuffer(entry.bufX);
+    gl.deleteBuffer(entry.bufY);
+    gl.deleteBuffer(entry.bufC);
+    if (entry.bufW !== undefined) gl.deleteBuffer(entry.bufW);
+    this.gpuCache.delete(id);
+  }
+
+  private getOrUploadGpuSet(set: PointSet): CachedGpuSet {
+    const cached = this.gpuCache.get(set.id);
+    if (cached !== undefined) {
+      this.gpuCache.delete(set.id);
+      this.gpuCache.set(set.id, cached);
+      return cached;
+    }
+    if (this.gpuCache.size >= MAX_CACHED_SETS) {
+      const oldestId = this.gpuCache.keys().next().value;
+      if (oldestId !== undefined) this.deleteCachedSet(oldestId);
+    }
+
+    const gl = this.gl;
+    const originX = set.bounds.xMin;
+    const originY = set.bounds.yMin;
+    const count = set.count;
+    const xHiLo = splitHiLo(set.x, originX, count);
+    const yHiLo = splitHiLo(set.y, originY, count);
+
+    const cData = new Float32Array(count);
+    for (let i = 0; i < count; i++) {
+      const v = set.color[i];
+      cData[i] = Number.isNaN(v) ? -3.4e38 : v;
+    }
+
+    const vao = ensureExists(gl.createVertexArray());
+    gl.bindVertexArray(vao);
+
+    const bufX = ensureExists(gl.createBuffer());
+    gl.bindBuffer(gl.ARRAY_BUFFER, bufX);
+    gl.bufferData(gl.ARRAY_BUFFER, xHiLo, gl.STATIC_DRAW);
+    gl.enableVertexAttribArray(this.aXLoc);
+    gl.vertexAttribPointer(this.aXLoc, 2, gl.FLOAT, false, 0, 0);
+
+    const bufY = ensureExists(gl.createBuffer());
+    gl.bindBuffer(gl.ARRAY_BUFFER, bufY);
+    gl.bufferData(gl.ARRAY_BUFFER, yHiLo, gl.STATIC_DRAW);
+    gl.enableVertexAttribArray(this.aYLoc);
+    gl.vertexAttribPointer(this.aYLoc, 2, gl.FLOAT, false, 0, 0);
+
+    const bufC = ensureExists(gl.createBuffer());
+    gl.bindBuffer(gl.ARRAY_BUFFER, bufC);
+    gl.bufferData(gl.ARRAY_BUFFER, cData, gl.STATIC_DRAW);
+    gl.enableVertexAttribArray(this.aCLoc);
+    gl.vertexAttribPointer(this.aCLoc, 1, gl.FLOAT, false, 0, 0);
+
+    let bufW: WebGLBuffer | undefined;
+    if (set.weight !== undefined && this.aWLoc !== -1) {
+      bufW = ensureExists(gl.createBuffer());
+      gl.bindBuffer(gl.ARRAY_BUFFER, bufW);
+      gl.bufferData(gl.ARRAY_BUFFER, set.weight, gl.STATIC_DRAW);
+      gl.enableVertexAttribArray(this.aWLoc);
+      gl.vertexAttribPointer(this.aWLoc, 1, gl.FLOAT, false, 0, 0);
+    } else if (this.aWLoc !== -1) {
+      gl.disableVertexAttribArray(this.aWLoc);
+      gl.vertexAttrib1f(this.aWLoc, 1.0);
+    }
+
+    gl.bindVertexArray(null);
+    const gpuSet: CachedGpuSet = {
+      vao,
+      bufX,
+      bufY,
+      bufC,
+      bufW,
+      originX,
+      originY,
+    };
+    this.gpuCache.set(set.id, gpuSet);
+    return gpuSet;
+  }
+
+  resize(cssWidth: number, cssHeight: number, dpr: number): void {
+    this.dpr = dpr;
+    const targetW = Math.max(1, Math.round(cssWidth * dpr));
+    const targetH = Math.max(1, Math.round(cssHeight * dpr));
+    if (this.canvas.width !== targetW || this.canvas.height !== targetH) {
+      this.canvas.width = targetW;
+      this.canvas.height = targetH;
+    }
+    this.width = targetW;
+    this.height = targetH;
+  }
+
+  /** Clears the canvas to the background colour without drawing points. */
+  clear(background: Uint8Array): void {
+    this._lastDrawnPoints = 0;
+    if (this.isLost || this.isDisposed || this.gl.isContextLost()) return;
+    const gl = this.gl;
+    gl.viewport(0, 0, this.width, this.height);
+    gl.clearColor(
+      background[0] / 255,
+      background[1] / 255,
+      background[2] / 255,
+      background[3] / 255,
+    );
+    gl.clear(gl.COLOR_BUFFER_BIT);
+  }
+
+  render(frame: RenderFrame): void {
+    if (
+      this.isLost ||
+      this.isDisposed ||
+      this.mainProgram === null ||
+      this.gl.isContextLost() ||
+      this.width <= 0 ||
+      this.height <= 0
+    ) {
+      this._lastDrawnPoints = 0;
+      return;
+    }
+
+    const gl = this.gl;
+    gl.viewport(0, 0, this.width, this.height);
+
+    const bg = frame.background;
+    gl.clearColor(bg[0] / 255, bg[1] / 255, bg[2] / 255, bg[3] / 255);
+    gl.clear(gl.COLOR_BUFFER_BIT);
+    gl.enable(gl.BLEND);
+    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+    gl.useProgram(this.mainProgram);
+
+    gl.activeTexture(gl.TEXTURE0);
+    gl.bindTexture(gl.TEXTURE_2D, this.lutTexture);
+    const lutBytes = frame.color.lut;
+    const lutEntries = Math.floor(lutBytes.length / 4);
+    if (this.lastLutBuffer !== lutBytes) {
+      gl.texImage2D(
+        gl.TEXTURE_2D,
+        0,
+        gl.RGBA,
+        lutEntries,
+        1,
+        0,
+        gl.RGBA,
+        gl.UNSIGNED_BYTE,
+        lutBytes,
+      );
+      this.lastLutBuffer = lutBytes;
+    }
+
+    gl.uniform1i(this.uLutLoc, 0);
+    gl.uniform1i(this.uLutSizeLoc, lutEntries);
+
+    const modeKind = frame.color.kind;
+    gl.uniform1i(
+      this.uColorModeLoc,
+      modeKind === 'uniform' ? 0 : modeKind === 'numeric' ? 1 : 2,
+    );
+
+    if (modeKind === 'numeric') {
+      const min = frame.color.min;
+      const span = frame.color.max - min;
+      this.uNumericRangeScratch[0] = min;
+      this.uNumericRangeScratch[1] = span > 0 ? 1.0 / span : 0.0;
+      gl.uniform2fv(this.uNumericRangeLoc, this.uNumericRangeScratch);
+    }
+
+    const nc = frame.color.nullColor;
+    this.uNullColorScratch[0] = nc[0] / 255;
+    this.uNullColorScratch[1] = nc[1] / 255;
+    this.uNullColorScratch[2] = nc[2] / 255;
+    this.uNullColorScratch[3] = nc[3] / 255;
+    gl.uniform4fv(this.uNullColorLoc, this.uNullColorScratch);
+
+    const pointSize = clamp(
+      frame.style.sizePx * this.dpr,
+      this.minPointSize,
+      this.maxPointSize,
+    );
+    gl.uniform1f(this.uPointSizeLoc, pointSize);
+    gl.uniform1f(this.uOpacityLoc, clamp(frame.style.opacity, 0, 1));
+
+    const view = frame.view;
+    const spanX = view.x1 - view.x0;
+    const spanY = view.y1 - view.y0;
+    if (spanX <= 0 || spanY <= 0) {
+      this._lastDrawnPoints = 0;
+      return;
+    }
+
+    this.uScaleScratch[0] = 1.0 / spanX;
+    this.uScaleScratch[1] = 1.0 / spanY;
+    gl.uniform2fv(this.uScaleLoc, this.uScaleScratch);
+
+    const points = frame.points;
+    if (points !== undefined && points.count > 0) {
+      const gpuSet = this.getOrUploadGpuSet(points);
+      gl.bindVertexArray(gpuSet.vao);
+
+      const dX = view.x0 - gpuSet.originX;
+      const hiX = Math.fround(dX);
+      this.uVxScratch[0] = hiX;
+      this.uVxScratch[1] = Math.fround(dX - hiX);
+      gl.uniform2fv(this.uVxLoc, this.uVxScratch);
+
+      const dY = view.y0 - gpuSet.originY;
+      const hiY = Math.fround(dY);
+      this.uVyScratch[0] = hiY;
+      this.uVyScratch[1] = Math.fround(dY - hiY);
+      gl.uniform2fv(this.uVyLoc, this.uVyScratch);
+
+      gl.drawArrays(gl.POINTS, 0, points.count);
+      gl.bindVertexArray(null);
+      this._lastDrawnPoints = points.count;
+    } else {
+      this._lastDrawnPoints = 0;
+    }
+
+    const highlight = frame.highlight;
+    if (highlight !== undefined && this.highlightProgram !== null) {
+      const clipX = ((highlight.x - view.x0) / spanX) * 2.0 - 1.0;
+      const clipY = ((highlight.y - view.y0) / spanY) * 2.0 - 1.0;
+      if (clipX >= -1.2 && clipX <= 1.2 && clipY >= -1.2 && clipY <= 1.2) {
+        gl.useProgram(this.highlightProgram);
+        gl.bindVertexArray(this.highlightVao);
+        this.uHighlightClipScratch[0] = clipX;
+        this.uHighlightClipScratch[1] = clipY;
+        gl.uniform2fv(this.uHighlightClipPosLoc, this.uHighlightClipScratch);
+
+        const ringSize = clamp(
+          pointSize + 6 * this.dpr,
+          this.minPointSize,
+          this.maxPointSize,
+        );
+        gl.uniform1f(this.uHighlightRingSizeLoc, ringSize);
+
+        const rgba = highlight.rgba;
+        this.uHighlightColorScratch[0] = rgba[0] / 255;
+        this.uHighlightColorScratch[1] = rgba[1] / 255;
+        this.uHighlightColorScratch[2] = rgba[2] / 255;
+        this.uHighlightColorScratch[3] = rgba[3] / 255;
+        gl.uniform4fv(this.uHighlightRingColorLoc, this.uHighlightColorScratch);
+
+        gl.drawArrays(gl.POINTS, 0, 1);
+        gl.bindVertexArray(null);
+      }
+    }
+  }
+
+  isContextLost(): boolean {
+    return this.isDisposed || this.isLost || this.gl.isContextLost();
+  }
+
+  get lastDrawnPoints(): number {
+    return this._lastDrawnPoints;
+  }
+
+  [Symbol.dispose](): void {
+    if (this.isDisposed) return;
+    this.isDisposed = true;
+    this.canvas.removeEventListener(
+      'webglcontextlost',
+      this.onContextLost,
+      false,
+    );
+    this.canvas.removeEventListener(
+      'webglcontextrestored',
+      this.onContextRestored,
+      false,
+    );
+    const gl = this.gl;
+    if (!gl.isContextLost()) {
+      for (const id of Array.from(this.gpuCache.keys())) {
+        this.deleteCachedSet(id);
+      }
+      if (this.highlightVao !== null) gl.deleteVertexArray(this.highlightVao);
+      if (this.mainProgram !== null) gl.deleteProgram(this.mainProgram);
+      if (this.highlightProgram !== null) {
+        gl.deleteProgram(this.highlightProgram);
+      }
+      if (this.lutTexture !== null) gl.deleteTexture(this.lutTexture);
+    }
+    this.dropCaches();
+  }
+}