/* PlotKit Layout ============== Handles laying out data on to a virtual canvas square canvas between 0.0 and 1.0. If you want to add new chart/plot types such as point plots, you need to add them here. Copyright --------- Copyright 2005,2006 (c) Alastair Tse For use under the BSD license. */ try { if (typeof(PlotKit.Base) == 'undefined') { throw "" } } catch (e) { throw "PlotKit.Layout depends on MochiKit.{Base,Color,DOM,Format} and PlotKit.Base" } // -------------------------------------------------------------------- // Start of Layout definition // -------------------------------------------------------------------- if (typeof(PlotKit.Layout) == 'undefined') { PlotKit.Layout = {}; } PlotKit.Layout.NAME = "PlotKit.Layout"; PlotKit.Layout.VERSION = PlotKit.VERSION; PlotKit.Layout.__repr__ = function() { return "[" + this.NAME + " " + this.VERSION + "]"; }; PlotKit.Layout.toString = function() { return this.__repr__(); } PlotKit.Layout.valid_styles = ["bar", "line", "pie", "point"]; // -------------------------------------------------------------------- // Start of Layout definition // -------------------------------------------------------------------- PlotKit.Layout = function(style, options) { this.options = { "barWidthFillFraction": 0.75, "barOrientation": "vertical", "xOriginIsZero": true, "yOriginIsZero": true, "xAxis": null, // [xmin, xmax] "yAxis": null, // [ymin, ymax] "xTicks": null, // [{label: "somelabel", v: value}, ..] (label opt.) "yTicks": null, // [{label: "somelabel", v: value}, ..] (label opt.) "xNumberOfTicks": 10, "yNumberOfTicks": 5, "xTickPrecision": 1, "yTickPrecision": 1, "pieRadius": 0.4 }; // valid external options : TODO: input verification this.style = style; MochiKit.Base.update(this.options, options ? options : {}); // externally visible states // overriden if xAxis and yAxis are set in options if (!MochiKit.Base.isUndefinedOrNull(this.options.xAxis)) { this.minxval = this.options.xAxis[0]; this.maxxval = this.options.xAxis[1]; this.xscale = this.maxxval - this.minxval; } else { this.minxval = 0; this.maxxval = null; this.xscale = null; // val -> pos factor (eg, xval * xscale = xpos) } if (!MochiKit.Base.isUndefinedOrNull(this.options.yAxis)) { this.minyval = this.options.yAxis[0]; this.maxyval = this.options.yAxis[1]; this.yscale = this.maxyval - this.minyval; } else { this.minyval = 0; this.maxyval = null; this.yscale = null; } this.bars = new Array(); // array of bars to plot for bar charts this.points = new Array(); // array of points to plot for line plots this.slices = new Array(); // array of slices to draw for pie charts this.xticks = new Array(); this.yticks = new Array(); // internal states this.datasets = new Array(); this.minxdelta = 0; this.xrange = 1; this.yrange = 1; this.hitTestCache = {x2maxy: null}; }; // -------------------------------------------------------------------- // Dataset Manipulation // -------------------------------------------------------------------- PlotKit.Layout.prototype.addDataset = function(setname, set_xy) { this.datasets[setname] = set_xy; }; PlotKit.Layout.prototype.removeDataset = function(setname, set_xy) { delete this.datasets[setname]; }; PlotKit.Layout.prototype.addDatasetFromTable = function(name, tableElement, xcol, ycol, lcol) { var isNil = MochiKit.Base.isUndefinedOrNull; var scrapeText = MochiKit.DOM.scrapeText; var strip = MochiKit.Format.strip; if (isNil(xcol)) xcol = 0; if (isNil(ycol)) ycol = 1; if (isNil(lcol)) lcol = -1; var rows = tableElement.tBodies[0].rows; var data = new Array(); var labels = new Array(); if (!isNil(rows)) { for (var i = 0; i < rows.length; i++) { data.push([parseFloat(strip(scrapeText(rows[i].cells[xcol]))), parseFloat(strip(scrapeText(rows[i].cells[ycol])))]); if (lcol >= 0){ labels.push({v: parseFloat(strip(scrapeText(rows[i].cells[xcol]))), label: strip(scrapeText(rows[i].cells[lcol]))}); } } this.addDataset(name, data); if (lcol >= 0) { this.options.xTicks = labels; } return true; } return false; }; // -------------------------------------------------------------------- // Evaluates the layout for the current data and style. // -------------------------------------------------------------------- PlotKit.Layout.prototype.evaluate = function() { this._evaluateLimits(); this._evaluateScales(); if (this.style == "bar") { if (this.options.barOrientation == "horizontal") { this._evaluateHorizBarCharts(); } else { this._evaluateBarCharts(); } this._evaluateBarTicks(); } else if (this.style == "line") { this._evaluateLineCharts(); this._evaluateLineTicks(); } else if (this.style == "pie") { this._evaluatePieCharts(); this._evaluatePieTicks(); } }; // Given the fractional x, y positions, report the corresponding // x, y values. PlotKit.Layout.prototype.hitTest = function(x, y) { // TODO: make this more efficient with better datastructures // for this.bars, this.points and this.slices var f = MochiKit.Format.twoDigitFloat; if ((this.style == "bar") && this.bars && (this.bars.length > 0)) { for (var i = 0; i < this.bars.length; i++) { var bar = this.bars[i]; if ((x >= bar.x) && (x <= bar.x + bar.w) && (y >= bar.y) && (y - bar.y <= bar.h)) return bar; } } else if (this.style == "line") { if (this.hitTestCache.x2maxy == null) { this._regenerateHitTestCache(); } // 1. find the xvalues that equal or closest to the give x var xval = x / this.xscale; var xvalues = this.hitTestCache.xvalues; var xbefore = null; var xafter = null; for (var i = 1; i < xvalues.length; i++) { if (xvalues[i] > xval) { xbefore = xvalues[i-1]; xafter = xvalues[i]; break; } } if ((xbefore != null)) { var ybefore = this.hitTestCache.x2maxy[xbefore]; var yafter = this.hitTestCache.x2maxy[xafter]; var yval = (1.0 - y)/this.yscale; // interpolate whether we will fall inside or outside var gradient = (yafter - ybefore) / (xafter - xbefore); var projmaxy = ybefore + gradient * (xval - xbefore); if (projmaxy >= yval) { // inside the highest curve (roughly) var obj = {xval: xval, yval: yval, xafter: xafter, yafter: yafter, xbefore: xbefore, ybefore: ybefore, yprojected: projmaxy }; return obj; } } } else if (this.style == "pie") { var dist = Math.sqrt((y-0.5)*(y-0.5) + (x-0.5)*(x-0.5)); if (dist > this.options.pieRadius) return null; // TODO: actually doesn't work if we don't know how the Canvas // lays it out, need to fix! var angle = Math.atan2(y - 0.5, x - 0.5) - Math.PI/2; for (var i = 0; i < this.slices.length; i++) { var slice = this.slices[i]; if (slice.startAngle < angle && slice.endAngle >= angle) return slice; } } return null; }; // Reports valid position rectangle for X value (only valid for bar charts) PlotKit.Layout.prototype.rectForX = function(x) { return null; }; // Reports valid angles through which X value encloses (only valid for pie charts) PlotKit.Layout.prototype.angleRangeForX = function(x) { return null; }; // -------------------------------------------------------------------- // START Internal Functions // -------------------------------------------------------------------- PlotKit.Layout.prototype._evaluateLimits = function() { // take all values from all datasets and find max and min var map = PlotKit.Base.map; var items = PlotKit.Base.items; var itemgetter = MochiKit.Base.itemgetter; var collapse = PlotKit.Base.collapse; var listMin = MochiKit.Base.listMin; var listMax = MochiKit.Base.listMax; var isNil = MochiKit.Base.isUndefinedOrNull; var all = collapse(map(itemgetter(1), items(this.datasets))); if (isNil(this.options.xAxis)) { if (this.options.xOriginIsZero) this.minxval = 0; else this.minxval = listMin(map(parseFloat, map(itemgetter(0), all))); this.maxxval = listMax(map(parseFloat, map(itemgetter(0), all))); } else { this.minxval = this.options.xAxis[0]; this.maxxval = this.options.xAxis[1]; this.xscale = this.maxval - this.minxval; } if (isNil(this.options.yAxis)) { if (this.options.yOriginIsZero) this.minyval = 0; else this.minyval = listMin(map(parseFloat, map(itemgetter(1), all))); this.maxyval = listMax(map(parseFloat, map(itemgetter(1), all))); } else { this.minyval = this.options.yAxis[0]; this.maxyval = this.options.yAxis[1]; this.yscale = this.maxyval - this.minyval; } }; PlotKit.Layout.prototype._evaluateScales = function() { var isNil = MochiKit.Base.isUndefinedOrNull; this.xrange = this.maxxval - this.minxval; if (this.xrange == 0) this.xscale = 1.0; else this.xscale = 1/this.xrange; this.yrange = this.maxyval - this.minyval; if (this.yrange == 0) this.yscale = 1.0; else this.yscale = 1/this.yrange; }; PlotKit.Layout.prototype._uniqueXValues = function() { var collapse = PlotKit.Base.collapse; var map = PlotKit.Base.map; var uniq = PlotKit.Base.uniq; var getter = MochiKit.Base.itemgetter; var items = PlotKit.Base.items; var xvalues = map(parseFloat, map(getter(0), collapse(map(getter(1), items(this.datasets))))); xvalues.sort(MochiKit.Base.compare); return uniq(xvalues); }; // Create the bars PlotKit.Layout.prototype._evaluateBarCharts = function() { var items = PlotKit.Base.items; var setCount = items(this.datasets).length; // work out how far separated values are var xdelta = 10000000; var xvalues = this._uniqueXValues(); for (var i = 1; i < xvalues.length; i++) { xdelta = Math.min(Math.abs(xvalues[i] - xvalues[i-1]), xdelta); } var barWidth = 0; var barWidthForSet = 0; var barMargin = 0; if (xvalues.length == 1) { // note we have to do something smarter if we only plot one value xdelta = 1.0; this.xscale = 1.0; this.minxval = xvalues[0]; barWidth = 1.0 * this.options.barWidthFillFraction; barWidthForSet = barWidth/setCount; barMargin = (1.0 - this.options.barWidthFillFraction)/2; } else { // readjust xscale to fix with bar charts if (this.xrange == 1) { this.xscale = 0.5; } else if (this.xrange == 2) { this.xscale = 1/3.0; } else { this.xscale = (1.0 - xdelta/this.xrange)/this.xrange; } barWidth = xdelta * this.xscale * this.options.barWidthFillFraction; barWidthForSet = barWidth / setCount; barMargin = xdelta * this.xscale * (1.0 - this.options.barWidthFillFraction)/2; } this.minxdelta = xdelta; // need this for tick positions // add all the rects this.bars = new Array(); var i = 0; for (var setName in this.datasets) { var dataset = this.datasets[setName]; if (PlotKit.Base.isFuncLike(dataset)) continue; for (var j = 0; j < dataset.length; j++) { var item = dataset[j]; var rect = { x: ((parseFloat(item[0]) - this.minxval) * this.xscale) + (i * barWidthForSet) + barMargin, y: 1.0 - ((parseFloat(item[1]) - this.minyval) * this.yscale), w: barWidthForSet, h: ((parseFloat(item[1]) - this.minyval) * this.yscale), xval: parseFloat(item[0]), yval: parseFloat(item[1]), name: setName }; if ((rect.x >= 0.0) && (rect.x <= 1.0) && (rect.y >= 0.0) && (rect.y <= 1.0)) { this.bars.push(rect); } } i++; } }; // Create the horizontal bars PlotKit.Layout.prototype._evaluateHorizBarCharts = function() { var items = PlotKit.Base.items; var setCount = items(this.datasets).length; // work out how far separated values are var xdelta = 10000000; var xvalues = this._uniqueXValues(); for (var i = 1; i < xvalues.length; i++) { xdelta = Math.min(Math.abs(xvalues[i] - xvalues[i-1]), xdelta); } var barWidth = 0; var barWidthForSet = 0; var barMargin = 0; // work out how far each far each bar is separated if (xvalues.length == 1) { // do something smarter if we only plot one value xdelta = 1.0; this.xscale = 1.0; this.minxval = xvalues[0]; barWidth = 1.0 * this.options.barWidthFillFraction; barWidthForSet = barWidth/setCount; barMargin = (1.0 - this.options.barWidthFillFraction)/2; } else { // readjust yscale to fix with bar charts this.xscale = (1.0 - xdelta/this.xrange)/this.xrange; barWidth = xdelta * this.xscale * this.options.barWidthFillFraction; barWidthForSet = barWidth / setCount; barMargin = xdelta * this.xscale * (1.0 - this.options.barWidthFillFraction)/2; } this.minxdelta = xdelta; // need this for tick positions // add all the rects this.bars = new Array(); var i = 0; for (var setName in this.datasets) { var dataset = this.datasets[setName]; if (PlotKit.Base.isFuncLike(dataset)) continue; for (var j = 0; j < dataset.length; j++) { var item = dataset[j]; var rect = { y: ((parseFloat(item[0]) - this.minxval) * this.xscale) + (i * barWidthForSet) + barMargin, x: 0.0, h: barWidthForSet, w: ((parseFloat(item[1]) - this.minyval) * this.yscale), xval: parseFloat(item[0]), yval: parseFloat(item[1]), name: setName }; // limit the x, y values so they do not overdraw if (rect.y <= 0.0) { rect.y = 0.0; } if (rect.y >= 1.0) { rect.y = 1.0; } if ((rect.x >= 0.0) && (rect.x <= 1.0)) { this.bars.push(rect); } } i++; } }; // Create the line charts PlotKit.Layout.prototype._evaluateLineCharts = function() { var items = PlotKit.Base.items; var setCount = items(this.datasets).length; // add all the rects this.points = new Array(); var i = 0; for (var setName in this.datasets) { var dataset = this.datasets[setName]; if (PlotKit.Base.isFuncLike(dataset)) continue; dataset.sort(function(a, b) { return compare(parseFloat(a[0]), parseFloat(b[0])); }); for (var j = 0; j < dataset.length; j++) { var item = dataset[j]; var point = { x: ((parseFloat(item[0]) - this.minxval) * this.xscale), y: 1.0 - ((parseFloat(item[1]) - this.minyval) * this.yscale), xval: parseFloat(item[0]), yval: parseFloat(item[1]), name: setName }; // limit the x, y values so they do not overdraw if (point.y <= 0.0) { point.y = 0.0; } if (point.y >= 1.0) { point.y = 1.0; } if ((point.x >= 0.0) && (point.x <= 1.0)) { this.points.push(point); } } i++; } }; // Create the pie charts PlotKit.Layout.prototype._evaluatePieCharts = function() { var items = PlotKit.Base.items; var sum = MochiKit.Iter.sum; var getter = MochiKit.Base.itemgetter; var setCount = items(this.datasets).length; // we plot the y values of the first dataset var dataset = items(this.datasets)[0][1]; var total = sum(map(getter(1), dataset)); this.slices = new Array(); var currentAngle = 0.0; for (var i = 0; i < dataset.length; i++) { var fraction = dataset[i][1] / total; var startAngle = currentAngle * Math.PI * 2; var endAngle = (currentAngle + fraction) * Math.PI * 2; var slice = {fraction: fraction, xval: dataset[i][0], yval: dataset[i][1], startAngle: startAngle, endAngle: endAngle }; if (dataset[i][1] != 0) { this.slices.push(slice); } currentAngle += fraction; } }; PlotKit.Layout.prototype._evaluateLineTicksForXAxis = function() { var isNil = MochiKit.Base.isUndefinedOrNull; if (this.options.xTicks) { // we use use specified ticks with optional labels this.xticks = new Array(); var makeTicks = function(tick) { var label = tick.label; if (isNil(label)) label = tick.v.toString(); var pos = this.xscale * (tick.v - this.minxval); if ((pos >= 0.0) && (pos <= 1.0)) { this.xticks.push([pos, label]); } }; MochiKit.Iter.forEach(this.options.xTicks, bind(makeTicks, this)); } else if (this.options.xNumberOfTicks) { // we use defined number of ticks as hint to auto generate var xvalues = this._uniqueXValues(); var roughSeparation = this.xrange / this.options.xNumberOfTicks; var tickCount = 0; this.xticks = new Array(); for (var i = 0; i <= xvalues.length; i++) { if ((xvalues[i] - this.minxval) >= (tickCount * roughSeparation)) { var pos = this.xscale * (xvalues[i] - this.minxval); if ((pos > 1.0) || (pos < 0.0)) continue; this.xticks.push([pos, xvalues[i]]); tickCount++; } if (tickCount > this.options.xNumberOfTicks) break; } } }; PlotKit.Layout.prototype._evaluateLineTicksForYAxis = function() { var isNil = MochiKit.Base.isUndefinedOrNull; if (this.options.yTicks) { this.yticks = new Array(); var makeTicks = function(tick) { var label = tick.label; if (isNil(label)) label = tick.v.toString(); var pos = 1.0 - (this.yscale * (tick.v - this.minyval)); if ((pos >= 0.0) && (pos <= 1.0)) { this.yticks.push([pos, label]); } }; MochiKit.Iter.forEach(this.options.yTicks, bind(makeTicks, this)); } else if (this.options.yNumberOfTicks) { // We use the optionally defined number of ticks as a guide this.yticks = new Array(); // if we get this separation right, we'll have good looking graphs var roundInt = PlotKit.Base.roundInterval; var prec = this.options.yTickPrecision; var roughSeparation = roundInt(this.yrange, this.options.yNumberOfTicks, prec); // round off each value of the y-axis to the precision // eg. 1.3333 at precision 1 -> 1.3 for (var i = 0; i <= this.options.yNumberOfTicks; i++) { var yval = this.minyval + (i * roughSeparation); var pos = 1.0 - ((yval - this.minyval) * this.yscale); if ((pos > 1.0) || (pos < 0.0)) continue; this.yticks.push([pos, MochiKit.Format.roundToFixed(yval, prec)]); } } }; PlotKit.Layout.prototype._evaluateLineTicks = function() { this._evaluateLineTicksForXAxis(); this._evaluateLineTicksForYAxis(); }; PlotKit.Layout.prototype._evaluateBarTicks = function() { this._evaluateLineTicks(); var centerInBar = function(tick) { return [tick[0] + (this.minxdelta * this.xscale)/2, tick[1]]; }; this.xticks = MochiKit.Base.map(bind(centerInBar, this), this.xticks); if (this.options.barOrientation == "horizontal") { // swap scales var tempticks = this.xticks; this.xticks = this.yticks; this.yticks = tempticks; // we need to invert the "yaxis" (which is now the xaxis when drawn) var invert = function(tick) { return [1.0 - tick[0], tick[1]]; } this.xticks = MochiKit.Base.map(invert, this.xticks); } }; PlotKit.Layout.prototype._evaluatePieTicks = function() { var isNil = MochiKit.Base.isUndefinedOrNull; var formatter = MochiKit.Format.numberFormatter("#%"); this.xticks = new Array(); if (this.options.xTicks) { // make a lookup dict for x->slice values var lookup = new Array(); for (var i = 0; i < this.slices.length; i++) { lookup[this.slices[i].xval] = this.slices[i]; } for (var i =0; i < this.options.xTicks.length; i++) { var tick = this.options.xTicks[i]; var slice = lookup[tick.v]; var label = tick.label; if (slice) { if (isNil(label)) label = tick.v.toString(); label += " (" + formatter(slice.fraction) + ")"; this.xticks.push([tick.v, label]); } } } else { // we make our own labels from all the slices for (var i =0; i < this.slices.length; i++) { var slice = this.slices[i]; var label = slice.xval + " (" + formatter(slice.fraction) + ")"; this.xticks.push([slice.xval, label]); } } }; PlotKit.Layout.prototype._regenerateHitTestCache = function() { this.hitTestCache.xvalues = this._uniqueXValues(); this.hitTestCache.xlookup = new Array(); this.hitTestCache.x2maxy = new Array(); var listMax = MochiKit.Base.listMax; var itemgetter = MochiKit.Base.itemgetter; var map = MochiKit.Base.map; // generate a lookup table for x values to y values var setNames = keys(this.datasets); for (var i = 0; i < setNames.length; i++) { var dataset = this.datasets[setNames[i]]; for (var j = 0; j < dataset.length; j++) { var xval = dataset[j][0]; var yval = dataset[j][1]; if (this.hitTestCache.xlookup[xval]) this.hitTestCache.xlookup[xval].push([yval, setNames[i]]); else this.hitTestCache.xlookup[xval] = [[yval, setNames[i]]]; } } for (var x in this.hitTestCache.xlookup) { var yvals = this.hitTestCache.xlookup[x]; this.hitTestCache.x2maxy[x] = listMax(map(itemgetter(0), yvals)); } }; // -------------------------------------------------------------------- // END Internal Functions // -------------------------------------------------------------------- // Namespace Iniitialisation PlotKit.LayoutModule = {}; PlotKit.LayoutModule.Layout = PlotKit.Layout; PlotKit.LayoutModule.EXPORT = [ "Layout" ]; PlotKit.LayoutModule.EXPORT_OK = []; PlotKit.LayoutModule.__new__ = function() { var m = MochiKit.Base; m.nameFunctions(this); this.EXPORT_TAGS = { ":common": this.EXPORT, ":all": m.concat(this.EXPORT, this.EXPORT_OK) }; }; PlotKit.LayoutModule.__new__(); MochiKit.Base._exportSymbols(this, PlotKit.LayoutModule);