From 5b9b214237898b4b841eea2ff664e9c65b661ddf Mon Sep 17 00:00:00 2001 From: Robert Konigsberg Date: Wed, 23 Apr 2014 08:42:58 -0400 Subject: [PATCH] x axis log scale. --- auto_tests/tests/dygraph-options-tests.js | 24 ++++++ auto_tests/tests/to_dom_coords.js | 81 ++++++++++++++++++-- dygraph-interaction-model.js | 20 +++-- dygraph-layout.js | 26 +++++-- dygraph-options-reference.js | 2 +- dygraph-options.js | 10 ++- dygraph.js | 119 ++++++++++++++++++++++-------- tests/logscale.html | 43 ++++++++--- 8 files changed, 259 insertions(+), 66 deletions(-) diff --git a/auto_tests/tests/dygraph-options-tests.js b/auto_tests/tests/dygraph-options-tests.js index 691b24a..792dcdb 100644 --- a/auto_tests/tests/dygraph-options-tests.js +++ b/auto_tests/tests/dygraph-options-tests.js @@ -30,3 +30,27 @@ DygraphOptionsTestCase.prototype.testGetSeriesNames = function() { var o = new DygraphOptions(g); assertEquals(["Y", "Y2", "Y3"], o.seriesNames()); }; + +/* + * Ensures that even if logscale is set globally, it doesn't impact the + * x axis. + */ +DygraphOptionsTestCase.prototype.getLogscaleForX = function() { + var opts = { + width: 480, + height: 320 + }; + var data = "X,Y,Y2,Y3\n" + + "1,-1,2,3"; + + // Kind of annoying that you need a DOM to test the object. + var graph = document.getElementById("graph"); + var g = new Dygraph(graph, data, opts); + + assertFalse(g.getOptionForAxis('logscale', 'x')); + assertFalse(g.getOptionForAxis('logscale', 'y')); + + g.updateOptions({ logscale : true }); + assertFalse(g.getOptionForAxis('logscale', 'x')); + assertTrue(g.getOptionForAxis('logscale', 'y')); +}; diff --git a/auto_tests/tests/to_dom_coords.js b/auto_tests/tests/to_dom_coords.js index 22ee3fe..70518a1 100644 --- a/auto_tests/tests/to_dom_coords.js +++ b/auto_tests/tests/to_dom_coords.js @@ -172,19 +172,25 @@ ToDomCoordsTestCase.prototype.testAxisTickSize = function() { assertEquals([500, 386], g.toDomCoords(100, 0)); } -ToDomCoordsTestCase.prototype.testChartLogarithmic = function() { +ToDomCoordsTestCase.prototype.testChartLogarithmic_YAxis = function() { var opts = { - drawXAxis: false, - drawYAxis: false, - drawXGrid: false, - drawYGrid: false, - logscale: true, rightGap: 0, valueRange: [1, 4], dateWindow: [0, 10], width: 400, height: 400, - colors: ['#ff0000'] + colors: ['#ff0000'], + axes: { + x: { + drawGrid: false, + drawAxis: false + }, + y: { + drawGrid: false, + drawAxis: false, + logscale: true + } + } } var graph = document.getElementById("graph"); @@ -203,3 +209,64 @@ ToDomCoordsTestCase.prototype.testChartLogarithmic = function() { assertEquals([400, 400], g.toDomCoords(10, 1)); assertEquals([400, 200], g.toDomCoords(10, 2)); } + +ToDomCoordsTestCase.prototype.testChartLogarithmic_XAxis = function() { + var opts = { + rightGap: 0, + valueRange: [1, 1000], + dateWindow: [1, 1000], + width: 400, + height: 400, + colors: ['#ff0000'], + axes: { + x: { + drawGrid: false, + drawAxis: false, + logscale: true + }, + y: { + drawGrid: false, + drawAxis: false + } + } + } + + var graph = document.getElementById("graph"); + g = new Dygraph(graph, [ [1,1], [10, 10], [100,100], [1000,1000] ], opts); + + var epsilon = 1e-8; + assertEqualsDelta(1, g.toDataXCoord(0), epsilon); + assertEqualsDelta(5.623413251903489, g.toDataXCoord(100), epsilon); + assertEqualsDelta(31.62277660168378, g.toDataXCoord(200), epsilon); + assertEqualsDelta(177.8279410038921, g.toDataXCoord(300), epsilon); + assertEqualsDelta(1000, g.toDataXCoord(400), epsilon); + + assertEqualsDelta(0, g.toDomXCoord(1), epsilon); + assertEqualsDelta(3.6036036036036037, g.toDomXCoord(10), epsilon); + assertEqualsDelta(39.63963963963964, g.toDomXCoord(100), epsilon); + assertEqualsDelta(400, g.toDomXCoord(1000), epsilon); + + assertEqualsDelta(0, g.toPercentXCoord(1), epsilon); + assertEqualsDelta(0.3333333333, g.toPercentXCoord(10), epsilon); + assertEqualsDelta(0.6666666666, g.toPercentXCoord(100), epsilon); + assertEqualsDelta(1, g.toPercentXCoord(1000), epsilon); + + // Now zoom in and ensure that the methods return reasonable values. + g.updateOptions({dateWindow: [ 10, 100 ]}); + + assertEqualsDelta(10, g.toDataXCoord(0), epsilon); + assertEqualsDelta(17.78279410038923, g.toDataXCoord(100), epsilon); + assertEqualsDelta(31.62277660168379, g.toDataXCoord(200), epsilon); + assertEqualsDelta(56.23413251903491, g.toDataXCoord(300), epsilon); + assertEqualsDelta(100, g.toDataXCoord(400), epsilon); + + assertEqualsDelta(-40, g.toDomXCoord(1), epsilon); + assertEqualsDelta(0, g.toDomXCoord(10), epsilon); + assertEqualsDelta(400, g.toDomXCoord(100), epsilon); + assertEqualsDelta(4400, g.toDomXCoord(1000), epsilon); + + assertEqualsDelta(-1, g.toPercentXCoord(1), epsilon); + assertEqualsDelta(0, g.toPercentXCoord(10), epsilon); + assertEqualsDelta(1, g.toPercentXCoord(100), epsilon); + assertEqualsDelta(2, g.toPercentXCoord(1000), epsilon); +} \ No newline at end of file diff --git a/dygraph-interaction-model.js b/dygraph-interaction-model.js index 2af345c..56491e1 100644 --- a/dygraph-interaction-model.js +++ b/dygraph-interaction-model.js @@ -38,8 +38,14 @@ Dygraph.Interaction.startPan = function(event, g, context) { var i, axis; context.isPanning = true; var xRange = g.xAxisRange(); - context.dateRange = xRange[1] - xRange[0]; - context.initialLeftmostDate = xRange[0]; + + if (g.getOptionForAxis("logscale", 'x')) { + context.initialLeftmostDate = Dygraph.log10(xRange[0]); + context.dateRange = Dygraph.log10(xRange[1]) - Dygraph.log10(xRange[0]); + } else { + context.initialLeftmostDate = xRange[0]; + context.dateRange = xRange[1] - xRange[0]; + } context.xUnitsPerPixel = context.dateRange / (g.plotter_.area.w - 1); if (g.attr_("panEdgeFraction")) { @@ -132,7 +138,12 @@ Dygraph.Interaction.movePan = function(event, g, context) { } } - g.dateWindow_ = [minDate, maxDate]; + if (g.getOptionForAxis("logscale", 'x')) { + g.dateWindow_ = [ Math.pow(Dygraph.LOG_SCALE, minDate), + Math.pow(Dygraph.LOG_SCALE, maxDate) ]; + } else { + g.dateWindow_ = [minDate, maxDate]; + } // y-axis scaling is automatic unless this is a full 2D pan. if (context.is2DPan) { @@ -160,8 +171,7 @@ Dygraph.Interaction.movePan = function(event, g, context) { minValue = maxValue - axis_data.dragValueRange; } } - var logscale = g.attributes_.getForAxis("logscale", i); - if (logscale) { + if (g.attributes_.getForAxis("logscale", i)) { axis.valueWindow = [ Math.pow(Dygraph.LOG_SCALE, minValue), Math.pow(Dygraph.LOG_SCALE, maxValue) ]; } else { diff --git a/dygraph-layout.js b/dygraph-layout.js index 315d8e6..2e81359 100644 --- a/dygraph-layout.js +++ b/dygraph-layout.js @@ -175,6 +175,7 @@ DygraphLayout.prototype.setYAxes = function (yAxes) { }; DygraphLayout.prototype.evaluate = function() { + this._xAxis = {}; this._evaluateLimits(); this._evaluateLineCharts(); this._evaluateLineTicks(); @@ -183,11 +184,15 @@ DygraphLayout.prototype.evaluate = function() { DygraphLayout.prototype._evaluateLimits = function() { var xlimits = this.dygraph_.xAxisRange(); - this.minxval = xlimits[0]; - this.maxxval = xlimits[1]; + this._xAxis.minxval = xlimits[0]; + this._xAxis.maxxval = xlimits[1]; var xrange = xlimits[1] - xlimits[0]; - this.xscale = (xrange !== 0 ? 1 / xrange : 1.0); + this._xAxis.xscale = (xrange !== 0 ? 1 / xrange : 1.0); + if (this.dygraph_.getOptionForAxis("logscale", 'x')) { + this._xAxis.xlogrange = Dygraph.log10(this._xAxis.maxxval) - Dygraph.log10(this._xAxis.minxval); + this._xAxis.xlogscale = (this._xAxis.xlogrange !== 0 ? 1.0 / this._xAxis.xlogrange : 1.0); + } for (var i = 0; i < this.yAxes_.length; i++) { var axis = this.yAxes_[i]; axis.minyval = axis.computedValueRange[0]; @@ -195,7 +200,7 @@ DygraphLayout.prototype._evaluateLimits = function() { axis.yrange = axis.maxyval - axis.minyval; axis.yscale = (axis.yrange !== 0 ? 1.0 / axis.yrange : 1.0); - if (axis.g.getOption("logscale")) { + if (this.dygraph_.getOption("logscale")) { axis.ylogrange = Dygraph.log10(axis.maxyval) - Dygraph.log10(axis.minyval); axis.ylogscale = (axis.ylogrange !== 0 ? 1.0 / axis.ylogrange : 1.0); if (!isFinite(axis.ylogrange) || isNaN(axis.ylogrange)) { @@ -207,6 +212,14 @@ DygraphLayout.prototype._evaluateLimits = function() { } }; +DygraphLayout._calcXNormal = function(value, axis, logscale) { + if (logscale) { + return ((Dygraph.log10(value) - Dygraph.log10(axis.minxval)) * axis.xlogscale); + } else { + return (value - axis.minxval) * axis.xscale; + } +}; + DygraphLayout._calcYNormal = function(axis, value, logscale) { if (logscale) { return 1.0 - ((Dygraph.log10(value) - Dygraph.log10(axis.minyval)) * axis.ylogscale); @@ -217,6 +230,7 @@ DygraphLayout._calcYNormal = function(axis, value, logscale) { DygraphLayout.prototype._evaluateLineCharts = function() { var isStacked = this.dygraph_.getOption("stackedGraph"); + var isLogscaleForX = this.dygraph_.getOptionForAxis("logscale", 'x'); for (var setIdx = 0; setIdx < this.points.length; setIdx++) { var points = this.points[setIdx]; @@ -230,7 +244,7 @@ DygraphLayout.prototype._evaluateLineCharts = function() { var point = points[j]; // Range from 0-1 where 0 represents left and 1 represents right. - point.x = (point.xval - this.minxval) * this.xscale; + point.x = DygraphLayout._calcXNormal(point.xval, this._xAxis, isLogscaleForX); // Range from 0-1 where 0 represents top and 1 represents bottom var yval = point.yval; if (isStacked) { @@ -273,7 +287,7 @@ DygraphLayout.prototype._evaluateLineTicks = function() { for (i = 0; i < this.xTicks_.length; i++) { tick = this.xTicks_[i]; label = tick.label; - pos = this.xscale * (tick.v - this.minxval); + pos = this.dygraph_.toPercentXCoord(tick.v); if ((pos >= 0.0) && (pos <= 1.0)) { this.xticks.push([pos, label]); } diff --git a/dygraph-options-reference.js b/dygraph-options-reference.js index 3ecc531..3030bc7 100644 --- a/dygraph-options-reference.js +++ b/dygraph-options-reference.js @@ -371,7 +371,7 @@ Dygraph.OPTIONS_REFERENCE = // "default": "false", "labels": ["Axis display"], "type": "boolean", - "description": "When set for a y-axis, the graph shows that axis in log scale. Any values less than or equal to zero are not displayed.\n\nNot compatible with showZero, and ignores connectSeparatedPoints. Also, showing log scale with valueRanges that are less than zero will result in an unviewable graph." + "description": "When set for the y-axis or x-axis, the graph shows that axis in log scale. Any values less than or equal to zero are not displayed. Showing log scale with ranges that go below zero will result in an unviewable graph.\n\n Not compatible with showZero. connectSeparatedPoints is ignored. This is ignored for date-based x-axes." }, "strokeWidth": { "default": "1.0", diff --git a/dygraph-options.js b/dygraph-options.js index 2dc88ae..98ef35f 100644 --- a/dygraph-options.js +++ b/dygraph-options.js @@ -290,11 +290,13 @@ DygraphOptions.prototype.getForAxis = function(name, axis) { } // User-specified global options second. - var result = this.getGlobalUser_(name); - if (result !== null) { - return result; + // But, hack, ignore globally-specified 'logscale' for 'x' axis declaration. + if (!(axis === 'x' && name === 'logscale')) { + var result = this.getGlobalUser_(name); + if (result !== null) { + return result; + } } - // Default axis options third. var defaultAxisOptions = Dygraph.DEFAULT_ATTRS.axes[axisString]; if (defaultAxisOptions.hasOwnProperty(name)) { diff --git a/dygraph.js b/dygraph.js index 184ec11..6c76b78 100644 --- a/dygraph.js +++ b/dygraph.js @@ -684,6 +684,14 @@ Dygraph.prototype.optionsViewForAxis_ = function(axis) { if (axis_opts && axis_opts[axis] && axis_opts[axis].hasOwnProperty(opt)) { return axis_opts[axis][opt]; } + + // I don't like that this is in a second spot. + if (axis === 'x' && opt === 'logscale') { + // return the default value. + // TODO(konigsberg): pull the default from a global default. + return false; + } + // user-specified attributes always trump defaults, even if they're less // specific. if (typeof(self.user_attrs_[opt]) != 'undefined') { @@ -842,7 +850,37 @@ Dygraph.prototype.toDataXCoord = function(x) { var area = this.plotter_.area; var xRange = this.xAxisRange(); - return xRange[0] + (x - area.x) / area.w * (xRange[1] - xRange[0]); + + if (!this.attributes_.getForAxis("logscale", 'x')) { + return xRange[0] + (x - area.x) / area.w * (xRange[1] - xRange[0]); + } else { + // TODO: remove duplicate code? + // Computing the inverse of toDomCoord. + var pct = (x - area.x) / area.w; + + // Computing the inverse of toPercentXCoord. The function was arrived at with + // the following steps: + // + // Original calcuation: + // pct = (log(x) - log(xRange[0])) / (log(xRange[1]) - log(xRange[0]))); + // + // Multiply both sides by the right-side demoninator. + // pct * (log(xRange[1] - log(xRange[0]))) = log(x) - log(xRange[0]) + // + // add log(xRange[0]) to both sides + // log(xRange[0]) + (pct * (log(xRange[1]) - log(xRange[0])) = log(x); + // + // Swap both sides of the equation, + // log(x) = log(xRange[0]) + (pct * (log(xRange[1]) - log(xRange[0])) + // + // Use both sides as the exponent in 10^exp and we're done. + // x = 10 ^ (log(xRange[0]) + (pct * (log(xRange[1]) - log(xRange[0]))) + var logr0 = Dygraph.log10(xRange[0]); + var logr1 = Dygraph.log10(xRange[1]); + var exponent = logr0 + (pct * (logr1 - logr0)); + var value = Math.pow(Dygraph.LOG_SCALE, exponent); + return value; + } }; /** @@ -870,21 +908,25 @@ Dygraph.prototype.toDataYCoord = function(y, axis) { // the following steps: // // Original calcuation: - // pct = (logr1 - Dygraph.log10(y)) / (logr1 - Dygraph.log10(yRange[0])); + // pct = (log(yRange[1]) - log(y)) / (log(yRange[1]) - log(yRange[0])); // - // Move denominator to both sides: - // pct * (logr1 - Dygraph.log10(yRange[0])) = logr1 - Dygraph.log10(y); + // Multiply both sides by the right-side demoninator. + // pct * (log(yRange[1]) - log(yRange[0])) = log(yRange[1]) - log(y); // - // subtract logr1, and take the negative value. - // logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))) = Dygraph.log10(y); + // subtract log(yRange[1]) from both sides. + // (pct * (log(yRange[1]) - log(yRange[0]))) - log(yRange[1]) = -log(y); // - // Swap both sides of the equation, and we can compute the log of the - // return value. Which means we just need to use that as the exponent in - // e^exponent. - // Dygraph.log10(y) = logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))); - + // and multiply both sides by -1. + // log(yRange[1]) - (pct * (logr1 - log(yRange[0])) = log(y); + // + // Swap both sides of the equation, + // log(y) = log(yRange[1]) - (pct * (log(yRange[1]) - log(yRange[0]))); + // + // Use both sides as the exponent in 10^exp and we're done. + // y = 10 ^ (log(yRange[1]) - (pct * (log(yRange[1]) - log(yRange[0])))); + var logr0 = Dygraph.log10(yRange[0]); var logr1 = Dygraph.log10(yRange[1]); - var exponent = logr1 - (pct * (logr1 - Dygraph.log10(yRange[0]))); + var exponent = logr1 - (pct * (logr1 - logr0)); var value = Math.pow(Dygraph.LOG_SCALE, exponent); return value; } @@ -916,14 +958,15 @@ Dygraph.prototype.toPercentYCoord = function(y, axis) { var pct; var logscale = this.attributes_.getForAxis("logscale", axis); - if (!logscale) { + if (logscale) { + var logr0 = Dygraph.log10(yRange[0]); + var logr1 = Dygraph.log10(yRange[1]); + pct = (logr1 - Dygraph.log10(y)) / (logr1 - logr0); + } else { // yRange[1] - y is unit distance from the bottom. // yRange[1] - yRange[0] is the scale of the range. // (yRange[1] - y) / (yRange[1] - yRange[0]) is the % from the bottom. pct = (yRange[1] - y) / (yRange[1] - yRange[0]); - } else { - var logr1 = Dygraph.log10(yRange[1]); - pct = (logr1 - Dygraph.log10(y)) / (logr1 - Dygraph.log10(yRange[0])); } return pct; }; @@ -947,7 +990,19 @@ Dygraph.prototype.toPercentXCoord = function(x) { } var xRange = this.xAxisRange(); - return (x - xRange[0]) / (xRange[1] - xRange[0]); + var pct; + var logscale = this.attributes_.getForAxis("logscale", 'x') ; + if (logscale == true) { // logscale can be null so we test for true explicitly. + var logr0 = Dygraph.log10(xRange[0]); + var logr1 = Dygraph.log10(xRange[1]); + pct = (Dygraph.log10(x) - logr0) / (logr1 - logr0); + } else { + // x - xRange[0] is unit distance from the left. + // xRange[1] - xRange[0] is the scale of the range. + // The full expression below is the % from the left. + pct = (x - xRange[0]) / (xRange[1] - xRange[0]); + } + return pct; }; /** @@ -1478,16 +1533,6 @@ Dygraph.prototype.doZoomX_ = function(lowX, highX) { }; /** - * Transition function to use in animations. Returns values between 0.0 - * (totally old values) and 1.0 (totally new values) for each frame. - * @private - */ -Dygraph.zoomAnimationFunction = function(frame, numFrames) { - var k = 1.5; - return (1.0 - Math.pow(k, -frame)) / (1.0 - Math.pow(k, -numFrames)); -}; - -/** * Zoom to something containing [minDate, maxDate] values. Don't confuse this * method with doZoomX which accepts pixel coordinates. This function redraws * the graph. @@ -1497,8 +1542,8 @@ Dygraph.zoomAnimationFunction = function(frame, numFrames) { * @private */ Dygraph.prototype.doZoomXDates_ = function(minDate, maxDate) { - // TODO(danvk): when yAxisRange is null (i.e. "fit to data", the animation - // can produce strange effects. Rather than the y-axis transitioning slowly + // TODO(danvk): when xAxisRange is null (i.e. "fit to data", the animation + // can produce strange effects. Rather than the x-axis transitioning slowly // between values, it can jerk around.) var old_window = this.xAxisRange(); var new_window = [minDate, maxDate]; @@ -1544,6 +1589,16 @@ Dygraph.prototype.doZoomY_ = function(lowY, highY) { }; /** + * Transition function to use in animations. Returns values between 0.0 + * (totally old values) and 1.0 (totally new values) for each frame. + * @private + */ +Dygraph.zoomAnimationFunction = function(frame, numFrames) { + var k = 1.5; + return (1.0 - Math.pow(k, -frame)) / (1.0 - Math.pow(k, -numFrames)); +}; + +/** * Reset the zoom to the original view coordinates. This is the same as * double-clicking on the graph. */ @@ -2874,7 +2929,7 @@ Dygraph.prototype.setXAxisOptions_ = function(isDate) { // TODO(danvk): use Dygraph.numberValueFormatter here? /** @private (shut up, jsdoc!) */ this.attrs_.axes.x.valueFormatter = function(x) { return x; }; - this.attrs_.axes.x.ticker = Dygraph.numericLinearTicks; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; this.attrs_.axes.x.axisLabelFormatter = this.attrs_.axes.x.valueFormatter; } }; @@ -3122,7 +3177,7 @@ Dygraph.prototype.parseArray_ = function(data) { // Some intelligent defaults for a numeric x-axis. /** @private (shut up, jsdoc!) */ this.attrs_.axes.x.valueFormatter = function(x) { return x; }; - this.attrs_.axes.x.ticker = Dygraph.numericLinearTicks; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; this.attrs_.axes.x.axisLabelFormatter = Dygraph.numberAxisLabelFormatter; return data; } @@ -3163,7 +3218,7 @@ Dygraph.prototype.parseDataTable_ = function(data) { } else if (indepType == 'number') { this.attrs_.xValueParser = function(x) { return parseFloat(x); }; this.attrs_.axes.x.valueFormatter = function(x) { return x; }; - this.attrs_.axes.x.ticker = Dygraph.numericLinearTicks; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; this.attrs_.axes.x.axisLabelFormatter = this.attrs_.axes.x.valueFormatter; } else { this.error("only 'date', 'datetime' and 'number' types are supported for " + diff --git a/tests/logscale.html b/tests/logscale.html index 98193ba..85818fd 100644 --- a/tests/logscale.html +++ b/tests/logscale.html @@ -16,17 +16,23 @@
- - + + + + +
Current scales:

X axis of dates

+
(Note: when the x-axis is dates, logscale is ignored on that axis.)

X axis of numbers

-- 2.7.4