diff --git a/src/TableRenderers.jsx b/src/TableRenderers.jsx
index c7907eb..5722390 100644
--- a/src/TableRenderers.jsx
+++ b/src/TableRenderers.jsx
@@ -15,14 +15,20 @@ function redColorScaleGenerator(values) {
function makeRenderer(opts = {}) {
class TableRenderer extends React.Component {
- getPivotSettings = memoize(props => {
+ constructor(props) {
+ super(props);
+
+ // We need state to record which entries are collapsed and which aren't.
+ // This is an object with flat-keys indicating if the corresponding rows
+ // should be collapsed.
+ this.state = {collapsedRows: {}, collapsedCols: {}};
+ }
+
+ getBasePivotSettings = memoize(props => {
// One-time extraction of pivot settings that we'll use throughout the render.
- const pivotData = new PivotData(props);
- const colAttrs = pivotData.props.cols;
- const rowAttrs = pivotData.props.rows;
- const rowKeys = pivotData.getRowKeys();
- const colKeys = pivotData.getColKeys();
+ const colAttrs = this.props.cols;
+ const rowAttrs = this.props.rows;
const tableOptions = {
rowTotals: true,
@@ -32,16 +38,48 @@ function makeRenderer(opts = {}) {
const rowTotals = tableOptions.rowTotals || colAttrs.length === 0;
const colTotals = tableOptions.colTotals || rowAttrs.length === 0;
+ const subtotalOptions = {
+ arrowCollapsed: "\u25B6",
+ arrowExpanded: "\u25E2",
+ ...this.props.subtotalOptions
+ };
+ const colSubtotalDisplay = {
+ displayOnTop: true,
+ enabled: colTotals, // by default enable if col totals are enabled.
+ hideOnExpand: false,
+ ...subtotalOptions.colSubtotalDisplay
+ };
+ const rowSubtotalDisplay = {
+ displayOnTop: false,
+ enabled: rowTotals, // by default enable if row totals are enabled.
+ hideOnExpand: false,
+ ...subtotalOptions.rowSubtotalDisplay
+ };
+
+ const pivotData = new PivotData(
+ props,
+ (!opts.subtotals) ? {} : {
+ rowEnabled: rowSubtotalDisplay.enabled,
+ colEnabled: colSubtotalDisplay.enabled,
+ rowPartialOnTop: rowSubtotalDisplay.displayOnTop,
+ colPartialOnTop: colSubtotalDisplay.displayOnTop,
+ },
+ );
+ const rowKeys = pivotData.getRowKeys();
+ const colKeys = pivotData.getColKeys();
+
return {
pivotData,
colAttrs,
rowAttrs,
colKeys,
rowKeys,
- colAttrSpans: this.calcAttrSpans(colKeys),
- rowAttrSpans: this.calcAttrSpans(rowKeys),
rowTotals,
colTotals,
+ arrowCollapsed: subtotalOptions.arrowCollapsed,
+ arrowExpanded: subtotalOptions.arrowExpanded,
+ colSubtotalDisplay,
+ rowSubtotalDisplay,
...this.heatmapMappers(
pivotData,
this.props.tableColorScaleGenerator,
@@ -50,27 +88,53 @@ function makeRenderer(opts = {}) {
),
};
});
+
+ toggleAttr = (rowOrCol, attrIdx, allKeys) => () => {
+ // Toggle an entire attribute. This only collapses the entire
+ // attribute. Important to keep things snappy.
- calcAttrSpans = (attrArr) => {
+ const keyLen = attrIdx + 1;
+ const collapsed = allKeys.filter(k => k.length == keyLen).map(flatKey);
+
+ const updates = {};
+ collapsed.forEach(k => {updates[k] = true;});
+
+ if (rowOrCol) {
+ this.setState(state => ({collapsedRows: {...state.collapsedRows, ...updates}}));
+ } else {
+ this.setState(state => ({collapsedCols: {...state.collapsedCols, ...updates}}));
+ }
+ }
+
+ toggleRowKey = flatRowKey => () => {
+ this.setState(state => (
+ {collapsedRows: {...state.collapsedRows, [flatRowKey]: !state.collapsedRows[flatRowKey]}}
+ ))
+ }
+
+ toggleColKey = flatColKey => () => {
+ this.setState(state => (
+ {collapsedCols: {...state.collapsedCols, [flatColKey]: !state.collapsedCols[flatColKey]}}
+ ))
+ }
+
+ calcAttrSpans = (attrArr, numAttrs) => {
// Given an array of attribute values (i.e. each element is another array with
// the value at every level), compute the spans for every attribute value at
// every level. The return value is a nested array of the same shape. It has
// -1's for repeated values and the span number otherwise.
- if (attrArr.length === 0) {
- return []
- }
-
const spans = [];
- const li = attrArr[0].map(() => 0); // Index of the last new value
- let lv = attrArr[0].map(() => null);
+ const li = Array(numAttrs).map(() => 0); // Index of the last new value
+ let lv = Array(numAttrs).map(() => null);
for(let i = 0;i < attrArr.length;i++) {
// Keep increasing span values as long as the last keys are the same. For
// the rest, record spans of 1. Update the indices too.
let cv = attrArr[i];
let ent = [];
let depth = 0;
- while (lv[depth] === cv[depth]) {
+ const limit = Math.min(lv.length, cv.length);
+ while (depth < limit && lv[depth] === cv[depth]) {
ent.push(-1);
spans[li[depth]][depth]++;
depth++;
@@ -136,15 +200,17 @@ function makeRenderer(opts = {}) {
clickHandler = (value, rowValues, colValues) => {
const colAttrs = this.props.cols;
const rowAttrs = this.props.rows;
- if (this.props.tableOptions && this.props.tableOptions.clickCallback ) {
+ if (this.props.tableOptions && this.props.tableOptions.clickCallback) {
const filters = {};
- for (const i of Object.keys(colAttrs)) {
+ const colLimit = Math.min(colAttrs.length, colValues.length);
+ for (let i = 0; i < colLimit;i++) {
const attr = colAttrs[i];
if (colValues[i] !== null) {
filters[attr] = colValues[i];
}
}
- for (const i of Object.keys(rowAttrs)) {
+ const rowLimit = Math.min(rowAttrs.length, rowValues.length);
+ for (let i = 0; i < rowLimit;i++) {
const attr = rowAttrs[i];
if (rowValues[i] !== null) {
filters[attr] = rowValues[i];
@@ -165,36 +231,74 @@ function makeRenderer(opts = {}) {
renderColHeaderRow = (attrName, attrIdx, pivotSettings) => {
// Render a single row in the column header at the top of the pivot table.
- const {rowAttrs, colAttrs, colKeys, colAttrSpans, rowTotals} = pivotSettings;
+ const {
+ rowAttrs,
+ colAttrs,
+ colKeys,
+ visibleColKeys,
+ colAttrSpans,
+ rowTotals,
+ arrowExpanded,
+ arrowCollapsed,
+ } = pivotSettings;
const spaceCell = (attrIdx === 0 && rowAttrs.length !== 0)
- ? (
)
+ ? ( | )
: null;
- const attrNameCell = ( | {attrName} | );
+ const needLabelToggle = opts.subtotals && attrIdx !== colAttrs.length - 1;
+ const attrNameCell = (
+
+ {needLabelToggle ? arrowExpanded + ' ' : null} {attrName}
+ |
+ );
const attrValueCells = [];
- const rowSpan = (attrIdx === colAttrs.length - 1 && rowAttrs.length !== 0) ? 2 : 1;
+ const rowIncrSpan = (rowAttrs.length !== 0) ? 1 : 0;
// Iterate through columns. Jump over duplicate values.
let i = 0;
- while (i < colKeys.length) {
- const colSpan = colAttrSpans[i][attrIdx];
- attrValueCells.push(
-
- {colKeys[i][attrIdx]}
- |
- )
+ while (i < visibleColKeys.length) {
+ const colKey = visibleColKeys[i]
+ const colSpan = (attrIdx < colKey.length) ? colAttrSpans[i][attrIdx] : 1;
+ if (attrIdx < colKey.length) {
+ const rowSpan = 1 + ((attrIdx === colAttrs.length - 1) ? rowIncrSpan : 0);
+ const flatColKey = flatKey(colKey.slice(0, attrIdx + 1));
+ const needColToggle = opts.subtotals && attrIdx !== colAttrs.length - 1;
+ const onClick = needColToggle ? this.toggleColKey(flatColKey) : null;
+ attrValueCells.push(
+
+ {needColToggle ? (this.state.collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded) + ' ' : null}
+ {colKey[attrIdx]}
+ |
+ )
+ } else if (attrIdx === colKey.length) {
+ const rowSpan = colAttrs.length - colKey.length + rowIncrSpan;
+ attrValueCells.push(
+ |
+ )
+ }
i = i + colSpan; // The next colSpan columns will have the same value anyway...
};
const totalCell = (attrIdx === 0 && rowTotals)
? (
@@ -209,22 +313,29 @@ function makeRenderer(opts = {}) {
...attrValueCells,
totalCell,
];
- return | {cells}
;
+ return {cells}
;
}
renderRowHeaderRow = (pivotSettings) => {
// Render just the attribute names of the rows (the actual attribute values
// will show up in the individual rows).
- const {rowAttrs, colAttrs} = pivotSettings;
+ const {rowAttrs, colAttrs, rowKeys, arrowExpanded} = pivotSettings;
return (
-
- {rowAttrs.map((r, i) => (
-
- {r}
- |
- ))}
-
+ |
+ {rowAttrs.map((r, i) => {
+ const needLabelToggle = opts.subtotals && i !== rowAttrs.length - 1;
+ return (
+
+ {needLabelToggle ? arrowExpanded + ' ': null} {r}
+ |
+ );
+ })}
+
{colAttrs.length === 0 ? 'Totals' : null}
|
@@ -237,40 +348,59 @@ function makeRenderer(opts = {}) {
const {
rowAttrs,
colAttrs,
- rowKeys,
+ visibleRowKeys,
rowAttrSpans,
- colKeys,
+ visibleColKeys,
pivotData,
rowTotals,
valueCellColors,
rowTotalColors,
+ arrowExpanded,
+ arrowCollapsed,
} = pivotSettings;
+ const colIncrSpan = (colAttrs.length !== 0) ? 1 : 0
const attrValueCells = rowKey.map((r, i) => {
const rowSpan = rowAttrSpans[rowIdx][i];
if (rowSpan > 0) {
- const colSpan = (i === rowKey.length - 1 && colAttrs.length !== 0) ? 2 : 1;
+ const flatRowKey = flatKey(rowKey.slice(0, i + 1));
+ const colSpan = 1 + ((i === rowAttrs.length - 1) ? colIncrSpan : 0);
+ const needRowToggle = opts.subtotals && i !== rowAttrs.length - 1
+ const onClick = needRowToggle ? this.toggleRowKey(flatRowKey) : null;
return (
+ {needRowToggle ? (this.state.collapsedRows[flatRowKey] ? arrowCollapsed : arrowExpanded) + ' ': null}
{r}
|
)
}
});
+
+ const attrValuePaddingCell = (rowKey.length < rowAttrs.length)
+ ? (
+ |
+ )
+ : null;
- const valueCells = colKeys.map((colKey, j) => {
+ const valueCells = visibleColKeys.map((colKey, j) => {
const agg = pivotData.getAggregator(rowKey, colKey);
const aggValue = agg.value();
const style = valueCellColors(rowKey, colKey, aggValue);
return (
@@ -286,6 +416,7 @@ function makeRenderer(opts = {}) {
const style = rowTotalColors(aggValue);
totalCell = (
| {rowCells});
+
+ return ( | {rowCells}
);
}
renderTotalsRow = (pivotSettings) => {
@@ -310,7 +442,7 @@ function makeRenderer(opts = {}) {
const {
rowAttrs,
colAttrs,
- colKeys,
+ visibleColKeys,
colTotalColors,
rowTotals,
pivotData
@@ -318,6 +450,7 @@ function makeRenderer(opts = {}) {
const totalLabelCell = (
@@ -325,14 +458,14 @@ function makeRenderer(opts = {}) {
|
);
- const totalValueCells = colKeys.map((colKey, j) => {
+ const totalValueCells = visibleColKeys.map((colKey, j) => {
const agg = pivotData.getAggregator([], colKey);
const aggValue = agg.value();
const style = colTotalColors([], colKey, aggValue);
return (
@@ -347,6 +480,7 @@ function makeRenderer(opts = {}) {
const aggValue = agg.value();
grandTotalCell = (
|
@@ -360,13 +494,62 @@ function makeRenderer(opts = {}) {
...totalValueCells,
grandTotalCell,
];
-
- return ( | {totalCells}
);
+
+ return ({totalCells}
);
}
+ visibleKeys = (keys, collapsed, numAttrs, subtotalDisplay) => keys.filter(
+ key => (
+ // Is the key hidden by one of its parents?
+ !key.slice(0, key.length - 1).some(
+ (k, j) => collapsed[flatKey(key.slice(0, j + 1))]
+ )
+ && (
+ key.length == numAttrs // Leaf key.
+ || flatKey(key) in collapsed // Children hidden. Must show total.
+ || !subtotalDisplay.hideOnExpand // Don't hide totals.
+ )
+ )
+ )
+
render() {
- const pivotSettings = this.getPivotSettings(this.props);
- const {colAttrs, rowAttrs, rowKeys, colTotals} = pivotSettings;
+ const basePivotSettings = this.getBasePivotSettings(this.props);
+ const {
+ colAttrs,
+ rowAttrs,
+ rowKeys,
+ colKeys,
+ colTotals,
+ rowSubtotalDisplay,
+ colSubtotalDisplay,
+ } = basePivotSettings;
+
+ // Need to account for exclusions to compute the effective row
+ // and column keys.
+ const visibleRowKeys = opts.subtotals
+ ? this.visibleKeys(
+ rowKeys,
+ this.state.collapsedRows,
+ rowAttrs.length,
+ rowSubtotalDisplay,
+ )
+ : rowKeys;
+ const visibleColKeys = opts.subtotals
+ ? this.visibleKeys(
+ colKeys,
+ this.state.collapsedCols,
+ colAttrs.length,
+ colSubtotalDisplay,
+ )
+ : colKeys;
+ const pivotSettings = {
+ visibleRowKeys,
+ visibleColKeys,
+ rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length),
+ colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length),
+ ...basePivotSettings,
+ };
+
return (
@@ -374,7 +557,7 @@ function makeRenderer(opts = {}) {
{rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)}
- {rowKeys.map((r, i) => this.renderTableRow(r, i, pivotSettings))}
+ {visibleRowKeys.map((r, i) => this.renderTableRow(r, i, pivotSettings))}
{colTotals && this.renderTotalsRow(pivotSettings)}
@@ -435,9 +618,13 @@ TSVExportRenderer.defaultProps = PivotData.defaultProps;
TSVExportRenderer.propTypes = PivotData.propTypes;
export default {
- Table: makeRenderer(),
+ 'Table': makeRenderer(),
'Table Heatmap': makeRenderer({heatmapMode: 'full'}),
'Table Col Heatmap': makeRenderer({heatmapMode: 'col'}),
'Table Row Heatmap': makeRenderer({heatmapMode: 'row'}),
+ 'Table With Subtotal': makeRenderer({subtotals: true}),
+ 'Table With Subtotal Heatmap': makeRenderer({heatmapMode: 'full', subtotals: true}),
+ 'Table With Subtotal Col Heatmap': makeRenderer({heatmapMode: 'col', subtotals: true}),
+ 'Table With Subtotal Row Heatmap': makeRenderer({heatmapMode: 'row', subtotals: true}),
'Exportable TSV': TSVExportRenderer,
};
diff --git a/src/Utilities.js b/src/Utilities.js
index 7bbf0ad..738df9e 100644
--- a/src/Utilities.js
+++ b/src/Utilities.js
@@ -531,7 +531,7 @@ Data Model class
*/
class PivotData {
- constructor(inputProps = {}) {
+ constructor(inputProps = {}, subtotals = {}) {
this.props = Object.assign({}, PivotData.defaultProps, inputProps);
PropTypes.checkPropTypes(
PivotData.propTypes,
@@ -549,6 +549,7 @@ class PivotData {
this.rowTotals = {};
this.colTotals = {};
this.allTotal = this.aggregator(this, [], []);
+ this.subtotals = subtotals;
this.sorted = false;
// iterate through input, accumulating data for cells
@@ -591,17 +592,18 @@ class PivotData {
);
}
- arrSort(attrs) {
+ arrSort(attrs, partialOnTop) {
const sortersArr = attrs.map(a => getSort(this.props.sorters, a));
return function(a, b) {
- for (const i of Object.keys(sortersArr)) {
+ const limit = Math.min(a.length, b.length);
+ for (let i = 0; i < limit; i++) {
const sorter = sortersArr[i];
const comparison = sorter(a[i], b[i]);
if (comparison !== 0) {
return comparison;
}
}
- return 0;
+ return partialOnTop ? b.length - a.length : a.length - b.length;
};
}
@@ -617,7 +619,7 @@ class PivotData {
this.rowKeys.sort((a, b) => -naturalSort(v(a, []), v(b, [])));
break;
default:
- this.rowKeys.sort(this.arrSort(this.props.rows));
+ this.rowKeys.sort(this.arrSort(this.props.rows, this.subtotals.rowPartialOnTop));
}
switch (this.props.colOrder) {
case 'value_a_to_z':
@@ -627,7 +629,7 @@ class PivotData {
this.colKeys.sort((a, b) => -naturalSort(v([], a), v([], b)));
break;
default:
- this.colKeys.sort(this.arrSort(this.props.cols));
+ this.colKeys.sort(this.arrSort(this.props.cols, this.subtotals.colPartialOnTop));
}
}
}
@@ -657,34 +659,48 @@ class PivotData {
this.allTotal.push(record);
- if (rowKey.length !== 0) {
+ const rowStart = this.subtotals.colEnabled ? 1 : Math.max(1, rowKey.length);
+ const colStart = this.subtotals.rowEnabled ? 1 : Math.max(1, colKey.length);
+
+ for (let ri = rowStart; ri <= rowKey.length; ri++) {
+ const fRowKey = rowKey.slice(0, ri);
+ const flatRowKey = flatKey(fRowKey);
if (!this.rowTotals[flatRowKey]) {
- this.rowKeys.push(rowKey);
- this.rowTotals[flatRowKey] = this.aggregator(this, rowKey, []);
+ this.rowKeys.push(fRowKey);
+ this.rowTotals[flatRowKey] = this.aggregator(this, fRowKey, []);
}
this.rowTotals[flatRowKey].push(record);
}
- if (colKey.length !== 0) {
+ for (let ci = colStart; ci <= colKey.length; ci++) {
+ const fColKey = colKey.slice(0, ci);
+ const flatColKey = flatKey(fColKey);
if (!this.colTotals[flatColKey]) {
- this.colKeys.push(colKey);
- this.colTotals[flatColKey] = this.aggregator(this, [], colKey);
+ this.colKeys.push(fColKey);
+ this.colTotals[flatColKey] = this.aggregator(this, [], fColKey);
}
this.colTotals[flatColKey].push(record);
}
- if (colKey.length !== 0 && rowKey.length !== 0) {
+ // And now fill in for all the sub-cells.
+ for (let ri = rowStart; ri <= rowKey.length; ri++) {
+ const fRowKey = rowKey.slice(0, ri);
+ const flatRowKey = flatKey(fRowKey);
if (!this.tree[flatRowKey]) {
this.tree[flatRowKey] = {};
}
- if (!this.tree[flatRowKey][flatColKey]) {
- this.tree[flatRowKey][flatColKey] = this.aggregator(
- this,
- rowKey,
- colKey
- );
+ for (let ci = colStart; ci <= colKey.length; ci++) {
+ const fColKey = colKey.slice(0, ci);
+ const flatColKey = flatKey(fColKey);
+ if (!this.tree[flatRowKey][flatColKey]) {
+ this.tree[flatRowKey][flatColKey] = this.aggregator(
+ this,
+ fRowKey,
+ fColKey
+ );
+ }
+ this.tree[flatRowKey][flatColKey].push(record);
}
- this.tree[flatRowKey][flatColKey].push(record);
}
}