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