Skip to content
Snippets Groups Projects
drawing-data.js 12.93 KiB
import {set} from 'd3-collection';
import memoize from 'fast-memoize';
import find from 'lodash/find';
import {DateTime} from 'luxon';
import {createSelector} from 'reselect';
import {format} from 'd3-format';

import {getCurrentVariableMedianStatistics} from '../../selectors/median-statistics-selector';
import {getVariables, getCurrentMethodID, getTimeSeries, getCurrentVariableTimeSeries, getTimeSeriesForTsKey,
    getTsRequestKey, getRequestTimeRange} from '../../selectors/time-series-selector';
import {getIanaTimeZone} from '../../selectors/time-zone-selector';


export const MASK_DESC = {
    ice: 'Ice Affected',
    fld: 'Flood',
    bkw: 'Backwater',
    zfl: 'Zeroflow',
    dry: 'Dry',
    ssn: 'Seasonal',
    pr: 'Partial Record',
    rat: 'Rating Development',
    eqp: 'Equipment Malfunction',
    mnt: 'Maintenance',
    dis: 'Discontinued',
    tst: 'Test',
    pmp: 'Pump',
    '***': 'Unavailable'
};

export const HASH_ID = {
    current: 'hash-45',
    compare: 'hash-135'
};

// Lines will be split if the difference exceeds 72 minutes.
export const MAX_LINE_POINT_GAP = 60 * 1000 * 72;

const PARM_CODES_TO_ACCUMULATE = ['00045'];

const toNumberString = format('.2f');


/*
 * @param {Array} points - Array of point objects
 * @return {Array} - Returns the array of points accumulated. If a null value is found,
 * the accumulator is set back to zero.
 */
const transformToCumulative = function(points) {
    let accumulatedValue = 0;
    return points.map((point) => {
        let result = {...point};
        if (point.value !== null) {
            accumulatedValue += point.value;
            result.value = parseFloat(toNumberString(accumulatedValue));
        } else {
            accumulatedValue = 0;
        }
        return result;
    });
};

/* Factory function that returns a function that returns an object where the properties are ts IDs and the values
 * are array of point objects that can be used to render a time series graph.
 * @param {Object} state
 * @return {Object} where the keys are ts ids and the values are an Array of point Objects.
 */
export const allPointsSelector = createSelector(
    getTimeSeries,
    getVariables,
    (timeSeries, variables) => {
        let allPoints = {};
        Object.keys(timeSeries).forEach((tsId) => {
            const ts = timeSeries[tsId];
            const variableId = ts.variable;
            const parmCd = variables[variableId].variableCode.value;
            if (PARM_CODES_TO_ACCUMULATE.includes(parmCd)) {
                allPoints[tsId] = transformToCumulative(ts.points);
            } else {
                allPoints[tsId] = ts.points;
            }
        });
        return allPoints;
    }
);

/* Factory function that for a given tsKey returns an object with keys that are the tsID and values an array of point objects
 * @param {Object} state
 * @param {String} tsKey
 * @return {Object} of keys are tsId, values are Array of point Objects
 */
export const pointsByTsKeySelector = memoize((tsKey, period) => createSelector(
    getTsRequestKey(tsKey, period),
    allPointsSelector,
    getTimeSeries,
    (tsRequestKey, points, timeSeries) => {
        let result = {};
        Object.keys(points).forEach((tsId) => {
            if (timeSeries[tsId].tsKey === tsRequestKey) {
                result[tsId] = points[tsId];
            }
        });
        return result;
    }));

/* Returns a select that returns all time series points for the current variable and in the select series, tsKey
 * by tsId.
 * @param {Object} state
 * @param {String} tsKey
 * @return Object
 */
export const currentVariablePointsByTsIdSelector = memoize(tsKey => createSelector(
    pointsByTsKeySelector(tsKey),
    getCurrentVariableTimeSeries(tsKey),
    (points, timeSeries) => {
        let result = {};
        if (points) {
            result = Object.keys(timeSeries).reduce((data, tsId) => {
                data[tsId] = points[tsId];
                return data;
            }, {});
        }
        return result;
    }
));

