/** * -------------------------------------------------------------------- * jQuery-Plugin "visualize" * by Scott Jehl, scott@filamentgroup.com * http://www.filamentgroup.com * Copyright (c) 2009 Filament Group * Dual licensed under the MIT (filamentgroup.com/examples/mit-license.txt) and GPL (filamentgroup.com/examples/gpl-license.txt) licenses. * * -------------------------------------------------------------------- */ (function ($) { $.fn.visualize = function (options, container) { return $(this).each(function () { //configuration var o = $.extend({ type: 'bar', //also available: area, pie, line width: $(this).width(), //height of canvas - defaults to table height height: $(this).height(), //height of canvas - defaults to table height appendTitle: true, //table caption text is added to chart title: null, //grabs from table caption if null appendKey: true, //color key is added to chart colors: ['#be1e2d', '#666699', '#92d5ea', '#ee8310', '#8d10ee', '#5a3b16', '#26a4ed', '#f45a90', '#e9e744'], textColors: [], //corresponds with colors array. null/undefined items will fall back to CSS parseDirection: 'x', //which direction to parse the table data pieMargin: 10, //pie charts only - spacing around pie pieLabelsAsPercent: true, pieLabelPos: 'inside', lineWeight: 4, //for line and area - stroke weight lineDots: false, //also available: 'single', 'double' dotInnerColor: "#ffffff", // only used for lineDots:'double' lineMargin: (options.lineDots ? 15 : 0), //for line and area - spacing around lines barGroupMargin: 10, chartId: '', xLabelParser: null, // function to parse labels as values valueParser: null, // function to parse values. must return a Number chartId: '', chartClass: '', barMargin: 1, //space around bars in bar chart (added to both sides of bar) yLabelInterval: 30, //distance between y labels interaction: false // only used for lineDots != false -- triggers mouseover and mouseout on original table }, options); //reset width, height to numbers o.width = parseFloat(o.width); o.height = parseFloat(o.height); // reset padding if graph is not lines if (o.type != 'line' && o.type != 'area') { o.lineMargin = 0; } var self = $(this); // scrape data from html table var tableData = {}; var colors = o.colors; var textColors = o.textColors; var parseLabels = function (direction) { var labels = []; if (direction == 'x') { self.find('thead tr').each(function (i) { $(this).find('th').each(function (j) { if (!labels[j]) { labels[j] = []; } labels[j][i] = $(this).text() }) }); } else { self.find('tbody tr').each(function (i) { $(this).find('th').each(function (j) { if (!labels[i]) { labels[i] = []; } labels[i][j] = $(this).text() }); }); } return labels; }; var fnParse = o.valueParser || parseFloat; var dataGroups = tableData.dataGroups = []; if (o.parseDirection == 'x') { self.find('tbody tr').each(function (i, tr) { dataGroups[i] = {}; dataGroups[i].points = []; dataGroups[i].color = colors[i]; if (textColors[i]) { dataGroups[i].textColor = textColors[i]; } $(tr).find('td').each(function (j, td) { dataGroups[i].points.push({ value: fnParse($(td).text()), elem: td, tableCords: [i, j] }); }); }); } else { var cols = self.find('tbody tr:eq(0) td').size(); for (var i = 0; i < cols; i++) { dataGroups[i] = {}; dataGroups[i].points = []; dataGroups[i].color = colors[i]; if (textColors[i]) { dataGroups[i].textColor = textColors[i]; } self.find('tbody tr').each(function (j) { dataGroups[i].points.push({ value: $(this).find('td').eq(i).text() * 1, elem: this, tableCords: [i, j] }); }); }; } var allItems = tableData.allItems = []; $(dataGroups).each(function (i, row) { var count = 0; $.each(row.points, function (j, point) { allItems.push(point); count += point.value; }); row.groupTotal = count; }); tableData.dataSum = 0; tableData.topValue = 0; tableData.bottomValue = Infinity; $.each(allItems, function (i, item) { tableData.dataSum += fnParse(item.value); if (fnParse(item.value, 10) > tableData.topValue) { tableData.topValue = fnParse(item.value, 10); } if (item.value < tableData.bottomValue) { tableData.bottomValue = fnParse(item.value); } }); var dataSum = tableData.dataSum; var topValue = tableData.topValue; var bottomValue = tableData.bottomValue; var xAllLabels = tableData.xAllLabels = parseLabels(o.parseDirection); var yAllLabels = tableData.yAllLabels = parseLabels(o.parseDirection === 'x' ? 'y' : 'x'); var xLabels = tableData.xLabels = []; $.each(tableData.xAllLabels, function (i, labels) { tableData.xLabels.push(labels[0]); }); var totalYRange = tableData.totalYRange = tableData.topValue - tableData.bottomValue; var zeroLocX = tableData.zeroLocX = 0; if ($.isFunction(o.xLabelParser)) { var xTopValue = null; var xBottomValue = null; $.each(xLabels, function (i, label) { label = xLabels[i] = o.xLabelParser(label); if (i === 0) { xTopValue = label; xBottomValue = label; } if (label > xTopValue) { xTopValue = label; } if (label < xBottomValue) { xBottomValue = label; } }); var totalXRange = tableData.totalXRange = xTopValue - xBottomValue; var xScale = tableData.xScale = (o.width - 2 * o.lineMargin) / totalXRange; var marginDiffX = 0; if (o.lineMargin) { var marginDiffX = -2 * xScale - o.lineMargin; } zeroLocX = tableData.zeroLocX = xBottomValue + o.lineMargin; tableData.xBottomValue = xBottomValue; tableData.xTopValue = xTopValue; tableData.totalXRange = totalXRange; } var yScale = tableData.yScale = (o.height - 2 * o.lineMargin) / totalYRange; var zeroLocY = tableData.zeroLocY = (o.height - 2 * o.lineMargin) * (tableData.topValue / tableData.totalYRange) + o.lineMargin; var yLabels = tableData.yLabels = []; var numLabels = Math.floor((o.height - 2 * o.lineMargin) / 30); var loopInterval = tableData.totalYRange / numLabels; //fix provided from lab loopInterval = Math.round(parseFloat(loopInterval) / 5) * 5; loopInterval = Math.max(loopInterval, 1); // var start = for (var j = Math.round(parseInt(tableData.bottomValue) / 5) * 5; j <= tableData.topValue - loopInterval; j += loopInterval) { yLabels.push(j); } if (yLabels[yLabels.length - 1] > tableData.topValue + loopInterval) { yLabels.pop(); } else if (yLabels[yLabels.length - 1] <= tableData.topValue - 10) { yLabels.push(tableData.topValue); } // populate some data $.each(dataGroups, function (i, row) { row.yLabels = tableData.yAllLabels[i]; $.each(row.points, function (j, point) { point.zeroLocY = tableData.zeroLocY; point.zeroLocX = tableData.zeroLocX; point.xLabels = tableData.xAllLabels[j]; point.yLabels = tableData.yAllLabels[i]; point.color = row.color; }); }); try { console.log(tableData); } catch (e) {} var charts = {}; charts.pie = { interactionPoints: dataGroups, setup: function () { charts.pie.draw(true); }, draw: function (drawHtml) { var centerx = Math.round(canvas.width() / 2); var centery = Math.round(canvas.height() / 2); var radius = centery - o.pieMargin; var counter = 0.0; if (drawHtml) { canvasContain.addClass('visualize-pie'); if (o.pieLabelPos == 'outside') { canvasContain.addClass('visualize-pie-outside'); } var toRad = function (integer) { return (Math.PI / 180) * integer; }; var labels = $('').insertAfter(canvas); } //draw the pie pieces $.each(dataGroups, function (i, row) { var fraction = row.groupTotal / dataSum; if (fraction <= 0 || isNaN(fraction)) return; ctx.beginPath(); ctx.moveTo(centerx, centery); ctx.arc(centerx, centery, radius, counter * Math.PI * 2 - Math.PI * 0.5, (counter + fraction) * Math.PI * 2 - Math.PI * 0.5, false); ctx.lineTo(centerx, centery); ctx.closePath(); ctx.fillStyle = dataGroups[i].color; ctx.fill(); // draw labels if (drawHtml) { var sliceMiddle = (counter + fraction / 2); var distance = o.pieLabelPos == 'inside' ? radius / 1.5 : radius + radius / 5; var labelx = Math.round(centerx + Math.sin(sliceMiddle * Math.PI * 2) * (distance)); var labely = Math.round(centery - Math.cos(sliceMiddle * Math.PI * 2) * (distance)); var leftRight = (labelx > centerx) ? 'right' : 'left'; var topBottom = (labely > centery) ? 'bottom' : 'top'; var percentage = parseFloat((fraction * 100).toFixed(2)); // interaction variables row.canvasCords = [labelx, labely]; row.zeroLocY = tableData.zeroLocY = 0; // related to zeroLocY and plugin API row.zeroLocX = tableData.zeroLocX = 0; // related to zeroLocX and plugin API row.value = row.groupTotal; if (percentage) { var labelval = (o.pieLabelsAsPercent) ? percentage + '%' : row.groupTotal; var labeltext = $('' + labelval + '').css(leftRight, 0).css(topBottom, 0); if (labeltext) var label = $('
  • ').appendTo(labels).css({ left: labelx, top: labely }).append(labeltext); labeltext.css('font-size', radius / 8).css('margin-' + leftRight, -labeltext.width() / 2).css('margin-' + topBottom, -labeltext.outerHeight() / 2); if (dataGroups[i].textColor) { labeltext.css('color', dataGroups[i].textColor); } } } counter += fraction; }); } }; (function () { var xInterval; var drawPoint = function (ctx, x, y, color, size) { ctx.moveTo(x, y); ctx.beginPath(); ctx.arc(x, y, size / 2, 0, 2 * Math.PI, false); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); }; charts.line = { interactionPoints: allItems, setup: function (area) { if (area) { canvasContain.addClass('visualize-area'); } else { canvasContain.addClass('visualize-line'); } //write X labels var xlabelsUL = $('').width(canvas.width()).height(canvas.height()).insertBefore(canvas); if (!o.customXLabels) { xInterval = (canvas.width() - 2 * o.lineMargin) / (xLabels.length - 1); $.each(xLabels, function (i) { var thisLi = $('
  • ' + this + '
  • ').prepend('').css('left', o.lineMargin + xInterval * i).appendTo(xlabelsUL); var label = thisLi.find('span:not(.line)'); var leftOffset = label.width() / -2; if (i == 0) { leftOffset = -20; } else if (i == xLabels.length - 1) { leftOffset = -label.width() + 20; } label.css('margin-left', leftOffset).addClass('label'); }); } else { o.customXLabels(tableData, xlabelsUL); } //write Y labels var liBottom = (canvas.height() - 2 * o.lineMargin) / (yLabels.length - 1); var ylabelsUL = $('').width(canvas.width()).height(canvas.height()) // .css('margin-top',-o.lineMargin) .insertBefore(scroller); $.each(yLabels, function (i) { var value = Math.floor(this); var posB = (value - bottomValue) * yScale + o.lineMargin; if (posB >= o.height - 1 || posB < 0) { return; } var thisLi = $('
  • ' + value + '
  • ').css('bottom', posB); if (Math.abs(posB) < o.height - 1) { thisLi.prepend(''); } thisLi.prependTo(ylabelsUL); var label = thisLi.find('span:not(.line)'); var topOffset = label.height() / -2; if (!o.lineMargin) { if (i == 0) { topOffset = -label.height(); } else if (i == yLabels.length - 1) { topOffset = 0; } } label.css('margin-top', topOffset).addClass('label'); }); //start from the bottom left ctx.translate(zeroLocX, zeroLocY); charts.line.draw(area); }, draw: function (area) { // prevent drawing on top of previous draw ctx.clearRect(-zeroLocX, -zeroLocY, o.width, o.height); // Calculate each point properties before hand var integer; $.each(dataGroups, function (i, row) { integer = o.lineMargin; // the current offset $.each(row.points, function (j, point) { if (o.xLabelParser) { point.canvasCords = [(xLabels[j] - zeroLocX) * xScale - xBottomValue, -(point.value * yScale)]; } else { point.canvasCords = [integer, -(point.value * yScale)]; } if (o.lineDots) { point.dotSize = o.dotSize || o.lineWeight * Math.PI; point.dotInnerSize = o.dotInnerSize || o.lineWeight * Math.PI / 2; if (o.lineDots == 'double') { point.innerColor = o.dotInnerColor; } } integer += xInterval; }); }); // fire custom event so we can enable rich interaction self.trigger('vizualizeBeforeDraw', { options: o, table: self, canvasContain: canvasContain, tableData: tableData }); // draw lines and areas $.each(dataGroups, function (h) { // Draw lines ctx.beginPath(); ctx.lineWidth = o.lineWeight; ctx.lineJoin = 'round'; $.each(this.points, function (g) { var loc = this.canvasCords; if (g == 0) { ctx.moveTo(loc[0], loc[1]); } ctx.lineTo(loc[0], loc[1]); }); ctx.strokeStyle = this.color; ctx.stroke(); // Draw fills if (area) { var integer = this.points[this.points.length - 1].canvasCords[0]; if (isFinite(integer)) ctx.lineTo(integer, 0); ctx.lineTo(o.lineMargin, 0); ctx.closePath(); ctx.fillStyle = this.color; ctx.globalAlpha = .3; ctx.fill(); ctx.globalAlpha = 1.0; } else { ctx.closePath(); } }); // draw points if (o.lineDots) { $.each(dataGroups, function (h) { $.each(this.points, function (g) { drawPoint(ctx, this.canvasCords[0], this.canvasCords[1], this.color, this.dotSize); if (o.lineDots === 'double') { drawPoint(ctx, this.canvasCords[0], this.canvasCords[1], this.innerColor, this.dotInnerSize); } }); }); } } }; })(); charts.area = { setup: function () { charts.line.setup(true); }, draw: charts.line.draw }; (function () { var horizontal, bottomLabels; charts.bar = { setup: function () { /** * We can draw horizontal or vertical bars depending on the * value of the 'barDirection' option (which may be 'vertical' or * 'horizontal'). */ horizontal = (o.barDirection == 'horizontal'); canvasContain.addClass('visualize-bar'); /** * Write labels along the bottom of the chart. If we're drawing * horizontal bars, these will be the yLabels, otherwise they * will be the xLabels. The positioning also varies slightly: * yLabels are values, hence they will span the whole width of * the canvas, whereas xLabels are supposed to line up with the * bars. */ bottomLabels = horizontal ? yLabels : xLabels; var xInterval = canvas.width() / (bottomLabels.length - (horizontal ? 1 : 0)); var xlabelsUL = $('').width(canvas.width()).height(canvas.height()).insertBefore(canvas); $.each(bottomLabels, function (i) { var thisLi = $('
  • ' + this + '
  • ').prepend('').css('left', xInterval * i).width(xInterval).appendTo(xlabelsUL); if (horizontal) { var label = thisLi.find('span.label'); label.css("margin-left", -label.width() / 2); } }); /** * Write labels along the left of the chart. Follows the same idea * as the bottom labels. */ var leftLabels = horizontal ? xLabels : yLabels; var liBottom = canvas.height() / (leftLabels.length - (horizontal ? 0 : 1)); var ylabelsUL = $('
      ').width(canvas.width()).height(canvas.height()).insertBefore(canvas); $.each(leftLabels, function (i) { var thisLi = $('
    • ' + this + '
    • ').prependTo(ylabelsUL); var label = thisLi.find('span:not(.line)').addClass('label'); if (horizontal) { /** * For left labels, we want to vertically align the text * to the middle of its container, but we don't know how * many lines of text we will have, since the labels could * be very long. * * So we set a min-height of liBottom, and a max-height * of liBottom + 1, so we can then check the label's actual * height to determine if it spans one line or more lines. */ label.css({ 'min-height': liBottom, 'max-height': liBottom + 1, 'vertical-align': 'middle' }); thisLi.css({ 'top': liBottom * i, 'min-height': liBottom }); var r = label[0].getClientRects()[0]; if (r.bottom - r.top == liBottom) { /* This means we have only one line of text; hence * we can centre the text vertically by setting the line-height, * as described at: * http://www.ampsoft.net/webdesign-l/vertical-aligned-nav-list.html * * (Although firefox has .height on the rectangle, IE doesn't, * so we use r.bottom - r.top rather than r.height.) */ label.css('line-height', parseInt(liBottom) + 'px'); } else { /* * If there is more than one line of text, then we shouldn't * touch the line height, but we should make sure the text * doesn't overflow the container. */ label.css("overflow", "hidden"); } } else { thisLi.css('bottom', liBottom * i).prepend(''); label.css('margin-top', -label.height() / 2) } }); charts.bar.draw(); }, draw: function () { // Draw bars if (horizontal) { // for horizontal, keep the same code, but rotate everything 90 degrees // clockwise. ctx.rotate(Math.PI / 2); } else { // for vertical, translate to the top left corner. ctx.translate(0, zeroLocY); } // Don't attempt to draw anything if all the values are zero, // otherwise we will get weird exceptions from the canvas methods. if (totalYRange <= 0) return; var yScale = (horizontal ? canvas.width() : canvas.height()) / totalYRange; var barWidth = horizontal ? (canvas.height() / xLabels.length) : (canvas.width() / (bottomLabels.length)); var linewidth = (barWidth - o.barGroupMargin * 2) / dataGroups.length; for (var h = 0; h < dataGroups.length; h++) { ctx.beginPath(); var strokeWidth = linewidth - (o.barMargin * 2); ctx.lineWidth = strokeWidth; var points = dataGroups[h].points; var integer = 0; for (var i = 0; i < points.length; i++) { // If the last value is zero, IE will go nuts and not draw anything, // so don't try to draw zero values at all. if (points[i].value != 0) { var xVal = (integer - o.barGroupMargin) + (h * linewidth) + linewidth / 2; xVal += o.barGroupMargin * 2; ctx.moveTo(xVal, 0); ctx.lineTo(xVal, Math.round(-points[i].value * yScale)); } integer += barWidth; } ctx.strokeStyle = dataGroups[h].color; ctx.stroke(); ctx.closePath(); } } }; })(); //create new canvas, set w&h attrs (not inline styles) var canvasNode = document.createElement("canvas"); var canvas = $(canvasNode).attr({ 'height': o.height, 'width': o.width }); //get title for chart var title = o.title || self.find('caption').text(); //create canvas wrapper div, set inline w&h, append var canvasContain = (container || $('
      ')).height(o.height).width(o.width); var scroller = $('
      ').appendTo(canvasContain).append(canvas); //title/key container if (o.appendTitle || o.appendKey) { var infoContain = $('
      ').appendTo(canvasContain); } //append title if (o.appendTitle) { $('
      ' + title + '
      ').appendTo(infoContain); } //append key if (o.appendKey) { var newKey = $('
        '); $.each(yAllLabels, function (i, label) { $('
      • ' + label + '
      • ').appendTo(newKey); }); newKey.appendTo(infoContain); }; // init interaction if (o.interaction) { // sets the canvas to track interaction // IE needs one div on top of the canvas since the VML shapes prevent mousemove from triggering correctly. // Pie charts needs tracker because labels goes on top of the canvas and also messes up with mousemove var tracker = $('
        ').css({ 'height': o.height + 'px', 'width': o.width + 'px', 'position': 'relative', 'z-index': 200 }).insertAfter(canvas); var triggerInteraction = function (overOut, data) { var data = $.extend({ canvasContain: canvasContain, tableData: tableData }, data); self.trigger('vizualize' + overOut, data); }; var over = false, last = false, started = false; tracker.mousemove(function (e) { var x, y, x1, y1, data, dist, i, current, selector, zLabel, elem, color, minDist, found, ev = e.originalEvent; // get mouse position relative to the tracker/canvas x = ev.layerX || ev.offsetX || 0; y = ev.layerY || ev.offsetY || 0; found = false; minDist = started ? 30000 : (o.type == 'pie' ? (Math.round(canvas.height() / 2) - o.pieMargin) / 3 : o.lineWeight * 4); // iterate datagroups to find points with matching $.each(charts[o.type].interactionPoints, function (i, current) { x1 = current.canvasCords[0] + zeroLocX; y1 = current.canvasCords[1] + (o.type == "pie" ? 0 : zeroLocY); dist = Math.sqrt((x1 - x) * (x1 - x) + (y1 - y) * (y1 - y)); if (dist < minDist) { found = current; minDist = dist; } }); if (o.multiHover && found) { x = found.canvasCords[0] + zeroLocX; y = found.canvasCords[1] + (o.type == "pie" ? 0 : zeroLocY); found = [found]; $.each(charts[o.type].interactionPoints, function (i, current) { if (current == found[0]) { return; } x1 = current.canvasCords[0] + zeroLocX; y1 = current.canvasCords[1] + zeroLocY; dist = Math.sqrt((x1 - x) * (x1 - x) + (y1 - y) * (y1 - y)); if (dist <= o.multiHover) { found.push(current); } }); } // trigger over and out only when state changes, instead of on every mousemove over = found; if (over != last) { if (over) { if (last) { triggerInteraction('Out', { point: last }); } triggerInteraction('Over', { point: over }); last = over; } if (last && !over) { triggerInteraction('Out', { point: last }); last = false; } started = true; } }); tracker.mouseleave(function () { triggerInteraction('Out', { point: last, mouseOutGraph: true }); over = (last = false); }); } //append new canvas to page if (!container) { canvasContain.insertAfter(this); } if (typeof (G_vmlCanvasManager) != 'undefined') { G_vmlCanvasManager.init(); G_vmlCanvasManager.initElement(canvas[0]); } //set up the drawing board var ctx = canvas[0].getContext('2d'); // Scroll graphs scroller.scrollLeft(o.width - scroller.width()); // init plugins $.each($.visualizePlugins, function (i, plugin) { plugin.call(self, o, tableData); }); //create chart charts[o.type].setup(); if (!container) { //add event for updating self.bind('visualizeRefresh', function () { self.visualize(o, $(this).empty()); }); //add event for redraw self.bind('visualizeRedraw', function () { charts[o.type].draw(); }); } }).next(); //returns canvas(es) }; // create array for plugins. if you wish to make a plugin, // just push your init funcion into this array $.visualizePlugins = []; })(jQuery);