From 48e614acf3084b9836803f8b014bf0e65f1ca119 Mon Sep 17 00:00:00 2001 From: Dan Vanderkam Date: Wed, 17 Aug 2011 13:01:47 -0400 Subject: [PATCH] Factor out ticker functions and clean up their semantics/usage. This introduces a new syntax for per-axis properties: axes: { x: { valueFormatter: ... } } The old syntax (xValueFormatter) still works, but is less general. The valueFormatter and axisLabelFormatter options are now used consistently between x- and y-axes and have a predictable set of parameters. Includes lots of tests for all relevant behaviors. --- auto_tests/misc/local.html | 3 + auto_tests/tests/axis_labels.js | 270 ++++++++++++++- auto_tests/tests/multiple_axes.js | 97 ++++++ auto_tests/tests/tickers.js | 334 ++++++++++++++++++ auto_tests/tests/utils_test.js | 77 +++++ docs/per-axis.html | 110 ++++++ dygraph-canvas.js | 12 +- dygraph-dev.js | 3 +- dygraph-options-reference.js | 73 ++-- dygraph-tickers.js | 377 ++++++++++++++++++++ dygraph-utils.js | 52 +-- dygraph.js | 701 ++++++++++++++------------------------ generate-combined.sh | 1 + jsTestDriver.conf | 1 + tests/is-zoomed.html | 2 +- tests/multi-scale.html | 98 ++++++ tests/number-display.html | 10 +- tests/two-axes.html | 8 +- tests/value-axis-formatters.html | 93 +++++ tests/x-axis-formatter.html | 22 +- tests/y-axis-formatter.html | 39 ++- 21 files changed, 1844 insertions(+), 539 deletions(-) create mode 100644 auto_tests/tests/multiple_axes.js create mode 100644 auto_tests/tests/tickers.js create mode 100644 auto_tests/tests/utils_test.js create mode 100644 docs/per-axis.html create mode 100644 dygraph-tickers.js create mode 100644 tests/multi-scale.html create mode 100644 tests/value-axis-formatters.html diff --git a/auto_tests/misc/local.html b/auto_tests/misc/local.html index a0bccd9..cd75c28 100644 --- a/auto_tests/misc/local.html +++ b/auto_tests/misc/local.html @@ -23,6 +23,7 @@ + @@ -30,6 +31,8 @@ + + + + diff --git a/dygraph-canvas.js b/dygraph-canvas.js index 9a162db..4f4a547 100644 --- a/dygraph-canvas.js +++ b/dygraph-canvas.js @@ -260,7 +260,7 @@ DygraphCanvasRenderer.prototype._renderAxis = function() { // height: this.attr_('axisLabelFontSize') + 2 + "px", overflow: "hidden" }; - var makeDiv = function(txt, axis) { + var makeDiv = function(txt, axis, prec_axis) { var div = document.createElement("div"); for (var name in labelStyle) { if (labelStyle.hasOwnProperty(name)) { @@ -268,8 +268,9 @@ DygraphCanvasRenderer.prototype._renderAxis = function() { } } var inner_div = document.createElement("div"); - // TODO(danvk): separate class for secondary y-axis - inner_div.className = 'dygraph-axis-label dygraph-axis-label-' + axis; + inner_div.className = 'dygraph-axis-label' + + ' dygraph-axis-label-' + axis + + (prec_axis ? ' dygraph-axis-label-' + prec_axis : ''); inner_div.appendChild(document.createTextNode(txt)); div.appendChild(inner_div); return div; @@ -282,14 +283,17 @@ DygraphCanvasRenderer.prototype._renderAxis = function() { if (this.attr_('drawYAxis')) { if (this.layout.yticks && this.layout.yticks.length > 0) { + var num_axes = this.dygraph_.numAxes(); for (var i = 0; i < this.layout.yticks.length; i++) { var tick = this.layout.yticks[i]; if (typeof(tick) == "function") return; var x = this.area.x; var sgn = 1; + var prec_axis = 'y1'; if (tick[0] == 1) { // right-side y-axis x = this.area.x + this.area.w; sgn = -1; + prec_axis = 'y2'; } var y = this.area.y + tick[1] * this.area.h; context.beginPath(); @@ -298,7 +302,7 @@ DygraphCanvasRenderer.prototype._renderAxis = function() { context.closePath(); context.stroke(); - var label = makeDiv(tick[2], 'y'); + var label = makeDiv(tick[2], 'y', num_axes == 2 ? prec_axis : null); var top = (y - this.attr_('axisLabelFontSize') / 2); if (top < 0) top = 0; diff --git a/dygraph-dev.js b/dygraph-dev.js index 34378ac..3f1e792 100644 --- a/dygraph-dev.js +++ b/dygraph-dev.js @@ -18,7 +18,8 @@ "dygraph-utils.js", "dygraph-gviz.js", "dygraph-interaction-model.js", - "dygraph-options-reference.js" // Shouldn't be included in generate-combined.sh + "dygraph-options-reference.js", // Shouldn't be included in generate-combined.sh + "dygraph-tickers.js" ]; for (var i = 0; i < source_files.length; i++) { diff --git a/dygraph-options-reference.js b/dygraph-options-reference.js index 74b7bed..2dc8b15 100644 --- a/dygraph-options-reference.js +++ b/dygraph-options-reference.js @@ -103,16 +103,22 @@ Dygraph.OPTIONS_REFERENCE = // "description": "Put <br/> between lines in the label string. Often used in conjunction with labelsDiv." }, "xValueFormatter": { - "default": "(Round to 2 decimal places)", - "labels": ["Axis display"], - "type": "function(x)", - "description": "Function to provide a custom display format for the X value for mouseover." + "default": "", + "labels": ["Deprecated"], + "type": "", + "description": "Prefer axes: { x: { valueFormatter } }" + }, + "valueFormatter": { + "default": "Depends on the type of your data.", + "labels": ["Legend", "Value display/formatting"], + "type": "function(num or millis, opts, dygraph)", + "description": "Function to provide a custom display format for the values displayed on mouseover. This does not affect the values that appear on tick marks next to the axes. To format those, see axisLabelFormatter. This is usually set on a per-axis basis. For date axes, you can call new Date(millis) to get a Date object. opts is a function you can call to access various options (e.g. opts('labelsKMB'))." }, "pixelsPerYLabel": { - "default": "30", - "labels": ["Axis display", "Grid"], + "default": "", + "labels": ["Deprecated"], "type": "integer", - "description": "Number of pixels to require between each x- and y-label. Larger values will yield a sparser axis with fewer ticks." + "description": "Prefer axes: { y: { pixelsPerLabel } }" }, "annotationMouseOverHandler": { "default": "null", @@ -183,8 +189,14 @@ Dygraph.OPTIONS_REFERENCE = // "xTicker": { "default": "Dygraph.dateTicker or Dygraph.numericTicks", "labels": ["Axis display"], - "type": "function(min, max, dygraph) -> [{v: ..., label: ...}, ...]", - "description": "This lets you specify an arbitrary function to generate tick marks on an axis. The tick marks are an array of (value, label) pairs. The built-in functions go to great lengths to choose good tick marks so, if you set this option, you'll most likely want to call one of them and modify the result." + "type": "function(min, max, pixels, opts, dygraph, vals) -> [{v: ..., label: ...}, ...]", + "description": "This lets you specify an arbitrary function to generate tick marks on an axis. The tick marks are an array of (value, label) pairs. The built-in functions go to great lengths to choose good tick marks so, if you set this option, you'll most likely want to call one of them and modify the result. See dygraph-tickers.js for an extensive discussion." + }, + "xTicker": { + "default": "", + "labels": ["Deprecated"], + "type": "", + "description": "Prefer axes: { x: { ticker } }" }, "xAxisLabelWidth": { "default": "50", @@ -211,10 +223,16 @@ Dygraph.OPTIONS_REFERENCE = // "description": "Set to either an object ({}) filled with options for this axis or to the name of an existing data series with its own axis to re-use that axis. See tests for usage." }, "pixelsPerXLabel": { - "default": "60", + "default": "", + "labels": ["Deprecated"], + "type": "integer", + "description": "Prefer axes { x: { pixelsPerLabel } }" + }, + "pixelsPerLabel": { + "default": "60 (x-axis) or 30 (y-axes)", "labels": ["Axis display", "Grid"], "type": "integer", - "description": "Number of pixels to require between each x- and y-label. Larger values will yield a sparser axis with fewer ticks." + "description": "Number of pixels to require between each x- and y-label. Larger values will yield a sparser axis with fewer ticks. This is set on a per-axis basis." }, "labelsDiv": { "default": "null", @@ -304,10 +322,10 @@ Dygraph.OPTIONS_REFERENCE = // "description": "Whether to hide the legend when the mouse leaves the chart area." }, "yValueFormatter": { - "default": "(Round to 2 decimal places)", - "labels": ["Axis display"], - "type": "function(x)", - "description": "Function to provide a custom display format for the Y value for mouseover." + "default": "", + "labels": ["Deprecated"], + "type": "", + "description": "Prefer axes: { y: { valueFormatter } }" }, "legend": { "default": "onmouseover", @@ -346,10 +364,16 @@ Dygraph.OPTIONS_REFERENCE = // "description": "When set, the heuristic that fixes the Y axis at zero for a data set with the minimum Y value of zero is disabled. \nThis is particularly useful for data sets that contain many zero values, especially for step plots which may otherwise have lines not visible running along the bottom axis." }, "xAxisLabelFormatter": { - "default": "Dygraph.dateAxisFormatter", - "labels": ["Axis display", "Value display/formatting"], - "type": "function(date, granularity)", - "description": "Function to call to format values along the x axis." + "default": "", + "labels": ["Deprecated"], + "type": "", + "description": "Prefer axes { x: { axisLabelFormatter } }" + }, + "axisLabelFormatter": { + "default": "Depends on the data type", + "labels": ["Axis display"], + "type": "function(number or Date, granularity, opts, dygraph)", + "description": "Function to call to format the tick values that appear along an axis. This is usually set on a per-axis basis. The first parameter is either a number (for a numeric axis) or a Date object (for a date axis). The second argument specifies how fine-grained the axis is. For date axes, this is a reference to the time granularity enumeration, defined in dygraph-tickers.js, e.g. Dygraph.WEEKLY. opts is a function which provides access to various options on the dygraph, e.g. opts('labelsKMB')." }, "clickCallback": { "snippet": "function(e, date_millis){
  alert(new Date(date_millis));
}", @@ -359,10 +383,10 @@ Dygraph.OPTIONS_REFERENCE = // "description": "A function to call when the canvas is clicked. The function should take three arguments, the event object for the click, the x-value that was clicked (for dates this is millis since epoch), and the closest points along that date. The points have these properties:\n * xval/yval: The data coordinates of the point (with dates/times as millis since epoch) \n * canvasx/canvasy: The canvas coordinates at which the point is drawn. \n name: The name of the data series to which the point belongs" }, "yAxisLabelFormatter": { - "default": "yValueFormatter", - "labels": ["Axis display", "Value display/formatting"], - "type": "function(x)", - "description": "Function used to format values along the Y axis. By default it uses the same as the yValueFormatter unless specified." + "default": "", + "labels": ["Deprecated"], + "type": "", + "description": "Prefer axes: { y: { axisLabelFormatter } }" }, "labels": { "default": "[\"X\", \"Y1\", \"Y2\", ...]*", @@ -580,7 +604,8 @@ Dygraph.OPTIONS_REFERENCE = // 'Rolling Averages', 'Value display/formatting', 'Zooming', - 'Debugging' + 'Debugging', + 'Deprecated' ]; var cats = {}; for (var i = 0; i < valid_cats.length; i++) cats[valid_cats[i]] = true; diff --git a/dygraph-tickers.js b/dygraph-tickers.js new file mode 100644 index 0000000..87c5ce5 --- /dev/null +++ b/dygraph-tickers.js @@ -0,0 +1,377 @@ +// Copyright 2011 Google Inc. All Rights Reserved. + +/** + * @fileoverview Description of this file. + * @author danvk@google.com (Dan Vanderkam) + * + * A ticker is a function with the following interface: + * + * function(a, b, pixels, options_view, dygraph, forced_values); + * -> [ { v: tick1_v, label: tick1_label[, label_v: label_v1] }, + * { v: tick2_v, label: tick2_label[, label_v: label_v2] }, + * ... + * ] + * + * The returned value is called a "tick list". + * + * Arguments + * --------- + * + * [a, b] is the range of the axis for which ticks are being generated. For a + * numeric axis, these will simply be numbers. For a date axis, these will be + * millis since epoch (convertable to Date objects using "new Date(a)" and "new + * Date(b)"). + * + * opts provides access to chart- and axis-specific options. It can be used to + * access number/date formatting code/options, check for a log scale, etc. + * + * pixels is the length of the axis in pixels. opts('pixelsPerLabel') is the + * minimum amount of space to be allotted to each label. For instance, if + * pixels=400 and opts('pixelsPerLabel')=40 then the ticker should return + * between zero and ten (400/40) ticks. + * + * dygraph is the Dygraph object for which an axis is being constructed. + * + * forced_values is used for secondary y-axes. The tick positions are typically + * set by the primary y-axis, so the secondary y-axis has no choice in where to + * put these. It simply has to generate labels for these data values. + * + * Tick lists + * ---------- + * Typically a tick will have both a grid/tick line and a label at one end of + * that line (at the bottom for an x-axis, at left or right for the y-axis). + * + * A tick may be missing one of these two components: + * - If "label_v" is specified instead of "v", then there will be no tick or + * gridline, just a label. + * - Similarly, if "label" is not specified, then there will be a gridline + * without a label. + * + * This flexibility is useful in a few situations: + * - For log scales, some of the tick lines may be too close to all have labels. + * - For date scales where years are being displayed, it is desirable to display + * tick marks at the beginnings of years but labels (e.g. "2006") in the + * middle of the years. + */ + +Dygraph.numericTicks = function(a, b, pixels, opts, dygraph, vals) { + var pixels_per_tick = opts('pixelsPerLabel'); + var ticks = []; + if (vals) { + for (var i = 0; i < vals.length; i++) { + ticks.push({v: vals[i]}); + } + } else { + // TODO(danvk): factor this log-scale block out into a separate function. + if (opts("logscale")) { + var nTicks = Math.floor(pixels / pixels_per_tick); + var minIdx = Dygraph.binarySearch(a, Dygraph.PREFERRED_LOG_TICK_VALUES, 1); + var maxIdx = Dygraph.binarySearch(b, Dygraph.PREFERRED_LOG_TICK_VALUES, -1); + if (minIdx == -1) { + minIdx = 0; + } + if (maxIdx == -1) { + maxIdx = Dygraph.PREFERRED_LOG_TICK_VALUES.length - 1; + } + // Count the number of tick values would appear, if we can get at least + // nTicks / 4 accept them. + var lastDisplayed = null; + if (maxIdx - minIdx >= nTicks / 4) { + for (var idx = maxIdx; idx >= minIdx; idx--) { + var tickValue = Dygraph.PREFERRED_LOG_TICK_VALUES[idx]; + var pixel_coord = Math.log(tickValue / a) / Math.log(b / a) * pixels; + var tick = { v: tickValue }; + if (lastDisplayed == null) { + lastDisplayed = { + tickValue : tickValue, + pixel_coord : pixel_coord + }; + } else { + if (Math.abs(pixel_coord - lastDisplayed.pixel_coord) >= pixels_per_tick) { + lastDisplayed = { + tickValue : tickValue, + pixel_coord : pixel_coord + }; + } else { + tick.label = ""; + } + } + ticks.push(tick); + } + // Since we went in backwards order. + ticks.reverse(); + } + } + + // ticks.length won't be 0 if the log scale function finds values to insert. + if (ticks.length == 0) { + // Basic idea: + // Try labels every 1, 2, 5, 10, 20, 50, 100, etc. + // Calculate the resulting tick spacing (i.e. this.height_ / nTicks). + // The first spacing greater than pixelsPerYLabel is what we use. + // TODO(danvk): version that works on a log scale. + var kmg2 = opts("labelsKMG2"); + if (kmg2) { + var mults = [1, 2, 4, 8]; + } else { + var mults = [1, 2, 5]; + } + var scale, low_val, high_val, nTicks; + for (var i = -10; i < 50; i++) { + if (kmg2) { + var base_scale = Math.pow(16, i); + } else { + var base_scale = Math.pow(10, i); + } + for (var j = 0; j < mults.length; j++) { + scale = base_scale * mults[j]; + low_val = Math.floor(a / scale) * scale; + high_val = Math.ceil(b / scale) * scale; + nTicks = Math.abs(high_val - low_val) / scale; + var spacing = pixels / nTicks; + // wish I could break out of both loops at once... + if (spacing > pixels_per_tick) break; + } + if (spacing > pixels_per_tick) break; + } + + // Construct the set of ticks. + // Allow reverse y-axis if it's explicitly requested. + if (low_val > high_val) scale *= -1; + for (var i = 0; i < nTicks; i++) { + var tickV = low_val + i * scale; + ticks.push( {v: tickV} ); + } + } + } + + // Add formatted labels to the ticks. + var k; + var k_labels = []; + if (opts("labelsKMB")) { + k = 1000; + k_labels = [ "K", "M", "B", "T" ]; + } + if (opts("labelsKMG2")) { + if (k) self.warn("Setting both labelsKMB and labelsKMG2. Pick one!"); + k = 1024; + k_labels = [ "k", "M", "G", "T" ]; + } + + var formatter = opts('axisLabelFormatter'); + + // Add labels to the ticks. + for (var i = 0; i < ticks.length; i++) { + if (ticks[i].label !== undefined) continue; // Use current label. + var tickV = ticks[i].v; + var absTickV = Math.abs(tickV); + // TODO(danvk): set granularity to something appropriate here. + var label = formatter(tickV, 0, opts, dygraph); + if (k_labels.length > 0) { + // TODO(danvk): should this be integrated into the axisLabelFormatter? + // Round up to an appropriate unit. + var n = k*k*k*k; + for (var j = 3; j >= 0; j--, n /= k) { + if (absTickV >= n) { + label = Dygraph.round_(tickV / n, opts('digitsAfterDecimal')) + + k_labels[j]; + break; + } + } + } + ticks[i].label = label; + } + + return ticks; +}; + + +Dygraph.dateTicker = function(a, b, pixels, opts, dygraph, vals) { + var pixels_per_tick = opts('pixelsPerLabel'); + var chosen = -1; + for (var i = 0; i < Dygraph.NUM_GRANULARITIES; i++) { + var num_ticks = Dygraph.numDateTicks(a, b, i); + if (pixels / num_ticks >= pixels_per_tick) { + chosen = i; + break; + } + } + + if (chosen >= 0) { + return Dygraph.getDateAxis(a, b, chosen, opts, dygraph); + } else { + // this can happen if self.width_ is zero. + return []; + } +}; + +// Time granularity enumeration +Dygraph.SECONDLY = 0; +Dygraph.TWO_SECONDLY = 1; +Dygraph.FIVE_SECONDLY = 2; +Dygraph.TEN_SECONDLY = 3; +Dygraph.THIRTY_SECONDLY = 4; +Dygraph.MINUTELY = 5; +Dygraph.TWO_MINUTELY = 6; +Dygraph.FIVE_MINUTELY = 7; +Dygraph.TEN_MINUTELY = 8; +Dygraph.THIRTY_MINUTELY = 9; +Dygraph.HOURLY = 10; +Dygraph.TWO_HOURLY = 11; +Dygraph.SIX_HOURLY = 12; +Dygraph.DAILY = 13; +Dygraph.WEEKLY = 14; +Dygraph.MONTHLY = 15; +Dygraph.QUARTERLY = 16; +Dygraph.BIANNUAL = 17; +Dygraph.ANNUAL = 18; +Dygraph.DECADAL = 19; +Dygraph.CENTENNIAL = 20; +Dygraph.NUM_GRANULARITIES = 21; + +Dygraph.SHORT_SPACINGS = []; +Dygraph.SHORT_SPACINGS[Dygraph.SECONDLY] = 1000 * 1; +Dygraph.SHORT_SPACINGS[Dygraph.TWO_SECONDLY] = 1000 * 2; +Dygraph.SHORT_SPACINGS[Dygraph.FIVE_SECONDLY] = 1000 * 5; +Dygraph.SHORT_SPACINGS[Dygraph.TEN_SECONDLY] = 1000 * 10; +Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_SECONDLY] = 1000 * 30; +Dygraph.SHORT_SPACINGS[Dygraph.MINUTELY] = 1000 * 60; +Dygraph.SHORT_SPACINGS[Dygraph.TWO_MINUTELY] = 1000 * 60 * 2; +Dygraph.SHORT_SPACINGS[Dygraph.FIVE_MINUTELY] = 1000 * 60 * 5; +Dygraph.SHORT_SPACINGS[Dygraph.TEN_MINUTELY] = 1000 * 60 * 10; +Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_MINUTELY] = 1000 * 60 * 30; +Dygraph.SHORT_SPACINGS[Dygraph.HOURLY] = 1000 * 3600; +Dygraph.SHORT_SPACINGS[Dygraph.TWO_HOURLY] = 1000 * 3600 * 2; +Dygraph.SHORT_SPACINGS[Dygraph.SIX_HOURLY] = 1000 * 3600 * 6; +Dygraph.SHORT_SPACINGS[Dygraph.DAILY] = 1000 * 86400; +Dygraph.SHORT_SPACINGS[Dygraph.WEEKLY] = 1000 * 604800; + +/** + * @private + * This is a list of human-friendly values at which to show tick marks on a log + * scale. It is k * 10^n, where k=1..9 and n=-39..+39, so: + * ..., 1, 2, 3, 4, 5, ..., 9, 10, 20, 30, ..., 90, 100, 200, 300, ... + * NOTE: this assumes that Dygraph.LOG_SCALE = 10. + */ +Dygraph.PREFERRED_LOG_TICK_VALUES = function() { + var vals = []; + for (var power = -39; power <= 39; power++) { + var range = Math.pow(10, power); + for (var mult = 1; mult <= 9; mult++) { + var val = range * mult; + vals.push(val); + } + } + return vals; +}(); + +Dygraph.numDateTicks = function(start_time, end_time, granularity) { + if (granularity < Dygraph.MONTHLY) { + // Generate one tick mark for every fixed interval of time. + var spacing = Dygraph.SHORT_SPACINGS[granularity]; + return Math.floor(0.5 + 1.0 * (end_time - start_time) / spacing); + } else { + var year_mod = 1; // e.g. to only print one point every 10 years. + var num_months = 12; + if (granularity == Dygraph.QUARTERLY) num_months = 3; + if (granularity == Dygraph.BIANNUAL) num_months = 2; + if (granularity == Dygraph.ANNUAL) num_months = 1; + if (granularity == Dygraph.DECADAL) { num_months = 1; year_mod = 10; } + if (granularity == Dygraph.CENTENNIAL) { num_months = 1; year_mod = 100; } + + var msInYear = 365.2524 * 24 * 3600 * 1000; + var num_years = 1.0 * (end_time - start_time) / msInYear; + return Math.floor(0.5 + 1.0 * num_years * num_months / year_mod); + } +}; + +Dygraph.getDateAxis = function(start_time, end_time, granularity, opts, dg) { + var formatter = opts("axisLabelFormatter"); + var ticks = []; + if (granularity < Dygraph.MONTHLY) { + // Generate one tick mark for every fixed interval of time. + var spacing = Dygraph.SHORT_SPACINGS[granularity]; + var format = '%d%b'; // e.g. "1Jan" + + // Find a time less than start_time which occurs on a "nice" time boundary + // for this granularity. + var g = spacing / 1000; + var d = new Date(start_time); + if (g <= 60) { // seconds + var x = d.getSeconds(); d.setSeconds(x - x % g); + } else { + d.setSeconds(0); + g /= 60; + if (g <= 60) { // minutes + var x = d.getMinutes(); d.setMinutes(x - x % g); + } else { + d.setMinutes(0); + g /= 60; + + if (g <= 24) { // days + var x = d.getHours(); d.setHours(x - x % g); + } else { + d.setHours(0); + g /= 24; + + if (g == 7) { // one week + d.setDate(d.getDate() - d.getDay()); + } + } + } + } + start_time = d.getTime(); + + for (var t = start_time; t <= end_time; t += spacing) { + ticks.push({ v:t, + label: formatter(new Date(t), granularity, opts, dg) + }); + } + } else { + // Display a tick mark on the first of a set of months of each year. + // Years get a tick mark iff y % year_mod == 0. This is useful for + // displaying a tick mark once every 10 years, say, on long time scales. + var months; + 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 ]; + } else if (granularity == Dygraph.QUARTERLY) { + months = [ 0, 3, 6, 9 ]; + } else if (granularity == Dygraph.BIANNUAL) { + months = [ 0, 6 ]; + } else if (granularity == Dygraph.ANNUAL) { + months = [ 0 ]; + } else if (granularity == Dygraph.DECADAL) { + months = [ 0 ]; + year_mod = 10; + } else if (granularity == Dygraph.CENTENNIAL) { + months = [ 0 ]; + year_mod = 100; + } else { + Dygraph.warn("Span of dates is too long"); + } + + var start_year = new Date(start_time).getFullYear(); + var end_year = new Date(end_time).getFullYear(); + var zeropad = Dygraph.zeropad; + for (var i = start_year; i <= end_year; i++) { + if (i % year_mod != 0) continue; + for (var j = 0; j < months.length; j++) { + var date_str = i + "/" + zeropad(1 + months[j]) + "/01"; + var t = Dygraph.dateStrToMillis(date_str); + if (t < start_time || t > end_time) continue; + ticks.push({ v:t, + label: formatter(new Date(t), granularity, opts, dg) + }); + } + } + } + + return ticks; +}; + +// These are set here so that this file can be included after dygraph.js. +Dygraph.DEFAULT_ATTRS.axes.x.ticker = Dygraph.dateTicker; +Dygraph.DEFAULT_ATTRS.axes.y.ticker = Dygraph.numericTicks; +Dygraph.DEFAULT_ATTRS.axes.y2.ticker = Dygraph.numericTicks; diff --git a/dygraph-utils.js b/dygraph-utils.js index 2ba6ab5..7cb5019 100644 --- a/dygraph-utils.js +++ b/dygraph-utils.js @@ -343,30 +343,6 @@ Dygraph.hmsString_ = function(date) { }; /** - * Convert a JS date (millis since epoch) to YYYY/MM/DD - * @param {Number} date The JavaScript date (ms since epoch) - * @return {String} A date of the form "YYYY/MM/DD" - * @private - */ -Dygraph.dateString_ = function(date) { - var zeropad = Dygraph.zeropad; - var d = new Date(date); - - // Get the year: - var year = "" + d.getFullYear(); - // Get a 0 padded month string - var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh - // Get a 0 padded day string - var day = zeropad(d.getDate()); - - var ret = ""; - var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); - if (frac) ret = " " + Dygraph.hmsString_(date); - - return year + "/" + month + "/" + day + ret; -}; - -/** * Round a number to the specified number of digits past the decimal point. * @param {Number} num The number to round * @param {Number} places The number of decimals to which to round @@ -495,6 +471,33 @@ Dygraph.update = function (self, o) { }; /** + * Copies all the properties from o to self. + * + * @private + */ +Dygraph.updateDeep = function (self, o) { + if (typeof(o) != 'undefined' && o !== null) { + for (var k in o) { + if (o.hasOwnProperty(k)) { + if (o[k] == null) { + self[k] = null; + } else if (Dygraph.isArrayLike(o[k])) { + self[k] = o[k].slice(); + } else if (typeof(o[k]) == 'object') { + if (typeof(self[k]) != 'object') { + self[k] = {}; + } + Dygraph.updateDeep(self[k], o[k]); + } else { + self[k] = o[k]; + } + } + } + } + return self; +}; + +/** * @private */ Dygraph.isArrayLike = function (o) { @@ -523,6 +526,7 @@ Dygraph.isDateLike = function (o) { }; /** + * Note: this only seems to work for arrays. * @private */ Dygraph.clone = function(o) { diff --git a/dygraph.js b/dygraph.js index 86a5b37..c04bccc 100644 --- a/dygraph.js +++ b/dygraph.js @@ -86,11 +86,96 @@ Dygraph.DEFAULT_ROLL_PERIOD = 1; Dygraph.DEFAULT_WIDTH = 480; Dygraph.DEFAULT_HEIGHT = 320; +// These are defined before DEFAULT_ATTRS so that it can refer to them. +/** + * @private + * Return a string version of a number. This respects the digitsAfterDecimal + * and maxNumberWidth options. + * @param {Number} x The number to be formatted + * @param {Dygraph} opts An options view + * @param {String} name The name of the point's data series + * @param {Dygraph} g The dygraph object + */ +Dygraph.numberValueFormatter = function(x, opts, pt, g) { + var sigFigs = opts('sigFigs'); + + if (sigFigs !== null) { + // User has opted for a fixed number of significant figures. + return Dygraph.floatFormat(x, sigFigs); + } + + var digits = opts('digitsAfterDecimal'); + var maxNumberWidth = opts('maxNumberWidth'); + + // switch to scientific notation if we underflow or overflow fixed display. + if (x !== 0.0 && + (Math.abs(x) >= Math.pow(10, maxNumberWidth) || + Math.abs(x) < Math.pow(10, -digits))) { + return x.toExponential(digits); + } else { + return '' + Dygraph.round_(x, digits); + } +}; + +/** + * variant for use as an axisLabelFormatter. + * @private + */ +Dygraph.numberAxisLabelFormatter = function(x, granularity, opts, g) { + return Dygraph.numberValueFormatter(x, opts, g); +}; + +/** + * Convert a JS date (millis since epoch) to YYYY/MM/DD + * @param {Number} date The JavaScript date (ms since epoch) + * @return {String} A date of the form "YYYY/MM/DD" + * @private + */ +Dygraph.dateString_ = function(date) { + var zeropad = Dygraph.zeropad; + var d = new Date(date); + + // Get the year: + var year = "" + d.getFullYear(); + // Get a 0 padded month string + var month = zeropad(d.getMonth() + 1); //months are 0-offset, sigh + // Get a 0 padded day string + var day = zeropad(d.getDate()); + + var ret = ""; + var frac = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); + if (frac) ret = " " + Dygraph.hmsString_(date); + + return year + "/" + month + "/" + day + ret; +}; + +/** + * Convert a JS date to a string appropriate to display on an axis that + * is displaying values at the stated granularity. + * @param {Date} date The date to format + * @param {Number} granularity One of the Dygraph granularity constants + * @return {String} The formatted date + * @private + */ +Dygraph.dateAxisFormatter = function(date, granularity) { + if (granularity >= Dygraph.DECADAL) { + return date.strftime('%Y'); + } else if (granularity >= Dygraph.MONTHLY) { + return date.strftime('%b %y'); + } else { + var frac = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds() + date.getMilliseconds(); + if (frac == 0 || granularity >= Dygraph.DAILY) { + return new Date(date.getTime() + 3600*1000).strftime('%d%b'); + } else { + return Dygraph.hmsString_(date.getTime()); + } + } +}; + + // Default attribute values. Dygraph.DEFAULT_ATTRS = { highlightCircleSize: 3, - pixelsPerXLabel: 60, - pixelsPerYLabel: 30, labelsDivWidth: 250, labelsDivStyles: { @@ -102,7 +187,6 @@ Dygraph.DEFAULT_ATTRS = { labelsKMG2: false, showLabelsOnHighlight: true, - yValueFormatter: function(a,b) { return Dygraph.numberFormatter(a,b); }, digitsAfterDecimal: 2, maxNumberWidth: 6, sigFigs: null, @@ -113,13 +197,10 @@ Dygraph.DEFAULT_ATTRS = { axisLabelFontSize: 14, xAxisLabelWidth: 50, yAxisLabelWidth: 50, - xAxisLabelFormatter: Dygraph.dateAxisFormatter, rightGap: 5, showRoller: false, - xValueFormatter: Dygraph.dateString_, xValueParser: Dygraph.dateParser, - xTicker: Dygraph.dateTicker, delimiter: ',', @@ -158,7 +239,29 @@ Dygraph.DEFAULT_ATTRS = { drawXGrid: true, gridLineColor: "rgb(128,128,128)", - interactionModel: null // will be set to Dygraph.Interaction.defaultModel + interactionModel: null, // will be set to Dygraph.Interaction.defaultModel + + // per-axis options + axes: { + x: { + pixelsPerLabel: 60, + axisLabelFormatter: Dygraph.dateAxisFormatter, + valueFormatter: Dygraph.dateString_, + ticker: null // will be set in dygraph-tickers.js + }, + y: { + pixelsPerLabel: 30, + valueFormatter: Dygraph.numberValueFormatter, + axisLabelFormatter: Dygraph.numberAxisLabelFormatter, + ticker: null // will be set in dygraph-tickers.js + }, + y2: { + pixelsPerLabel: 30, + valueFormatter: Dygraph.numberValueFormatter, + axisLabelFormatter: Dygraph.numberAxisLabelFormatter, + ticker: null // will be set in dygraph-tickers.js + } + } }; // Directions for panning and zooming. Use bit operations when combined @@ -204,6 +307,13 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { // Support two-argument constructor if (attrs == null) { attrs = {}; } + attrs = Dygraph.mapLegacyOptions_(attrs); + + if (!div) { + Dygraph.error("Constructing dygraph with a non-existent div!"); + return; + } + // Copy the important bits into the object // TODO(danvk): most of these should just stay in the attrs_ dictionary. this.maindiv_ = div; @@ -263,8 +373,9 @@ Dygraph.prototype.__init__ = function(div, file, attrs) { this.user_attrs_ = {}; Dygraph.update(this.user_attrs_, attrs); + // This sequence ensures that Dygraph.DEFAULT_ATTRS is never modified. this.attrs_ = {}; - Dygraph.update(this.attrs_, Dygraph.DEFAULT_ATTRS); + Dygraph.updateDeep(this.attrs_, Dygraph.DEFAULT_ATTRS); this.boundaryIds_ = []; @@ -336,6 +447,39 @@ Dygraph.prototype.attr_ = function(name, seriesName) { }; /** + * @private + * @param String} axis The name of the axis (i.e. 'x', 'y' or 'y2') + * @return { ... } A function mapping string -> option value + */ +Dygraph.prototype.optionsViewForAxis_ = function(axis) { + var self = this; + return function(opt) { + var axis_opts = self.user_attrs_['axes']; + if (axis_opts && axis_opts[axis] && axis_opts[axis][opt]) { + return axis_opts[axis][opt]; + } + // user-specified attributes always trump defaults, even if they're less + // specific. + if (typeof(self.user_attrs_[opt]) != 'undefined') { + return self.user_attrs_[opt]; + } + + axis_opts = self.attrs_['axes']; + if (axis_opts && axis_opts[axis] && axis_opts[axis][opt]) { + return axis_opts[axis][opt]; + } + // check old-style axis options + // TODO(danvk): add a deprecation warning if either of these match. + if (axis == 'y' && self.axes_[0].hasOwnProperty(opt)) { + return self.axes_[0][opt]; + } else if (axis == 'y2' && self.axes_[1].hasOwnProperty(opt)) { + return self.axes_[1][opt]; + } + return self.attr_(opt); + }; +}; + +/** * Returns the current rolling period, as set by the user or an option. * @return {Number} The number of points in the rolling window */ @@ -1228,9 +1372,15 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) { return html; } - var html = this.attr_('xValueFormatter')(x) + ":"; + var xOptView = this.optionsViewForAxis_('x'); + var xvf = xOptView('valueFormatter'); + var html = xvf(x, xOptView, this.attr_('labels')[0], this) + ":"; - var fmtFunc = this.attr_('yValueFormatter'); + var yOptViews = []; + var num_axes = this.numAxes(); + for (var i = 0; i < num_axes; i++) { + yOptViews[i] = this.optionsViewForAxis_('y' + (i ? 1 + i : '')); + } var showZeros = this.attr_("labelsShowZeroValues"); var sepLines = this.attr_("labelsSeparateLines"); for (var i = 0; i < this.selPoints_.length; i++) { @@ -1239,8 +1389,11 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) { if (!Dygraph.isOK(pt.canvasy)) continue; if (sepLines) html += "
"; + var yOptView = yOptViews[this.seriesToAxisMap_[pt.name]]; + var fmtFunc = yOptView('valueFormatter'); var c = this.plotter_.colors[pt.name]; - var yval = fmtFunc(pt.yval, this); + var yval = fmtFunc(pt.yval, yOptView, pt.name, this); + // TODO(danvk): use a template string here and make it an attribute. html += " " + pt.name + ":" @@ -1402,57 +1555,6 @@ Dygraph.prototype.getSelection = function() { }; /** - * @private - * Return a string version of a number. This respects the digitsAfterDecimal - * and maxNumberWidth options. - * @param {Number} x The number to be formatted - * @param {Dygraph} g The dygraph object - */ -Dygraph.numberFormatter = function(x, g) { - var sigFigs = g.attr_('sigFigs'); - - if (sigFigs !== null) { - // User has opted for a fixed number of significant figures. - return Dygraph.floatFormat(x, sigFigs); - } - - var digits = g.attr_('digitsAfterDecimal'); - var maxNumberWidth = g.attr_('maxNumberWidth'); - - // switch to scientific notation if we underflow or overflow fixed display. - if (x !== 0.0 && - (Math.abs(x) >= Math.pow(10, maxNumberWidth) || - Math.abs(x) < Math.pow(10, -digits))) { - return x.toExponential(digits); - } else { - return '' + Dygraph.round_(x, digits); - } -}; - -/** - * Convert a JS date to a string appropriate to display on an axis that - * is displaying values at the stated granularity. - * @param {Date} date The date to format - * @param {Number} granularity One of the Dygraph granularity constants - * @return {String} The formatted date - * @private - */ -Dygraph.dateAxisFormatter = function(date, granularity) { - if (granularity >= Dygraph.DECADAL) { - return date.strftime('%Y'); - } else if (granularity >= Dygraph.MONTHLY) { - return date.strftime('%b %y'); - } else { - var frac = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds() + date.getMilliseconds(); - if (frac == 0 || granularity >= Dygraph.DAILY) { - return new Date(date.getTime() + 3600*1000).strftime('%d%b'); - } else { - return Dygraph.hmsString_(date.getTime()); - } - } -}; - -/** * Fires when there's data available to be graphed. * @param {String} data Raw CSV data to be plotted * @private @@ -1462,10 +1564,6 @@ Dygraph.prototype.loadedEvent_ = function(data) { this.predraw_(); }; -Dygraph.prototype.months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -Dygraph.prototype.quarters = ["Jan", "Apr", "Jul", "Oct"]; - /** * Add ticks on the x-axis representing years, months, quarters, weeks, or days * @private @@ -1479,358 +1577,18 @@ Dygraph.prototype.addXTicks_ = function() { range = [this.rawData_[0][0], this.rawData_[this.rawData_.length - 1][0]]; } - var xTicks = this.attr_('xTicker')(range[0], range[1], this); + var xAxisOptionsView = this.optionsViewForAxis_('x'); + var xTicks = xAxisOptionsView('ticker')( + range[0], + range[1], + this.width_, // TODO(danvk): should be area.width + xAxisOptionsView, + this); + // var msg = 'ticker(' + range[0] + ', ' + range[1] + ', ' + this.width_ + ', ' + this.attr_('pixelsPerXLabel') + ') -> ' + JSON.stringify(xTicks); + // console.log(msg); this.layout_.setXTicks(xTicks); }; -// Time granularity enumeration -Dygraph.SECONDLY = 0; -Dygraph.TWO_SECONDLY = 1; -Dygraph.FIVE_SECONDLY = 2; -Dygraph.TEN_SECONDLY = 3; -Dygraph.THIRTY_SECONDLY = 4; -Dygraph.MINUTELY = 5; -Dygraph.TWO_MINUTELY = 6; -Dygraph.FIVE_MINUTELY = 7; -Dygraph.TEN_MINUTELY = 8; -Dygraph.THIRTY_MINUTELY = 9; -Dygraph.HOURLY = 10; -Dygraph.TWO_HOURLY = 11; -Dygraph.SIX_HOURLY = 12; -Dygraph.DAILY = 13; -Dygraph.WEEKLY = 14; -Dygraph.MONTHLY = 15; -Dygraph.QUARTERLY = 16; -Dygraph.BIANNUAL = 17; -Dygraph.ANNUAL = 18; -Dygraph.DECADAL = 19; -Dygraph.CENTENNIAL = 20; -Dygraph.NUM_GRANULARITIES = 21; - -Dygraph.SHORT_SPACINGS = []; -Dygraph.SHORT_SPACINGS[Dygraph.SECONDLY] = 1000 * 1; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_SECONDLY] = 1000 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.FIVE_SECONDLY] = 1000 * 5; -Dygraph.SHORT_SPACINGS[Dygraph.TEN_SECONDLY] = 1000 * 10; -Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_SECONDLY] = 1000 * 30; -Dygraph.SHORT_SPACINGS[Dygraph.MINUTELY] = 1000 * 60; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_MINUTELY] = 1000 * 60 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.FIVE_MINUTELY] = 1000 * 60 * 5; -Dygraph.SHORT_SPACINGS[Dygraph.TEN_MINUTELY] = 1000 * 60 * 10; -Dygraph.SHORT_SPACINGS[Dygraph.THIRTY_MINUTELY] = 1000 * 60 * 30; -Dygraph.SHORT_SPACINGS[Dygraph.HOURLY] = 1000 * 3600; -Dygraph.SHORT_SPACINGS[Dygraph.TWO_HOURLY] = 1000 * 3600 * 2; -Dygraph.SHORT_SPACINGS[Dygraph.SIX_HOURLY] = 1000 * 3600 * 6; -Dygraph.SHORT_SPACINGS[Dygraph.DAILY] = 1000 * 86400; -Dygraph.SHORT_SPACINGS[Dygraph.WEEKLY] = 1000 * 604800; - -/** - * @private - * If we used this time granularity, how many ticks would there be? - * This is only an approximation, but it's generally good enough. - */ -Dygraph.prototype.NumXTicks = function(start_time, end_time, granularity) { - if (granularity < Dygraph.MONTHLY) { - // Generate one tick mark for every fixed interval of time. - var spacing = Dygraph.SHORT_SPACINGS[granularity]; - return Math.floor(0.5 + 1.0 * (end_time - start_time) / spacing); - } else { - var year_mod = 1; // e.g. to only print one point every 10 years. - var num_months = 12; - if (granularity == Dygraph.QUARTERLY) num_months = 3; - if (granularity == Dygraph.BIANNUAL) num_months = 2; - if (granularity == Dygraph.ANNUAL) num_months = 1; - if (granularity == Dygraph.DECADAL) { num_months = 1; year_mod = 10; } - if (granularity == Dygraph.CENTENNIAL) { num_months = 1; year_mod = 100; } - - var msInYear = 365.2524 * 24 * 3600 * 1000; - var num_years = 1.0 * (end_time - start_time) / msInYear; - return Math.floor(0.5 + 1.0 * num_years * num_months / year_mod); - } -}; - -/** - * @private - * - * Construct an x-axis of nicely-formatted times on meaningful boundaries - * (e.g. 'Jan 09' rather than 'Jan 22, 2009'). - * - * Returns an array containing {v: millis, label: label} dictionaries. - */ -Dygraph.prototype.GetXAxis = function(start_time, end_time, granularity) { - var formatter = this.attr_("xAxisLabelFormatter"); - var ticks = []; - if (granularity < Dygraph.MONTHLY) { - // Generate one tick mark for every fixed interval of time. - var spacing = Dygraph.SHORT_SPACINGS[granularity]; - var format = '%d%b'; // e.g. "1Jan" - - // Find a time less than start_time which occurs on a "nice" time boundary - // for this granularity. - var g = spacing / 1000; - var d = new Date(start_time); - if (g <= 60) { // seconds - var x = d.getSeconds(); d.setSeconds(x - x % g); - } else { - d.setSeconds(0); - g /= 60; - if (g <= 60) { // minutes - var x = d.getMinutes(); d.setMinutes(x - x % g); - } else { - d.setMinutes(0); - g /= 60; - - if (g <= 24) { // days - var x = d.getHours(); d.setHours(x - x % g); - } else { - d.setHours(0); - g /= 24; - - if (g == 7) { // one week - d.setDate(d.getDate() - d.getDay()); - } - } - } - } - start_time = d.getTime(); - - for (var t = start_time; t <= end_time; t += spacing) { - ticks.push({ v:t, label: formatter(new Date(t), granularity) }); - } - } else { - // Display a tick mark on the first of a set of months of each year. - // Years get a tick mark iff y % year_mod == 0. This is useful for - // displaying a tick mark once every 10 years, say, on long time scales. - var months; - 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 ]; - } else if (granularity == Dygraph.QUARTERLY) { - months = [ 0, 3, 6, 9 ]; - } else if (granularity == Dygraph.BIANNUAL) { - months = [ 0, 6 ]; - } else if (granularity == Dygraph.ANNUAL) { - months = [ 0 ]; - } else if (granularity == Dygraph.DECADAL) { - months = [ 0 ]; - year_mod = 10; - } else if (granularity == Dygraph.CENTENNIAL) { - months = [ 0 ]; - year_mod = 100; - } else { - this.warn("Span of dates is too long"); - } - - var start_year = new Date(start_time).getFullYear(); - var end_year = new Date(end_time).getFullYear(); - var zeropad = Dygraph.zeropad; - for (var i = start_year; i <= end_year; i++) { - if (i % year_mod != 0) continue; - for (var j = 0; j < months.length; j++) { - var date_str = i + "/" + zeropad(1 + months[j]) + "/01"; - var t = Dygraph.dateStrToMillis(date_str); - if (t < start_time || t > end_time) continue; - ticks.push({ v:t, label: formatter(new Date(t), granularity) }); - } - } - } - - return ticks; -}; - - -/** - * Add ticks to the x-axis based on a date range. - * @param {Number} startDate Start of the date window (millis since epoch) - * @param {Number} endDate End of the date window (millis since epoch) - * @param {Dygraph} self The dygraph object - * @return { [Object] } Array of {label, value} tuples. - * @public - */ -Dygraph.dateTicker = function(startDate, endDate, self) { - // TODO(danvk): why does this take 'self' as a param? - var chosen = -1; - for (var i = 0; i < Dygraph.NUM_GRANULARITIES; i++) { - var num_ticks = self.NumXTicks(startDate, endDate, i); - if (self.width_ / num_ticks >= self.attr_('pixelsPerXLabel')) { - chosen = i; - break; - } - } - - if (chosen >= 0) { - return self.GetXAxis(startDate, endDate, chosen); - } else { - // this can happen if self.width_ is zero. - return []; - } -}; - -/** - * @private - * This is a list of human-friendly values at which to show tick marks on a log - * scale. It is k * 10^n, where k=1..9 and n=-39..+39, so: - * ..., 1, 2, 3, 4, 5, ..., 9, 10, 20, 30, ..., 90, 100, 200, 300, ... - * NOTE: this assumes that Dygraph.LOG_SCALE = 10. - */ -Dygraph.PREFERRED_LOG_TICK_VALUES = function() { - var vals = []; - for (var power = -39; power <= 39; power++) { - var range = Math.pow(10, power); - for (var mult = 1; mult <= 9; mult++) { - var val = range * mult; - vals.push(val); - } - } - return vals; -}(); - -// TODO(konigsberg): Update comment. -/** - * Add ticks when the x axis has numbers on it (instead of dates) - * - * @param {Number} minV minimum value - * @param {Number} maxV maximum value - * @param self - * @param {function} attribute accessor function. - * @return {[Object]} Array of {label, value} tuples. - */ -Dygraph.numericTicks = function(minV, maxV, self, axis_props, vals) { - var attr = function(k) { - if (axis_props && axis_props.hasOwnProperty(k)) return axis_props[k]; - return self.attr_(k); - }; - - var ticks = []; - if (vals) { - for (var i = 0; i < vals.length; i++) { - ticks.push({v: vals[i]}); - } - } else { - if (axis_props && attr("logscale")) { - var pixelsPerTick = attr('pixelsPerYLabel'); - // NOTE(konigsberg): Dan, should self.height_ be self.plotter_.area.h? - var nTicks = Math.floor(self.height_ / pixelsPerTick); - var minIdx = Dygraph.binarySearch(minV, Dygraph.PREFERRED_LOG_TICK_VALUES, 1); - var maxIdx = Dygraph.binarySearch(maxV, Dygraph.PREFERRED_LOG_TICK_VALUES, -1); - if (minIdx == -1) { - minIdx = 0; - } - if (maxIdx == -1) { - maxIdx = Dygraph.PREFERRED_LOG_TICK_VALUES.length - 1; - } - // Count the number of tick values would appear, if we can get at least - // nTicks / 4 accept them. - var lastDisplayed = null; - if (maxIdx - minIdx >= nTicks / 4) { - var axisId = axis_props.yAxisId; - for (var idx = maxIdx; idx >= minIdx; idx--) { - var tickValue = Dygraph.PREFERRED_LOG_TICK_VALUES[idx]; - var domCoord = axis_props.g.toDomYCoord(tickValue, axisId); - var tick = { v: tickValue }; - if (lastDisplayed == null) { - lastDisplayed = { - tickValue : tickValue, - domCoord : domCoord - }; - } else { - if (domCoord - lastDisplayed.domCoord >= pixelsPerTick) { - lastDisplayed = { - tickValue : tickValue, - domCoord : domCoord - }; - } else { - tick.label = ""; - } - } - ticks.push(tick); - } - // Since we went in backwards order. - ticks.reverse(); - } - } - - // ticks.length won't be 0 if the log scale function finds values to insert. - if (ticks.length == 0) { - // Basic idea: - // Try labels every 1, 2, 5, 10, 20, 50, 100, etc. - // Calculate the resulting tick spacing (i.e. this.height_ / nTicks). - // The first spacing greater than pixelsPerYLabel is what we use. - // TODO(danvk): version that works on a log scale. - if (attr("labelsKMG2")) { - var mults = [1, 2, 4, 8]; - } else { - var mults = [1, 2, 5]; - } - var scale, low_val, high_val, nTicks; - // TODO(danvk): make it possible to set this for x- and y-axes independently. - var pixelsPerTick = attr('pixelsPerYLabel'); - for (var i = -10; i < 50; i++) { - if (attr("labelsKMG2")) { - var base_scale = Math.pow(16, i); - } else { - var base_scale = Math.pow(10, i); - } - for (var j = 0; j < mults.length; j++) { - scale = base_scale * mults[j]; - low_val = Math.floor(minV / scale) * scale; - high_val = Math.ceil(maxV / scale) * scale; - nTicks = Math.abs(high_val - low_val) / scale; - var spacing = self.height_ / nTicks; - // wish I could break out of both loops at once... - if (spacing > pixelsPerTick) break; - } - if (spacing > pixelsPerTick) break; - } - - // Construct the set of ticks. - // Allow reverse y-axis if it's explicitly requested. - if (low_val > high_val) scale *= -1; - for (var i = 0; i < nTicks; i++) { - var tickV = low_val + i * scale; - ticks.push( {v: tickV} ); - } - } - } - - // Add formatted labels to the ticks. - var k; - var k_labels = []; - if (attr("labelsKMB")) { - k = 1000; - k_labels = [ "K", "M", "B", "T" ]; - } - if (attr("labelsKMG2")) { - if (k) self.warn("Setting both labelsKMB and labelsKMG2. Pick one!"); - k = 1024; - k_labels = [ "k", "M", "G", "T" ]; - } - var formatter = attr('yAxisLabelFormatter') ? - attr('yAxisLabelFormatter') : attr('yValueFormatter'); - - // Add labels to the ticks. - for (var i = 0; i < ticks.length; i++) { - if (ticks[i].label !== undefined) continue; // Use current label. - var tickV = ticks[i].v; - var absTickV = Math.abs(tickV); - var label = formatter(tickV, self); - if (k_labels.length > 0) { - // Round up to an appropriate unit. - var n = k*k*k*k; - for (var j = 3; j >= 0; j--, n /= k) { - if (absTickV >= n) { - label = Dygraph.round_(tickV / n, attr('digitsAfterDecimal')) + k_labels[j]; - break; - } - } - } - ticks[i].label = label; - } - - return ticks; -}; - /** * @private * Computes the range of the data series (including confidence intervals). @@ -2123,7 +1881,6 @@ Dygraph.prototype.computeYAxes_ = function() { } } - this.axes_ = [{ yAxisId : 0, g : this }]; // always have at least one y-axis. this.seriesToAxisMap_ = {}; @@ -2323,12 +2080,14 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) { // Add ticks. By default, all axes inherit the tick positions of the // primary axis. However, if an axis is specifically marked as having // independent ticks, then that is permissible as well. + var opts = this.optionsViewForAxis_('y' + (i ? '2' : '')); + var ticker = opts('ticker'); if (i == 0 || axis.independentTicks) { - axis.ticks = - Dygraph.numericTicks(axis.computedValueRange[0], - axis.computedValueRange[1], - this, - axis); + axis.ticks = ticker(axis.computedValueRange[0], + axis.computedValueRange[1], + this.height_, // TODO(danvk): should be area.height + opts, + this); } else { var p_axis = this.axes_[0]; var p_ticks = p_axis.ticks; @@ -2341,10 +2100,12 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) { tick_values.push(y_val); } - axis.ticks = - Dygraph.numericTicks(axis.computedValueRange[0], - axis.computedValueRange[1], - this, axis, tick_values); + axis.ticks = ticker(axis.computedValueRange[0], + axis.computedValueRange[1], + this.height_, // TODO(danvk): should be area.height + opts, + this, + tick_values); } } }; @@ -2508,18 +2269,18 @@ Dygraph.prototype.detectTypeFromString_ = function(str) { } if (isDate) { - this.attrs_.xValueFormatter = Dygraph.dateString_; this.attrs_.xValueParser = Dygraph.dateParser; - this.attrs_.xTicker = Dygraph.dateTicker; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; } else { - // TODO(danvk): use Dygraph.numberFormatter here? - /** @private (shut up, jsdoc!) */ - this.attrs_.xValueFormatter = function(x) { return x; }; /** @private (shut up, jsdoc!) */ this.attrs_.xValueParser = function(x) { return parseFloat(x); }; - this.attrs_.xTicker = Dygraph.numericTicks; - this.attrs_.xAxisLabelFormatter = this.attrs_.xValueFormatter; + // 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.numericTicks; + this.attrs_.axes.x.axisLabelFormatter = this.attrs_.axes.x.valueFormatter; } }; @@ -2730,9 +2491,9 @@ Dygraph.prototype.parseArray_ = function(data) { if (Dygraph.isDateLike(data[0][0])) { // Some intelligent defaults for a date x-axis. - this.attrs_.xValueFormatter = Dygraph.dateString_; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; - this.attrs_.xTicker = Dygraph.dateTicker; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; // Assume they're all dates. var parsedData = Dygraph.clone(data); @@ -2753,8 +2514,9 @@ Dygraph.prototype.parseArray_ = function(data) { } else { // Some intelligent defaults for a numeric x-axis. /** @private (shut up, jsdoc!) */ - this.attrs_.xValueFormatter = function(x) { return x; }; - this.attrs_.xTicker = Dygraph.numericTicks; + this.attrs_.axes.x.valueFormatter = function(x) { return x; }; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.numberAxisLabelFormatter; + this.attrs_.axes.x.ticker = Dygraph.numericTicks; return data; } }; @@ -2774,15 +2536,15 @@ Dygraph.prototype.parseDataTable_ = function(data) { var indepType = data.getColumnType(0); if (indepType == 'date' || indepType == 'datetime') { - this.attrs_.xValueFormatter = Dygraph.dateString_; this.attrs_.xValueParser = Dygraph.dateParser; - this.attrs_.xTicker = Dygraph.dateTicker; - this.attrs_.xAxisLabelFormatter = Dygraph.dateAxisFormatter; + this.attrs_.axes.x.valueFormatter = Dygraph.dateString_; + this.attrs_.axes.x.ticker = Dygraph.dateTicker; + this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter; } else if (indepType == 'number') { - this.attrs_.xValueFormatter = function(x) { return x; }; this.attrs_.xValueParser = function(x) { return parseFloat(x); }; - this.attrs_.xTicker = Dygraph.numericTicks; - this.attrs_.xAxisLabelFormatter = this.attrs_.xValueFormatter; + this.attrs_.axes.x.valueFormatter = function(x) { return x; }; + 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 " + "column 1 of DataTable input (Got '" + indepType + "')"); @@ -2943,9 +2705,13 @@ Dygraph.prototype.start_ = function() { * avoiding the occasional infinite loop and preventing redraws when it's not * necessary (e.g. when updating a callback). */ -Dygraph.prototype.updateOptions = function(attrs, block_redraw) { +Dygraph.prototype.updateOptions = function(input_attrs, block_redraw) { if (typeof(block_redraw) == 'undefined') block_redraw = false; + // mapLegacyOptions_ drops the "file" parameter as a convenience to us. + var file = input_attrs['file']; + var attrs = Dygraph.mapLegacyOptions_(input_attrs); + // TODO(danvk): this is a mess. Move these options into attr_. if ('rollPeriod' in attrs) { this.rollPeriod_ = attrs.rollPeriod; @@ -2970,15 +2736,15 @@ Dygraph.prototype.updateOptions = function(attrs, block_redraw) { // Check if this set options will require new points. var requiresNewPoints = Dygraph.isPixelChangingOptionList(this.attr_("labels"), attrs); - Dygraph.update(this.user_attrs_, attrs); + Dygraph.updateDeep(this.user_attrs_, attrs); - if (attrs['file']) { - this.file_ = attrs['file']; + if (file) { + this.file_ = file; if (!block_redraw) this.start_(); } else { if (!block_redraw) { if (requiresNewPoints) { - this.predraw_(); + this.predraw_(); } else { this.renderGraph_(false, false); } @@ -2987,6 +2753,43 @@ Dygraph.prototype.updateOptions = function(attrs, block_redraw) { }; /** + * Returns a copy of the options with deprecated names converted into current + * names. Also drops the (potentially-large) 'file' attribute. If the caller is + * interested in that, they should save a copy before calling this. + * @private + */ +Dygraph.mapLegacyOptions_ = function(attrs) { + var my_attrs = {}; + for (var k in attrs) { + if (k == 'file') continue; + if (attrs.hasOwnProperty(k)) my_attrs[k] = attrs[k]; + } + + var set = function(axis, opt, value) { + if (!my_attrs.axes) my_attrs.axes = {}; + if (!my_attrs.axes[axis]) my_attrs.axes[axis] = {}; + my_attrs.axes[axis][opt] = value; + }; + var map = function(opt, axis, new_opt) { + if (typeof(attrs[opt]) != 'undefined') { + set(axis, new_opt, attrs[opt]); + delete my_attrs[opt]; + } + }; + + // This maps, e.g., xValueFormater -> axes: { x: { valueFormatter: ... } } + map('xValueFormatter', 'x', 'valueFormatter'); + map('pixelsPerXLabel', 'x', 'pixelsPerLabel'); + map('xAxisLabelFormatter', 'x', 'axisLabelFormatter'); + map('xTicker', 'x', 'ticker'); + map('yValueFormatter', 'y', 'valueFormatter'); + map('pixelsPerYLabel', 'y', 'pixelsPerLabel'); + map('yAxisLabelFormatter', 'y', 'axisLabelFormatter'); + map('yTicker', 'y', 'ticker'); + return my_attrs; +}; + +/** * Resizes the dygraph. If no parameters are specified, resizes to fill the * containing div (which has presumably changed size since the dygraph was * instantiated. If the width/height are specified, the div will be resized. diff --git a/generate-combined.sh b/generate-combined.sh index d38772c..0f3064a 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-tickers.js \ rgbcolor/rgbcolor.js \ strftime/strftime-min.js \ | perl -ne 'print unless m,REMOVE_FOR_COMBINED,..m,/REMOVE_FOR_COMBINED,' \ diff --git a/jsTestDriver.conf b/jsTestDriver.conf index d6c2479..7d89afd 100644 --- a/jsTestDriver.conf +++ b/jsTestDriver.conf @@ -12,6 +12,7 @@ load: - dygraph-gviz.js - dygraph-interaction-model.js - dygraph-options-reference.js + - dygraph-tickers.js - dygraph-dev.js - excanvas.js - auto_tests/tests/*.js diff --git a/tests/is-zoomed.html b/tests/is-zoomed.html index a9bdeb5..7aa1da2 100644 --- a/tests/is-zoomed.html +++ b/tests/is-zoomed.html @@ -90,7 +90,7 @@

-

The Tests for zoom operations show a full example of this in action.

+

The Tests for zoom operations show a full example of this in action.

Programmatic Zoom

diff --git a/tests/multi-scale.html b/tests/multi-scale.html new file mode 100644 index 0000000..b10cd93 --- /dev/null +++ b/tests/multi-scale.html @@ -0,0 +1,98 @@ + + + + + multi-scale + + + + +

Gridlines and axis labels make charts easier to understand. They give + the lines a clear scale. Unless you tell it otherwise, dygraphs will choose + a y-axis and set of gridlines which include all of your data.

+ +

If you have many series with different scales, this will compress the + variation in all but the largest one. Standard ways to deal with this + include secondary y-axes and log scales.

+ +

If neither of these is to your liking, you can manually rescale your + series and undo that scaling for the hover values. This demo shows how to + do it.

+ +
+ +

Hover over to see the original values. This is what the data looks + like without any rescaling:

+ +
+ + + + diff --git a/tests/number-display.html b/tests/number-display.html index a1a8c64..c6d93b3 100644 --- a/tests/number-display.html +++ b/tests/number-display.html @@ -98,10 +98,8 @@ html += '\n'; var attr = {}; - var g_mock = { - attr_: function(x) { - return attr[x]; - } + var opts = function(x) { + return attr[x]; }; for (var j = 0; j < nums.length; j++) { var x = nums[j]; @@ -109,11 +107,11 @@ html += '' + x + ''; for (var i = 0; i < scientific.length; i++) { attr = { sigFigs: scientific[i] }; - html += '' + Dygraph.numberFormatter(x, g_mock) + ''; + html += '' + Dygraph.numberFormatter(x, opts) + ''; } for (var i = 0; i < fixed.length; i++) { attr = { sigFigs: null, digitsAfterDecimal: fixed[i][0], maxNumberWidth: fixed[i][1] }; - html += '' + Dygraph.numberFormatter(x, g_mock) + ''; + html += '' + Dygraph.numberFormatter(x, opts) + ''; } html += '\n'; } diff --git a/tests/two-axes.html b/tests/two-axes.html index ad24648..d1c876b 100644 --- a/tests/two-axes.html +++ b/tests/two-axes.html @@ -45,12 +45,16 @@ height: 350, 'Y3': { axis: { - // set axis-related properties here - labelsKMB: true } }, 'Y4': { axis: 'Y3' // use the same y-axis as series Y3 + }, + axes: { + y2: { + // set axis-related properties here + labelsKMB: true + } } } ); diff --git a/tests/value-axis-formatters.html b/tests/value-axis-formatters.html new file mode 100644 index 0000000..193c48e --- /dev/null +++ b/tests/value-axis-formatters.html @@ -0,0 +1,93 @@ + + + + + valueFormatter and axisLabelFormatter + + + + + + +

Multiple y-axes

+

This demonstrates how the valueFormatter and axisLabelFormatter options work. The valueFormatter controls the display of the legend. The axisLabelFormatter controls the display of axis tick marks. These can be set on a per-axis basis.

+
+ +
    +
  • xvf = x-axis valueFormatter +
  • yvf = y-axis valueFormatter +
  • y2vf = secondary y-axis valueFormatter +
  • xalf = x-axis axisLabelFormatter +
  • yalf = y-axis axisLabelFormatter +
  • y2alf = secondary y-axis axisLabelFormatter +
+ + + + diff --git a/tests/x-axis-formatter.html b/tests/x-axis-formatter.html index b370a86..b008ad8 100644 --- a/tests/x-axis-formatter.html +++ b/tests/x-axis-formatter.html @@ -45,10 +45,13 @@ document.getElementById("offby2"), HourlyData(), { - xAxisLabelFormatter: - function(d, gran) { - return Dygraph.dateAxisFormatter(new Date(d.getTime() + 7200*1000), gran); + axes: { + x: { + axisLabelFormatter: function(d, gran) { + return Dygraph.dateAxisFormatter(new Date(d.getTime() + 7200*1000), gran); + } } + } }); new Dygraph( @@ -56,12 +59,15 @@ HourlyData(), { xAxisLabelWidth: 70, - xAxisLabelFormatter: - function(d, gran) { - return Dygraph.zeropad(d.getHours()) + ":" - + Dygraph.zeropad(d.getMinutes()) + ":" - + Dygraph.zeropad(d.getSeconds()); + axes: { + x: { + axisLabelFormatter: function(d, gran) { + return Dygraph.zeropad(d.getHours()) + ":" + + Dygraph.zeropad(d.getMinutes()) + ":" + + Dygraph.zeropad(d.getSeconds()); + } } + } }); diff --git a/tests/y-axis-formatter.html b/tests/y-axis-formatter.html index c1c09f9..f2fbd00 100644 --- a/tests/y-axis-formatter.html +++ b/tests/y-axis-formatter.html @@ -17,7 +17,8 @@

Potential Y Axis formatting problems for small values

-

The problem using default y axis formatting for very small values:

+

The problem using default y axis formatting for very small values:
+ (this was more of a problem before dygraphs automatically switched to scientific notation)

@@ -73,15 +78,19 @@ ], { stepPlot: true, - yValueFormatter: function(x) { - var shift = Math.pow(10, 5) - return "*" + Math.round(x * shift) / shift - }, - yAxisLabelFormatter: function(x) { - var shift = Math.pow(10, 5) - return "+" + Math.round(x * shift) / shift - }, - labels: ["X", "Data"] + labels: ["X", "Data"], + axes: { + y: { + valueFormatter: function(x) { + var shift = Math.pow(10, 5) + return "*" + Math.round(x * shift) / shift + }, + axisLabelFormatter: function(x) { + var shift = Math.pow(10, 5) + return "+" + Math.round(x * shift) / shift + } + } + } } ); -- 2.7.4