/* Returns a selector that returns all time series points for the current variable and in the selected series, tsKey.
 * @param {Object} state
 * @param {String} tsKey
 * @return Array of Array of points
 */
export const currentVariablePointsSelector = memoize(tsKey => createSelector(
    pointsByTsKeySelector(tsKey),
    getCurrentVariableTimeSeries(tsKey),
    (points, timeSeries) => {
        return timeSeries ? Object.keys(timeSeries).map((tsId) => points[tsId]) : [];
    }
));

/**
 * Returns a selector that, for a given tsKey:
 * Returns an array of time points for all time series.
 * @param  {Object} state     Redux store
 * @param  {String} tsKey     Time series key
 * @return {Array}            Array of array of points.
 */
export const pointsSelector = memoize((tsKey) => createSelector(
    pointsByTsKeySelector(tsKey),
    (points) => {
        return Object.values(points);
    }
));

/*
 * Returns an object which identifies which classes to use for the point
 * @param {Object} point
 * @return {Object}
 */
export const classesForPoint = point => {
    return {
        approved: point.qualifiers.indexOf('A') > -1,
        estimated: point.qualifiers.indexOf('e') > -1 || point.qualifiers.indexOf('E') > -1
    };
};

/*
 * @ return {Array of Arrays of Objects} where the properties are date (universal), class,  and value
*/
export const getCurrentVariableMedianStatPoints = createSelector(
    getCurrentVariableMedianStatistics,
    getRequestTimeRange('current'),
    getIanaTimeZone,
    (stats, timeRange, ianaTimeZone) => {
        if (!stats || !timeRange) {
            return [];
        }

        // From the time range and time zone, determine the dates that we need to create the points arrays.
        // Note that the first and last dates should match the time range's start and end time
        let datesOfInterest = [];
        let nextDateTime = DateTime.fromMillis(timeRange.start, {zone: ianaTimeZone});
        datesOfInterest.push({
            year: nextDateTime.year,
            month: nextDateTime.month.toString(),
            day: nextDateTime.day.toString(),
            utcDate: timeRange.start
        });
        nextDateTime = nextDateTime.startOf('day').plus({days: 1});
        while (nextDateTime.valueOf() <= timeRange.end) {
            datesOfInterest.push({
                year: nextDateTime.year,
                month: nextDateTime.month.toString(),
                day: nextDateTime.day.toString(),
                utcDate: nextDateTime.toMillis()
            });
            nextDateTime = nextDateTime.plus({days: 1});
        }
        nextDateTime = DateTime.fromMillis(timeRange.end, {zone: ianaTimeZone});
        datesOfInterest.push({
            year: nextDateTime.year,
            month: nextDateTime.month.toString(),
            day: nextDateTime.day.toString(),
            utcDate: timeRange.end
        });

        // Retrieve the median data matching the datesOfInterest and then create points arrays suitable for plotting.
        return Object.values(stats).map((seriesStats) => {
            return datesOfInterest
                .map((date) => {
                    let stat = find(seriesStats, {'month_nu': date.month, 'day_nu': date.day});
                    return {
                        value: stat && stat.p50_va ? parseFloat(stat.p50_va) : null,
                        date: date.utcDate
                    };
                })
                .filter((point) => {
                    return point.value !== null;
                });
        });
    });


/**
 * Factory function create a function that
 * returns an array of points for each visible time series.
 * @param  {Object} state     Redux store
 * @return {Array}            Array of point arrays.
 */
export const visiblePointsSelector = createSelector(
    currentVariablePointsSelector('current'),
    currentVariablePointsSelector('compare'),
    getCurrentVariableMedianStatPoints,
    (state) => state.ivTimeSeriesState.showIVTimeSeries,
    (current, compare, median, showSeries) => {
        const pointArray = [];
        if (showSeries['current']) {
            Array.prototype.push.apply(pointArray, current);

        }
        if (showSeries['compare']) {
            Array.prototype.push.apply(pointArray, compare);
        }
        if (showSeries['median']) {
            Array.prototype.push.apply(pointArray, Object.keys(median).map(tsId => median[tsId] ?  median[tsId] : []));
        }
        return pointArray;
    }
);


