From ccd9d7c2bf76882f57d29161fe69b0db53124f54 Mon Sep 17 00:00:00 2001 From: Paul Felix Date: Thu, 25 Aug 2011 14:28:11 -0700 Subject: [PATCH] A range selector widget for dygraphs. This activates a more discoverable UI for panning and zooming, very similar to the one used by the Annotated Timeline widget on Google Finance. Enable it by passing 'showRangeSelector: true' in the constructor options. --- auto_tests/tests/range_selector.js | 140 +++++++++ auto_tests/tests/tickers.js | 4 + dygraph-canvas.js | 57 +--- dygraph-dev.js | 5 +- dygraph-layout.js | 75 ++++- dygraph-options-reference.js | 26 +- dygraph-range-selector.js | 606 +++++++++++++++++++++++++++++++++++++ dygraph-tickers.js | 2 +- dygraph-utils.js | 43 ++- dygraph.js | 49 ++- generate-combined.sh | 1 + jsTestDriver.conf | 1 + tests/range-selector.html | 64 ++++ 13 files changed, 982 insertions(+), 91 deletions(-) create mode 100644 auto_tests/tests/range_selector.js create mode 100644 dygraph-range-selector.js create mode 100644 tests/range-selector.html diff --git a/auto_tests/tests/range_selector.js b/auto_tests/tests/range_selector.js new file mode 100644 index 0000000..4ddfcb1 --- /dev/null +++ b/auto_tests/tests/range_selector.js @@ -0,0 +1,140 @@ +// Copyright 2011 Google Inc. All Rights Reserved. + +/** + * @fileoverview Regression tests for range selector. + * @author paul.eric.felix@gmail.com (Paul Felix) + */ +var RangeSelectorTestCase = TestCase("range-selector"); + +RangeSelectorTestCase.prototype.setUp = function() { + document.body.innerHTML = "
"; +}; + +RangeSelectorTestCase.prototype.tearDown = function() { +}; + +RangeSelectorTestCase.prototype.testRangeSelector = function() { + var opts = { + width: 480, + height: 320, + showRangeSelector: true + }; + var data = [ + [1, 10], + [2, 15], + [3, 10], + [4, 15], + [5, 10], + [6, 15], + [7, 10], + [8, 15], + [9, 10] + ]; + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + this.assertGraphExistence(g, graph); +}; + +RangeSelectorTestCase.prototype.testRangeSelectorWithErrorBars = function() { + var opts = { + width: 480, + height: 320, + errorBars: true, + showRangeSelector: true + }; + var data = [ + [1, [10, 10]], + [2, [15, 10]], + [3, [10, 10]], + [4, [15, 10]], + [5, [10, 10]], + [6, [15, 20]], + [7, [10, 20]], + [8, [15, 20]], + [9, [10, 20]] + ]; + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + this.assertGraphExistence(g, graph); +}; + +RangeSelectorTestCase.prototype.testRangeSelectorWithCustomBars = function() { + var opts = { + width: 480, + height: 320, + customBars: true, + showRangeSelector: true + }; + var data = [ + [1, [10, 10, 100]], + [2, [15, 20, 110]], + [3, [10, 30, 100]], + [4, [15, 40, 110]], + [5, [10, 120, 100]], + [6, [15, 50, 110]], + [7, [10, 70, 100]], + [8, [15, 90, 110]], + [9, [10, 50, 100]] + ]; + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + this.assertGraphExistence(g, graph); +}; + +RangeSelectorTestCase.prototype.testRangeSelectorWithLogScale = function() { + var opts = { + width: 480, + height: 320, + logscale: true, + showRangeSelector: true + }; + var data = [ + [1, 10], + [2, 15], + [3, 10], + [4, 15], + [5, 10], + [6, 15], + [7, 10], + [8, 15], + [9, 10] + ]; + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + this.assertGraphExistence(g, graph); +}; + +RangeSelectorTestCase.prototype.testRangeSelectorOptions = function() { + var opts = { + width: 480, + height: 320, + showRangeSelector: true, + rangeSelectorHeight: 30, + rangeSelectorPlotFillColor: 'lightyellow', + rangeSelectorPlotStyleColor: 'yellow' + }; + var data = [ + [1, 10], + [2, 15], + [3, 10], + [4, 15], + [5, 10], + [6, 15], + [7, 10], + [8, 15], + [9, 10] + ]; + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + this.assertGraphExistence(g, graph); +}; + +RangeSelectorTestCase.prototype.assertGraphExistence = function(g, graph) { + assertNotNull(g); + var zoomhandles = graph.getElementsByClassName('dygraph_rangesel_zoomhandle'); + assertEquals(2, zoomhandles.length); + var bgcanvas = graph.getElementsByClassName('dygraph_rangesel_bgcanvas'); + assertEquals(1, bgcanvas.length); + var fgcanvas = graph.getElementsByClassName('dygraph_rangesel_fgcanvas'); + assertEquals(1, fgcanvas.length); +} diff --git a/auto_tests/tests/tickers.js b/auto_tests/tests/tickers.js index c3ae456..2d7fffc 100644 --- a/auto_tests/tests/tickers.js +++ b/auto_tests/tests/tickers.js @@ -69,6 +69,10 @@ TickerTestCase.prototype.testAllDateTickers = function() { assertEquals([{"v":1167627600000,"label":"Jan 07"},{"v":1175400000000,"label":"Apr 07"},{"v":1183262400000,"label":"Jul 07"},{"v":1191211200000,"label":"Oct 07"}], Dygraph.dateTicker(1167627600000, 1199077200000, 480, this.createOptionsViewForAxis('x'))); assertEquals([{"v":1167627600000,"label":"Jan 07"},{"v":1175400000000,"label":"Apr 07"},{"v":1183262400000,"label":"Jul 07"},{"v":1191211200000,"label":"Oct 07"}], Dygraph.dateTicker(1167627600000, 1199077200000, 600, this.createOptionsViewForAxis('x'))); // assertEquals([{"v":1167627600000,"label":"Jan 07"},{"v":1170306000000,"label":"Feb 07"},{"v":1172725200000,"label":"Mar 07"},{"v":1175400000000,"label":"Apr 07"},{"v":1177992000000,"label":"May 07"},{"v":1180670400000,"label":"Jun 07"},{"v":1183262400000,"label":"Jul 07"},{"v":1185940800000,"label":"Aug 07"},{"v":1188619200000,"label":"Sep 07"},{"v":1191211200000,"label":"Oct 07"},{"v":1193889600000,"label":"Nov 07"},{"v":1196485200000,"label":"Dec 07"},{"v":null,"label":"undefined NaN"}], Dygraph.dateTicker(1167627600000, 1199077200000, 800, this.createOptionsViewForAxis('x'))); + + // Test monthly for time span starting Dec 31, 2010. + assertEquals([{"v":1293858000000,"label":"Jan 11"},{"v":1296536400000,"label":"Feb 11"},{"v":1298955600000,"label":"Mar 11"},{"v":1301630400000,"label":"Apr 11"},{"v":1304222400000,"label":"May 11"},{"v":1306900800000,"label":"Jun 11"},{"v":1309492800000,"label":"Jul 11"},{"v":1312171200000,"label":"Aug 11"}], Dygraph.dateTicker(1293771600000, 1312862400000, 727, this.createOptionsViewForAxis('x'))); + assertEquals([{"v":1201842000000,"label":"01Feb"},{"v":1201928400000,"label":"02Feb"},{"v":1202014800000,"label":"03Feb"},{"v":1202101200000,"label":"04Feb"},{"v":1202187600000,"label":"05Feb"},{"v":1202274000000,"label":"06Feb"}], Dygraph.dateTicker(1201842000000, 1202274000000, 700, this.createOptionsViewForAxis('x'))); assertEquals([{"v":1210132800000,"label":"07May"},{"v":1210154400000,"label":"06:00"},{"v":1210176000000,"label":"12:00"},{"v":1210197600000,"label":"18:00"},{"v":1210219200000,"label":"08May"},{"v":1210240800000,"label":"06:00"},{"v":1210262400000,"label":"12:00"},{"v":1210284000000,"label":"18:00"},{"v":1210305600000,"label":"09May"}], Dygraph.dateTicker(1210132800000, 1210305600000, 480, this.createOptionsViewForAxis('x'))); assertEquals([{"v":1210132800000,"label":"07May"},{"v":1210219200000,"label":"08May"},{"v":1210305600000,"label":"09May"},{"v":1210392000000,"label":"10May"},{"v":1210478400000,"label":"11May"}], Dygraph.dateTicker(1210132800000, 1210478400000, 480, this.createOptionsViewForAxis('x'))); diff --git a/dygraph-canvas.js b/dygraph-canvas.js index 90d1b4e..f4140e6 100644 --- a/dygraph-canvas.js +++ b/dygraph-canvas.js @@ -44,7 +44,7 @@ DygraphCanvasRenderer = function(dygraph, element, elementContext, layout) { this.annotations = new Array(); this.chartLabels = {}; - this.area = this.computeArea_(); + this.area = layout.plotArea; this.container.style.position = "relative"; this.container.style.width = this.width + "px"; @@ -65,56 +65,6 @@ DygraphCanvasRenderer.prototype.attr_ = function(x) { return this.dygraph_.attr_(x); }; -// Compute the box which the chart should be drawn in. This is the canvas's -// box, less space needed for axis and chart labels. -// TODO(danvk): this belongs in DygraphLayout. -DygraphCanvasRenderer.prototype.computeArea_ = function() { - var area = { - // TODO(danvk): per-axis setting. - x: 0, - y: 0 - }; - if (this.attr_('drawYAxis')) { - area.x = this.attr_('yAxisLabelWidth') + 2 * this.attr_('axisTickSize'); - } - - area.w = this.width - area.x - this.attr_('rightGap'); - area.h = this.height; - if (this.attr_('drawXAxis')) { - if (this.attr_('xAxisHeight')) { - area.h -= this.attr_('xAxisHeight'); - } else { - area.h -= this.attr_('axisLabelFontSize') + 2 * this.attr_('axisTickSize'); - } - } - - // Shrink the drawing area to accomodate additional y-axes. - if (this.dygraph_.numAxes() == 2) { - // TODO(danvk): per-axis setting. - area.w -= (this.attr_('yAxisLabelWidth') + 2 * this.attr_('axisTickSize')); - } else if (this.dygraph_.numAxes() > 2) { - this.dygraph_.error("Only two y-axes are supported at this time. (Trying " + - "to use " + this.dygraph_.numAxes() + ")"); - } - - // Add space for chart labels: title, xlabel and ylabel. - if (this.attr_('title')) { - area.h -= this.attr_('titleHeight'); - area.y += this.attr_('titleHeight'); - } - if (this.attr_('xlabel')) { - area.h -= this.attr_('xLabelHeight'); - } - if (this.attr_('ylabel')) { - // It would make sense to shift the chart here to make room for the y-axis - // label, but the default yAxisLabelWidth is large enough that this results - // in overly-padded charts. The y-axis label should fit fine. If it - // doesn't, the yAxisLabelWidth option can be increased. - } - - return area; -}; - DygraphCanvasRenderer.prototype.clear = function() { if (this.isIE) { // VML takes a while to start up, so we just poll every this.IEDelay @@ -240,7 +190,7 @@ DygraphCanvasRenderer.prototype.render = function() { // Do the ordinary rendering, as before this._renderLineChart(); this._renderAxis(); - this._renderChartLabels(); + this._renderChartLabels(); this._renderAnnotations(); }; @@ -261,6 +211,7 @@ DygraphCanvasRenderer.prototype._renderAxis = function() { color: this.attr_('axisLabelColor'), width: this.attr_('axisLabelWidth') + "px", // height: this.attr_('axisLabelFontSize') + 2 + "px", + lineHeight: "normal", // Something other than "normal" line-height screws up label positioning. overflow: "hidden" }; var makeDiv = function(txt, axis, prec_axis) { @@ -602,7 +553,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { var isNullOrNaN = function(x) { return (x === null || isNaN(x)); }; - + // TODO(danvk): use this.attr_ for many of these. var context = this.elementContext; var fillAlpha = this.attr_('fillAlpha'); diff --git a/dygraph-dev.js b/dygraph-dev.js index 006eccc..1f6005a 100644 --- a/dygraph-dev.js +++ b/dygraph-dev.js @@ -24,8 +24,9 @@ "dygraph-utils.js", "dygraph-gviz.js", "dygraph-interaction-model.js", - "dygraph-options-reference.js", // Shouldn't be included in generate-combined.sh - "dygraph-tickers.js" + "dygraph-range-selector.js", + "dygraph-tickers.js", + "dygraph-options-reference.js" // Shouldn't be included in generate-combined.sh ]; for (var i = 0; i < source_files.length; i++) { diff --git a/dygraph-layout.js b/dygraph-layout.js index 17de467..915d235 100644 --- a/dygraph-layout.js +++ b/dygraph-layout.js @@ -30,6 +30,7 @@ DygraphLayout = function(dygraph) { this.datasets = new Array(); this.annotations = new Array(); this.yAxes_ = null; + this.plotArea = this.computePlotArea_(); // TODO(danvk): it's odd that xTicks_ and yTicks_ are inputs, but xticks and // yticks are outputs. Clean this up. @@ -45,6 +46,60 @@ DygraphLayout.prototype.addDataset = function(setname, set_xy) { this.datasets[setname] = set_xy; }; +// Compute the box which the chart should be drawn in. This is the canvas's +// box, less space needed for axis and chart labels. +DygraphLayout.prototype.computePlotArea_ = function() { + var area = { + // TODO(danvk): per-axis setting. + x: 0, + y: 0 + }; + if (this.attr_('drawYAxis')) { + area.x = this.attr_('yAxisLabelWidth') + 2 * this.attr_('axisTickSize'); + } + + area.w = this.dygraph_.width_ - area.x - this.attr_('rightGap'); + area.h = this.dygraph_.height_; + if (this.attr_('drawXAxis')) { + if (this.attr_('xAxisHeight')) { + area.h -= this.attr_('xAxisHeight'); + } else { + area.h -= this.attr_('axisLabelFontSize') + 2 * this.attr_('axisTickSize'); + } + } + + // Shrink the drawing area to accomodate additional y-axes. + if (this.dygraph_.numAxes() == 2) { + // TODO(danvk): per-axis setting. + area.w -= (this.attr_('yAxisLabelWidth') + 2 * this.attr_('axisTickSize')); + } else if (this.dygraph_.numAxes() > 2) { + this.dygraph_.error("Only two y-axes are supported at this time. (Trying " + + "to use " + this.dygraph_.numAxes() + ")"); + } + + // Add space for chart labels: title, xlabel and ylabel. + if (this.attr_('title')) { + area.h -= this.attr_('titleHeight'); + area.y += this.attr_('titleHeight'); + } + if (this.attr_('xlabel')) { + area.h -= this.attr_('xLabelHeight'); + } + if (this.attr_('ylabel')) { + // It would make sense to shift the chart here to make room for the y-axis + // label, but the default yAxisLabelWidth is large enough that this results + // in overly-padded charts. The y-axis label should fit fine. If it + // doesn't, the yAxisLabelWidth option can be increased. + } + + // Add space for range selector, if needed. + if (this.attr_('showRangeSelector')) { + area.h -= this.attr_('rangeSelectorHeight') + 4; + } + + return area; +}; + DygraphLayout.prototype.setAnnotations = function(ann) { // The Dygraph object's annotations aren't parsed. We parse them here and // save a copy. If there is no parser, then the user must be using raw format. @@ -101,7 +156,7 @@ DygraphLayout.prototype._evaluateLimits = function() { if (series.length > 1) { var x1 = series[0][0]; if (!this.minxval || x1 < this.minxval) this.minxval = x1; - + var x2 = series[series.length - 1][0]; if (!this.maxxval || x2 > this.maxxval) this.maxxval = x2; } @@ -254,7 +309,7 @@ DygraphLayout.prototype._evaluateAnnotations = function() { if (!this.annotations || !this.annotations.length) { return; } - + // TODO(antrob): loop through annotations not points. for (var i = 0; i < this.points.length; i++) { var p = this.points[i]; @@ -280,25 +335,25 @@ DygraphLayout.prototype.removeAllDatasets = function() { */ DygraphLayout.prototype.unstackPointAtIndex = function(idx) { var point = this.points[idx]; - + // Clone the point since we modify it - var unstackedPoint = {}; + var unstackedPoint = {}; for (var i in point) { unstackedPoint[i] = point[i]; } - + if (!this.attr_("stackedGraph")) { return unstackedPoint; } - - // The unstacked yval is equal to the current yval minus the yval of the + + // The unstacked yval is equal to the current yval minus the yval of the // next point at the same xval. for (var i = idx+1; i < this.points.length; i++) { if (this.points[i].xval == point.xval) { - unstackedPoint.yval -= this.points[i].yval; + unstackedPoint.yval -= this.points[i].yval; break; } } - + return unstackedPoint; -} +} diff --git a/dygraph-options-reference.js b/dygraph-options-reference.js index 9991484..88ec2a1 100644 --- a/dygraph-options-reference.js +++ b/dygraph-options-reference.js @@ -581,6 +581,30 @@ Dygraph.OPTIONS_REFERENCE = // "labels": [ "Debugging" ], "type": "string", "description": "Set this option to log timing information. The value of the option will be logged along with the timimg, so that you can distinguish multiple dygraphs on the same page." + }, + "showRangeSelector": { + "default": "false", + "labels": ["Interactive Elements"], + "type": "boolean", + "description": "Show the range selector widget. This option can only be specified at Dygraph creation time." + }, + "rangeSelectorHeight": { + "default": "40", + "labels": ["Interactive Elements"], + "type": "integer", + "description": "Height, in pixels, of the range selector widget. This option can only be specified at Dygraph creation time." + }, + "rangeSelectorPlotStrokeColor": { + "default": "#808FAB", + "labels": ["Interactive Elements"], + "type": "string", + "description": "The range selector mini plot stroke color. This can be of the form \"#AABBCC\" or \"rgb(255,100,200)\" or \"yellow\". You can also specify null or \"\" to turn off stroke." + }, + "rangeSelectorPlotFillColor": { + "default": "#A7B1C4", + "labels": ["Interactive Elements"], + "type": "string", + "description": "The range selector mini plot fill color. This can be of the form \"#AABBCC\" or \"rgb(255,100,200)\" or \"yellow\". You can also specify null or \"\" to turn off fill." } } ; // @@ -593,7 +617,7 @@ Dygraph.OPTIONS_REFERENCE = // (function() { var warn = function(msg) { if (console) console.warn(msg); }; var flds = ['type', 'default', 'description']; - var valid_cats = [ + var valid_cats = [ 'Annotations', 'Axis display', 'Chart labels', diff --git a/dygraph-range-selector.js b/dygraph-range-selector.js new file mode 100644 index 0000000..525949b --- /dev/null +++ b/dygraph-range-selector.js @@ -0,0 +1,606 @@ +// Copyright 2011 Paul Felix (paul.eric.felix@gmail.com) +// All Rights Reserved. + +/** + * @fileoverview This file contains the DygraphRangeSelector class used to provide + * a timeline range selector widget for dygraphs. + */ + +/** + * The DygraphRangeSelector class provides a timeline range selector widget. + * @param {Dygraph} dygraph The dygraph object + * @constructor + */ +DygraphRangeSelector = function(dygraph) { + this.isIE_ = /MSIE/.test(navigator.userAgent) && !window.opera; + this.isUsingExcanvas = typeof(G_vmlCanvasManager) != 'undefined'; + this.dygraph_ = dygraph; + this.createCanvases_(); + this.createZoomHandles_(); + this.initInteraction_(); +}; + +/** + * Adds the range selector to the dygraph. + * @param {Object} graphDiv The container div for the range selector. + * @param {DygraphLayout} layout The DygraphLayout object for this graph. + */ +DygraphRangeSelector.prototype.addToGraph = function(graphDiv, layout) { + this.layout_ = layout; + this.resize_(); + graphDiv.appendChild(this.bgcanvas_); + graphDiv.appendChild(this.fgcanvas_); + graphDiv.appendChild(this.leftZoomHandle_); + graphDiv.appendChild(this.rightZoomHandle_); +}; + +/** + * Renders the static background portion of the range selector. + */ +DygraphRangeSelector.prototype.renderStaticLayer = function() { + this.resize_(); + this.drawStaticLayer_(); +}; + +/** + * Renders the interactive foreground portion of the range selector. + */ +DygraphRangeSelector.prototype.renderInteractiveLayer = function() { + if (this.isChangingRange_) { + return; + } + + // The zoom handle image may not be loaded yet. May need to try again later. + if (this.leftZoomHandle_.height == 0 && this.leftZoomHandle_.retryCount != 5) { + var self = this; + setTimeout(function() { self.renderInteractiveLayer(); }, 300); + var retryCount = this.leftZoomHandle_.retryCount; + this.leftZoomHandle_.retryCount = retryCount == undefined ? 1 : retryCount+1; + return; + } + + this.placeZoomHandles_(); + this.drawInteractiveLayer_(); +}; + +/** + * @private + * Resizes the range selector. + */ +DygraphRangeSelector.prototype.resize_ = function() { + function setCanvasRect(canvas, rect) { + canvas.style.top = rect.y + 'px'; + canvas.style.left = rect.x + 'px'; + canvas.width = rect.w; + canvas.height = rect.h; + canvas.style.width = canvas.width + 'px'; // for IE + canvas.style.height = canvas.height + 'px'; // for IE + }; + + var plotArea = this.layout_.plotArea; + var xAxisLabelHeight = this.attr_('axisLabelFontSize') + 2 * this.attr_('axisTickSize'); + this.canvasRect_ = { + x: plotArea.x, + y: plotArea.y + plotArea.h + xAxisLabelHeight + 4, + w: plotArea.w, + h: this.attr_('rangeSelectorHeight') + }; + + setCanvasRect(this.bgcanvas_, this.canvasRect_); + setCanvasRect(this.fgcanvas_, this.canvasRect_); +}; + +DygraphRangeSelector.prototype.attr_ = function(name) { + return this.dygraph_.attr_(name); +}; + +/** + * @private + * Creates the background and foreground canvases. + */ +DygraphRangeSelector.prototype.createCanvases_ = function() { + this.bgcanvas_ = Dygraph.createCanvas(); + this.bgcanvas_.className = 'dygraph_rangesel_bgcanvas'; + this.bgcanvas_.style.position = 'absolute'; + this.bgcanvas_ctx_ = Dygraph.getContext(this.bgcanvas_); + + this.fgcanvas_ = Dygraph.createCanvas(); + this.fgcanvas_.className = 'dygraph_rangesel_fgcanvas'; + this.fgcanvas_.style.position = 'absolute'; + this.fgcanvas_.style.cursor = 'default'; + this.fgcanvas_ctx_ = Dygraph.getContext(this.fgcanvas_); +}; + +/** + * @private + * Creates the zoom handle elements. + */ +DygraphRangeSelector.prototype.createZoomHandles_ = function() { + var img = new Image(); + img.className = 'dygraph_rangesel_zoomhandle'; + img.style.position = 'absolute'; + img.style.visibility = 'hidden'; // Initially hidden so they don't show up in the wrong place. + img.style.cursor = 'col-resize'; + img.src = 'data:image/png;base64,\ +iVBORw0KGgoAAAANSUhEUgAAAAkAAAAQCAYAAADESFVDAAAAAXNSR0IArs4c6QAAAAZiS0dEANAA\ +zwDP4Z7KegAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAAd0SU1FB9sHGw0cMqdt1UwAAAAZdEVYdENv\ +bW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAaElEQVQoz+3SsRFAQBCF4Z9WJM8KCDVwownl\ +6YXsTmCUsyKGkZzcl7zkz3YLkypgAnreFmDEpHkIwVOMfpdi9CEEN2nGpFdwD03yEqDtOgCaun7s\ +qSTDH32I1pQA2Pb9sZecAxc5r3IAb21d6878xsAAAAAASUVORK5CYII='; + + this.leftZoomHandle_ = img; + this.rightZoomHandle_ = img.cloneNode(false); +}; + +/** + * @private + * Sets up the interaction for the range selector. + */ +DygraphRangeSelector.prototype.initInteraction_ = function() { + var self = this; + var topElem = this.isIE_ ? document : window; + var xLast = 0; + var handle = null; + var isZooming = false; + var isPanning = false; + + function toXDataWindow(zoomHandleStatus) { + var xDataLimits = self.dygraph_.xAxisExtremes(); + var fact = (xDataLimits[1] - xDataLimits[0])/self.canvasRect_.w; + var xDataMin = xDataLimits[0] + (zoomHandleStatus.leftHandlePos - self.canvasRect_.x)*fact; + var xDataMax = xDataLimits[0] + (zoomHandleStatus.rightHandlePos - self.canvasRect_.x)*fact; + return [xDataMin, xDataMax]; + }; + + function onZoomStart(e) { + Dygraph.cancelEvent(e); + isZooming = true; + xLast = e.screenX; + handle = e.target ? e.target : e.srcElement; + Dygraph.addEvent(topElem, 'mousemove', onZoom); + Dygraph.addEvent(topElem, 'mouseup', onZoomEnd); + self.fgcanvas_.style.cursor = 'col-resize'; + }; + + function onZoom(e) { + if (!isZooming) { + return; + } + var delX = e.screenX - xLast; + if (Math.abs(delX) < 4) { + return; + } + xLast = e.screenX; + var zoomHandleStatus = self.getZoomHandleStatus_(); + var halfHandleWidth = Math.round(handle.width/2); + if (handle == self.leftZoomHandle_) { + var newPos = zoomHandleStatus.leftHandlePos + delX; + newPos = Math.min(newPos, zoomHandleStatus.rightHandlePos - handle.width - 3); + newPos = Math.max(newPos, self.canvasRect_.x); + } else { + var newPos = zoomHandleStatus.rightHandlePos + delX; + newPos = Math.min(newPos, self.canvasRect_.x + self.canvasRect_.w); + newPos = Math.max(newPos, zoomHandleStatus.leftHandlePos + handle.width + 3); + } + handle.style.left = (newPos - halfHandleWidth) + 'px'; + self.drawInteractiveLayer_(); + + // Zoom on the fly (if not using excanvas). + if (!self.isUsingExcanvas) { + doZoom(); + } + }; + + function onZoomEnd(e) { + if (!isZooming) { + return; + } + isZooming = false; + Dygraph.removeEvent(topElem, 'mousemove', onZoom); + Dygraph.removeEvent(topElem, 'mouseup', onZoomEnd); + self.fgcanvas_.style.cursor = 'default'; + + // If using excanvas, Zoom now. + if (self.isUsingExcanvas) { + doZoom(); + } + }; + + function doZoom() { + try { + var zoomHandleStatus = self.getZoomHandleStatus_(); + self.isChangingRange_ = true; + if (!zoomHandleStatus.isZoomed) { + self.dygraph_.doUnzoom_(); + } else { + var xDataWindow = toXDataWindow(zoomHandleStatus); + self.dygraph_.doZoomXDates_(xDataWindow[0], xDataWindow[1]); + } + } finally { + self.isChangingRange_ = false; + } + }; + + function isMouseInPanZone(e) { + // Getting clientX directly from the event is not accurate enough :( + var clientX = self.canvasRect_.x + (e.layerX != undefined ? e.layerX : e.offsetX); + var zoomHandleStatus = self.getZoomHandleStatus_(); + return (clientX > zoomHandleStatus.leftHandlePos && clientX < zoomHandleStatus.rightHandlePos); + }; + + function onPanStart(e) { + if (!isPanning && isMouseInPanZone(e) && self.getZoomHandleStatus_().isZoomed) { + Dygraph.cancelEvent(e); + isPanning = true; + xLast = e.screenX; + Dygraph.addEvent(topElem, 'mousemove', onPan); + Dygraph.addEvent(topElem, 'mouseup', onPanEnd); + } + }; + + function onPan(e) { + if (!isPanning) { + return; + } + + var delX = e.screenX - xLast; + if (Math.abs(delX) < 4) { + return; + } + xLast = e.screenX; + + // Move range view + var zoomHandleStatus = self.getZoomHandleStatus_(); + var leftHandlePos = zoomHandleStatus.leftHandlePos; + var rightHandlePos = zoomHandleStatus.rightHandlePos; + var rangeSize = rightHandlePos - leftHandlePos; + if (leftHandlePos + delX <= self.canvasRect_.x) { + leftHandlePos = self.canvasRect_.x; + rightHandlePos = leftHandlePos + rangeSize; + } else if (rightHandlePos + delX >= self.canvasRect_.x + self.canvasRect_.w) { + rightHandlePos = self.canvasRect_.x + self.canvasRect_.w; + leftHandlePos = rightHandlePos - rangeSize; + } else { + leftHandlePos += delX; + rightHandlePos += delX; + } + var halfHandleWidth = Math.round(self.leftZoomHandle_.width/2); + self.leftZoomHandle_.style.left = (leftHandlePos - halfHandleWidth) + 'px'; + self.rightZoomHandle_.style.left = (rightHandlePos - halfHandleWidth) + 'px'; + self.drawInteractiveLayer_(); + + // Do pan on the fly (if not using excanvas). + if (!self.isUsingExcanvas) { + doPan(); + } + }; + + function onPanEnd(e) { + if (!isPanning) { + return; + } + isPanning = false; + Dygraph.removeEvent(topElem, 'mousemove', onPan); + Dygraph.removeEvent(topElem, 'mouseup', onPanEnd); + // If using excanvas, do pan now. + if (self.isUsingExcanvas) { + doPan(); + } + }; + + function doPan() { + try { + self.isChangingRange_ = true; + self.dygraph_.dateWindow_ = toXDataWindow(self.getZoomHandleStatus_()); + self.dygraph_.drawGraph_(false); + } finally { + self.isChangingRange_ = false; + } + }; + + function onCanvasMouseMove(e) { + if (isZooming || isPanning) { + return; + } + var cursor = isMouseInPanZone(e) ? 'move' : 'default'; + if (cursor != self.fgcanvas_.style.cursor) { + self.fgcanvas_.style.cursor = cursor; + } + }; + + var interactionModel = { + mousedown: function(event, g, context) { + context.initializeMouseDown(event, g, context); + Dygraph.startPan(event, g, context); + }, + mousemove: function(event, g, context) { + if (context.isPanning) { + Dygraph.movePan(event, g, context); + } + }, + mouseup: function(event, g, context) { + if (context.isPanning) { + Dygraph.endPan(event, g, context); + } + } + }; + + this.dygraph_.attrs_.interactionModel = interactionModel; + this.dygraph_.attrs_.panEdgeFraction = .0001; + + Dygraph.addEvent(this.leftZoomHandle_, 'dragstart', onZoomStart); + Dygraph.addEvent(this.rightZoomHandle_, 'dragstart', onZoomStart); + Dygraph.addEvent(this.fgcanvas_, 'mousedown', onPanStart); + Dygraph.addEvent(this.fgcanvas_, 'mousemove', onCanvasMouseMove); +}; + +/** + * @private + * Draws the static layer in the background canvas. + */ +DygraphRangeSelector.prototype.drawStaticLayer_ = function() { + var ctx = this.bgcanvas_ctx_; + ctx.clearRect(0, 0, this.canvasRect_.w, this.canvasRect_.h); + var margin = .5; + try { + this.drawMiniPlot_(); + } catch(ex) { + } + ctx.strokeStyle = 'lightgray'; + if (false) { + ctx.strokeRect(margin, margin, this.canvasRect_.w-margin, this.canvasRect_.h-margin); + } else { + ctx.beginPath(); + ctx.moveTo(margin, margin); + ctx.lineTo(margin, this.canvasRect_.h-margin); + ctx.lineTo(this.canvasRect_.w-margin, this.canvasRect_.h-margin); + ctx.lineTo(this.canvasRect_.w-margin, margin); + ctx.stroke(); + } +}; + + +/** + * @private + * Draws the mini plot in the background canvas. + */ +DygraphRangeSelector.prototype.drawMiniPlot_ = function() { + var fillStyle = this.attr_('rangeSelectorPlotFillColor'); + var strokeStyle = this.attr_('rangeSelectorPlotStrokeColor'); + if (!fillStyle && !strokeStyle) { + return; + } + + var combinedSeriesData = this.computeCombinedSeriesAndLimits_(); + var yRange = combinedSeriesData.yMax - combinedSeriesData.yMin; + + // Draw the mini plot. + var ctx = this.bgcanvas_ctx_; + var margin = .5; + + var xExtremes = this.dygraph_.xAxisExtremes(); + var xRange = Math.max(xExtremes[1] - xExtremes[0], 1.e-30); + var xFact = (this.canvasRect_.w - margin)/xRange; + var yFact = (this.canvasRect_.h - margin)/yRange; + var canvasWidth = this.canvasRect_.w - margin; + var canvasHeight = this.canvasRect_.h - margin; + + ctx.beginPath(); + ctx.moveTo(margin, canvasHeight); + for (var i = 0; i < combinedSeriesData.data.length; i++) { + var dataPoint = combinedSeriesData.data[i]; + var x = (dataPoint[0] - xExtremes[0])*xFact; + var y = canvasHeight - (dataPoint[1] - combinedSeriesData.yMin)*yFact; + if (isFinite(x) && isFinite(y)) { + ctx.lineTo(x, y); + } + } + ctx.lineTo(canvasWidth, canvasHeight); + ctx.closePath(); + + if (fillStyle) { + var lingrad = this.bgcanvas_ctx_.createLinearGradient(0, 0, 0, canvasHeight); + lingrad.addColorStop(0, 'white'); + lingrad.addColorStop(1, fillStyle); + this.bgcanvas_ctx_.fillStyle = lingrad; + ctx.fill(); + } + + if (strokeStyle) { + this.bgcanvas_ctx_.strokeStyle = strokeStyle; + this.bgcanvas_ctx_.lineWidth = 1.5; + ctx.stroke(); + } +}; + +/** + * @private + * Computes and returns the combinded series data along with min/max for the mini plot. + * @return {Object} An object containing combinded series array, ymin, ymax. + */ +DygraphRangeSelector.prototype.computeCombinedSeriesAndLimits_ = function() { + var data = this.dygraph_.rawData_; + var logscale = this.attr_('logscale'); + + // Create a combined series (average of all series values). + var combinedSeries = []; + var sum; + var count; + var mutipleValues = typeof data[0][1] != 'number'; + + if (mutipleValues) { + sum = []; + count = []; + for (var k = 0; k < data[0][1].length; k++) { + sum.push(0); + count.push(0); + } + mutipleValues = true; + } + + for (var i = 0; i < data.length; i++) { + var dataPoint = data[i]; + var xVal = dataPoint[0]; + var yVal; + + if (mutipleValues) { + for (var k = 0; k < sum.length; k++) { + sum[k] = count[k] = 0; + } + } else { + sum = count = 0; + } + + for (var j = 1; j < dataPoint.length; j++) { + if (this.dygraph_.visibility()[j-1]) { + if (mutipleValues) { + for (var k = 0; k < sum.length; k++) { + var y = dataPoint[j][k]; + if (y == null || isNaN(y)) continue; + sum[k] += y; + count[k]++; + } + } else { + var y = dataPoint[j]; + if (y == null || isNaN(y)) continue; + sum += y; + count++; + } + } + } + + if (mutipleValues) { + for (var k = 0; k < sum.length; k++) { + sum[k] /= count[k]; + } + yVal = sum.slice(0); + } else { + yVal = sum/count; + } + + combinedSeries.push([xVal, yVal]); + } + + // Account for roll period, fractions. + combinedSeries = this.dygraph_.rollingAverage(combinedSeries, this.dygraph_.rollPeriod_); + + if (typeof combinedSeries[0][1] != 'number') { + for (var i = 0; i < combinedSeries.length; i++) { + var yVal = combinedSeries[i][1]; + combinedSeries[i][1] = yVal[0]; + } + } + + // Compute the y range. + var yMin = Number.MAX_VALUE; + var yMax = -Number.MAX_VALUE; + for (var i = 0; i < combinedSeries.length; i++) { + var yVal = combinedSeries[i][1]; + if (!logscale || yVal > 0) { + yMin = Math.min(yMin, yVal); + yMax = Math.max(yMax, yVal); + } + } + + // Convert Y data to log scale if needed. + // Also, expand the Y range to compress the mini plot a little. + var extraPercent = .25; + if (logscale) { + yMax = Dygraph.log10(yMax); + yMax += yMax*extraPercent; + yMin = Dygraph.log10(yMin); + for (var i = 0; i < combinedSeries.length; i++) { + combinedSeries[i][1] = Dygraph.log10(combinedSeries[i][1]); + } + } else { + var yExtra; + yRange = yMax - yMin; + if (yRange <= Number.MIN_VALUE) { + yExtra = yMax*extraPercent; + } else { + yExtra = yRange*extraPercent; + } + yMax += yExtra; + yMin -= yExtra; + } + + return {data: combinedSeries, yMin: yMin, yMax: yMax}; +}; + +/** + * @private + * Places the zoom handles in the proper position based on the current X data window. + */ +DygraphRangeSelector.prototype.placeZoomHandles_ = function() { + var xExtremes = this.dygraph_.xAxisExtremes(); + var xWindowLimits = this.dygraph_.xAxisRange(); + var xRange = xExtremes[1] - xExtremes[0]; + var leftPercent = Math.max(0, (xWindowLimits[0] - xExtremes[0])/xRange); + var rightPercent = Math.max(0, (xExtremes[1] - xWindowLimits[1])/xRange); + var leftCoord = this.canvasRect_.x + this.canvasRect_.w*leftPercent; + var rightCoord = this.canvasRect_.x + this.canvasRect_.w*(1 - rightPercent); + var handleTop = Math.round(Math.max(this.canvasRect_.y, this.canvasRect_.y + (this.canvasRect_.h - this.leftZoomHandle_.height)/2)); + var halfHandleWidth = Math.round(this.leftZoomHandle_.width/2); + this.leftZoomHandle_.style.left = Math.round(leftCoord - halfHandleWidth) + 'px'; + this.leftZoomHandle_.style.top = handleTop + 'px'; + this.rightZoomHandle_.style.left = Math.round(rightCoord - halfHandleWidth) + 'px'; + this.rightZoomHandle_.style.top = this.leftZoomHandle_.style.top; + + this.leftZoomHandle_.style.visibility = 'visible'; + this.rightZoomHandle_.style.visibility = 'visible'; +}; + +/** + * @private + * Draws the interactive layer in the foreground canvas. + */ +DygraphRangeSelector.prototype.drawInteractiveLayer_ = function() { + var ctx = this.fgcanvas_ctx_; + ctx.clearRect(0, 0, this.canvasRect_.w, this.canvasRect_.h); + var margin = 1; + var width = this.canvasRect_.w - margin; + var height = this.canvasRect_.h - margin; + var zoomHandleStatus = this.getZoomHandleStatus_(); + + ctx.strokeStyle = 'black'; + if (!zoomHandleStatus.isZoomed) { + ctx.beginPath(); + ctx.moveTo(margin, margin); + ctx.lineTo(margin, height); + ctx.lineTo(width, height); + ctx.lineTo(width, margin); + ctx.stroke(); + } else { + leftHandleCanvasPos = Math.max(margin, zoomHandleStatus.leftHandlePos - this.canvasRect_.x); + rightHandleCanvasPos = Math.min(width, zoomHandleStatus.rightHandlePos - this.canvasRect_.x); + + ctx.fillStyle = 'rgba(240, 240, 240, 0.6)'; + ctx.fillRect(margin, margin, leftHandleCanvasPos, height - margin); + ctx.fillRect(rightHandleCanvasPos, margin, width - rightHandleCanvasPos, height - margin); + + ctx.beginPath(); + ctx.moveTo(margin, margin); + ctx.lineTo(leftHandleCanvasPos, margin); + ctx.lineTo(leftHandleCanvasPos, height); + ctx.lineTo(rightHandleCanvasPos, height); + ctx.lineTo(rightHandleCanvasPos, margin); + ctx.lineTo(width, margin); + ctx.stroke(); + } +}; + +/** + * @private + * Returns the current zoom handle position information. + * @return {Object} The zoom handle status. + */ +DygraphRangeSelector.prototype.getZoomHandleStatus_ = function() { + var halfHandleWidth = Math.round(this.leftZoomHandle_.width/2); + var leftHandlePos = parseInt(this.leftZoomHandle_.style.left) + halfHandleWidth; + var rightHandlePos = parseInt(this.rightZoomHandle_.style.left) + halfHandleWidth; + return { + leftHandlePos: leftHandlePos, + rightHandlePos: rightHandlePos, + isZoomed: (leftHandlePos - 1 > this.canvasRect_.x || rightHandlePos + 1 < this.canvasRect_.x+this.canvasRect_.w) + }; +}; diff --git a/dygraph-tickers.js b/dygraph-tickers.js index cbaaa43..0ca06e7 100644 --- a/dygraph-tickers.js +++ b/dygraph-tickers.js @@ -339,7 +339,7 @@ Dygraph.getDateAxis = function(start_time, end_time, granularity, opts, dg) { var year_mod = 1; // e.g. to only print one point every 10 years. if (granularity == Dygraph.MONTHLY) { - months = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ]; + months = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 ]; } else if (granularity == Dygraph.QUARTERLY) { months = [ 0, 3, 6, 9 ]; } else if (granularity == Dygraph.BIANNUAL) { diff --git a/dygraph-utils.js b/dygraph-utils.js index 9192ae4..1b6a593 100644 --- a/dygraph-utils.js +++ b/dygraph-utils.js @@ -93,20 +93,35 @@ Dygraph.getContext = function(canvas) { * @private * Add an event handler. This smooths a difference between IE and the rest of * the world. - * @param { DOM element } el The element to add the event to. - * @param { String } evt The name of the event, e.g. 'click' or 'mousemove'. + * @param { DOM element } elem The element to add the event to. + * @param { String } type The type of the event, e.g. 'click' or 'mousemove'. * @param { Function } fn The function to call on the event. The function takes * one parameter: the event object. */ -Dygraph.addEvent = function(el, evt, fn) { - var normed_fn = function(e) { - if (!e) var e = window.event; - fn(e); - }; - if (window.addEventListener) { // Mozilla, Netscape, Firefox - el.addEventListener(evt, normed_fn, false); - } else { // IE - el.attachEvent('on' + evt, normed_fn); +Dygraph.addEvent = function addEvent(elem, type, fn) { + if (elem.addEventListener) { + elem.addEventListener(type, fn, false); + } else { + elem[type+fn] = function(){fn(window.event);}; + elem.attachEvent('on'+type, elem[type+fn]); + } +}; + +/** + * @private + * Remove an event handler. This smooths a difference between IE and the rest of + * the world. + * @param { DOM element } elem The element to add the event to. + * @param { String } type The type of the event, e.g. 'click' or 'mousemove'. + * @param { Function } fn The function to call on the event. The function takes + * one parameter: the event object. + */ +Dygraph.removeEvent = function addEvent(elem, type, fn) { + if (elem.removeEventListener) { + elem.removeEventListener(type, fn, false); + } else { + elem.detachEvent('on'+type, elem[type+fn]); + elem[type+fn] = null; } }; @@ -609,6 +624,8 @@ Dygraph.isPixelChangingOptionList = function(labels, attrs) { 'pixelsPerYLabel': true, 'pointClickCallback': true, 'pointSize': true, + 'rangeSelectorPlotFillColor': true, + 'rangeSelectorPlotStrokeColor': true, 'showLabelsOnHighlight': true, 'showRoller': true, 'sigFigs': true, @@ -621,7 +638,7 @@ Dygraph.isPixelChangingOptionList = function(labels, attrs) { 'yAxisLabelFormatter': true, 'yValueFormatter': true, 'zoomCallback': true - }; + }; // Assume that we do not require new points. // This will change to true if we actually do need new points. @@ -659,7 +676,7 @@ Dygraph.isPixelChangingOptionList = function(labels, attrs) { // If this was not a series specific option list, check if its a pixel changing property. } else if (!pixelSafeOptions[property]) { requiresNewPoints = true; - } + } } } diff --git a/dygraph.js b/dygraph.js index 66d32fa..8125141 100644 --- a/dygraph.js +++ b/dygraph.js @@ -244,6 +244,12 @@ Dygraph.DEFAULT_ATTRS = { interactionModel: null, // will be set to Dygraph.Interaction.defaultModel + // Range selector options + showRangeSelector: false, + rangeSelectorHeight: 40, + rangeSelectorPlotStrokeColor: "#808FAB", + rangeSelectorPlotFillColor: "#A7B1C4", + // per-axis options axes: { x: { @@ -305,6 +311,7 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { document.readyState != 'complete') { var self = this; setTimeout(function() { self.__init__(div, file, attrs) }, 100); + return; } // Support two-argument constructor @@ -348,15 +355,15 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { if (div.style.height == '' && attrs.height) { div.style.height = attrs.height + "px"; } - if (div.style.height == '' && div.offsetHeight == 0) { + if (div.style.height == '' && div.clientHeight == 0) { div.style.height = Dygraph.DEFAULT_HEIGHT + "px"; if (div.style.width == '') { div.style.width = Dygraph.DEFAULT_WIDTH + "px"; } } // these will be zero if the dygraph's div is hidden. - this.width_ = div.offsetWidth; - this.height_ = div.offsetHeight; + this.width_ = div.clientWidth; + this.height_ = div.clientHeight; // TODO(danvk): set fillGraph to be part of attrs_ here, not user_attrs_. if (attrs['stackedGraph']) { @@ -779,11 +786,26 @@ Dygraph.prototype.createInterface_ = function() { this.hidden_ = this.createPlotKitCanvas_(this.canvas_); this.hidden_ctx_ = Dygraph.getContext(this.hidden_); + if (this.attr_('showRangeSelector')) { + // The range selector must be created here so that its canvases and contexts get created here. + // For some reason, if the canvases and contexts don't get created here, things don't work in IE. + // The range selector also sets xAxisHeight in order to reserve space. + this.rangeSelector_ = new DygraphRangeSelector(this); + } + // The interactive parts of the graph are drawn on top of the chart. this.graphDiv.appendChild(this.hidden_); this.graphDiv.appendChild(this.canvas_); this.mouseEventElement_ = this.canvas_; + // Create the grapher + this.layout_ = new DygraphLayout(this); + + if (this.rangeSelector_) { + // This needs to happen after the graph canvases are added to the div and the layout object is created. + this.rangeSelector_.addToGraph(this.graphDiv, this.layout_); + } + var dygraph = this; Dygraph.addEvent(this.mouseEventElement_, 'mousemove', function(e) { dygraph.mouseMove_(e); @@ -792,9 +814,6 @@ Dygraph.prototype.createInterface_ = function() { dygraph.mouseOut_(e); }); - // Create the grapher - this.layout_ = new DygraphLayout(this); - this.createStatusMessage_(); this.createDragInterface_(); @@ -1106,7 +1125,7 @@ Dygraph.prototype.createDragInterface_ = function() { * up any previous zoom rectangles that were drawn. This could be optimized to * avoid extra redrawing, but it's tricky to avoid interactions with the status * dots. - * + * * @param {Number} direction the direction of the zoom rectangle. Acceptable * values are Dygraph.HORIZONTAL and Dygraph.VERTICAL. * @param {Number} startX The X position where the drag started, in canvas @@ -1492,11 +1511,11 @@ Dygraph.prototype.setSelection = function(row) { for (var i in this.layout_.datasets) { if (row < this.layout_.datasets[i].length) { var point = this.layout_.points[pos+row]; - + if (this.attr_("stackedGraph")) { point = this.layout_.unstackPointAtIndex(pos+row); } - + this.selPoints_.push(point); } pos += this.layout_.datasets[i].length; @@ -1665,6 +1684,10 @@ Dygraph.prototype.predraw_ = function() { // edge of the div, if we have two y-axes. this.positionLabelsDiv_(); + if (this.rangeSelector_) { + this.rangeSelector_.renderStaticLayer(); + } + // If the data or options have changed, then we'd better redraw. this.drawGraph_(); @@ -1857,6 +1880,10 @@ Dygraph.prototype.renderGraph_ = function(is_initial_draw, clearSelection) { } } + if (this.rangeSelector_) { + this.rangeSelector_.renderInteractiveLayer(); + } + if (this.attr_("drawCallback") !== null) { this.attr_("drawCallback")(this, is_initial_draw); } @@ -2824,8 +2851,8 @@ Dygraph.prototype.resize = function(width, height) { this.width_ = width; this.height_ = height; } else { - this.width_ = this.maindiv_.offsetWidth; - this.height_ = this.maindiv_.offsetHeight; + this.width_ = this.maindiv_.clientWidth; + this.height_ = this.maindiv_.clientHeight; } if (old_width != this.width_ || old_height != this.height_) { diff --git a/generate-combined.sh b/generate-combined.sh index 0f3064a..ea9f92b 100755 --- a/generate-combined.sh +++ b/generate-combined.sh @@ -12,6 +12,7 @@ dygraph.js \ dygraph-utils.js \ dygraph-gviz.js \ dygraph-interaction-model.js \ +dygraph-range-selector.js \ dygraph-tickers.js \ rgbcolor/rgbcolor.js \ strftime/strftime-min.js \ diff --git a/jsTestDriver.conf b/jsTestDriver.conf index 7d89afd..5f6d1fa 100644 --- a/jsTestDriver.conf +++ b/jsTestDriver.conf @@ -12,6 +12,7 @@ load: - dygraph-gviz.js - dygraph-interaction-model.js - dygraph-options-reference.js + - dygraph-range-selector.js - dygraph-tickers.js - dygraph-dev.js - excanvas.js diff --git a/tests/range-selector.html b/tests/range-selector.html new file mode 100644 index 0000000..58af000 --- /dev/null +++ b/tests/range-selector.html @@ -0,0 +1,64 @@ + + + + + Temperatures with Range Selector + + + + + + + + +

Demo of a graph with the range selector.

+

+ No roll period. +

+
+

+ Roll period of 14 timesteps, custom range selector height and plot color. +

+
+ + + -- 2.7.4