Skip to content
Snippets Groups Projects
index.js 18.7 KiB
Newer Older
/**
 * Hydrograph charting module.
 */
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
const { extent } = require('d3-array');
const { line: d3Line, curveStepAfter } = require('d3-shape');
const { select } = require('d3-selection');

const { createStructuredSelector } = require('reselect');

const { addSVGAccessibility } = require('../../accessibility');
const { USWDS_SMALL_SCREEN, STATIC_URL } = require('../../config');
const { dispatch, link, provide } = require('../../lib/redux');
const { Actions } = require('../../store');
const { mediaQuery } = require('../../utils');

const { audibleUI } = require('./audible');
const { appendAxes, axesSelector } = require('./axes');
const { cursorSlider } = require('./cursor');
const {lineSegmentsByParmCdSelector, currentVariableLineSegmentsSelector, MASK_DESC, HASH_ID
} = require('./drawingData');
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
const { CIRCLE_RADIUS_SINGLE_PT, SPARK_LINE_DIM, layoutSelector } = require('./layout');
const { drawSimpleLegend, legendMarkerRowsSelector } = require('./legend');
const { plotSeriesSelectTable, availableTimeSeriesSelector } = require('./parameters');
const { xScaleSelector, yScaleSelector, timeSeriesScalesByParmCdSelector } = require('./scales');
const { allTimeSeriesSelector, isVisibleSelector, titleSelector, descriptionSelector,
    currentVariableTimeSeriesSelector, hasTimeSeriesWithPoints } = require('./timeSeries');
const { createTooltipFocus, createTooltipText } = require('./tooltip');
const { coerceStatisticalSeries } = require('./statistics');

