blob: 0b97b9653d4948fffcb9a2791e5b8f58485e9840 [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {Trash} from '../base/disposable';
import {findRef, toHTMLElement} from '../base/dom_utils';
import {Rect} from '../base/geom';
import {assertExists} from '../base/logging';
import {Style} from './common';
import {scheduleFullRedraw} from './raf';
import {VirtualScrollHelper} from './virtual_scroll_helper';
/**
* The |VirtualTable| widget can be useful when attempting to render a large
* amount of tabular data - i.e. dumping the entire contents of a database
* table.
*
* A naive approach would be to load the entire dataset from the table and
* render it into the DOM. However, this has a number of disadvantages:
* - The query could potentially be very slow on large enough datasets.
* - The amount of data pulled could be larger than the available memory.
* - Rendering thousands of DOM elements using Mithril can get be slow.
* - Asking the browser to create and update thousands of elements on the DOM
* can also be slow.
*
* This implementation takes advantage of the fact that computer monitors are
* only so tall, so most will only be able to display a small subset of rows at
* a given time, and the user will have to scroll to reveal more data.
*
* Thus, this widgets operates in such a way as to only render the DOM elements
* that are visible within the given scrolling container's viewport. To avoid
* spamming render updates, we render a few more rows above and below the
* current viewport, and only trigger an update once the user scrolls too close
* to the edge of the rendered data. These margins and tolerances are
* configurable with the |renderOverdrawPx| and |renderTolerancePx| attributes.
*
* When it comes to loading data, it's often more performant to run fewer large
* queries compared to more frequent smaller queries. Running a new query every
* time we want to update the DOM is usually too frequent, and results in
* flickering as the data is usually not loaded at the time the relevant row
* scrolls into view.
*
* Thus, this implementation employs two sets of limits, one to refresh the DOM
* and one larger one to re-query the data. The latter may be configured using
* the |queryOverdrawPx| and |queryTolerancePx| attributes.
*
* The smaller DOM refreshes and handled internally, but the user must be called
* to invoke a new query update. When new data is required, the |onReload|
* callback is called with the row offset and count.
*
* The data must be passed in the |data| attribute which contains the offset of
* the currently loaded data and a number of rows.
*
* Row and column content is flexible as m.Children are accepted and passed
* straight to mithril.
*
* The widget is quite opinionated in terms of its styling, but the entire
* widget and each row may be tweaked using |className| and |style| attributes
* which behave in the same way as they do on other Mithril components.
*/
export interface VirtualTableAttrs {
// A list of columns containing the header row content and column widths
columns: VirtualTableColumn[];
// Row height in px (each row must have the same height)
rowHeight: number;
// Offset of the first row
firstRowOffset: number;
// Total number of rows
numRows: number;
// The row data to render
rows: VirtualTableRow[];
// Optional: Called when we need to reload data
onReload?: (rowOffset: number, rowCount: number) => void;
// Additional class name applied to the table container element
className?: string;
// Additional styles applied to the table container element
style?: Style;
// Optional: Called when a row is hovered, passing the hovered row's id
onRowHover?: (id: number) => void;
// Optional: Called when a row is un-hovered, passing the un-hovered row's id
onRowOut?: (id: number) => void;
// Optional: Number of pixels equivalent of rows to overdraw above and below
// the viewport
// Defaults to a sensible value
renderOverdrawPx?: number;
// Optional: How close we can get to the edge before triggering a DOM redraw
// Defaults to a sensible value
renderTolerancePx?: number;
// Optional: Number of pixels equivalent of rows to query above and below the
// viewport
// Defaults to a sensible value
queryOverdrawPx?: number;
// Optional: How close we can get to the edge if the loaded data before we
// trigger another query
// Defaults to a sensible value
queryTolerancePx?: number;
}
export interface VirtualTableColumn {
// Content to render in the header row
header: m.Children;
// CSS width e.g. 12px, 4em, etc...
width: string;
}
export interface VirtualTableRow {
// Id for this row (must be unique within this dataset)
// Used for callbacks and as a Mithril key.
id: number;
// Data for each column in this row - must match number of elements in columns
cells: m.Children[];
// Optional: Additional class name applied to the row element
className?: string;
}
export class VirtualTable implements m.ClassComponent<VirtualTableAttrs> {
private readonly CONTAINER_REF = 'CONTAINER';
private readonly SLIDER_REF = 'SLIDER';
private readonly trash = new Trash();
private renderBounds = {rowStart: 0, rowEnd: 0};
view({attrs}: m.Vnode<VirtualTableAttrs>): m.Children {
const {columns, className, numRows, rowHeight, style} = attrs;
return m(
'.pf-vtable',
{className, style, ref: this.CONTAINER_REF},
m(
'.pf-vtable-content',
m(
'.pf-vtable-header',
columns.map((col) =>
m('.pf-vtable-data', {style: {width: col.width}}, col.header),
),
),
m(
'.pf-vtable-slider',
{ref: this.SLIDER_REF, style: {height: `${rowHeight * numRows}px`}},
m(
'.pf-vtable-puck',
{
style: {
transform: `translateY(${
this.renderBounds.rowStart * rowHeight
}px)`,
},
},
this.renderContent(attrs),
),
),
),
);
}
private renderContent(attrs: VirtualTableAttrs): m.Children {
const rows: m.ChildArray = [];
for (
let i = this.renderBounds.rowStart;
i < this.renderBounds.rowEnd;
++i
) {
rows.push(this.renderRow(attrs, i));
}
return rows;
}
private renderRow(attrs: VirtualTableAttrs, i: number): m.Children {
const {rows, firstRowOffset, rowHeight, columns, onRowHover, onRowOut} =
attrs;
if (i >= firstRowOffset && i < firstRowOffset + rows.length) {
// Render the row...
const index = i - firstRowOffset;
const rowData = rows[index];
return m(
'.pf-vtable-row',
{
className: rowData.className,
style: {height: `${rowHeight}px`},
onmouseover: () => {
onRowHover?.(rowData.id);
},
onmouseout: () => {
onRowOut?.(rowData.id);
},
},
rowData.cells.map((data, colIndex) =>
m('.pf-vtable-data', {style: {width: columns[colIndex].width}}, data),
),
);
} else {
// Render a placeholder div with the same height as a row but a
// transparent background
return m('', {style: {height: `${rowHeight}px`}});
}
}
oncreate({dom, attrs}: m.VnodeDOM<VirtualTableAttrs>) {
const {
renderOverdrawPx = 200,
renderTolerancePx = 100,
queryOverdrawPx = 10_000,
queryTolerancePx = 5_000,
} = attrs;
const sliderEl = toHTMLElement(assertExists(findRef(dom, this.SLIDER_REF)));
const containerEl = assertExists(findRef(dom, this.CONTAINER_REF));
const virtualScrollHelper = new VirtualScrollHelper(sliderEl, containerEl, [
{
overdrawPx: renderOverdrawPx,
tolerancePx: renderTolerancePx,
callback: ({top, bottom}: Rect) => {
const height = bottom - top;
const rowStart = Math.floor(top / attrs.rowHeight / 2) * 2;
const rowCount = Math.ceil(height / attrs.rowHeight / 2) * 2;
this.renderBounds = {rowStart, rowEnd: rowStart + rowCount};
scheduleFullRedraw();
},
},
{
overdrawPx: queryOverdrawPx,
tolerancePx: queryTolerancePx,
callback: ({top, bottom}: Rect) => {
const rowStart = Math.floor(top / attrs.rowHeight / 2) * 2;
const rowEnd = Math.ceil(bottom / attrs.rowHeight);
attrs.onReload?.(rowStart, rowEnd - rowStart);
},
},
]);
this.trash.add(virtualScrollHelper);
}
onremove(_: m.VnodeDOM<VirtualTableAttrs>) {
this.trash.dispose();
}
}