blob: 15e409abf309fb96f52a979ecbf6c7da22939096 [file]
// 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 {Engine} from '../../../trace_processor/engine';
import {NUM, type QueryResult} from '../../../trace_processor/query_result';
import {
ChartSource,
SQLChartLoader,
type QueryConfig,
type ChartLoaderResult,
rangeFilters,
} from './chart_sql_source';
/**
* A single bucket in the histogram.
*/
export interface HistogramBucket {
/** Start value of the bucket (inclusive) */
readonly start: number;
/** End value of the bucket (exclusive) */
readonly end: number;
/** Count of values in this bucket */
readonly count: number;
}
/**
* Data provided by a histogram data source.
*/
export interface HistogramData {
/** The computed buckets */
readonly buckets: readonly HistogramBucket[];
/** Minimum value in the data */
readonly min: number;
/** Maximum value in the data */
readonly max: number;
/** Total count of all values */
readonly totalCount: number;
/** Count of null values (excluded from histogram) */
readonly nullCount: number;
/** Count of non-numeric values (excluded from histogram) */
readonly nonNumericCount: number;
/** Whether the data is integer-valued (affects tick formatting) */
readonly isInteger?: boolean;
}
/**
* Configuration for histogram bucket computation.
*/
export interface HistogramConfig {
/**
* Fixed bucket size. If provided, bucketCount is ignored.
* The number of buckets will be determined by (max - min) / bucketSize.
*/
readonly bucketSize?: number;
/**
* Number of buckets to create. Ignored if bucketSize is provided.
* Defaults to automatic calculation using Terrell-Scott rule.
*/
readonly bucketCount?: number;
/**
* Minimum value for the histogram range.
* If not provided, will be computed from the data.
*/
readonly minValue?: number;
/**
* Maximum value for the histogram range.
* If not provided, will be computed from the data.
*/
readonly maxValue?: number;
/**
* When true, indicates that the data contains only integer values.
* Bucket boundaries will be snapped to integer values (bucket size
* is ceiled to the nearest integer).
*/
readonly integer?: boolean;
}
/**
* Computes histogram data from an array of numbers.
* This is a utility function for use by parent components.
*/
export function computeHistogram(
values: readonly (number | null | undefined)[],
config: HistogramConfig = {},
): HistogramData {
// Filter to only valid numbers
const validValues = values.filter(
(v): v is number => typeof v === 'number' && !isNaN(v) && isFinite(v),
);
const nullCount = values.filter((v) => v === null || v === undefined).length;
const nonNumericCount = values.length - validValues.length - nullCount;
if (validValues.length === 0) {
return {
buckets: [],
min: 0,
max: 0,
totalCount: 0,
nullCount,
nonNumericCount,
};
}
// Compute min/max from data or use config values
const dataMin = Math.min(...validValues);
const dataMax = Math.max(...validValues);
const min = config.minValue ?? dataMin;
const max = config.maxValue ?? dataMax;
// Handle edge case where all values are the same
if (min === max) {
return {
buckets: [{start: min, end: min + 1, count: validValues.length}],
min,
max,
totalCount: validValues.length,
nullCount,
nonNumericCount,
};
}
// Determine bucket size
let bucketSize: number;
let bucketCount: number;
if (config.bucketSize !== undefined) {
bucketSize = config.bucketSize;
if (config.integer) {
bucketSize = Math.max(1, Math.ceil(bucketSize));
}
bucketCount = Math.ceil((max - min) / bucketSize);
} else if (config.bucketCount !== undefined) {
bucketCount = config.bucketCount;
bucketSize = (max - min) / bucketCount;
if (config.integer) {
bucketSize = Math.max(1, Math.ceil(bucketSize));
bucketCount = Math.ceil((max - min) / bucketSize);
}
} else {
// Terrell-Scott rule: k = (2 * n)^(1/3)
bucketCount = Math.max(
5,
Math.min(100, Math.ceil(Math.pow(2 * validValues.length, 1 / 3))),
);
bucketSize = (max - min) / bucketCount;
if (config.integer) {
bucketSize = Math.max(1, Math.ceil(bucketSize));
bucketCount = Math.ceil((max - min) / bucketSize);
}
}
// Initialize buckets
const buckets: HistogramBucket[] = [];
for (let i = 0; i < bucketCount; i++) {
buckets.push({
start: min + i * bucketSize,
end: min + (i + 1) * bucketSize,
count: 0,
});
}
// Count values into buckets
for (const value of validValues) {
if (value < min || value > max) continue;
let bucketIndex = Math.floor((value - min) / bucketSize);
// Handle edge case where value === max
if (bucketIndex >= bucketCount) {
bucketIndex = bucketCount - 1;
}
const bucket = buckets[bucketIndex];
buckets[bucketIndex] = {...bucket, count: bucket.count + 1};
}
return {
buckets,
min,
max,
totalCount: validValues.length,
nullCount,
nonNumericCount,
};
}
/**
* Configuration for histogram loaders.
*/
export interface HistogramLoaderConfig {
/** Fixed bucket size. If provided, bucketCount is ignored. */
readonly bucketSize?: number;
/** Number of buckets to create. Ignored if bucketSize is provided. */
readonly bucketCount?: number;
/** Minimum value for the histogram range. */
readonly minValue?: number;
/** Maximum value for the histogram range. */
readonly maxValue?: number;
/**
* When true, indicates that the data contains only integer values.
* Filter bounds will be snapped: min is floored, max is ceiled.
* Bucket boundaries will also snap to integers.
*/
readonly integer?: boolean;
/**
* Range filter to apply to the data (e.g., from brush selection).
* Only values within [min, max] are included.
*/
readonly filter?: {
readonly min: number;
readonly max: number;
};
}
/** Result returned by histogram loaders. */
export type HistogramLoaderResult = ChartLoaderResult<HistogramData>;
/**
* Loader interface for histogram data.
*/
export interface HistogramLoader {
use(config: HistogramLoaderConfig): HistogramLoaderResult;
dispose(): void;
}
/**
* In-memory histogram loader for static datasets.
*
* Takes values in the constructor and computes histograms synchronously.
* Caches the result when config hasn't changed.
*/
export class InMemoryHistogramLoader implements HistogramLoader {
private readonly values: readonly number[];
private cachedConfig?: string;
private cachedData?: HistogramData;
constructor(values: readonly (number | null | undefined)[]) {
// Pre-filter to valid numbers
this.values = values.filter(
(v): v is number => typeof v === 'number' && !isNaN(v) && isFinite(v),
);
}
use(config: HistogramLoaderConfig): HistogramLoaderResult {
const configKey = JSON.stringify(config);
// Return cached result if config unchanged
if (this.cachedConfig === configKey && this.cachedData !== undefined) {
return {data: this.cachedData, isPending: false};
}
// Apply filter if provided, snapping bounds for integer data
let filteredValues = this.values;
if (config.filter) {
const filterMin = config.integer
? Math.floor(config.filter.min)
: config.filter.min;
const filterMax = config.integer
? Math.ceil(config.filter.max)
: config.filter.max;
filteredValues = this.values.filter(
(v) => v >= filterMin && v <= filterMax,
);
}
// Compute histogram
const data = computeHistogram(filteredValues, {
bucketSize: config.bucketSize,
bucketCount: config.bucketCount,
minValue: config.minValue,
maxValue: config.maxValue,
integer: config.integer,
});
// Cache result
this.cachedConfig = configKey;
this.cachedData = data;
return {data, isPending: false};
}
dispose(): void {
this.cachedConfig = undefined;
this.cachedData = undefined;
}
}
/**
* Configuration for SQLHistogramLoader.
*/
export interface SQLHistogramLoaderOpts {
/** The trace processor engine to run queries against. */
readonly engine: Engine;
/** SQL query that returns numeric values for the histogram. */
readonly query: string;
/** Column name to use for histogram values. */
readonly valueColumn: string;
}
const DEFAULT_BUCKET_COUNT = 20;
/**
* SQL-based histogram loader with async loading and caching.
*
* Performs histogram aggregation directly in SQL for efficiency with large
* datasets.
*/
export class SQLHistogramLoader
extends SQLChartLoader<HistogramLoaderConfig, HistogramData>
implements HistogramLoader
{
private readonly valCol: string;
constructor(opts: SQLHistogramLoaderOpts) {
super(
opts.engine,
new ChartSource({
query: opts.query,
schema: {[opts.valueColumn]: 'real'},
}),
);
this.valCol = opts.valueColumn;
}
protected buildQueryConfig(config: HistogramLoaderConfig): QueryConfig {
const bucketCount = config.bucketCount ?? DEFAULT_BUCKET_COUNT;
// Snap filter bounds for integer data
const filter =
config.filter !== undefined
? config.integer === true
? {
min: Math.floor(config.filter.min),
max: Math.ceil(config.filter.max),
}
: config.filter
: undefined;
return {
type: 'histogram',
valueColumn: this.valCol,
bucketCount,
bucketSize: config.bucketSize,
minValue: config.minValue,
filters: rangeFilters(this.valCol, filter),
};
}
protected parseResult(
queryResult: QueryResult,
config: HistogramLoaderConfig,
): HistogramData {
const bucketCount = config.bucketCount ?? DEFAULT_BUCKET_COUNT;
let min = 0;
let max = 0;
let totalCount = 0;
const bucketCounts = new Map<number, number>();
const iter = queryResult.iter({
_min: NUM,
_max: NUM,
_total: NUM,
_bucket_idx: NUM,
_count: NUM,
});
for (; iter.valid(); iter.next()) {
min = iter._min;
max = iter._max;
totalCount = iter._total;
bucketCounts.set(iter._bucket_idx, iter._count);
}
if (totalCount === 0) {
return {
buckets: [],
min: 0,
max: 0,
totalCount: 0,
nullCount: 0,
nonNumericCount: 0,
isInteger: config.integer,
};
}
// Build bucket array (including empty buckets). When the caller pinned
// the boundaries via {bucketSize, minValue}, use those directly so the
// displayed boundaries match what the SQL bucketed against. Otherwise
// compute the auto-binned boundaries from the data range.
let boundaryMin = min;
let bucketSize: number;
if (config.bucketSize !== undefined && config.minValue !== undefined) {
boundaryMin = config.minValue;
bucketSize = config.bucketSize;
} else {
bucketSize = (max - min) / bucketCount;
if (config.integer === true) {
bucketSize = Math.max(1, Math.ceil(bucketSize));
}
}
const buckets: HistogramBucket[] = [];
for (let i = 0; i < bucketCount; i++) {
buckets.push({
start: boundaryMin + i * bucketSize,
end: boundaryMin + (i + 1) * bucketSize,
count: bucketCounts.get(i) ?? 0,
});
}
return {
buckets,
min,
max,
totalCount,
nullCount: 0,
nonNumericCount: 0,
isInteger: config.integer,
};
}
protected override extraCacheKey(
config: HistogramLoaderConfig,
): Record<string, string | number | boolean | undefined> {
return {integer: config.integer};
}
}