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