diff --git a/API.txt b/API.txt index bd0c663..a99dd1a 100644 --- a/API.txt +++ b/API.txt @@ -52,8 +52,9 @@ drawing. As a special case, a null value for lines is interpreted as a line segment end, i.e. the points before and after the null value are not connected. -Lines and points take two coordinates. For bars, you can specify a -third coordinate which is the bottom of the bar (defaults to 0). +Lines and points take two coordinates. For filled lines and bars, you +can specify a third coordinate which is the bottom of the filled +area/bar (defaults to 0). The format of a single series object is as follows: @@ -479,7 +480,7 @@ The most important options are "lines", "points" and "bars" that specify whether and how lines, points and bars should be shown for each data series. In case you don't specify anything at all, Flot will default to showing lines (you can turn this off with -lines: { show: false}). You can specify the various types +lines: { show: false }). You can specify the various types independently of each other, and Flot will happily draw each of them in turn (this is probably only useful for lines and points), e.g. diff --git a/NEWS.txt b/NEWS.txt index b02aa41..6ef6d54 100644 --- a/NEWS.txt +++ b/NEWS.txt @@ -1,6 +1,18 @@ Flot x.x -------- +Changes: + +- Support for specifying a bottom for each point for line charts when + filling them, this means that an arbitrary bottom can be used + instead of just the x axis (based on patches patiently provided by + Roman V. Prikhodchenko). +- New fillbetween plugin that can compute a bottom for a series from + another series, useful for filling areas between lines (see new + example percentiles.html for a use case). +- More predictable handling of gaps for the stacking plugin, now all + undefined ranges are skipped. + Bug fixes: @@ -8,7 +20,9 @@ Bug fixes: (reported by ragingchikn, issue 242). - Fixed problem with ticks and the border (based on patch from ultimatehustler69, issue 236). - +- Fixed problem with plugins adding options to the series objects. +- Fixed a problem introduced in 0.6 with specifying a gradient with { + brightness: x, opacity: y }. Flot 0.6 -------- diff --git a/examples/index.html b/examples/index.html index e549226..e9074c3 100644 --- a/examples/index.html +++ b/examples/index.html @@ -29,13 +29,14 @@
Some more esoteric features:
+Various features:
Height in centimeters of individuals from the US (2003-2006) as function of + age in years (source: CDC). + The 15%-85%, 25%-75% and 50% percentiles are indicated.
+ +For each point of a filled curve, you can specify an arbitrary + bottom. As this example illustrates, this can be useful for + plotting percentiles. If you have the data sets available without + appropriate fill bottoms, you can use the fillbetween plugin to + compute the data point bottoms automatically.
+ + + + + diff --git a/examples/stacking.html b/examples/stacking.html index 62e0c7b..81e85fb 100644 --- a/examples/stacking.html +++ b/examples/stacking.html @@ -50,7 +50,7 @@ $(function () { $.plot($("#placeholder"), [ d1, d2, d3 ], { series: { stack: stack, - lines: { show: lines, steps: steps }, + lines: { show: lines, fill: true, steps: steps }, bars: { show: bars, barWidth: 0.6 } } }); diff --git a/jquery.flot.fillbetween.js b/jquery.flot.fillbetween.js new file mode 100644 index 0000000..69700e7 --- /dev/null +++ b/jquery.flot.fillbetween.js @@ -0,0 +1,183 @@ +/* +Flot plugin for computing bottoms for filled line and bar charts. + +The case: you've got two series that you want to fill the area +between. In Flot terms, you need to use one as the fill bottom of the +other. You can specify the bottom of each data point as the third +coordinate manually, or you can use this plugin to compute it for you. + +In order to name the other series, you need to give it an id, like this + + var dataset = [ + { data: [ ... ], id: "foo" } , // use default bottom + { data: [ ... ], fillBetween: "foo" }, // use first dataset as bottom + ]; + + $.plot($("#placeholder"), dataset, { line: { show: true, fill: true }}); + +As a convenience, if the id given is a number that doesn't appear as +an id in the series, it is interpreted as the index in the array +instead (so fillBetween: 0 can also mean the first series). + +Internally, the plugin modifies the datapoints in each series. For +line series, extra data points might be inserted through +interpolation. Note that at points where the bottom line is not +defined (due to a null point or start/end of line), the current line +will show a gap too. The algorithm comes from the jquery.flot.stack.js +plugin, possibly some code could be shared. +*/ + +(function ($) { + var options = { + series: { fillBetween: null } // or number + }; + + function init(plot) { + function findBottomSeries(s, allseries) { + var i; + for (i = 0; i < allseries.length; ++i) { + if (allseries[i].id == s.fillBetween) + return allseries[i]; + } + + if (typeof s.fillBetween == "number") { + i = s.fillBetween; + + if (i < 0 || i >= allseries.length) + return null; + + return allseries[i]; + } + + return null; + } + + function computeFillBottoms(plot, s, datapoints) { + if (s.fillBetween == null) + return; + + var other = findBottomSeries(s, plot.getData()); + if (!other) + return; + + var ps = datapoints.pointsize, + points = datapoints.points, + otherps = other.datapoints.pointsize, + otherpoints = other.datapoints.points, + newpoints = [], + px, py, intery, qx, qy, bottom, + withlines = s.lines.show, + withbottom = ps > 2 && datapoints.format[2].y, + withsteps = withlines && s.lines.steps, + fromgap = true, + i = 0, j = 0, l; + + while (true) { + if (i >= points.length) + break; + + l = newpoints.length; + + if (points[i] == null) { + // copy gaps + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + i += ps; + } + else if (j >= otherpoints.length) { + // for lines, we can't use the rest of the points + if (!withlines) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + } + i += ps; + } + else if (otherpoints[j] == null) { + // oops, got a gap + for (m = 0; m < ps; ++m) + newpoints.push(null); + fromgap = true; + j += otherps; + } + else { + // cases where we actually got two points + px = points[i]; + py = points[i + 1]; + qx = otherpoints[j]; + qy = otherpoints[j + 1]; + bottom = 0; + + if (px == qx) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + //newpoints[l + 1] += qy; + bottom = qy; + + i += ps; + j += otherps; + } + else if (px > qx) { + // we got past point below, might need to + // insert interpolated extra point + if (withlines && i > 0 && points[i - ps] != null) { + intery = py + (points[i - ps + 1] - py) * (qx - px) / (points[i - ps] - px); + newpoints.push(qx); + newpoints.push(intery) + for (m = 2; m < ps; ++m) + newpoints.push(points[i + m]); + bottom = qy; + } + + j += otherps; + } + else { // px < qx + if (fromgap && withlines) { + // if we come from a gap, we just skip this point + i += ps; + continue; + } + + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + // we might be able to interpolate a point below, + // this can give us a better y + if (withlines && j > 0 && otherpoints[j - otherps] != null) + bottom = qy + (otherpoints[j - otherps + 1] - qy) * (px - qx) / (otherpoints[j - otherps] - qx); + + //newpoints[l + 1] += bottom; + + i += ps; + } + + fromgap = false; + + if (l != newpoints.length && withbottom) + newpoints[l + 2] = bottom; + } + + // maintain the line steps invariant + if (withsteps && l != newpoints.length && l > 0 + && newpoints[l] != null + && newpoints[l] != newpoints[l - ps] + && newpoints[l + 1] != newpoints[l - ps + 1]) { + for (m = 0; m < ps; ++m) + newpoints[l + ps + m] = newpoints[l + m]; + newpoints[l + 1] = newpoints[l - ps + 1]; + } + } + + datapoints.points = newpoints; + } + + plot.hooks.processDatapoints.push(computeFillBottoms); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'fillbetween', + version: '1.0' + }); +})(jQuery); diff --git a/jquery.flot.js b/jquery.flot.js index e54988a..3c86774 100644 --- a/jquery.flot.js +++ b/jquery.flot.js @@ -330,7 +330,7 @@ if (s.lines.show == null) { var v, show = true; for (v in s) - if (s[v].show) { + if (s[v] && s[v].show) { show = false; break; } @@ -382,7 +382,7 @@ format.push({ x: true, number: true, required: true }); format.push({ y: true, number: true, required: true }); - if (s.bars.show) + if (s.bars.show || (s.lines.show && s.lines.fill)) format.push({ y: true, number: true, required: false, defaultValue: 0 }); s.datapoints.format = format; @@ -1364,18 +1364,40 @@ var points = datapoints.points, ps = datapoints.pointsize, bottom = Math.min(Math.max(0, axisy.min), axisy.max), - top, lastX = 0, areaOpen = false; - - for (var i = ps; i < points.length; i += ps) { - var x1 = points[i - ps], y1 = points[i - ps + 1], - x2 = points[i], y2 = points[i + 1]; - - if (areaOpen && x1 != null && x2 == null) { - // close area - ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom)); - ctx.fill(); - areaOpen = false; - continue; + i = 0, top, areaOpen = false, + ypos = 1, segmentStart = 0, segmentEnd = 0; + + // we process each segment in two turns, first forward + // direction to sketch out top, then once we hit the + // end we go backwards to sketch the bottom + while (true) { + if (ps > 0 && i > points.length + ps) + break; + + i += ps; // ps is negative if going backwards + + var x1 = points[i - ps], + y1 = points[i - ps + ypos], + x2 = points[i], y2 = points[i + ypos]; + + if (areaOpen) { + if (ps > 0 && x1 != null && x2 == null) { + // at turning point + segmentEnd = i; + ps = -ps; + ypos = 2; + continue; + } + + if (ps < 0 && i == segmentStart + ps) { + // done with the reverse sweep + ctx.fill(); + areaOpen = false; + ps = -ps; + ypos = 1; + i = segmentStart = segmentEnd + ps; + continue; + } } if (x1 == null || x2 == null) @@ -1422,22 +1444,22 @@ if (y1 >= axisy.max && y2 >= axisy.max) { ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max)); - lastX = x2; continue; } else if (y1 <= axisy.min && y2 <= axisy.min) { ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min)); - lastX = x2; continue; } // else it's a bit more complicated, there might - // be two rectangles and two triangles we need to fill - // in; to find these keep track of the current x values + // be a flat maxed out rectangle first, then a + // triangular cutout or reverse; to find these + // keep track of the current x values var x1old = x1, x2old = x2; - // and clip the y values, without shortcutting + // clip the y values, without shortcutting, we + // go through all cases in turn // clip with ymin if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) { @@ -1459,43 +1481,27 @@ y2 = axisy.max; } - // if the x value was changed we got a rectangle // to fill if (x1 != x1old) { - if (y1 <= axisy.min) - top = axisy.min; - else - top = axisy.max; - - ctx.lineTo(axisx.p2c(x1old), axisy.p2c(top)); - ctx.lineTo(axisx.p2c(x1), axisy.p2c(top)); + ctx.lineTo(axisx.p2c(x1old), axisy.p2c(y1)); + // it goes to (x1, y1), but we fill that below } - // fill the triangles + // fill triangular section, this sometimes result + // in redundant points if (x1, y1) hasn't changed + // from previous line to, but we just ignore that ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); // fill the other rectangle if it's there if (x2 != x2old) { - if (y2 <= axisy.min) - top = axisy.min; - else - top = axisy.max; - - ctx.lineTo(axisx.p2c(x2), axisy.p2c(top)); - ctx.lineTo(axisx.p2c(x2old), axisy.p2c(top)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + ctx.lineTo(axisx.p2c(x2old), axisy.p2c(y2)); } - - lastX = Math.max(x2, x2old); - } - - if (areaOpen) { - ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom)); - ctx.fill(); } } - + ctx.save(); ctx.translate(plotOffset.left, plotOffset.top); ctx.lineJoin = "round"; @@ -2039,9 +2045,12 @@ for (var i = 0, l = spec.colors.length; i < l; ++i) { var c = spec.colors[i]; if (typeof c != "string") { - c = $.color.parse(defaultColor).scale('rgb', c.brightness); - c.a *= c.opacity; - c = c.toString(); + var co = $.color.parse(defaultColor); + if (c.brightness != null) + co = co.scale('rgb', c.brightness) + if (c.opacity != null) + co.a *= c.opacity; + c = co.toString(); } gradient.addColorStop(i / (l - 1), c); } diff --git a/jquery.flot.stack.js b/jquery.flot.stack.js index 4dbd29f..41be965 100644 --- a/jquery.flot.stack.js +++ b/jquery.flot.stack.js @@ -1,7 +1,12 @@ /* Flot plugin for stacking data sets, i.e. putting them on top of each -other, for accumulative graphs. Note that the plugin assumes the data -is sorted on x. Also note that stacking a mix of positive and negative +other, for accumulative graphs. + +The plugin assumes the data is sorted on x. For line charts, it is +assumed that if a line has an undefined gap (from a null point), then +the line above it should have the same gap - insert zeros instead of +"null" if you want another behaviour. This also holds for the start +and end of the chart. Note that stacking a mix of positive and negative values in most instances doesn't make sense (so it looks weird). Two or more series are stacked when their "stack" attribute is set to @@ -14,15 +19,15 @@ specify the default stack, you can set or specify it for a specific series - $.plot($("#placeholder"), [{ data: [ ... ], stack: true ]) + $.plot($("#placeholder"), [{ data: [ ... ], stack: true }]) The stacking order is determined by the order of the data series in the array (later series end up on top of the previous). Internally, the plugin modifies the datapoints in each series, adding an offset to the y value. For line series, extra data points are -inserted through interpolation. For bar charts, the second y value is -also adjusted. +inserted through interpolation. If there's a second y value, it's also +adjusted (e.g for bar charts or filled areas). */ (function ($) { @@ -51,15 +56,17 @@ also adjusted. var other = findMatchingSeries(s, plot.getData()); if (!other) return; - + var ps = datapoints.pointsize, points = datapoints.points, otherps = other.datapoints.pointsize, otherpoints = other.datapoints.points, newpoints = [], px, py, intery, qx, qy, bottom, - withlines = s.lines.show, withbars = s.bars.show, + withlines = s.lines.show, + withbottom = ps > 2 && datapoints.format[2].y, withsteps = withlines && s.lines.steps, + fromgap = true, i = 0, j = 0, l; while (true) { @@ -68,14 +75,27 @@ also adjusted. l = newpoints.length; - if (j >= otherpoints.length - || otherpoints[j] == null - || points[i] == null) { - // degenerate cases + if (points[i] == null) { + // copy gaps for (m = 0; m < ps; ++m) newpoints.push(points[i + m]); i += ps; } + else if (j >= otherpoints.length) { + // for lines, we can't use the rest of the points + if (!withlines) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + } + i += ps; + } + else if (otherpoints[j] == null) { + // oops, got a gap + for (m = 0; m < ps; ++m) + newpoints.push(null); + fromgap = true; + j += otherps; + } else { // cases where we actually got two points px = points[i]; @@ -108,21 +128,29 @@ also adjusted. j += otherps; } - else { + else { // px < qx + if (fromgap && withlines) { + // if we come from a gap, we just skip this point + i += ps; + continue; + } + for (m = 0; m < ps; ++m) newpoints.push(points[i + m]); // we might be able to interpolate a point below, // this can give us a better y - if (withlines && j > 0 && otherpoints[j - ps] != null) - bottom = qy + (otherpoints[j - ps + 1] - qy) * (px - qx) / (otherpoints[j - ps] - qx); + if (withlines && j > 0 && otherpoints[j - otherps] != null) + bottom = qy + (otherpoints[j - otherps + 1] - qy) * (px - qx) / (otherpoints[j - otherps] - qx); newpoints[l + 1] += bottom; i += ps; } + + fromgap = false; - if (l != newpoints.length && withbars) + if (l != newpoints.length && withbottom) newpoints[l + 2] += bottom; } @@ -136,7 +164,7 @@ also adjusted. newpoints[l + 1] = newpoints[l - ps + 1]; } } - + datapoints.points = newpoints; } @@ -147,6 +175,6 @@ also adjusted. init: init, options: options, name: 'stack', - version: '1.0' + version: '1.1' }); })(jQuery);