const getLineClasses = function(pt, isCurrentMethod) {
    let dataMask = null;
    if (pt.value === null) {
        let qualifiers = set(pt.qualifiers.map(q => q.toLowerCase()));

        // current business rules specify that a particular data point
        // will only have at most one masking qualifier
        let maskIntersection = Object.keys(MASK_DESC).filter(x => qualifiers.has(x));
        dataMask = maskIntersection[0];
    }
    return {
        ...classesForPoint(pt),
        currentMethod: isCurrentMethod,
        dataMask
    };
};


/**
 * Factory function creates a function that:
 * Returns all points in a time series grouped into line segments, for each time series.
 * @param  {Object} state     Redux store
 * @param  {String} tsKey Time series key
 * @return {Object}  Keys are ts Ids, values are  of array of line segments.
 */
export const lineSegmentsSelector = memoize((tsKey, period) => createSelector(
    pointsByTsKeySelector(tsKey, period),
    getCurrentMethodID,
    (tsPoints, currentMethodID) => {
        console.log('In lineSegementsSelector current method id is ' + currentMethodID);
        let seriesLines = {};
        Object.keys(tsPoints).forEach((tsId) => {
            const methodID = tsId.split(':')[0];
            if (tsPoints[tsId].length) {
                console.log('Drawing points for methodID ' + methodID);
            }
            const points = tsPoints[tsId];
            let lines = [];

            // Accumulate data into line groups, splitting on the estimated and
            // approval status.
            let lastClasses = {};

            for (let pt of points) {
                // Classes to put on the line with this point.
                let lineClasses = getLineClasses(pt, !currentMethodID || currentMethodID === parseInt(methodID));

                // If this is a non-masked data point, split lines if the gap
                // from the period point exceeds MAX_LINE_POINT_GAP.
                let splitOnGap = false;
                if (!lineClasses.dataMask && lines.length > 0) {
                    const lastPoints = lines[lines.length - 1].points;
                    const lastPtDateTime = lastPoints[lastPoints.length - 1].dateTime;
                    if (pt.dateTime - lastPtDateTime > MAX_LINE_POINT_GAP) {
                        splitOnGap = true;
                    }
                }

                // If this point doesn't have the same classes as the last point,
                // create a new line for it.
                if (lastClasses.approved !== lineClasses.approved ||
                    lastClasses.estimated !== lineClasses.estimated ||
                    lastClasses.currentMethod !== lineClasses.currentMethod ||
                    lastClasses.dataMask !== lineClasses.dataMask ||
                    splitOnGap) {
                    lines.push({
                        classes: lineClasses,
                        points: []
                    });
                }

                // Add this point to the current line.
                lines[lines.length - 1].points.push(pt);

                // Cache the classes for the next loop iteration.
                lastClasses = lineClasses;
            }
            seriesLines[tsId] = lines;
        });
        return seriesLines;
    }
));


/**
 * Factory function creates a function that, for a given tsKey:
 * @return {Object} - Mapping of parameter code Array of line segments.
 */
export const lineSegmentsByParmCdSelector = memoize((tsKey, period) => createSelector(
    lineSegmentsSelector(tsKey, period),
    getTimeSeriesForTsKey(tsKey, period),
    getVariables,
    (lineSegmentsBySeriesID, timeSeriesMap, variables) => {
        return Object.keys(lineSegmentsBySeriesID).reduce((byVarID, sID) => {
            const series = timeSeriesMap[sID];
            const parmCd = variables[series.variable].variableCode.value;
            byVarID[parmCd] = byVarID[parmCd] || [];
            byVarID[parmCd].push(lineSegmentsBySeriesID[sID]);
            return byVarID;
        }, {});
    }
));


/**
 * Factory function creates a function that, for a given tsKey:
 * Returns mapping of series ID to line segments for the currently selected variable.
 * @return {Object} - Keys are time series ids and values are the line segment arrays
 */
export const currentVariableLineSegmentsSelector = memoize(tsKey => createSelector(
    getCurrentVariableTimeSeries(tsKey),
    lineSegmentsSelector(tsKey),
    (seriesMap, linesMap) => {
        return Object.keys(seriesMap).reduce((visMap, sID) => {
                visMap[sID] = linesMap[sID];
                return visMap;
            }, {});

    }
));