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