There are some libraries to display Pie Chart such as ChartJS (free) or some other commercial (non-free) libraries which also can display many kinds of charts (bar chart, line chart, etc.), but sometimes we only need to display Pie Chart, in this case we can use this simple Javascript below.
I've tried ChartJS (https://www.chartjs.org/) and I found it very good and beautiful, but unfortunately the size is very large (159 KB for minified version 2.7.3), so I tried to learn how to create pie chart using JS, I read some samples from Internet and have put bits of code from some places and implemented a new simple PieChartJS.
- Designed to be used for Responsive web, canvas' parent ('div') is very important because canvas size will be dynamically updated to follow parent size. To see the Responsive effect please use desktop browser and resize the viewport or use smartphone to change the orientation to portrait/landscape.
- The Javascript code inside this page has many comments to explain the main logic of the code and to encourage others to learn and customize (if without comments and minified then code is only 5 KB)
- Optimized (reduced) the Math calculation by using 'cache' to avoid too many Math calculations.
- Precise logic to detect whether a point X,Y (by mouse pointer) is actually inside a slice of pie or not.
- There are 2 ways to highlight any slice of pie, either by touch the slice or use mouse over the slice or click the buttons (some sample buttons below the chart) to highlight slice.
Below are some sample canvas for my PieChartJS, it is very simple and free for anyone to use or modify.
The full source code is below, the code is small and easy to understand, anyone is welcome to use and share the code freely.
function highlight(pieId) { pie1.highlightSlice(pieId); pie2.highlightSlice(pieId); pie3.highlightSlice(pieId); } // see X11 color names = https://en.wikipedia.org/wiki/Web_colors var x11colors = [ '#ffc0cb' // pink , '#ff69b4' // hot pink , '#c71585' // Medium Violet Red , '#00ff00' // LIME (full brightest green) , '#90ee90' // light green , '#008000' // green , '#006400' // Dark green , '#808000' // Olive , '#ff0000' // red , '#fa8072' // Salmon , '#dc143c' // crimson (deep red) , '#b22222' // Fire brick , '#ffff00' // yellow , '#fffacd' // Lemon Chiffon , '#f0e68c' // Khaki , '#ffd700' // Gold , '#87ceeb' // Sky Blue , '#4169e1' // Royal blue , '#0000ff' // Blue , '#000080' // Navy , '#f5deb3' // Wheat , '#d2b48c' // Tan , '#daa520' // Golden rod , '#b8860b' // Dark Golden rod , '#d2691e' // Chocolate , '#a52a2a' // Brown , '#800000' // Maroon , '#e6e6fa' // Lavender , '#ee82ee' // Violet , '#ff00ff' // Magenta , '#8a2be2' // Blue Violet , '#800080' // Purple , '#4b0082' // Indigo , '#fffafa' // Snow , '#f0fff0' // Honeydew , '#fffff0' // Ivory , '#faebd7' // Antique White , '#ffe4e1' // Mistry Rose , '#c0c0c0' // Silver , '#808080' // Gray , '#708090' // Slate Gray ]; function displayPieChart() { // format: label text, value, backgroundColor, (optional) stroke/outline color when active/highlight const chartData = [ ['January', 30, x11colors[0], x11colors[17]], ['February', 19, x11colors[4], x11colors[0]], ['March', 13, x11colors[5], x11colors[4]], ['April', 1, x11colors[9], x11colors[5]], ['May', 49, x11colors[20], x11colors[9]], ['June', 90, x11colors[15]], ['July', 59, x11colors[30], x11colors[15]], ['August', 19, x11colors[2]], ['September', 79, x11colors[11], x11colors[2]], ['October', 45, x11colors[22]], ['November', 91, x11colors[33], x11colors[22]], ['December', 29, x11colors[17], x11colors[33]] ]; pie1 = new PieChartJS('mycanvas-pie1', chartData); const chartData2 = [ ['Tiger', 2, x11colors[0], x11colors[1]], ['Elephant', 1, x11colors[4]], ['Lion', 3, x11colors[5]], ['Chicken', 4, x11colors[9], x11colors[10]], ['Ant', 9, x11colors[20]], ['Cow', 5, x11colors[15], x11colors[16]], ['Sheep', 7, x11colors[30]], ['Lamb', 3, x11colors[2], x11colors[3]], ['Dog', 9, x11colors[11]], ['Cat', 4, x11colors[22], x11colors[23]], ['Monkey', 2, x11colors[33], x11colors[34]], ['Pig', 6, x11colors[17]] ]; pie2 = new PieChartJS('mycanvas-pie2', chartData2, true); const chartData3 = [ ['Male', 6, x11colors[10]], ['Female', 12, x11colors[18], '#fff'], ['Unknown', 3, x11colors[29], x11colors[30]] ]; pie3 = new PieChartJS('mycanvas-pie3', chartData3, true); } // declare global variable (not inside function) var pie1, pie2, pie3; function PieChartJS(canvasElementId, data, startFrom0oClock) { var self = { canvas: document.getElementById(canvasElementId) // copy from parameter , data: data // copy from parameter , ctx: null , sliceArray: [] , animIncrementStep: 3 // total pixel each step , totalAllValue: 0 , centX: 0 , centY: 0 , radius: 0 , MAX_RADIUS: 0 // convert 1 Degree to Radian = Math.PI / 180 , ONE_DEGREE_TO_RADIAN: 0.017453292519943295 // convert 1 Radian to Degree = 180 / Math.PI , ONE_RADIAN_TO_DEGREE: 57.29577951308232 // How many Degree in 1 full circle ? = 360 Degree // How many Radian angle in 1 full circle ? = (2 * Math.PI) = 6.283185307179586 Radian , MAX_RADIAN_VALUE: 6.283185307179586 , degToRad: function(deg) { // Math.PI = 3.141592653589793 // Math.PI / 180 = 0.017453292519943295 return deg * this.ONE_DEGREE_TO_RADIAN; //deg * (Math.PI / 180); } , radToDeg: function(rad) { return rad * this.ONE_RADIAN_TO_DEGREE; //rad * (180 / Math.PI); } // get point [x,y] from a slice , getPointXY: function(sliceRadius, radianAngle) { //console.log('getPointXY(' + sliceRadius + ',' + radianAngle + ')'); // WARNING: this function is slow because using Math.cos() and Math.sin() // use cache if possible return { 'x': this.centX + sliceRadius * Math.cos(radianAngle), 'y': this.centY + sliceRadius * Math.sin(radianAngle) }; } , SliceOfPie: function(data, radStartAngle, radEndAngle, radius) { // for enlarging slice size animation by changing its radius size // so need to save individual slice's radius this.radius = radius; this.radStartAngle = radStartAngle; this.radEndAngle = radEndAngle; this.data = data; } , drawSlice: function(slice) { let ctx = this.ctx; if (ctx) { // set this point as beginning of path ctx.beginPath(); // 1. draw the arc (THIS IS starting point) ctx.arc(this.centX, this.centY, slice.radius, slice.radStartAngle, slice.radEndAngle); // 2. draw line from end of arc to center ctx.lineTo(this.centX, this.centY); // line to go back to center make a PIE SLICE // 3. draw line from center to start of arc (to complete line) // let point = this.getPointXY(slice.radius, slice.radStartAngle); // ctx.lineTo(point.x, point.y); // use closePath() instead of calculate the point using getPointXY() --> slow math calculation ctx.closePath(); // NOTE: must call closePath() before call fill() or stroke() !! // IMPORTANT: default stroke is in the MIDDLE of the line, // to change it to 'INNER', we need to work in sequence: fill() then clip() then stroke()) ctx.fillStyle = slice.data[2]; ctx.fill(); // draw border ONLY if this slice is active if(this.activeSliceOfPie >= 0 && this.activeSliceOfPie < this.sliceArray.length && this.sliceArray[this.activeSliceOfPie] == slice) { //console.log('drawSlice(slice), this is the active slice: ' + this.activeSliceOfPie); // need to draw border ctx.save(); // save before clip ctx.clip(); // lineWidth was previously defined but not working, so always define it before use ctx.lineWidth = 3 * 2; // will be used in HALF (because inner clip) // use the provided value if existed or use default WHITE let borderColor = '#fff'; if(slice.data[3]) { borderColor = slice.data[3]; } ctx.strokeStyle = borderColor; ctx.stroke(); // restore will let open all canvas area (remove clip) ctx.restore(); } } } , onWindowResize: function() { if(!this.canvas) { return; } // get parent size let parentW = this.canvas.parentNode.offsetWidth; let parentH = this.canvas.parentNode.offsetHeight; // resize canvas as BIG as parent if(this.canvas.width != parentW || this.canvas.height != parentH) { //console.log('parent: ' + parentW + '*' + parentH + ', canvas: ' + this.canvas.width + '*' + this.canvas.height); let min = Math.min(parentW, parentH); // square w = h this.canvas.width = min; this.canvas.height = this.canvas.width; this.MAX_RADIUS = (min / 2) - 10; // -5 : gap to avoid pie go outside parent // minus 10% this.radius = (this.MAX_RADIUS * 90) / 100; // center this.centX = min / 2; this.centY = this.centX;//this.height / 2; // resized means NEED TO recreate all slices and do full draw(), not only redraw same slices this.refreshSliceArray();//true); // force this.redraw(); } } , drawLabels: function() { //console.log('drawLabels()'); if (this.ctx) { let xy, centerRadian; for (let i in this.sliceArray) { let slice = this.sliceArray[i]; // to optimize speed by avoiding next calculation of xy, use cache !! if(slice.cachedRadius && slice.cachedRadius == slice.radius) { // hit cache (same as cached value), so use it xy = slice.cachedXY; //console.log('** cache hit'); } else { // no cache OR different with cached value, so update it slice.cachedRadius = slice.radius; // calculate center radian angle if(this.startFrom0oClock && slice.radStartAngle > slice.radEndAngle) { centerRadian = (slice.radStartAngle + slice.radEndAngle) - this.MAX_RADIAN_VALUE; centerRadian /= 2.0; } else { centerRadian = (slice.radStartAngle + slice.radEndAngle) / 2.0; } // get the point XY using slice's current radius xy = this.getPointXY(slice.radius, centerRadian); // save into cache slice.cachedXY = xy; } let text = this.data[i][0]; let textWidth = this.ctx.measureText(text); if(xy['x'] + textWidth.width >= this.canvas.width) { // over to the right, so change it xy['x'] = this.canvas.width - (textWidth.width + 3); } // text outline (stroke) this.ctx.fillStyle = '#777'; this.ctx.fillText(text, xy['x'] -1, xy['y'] -1); // top left this.ctx.fillText(text, xy['x'] -0, xy['y'] -1); // top center this.ctx.fillText(text, xy['x'] +1, xy['y'] -1); // top right this.ctx.fillText(text, xy['x'] -1, xy['y'] -0); // middle left this.ctx.fillText(text, xy['x'] -0, xy['y'] -0); // middle center this.ctx.fillText(text, xy['x'] +1, xy['y'] -0); // middle right this.ctx.fillText(text, xy['x'] -1, xy['y'] +1); // bottom left this.ctx.fillText(text, xy['x'] -0, xy['y'] +1); // bottom center this.ctx.fillText(text, xy['x'] +1, xy['y'] +1); // bottom right // main text, same like as the pie this.ctx.fillStyle = this.data[i][2]; this.ctx.fillStyle = '#fff';//this.data[i][2]; this.ctx.fillText(text, xy['x'], xy['y']); } } } , updateSliceArray: function(radius) { console.log('updateSliceArray(' + radius + ')'); for (let i in this.data) { this.sliceArray[i].radius = radius; } } , refreshSliceArray: function() { if(this.totalAllValue <= 0) { this.sliceArray = []; // no slice return; } let radStartAngle = 0, radEndAngle = 0; if(this.startFrom0oClock) { radStartAngle = 1.5 * Math.PI; } for (let i in this.data) { // how many degree this 1 slice ? .. 360 * (value / total) let angle = this.MAX_RADIAN_VALUE * (this.data[i][1] / this.totalAllValue); radEndAngle = radStartAngle + angle; if(this.startFrom0oClock && radEndAngle > this.MAX_RADIAN_VALUE) { radEndAngle -= this.MAX_RADIAN_VALUE; } this.sliceArray[i] = new this.SliceOfPie(this.data[i], radStartAngle, radEndAngle, this.radius); // update for next loop radStartAngle = radEndAngle; } } , getRadianDegree: function(x, y) { let x2 = x - this.centX; let y2 = y - this.centY; let v1dl = Math.sqrt((x2 * x2) + (y2 * y2)); let radDegree = Math.acos(x2 / v1dl); if (y2 < 0) { radDegree = this.MAX_RADIAN_VALUE - radDegree; } return radDegree; } , onMouseOver: function(e) { let x = e.pageX - this.canvas.offsetLeft, y = e.pageY - this.canvas.offsetTop, chk = (x - this.centX) * (x - this.centX) + (y - this.centY) * (y - this.centY); // v1dl, // deg; //console.log('x: ' + x + ', y: ' + y + ', chk: ' + chk + ', centX: ' + this.centX + ', centY: ' + this.centY + ', radius: ' + this.radius); // is point inside radius ? if (chk <= this.radius * this.radius) { // it is inside radius, so check inside which slice ? for (let i in this.sliceArray) { if(this.isPointInsideSliceOfPie(x, y, this.sliceArray[i])) { // this point is inside this slice of pie // reset previous active slice (if exist) if (this.activeSliceOfPie) { if(this.activeSliceOfPie != i) { // there is previous active slice and it is not this slice // so remove it this.sliceArray[this.activeSliceOfPie].radius = this.radius; // highlight new slice this.highlightSlice(i); } } else { // no previous active slice, so activate this slice //console.log('onMouseOver(), point: ' + x + ',' + y + ', inside pie: ' + i); this.highlightSlice(i); } break; } } } else { if (this.activeSliceOfPie) { let slice = this.sliceArray[this.activeSliceOfPie]; if( ! this.isPointInsideSliceOfPie(x, y, slice)) { // outside this.removeAllHighlightSlice(); this.redraw(); } } } } , removeAllHighlightSlice: function() { // reset all sclies' radius for(let i in this.sliceArray) { this.sliceArray[i].radius = this.radius; } // remove active slice delete this.activeSliceOfPie; this.redraw(); } , highlightSlice: function(sliceId) { // check limit if(this.sliceArray == null || this.sliceArray.length < 1 || sliceId < 0 || sliceId >= this.sliceArray.length) { //console.log('highlightSlice(' + sliceId + '), no array or over limit'); this.removeAllHighlightSlice(); return; } //console.log('highlightSlice(' + sliceId + ')'); // // reset ONLY previous highlighted slice // if(this.activeSliceOfPie) { // if(this.activeSliceOfPie == sliceId) { // console.log('highlightSlice(' + sliceId + '), same id, do nothing'); // return; // } // // // reset it // this.sliceArray[this.activeSliceOfPie].radius = this.radius; // this.drawSlice(this.sliceArray[this.activeSliceOfPie]); // } // reset all pies' radius to make sure there is only 1 pie highlighted !! for(let i in this.sliceArray) { this.sliceArray[i].radius = this.radius; } this.activeSliceOfPie = sliceId; // not just redraw() but need to use animate() this.animate(); } , isPointInsideSliceOfPie: function(x, y, slice) { //console.log('isPointInsideSliceOfPie(' + x + ',' + y + ', slice angle:' + slice.radStartAngle + ',' + slice.radEndAngle + ')'); // 1. check if the angle is inside or not (because cheaper/faster operator then checking distance/radius ???? TODO:) // calculate the Radian angle of this point let angle = Math.atan2(y - this.centY, x - this.centY); // if negative then convert to positive if(angle < 0) { angle += this.MAX_RADIAN_VALUE; } //console.log('angle: ' + angle); if(this.startFrom0oClock && slice.radStartAngle > slice.radEndAngle) { if(angle < slice.radStartAngle && angle > slice.radEndAngle) { return false; } } else if(angle < slice.radStartAngle || angle > slice.radEndAngle) { //console.log('** point is OUTSIDE slice angle'); return false; // this point is outside of pie } // 2. check distance and radius let pointDistance = ((x - this.centX) * (x - this.centX)) + ((y - this.centY) * (y - this.centY)); if(pointDistance > slice.radius * slice.radius) { //console.log('** point is OUTSIDE slice distance'); return false; // point is outside radius } return true; // this point is inside slice of pie } , redraw: function () { //console.log('redraw(), full drawing without setting sliceArray'); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); for (let i in this.sliceArray) { this.drawSlice(this.sliceArray[i]); } this.drawLabels(); } , animate: function () { //console.log('animate()'); if(this.activeSliceOfPie >= 0 && this.sliceArray[this.activeSliceOfPie]) { //console.log('animate(), active slice: ' + this.activeSliceOfPie); // make it BIGGER radius in steps (feel like animation) if (this.sliceArray[this.activeSliceOfPie].radius > this.MAX_RADIUS) { // limit this.sliceArray[this.activeSliceOfPie].radius = this.MAX_RADIUS; } else { // request to call this function again (recursive) window.requestAnimationFrame(this.animate.bind(this)); // use bind() for next loop } // make radius bigger (pop out effect) this.sliceArray[this.activeSliceOfPie].radius += this.animIncrementStep; // must redraw all slices, because previous active slice must be refreshed too this.redraw(); } } // start/init , _create: function(canvasElementId, data, startFrom0oClock) { if(canvasElementId == null || data == null) { return null; } this.canvas = document.getElementById(canvasElementId); if(this.canvas == null) { return null; } this.data = data; // parsing to boolean true/false, true = slice start from top, false = slice start from 3 O'Clock this.startFrom0oClock = startFrom0oClock ? true : false; this.ctx = this.canvas.getContext('2d'); // constant value this.ctx.font = 'bold 12px sans-serif'; // label font this.ctx.lineWidth = 5; // calculate total value for degToRad this.totalAllValue = 0; data.forEach(function(value, index, ele) { // calculate total this.totalAllValue += value[1]; }.bind(this)); // send 'this' to the function !! console.log('total all value: ' + this.totalAllValue); // init this.onWindowResize(); // catch mouse event related to this canvas this.canvas.addEventListener('mousemove', this.onMouseOver.bind(this), false); // make Responsive by catch window (viewport) resize and re-init window.addEventListener('resize', this.onWindowResize.bind(this), false); return this; // return of 'this' (PieChartJS) object } }; // IIFE (Immediately-invoked function expression) -> return PieChartJS of this data set object return self._create(canvasElementId, data, startFrom0oClock); } // function PieChartJS(canvas, data) { displayPieChart();