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