diff --git a/auto_tests/tests/boxplot.js b/auto_tests/tests/boxplot.js
new file mode 100644
index 000000000..6ff8c2136
--- /dev/null
+++ b/auto_tests/tests/boxplot.js
@@ -0,0 +1,80 @@
+/**
+ * @fileoverview Tests for boxplot option.
+ *
+ * @author vojtech.horky@gmail.com (Vojtech Horky)
+ */
+import Dygraph from '../../src/dygraph';
+import * as utils from '../../src/dygraph-utils';
+import Proxy from './Proxy';
+import CanvasAssertions from './CanvasAssertions';
+
+describe("boxplot", function() {
+
+cleanupAfterEach();
+useProxyCanvas(utils, Proxy);
+
+//Here we assume the values are at 1,2,3,... (without extra gaps)
+var assertWhisker = function(graph, x_center, y_box, y_whisker, predicate) {
+ CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ graph.toDomCoords(x_center - 1.0/6.0, y_whisker),
+ graph.toDomCoords(x_center + 1.0/6.0, y_whisker), predicate);
+ CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ graph.toDomCoords(x_center, y_whisker),
+ graph.toDomCoords(x_center, y_box), predicate);
+};
+
+var assertBox = function(graph, x_center, y_low, y_mid, y_high, predicate) {
+ CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ graph.toDomCoords(x_center - 1.0/3.0, y_mid),
+ graph.toDomCoords(x_center + 1.0/3.0, y_mid), predicate);
+ // Rectangle is not captured by the context.
+ //CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ // graph.toDomCoords(x_center - 1.0/3.0, y_low),
+ // graph.toDomCoords(x_center + 1.0/3.0, y_low), predicate);
+ //CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ // graph.toDomCoords(x_center - 1.0/3.0, y_high),
+ // graph.toDomCoords(x_center + 1.0/3.0, y_high), predicate);
+ //CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ // graph.toDomCoords(x_center - 1.0/3.0, y_low),
+ // graph.toDomCoords(x_center - 1.0/3.0, y_high), predicate);
+ //CanvasAssertions.assertLineDrawn(graph.hidden_ctx_,
+ // graph.toDomCoords(x_center + 1.0/3.0, y_low),
+ // graph.toDomCoords(x_center + 1.0/3.0, y_high), predicate);
+}
+
+
+it('testSimpleBoxplot', function() {
+ var opts = {
+ width: 400,
+ height: 320,
+ boxplot: true,
+ colors: [ "#ff0000" ],
+ xRangePad: 200,
+ labels: [ "X", "Y" ]
+ };
+ var data = [
+ [ 1, [ 25, 5, 17, 33, 48] ],
+ [ 2, [ 20, 3, 19, 22, 31] ]
+ ];
+
+ var graph = document.getElementById("graph");
+ var g = new Dygraph(graph, data, opts);
+
+ // Test number of lines drawn. Here only the whiskers, median line and
+ // the connecting line are counted.
+ assert.equal(11, CanvasAssertions.numLinesDrawn(g.hidden_ctx_, "#ff0000"));
+
+ var props = { strokeStyle: "#ff0000" };
+
+ // Assert the (first) box is drawn.
+ assertBox(g, 1, 17, 25, 33, props);
+ assertWhisker(g, 1, 17, 5, props);
+ assertWhisker(g, 1, 33, 48, props);
+
+ // Assert the second box.
+ assertBox(g, 2, 19, 20, 22, props);
+ assertWhisker(g, 2, 19, 3, props);
+ assertWhisker(g, 2, 22, 31, props);
+});
+
+});
diff --git a/gallery/boxplot.js b/gallery/boxplot.js
new file mode 100644
index 000000000..9989ecb67
--- /dev/null
+++ b/gallery/boxplot.js
@@ -0,0 +1,17 @@
+Gallery.register(
+ 'boxplot',
+ {
+ name: 'Boxplot Demo',
+ title: 'Boxplot Demo',
+ setup: function(parent) {
+ parent.innerHTML = "
";
+ },
+ run: function() {
+ var g = new Dygraph(document.getElementById("boxplot_div"), dataBoxplot,
+ {
+ boxplot: true,
+ xRangePad: 100,
+ strokeWidth: 0.0
+ });
+ }
+ });
diff --git a/gallery/data.js b/gallery/data.js
index bbccab052..97fb9773d 100644
--- a/gallery/data.js
+++ b/gallery/data.js
@@ -2683,3 +2683,12 @@ var stockData = function() {
"2009-09-15,9280.67;9712.28;9829.87,4297.2232125907;4497.07133894216;4551.51896800004\n" +
"2009-10-15,9487.67;9712.73;10092.2,4388.84340147194;4492.9525342659;4668.48924723722\n";
};
+
+var dataBoxplot = function() {
+ return "Index,Alpha,Bravo\n" +
+ "1,68;51;61;74;134,17;12;19\n" +
+ "2,76;20;31;106;137,50;23;35;47;56;91\n" +
+ "3,35;4;8;45;90;136,21;10;17;20;23;35\n" +
+ "4,21;10;17;20;23;35,32;4;8;90;136\n"
+ ;
+};
diff --git a/gallery/index.html b/gallery/index.html
index c953deb37..fea6d6f1c 100644
--- a/gallery/index.html
+++ b/gallery/index.html
@@ -27,6 +27,7 @@
+
diff --git a/src/datahandler/boxplot.js b/src/datahandler/boxplot.js
new file mode 100644
index 000000000..c8fff1171
--- /dev/null
+++ b/src/datahandler/boxplot.js
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2016 Vojtech Horky (vojtech.horky@gmail.com)
+ * MIT-licensed (http://opensource.org/licenses/MIT)
+ */
+
+/**
+ * @fileoverview DataHandler implementation for the "boxplot" data formats.
+ *
+ * @author Vojtech Horky (vojtech.horky@gmail.com)
+ */
+
+import DygraphDataHandler from './datahandler';
+import DygraphLayout from '../dygraph-layout';
+
+"use strict";
+
+var BoxplotHandler = function() {
+ DygraphDataHandler.call(this);
+};
+
+BoxplotHandler.prototype = new DygraphDataHandler();
+
+/** @inheritDoc */
+BoxplotHandler.prototype.extractSeries = function(rawData, seriesIndex, options) {
+ var series = [];
+ var x, y, q0, q1, q2, q3, q4;
+ for (var j = 0; j < rawData.length; j++) {
+ x = rawData[j][0];
+ var data = rawData[j][seriesIndex];
+ y = data[0];
+ switch (data.length) {
+ case 3:
+ q0 = null;
+ q1 = data[1];
+ q2 = y;
+ q3 = data[2];
+ q4 = null;
+ break;
+ case 5:
+ q0 = data[1];
+ q1 = data[2];
+ q2 = y;
+ q3 = data[3];
+ q4 = data[4];
+ break;
+ case 6:
+ q0 = data[1];
+ q1 = data[2];
+ q2 = data[3]
+ q3 = data[4];
+ q4 = data[5];
+ break;
+ default:
+ q0 = null;
+ q1 = null;
+ q2 = null;
+ q3 = null;
+ q4 = null;
+ }
+
+ series.push([ x, y, [ q0, q1, q2, q3, q4 ] ]);
+ }
+ return series;
+};
+
+/** @inheritDoc */
+BoxplotHandler.prototype.getExtremeYValues = function(series, dateWindow, options) {
+ var low = null, high = null;
+
+ for (var i = 0; i < series.length; i++) {
+ var y = series[i][1];
+ if (y === null || isNaN(y)) {
+ continue;
+ }
+
+ var q0 = series[i][2][0];
+ var q4 = series[i][2][4];
+
+ if ((low === null) || (q0 < low)) {
+ low = q0;
+ }
+ if ((high === null) || (q4 > high)) {
+ high = q4;
+ }
+ }
+
+ return [ low, high ];
+};
+
+/** @inheritDoc */
+BoxplotHandler.prototype.onPointsCreated_ = function(series, points) {
+ for (var i = 0; i < series.length; ++i) {
+ var item = series[i];
+ var point = points[i];
+
+ point.box = [ DygraphDataHandler.parseFloat(item[2][1]),
+ DygraphDataHandler.parseFloat(item[2][2]),
+ DygraphDataHandler.parseFloat(item[2][3]) ];
+
+ point.whisker = [ DygraphDataHandler.parseFloat(item[2][0]),
+ DygraphDataHandler.parseFloat(item[2][4]) ]
+ }
+};
+
+/** @inheritDoc */
+BoxplotHandler.prototype.onLineEvaluated = function(points, axis, logscale) {
+ for (var j = 0; j < points.length; j++) {
+ var p = points[j];
+ p.y_box = [ DygraphLayout.calcYNormal_(axis, p.box[0], logscale),
+ DygraphLayout.calcYNormal_(axis, p.box[1], logscale),
+ DygraphLayout.calcYNormal_(axis, p.box[2], logscale) ];
+
+ p.y_whisker = [ DygraphLayout.calcYNormal_(axis, p.whisker[0], logscale),
+ DygraphLayout.calcYNormal_(axis, p.whisker[1], logscale) ];
+ }
+};
+
+export default BoxplotHandler;
diff --git a/src/dygraph-canvas.js b/src/dygraph-canvas.js
index bd37e67e0..5e9663d32 100644
--- a/src/dygraph-canvas.js
+++ b/src/dygraph-canvas.js
@@ -407,6 +407,10 @@ DygraphCanvasRenderer._Plotters = {
fillPlotter: function(e) {
DygraphCanvasRenderer._fillPlotter(e);
},
+
+ boxplotPlotter: function(e) {
+ DygraphCanvasRenderer._boxplotPlotter(e);
+ },
errorPlotter: function(e) {
DygraphCanvasRenderer._errorPlotter(e);
@@ -540,6 +544,108 @@ DygraphCanvasRenderer._errorPlotter = function(e) {
};
+/** Displays a boxplot for each datapoint.
+ *
+ * @author Vojtech Horky (vojtech.horky@gmail.com)
+ * @private
+ */
+DygraphCanvasRenderer._boxplotPlotter = function(e) {
+ if (!e.dygraph.getBooleanOption("boxplot")) {
+ return;
+ }
+
+ var drawWhisker = function(ctx, width, x, box_y, whisker_y) {
+ ctx.beginPath();
+ ctx.moveTo(x,box_y);
+ ctx.lineTo(x,whisker_y);
+ ctx.moveTo(x - width/2, whisker_y);
+ ctx.lineTo(x + width/2, whisker_y);
+ ctx.stroke();
+ };
+
+ var lighterColor = function(color) {
+ color.r = Math.floor(171 + color.r / 3);
+ color.g = Math.floor(171 + color.g / 3);
+ color.b = Math.floor(171 + color.b / 3);
+ return 'rgba(' + color.r + ',' + color.g + ',' + color.b + ',0.5)';
+ };
+
+ var recomputeY = function(e, normalizedY) {
+ return normalizedY * e.plotArea.h + e.plotArea.y;
+ };
+
+ var ctx = e.drawingContext;
+ var points = e.points;
+
+ // Ligher color for the box
+ ctx.fillStyle = lighterColor(Dygraph.toRGB_(e.color));
+
+ // Find pseudo-optimal bar width. Determine the minimal gap between
+ // two points.
+ var getMinimalGap = function(points) {
+ var minGap = Infinity;
+ var prevX = null;
+ for (var i = 0; i < points.length; i++) {
+ if (points[i] === null) {
+ continue;
+ }
+ if (prevX === null) {
+ prevX = points[i].canvasx;
+ continue;
+ }
+ var gap = points[i].canvasx - prevX;
+ if (gap < minGap) {
+ minGap = gap;
+ }
+ prevX = points[i].canvasx;
+ }
+ return minGap;
+ };
+
+ var drawBox = function(ctx, x, y, width, height, middleY) {
+ ctx.fillRect(x, y, width, height);
+ ctx.strokeRect(x, y, width, height);
+ ctx.beginPath();
+ ctx.moveTo(x, middleY);
+ ctx.lineTo(x + width, middleY);
+ ctx.stroke();
+ };
+
+ var drawWhisker = function(ctx, width, x, box_y, whisker_y) {
+ ctx.beginPath();
+ ctx.moveTo(x,box_y);
+ ctx.lineTo(x,whisker_y);
+ ctx.moveTo(x - width/2.0, whisker_y);
+ ctx.lineTo(x + width/2.0, whisker_y);
+ ctx.stroke();
+ };
+
+ var boxHalfWidth = getMinimalGap(points) / 3.0;
+
+ // Plot the actual points
+ for (var i = 0; i < points.length; i++) {
+ var p = points[i];
+
+ var boxBottom = recomputeY(e, p.y_box[0]);
+ var boxMiddle = recomputeY(e, p.y_box[1]);
+ var boxTop = recomputeY(e, p.y_box[2]);
+
+ var whiskerLow = recomputeY(e, p.y_whisker[0]);
+ var whiskerHigh = recomputeY(e, p.y_whisker[1]);
+
+ var x = p.canvasx;
+
+ drawBox(ctx, x - boxHalfWidth, boxTop,
+ 2 * boxHalfWidth, boxBottom - boxTop, boxMiddle);
+
+ if (!isNaN(whiskerLow)) {
+ drawWhisker(ctx, boxHalfWidth, x, boxTop, whiskerHigh);
+ drawWhisker(ctx, boxHalfWidth, x, boxBottom, whiskerLow);
+ }
+ }
+};
+
+
/**
* Proxy for CanvasRenderingContext2D which drops moveTo/lineTo calls which are
* superfluous. It accumulates all movements which haven't changed the x-value
diff --git a/src/dygraph-default-attrs.js b/src/dygraph-default-attrs.js
index 5b7cb32f9..371e07218 100644
--- a/src/dygraph-default-attrs.js
+++ b/src/dygraph-default-attrs.js
@@ -44,6 +44,7 @@ var DEFAULT_ATTRS = {
fractions: false,
wilsonInterval: true, // only relevant if fractions is true
customBars: false,
+ boxplot: false,
fillGraph: false,
fillAlpha: 0.15,
connectSeparatedPoints: false,
@@ -93,6 +94,7 @@ var DEFAULT_ATTRS = {
plotter: [
DygraphCanvasRenderer._fillPlotter,
DygraphCanvasRenderer._errorPlotter,
+ DygraphCanvasRenderer._boxplotPlotter,
DygraphCanvasRenderer._linePlotter
],
diff --git a/src/dygraph-options-reference.js b/src/dygraph-options-reference.js
index 4b1a23bf6..9bbdab276 100644
--- a/src/dygraph-options-reference.js
+++ b/src/dygraph-options-reference.js
@@ -581,6 +581,12 @@ OPTIONS_REFERENCE = //
"type": "boolean",
"description": "When set, parse each CSV cell as \"low;middle;high\". Error bars will be drawn for each point between low and high, with the series itself going through middle."
},
+ "boxplot": {
+ "default": "false",
+ "labels": ["CSV parsing", "Boxplot"],
+ "type": "boolean",
+ "description": "When set, parse each CSV cell as \"mean;min;first-quartile;median;third-quartile;max\" or \"mean=median;min;first-quartile;third-quartile;max\" or \"mean=median;first-quartile;third-quartile\" (whiskers not drawn). Boxplot will be drawn for each point, with the series itself going through mean."
+ },
"colorValue": {
"default": "1.0",
"labels": ["Data Series Colors"],
@@ -858,6 +864,7 @@ var flds = ['type', 'default', 'description'];
var valid_cats = [
'Annotations',
'Axis display',
+ 'Boxplot',
'Chart labels',
'CSV parsing',
'Callbacks',
diff --git a/src/dygraph.js b/src/dygraph.js
index e5053ba15..eae858b84 100644
--- a/src/dygraph.js
+++ b/src/dygraph.js
@@ -59,6 +59,7 @@ import CustomBarsHandler from './datahandler/bars-custom';
import DefaultFractionHandler from './datahandler/default-fractions';
import FractionsBarsHandler from './datahandler/bars-fractions';
import BarsHandler from './datahandler/bars';
+import BoxplotHandler from './datahandler/boxplot';
import AnnotationsPlugin from './plugins/annotations';
import AxesPlugin from './plugins/axes';
@@ -1981,6 +1982,8 @@ Dygraph.prototype.getHandlerClass_ = function() {
handlerClass = CustomBarsHandler;
} else if (this.getBooleanOption('errorBars')) {
handlerClass = ErrorBarsHandler;
+ } else if (this.getBooleanOption('boxplot')) {
+ handlerClass = BoxplotHandler;
} else {
handlerClass = DefaultHandler;
}
@@ -2792,6 +2795,49 @@ Dygraph.prototype.parseCSV_ = function(data) {
}
}
}
+ } else if (this.getBooleanOption("boxplot")) {
+ // Boxplots needs five numbers but we allow also extra value for the
+ // actual point
+ // - median;first-quartile;third-quartile
+ // - mean;min;first-quartile;median;third-quartile;max
+ // - median;min;first-quartile;third-quartile;max
+ // However we always output a tuple of (mean := median if missing)
+ // [ mean, min, first-quartile, median, third-quartile, max ]
+ for (j = 1; j < inFields.length; j++) {
+ var val = inFields[j];
+ if (/^ *$/.test(val)) {
+ fields[j] = [null, null, null, null, null, null];
+ } else {
+ vals = val.split(";");
+ if (vals.length == 3) {
+ fields[j] = [ utils.parseFloat_(vals[0], i, line),
+ null,
+ utils.parseFloat_(vals[1], i, line),
+ utils.parseFloat_(vals[0], i, line),
+ utils.parseFloat_(vals[2], i, line),
+ null ];
+ } else if (vals.length == 5) {
+ fields[j] = [ utils.parseFloat_(vals[0], i, line),
+ utils.parseFloat_(vals[1], i, line),
+ utils.parseFloat_(vals[2], i, line),
+ utils.parseFloat_(vals[0], i, line),
+ utils.parseFloat_(vals[3], i, line),
+ utils.parseFloat_(vals[4], i, line) ];
+ } else if (vals.length == 6) {
+ fields[j] = [ utils.parseFloat_(vals[0], i, line),
+ utils.parseFloat_(vals[1], i, line),
+ utils.parseFloat_(vals[2], i, line),
+ utils.parseFloat_(vals[3], i, line),
+ utils.parseFloat_(vals[4], i, line),
+ utils.parseFloat_(vals[5], i, line) ];
+ } else {
+ console.warn('When using boxplot, values must be either blank ' +
+ 'or "mean;min;first-quartile;median;third-quartile;max" ' +
+ 'or "median;min;first-quartile;third-quartile;max" tuples (got "' + val +
+ '" on line ' + (1+i));
+ }
+ }
+ }
} else {
// Values are just numbers
for (j = 1; j < inFields.length; j++) {
@@ -3537,6 +3583,7 @@ Dygraph.Plugins = {
Dygraph.DataHandlers = {
DefaultHandler,
BarsHandler,
+ BoxplotHandler,
CustomBarsHandler,
DefaultFractionHandler,
ErrorBarsHandler,