xref: /aosp_15_r20/external/perfetto/ui/src/core/pivot_table_manager.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {
16  PivotTableQuery,
17  PivotTableQueryMetadata,
18  PivotTableResult,
19  PivotTableState,
20  COUNT_AGGREGATION,
21  TableColumn,
22  toggleEnabled,
23  tableColumnEquals,
24  AggregationFunction,
25} from './pivot_table_types';
26import {AreaSelection} from '../public/selection';
27import {
28  aggregationIndex,
29  generateQueryFromState,
30} from './pivot_table_query_generator';
31import {Aggregation, PivotTree} from './pivot_table_types';
32import {Engine} from '../trace_processor/engine';
33import {ColumnType} from '../trace_processor/query_result';
34import {SortDirection} from '../base/comparison_utils';
35import {assertTrue} from '../base/logging';
36import {featureFlags} from './feature_flags';
37
38export const PIVOT_TABLE_REDUX_FLAG = featureFlags.register({
39  id: 'pivotTable',
40  name: 'Pivot tables V2',
41  description: 'Second version of pivot table',
42  defaultValue: true,
43});
44
45function expectNumber(value: ColumnType): number {
46  if (typeof value === 'number') {
47    return value;
48  } else if (typeof value === 'bigint') {
49    return Number(value);
50  }
51  throw new Error(`number or bigint was expected, got ${typeof value}`);
52}
53
54// Auxiliary class to build the tree from query response.
55export class PivotTableTreeBuilder {
56  private readonly root: PivotTree;
57  queryMetadata: PivotTableQueryMetadata;
58
59  get pivotColumnsCount(): number {
60    return this.queryMetadata.pivotColumns.length;
61  }
62
63  get aggregateColumns(): Aggregation[] {
64    return this.queryMetadata.aggregationColumns;
65  }
66
67  constructor(queryMetadata: PivotTableQueryMetadata, firstRow: ColumnType[]) {
68    this.queryMetadata = queryMetadata;
69    this.root = this.createNode(firstRow);
70    let tree = this.root;
71    for (let i = 0; i + 1 < this.pivotColumnsCount; i++) {
72      const value = firstRow[i];
73      tree = this.insertChild(tree, value, this.createNode(firstRow));
74    }
75    tree.rows.push(firstRow);
76  }
77
78  // Add incoming row to the tree being built.
79  ingestRow(row: ColumnType[]) {
80    let tree = this.root;
81    this.updateAggregates(tree, row);
82    for (let i = 0; i + 1 < this.pivotColumnsCount; i++) {
83      const nextTree = tree.children.get(row[i]);
84      if (nextTree === undefined) {
85        // Insert the new node into the tree, and make variable `tree` point
86        // to the newly created node.
87        tree = this.insertChild(tree, row[i], this.createNode(row));
88      } else {
89        this.updateAggregates(nextTree, row);
90        tree = nextTree;
91      }
92    }
93    tree.rows.push(row);
94  }
95
96  build(): PivotTree {
97    return this.root;
98  }
99
100  updateAggregates(tree: PivotTree, row: ColumnType[]) {
101    const countIndex = this.queryMetadata.countIndex;
102    const treeCount =
103      countIndex >= 0 ? expectNumber(tree.aggregates[countIndex]) : 0;
104    const rowCount =
105      countIndex >= 0
106        ? expectNumber(
107            row[aggregationIndex(this.pivotColumnsCount, countIndex)],
108          )
109        : 0;
110
111    for (let i = 0; i < this.aggregateColumns.length; i++) {
112      const agg = this.aggregateColumns[i];
113
114      const currAgg = tree.aggregates[i];
115      const childAgg = row[aggregationIndex(this.pivotColumnsCount, i)];
116      if (typeof currAgg === 'number' && typeof childAgg === 'number') {
117        switch (agg.aggregationFunction) {
118          case 'SUM':
119          case 'COUNT':
120            tree.aggregates[i] = currAgg + childAgg;
121            break;
122          case 'MAX':
123            tree.aggregates[i] = Math.max(currAgg, childAgg);
124            break;
125          case 'MIN':
126            tree.aggregates[i] = Math.min(currAgg, childAgg);
127            break;
128          case 'AVG': {
129            const currSum = currAgg * treeCount;
130            const addSum = childAgg * rowCount;
131            tree.aggregates[i] = (currSum + addSum) / (treeCount + rowCount);
132            break;
133          }
134        }
135      }
136    }
137    tree.aggregates[this.aggregateColumns.length] = treeCount + rowCount;
138  }
139
140  // Helper method that inserts child node into the tree and returns it, used
141  // for more concise modification of local variable pointing to the current
142  // node being built.
143  insertChild(tree: PivotTree, key: ColumnType, child: PivotTree): PivotTree {
144    tree.children.set(key, child);
145
146    return child;
147  }
148
149  // Initialize PivotTree from a row.
150  createNode(row: ColumnType[]): PivotTree {
151    const aggregates = [];
152
153    for (let j = 0; j < this.aggregateColumns.length; j++) {
154      aggregates.push(row[aggregationIndex(this.pivotColumnsCount, j)]);
155    }
156    aggregates.push(
157      row[
158        aggregationIndex(this.pivotColumnsCount, this.aggregateColumns.length)
159      ],
160    );
161
162    return {
163      isCollapsed: false,
164      children: new Map(),
165      aggregates,
166      rows: [],
167    };
168  }
169}
170
171function createEmptyQueryResult(
172  metadata: PivotTableQueryMetadata,
173): PivotTableResult {
174  return {
175    tree: {
176      aggregates: [],
177      isCollapsed: false,
178      children: new Map(),
179      rows: [],
180    },
181    metadata,
182  };
183}
184
185// Controller responsible for showing the panel with pivot table, as well as
186// executing its queries and post-processing query results.
187export class PivotTableManager {
188  state: PivotTableState = createEmptyPivotTableState();
189
190  constructor(private engine: Engine) {}
191
192  setSelectionArea(area: AreaSelection) {
193    if (!PIVOT_TABLE_REDUX_FLAG.get()) {
194      return;
195    }
196    this.state.selectionArea = area;
197    this.refresh();
198  }
199
200  addAggregation(aggregation: Aggregation, after: number) {
201    this.state.selectedAggregations.splice(after, 0, aggregation);
202    this.refresh();
203  }
204
205  removeAggregation(index: number) {
206    this.state.selectedAggregations.splice(index, 1);
207    this.refresh();
208  }
209
210  setPivotSelected(args: {column: TableColumn; selected: boolean}) {
211    toggleEnabled(
212      tableColumnEquals,
213      this.state.selectedPivots,
214      args.column,
215      args.selected,
216    );
217    this.refresh();
218  }
219
220  setAggregationFunction(index: number, fn: AggregationFunction) {
221    this.state.selectedAggregations[index].aggregationFunction = fn;
222    this.refresh();
223  }
224
225  setSortColumn(aggregationIndex: number, order: SortDirection) {
226    this.state.selectedAggregations = this.state.selectedAggregations.map(
227      (agg, index) => ({
228        column: agg.column,
229        aggregationFunction: agg.aggregationFunction,
230        sortDirection: index === aggregationIndex ? order : undefined,
231      }),
232    );
233    this.refresh();
234  }
235
236  setOrder(from: number, to: number, direction: DropDirection) {
237    const pivots = this.state.selectedPivots;
238    this.state.selectedPivots = performReordering(
239      computeIntervals(pivots.length, from, to, direction),
240      pivots,
241    );
242    this.refresh();
243  }
244
245  setAggregationOrder(from: number, to: number, direction: DropDirection) {
246    const aggregations = this.state.selectedAggregations;
247    this.state.selectedAggregations = performReordering(
248      computeIntervals(aggregations.length, from, to, direction),
249      aggregations,
250    );
251    this.refresh();
252  }
253
254  setConstrainedToArea(constrain: boolean) {
255    this.state.constrainToArea = constrain;
256    this.refresh();
257  }
258
259  private refresh() {
260    this.state.queryResult = undefined;
261    if (!PIVOT_TABLE_REDUX_FLAG.get()) {
262      return;
263    }
264    this.processQuery(generateQueryFromState(this.state));
265  }
266
267  private async processQuery(query: PivotTableQuery) {
268    const result = await this.engine.query(query.text);
269    try {
270      await result.waitAllRows();
271    } catch {
272      // waitAllRows() frequently throws an exception, which is ignored in
273      // its other calls, so it's ignored here as well.
274    }
275
276    const columns = result.columns();
277
278    const it = result.iter({});
279    function nextRow(): ColumnType[] {
280      const row: ColumnType[] = [];
281      for (const column of columns) {
282        row.push(it.get(column));
283      }
284      it.next();
285      return row;
286    }
287
288    if (!it.valid()) {
289      // Iterator is invalid after creation; means that there are no rows
290      // satisfying filtering criteria. Return an empty tree.
291      this.state.queryResult = createEmptyQueryResult(query.metadata);
292      return;
293    }
294
295    const treeBuilder = new PivotTableTreeBuilder(query.metadata, nextRow());
296    while (it.valid()) {
297      treeBuilder.ingestRow(nextRow());
298    }
299    this.state.queryResult = {
300      tree: treeBuilder.build(),
301      metadata: query.metadata,
302    };
303  }
304}
305
306function createEmptyPivotTableState(): PivotTableState {
307  return {
308    queryResult: undefined,
309    selectedPivots: [
310      {
311        kind: 'regular',
312        table: '_slice_with_thread_and_process_info',
313        column: 'name',
314      },
315    ],
316    selectedAggregations: [
317      {
318        aggregationFunction: 'SUM',
319        column: {
320          kind: 'regular',
321          table: '_slice_with_thread_and_process_info',
322          column: 'dur',
323        },
324        sortDirection: 'DESC',
325      },
326      {
327        aggregationFunction: 'SUM',
328        column: {
329          kind: 'regular',
330          table: '_slice_with_thread_and_process_info',
331          column: 'thread_dur',
332        },
333      },
334      COUNT_AGGREGATION,
335    ],
336    constrainToArea: true,
337  };
338}
339
340// Drag&Drop logic
341
342export type DropDirection = 'left' | 'right';
343
344export interface Interval {
345  from: number;
346  to: number;
347}
348
349/*
350 * When a drag'n'drop is performed in a linear sequence, the resulting reordered
351 * array will consist of several contiguous subarrays of the original glued
352 * together.
353 *
354 * This function implements the computation of these intervals.
355 *
356 * The drag'n'drop operation performed is as follows: in the sequence with given
357 * length, the element with index `dragFrom` is dropped on the `direction` to
358 * the element `dragTo`.
359 */
360
361export function computeIntervals(
362  length: number,
363  dragFrom: number,
364  dragTo: number,
365  direction: DropDirection,
366): Interval[] {
367  assertTrue(dragFrom !== dragTo);
368
369  if (dragTo < dragFrom) {
370    const prefixLen = direction == 'left' ? dragTo : dragTo + 1;
371    return [
372      // First goes unchanged prefix.
373      {from: 0, to: prefixLen},
374      // Then goes dragged element.
375      {from: dragFrom, to: dragFrom + 1},
376      // Then goes suffix up to dragged element (which has already been moved).
377      {from: prefixLen, to: dragFrom},
378      // Then the rest of an array.
379      {from: dragFrom + 1, to: length},
380    ];
381  }
382
383  // Other case: dragTo > dragFrom
384  const prefixLen = direction == 'left' ? dragTo : dragTo + 1;
385  return [
386    {from: 0, to: dragFrom},
387    {from: dragFrom + 1, to: prefixLen},
388    {from: dragFrom, to: dragFrom + 1},
389    {from: prefixLen, to: length},
390  ];
391}
392
393export function performReordering<T>(intervals: Interval[], arr: T[]): T[] {
394  const result: T[] = [];
395
396  for (const interval of intervals) {
397    result.push(...arr.slice(interval.from, interval.to));
398  }
399
400  return result;
401}
402