dataTransform.js

import * as d3 from "d3";
import * as d3Curve from "d3-interpolate-curve";

/**
 * Data Transform
 *
 * @module
 * @returns {Object}
 */
export default function dataTransform(data) {

	const SINGLE_SERIES = 1;
	const MULTI_SERIES = 2;
	const coordinates = ["x", "y", "z"];

	/**
	 * Data Type (Single or Multi Series)
	 *
	 * @param data
	 */
	const dataType = function(data) {
		return data.key !== undefined ? SINGLE_SERIES : MULTI_SERIES;
	};

	/************* HELPER FUNCTIONS *******************/

	/**
	 * Union Two Arrays
	 *
	 * @private
	 * @param {Array} array1 - First Array.
	 * @param {Array} array2 - Second Array.
	 * @returns {Array}
	 */
	const union = function(array1, array2) {
		const ret = [];
		let arr;

		if (array1.length > array2.length) {
			arr = array2.concat(array1);
		} else {
			arr = array1.concat(array2);
		}

		let len = arr.length;
		const assoc = {};

		while (len--) {
			const item = arr[len];

			if (!assoc[item]) {
				ret.unshift(item);
				assoc[item] = true;
			}
		}

		return ret;
	};

	/**
	 * How Many Decimal Places?
	 *
	 * @private
	 * @param {number} num - Float Number.
	 * @returns {number}
	 */
	const decimalPlaces = function(num) {
		const match = ("" + num).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
		if (!match) {
			return 0;
		}

		return Math.max(
			0,
			// Number of digits right of decimal point.
			(match[1] ? match[1].length : 0)
			// Adjust for scientific notation.
			-
			(match[2] ? +match[2] : 0)
		);
	};

	/************* SINGLE SERIES FUNCTIONS ************/

	/**
	 * Row Key (Single Series)
	 *
	 * @returns {Array}
	 */
	const singleRowKey = function(data) {
		return Object.values(data)[0];
	};

	/**
	 * Row Total (Single Series)
	 *
	 * @returns {number}
	 */
	const singleRowTotal = function(data) {
		return d3.sum(data.values, (d) => d.value);
	};

	/**
	 * Row Value Keys (Single Series)
	 *
	 * @returns {Array}
	 */
	const singleRowValueKeys = function(data) {
		return data.values.length ? Object.keys(data.values[0]) : [];
	};

	/**
	 * Column Keys (Single Series)
	 *
	 * @returns {Array}
	 */
	const singleColumnKeys = function(data) {
		return Object.values(data.values).map((d) => d.key);
	};

	/**
	 * Value Min (Single Series)
	 *
	 * @returns {number}
	 */
	const singleValueMin = function(data) {
		return d3.min(data.values, (d) => +d.value);
	};

	/**
	 * Value Max (Single Series)
	 *
	 * @returns {number}
	 */
	const singleValueMax = function(data) {
		return d3.max(data.values, (d) => +d.value);
	};

	/**
	 * Value Extent (Single Series)
	 *
	 * @returns {Array}
	 */
	const singleValueExtent = function(data) {
		return d3.extent(data.values, (d) => +d.value);
	};

	/**
	 * Coordinates Min (Single Series)
	 *
	 * @returns {Object}
	 */
	const singleCoordinatesMin = function(data) {
		return coordinates.reduce((maximums, coord) => {
			maximums[coord] = d3.min(data.values, (d) => +d[coord]);
			return maximums;
		}, {});
	};

	/**
	 * Coordinates Max (Single Series)
	 *
	 * @returns {Object}
	 */
	const singleCoordinatesMax = function(data) {
		return coordinates.reduce((maximums, coord) => {
			maximums[coord] = d3.max(data.values, (d) => +d[coord]);
			return maximums;
		}, {});
	};

	/**
	 * Coordinates Extent (Single Series)
	 *
	 * @returns {Object}
	 */
	const singleCoordinatesExtent = function(data) {
		return coordinates.reduce((extents, coord) => {
			extents[coord] = d3.extent(data.values, (d) => +d[coord]);
			return extents;
		}, {});
	};

	/**
	 * Thresholds (Single Series)
	 *
	 * @returns {Array}
	 */
	const singleThresholds = function(data) {
		const bands = [0.15, 0.40, 0.55, 0.90];
		const min = singleValueMin(data);
		const max = singleValueMax(data);
		const distance = max - min;

		return bands.map((v) => Number((min + (v * distance)).toFixed(singleMaxDecimalPlace(data))));
	};

	/**
	 * Max Decimal Place (Single Series)
	 *
	 * @returns {number}
	 */
	const singleMaxDecimalPlace = function(data) {
		return data.values.reduce((places, d) => {
			places = d3.max([places, decimalPlaces(d.value)]);

			// toFixed must be between 0 and 20
			return places > 20 ? 20 : places;
		}, 0);
	};

	/**
	 * Single Series Summary
	 *
	 * @returns {Object}
	 */
	const singleSummary = function(data) {
		return {
			dataType: dataType(data),
			rowKey: singleRowKey(data),
			rowTotal: singleRowTotal(data),
			columnKeys: singleColumnKeys(data),
			valueMin: singleValueMin(data),
			valueMax: singleValueMax(data),
			valueExtent: singleValueExtent(data),
			coordinatesMin: singleCoordinatesMin(data),
			coordinatesMax: singleCoordinatesMax(data),
			coordinatesExtent: singleCoordinatesExtent(data),
			maxDecimalPlace: singleMaxDecimalPlace(data),
			thresholds: singleThresholds(data),
			rowValuesKeys: singleRowValueKeys(data)
		}
	};

	/************* MULTI SERIES FUNCTIONS *************/

	/**
	 * Row Keys (Multi Series)
	 *
	 * @returns {Array}
	 */
	const multiRowKeys = function(data) {
		return data.map((d) => d.key);
	};

	/**
	 * Row Totals (Multi Series)
	 *
	 * @returns {Object}
	 */
	const multiRowTotals = function(data) {
		return data.reduce((totals, row) => {
			totals[row.key] = singleRowTotal(row);
			return totals;
		}, {});
	};

	/**
	 * Row Totals Max (Multi Series)
	 *
	 * @returns {number}
	 */
	const multiRowTotalsMax = function(data) {
		return d3.max(Object.values(multiRowTotals(data)));
	};

	/**
	 * Row Value Keys (Multi Series)
	 *
	 * @returns {Array}
	 */
	const multiRowValueKeys = function(data) {
		return data.length ? Object.keys(data[0].values[0]) : [];
	};

	/**
	 * Column Keys (Multi Series)
	 *
	 * @returns {Array}
	 */
	const multiColumnKeys = function(data) {
		return data.reduce((keys, row) => {
			const tmp = [];
			row.values.forEach((d, i) => {
				tmp[i] = d.key;
			});

			keys = union(keys, tmp);

			return keys;
		}, []);
	};

	/**
	 * Column Totals (Multi Series)
	 *
	 * @returns {Object}
	 */
	const multiColumnTotals = function(data) {
		return data.reduce((totals, row) => {
			row.values.forEach((d) => {
				const columnName = d.key;
				totals[columnName] = (typeof totals[columnName] === "undefined" ? 0 : totals[columnName]);
				totals[columnName] += d.value;
			});

			return totals;
		}, {});
	};

	/**
	 * Column Totals Max (Multi Series)
	 *
	 * @returns {number}
	 */
	const multiColumnTotalsMax = function(data) {
		return d3.max(Object.values(multiColumnTotals(data)));
	};

	/**
	 * Value Min (Multi Series)
	 *
	 * @returns {number}
	 */
	const multiValueMin = function(data) {
		return d3.min(data.map((row) => singleValueMin(row)));
	};

	/**
	 * Value Max (Multi Series)
	 *
	 * @returns {number}
	 */
	const multiValueMax = function(data) {
		return d3.max(data.map((row) => singleValueMax(row)));
	};

	/**
	 * Value Extent (Multi Series)
	 *
	 * @returns {Array}
	 */
	const multiValueExtent = function(data) {
		return [multiValueMin(data), multiValueMax(data)];
	};

	/**
	 * Coordinates Min (Multi Series)
	 *
	 * @returns {Object}
	 */
	const multiCoordinatesMin = function(data) {
		return data.map((row) => singleCoordinatesMin(row)).reduce((minimums, row) => {
			coordinates.forEach((coord) => {
				minimums[coord] = (coord in minimums ? d3.min([minimums[coord], +row[coord]]) : row[coord]);
			});

			return minimums;
		}, {});
	};

	/**
	 * Coordinates Max (Multi Series)
	 *
	 * @returns {Object}
	 */
	const multiCoordinatesMax = function(data) {
		return data.map((row) => singleCoordinatesMax(row)).reduce((maximums, row) => {
			coordinates.forEach((coord) => {
				maximums[coord] = (coord in maximums ? d3.max([maximums[coord], +row[coord]]) : row[coord]);
			});

			return maximums;
		}, {});
	};

	/**
	 * Coordinates Extent (Multi Series)
	 *
	 * @returns {Object}
	 */
	const multiCoordinatesExtent = function(data) {
		return coordinates.reduce((extents, coord) => {
			extents[coord] = [multiCoordinatesMin(data)[coord], multiCoordinatesMax(data)[coord]];

			return extents;
		}, {});
	};

	/**
	 * Thresholds (Multi Series)
	 *
	 * @returns {Array}
	 */
	const multiThresholds = function(data) {
		const bands = [0.15, 0.40, 0.55, 0.90];
		const min = multiValueMin(data);
		const max = multiValueMax(data);
		const distance = max - min;

		return bands.map((v) => Number((min + (v * distance)).toFixed(multiMaxDecimalPlace(data))));
	};

	/**
	 * Max Decimal Place (Multi Series)
	 *
	 * @returns {number}
	 */
	const multiMaxDecimalPlace = function(data) {
		return d3.max(data.map((d) => singleMaxDecimalPlace(d)));
	};

	/**
	 * Multi Series Summary
	 *
	 * @returns {Object}
	 */
	const multiSummary = function(data) {
		return {
			dataType: dataType(data),
			rowKeys: multiRowKeys(data),
			rowTotals: multiRowTotals(data),
			rowTotalsMax: multiRowTotalsMax(data),
			columnKeys: multiColumnKeys(data),
			columnTotals: multiColumnTotals(data),
			columnTotalsMax: multiColumnTotalsMax(data),
			valueMin: multiValueMin(data),
			valueMax: multiValueMax(data),
			valueExtent: multiValueExtent(data),
			coordinatesMin: multiCoordinatesMin(data),
			coordinatesMax: multiCoordinatesMax(data),
			coordinatesExtent: multiCoordinatesExtent(data),
			maxDecimalPlace: multiMaxDecimalPlace(data),
			thresholds: multiThresholds(data),
			rowValuesKeys: multiRowValueKeys(data)
		}
	};

	/************* MAIN FUNCTIONS **********************/

	/**
	 * Summary
	 *
	 * @returns {Object}
	 */
	const summary = function() {
		if (dataType(data) === SINGLE_SERIES) {
			return singleSummary(data);
		} else {
			return multiSummary(data);
		}
	};

	/**
	 * Stack Data (for Stacked Bar Chart, Donut Chart)
	 *
	 * @returns {Array}
	 */
	const stack = function() {
		const values = [];
		let y0 = 0;
		let y1 = 0;
		data.values.forEach((d, i) => {
			y1 = y0 + d.value;
			values[i] = {
				key: d.key,
				value: d.value,
				y0: y0,
				y1: y1
			};
			y0 += d.value;
		});

		return {
			key: data.key,
			values: values
		};
	};

	/**
	 * Rotate Data
	 *
	 * @returns {Array}
	 */
	const rotate = function() {
		const columnKeys = data.map((d) => d.key);
		const rowKeys = data[0].values.map((d) => d.key);

		const rotated = rowKeys.map((rowKey, rowIndex) => {
			const values = columnKeys.map((columnKey, columnIndex) => {
				// Copy the values from the original object
				const values = Object.assign({}, data[columnIndex].values[rowIndex]);
				// Swap the key over
				values.key = columnKey;

				return values;
			});

			return {
				key: rowKey,
				values: values
			};
		});

		return rotated;
	};

	/**
	 * Smooth Data
	 *
	 * Returns a copy of the input data series which is subsampled into a 100 samples,
	 * and has the smoothed values based on a provided d3.curve function.
	 *
	 * @param curveFunction
	 * @returns {{values: *, key: *}}
	 */
	const smooth = function(curveFunction, samples = 100) {
		const epsilon = 0.00001;

		const values = data.values.map((d) => d.value);

		const sampler = d3.range(0, 1, 1 / samples);
		const keyPolator = (t) => (Number((t * samples).toFixed(0)) + 1);

		const valuePolator = d3Curve.interpolateFromCurve(values, curveFunction, epsilon, samples);

		const smoothed = {
			key: data.key,
			values: sampler.map((t) => ({
				key: keyPolator(t),
				value: valuePolator(t)
			}))
		};

		return smoothed;
	};

	return {
		summary: summary,
		rotate: rotate,
		stacked: stack,
		smooth: smooth
	};
}