| // 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; |
| } |