kiwis
Version:
A Pandas-inspired data wrangling toolkit in JavaScript
357 lines (301 loc) • 9.97 kB
JavaScript
'use strict';
const d3 = require('d3-array');
const nest = require('d3-collection').nest;
const flatten = require('flat');
const Series = require('./Series.js');
const Validator = require('./Validator.js');
/**
* @class
* @property {number} length The number of rows in the PivotTable
* @property {boolean} empty Whether the PivotTable contains any row or not
* @property {string[]} columns The columns of the PivotTable, starting with the pivots
*/
class PivotTable {
/**
* @function callback
* @param {Object} currentElement
* @param {number} index
*/
/**
* @constructor
* @hideconstructor
* @param {Object[]} entries An array of key/value objects
* @param {string[]} columns An array of column names
* @param {DataFrame} df A DataFrame containing the unnested data
*/
constructor(df, columns) {
this._df = df.clone().sort(columns);
this._pivots = columns;
this._columns = this._df._columns.filter(column => !this._pivots.includes(column));
this._kw = require('./Kiwis.js');
const entries = this._pivots
.reduce((acc, column) => {
return acc.key(d => d[column]).sortKeys((a, b) => a - b);
}, nest())
.entries(this._df._data);
const parseEntry = (acc, entry) => {
const isLeaves = !('key' in entry.values[0] && 'values' in entry.values[0]);
return {
...acc,
[entry.key]: isLeaves
? this._kw.DataFrame(entry.values).drop(this._pivots)
: entry.values.reduce(parseEntry, {})
};
}
this._tree = entries.reduce(parseEntry, {});
}
get length() {
return this._df.length;
}
get empty() {
return this._df.length === 0;
}
get columns() {
return [
...this._pivots,
this._df._columns.filter(column => !this._pivots.includes(column))
];
}
/**
* Applies the given callback function on the leaves of the PivotTable, returning a DataFrame
* @param {callback} callback
* @param {Object} [options]
* @param {string} [options.name='data'] Name to use for the column in the output DataFrame
* @returns {DataFrame}
* @example
* // For each leaf, computes the number of rows where 'score' is greater than 3
* df.pivot(['sector', 'date']).rollup(
* leaf => leaf.filter(row => row.score > 3).length,
* { name: 'nbHighScores' }
* );
*/
rollup(callback, options = {}) {
Validator.function('PivotTable.rollup()', 'callback', callback);
Validator.options('PivotTable.rollup()', options, [
{ key: 'name', type: 'string' }
]);
const name = options.name || 'data';
const DataFrame = require('./DataFrame.js');
const applyToLeaves = (acc, [key, value]) => {
const data = value instanceof DataFrame
? callback(value.toArray())
: Object.entries(value).reduce(applyToLeaves, {});
return {
...acc,
[key]: data
};
};
const rolledUp = Object.entries(this._tree).reduce(applyToLeaves, {});
const flattened = flatten(rolledUp, { delimiter: '|' });
const data = Object.entries(flattened).reduce((acc, [key, value]) => {
const values = [...key.split('|'), value];
const row = values.reduce((acc, value, index) => ({
...acc,
[index < this._pivots.length ? this._pivots[index] : name]: value
}), {});
return [...acc, row];
}, []);
return new DataFrame(data);
}
/**
* Counts the number of leaves for each branch of the PivotTable
* @returns {DataFrame}
* @example
* df.pivot(['sector', 'date']).count();
*/
count() {
return this.rollup(l => l.length, { name: 'count' });
}
/**
* Computes the sum of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the sum of the column 'score'
* df.pivot(['sector', 'date']).sum('score');
*/
sum(column) {
Validator.string('PivotTable.sum()', 'column', column, { enum: this._columns });
const name = 'sum' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.sum(l, d => d[column]), { name: name });
}
/**
* Computes the minimum value of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the minimum of the column 'score'
* df.pivot(['sector', 'date']).min('score');
*/
min(column) {
Validator.string('PivotTable.min()', 'column', column, { enum: this._columns });
const name = 'min' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.min(l, d => +d[column]), { name: name });
}
/**
* Computes the maximum value of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the maximum of the column 'score'
* df.pivot(['sector', 'date']).max('score');
*/
max(column) {
Validator.string('PivotTable.max()', 'column', column, { enum: this._columns });
const name = 'max' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.max(l, d => +d[column]), { name: name });
}
/**
* Computes the mean of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the mean of the column 'score'
* df.pivot(['sector', 'date']).mean('score');
*/
mean(column) {
Validator.string('PivotTable.mean()', 'column', column, { enum: this._columns });
const name = 'mean' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.mean(l, d => +d[column]), { name: name });
}
/**
* Computes the median of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the median of the column 'score'
* df.pivot(['sector', 'date']).median('score');
*/
median(column) {
Validator.string('PivotTable.median()', 'column', column, { enum: this._columns });
const name = 'median' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.median(l, d => +d[column]), { name: name });
}
/**
* Computes the standard deviation of a given column of the PivotTable
* @returns {DataFrame}
* @example
* // For each leaf, computes the standard deviation of the column 'score'
* df.pivot(['sector', 'date']).std('score');
*/
std(column) {
Validator.string('PivotTable.std()', 'column', column, { enum: this._columns });
const name = 'std' + column[0].toUpperCase() + column.slice(1);
return this.rollup(l => d3.deviation(l, d => +d[column]), { name: name });
}
/**
* Formats the PivotTable for display
* @returns {string}
*/
toString() {
if (this.empty) {
return 'Empty PivotTable';
}
const MAX_WIDTH = 42;
const NB_COLS = 180;
const MAX_LENGTH_LEAVES = 5;
const widths = [...this._pivots, ...this._columns]
.map(column => Math.max(
column.length,
d3.max(
this._df._data,
d => !this._kw.isNA(d[column]) ? d[column].toString().length : 0
)
))
.map(width => width > MAX_WIDTH ? MAX_WIDTH : width);
const computeWidth = (index) => {
return d3.sum(widths.slice(0, index + 1)) + 3 * index;
}
const visibleColumns = this._columns.filter((column, index) => computeWidth(index + this._pivots.length) <= NB_COLS);
const lines = [];
lines.push([
...this._pivots.map((column, index) => column.padStart(widths[index])),
...visibleColumns.map((column, index) => column.padStart(widths[index + this._pivots.length]))
].join(' | '));
lines.push(
'='.repeat(computeWidth(this._pivots.length + visibleColumns.length - 1))
+ (visibleColumns.length < this._df._columns.length ? ' ...' : '')
);
let previousRow;
let isPreviousVisible;
this._df._data.forEach((row, rowIndex) => {
const leaves = this._pivots.reduce((acc, column) => {
return acc[row[column]];
}, this._tree);
const isVisible = leaves.toArray().slice(0, MAX_LENGTH_LEAVES)
.some(e => this._kw.isEquivalent(e, Object.keys(row).reduce((acc, key) => {
if (this._pivots.includes(key))
return acc;
return {
...acc,
[key]: row[key]
};
}, {})));
if (!isVisible && !isPreviousVisible) return;
const line = [
...this._pivots.map((column, index) => {
if (!previousRow || previousRow[column] !== row[column]
|| this._pivots.slice(0, index).filter(column => row[column] !== previousRow[column]).length > 0)
return row[column].toString().padStart(widths[index]);
return '.'.padStart(widths[index]);
})
];
if (isVisible) {
line.push(...visibleColumns.map((column, index) => {
const cell = !this._kw.isNA(row[column]) ? row[column].toString() : 'N/A';
return cell.length > MAX_WIDTH
? `${cell.substr(0, MAX_WIDTH - 3)}...`
: cell.padStart(widths[index + this._pivots.length]);
}));
}
else {
line.push(`... ${leaves.length - MAX_LENGTH_LEAVES} more`);
}
lines.push(line.join(' | '));
previousRow = row;
isPreviousVisible = isVisible;
});
lines.push('');
lines.push(`[${this.length} rows × ${this._columns.length} columns]`);
lines.push(`Pivot along: ${this._pivots.join(', ')}`);
lines.push(`Columns: ${this._columns.join(', ')}`);
return lines.join('\n');
}
/**
* Displays the DataFrame
*/
show() {
console.log(this.toString());
console.log();
}
/**
* Exports the PivotTable as JSON
* @param {string} [path=null] Path of the file to save
* @param {Object} [options]
* @param {boolean} [options.prettify=true] Prettify JSON output
* @returns {string}
* @example
* pivotTable.toJSON('myPivotTable.json');
*/
toJSON(path, options = {}) {
Validator.options('PivotTable.toJSON()', options, [
{ key: 'prettify', type: 'boolean' }
]);
const prettify = options.prettify !== undefined ? options.prettify : true;
const toArray = (acc, [key, value]) => {
if (value instanceof require('./DataFrame.js')) {
return {
...acc,
[key]: value.toArray()
};
}
return {
...acc,
[key]: Object.entries(value).reduce(toArray, {})
};
}
const content = JSON.stringify(
Object.entries(this._tree).reduce(toArray, {}),
null,
prettify ? '\t' : null
);
if (path) eval('require')('fs').writeFileSync(path, content);
return content;
}
}
module.exports = PivotTable;