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(); + } +}