From: Klaus Weidner Date: Sat, 25 Feb 2012 20:18:26 +0000 (-0800) Subject: Merge branch 'master' of git://github.com/danvk/dygraphs into highlight3 X-Git-Tag: v1.0.0~314^2~15^2~5 X-Git-Url: https://adrianiainlam.tk/git/?a=commitdiff_plain;h=5469113bc1d7bb38c9214e709c87598b7f6ef256;hp=4270a2836fae730a6c3e0590d4a6fba7d63f86bc;p=dygraphs.git Merge branch 'master' of git://github.com/danvk/dygraphs into highlight3 Conflicts: auto_tests/tests/callback.js dygraph-canvas.js dygraph-options-reference.js --- diff --git a/auto_tests/tests/callback.js b/auto_tests/tests/callback.js index 3431070..1b851b5 100644 --- a/auto_tests/tests/callback.js +++ b/auto_tests/tests/callback.js @@ -1,4 +1,4 @@ -/** +/** * @fileoverview Test cases for the callbacks. * * @author uemit.seren@gmail.com (Ümit Seren) @@ -7,40 +7,43 @@ var CallbackTestCase = TestCase("callback"); CallbackTestCase.prototype.setUp = function() { - document.body.innerHTML = "
"; + document.body.innerHTML = "
"; + this.styleSheet = document.createElement("style"); + this.styleSheet.type = "text/css"; + document.getElementsByTagName("head")[0].appendChild(this.styleSheet); }; CallbackTestCase.prototype.tearDown = function() { }; - + var data = "X,a\,b,c\n" + - "10,-1,1,2\n" + - "11,0,3,1\n" + - "12,1,4,2\n" + - "13,0,2,3\n"; - - + "10,-1,1,2\n" + + "11,0,3,1\n" + + "12,1,4,2\n" + + "13,0,2,3\n"; + + /** * This tests that when the function idxToRow_ returns the proper row and the onHiglightCallback - * is properly called when the first series is hidden (setVisibility = false) - * + * is properly called when the first series is hidden (setVisibility = false) + * */ CallbackTestCase.prototype.testHighlightCallbackIsCalled = function() { var h_row; var h_pts; - var highlightCallback = function(e, x, pts, row) { + var highlightCallback = function(e, x, pts, row) { h_row = row; h_pts = pts; - }; + }; var graph = document.getElementById("graph"); var g = new Dygraph(graph, data, { width: 100, - height : 100, + height: 100, visibility: [false, true, true], - highlightCallback : highlightCallback, + highlightCallback: highlightCallback }); DygraphOps.dispatchMouseMove(g, 13, 10); @@ -60,7 +63,7 @@ CallbackTestCase.prototype.testDrawPointCallback_disabled = function() { var callback = function() { called = true; - }; + }; var graph = document.getElementById("graph"); var g = new Dygraph(graph, data, { @@ -78,7 +81,7 @@ CallbackTestCase.prototype.testDrawPointCallback_enabled = function() { var callback = function() { called = true; - }; + }; var graph = document.getElementById("graph"); var g = new Dygraph(graph, data, { @@ -99,7 +102,7 @@ CallbackTestCase.prototype.testDrawPointCallback_pointSize = function() { var callback = function(g, seriesName, canvasContext, cx, cy, color, pointSizeParam) { pointSize = pointSizeParam; count++; - }; + }; var graph = document.getElementById("graph"); var g = new Dygraph(graph, data, { @@ -121,15 +124,15 @@ CallbackTestCase.prototype.testDrawPointCallback_pointSize = function() { /** * This tests that when the function idxToRow_ returns the proper row and the onHiglightCallback - * is properly called when the first series is hidden (setVisibility = false) - * + * is properly called when the first series is hidden (setVisibility = false) + * */ CallbackTestCase.prototype.testDrawHighlightPointCallbackIsCalled = function() { var called = false; var drawHighlightPointCallback = function() { called = true; - }; + }; var graph = document.getElementById("graph"); var g = new Dygraph(graph, data, @@ -143,3 +146,114 @@ CallbackTestCase.prototype.testDrawHighlightPointCallbackIsCalled = function() { DygraphOps.dispatchMouseMove(g, 13, 10); assertTrue(called); }; + +/** + * Test the closest-series highlighting methods for normal and stacked modes. + * Also pass in line widths for plain and highlighted lines for easier visual + * confirmation that the highlighted line is drawn on top of the others. + */ +var runClosestTest = function(isStacked, widthNormal, widthHighlighted) { + var h_row; + var h_pts; + var h_series; + + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, + { + width: 600, + height: 400, + visibility: [false, true, true], + stackedGraph: isStacked, + strokeWidth: widthNormal, + strokeBorderWidth: 2, + highlightCircleSize: widthNormal * 2, + highlightSeriesBackgroundFade: 0.7, + highlightSeriesAnimate: true, + + highlightSeriesOpts: { + strokeWidth: widthHighlighted, + highlightCircleSize: widthHighlighted * 2 + } + }); + + var highlightCallback = function(e, x, pts, row, set) { + h_row = row; + h_pts = pts; + h_series = set; + document.getElementById('selection').innerHTML='row=' + row + ', set=' + set; + }; + + g.updateOptions({highlightCallback: highlightCallback}, true); + + if (isStacked) { + DygraphOps.dispatchMouseMove(g, 11.45, 1.4); + assertEquals(1, h_row); + assertEquals('c', h_series); + + //now move up in the same row + DygraphOps.dispatchMouseMove(g, 11.45, 1.5); + assertEquals(1, h_row); + assertEquals('b', h_series); + + //and a bit to the right + DygraphOps.dispatchMouseMove(g, 11.55, 1.5); + assertEquals(2, h_row); + assertEquals('c', h_series); + } else { + DygraphOps.dispatchMouseMove(g, 11, 1.5); + assertEquals(1, h_row); + assertEquals('c', h_series); + + //now move up in the same row + DygraphOps.dispatchMouseMove(g, 11, 2.5); + assertEquals(1, h_row); + assertEquals('b', h_series); + } + + return g; +}; + +/** + * Test basic closest-point highlighting. + */ +CallbackTestCase.prototype.testClosestPointCallback = function() { + runClosestTest(false, 1, 3); +} + +/** + * Test setSelection() with series name + */ +CallbackTestCase.prototype.testSetSelection = function() { + var g = runClosestTest(false, 1, 3); + assertEquals(1, g.attr_('strokeWidth', 'c')); + g.setSelection(false, 'c'); + assertEquals(3, g.attr_('strokeWidth', 'c')); +} + +/** + * Test closest-point highlighting for stacked graph + */ +CallbackTestCase.prototype.testClosestPointStackedCallback = function() { + runClosestTest(true, 1, 3); +} + +/** + * Closest-point highlighting with legend CSS - border around active series. + */ +CallbackTestCase.prototype.testClosestPointCallbackCss1 = function() { + var css = "div.dygraph-legend > span { display: block; }\n" + + "div.dygraph-legend > span.highlight { border: 1px solid grey; }\n"; + this.styleSheet.innerHTML = css; + runClosestTest(false, 2, 4); +} + +/** + * Closest-point highlighting with legend CSS - show only closest series. + */ +CallbackTestCase.prototype.testClosestPointCallbackCss2 = function() { + var css = "div.dygraph-legend > span { display: none; }\n" + + "div.dygraph-legend > span.highlight { display: inline; }\n"; + this.styleSheet.innerHTML = css; + runClosestTest(false, 10, 15); + // TODO(klausw): verify that the highlighted line is drawn on top? +} diff --git a/dygraph-canvas.js b/dygraph-canvas.js index 10936de..5fd02e5 100644 --- a/dygraph-canvas.js +++ b/dygraph-canvas.js @@ -658,6 +658,114 @@ DygraphCanvasRenderer.prototype._renderAnnotations = function() { } }; +DygraphCanvasRenderer.prototype._drawStyledLine = function( + ctx, i, setName, color, strokeWidth, strokePattern, drawPoints, + drawPointCallback, pointSize) { + var isNullOrNaN = function(x) { + return (x === null || isNaN(x)); + }; + + var stepPlot = this.attr_("stepPlot"); + var firstIndexInSet = this.layout.setPointsOffsets[i]; + var setLength = this.layout.setPointsLengths[i]; + var afterLastIndexInSet = firstIndexInSet + setLength; + var points = this.layout.points; + var prevX = null; + var prevY = null; + var pointsOnLine = []; // Array of [canvasx, canvasy] pairs. + if (!Dygraph.isArrayLike(strokePattern)) { + strokePattern = null; + } + + var point; + ctx.save(); + for (var j = firstIndexInSet; j < afterLastIndexInSet; j++) { + point = points[j]; + if (isNullOrNaN(point.canvasy)) { + if (stepPlot && prevX !== null) { + // Draw a horizontal line to the start of the missing data + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = this.attr_('strokeWidth'); + this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern); + ctx.stroke(); + } + // this will make us move to the next point, not draw a line to it. + prevX = prevY = null; + } else { + // A point is "isolated" if it is non-null but both the previous + // and next points are null. + var isIsolated = (!prevX && (j == points.length - 1 || + isNullOrNaN(points[j+1].canvasy))); + if (prevX === null) { + prevX = point.canvasx; + prevY = point.canvasy; + } else { + // Skip over points that will be drawn in the same pixel. + if (Math.round(prevX) == Math.round(point.canvasx) && + Math.round(prevY) == Math.round(point.canvasy)) { + continue; + } + // TODO(antrob): skip over points that lie on a line that is already + // going to be drawn. There is no need to have more than 2 + // consecutive points that are collinear. + if (strokeWidth) { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth; + if (stepPlot) { + this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern); + prevX = point.canvasx; + } + this._dashedLine(ctx, prevX, prevY, point.canvasx, point.canvasy, strokePattern); + prevX = point.canvasx; + prevY = point.canvasy; + ctx.stroke(); + } + } + + if (drawPoints || isIsolated) { + pointsOnLine.push([point.canvasx, point.canvasy]); + } + } + } + for (var idx = 0; idx < pointsOnLine.length; idx++) { + var cb = pointsOnLine[idx]; + ctx.save(); + drawPointCallback( + this.dygraph_, setName, ctx, cb[0], cb[1], color, pointSize); + ctx.restore(); + } + firstIndexInSet = afterLastIndexInSet; + ctx.restore(); +}; + +DygraphCanvasRenderer.prototype._drawLine = function(ctx, i) { + var setNames = this.layout.setNames; + var setName = setNames[i]; + + var strokeWidth = this.dygraph_.attr_("strokeWidth", setName); + var borderWidth = this.dygraph_.attr_("strokeBorderWidth", setName); + var drawPointCallback = this.dygraph_.attr_("drawPointCallback", setName) || + Dygraph.Circles.DEFAULT; + if (borderWidth && strokeWidth) { + this._drawStyledLine(ctx, i, setName, + this.dygraph_.attr_("strokeBorderColor", setName), + strokeWidth + 2 * borderWidth, + this.dygraph_.attr_("strokePattern", setName), + this.dygraph_.attr_("drawPoints", setName), + drawPointCallback, + this.dygraph_.attr_("pointSize", setName)); + } + + this._drawStyledLine(ctx, i, setName, + this.colors[setName], + strokeWidth, + this.dygraph_.attr_("strokePattern", setName), + this.dygraph_.attr_("drawPoints", setName), + drawPointCallback, + this.dygraph_.attr_("pointSize", setName)); +}; /** * Actually draw the lines chart, including error bars. @@ -665,12 +773,8 @@ DygraphCanvasRenderer.prototype._renderAnnotations = function() { * @private */ 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 ctx = this.elementContext; var fillAlpha = this.attr_('fillAlpha'); var errorBars = this.attr_("errorBars") || this.attr_("customBars"); var fillGraph = this.attr_("fillGraph"); @@ -698,8 +802,8 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { } // create paths - var ctx = context; if (errorBars) { + ctx.save(); if (fillGraph) { this.dygraph_.warn("Can't use fillGraph option with error bars"); } @@ -710,7 +814,6 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { color = this.colors[setName]; // setup graphics context - ctx.save(); prevX = NaN; prevY = NaN; prevYs = [-1, -1]; @@ -759,7 +862,9 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { } ctx.fill(); } + ctx.restore(); } else if (fillGraph) { + ctx.save(); var baseline = []; // for stacked graphs: baseline for filling // process sets in reverse order (needed for stacked graphs) @@ -773,7 +878,6 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { axisY = this.area.h * axisY + this.area.y; // setup graphics context - ctx.save(); prevX = NaN; prevYs = [-1, -1]; yscale = axis.yscale; @@ -815,6 +919,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { } ctx.fill(); } + ctx.restore(); } // Drawing the lines. @@ -822,91 +927,8 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() { var afterLastIndexInSet = 0; var setLength = 0; for (i = 0; i < setCount; i += 1) { - firstIndexInSet = this.layout.setPointsOffsets[i]; - setLength = this.layout.setPointsLengths[i]; - afterLastIndexInSet = firstIndexInSet + setLength; - setName = setNames[i]; - color = this.colors[setName]; - var strokeWidth = this.dygraph_.attr_("strokeWidth", setName); - - // setup graphics context - // TODO(konigsberg): This function has ctx and context. Clarify the difference. - context.save(); - var pointSize = this.dygraph_.attr_("pointSize", setName); - prevX = null; - prevY = null; - var drawPoints = this.dygraph_.attr_("drawPoints", setName); - var drawPointCallback = this.dygraph_.attr_("drawPointCallback", setName); - if (!drawPointCallback) { - drawPointCallback = Dygraph.Circles.DEFAULT; - } - var pointsOnLine = []; // Array of [canvasx, canvasy] pairs. - var strokePattern = this.dygraph_.attr_("strokePattern", setName); - if (!Dygraph.isArrayLike(strokePattern)) { - strokePattern = null; - } - for (j = firstIndexInSet; j < afterLastIndexInSet; j++) { - point = points[j]; - if (isNullOrNaN(point.canvasy)) { - if (stepPlot && prevX !== null) { - // Draw a horizontal line to the start of the missing data - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = this.attr_('strokeWidth'); - this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern); - ctx.stroke(); - } - // this will make us move to the next point, not draw a line to it. - prevX = prevY = null; - } else { - // A point is "isolated" if it is non-null but both the previous - // and next points are null. - var isIsolated = (!prevX && (j == points.length - 1 || - isNullOrNaN(points[j+1].canvasy))); - if (prevX === null) { - prevX = point.canvasx; - prevY = point.canvasy; - } else { - // Skip over points that will be drawn in the same pixel. - if (Math.round(prevX) == Math.round(point.canvasx) && - Math.round(prevY) == Math.round(point.canvasy)) { - continue; - } - // TODO(antrob): skip over points that lie on a line that is already - // going to be drawn. There is no need to have more than 2 - // consecutive points that are collinear. - if (strokeWidth) { - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = strokeWidth; - if (stepPlot) { - this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern); - prevX = point.canvasx; - } - this._dashedLine(ctx, prevX, prevY, point.canvasx, point.canvasy, strokePattern); - prevX = point.canvasx; - prevY = point.canvasy; - ctx.stroke(); - } - } - - if (drawPoints || isIsolated) { - pointsOnLine.push([point.canvasx, point.canvasy]); - } - } - } - for (var idx = 0; idx < pointsOnLine.length; idx++) { - var cb = pointsOnLine[idx]; - ctx.save(); - drawPointCallback( - this.dygraph_, setName, ctx, cb[0], cb[1], color, pointSize); - ctx.restore(); - - } - firstIndexInSet = afterLastIndexInSet; + this._drawLine(ctx, i); } - - context.restore(); }; /** diff --git a/dygraph-options-reference.js b/dygraph-options-reference.js index 2b45ed3..9217f3d 100644 --- a/dygraph-options-reference.js +++ b/dygraph-options-reference.js @@ -93,13 +93,14 @@ Dygraph.OPTIONS_REFERENCE = // "highlightCallback": { "default": "null", "labels": ["Callbacks"], - "type": "function(event, x, points, row)", + "type": "function(event, x, points, row, seriesName)", "description": "When set, this callback gets called every time a new point is highlighted.", "parameters": [ ["event", "the JavaScript mousemove event"], ["x", "the x-coordinate of the highlighted points"], ["points", "an array of highlighted points: [ {name: 'series', yval: y-value}, … ]"], - ["row", "???"] + ["row", "integer index of the highlighted row in the data table, starting from 0"], + ["seriesName", "name of the highlighted series, only present if highlightSeriesOpts is set."] ] }, "drawHighlightPointCallback": { @@ -108,6 +109,24 @@ Dygraph.OPTIONS_REFERENCE = // "type": "function(g, seriesName, canvasContext, cx, cy, color, pointSize)", "description": "Draw a custom item when a point is highlighted. Default is a small dot matching the series color." }, + "highlightSeriesOpts": { + "default": "null", + "labels": ["Interactive Elements"], + "type": "Object", + "description": "When set, the options from this object are applied to the timeseries closest to the mouse pointer for interactive highlighting. See also 'highlightCallback'. Example: highlightSeriesOpts: { strokeWidth: 3 }." + }, + "highlightSeriesBackgroundFade": { + "default": "0", + "labels": ["Interactive Elements"], + "type": "number", + "description": "When nonzero, dim the background while highlighting series. 0=fully visible, 1=hidden" + }, + "highlightSeriesAnimate": { + "default": "false", + "labels": ["Interactive Elements"], + "type": "boolean", + "description": "Animate the background dimming for nonzero highlightSeriesBackgroundFade." + }, "includeZero": { "default": "false", "labels": ["Axis display"], @@ -324,7 +343,7 @@ Dygraph.OPTIONS_REFERENCE = // "strokeWidth": { "default": "1.0", "labels": ["Data Line display"], - "type": "integer", + "type": "float", "example": "0.5, 2.0", "description": "The width of the lines connecting data points. This can be used to increase the contrast or some graphs." }, @@ -335,6 +354,20 @@ Dygraph.OPTIONS_REFERENCE = // "example": "[10, 2, 5, 2]", "description": "A custom pattern array where the even index is a draw and odd is a space in pixels. If null then it draws a solid line. The array should have a even length as any odd lengthed array could be expressed as a smaller even length array." }, + "strokeBorderWidth": { + "default": "null", + "labels": ["Data Line display"], + "type": "float", + "example": "1.0", + "description": "Draw a border around graph lines to make crossing lines more easily distinguishable. Useful for graphs with many lines." + }, + "strokeBorderColor": { + "default": "white", + "labels": ["Data Line display"], + "type": "string", + "example": "red, #ccffdd", + "description": "Color for the line border used if strokeBorderWidth is set." + }, "wilsonInterval": { "default": "true", "labels": ["Error Bars"], diff --git a/dygraph.js b/dygraph.js index 680aa81..1d97bda 100644 --- a/dygraph.js +++ b/dygraph.js @@ -186,6 +186,9 @@ Dygraph.dateAxisFormatter = function(date, granularity) { // Default attribute values. Dygraph.DEFAULT_ATTRS = { highlightCircleSize: 3, + highlightSeriesOpts: null, + highlightSeriesBackgroundFade: 0, + highlightSeriesAnimated: false, labelsDivWidth: 250, labelsDivStyles: { @@ -202,6 +205,8 @@ Dygraph.DEFAULT_ATTRS = { sigFigs: null, strokeWidth: 1.0, + strokeBorderWidth: 0, + strokeBorderColor: "white", axisTickSize: 3, axisLabelFontSize: 14, @@ -398,6 +403,7 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.boundaryIds_ = []; this.setIndexByName_ = {}; + this.datasetIndex_ = []; // Create the containing DIV and other interactive elements this.createInterface_(); @@ -452,18 +458,31 @@ Dygraph.prototype.attr_ = function(name, seriesName) { Dygraph.OPTIONS_REFERENCE[name] = true; } // - if (this.user_attrs_ !== null && seriesName && - typeof(this.user_attrs_[seriesName]) != 'undefined' && - this.user_attrs_[seriesName] !== null && - typeof(this.user_attrs_[seriesName][name]) != 'undefined') { - return this.user_attrs_[seriesName][name]; - } else if (this.user_attrs_ !== null && typeof(this.user_attrs_[name]) != 'undefined') { - return this.user_attrs_[name]; - } else if (this.attrs_ !== null && typeof(this.attrs_[name]) != 'undefined') { - return this.attrs_[name]; - } else { - return null; + + var sources = []; + sources.push(this.attrs_); + if (this.user_attrs_) { + sources.push(this.user_attrs_); + if (seriesName) { + if (this.user_attrs_.hasOwnProperty(seriesName)) { + sources.push(this.user_attrs_[seriesName]); + } + if (seriesName === this.highlightSet_ && + this.user_attrs_.hasOwnProperty('highlightSeriesOpts')) { + sources.push(this.user_attrs_['highlightSeriesOpts']); + } + } } + + var ret = null; + for (var i = sources.length - 1; i >= 0; --i) { + var source = sources[i]; + if (source.hasOwnProperty(name)) { + ret = source[name]; + break; + } + } + return ret; }; /** @@ -1461,74 +1480,165 @@ Dygraph.prototype.doAnimatedZoom = function(oldXRange, newXRange, oldYRanges, ne }; /** - * When the mouse moves in the canvas, display information about a nearby data - * point and draw dots over those points in the data series. This function - * takes care of cleanup of previously-drawn dots. - * @param {Object} event The mousemove event from the browser. - * @private + * Get the current graph's area object. + * + * Returns: {x, y, w, h} */ -Dygraph.prototype.mouseMove_ = function(event) { - // This prevents JS errors when mousing over the canvas before data loads. - var points = this.layout_.points; - if (points === undefined) return; +Dygraph.prototype.getArea = function() { + return this.plotter_.area; +}; +/** + * Convert a mouse event to DOM coordinates relative to the graph origin. + * + * Returns a two-element array: [X, Y]. + */ +Dygraph.prototype.eventToDomCoords = function(event) { var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_); + var canvasy = Dygraph.pageY(event) - Dygraph.findPosY(this.mouseEventElement_); + return [canvasx, canvasy]; +}; - var lastx = -1; - var i; - - // Loop through all the points and find the date nearest to our current - // location. - var minDist = 1e+100; +/** + * Given a canvas X coordinate, find the closest row. + * @param {Number} domX graph-relative DOM X coordinate + * Returns: row number, integer + * @private + */ +Dygraph.prototype.findClosestRow = function(domX) { + var minDistX = null; var idx = -1; - for (i = 0; i < points.length; i++) { + var points = this.layout_.points; + var l = points.length; + for (var i = 0; i < l; i++) { var point = points[i]; if (point === null) continue; - var dist = Math.abs(point.canvasx - canvasx); - if (dist > minDist) continue; - minDist = dist; + var dist = Math.abs(point.canvasx - domX); + if (minDistX !== null && dist >= minDistX) continue; + minDistX = dist; idx = i; } - if (idx >= 0) lastx = points[idx].xval; + return this.idxToRow_(idx); +}; - // Extract the points we've selected - this.selPoints_ = []; - var l = points.length; - if (!this.attr_("stackedGraph")) { - for (i = 0; i < l; i++) { - if (points[i].xval == lastx) { - this.selPoints_.push(points[i]); +/** + * Given canvas X,Y coordinates, find the closest point + * @param {Number} domX graph-relative DOM X coordinate + * @param {Number} domY graph-relative DOM Y coordinate + * Returns: {row, seriesName, point} + * @private + */ +Dygraph.prototype.findClosestPoint = function(domX, domY) { + var minDist = null; + var idx = -1; + var points = this.layout_.points; + var dist, dx, dy, point, closestPoint, closestSeries; + for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) { + var first = this.layout_.setPointsOffsets[setIdx]; + var len = this.layout_.setPointsLengths[setIdx]; + for (var i = 0; i < len; ++i) { + var point = points[first + i]; + if (point === null) continue; + dx = point.canvasx - domX; + dy = point.canvasy - domY; + dist = dx * dx + dy * dy; + if (minDist !== null && dist >= minDist) continue; + minDist = dist; + closestPoint = point; + closestSeries = setIdx; + idx = i; + } + } + var name = this.layout_.setNames[closestSeries]; + return { + row: idx, + seriesName: name, + point: closestPoint + }; +}; + +/** + * Given canvas X,Y coordinates, find the touched area in a stacked graph. + * @param {Number} domX graph-relative DOM X coordinate + * @param {Number} domY graph-relative DOM Y coordinate + * Returns: {row, seriesName, point} + * @private + */ +Dygraph.prototype.findStackedPoint = function(domX, domY) { + var row = this.findClosestRow(domX); + var points = this.layout_.points; + var closestPoint, closestSeries; + for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) { + var first = this.layout_.setPointsOffsets[setIdx]; + var len = this.layout_.setPointsLengths[setIdx]; + if (row >= len) continue; + var p1 = points[first + row]; + var py = p1.canvasy; + if (domX > p1.canvasx && row + 1 < len) { + // interpolate series Y value using next point + var p2 = points[first + row + 1]; + var dx = p2.canvasx - p1.canvasx; + if (dx > 0) { + var r = (domX - p1.canvasx) / dx; + py += r * (p2.canvasy - p1.canvasy); } - } - } else { - // Need to 'unstack' points starting from the bottom - var cumulative_sum = 0; - for (i = l - 1; i >= 0; i--) { - if (points[i].xval == lastx) { - var p = {}; // Clone the point since we modify it - for (var k in points[i]) { - p[k] = points[i][k]; - } - p.yval -= cumulative_sum; - cumulative_sum += p.yval; - this.selPoints_.push(p); + } else if (domX < p1.canvasx && row > 0) { + // interpolate series Y value using previous point + var p0 = points[first + row - 1]; + var dx = p1.canvasx - p0.canvasx; + if (dx > 0) { + var r = (p1.canvasx - domX) / dx; + py += r * (p0.canvasy - p1.canvasy); } } - this.selPoints_.reverse(); + // Stop if the point (domX, py) is above this series' upper edge + if (setIdx > 0 && py >= domY) break; + closestPoint = p1; + closestSeries = setIdx; } + var name = this.layout_.setNames[closestSeries]; + return { + row: row, + seriesName: name, + point: closestPoint + }; +}; + +/** + * When the mouse moves in the canvas, display information about a nearby data + * point and draw dots over those points in the data series. This function + * takes care of cleanup of previously-drawn dots. + * @param {Object} event The mousemove event from the browser. + * @private + */ +Dygraph.prototype.mouseMove_ = function(event) { + // This prevents JS errors when mousing over the canvas before data loads. + var points = this.layout_.points; + if (points === undefined) return; - if (this.attr_("highlightCallback")) { - var px = this.lastx_; - if (px !== null && lastx != px) { - // only fire if the selected point has changed. - this.attr_("highlightCallback")(event, lastx, this.selPoints_, this.idxToRow_(idx)); + var canvasCoords = this.eventToDomCoords(event); + var canvasx = canvasCoords[0]; + var canvasy = canvasCoords[1]; + + var highlightSeriesOpts = this.attr_("highlightSeriesOpts"); + var selectionChanged = false; + if (highlightSeriesOpts) { + var closest; + if (this.attr_("stackedGraph")) { + closest = this.findStackedPoint(canvasx, canvasy); + } else { + closest = this.findClosestPoint(canvasx, canvasy); } + selectionChanged = this.setSelection(closest.row, closest.seriesName); + } else { + var idx = this.findClosestRow(canvasx); + selectionChanged = this.setSelection(idx); } - // Save last x position for callbacks. - this.lastx_ = lastx; - - this.updateSelection_(); + var callback = this.attr_("highlightCallback"); + if (callback && selectionChanged) { + callback(event, this.lastx_, this.selPoints_, this.lastRow_, this.highlightSet_); + } }; /** @@ -1659,7 +1769,7 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) { if (html !== '') html += (sepLines ? '
' : ' '); strokePattern = this.attr_("strokePattern", labels[i]); dash = this.generateLegendDashHTML_(strokePattern, c, oneEmWidth); - html += "" + dash + + html += "" + dash + " " + labels[i] + ""; } return html; @@ -1687,9 +1797,10 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) { c = this.plotter_.colors[pt.name]; var yval = fmtFunc(pt.yval, yOptView, pt.name, this); + var cls = (pt.name == this.highlightSet_) ? " class='highlight'" : ""; // TODO(danvk): use a template string here and make it an attribute. - html += " " + pt.name + - ":" + yval; + html += "" + " " + pt.name + + ":" + yval + ""; } return html; }; @@ -1721,16 +1832,66 @@ Dygraph.prototype.setLegendHTML_ = function(x, sel_points) { } }; +Dygraph.prototype.animateSelection_ = function(direction) { + var totalSteps = 10; + var millis = 30; + if (this.fadeLevel === undefined) { + this.fadeLevel = 0; + this.animateId = 0; + } + var start = this.fadeLevel; + var steps = direction < 0 ? start : totalSteps - start; + if (steps <= 0) { + if (this.fadeLevel) { + this.updateSelection_(1.0); + } + return; + } + + var thisId = ++this.animateId; + var that = this; + Dygraph.repeatAndCleanup( + function(n) { + // ignore simultaneous animations + if (that.animateId != thisId) return; + + that.fadeLevel += direction; + if (that.fadeLevel === 0) { + that.clearSelection(); + } else { + that.updateSelection_(that.fadeLevel / totalSteps); + } + }, + steps, millis, function() {}); +}; + /** * Draw dots over the selectied points in the data series. This function * takes care of cleanup of previously-drawn dots. * @private */ -Dygraph.prototype.updateSelection_ = function() { +Dygraph.prototype.updateSelection_ = function(opt_animFraction) { // Clear the previously drawn vertical, if there is one var i; var ctx = this.canvas_ctx_; - if (this.previousVerticalX_ >= 0) { + if (this.attr_('highlightSeriesOpts')) { + ctx.clearRect(0, 0, this.width_, this.height_); + var alpha = this.attr_('highlightSeriesBackgroundFade'); + if (alpha) { + if (this.attr_('highlightSeriesAnimate')) { + if (opt_animFraction === undefined) { + // start a new animation + this.animateSelection_(1); + return; + } + alpha *= opt_animFraction; + } + ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')'; + ctx.fillRect(0, 0, this.width_, this.height_); + } + var setIdx = this.datasetIndexFromSetName_(this.highlightSet_); + this.plotter_._drawLine(ctx, setIdx); + } else if (this.previousVerticalX_ >= 0) { // Determine the maximum highlight circle size. var maxCircleSize = 0; var labels = this.attr_('labels'); @@ -1780,17 +1941,27 @@ Dygraph.prototype.updateSelection_ = function() { * using getSelection(). * @param { Integer } row number that should be highlighted (i.e. appear with * hover dots on the chart). Set to false to clear any selection. + * @param { seriesName } optional series name to highlight that series with the + * the highlightSeriesOpts setting. */ -Dygraph.prototype.setSelection = function(row) { +Dygraph.prototype.setSelection = function(row, opt_seriesName) { // Extract the points we've selected this.selPoints_ = []; var pos = 0; if (row !== false) { - row = row - this.boundaryIds_[0][0]; + for (var i = 0; i < this.boundaryIds_.length; i++) { + if (this.boundaryIds_[i] !== undefined) { + row -= this.boundaryIds_[i][0]; + break; + } + } } + var changed = false; if (row !== false && row >= 0) { + if (row != this.lastRow_) changed = true; + this.lastRow_ = row; for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) { var set = this.layout_.datasets[setIdx]; if (row < set.length) { @@ -1804,15 +1975,26 @@ Dygraph.prototype.setSelection = function(row) { } pos += set.length; } + } else { + if (this.lastRow_ >= 0) changed = true; + this.lastRow_ = -1; } if (this.selPoints_.length) { this.lastx_ = this.selPoints_[0].xval; - this.updateSelection_(); } else { - this.clearSelection(); + this.lastx_ = -1; + } + + if (opt_seriesName !== undefined) { + if (this.highlightSet_ !== opt_seriesName) changed = true; + this.highlightSet_ = opt_seriesName; } + if (changed) { + this.updateSelection_(undefined); + } + return changed; }; /** @@ -1836,10 +2018,17 @@ Dygraph.prototype.mouseOut_ = function(event) { */ Dygraph.prototype.clearSelection = function() { // Get rid of the overlay data + if (this.fadeLevel) { + this.animateSelection_(-1); + return; + } this.canvas_ctx_.clearRect(0, 0, this.width_, this.height_); + this.fadeLevel = 0; this.setLegendHTML_(); this.selPoints_ = []; this.lastx_ = -1; + this.lastRow_ = -1; + this.highlightSet_ = null; }; /** @@ -1860,6 +2049,10 @@ Dygraph.prototype.getSelection = function() { return -1; }; +Dygraph.prototype.getHighlightSeries = function() { + return this.highlightSet_; +}; + /** * Fires when there's data available to be graphed. * @param {String} data Raw CSV data to be plotted @@ -2133,10 +2326,12 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) { if (labels.length > 0) { this.setIndexByName_[labels[0]] = 0; } + var dataIdx = 0; for (var i = 1; i < datasets.length; i++) { this.setIndexByName_[labels[i]] = i; if (!this.visibility()[i - 1]) continue; this.layout_.addDataset(labels[i], datasets[i]); + this.datasetIndex_[i] = dataIdx++; } this.computeYAxisRanges_(extremes); @@ -3322,6 +3517,15 @@ Dygraph.prototype.indexFromSetName = function(name) { }; /** + * Get the internal dataset index given its name. These are numbered starting from 0, + * and only count visible sets. + * @private + */ +Dygraph.prototype.datasetIndexFromSetName_ = function(name) { + return this.datasetIndex_[this.indexFromSetName(name)]; +}; + +/** * @private * Adds a default style for the annotation CSS classes to the document. This is * only executed when annotations are actually used. It is designed to only be