Newer
Older
/**
* Hydrograph charting module.
*/
const { bisector } = require('d3-array');
Naab, Daniel James
committed
const { mouse, select } = require('d3-selection');
const { line } = require('d3-shape');
const { timeFormat } = require('d3-time-format');
const { addSVGAccessibility, addSROnlyTable } = require('../../accessibility');
const { getTimeseries } = require('../../models');
const { appendAxes, createAxes } = require('./axes');
const { createScales } = 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
};
// 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');
Naab, Daniel James
committed
class Hydrograph {
/**
* @param {Array} data IV data as returned by models/getTimeseries
Bucknell, Mary S.
committed
* @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}) {
Bucknell, Mary S.
committed
this._yLabel = yLabel;
Bucknell, Mary S.
committed
this._desc = desc;
this._element = element;
if (this._data && this._data.length) {
this._drawChart();
} else {
this._drawMessage('No data is available for this site.');
}
}
_drawChart() {
// Set up parent element and SVG
this._element.innerHTML = '';
const 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}`);
Bucknell, Mary S.
committed
addSVGAccessibility({
Bucknell, Mary S.
committed
svg: svg,
title: this._title,
description: this._desc,
isInteractive: true
});
Bucknell, Mary S.
committed
addSROnlyTable({
container: this._element,
columnNames: [this._title, 'Time'],
data: this._data.map((value) => {
return [value.value, value.time];
})
});
// We'll actually be appending to a <g> element
const plot = svg.append('g')
.attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);
// Create x/y scaling for the full (100%) view.
const {xScale, yScale} = createScales(
this._data,
WIDTH - MARGIN.right,
HEIGHT - (MARGIN.top + MARGIN.bottom)
);
const {xAxis, yAxis} = createAxes(xScale, yScale, -WIDTH + MARGIN.right);
// Draw the graph components with the given scaling.
appendAxes({
plot,
xAxis,
yAxis,
xLoc: {x: 0, y: HEIGHT - (MARGIN.top + MARGIN.bottom)},
yLoc: {x: 0, y: 0},
yLabelLoc: {x: HEIGHT / -2 + MARGIN.top, y: -35},
Bucknell, Mary S.
committed
yTitle: this._yLabel
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
this._plotDataLine(plot, xScale, yScale);
this._plotTooltips(plot, xScale, yScale);
}
_drawMessage(message) {
// Set up parent element and SVG
this._element.innerHTML = '';
const alertBox = select(this._element)
.append('div')
.attr('class', 'usa-alert usa-alert-warning')
.append('div')
.attr('class', 'usa-alert-body');
alertBox.append('h3')
.attr('class', 'usa-alert-heading')
.html('Hydrograph Alert');
alertBox.append('p')
.html(message);
}
_plotDataLine(plot, xScale, yScale) {
const newLine = line()
.x(d => xScale(d.time))
.y(d => yScale(d.value));
plot.append('path')
.datum(this._data)
.classed('line', true)
.attr('d', newLine);
}
_plotTooltips(plot, xScale, yScale) {
// Create a node to hightlight the currently selected date/time.
let focus = plot.append('g')
.attr('class', 'focus')
.style('display', 'none');
focus.append('circle')
.attr('r', 7.5);
focus.append('text');
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 = xScale.invert(mouse(nodes[i])[0]);
let {datum, index} = this._getNearestTime(time);
// 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.
let isFirstHalf = index < this._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');
});
}
_getNearestTime(time) {
let index = bisectDate(this._data, time, 1);
let datum;
let d0 = this._data[index - 1];
let d1 = this._data[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
Naab, Daniel James
committed
function attachToNode(node, {siteno}) {
getTimeseries({sites: [siteno]}, series => {
let dataIsValid = series[0] && !series[0].values.some(d => d.value === -999999);
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) : ''
});
});
}
module.exports = {Hydrograph, attachToNode};