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