WIP: new table implementation

This commit is contained in:
Jakob Pinterits
2024-08-29 08:50:59 +02:00
parent 7f364f183e
commit 1996ec90df
5 changed files with 265 additions and 280 deletions

View File

@@ -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<Required<TableDeltaState>, '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<TableState>;
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<ComponentBase>
): 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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"]