diff --git a/frontend/code/components/table.ts b/frontend/code/components/table.ts index da0a0134..f63f567c 100644 --- a/frontend/code/components/table.ts +++ b/frontend/code/components/table.ts @@ -2,128 +2,31 @@ import { markEventAsHandled } from '../eventHandling'; import { ComponentBase, ComponentState } from './componentBase'; type TableValue = number | string; -type DataFromBackend = { [label: string]: TableValue[] } | TableValue[][]; -type TableDeltaState = ComponentState & { +type TableStyle = { + left: number; + top: number | 'header'; + width: number; + height: number; + + fontWeight?: 'normal' | 'bold'; +}; + +type TableState = ComponentState & { _type_: 'Table-builtin'; - data?: DataFromBackend; show_row_numbers?: boolean; + headers?: string[] | null; + data?: TableValue[][]; + styling?: TableStyle[]; }; -// We can receive data either as an object or as a 2d array, but we store it -// as an array of Columns -type TableState = Omit, 'data'> & { - data: TableColumn[]; -}; - -class TableColumn { - public name: string; - public dataType: 'number' | 'text' | 'empty'; - public alignment: string; - public values: TableValue[]; - - constructor(name: string, values: TableValue[]) { - this.name = name; - this.values = values; - this.dataType = this._determineDataType(values); - this.alignment = this.dataType === 'number' ? 'right' : 'left'; - } - - private _determineDataType( - values: TableValue[] - ): 'number' | 'text' | 'empty' { - if (values.length === 0) { - return 'empty'; - } - - if (typeof values[0] === 'number') { - return 'number'; - } - - return 'text'; - } -} - -function dataToColumns(data: DataFromBackend): TableColumn[] { - let columns: TableColumn[] = []; - - if (Array.isArray(data)) { - let numColumns = data.length === 0 ? 0 : data[0].length; - - for (let i = 0; i < numColumns; i++) { - let values = data.map((row) => row[i]); - columns.push(new TableColumn('', values)); - } - } else { - for (let [name, values] of Object.entries(data)) { - columns.push(new TableColumn(name, values)); - } - } - - return columns; -} - -class SortOrder { - private sortOrder: [string, number][] = []; - - add(columnName: string, ascending: boolean): void { - this.sortOrder = this.sortOrder.filter((it) => it[0] !== columnName); - this.sortOrder.unshift([columnName, ascending ? 1 : -1]); - } - - sort(columns: TableColumn[]): void { - if (columns.length === 0) { - return; - } - - let valuesByColumnName: { [columnName: string]: TableValue[] } = {}; - for (let column of columns) { - valuesByColumnName[column.name] = column.values; - } - - // Perform an argsort - function cmp(i: number, j: number): number { - for (let [columnName, multiplier] of this.sortOrder) { - let values = valuesByColumnName[columnName]; - if (values === undefined) { - // The table's contents must've changed and no longer have a - // column with this name - continue; - } - - let a = values[i]; - let b = values[j]; - - if (a < b) { - return -1 * multiplier; - } else if (a > b) { - return 1 * multiplier; - } - } - - return 0; - } - - let indices = [...columns[0].values.keys()]; - indices.sort(cmp.bind(this)); - - // Now that we have the sorted indices, we can use them to reorder the values - for (let column of columns) { - column.values = indices.map((i) => column.values[i]); - } - } -} - export class TableComponent extends ComponentBase { - state: TableState; + state: Required; private tableElement: HTMLElement; - private sortOrder = new SortOrder(); - - private headerCells: HTMLElement[] = []; - private rowNumberCells: HTMLElement[] = []; - private dataCells: HTMLElement[] = []; + private dataWidth: number; + private dataHeight: number; createElement(): HTMLElement { let element = document.createElement('div'); @@ -136,139 +39,128 @@ export class TableComponent extends ComponentBase { } updateElement( - deltaState: TableDeltaState, + deltaState: TableState, latentComponents: Set ): void { super.updateElement(deltaState, latentComponents); + // Content if (deltaState.data !== undefined) { - this.state.data = dataToColumns(deltaState.data); + console.log(`Headers ${deltaState.headers}`); + console.log(`Data ${deltaState.data}`); + this.updateContent(); + } - this.displayData(); + // Anything else requires a styling update + this.updateStyling(); + } - if (deltaState.show_row_numbers ?? this.state.show_row_numbers) { - this.showRowNumbers(); - } + /// Removes any previous content and updates the table with the new data. + /// Does not apply any sort of styling, not even to the headers or row + /// numbers. + private updateContent(): void { + // Clear the old table + this.tableElement.innerHTML = ''; - if (Array.isArray(deltaState.data)) { - this.hideColumnHeaders(); - } else { - this.showColumnHeaders(); - } - } else if ( - deltaState.show_row_numbers !== undefined && - deltaState.show_row_numbers !== this.state.show_row_numbers - ) { - if (deltaState.show_row_numbers) { - this.showRowNumbers(); - } else { - this.hideRowNumbers(); + // If there is no data, this is it + if (this.state.data.length === 0) { + return; + } + + this.dataHeight = this.state.data.length; + this.dataWidth = this.state.data[0].length; + + // Update the table's CSS to match the number of rows & columns + this.tableElement.style.gridTemplateColumns = `repeat(${ + this.dataWidth + 1 + }, auto)`; + + this.tableElement.style.gridTemplateRows = `repeat(${ + this.dataHeight + 1 + }, auto)`; + + // Empty top-left corner + let itemElement = document.createElement('div'); + itemElement.classList.add('rio-table-header'); + itemElement.textContent = ''; + this.tableElement.appendChild(itemElement); + + // Add the headers + let headers: string[]; + + if (this.state.headers === null) { + headers = new Array(this.dataWidth).fill(''); + } else { + headers = this.state.headers; + } + + for (let ii = 0; ii < this.dataWidth; ii++) { + let itemElement = document.createElement('div'); + itemElement.classList.add('rio-table-header'); + itemElement.textContent = headers[ii]; + this.tableElement.appendChild(itemElement); + } + + // Add the cells + for (let data_yy = 0; data_yy < this.dataHeight; data_yy++) { + // Row number + let itemElement = document.createElement('div'); + itemElement.classList.add('rio-table-row-number'); + itemElement.textContent = (data_yy + 1).toString(); + this.tableElement.appendChild(itemElement); + + // Data value + for (let data_xx = 0; data_xx < this.dataWidth; data_xx++) { + let itemElement = document.createElement('div'); + itemElement.classList.add('rio-table-item'); + itemElement.textContent = + this.state.data[data_yy][data_xx].toString(); + this.tableElement.appendChild(itemElement); } } + + // Add row-highlighters. These span entire rows and change colors when + // hovered + for (let ii = 0; ii < this.dataHeight; ii++) { + let itemElement = document.createElement('div'); + itemElement.classList.add('rio-table-row-highlighter'); + itemElement.style.gridRow = `${ii + 2}`; + itemElement.style.gridColumn = `1 / span ${this.dataWidth + 1}`; + this.tableElement.appendChild(itemElement); + } } - private displayData(): void { - for (let element of this.dataCells) { - element.remove(); + /// Updates the styling of the already populated table. + private updateStyling(): void { + for (let style of this.state.styling) { + this.applySingleStyle(style); + } + } + + private applySingleStyle(style: TableStyle): void { + // Come up with the CSS to apply to the targeted cells + let css = {}; + + if (style.fontWeight !== undefined) { + css['font-weight'] = style.fontWeight; } - this.dataCells = []; + // Find the targeted area + let styleLeft = style.left + 1; + let styleWidth = style.width; + let styleTop = style.top === 'header' ? 0 : style.top + 1; + let styleHeight = style.height; - let columnNr = 2; - for (let column of this.state.data) { - for (let [rowNr, value] of column.values.entries()) { - let cell = document.createElement('span'); - cell.textContent = `${value}`; - cell.style.textAlign = column.alignment; + let htmlWidth = this.dataWidth + 1; - cell.style.gridRow = `${rowNr + 2}`; - cell.style.gridColumn = `${columnNr}`; - this.tableElement.appendChild(cell); - this.dataCells.push(cell); + // Apply the CSS to all selected cells + for (let yy = styleTop; yy < styleTop + styleHeight; yy++) { + for (let xx = styleLeft; xx < styleLeft + styleWidth; xx++) { + let index = yy * htmlWidth + xx; + let cell = this.tableElement.children[index] as HTMLElement; + + Object.assign(cell.style, css); } - - columnNr++; } } - - private showRowNumbers(): void { - this.hideRowNumbers(); - - let numRows = - this.state.data.length === 0 ? 0 : this.state.data[0].values.length; - - for (let i = 0; i < numRows; i++) { - let cell = document.createElement('span'); - cell.textContent = `${i + 1}.`; - cell.style.textAlign = 'right'; - cell.style.opacity = '0.5'; - - cell.style.gridRow = `${i + 2}`; - cell.style.gridColumn = '1'; - this.tableElement.appendChild(cell); - - this.rowNumberCells.push(cell); - } - } - - private hideRowNumbers(): void { - for (let element of this.rowNumberCells) { - element.remove(); - } - - this.rowNumberCells = []; - } - - private showColumnHeaders(): void { - this.hideColumnHeaders(); - - for (let [i, column] of this.state.data.entries()) { - let cell = document.createElement('span'); - cell.classList.add('header'); - cell.textContent = column.name; - cell.style.textAlign = column.alignment; - cell.style.opacity = '0.5'; - - cell.addEventListener( - 'click', - this.onHeaderClick.bind(this, column.name) - ); - - cell.style.gridRow = '1'; - cell.style.gridColumn = `${i + 2}`; - this.tableElement.appendChild(cell); - - this.headerCells.push(cell); - } - } - - private hideColumnHeaders(): void { - for (let element of this.headerCells) { - element.remove(); - } - - this.headerCells = []; - } - - private onHeaderClick(columnName: string, event: MouseEvent): void { - let clickedHeader = event.target as HTMLElement; - if (clickedHeader.tagName !== 'SPAN') { - clickedHeader = clickedHeader.parentElement!; - } - - let ascending = clickedHeader.dataset.sort !== 'ascending'; - - // Remove the `data-sort` attribute from all other headers - for (let cell of this.headerCells) { - delete cell.dataset.sort; - } - clickedHeader.dataset.sort = ascending ? 'ascending' : 'descending'; - - this.sortOrder.add(columnName, ascending); - this.sortOrder.sort(this.state.data); - this.displayData(); - - // Eat the event - markEventAsHandled(event); - } } diff --git a/frontend/css/style.scss b/frontend/css/style.scss index 6d8bec0a..de98f7db 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -2672,28 +2672,37 @@ $rio-input-box-small-label-spacing-top: 0.5rem; display: grid; - & > * { - padding: 0.5rem 0.8rem; + .rio-table-header { + position: relative; + + font-weight: bold; + text-align: center; } - .header { - display: flex; - cursor: pointer; - } - - .header::after { - content: '▴'; - display: inline-block; - margin-left: 0.3rem; - opacity: 0; - } - .header[data-sort='ascending']::after { - content: '▴'; - opacity: 1; - } - .header[data-sort='descending']::after { + .rio-table-header:hover::after { content: '▾'; - opacity: 1; + position: absolute; + top: 0; + right: 0.5rem; + bottom: 0; + } + + .rio-table-row-number { + opacity: 0.5; + text-align: right; + } + + .rio-table-item { + } + + & > div { + padding: 0.5rem; + } + + .rio-table-row-highlighter { + padding: 0 !important; + height: 0.5rem; + background-color: var(--rio-local-bg-active); } } diff --git a/rio/components/table.py b/rio/components/table.py index f1cb7693..00a3ab53 100644 --- a/rio/components/table.py +++ b/rio/components/table.py @@ -26,7 +26,7 @@ TableValue = int | float | str @dataclass class TableSelection: _left: int - _top: int + _top: int | Literal["header"] _width: int _height: int @@ -58,9 +58,13 @@ class TableSelection: def _index_to_start_and_extent( - index: int | slice, + index: int | slice | str, size_in_axis: int, -) -> Tuple[int, int]: + axis: Literal["x", "y"], +) -> Tuple[ + int | Literal["header"], + int, +]: """ Given a one-axis `__getitem__` index, returns the start and extent of the slice as non-negative integers. @@ -100,6 +104,10 @@ def _index_to_start_and_extent( f"Table indices should be integers or slices, not {index!r}" ) + # In the y-axis, "header" is a valid index + elif axis == "y" and index == "header": + return "header", 1 + # Anything else is invalid else: raise ValueError( @@ -131,7 +139,11 @@ def _string_index_to_start_and_extent( index: str | int | slice, column_names: list[str] | None, size_in_axis: int, -) -> Tuple[int, int]: + axis: Literal["x", "y"], +) -> Tuple[ + int | Literal["header"], + int, +]: """ Same as `_index_to_start_and_extent`, but with support for string indices. """ @@ -150,15 +162,24 @@ def _string_index_to_start_and_extent( return left, 1 # Otherwise delegate to the other function - return _index_to_start_and_extent(index, size_in_axis) + return _index_to_start_and_extent( + index, + size_in_axis, + axis, + ) def _indices_to_rectangle( - index: str | tuple[int | slice, str | int | slice], + index: str | tuple[int | slice | str, str | int | slice], column_names: list[str] | None, data_width: int, data_height: int, -) -> tuple[int, int, int, int]: +) -> tuple[ + int, + int | Literal["header"], + int, + int, +]: # Get the raw x & y indices if isinstance(index, str): index_y = slice(None) @@ -179,21 +200,25 @@ def _indices_to_rectangle( top, height = _index_to_start_and_extent( index_y, data_height, + axis="y", ) left, width = _string_index_to_start_and_extent( index_x, column_names, data_width, + axis="x", ) + assert isinstance(left, int), left # Left can't be "header" + return left, top, width, height @final class Table(FundamentalComponent): """ - A table of data. + Display & input for tabular data. Tables are a way to display data in a grid, with rows and columns. They are very useful for displaying data that is naturally tabular, such as @@ -242,11 +267,9 @@ class Table(FundamentalComponent): # All headers, if present _headers: list[str] | None = None - # All data. This is initialized in `__post_init__`, so most code can rely on - # the type hint to be correct, despite the invalid assignment here. - # - # This is a list of rows. - _data: list[list[TableValue]] = None # type: ignore + # The data, as a list of rows ("row-major"). This is initialized in + # `__post_init__`. + _data: list[list[TableValue]] = [] # All styles applied to the table, in the order they were added. _styling: list[TableSelection] = [] @@ -276,17 +299,34 @@ class Table(FundamentalComponent): self._headers = list(data.keys()) # Verify all columns have the same length - lengths = [len(list(column)) for column in self.data.values()] - if len(set(lengths)) > 1: + columns: list[list[TableValue]] = [] + column_lengths = set() + + for column in data.values(): + column = list(column) + column_lengths.add(len(column)) + columns.append(column) + + if len(column_lengths) > 1: raise ValueError("All table columns must have the same length") # Black magic to transpose the data self._data = list(map(list, zip(*self.data.values()))) # Iterable of iterables - data = typing.cast(Iterable[Iterable[TableValue]], self.data) - self._headers = None - self._data = [list(row) for row in data] + else: + data = typing.cast(Iterable[Iterable[TableValue]], self.data) + self._headers = None + self._data = [] + row_lengths = set() + + for row in data: + row = list(row) + row_lengths.add(len(row)) + self._data.append(row) + + if len(row_lengths) > 1: + raise ValueError("All table rows must have the same length") def _custom_serialize(self) -> JsonDoc: return { @@ -321,12 +361,16 @@ class Table(FundamentalComponent): def __getitem__( self, - index: str | Tuple[int | slice, str | int | slice], + index: str + | Tuple[ + int | slice | str, + int | slice | str, + ], ) -> TableSelection: # Get the index as a tuple (top, left, height, width) data_height, data_width = self._shape() - top, left, height, width = _indices_to_rectangle( + left, top, width, height = _indices_to_rectangle( index, self._headers, data_width, @@ -335,16 +379,23 @@ class Table(FundamentalComponent): # Verify the indices are within bounds right = left + width - bottom = top + height + out_of_bounds = (left < 0 or left >= data_width) or ( + right < 0 or right > data_width + ) - if ( - (top < 0 or top >= data_height) - or (left < 0 or left >= data_width) - or (bottom < 0 or bottom > data_height) - or (right < 0 or right > data_width) - ): + if top == "header": + assert height == 1, height + else: + bottom = top + height + out_of_bounds = ( + out_of_bounds + or (top < 0 or top >= data_height) + or (bottom < 0 or bottom > data_height) + ) + + if out_of_bounds: raise IndexError( - f"Table index out of bounds. You're trying to select [{top}:{bottom}, {left}:{right}] but the table is only {data_height}x{data_width}" + f"The table index {index!r} is out of bounds for a table of size {data_height}x{data_width}" ) # Construct the result diff --git a/rio/serialization.py b/rio/serialization.py index ec0e9e62..0b7087ef 100644 --- a/rio/serialization.py +++ b/rio/serialization.py @@ -145,8 +145,8 @@ def serialize_and_host_component(component: rio.Component) -> JsonDoc: ).items(): result[name] = serializer(sess, getattr(component, name)) - # Encode any internal additional state. Doing it this late allows the custom - # serialization to overwrite automatically generated values. + # Encode any internal additional state. Doing it this late allows the + # custom serialization to overwrite automatically generated values. result["_type_"] = component._unique_id result.update(component._custom_serialize()) diff --git a/tests/test_table_indexing.py b/tests/test_table_indexing.py index 141293e3..f2dc06ba 100644 --- a/tests/test_table_indexing.py +++ b/tests/test_table_indexing.py @@ -3,7 +3,7 @@ Tables support numpy-style 2D indexing. This is rather complex, hence the tests here. """ -from typing import Any, Type +from typing import * import pytest @@ -230,12 +230,45 @@ make_index = MakeIndex() False, ValueError, ), + # Indexing into the header + ( + make_index["header", :], + False, + (0, "header", 10, 1), + ), + ( + make_index["header", 3:5], + False, + (3, "header", 2, 1), + ), + ( + make_index["header", -2:], + False, + (8, "header", 2, 1), + ), + ( + make_index["header", :-2], + False, + (0, "header", 8, 1), + ), + # Indexing into the header, but in the wrong axis + ( + make_index[0, "header"], + False, + ValueError, + ), ], ) def test_indices( index: Any, enable_column_names: bool, - result_should: tuple[int, int, int, int] | Type[Exception], + result_should: tuple[ + int, + int | Literal["header"], + int, + int, + ] + | Type[Exception], ) -> None: if enable_column_names: column_names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]