data_nest.js

import { ascending, max, sum } from "d3-array";
import { nest } from "d3-collection";

import { datumDate } from "../util/date.js";

/**
 * A callback function that operates on a nested data layer datum object.
 *
 * @callback module:data~NestedDataOperatorFunction
 * @param {object} datum the datum object being operated on
 * @param {string} key the layer key the datum object is a member of
 * @param {object} [prevDatum] the previous datum object in the layer, if available
 * @returns {void}
 */

/**
 * Normalize the data arrays resulting from a `d3.nest` operation so that all group
 * value arrays have the same number of elements, based on a Date property named
 * `date`.
 *
 * The data values are assumed to be sorted by `date` already, and are modified in-place.
 * This makes the data suitable to passing to `d3.stack`, which expects all stack data
 * arrays to have the same number of values, for the same keys. When querying for data
 * in SolarNetwork there might be gaps in the results, so this function can be used to
 * "fill in" those gaps with "dummy" values so that there are no more gaps.
 *
 * Filled-in data objects are automatically populated with an appropriate `date` property
 * and a `sourceId` property taken from the `key` of the layer the gap if found in. You
 * can pass a `fillTemplate` object with static properties to also include on all filled-in
 * data objects. You can also pass a `fillFn` function to populate the filled-in objects
 * with dynamic data.
 *
 * For example, given:
 *
 * ```
 * const layerData = [
 *   { key : 'A', values : [{date : new Date('2011-12-02 12:00')}, {date : new Date('2011-12-02 12:10')}] },
 *   { key : 'B', values : [{date : new Date('2011-12-02 12:00')}] }
 * ];
 *
 * normalizeNestedStackDataByDate(layerData);
 * ```
 *
 * The `layerData` would be modified in-place and look like this (notice the filled in second data value in the **B** group):
 *
 * ```
 * [
 *   { key : 'A', values : [{date : new Date('2011-12-02 12:00')}, {date : new Date('2011-12-02 12:10')}] },
 *   { key : 'B', values : [{date : new Date('2011-12-02 12:00')}, {date : new Date('2011-12-02 12:10'), sourceId : 'B'}] }
 * ]
 * ```
 *
 * @param {object[]} layerData - An array of objects with `key` and `values` properties, as returned from `d3.nest().entries()`
 * @param {string} layerData.key - The layer's key value.
 * @param {object[]} layerData.values - The layer's value array.
 * @param {object} [fillTemplate] - An object to use as a template for any filled-in data objects.
 *                                  The `date` property will be populated automatically, and a `sourceId`
 *                                  property will be populated by the layer's `key`.
 * @param {module:data~NestedDataOperatorFunction} [fillFn] - An optional function to populate filled-in data objects with.
 *                                                            This function is invoked **after** populating any `fillTemplate` values.
 * @returns {void}
 * @alias module:data~normalizeNestedStackDataByDate
 */
export function normalizeNestedStackDataByDate(layerData, fillTemplate, fillFn) {
	var i = 0,
		j,
		k,
		jMax = layerData.length - 1,
		dummy,
		prop,
		copyIndex;
	// fill in "holes" for each stack, if more than one stack. we assume data already sorted by date
	if (jMax > 0) {
		while (
			i <
			max(
				layerData.map(function (e) {
					return e.values.length;
				}),
			)
		) {
			dummy = undefined;
			for (j = 0; j <= jMax; j++) {
				if (layerData[j].values.length <= i) {
					continue;
				}
				if (j < jMax) {
					k = j + 1;
				} else {
					k = 0;
				}
				if (
					layerData[k].values.length <= i ||
					layerData[j].values[i].date.getTime() < layerData[k].values[i].date.getTime()
				) {
					dummy = { date: layerData[j].values[i].date, sourceId: layerData[k].key };
					if (fillTemplate) {
						for (prop in fillTemplate) {
							if (Object.prototype.hasOwnProperty.call(fillTemplate, prop)) {
								dummy[prop] = fillTemplate[prop];
							}
						}
					}
					if (fillFn) {
						copyIndex = layerData[k].values.length > i ? i : i > 0 ? i - 1 : null;
						fillFn(
							dummy,
							layerData[k].key,
							copyIndex !== null ? layerData[k].values[copyIndex] : undefined,
						);
					}
					layerData[k].values.splice(i, 0, dummy);
				}
			}
			if (dummy === undefined) {
				i++;
			}
		}
	}
}

