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,