@bokeh/bokehjs
Version:
Interactive, novel data visualization
459 lines • 17.3 kB
JavaScript
import { RowSelectionModel } from "@bokeh/slickgrid/plugins/slick.rowselectionmodel";
import { CheckboxSelectColumn } from "@bokeh/slickgrid/plugins/slick.checkboxselectcolumn";
import { CellExternalCopyManager } from "@bokeh/slickgrid/plugins/slick.cellexternalcopymanager";
import { Grid as SlickGrid } from "@bokeh/slickgrid";
import { div } from "../../../core/dom";
import { dict } from "../../../core/util/object";
import { unique_id } from "../../../core/util/string";
import { isString, isNumber } from "../../../core/util/types";
import { some, range, sort_by, map } from "../../../core/util/array";
import { filter } from "../../../core/util/arrayable";
import { is_NDArray } from "../../../core/util/ndarray";
import { logger } from "../../../core/logging";
import { WidgetView } from "../widget";
import { DTINDEX_NAME } from "./definitions";
import { TableWidget } from "./table_widget";
import { TableColumn } from "./table_column";
import { build_view } from "../../../core/build_views";
import tables_css, * as tables from "../../../styles/widgets/tables.css";
import slickgrid_css from "../../../styles/widgets/slickgrid.css";
export const AutosizeModes = {
fit_columns: "FCV",
fit_viewport: "FVC",
force_fit: "LFF",
none: "NOA",
};
let _warned_not_reorderable = false;
export class TableDataProvider {
static __name__ = "TableDataProvider";
index;
source;
view;
constructor(source, view) {
this.init(source, view);
}
init(source, view) {
if (DTINDEX_NAME in source.data) {
throw new Error(`special name ${DTINDEX_NAME} cannot be used as a data table column`);
}
this.source = source;
this.view = view;
this.index = [...this.view.indices];
}
getLength() {
return this.index.length;
}
getItem(offset) {
const item = {};
const data = dict(this.source.data);
for (const [field, column] of data) {
const i = this.index[offset];
const value = is_NDArray(column) ? column.get(i) : column[i];
item[field] = value;
}
item[DTINDEX_NAME] = this.index[offset];
return item;
}
getField(offset, field) {
if (field == DTINDEX_NAME) {
return this.index[offset];
}
else {
const data = dict(this.source.data);
const column = data.get(field) ?? [];
const i = this.index[offset];
return is_NDArray(column) ? column.get(i) : column[i];
}
}
setField(offset, field, value) {
// field assumed never to be internal index name (ctor would throw)
const index = this.index[offset];
const patches = new Map([
[field, [[index, value]]],
]);
this.source.patch(patches);
}
getRecords() {
return range(0, this.getLength()).map((i) => this.getItem(i));
}
getItems() {
return this.getRecords();
}
slice(start, end, step = 1) {
end = end ?? this.getLength();
return range(start, end, step).map((i) => this.getItem(i));
}
sort(columns) {
let cols = columns.map((column) => [column.sortCol, column.sortAsc ? 1 : -1]);
if (cols.length == 0) {
cols = [[{ field: DTINDEX_NAME }, 1]];
}
const records = this.getRecords();
const lookup = {};
this.index.forEach((v, i) => lookup[v] = i);
this.index.sort((i0, i1) => {
for (const [col, sign] of cols) {
const field = col.field;
const v0 = records[lookup[i0]][field];
const v1 = records[lookup[i1]][field];
if (col.sorter != null) {
return sign * col.sorter.compute(v0, v1);
}
if (v0 === v1) {
continue;
}
if (isNumber(v0) && isNumber(v1)) {
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
return sign * (v0 - v1 || +isNaN(v0) - +isNaN(v1));
}
else {
const result = `${v0}`.localeCompare(`${v1}`);
if (result == 0) {
continue;
}
else {
return sign * result;
}
}
}
return 0;
});
}
}
export class DataTableView extends WidgetView {
static __name__ = "DataTableView";
cds_view;
data;
grid;
_in_selection_update = false;
_width = null;
_filtered_selection = [];
get data_source() {
return this.model.properties.source;
}
wrapper_el;
children_views() {
return [...super.children_views(), this.cds_view];
}
async lazy_initialize() {
await super.lazy_initialize();
this.cds_view = await build_view(this.model.view, { parent: this });
}
remove() {
this.cds_view.remove();
this.grid.destroy();
super.remove();
}
connect_signals() {
super.connect_signals();
this.connect(this.model.change, () => this.rerender());
for (const column of this.model.columns) {
this.connect(column.change, () => this.rerender());
}
// changes to the source trigger the callback below via
// compute_indices hooks in cds view
// TODO reevaluate the control flow when taking a general look at events
this.connect(this.model.view.change, () => this.updateGrid());
this.connect(this.model.source.selected.change, () => this.updateSelection());
this.connect(this.model.source.selected.properties.indices.change, () => this.updateSelection());
}
stylesheets() {
return [...super.stylesheets(), slickgrid_css, tables_css];
}
_after_resize() {
super._after_resize();
this.grid.resizeCanvas();
this.updateLayout(true, false);
}
_after_layout() {
super._after_layout();
this.grid.resizeCanvas();
this.updateLayout(true, false);
}
box_sizing() {
const sizing = super.box_sizing();
if (this.model.autosize_mode === "fit_viewport" && this._width != null) {
sizing.width = this._width;
}
return sizing;
}
updateLayout(initialized, rerender) {
const autosize = this.autosize;
if (autosize === AutosizeModes.fit_columns || autosize === AutosizeModes.force_fit) {
if (!initialized) {
this.grid.resizeCanvas();
}
this.grid.autosizeColumns();
}
else if (initialized && rerender && autosize === AutosizeModes.fit_viewport) {
this.invalidate_layout();
}
}
updateGrid() {
this.data.init(this.model.source, this.model.view);
// This is obnoxious but there is no better way to programmatically force
// a re-sort on the existing sorted columns until/if we start using DataView
if (this.model.sortable) {
const columns = this.grid.getColumns();
const sorters = this.grid.getSortColumns().map((x) => ({
sortCol: {
field: columns[this.grid.getColumnIndex(x.columnId)].field,
},
sortAsc: x.sortAsc,
}));
this.data.sort(sorters);
}
this._sync_selected_with_view();
this.updateSelection();
this.grid.invalidate();
this.updateLayout(true, true);
}
updateSelection() {
if (this.model.selectable === false || this._in_selection_update) {
return;
}
const { indices } = this.model.source.selected;
const lookup = {};
this.data.index.forEach((v, i) => lookup[v] = i);
const permuted_indices = sort_by(map(indices, (x) => lookup[x]), (x) => x);
this._in_selection_update = true;
try {
this.grid.setSelectedRows([...permuted_indices]);
}
finally {
this._in_selection_update = false;
}
// If the selection is not in the current slickgrid viewport, scroll the
// datatable to start at the row before the first selected row, so that
// the selection is immediately brought into view. We don't scroll when
// the selection is already in the viewport so that selecting from the
// datatable itself does not re-scroll.
const cur_grid_range = this.grid.getViewport();
const scroll_index = this.model.get_scroll_index(cur_grid_range, permuted_indices);
if (scroll_index != null) {
this.grid.scrollRowToTop(scroll_index);
}
}
newIndexColumn() {
return {
id: unique_id(),
name: this.model.index_header,
field: DTINDEX_NAME,
width: this.model.index_width,
behavior: "select",
cannotTriggerInsert: true,
resizable: false,
selectable: false,
sortable: true,
cssClass: tables.cell_index,
headerCssClass: tables.header_index,
};
}
get autosize() {
let autosize;
if (this.model.fit_columns === true) {
autosize = AutosizeModes.force_fit;
}
else if (this.model.fit_columns === false) {
autosize = AutosizeModes.none;
}
else {
autosize = AutosizeModes[this.model.autosize_mode];
}
return autosize;
}
render() {
super.render();
this.wrapper_el = div({ class: tables.data_table });
this.shadow_el.appendChild(this.wrapper_el);
}
_render_table() {
const columns = this.model.columns.filter((column) => column.visible).map((column) => {
return { ...column.toColumn(), parent: this };
});
let checkbox_selector = null;
if (this.model.selectable == "checkbox") {
checkbox_selector = new CheckboxSelectColumn({ cssClass: tables.cell_select });
columns.unshift(checkbox_selector.getColumnDefinition());
}
if (this.model.index_position != null) {
const index_position = this.model.index_position;
const index = this.newIndexColumn();
// This is to be able to provide negative index behaviour that
// matches what python users will expect
if (index_position == -1) {
columns.push(index);
}
else if (index_position < -1) {
columns.splice(index_position + 1, 0, index);
}
else {
columns.splice(index_position, 0, index);
}
}
let { reorderable } = this.model;
if (reorderable && !(typeof $ != "undefined" && typeof $.fn != "undefined" && "sortable" in $.fn)) {
if (!_warned_not_reorderable) {
logger.warn("jquery-ui is required to enable DataTable.reorderable");
_warned_not_reorderable = true;
}
reorderable = false;
}
let frozen_row = -1;
let frozen_bottom = false;
const { frozen_rows, frozen_columns } = this.model;
const frozen_column = frozen_columns == null ? -1 : frozen_columns - 1;
if (frozen_rows != null) {
frozen_bottom = frozen_rows < 0;
frozen_row = Math.abs(frozen_rows);
}
const options = {
enableCellNavigation: this.model.selectable !== false,
enableColumnReorder: reorderable,
autosizeColsMode: this.autosize,
multiColumnSort: this.model.sortable,
editable: this.model.editable,
autoEdit: this.model.auto_edit,
autoHeight: false,
rowHeight: this.model.row_height,
frozenColumn: frozen_column,
frozenRow: frozen_row,
frozenBottom: frozen_bottom,
explicitInitialization: false,
multiSelect: this.model.multi_selectable,
};
this.data = new TableDataProvider(this.model.source, this.model.view);
this.grid = new SlickGrid(this.wrapper_el, this.data, columns, options);
if (this.autosize == AutosizeModes.fit_viewport) {
this.grid.autosizeColumns();
let width = 0;
for (const column of columns) {
width += column.width ?? 0;
}
this._width = Math.ceil(width);
}
this.grid.onSort.subscribe((_event, args) => {
if (!this.model.sortable) {
return;
}
const to_sort = args.sortCols;
if (to_sort == null) {
return;
}
this.data.sort(to_sort);
this.grid.invalidate();
this.updateSelection();
this.grid.render();
if (!this.model.header_row) {
this._hide_header();
}
this.model.update_sort_columns(to_sort);
});
if (this.model.selectable !== false) {
this.grid.setSelectionModel(new RowSelectionModel({ selectActiveRow: checkbox_selector == null }));
if (checkbox_selector != null) {
this.grid.registerPlugin(checkbox_selector);
}
const pluginOptions = {
dataItemColumnValueExtractor(val, col) {
// As defined in this file, Item can contain any type values
let value = val[col.field];
if (isString(value)) {
value = value.replace(/\n/g, "\\n");
}
return value;
},
includeHeaderWhenCopying: false,
};
this.grid.registerPlugin(new CellExternalCopyManager(pluginOptions));
this.grid.onSelectedRowsChanged.subscribe((_event, args) => {
if (this._in_selection_update) {
return;
}
this.model.source.selected.indices = args.rows.map((i) => this.data.index[i]);
});
this.updateSelection();
if (!this.model.header_row) {
this._hide_header();
}
}
}
_after_render() {
const initialized = typeof this.grid !== "undefined";
this._render_table();
this.updateLayout(initialized, false);
super._after_render();
}
_hide_header() {
for (const el of this.shadow_el.querySelectorAll(".slick-header-columns")) {
el.style.height = "0px";
}
this.grid.resizeCanvas();
}
get_selected_rows() {
return this.grid.getSelectedRows();
}
_sync_selected_with_view() {
const index = this.data.view.indices;
const { source } = this.data;
const not_filtered = filter(source.selected.indices, (i) => index.get(i));
const was_filtered = new Set(filter(this._filtered_selection, (i) => index.get(i)));
this._filtered_selection = [
...filter(this._filtered_selection, (i) => !was_filtered.has(i)),
...filter(source.selected.indices, (i) => !index.get(i)),
];
source.selected.indices = [
...was_filtered,
...not_filtered,
];
}
}
export class DataTable extends TableWidget {
static __name__ = "DataTable";
_sort_columns = [];
get sort_columns() {
return this._sort_columns;
}
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = DataTableView;
this.define(({ List, Bool, Int, Ref, Str, Enum, Or, Nullable }) => ({
autosize_mode: [Enum("fit_columns", "fit_viewport", "none", "force_fit"), "force_fit"],
auto_edit: [Bool, false],
columns: [List(Ref(TableColumn)), []],
fit_columns: [Nullable(Bool), null],
frozen_columns: [Nullable(Int), null],
frozen_rows: [Nullable(Int), null],
sortable: [Bool, true],
reorderable: [Bool, true],
editable: [Bool, false],
selectable: [Or(Bool, Enum("checkbox")), true],
index_position: [Nullable(Int), 0],
index_header: [Str, "#"],
index_width: [Int, 40],
scroll_to_selection: [Bool, true],
header_row: [Bool, true],
row_height: [Int, 25],
multi_selectable: [Bool, true],
}));
this.override({
width: 600,
height: 400,
});
}
update_sort_columns(sort_cols) {
this._sort_columns = sort_cols.map(({ sortCol, sortAsc }) => ({ field: sortCol.field, sortAsc }));
}
get_scroll_index(grid_range, selected_indices) {
if (!this.scroll_to_selection || (selected_indices.length == 0)) {
return null;
}
if (!some(selected_indices, i => grid_range.top <= i && i <= grid_range.bottom)) {
return Math.max(0, Math.min(...selected_indices) - 1);
}
return null;
}
}
//# sourceMappingURL=data_table.js.map