/**
 * Combine the layers resulting from a `d3.nest` operation into a single, aggregated
 * layer.
 *
 * This can be used to combine all sources of a single data type, for example
 * to show all "power" sources as a single layer of chart data. The resulting object
 * has the same structure as the input `layerData` parameter, with just a
 * single layer of data.
 *
 * For example:
 *
 * ```
 * const layerData = [
 *   { key : 'A', values : [{watts : 123, foo : 1}, {watts : 234, foo : 2}] },
 *   { key : 'B', values : [{watts : 345, foo : 3}, {watts : 456, foo : 4}] }
 * ];
 *
 * const result = aggregateNestedDataLayers(layerData,
 *     'A and B', ['foo'], ['watts'], {'combined' : true});
 * ```
 *
 * Then `result` would look like this:
 *
 * ```
 * [
 *   { key : 'A and B', values : [{watts : 468, foo : 1, combined : true},
 *                                {watts : 690, foo : 2, combined : true}] }
 * ]
 * ```
 *
 * @param {object[]} layerData - An array of objects with `key` and `values` properties, as returned from `d3.nest().entries()`
 * @param {string} layerData.key - The layer's key value.
 * @param {object[]} layerData.values - The layer's value array.
 * @param {string} resultKey - The `key` property to assign to the returned layer.
 * @param {string[]} copyProperties - An array of string property names to copy as-is from the **first** layer's data values.
 * @param {string[]} sumProperties - An array of string property names to add together from **all** layer data.
 * @param {object} staticProperties - Static properties to copy as-is to **all** output data values.
 * @return {object[]} An array of objects with `key` and `value` properties, the same structure as the provided `layerData` argument
 * @alias module:data~aggregateNestedDataLayers
 */
export function aggregateNestedDataLayers(
	layerData,
	resultKey,
	copyProperties,
	sumProperties,
	staticProperties,
) {
	// combine all layers into a single source
	var layerCount = layerData.length,
		dataLength,
		i,
		j,
		k,
		copyPropLength = copyProperties ? copyProperties.length : 0,
		sumPropLength = sumProperties ? sumProperties.length : 0,
		d,
		val,
		clone,
		array;

	dataLength = layerData[0].values.length;
	if (dataLength > 0) {
		array = [];
		for (i = 0; i < dataLength; i += 1) {
			d = layerData[0].values[i];
			clone = {};
			if (staticProperties !== undefined) {
				for (val in staticProperties) {
					if (Object.prototype.hasOwnProperty.call(staticProperties, val)) {
						clone[val] = staticProperties[val];
					}
				}
			}
			for (k = 0; k < copyPropLength; k += 1) {
				clone[copyProperties[k]] = d[copyProperties[k]];
			}
			for (k = 0; k < sumPropLength; k += 1) {
				clone[sumProperties[k]] = 0;
			}
			for (j = 0; j < layerCount; j += 1) {
				for (k = 0; k < sumPropLength; k += 1) {
					val = layerData[j].values[i][sumProperties[k]];
					if (val !== undefined) {
						clone[sumProperties[k]] += val;
					}
				}
			}
			array.push(clone);
		}
		layerData = [{ key: resultKey, values: array }];
	}

	return layerData;
}

