diff --git a/package.json b/package.json index 161720e..c3ad56c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "test:jest": "jest", "test": "npm run test:eslint && npm run test:prettier && npm run test:jest", "clean": "rm -rf __tests__ PivotTable.js* PivotTableUI.js* PlotlyRenderers.js* TableRenderers.js* Utilities.js* pivottable.css", - "doPublish": "npm run clean && cp src/pivottable.css . && babel src --out-dir=. --source-maps --presets=env,react --plugins babel-plugin-add-module-exports && npm publish", + "build": "npm run clean && cp src/pivottable.css . && babel src --out-dir=. --source-maps --presets=env,react --plugins babel-plugin-add-module-exports", + "prepare": "npm run build", + "doPublish": "npm run build && npm publish", "postpublish": "npm run clean", "deploy": "webpack -p && mv bundle.js examples && cd examples && git init && git add . && git commit -m build && git push --force git@github.com:plotly/react-pivottable.git master:gh-pages && rm -rf .git bundle.js" }, diff --git a/src/PivotTableUI.jsx b/src/PivotTableUI.jsx index 40296cb..77e6082 100644 --- a/src/PivotTableUI.jsx +++ b/src/PivotTableUI.jsx @@ -54,7 +54,7 @@ export class DraggableAttribute extends React.Component { style={{ display: 'block', cursor: 'initial', - zIndex: this.props.zIndex + zIndex: this.props.zIndex, }} onClick={() => this.props.moveFilterBoxToTop(this.props.name)} > @@ -135,7 +135,7 @@ export class DraggableAttribute extends React.Component { } toggleFilterBox() { - this.setState({ open: !this.state.open}); + this.setState({open: !this.state.open}); this.props.moveFilterBoxToTop(this.props.name); } @@ -367,19 +367,15 @@ class PivotTableUI extends React.PureComponent { ); } - render() { - const numValsAllowed = - this.props.aggregators[this.props.aggregatorName]([])().numInputs || 0; - - const rendererName = - this.props.rendererName in this.props.renderers - ? this.props.rendererName - : Object.keys(this.props.renderers)[0]; - - const rendererCell = ( + rendererCell() { + return ( ); + } + + aggregatorCell() { + const numValsAllowed = + this.props.aggregators[this.props.aggregatorName]([])().numInputs || 0; const sortIcons = { key_a_to_z: { @@ -407,7 +408,7 @@ class PivotTableUI extends React.PureComponent { value_z_to_a: {rowSymbol: '↑', colSymbol: '←', next: 'key_a_to_z'}, }; - const aggregatorCell = ( + return ( ); + } + render() { const unusedAttrs = Object.keys(this.attrValues) .filter( e => @@ -520,40 +523,38 @@ class PivotTableUI extends React.PureComponent { ); - if (horizUnused) { - return ( - - this.setState({openDropdown: false})}> - - {rendererCell} - {unusedAttrsCell} - - - {aggregatorCell} - {colAttrsCell} - - - {rowAttrsCell} - {outputCell} - - -
- ); - } - - return ( - - this.setState({openDropdown: false})}> - - {rendererCell} - {aggregatorCell} + const outputRows = horizUnused + ? [ + + {this.rendererCell()} + {unusedAttrsCell} + , + + {this.aggregatorCell()} {colAttrsCell} - - + , + + {rowAttrsCell} + {outputCell} + , + ] + : [ + + {this.rendererCell()} + {this.aggregatorCell()} + {colAttrsCell} + , + {unusedAttrsCell} {rowAttrsCell} {outputCell} - + , + ]; + + return ( +
+ this.setState({openDropdown: false})}> + {outputRows}
); diff --git a/src/TableRenderers.jsx b/src/TableRenderers.jsx index 849894d..f9c85ed 100644 --- a/src/TableRenderers.jsx +++ b/src/TableRenderers.jsx @@ -1,46 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {PivotData} from './Utilities'; - -// helper function for setting row/col-span in pivotTableRenderer -const spanSize = function(arr, i, j) { - let x; - if (i !== 0) { - let asc, end; - let noDraw = true; - for ( - x = 0, end = j, asc = end >= 0; - asc ? x <= end : x >= end; - asc ? x++ : x-- - ) { - if (arr[i - 1][x] !== arr[i][x]) { - noDraw = false; - } - } - if (noDraw) { - return -1; - } - } - let len = 0; - while (i + len < arr.length) { - let asc1, end1; - let stop = false; - for ( - x = 0, end1 = j, asc1 = end1 >= 0; - asc1 ? x <= end1 : x >= end1; - asc1 ? x++ : x-- - ) { - if (arr[i][x] !== arr[i + len][x]) { - stop = true; - } - } - if (stop) { - break; - } - len++; - } - return len; -}; +import {PivotData, flatKey} from './Utilities'; + +/* eslint-disable react/prop-types */ +// eslint can't see inherited propTypes! function redColorScaleGenerator(values) { const min = Math.min.apply(Math, values); @@ -53,241 +16,713 @@ function redColorScaleGenerator(values) { } function makeRenderer(opts = {}) { - class TableRenderer extends React.PureComponent { - render() { - const pivotData = new PivotData(this.props); - const colAttrs = pivotData.props.cols; - const rowAttrs = pivotData.props.rows; + class TableRenderer extends React.Component { + 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() { + // One-time extraction of pivot settings that we'll use throughout the render. + + const props = this.props; + const colAttrs = props.cols; + const rowAttrs = props.rows; + + const tableOptions = Object.assign( + { + rowTotals: true, + colTotals: true, + }, + props.tableOptions + ); + const rowTotals = tableOptions.rowTotals || colAttrs.length === 0; + const colTotals = tableOptions.colTotals || rowAttrs.length === 0; + + const subtotalOptions = Object.assign( + { + arrowCollapsed: '\u25B6', + arrowExpanded: '\u25E2', + }, + props.subtotalOptions + ); + + const colSubtotalDisplay = Object.assign( + { + displayOnTop: false, + enabled: rowTotals, + hideOnExpand: false, + }, + subtotalOptions.colSubtotalDisplay + ); + + const rowSubtotalDisplay = Object.assign( + { + displayOnTop: true, + enabled: colTotals, + 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(); - const grandTotalAggregator = pivotData.getAggregator([], []); + // Also pre-calculate all the callbacks for cells, etc... This is nice to have to + // avoid re-calculations of the call-backs on cell expansions, etc... + const cellCallbacks = {}; + const rowTotalCallbacks = {}; + const colTotalCallbacks = {}; + let grandTotalCallback = null; + if (tableOptions.clickCallback) { + for (const rowKey in rowKeys) { + const flatRowKey = flatKey(rowKey); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + for (const colKey in colKeys) { + cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler( + pivotData, + rowKey, + colKey + ); + } + } + + // Add in totals as well. + if (rowTotals) { + for (const rowKey in rowKeys) { + rowTotalCallbacks[flatKey(rowKey)] = TableRenderer.clickHandler( + pivotData, + rowKey, + [] + ); + } + } + if (colTotals) { + for (const colKey in colKeys) { + colTotalCallbacks[flatKey(colKey)] = TableRenderer.clickHandler( + pivotData, + [], + colKey + ); + } + } + if (rowTotals && colTotals) { + grandTotalCallback = TableRenderer.clickHandler(pivotData, [], []); + } + } + + return Object.assign( + { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: subtotalOptions.arrowCollapsed, + arrowExpanded: subtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + }, + TableRenderer.heatmapMappers( + pivotData, + props.tableColorScaleGenerator, + colTotals, + rowTotals + ) + ); + } + + clickHandler(pivotData, rowValues, colValues) { + const colAttrs = this.props.cols; + const rowAttrs = this.props.rows; + const value = pivotData.getAggregator(rowValues, colValues).value(); + const filters = {}; + 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]; + } + } + 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]; + } + } + return e => + this.props.tableOptions.clickCallback(e, value, filters, pivotData); + } + + collapseAttr(rowOrCol, attrIdx, allKeys) { + return () => { + // Collapse an entire attribute. + + 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: Object.assign({}, state.collapsedRows, updates), + })); + } else { + this.setState(state => ({ + collapsedCols: Object.assign({}, state.collapsedCols, updates), + })); + } + }; + } + + expandAttr(rowOrCol, attrIdx, allKeys) { + return () => { + // Expand an entire attribute. This implicitly implies expanding all of the + // parents as well. It's a bit inefficient but ah well... + + const updates = {}; + allKeys.forEach(k => { + for (let i = 0; i <= attrIdx; i++) { + updates[flatKey(k.slice(0, i + 1))] = false; + } + }); + + if (rowOrCol) { + this.setState(state => ({ + collapsedRows: Object.assign({}, state.collapsedRows, updates), + })); + } else { + this.setState(state => ({ + collapsedCols: Object.assign({}, state.collapsedCols, updates), + })); + } + }; + } + + toggleRowKey(flatRowKey) { + return () => { + this.setState(state => ({ + collapsedRows: Object.assign({}, state.collapsedRows, { + [flatRowKey]: !state.collapsedRows[flatRowKey], + }), + })); + }; + } + + toggleColKey(flatColKey) { + return () => { + this.setState(state => ({ + collapsedCols: Object.assign({}, 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. + + const spans = []; + // Index of the last new value + const li = Array(numAttrs).map(() => 0); + 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. + const cv = attrArr[i]; + const ent = []; + let depth = 0; + const limit = Math.min(lv.length, cv.length); + while (depth < limit && lv[depth] === cv[depth]) { + ent.push(-1); + spans[li[depth]][depth]++; + depth++; + } + while (depth < cv.length) { + li[depth] = i; + ent.push(1); + depth++; + } + spans.push(ent); + lv = cv; + } + return spans; + } + + static heatmapMappers( + pivotData, + colorScaleGenerator, + colTotals, + rowTotals + ) { let valueCellColors = () => {}; let rowTotalColors = () => {}; let colTotalColors = () => {}; if (opts.heatmapMode) { - const colorScaleGenerator = this.props.tableColorScaleGenerator; - const rowTotalValues = colKeys.map(x => - pivotData.getAggregator([], x).value() - ); - rowTotalColors = colorScaleGenerator(rowTotalValues); - const colTotalValues = rowKeys.map(x => - pivotData.getAggregator(x, []).value() - ); - colTotalColors = colorScaleGenerator(colTotalValues); - + if (colTotals) { + const colTotalValues = Object.values(pivotData.colTotals).map(a => + a.value() + ); + colTotalColors = colorScaleGenerator(colTotalValues); + } + if (rowTotals) { + const rowTotalValues = Object.values(pivotData.rowTotals).map(a => + a.value() + ); + rowTotalColors = colorScaleGenerator(rowTotalValues); + } if (opts.heatmapMode === 'full') { const allValues = []; - rowKeys.map(r => - colKeys.map(c => - allValues.push(pivotData.getAggregator(r, c).value()) - ) + Object.values(pivotData.tree).map(cd => + Object.values(cd).map(a => allValues.push(a.value())) ); const colorScale = colorScaleGenerator(allValues); valueCellColors = (r, c, v) => colorScale(v); } else if (opts.heatmapMode === 'row') { const rowColorScales = {}; - rowKeys.map(r => { - const rowValues = colKeys.map(x => - pivotData.getAggregator(r, x).value() - ); - rowColorScales[r] = colorScaleGenerator(rowValues); + Object.entries(pivotData.tree).map(([rk, cd]) => { + const rowValues = Object.values(cd).map(a => a.value()); + rowColorScales[rk] = colorScaleGenerator(rowValues); }); - valueCellColors = (r, c, v) => rowColorScales[r](v); + valueCellColors = (r, c, v) => rowColorScales[flatKey(r)](v); } else if (opts.heatmapMode === 'col') { const colColorScales = {}; - colKeys.map(c => { - const colValues = rowKeys.map(x => - pivotData.getAggregator(x, c).value() - ); - colColorScales[c] = colorScaleGenerator(colValues); - }); - valueCellColors = (r, c, v) => colColorScales[c](v); + const colValues = {}; + Object.values(pivotData.tree).map(cd => + Object.entries(cd).map(([ck, a]) => { + if (!(ck in colValues)) { + colValues[ck] = []; + } + colValues[ck].push(a.value()); + }) + ); + for (const k in colValues) { + colColorScales[k] = colorScaleGenerator(colValues[k]); + } + valueCellColors = (r, c, v) => colColorScales[flatKey(c)](v); } } + return {valueCellColors, rowTotalColors, colTotalColors}; + } - const getClickHandler = - this.props.tableOptions && this.props.tableOptions.clickCallback - ? (value, rowValues, colValues) => { - const filters = {}; - for (const i of Object.keys(colAttrs || {})) { - const attr = colAttrs[i]; - if (colValues[i] !== null) { - filters[attr] = colValues[i]; - } - } - for (const i of Object.keys(rowAttrs || {})) { - const attr = rowAttrs[i]; - if (rowValues[i] !== null) { - filters[attr] = rowValues[i]; - } - } - return e => - this.props.tableOptions.clickCallback( - e, - value, - filters, - pivotData - ); + renderColHeaderRow(attrName, attrIdx, pivotSettings) { + // Render a single row in the column header at the top of the pivot table. + + const { + rowAttrs, + colAttrs, + colKeys, + visibleColKeys, + colAttrSpans, + rowTotals, + arrowExpanded, + arrowCollapsed, + colSubtotalDisplay, + maxColVisible, + } = pivotSettings; + + const spaceCell = + attrIdx === 0 && rowAttrs.length !== 0 ? ( + + ) : null; + + const needToggle = + opts.subtotals && + colSubtotalDisplay.enabled && + attrIdx !== colAttrs.length - 1; + let clickHandle = null; + let subArrow = null; + if (needToggle) { + clickHandle = + attrIdx + 1 < maxColVisible + ? this.collapseAttr(false, attrIdx, colKeys) + : this.expandAttr(false, attrIdx, colKeys); + subArrow = + (attrIdx + 1 < maxColVisible ? arrowExpanded : arrowCollapsed) + ' '; + } + const attrNameCell = ( + + {subArrow} + {attrName} + + ); + + const attrValueCells = []; + const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0; + // Iterate through columns. Jump over duplicate values. + let i = 0; + 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 onClick = needToggle ? this.toggleColKey(flatColKey) : null; + attrValueCells.push( + + {needToggle + ? (this.state.collapsedCols[flatColKey] + ? arrowCollapsed + : arrowExpanded) + ' ' + : null} + {colKey[attrIdx]} + + ); + } else if (attrIdx === colKey.length) { + const rowSpan = colAttrs.length - colKey.length + rowIncrSpan; + attrValueCells.push( + + ); + } + // The next colSpan columns will have the same value anyway... + i = i + colSpan; + } + + const totalCell = + attrIdx === 0 && rowTotals ? ( + + Totals + + ) : null; + + const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; + 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, + rowKeys, + arrowCollapsed, + arrowExpanded, + rowSubtotalDisplay, + maxRowVisible, + } = pivotSettings; + return ( + + {rowAttrs.map((r, i) => { + const needLabelToggle = + opts.subtotals && + rowSubtotalDisplay.enabled && + i !== rowAttrs.length - 1; + let clickHandle = null; + let subArrow = null; + if (needLabelToggle) { + clickHandle = + i + 1 < maxRowVisible + ? this.collapseAttr(true, i, rowKeys) + : this.expandAttr(true, i, rowKeys); + subArrow = + (i + 1 < maxRowVisible ? arrowExpanded : arrowCollapsed) + ' '; } - : null; + return ( + + {subArrow} + {r} + + ); + })} + + {colAttrs.length === 0 ? 'Totals' : null} + + + ); + } + + renderTableRow(rowKey, rowIdx, pivotSettings) { + // Render a single row in the pivot table. + + const { + rowAttrs, + colAttrs, + rowAttrSpans, + visibleColKeys, + pivotData, + rowTotals, + valueCellColors, + rowTotalColors, + arrowExpanded, + arrowCollapsed, + cellCallbacks, + rowTotalCallbacks, + } = pivotSettings; + + const flatRowKey = flatKey(rowKey); + + const colIncrSpan = colAttrs.length !== 0 ? 1 : 0; + const attrValueCells = rowKey.map((r, i) => { + const rowSpan = rowAttrSpans[rowIdx][i]; + if (rowSpan > 0) { + 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} + + ); + } + return null; + }); + + const attrValuePaddingCell = + rowKey.length < rowAttrs.length ? ( + + ) : null; + + const rowClickHandlers = cellCallbacks[flatRowKey] || {}; + const valueCells = visibleColKeys.map(colKey => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator(rowKey, colKey); + const aggValue = agg.value(); + const style = valueCellColors(rowKey, colKey, aggValue); + return ( + + {agg.format(aggValue)} + + ); + }); + + let totalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator(rowKey, []); + const aggValue = agg.value(); + const style = rowTotalColors(aggValue); + totalCell = ( + + {agg.format(aggValue)} + + ); + } + + const rowCells = [ + ...attrValueCells, + attrValuePaddingCell, + ...valueCells, + totalCell, + ]; + + return {rowCells}; + } + + renderTotalsRow(pivotSettings) { + // Render the final totals rows that has the totals for all the columns. + + const { + rowAttrs, + colAttrs, + visibleColKeys, + colTotalColors, + rowTotals, + pivotData, + colTotalCallbacks, + grandTotalCallback, + } = pivotSettings; + + const totalLabelCell = ( + + Totals + + ); + + const totalValueCells = visibleColKeys.map(colKey => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator([], colKey); + const aggValue = agg.value(); + const style = colTotalColors([], colKey, aggValue); + return ( + + {agg.format(aggValue)} + + ); + }); + + let grandTotalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator([], []); + const aggValue = agg.value(); + grandTotalCell = ( + + {agg.format(aggValue)} + + ); + } + + const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell]; + + return {totalCells}; + } + + visibleKeys(keys, collapsed, numAttrs, subtotalDisplay) { + return keys.filter( + key => + // Is the key hidden by one of its parents? + !key.some((k, j) => collapsed[flatKey(key.slice(0, j))]) && + // Leaf key. + (key.length === numAttrs || + // Children hidden. Must show total. + flatKey(key) in collapsed || + // Don't hide totals. + !subtotalDisplay.hideOnExpand) + ); + } + + render() { + if (this.cachedProps !== this.props) { + this.cachedProps = this.props; + this.cachedBasePivotSettings = this.getBasePivotSettings(); + } + const { + colAttrs, + rowAttrs, + rowKeys, + colKeys, + colTotals, + rowSubtotalDisplay, + colSubtotalDisplay, + } = this.cachedBasePivotSettings; + + // 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 = Object.assign( + { + visibleRowKeys, + maxRowVisible: Math.max(...visibleRowKeys.map(k => k.length)), + visibleColKeys, + maxColVisible: Math.max(...visibleColKeys.map(k => k.length)), + rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length), + colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length), + }, + this.cachedBasePivotSettings + ); return ( - {colAttrs.map(function(c, j) { - return ( - - {j === 0 && - rowAttrs.length !== 0 && ( - - {colKeys.map(function(colKey, i) { - const x = spanSize(colKeys, i, j); - if (x === -1) { - return null; - } - return ( - - ); - })} - - {j === 0 && ( - - )} - - ); - })} - - {rowAttrs.length !== 0 && ( - - {rowAttrs.map(function(r, i) { - return ( - - ); - })} - - + {colAttrs.map((c, j) => + this.renderColHeaderRow(c, j, pivotSettings) )} + {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)} - - {rowKeys.map(function(rowKey, i) { - const totalAggregator = pivotData.getAggregator(rowKey, []); - return ( - - {rowKey.map(function(txt, j) { - const x = spanSize(rowKeys, i, j); - if (x === -1) { - return null; - } - return ( - - ); - })} - {colKeys.map(function(colKey, j) { - const aggregator = pivotData.getAggregator(rowKey, colKey); - return ( - - ); - })} - - - ); - })} - - - - - {colKeys.map(function(colKey, i) { - const totalAggregator = pivotData.getAggregator([], colKey); - return ( - - ); - })} - - - + {visibleRowKeys.map((r, i) => + this.renderTableRow(r, i, pivotSettings) + )} + {colTotals && this.renderTotalsRow(pivotSettings)}
- )} - {c} - {colKey[j]} - - Totals -
- {r} - - {colAttrs.length === 0 ? 'Totals' : null} -
- {txt} - - {aggregator.format(aggregator.value())} - - {totalAggregator.format(totalAggregator.value())} -
- Totals - - {totalAggregator.format(totalAggregator.value())} - - {grandTotalAggregator.format(grandTotalAggregator.value())} -
); @@ -351,5 +786,18 @@ export default { '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 6d3eefb..651c10a 100644 --- a/src/Utilities.js +++ b/src/Utilities.js @@ -522,12 +522,16 @@ const derivers = { }, }; +// Given an array of attribute values, convert to a key that +// can be used in objects. +const flatKey = attrVals => attrVals.join(String.fromCharCode(0)); + /* Data Model class */ class PivotData { - constructor(inputProps = {}) { + constructor(inputProps = {}, subtotals = {}) { this.props = Object.assign({}, PivotData.defaultProps, inputProps); PropTypes.checkPropTypes( PivotData.propTypes, @@ -545,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 @@ -587,24 +592,18 @@ class PivotData { ); } - arrSort(attrs) { - let a; - const sortersArr = (() => { - const result = []; - for (a of Array.from(attrs)) { - result.push(getSort(this.props.sorters, a)); - } - return result; - })(); + 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 ? a.length - b.length : b.length - a.length; }; } @@ -620,7 +619,9 @@ 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': @@ -630,7 +631,9 @@ 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) + ); } } } @@ -649,52 +652,64 @@ class PivotData { // this code is called in a tight loop const colKey = []; const rowKey = []; - for (const x of Array.from(this.props.cols)) { + for (const x of this.props.cols) { colKey.push(x in record ? record[x] : 'null'); } - for (const x of Array.from(this.props.rows)) { + for (const x of this.props.rows) { rowKey.push(x in record ? record[x] : 'null'); } - const flatRowKey = rowKey.join(String.fromCharCode(0)); - const flatColKey = colKey.join(String.fromCharCode(0)); this.allTotal.push(record); - if (rowKey.length !== 0) { + const rowStart = this.subtotals.rowEnabled ? 1 : Math.max(1, rowKey.length); + const colStart = this.subtotals.colEnabled ? 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); } } getAggregator(rowKey, colKey) { let agg; - const flatRowKey = rowKey.join(String.fromCharCode(0)); - const flatColKey = colKey.join(String.fromCharCode(0)); + const flatRowKey = flatKey(rowKey); + const flatColKey = flatKey(colKey); if (rowKey.length === 0 && colKey.length === 0) { agg = this.allTotal; } else if (rowKey.length === 0) { @@ -808,5 +823,6 @@ export { numberFormat, getSort, sortAs, + flatKey, PivotData, }; diff --git a/src/__tests__/Utilities-test.js b/src/__tests__/Utilities-test.js index d7f41bd..901fbb9 100644 --- a/src/__tests__/Utilities-test.js +++ b/src/__tests__/Utilities-test.js @@ -136,6 +136,91 @@ describe(' utils', function() { expect(agg.format(val)).toBe('4'); }); }); + + describe('with rows/cols and subtotals', function() { + const pd = new utils.PivotData( + { + data: fixtureData, + rows: ['name', 'colour'], + cols: ['trials', 'successes'], + }, + // Subtotals settings. + { + rowEnabled: true, + colEnabled: true, + rowPartialOnTop: true, + colPartialOnTop: false, + }, + ); + + it('has correctly-ordered row keys', () => + expect(pd.getRowKeys()).toEqual([ + ['Carol'], + ['Carol', 'yellow'], + ['Jane'], + ['Jane', 'red'], + ['John'], + ['John', 'blue'], + ['Nick'], + ['Nick', 'blue'], + ])); + + it('has correctly-ordered col keys', () => + expect(pd.getColKeys()).toEqual([ + [95, 25], + [95], + [102, 14], + [102], + [103, 12], + [103], + [112, 30], + [112], + ])); + + it('can be iterated over', function() { + let numNotNull = 0; + let numNull = 0; + for (const r of Array.from(pd.getRowKeys())) { + for (const c of Array.from(pd.getColKeys())) { + if (pd.getAggregator(r, c).value() !== null) { + numNotNull++; + } else { + numNull++; + } + } + } + expect(numNotNull).toBe(16); + expect(numNull).toBe(48); + }); + + it('has a correct spot-checked aggregator', function() { + const agg = pd.getAggregator(['Carol', 'yellow'], [102, 14]); + const val = agg.value(); + expect(val).toBe(1); + expect(agg.format(val)).toBe('1'); + }); + + it('has a correct spot-checked aggregator #2', function() { + const agg = pd.getAggregator(['John'], [112, 30]); + const val = agg.value(); + expect(val).toBe(1); + expect(agg.format(val)).toBe('1'); + }); + + it('has a correct spot-checked aggregator #3', function() { + const agg = pd.getAggregator(['Jane', 'red'], [102]); + const val = agg.value(); + expect(val).toBe(null); + expect(agg.format(val)).toBe(''); + }); + + it('has a correct grand total aggregator', function() { + const agg = pd.getAggregator([], []); + const val = agg.value(); + expect(val).toBe(4); + expect(agg.format(val)).toBe('4'); + }); + }); }); describe('.aggregatorTemplates', function() {