I would greatly appreciate the input of any gurus out there. I have recently begun learning JavaScript and then jQuery and jQuery-UI and have thought I would take a stab at writing my own jQuery-UI plugin, the result of which you can see here:
http://jsfiddle.net/ben1729/djA6G/ .
It's basically a pie chart into which you can drill down, rendered using HTML5 canvas. The data provided is dummy and so just oscillates between two data sets. I envisaged it displaying population of continents then by country upon drilldown etc.
What I'm after is constructive criticism. If there are any best practices I have violated or any obvious functionality I have missed then please let me know.
jQuery UI plugin:
(function($) {
// Utility object with helper functions
var utils = {
tau: 2 * Math.PI,
angleOrigin: -Math.PI / 2,
sum: function(toSum) {
var total = 0;
$.each(toSum, function(n, value) {
total += value;
});
return total;
},
normalise: function(toNormalise) {
var total = utils.sum(toNormalise);
var toReturn = [];
$.each(toNormalise, function(n, value) {
toReturn.push(utils.tau * value / total);
});
return toReturn;
},
distanceSqrd: function(x1, y1, x2, y2) {
return Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2);
},
bearing: function(x, y, originX, originY) {
var toReturn = Math.atan2(x - originX, originY - y);
if (toReturn < 0) {
toReturn += utils.tau;
}
return toReturn;
},
getIndex: function(bearing, dataArray) {
var cumulativeAngle = 0;
var toReturn = 0;
$.each(dataArray, function(n, value) {
cumulativeAngle += value;
if (bearing < cumulativeAngle) {
toReturn = n;
return false;
}
});
return toReturn;
}
};
// Object for storing drawing functionality
var renderer = {
clear: function(widget) {
widget.context.clearRect(0, 0, widget.options.width, widget.options.height);
},
drawData: function(widget, opacity) {
var startAngle = utils.angleOrigin;
$.each(widget.dataArray, function(n, value) {
var colour = widget.options.colourFn(n);
renderer.drawSector(widget, colour, startAngle, startAngle + value, opacity);
startAngle += value;
});
},
drawSector: function(widget, colour, startAngle, endAngle, opacity) {
var context = widget.context;
context.globalAlpha = opacity || 1;
context.fillStyle = colour;
context.beginPath();
context.moveTo(widget.centreX, widget.centreY);
context.arc(widget.centreX, widget.centreY, widget.options.radius, startAngle, endAngle);
context.lineTo(widget.centreX, widget.centreY);
context.closePath();
context.fill();
context.globalAlpha = 1;
},
// Based on current data, the selected segment will swallow the others then call the callback
swallowOthers: function(widget, selectedIndex, callback) {
var startAngle = utils.angleOrigin;
$.each(widget.dataArray, function(n, value) {
if (n === selectedIndex) {
return false;
} else {
startAngle += value;
}
});
var endAngle = startAngle + widget.dataArray[selectedIndex];
var stepSize = (utils.tau - widget.dataArray[selectedIndex]) / 50;
var colour = widget.options.colourFn(selectedIndex);
var swallow = function() {
if (endAngle - startAngle < utils.tau) {
endAngle += stepSize;
renderer.drawSector(widget, colour, startAngle, endAngle);
setTimeout(swallow, 20);
} else {
callback();
}
};
setTimeout(swallow, 20);
},
// The current data will fade in from the old colour then call the callback
fadeInNewData: function(widget, oldColour, callback) {
var opacity = 0;
var fadeIn = function() {
opacity += 0.02;
if (opacity <= 1) {
renderer.clear(widget);
if (oldColour) {
renderer.drawSector(widget, oldColour, 0, utils.tau);
}
renderer.drawData(widget, opacity);
setTimeout(fadeIn, 20);
} else {
callback();
}
};
setTimeout(fadeIn, 20);
},
// Fade out the old data
fadeOutOldData: function(widget, callback) {
var opacity = 1;
var fadeOut = function() {
opacity -= 0.02;
if (opacity >= 0) {
renderer.clear(widget);
renderer.drawData(widget, opacity);
setTimeout(fadeOut, 20);
} else {
callback();
}
};
setTimeout(fadeOut, 50);
}
};
$.widget("ui.piChart", {
canvas: null,
$canvas: null,
context: null,
centreX: null,
centreY: null,
dataArray: null,
isAnimating: false,
radiusSqrd: null,
hoverIndex: -1,
// These options will be used as defaults
options: {
dataProvider: null,
// Required
width: 200,
height: 200,
radius: 80,
colourFn: function(selectedIndex) {
var defaultRainbow = ['red', 'orange', 'yellow', 'green', 'blue'];
return defaultRainbow[selectedIndex % defaultRainbow.length];
},
animationComplete: function(selectedIndex) {},
mouseMove: function(hoverIndex) {}
},
// Set up the widget
_create: function() {
// Store reference to self
var self = this;
// Create HTML5 canvas
this.canvas = $('<canvas>', {
width: this.options.width,
height: this.options.height
}).attr('id', 'ui-piChart-canvas').appendTo(this.element[0])[0];
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
this.$canvas = $(this.canvas);
// Other useful variables to store
this.context = this.canvas.getContext('2d');
this.centreX = this.options.width / 2;
this.centreY = this.options.height / 2;
this.radiusSqrd = this.options.radius * this.options.radius;
// Get current data
this.dataArray = utils.normalise(this.options.dataProvider.getRoot());
// Initial draw of the data
renderer.clear(this);
renderer.drawData(this);
// Click event handler
this.$canvas.click(function(event) {
if (!self.isAnimating && utils.distanceSqrd(event.offsetX, event.offsetY, self.centreX, self.centreY) < self.radiusSqrd) {
// Get the selected index based on the click location
var bearing = utils.bearing(event.offsetX, event.offsetY, self.centreX, self.centreY);
var selectedIndex = utils.getIndex(bearing, self.dataArray);
// Check whether there is child data for the selected index
if (self.options.dataProvider.hasChildren(selectedIndex)) {
// Start the animation
self.isAnimating = true;
// Store the previous colour for the purposes of fade in
var oldColour = self.options.colourFn(selectedIndex);
// First swallow the other segments
renderer.swallowOthers(self, selectedIndex, function() {
// Reset the data
self.dataArray = utils.normalise(self.options.dataProvider.getChildren(selectedIndex));
// Fade in the new data
renderer.fadeInNewData(self, oldColour, function() {
// Paint the pie chart for a final time
renderer.clear(self);
renderer.drawData(self);
self.options.animationComplete(selectedIndex);
self.isAnimating = false;
});
});
}
}
});
// Mousemove event
this.$canvas.mousemove(function(event) {
if (!self.isAnimating && utils.distanceSqrd(event.offsetX, event.offsetY, self.centreX, self.centreY) < self.radiusSqrd) {
// Get the selected index based on the click location
var bearing = utils.bearing(event.offsetX, event.offsetY, self.centreX, self.centreY);
var selectedIndex = utils.getIndex(bearing, self.dataArray);
if (selectedIndex !== self.hoverIndex) {
self.hoverIndex = selectedIndex;
self.options.mouseMove(self.hoverIndex);
}
}
});
},
// Use the _setOption method to respond to changes to options
_setOption: function(key, value) {
// Store reference to self
var self = this;
switch (key) {
case "data":
if (!self.isAnimating) {
// Start the animation
self.isAnimating = true;
// Redraw with new data
renderer.fadeOutOldData(self, function() {
self.dataArray = utils.normalise(value);
renderer.clear(self);
renderer.fadeInNewData(self, null, function() {
// Paint the pie chart for a final time
renderer.clear(self);
renderer.drawData(self);
self.options.animationComplete(-1);
self.isAnimating = false;
});
});
}
break;
// TODO - Other options to set go here
}
// In jQuery UI 1.8, you have to manually invoke the _setOption method from the base widget
$.Widget.prototype._setOption.apply(this, arguments);
},
// Use the destroy method to clean up any modifications your widget has made to the DOM
destroy: function() {
$(this.canvas).remove();
// In jQuery UI 1.8, you must invoke the destroy method from the base widget
$.Widget.prototype.destroy.call(this);
}
});
})(jQuery);
The code being used by the client:
<script type="text/javascript">
$(function() {
// Dummy data provider which switches between two datasets
var dataProvider = (function() {
var data1 = [1, 2, 3, 4, 25];
var data2 = [3, 3, 4, 5];
var oddCalls = false;
return {
getRoot: function() {
oddCalls = false;
return data2;
},
getChildren: function(selectedIndex) {
$('p#lastSelectedIndex').text('Selected index: ' + selectedIndex);
oddCalls = (oddCalls === false);
if (oddCalls) {
return data1;
} else {
return data2;
}
},
hasChildren: function(selectedIndex) {
return true;
}
};
})();
// Create the pi chart
$('#create').click(function() {
$('#canvasDiv').piChart({
dataProvider: dataProvider,
mouseMove: function(hoverIndex) {
$('p#hoverIndex').text('Hover index: ' + hoverIndex);
}
});
$('p#lastSelectedIndex').text('Root');
});
// Reset the dataset usin
$('#reset').click(function() {
$('#canvasDiv').piChart('option', 'data', dataProvider.getRoot());
$('p#lastSelectedIndex').text('Root');
$('p#hoverIndex').text('');
});
// Destroy the pi chart
$('#destroy').click(function() {
$('#canvasDiv').piChart('destroy');
$('p#lastSelectedIndex').text('');
$('p#hoverIndex').text('');
});
});
</script>
<div id="canvasDiv"></div>
<button id="create">Create</button>
<button id="reset">Reset</button>
<button id="destroy">Destroy</button>
<p id="lastSelectedIndex"></p>
<p id="hoverIndex"></p>
Happy nit-picking!