@bokeh/bokehjs
Version:
Interactive, novel data visualization
261 lines • 10.2 kB
JavaScript
import { span } from "../../../core/dom";
import { is_nullish } from "../../../core/util/types";
import { Grid as SlickGrid, Group } from "@bokeh/slickgrid";
import { DTINDEX_NAME } from "./definitions";
import { TableDataProvider, DataTableView, DataTable } from "./data_table";
import { ColumnDataSource } from "../../sources/column_data_source";
import { RowAggregator } from "./row_aggregators";
import { Model } from "../../../model";
function groupCellFormatter(_row, _cell, _value, _columnDef, dataContext) {
const { collapsed, level, title } = dataContext;
const toggle = span({
class: `slick-group-toggle ${collapsed ? "collapsed" : "expanded"}`,
style: { "margin-left": `${level * 15}px` },
});
const titleElement = span({
class: "slick-group-title",
}, title);
return `${toggle.outerHTML}${titleElement.outerHTML}`;
}
function indentFormatter(formatter, indent) {
return (row, cell, value, columnDef, dataContext) => {
const spacer = span({
class: "slick-group-toggle",
style: { "margin-left": `${(indent ?? 0) * 15}px` },
});
const formatted = formatter != null ? formatter(row, cell, value, columnDef, dataContext) : `${value}`;
return `${spacer.outerHTML}${formatted.replace(/^<div/, "<span").replace(/div>$/, "span>")}`;
};
}
function handleGridClick(event, args) {
const item = this.getDataItem(args.row);
if (item instanceof Group && event.target.classList.contains("slick-group-toggle")) {
if (item.collapsed) {
this.getData().expandGroup(item.groupingKey);
}
else {
this.getData().collapseGroup(item.groupingKey);
}
event.stopImmediatePropagation();
event.preventDefault();
this.invalidate();
this.render();
}
}
export class GroupingInfo extends Model {
static __name__ = "GroupingInfo";
constructor(attrs) {
super(attrs);
}
static {
this.define(({ Bool, Str, List, Ref }) => ({
getter: [Str, ""],
aggregators: [List(Ref(RowAggregator)), []],
collapsed: [Bool, false],
}));
}
get comparer() {
return (a, b) => {
return a.value === b.value ? 0 : a.value > b.value ? 1 : -1;
};
}
}
export class DataCubeProvider extends TableDataProvider {
static __name__ = "DataCubeProvider";
columns;
groupingInfos;
groupingDelimiter;
toggledGroupsByLevel;
rows;
target;
constructor(source, view, columns, target) {
super(source, view);
this.columns = columns;
this.groupingInfos = [];
this.groupingDelimiter = ":|:";
this.target = target;
}
setGrouping(groupingInfos) {
this.groupingInfos = groupingInfos;
this.toggledGroupsByLevel = groupingInfos.map(() => ({}));
const row_indices = this.target.get_array("row_indices");
const labels = this.target.get_array("labels");
const parents = [];
const parent_labels = [];
row_indices.forEach((indices, i) => {
if (typeof indices === "number") {
this.toggledGroupsByLevel[parent_labels.length - 1][parent_labels.join(this.groupingDelimiter)] = false;
}
else {
while (parents.length > 0 && !indices.every((index) => parents[parents.length - 1].includes(index))) {
parents.pop();
parent_labels.pop();
}
if (parent_labels.length > 0) {
this.toggledGroupsByLevel[parent_labels.length - 1][parent_labels.join(this.groupingDelimiter)] = false;
}
parents.push(indices);
parent_labels.push(labels[i]);
}
});
this.refresh();
}
extractGroups(rows, parent_group) {
const groups = [];
const groupsByValue = new Map();
const level = parent_group != null ? parent_group.level + 1 : 0;
const { comparer, getter } = this.groupingInfos[level];
const column = this.source.get(getter);
for (const row of rows) {
const value = column[row];
let group = groupsByValue.get(value);
if (group == null) {
const groupingKey = parent_group != null ? `${parent_group.groupingKey}${this.groupingDelimiter}${value}` : `${value}`;
group = Object.assign(new Group(), { value, level, groupingKey });
groups.push(group);
groupsByValue.set(value, group);
}
group.rows.push(row);
}
if (level < this.groupingInfos.length - 1) {
for (const group of groups) {
group.groups = this.extractGroups(group.rows, group);
}
}
groups.sort(comparer);
return groups;
}
calculateTotals(group, aggregators) {
const totals = { avg: {}, max: {}, min: {}, sum: {} };
for (const aggregator of aggregators) {
aggregator.init();
for (const row of group.rows) {
aggregator.accumulate(this.source.get_row(row));
}
aggregator.storeResult(totals);
}
return totals;
}
addTotals(groups, level = 0) {
const { aggregators, collapsed: groupCollapsed } = this.groupingInfos[level];
const toggledGroups = this.toggledGroupsByLevel[level];
for (const group of groups) {
if (!is_nullish(group.groups)) { // XXX: bad typings
this.addTotals(group.groups, level + 1);
}
if (aggregators.length != 0 && group.rows.length != 0) {
group.totals = this.calculateTotals(group, aggregators);
}
group.collapsed = groupCollapsed !== toggledGroups[group.groupingKey];
group.title = group.value ? `${group.value}` : "";
}
}
flattenedGroupedRows(groups, level = 0) {
const rows = [];
for (const group of groups) {
rows.push(group);
if (!group.collapsed) {
const subRows = !is_nullish(group.groups) // XXX: bad typings
? this.flattenedGroupedRows(group.groups, level + 1)
: group.rows;
rows.push(...subRows);
}
}
return rows;
}
refresh() {
const groups = this.extractGroups(this.view.indices);
const labels = this.source.get(this.columns[0].field);
if (groups.length != 0) {
this.addTotals(groups);
this.rows = this.flattenedGroupedRows(groups);
this.target.data = {
row_indices: this.rows.map(value => value instanceof Group ? value.rows : value),
labels: this.rows.map(value => value instanceof Group ? value.title : labels[value]),
};
}
}
getLength() {
return this.rows.length;
}
getItem(i) {
const item = this.rows[i];
return item instanceof Group
? item
: { [DTINDEX_NAME]: item, ...this.source.get_row(item) };
}
getItemMetadata(i) {
const my_item = this.rows[i];
const columns = this.columns.slice(1);
const aggregators = my_item instanceof Group
? this.groupingInfos[my_item.level].aggregators
: [];
function adapter(column) {
const { field: my_field, formatter } = column;
const aggregator = aggregators.find(({ field_ }) => field_ === my_field);
if (aggregator != null) {
const { key } = aggregator;
return {
formatter(row, cell, _value, columnDef, dataContext) {
return formatter != null ? formatter(row, cell, dataContext.totals[key][my_field], columnDef, dataContext) : "";
},
};
}
return {};
}
return my_item instanceof Group
? {
selectable: false,
focusable: false,
cssClasses: "slick-group",
columns: [{ formatter: groupCellFormatter }, ...columns.map(adapter)],
}
: {};
}
collapseGroup(grouping_key) {
const level = grouping_key.split(this.groupingDelimiter).length - 1;
this.toggledGroupsByLevel[level][grouping_key] = !this.groupingInfos[level].collapsed;
this.refresh();
}
expandGroup(grouping_key) {
const level = grouping_key.split(this.groupingDelimiter).length - 1;
this.toggledGroupsByLevel[level][grouping_key] = this.groupingInfos[level].collapsed;
this.refresh();
}
}
export class DataCubeView extends DataTableView {
static __name__ = "DataCubeView";
_render_table() {
const options = {
enableCellNavigation: this.model.selectable !== false,
enableColumnReorder: false,
autosizeColsMode: this.autosize,
multiColumnSort: false,
editable: this.model.editable,
autoEdit: this.model.auto_edit,
rowHeight: this.model.row_height,
};
const columns = this.model.columns.map(column => column.toColumn());
columns[0].formatter = indentFormatter(columns[0].formatter, this.model.grouping.length);
delete columns[0].editor;
this.data = new DataCubeProvider(this.model.source, this.model.view, columns, this.model.target);
this.data.setGrouping(this.model.grouping);
this.el.style.width = `${this.model.width}px`;
this.grid = new SlickGrid(this.wrapper_el, this.data, columns, options);
this.grid.onClick.subscribe(handleGridClick);
}
}
export class DataCube extends DataTable {
static __name__ = "DataCube";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = DataCubeView;
this.define(({ List, Ref }) => ({
grouping: [List(Ref(GroupingInfo)), []],
target: [Ref(ColumnDataSource)],
}));
}
}
//# sourceMappingURL=data_cube.js.map