Support stroke patterns (i.e. dashed/dotted lines).
authorbluthen <russ@coldstonelabs.org>
Thu, 26 Jan 2012 22:37:43 +0000 (17:37 -0500)
committerDan Vanderkam <danvk@google.com>
Thu, 26 Jan 2012 22:37:43 +0000 (17:37 -0500)
Patterns can be arbitrarily complicated sequences of lines and breaks,
or pre-built constants like Dygraph.DOTTED_LINE.

See tests/per-series.html for an example of how this works.

commit 214caf668bb8472605c505c124fd14e5d3b5a956
Merge: db2e28c 25c4046
Author: Dan Vanderkam <danvk@google.com>
Date:   Thu Jan 26 17:33:08 2012 -0500

    Merge branch 'stroke_pattern' of https://github.com/bluthen/dygraphs into bluthen

commit 25c40460bbd25f05cfd0be9da3535dd4662cfadb
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Thu Jan 26 14:43:14 2012 -0600

    Few changes suggested by Dan Vanderkam.

commit a9965f34b5470c2db5656cac1d8d3debd3a0d4e7
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Thu Jan 26 00:41:44 2012 -0600

    Stroke patterns in the legend that scale to 1em.

commit 62f2905c52e6936339dd1de7af1844aaced95c05
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Sat Jan 21 12:14:03 2012 -0600

    Sets dimensions for graph div in dash test so it could pass if default size ever changes.

commit bfece39cc4f2e365cf692b81cffdf91a773625ac
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Fri Jan 20 17:22:33 2012 -0600

    Added a simple dash unit test. It tests if it draw the correct number of lines
    and remembers pattern history between points.

commit 57539c89e29a1e45a6d1000ebe53c496eb88a61b
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Wed Jan 18 23:50:13 2012 -0600

    Comment wording changes. Added default string stroke patterns object to allow for more code reuse.

commit de286626b93dc7ce49ce50abfba83ffaa29db068
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Wed Jan 18 22:48:16 2012 -0600

    Use "font-weight: bold"  in style instead of the bold tag.

commit f5958230321df474bfb1339b9a97c64f92f20621
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Wed Jan 18 22:12:50 2012 -0600

    Reverted CanvasAssertions.js to have no modifications.
    Moved array compare function to dygraph-utils.
    Moved dashedLine into DygraphCanvasRenderer instead of in
    CanvasRenderingContext2D. Added some comments and used better variable
    names. Included are some lint warning fixes and style conformity changes.

commit 4b5e255dff699f59a5ced5186c4e4558b09a4003
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Wed Jan 18 14:33:13 2012 -0600

    Actual dashedLine coordinates checked now. Oops.

commit a5930c675465e6566854d3b6e2b9d6fa606ce91c
Author: Russell Valentine <russ@coldstonelabs.org>
Date:   Wed Jan 18 14:25:51 2012 -0600

    per series stroke pattern support.

auto_tests/tests/simple_drawing.js
dygraph-canvas.js
dygraph-options-reference.js
dygraph-utils.js
dygraph.js
tests/per-series.html

index ca19301..0ea13a4 100644 (file)
@@ -56,3 +56,27 @@ SimpleDrawingTestCase.prototype.testDrawSimpleRangePlusOne = function() {
     lineWidth: 1
   });
 }
+
+/**
+ * Tests that it is drawing dashes, and it remember the dash history between
+ * points.
+ */
+SimpleDrawingTestCase.prototype.testDrawSimpleDash = function() {
+  var opts = {
+      drawXGrid: false,
+      drawYGrid: false,
+      drawXAxis: false,
+      drawYAxis: false,
+      'Y1': {strokePattern: [25, 7, 7, 7]},
+      colors: ['#ff0000']
+  };
+
+  var graph = document.getElementById("graph");
+  // Set the dims so we pass if default changes.
+  graph.style.width='480px';
+  graph.style.height='320px';
+  var g = new Dygraph(graph, [[1, 4], [2, 5], [3, 3], [4, 7], [5, 9]], opts);
+  htx = g.hidden_ctx_;
+
+  assertEquals(29, CanvasAssertions.numLinesDrawn(htx, "#ff0000"));
+};
index a62616a..c0d142f 100644 (file)
@@ -28,6 +28,7 @@
 /*global Dygraph:false,RGBColor:false */
 "use strict";
 
