/** * @docauthor Jonathan Jackson * @class Pivot * # Welcome to Pivot.js * * Pivot.js is a simple way for you to get to your data. It allows for the * creation of highly customizable unique table views from your browser. * * > In data processing, a pivot table is a data summarization tool found in * > data visualization programs such as spreadsheets or business intelligence * > software. Among other functions, pivot-table tools can automatically sort, * > count, total or give the average of the data stored in one table or * > spreadsheet. It displays the results in a second table (called a "pivot * > table") showing the summarized data. * * In our case, results (or the pivot-table) will be displayed as an HTML table * pivoting from the input data (CSV or JSON). Without further ado let's get to usage. * * View an [example](http://rjackson.github.com/pivot.js/). * * #Usage * * Step one is to initialize the pivot object. It expects the following attributes: * * - `csv` - which should contain a valid string of comma separated values. It is * __important to note__ that you must include a header row in the CSV for pivot * to work properly (you'll understand why in a minute). * * - `json` - which should contain a valid JSON string. At this time this string * must be an array of arrays, and not an array of objects (storing the field * names with each row consumes significantly more space). * * - `fields` - which should be an array of objects. This is used to instruct * pivot on how to interact with the fields you pass in. It keys off of the * header row names. And is formated like so: * * [ {name: 'header-name', type: 'string', optional_attributes: 'optional field' }, * {name: 'header-name', type: 'string', optional_attributes: 'optional field' }] * * * - `filters` (default is empty) - which should contain any filters you would like to restrict your data to. A filter is defined as an object like so: * * {zip_code: '34471'} * * * Those are the options that you should consider. There are other options that are well covered in the spec * A valid pivot could then be set up from like so. * * * var field_definitions = [{name: 'last_name', type: 'string', filterable: true}, * {name: 'first_name', type: 'string', filterable: true}, * {name: 'zip_code', type: 'integer', filterable: true}, * {name: 'pseudo_zip', type: 'integer', filterable: true }, * {name: 'billed_amount', type: 'float', rowLabelable: false,}, * {name: 'last_billed_date', type: 'date', filterable: true} * * // from csv data: * var csv_string = "last_name,first_name,zip_code,billed_amount,last_billed_date\n" + * "Jackson,Robert,34471,100.00,\"Tue, 24 Jan 2012 00:00:00 +0000\"\n" + * "Jackson,Jonathan,39401,124.63,\"Fri, 17 Feb 2012 00:00:00 +0000\"" * pivot.init({csv: csv_string, fields: field_definitions}); * * // from json data: * var json_string = '[["last_name","first_name","zip_code","billed_amount","last_billed_date"],' + * ' ["Jackson", "Robert", 34471, 100.00, "Tue, 24 Jan 2012 00:00:00 +0000"],' + * ' ["Smith", "Jon", 34471, 173.20, "Mon, 13 Feb 2012 00:00:00 +0000"]]' * * pivot.init({json: json_string, fields: field_definitions}); * */ var pivot = (function(){ 'use strict'; var fields, filters, rawData, data, dataFilters, displayFields, results, resultsColumns; init(); /** * Initializes a new pivot. * Optional parameters: * * fields * * filters * * rowLabels * * columnLabels * * summaries * @param {Object} */ function init(options){ rawData = [], data = [], dataFilters = {}, fields = {}, filters = {}; displayFields = {rowLabels: {}, columnLabels: {}, summaries: {}}; if (options === undefined) options = {}; if (options.fields !== undefined) setFields(options.fields); if (options.filters !== undefined) setFilters(options.filters); if (options.rowLabels !== undefined) setRowLabelDisplayFields(options.rowLabels); if (options.columnLabels !== undefined) setColumnLabelDisplayFields(options.columnLabels); if (options.summaries !== undefined) setSummaryDisplayFields(options.summaries); if (options.csv !== undefined) processCSV(options.csv) if (options.json !== undefined) processJSON(options.json) return pivot; } /** * Calls init with no options, which effectively resets the current pivot. */ function reset(){ return init(); }; /** * Very cool little function. If called like so: `pivot.config(true)` will return the exact object you would need * to create the current pivot from scratch. If passed with no argument will return everything except fields. */ function config(showFields){ var fields; if (showFields === undefined) fields = cloneFields() else if (showFields === false) fields = "Pass showFields as true in order to view fields here."; return { fields: fields, filters: filters, rowLabels: objectKeys(displayFields.rowLabels), columnLabels: objectKeys(displayFields.columnLabels), summaries: objectKeys(displayFields.summaries) }; }; function pivotUtils(){ return { pad: pad, padRight: padRight, padLeft: padLeft, formatDate: formatDate, formatTime: formatTime, isArray: isArray, isRegExp: isRegExp, shallowClone: shallowClone, objectKeys: objectKeys, objectType: objectType, sortNumerically: sortNumerically } }; function pad(sideToPad, input, width, padString){ if (padString === undefined) padString = " "; input = input.toString(); padString = padString.toString(); while (input.length < width) { if (sideToPad === "left") input = padString + input; else input = input + padString; } return input }; function padRight(input, width, padString){ return pad('right', input, width, padString) }; function padLeft(input, width, padString){ return pad('left', input, width, padString) }; function formatDate(value){ return value.getUTCFullYear() + '-' + padLeft((value.getUTCMonth() + 1), 2, "0") + '-' + padLeft(value.getUTCDate(), 2, '0'); }; function formatTime(value){ return formatDate(value) + ' ' + padLeft(value.getUTCHours(), 2,'0') + ':' + padLeft(value.getUTCMinutes(),2,'0'); }; function isArray(arg){ if(!Array.isArray) return objectType(arg) == 'array'; else return Array.isArray(arg); }; function isRegExp(arg){ return objectType(arg) == 'regexp'; }; function shallowClone(input){ var output = {}; for (var key in input) { if (input.hasOwnProperty(key)) output[key] = input[key]; } return output; }; function objectKeys(object){ if (Object.keys) return Object.keys(object); var output = []; for (key in object){ output.push(key); } return output; }; function objectType(obj) { return ({}).toString.call(obj).match(/\s([a-z|A-Z]+)/)[1].toLowerCase(); }; function sortNumerically(array){ return array.sort(function(a,b){ return a - b;}); }; function processHeaderRow(row){ var output = []; var o = {}, i = -1, m = row.length; while (++i < m) { var field = fields[row[i]]; if (field === undefined) field = appendField(row[i]); output.push(field); }; return output; }; function processJSON(input) { var header, pseudoFields = restrictFields('pseudo'); if (objectType(input) === 'string') input = JSON.parse(input); rawData = []; var o = {}, j = -1, m = input.length; while (++j < m) { if (j === 0) header = processHeaderRow(input[j]); else rawData.push(processRow(input[j], header, pseudoFields)); }; }; // Accepts csv as a string function processCSV(text) { var header, pseudoFields = restrictFields('pseudo'); rawData = processRows(text, function(row, i){ if (i === 0) header = processHeaderRow(row); else return processRow(row, header, pseudoFields); }); }; function processRows(text, f) { var EOL = {}, // sentinel value for end-of-line EOF = {}, // sentinel value for end-of-file rows = [], // output rows re = /\r\n|[,\r\n]/g, // field separator regex n = 0, // the current line number t, // the current token eol; // is the current token followed by EOL? re.lastIndex = 0; // work-around bug in FF 3.6 /** @private Returns the next token. */ function token() { if (re.lastIndex >= text.length) return EOF; // special case: end of file if (eol) { eol = false; return EOL; } // special case: end of line // special case: quotes var j = re.lastIndex; if (text.charCodeAt(j) === 34) { var i = j; while (i++ < text.length) { if (text.charCodeAt(i) === 34) { if (text.charCodeAt(i + 1) !== 34) break; i++; } } re.lastIndex = i + 2; var c = text.charCodeAt(i + 1); if (c === 13) { eol = true; if (text.charCodeAt(i + 2) === 10) re.lastIndex++; } else if (c === 10) { eol = true; } return text.substring(j + 1, i).replace(/""/g, "\""); } // common case var m = re.exec(text); if (m) { eol = m[0].charCodeAt(0) !== 44; return text.substring(j, m.index); } re.lastIndex = text.length; return text.substring(j); } while ((t = token()) !== EOF) { var a = []; while ((t !== EOL) && (t !== EOF)) { a.push(t); t = token(); } if (f && !(a = f(a, n++))) continue; rows.push(a); } return rows; }; function processRow(row, header, pseudoFields) { // process actual fields var o = {}, j = -1, m = header.length; while (++j < m) { var value = castFieldValue(header[j].name, row[j]); o[header[j].name] = value; addFieldValue(header[j].name, value); }; // process pseudo fields j = -1, m = pseudoFields.length; while (++j < m) { var field = pseudoFields[j], value = castFieldValue(field.name, field.pseudoFunction(o, field)); o[field.name] = value; addFieldValue(field.name, value); }; return o; }; /** * Entry point for several filter methods. * See: * * * getFilters() - returns filters applied to current pivot * * setFilters() - sets a series of filters * * appendFilter() - adds a filter to current pivot filters * * applyFilter() - runs the filters on the values * * @param {String} * @return {function} One of the fucntions defined above. */ function pivotFilters(type){ var opts = { all: getFilters, set: setFilters, apply: applyFilter, add: appendFilter } if (type !== undefined) { return opts[type] } else { return opts }; }; function castFilterValues(restrictions){ if (restrictions === undefined) restrictions = filters; var field; for (field in restrictions){ if (restrictions.hasOwnProperty(field)) if (isRegExp(restrictions[field])) { // no need to change } else if (isArray(restrictions[field])) { var i = -1, m = restrictions[field].length; while (++i < m) { restrictions[field][i] = castFieldValue(field, restrictions[field][i]) }; } else { restrictions[field] = castFieldValue(field, restrictions[field]) } }; }; /** * Takes a new restrction (filter) and appends it to current pivot's filters * @param {Object} newRestriction should looke like {"last_name":"Jackson"} */ function appendFilter(newRestriction) { for (var key in newRestriction) { if (newRestriction.hasOwnProperty(key)) filters[key] = newRestriction[key]; } castFilterValues(); }; /** * Returns current pivot's filters */ function getFilters(){ return filters; }; /** * Accepts list of restrictions, assigns them as current pivot's filters and casts their values. * @param {Object} restrictions - should looke something like {"employer":"Acme Corp"} */ function setFilters(restrictions){ filters = restrictions; resetResults(); castFilterValues(); }; /** * Applies the current pivot's filters to the data returning a list of values * Optionally allows you to set filters and apply them. * @param {Object} restrictions allows you to pass the filters to apply without using set first. */ function applyFilter(restrictions){ var dataToFilter = data, filteredData = []; if (restrictions !== undefined) setFilters(restrictions); var preserveFilter = preserveFilteredData(); if (preserveFilter) { dataToFilter = data; } else { dataToFilter = rawData; } var dataToFilterLength = dataToFilter.length, filterLength = objectKeys(filters).length, i = -1; while (++i < dataToFilterLength) { var row = dataToFilter[i], matches = 0; for (var key in filters) { if (filters.hasOwnProperty(key) && row.hasOwnProperty(key) && matchesFilter(filters[key], row[key])) matches += 1; } if (matches === filterLength) { filteredData.push(row); }; }; data = filteredData; dataFilters = shallowClone(filters); resetResults(); return data; }; function matchesFilter(filter, value){ if (isArray(filter)) { var i = -1, m = filter.length; while (++i < m) { if(filter[i] === value) return true }; } else if (isRegExp(filter)){ return filter.test(value); } else { return value === filter; } return false }; function preserveFilteredData(){ var matches = 0, dataFiltersLength = objectKeys(dataFilters).length; for (var key in dataFilters) { if (dataFilters.hasOwnProperty(key) && dataFilters.hasOwnProperty(key) && filters[key] === dataFilters[key]) matches += 1; } return dataFiltersLength > 0 && matches >= dataFiltersLength; }; /** * Entry point for several field methods. * See: * * * restrictFields() * * cloneFields() * * appendField() * * getFields() * * getField() * * setField() * * @param {String} * @return {function} One of the fucntions defined above. */ function pivotFields(type){ var opts = { columnLabelable: restrictFields('columnLabelable'), rowLabelable: restrictFields('rowLabelable'), summarizable: restrictFields('summarizable'), filterable: restrictFields('filterable'), pseudo: restrictFields('pseudo'), clone: cloneFields, add: appendField, all: getFields, set: setFields, get: getField } if (type !== undefined) { return opts[type] } else { return opts }; }; /** * Method for setting multiple fields. Usually used on pivot.init(). * See {@link pivot#appendField} for more information. * @param {Object} * @return {undefined} */ function setFields(listing){ fields = {}; var i = -1, m = listing.length; while (++i < m) { appendField(listing[i]); } }; function cloneFields(){ var fieldsOutput = []; for (var field in fields){ var newField = {}; for (var key in fields[field]){ if (fields[field].hasOwnProperty(key) && key !== 'values') newField[key] = fields[field][key]; } fieldsOutput.push(newField); } return fieldsOutput; } /** * Returns array of defined field objects. */ function getFields(){ var retFields = []; for (var key in fields) { if (fields.hasOwnProperty(key)) retFields[fields[key].index] = fields[key]; } return retFields; }; /** * Returns list of defined fields filtered by type * @param {String} 'columnLabelable', 'rowLabelable', 'summarizable', 'filterable', or 'pseudo' */ function restrictFields(type){ var retFields = []; for (var key in fields) { if (fields.hasOwnProperty(key) && fields[key][type] === true) retFields.push(fields[key]); } return retFields; }; /** * Attr reader for fields * @param {String} Something like 'last_name' */ function getField(name){ return fields[name]; }; /** * Returns the sum value of all rows passed to it. */ function defaultSummarizeFunctionSum(rows, field){ var runningTotal = 0, i = -1, m = rows.length; while (++i < m) { runningTotal += rows[i][field.dataSource]; }; return runningTotal; }; /** * Returns Average of values passed in from rows */ function defaultSummarizeFunctionAvg(rows, field){ return defaultSummarizeFunctionSum(rows, field)/rows.length; }; /** * Returns count of rows */ function defaultSummarizeFunctionCount(rows, field){ return rows.length; } /** * The main engine by which you create and assign field. Takes an object that should look something like {name: 'last_name',type: 'string', filterable: true}, and assigns all the associated attributes to their correct state. * Allowed field attributes are * * filterable - Allows you to filter based off this field * * rowLabelable - Allows you to display rowLabels based off this field * * columnLabelable - Allows you to display columnLabels based off this field * * summarizable - Allows you to create a summary field. * * pseudo - Allows you to treat an anonymous function as a field (ie you could treat the sum of a set of values as a field) * * sortFunction - Allows you to override the default sort function for columnLabelable fields. * * displayFunction - Allows you to override the default display function. Using this function you can completely customize the way a field is displayed without having to modify the internal storage. * Be sure to run through the source on this one if you are unsure as to what it does. It's pretty straightforward, but definitely bears looking into. * @param {Object} field */ function appendField(field){ // if field is a simple string setup and object with that string as a name if (objectType(field) === 'string') field = {name: field}; if (field.type === undefined) field.type = 'string'; if (field.pseudo === undefined) field.pseudo = false; if (field.rowLabelable === undefined) field.rowLabelable = true; if (field.columnLabelable === undefined) field.columnLabelable = false; if (field.filterable === undefined) field.filterable = false; if (field.dataSource === undefined) field.dataSource = field.name; if (field.summarizable && (field.rowLabelable || field.columnLabelable || field.filterable)) { var summarizable_field = shallowClone(field); summarizable_field.rowLabelable = false; summarizable_field.filterable = false; summarizable_field.dataSource = field.name; if (summarizable_field.summarizable !== true) summarizable_field.name = summarizable_field.name + '_' + summarizable_field.summarizable; else summarizable_field.name = summarizable_field.name + '_count' appendField(summarizable_field); field.summarizable = false; field.summarizeFunction = undefined; } else if (field.summarizable) { if (field.summarizeFunction === undefined){ switch (field.summarizable){ case 'sum': field.summarizeFunction = defaultSummarizeFunctionSum; break; case 'avg': field.summarizeFunction = defaultSummarizeFunctionAvg; break; default: field.summarizeFunction = defaultSummarizeFunctionCount; break; }; field.summarizable = true; }; } else { field.summarizable = false } if (field.pseudo && field.pseudoFunction === undefined) field.pseudoFunction = function(row){ return '' }; if (field.displayFunction === undefined) field.displayFunction = displayFieldValue; field.values = {}; field.displayValues = {}; field.index = objectKeys(fields).length; fields[field.name] = field; return field; }; /** * Adds value to field based off of the Fields' displayFunction, defaults to count. */ function addFieldValue(field, value){ if (fields[field] === undefined || fields[field].filterable === false) return; if (fields[field].values[value] === undefined) { fields[field].values[value] = {count: 1, displayValue: fields[field].displayFunction(value, field)}; } else { fields[field].values[value].count += 1; } }; /** * Helper for displaying properly formated field values. */ function displayFieldValue(value, fieldName){ var field; if (objectType(fieldName) === 'string') field = fields[fieldName]; if (field === undefined) field = appendField(fieldName); switch (field.type){ case "cents": return '$' + (value/100).toFixed(2); case "currency": return '$' + value.toFixed(2); case "date": return formatDate(new Date(value)); case "time": return formatTime(new Date(value)); default: return value; } } /** * Used to change the string value as parsed from the CSV into the type of field it expects. */ function castFieldValue(fieldName, value){ var field, retValue; if (objectType(fieldName) === 'string') field = fields[fieldName]; if (field === undefined) field = appendField(fieldName); switch (field.type){ case "integer": case "cents": if (objectType(value) === 'number') return value; else return parseInt(value, 10); case "float": case "currency": if (objectType(value) === 'number') return value; else return parseFloat(value, 10); case "date": case "time": switch (objectType(value)){ case 'number': case 'date': return value; default: if (/^\d+$/.test(value)) return parseInt(value); else return Date.parse(value); }; default: return value.toString(); } }; /** * Returns object containing the raw fields(rawData) and filtered fields(data). * @param string, either 'raw', or 'all'. * @return {Object} An object containing lists of fields */ function pivotData(type) { var opts = {raw: rawData, all: data }; if (type !== undefined) { return opts[type] } else { return opts }; } /** * Entry point for several display methods. See {@link pivot#pivotDisplayAll}, {@link pivot#pivotDisplayRowLabels}, {@link pivot#pivotDisplaycolumnLabels}, and {@link pivot#pivotDisplaySummaries} * @return {function} One of the fucntions defined above. */ function pivotDisplay(){ return { all: pivotDisplayAll, rowLabels: pivotDisplayRowLabels, columnLabels: pivotDisplayColumnLabels, summaries: pivotDisplaySummaries } }; /** * This will return an object containing rowLabels, summaries, and columnLabels that are currently applied to the pivot. */ function pivotDisplayAll(){ return displayFields; }; /** * Returns either list of rowLabels or allows you to access the {@link pivot#setRowLabelDisplayFields}. * * Called from pivot like so: pivot.display().rowLabels().set() or pivot.display().rowLabels().get */ function pivotDisplayRowLabels(){ return { set: setRowLabelDisplayFields, get: displayFields.rowLabels } }; /** * Returns either list of columnLabels or allows you to access the {@link pivot#setColumnLabelDisplayFields}. * * Called from pivot like so: pivot.display().columnLabels().set() or pivot.display().columnLabels().get */ function pivotDisplayColumnLabels(){ return { set: setColumnLabelDisplayFields, get: displayFields.columnLabels } }; /** * Returns either list of summaries (labels) or allows you to access the {@link pivot#setSummaryDisplayFields}. * * Called from pivot like so: pivot.display().summaries().set() or pivot.display().summaries().get */ function pivotDisplaySummaries(){ return { set: setSummaryDisplayFields, get: displayFields.summaries } }; /** * This method allows you to append a new label field to the specified type. For example, you could set a new displayRowLabel by sending it as the type and 'city' as the field * @param string type - must be either 'rowLabels', 'columnLabels', or 'summaries' * @param string field - Specify the label you would like to add. * @private * @return {undefined} */ function appendDisplayField(type, field){ if (objectType(field) === 'string') field = fields[field]; resetResults(); displayFields[type][field.name] = field; }; /** * This method simply calls appendDisplayField on a collection passing in each to appendDisplayField. The object should look something like the following * {'rowLabels':['city','state'],'columnLabels':['billed_amount']} * @private * @return {undefined} */ function setDisplayFields(type, listing){ displayFields[type] = {}; resetResults(); var i = -1, m = listing.length; while (++i < m) { appendDisplayField(type, listing[i]); }; }; /** * Allows setting of row label fields * @param listing Should look like ['city','state'] * @return {undefined} */ function setRowLabelDisplayFields(listing){ setDisplayFields('rowLabels', listing); }; /** * Allows setting of column label fields * @param listing - Should look like ['city','state'] * @return {undefined} */ function setColumnLabelDisplayFields(listing){ setDisplayFields('columnLabels', listing); }; /** * Allows setting of summary label fields * @param listing - Should look like ['billed_amount'] * @return {undefined} */ function setSummaryDisplayFields(listing){ setDisplayFields('summaries', listing); }; /** * Entry point for several results methods. * See: * * * getDataResults() - returns filters applied to current pivot * * getColumnResults() - sets a series of filters * * @return {function} One of the fucntions defined above. */ function pivotResults(){ return { all: getFormattedResults, columns: getColumnResults } }; function resetResults(){ results = undefined; resultsColumns = undefined; } function getFormattedResults(){ if (results !== undefined && resultsColumns !== undefined) return getResultArray(); applyFilter(); results = {}; resultsColumns = []; processRowLabelResults(); if (objectKeys(displayFields.columnLabels).length > 0) processColumnLabelResults(); else { populateSummaryColumnsResults(); processSummaryResults(); } return getResultArray(); }; function processRowLabelResults(){ var i = -1, m = data.length, keys; while (++i < m) { var row = data[i], resultKey = ''; for (var key in displayFields.rowLabels) { if (displayFields.rowLabels.hasOwnProperty(key)) { if (i === 0) resultsColumns.push({fieldName: key, width: 1, type: 'row'}); resultKey += key + ':' + row[key] + '|'; } } if (results[resultKey] === undefined) { results[resultKey] = {}; for (var key in displayFields.rowLabels) { if (displayFields.rowLabels.hasOwnProperty(key)) results[resultKey][key] = fields[key].displayFunction(row[key], key); } results[resultKey].rows = []; }; results[resultKey].rows.push(row); }; }; function processColumnLabelResults(){ for (var key in displayFields.columnLabels) { if (displayFields.columnLabels.hasOwnProperty(key)) { var columnLabelColumns = {}; for (var resultKey in results) { var values = pluckValues(results[resultKey].rows, fields[key]); for (var value in values){ if (columnLabelColumns[value] === undefined) columnLabelColumns[value] = 1; else columnLabelColumns[value] += 1; results[resultKey][value] = getSummaryResults(values[value]); }; } populateColumnLabelColumnsResults(key, columnLabelColumns); }; }; return results; }; function pluckValues(rows, field){ var i = -1, m = rows.length, output = {}; while (++i < m){ var value = rows[i][field.name]; if (output[value] === undefined) output[value] = {rows: []} output[value].rows.push(rows[i]); }; return output; } function processSummaryResults(){ for (var resultKey in results) { getSummaryResults(results[resultKey]) }; return results; }; function getSummaryResults(result){ var output = {}; for (var key in displayFields.summaries) { if (displayFields.summaries.hasOwnProperty(key)) { result[key] = fields[key].summarizeFunction(result.rows, fields[key]); result[key] = fields[key].displayFunction(result[key], key); } }; return result; }; function getResultArray(){ var output = [], keys = objectKeys(results).sort(), i = -1, m = keys.length; while (++i < m){ output.push(results[keys[i]]) }; return output; }; function getColumnResults(){ if (results === undefined || resultsColumns === undefined) getFormattedResults(); return resultsColumns; } function populateSummaryColumnsResults(){ for (var key in displayFields.summaries){ if (displayFields.summaries.hasOwnProperty(key)) resultsColumns.push({fieldName: key, width: 1, type: 'summary'}) } return resultsColumns; }; function populateColumnLabelColumnsResults(key, columnLabels){ var keys = objectKeys(columnLabels).sort(fields[key].sortFunction), i = -1, m = keys.length, w = objectKeys(displayFields.summaries).length; while (++i < m){ resultsColumns.push({fieldName: keys[i], width: w, type: 'column'}) }; return resultsColumns; } // Entry Point return { init: init, reset: reset, config: config, utils: pivotUtils, csv: processCSV, json: processJSON, data: pivotData, results: pivotResults, fields: pivotFields, filters: pivotFilters, display: pivotDisplay } })();