| /* |
| Flot plugin for rendering pie charts. The plugin assumes the data is |
| coming is as a single data value for each series, and each of those |
| values is a positive value or zero (negative numbers don't make |
| any sense and will cause strange effects). The data values do |
| NOT need to be passed in as percentage values because it |
| internally calculates the total and percentages. |
| |
| * Created by Brian Medendorp, June 2009 |
| * Updated November 2009 with contributions from: btburnett3, Anthony Aragues and Xavi Ivars |
| |
| * Changes: |
| 2009-10-22: lineJoin set to round |
| 2009-10-23: IE full circle fix, donut |
| 2009-11-11: Added basic hover from btburnett3 - does not work in IE, and center is off in Chrome and Opera |
| 2009-11-17: Added IE hover capability submitted by Anthony Aragues |
| 2009-11-18: Added bug fix submitted by Xavi Ivars (issues with arrays when other JS libraries are included as well) |
| |
| |
| Available options are: |
| series: { |
| pie: { |
| show: true/false |
| radius: 0-1 for percentage of fullsize, or a specified pixel length, or 'auto' |
| innerRadius: 0-1 for percentage of fullsize or a specified pixel length, for creating a donut effect |
| startAngle: 0-2 factor of PI used for starting angle (in radians) i.e 3/2 starts at the top, 0 and 2 have the same result |
| tilt: 0-1 for percentage to tilt the pie, where 1 is no tilt, and 0 is completely flat (nothing will show) |
| offset: { |
| top: integer value to move the pie up or down |
| left: integer value to move the pie left or right, or 'auto' |
| }, |
| stroke: { |
| color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#FFF') |
| width: integer pixel width of the stroke |
| }, |
| label: { |
| show: true/false, or 'auto' |
| formatter: a user-defined function that modifies the text/style of the label text |
| radius: 0-1 for percentage of fullsize, or a specified pixel length |
| background: { |
| color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#000') |
| opacity: 0-1 |
| }, |
| threshold: 0-1 for the percentage value at which to hide labels (if they're too small) |
| }, |
| combine: { |
| threshold: 0-1 for the percentage value at which to combine slices (if they're too small) |
| color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#CCC'), if null, the plugin will automatically use the color of the first slice to be combined |
| label: any text value of what the combined slice should be labeled |
| } |
| highlight: { |
| opacity: 0-1 |
| } |
| } |
| } |
| |
| More detail and specific examples can be found in the included HTML file. |
| |
| */ |
| |
| (function ($) |
| { |
| function init(plot) // this is the "body" of the plugin |
| { |
| var canvas = null; |
| var target = null; |
| var maxRadius = null; |
| var centerLeft = null; |
| var centerTop = null; |
| var total = 0; |
| var redraw = true; |
| var redrawAttempts = 10; |
| var shrink = 0.95; |
| var legendWidth = 0; |
| var processed = false; |
| var raw = false; |
| |
| // interactive variables |
| var highlights = []; |
| |
| // add hook to determine if pie plugin in enabled, and then perform necessary operations |
| plot.hooks.processOptions.push(checkPieEnabled); |
| plot.hooks.bindEvents.push(bindEvents); |
| |
| // check to see if the pie plugin is enabled |
| function checkPieEnabled(plot, options) |
| { |
| if (options.series.pie.show) |
| { |
| //disable grid |
| options.grid.show = false; |
| |
| // set labels.show |
| if (options.series.pie.label.show=='auto') |
| if (options.legend.show) |
| options.series.pie.label.show = false; |
| else |
| options.series.pie.label.show = true; |
| |
| // set radius |
| if (options.series.pie.radius=='auto') |
| if (options.series.pie.label.show) |
| options.series.pie.radius = 3/4; |
| else |
| options.series.pie.radius = 1; |
| |
| // ensure sane tilt |
| if (options.series.pie.tilt>1) |
| options.series.pie.tilt=1; |
| if (options.series.pie.tilt<0) |
| options.series.pie.tilt=0; |
| |
| // add processData hook to do transformations on the data |
| plot.hooks.processDatapoints.push(processDatapoints); |
| plot.hooks.drawOverlay.push(drawOverlay); |
| |
| // add draw hook |
| plot.hooks.draw.push(draw); |
| } |
| } |
| |
| // bind hoverable events |
| function bindEvents(plot, eventHolder) |
| { |
| var options = plot.getOptions(); |
| |
| if (options.series.pie.show && options.grid.hoverable) |
| eventHolder.unbind('mousemove').mousemove(onMouseMove); |
| |
| if (options.series.pie.show && options.grid.clickable) |
| eventHolder.unbind('click').click(onClick); |
| } |
| |
| |
| // debugging function that prints out an object |
| function alertObject(obj) |
| { |
| var msg = ''; |
| function traverse(obj, depth) |
| { |
| if (!depth) |
| depth = 0; |
| for (var i = 0; i < obj.length; ++i) |
| { |
| for (var j=0; j<depth; j++) |
| msg += '\t'; |
| |
| if( typeof obj[i] == "object") |
| { // its an object |
| msg += ''+i+':\n'; |
| traverse(obj[i], depth+1); |
| } |
| else |
| { // its a value |
| msg += ''+i+': '+obj[i]+'\n'; |
| } |
| } |
| } |
| traverse(obj); |
| alert(msg); |
| } |
| |
| function calcTotal(data) |
| { |
| for (var i = 0; i < data.length; ++i) |
| { |
| var item = parseFloat(data[i].data[0][1]); |
| if (item) |
| total += item; |
| } |
| } |
| |
| function processDatapoints(plot, series, data, datapoints) |
| { |
| if (!processed) |
| { |
| processed = true; |
| |
| canvas = plot.getCanvas(); |
| target = $(canvas).parent(); |
| options = plot.getOptions(); |
| |
| plot.setData(combine(plot.getData())); |
| } |
| } |
| |
| function setupPie() |
| { |
| legendWidth = target.children().filter('.legend').children().width(); |
| |
| // calculate maximum radius and center point |
| maxRadius = Math.min(canvas.width,(canvas.height/options.series.pie.tilt))/2; |
| centerTop = (canvas.height/2)+options.series.pie.offset.top; |
| centerLeft = (canvas.width/2); |
| |
| if (options.series.pie.offset.left=='auto') |
| if (options.legend.position.match('w')) |
| centerLeft += legendWidth/2; |
| else |
| centerLeft -= legendWidth/2; |
| else |
| centerLeft += options.series.pie.offset.left; |
| |
| if (centerLeft<maxRadius) |
| centerLeft = maxRadius; |
| else if (centerLeft>canvas.width-maxRadius) |
| centerLeft = canvas.width-maxRadius; |
| } |
| |
| function fixData(data) |
| { |
| for (var i = 0; i < data.length; ++i) |
| { |
| if (typeof(data[i].data)=='number') |
| data[i].data = [[1,data[i].data]]; |
| else if (typeof(data[i].data)=='undefined' || typeof(data[i].data[0])=='undefined') |
| { |
| if (typeof(data[i].data)!='undefined' && typeof(data[i].data.label)!='undefined') |
| data[i].label = data[i].data.label; // fix weirdness coming from flot |
| data[i].data = [[1,0]]; |
| |
| } |
| } |
| return data; |
| } |
| |
| function combine(data) |
| { |
| data = fixData(data); |
| calcTotal(data); |
| var combined = 0; |
| var numCombined = 0; |
| var color = options.series.pie.combine.color; |
| |
| var newdata = []; |
| for (var i = 0; i < data.length; ++i) |
| { |
| // make sure its a number |
| data[i].data[0][1] = parseFloat(data[i].data[0][1]); |
| if (!data[i].data[0][1]) |
| data[i].data[0][1] = 0; |
| |
| if (data[i].data[0][1]/total<=options.series.pie.combine.threshold) |
| { |
| combined += data[i].data[0][1]; |
| numCombined++; |
| if (!color) |
| color = data[i].color; |
| } |
| else |
| { |
| newdata.push({ |
| data: [[1,data[i].data[0][1]]], |
| color: data[i].color, |
| label: data[i].label, |
| angle: (data[i].data[0][1]*(Math.PI*2))/total, |
| percent: (data[i].data[0][1]/total*100) |
| }); |
| } |
| } |
| if (numCombined>0) |
| newdata.push({ |
| data: [[1,combined]], |
| color: color, |
| label: options.series.pie.combine.label, |
| angle: (combined*(Math.PI*2))/total, |
| percent: (combined/total*100) |
| }); |
| return newdata; |
| } |
| |
| function draw(plot, newCtx) |
| { |
| if (!target) return; // if no series were passed |
| ctx = newCtx; |
| |
| setupPie(); |
| var slices = plot.getData(); |
| |
| var attempts = 0; |
| while (redraw && attempts<redrawAttempts) |
| { |
| redraw = false; |
| if (attempts>0) |
| maxRadius *= shrink; |
| attempts += 1; |
| clear(); |
| if (options.series.pie.tilt<=0.8) |
| drawShadow(); |
| drawPie(); |
| } |
| if (attempts >= redrawAttempts) { |
| clear(); |
| target.prepend('<div class="error">Could not draw pie with labels contained inside canvas</div>'); |
| } |
| |
| if ( plot.setSeries && plot.insertLegend ) |
| { |
| plot.setSeries(slices); |
| plot.insertLegend(); |
| } |
| |
| // we're actually done at this point, just defining internal functions at this point |
| |
| function clear() |
| { |
| ctx.clearRect(0,0,canvas.width,canvas.height); |
| target.children().filter('.pieLabel, .pieLabelBackground').remove(); |
| } |
| |
| function drawShadow() |
| { |
| var shadowLeft = 5; |
| var shadowTop = 15; |
| var edge = 10; |
| var alpha = 0.02; |
| |
| // set radius |
| if (options.series.pie.radius>1) |
| var radius = options.series.pie.radius; |
| else |
| var radius = maxRadius * options.series.pie.radius; |
| |
| if (radius>=(canvas.width/2)-shadowLeft || radius*options.series.pie.tilt>=(canvas.height/2)-shadowTop || radius<=edge) |
| return; // shadow would be outside canvas, so don't draw it |
| |
| ctx.save(); |
| ctx.translate(shadowLeft,shadowTop); |
| ctx.globalAlpha = alpha; |
| ctx.fillStyle = '#000'; |
| |
| // center and rotate to starting position |
| ctx.translate(centerLeft,centerTop); |
| ctx.scale(1, options.series.pie.tilt); |
| |
| //radius -= edge; |
| for (var i=1; i<=edge; i++) |
| { |
| ctx.beginPath(); |
| ctx.arc(0,0,radius,0,Math.PI*2,false); |
| ctx.fill(); |
| radius -= i; |
| } |
| |
| ctx.restore(); |
| } |
| |
| function drawPie() |
| { |
| startAngle = Math.PI*options.series.pie.startAngle; |
| |
| // set radius |
| if (options.series.pie.radius>1) |
| var radius = options.series.pie.radius; |
| else |
| var radius = maxRadius * options.series.pie.radius; |
| |
| // center and rotate to starting position |
| ctx.save(); |
| ctx.translate(centerLeft,centerTop); |
| ctx.scale(1, options.series.pie.tilt); |
| //ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera |
| |
| // draw slices |
| ctx.save(); |
| var currentAngle = startAngle; |
| for (var i = 0; i < slices.length; ++i) |
| { |
| slices[i].startAngle = currentAngle; |
| drawSlice(slices[i].angle, slices[i].color, true); |
| } |
| ctx.restore(); |
| |
| // draw slice outlines |
| ctx.save(); |
| ctx.lineWidth = options.series.pie.stroke.width; |
| currentAngle = startAngle; |
| for (var i = 0; i < slices.length; ++i) |
| drawSlice(slices[i].angle, options.series.pie.stroke.color, false); |
| ctx.restore(); |
| |
| // draw donut hole |
| drawDonutHole(ctx); |
| |
| // draw labels |
| if (options.series.pie.label.show) |
| drawLabels(); |
| |
| // restore to original state |
| ctx.restore(); |
| |
| function drawSlice(angle, color, fill) |
| { |
| if (angle<=0) |
| return; |
| |
| if (fill) |
| ctx.fillStyle = color; |
| else |
| { |
| ctx.strokeStyle = color; |
| ctx.lineJoin = 'round'; |
| } |
| |
| ctx.beginPath(); |
| if (Math.abs(angle - Math.PI*2) > 0.000000001) |
| ctx.moveTo(0,0); // Center of the pie |
| else if ($.browser.msie) |
| angle -= 0.0001; |
| //ctx.arc(0,0,radius,0,angle,false); // This doesn't work properly in Opera |
| ctx.arc(0,0,radius,currentAngle,currentAngle+angle,false); |
| ctx.closePath(); |
| //ctx.rotate(angle); // This doesn't work properly in Opera |
| currentAngle += angle; |
| |
| if (fill) |
| ctx.fill(); |
| else |
| ctx.stroke(); |
| } |
| |
| function drawLabels() |
| { |
| var currentAngle = startAngle; |
| |
| // set radius |
| if (options.series.pie.label.radius>1) |
| var radius = options.series.pie.label.radius; |
| else |
| var radius = maxRadius * options.series.pie.label.radius; |
| |
| for (var i = 0; i < slices.length; ++i) |
| { |
| if (slices[i].percent >= options.series.pie.label.threshold*100) |
| drawLabel(slices[i], currentAngle, i); |
| currentAngle += slices[i].angle; |
| } |
| |
| function drawLabel(slice, startAngle, index) |
| { |
| if (slice.data[0][1]==0) |
| return; |
| |
| // format label text |
| var lf = options.legend.labelFormatter, text, plf = options.series.pie.label.formatter; |
| if (lf) |
| text = lf(slice.label, slice); |
| else |
| text = slice.label; |
| if (plf) |
| text = plf(text, slice); |
| |
| var halfAngle = ((startAngle+slice.angle) + startAngle)/2; |
| var x = centerLeft + Math.round(Math.cos(halfAngle) * radius); |
| var y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt; |
| |
| var html = '<span class="pieLabel" id="pieLabel'+index+'" style="position:absolute;top:' + y + 'px;left:' + x + 'px;">' + text + "</span>"; |
| target.append(html); |
| var label = target.children('#pieLabel'+index); |
| var labelTop = (y - label.height()/2); |
| var labelLeft = (x - label.width()/2); |
| label.css('top', labelTop); |
| label.css('left', labelLeft); |
| |
| // check to make sure that the label is not outside the canvas |
| if (0-labelTop>0 || 0-labelLeft>0 || canvas.height-(labelTop+label.height())<0 || canvas.width-(labelLeft+label.width())<0) |
| redraw = true; |
| |
| if (options.series.pie.label.background.opacity != 0) { |
| // put in the transparent background separately to avoid blended labels and label boxes |
| var c = options.series.pie.label.background.color; |
| if (c == null) { |
| c = slice.color; |
| } |
| var pos = 'top:'+labelTop+'px;left:'+labelLeft+'px;'; |
| $('<div class="pieLabelBackground" style="position:absolute;width:' + label.width() + 'px;height:' + label.height() + 'px;' + pos +'background-color:' + c + ';"> </div>').insertBefore(label).css('opacity', options.series.pie.label.background.opacity); |
| } |
| } // end individual label function |
| } // end drawLabels function |
| } // end drawPie function |
| } // end draw function |
| |
| // Placed here because it needs to be accessed from multiple locations |
| function drawDonutHole(layer) |
| { |
| // draw donut hole |
| if(options.series.pie.innerRadius > 0) |
| { |
| // subtract the center |
| layer.save(); |
| innerRadius = options.series.pie.innerRadius > 1 ? options.series.pie.innerRadius : maxRadius * options.series.pie.innerRadius; |
| layer.globalCompositeOperation = 'destination-out'; // this does not work with excanvas, but it will fall back to using the stroke color |
| layer.beginPath(); |
| layer.fillStyle = options.series.pie.stroke.color; |
| layer.arc(0,0,innerRadius,0,Math.PI*2,false); |
| layer.fill(); |
| layer.closePath(); |
| layer.restore(); |
| |
| // add inner stroke |
| layer.save(); |
| layer.beginPath(); |
| layer.strokeStyle = options.series.pie.stroke.color; |
| layer.arc(0,0,innerRadius,0,Math.PI*2,false); |
| layer.stroke(); |
| layer.closePath(); |
| layer.restore(); |
| // TODO: add extra shadow inside hole (with a mask) if the pie is tilted. |
| } |
| } |
| |
| //-- Additional Interactive related functions -- |
| |
| function isPointInPoly(poly, pt) |
| { |
| for(var c = false, i = -1, l = poly.length, j = l - 1; ++i < l; j = i) |
| ((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || (poly[j][1] <= pt[1] && pt[1]< poly[i][1])) |
| && (pt[0] < (poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1]) / (poly[j][1] - poly[i][1]) + poly[i][0]) |
| && (c = !c); |
| return c; |
| } |
| |
| function findNearbySlice(mouseX, mouseY) |
| { |
| var slices = plot.getData(), |
| options = plot.getOptions(), |
| radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius; |
| |
| for (var i = 0; i < slices.length; ++i) |
| { |
| var s = slices[i]; |
| |
| if(s.pie.show) |
| { |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.moveTo(0,0); // Center of the pie |
| //ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here. |
| ctx.arc(0,0,radius,s.startAngle,s.startAngle+s.angle,false); |
| ctx.closePath(); |
| x = mouseX-centerLeft; |
| y = mouseY-centerTop; |
| if(ctx.isPointInPath) |
| { |
| if (ctx.isPointInPath(mouseX-centerLeft, mouseY-centerTop)) |
| { |
| //alert('found slice!'); |
| ctx.restore(); |
| return {datapoint: [s.percent, s.data], dataIndex: 0, series: s, seriesIndex: i}; |
| } |
| } |
| else |
| { |
| // excanvas for IE doesn;t support isPointInPath, this is a workaround. |
| p1X = (radius * Math.cos(s.startAngle)); |
| p1Y = (radius * Math.sin(s.startAngle)); |
| p2X = (radius * Math.cos(s.startAngle+(s.angle/4))); |
| p2Y = (radius * Math.sin(s.startAngle+(s.angle/4))); |
| p3X = (radius * Math.cos(s.startAngle+(s.angle/2))); |
| p3Y = (radius * Math.sin(s.startAngle+(s.angle/2))); |
| p4X = (radius * Math.cos(s.startAngle+(s.angle/1.5))); |
| p4Y = (radius * Math.sin(s.startAngle+(s.angle/1.5))); |
| p5X = (radius * Math.cos(s.startAngle+s.angle)); |
| p5Y = (radius * Math.sin(s.startAngle+s.angle)); |
| arrPoly = [[0,0],[p1X,p1Y],[p2X,p2Y],[p3X,p3Y],[p4X,p4Y],[p5X,p5Y]]; |
| arrPoint = [x,y]; |
| // TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt? |
| if(isPointInPoly(arrPoly, arrPoint)) |
| { |
| ctx.restore(); |
| return {datapoint: [s.percent, s.data], dataIndex: 0, series: s, seriesIndex: i}; |
| } |
| } |
| ctx.restore(); |
| } |
| } |
| |
| return null; |
| } |
| |
| function onMouseMove(e) |
| { |
| triggerClickHoverEvent('plothover', e); |
| } |
| |
| function onClick(e) |
| { |
| triggerClickHoverEvent('plotclick', e); |
| } |
| |
| // trigger click or hover event (they send the same parameters so we share their code) |
| function triggerClickHoverEvent(eventname, e) |
| { |
| var offset = plot.offset(), |
| canvasX = parseInt(e.pageX - offset.left), |
| canvasY = parseInt(e.pageY - offset.top), |
| item = findNearbySlice(canvasX, canvasY); |
| |
| if (options.grid.autoHighlight) |
| { |
| // clear auto-highlights |
| for (var i = 0; i < highlights.length; ++i) |
| { |
| var h = highlights[i]; |
| if (h.auto == eventname && !(item && h.series == item.series)) |
| unhighlight(h.series); |
| } |
| } |
| |
| // highlight the slice |
| if (item) |
| highlight(item.series, eventname); |
| |
| // trigger any hover bind events |
| var pos = { pageX: e.pageX, pageY: e.pageY }; |
| target.trigger(eventname, [ pos, item ]); |
| } |
| |
| function highlight(s, auto) |
| { |
| if (typeof s == "number") |
| s = series[s]; |
| |
| var i = indexOfHighlight(s); |
| if (i == -1) |
| { |
| highlights.push({ series: s, auto: auto }); |
| plot.triggerRedrawOverlay(); |
| } |
| else if (!auto) |
| highlights[i].auto = false; |
| } |
| |
| function unhighlight(s) |
| { |
| if (s == null) |
| { |
| highlights = []; |
| plot.triggerRedrawOverlay(); |
| } |
| |
| if (typeof s == "number") |
| s = series[s]; |
| |
| var i = indexOfHighlight(s); |
| if (i != -1) |
| { |
| highlights.splice(i, 1); |
| plot.triggerRedrawOverlay(); |
| } |
| } |
| |
| function indexOfHighlight(s) |
| { |
| for (var i = 0; i < highlights.length; ++i) |
| { |
| var h = highlights[i]; |
| if (h.series == s) |
| return i; |
| } |
| return -1; |
| } |
| |
| function drawOverlay(plot, octx) |
| { |
| //alert(options.series.pie.radius); |
| var options = plot.getOptions(); |
| //alert(options.series.pie.radius); |
| |
| var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius; |
| |
| octx.save(); |
| octx.translate(centerLeft, centerTop); |
| octx.scale(1, options.series.pie.tilt); |
| |
| for (i = 0; i < highlights.length; ++i) |
| drawHighlight(highlights[i].series); |
| |
| drawDonutHole(octx); |
| |
| octx.restore(); |
| |
| function drawHighlight(series) |
| { |
| if (series.angle < 0) return; |
| |
| //octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString(); |
| octx.fillStyle = "rgba(255, 255, 255, "+options.series.pie.highlight.opacity+")"; // this is temporary until we have access to parseColor |
| |
| octx.beginPath(); |
| if (Math.abs(series.angle - Math.PI*2) > 0.000000001) |
| octx.moveTo(0,0); // Center of the pie |
| octx.arc(0,0,radius,series.startAngle,series.startAngle+series.angle,false); |
| octx.closePath(); |
| octx.fill(); |
| } |
| |
| } |
| |
| } // end init (plugin body) |
| |
| // define pie specific options and their default values |
| var options = { |
| series: { |
| pie: { |
| show: false, |
| radius: 'auto', // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value) |
| innerRadius:0, /* for donut */ |
| startAngle: 3/2, |
| tilt: 1, |
| offset: { |
| top: 0, |
| left: 'auto' |
| }, |
| stroke: { |
| color: '#FFF', |
| width: 1 |
| }, |
| label: { |
| show: 'auto', |
| formatter: function(label, slice){ |
| return '<div style="font-size:x-small;text-align:center;padding:2px;color:'+slice.color+';">'+label+'<br/>'+Math.round(slice.percent)+'%</div>'; |
| }, // formatter function |
| radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value) |
| background: { |
| color: null, |
| opacity: 0 |
| }, |
| threshold: 0 // percentage at which to hide the label (i.e. the slice is too narrow) |
| }, |
| combine: { |
| threshold: -1, // percentage at which to combine little slices into one larger slice |
| color: null, // color to give the new slice (auto-generated if null) |
| label: 'Other' // label to give the new slice |
| }, |
| highlight: { |
| //color: '#FFF', // will add this functionality once parseColor is available |
| opacity: 0.5 |
| } |
| } |
| } |
| }; |
| |
| $.plot.plugins.push({ |
| init: init, |
| options: options, |
| name: "pie", |
| version: "1.0" |
| }); |
| })(jQuery); |