diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000000000000000000000000000000000..85af630bdd1bcaf0427ba72f929ca6147533793a --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "plugins": ["transform-object-rest-spread"] +} diff --git a/.eslintrc b/.eslintrc index 9e0889f17ebb43f973d9d8029520ae21e35cebeb..805e164e747af3e79caccf8fc64a0cd12864877b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,9 @@ }, "parserOptions": { "ecmaVersion": 6, + "ecmaFeatures": { + "experimentalObjectRestSpread": true + }, "sourceType": "module" }, "plugins": [ diff --git a/assets/src/scripts/accessibility.js b/assets/src/scripts/accessibility.js index 377e3d660e38584d0c7b10971b0b7ba1b5f3bad0..36583b8286a6370deb2f53a7b712e51e350c577a 100644 --- a/assets/src/scripts/accessibility.js +++ b/assets/src/scripts/accessibility.js @@ -1,5 +1,3 @@ -const { select } = require('d3-selection'); - /** * Adds accessibility attributes to the svg. * This was based on the recommendations in this article: https://www.w3.org/WAI/PF/HTML/wiki/Canvas @@ -8,7 +6,7 @@ const { select } = require('d3-selection'); * @param {String} description * @param {Boolean} isInteractive */ -function addSVGAccessibility({svg, title, description, isInteractive}) { +function addSVGAccessibility(svg, {title, description, isInteractive}) { svg.attr('title', title) .attr('desc', description) .attr('aria-labelledby', 'title desc'); @@ -25,8 +23,10 @@ function addSVGAccessibility({svg, title, description, isInteractive}) { * @param {Array} columnNames - array of strings * @param {Array} data - array of array of strings */ -function addSROnlyTable({container, columnNames, data}) { - const table = select(container) +function addSROnlyTable(container, {columnNames, data}) { + container.selectAll('table.usa-sr-only').remove(); + + const table = container .append('table') .attr('class', 'usa-sr-only'); @@ -57,4 +57,3 @@ function addSROnlyTable({container, columnNames, data}) { } module.exports = {addSVGAccessibility, addSROnlyTable}; - diff --git a/assets/src/scripts/accessibility.spec.js b/assets/src/scripts/accessibility.spec.js index a975e91c98d3f2f9f591deacceb1809860cc83ab..22d33af0e069a069516949d6e5c425c088d93266 100644 --- a/assets/src/scripts/accessibility.spec.js +++ b/assets/src/scripts/accessibility.spec.js @@ -14,8 +14,7 @@ describe('svgAccessibility tests', () => { }); it('Should add a title, desc, and aria attributes', () => { - addSVGAccessibility({ - svg: svg, + addSVGAccessibility(svg, { title: 'This is a title', description: 'This is a description', isInteractive: false @@ -29,8 +28,7 @@ describe('svgAccessibility tests', () => { }); it('Should not add a tabindex if isInteractive is false', () => { - addSVGAccessibility({ - svg: svg, + addSVGAccessibility(svg, { title: 'This is a title', description: 'This is a description', isInteractive: false @@ -39,8 +37,7 @@ describe('svgAccessibility tests', () => { }); it('Should add a tabindex if isInteractive is true', () => { - addSVGAccessibility({ - svg: svg, + addSVGAccessibility(svg, { title: 'This is a title', description: 'This is a description', isInteractive: true @@ -49,7 +46,7 @@ describe('svgAccessibility tests', () => { }); }); - describe('addSROnlyTable tests', () => { + describe('SROnlyTable tests', () => { let container; let columnNames = ['Postal Code', 'FIPS', 'Name']; let data = [ @@ -61,8 +58,7 @@ describe('svgAccessibility tests', () => { beforeEach(() => { container = select('body').append('div').attr('id', 'test-div'); - addSROnlyTable({ - container: document.getElementById('test-div'), + addSROnlyTable(select(document.getElementById('test-div')), { columnNames: columnNames, data: data }); @@ -96,4 +92,4 @@ describe('svgAccessibility tests', () => { }); }); -}); \ No newline at end of file +}); diff --git a/assets/src/scripts/components/hydrograph/axes.js b/assets/src/scripts/components/hydrograph/axes.js index c93f6dd71e4b7cef743d4b44304790e6f4406ff6..655c024514e2b51da57ab4b7bd5512e65a3a128c 100644 --- a/assets/src/scripts/components/hydrograph/axes.js +++ b/assets/src/scripts/components/hydrograph/axes.js @@ -2,9 +2,14 @@ const { axisBottom, axisLeft } = require('d3-axis'); const { format } = require('d3-format'); const { timeDay } = require('d3-time'); const { timeFormat } = require('d3-time-format'); +const { createSelector } = require('reselect'); + +const { WIDTH, HEIGHT, MARGIN } = require('./layout'); +const { xScaleSelector, yScaleSelector } = require('./scales'); const yTickCount = 5; + /** * Helper function which generates y tick values for a scale * @param {Object} yScale - d3 scale @@ -26,7 +31,7 @@ function yTickValues(yScale) { * @param {Number} yTickSize Size of inner ticks for the y-axis * @return {Object} {xAxis, yAxis} - D3 Axis */ -function createAxes(xScale, yScale, yTickSize) { +function createAxes({xScale, yScale}, yTickSize) { // Create x-axis const xAxis = axisBottom() .scale(xScale) @@ -46,33 +51,42 @@ function createAxes(xScale, yScale, yTickSize) { return {xAxis, yAxis}; } -/** - * Updates/Sets the yAxis.tickValues - * @param {Object} yAxis - d3 axis - * @param {Object} yScale - d3 scale - */ -function updateYAxis(yAxis, yScale) { - yAxis.tickValues(yTickValues(yScale)); -} -/** - * Adds the given axes to a node - * @param {Object} plot Node to append to - * @param {Object} xAxis D3 Axis x-axis - * @param {Object} yAxis D3 Axis y-axis - * @param {Object} xLoc {x, y} location of x-axis - * @param {Object} yLoc {x, y} location of y-axis - * @param {Object} yLabelLoc {x, y} location of y-axis label - * @param {String} yTitle y-axis label - */ -function appendAxes({plot, xAxis, yAxis, xLoc, yLoc, yLabelLoc, yTitle}) { - plot.append('g') +const axesSelector = createSelector( + xScaleSelector, + yScaleSelector, + (state) => state.title, + (xScale, yScale, title) => { + return { + ...createAxes({xScale, yScale}, -WIDTH + MARGIN.right), + yTitle: title + }; + } +); + + +function appendAxes(elem, {xAxis, yAxis, yTitle}) { + const xLoc = { + x: 0, + y: HEIGHT - (MARGIN.top + MARGIN.bottom) + }; + const yLoc = {x: 0, y: 0}; + const yLabelLoc = { + x: HEIGHT / -2 + MARGIN.top, + y: -35 + }; + + // Remove existing axes before adding the new ones. + elem.selectAll('.x-axis, .y-axis').remove(); + + // Add x-axis + elem.append('g') .attr('class', 'x-axis') .attr('transform', `translate(${xLoc.x}, ${xLoc.y})`) .call(xAxis); // Add y-axis and a text label - plot.append('g') + elem.append('g') .attr('class', 'y-axis') .attr('transform', `translate(${yLoc.x}, ${yLoc.y})`) .call(yAxis) @@ -86,4 +100,19 @@ function appendAxes({plot, xAxis, yAxis, xLoc, yLoc, yLabelLoc, yTitle}) { } -module.exports = {createAxes, updateYAxis, appendAxes}; +/** + * Adds the given axes to a node + * @param {Object} elem Node to append to + * @param {Object} xAxis D3 Axis x-axis + * @param {Object} yAxis D3 Axis y-axis + * @param {Object} xLoc {x, y} location of x-axis + * @param {Object} yLoc {x, y} location of y-axis + * @param {Object} yLabelLoc {x, y} location of y-axis label + * @param {String} yTitle y-axis label + */ +function plotAxes(elem, store) { + elem.call(appendAxes, axesSelector(store.getState())); +} + + +module.exports = {createAxes, appendAxes, axesSelector, plotAxes}; diff --git a/assets/src/scripts/components/hydrograph/axes.spec.js b/assets/src/scripts/components/hydrograph/axes.spec.js index 6d07d4d2603e5eb3c61d70c7565244b10ed1b241..0ecee414e036c3b5ecb051843af8bbe13e1fc997 100644 --- a/assets/src/scripts/components/hydrograph/axes.spec.js +++ b/assets/src/scripts/components/hydrograph/axes.spec.js @@ -1,24 +1,21 @@ -const { createAxes, updateYAxis, appendAxes } = require('./axes'); const { scaleLinear } = require('d3-scale'); const { select } = require('d3-selection'); +const { createAxes, appendAxes } = require('./axes'); + describe('Chart axes', () => { // xScale is oriented on the left const xScale = scaleLinear().range([0, 10]).domain([0, 10]); const yScale = scaleLinear().range([0, 10]).domain([0, 10]); - const {xAxis, yAxis} = createAxes(xScale, yScale, 100); + const {xAxis, yAxis} = createAxes({xScale, yScale}, 100); let svg; beforeEach(() => { svg = select(document.body).append('svg'); - appendAxes({ - plot: svg, + appendAxes(svg, { xAxis, yAxis, - xLoc: {x: 10, y: 10}, - yLoc: {x: 0, y: 100}, - yLabelLoc: {x: 10, y: 10}, yTitle: 'Label title' }); }); @@ -37,19 +34,6 @@ describe('Chart axes', () => { it('axes appended', () => { // Should be translated - expect(svg.select('.x-axis').attr('transform')).toBe('translate(10, 10)'); - expect(svg.select('.y-axis').attr('transform')).toBe('translate(0, 100)'); expect(svg.select('.y-axis-label').text()).toEqual('Label title'); }); - - it('tickValues should change with scale with new domain', () => { - const originalTickValues = yAxis.tickValues(); - yScale.domain([2, 20]); - updateYAxis(yAxis, yScale); - expect(yAxis.tickValues()).not.toEqual(originalTickValues); - - yScale.domain([0, 10]); - updateYAxis(yAxis, yScale); - expect(yAxis.tickValues()).toEqual(originalTickValues); - }); }); diff --git a/assets/src/scripts/components/hydrograph/index.js b/assets/src/scripts/components/hydrograph/index.js index d2533d531de5ac560e7a2213a6749df616bfbb77..45096c60cabed57de96d7241986688618c84c8ad 100644 --- a/assets/src/scripts/components/hydrograph/index.js +++ b/assets/src/scripts/components/hydrograph/index.js @@ -1,290 +1,176 @@ /** * Hydrograph charting module. */ -const { bisector, extent, min, max } = require('d3-array'); +const { bisector } = require('d3-array'); +const { reduxConnect: connect, reduxDispatch: dispatch, + reduxFromState: fromState, reduxProvide: provide } = require('d3-redux'); const { mouse, select } = require('d3-selection'); const { line } = require('d3-shape'); -const { timeFormat } = require('d3-time-format'); const { addSVGAccessibility, addSROnlyTable } = require('../../accessibility'); -const { getTimeseries, getPreviousYearTimeseries } = require('../../models'); -const { appendAxes, updateYAxis, createAxes } = require('./axes'); -const { createScales, createXScale, updateYScale } = require('./scales'); - - -// Define width, height and margin for the SVG. -// Use a fixed size, and scale to device width using CSS. -const WIDTH = 800; -const HEIGHT = WIDTH / 2; -const ASPECT_RATIO_PERCENT = `${100 * HEIGHT / WIDTH}%`; -const MARGIN = { - top: 20, - right: 75, - bottom: 45, - left: 50 -}; +const { plotAxes } = require('./axes'); +const { WIDTH, HEIGHT, ASPECT_RATIO_PERCENT, MARGIN } = require('./layout'); +const { pointsSelector, validPointsSelector, isVisibleSelector } = require('./points'); +const { xScaleSelector, yScaleSelector } = require('./scales'); +const { Actions, configureStore } = require('./store'); // Function that returns the left bounding point for a given chart point. const bisectDate = bisector(d => d.time).left; -// Create a time formatting function from D3's timeFormat -const formatTime = timeFormat('%c %Z'); - - -class Hydrograph { - /** - * @param {Array} data IV data as returned by models/getTimeseries - * @param {String} yLabel y-axis label - * @param {String} title for svg's title attribute - * @param {String} desc for svg's desc attribute - * @param {Node} element Dom node to insert - */ - constructor({data=[], yLabel='Data', title='', desc='', element}) { - this._yLabel = yLabel; - this._title = title; - this._desc = desc; - this._element = element; - this._tsData = {}; - - if (data && data.length) { - this._tsData.current = data; - this._drawChart(); - } else { - this._drawMessage('No data is available for this site.'); - } - } - - /** - * Add a new time series to the Hydrograph. The time series is assumed to be - * data that is over the same date range in a different year. - * @param {Array} data - IV data as returned by models.getTimeseires - */ - addCompareTimeSeries(data) { - //Save data - will be needed in order to implement the tooltips - this._tsData.compare = data; - - // Update the yScale by determining the new extent - const currentYExtent = extent(this._tsData.current, d => d.value); - const yExtent = extent(data, d => d.value); - const yDataExtent = [min([yExtent[0], currentYExtent[0]]), max([yExtent[1], currentYExtent[1]])]; - updateYScale(this.scale.yScale, yDataExtent); - - // Create a x scale for the new data - const xScale = createXScale(data, WIDTH - MARGIN.right); - - // Update the yAxis - updateYAxis(this.axis.yAxis, this.scale.yScale); - this.svg.select('.y-axis') - .call(this.axis.yAxis); - - //Update the current ts line - select('#ts-current') - .attr('d', this.currentLine(this._tsData.current)); - - // Add the new time series - this._plotDataLine(this.plot, {xScale: xScale, yScale: this.scale.yScale}, 'compare'); - } - - /** - * Remove the compare time series from the plot and rescale - */ - removeCompareTimeSeries() { - // Remove the compare time series - this.svg.select('#ts-compare').remove(); - this.svg.select('.x-top-axis').remove(); - delete this._tsData.compare; - // Update the y scale and redraw the axis - const currentYExtent = extent(this._tsData.current, d => d.value); - updateYScale(this.scale.yScale, currentYExtent); - updateYAxis(this.axis.yAxis, this.scale.yScale); - this.svg.select('.y-axis') - .call(this.axis.yAxis); - - //Redraw the current ts - select('#ts-current') - .attr('d', this.currentLine(this._tsData.current)); - } - - _drawChart() { - // Set up parent element and SVG - this.svg = select(this._element) - .append('div') - .attr('class', 'hydrograph-container') - .style('padding-bottom', ASPECT_RATIO_PERCENT) - .append('svg') - .attr('preserveAspectRatio', 'xMinYMin meet') - .attr('viewBox', `0 0 ${WIDTH} ${HEIGHT}`); - - addSVGAccessibility({ - svg: this.svg, - title: this._title, - description: this._desc, - isInteractive: true - }); - - addSROnlyTable({ - container: this._element, - columnNames: [this._title, 'Time'], - data: this._tsData.current.map((value) => { - return [value.value, value.time]; - }) - }); - // We'll actually be appending to a <g> element - this.plot = this.svg.append('g') - .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); - - // Create x/y scaling for the full (100%) view. - this.scale = createScales( - this._tsData.current, - WIDTH - MARGIN.right, - HEIGHT - (MARGIN.top + MARGIN.bottom) - ); - this.axis = createAxes(this.scale.xScale, this.scale.yScale, -WIDTH + MARGIN.right); - - // Draw the graph components with the given scaling. - appendAxes({ - plot: this.plot, - xAxis: this.axis.xAxis, - yAxis: this.axis.yAxis, - xLoc: {x: 0, y: HEIGHT - (MARGIN.top + MARGIN.bottom)}, - yLoc: {x: 0, y: 0}, - yLabelLoc: {x: HEIGHT / -2 + MARGIN.top, y: -35}, - yTitle: this._yLabel - }); - this.currentLine = this._plotDataLine(this.plot, this.scale, 'current'); - this._plotTooltips(this.plot, this.scale, 'current'); - } - - _drawMessage(message) { - // Set up parent element and SVG - this._element.innerHTML = ''; - const alertBox = select(this._element) - .append('div') +const drawMessage = function (elem, message) { + // Set up parent element and SVG + elem.innerHTML = ''; + const alertBox = elem + .append('div') .attr('class', 'usa-alert usa-alert-warning') .append('div') - .attr('class', 'usa-alert-body'); - alertBox.append('h3') + .attr('class', 'usa-alert-body'); + alertBox + .append('h3') .attr('class', 'usa-alert-heading') .html('Hydrograph Alert'); - alertBox.append('p') + alertBox + .append('p') .html(message); - } - - _plotDataLine(plot, scale, tsDataKey) { - let tsLine = line() - .x(d => scale.xScale(d.time)) - .y(d => scale.yScale(d.value)); +}; - plot.append('path') - .datum(this._tsData[tsDataKey]) - .classed('line', true) - .attr('id', 'ts-' + tsDataKey) - .attr('d', tsLine); - return tsLine; - } - _plotTooltips(plot, scale, tsDataKey) { - // Create a node to hightlight the currently selected date/time. - let focus = plot.append('g') - .attr('class', 'focus') - .style('display', 'none'); +const plotDataLine = function (elem, store, tsDataKey) { + const elemId = 'ts-' + tsDataKey; + elem.selectAll(`#${elemId}`).remove(); - focus.append('circle') - .attr('r', 7.5); + const state = store.getState(); + if (!isVisibleSelector(state, tsDataKey)) { + return; + } - focus.append('text'); + elem.datum(fromState(state => validPointsSelector(state, tsDataKey))) + .append('path') + .classed('line', true) + .attr('id', elemId) + .attr('d', line().x(d => d.x) + .y(d => d.y)); +}; - plot.append('rect') - .attr('class', 'overlay') - .attr('width', WIDTH) - .attr('height', HEIGHT) - .on('mouseover', () => focus.style('display', null)) - .on('mouseout', () => focus.style('display', 'none')) - .on('mousemove', (d, i, nodes) => { - // Get the nearest data point for the current mouse position. - let time = scale.xScale.invert(mouse(nodes[i])[0]); - let {datum, index} = this._getNearestTime(time, tsDataKey); - // Move the focus node to this date/time. - focus.attr('transform', `translate(${scale.xScale(datum.time)}, ${scale.yScale(datum.value)})`); +const getNearestTime = function (data, time) { + let index = bisectDate(data, time, 1); + let datum; + let d0 = data[index - 1]; + let d1 = data[index]; - // Draw text, anchored to the left or right, depending on - // which side of the graph the point is on. - let isFirstHalf = index < this._tsData[tsDataKey].length / 2; - focus.select('text') - .text(() => datum.label) - .attr('text-anchor', isFirstHalf ? 'start' : 'end') - .attr('x', isFirstHalf ? 15 : -15) - .attr('dy', isFirstHalf ? '.31em' : '-.31em'); - }); + if (d0 && d1) { + datum = time - d0.time > d1.time - time ? d1 : d0; + } else { + datum = d0 || d1; } - _getNearestTime(time, tsDataKey) { - let index = bisectDate(this._tsData[tsDataKey], time, 1); + // Return the nearest data point and its index. + return { + datum, + index: datum === d0 ? index - 1 : index + }; +}; - let datum; - let d0 = this._tsData[tsDataKey][index - 1]; - let d1 = this._tsData[tsDataKey][index]; - if (d0 && d1) { - datum = time - d0.time > d1.time - time ? d1 : d0; - } else { - datum = d0 || d1; - } - // Return the nearest data point and its index. - return { - datum, - index: datum === d0 ? index - 1 : index - }; - } -} +const plotTooltips = function (elem, store, tsDataKey) { + // Create a node to hightlight the currently selected date/time. + let focus = elem.append('g') + .attr('class', 'focus') + .style('display', 'none'); + focus.append('circle') + .attr('r', 7.5); + focus.append('text'); + + elem.append('rect') + .attr('class', 'overlay') + .attr('width', WIDTH) + .attr('height', HEIGHT) + .on('mouseover', () => focus.style('display', null)) + .on('mouseout', () => focus.style('display', 'none')) + .on('mousemove', function () { + const state = store.getState(); + const xScale = xScaleSelector(state, tsDataKey); + const yScale = yScaleSelector(state, tsDataKey); + const data = pointsSelector(state, tsDataKey); + + // Get the nearest data point for the current mouse position. + const time = xScale.invert(mouse(this)[0]); + const {datum, index} = getNearestTime(data, time); + if (!datum) { + return; + } + // Move the focus node to this date/time. + focus.attr('transform', `translate(${xScale(datum.time)}, ${yScale(datum.value)})`); + + // Draw text, anchored to the left or right, depending on + // which side of the graph the point is on. + const isFirstHalf = index < data.length / 2; + focus.select('text') + .text(() => datum.label) + .attr('text-anchor', isFirstHalf ? 'start' : 'end') + .attr('x', isFirstHalf ? 15 : -15) + .attr('dy', isFirstHalf ? '.31em' : '-.31em'); + }); +}; -function attachToNode(node, {siteno}) { - let hydrograph; - let getLastYearTS; - getTimeseries({sites: [siteno]}).then((series) => { - let dataIsValid = series && series[0] && - !series[0].values.some(d => d.value === -999999); - hydrograph = new Hydrograph({ - element: node, - data: dataIsValid ? series[0].values : [], - yLabel: dataIsValid ? series[0].variableDescription : 'No data', - title: dataIsValid ? series[0].variableName : '', - desc: dataIsValid ? series[0].variableDescription + ' from ' + - formatTime(series[0].seriesStartDate) + ' to ' + - formatTime(series[0].seriesEndDate) : '' - }); - if (dataIsValid) { - getLastYearTS = getPreviousYearTimeseries({ - site: node.dataset.siteno, - startTime: series[0].seriesStartDate, - endTime: series[0].seriesEndDate + +const timeSeriesGraph = function (elem, store) { + elem.append('div') + .attr('class', 'hydrograph-container') + .style('padding-bottom', ASPECT_RATIO_PERCENT) + .append('svg') + .attr('preserveAspectRatio', 'xMinYMin meet') + .attr('viewBox', `0 0 ${WIDTH} ${HEIGHT}`) + .call(connect(function (elem) { + let state = store.getState(); + elem.call(addSVGAccessibility, { + title: state.title, + description: state.desc, + isInteractive: true }); - } - }, () => - hydrograph = new Hydrograph({ - element: node, - data: [] + })) + .append('g') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`) + .call(connect(plotAxes), store) + .call(connect(plotDataLine), store, 'current') + .call(connect(plotDataLine), store, 'compare') + //.call(plotTooltips, store, 'compare') + .call(plotTooltips, store, 'current'); + elem.call(connect(function (elem) { + const state = store.getState(); + elem.call(addSROnlyTable, { + columnNames: [state.title, 'Time'], + data: pointsSelector(state, 'current').map((value) => { + return [value.value, value.time]; }) - ); - let lastYearInput = node.getElementsByClassName('hydrograph-last-year-input'); - if (lastYearInput.length > 0) { - lastYearInput[0].addEventListener('change', (evt) => { - if (evt.target.checked) { - getLastYearTS.then((series) => { - hydrograph.addCompareTimeSeries(series[0].values); - }); - } else { - hydrograph.removeCompareTimeSeries(); - } }); + })); +}; + + +const attachToNode = function (node, {siteno} = {}) { + if (!siteno) { + select(node).call(drawMessage, 'No data is available.'); + return; } -} + + let store = configureStore(); + + select(node) + .call(provide(store)) + .call(timeSeriesGraph, store) + .select('.hydrograph-last-year-input') + .on('change', dispatch(function () { + return Actions.toggleTimeseries('compare', this.checked); + })); + store.dispatch(Actions.retrieveTimeseries(siteno)); +}; -module.exports = {Hydrograph, attachToNode}; +module.exports = {attachToNode, getNearestTime, timeSeriesGraph}; diff --git a/assets/src/scripts/components/hydrograph/index.spec.js b/assets/src/scripts/components/hydrograph/index.spec.js index 065e529f3c23ad99b26a95059d9c3f346c612073..cf2645bb3daf264fd2cf6a21ceafe64c035a394a 100644 --- a/assets/src/scripts/components/hydrograph/index.spec.js +++ b/assets/src/scripts/components/hydrograph/index.spec.js @@ -1,6 +1,8 @@ const { select, selectAll } = require('d3-selection'); +const { reduxProvide: provide } = require('d3-redux'); -const Hydrograph = require('./index').Hydrograph; +const { attachToNode, getNearestTime, timeSeriesGraph } = require('./index'); +const { Actions, configureStore } = require('./store'); describe('Hydrograph charting module', () => { @@ -18,35 +20,59 @@ describe('Hydrograph charting module', () => { }); it('empty graph displays warning', () => { - new Hydrograph({element: graphNode}); + attachToNode(graphNode, {}); expect(graphNode.innerHTML).toContain('No data is available'); }); it('single data point renders', () => { - new Hydrograph({ - element: graphNode, - data: [{ - time: new Date(), - value: 10, - label: 'Label' - }] + const store = configureStore({ + tsData: { + current: { + data: [{ + time: new Date(), + value: 10, + label: 'Label' + }], + show: true + }, + compare: { + data: [], + show: false + } + }, + title: '', + desc: '' }); + select(graphNode) + .call(provide(store)) + .call(timeSeriesGraph, store); expect(graphNode.innerHTML).toContain('hydrograph-container'); }); describe('SVG has been made accessibile', () => { let svg; beforeEach(() => { - new Hydrograph({ - element: graphNode, + const store = configureStore({ + tsData: { + current: { + data: [{ + time: new Date(), + value: 10, + label: 'Label' + }], + show: true + }, + compare: { + data: [], + show: false + } + }, title: 'My Title', desc: 'My Description', - data: [{ - time: new Date(), - value: 10, - label: 'Label' - }] }); + select(graphNode) + .call(provide(store)) + .call(timeSeriesGraph, store); svg = select('svg'); }); @@ -66,7 +92,23 @@ describe('Hydrograph charting module', () => { describe('Renders real data from site #05370000', () => { /* eslint no-use-before-define: "ignore" */ beforeEach(() => { - new Hydrograph({element: graphNode, data: MOCK_DATA}); + const store = configureStore({ + tsData: { + current: { + data: MOCK_DATA, + show: true + }, + compare: { + data: [], + show: false + } + }, + title: 'My Title', + desc: 'My Description', + }); + select(graphNode) + .call(provide(store)) + .call(timeSeriesGraph, store); }); it('should render an svg node', () => { @@ -84,7 +126,6 @@ describe('Hydrograph charting module', () => { }); describe('Hydrograph tooltips', () => { - let graph; let data = [12, 13, 14, 15, 16].map(hour => { return { time: new Date(`2018-01-03T${hour}:00:00.000Z`), @@ -92,9 +133,6 @@ describe('Hydrograph charting module', () => { value: 0 }; }); - beforeEach(() => { - graph = new Hydrograph({element: graphNode, data: data}); - }); it('return correct data points via getNearestTime' , () => { // Check each date with the given offset against the hourly-spaced @@ -108,7 +146,8 @@ describe('Hydrograph charting module', () => { expected = {datum: data[index + 1], index: index + 1}; } let time = new Date(datum.time.getTime() + offset); - let returned = graph._getNearestTime(time, 'current'); + let returned = getNearestTime(data, time); + expect(returned.datum.time).toBe(expected.datum.time); expect(returned.datum.index).toBe(expected.datum.index); } @@ -129,17 +168,33 @@ describe('Hydrograph charting module', () => { describe('Adding and removing compare time series', () => { /* eslint no-use-before-define: "ignore" */ let hydrograph; + let store; beforeEach(() => { - hydrograph = new Hydrograph({element: graphNode, data: MOCK_DATA}); - hydrograph.addCompareTimeSeries(MOCK_DATA_FOR_PREVIOUS_YEAR); + store = configureStore({ + tsData: { + current: { + data: MOCK_DATA, + show: true + }, + compare: { + data: MOCK_DATA_FOR_PREVIOUS_YEAR, + show: true + } + }, + title: 'My Title', + desc: 'My Description', + }); + select(graphNode) + .call(provide(store)) + .call(timeSeriesGraph, store); }); it('Should render two lines', () => { expect(selectAll('svg path.line').size()).toBe(2); }); - it('Should remove one of lthe lines when removing the compare time series', () => { - hydrograph.removeCompareTimeSeries(); + it('Should remove one of the lines when removing the compare time series', () => { + store.dispatch(Actions.toggleTimeseries('compare', false)); expect(selectAll('svg path.line').size()).toBe(1); }); diff --git a/assets/src/scripts/components/hydrograph/layout.js b/assets/src/scripts/components/hydrograph/layout.js new file mode 100644 index 0000000000000000000000000000000000000000..8cf238c1a5757d42ba26dd8113937f4f16aae535 --- /dev/null +++ b/assets/src/scripts/components/hydrograph/layout.js @@ -0,0 +1,11 @@ +// Define width, height and margin for the SVG. +// Use a fixed size, and scale to device width using CSS. +export const WIDTH = 800; +export const HEIGHT = WIDTH / 2; +export const ASPECT_RATIO_PERCENT = `${100 * HEIGHT / WIDTH}%`; +export const MARGIN = { + top: 20, + right: 75, + bottom: 45, + left: 50 +}; diff --git a/assets/src/scripts/components/hydrograph/points.js b/assets/src/scripts/components/hydrograph/points.js new file mode 100644 index 0000000000000000000000000000000000000000..5b08913deec224d36098e02e76900cb81331a02b --- /dev/null +++ b/assets/src/scripts/components/hydrograph/points.js @@ -0,0 +1,40 @@ +const { createSelector } = require('reselect'); + +const { xScaleSelector, yScaleSelector } = require('./scales'); + + +const pointsSelector = function (state, tsDataKey) { + if (state.tsData[tsDataKey]) { + return state.tsData[tsDataKey].data; + } + return []; +}; + + +const isVisibleSelector = function (state, tsDataKey) { + if (state.tsData[tsDataKey]) { + return state.tsData[tsDataKey].show; + } + return false; +}; + + +const validPointsSelector = createSelector( + pointsSelector, + xScaleSelector, + yScaleSelector, + (tsData, xScale, yScale) => { + let a = tsData + .filter(d => d.value !== undefined) + .map(d => { + return { + x: xScale(d.time), + y: yScale(d.value) + }; + }); + return a; + } +); + + +module.exports = { pointsSelector, validPointsSelector, isVisibleSelector }; diff --git a/assets/src/scripts/components/hydrograph/scales.js b/assets/src/scripts/components/hydrograph/scales.js index a927f1f02856a8e480483096b697794dee526396..aff9145e18a866278c09e9b0640bf85e5c648fbd 100644 --- a/assets/src/scripts/components/hydrograph/scales.js +++ b/assets/src/scripts/components/hydrograph/scales.js @@ -1,8 +1,12 @@ const { extent } = require('d3-array'); const { scaleLinear, scaleTime } = require('d3-scale'); +const { createSelector } = require('reselect'); + +const { WIDTH, HEIGHT, MARGIN } = require('./layout'); const paddingRatio = 0.2; + /** * Return domainExtent padded on both ends by paddingRatio * @param {Array} domainExtent - array of two numbers @@ -14,14 +18,14 @@ function extendDomain(domainExtent) { } /** - * Create an xcale oriented on the left + * Create an x-scale oriented on the left * @param {Array} data - Array contains {time, ...} * @param {Number} xSize - range of scale * @eturn {Object} d3 scale for time. */ -function createXScale(data, xSize) { - // Calculate max and min for data - const xExtent = extent(data, d => d.time); +function createXScale(values, xSize) { + // Calculate max and min for values + const xExtent = values.length ? extent(values, d => d.time) : [0, 1]; // xScale is oriented on the left return scaleTime() @@ -30,19 +34,42 @@ function createXScale(data, xSize) { } /** - * Create an ycale oriented on the bottom + * Create an yscale oriented on the bottom * @param {Array} data - Array contains {value, ...} * @param {Number} xSize - range of scale * @eturn {Object} d3 scale for value. */ -function createYScale(data, ySize) { +function createYScale(tsData, ySize) { + let yExtent; + // Calculate max and min for data - const yExtent = extent(data, d => d.value); + for (let key of Object.keys(tsData)) { + if (!tsData[key].show || tsData[key].data.length === 0) { + continue; + } + + const thisExtent = extent(tsData[key].data, d => d.value); + if (yExtent !== undefined) { + yExtent = [ + Math.min(thisExtent[0], yExtent[0]), + Math.max(thisExtent[1], yExtent[1]) + ]; + } else { + yExtent = thisExtent; + } + } + + // Add padding to the extent and handle empty data sets. + if (yExtent) { + yExtent = extendDomain(yExtent); + } else { + yExtent = [0, 1]; + } // yScale is oriented on the bottom return scaleLinear() .range([ySize, 0]) - .domain(extendDomain(yExtent)); + .domain(yExtent); } /** @@ -52,22 +79,31 @@ function createYScale(data, ySize) { * @param {Number} ySize Y range of scale * @return {Object} {xScale, yScale} */ -function createScales(data, xSize, ySize) { - const xScale = createXScale(data, xSize); - const yScale = createYScale(data, ySize); +function createScales(tsData, xSize, ySize) { + const xScale = createXScale(tsData.current.data, xSize); + const yScale = createYScale(tsData, ySize); return {xScale, yScale}; } -/** - * Updates the domain of yScale with the new extent including the paddingRatio - * @param yScale - * @param newYDataExtent - */ -function updateYScale(yScale, newYDataExtent) { - yScale.domain(extendDomain(newYDataExtent)); -} +const xScaleSelector = createSelector( + (state) => state.tsData, + (state, tsDataKey = 'current') => tsDataKey, + (tsData, tsDataKey) => { + if (tsData[tsDataKey]) { + return createXScale(tsData[tsDataKey].data, WIDTH - MARGIN.right); + } else { + return null; + } + } +); + + +const yScaleSelector = createSelector( + (state) => state.tsData, + (tsData) => createYScale(tsData, HEIGHT - (MARGIN.top + MARGIN.bottom)) +); -module.exports = {createScales, createXScale, createYScale, updateYScale}; +module.exports = {createScales, xScaleSelector, yScaleSelector}; diff --git a/assets/src/scripts/components/hydrograph/scales.spec.js b/assets/src/scripts/components/hydrograph/scales.spec.js index 6ea3acd6a56ef8dc3e9694e760707975afea6c4e..720895561964df30628a857fe892a29240f819ec 100644 --- a/assets/src/scripts/components/hydrograph/scales.spec.js +++ b/assets/src/scripts/components/hydrograph/scales.spec.js @@ -1,4 +1,4 @@ -const { createScales, updateYScale } = require('./scales'); +const { createScales } = require('./scales'); describe('Charting scales', () => { @@ -9,10 +9,11 @@ describe('Charting scales', () => { value: hour }; }); - let {xScale, yScale} = createScales(data, 200, 100); + let tsData = {current: {data: data}}; + let {xScale, yScale} = createScales(tsData, 200, 100); it('scales created', () => { - let {xScale, yScale} = createScales(data, 200, 100); + let {xScale, yScale} = createScales(tsData, 200, 100); expect(xScale).toEqual(jasmine.any(Function)); expect(yScale).toEqual(jasmine.any(Function)); }); @@ -40,20 +41,4 @@ describe('Charting scales', () => { expect(yScale(.5)).not.toBeNaN(); expect(yScale(.999)).not.toBeNaN(); }); - - it('Domain should be extended if the new data extent exceeds the extent used to create the yScale', () => { - const currentDomain = yScale.domain(); - updateYScale(yScale, [-1, 25]); - const newDomain = yScale.domain(); - expect(newDomain[1]).toBeGreaterThan(currentDomain[1]); - expect(newDomain[0]).toBeLessThan(currentDomain[0]); - }); - - it('Domain should not be descreased if the new data extent is less than the extents used to create the yScale', () => { - const currentDomain = yScale.domain(); - updateYScale(yScale, [2, 20]); - const newDomain = yScale.domain(); - expect(newDomain[0]).toBeGreaterThan(currentDomain[0]); - expect(newDomain[1]).toBeLessThan(currentDomain[1]); - }); }); diff --git a/assets/src/scripts/components/hydrograph/store.js b/assets/src/scripts/components/hydrograph/store.js new file mode 100644 index 0000000000000000000000000000000000000000..8f663dfba0f8107f10cd122ef07143e1ae440b35 --- /dev/null +++ b/assets/src/scripts/components/hydrograph/store.js @@ -0,0 +1,137 @@ +const { timeFormat } = require('d3-time-format'); +const { applyMiddleware, createStore } = require('redux'); +const { default: thunk } = require('redux-thunk'); + +const { getPreviousYearTimeseries, getTimeseries } = require('../../models'); + +// Create a time formatting function from D3's timeFormat +const formatTime = timeFormat('%c %Z'); + + +export const Actions = { + retrieveTimeseries(siteno, startDate=null, endDate=null) { + return function (dispatch) { + return getTimeseries({sites: [siteno], startDate, endDate}).then( + series => { + dispatch(Actions.addTimeseries('current', siteno, series[0])); + + // Trigger a call to get last year's data + const startTime = series[0].seriesStartDate; + const endTime = series[0].seriesEndDate; + dispatch(Actions.retrieveCompareTimeseries(siteno, startTime, endTime)); + }, + () => dispatch(Actions.resetTimeseries('current')) + ); + }; + }, + retrieveCompareTimeseries(site, startTime, endTime) { + return function (dispatch) { + return getPreviousYearTimeseries({site, startTime, endTime}).then( + series => dispatch(Actions.addTimeseries('compare', site, series[0], false)), + () => dispatch(Actions.resetTimeseries('compare')) + ); + }; + }, + toggleTimeseries(key, show) { + return { + type: 'TOGGLE_TIMESERIES', + key, + show + }; + }, + addTimeseries(key, siteno, data, show=true) { + return { + type: 'ADD_TIMESERIES', + key, + siteno, + data, + show + }; + }, + resetTimeseries(key) { + return { + type: 'RESET_TIMESERIES', + key + }; + } +}; + + +const timeSeriesReducer = function (state={}, action) { + switch (action.type) { + case 'ADD_TIMESERIES': + // If data is valid + if (action.data && action.data.values && + !action.data.values.some(d => d.value === -999999)) { + return { + ...state, + tsData: { + ...state.tsData, + [action.key]: { + ...state.tsData[action.key], + data: action.data.values, + show: action.show + } + }, + title: action.data.variableName, + desc: action.data.variableDescription + ' from ' + + formatTime(action.data.seriesStartDate) + ' to ' + + formatTime(action.data.seriesEndDate) + }; + } else { + return state.dispatch(Actions.resetTimeseries()); + } + + case 'TOGGLE_TIMESERIES': + return { + ...state, + tsData: { + ...state.tsData, + [action.key]: { + ...state.tsData[action.key], + show: action.show + } + } + }; + + case 'RESET_TIMESERIES': + return { + ...state, + tsData: { + ...state.tsData, + [action.key]: { + ...state.tsData[action.key], + data: [], + show: false + } + } + }; + + default: + return state; + } +}; + + +export const configureStore = function (initialState) { + initialState = { + tsData: { + current: { + data: [], + show: true + }, + compare: { + data: [], + show: false + } + }, + title: '', + desc: '', + ...initialState + }; + return createStore( + timeSeriesReducer, + initialState, + applyMiddleware(thunk) + ); +}; diff --git a/package-lock.json b/package-lock.json index ffb3afabc95e826b54598760a32fa3def9ffeca4..c22f670582f72dd5fd7f5a403b6ab46d3da57c45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -656,6 +656,12 @@ "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", "dev": true }, + "babel-plugin-syntax-object-rest-spread": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=", + "dev": true + }, "babel-plugin-syntax-trailing-function-commas": { "version": "6.22.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", @@ -918,6 +924,16 @@ "babel-runtime": "6.26.0" } }, + "babel-plugin-transform-object-rest-spread": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz", + "integrity": "sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=", + "dev": true, + "requires": { + "babel-plugin-syntax-object-rest-spread": "6.13.0", + "babel-runtime": "6.26.0" + } + }, "babel-plugin-transform-regenerator": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", @@ -2723,6 +2739,14 @@ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.0.tgz", "integrity": "sha1-ZkLlBsb6OmSFldKyRpeIqNElKdM=" }, + "d3-redux": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/d3-redux/-/d3-redux-0.0.6.tgz", + "integrity": "sha512-vU5oQQKZUD/9pmhQ/GoECbbU6tFHzoKQaskuLJtX3ORDLXYJzrf2iFo+FJKf43FQIK/rE9RyogKYlHXngpq0CQ==", + "requires": { + "d3-selection": "1.2.0" + } + }, "d3-request": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/d3-request/-/d3-request-1.0.6.tgz", @@ -6353,8 +6377,7 @@ "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", - "dev": true + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" }, "js-yaml": { "version": "3.10.0", @@ -6879,8 +6902,12 @@ "lodash": { "version": "4.17.4", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", - "dev": true + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" + }, + "lodash-es": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.4.tgz", + "integrity": "sha1-3MHXVS4VCgZABzupyzHXDwMpUOc=" }, "lodash.assign": { "version": "4.2.0", @@ -6899,6 +6926,12 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "dev": true + }, "lodash.memoize": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", @@ -7021,7 +7054,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", - "dev": true, "requires": { "js-tokens": "3.0.2" } @@ -9135,6 +9167,31 @@ "dev": true, "optional": true }, + "redux": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", + "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", + "requires": { + "lodash": "4.17.4", + "lodash-es": "4.17.4", + "loose-envify": "1.3.1", + "symbol-observable": "1.2.0" + } + }, + "redux-mock-store": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.1.tgz", + "integrity": "sha512-B+iZ98ESHw4EAWVLKUknQlop1OdLKOayGRmd6KavNtC0zoSsycD8hTt0hEr1eUTw2gmYJOdfBY5QAgZweTUcLQ==", + "dev": true, + "requires": { + "lodash.isplainobject": "4.0.6" + } + }, + "redux-thunk": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz", + "integrity": "sha1-5hWhbha0ehmlFXZhM9Hj6Zt4UuU=" + }, "regenerate": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz", @@ -9332,6 +9389,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=" + }, "resolve": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz", @@ -10399,6 +10461,11 @@ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, "syntax-error": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.3.0.tgz", diff --git a/package.json b/package.json index 95f015501a9ceb64f7c97b28d555500ec7a4922f..cbf57f9e5e3e71a3cf9b77f8728f6adfedd89ea7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "homepage": "https://github.com/usgs/waterdataui#readme", "devDependencies": { "babel-core": "^6.26.0", + "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-polyfill": "^6.26.0", "babel-preset-env": "^1.6.1", "babelify": "^8.0.0", @@ -57,6 +58,7 @@ "nodemon": "^1.14.11", "npm-run-all": "^4.1.2", "proxyquireify": "^3.2.1", + "redux-mock-store": "^1.5.1", "tinyify": "^2.4.0", "uglify-js": "^3.3.5", "uglifycss": "0.0.27", @@ -64,9 +66,13 @@ }, "dependencies": { "d3": "^4.12.2", + "d3-redux": "0.0.6", "esri-leaflet": "^2.1.2", "font-awesome": "^4.7.0", "leaflet": "^1.3.1", + "redux": "^3.7.2", + "redux-thunk": "^2.2.0", + "reselect": "^3.0.1", "uswds": "^1.4.4" }, "browserify": {