UNPKG

nightscout

Version:

Nightscout acts as a web-based CGM (Continuous Glucose Monitor) to allow multiple caregivers to remotely view a patients glucose data in realtime.

750 lines (587 loc) 27.4 kB
'use strict'; var _ = require('lodash'); var times = require('../times'); var d3locales = require('./d3locales'); var scrolling = false , scrollNow = 0 , scrollBrushExtent = null , scrollRange = null; var PADDING_BOTTOM = 30 , OPEN_TOP_HEIGHT = 8 , CONTEXT_MAX = 420 , CONTEXT_MIN = 36 , FOCUS_MAX = 510 , FOCUS_MIN = 30; var loadTime = Date.now(); function init (client, d3, $) { var chart = {}; var utils = client.utils; var renderer = client.renderer; var defs = d3.select('body').append('svg').append('defs'); // add defs for combo boluses var dashWidth = 5; defs.append('pattern') .attr('id', 'hash') .attr('patternUnits', 'userSpaceOnUse') .attr('width', 6) .attr('height', 6) .attr('x', 0) .attr('y', 0) .append('g') .style('fill', 'none') .style('stroke', '#0099ff') .style('stroke-width', 2) .append('path').attr('d', 'M0,0 l' + dashWidth + ',' + dashWidth) .append('path').attr('d', 'M' + dashWidth + ',0 l-' + dashWidth + ',' + dashWidth); // arrow head defs.append('marker') .attr('id', 'arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 5) .attr('refY', 0) .attr('markerWidth', 8) .attr('markerHeight', 8) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('class', 'arrowHead'); var localeFormatter = d3.timeFormatLocale(d3locales.locale(client.settings.language)); function beforeBrushStarted () { // go ahead and move the brush because // a single click will not execute the brush event var now = new Date(); var dx = chart.xScale2(now) - chart.xScale2(new Date(now.getTime() - client.focusRangeMS)); var cx = d3.mouse(this)[0]; var x0 = cx - dx / 2; var x1 = cx + dx / 2; var range = chart.xScale2.range(); var X0 = range[0]; var X1 = range[1]; var brush = x0 < X0 ? [X0, X0 + dx] : x1 > X1 ? [X1 - dx, X1] : [x0, x1]; chart.theBrush.call(chart.brush.move, brush); } function brushStarted () { // update the opacity of the context data points to brush extent chart.context.selectAll('circle') .data(client.entries) .style('opacity', 1); } function brushEnded () { // update the opacity of the context data points to brush extent var selectedRange = chart.createAdjustedRange(); var from = selectedRange[0].getTime(); var to = selectedRange[1].getTime(); chart.context.selectAll('circle') .data(client.entries) .style('opacity', function(d) { return renderer.highlightBrushPoints(d, from, to) }); } var extent = client.dataExtent(); var yScaleType; if (client.settings.scaleY === 'linear') { yScaleType = d3.scaleLinear; } else { yScaleType = d3.scaleLog; } var focusYDomain = [utils.scaleMgdl(FOCUS_MIN), utils.scaleMgdl(FOCUS_MAX)]; var contextYDomain = [utils.scaleMgdl(CONTEXT_MIN), utils.scaleMgdl(CONTEXT_MAX)]; function dynamicDomain () { // allow y-axis to extend all the way to the top of the basal area, but leave room to display highest value var mult = 1.15 , targetTop = client.settings.thresholds.bgTargetTop // filter to only use actual SGV's (not rawbg's) to set the view window. // can switch to Logarithmic (non-dynamic) to see anything that doesn't fit in the dynamicDomain , mgdlMax = d3.max(client.entries, function(d) { if (d.type === 'sgv') { return d.mgdl; } }); // use the 99th percentile instead of max to avoid rescaling for 1 flukey data point // need to sort client.entries by mgdl first //, mgdlMax = d3.quantile(client.entries, 0.99, function (d) { return d.mgdl; }); return [ utils.scaleMgdl(FOCUS_MIN) , Math.max(utils.scaleMgdl(mgdlMax * mult), utils.scaleMgdl(targetTop * mult)) ]; } function dynamicDomainOrElse (defaultDomain) { if (client.entries && (client.entries.length > 0) && (client.settings.scaleY === 'linear' || client.settings.scaleY === 'log-dynamic')) { return dynamicDomain(); } else { return defaultDomain; } } // define the parts of the axis that aren't dependent on width or height var xScale = chart.xScale = d3.scaleTime().domain(extent); focusYDomain = dynamicDomainOrElse(focusYDomain); var yScale = chart.yScale = yScaleType() .domain(focusYDomain); var xScale2 = chart.xScale2 = d3.scaleTime().domain(extent); contextYDomain = dynamicDomainOrElse(contextYDomain); var yScale2 = chart.yScale2 = yScaleType() .domain(contextYDomain); chart.xScaleBasals = d3.scaleTime().domain(extent); chart.yScaleBasals = d3.scaleLinear() .domain([0, 5]); var formatMillisecond = localeFormatter.format('.%L') , formatSecond = localeFormatter.format(':%S') , formatMinute = client.settings.timeFormat === 24 ? localeFormatter.format('%H:%M') : localeFormatter.format('%-I:%M') , formatHour = client.settings.timeFormat === 24 ? localeFormatter.format('%H:%M') : localeFormatter.format('%-I %p') , formatDay = localeFormatter.format('%a %d') , formatWeek = localeFormatter.format('%b %d') , formatMonth = localeFormatter.format('%B') , formatYear = localeFormatter.format('%Y'); var tickFormat = function(date) { return (d3.timeSecond(date) < date ? formatMillisecond : d3.timeMinute(date) < date ? formatSecond : d3.timeHour(date) < date ? formatMinute : d3.timeDay(date) < date ? formatHour : d3.timeMonth(date) < date ? (d3.timeWeek(date) < date ? formatDay : formatWeek) : d3.timeYear(date) < date ? formatMonth : formatYear)(date); }; var tickValues = client.ticks(client); chart.xAxis = d3.axisBottom(xScale) chart.xAxis = d3.axisBottom(xScale) .tickFormat(tickFormat) .ticks(6); chart.yAxis = d3.axisLeft(yScale) .tickFormat(d3.format('d')) .tickValues(tickValues); chart.xAxis2 = d3.axisBottom(xScale2) .tickFormat(tickFormat) .ticks(6); chart.yAxis2 = d3.axisRight(yScale2) .tickFormat(d3.format('d')) .tickValues(tickValues); d3.select('tick') .style('z-index', '10000'); // setup a brush chart.brush = d3.brushX() .on('start', brushStarted) .on('brush', function brush (time) { // layouting the graph causes a brushed event // ignore retro data load the first two seconds if (Date.now() - loadTime > 2000) client.loadRetroIfNeeded(); client.brushed(time); }) .on('end', brushEnded); chart.theBrush = null; chart.futureOpacity = (function() { var scale = d3.scaleLinear() .domain([times.mins(25).msecs, times.mins(60).msecs]) .range([0.8, 0.1]); return function(delta) { if (delta < 0) { return null; } else { return scale(delta); } }; })(); // create svg and g to contain the chart contents chart.charts = d3.select('#chartContainer').append('svg') .append('g') .attr('class', 'chartContainer'); chart.basals = chart.charts.append('g').attr('class', 'chart-basals'); chart.focus = chart.charts.append('g').attr('class', 'chart-focus'); chart.drag = chart.focus.append('g').attr('class', 'drag-area'); // create the x axis container chart.focus.append('g') .attr('class', 'x axis') .style("font-size", "16px"); // create the y axis container chart.focus.append('g') .attr('class', 'y axis') .style("font-size", "16px"); chart.context = chart.charts.append('g') .attr('class', 'chart-context'); // create the x axis container chart.context.append('g') .attr('class', 'x axis') .style("font-size", "16px"); // create the y axis container chart.context.append('g') .attr('class', 'y axis') .style("font-size", "16px"); chart.createBrushedRange = function() { var brushedRange = chart.theBrush && d3.brushSelection(chart.theBrush.node()) || null; var range = brushedRange && brushedRange.map(chart.xScale2.invert); var dataExtent = client.dataExtent(); if (!brushedRange) { // console.log('No current brushed range. Setting range to last focusRangeMS amount of available data'); range = dataExtent; range[0] = new Date(range[1].getTime() - client.focusRangeMS); } var end = range[1].getTime(); if (!chart.inRetroMode()) { end = client.now > dataExtent[1].getTime() ? client.now : dataExtent[1].getTime(); } range[1] = new Date(end); range[0] = new Date(end - client.focusRangeMS); // console.log('createBrushedRange: ', brushedRange, range); return range; } chart.createAdjustedRange = function() { var adjustedRange = chart.createBrushedRange(); adjustedRange[1] = new Date(Math.max(adjustedRange[1].getTime(), client.forecastTime)); return adjustedRange; } chart.inRetroMode = function inRetroMode () { var brushedRange = chart.theBrush && d3.brushSelection(chart.theBrush.node()) || null; if (!brushedRange || !chart.xScale2) { return false; } var maxTime = chart.xScale2.domain()[1].getTime(); var brushTime = chart.xScale2.invert(brushedRange[1]).getTime(); return brushTime < maxTime; }; // called for initial update and updates for resize chart.update = function update (init) { if (client.documentHidden && !init) { console.info('Document Hidden, not updating - ' + (new Date())); return; } chart.setForecastTime(); var chartContainer = $('#chartContainer'); if (chartContainer.length < 1) { console.warn('Unable to find element for #chartContainer'); return; } // get current data range var dataRange = client.dataExtent(); var chartContainerRect = chartContainer[0].getBoundingClientRect(); var chartWidth = chartContainerRect.width; var chartHeight = chartContainerRect.height - PADDING_BOTTOM; // get the height of each chart based on its container size ratio var focusHeight = chart.focusHeight = chartHeight * .7; var contextHeight = chart.contextHeight = chartHeight * .3; chart.basalsHeight = focusHeight / 4; // get current brush extent var currentRange = chart.createAdjustedRange(); var currentBrushExtent = chart.createBrushedRange(); // only redraw chart if chart size has changed var widthChanged = (chart.prevChartWidth !== chartWidth); if (widthChanged || (chart.prevChartHeight !== chartHeight)) { //if rotated if (widthChanged) { client.browserUtils.closeLastOpenedDrawer(); } chart.prevChartWidth = chartWidth; chart.prevChartHeight = chartHeight; //set the width and height of the SVG element chart.charts.attr('width', chartWidth) .attr('height', chartHeight + PADDING_BOTTOM); // ranges are based on the width and height available so reset chart.xScale.range([0, chartWidth]); chart.xScale2.range([0, chartWidth]); chart.xScaleBasals.range([0, chartWidth]); chart.yScale.range([focusHeight, 0]); chart.yScale2.range([contextHeight, 0]); chart.yScaleBasals.range([0, focusHeight / 4]); if (init) { // if first run then just display axis with no transition chart.focus.select('.x') .attr('transform', 'translate(0,' + focusHeight + ')') .call(chart.xAxis); chart.focus.select('.y') .attr('transform', 'translate(' + chartWidth + ',0)') .call(chart.yAxis); // if first run then just display axis with no transition chart.context .attr('transform', 'translate(0,' + focusHeight + ')') chart.context.select('.x') .attr('transform', 'translate(0,' + contextHeight + ')') .call(chart.xAxis2); chart.theBrush = chart.context.append('g') .attr('class', 'x brush') .call(chart.brush) .call(g => g.select(".overlay") .datum({ type: 'selection' }) .on('mousedown touchstart', beforeBrushStarted)); chart.theBrush.selectAll('rect') .attr('y', 0) .attr('height', contextHeight) .attr('width', '100%'); // disable resizing of brush chart.context.select('.x.brush').select('.overlay').style('cursor', 'move'); chart.context.select('.x.brush').selectAll('.handle') .style('cursor', 'move'); chart.context.select('.x.brush').select('.selection') .style('visibility', 'hidden'); // add a line that marks the current time chart.focus.append('line') .attr('class', 'now-line') .attr('x1', chart.xScale(new Date(client.now))) .attr('y1', chart.yScale(focusYDomain[0])) .attr('x2', chart.xScale(new Date(client.now))) .attr('y2', chart.yScale(focusYDomain[1])) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); // add a y-axis line that shows the high bg threshold chart.focus.append('line') .attr('class', 'high-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh))) .style('stroke-dasharray', ('1, 6')) .attr('stroke', '#777'); // add a y-axis line that shows the high bg threshold chart.focus.append('line') .attr('class', 'target-top-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); // add a y-axis line that shows the low bg threshold chart.focus.append('line') .attr('class', 'target-bottom-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); // add a y-axis line that shows the low bg threshold chart.focus.append('line') .attr('class', 'low-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow))) .style('stroke-dasharray', ('1, 6')) .attr('stroke', '#777'); // add a y-axis line that opens up the brush extent from the context to the focus chart.context.append('line') .attr('class', 'open-top') .attr('stroke', '#111') .attr('stroke-width', OPEN_TOP_HEIGHT); // add a x-axis line that closes the the brush container on left side chart.context.append('line') .attr('class', 'open-left') .attr('stroke', 'white'); // add a x-axis line that closes the the brush container on right side chart.context.append('line') .attr('class', 'open-right') .attr('stroke', 'white'); // add a line that marks the current time chart.context.append('line') .attr('class', 'now-line') .attr('x1', chart.xScale(new Date(client.now))) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale(new Date(client.now))) .attr('y2', chart.yScale2(contextYDomain[1])) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); // add a y-axis line that shows the high bg threshold chart.context.append('line') .attr('class', 'high-line') .attr('x1', chart.xScale(dataRange[0])) .attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .attr('x2', chart.xScale(dataRange[1])) .attr('y2', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); // add a y-axis line that shows the low bg threshold chart.context.append('line') .attr('class', 'low-line') .attr('x1', chart.xScale(dataRange[0])) .attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .attr('x2', chart.xScale(dataRange[1])) .attr('y2', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .style('stroke-dasharray', ('3, 3')) .attr('stroke', 'grey'); } else { // for subsequent updates use a transition to animate the axis to the new position chart.focus.select('.x') .attr('transform', 'translate(0,' + focusHeight + ')') .call(chart.xAxis); chart.focus.select('.y') .attr('transform', 'translate(' + chartWidth + ', 0)') .call(chart.yAxis); chart.context .attr('transform', 'translate(0,' + focusHeight + ')') chart.context.select('.x') .attr('transform', 'translate(0,' + contextHeight + ')') .call(chart.xAxis2); chart.basals; // reset brush location chart.theBrush.selectAll('rect') .attr('y', 0) .attr('height', contextHeight); // console.log('chart.update(): Redrawing old brush with new dimensions: ', currentBrushExtent); // redraw old brush with new dimensions chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2)); // transition lines to correct location chart.focus.select('.high-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgHigh))); chart.focus.select('.target-top-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))); chart.focus.select('.target-bottom-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))); chart.focus.select('.low-line') .attr('x1', chart.xScale.range()[0]) .attr('y1', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow))) .attr('x2', chart.xScale.range()[1]) .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow))); // transition open-top line to correct location chart.context.select('.open-top') .attr('x1', chart.xScale2(currentRange[0])) .attr('y1', chart.yScale2(utils.scaleMgdl(CONTEXT_MAX)) + Math.floor(OPEN_TOP_HEIGHT/2.0)-1) .attr('x2', chart.xScale2(currentRange[1])) .attr('y2', chart.yScale2(utils.scaleMgdl(CONTEXT_MAX)) + Math.floor(OPEN_TOP_HEIGHT/2.0)-1); // transition open-left line to correct location chart.context.select('.open-left') .attr('x1', chart.xScale2(currentRange[0])) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale2(currentRange[0])) .attr('y2', chart.yScale2(contextYDomain[1])); // transition open-right line to correct location chart.context.select('.open-right') .attr('x1', chart.xScale2(currentRange[1])) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale2(currentRange[1])) .attr('y2', chart.yScale2(contextYDomain[1])); // transition high line to correct location chart.context.select('.high-line') .attr('x1', chart.xScale2(dataRange[0])) .attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))) .attr('x2', chart.xScale2(dataRange[1])) .attr('y2', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetTop))); // transition low line to correct location chart.context.select('.low-line') .attr('x1', chart.xScale2(dataRange[0])) .attr('y1', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))) .attr('x2', chart.xScale2(dataRange[1])) .attr('y2', chart.yScale2(utils.scaleMgdl(client.settings.thresholds.bgTargetBottom))); } } chart.updateContext(dataRange); chart.xScaleBasals.domain(dataRange); // console.log('chart.update(): Redrawing brush due to update: ', currentBrushExtent); chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2)); }; chart.updateContext = function(dataRange_) { if (client.documentHidden) { console.info('Document Hidden, not updating - ' + (new Date())); return; } // get current data range var dataRange = dataRange_ || client.dataExtent(); // update domain chart.xScale2.domain(dataRange); renderer.addContextCircles(); // update x axis domain chart.context.select('.x').call(chart.xAxis2); }; function scrollUpdate () { var nowDate = scrollNow; var currentBrushExtent = scrollBrushExtent; var currentRange = scrollRange; chart.setForecastTime(); chart.xScale.domain(currentRange); focusYDomain = dynamicDomainOrElse(focusYDomain); chart.yScale.domain(focusYDomain); chart.xScaleBasals.domain(currentRange); // remove all insulin/carb treatment bubbles so that they can be redrawn to correct location d3.selectAll('.path').remove(); // transition open-top line to correct location chart.context.select('.open-top') .attr('x1', chart.xScale2(currentRange[0])) .attr('y1', chart.yScale2(contextYDomain[1]) + Math.floor(OPEN_TOP_HEIGHT / 2.0)-1) .attr('x2', chart.xScale2(currentRange[1])) .attr('y2', chart.yScale2(contextYDomain[1]) + Math.floor(OPEN_TOP_HEIGHT / 2.0)-1); // transition open-left line to correct location chart.context.select('.open-left') .attr('x1', chart.xScale2(currentRange[0])) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale2(currentRange[0])) .attr('y2', chart.yScale2(contextYDomain[1])); // transition open-right line to correct location chart.context.select('.open-right') .attr('x1', chart.xScale2(currentRange[1])) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale2(currentRange[1])) .attr('y2', chart.yScale2(contextYDomain[1])); chart.focus.select('.now-line') .attr('x1', chart.xScale(nowDate)) .attr('y1', chart.yScale(focusYDomain[0])) .attr('x2', chart.xScale(nowDate)) .attr('y2', chart.yScale(focusYDomain[1])); chart.context.select('.now-line') .attr('x1', chart.xScale2(currentBrushExtent[1])) .attr('y1', chart.yScale2(contextYDomain[0])) .attr('x2', chart.xScale2(currentBrushExtent[1])) .attr('y2', chart.yScale2(contextYDomain[1])); // update x,y axis chart.focus.select('.x.axis').call(chart.xAxis); chart.focus.select('.y.axis').call(chart.yAxis); renderer.addBasals(client); renderer.addFocusCircles(); renderer.addTreatmentCircles(nowDate); renderer.addTreatmentProfiles(client); renderer.drawTreatments(client); // console.log('scrollUpdate(): Redrawing brush due to update: ', currentBrushExtent); chart.theBrush.call(chart.brush.move, currentBrushExtent.map(chart.xScale2)); scrolling = false; } chart.scroll = function scroll (nowDate) { scrollNow = nowDate; scrollBrushExtent = chart.createBrushedRange(); scrollRange = chart.createAdjustedRange(); if (!scrolling) { requestAnimationFrame(scrollUpdate); } scrolling = true; }; chart.getMaxForecastMills = function getMaxForecastMills () { // limit lookahead to the same as lookback var selectedRange = chart.createBrushedRange(); var to = selectedRange[1].getTime(); return to + client.focusRangeMS; }; chart.getForecastData = function getForecastData () { var maxForecastAge = chart.getMaxForecastMills(); var pointTypes = client.settings.showForecast.split(' '); var points = pointTypes.reduce( function (points, type) { /* eslint-disable-next-line security/detect-object-injection */ // verified false positive return points.concat(client.sbx.pluginBase.forecastPoints[type] || []); }, [] ); return _.filter(points, function isShown (point) { return point.mills < maxForecastAge; }); }; chart.setForecastTime = function setForecastTime () { if (client.sbx.pluginBase.forecastPoints) { var shownForecastPoints = chart.getForecastData(); // Get maximum time we will allow projected forward in time // based on the number of hours the user has selected to show. var maxForecastMills = chart.getMaxForecastMills(); var selectedRange = chart.createBrushedRange(); var to = selectedRange[1].getTime(); // Default min forecast projection times to the default amount of time to forecast var minForecastMills = to + client.defaultForecastTime; var availForecastMills = 0; // Determine what the maximum forecast time is that is available in the forecast data if (shownForecastPoints.length > 0) { availForecastMills = _.max(_.map(shownForecastPoints, function(point) { return point.mills })); } // Limit the amount shown to the maximum time allowed to be projected forward based // on the number of hours the user has selected to show var forecastMills = Math.min(availForecastMills, maxForecastMills); // Don't allow the forecast time to go below the minimum forecast time client.forecastTime = Math.max(forecastMills, minForecastMills); } }; return chart; } module.exports = init;