UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

252 lines 9.83 kB
import { span } from "../../../core/dom"; import { dict } from "../../../core/util/object"; import { is_nullish } from "../../../core/util/types"; import { assert } from "../../../core/util/assert"; 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(() => ({})); 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]; for (const row of rows) { const column = dict(this.source.data).get(getter); assert(column != null); 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: {} }; const data = dict(this.source.data); const names = [...data.keys()]; const items = group.rows.map((i) => { return names.reduce((obj, name) => ({ ...obj, [name]: data.get(name)[i] }), {}); }); for (const aggregator of aggregators) { aggregator.init(); for (const item of items) { aggregator.accumulate(item); } 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 data = dict(this.source.data); const labels = data.get(this.columns[0].field); assert(labels != null); 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]; const data = dict(this.source.data); return item instanceof Group ? item : [...data.keys()].reduce((obj, name) => ({ ...obj, [name]: data.get(name)[item] }), { [DTINDEX_NAME]: 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