+
 var DygraphCanvasRenderer = function(dygraph, element, elementContext, layout) {
   this.dygraph_ = dygraph;
 
@@ -838,6 +839,10 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
     prevX = null;
     prevY = null;
     var drawPoints = this.dygraph_.attr_("drawPoints", setName);
+    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)) {
@@ -846,8 +851,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
           ctx.beginPath();
           ctx.strokeStyle = color;
           ctx.lineWidth = this.attr_('strokeWidth');
-          ctx.moveTo(prevX, prevY);
-          ctx.lineTo(point.canvasx, prevY);
+          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.
@@ -873,13 +877,12 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
             ctx.beginPath();
             ctx.strokeStyle = color;
             ctx.lineWidth = strokeWidth;
-            ctx.moveTo(prevX, prevY);
             if (stepPlot) {
-              ctx.lineTo(point.canvasx, prevY);
+              this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern);
             }
+            this._dashedLine(ctx, prevX, prevY, point.canvasx, point.canvasy, strokePattern);
             prevX = point.canvasx;
             prevY = point.canvasy;
-            ctx.lineTo(prevX, prevY);
             ctx.stroke();
           }
         }
@@ -898,3 +901,94 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
 
   context.restore();
 };
+
+/**
+ * This does dashed lines onto a canvas for a given pattern. You must call
+ * ctx.stroke() after to actually draw it, much line ctx.lineTo(). It remembers
+ * the state of the line in regards to where we left off on drawing the pattern.
+ * You can draw a dashed line in several function calls and the pattern will be
+ * continous as long as you didn't call this function with a different pattern
+ * in between.
+ * @param ctx The canvas 2d context to draw on.
+ * @param x The start of the line's x coordinate.
+ * @param y The start of the line's y coordinate.
+ * @param x2 The end of the line's x coordinate.
+ * @param y2 The end of the line's y coordinate.
+ * @param pattern The dash pattern to draw, an array of integers where even 
+ * index is drawn and odd index is not drawn (Ex. [10, 2, 5, 2], 10 is drawn 5
+ * is drawn, 2 is the space between.). A null pattern, array of length one, or
+ * empty array will do just a solid line.
+ * @private
+ */
+DygraphCanvasRenderer.prototype._dashedLine = function(ctx, x, y, x2, y2, pattern) {
+  // Original version http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas
+  // Modified by Russell Valentine to keep line history and continue the pattern
+  // where it left off.
+  var dx, dy, len, rot, patternIndex, segment;
+
+  // If we don't have a pattern or it is an empty array or of size one just
+  // do a solid line.
+  if (!pattern || pattern.length <= 1) {
+    ctx.moveTo(x, y);
+    ctx.lineTo(x2, y2);
+    return;
+  }
+
+  // If we have a different dash pattern than the last time this was called we
+  // reset our dash history and start the pattern from the begging 
+  // regardless of state of the last pattern.
+  if (!Dygraph.compareArrays(pattern, this._dashedLineToHistoryPattern)) {
+    this._dashedLineToHistoryPattern = pattern;
+    this._dashedLineToHistory = [0, 0];
+  }
+  ctx.save();
+
+  // Calculate transformation parameters
+  dx = (x2-x);
+  dy = (y2-y);
+  len = Math.sqrt(dx*dx + dy*dy);
+  rot = Math.atan2(dy, dx);
+
+  // Set transformation
+  ctx.translate(x, y);
+  ctx.moveTo(0, 0);
+  ctx.rotate(rot);
+
+  // Set last pattern index we used for this pattern.
+  patternIndex = this._dashedLineToHistory[0];
+  x = 0;
+  while (len > x) {
+    // Get the length of the pattern segment we are dealing with.
+    segment = pattern[patternIndex];
+    // If our last draw didn't complete the pattern segment all the way we 
+    // will try to finish it. Otherwise we will try to do the whole segment.
+    if (this._dashedLineToHistory[1]) {
+      x += this._dashedLineToHistory[1];
+    } else {
+      x += segment;
+    }
+    if (x > len) {
+      // We were unable to complete this pattern index all the way, keep
+      // where we are the history so our next draw continues where we left off
+      // in the pattern.
+      this._dashedLineToHistory = [patternIndex, x-len];
+      x = len;
+    } else {
+      // We completed this patternIndex, we put in the history that we are on
+      // the beginning of the next segment.
+      this._dashedLineToHistory = [(patternIndex+1)%pattern.length, 0];
+    }
+
+    // We do a line on a even pattern index and just move on a odd pattern index.
+    // The move is the empty space in the dash.
+    if(patternIndex % 2 === 0) {
+      ctx.lineTo(x, 0);
+    } else {
+      ctx.moveTo(x, 0);
+    }
+    // If we are not done, next loop process the next pattern segment, or the
+    // first segment again if we are at the end of the pattern.
+    patternIndex = (patternIndex+1) % pattern.length;
+  }
+  ctx.restore();
+};
index fec5254..a94a54f 100644 (file)
@@ -263,6 +263,13 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
     "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."
   },