const { getCurrentDateRange, getTimeSeriesCollectionIds, isLoadingTS } = require('../../selectors/timeSeriesSelector');
const drawMessage = function (elem, message) {
    // Set up parent element and SVG
    elem.innerHTML = '';
    const alertBox = elem
        .append('div')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('class', 'usa-alert usa-alert-warning')
            .append('div')
                .attr('class', 'usa-alert-body');
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('class', 'usa-alert-heading')
            .html('Hydrograph Alert');
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .html(message);
const plotDataLine = function (elem, {visible, lines, tsKey, xScale, yScale}) {
Yan, Andrew N.'s avatar
Yan, Andrew N. committed
        if (line.classes.dataMask === null) {
            // If this is a single point line, then represent it as a circle.
            // Otherwise, render as a line.
            if (line.points.length === 1) {
                elem.append('circle')
                    .data(line.points)
                    .classed('line-segment', true)
                    .classed('approved', line.classes.approved)
                    .classed('estimated', line.classes.estimated)
                    .attr('r', CIRCLE_RADIUS_SINGLE_PT)
                    .attr('cx', d => xScale(d.dateTime))
                    .attr('cy', d => yScale(d.value));
            } else {
                const tsLine = d3Line()
                    .x(d => xScale(d.dateTime))
                    .y(d => yScale(d.value));
                elem.append('path')
                    .datum(line.points)
                    .classed('line-segment', true)
                    .classed('approved', line.classes.approved)
                    .classed('estimated', line.classes.estimated)
                    .classed(`ts-${tsKey}`, true)
                    .attr('d', tsLine);
            }
            const maskCode = line.classes.dataMask.toLowerCase();
            const maskDisplayName = MASK_DESC[maskCode].replace(' ', '-').toLowerCase();
            const [xDomainStart, xDomainEnd] = extent(line.points, d => d.dateTime);
            const [yRangeStart, yRangeEnd] = yScale.domain();
            let maskGroup = elem.append('g')
                .attr('class', `${tsKey}-mask-group`);
            const xSpan = xScale(xDomainEnd) - xScale(xDomainStart);
            maskGroup.append('rect')
                .attr('x', xScale(xDomainStart))
                .attr('y', yScale(yRangeEnd))
                .attr('width', rectWidth)
                .attr('height', Math.abs(yScale(yRangeEnd) - yScale(yRangeStart)))
                .attr('class', `mask ${maskDisplayName}-mask`);

            const patternId = HASH_ID[tsKey] ? `url(#${HASH_ID[tsKey]})` : '';
            maskGroup.append('rect')
                .attr('x', xScale(xDomainStart))
                .attr('y', yScale(yRangeEnd))
                .attr('width', rectWidth)
                .attr('height', Math.abs(yScale(yRangeEnd) - yScale(yRangeStart)))
const plotDataLines = function (elem, {visible, tsLinesMap, tsKey, xScale, yScale}, container) {
    container = container || elem.append('g');
    const elemId = `ts-${tsKey}-group`;
    container.selectAll(`#${elemId}`).remove();
    const tsLineGroup = container
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('id', elemId)
            .classed('tsKey', true);
    for (const lines of Object.values(tsLinesMap)) {
        plotDataLine(tsLineGroup, {visible, lines, tsKey, xScale, yScale});
const plotSvgDefs = function (elem) {

    let defs = elem.append('defs');

    defs.append('mask')
        .attr('id', 'display-mask')
        .attr('maskUnits', 'userSpaceOnUse')
        .append('rect')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('x', '0')
            .attr('y', '0')
            .attr('width', '100%')
            .attr('height', '100%')
            .attr('fill', '#0000ff');

    defs.append('pattern')
        .attr('id', HASH_ID.current)
        .attr('width', '8')
        .attr('height', '8')
        .attr('patternUnits', 'userSpaceOnUse')
        .attr('patternTransform', 'rotate(45)')
        .append('rect')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('width', '4')
            .attr('height', '8')
            .attr('transform', 'translate(0, 0)')
            .attr('mask', 'url(#display-mask)');

    defs.append('pattern')
        .attr('id', HASH_ID.compare)
        .attr('width', '8')
        .attr('height', '8')
        .attr('patternUnits', 'userSpaceOnUse')
        .attr('patternTransform', 'rotate(135)')
        .append('rect')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('width', '4')
            .attr('height', '8')
            .attr('transform', 'translate(0, 0)')
            .attr('mask', 'url(#display-mask)');
const timeSeriesLegend = function (elem) {
        .classed('hydrograph-container', true)
        .call(link(drawSimpleLegend, createStructuredSelector({
            legendMarkerRows: legendMarkerRowsSelector,
            layout: layoutSelector
        })));
/**
 * Plots the median points for a single median time series.
 * @param  {Object} elem
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
 * @param  {Function} xscale
 * @param  {Function} yscale
 * @param  {Number} modulo
 * @param  {Array} points
const plotMedianPoints = function (elem, {xscale, yscale, modulo, points}) {
Yan, Andrew N.'s avatar
Yan, Andrew N. committed
    const stepFunction = d3Line()
            return xscale(d.dateTime);
        })
            return yscale(d.value);
        });
    let medianGrp = elem.append('g');
    medianGrp.append('path')
        .datum(points)
        .classed('median-data-series', true)
        .classed('median-step', true)
        .classed(`median-step-${modulo}`, true)
        .attr('d', stepFunction);
/**
 * Plots the median points for all median time series for the current variable.
 * @param  {Object} elem
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
 * @param  {Boolean} visible
 * @param  {Function} xscale
 * @param  {Function} yscale
 * @param  {Array} pointsList
const plotAllMedianPoints = function (elem, {visible, xscale, yscale, seriesMap, dateRange}) {
    elem.select('#median-points').remove();
    if (!visible) {
        return;
    }
    const container = elem
        .append('g')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('id', 'median-points');
    for (const [index, seriesID] of Object.keys(seriesMap).entries()) {
        const points = coerceStatisticalSeries(seriesMap[seriesID], dateRange);
        plotMedianPoints(container, {xscale, yscale, modulo: index % 6, points});
const createTitle = function (elem) {
        .classed('time-series-graph-title', true)
        .call(link((elem, title) => {
            elem.html(title);
        }, titleSelector));
const watermark = function (elem) {
    elem.append('img')
        .classed('watermark', true)
        .attr('src', STATIC_URL + '/img/USGS_green_logo.svg')
        .call(link(function (elem, layout) {
            const transformStringSmallScreen = `matrix(0.5, 0, 0, 0.5, ${(layout.width - layout.margin.left) * .025
            + layout.margin.left - 50}, ${layout.height * .60})`;
            const transformStringForAllOtherScreens = `matrix(1, 0, 0, 1, ${(layout.width - layout.margin.left) * .025
            + layout.margin.left}, ${(layout.height * .75 - (-1 * layout.height + 503) * .12)})`;
                // calculates the watermark position based on current layout dimensions
                // and a conversion factor minus the area for blank space due to scaling
                elem.style('transform', transformStringSmallScreen);
                elem.style('-webkit-transform', transformStringSmallScreen);
Briggs, Aaron Shane's avatar
Briggs, Aaron Shane committed
            } else {
                // calculates the watermark position based on current layout dimensions and a conversion factor
                elem.style('transform', transformStringForAllOtherScreens);
                elem.style('-webkit-transform', transformStringForAllOtherScreens);
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .attr('class', 'hydrograph-container')
            .call(createTitle)
            .call(createTooltipText)
            .append('svg')
                .attr('xmlns', 'http://www.w3.org/2000/svg')
                .classed('hydrograph-svg', true)
                .call(link((elem, layout) => {
                    elem.attr('viewBox', `0 0 ${layout.width + layout.margin.left + layout.margin.right} ${layout.height + layout.margin.top + layout.margin.bottom}`);
                    elem.attr('width', layout.width);
                    elem.attr('height', layout.height);
                }, layoutSelector))
                .call(link(addSVGAccessibility, createStructuredSelector({
                    title: titleSelector,
                    description: descriptionSelector,
                    isInteractive: () => true
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
                .call(plotSvgDefs)
                .call(svg => {
                    svg.append('g')
                        .call(link((elem, layout) => elem.attr('transform', `translate(${layout.margin.left},${layout.margin.top})`), layoutSelector))
                        .call(link(appendAxes, axesSelector))
                        .call(link(plotDataLines, createStructuredSelector({
                            visible: isVisibleSelector('current'),
                            tsLinesMap: currentVariableLineSegmentsSelector('current'),
                            xScale: xScaleSelector('current'),
                            yScale: yScaleSelector,
                            tsKey: () => 'current'
                        })))
                        .call(link(plotDataLines, createStructuredSelector({
                            visible: isVisibleSelector('compare'),
                            tsLinesMap: currentVariableLineSegmentsSelector('compare'),
                            xScale: xScaleSelector('compare'),
                            yScale: yScaleSelector,
                            tsKey: () => 'compare'
                        })))
                        .call(createTooltipFocus)
                        .call(link(plotAllMedianPoints, createStructuredSelector({
                            visible: isVisibleSelector('median'),
                            xscale: xScaleSelector('current'),
                            yscale: yScaleSelector,
                            seriesMap: currentVariableTimeSeriesSelector('median'),
                            dateRange: getCurrentDateRange
                        })));
 * Create the show last year toggle and the audible toggle for the time series graph.
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
 * @param {Object} elem - D3 selection
 */
const graphControls = function (elem) {
    const graphControlDiv = elem.append('ul')
        .classed('usa-fieldset-inputs', true)
        .classed('usa-unstyled-list', true)
        .classed('graph-controls-container', true);

    graphControlDiv.append('li')
        .call(audibleUI);
    const compareControlDiv = graphControlDiv.append('li');
    compareControlDiv.append('input')
        .attr('type', 'checkbox')
        .attr('id', 'last-year-checkbox')
        .attr('aria-labelledby', 'last-year-label')
        .attr('ga-on', 'click')
        .attr('ga-event-category', 'TimeSeriesGraph')
        .attr('ga-event-action', 'toggleCompare')
        .on('click', dispatch(function () {
            return Actions.toggleTimeSeries('compare', this.checked);
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
        // Disables the checkbox if no compare time series for the current variable
        .call(link(function (elem, compareTimeSeries) {
            const exists = Object.keys(compareTimeSeries) ?
                Object.values(compareTimeSeries).filter(tsValues => tsValues.points.length).length > 0 : false;
            elem.property('disabled', !exists);
        }, currentVariableTimeSeriesSelector('compare')))
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
        // Sets the state of the toggle
        .call(link(function (elem, checked) {
            elem.property('checked', checked);
    compareControlDiv.append('label')
        .attr('id', 'last-year-label')
        .attr('for', 'last-year-checkbox')
        .text('Compare to last year');
const controlDisplay = function (elem, showElem) {
    elem.attr('hidden', showElem ? null : true);
const loadingIndicator = function (elem, {showLoadingIndicator, sizeClass}) {
    elem.select('.loading-indicator').remove();
    if (showLoadingIndicator) {
        elem.append('i')
            .attr('class', `loading-indicator fas ${sizeClass} fa-spin fa-spinner`);
    }
};
const dateRangeControls = function (elem, siteno) {
    const DATE_RANGE = [{
        label: 'seven-day',
        name: '7 days',
        period: 'P7D'
    }, {
        label: 'thirty-days',
        name: '30 days',
        period: 'P30D'
    }, {
        label: 'one-year',
        name: '1 year',
        period: 'P1Y'
    }];

    const container = elem.insert('div', ':nth-child(2)')
        .attr('id', 'ts-daterange-select-container')
        .call(link(function(container, showControls) {
            container.attr('hidden', showControls ? null : true);
        }, hasTimeSeriesWithPoints('current', 'P7D')));
    const listContainer = container.append('ul')
        .attr('class', 'usa-fieldset-inputs usa-unstyled-list');
    const li = listContainer.selectAll('li')
        .data(DATE_RANGE)
        .enter().append('li');
    listContainer.call(link(loadingIndicator, createStructuredSelector({
        showLoadingIndicator: isLoadingTS('current'),
        sizeClass: () => 'fa-lg'
    })));

    li.append('input')
        .attr('type', 'radio')
        .attr('name', 'ts-daterange-input')
        .attr('id', d => d.label)
        .attr('value', d => d.period)
        .attr('ga-on', 'click')
        .attr('ga-event-category', 'TimeSeriesGraph')
        .attr('ga-event-action', d => `changeDateRangeTo${d.period}`)
        .on('change', dispatch(function () {
            return Actions.retrieveExtendedTimeSeries(
                siteno,
                li.select('input:checked').attr('value')
            );
        }));
    li.append('label')
        .attr('for', (d) => d.label)
        .text((d) => d.name);
    li.select(`#${DATE_RANGE[0].label}`).attr('checked', true);
const noDataAlert = function (elem, tsCollectionIds) {
    elem.select('#no-data-message').remove();
    if (tsCollectionIds && tsCollectionIds.length === 0) {
        elem.append('div')
            .attr('id', 'no-data-message')
            .attr('class', 'usa-alert usa-alert-info')
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
                .attr('class', 'usa-alert-body')
                .append('p')
                    .attr('class', 'usa-alert-text')
                    .text('No current time series data available for this site');
const attachToNode = function (store, node, {siteno, parameter, compare, cursorOffset} = {}) {
    if (!siteno) {
        select(node).call(drawMessage, 'No data is available.');
        return;
    store.dispatch(Actions.resizeUI(window.innerWidth, node.offsetWidth));
        .call(provide(store));
    select(node)
        .call(link(noDataAlert, getTimeSeriesCollectionIds('current', 'P7D')));
    select(node).select('.loading-indicator-container')
        .call(link(loadingIndicator, createStructuredSelector({
            showLoadingIndicator: isLoadingTS('current', 'P7D'),
    select(node)
        .call(dateRangeControls, siteno);
    // If specified, turn the visibility of the comparison time series on.
    if (compare) {
        store.dispatch(Actions.toggleTimeSeries('compare', true));
    }

    // If specified, initialize the cursorOffset
    if (cursorOffset !== undefined) {
        store.dispatch(Actions.setCursorOffset(cursorOffset));
    }

        .call(link(controlDisplay, hasTimeSeriesWithPoints('current', 'P7D')))
Bucknell, Mary S.'s avatar
Bucknell, Mary S. committed
            .classed('ts-legend-controls-container', true)
            .call(timeSeriesLegend)
            .call(graphControls);
    select(node).select('.select-time-series-container')
        .call(link(plotSeriesSelectTable, createStructuredSelector({
            siteno: () => siteno,
            availableTimeSeries: availableTimeSeriesSelector,
            lineSegmentsByParmCd: lineSegmentsByParmCdSelector('current', 'P7D'),
            timeSeriesScalesByParmCd: timeSeriesScalesByParmCdSelector('current', 'P7D', SPARK_LINE_DIM),
            layout: layoutSelector
        })));
    select(node).select('.provisional-data-alert')
        .call(link(function (elem, allTimeSeries) {
            elem.attr('hidden', Object.keys(allTimeSeries).length ? null : true);
        }, allTimeSeriesSelector));
    window.onresize = function () {
Yan, Andrew N.'s avatar
Yan, Andrew N. committed
        store.dispatch(Actions.resizeUI(window.innerWidth, node.offsetWidth));
    store.dispatch(Actions.retrieveTimeSeries(siteno, parameter ? [parameter] : null));
module.exports = {attachToNode, timeSeriesLegend, timeSeriesGraph};