/** * Class: ProtoChart * Version: v0.5 beta * * ProtoChart is a charting lib on top of Prototype. * This library is heavily motivated by excellent work done by: * * Flot * * Flotr * * Complete examples can be found at: */ /** * Events: * ProtoChart:mousemove - Fired when mouse is moved over the chart * ProtoChart:plotclick - Fired when graph is clicked * ProtoChart:dataclick - Fired when graph is clicked AND the click is on a data point * ProtoChart:selected - Fired when certain region on the graph is selected * ProtoChart:hit - Fired when mouse is moved near or over certain data point on the graph */ if(!Proto) var Proto = {}; Proto.Chart = Class.create({ /** * Function: * {Object} elem * {Object} data * {Object} options */ initialize: function(elem, data, options) { options = options || {}; this.graphData = []; /** * Property: options * * Description: Various options can be set. More details in description. * * colors: * {Array} - pass in a array which contains strings of colors you want to use. Default has 6 color set. * * legend: * {BOOL} - show - if you want to show the legend. Default is false * {integer} - noColumns - Number of columns for the legend. Default is 1 * {function} - labelFormatter - A function that returns a string. The function is called with a string and is expected to return a string. Default = null * {string} - labelBoxBorderColor - border color for the little label boxes. Default #CCC * {HTMLElem} - container - an HTML id or HTML element where the legend should be rendered. If left null means to put the legend on top of the Chart * {string} - position - position for the legend on the Chart. Default value 'ne' * {integer} - margin - default valud of 5 * {string} - backgroundColor - default to null (which means auto-detect) * {float} - backgroundOpacity - leave it 0 to avoid background * * xaxis (yaxis) options: * {string} - mode - default is null but you can pass a string "time" to indicate time series * {integer} - min * {integer} - max * {float} - autoscaleMargin - in % to add if auto-setting min/max * {mixed} - ticks - either [1, 3] or [[1, "a"], 3] or a function which gets axis info and returns ticks * {function} - tickFormatter - A function that returns a string as a tick label. Default is null * {float} - tickDecimals * {integer} - tickSize * {integer} - minTickSize * {array} - monthNames * {string} - timeformat * * Points / Lines / Bars options: * {bool} - show, default is false * {integer} - radius: default is 3 * {integer} - lineWidth : default is 2 * {bool} - fill : default is true * {string} - fillColor: default is #ffffff * * Grid options: * {string} - color * {string} - backgroundColor - defualt is *null* * {string} - tickColor - default is *#dddddd* * {integer} - labelMargin - should be in pixels default is 3 * {integer} - borderWidth - default *1* * {bool} - clickable - default *null* - pass in TRUE if you wish to monitor click events * {mixed} - coloredAreas - default *null* - pass in mixed object eg. {x1, x2} * {string} - coloredAreasColor - default *#f4f4f4* * {bool} - drawXAxis - default *true* * {bool} - drawYAxis - default *true* * * selection options: * {string} - mode : either "x", "y" or "xy" * {string} - color : string */ this.options = this.merge(options,{ colors: ["#edc240", "#00A8F0", "#C0D800", "#cb4b4b", "#4da74d", "#9440ed"], legend: { show: false, noColumns: 1, labelFormatter: null, labelBoxBorderColor: "#ccc", container: null, position: "ne", margin: 5, backgroundColor: null, backgroundOpacity: 0.85 }, xaxis: { mode: null, min: null, max: null, autoscaleMargin: null, ticks: null, tickFormatter: null, tickDecimals: null, tickSize: null, minTickSize: null, monthNames: null, timeformat: null }, yaxis: { mode: null, min: null, max: null, ticks: null, tickFormatter: null, tickDecimals: null, tickSize: null, minTickSize: null, monthNames: null, timeformat: null, autoscaleMargin: 0.02 }, points: { show: false, radius: 3, lineWidth: 2, fill: true, fillColor: "#ffffff" }, lines: { show: false, lineWidth: 2, fill: false, fillColor: null }, bars: { show: false, lineWidth: 2, barWidth: 1, fill: true, fillColor: null, showShadow: false, fillOpacity: 0.4, autoScale: true }, pies: { show: false, radius: 50, borderWidth: 1, fill: true, fillColor: null, fillOpacity: 0.90, labelWidth: 30, fontSize: 11, autoScale: true }, grid: { color: "#545454", backgroundColor: null, tickColor: "#dddddd", labelMargin: 3, borderWidth: 1, clickable: null, coloredAreas: null, coloredAreasColor: "#f4f4f4", drawXAxis: true, drawYAxis: true }, mouse: { track: false, position: 'se', fixedPosition: true, clsName: 'mouseValHolder', trackFormatter: this.defaultTrackFormatter, margin: 3, color: '#ff3f19', trackDecimals: 1, sensibility: 2, radius: 5, lineColor: '#cb4b4b' }, selection: { mode: null, color: "#97CBFF" }, allowDataClick: true, makeRandomColor: false, shadowSize: 4 }); /* * Local variables. */ this.canvas = null; this.overlay = null; this.eventHolder = null; this.context = null; this.overlayContext = null; this.domObj = $(elem); this.xaxis = {}; this.yaxis = {}; this.chartOffset = {left: 0, right: 0, top: 0, bottom: 0}; this.yLabelMaxWidth = 0; this.yLabelMaxHeight = 0; this.xLabelBoxWidth = 0; this.canvasWidth = 0; this.canvasHeight = 0; this.chartWidth = 0; this.chartHeight = 0; this.hozScale = 0; this.vertScale = 0; this.workarounds = {}; this.domObj = $(elem); this.barDataRange = []; this.lastMousePos = { pageX: null, pageY: null }; this.selection = { first: { x: -1, y: -1}, second: { x: -1, y: -1} }; this.prevSelection = null; this.selectionInterval = null; this.ignoreClick = false; this.prevHit = null; if(this.options.makeRandomColor) this.options.color = this.makeRandomColor(this.options.colors); this.setData(data); this.constructCanvas(); this.setupGrid(); this.draw(); }, /** * Private function internally used. */ merge: function(src, dest) { var result = dest || {}; for(var i in src){ result[i] = (typeof(src[i]) == 'object' && !(src[i].constructor == Array || src[i].constructor == RegExp)) ? this.merge(src[i], dest[i]) : result[i] = src[i]; } return result; }, /** * Function: setData * {Object} data * * Description: * Sets datasoruces properly then sets the Bar Width accordingly, then copies the default data options and then processes the graph data * * Returns: none * */ setData: function(data) { this.graphData = this.parseData(data); this.setBarWidth(); this.copyGraphDataOptions(); this.processGraphData(); }, /** * Function: parseData * {Object} data * * Return: * {Object} result * * Description: * Takes the provided data object and converts it into generic data that we can understand. User can pass in data in 3 different ways: * - [d1, d2] * - [{data: d1, label: "data1"}, {data: d2, label: "data2"}] * - [d1, {data: d1, label: "data1"}] * * This function parses these senarios and makes it readable */ parseData: function(data) { var res = []; data.each(function(d){ var s; if(d.data) { s = {}; for(var v in d) { s[v] = d[v]; } } else { s = {data: d}; } res.push(s); }.bind(this)); return res; }, /** * function: makeRandomColor * {Object} colorSet * * Return: * {Array} result - array containing random colors */ makeRandomColor: function(colorSet) { var randNum = Math.floor(Math.random() * colorSet.length); var randArr = []; var newArr = []; randArr.push(randNum); while(randArr.length < colorSet.length) { var tempNum = Math.floor(Math.random() * colorSet.length); while(checkExisted(tempNum, randArr)) tempNum = Math.floor(Math.random() * colorSet.length); randArr.push(tempNum); } randArr.each(function(ra){ newArr.push(colorSet[ra]); }.bind(this)); return newArr; }, /** * function: checkExisted * {Object} needle * {Object} haystack * * return: * {bool} existed - true if it finds needle in the haystack */ checkExisted: function(needle, haystack) { var existed = false; haystack.each(function(aNeedle){ if(aNeedle == needle) { existed = true; throw $break; } }.bind(this)); return existed; }, /** * function: setBarWidth * * Description: sets the bar width for Bar Graph, you should enable *autoScale* property for bar graph */ setBarWidth: function() { if(this.options.bars.show && this.options.bars.autoScale) { this.options.bars.barWidth = 1 / this.graphData.length / 1.2; } }, /** * Function: copyGraphDataOptions * * Description: Private function that goes through each graph data (series) and assigned the graph * properties to it. */ copyGraphDataOptions: function() { var i, neededColors = this.graphData.length, usedColors = [], assignedColors = []; this.graphData.each(function(gd){ var sc = gd.color; if(sc) { --neededColors; if(Object.isNumber(sc)) { assignedColors.push(sc); } else { usedColors.push(this.parseColor(sc)); } } }.bind(this)); assignedColors.each(function(ac){ neededColors = Math.max(neededColors, ac + 1); }); var colors = []; var variation = 0; i = 0; while (colors.length < neededColors) { var c; if (this.options.colors.length == i) { c = new Proto.Color(100, 100, 100); } else { c = this.parseColor(this.options.colors[i]); } var sign = variation % 2 == 1 ? -1 : 1; var factor = 1 + sign * Math.ceil(variation / 2) * 0.2; c.scale(factor, factor, factor); colors.push(c); ++i; if (i >= this.options.colors.length) { i = 0; ++variation; } } var colorIndex = 0, s; this.graphData.each(function(gd){ if(gd.color == null) { gd.color = colors[colorIndex].toString(); ++colorIndex; } else if(Object.isNumber(gd.color)) { gd.color = colors[gd.color].toString(); } gd.lines = Object.extend(Object.clone(this.options.lines), gd.lines); gd.points = Object.extend(Object.clone(this.options.points), gd.points); gd.bars = Object.extend(Object.clone(this.options.bars), gd.bars); gd.mouse = Object.extend(Object.clone(this.options.mouse), gd.mouse); if (gd.shadowSize == null) { gd.shadowSize = this.options.shadowSize; } }.bind(this)); }, /** * Function: processGraphData * * Description: processes graph data, setup xaxis and yaxis min and max points. */ processGraphData: function() { this.xaxis.datamin = this.yaxis.datamin = Number.MAX_VALUE; this.xaxis.datamax = this.yaxis.datamax = Number.MIN_VALUE; this.graphData.each(function(gd) { var data = gd.data; data.each(function(d){ if(d == null) { return; } var x = d[0], y = d[1]; if(!x || !y || isNaN(x = +x) || isNaN(y = +y)) { d = null; return; } if (x < this.xaxis.datamin) this.xaxis.datamin = x; if (x > this.xaxis.datamax) this.xaxis.datamax = x; if (y < this.yaxis.datamin) this.yaxis.datamin = y; if (y > this.yaxis.datamax) this.yaxis.datamax = y; }.bind(this)); }.bind(this)); if (this.xaxis.datamin == Number.MAX_VALUE) this.xaxis.datamin = 0; if (this.yaxis.datamin == Number.MAX_VALUE) this.yaxis.datamin = 0; if (this.xaxis.datamax == Number.MIN_VALUE) this.xaxis.datamax = 1; if (this.yaxis.datamax == Number.MIN_VALUE) this.yaxis.datamax = 1; }, /** * Function: constructCanvas * * Description: constructs the main canvas for drawing. It replicates the HTML elem (usually DIV) passed * in via constructor. If there is no height/width assigned to the HTML elem then we take a default size * of 400px (width) and 300px (height) */ constructCanvas: function() { this.canvasWidth = this.domObj.getWidth(); this.canvasHeight = this.domObj.getHeight(); this.domObj.update(""); // clear target this.domObj.setStyle({ "position": "relative" }); if (this.canvasWidth <= 0) { this.canvasWdith = 400; } if(this.canvasHeight <= 0) { this.canvasHeight = 300; } this.canvas = (Prototype.Browser.IE) ? document.createElement("canvas") : new Element("CANVAS", {'width': this.canvasWidth, 'height': this.canvasHeight}); Element.extend(this.canvas); this.canvas.style.width = this.canvasWidth + "px"; this.canvas.style.height = this.canvasHeight + "px"; this.domObj.appendChild(this.canvas); if (Prototype.Browser.IE) // excanvas hack { this.canvas = $(window.G_vmlCanvasManager.initElement(this.canvas)); } this.canvas = $(this.canvas); this.context = this.canvas.getContext("2d"); this.overlay = (Prototype.Browser.IE) ? document.createElement("canvas") : new Element("CANVAS", {'width': this.canvasWidth, 'height': this.canvasHeight}); Element.extend(this.overlay); this.overlay.style.width = this.canvasWidth + "px"; this.overlay.style.height = this.canvasHeight + "px"; this.overlay.style.position = "absolute"; this.overlay.style.left = "0px"; this.overlay.style.right = "0px"; this.overlay.setStyle({ 'position': 'absolute', 'left': '0px', 'right': '0px' }); this.domObj.appendChild(this.overlay); if (Prototype.Browser.IE) { this.overlay = $(window.G_vmlCanvasManager.initElement(this.overlay)); } this.overlay = $(this.overlay); this.overlayContext = this.overlay.getContext("2d"); if(this.options.selection.mode) { this.overlay.observe('mousedown', this.onMouseDown.bind(this)); this.overlay.observe('mousemove', this.onMouseMove.bind(this)); } if(this.options.grid.clickable) { this.overlay.observe('click', this.onClick.bind(this)); } if(this.options.mouse.track) { this.overlay.observe('mousemove', this.onMouseMove.bind(this)); } }, /** * function: setupGrid * * Description: a container function that does a few interesting things. * * 1. calls function which makes sure that our axis are expanded if needed * * 2. calls function providing xaxis options which fixes the ranges according to data points * * 3. calls function for xaxis which generates ticks according to options provided by user * * 4. calls function for xaxis that sets the ticks * * similar sequence is called for y-axis. * * At the end if this is a pie chart than we insert Labels (around the pie chart) via and we also call */ setupGrid: function() { if(this.options.bars.show) { this.xaxis.max += 0.5; this.xaxis.min -= 0.5; } //x-axis this.extendXRangeIfNeededByBar(); this.setRange(this.xaxis, this.options.xaxis); this.prepareTickGeneration(this.xaxis, this.options.xaxis); this.setTicks(this.xaxis, this.options.xaxis); //y-axis this.setRange(this.yaxis, this.options.yaxis); this.prepareTickGeneration(this.yaxis, this.options.yaxis); this.setTicks(this.yaxis, this.options.yaxis); this.setSpacing(); if(!this.options.pies.show) { this.insertLabels(); } this.insertLegend(); }, /** * function: setRange * * parameters: * {Object} axis * {Object} axisOptions */ setRange: function(axis, axisOptions) { var min = axisOptions.min != null ? axisOptions.min : axis.datamin; var max = axisOptions.max != null ? axisOptions.max : axis.datamax; if (max - min == 0.0) { // degenerate case var widen; if (max == 0.0) widen = 1.0; else widen = 0.01; min -= widen; max += widen; } else { // consider autoscaling var margin = axisOptions.autoscaleMargin; if (margin != null) { if (axisOptions.min == null) { min -= (max - min) * margin; // make sure we don't go below zero if all values // are positive if (min < 0 && axis.datamin >= 0) min = 0; } if (axisOptions.max == null) { max += (max - min) * margin; if (max > 0 && axis.datamax <= 0) max = 0; } } } axis.min = min; axis.max = max; }, /** * function: prepareTickGeneration * * Parameters: * {Object} axis * {Object} axisOptions */ prepareTickGeneration: function(axis, axisOptions) { // estimate number of ticks var noTicks; if (Object.isNumber(axisOptions.ticks) && axisOptions.ticks > 0) noTicks = axisOptions.ticks; else if (axis == this.xaxis) noTicks = this.canvasWidth / 100; else noTicks = this.canvasHeight / 60; var delta = (axis.max - axis.min) / noTicks; var size, generator, unit, formatter, i, magn, norm; if (axisOptions.mode == "time") { function formatDate(d, fmt, monthNames) { var leftPad = function(n) { n = "" + n; return n.length == 1 ? "0" + n : n; }; var r = []; var escape = false; if (monthNames == null) monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; for (var i = 0; i < fmt.length; ++i) { var c = fmt.charAt(i); if (escape) { switch (c) { case 'h': c = "" + d.getHours(); break; case 'H': c = leftPad(d.getHours()); break; case 'M': c = leftPad(d.getMinutes()); break; case 'S': c = leftPad(d.getSeconds()); break; case 'd': c = "" + d.getDate(); break; case 'm': c = "" + (d.getMonth() + 1); break; case 'y': c = "" + d.getFullYear(); break; case 'b': c = "" + monthNames[d.getMonth()]; break; } r.push(c); escape = false; } else { if (c == "%") escape = true; else r.push(c); } } return r.join(""); } // map of app. size of time units in milliseconds var timeUnitSize = { "second": 1000, "minute": 60 * 1000, "hour": 60 * 60 * 1000, "day": 24 * 60 * 60 * 1000, "month": 30 * 24 * 60 * 60 * 1000, "year": 365.2425 * 24 * 60 * 60 * 1000 }; // the allowed tick sizes, after 1 year we use // an integer algorithm var spec = [ [1, "second"], [2, "second"], [5, "second"], [10, "second"], [30, "second"], [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], [30, "minute"], [1, "hour"], [2, "hour"], [4, "hour"], [8, "hour"], [12, "hour"], [1, "day"], [2, "day"], [3, "day"], [0.25, "month"], [0.5, "month"], [1, "month"], [2, "month"], [3, "month"], [6, "month"], [1, "year"] ]; var minSize = 0; if (axisOptions.minTickSize != null) { if (typeof axisOptions.tickSize == "number") minSize = axisOptions.tickSize; else minSize = axisOptions.minTickSize[0] * timeUnitSize[axisOptions.minTickSize[1]]; } for (i = 0; i < spec.length - 1; ++i) { if (delta < (spec[i][0] * timeUnitSize[spec[i][1]] + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) { break; } } size = spec[i][0]; unit = spec[i][1]; // special-case the possibility of several years if (unit == "year") { magn = Math.pow(10, Math.floor(Math.log(delta / timeUnitSize.year) / Math.LN10)); norm = (delta / timeUnitSize.year) / magn; if (norm < 1.5) size = 1; else if (norm < 3) size = 2; else if (norm < 7.5) size = 5; else size = 10; size *= magn; } if (axisOptions.tickSize) { size = axisOptions.tickSize[0]; unit = axisOptions.tickSize[1]; } var floorInBase = this.floorInBase; //gives us a reference to a global function.. generator = function(axis) { var ticks = [], tickSize = axis.tickSize[0], unit = axis.tickSize[1], d = new Date(axis.min); var step = tickSize * timeUnitSize[unit]; if (unit == "second") d.setSeconds(floorInBase(d.getSeconds(), tickSize)); if (unit == "minute") d.setMinutes(floorInBase(d.getMinutes(), tickSize)); if (unit == "hour") d.setHours(floorInBase(d.getHours(), tickSize)); if (unit == "month") d.setMonth(floorInBase(d.getMonth(), tickSize)); if (unit == "year") d.setFullYear(floorInBase(d.getFullYear(), tickSize)); // reset smaller components d.setMilliseconds(0); if (step >= timeUnitSize.minute) d.setSeconds(0); if (step >= timeUnitSize.hour) d.setMinutes(0); if (step >= timeUnitSize.day) d.setHours(0); if (step >= timeUnitSize.day * 4) d.setDate(1); if (step >= timeUnitSize.year) d.setMonth(0); var carry = 0, v; do { v = d.getTime(); ticks.push({ v: v, label: axis.tickFormatter(v, axis) }); if (unit == "month") { if (tickSize < 1) { d.setDate(1); var start = d.getTime(); d.setMonth(d.getMonth() + 1); var end = d.getTime(); d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); carry = d.getHours(); d.setHours(0); } else d.setMonth(d.getMonth() + tickSize); } else if (unit == "year") { d.setFullYear(d.getFullYear() + tickSize); } else d.setTime(v + step); } while (v < axis.max); return ticks; }; formatter = function (v, axis) { var d = new Date(v); // first check global format if (axisOptions.timeformat != null) return formatDate(d, axisOptions.timeformat, axisOptions.monthNames); var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; var span = axis.max - axis.min; if (t < timeUnitSize.minute) fmt = "%h:%M:%S"; else if (t < timeUnitSize.day) { if (span < 2 * timeUnitSize.day) fmt = "%h:%M"; else fmt = "%b %d %h:%M"; } else if (t < timeUnitSize.month) fmt = "%b %d"; else if (t < timeUnitSize.year) { if (span < timeUnitSize.year) fmt = "%b"; else fmt = "%b %y"; } else fmt = "%y"; return formatDate(d, fmt, axisOptions.monthNames); }; } else { // pretty rounding of base-10 numbers var maxDec = axisOptions.tickDecimals; var dec = -Math.floor(Math.log(delta) / Math.LN10); if (maxDec != null && dec > maxDec) dec = maxDec; magn = Math.pow(10, -dec); norm = delta / magn; // norm is between 1.0 and 10.0 if (norm < 1.5) size = 1; else if (norm < 3) { size = 2; // special case for 2.5, requires an extra decimal if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { size = 2.5; ++dec; } } else if (norm < 7.5) size = 5; else size = 10; size *= magn; if (axisOptions.minTickSize != null && size < axisOptions.minTickSize) size = axisOptions.minTickSize; if (axisOptions.tickSize != null) size = axisOptions.tickSize; axis.tickDecimals = Math.max(0, (maxDec != null) ? maxDec : dec); var floorInBase = this.floorInBase; generator = function (axis) { var ticks = []; var start = floorInBase(axis.min, axis.tickSize); // then spew out all possible ticks var i = 0, v; do { v = start + i * axis.tickSize; ticks.push({ v: v, label: axis.tickFormatter(v, axis) }); ++i; } while (v < axis.max); return ticks; }; formatter = function (v, axis) { if(v) { return v.toFixed(axis.tickDecimals); } return 0; }; } axis.tickSize = unit ? [size, unit] : size; axis.tickGenerator = generator; if (Object.isFunction(axisOptions.tickFormatter)) axis.tickFormatter = function (v, axis) { return "" + axisOptions.tickFormatter(v, axis); }; else axis.tickFormatter = formatter; }, /** * function: extendXRangeIfNeededByBar */ extendXRangeIfNeededByBar: function() { if (this.options.xaxis.max == null) { // great, we're autoscaling, check if we might need a bump var newmax = this.xaxis.max; this.graphData.each(function(gd){ if(gd.bars.show && gd.bars.barWidth + this.xaxis.datamax > newmax) { newmax = this.xaxis.datamax + gd.bars.barWidth; } }.bind(this)); this.xaxis.nax = newmax; } }, /** * function: setTicks * * parameters: * {Object} axis * {Object} axisOptions */ setTicks: function(axis, axisOptions) { axis.ticks = []; if (axisOptions.ticks == null) axis.ticks = axis.tickGenerator(axis); else if (typeof axisOptions.ticks == "number") { if (axisOptions.ticks > 0) axis.ticks = axis.tickGenerator(axis); } else if (axisOptions.ticks) { var ticks = axisOptions.ticks; if (Object.isFunction(ticks)) // generate the ticks ticks = ticks({ min: axis.min, max: axis.max }); // clean up the user-supplied ticks, copy them over //var i, v; ticks.each(function(t, i){ var v = null; var label = null; if(typeof t == 'object') { v = t[0]; if(t.length > 1) { label = t[1]; } } else { v = t; } if(!label) { label = axis.tickFormatter(v, axis); } axis.ticks[i] = {v: v, label: label} }.bind(this)); } if (axisOptions.autoscaleMargin != null && axis.ticks.length > 0) { if (axisOptions.min == null) axis.min = Math.min(axis.min, axis.ticks[0].v); if (axisOptions.max == null && axis.ticks.length > 1) axis.max = Math.min(axis.max, axis.ticks[axis.ticks.length - 1].v); } }, /** * Function: setSpacing * * Parameters: none */ setSpacing: function() { // calculate y label dimensions var i, labels = [], l; for (i = 0; i < this.yaxis.ticks.length; ++i) { l = this.yaxis.ticks[i].label; if (l) labels.push('
' + l + '
'); } if (labels.length > 0) { var dummyDiv = new Element('div', {'style': 'position:absolute;top:-10000px;font-size:smaller'}); dummyDiv.update(labels.join("")); this.domObj.insert(dummyDiv); this.yLabelMaxWidth = dummyDiv.getWidth(); this.yLabelMaxHeight = dummyDiv.select('div')[0].getHeight(); dummyDiv.remove(); } var maxOutset = this.options.grid.borderWidth; if (this.options.points.show) maxOutset = Math.max(maxOutset, this.options.points.radius + this.options.points.lineWidth/2); for (i = 0; i < this.graphData.length; ++i) { if (this.graphData[i].points.show) maxOutset = Math.max(maxOutset, this.graphData[i].points.radius + this.graphData[i].points.lineWidth/2); } this.chartOffset.left = this.chartOffset.right = this.chartOffset.top = this.chartOffset.bottom = maxOutset; this.chartOffset.left += this.yLabelMaxWidth + this.options.grid.labelMargin; this.chartWidth = this.canvasWidth - this.chartOffset.left - this.chartOffset.right; this.xLabelBoxWidth = this.chartWidth / 6; labels = []; for (i = 0; i < this.xaxis.ticks.length; ++i) { l = this.xaxis.ticks[i].label; if (l) { labels.push('' + l + ''); } } var xLabelMaxHeight = 0; if (labels.length > 0) { var dummyDiv = new Element('div', {'style': 'position:absolute;top:-10000px;font-size:smaller'}); dummyDiv.update(labels.join("")); this.domObj.appendChild(dummyDiv); xLabelMaxHeight = dummyDiv.getHeight(); dummyDiv.remove(); } this.chartOffset.bottom += xLabelMaxHeight + this.options.grid.labelMargin; this.chartHeight = this.canvasHeight - this.chartOffset.bottom - this.chartOffset.top; this.hozScale = this.chartWidth / (this.xaxis.max - this.xaxis.min); this.vertScale = this.chartHeight / (this.yaxis.max - this.yaxis.min); }, /** * function: draw */ draw: function() { if(this.options.bars.show) { this.extendXRangeIfNeededByBar(); this.setSpacing(); this.drawGrid(); this.drawBarGraph(this.graphData, this.barDataRange); } else if(this.options.pies.show) { this.preparePieData(this.graphData); this.drawPieGraph(this.graphData); } else { this.drawGrid(); for (var i = 0; i < this.graphData.length; i++) { this.drawGraph(this.graphData[i]); } } }, /** * function: translateHoz * * Paramters: * {Object} x * * Description: Given a value this function translate it to relative x coord on canvas */ translateHoz: function(x) { return (x - this.xaxis.min) * this.hozScale; }, /** * function: translateVert * * parameters: * {Object} y * * Description: Given a value this function translate it to relative y coord on canvas */ translateVert: function(y) { return this.chartHeight - (y - this.yaxis.min) * this.vertScale; }, /** * function: drawGrid * * parameters: none * * description: draws the actual grid on the canvas */ drawGrid: function() { var i; this.context.save(); this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.context.translate(this.chartOffset.left, this.chartOffset.top); // draw background, if any if (this.options.grid.backgroundColor != null) { this.context.fillStyle = this.options.grid.backgroundColor; this.context.fillRect(0, 0, this.chartWidth, this.chartHeight); } // draw colored areas if (this.options.grid.coloredAreas) { var areas = this.options.grid.coloredAreas; if (Object.isFunction(areas)) { areas = areas({ xmin: this.xaxis.min, xmax: this.xaxis.max, ymin: this.yaxis.min, ymax: this.yaxis.max }); } areas.each(function(a){ // clip if (a.x1 == null || a.x1 < this.xaxis.min) a.x1 = this.xaxis.min; if (a.x2 == null || a.x2 > this.xaxis.max) a.x2 = this.xaxis.max; if (a.y1 == null || a.y1 < this.yaxis.min) a.y1 = this.yaxis.min; if (a.y2 == null || a.y2 > this.yaxis.max) a.y2 = this.yaxis.max; var tmp; if (a.x1 > a.x2) { tmp = a.x1; a.x1 = a.x2; a.x2 = tmp; } if (a.y1 > a.y2) { tmp = a.y1; a.y1 = a.y2; a.y2 = tmp; } if (a.x1 >= this.xaxis.max || a.x2 <= this.xaxis.min || a.x1 == a.x2 || a.y1 >= this.yaxis.max || a.y2 <= this.yaxis.min || a.y1 == a.y2) return; this.context.fillStyle = a.color || this.options.grid.coloredAreasColor; this.context.fillRect(Math.floor(this.translateHoz(a.x1)), Math.floor(this.translateVert(a.y2)), Math.floor(this.translateHoz(a.x2) - this.translateHoz(a.x1)), Math.floor(this.translateVert(a.y1) - this.translateVert(a.y2))); }.bind(this)); } // draw the inner grid this.context.lineWidth = 1; this.context.strokeStyle = this.options.grid.tickColor; this.context.beginPath(); var v; if (this.options.grid.drawXAxis) { this.xaxis.ticks.each(function(aTick){ v = aTick.v; if(v <= this.xaxis.min || v >= this.xaxis.max) { return; } this.context.moveTo(Math.floor(this.translateHoz(v)) + this.context.lineWidth / 2, 0); this.context.lineTo(Math.floor(this.translateHoz(v)) + this.context.lineWidth / 2, this.chartHeight); }.bind(this)); } if (this.options.grid.drawYAxis) { this.yaxis.ticks.each(function(aTick){ v = aTick.v; if(v <= this.yaxis.min || v >= this.yaxis.max) { return; } this.context.moveTo(0, Math.floor(this.translateVert(v)) + this.context.lineWidth / 2); this.context.lineTo(this.chartWidth, Math.floor(this.translateVert(v)) + this.context.lineWidth / 2); }.bind(this)); } this.context.stroke(); if (this.options.grid.borderWidth) { // draw border this.context.lineWidth = this.options.grid.borderWidth; this.context.strokeStyle = this.options.grid.color; this.context.lineJoin = "round"; this.context.strokeRect(0, 0, this.chartWidth, this.chartHeight); this.context.restore(); } }, /** * function: insertLabels * * parameters: none * * description: inserts the label with proper spacing. Both on X and Y axis */ insertLabels: function() { this.domObj.select(".tickLabels").invoke('remove'); var i, tick; var html = '
'; // do the x-axis this.xaxis.ticks.each(function(tick){ if (!tick.label || tick.v < this.xaxis.min || tick.v > this.xaxis.max) return; html += '
' + tick.label + "
"; }.bind(this)); // do the y-axis this.yaxis.ticks.each(function(tick){ if (!tick.label || tick.v < this.yaxis.min || tick.v > this.yaxis.max) return; html += '
' + tick.label + "
"; }.bind(this)); html += '
'; this.domObj.insert(html); }, /** * function: drawGraph * * Paramters: * {Object} graphData * * Description: given a graphData (series) this function calls a proper lower level method to draw it. */ drawGraph: function(graphData) { if (graphData.lines.show || (!graphData.bars.show && !graphData.points.show)) this.drawGraphLines(graphData); if (graphData.bars.show) this.drawGraphBar(graphData); if (graphData.points.show) this.drawGraphPoints(graphData); }, /** * function: plotLine * * parameters: * {Object} data * {Object} offset * * description: * Helper function that plots a line based on the data provided */ plotLine: function(data, offset) { var prev, cur = null, drawx = null, drawy = null; this.context.beginPath(); for (var i = 0; i < data.length; ++i) { prev = cur; cur = data[i]; if (prev == null || cur == null) continue; var x1 = prev[0], y1 = prev[1], x2 = cur[0], y2 = cur[1]; // clip with ymin if (y1 <= y2 && y1 < this.yaxis.min) { if (y2 < this.yaxis.min) continue; // line segment is outside // compute new intersection point x1 = (this.yaxis.min - y1) / (y2 - y1) * (x2 - x1) + x1; y1 = this.yaxis.min; } else if (y2 <= y1 && y2 < this.yaxis.min) { if (y1 < this.yaxis.min) continue; x2 = (this.yaxis.min - y1) / (y2 - y1) * (x2 - x1) + x1; y2 = this.yaxis.min; } // clip with ymax if (y1 >= y2 && y1 > this.yaxis.max) { if (y2 > this.yaxis.max) continue; x1 = (this.yaxis.max - y1) / (y2 - y1) * (x2 - x1) + x1; y1 = this.yaxis.max; } else if (y2 >= y1 && y2 > this.yaxis.max) { if (y1 > this.yaxis.max) continue; x2 = (this.yaxis.max - y1) / (y2 - y1) * (x2 - x1) + x1; y2 = this.yaxis.max; } // clip with xmin if (x1 <= x2 && x1 < this.xaxis.min) { if (x2 < this.xaxis.min) continue; y1 = (this.xaxis.min - x1) / (x2 - x1) * (y2 - y1) + y1; x1 = this.xaxis.min; } else if (x2 <= x1 && x2 < this.xaxis.min) { if (x1 < this.xaxis.min) continue; y2 = (this.xaxis.min - x1) / (x2 - x1) * (y2 - y1) + y1; x2 = this.xaxis.min; } // clip with xmax if (x1 >= x2 && x1 > this.xaxis.max) { if (x2 > this.xaxis.max) continue; y1 = (this.xaxis.max - x1) / (x2 - x1) * (y2 - y1) + y1; x1 = this.xaxis.max; } else if (x2 >= x1 && x2 > this.xaxis.max) { if (x1 > this.xaxis.max) continue; y2 = (this.xaxis.max - x1) / (x2 - x1) * (y2 - y1) + y1; x2 = this.xaxis.max; } if (drawx != this.translateHoz(x1) || drawy != this.translateVert(y1) + offset) this.context.moveTo(this.translateHoz(x1), this.translateVert(y1) + offset); drawx = this.translateHoz(x2); drawy = this.translateVert(y2) + offset; this.context.lineTo(drawx, drawy); } this.context.stroke(); }, /** * function: plotLineArea * * parameters: * {Object} data * * description: * Helper functoin that plots a colored line graph. This function * takes the data nad then fill in the area on the graph properly */ plotLineArea: function(data) { var prev, cur = null; var bottom = Math.min(Math.max(0, this.yaxis.min), this.yaxis.max); var top, lastX = 0; var areaOpen = false; for (var i = 0; i < data.length; ++i) { prev = cur; cur = data[i]; if (areaOpen && prev != null && cur == null) { // close area this.context.lineTo(this.translateHoz(lastX), this.translateVert(bottom)); this.context.fill(); areaOpen = false; continue; } if (prev == null || cur == null) continue; var x1 = prev[0], y1 = prev[1], x2 = cur[0], y2 = cur[1]; // clip x values // clip with xmin if (x1 <= x2 && x1 < this.xaxis.min) { if (x2 < this.xaxis.min) continue; y1 = (this.xaxis.min - x1) / (x2 - x1) * (y2 - y1) + y1; x1 = this.xaxis.min; } else if (x2 <= x1 && x2 < this.xaxis.min) { if (x1 < this.xaxis.min) continue; y2 = (this.xaxis.min - x1) / (x2 - x1) * (y2 - y1) + y1; x2 = this.xaxis.min; } // clip with xmax if (x1 >= x2 && x1 > this.xaxis.max) { if (x2 > this.xaxis.max) continue; y1 = (this.xaxis.max - x1) / (x2 - x1) * (y2 - y1) + y1; x1 = this.xaxis.max; } else if (x2 >= x1 && x2 > this.xaxis.max) { if (x1 > this.xaxis.max) continue; y2 = (this.xaxis.max - x1) / (x2 - x1) * (y2 - y1) + y1; x2 = this.xaxis.max; } if (!areaOpen) { // open area this.context.beginPath(); this.context.moveTo(this.translateHoz(x1), this.translateVert(bottom)); areaOpen = true; } // now first check the case where both is outside if (y1 >= this.yaxis.max && y2 >= this.yaxis.max) { this.context.lineTo(this.translateHoz(x1), this.translateVert(this.yaxis.max)); this.context.lineTo(this.translateHoz(x2), this.translateVert(this.yaxis.max)); continue; } else if (y1 <= this.yaxis.min && y2 <= this.yaxis.min) { this.context.lineTo(this.translateHoz(x1), this.translateVert(this.yaxis.min)); this.context.lineTo(this.translateHoz(x2), this.translateVert(this.yaxis.min)); continue; } var x1old = x1, x2old = x2; // clip with ymin if (y1 <= y2 && y1 < this.yaxis.min && y2 >= this.yaxis.min) { x1 = (this.yaxis.min - y1) / (y2 - y1) * (x2 - x1) + x1; y1 = this.yaxis.min; } else if (y2 <= y1 && y2 < this.yaxis.min && y1 >= this.yaxis.min) { x2 = (this.yaxis.min - y1) / (y2 - y1) * (x2 - x1) + x1; y2 = this.yaxis.min; } // clip with ymax if (y1 >= y2 && y1 > this.yaxis.max && y2 <= this.yaxis.max) { x1 = (this.yaxis.max - y1) / (y2 - y1) * (x2 - x1) + x1; y1 = this.yaxis.max; } else if (y2 >= y1 && y2 > this.yaxis.max && y1 <= this.yaxis.max) { x2 = (this.yaxis.max - y1) / (y2 - y1) * (x2 - x1) + x1; y2 = this.yaxis.max; } // if the x value was changed we got a rectangle // to fill if (x1 != x1old) { if (y1 <= this.yaxis.min) top = this.yaxis.min; else top = this.yaxis.max; this.context.lineTo(this.translateHoz(x1old), this.translateVert(top)); this.context.lineTo(this.translateHoz(x1), this.translateVert(top)); } // fill the triangles this.context.lineTo(this.translateHoz(x1), this.translateVert(y1)); this.context.lineTo(this.translateHoz(x2), this.translateVert(y2)); // fill the other rectangle if it's there if (x2 != x2old) { if (y2 <= this.yaxis.min) top = this.yaxis.min; else top = this.yaxis.max; this.context.lineTo(this.translateHoz(x2old), this.translateVert(top)); this.context.lineTo(this.translateHoz(x2), this.translateVert(top)); } lastX = Math.max(x2, x2old); } if (areaOpen) { this.context.lineTo(this.translateHoz(lastX), this.translateVert(bottom)); this.context.fill(); } }, /** * function: drawGraphLines * * parameters: * {Object} graphData * * description: * Main function that daws the line graph. This function is called * if lines property is set to show or no other type of * graph is specified. This function depends on and * functions. */ drawGraphLines: function(graphData) { this.context.save(); this.context.translate(this.chartOffset.left, this.chartOffset.top); this.context.lineJoin = "round"; var lw = graphData.lines.lineWidth; var sw = graphData.shadowSize; // FIXME: consider another form of shadow when filling is turned on if (sw > 0) { // draw shadow in two steps this.context.lineWidth = sw / 2; this.context.strokeStyle = "rgba(0,0,0,0.1)"; this.plotLine(graphData.data, lw/2 + sw/2 + this.context.lineWidth/2); this.context.lineWidth = sw / 2; this.context.strokeStyle = "rgba(0,0,0,0.2)"; this.plotLine(graphData.data, lw/2 + this.context.lineWidth/2); } this.context.lineWidth = lw; this.context.strokeStyle = graphData.color; if (graphData.lines.fill) { this.context.fillStyle = graphData.lines.fillColor != null ? graphData.lines.fillColor : this.parseColor(graphData.color).scale(null, null, null, 0.4).toString(); this.plotLineArea(graphData.data, 0); } this.plotLine(graphData.data, 0); this.context.restore(); }, /** * function: plotPoints * * parameters: * {Object} data * {Object} radius * {Object} fill * * description: * Helper function that draws the point graph according to the data provided. Size of each * point is provided by radius variable and fill specifies if points * are filled */ plotPoints: function(data, radius, fill) { for (var i = 0; i < data.length; ++i) { if (data[i] == null) continue; var x = data[i][0], y = data[i][1]; if (x < this.xaxis.min || x > this.xaxis.max || y < this.yaxis.min || y > this.yaxis.max) continue; this.context.beginPath(); this.context.arc(this.translateHoz(x), this.translateVert(y), radius, 0, 2 * Math.PI, true); if (fill) this.context.fill(); this.context.stroke(); } }, /** * function: plotPointShadows * * parameters: * {Object} data * {Object} offset * {Object} radius * * description: * Helper function that draws the shadows for the points. */ plotPointShadows: function(data, offset, radius) { for (var i = 0; i < data.length; ++i) { if (data[i] == null) continue; var x = data[i][0], y = data[i][1]; if (x < this.xaxis.min || x > this.xaxis.max || y < this.yaxis.min || y > this.yaxis.max) continue; this.context.beginPath(); this.context.arc(this.translateHoz(x), this.translateVert(y) + offset, radius, 0, Math.PI, false); this.context.stroke(); } }, /** * function: drawGraphPoints * * paramters: * {Object} graphData * * description: * Draws the point graph onto the canvas. This function depends on helper * functions and */ drawGraphPoints: function(graphData) { this.context.save(); this.context.translate(this.chartOffset.left, this.chartOffset.top); var lw = graphData.lines.lineWidth; var sw = graphData.shadowSize; if (sw > 0) { // draw shadow in two steps this.context.lineWidth = sw / 2; this.context.strokeStyle = "rgba(0,0,0,0.1)"; this.plotPointShadows(graphData.data, sw/2 + this.context.lineWidth/2, graphData.points.radius); this.context.lineWidth = sw / 2; this.context.strokeStyle = "rgba(0,0,0,0.2)"; this.plotPointShadows(graphData.data, this.context.lineWidth/2, graphData.points.radius); } this.context.lineWidth = graphData.points.lineWidth; this.context.strokeStyle = graphData.color; this.context.fillStyle = graphData.points.fillColor != null ? graphData.points.fillColor : graphData.color; this.plotPoints(graphData.data, graphData.points.radius, graphData.points.fill); this.context.restore(); }, /** * function: preparePieData * * parameters: * {Object} graphData * * Description: * Helper function that manipulates the given data stream so that it can * be plotted as a Pie Chart */ preparePieData: function(graphData) { for(i = 0; i < graphData.length; i++) { var data = 0; for(j = 0; j < graphData[i].data.length; j++){ data += parseInt(graphData[i].data[j][1]); } graphData[i].data = data; } }, /** * function: drawPieShadow * * {Object} anchorX * {Object} anchorY * {Object} radius * * description: * Helper function that draws a shadow for the Pie Chart. This just draws * a circle with offset that simulates shadow. We do not give each piece * of the pie an individual shadow. */ drawPieShadow: function(anchorX, anchorY, radius) { this.context.beginPath(); this.context.moveTo(anchorX, anchorY); this.context.fillStyle = 'rgba(0,0,0,' + 0.1 + ')'; startAngle = 0; endAngle = (Math.PI/180)*360; this.context.arc(anchorX + 2, anchorY +2, radius + (this.options.shadowSize/2), startAngle, endAngle, false); this.context.fill(); this.context.closePath(); }, /** * function: drawPieGraph * * parameters: * {Object} graphData * * description: * Draws the actual pie chart. This function depends on helper function * to draw the actual shadow */ drawPieGraph: function(graphData) { var sumData = 0; var radius = 0; var centerX = this.chartWidth/2; var centerY = this.chartHeight/2; var startAngle = 0; var endAngle = 0; var fontSize = this.options.pies.fontSize; var labelWidth = this.options.pies.labelWidth; //determine Pie Radius if(!this.options.pies.autoScale) radius = this.options.pies.radius; else radius = (this.chartHeight * 0.85)/2; var labelRadius = radius * 1.05; for(i = 0; i < graphData.length; i++) sumData += graphData[i].data; // used to adjust labels so that everything adds up to 100% totalPct = 0; //lets draw the shadow first.. we don't need an individual shadow to every pie rather we just //draw a circle underneath to simulate the shadow... this.drawPieShadow(centerX, centerY, radius, 0, 0); //lets draw the actual pie chart now. graphData.each(function(gd, j){ var pct = gd.data / sumData; startAngle = endAngle; endAngle += pct * (2 * Math.PI); var sliceMiddle = (endAngle - startAngle) / 2 + startAngle; var labelX = centerX + Math.cos(sliceMiddle) * labelRadius; var labelY = centerY + Math.sin(sliceMiddle) * labelRadius; var anchorX = centerX; var anchorY = centerY; var textAlign = null; var verticalAlign = null; var left = 0; var top = 0; //draw pie: //drawing pie this.context.beginPath(); this.context.moveTo(anchorX, anchorY); this.context.arc(anchorX, anchorY, radius, startAngle, endAngle, false); this.context.closePath(); this.context.fillStyle = this.parseColor(gd.color).scale(null, null, null, this.options.pies.fillOpacity).toString(); if(this.options.pies.fill) { this.context.fill(); } // drawing labels if (sliceMiddle <= 0.25 * (2 * Math.PI)) { // text on top and align left textAlign = "left"; verticalAlign = "top"; left = labelX; top = labelY + fontSize; } else if (sliceMiddle > 0.25 * (2 * Math.PI) && sliceMiddle <= 0.5 * (2 * Math.PI)) { // text on bottom and align left textAlign = "left"; verticalAlign = "bottom"; left = labelX - labelWidth; top = labelY; } else if (sliceMiddle > 0.5 * (2 * Math.PI) && sliceMiddle <= 0.75 * (2 * Math.PI)) { // text on bottom and align right textAlign = "right"; verticalAlign = "bottom"; left = labelX - labelWidth; top = labelY - fontSize; } else { // text on top and align right textAlign = "right"; verticalAlign = "bottom"; left = labelX; top = labelY - fontSize; } left = left + "px"; top = top + "px"; var textVal = Math.round(pct * 100); if (j == graphData.length - 1) { if (textVal + totalPct < 100) { textVal = textVal + 1; } else if (textVal + totalPct > 100) { textVal = textVal - 1; }; } var html = "
" + textVal + "%
"; //$(html).appendTo(target); this.domObj.insert(html); totalPct = totalPct + textVal; }.bind(this)); }, /** * function: drawBarGraph * * parameters: * {Object} graphData * {Object} barDataRange * * description: * Goes through each series in graphdata and passes it onto function */ drawBarGraph: function(graphData, barDataRange) { graphData.each(function(gd, i){ this.drawGraphBars(gd, i, graphData.size(), barDataRange); }.bind(this)); }, /** * function: drawGraphBar * * parameters: * {Object} graphData * * description: * This function is called when an individual series in GraphData is bar graph and plots it */ drawGraphBar: function(graphData) { this.drawGraphBars(graphData, 0, this.graphData.length, this.barDataRange); }, /** * function: plotBars * * parameters: * {Object} graphData * {Object} data * {Object} barWidth * {Object} offset * {Object} fill * {Object} counter * {Object} total * {Object} barDataRange * * description: * Helper function that draws the bar graph based on data. */ plotBars: function(graphData, data, barWidth, offset, fill,counter, total, barDataRange) { var shift = 0; if(total % 2 == 0) { shift = (1 + ((counter - total /2 ) - 1)) * barWidth; } else { var interval = 0.5; if(counter == (total/2 - interval )) { shift = - barWidth * interval; } else { shift = (interval + (counter - Math.round(total/2))) * barWidth; } } var rangeData = []; data.each(function(d){ if(!d) return; var x = d[0], y = d[1]; var drawLeft = true, drawTop = true, drawRight = true; var left = x + shift, right = x + barWidth + shift, bottom = 0, top = y; var rangeDataPoint = {}; rangeDataPoint.left = left; rangeDataPoint.right = right; rangeDataPoint.value = top; rangeData.push(rangeDataPoint); if (right < this.xaxis.min || left > this.xaxis.max || top < this.yaxis.min || bottom > this.yaxis.max) return; // clip if (left < this.xaxis.min) { left = this.xaxis.min; drawLeft = false; } if (right > this.xaxis.max) { right = this.xaxis.max; drawRight = false; } if (bottom < this.yaxis.min) bottom = this.yaxis.min; if (top > this.yaxis.max) { top = this.yaxis.max; drawTop = false; } if(graphData.bars.showShadow && graphData.shadowSize > 0) this.plotShadowOutline(graphData, this.context.strokeStyle, left, bottom, top, right, drawLeft, drawRight, drawTop); // fill the bar if (fill) { this.context.beginPath(); this.context.moveTo(this.translateHoz(left), this.translateVert(bottom) + offset); this.context.lineTo(this.translateHoz(left), this.translateVert(top) + offset); this.context.lineTo(this.translateHoz(right), this.translateVert(top) + offset); this.context.lineTo(this.translateHoz(right), this.translateVert(bottom) + offset); this.context.fill(); } // draw outline if (drawLeft || drawRight || drawTop) { this.context.beginPath(); this.context.moveTo(this.translateHoz(left), this.translateVert(bottom) + offset); if (drawLeft) this.context.lineTo(this.translateHoz(left), this.translateVert(top) + offset); else this.context.moveTo(this.translateHoz(left), this.translateVert(top) + offset); if (drawTop) this.context.lineTo(this.translateHoz(right), this.translateVert(top) + offset); else this.context.moveTo(this.translateHoz(right), this.translateVert(top) + offset); if (drawRight) this.context.lineTo(this.translateHoz(right), this.translateVert(bottom) + offset); else this.context.moveTo(this.translateHoz(right), this.translateVert(bottom) + offset); this.context.stroke(); } }.bind(this)); barDataRange.push(rangeData); }, /** * function: plotShadowOutline * * parameters: * {Object} graphData * {Object} orgStrokeStyle * {Object} left * {Object} bottom * {Object} top * {Object} right * {Object} drawLeft * {Object} drawRight * {Object} drawTop * * description: * Helper function that draws a outline simulating shadow for bar chart */ plotShadowOutline: function(graphData, orgStrokeStyle, left, bottom, top, right, drawLeft, drawRight, drawTop) { var orgOpac = 0.3; for(var n = 1; n <= this.options.shadowSize/2; n++) { var opac = orgOpac * n; this.context.beginPath(); this.context.strokeStyle = "rgba(0,0,0," + opac + ")"; this.context.moveTo(this.translateHoz(left) + n, this.translateVert(bottom)); if(drawLeft) this.context.lineTo(this.translateHoz(left) + n, this.translateVert(top) - n); else this.context.moveTo(this.translateHoz(left) + n, this.translateVert(top) - n); if(drawTop) this.context.lineTo(this.translateHoz(right) + n, this.translateVert(top) - n); else this.context.moveTo(this.translateHoz(right) + n, this.translateVert(top) - n); if(drawRight) this.context.lineTo(this.translateHoz(right) + n, this.translateVert(bottom)); else this.context.lineTo(this.translateHoz(right) + n, this.translateVert(bottom)); this.context.stroke(); this.context.closePath(); } this.context.strokeStyle = orgStrokeStyle; }, /** * function: drawGraphBars * * parameters: * {Object} graphData * {Object} counter * {Object} total * {Object} barDataRange * * description: * Draws the actual bar graphs. Calls to draw the individual bar */ drawGraphBars: function(graphData, counter, total, barDataRange){ this.context.save(); this.context.translate(this.chartOffset.left, this.chartOffset.top); this.context.lineJoin = "round"; var bw = graphData.bars.barWidth; var lw = Math.min(graphData.bars.lineWidth, bw); this.context.lineWidth = lw; this.context.strokeStyle = graphData.color; if (graphData.bars.fill) { this.context.fillStyle = graphData.bars.fillColor != null ? graphData.bars.fillColor : this.parseColor(graphData.color).scale(null, null, null, this.options.bars.fillOpacity).toString(); } this.plotBars(graphData, graphData.data, bw, 0, graphData.bars.fill, counter, total, barDataRange); this.context.restore(); }, /** * function: insertLegend * * description: * inserts legend onto the graph. *legend: {show: true}* must be set in * for for this to work. */ insertLegend: function() { this.domObj.select(".legend").invoke('remove'); if (!this.options.legend.show) return; var fragments = []; var rowStarted = false; this.graphData.each(function(gd, index){ if(!gd.label) { return; } if(index % this.options.legend.noColumns == 0) { if(rowStarted) { fragments.push(''); } fragments.push(''); rowStarted = true; } var label = gd.label; if(this.options.legend.labelFormatter != null) { label = this.options.legend.labelFormatter(label); } fragments.push( '
' + '' + label + ''); }.bind(this)); if (rowStarted) fragments.push(''); if(fragments.length > 0){ var table = '' + fragments.join("") + '
'; if($(this.options.legend.container) != null){ $(this.options.legend.container).insert(table); }else{ var pos = ''; var p = this.options.legend.position, m = this.options.legend.margin; if(p.charAt(0) == 'n') pos += 'top:' + (m + this.chartOffset.top) + 'px;'; else if(p.charAt(0) == 's') pos += 'bottom:' + (m + this.chartOffset.bottom) + 'px;'; if(p.charAt(1) == 'e') pos += 'right:' + (m + this.chartOffset.right) + 'px;'; else if(p.charAt(1) == 'w') pos += 'left:' + (m + this.chartOffset.bottom) + 'px;'; var div = this.domObj.insert('
' + table + '
').getElementsBySelector('div.ProtoChart-legend').first(); if(this.options.legend.backgroundOpacity != 0.0){ var c = this.options.legend.backgroundColor; if(c == null){ var tmp = (this.options.grid.backgroundColor != null) ? this.options.grid.backgroundColor : this.extractColor(div); c = this.parseColor(tmp).adjust(null, null, null, 1).toString(); } this.domObj.insert('
').select('div.ProtoChart-legend-bg').first().setStyle({ 'opacity': this.options.legend.backgroundOpacity }); } } } }, /** * Function: onMouseMove * * parameters: * event: {Object} ev * * Description: * Called whenever the mouse is moved on the graph. This takes care of the mousetracking. * This event also fires event, which gets current position of the * mouse as a parameters. */ onMouseMove: function(ev) { var e = ev || window.event; if (e.pageX == null && e.clientX != null) { var de = document.documentElement, b = $(document.body); this.lastMousePos.pageX = e.clientX + (de && de.scrollLeft || b.scrollLeft || 0); this.lastMousePos.pageY = e.clientY + (de && de.scrollTop || b.scrollTop || 0); } else { this.lastMousePos.pageX = e.pageX; this.lastMousePos.pageY = e.pageY; } var offset = this.overlay.cumulativeOffset(); var pos = { x: this.xaxis.min + (e.pageX - offset.left - this.chartOffset.left) / this.hozScale, y: this.yaxis.max - (e.pageY - offset.top - this.chartOffset.top) / this.vertScale }; if(this.options.mouse.track && this.selectionInterval == null) { this.hit(ev, pos); } this.domObj.fire("ProtoChart:mousemove", [ pos ]); }, /** * Function: onMouseDown * * Parameters: * Event - {Object} e * * Description: * Called whenever the mouse is clicked. */ onMouseDown: function(e) { if (e.which != 1) // only accept left-click return; document.body.focus(); if (document.onselectstart !== undefined && this.workarounds.onselectstart == null) { this.workarounds.onselectstart = document.onselectstart; document.onselectstart = function () { return false; }; } if (document.ondrag !== undefined && this.workarounds.ondrag == null) { this.workarounds.ondrag = document.ondrag; document.ondrag = function () { return false; }; } this.setSelectionPos(this.selection.first, e); if (this.selectionInterval != null) clearInterval(this.selectionInterval); this.lastMousePos.pageX = null; this.selectionInterval = setInterval(this.updateSelectionOnMouseMove.bind(this), 200); this.overlay.observe("mouseup", this.onSelectionMouseUp.bind(this)); }, /** * Function: onClick * parameters: * Event - {Object} e * Description: * Handles the "click" event on the chart. This function fires event. If * is enabled then it also fires event which gives * you access to exact data point where user clicked. */ onClick: function(e) { if (this.ignoreClick) { this.ignoreClick = false; return; } var offset = this.overlay.cumulativeOffset(); var pos ={ x: this.xaxis.min + (e.pageX - offset.left - this.chartOffset.left) / this.hozScale, y: this.yaxis.max - (e.pageY - offset.top - this.chartOffset.top) / this.vertScale }; this.domObj.fire("ProtoChart:plotclick", [ pos ]); if(this.options.allowDataClick) { var dataPoint = {}; if(this.options.points.show) { dataPoint = this.getDataClickPoint(pos, this.options); this.domObj.fire("ProtoChart:dataclick", [dataPoint]); } else if(this.options.lines.show && this.options.points.show) { dataPoint = this.getDataClickPoint(pos, this.options); this.domObj.fire("ProtoChart:dataclick", [dataPoint]); } else if(this.options.bars.show) { if(this.barDataRange.length > 0) { dataPoint = this.getDataClickPoint(pos, this.options, this.barDataRange); this.domObj.fire("ProtoChart:dataclick", [dataPoint]); } } } }, /** * Internal function used by onClick method. */ getDataClickPoint: function(pos, options, barDataRange) { pos.x = parseInt(pos.x); pos.y = parseInt(pos.y); var yClick = pos.y.toFixed(0); var dataVal = {}; dataVal.position = pos; dataVal.value = ''; if(options.points.show) { this.graphData.each(function(gd){ var temp = gd.data; var xClick = parseInt(pos.x.toFixed(0)); if(xClick < 0) { xClick = 0; } if(temp[xClick] && yClick >= temp[xClick][1] - (this.options.points.radius * 10) && yClick <= temp[xClick][1] + (this.options.points.radius * 10)) { dataVal.value = temp[xClick][1]; throw $break; } }.bind(this)); } else if(options.bars.show) { xClick = pos.x; this.barDataRange.each(function(barData){ barData.each(function(data){ var temp = data; if(xClick > temp.left && xClick < temp.right) { dataVal.value = temp.value; throw $break; } }.bind(this)); }.bind(this)); } return dataVal; }, /** * Function: triggerSelectedEvent * * Description: * Internal function called when a selection on the graph is made. This function * fires event which has a parameter representing the selection * { * x1: {int}, y1: {int}, * x2: {int}, y2: {int} * } */ triggerSelectedEvent: function() { var x1, x2, y1, y2; if (this.selection.first.x <= this.selection.second.x) { x1 = this.selection.first.x; x2 = this.selection.second.x; } else { x1 = this.selection.second.x; x2 = this.selection.first.x; } if (this.selection.first.y >= this.selection.second.y) { y1 = this.selection.first.y; y2 = this.selection.second.y; } else { y1 = this.selection.second.y; y2 = this.selection.first.y; } x1 = this.xaxis.min + x1 / this.hozScale; x2 = this.xaxis.min + x2 / this.hozScale; y1 = this.yaxis.max - y1 / this.vertScale; y2 = this.yaxis.max - y2 / this.vertScale; this.domObj.fire("ProtoChart:selected", [ { x1: x1, y1: y1, x2: x2, y2: y2 } ]); }, /** * Internal function */ onSelectionMouseUp: function(e) { if (document.onselectstart !== undefined) document.onselectstart = this.workarounds.onselectstart; if (document.ondrag !== undefined) document.ondrag = this.workarounds.ondrag; if (this.selectionInterval != null) { clearInterval(this.selectionInterval); this.selectionInterval = null; } this.setSelectionPos(this.selection.second, e); this.clearSelection(); if (!this.selectionIsSane() || e.which != 1) return false; this.drawSelection(); this.triggerSelectedEvent(); this.ignoreClick = true; return false; }, setSelectionPos: function(pos, e) { var offset = $(this.overlay).cumulativeOffset(); if (this.options.selection.mode == "y") { if (pos == this.selection.first) pos.x = 0; else pos.x = this.chartWidth; } else { pos.x = e.pageX - offset.left - this.chartOffset.left; pos.x = Math.min(Math.max(0, pos.x), this.chartWidth); } if (this.options.selection.mode == "x") { if (pos == this.selection.first) pos.y = 0; else pos.y = this.chartHeight; } else { pos.y = e.pageY - offset.top - this.chartOffset.top; pos.y = Math.min(Math.max(0, pos.y), this.chartHeight); } }, updateSelectionOnMouseMove: function() { if (this.lastMousePos.pageX == null) return; this.setSelectionPos(this.selection.second, this.lastMousePos); this.clearSelection(); if (this.selectionIsSane()) this.drawSelection(); }, clearSelection: function() { if (this.prevSelection == null) return; var x = Math.min(this.prevSelection.first.x, this.prevSelection.second.x), y = Math.min(this.prevSelection.first.y, this.prevSelection.second.y), w = Math.abs(this.prevSelection.second.x - this.prevSelection.first.x), h = Math.abs(this.prevSelection.second.y - this.prevSelection.first.y); this.overlayContext.clearRect(x + this.chartOffset.left - this.overlayContext.lineWidth, y + this.chartOffset.top - this.overlayContext.lineWidth, w + this.overlayContext.lineWidth*2, h + this.overlayContext.lineWidth*2); this.prevSelection = null; }, /** * Function: setSelection * * Parameters: * Area - {Object} area represented as a range like: {x1: 3, y1: 3, x2: 4, y2: 8} * * Description: * Sets the current graph selection to the provided range. Calls and * functions internally. */ setSelection: function(area) { this.clearSelection(); if (this.options.selection.mode == "x") { this.selection.first.y = 0; this.selection.second.y = this.chartHeight; } else { this.selection.first.y = (this.yaxis.max - area.y1) * this.vertScale; this.selection.second.y = (this.yaxis.max - area.y2) * this.vertScale; } if (this.options.selection.mode == "y") { this.selection.first.x = 0; this.selection.second.x = this.chartWidth; } else { this.selection.first.x = (area.x1 - this.xaxis.min) * this.hozScale; this.selection.second.x = (area.x2 - this.xaxis.min) * this.hozScale; } this.drawSelection(); this.triggerSelectedEvent(); }, /** * Function: drawSelection * Description: Internal function called to draw the selection made on the graph. */ drawSelection: function() { if (this.prevSelection != null && this.selection.first.x == this.prevSelection.first.x && this.selection.first.y == this.prevSelection.first.y && this.selection.second.x == this.prevSelection.second.x && this.selection.second.y == this.prevSelection.second.y) { return; } this.overlayContext.strokeStyle = this.parseColor(this.options.selection.color).scale(null, null, null, 0.8).toString(); this.overlayContext.lineWidth = 1; this.context.lineJoin = "round"; this.overlayContext.fillStyle = this.parseColor(this.options.selection.color).scale(null, null, null, 0.4).toString(); this.prevSelection = { first: { x: this.selection.first.x, y: this.selection.first.y }, second: { x: this.selection.second.x, y: this.selection.second.y } }; var x = Math.min(this.selection.first.x, this.selection.second.x), y = Math.min(this.selection.first.y, this.selection.second.y), w = Math.abs(this.selection.second.x - this.selection.first.x), h = Math.abs(this.selection.second.y - this.selection.first.y); this.overlayContext.fillRect(x + this.chartOffset.left, y + this.chartOffset.top, w, h); this.overlayContext.strokeRect(x + this.chartOffset.left, y + this.chartOffset.top, w, h); }, /** * Internal function */ selectionIsSane: function() { var minSize = 5; return Math.abs(this.selection.second.x - this.selection.first.x) >= minSize && Math.abs(this.selection.second.y - this.selection.first.y) >= minSize; }, /** * Internal function that formats the track. This is the format the text is shown when mouse * tracking is enabled. */ defaultTrackFormatter: function(val) { return '['+val.x+', '+val.y+']'; }, /** * Function: clearHit */ clearHit: function(){ if(this.prevHit){ this.overlayContext.clearRect( this.translateHoz(this.prevHit.x) + this.chartOffset.left - this.options.mouse.radius*2, this.translateVert(this.prevHit.y) + this.chartOffset.top - this.options.mouse.radius*2, this.options.mouse.radius*3 + this.options.points.lineWidth*3, this.options.mouse.radius*3 + this.options.points.lineWidth*3 ); this.prevHit = null; } }, /** * Function: hit * * Parameters: * event - {Object} event object * mouse - {Object} mouse object that is used to keep track of mouse movement * * Description: * If hit occurs this function will fire a ProtoChart:hit event. */ hit: function(event, mouse){ /** * Nearest data element. */ var n = { dist:Number.MAX_VALUE, x:null, y:null, mouse:null }; for(var i = 0, data, xsens, ysens; i < this.graphData.length; i++){ if(!this.graphData[i].mouse.track) continue; data = this.graphData[i].data; xsens = (this.hozScale*this.graphData[i].mouse.sensibility); ysens = (this.vertScale*this.graphData[i].mouse.sensibility); for(var j = 0, xabs, yabs; j < data.length; j++){ xabs = this.hozScale*Math.abs(data[j][0] - mouse.x); yabs = this.vertScale*Math.abs(data[j][1] - mouse.y); if(xabs < xsens && yabs < ysens && (xabs+yabs) < n.dist){ n.dist = (xabs+yabs); n.x = data[j][0]; n.y = data[j][1]; n.mouse = this.graphData[i].mouse; } } } if(n.mouse && n.mouse.track && !this.prevHit || (this.prevHit && n.x != this.prevHit.x && n.y != this.prevHit.y)){ var el = this.domObj.select('.'+this.options.mouse.clsName).first(); if(!el){ var pos = '', p = this.options.mouse.position, m = this.options.mouse.margin; if(p.charAt(0) == 'n') pos += 'top:' + (m + this.chartOffset.top) + 'px;'; else if(p.charAt(0) == 's') pos += 'bottom:' + (m + this.chartOffset.bottom) + 'px;'; if(p.charAt(1) == 'e') pos += 'right:' + (m + this.chartOffset.right) + 'px;'; else if(p.charAt(1) == 'w') pos += 'left:' + (m + this.chartOffset.bottom) + 'px;'; this.domObj.insert(''); return; } if(n.x !== null && n.y !== null){ el.setStyle({display:'block'}); this.clearHit(); if(n.mouse.lineColor != null){ this.overlayContext.save(); this.overlayContext.translate(this.chartOffset.left, this.chartOffset.top); this.overlayContext.lineWidth = this.options.points.lineWidth; this.overlayContext.strokeStyle = n.mouse.lineColor; this.overlayContext.fillStyle = '#ffffff'; this.overlayContext.beginPath(); this.overlayContext.arc(this.translateHoz(n.x), this.translateVert(n.y), this.options.mouse.radius, 0, 2 * Math.PI, true); this.overlayContext.fill(); this.overlayContext.stroke(); this.overlayContext.restore(); } this.prevHit = n; var decimals = n.mouse.trackDecimals; if(decimals == null || decimals < 0) decimals = 0; if(!this.options.mouse.fixedPosition) { el.setStyle({ left: (this.translateHoz(n.x) + this.options.mouse.radius + 10) + "px", top: (this.translateVert(n.y) + this.options.mouse.radius + 10) + "px" }); } el.innerHTML = n.mouse.trackFormatter({x: n.x.toFixed(decimals), y: n.y.toFixed(decimals)}); this.domObj.fire( 'ProtoChart:hit', [n] ) }else if(this.options.prevHit){ el.setStyle({display:'none'}); this.clearHit(); } } }, /** * Internal function */ floorInBase: function(n, base) { return base * Math.floor(n / base); }, /** * Function: extractColor * * Parameters: * element - HTML element or ID of an HTML element * * Returns: * color in string format */ extractColor: function(element) { var color; do { color = $(element).getStyle('background-color').toLowerCase(); if(color != '' && color != 'transparent') { break; } element = element.up(0); //or else just get the parent .... } while(element.nodeName.toLowerCase() != 'body'); //safari fix if(color == 'rgba(0, 0, 0, 0)') return 'transparent'; return color; }, /** * Function: parseColor * * Parameters: * str - color string in different formats * * Returns: * a Proto.Color Object - use toString() function to retreive the color in rgba/rgb format */ parseColor: function(str) { var result; /** * rgb(num,num,num) */ if((result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))) return new Proto.Color(parseInt(result[1]), parseInt(result[2]), parseInt(result[3])); /** * rgba(num,num,num,num) */ if((result = /rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))) return new Proto.Color(parseInt(result[1]), parseInt(result[2]), parseInt(result[3]), parseFloat(result[4])); /** * rgb(num%,num%,num%) */ if((result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))) return new Proto.Color(parseFloat(result[1])*2.55, parseFloat(result[2])*2.55, parseFloat(result[3])*2.55); /** * rgba(num%,num%,num%,num) */ if((result = /rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))) return new Proto.Color(parseFloat(result[1])*2.55, parseFloat(result[2])*2.55, parseFloat(result[3])*2.55, parseFloat(result[4])); /** * #a0b1c2 */ if((result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))) return new Proto.Color(parseInt(result[1],16), parseInt(result[2],16), parseInt(result[3],16)); /** * #fff */ if((result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))) return new Proto.Color(parseInt(result[1]+result[1],16), parseInt(result[2]+result[2],16), parseInt(result[3]+result[3],16)); /** * Otherwise, check if user wants transparent .. or we just return a standard color; */ var name = str.strip().toLowerCase(); if(name == 'transparent'){ return new Proto.Color(255, 255, 255, 0); } return new Proto.Color(100,100,100, 1); } }); if(!Proto) var Proto = {}; /** * Class: Proto.Color * * Helper class that manipulates colors using RGBA values. * */ Proto.Color = Class.create({ initialize: function(r, g, b, a) { this.rgba = ['r', 'g', 'b', 'a']; var x = 4; while(-1<--x) { this[this.rgba[x]] = arguments[x] || ((x==3) ? 1.0 : 0); } }, toString: function() { if(this.a >= 1.0) { return "rgb(" + [this.r, this.g, this.b].join(",") +")"; } else { return "rgba("+[this.r, this.g, this.b, this.a].join(",")+")"; } }, scale: function(rf, gf, bf, af) { x = 4; while(-1<--x) { if(arguments[x] != null) { this[this.rgba[x]] *= arguments[x]; } } return this.normalize(); }, adjust: function(rd, gd, bd, ad) { x = 4; //rgba.length while (-1<--x) { if (arguments[x] != null) this[this.rgba[x]] += arguments[x]; } return this.normalize(); }, clone: function() { return new Proto.Color(this.r, this.b, this.g, this.a); }, limit: function(val,minVal,maxVal) { return Math.max(Math.min(val, maxVal), minVal); }, normalize: function() { this.r = this.limit(parseInt(this.r), 0, 255); this.g = this.limit(parseInt(this.g), 0, 255); this.b = this.limit(parseInt(this.b), 0, 255); this.a = this.limit(this.a, 0, 1); return this; } });