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