diff --git a/examples/App.jsx b/examples/App.jsx index ded8dc8..a7cbc44 100644 --- a/examples/App.jsx +++ b/examples/App.jsx @@ -1,9 +1,10 @@ import React from 'react'; import tips from './tips'; import {sortAs} from '../src/Utilities'; +import SubtotalRenderers from '../src/SubtotalRenderers'; import TableRenderers from '../src/TableRenderers'; -import createPlotlyComponent from 'react-plotly.js/factory'; import createPlotlyRenderers from '../src/PlotlyRenderers'; +import createPlotlyComponent from 'react-plotly.js/factory'; import PivotTableUI from '../src/PivotTableUI'; import '../src/pivottable.css'; import Dropzone from 'react-dropzone'; @@ -27,6 +28,7 @@ class PivotTableUISmartWrapper extends React.PureComponent { renderers={Object.assign( {}, TableRenderers, + SubtotalRenderers, createPlotlyRenderers(Plot) )} {...this.state.pivotState} @@ -44,11 +46,11 @@ export default class App extends React.Component { filename: 'Sample Dataset: Tips', pivotState: { data: tips, - rows: ['Payer Gender'], - cols: ['Party Size'], - aggregatorName: 'Sum over Sum', - vals: ['Tip', 'Total Bill'], - rendererName: 'Grouped Column Chart', + rows: ['Day of Week', 'Party Size'], + cols: ['Payer Gender', 'Meal'], + aggregatorName: 'Sum', + vals: ['Tip'], + rendererName: 'Table With Subtotal', sorters: { Meal: sortAs(['Lunch', 'Dinner']), 'Day of Week': sortAs([ @@ -68,7 +70,6 @@ export default class App extends React.Component { ) { names.push(record.Meal); }); - alert(names.join('\n')); }, }, }, diff --git a/examples/index.jsx b/examples/index.jsx index 0a4cf74..d2f446f 100644 --- a/examples/index.jsx +++ b/examples/index.jsx @@ -1,20 +1,19 @@ import React from 'react' -import ReactDOM from 'react-dom' -import { AppContainer } from 'react-hot-loader' +import { createRoot } from 'react-dom/client' import App from './App' -const render = Component => { - ReactDOM.render( - - - , - document.getElementById('app'), - ) -} +// Create a root +const container = document.getElementById('app') +const root = createRoot(container) -render(App) +// Render the app directly without AppContainer +root.render() // Webpack Hot Module Replacement API if (module.hot) { - module.hot.accept('./App', () => { render(App) }) + module.hot.accept('./App', () => { + // When App is updated, re-render + const NextApp = require('./App').default + root.render() + }) } diff --git a/package.json b/package.json index f4adaf6..3fd05df 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "PivotTableUI.js", "PlotlyRenderers.js", "TableRenderers.js", + "SubtotalsRenderers.js", "Utilities.js", "PivotTable.js.map", "PivotTableUI.js.map", diff --git a/src/PivotTableUI.jsx b/src/PivotTableUI.jsx index 0de055f..461a411 100644 --- a/src/PivotTableUI.jsx +++ b/src/PivotTableUI.jsx @@ -210,6 +210,7 @@ export class Dropdown extends React.PureComponent { this.props.toggle(); } else { this.props.setValue(r); + this.props.toggle(); } }} className={ @@ -284,12 +285,40 @@ class PivotTableUI extends React.PureComponent { this.setState(newState); } + handleDuplicates(newAttributes, existingAttributes) { + if (!newAttributes || !existingAttributes) { + return existingAttributes || []; + } + const duplicates = newAttributes.filter(item => + existingAttributes.includes(item) + ); + return duplicates.length > 0 + ? existingAttributes.filter(item => !duplicates.includes(item)) + : existingAttributes; + } + sendPropUpdate(command) { this.props.onChange(update(this.props, command)); } propUpdater(key) { - return value => this.sendPropUpdate({[key]: {$set: value}}); + return value => { + const update = {[key]: {$set: value}}; + + if (key === 'rows') { + const updatedCols = this.handleDuplicates(value, this.props.cols); + if (updatedCols.length !== this.props.cols.length) { + update.cols = {$set: updatedCols}; + } + } else if (key === 'cols') { + const updatedRows = this.handleDuplicates(value, this.props.rows); + if (updatedRows.length !== this.props.rows.length) { + update.rows = {$set: updatedRows}; + } + } + + this.sendPropUpdate(update); + }; } setValuesInFilter(attribute, values) { @@ -502,7 +531,6 @@ class PivotTableUI extends React.PureComponent { !this.props.hiddenAttributes.includes(e) && !this.props.hiddenFromDragDrop.includes(e) ); - const colAttrsCell = this.makeDnDCell( colAttrs, this.propUpdater('cols'), @@ -519,6 +547,7 @@ class PivotTableUI extends React.PureComponent { this.propUpdater('rows'), 'pvtAxisContainer pvtVertList pvtRows' ); + const outputCell = ( { + // eslint-disable-next-line no-magic-numbers + const nonRed = 255 - Math.round((255 * (x - min)) / (max - min)); + return {backgroundColor: `rgb(255,${nonRed},${nonRed})`}; + }; +} + +function makeRenderer(opts = {}) { + class SubtotalRenderer extends React.Component { + constructor(props) { + super(props); + this.state = {collapsedRows: {}, collapsedCols: {}}; + } + + componentDidMount() { + if ( + opts.subtotals && + !document.getElementById('react-pivottable-subtotal-styles') + ) { + const style = document.createElement('style'); + style.id = 'react-pivottable-subtotal-styles'; + style.innerHTML = ` + .pvtSubtotal { + font-weight: bold; + background-color: #f0f0f0; + } + .pvtSubtotalRow { + border-top: 1px solid #ddd; + } + .pvtSubtotalVal { + color: #777; + font-style: italic; + } + `; + document.head.appendChild(style); + } + } + + getBasePivotSettings() { + 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 cellCallbacks = {}; + const rowTotalCallbacks = {}; + const colTotalCallbacks = {}; + let grandTotalCallback = null; + if (tableOptions.clickCallback) { + rowKeys.forEach(rowKey => { + const flatRowKey = flatKey(rowKey); + cellCallbacks[flatRowKey] = {}; + colKeys.forEach(colKey => { + const flatColKey = flatKey(colKey); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + cellCallbacks[flatRowKey][flatColKey] = this.clickHandler( + pivotData, + rowKey, + colKey + ); + }); + rowTotalCallbacks[flatRowKey] = this.clickHandler( + pivotData, + rowKey, + [] + ); + }); + colKeys.forEach(colKey => { + const flatColKey = flatKey(colKey); + colTotalCallbacks[flatColKey] = this.clickHandler( + pivotData, + [], + colKey + ); + }); + grandTotalCallback = this.clickHandler(pivotData, [], []); + } + + return Object.assign( + { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: subtotalOptions.arrowCollapsed, + arrowExpanded: subtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + }, + SubtotalRenderer.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 function() { + var flatCollapseKeys = {}; + for (var i = 0; i < allKeys.length; i++) { + var k = allKeys[i]; + var slicedKey = k.slice(0, attrIdx + 1); + flatCollapseKeys[flatKey(slicedKey)] = true; + } + this.setState(function(prevState) { + if (rowOrCol === 'row') { + return { + collapsedRows: Object.assign( + {}, + prevState.collapsedRows, + flatCollapseKeys + ), + }; + } else if (rowOrCol === 'col') { + return { + collapsedCols: Object.assign( + {}, + prevState.collapsedCols, + flatCollapseKeys + ), + }; + } + return null; + }); + }.bind(this); + } + + expandAttr(rowOrCol, attrIdx, allKeys) { + return function() { + var flatCollapseKeys = {}; + for (var i = 0; i < allKeys.length; i++) { + var k = allKeys[i]; + var slicedKey = k.slice(0, attrIdx + 1); + flatCollapseKeys[flatKey(slicedKey)] = false; + } + this.setState(function(prevState) { + if (rowOrCol === 'row') { + return { + collapsedRows: Object.assign( + {}, + prevState.collapsedRows, + flatCollapseKeys + ), + }; + } else if (rowOrCol === 'col') { + return { + collapsedCols: Object.assign( + {}, + prevState.collapsedCols, + flatCollapseKeys + ), + }; + } + return null; + }); + }.bind(this); + } + + toggleRowKey(flatRowKey) { + return function() { + this.setState(function(prevState) { + var newCollapsedRows = Object.assign({}, prevState.collapsedRows); + newCollapsedRows[flatRowKey] = !prevState.collapsedRows[flatRowKey]; + return {collapsedRows: newCollapsedRows}; + }); + }.bind(this); + } + + toggleColKey(flatColKey) { + return function() { + this.setState(function(prevState) { + var newCollapsedCols = Object.assign({}, prevState.collapsedCols); + newCollapsedCols[flatColKey] = !prevState.collapsedCols[flatColKey]; + return {collapsedCols: newCollapsedCols}; + }); + }.bind(this); + } + + // 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 + // each level. + calcAttrSpans(attrArr, numAttrs) { + const spans = []; + const li = Array(numAttrs).map(() => 0); + let lv = Array(numAttrs).map(() => null); + for (let i = 0; i < attrArr.length; i++) { + const cv = attrArr[i]; + const isSubtotal = cv[cv.length - 1] === '__subtotal__'; + const actualCv = isSubtotal ? cv.slice(0, -1) : cv; + + const ent = []; + let depth = 0; + const limit = Math.min(lv.length, actualCv.length); + while (depth < limit && lv[depth] === actualCv[depth]) { + ent.push(-1); + spans[li[depth]][depth]++; + depth++; + } + while (depth < actualCv.length) { + li[depth] = i; + ent.push(1); + depth++; + } + spans.push(ent); + lv = actualCv; + } + return spans; + } + + static heatmapMappers( + pivotData, + colorScaleGenerator, + colTotals, + rowTotals + ) { + const colMapper = {}; + const rowMapper = {}; + + if (colorScaleGenerator && opts.heatmapMode) { + const valueCellColors = {}; + const rowTotalColors = {}; + const colTotalColors = {}; + let grandTotalColor = null; + + const allValues = []; + const rowValues = {}; + const colValues = {}; + + pivotData.forEachCell((val, rowKey, colKey) => { + if (val !== null && !isNaN(val)) { + allValues.push(val); + + const flatRow = flatKey(rowKey); + if (!rowValues[flatRow]) { + rowValues[flatRow] = []; + } + rowValues[flatRow].push(val); + + const flatCol = flatKey(colKey); + if (!colValues[flatCol]) { + colValues[flatCol] = []; + } + colValues[flatCol].push(val); + } + }); + + if (opts.heatmapMode === 'row' && colTotals) { + pivotData.getRowKeys().forEach(rowKey => { + let rowTotal = 0; + let hasValidValues = false; + pivotData.getColKeys().forEach(colKey => { + const agg = pivotData.getAggregator(rowKey, colKey); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + rowTotal += val; + hasValidValues = true; + } + } + }); + + if (hasValidValues && rowTotal !== 0) { + const flatRow = flatKey(rowKey); + if (!rowValues[flatRow]) { + rowValues[flatRow] = []; + } + rowValues[flatRow].push(rowTotal); + } + }); + } + + if (opts.heatmapMode === 'col' && rowTotals) { + pivotData.getColKeys().forEach(colKey => { + let colTotal = 0; + let hasValidValues = false; + pivotData.getRowKeys().forEach(rowKey => { + const agg = pivotData.getAggregator(rowKey, colKey); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + colTotal += val; + hasValidValues = true; + } + } + }); + + if (hasValidValues && colTotal !== 0) { + const flatCol = flatKey(colKey); + if (!colValues[flatCol]) { + colValues[flatCol] = []; + } + colValues[flatCol].push(colTotal); + } + }); + } + + if (colTotals) { + const rowTotalValues = []; + pivotData.forEachTotal(([valKey, _x]) => { + const val = pivotData.getAggregator([valKey], []).value(); + if (val !== null && !isNaN(val)) { + rowTotalValues.push(val); + if (opts.heatmapMode === 'full') { + allValues.push(val); + } + } + }); + + const rowTotalColorScale = + opts.heatmapMode === 'full' + ? colorScaleGenerator(allValues) + : colorScaleGenerator(rowTotalValues); + + pivotData.forEachTotal(([valKey, _x]) => { + const val = pivotData.getAggregator([valKey], []).value(); + if (val !== null && !isNaN(val)) { + rowTotalColors[flatKey([valKey])] = rowTotalColorScale(val); + } + }); + } + + if (rowTotals) { + const colTotalValues = []; + pivotData.forEachTotal(([_x, valKey]) => { + const val = pivotData.getAggregator([], [valKey]).value(); + if (val !== null && !isNaN(val)) { + colTotalValues.push(val); + if (opts.heatmapMode === 'full') { + allValues.push(val); + } + } + }); + + const colTotalColorScale = + opts.heatmapMode === 'full' + ? colorScaleGenerator(allValues) + : colorScaleGenerator(colTotalValues); + + pivotData.forEachTotal(([_x, valKey]) => { + const val = pivotData.getAggregator([], [valKey]).value(); + if (val !== null && !isNaN(val)) { + colTotalColors[flatKey([valKey])] = colTotalColorScale(val); + } + }); + } + + if (colTotals && rowTotals) { + const grandTotalVal = pivotData.getAggregator([], []).value(); + if (grandTotalVal !== null && !isNaN(grandTotalVal)) { + if (opts.heatmapMode === 'full') { + allValues.push(grandTotalVal); + const grandTotalColorScale = colorScaleGenerator(allValues); + grandTotalColor = grandTotalColorScale(grandTotalVal); + } + } + } + + if (rowTotals) { + colMapper.totalColor = key => colTotalColors[flatKey([key])]; + } + if (colTotals) { + rowMapper.totalColor = key => rowTotalColors[flatKey([key])]; + } + if (grandTotalColor) { + colMapper.grandTotalColor = grandTotalColor; + } + + if (opts.heatmapMode === 'full') { + // Full heatmap: Compare values across the entire table + // Note: allValues already contains all cell values from earlier collection + const colorScale = colorScaleGenerator(allValues); + pivotData.forEachCell((val, rowKey, colKey) => { + if (val !== null && !isNaN(val)) { + valueCellColors[ + `${flatKey(rowKey)}_${flatKey(colKey)}` + ] = colorScale(val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + + colMapper.bgColorFromSubtotalValue = value => { + if (value !== null && !isNaN(value)) { + return colorScale(value); + } + return null; + }; + } else if (opts.heatmapMode === 'row') { + // Row heatmap: Compare values within each row + // Note: rowValues already populated from earlier collection + const rowColorScales = {}; + Object.entries(rowValues).forEach(([flatRow, values]) => { + if (values.length > 0) { + rowColorScales[flatRow] = colorScaleGenerator(values); + } + }); + + pivotData.forEachCell((val, rowKey, colKey) => { + const flatRow = flatKey(rowKey); + if (val !== null && !isNaN(val) && rowColorScales[flatRow]) { + valueCellColors[`${flatRow}_${flatKey(colKey)}`] = rowColorScales[ + flatRow + ](val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + + colMapper.bgColorFromSubtotalValue = (value, rowKey) => { + if (value !== null && !isNaN(value)) { + const flatRow = flatKey(rowKey); + if (rowColorScales[flatRow]) { + return rowColorScales[flatRow](value); + } + } + return null; + }; + } else if (opts.heatmapMode === 'col') { + // Column heatmap: Compare values within each column + // Note: colValues already populated from earlier collection + const colColorScales = {}; + Object.entries(colValues).forEach(([flatCol, values]) => { + if (values.length > 0) { + colColorScales[flatCol] = colorScaleGenerator(values); + } + }); + + pivotData.forEachCell((val, rowKey, colKey) => { + const flatCol = flatKey(colKey); + if (val !== null && !isNaN(val) && colColorScales[flatCol]) { + valueCellColors[`${flatKey(rowKey)}_${flatCol}`] = colColorScales[ + flatCol + ](val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + + colMapper.bgColorFromSubtotalValue = (value, rowKey, colKey) => { + if (value !== null && !isNaN(value)) { + const flatCol = flatKey(colKey); + if (colColorScales[flatCol]) { + return colColorScales[flatCol](value); + } + } + return null; + }; + } + } + return {colMapper, rowMapper}; + } + + renderColHeaderRow(attrName, attrIdx, pivotSettings) { + const { + rowAttrs, + colAttrs, + visibleColKeys, + colAttrSpans, + rowTotals, + arrowExpanded, + arrowCollapsed, + colSubtotalDisplay, + } = pivotSettings; + + const spaceCell = + attrIdx === 0 && rowAttrs.length !== 0 ? ( + + ) : null; + + const needToggle = + opts.subtotals && + colSubtotalDisplay.enabled && + attrIdx !== colAttrs.length - 1; + const attrNameCell = ( + + {attrName} + + ); + + const attrValueCells = []; + const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0; + let i = 0; + while (i < visibleColKeys.length) { + const colKey = visibleColKeys[i]; + const isSubtotalCol = colKey[colKey.length - 1] === '__subtotal__'; + const actualColKey = isSubtotalCol ? colKey.slice(0, -1) : colKey; + + const colSpan = + attrIdx < actualColKey.length ? colAttrSpans[i][attrIdx] : 1; + if (attrIdx < actualColKey.length) { + const rowSpan = + 1 + (attrIdx === colAttrs.length - 1 ? rowIncrSpan : 0); + const flatColKey = flatKey(actualColKey.slice(0, attrIdx + 1)); + const onClick = needToggle ? this.toggleColKey(flatColKey) : null; + + let headerText = actualColKey[attrIdx]; + let headerClass = 'pvtColLabel'; + + const isCollapsedParent = + this.state.collapsedCols[flatColKey] && + actualColKey.length < colAttrs.length; + + if (isSubtotalCol) { + headerText = `${headerText} (Subtotal)`; + headerClass += ' pvtSubtotal'; + } else if (isCollapsedParent) { + headerClass += ' pvtSubtotal'; + } + + attrValueCells.push( + + {needToggle + ? (this.state.collapsedCols[flatColKey] + ? arrowCollapsed + : arrowExpanded) + ' ' + : null} + {headerText} + + ); + } else if (attrIdx === actualColKey.length) { + const rowSpan = colAttrs.length - actualColKey.length + rowIncrSpan; + const flatColKey = flatKey(actualColKey); + const isCollapsedParent = + this.state.collapsedCols[flatColKey] && + actualColKey.length < colAttrs.length; + + attrValueCells.push( + + ); + } + i = i + colSpan; + } + + const totalCell = + attrIdx === 0 && rowTotals ? ( + + Totals + + ) : null; + + const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; + return cells; + } + + renderRowHeaderRow(pivotSettings) { + const {colAttrs, rowAttrs} = pivotSettings; + const cells = []; + if (rowAttrs.length !== 0) { + rowAttrs.map(function(r, i) { + cells.push( + + {r} + + ); + }); + cells.push( + + {colAttrs.length === 0 ? 'Totals' : null} + + ); + } + return cells; + } + + renderTableRow(rowKey, rowIdx, pivotSettings) { + const { + colKeys, + rowAttrs, + colAttrs, + rowTotals, + pivotData, + rowMapper, + colMapper, + cellCallbacks, + rowTotalCallbacks, + } = pivotSettings; + + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols + ); + + const cells = []; + const isSubtotalRow = rowKey[rowKey.length - 1] === '__subtotal__'; + const actualRowKey = isSubtotalRow ? rowKey.slice(0, -1) : rowKey; + + visibleColKeys.forEach((colKey, i) => { + try { + if (!actualRowKey || !colKey) { + cells.push(); + return; + } + + let aggregator, + className, + valCss = {}; + + const isSubtotalCol = colKey[colKey.length - 1] === '__subtotal__'; + const actualColKey = isSubtotalCol ? colKey.slice(0, -1) : colKey; + + const needsSubtotalValue = + isSubtotalRow || + isSubtotalCol || + (actualColKey.length < colAttrs.length && + this.state.collapsedCols[flatKey(actualColKey)]) || + (actualRowKey.length < rowAttrs.length && + this.state.collapsedRows[flatKey(actualRowKey)]); + + if (needsSubtotalValue) { + const value = this.calculateSubtotal( + pivotData, + actualRowKey, + actualColKey, + pivotSettings + ); + className = 'pvtSubtotal'; + + const tempAggregator = this.safeGetAggregator(pivotData, [], []); + aggregator = { + value: () => value, + format: tempAggregator ? tempAggregator.format : x => x, + }; + + if (opts.heatmapMode && colMapper.bgColorFromSubtotalValue) { + let cellColor; + if (opts.heatmapMode === 'full') { + cellColor = colMapper.bgColorFromSubtotalValue(value); + } else if (opts.heatmapMode === 'row') { + cellColor = colMapper.bgColorFromSubtotalValue( + value, + actualRowKey + ); + } else if (opts.heatmapMode === 'col') { + cellColor = colMapper.bgColorFromSubtotalValue( + value, + actualRowKey, + actualColKey + ); + } + + if (cellColor) { + valCss = cellColor; + } + } + } else { + aggregator = this.safeGetAggregator( + pivotData, + actualRowKey, + actualColKey + ); + className = 'pvtVal'; + + if (opts.heatmapMode && colMapper.bgColorFromRowColKey) { + const cellColor = colMapper.bgColorFromRowColKey( + actualRowKey, + actualColKey + ); + if (cellColor) { + valCss = cellColor; + } + } + } + + if (!aggregator || aggregator.value() === null) { + cells.push( + + ); + return; + } + + const val = aggregator.value(); + + let formattedVal; + if (val === null) { + formattedVal = ''; + } else if (className === 'pvtSubtotal' && val === 0) { + formattedVal = ''; + } else { + formattedVal = aggregator.format(val); + } + + const cellKey = flatKey(actualRowKey); + const colCellKey = flatKey(actualColKey); + + cells.push( + + {formattedVal} + + ); + } catch (error) { + cells.push(); + } + }); + + if (rowTotals) { + try { + const className = isSubtotalRow ? 'pvtTotal pvtSubtotal' : 'pvtTotal'; + let valCss = {}; + + let totalVal = 0; + let formattedTotal = ''; + + if (isSubtotalRow) { + totalVal = this.calculateSubtotal( + pivotData, + actualRowKey, + [], + pivotSettings + ); + + if (opts.heatmapMode && colMapper.bgColorFromSubtotalValue) { + let cellColor; + if (opts.heatmapMode === 'full') { + cellColor = colMapper.bgColorFromSubtotalValue(totalVal); + } else if (opts.heatmapMode === 'row') { + cellColor = colMapper.bgColorFromSubtotalValue( + totalVal, + actualRowKey + ); + } else if (opts.heatmapMode === 'col') { + cellColor = colMapper.bgColorFromSubtotalValue( + totalVal, + actualRowKey, + [] + ); + } + + if (cellColor) { + valCss = cellColor; + } + } + } else if ( + actualRowKey.length < rowAttrs.length && + this.state.collapsedRows[flatKey(actualRowKey)] + ) { + totalVal = this.calculateSubtotal( + pivotData, + actualRowKey, + [], + pivotSettings + ); + + if (opts.heatmapMode && colMapper.bgColorFromSubtotalValue) { + let cellColor; + if (opts.heatmapMode === 'full') { + cellColor = colMapper.bgColorFromSubtotalValue(totalVal); + } else if (opts.heatmapMode === 'row') { + cellColor = colMapper.bgColorFromSubtotalValue( + totalVal, + actualRowKey + ); + } else if (opts.heatmapMode === 'col') { + cellColor = colMapper.bgColorFromSubtotalValue( + totalVal, + actualRowKey, + [] + ); + } + + if (cellColor) { + valCss = cellColor; + } + } + } else { + pivotData.getColKeys().forEach(colKey => { + const agg = this.safeGetAggregator( + pivotData, + actualRowKey, + colKey + ); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + totalVal += val; + } + } + }); + + if (opts.heatmapMode && totalVal !== 0) { + if ( + opts.heatmapMode === 'row' && + colMapper.bgColorFromSubtotalValue + ) { + const cellColor = colMapper.bgColorFromSubtotalValue( + totalVal, + actualRowKey + ); + if (cellColor) { + valCss = cellColor; + } + } else if (rowMapper.totalColor) { + const cellColor = rowMapper.totalColor(actualRowKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + } + } + + if (totalVal !== 0 || isSubtotalRow) { + const tempAggregator = this.safeGetAggregator(pivotData, [], []); + const formatFunc = + tempAggregator && tempAggregator.format + ? tempAggregator.format + : x => x; + if (className.includes('pvtSubtotal') && totalVal === 0) { + formattedTotal = ''; + } else { + formattedTotal = + totalVal === null || totalVal === 0 ? '' : formatFunc(totalVal); + } + } + + const cellKey = flatKey(actualRowKey); + + cells.push( + + {formattedTotal} + + ); + } catch (error) { + cells.push(); + } + } + + return cells; + } + + renderTotalsRow(pivotSettings) { + const { + colKeys, + colAttrs, + rowAttrs, + colTotals, + pivotData, + colMapper, + grandTotalCallback, + colTotalCallbacks, + } = pivotSettings; + + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols + ); + + const cells = []; + cells.push( + + Totals + + ); + + visibleColKeys.forEach((colKey, i) => { + try { + const isSubtotalCol = colKey[colKey.length - 1] === '__subtotal__'; + const actualColKey = isSubtotalCol ? colKey.slice(0, -1) : colKey; + + if (!actualColKey) { + cells.push(); + return; + } + + let colTotal = 0; + + const flatColKey = flatKey(actualColKey); + const isCollapsedParent = + this.state.collapsedCols[flatColKey] && + actualColKey.length < colAttrs.length; + + if (isSubtotalCol || isCollapsedParent) { + colTotal = this.calculateSubtotal( + pivotData, + [], + actualColKey, + pivotSettings + ); + } else { + pivotData.getRowKeys().forEach(rowKey => { + const agg = this.safeGetAggregator( + pivotData, + rowKey, + actualColKey + ); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + colTotal += val; + } + } + }); + } + + let valCss = {}; + if (isSubtotalCol || isCollapsedParent) { + if (opts.heatmapMode && colMapper.bgColorFromSubtotalValue) { + let cellColor; + if (opts.heatmapMode === 'full') { + cellColor = colMapper.bgColorFromSubtotalValue(colTotal); + } else if (opts.heatmapMode === 'row') { + cellColor = colMapper.bgColorFromSubtotalValue(colTotal, []); + } else if (opts.heatmapMode === 'col') { + cellColor = colMapper.bgColorFromSubtotalValue( + colTotal, + [], + actualColKey + ); + } + + if (cellColor) { + valCss = cellColor; + } + } + } else { + if (opts.heatmapMode && colTotal !== 0) { + if ( + opts.heatmapMode === 'col' && + colMapper.bgColorFromSubtotalValue + ) { + const cellColor = colMapper.bgColorFromSubtotalValue( + colTotal, + [], + actualColKey + ); + if (cellColor) { + valCss = cellColor; + } + } else if (colMapper.totalColor) { + const cellColor = colMapper.totalColor(actualColKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + } + } + + const tempAggregator = this.safeGetAggregator(pivotData, [], []); + const format = + tempAggregator && tempAggregator.format + ? tempAggregator.format + : x => x; + + let displayValue; + if (colTotal === null || colTotal === 0) { + displayValue = ''; + } else { + displayValue = format ? format(colTotal) : colTotal; + } + + cells.push( + + {displayValue} + + ); + } catch (error) { + cells.push(); + } + }); + + if (colTotals) { + try { + let grandTotal = 0; + let validValuesFound = false; + + try { + const grandTotalAggregator = pivotData.getAggregator([], []); + if (grandTotalAggregator) { + const val = grandTotalAggregator.value(); + if (val !== null && !isNaN(val)) { + grandTotal = val; + validValuesFound = true; + } + } + } catch (e) { + // Error getting grand total directly, will calculate manually + } + + if (!validValuesFound) { + pivotData.getRowKeys().forEach(rowKey => { + pivotData.getColKeys().forEach(colKey => { + try { + const agg = this.safeGetAggregator(pivotData, rowKey, colKey); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + grandTotal += val; + validValuesFound = true; + } + } + } catch (e) { + // Ignore errors for missing combinations + } + }); + }); + } + + const tempAggregator = this.safeGetAggregator(pivotData, [], []); + const format = + tempAggregator && tempAggregator.format + ? tempAggregator.format + : x => x; + + cells.push( + + {validValuesFound && grandTotal !== 0 + ? format + ? format(grandTotal) + : grandTotal + : ''} + + ); + } catch (error) { + cells.push(); + } + } + + return cells; + } + + visibleKeys(keys, collapsed) { + if (!opts.subtotals) { + return keys; + } + + const sortedKeys = keys.slice().sort((a, b) => { + const minLength = Math.min(a.length, b.length); + for (let i = 0; i < minLength; i++) { + const aStr = String(a[i]); + const bStr = String(b[i]); + const cmp = aStr.localeCompare(bStr); + if (cmp !== 0) { + return cmp; + } + } + return a.length - b.length; + }); + + const result = []; + const processedKeys = new Set(); + + for (const key of sortedKeys) { + let parentCollapsed = false; + let deepestCollapsedParent = null; + + for (let i = 0; i < key.length; i++) { + const parentKey = key.slice(0, i + 1); + const flatParentKey = flatKey(parentKey); + if (collapsed[flatParentKey]) { + parentCollapsed = true; + deepestCollapsedParent = parentKey; + break; + } + } + + if (parentCollapsed) { + const flatParentKey = flatKey(deepestCollapsedParent); + if (!processedKeys.has(flatParentKey)) { + result.push(deepestCollapsedParent); + processedKeys.add(flatParentKey); + } + } else { + const flatKey_ = flatKey(key); + if (!processedKeys.has(flatKey_)) { + result.push(key); + processedKeys.add(flatKey_); + } + } + } + + const finalResult = []; + const addedSubtotals = new Set(); + + const parentGroups = new Map(); + for (const key of result) { + for (let level = 1; level < key.length; level++) { + const parentKey = key.slice(0, level); + const parentKeyStr = flatKey(parentKey); + + if (!parentGroups.has(parentKeyStr)) { + parentGroups.set(parentKeyStr, { + key: parentKey, + level: level, + lastChildIndex: -1, + }); + } + } + } + + for (let i = 0; i < result.length; i++) { + const key = result[i]; + for (let level = 1; level < key.length; level++) { + const parentKey = key.slice(0, level); + const parentKeyStr = flatKey(parentKey); + const parentGroup = parentGroups.get(parentKeyStr); + + if (parentGroup) { + parentGroup.lastChildIndex = Math.max( + parentGroup.lastChildIndex, + i + ); + } + } + } + + for (let i = 0; i < result.length; i++) { + const key = result[i]; + finalResult.push(key); + + const subtotalsToAdd = []; + + for (let level = key.length - 1; level >= 1; level--) { + const parentKey = key.slice(0, level); + const parentKeyStr = flatKey(parentKey); + const parentGroup = parentGroups.get(parentKeyStr); + + if (collapsed[parentKeyStr]) { + continue; + } + + if (parentGroup && parentGroup.lastChildIndex === i) { + const subtotalKey = [...parentKey, '__subtotal__']; + const subtotalKeyStr = flatKey(subtotalKey); + + if (!addedSubtotals.has(subtotalKeyStr)) { + subtotalsToAdd.push(subtotalKey); + addedSubtotals.add(subtotalKeyStr); + } + } + } + + finalResult.push(...subtotalsToAdd); + } + + return finalResult; + } + + getSubtotal(rowKey, colKey, pivotSettings) { + const {pivotData} = pivotSettings; + return pivotData.getAggregator(rowKey, colKey).value(); + } + + hasSubtotals(rowOrCol, key, pivotSettings) { + const {rowAttrs, colAttrs} = pivotSettings; + const attrs = rowOrCol === 'row' ? rowAttrs : colAttrs; + + return key.length < attrs.length; + } + + safeGetAggregator(pivotData, rowKey, colKey) { + try { + return pivotData.getAggregator(rowKey, colKey); + } catch (error) { + return null; + } + } + + calculateSubtotal(pivotData, rowKey, colKey, pivotSettings) { + const {rowAttrs, colAttrs} = pivotSettings; + + if ( + rowKey.length === rowAttrs.length && + colKey.length === colAttrs.length + ) { + const agg = this.safeGetAggregator(pivotData, rowKey, colKey); + return agg ? agg.value() : 0; + } + + let total = 0; + let hasValidValues = false; + + const childRowKeys = []; + if (rowKey.length < rowAttrs.length) { + pivotData.getRowKeys().forEach(fullRowKey => { + let isChild = true; + for (let i = 0; i < rowKey.length; i++) { + if (fullRowKey[i] !== rowKey[i]) { + isChild = false; + break; + } + } + + if (isChild) { + childRowKeys.push(fullRowKey); + } + }); + } else { + childRowKeys.push(rowKey); + } + + const childColKeys = []; + if (colKey.length < colAttrs.length) { + pivotData.getColKeys().forEach(fullColKey => { + let isChild = true; + for (let i = 0; i < colKey.length; i++) { + if (fullColKey[i] !== colKey[i]) { + isChild = false; + break; + } + } + + if (isChild) { + childColKeys.push(fullColKey); + } + }); + } else { + childColKeys.push(colKey); + } + + if (childRowKeys.length === 0 || childColKeys.length === 0) { + const agg = this.safeGetAggregator(pivotData, rowKey, colKey); + return agg ? agg.value() || 0 : 0; + } + + childRowKeys.forEach(childRowKey => { + childColKeys.forEach(childColKey => { + const agg = this.safeGetAggregator( + pivotData, + childRowKey, + childColKey + ); + if (agg) { + const val = agg.value(); + if (val !== null && !isNaN(val)) { + total += val; + hasValidValues = true; + } + } + }); + }); + + return hasValidValues ? total : 0; + } + + render() { + const pivotSettings = this.getBasePivotSettings(); + const {colAttrs, rowAttrs, rowKeys, colKeys, rowTotals} = pivotSettings; + + const renderedLabels = {}; + + const visibleRowKeys = opts.subtotals + ? this.visibleKeys(rowKeys, this.state.collapsedRows) + : rowKeys; + const visibleColKeys = opts.subtotals + ? this.visibleKeys(colKeys, this.state.collapsedCols) + : colKeys; + + const finalPivotSettings = 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), + }, + pivotSettings + ); + + const rowspans = {}; + visibleRowKeys.forEach((rowKey, rowIdx) => { + const isSubtotalRow = rowKey[rowKey.length - 1] === '__subtotal__'; + const actualRowKey = isSubtotalRow ? rowKey.slice(0, -1) : rowKey; + + for (let level = 0; level < actualRowKey.length; level++) { + const cellKey = `${rowIdx}-${level}`; + + let span = 1; + let j = rowIdx + 1; + while (j < visibleRowKeys.length) { + const nextKey = visibleRowKeys[j]; + const isNextSubtotal = + nextKey[nextKey.length - 1] === '__subtotal__'; + const actualNextKey = isNextSubtotal + ? nextKey.slice(0, -1) + : nextKey; + + if (level >= actualNextKey.length) { + break; + } + + let matches = true; + for (let l = 0; l <= level; l++) { + if ( + l >= actualNextKey.length || + actualNextKey[l] !== actualRowKey[l] + ) { + matches = false; + break; + } + } + + if (!matches) { + break; + } + span++; + j++; + } + + rowspans[cellKey] = span; + } + }); + + const renderedRows = visibleRowKeys.map((rowKey, i) => { + const rowCells = []; + + const isSubtotalRow = rowKey[rowKey.length - 1] === '__subtotal__'; + const actualRowKey = isSubtotalRow ? rowKey.slice(0, -1) : rowKey; + + if (isSubtotalRow) { + rowCells.push( + + ); + } else { + for (let level = 0; level < actualRowKey.length; level++) { + const labelKey = `${actualRowKey.slice(0, level + 1).join('|')}`; + + if (!renderedLabels[labelKey]) { + renderedLabels[labelKey] = true; + + const cellKey = `${i}-${level}`; + const rowspan = rowspans[cellKey] || 1; + + const flatRowKey = flatKey(actualRowKey.slice(0, level + 1)); + const isCollapsed = this.state.collapsedRows[flatRowKey]; + + let className = 'pvtRowLabel'; + + let icon = null; + + if (level + 1 < rowAttrs.length) { + if (isCollapsed) { + className += ' collapsed'; + icon = pivotSettings.arrowCollapsed; + } else { + className += ' expanded'; + icon = pivotSettings.arrowExpanded; + } + rowCells.push( + + {icon && ( + + {icon} + + )} + {actualRowKey[level]} + + ); + continue; + } + + const isLeafLevel = + level === actualRowKey.length - 1 && + actualRowKey.length === rowAttrs.length; + const leafColspan = isLeafLevel ? 2 : 1; + + rowCells.push( + + {icon && {icon}} + {actualRowKey[level]} + + ); + } + } + + if (actualRowKey.length < rowAttrs.length) { + rowCells.push( + + ); + } + } + + const dataCells = this.renderTableRow(rowKey, i, finalPivotSettings); + + return ( + + {rowCells} + {dataCells} + + ); + }); + + const colAttrsHeaders = colAttrs.map((attrName, i) => { + return ( + + {this.renderColHeaderRow(attrName, i, finalPivotSettings)} + + ); + }); + + let rowAttrsHeader = null; + if (rowAttrs.length > 0) { + rowAttrsHeader = ( + {this.renderRowHeaderRow(finalPivotSettings)} + ); + } + + let totalHeader = null; + if (rowTotals) { + totalHeader = ( + {this.renderTotalsRow(finalPivotSettings)} + ); + } + + return ( + + + {colAttrsHeaders} + {rowAttrsHeader} + + + {renderedRows} + {totalHeader} + +
+ ); + } + } + + SubtotalRenderer.defaultProps = Object.assign({}, PivotData.defaultProps, { + tableColorScaleGenerator: redColorScaleGenerator, + tableOptions: {}, + }); + SubtotalRenderer.propTypes = Object.assign({}, PivotData.propTypes, { + tableColorScaleGenerator: PropTypes.func, + tableOptions: PropTypes.object, + }); + return SubtotalRenderer; +} + +class TSVExportRenderer extends React.PureComponent { + render() { + const pivotData = new PivotData(this.props); + const rowKeys = pivotData.getRowKeys(); + const colKeys = pivotData.getColKeys(); + if (rowKeys.length === 0) { + rowKeys.push([]); + } + if (colKeys.length === 0) { + colKeys.push([]); + } + + const headerRow = pivotData.props.rows.map(r => r); + if (colKeys.length === 1 && colKeys[0].length === 0) { + headerRow.push(this.props.aggregatorName); + } else { + colKeys.map(c => headerRow.push(c.join('-'))); + } + + const result = rowKeys.map(r => { + const row = r.map(x => x); + colKeys.map(c => { + const aggregator = pivotData.getAggregator(r, c); + row.push(aggregator.value()); + }); + return row; + }); + + result.unshift(headerRow); + + return ( +