Passion & Opportunity ? continue : break

How to make Pie Chart using vanilla Javascript?
Written: 2019-03-03 15:54:41 Last update: 2019-08-22 19:55:56

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.

NOTE:
  • 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.

Sorry, canvas not supported
Sorry, canvas not supported
Sorry, canvas not supported

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();