blob: 5e83542f1e77254adbc7dce4088d4a883efc90b2 [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,
STR_NULL,
type QueryResult,
} from '../../../trace_processor/query_result';
import {
ChartSource,
SQLChartLoader,
type QueryConfig,
type ChartLoaderResult,
type PointColumnSpec,
rangeFilters,
} from './chart_sql_source';
import type {
LineChartData,
LineChartPoint,
LineChartSeries,
} from './line_chart';
/**
* Configuration for SQLLineChartLoader.
*/
export interface SQLLineChartLoaderOpts {
/** The trace processor engine to run queries against. */
readonly engine: Engine;
/**
* SQL query that provides the raw data.
* Must include the X and Y columns (and optionally the series column).
*/
readonly query: string;
/** Column name for the X axis (numeric). */
readonly xColumn: string;
/** Column name for the Y axis (numeric). */
readonly yColumn: string;
/**
* Optional column name for grouping points into separate series.
* When omitted, all points belong to a single series named after yColumn.
*/
readonly seriesColumn?: string;
}
/**
* Per-use configuration for the line chart loader.
*/
export interface LineChartLoaderConfig {
/** Filter to only include points within this X range (e.g., from brush). */
readonly xRange?: {readonly min: number; readonly max: number};
/**
* Maximum number of points per series. When the query returns more
* points, evenly-spaced samples are kept (first and last are always
* retained). Defaults to no limit.
*/
readonly maxPoints?: number;
}
/** Result returned by the line chart loader. */
export type LineChartLoaderResult = ChartLoaderResult<LineChartData>;
/**
* SQL-based line chart loader with async loading and caching.
*
* Fetches ordered (x, y) points directly from SQL, optionally grouped
* into multiple series by a grouping column.
*/
export class SQLLineChartLoader extends SQLChartLoader<
LineChartLoaderConfig,
LineChartData
> {
private readonly xCol: string;
private readonly yCol: string;
private readonly seriesCol: string | undefined;
constructor(opts: SQLLineChartLoaderOpts) {
const xCol = opts.xColumn;
const yCol = opts.yColumn;
const seriesCol = opts.seriesColumn;
const schema: Record<string, 'text' | 'real'> = {
[xCol]: 'real',
[yCol]: 'real',
};
if (seriesCol !== undefined) {
schema[seriesCol] = 'text';
}
super(opts.engine, new ChartSource({query: opts.query, schema}));
this.xCol = xCol;
this.yCol = yCol;
this.seriesCol = seriesCol;
}
protected buildQueryConfig(config: LineChartLoaderConfig): QueryConfig {
const columns: PointColumnSpec[] = [
{column: this.xCol, alias: '_x', cast: 'real'},
{column: this.yCol, alias: '_y', cast: 'real'},
];
// When no breakdown column is configured, buildPoints() won't add
// _series to the SELECT. Add NULL AS _series explicitly so that
// parseResult's iter spec always finds the column.
if (this.seriesCol === undefined) {
columns.push({alias: '_series', cast: 'text'});
}
return {
type: 'points',
columns,
breakdown: this.seriesCol,
filters: rangeFilters(this.xCol, config.xRange),
orderBy:
this.seriesCol !== undefined
? [{column: '_series'}, {column: '_x'}]
: [{column: '_x'}],
};
}
protected parseResult(
queryResult: QueryResult,
config: LineChartLoaderConfig,
): LineChartData {
const seriesMap = new Map<string, LineChartPoint[]>();
const defaultName = this.seriesCol !== undefined ? '' : this.yCol;
const iter = queryResult.iter({_x: NUM, _y: NUM, _series: STR_NULL});
for (; iter.valid(); iter.next()) {
const name = iter._series ?? defaultName;
let points = seriesMap.get(name);
if (points === undefined) {
points = [];
seriesMap.set(name, points);
}
points.push({x: iter._x, y: iter._y});
}
const series: LineChartSeries[] = [];
for (const [name, points] of seriesMap) {
series.push({
name,
points:
config.maxPoints !== undefined
? downsample(points, config.maxPoints)
: points,
});
}
return {series};
}
protected override extraCacheKey(
config: LineChartLoaderConfig,
): Record<string, string | number | boolean | undefined> {
return {maxPoints: config.maxPoints};
}
}
/**
* Downsample an array of points to at most `maxPoints` entries.
* Always keeps the first and last points; intermediate points are
* evenly sampled.
*/
function downsample(
points: readonly LineChartPoint[],
maxPoints: number,
): LineChartPoint[] {
if (points.length <= maxPoints) return [...points];
if (maxPoints <= 2) {
return [points[0], points[points.length - 1]];
}
const result: LineChartPoint[] = [points[0]];
const step = (points.length - 1) / (maxPoints - 1);
for (let i = 1; i < maxPoints - 1; i++) {
result.push(points[Math.round(i * step)]);
}
result.push(points[points.length - 1]);
return result;
}