/**
 * Transform raw SolarNetwork timeseries data by combining datum from multiple sources into a single
 * data per time key.
 *
 * This method produces a single array of objects with metric properties derived by grouping
 * that property within a single time slot across one or more source IDs. Conceptually this is
 * similar to {@link module:data~aggregateNestedDataLayers} except groups of source IDs can be
 * produced in the final result.
 *
 * The data will be passed through {@link module:data~normalizeNestedStackDataByDate} so that every
 * result object will contain every configured output group, but missing data will result in a
 * `null` value.
 *
 * Here's an example where two sources `A` and `B` are combined into a single group `Generation`
 * and a third source `C` is passed through as another group `Consumption`:
 *
 * ```
 * const queryData = [
 *     {localDate: '2018-05-05', localTime: '11:00', sourceId: 'A', watts : 123},
 *     {localDate: '2018-05-05', localTime: '11:00', sourceId: 'B', watts : 234},
 *     {localDate: '2018-05-05', localTime: '11:00', sourceId: 'C', watts : 345},
 *     {localDate: '2018-05-05', localTime: '12:00', sourceId: 'A', watts : 456},
 *     {localDate: '2018-05-05', localTime: '12:00', sourceId: 'B', watts : 567},
 *     {localDate: '2018-05-05', localTime: '12:00', sourceId: 'C', watts : 678},
 * ];
 * const sourceMap = new Map([
 *     ['A', 'Generation'],
 *     ['B', 'Generation'],
 *     ['C', 'Consumption'],
 * ]);
 *
 * const result = groupedBySourceMetricDataArray(queryData, 'watts', sourceMap);
 * ```
 *
 * Then `result` would look like this:
 *
 * ```
 * [
 *     {date : new Date('2018-05-05T11:00Z'), Generation : 357, Consumption: 345},
 *     {date : new Date('2018-05-05T12:00Z'), Generation : 1023, Consumption: 678}
 * ]
 * ```
 *
 * @param {object[]} data the raw data returned from SolarNetwork
 * @param {string} metricName the datum property name to extract
 * @param {Map} [sourceIdMap] an optional mapping of input source IDs to output source IDs; this can be used
 *                            to control the grouping of data, by mapping multiple input source IDs to the same
 *                            output source ID
 * @param {function} [aggFn] an optional aggregate function to apply to the metric values;
 *                           defaults to `d3.sum`; **note** that the function will be passed an array of input
 *                           data objects, not metric values
 * @returns {object[]} array of datum objects, each with a date and one metric value per source ID
 * @alias module:data~groupedBySourceMetricDataArray
 */
export function groupedBySourceMetricDataArray(data, metricName, sourceIdMap, aggFn) {
	const metricExtractorFn = function metricExtractor(d) {
		return d[metricName];
	};
	const rollupFn = typeof aggFn === "function" ? aggFn : sum;
	const layerData = nest()
		// group first by source
		.key((d) => {
			return sourceIdMap && sourceIdMap.has(d.sourceId)
				? sourceIdMap.get(d.sourceId)
				: d.sourceId;
		})
		.sortKeys(ascending)
		// group second by date
		.key((d) => {
			return d.localDate + " " + d.localTime;
		})
		// sum desired property in date group
		.rollup((values) => {
			const r = {
				date: datumDate(values[0]),
			};
			let metricKey = values[0].sourceId;
			if (sourceIdMap && sourceIdMap.has(metricKey)) {
				metricKey = sourceIdMap.get(metricKey);
			}
			r[metricKey] = rollupFn(values, metricExtractorFn);
			return r;
		})
		// un-nest to single group by source
		.entries(data)
		.map(function (layer) {
			return {
				key: layer.key,
				values: layer.values.map(function (d) {
					return d.value;
				}),
			};
		});

	// ensure all layers have the same time keys
	normalizeNestedStackDataByDate(layerData, null, (d, key) => {
		// make sure filled-in data has the metric property defined
		d[key] = null;
	});

	// reduce to single array with multiple metric properties
	return layerData.reduce(function (combined, layer) {
		if (!combined) {
			return layer.values;
		}
		combined.forEach(function (d, i) {
			const v = layer.values[i][layer.key];
			d[layer.key] = v;
		});
		return combined;
	}, null);
}

export default Object.freeze({
	aggregateNestedDataLayers: aggregateNestedDataLayers,
	groupedBySourceMetricDataArray: groupedBySourceMetricDataArray,
	normalizeNestedStackDataByDate: normalizeNestedStackDataByDate,
});