+  "strokePattern": {
+    "default": "null",
+    "labels": ["Data Line display"],
+    "type": "array<integer>",
+    "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."
+  },
   "wilsonInterval": {
     "default": "true",
     "labels": ["Error Bars"],
index 8b9dfd9..5a21430 100644 (file)
@@ -35,6 +35,13 @@ Dygraph.ERROR = 3;
 // https://github.com/eriwen/javascript-stacktrace
 Dygraph.LOG_STACK_TRACES = false;
 
+/** A dotted line stroke pattern. */
+Dygraph.DOTTED_LINE = [2, 2];
+/** A dashed line stroke pattern. */
+Dygraph.DASHED_LINE = [7, 3];
+/** A dot dash stroke pattern. */
+Dygraph.DOT_DASH_LINE = [7, 2, 2, 2];
+
 /**
  * @private
  * Log an error on the JS console at the given severity.
@@ -775,3 +782,26 @@ Dygraph.isPixelChangingOptionList = function(labels, attrs) {
 
   return requiresNewPoints;
 };
+
+/**
+ * Compares two arrays to see if they are equal. If either parameter is not an
+ * array it will return false. Does a shallow compare 
+ * Dygraph.compareArrays([[1,2], [3, 4]], [[1,2], [3,4]]) === false.
+ * @param array1 first array
+ * @param array2 second array
+ * @return True if both parameters are arrays, and contents are equal.
+ */
+Dygraph.compareArrays = function(array1, array2) {
+  if (!Dygraph.isArrayLike(array1) || !Dygraph.isArrayLike(array2)) {
+    return false;
+  }
+  if (array1.length !== array2.length) {
+    return false;
+  }
+  for (var i = 0; i < array1.length; i++) {
+    if (array1[i] !== array2[i]) {
+      return false;
+    }
+  }
+  return true;
+};
index c633df7..1c25f9f 100644 (file)
@@ -1550,18 +1550,92 @@ Dygraph.prototype.idxToRow_ = function(idx) {
 
 /**
  * @private
+ * Generates legend html dash for any stroke pattern. It will try to scale the
+ * pattern to fit in 1em width. Or if small enough repeat the partern for 1em
+ * width.
+ * @param strokePattern The pattern
+ * @param color The color of the series.
+ * @param oneEmWidth The width in pixels of 1em in the legend.
+ */
+Dygraph.prototype.generateLegendDashHTML_ = function(strokePattern, color, oneEmWidth) {
+  var dash = "";
+  var i, j, paddingLeft, marginRight;
+  var strokePixelLength = 0, segmentLoop = 0;
+  var normalizedPattern = [];
+  var loop;
+  // IE 7,8 fail at these divs, so they get boring legend, have not tested 9.
+  var isIE = (/MSIE/.test(navigator.userAgent) && !window.opera);
+  if(isIE) {
+    return "&mdash;";
+  }
+  if (!strokePattern || strokePattern.length <= 1) {
+    // Solid line
+    dash = "<div style=\"display: inline-block; position: relative; " +
+    "bottom: .5ex; padding-left: 1em; height: 1px; " +
+    "border-bottom: 2px solid " + color + ";\"></div>";
+  } else {
+    // Compute the length of the pixels including the first segment twice, 
+    // since we repeat it.
+    for (i = 0; i <= strokePattern.length; i++) {
+      strokePixelLength += strokePattern[i%strokePattern.length];
+    }
+
+    // See if we can loop the pattern by itself at least twice.
+    loop = Math.floor(oneEmWidth/(strokePixelLength-strokePattern[0]));
+    if (loop > 1) {
+      // This pattern fits at least two times, no scaling just convert to em;
+      for (i = 0; i < strokePattern.length; i++) {
+        normalizedPattern[i] = strokePattern[i]/oneEmWidth;
+      }
+      // Since we are repeating the pattern, we don't worry about repeating the
+      // first segment in one draw.
+      segmentLoop = normalizedPattern.length;
+    } else {
+      // If the pattern doesn't fit in the legend we scale it to fit.
+      loop = 1;
+      for (i = 0; i < strokePattern.length; i++) {
+        normalizedPattern[i] = strokePattern[i]/strokePixelLength;
+      }
+      // For the scaled patterns we do redraw the first segment.
+      segmentLoop = normalizedPattern.length+1;
+    }
+    // Now make the pattern.
+    for (j = 0; j < loop; j++) {
+      for (i = 0; i < segmentLoop; i+=2) {
+        // The padding is the drawn segment.
+        paddingLeft = normalizedPattern[i%normalizedPattern.length];
+        if (i < strokePattern.length) {
+          // The margin is the space segment.
+          marginRight = normalizedPattern[(i+1)%normalizedPattern.length];
+        } else {
+          // The repeated first segment has no right margin.
+          marginRight = 0;
+        }
+        dash += "<div style=\"display: inline-block; position: relative; " +
+          "bottom: .5ex; margin-right: " + marginRight + "em; padding-left: " +
+          paddingLeft + "em; height: 1px; border-bottom: 2px solid " + color +
+          ";\"></div>";
+      }
+    }
+  }
+  return dash;
+};
+
+/**
+ * @private
  * Generates HTML for the legend which is displayed when hovering over the
  * chart. If no selected points are specified, a default legend is returned
  * (this may just be the empty string).
  * @param { Number } [x] The x-value of the selected points.
  * @param { [Object] } [sel_points] List of selected points for the given
  * x-value. Should have properties like 'name', 'yval' and 'canvasy'.
+ * @param { Number } [oneEmWidth] The pixel width for 1em in the legend.
  */
-Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) {
+Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) {
   // If no points are selected, we display a default legend. Traditionally,
   // this has been blank. But a better default would be a conventional legend,
   // which provides essential information for a non-interactive chart.
-  var html, sepLines, i, c;
+  var html, sepLines, i, c, dash, strokePattern;
   if (typeof(x) === 'undefined') {
     if (this.attr_('legend') != 'always') return '';
 
@@ -1572,8 +1646,10 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) {
       if (!this.visibility()[i - 1]) continue;
       c = this.plotter_.colors[labels[i]];
       if (html !== '') html += (sepLines ? '<br/>' : ' ');
-      html += "<b><span style='color: " + c + ";'>&mdash;" + labels[i] +
-        "</span></b>";
+      strokePattern = this.attr_("strokePattern", labels[i]);
+      dash = this.generateLegendDashHTML_(strokePattern, c, oneEmWidth);
+      html += "<span style='font-weight: bold; color: " + c + ";'>" + dash + 
+        " " + labels[i] + "</span>";
     }
     return html;
   }
@@ -1616,8 +1692,14 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points) {
  * x-value. Should have properties like 'name', 'yval' and 'canvasy'.
  */
 Dygraph.prototype.setLegendHTML_ = function(x, sel_points) {
-  var html = this.generateLegendHTML_(x, sel_points);
   var labelsDiv = this.attr_("labelsDiv");
+  var sizeSpan = document.createElement('span');
+  // Calculates the width of 1em in pixels for the legend.
+  sizeSpan.setAttribute('style', 'margin: 0; padding: 0 0 0 1em; border: 0;');
+  labelsDiv.appendChild(sizeSpan);
+  var oneEmWidth=sizeSpan.offsetWidth;
+
+  var html = this.generateLegendHTML_(x, sel_points, oneEmWidth);
   if (labelsDiv !== null) {
     labelsDiv.innerHTML = html;
   } else {
index c1b400a..98501ac 100644 (file)
   <body>
     <h2>Chart with per-series properties</h2>
     <div id="demodiv"></div>
-
+    <h2>Chart with per-series properties with legend.</h2>
+    <div id="demodiv2"></div>
     <script type="text/javascript">
+      data = function() {
+        var zp = function(x) { if (x < 10) return "0"+x; else return x; };
+        var r = "date,parabola,line,another line,sine wave,sine wave2\n";
+        for (var i=1; i<=31; i++) {
+          r += "200610" + zp(i);
+          r += "," + 10*(i*(31-i));
+          r += "," + 10*(8*i);
+          r += "," + 10*(250 - 8*i);
+          r += "," + 10*(125 + 125 * Math.sin(0.3*i));
+          r += "," + 10*(125 + 125 * Math.sin(0.3*i+Math.PI));
+          r += "\n";
+        }
+        return r;
+      };
       g = new Dygraph(
               document.getElementById("demodiv"),
-              function() {
-                var zp = function(x) { if (x < 10) return "0"+x; else return x; };
-                var r = "date,parabola,line,another line,sine wave\n";
-                for (var i=1; i<=31; i++) {
-                r += "200610" + zp(i);
-                r += "," + 10*(i*(31-i));
-                r += "," + 10*(8*i);
-                r += "," + 10*(250 - 8*i);
-                r += "," + 10*(125 + 125 * Math.sin(0.3*i));
-                r += "\n";
-                }
-                return r;
-              },
+              data,
               {
                 strokeWidth: 2,
                 'parabola': {
                 'sine wave': {
                   strokeWidth: 3,
                   highlightCircleSize: 10
+                },
+                'sine wave2': {
+                  strokePattern: [10, 2, 5, 2],
+                  strokeWidth: 2,
+                  highlightCircleSize: 3
+                }
+              }
+          );
+      g2 = new Dygraph(
+              document.getElementById("demodiv2"),
+              data,
+              {
+                legend: 'always',
+                strokeWidth: 2,
+                'parabola': {
+                  strokePattern: null,
+                  drawPoints: true,
+                  pointSize: 4,
+                  highlightCircleSize: 6
+                },
+                'line': {
+                  strokePattern: Dygraph.DASHED_LINE,
+                  strokeWidth: 1.0,
+                  drawPoints: true,
+                  pointSize: 1.5
+                },
+                'another line': {
+                  strokePattern: [25, 5]
+                },
+                'sine wave': {
+                  strokePattern: Dygraph.DOTTED_LINE,
+                  strokeWidth: 3,
+                  highlightCircleSize: 10
+                },
+                'sine wave2': {
+                  strokePattern: Dygraph.DOT_DASH_LINE,
+                  strokeWidth: 2,
+                  highlightCircleSize: 3
                 }
               }
           );
+
     </script>
 </body>
 </html>