xref: /aosp_15_r20/external/perfetto/ui/src/widgets/virtual_table.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 m from 'mithril';
16import {findRef, toHTMLElement} from '../base/dom_utils';
17import {assertExists} from '../base/logging';
18import {Style} from './common';
19import {scheduleFullRedraw} from './raf';
20import {VirtualScrollHelper} from './virtual_scroll_helper';
21import {DisposableStack} from '../base/disposable_stack';
22
23/**
24 * The |VirtualTable| widget can be useful when attempting to render a large
25 * amount of tabular data - i.e. dumping the entire contents of a database
26 * table.
27 *
28 * A naive approach would be to load the entire dataset from the table and
29 * render it into the DOM. However, this has a number of disadvantages:
30 * - The query could potentially be very slow on large enough datasets.
31 * - The amount of data pulled could be larger than the available memory.
32 * - Rendering thousands of DOM elements using Mithril can get be slow.
33 * - Asking the browser to create and update thousands of elements on the DOM
34 *   can also be slow.
35 *
36 * This implementation takes advantage of the fact that computer monitors are
37 * only so tall, so most will only be able to display a small subset of rows at
38 * a given time, and the user will have to scroll to reveal more data.
39 *
40 * Thus, this widgets operates in such a way as to only render the DOM elements
41 * that are visible within the given scrolling container's viewport. To avoid
42 * spamming render updates, we render a few more rows above and below the
43 * current viewport, and only trigger an update once the user scrolls too close
44 * to the edge of the rendered data. These margins and tolerances are
45 * configurable with the |renderOverdrawPx| and |renderTolerancePx| attributes.
46 *
47 * When it comes to loading data, it's often more performant to run fewer large
48 * queries compared to more frequent smaller queries. Running a new query every
49 * time we want to update the DOM is usually too frequent, and results in
50 * flickering as the data is usually not loaded at the time the relevant row
51 * scrolls into view.
52 *
53 * Thus, this implementation employs two sets of limits, one to refresh the DOM
54 * and one larger one to re-query the data. The latter may be configured using
55 * the |queryOverdrawPx| and |queryTolerancePx| attributes.
56 *
57 * The smaller DOM refreshes and handled internally, but the user must be called
58 * to invoke a new query update. When new data is required, the |onReload|
59 * callback is called with the row offset and count.
60 *
61 * The data must be passed in the |data| attribute which contains the offset of
62 * the currently loaded data and a number of rows.
63 *
64 * Row and column content is flexible as m.Children are accepted and passed
65 * straight to mithril.
66 *
67 * The widget is quite opinionated in terms of its styling, but the entire
68 * widget and each row may be tweaked using |className| and |style| attributes
69 * which behave in the same way as they do on other Mithril components.
70 */
71
72export interface VirtualTableAttrs {
73  // A list of columns containing the header row content and column widths
74  columns: VirtualTableColumn[];
75
76  // Row height in px (each row must have the same height)
77  rowHeight: number;
78
79  // Offset of the first row
80  firstRowOffset: number;
81
82  // Total number of rows
83  numRows: number;
84
85  // The row data to render
86  rows: VirtualTableRow[];
87
88  // Optional: Called when we need to reload data
89  onReload?: (rowOffset: number, rowCount: number) => void;
90
91  // Additional class name applied to the table container element
92  className?: string;
93
94  // Additional styles applied to the table container element
95  style?: Style;
96
97  // Optional: Called when a row is hovered, passing the hovered row's id
98  onRowHover?: (id: number) => void;
99
100  // Optional: Called when a row is un-hovered, passing the un-hovered row's id
101  onRowOut?: (id: number) => void;
102
103  // Optional: Number of pixels equivalent of rows to overdraw above and below
104  // the viewport
105  // Defaults to a sensible value
106  renderOverdrawPx?: number;
107
108  // Optional: How close we can get to the edge before triggering a DOM redraw
109  // Defaults to a sensible value
110  renderTolerancePx?: number;
111
112  // Optional: Number of pixels equivalent of rows to query above and below the
113  // viewport
114  // Defaults to a sensible value
115  queryOverdrawPx?: number;
116
117  // Optional: How close we can get to the edge if the loaded data before we
118  // trigger another query
119  // Defaults to a sensible value
120  queryTolerancePx?: number;
121}
122
123export interface VirtualTableColumn {
124  // Content to render in the header row
125  header: m.Children;
126
127  // CSS width e.g. 12px, 4em, etc...
128  width: string;
129}
130
131export interface VirtualTableRow {
132  // Id for this row (must be unique within this dataset)
133  // Used for callbacks and as a Mithril key.
134  id: number;
135
136  // Data for each column in this row - must match number of elements in columns
137  cells: m.Children[];
138
139  // Optional: Additional class name applied to the row element
140  className?: string;
141}
142
143export class VirtualTable implements m.ClassComponent<VirtualTableAttrs> {
144  private readonly CONTAINER_REF = 'CONTAINER';
145  private readonly SLIDER_REF = 'SLIDER';
146  private readonly trash = new DisposableStack();
147  private renderBounds = {rowStart: 0, rowEnd: 0};
148
149  view({attrs}: m.Vnode<VirtualTableAttrs>): m.Children {
150    const {columns, className, numRows, rowHeight, style} = attrs;
151    return m(
152      '.pf-vtable',
153      {className, style, ref: this.CONTAINER_REF},
154      m(
155        '.pf-vtable-content',
156        m(
157          '.pf-vtable-header',
158          columns.map((col) =>
159            m('.pf-vtable-data', {style: {width: col.width}}, col.header),
160          ),
161        ),
162        m(
163          '.pf-vtable-slider',
164          {ref: this.SLIDER_REF, style: {height: `${rowHeight * numRows}px`}},
165          m(
166            '.pf-vtable-puck',
167            {
168              style: {
169                transform: `translateY(${
170                  this.renderBounds.rowStart * rowHeight
171                }px)`,
172              },
173            },
174            this.renderContent(attrs),
175          ),
176        ),
177      ),
178    );
179  }
180
181  private renderContent(attrs: VirtualTableAttrs): m.Children {
182    const rows: m.ChildArray = [];
183    for (
184      let i = this.renderBounds.rowStart;
185      i < this.renderBounds.rowEnd;
186      ++i
187    ) {
188      rows.push(this.renderRow(attrs, i));
189    }
190    return rows;
191  }
192
193  private renderRow(attrs: VirtualTableAttrs, i: number): m.Children {
194    const {rows, firstRowOffset, rowHeight, columns, onRowHover, onRowOut} =
195      attrs;
196    if (i >= firstRowOffset && i < firstRowOffset + rows.length) {
197      // Render the row...
198      const index = i - firstRowOffset;
199      const rowData = rows[index];
200      return m(
201        '.pf-vtable-row',
202        {
203          className: rowData.className,
204          style: {height: `${rowHeight}px`},
205          onmouseover: () => {
206            onRowHover?.(rowData.id);
207          },
208          onmouseout: () => {
209            onRowOut?.(rowData.id);
210          },
211        },
212        rowData.cells.map((data, colIndex) =>
213          m('.pf-vtable-data', {style: {width: columns[colIndex].width}}, data),
214        ),
215      );
216    } else {
217      // Render a placeholder div with the same height as a row but a
218      // transparent background
219      return m('', {style: {height: `${rowHeight}px`}});
220    }
221  }
222
223  oncreate({dom, attrs}: m.VnodeDOM<VirtualTableAttrs>) {
224    const {
225      renderOverdrawPx = 200,
226      renderTolerancePx = 100,
227      queryOverdrawPx = 10_000,
228      queryTolerancePx = 5_000,
229    } = attrs;
230
231    const sliderEl = toHTMLElement(assertExists(findRef(dom, this.SLIDER_REF)));
232    const containerEl = assertExists(findRef(dom, this.CONTAINER_REF));
233    const virtualScrollHelper = new VirtualScrollHelper(sliderEl, containerEl, [
234      {
235        overdrawPx: renderOverdrawPx,
236        tolerancePx: renderTolerancePx,
237        callback: (rect) => {
238          const rowStart = Math.floor(rect.top / attrs.rowHeight / 2) * 2;
239          const rowCount = Math.ceil(rect.height / attrs.rowHeight / 2) * 2;
240          this.renderBounds = {rowStart, rowEnd: rowStart + rowCount};
241          scheduleFullRedraw();
242        },
243      },
244      {
245        overdrawPx: queryOverdrawPx,
246        tolerancePx: queryTolerancePx,
247        callback: (rect) => {
248          const rowStart = Math.floor(rect.top / attrs.rowHeight / 2) * 2;
249          const rowEnd = Math.ceil(rect.bottom / attrs.rowHeight);
250          attrs.onReload?.(rowStart, rowEnd - rowStart);
251        },
252      },
253    ]);
254    this.trash.use(virtualScrollHelper);
255  }
256
257  onremove(_: m.VnodeDOM<VirtualTableAttrs>) {
258    this.trash.dispose();
259  }
260}
261