With Apple's new "Bedtime" feature on IOS utilising a radial slider with 2 control points, I thought I'd try my hand at developing a radial slider for the web - as an alternative to the usual horizontal slider. This slider will only have 1 control point, but could be easily modified to add a second.

My goto for this project is RaphaëlJS. It "just works" out of the box with touchscreen devices and on older browsers.

The Slider

We'll be producing this:
slider

Project Setup

For this, I'm using NPM and Node as a package manager, and as a web server. Go ahead and setup a new directory somewhere, and type the following commands inside it:

npm init
# just hit enter all the way through
# project name may need to be changed to conform
npm install --save connect serve-static raphael

Now load up your favourite text editor and create 3 new files: index.html, dial.js and server.js

Inside server.js we setup the connect http middleware framework and register the serve-static middleware.

var connect = require('connect');
var serveStatic = require('serve-static');
connect().use(serveStatic(__dirname)).listen(8080, function(){
    console.log('Server running on 8080...');
});

Inside index.html - some basic scaffolding. I'm using a thin Roboto variant from google in the dial.

<html>
    <head>
        <script src="node_modules/raphael/raphael.min.js"></script>
        <script src="dial.js"></script>
        <link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
    </head>
    <body>
        <div id="dial" style="width: 500px; height: 500px"></div>
    </body>
</html>

If you type the following into a terminal:

npm start

you should get a webserver on port 8080 that you can navigate to in your browser.

The Radial Slider Component Explained

The radial slider is made up of 4 parts:

  • The "donut" background element
  • The "arc" that represents the current percentage
  • The "drag handle"
  • The "text"

The donut is pretty basic. It's just a circle with a thick stroke.

The drag handle position however, and the arc require a tiny bit of maths. We'll need a couple of helpers.

The Mathy Bits

Don't copy and paste this stuff, proper source code further down. This is just for illustrative purposes

Get cartesian co-ordinates from angle

This takes a percentage (from 0 to 100), converts it straight into radians, and finds the x and y co-ords around the circumference of any given circle. Note that we're subtracting Math.PI / 2 because we want to rotate it 90 degrees left to put the origin at the top as opposed to on the right.

        // get cartesian coords from percentage around dial
        var getDialCoords = function(percent) {
            var angleInRadians = percent / 100 * Math.PI * 2 - Math.PI / 2;
            return { x: params.radius * Math.cos(angleInRadians) + params.centreX, y: params.radius * Math.sin(angleInRadians) + params.centreY };
        };
Draw a circular arc

Drawing a circular arc is a little more complicated. Thankfully, RaphaëlJS wraps SVG for us, so we can put together an SVG path string using the elliptical arc curve commands from the SVG spec.
We can't use this as-is to draw a 100 percent arc. We can handle that special use-case in our code by segmenting it into two 50 percent arcs.

        // get an SVG path string that describes a circular arc
        // Must be less than 360 degress
        var getCircularArcString = function(startPercent, endPercent) {

            var startPos = getDialCoords(startPercent);
            var endPos = getDialCoords(endPercent);
            var largeArcFlag = (endPercent - startPercent > 50) ? 1 : 0;

            var arcSVG = [params.radius, params.radius, 0, largeArcFlag, 1, endPos.x, endPos.y].join(' ');
            return 'M' + startPos.x + ' ' + startPos.y + ' A ' + arcSVG;
        };
And the last "mathy bit"

We need to do a little bit of maths for the drag handle - to calculate the angle between our cursor (x and y) and an origin (the centre of the circle). Math.atan2 to the rescue.

                // calculate angle from cursor position
                var angle = Math.atan2(y - params.centreY, x - params.centreX) + Math.PI / 2;
                var percent = angle / (Math.PI * 2) * 100;
                if(percent < 0)
                    percent = 100 + percent;

The RaphaëlJS bits

Since we'll be using RaphaëlJS - we should really wrap our component up so we can call it in the same way as any other component - i.e. paper.circle(), paper.rect(), or in our case: paper.dial().

We do this is by using Raphael.fn to extend, and since we want to be good non-polluting JavaScript citizens - we'll use the "Revealing Module Pattern" to implement our logic, as below:

Raphael.fn.dial = function(setParams) {
    var paper = this;
    var dial = (function() {
        // do private stuff here without polluting Raphael or global scope
        return {
             // put stuff you want to make public here
             somePublicProperty: somePrivateProperty,
             somePublicMethod: somePrivateMethod
        }
    })();
    return dial;
};

The Radial Slider

Without further ado, here it is! Throw this into dial.js

Raphael.fn.dial = function(setParams) {

    var paper = this;

    var dial = (function() {

        // default params
        var params = {
            centreX: paper.width / 2,
            centreY: paper.height / 2,
            strokeWidth: Math.min(paper.width, paper.height) * 0.1,
            radius: Math.min(paper.width, paper.height) * 0.4,
            backgroundColor: '#eee',
            dialColor: '#387bc6',
            handleColor: '#fff',
            handleRadius: Math.min(paper.width, paper.height) * 0.1 * 0.45,
            dialPercent: 30,
            textColor: '#555',
            textFontFamily: 'Roboto',
            textFontSize: Math.min(paper.width, paper.height) / 4,
            onDrag: undefined,
            onStartDrag: undefined,
            onEndDrag: undefined
        };

        // set params from passed arguments (if present)
        if(typeof(setParams) != 'undefined') {
            for(var i in params)
                if(typeof(setParams[i]) != 'undefined')
                    params[i] = setParams[i];
        }
        

        // Helper functions
        // ----------------

        // get cartesian coords from percentage around dial
        var getDialCoords = function(percent) {
            var angleInRadians = percent / 100 * Math.PI * 2 - Math.PI / 2;
            return { x: params.radius * Math.cos(angleInRadians) + params.centreX, y: params.radius * Math.sin(angleInRadians) + params.centreY };
        };

        // get an SVG path string that describes a circular arc
        // Must be less than 360 degress
        var getCircularArcString = function(startPercent, endPercent) {

            var startPos = getDialCoords(startPercent);
            var endPos = getDialCoords(endPercent);
            var largeArcFlag = (endPercent - startPercent > 50) ? 1 : 0;

            var arcSVG = [params.radius, params.radius, 0, largeArcFlag, 1, endPos.x, endPos.y].join(' ');
            return 'M' + startPos.x + ' ' + startPos.y + ' A ' + arcSVG;
        };

        // function to draw a path for the dial on the paper
        var getDialPathString = function() {

            var percent = Math.min(params.dialPercent, 100); // percentages over 100 don't make sense
            if(percent == 100) // special case
                return getCircularArcString(0,50) + ' ' + getCircularArcString(50,100);
            
            return getCircularArcString(0, percent);
        };
        
        var background, dial, text, handle;

        // draw/redraw
        var refresh = function() {
            // background
            if(typeof(background) != 'undefined')
                background.remove();
            
            background = paper.circle(params.centreX, params.centreY, params.radius).attr({
                'stroke-width': params.strokeWidth,
                'stroke': params.backgroundColor
            });

            // dial
            if(typeof(dial) != 'undefined')
                dial.remove();
            
            dial = paper.path(getDialPathString()).attr({
                'stroke-width': params.strokeWidth,
                'stroke-linecap': 'round',
                'stroke': params.dialColor
            });
            
            // text
            if(typeof(text) != 'undefined')
                text.remove();
            
            text = paper.text(params.centreX, params.centreY, Math.round(params.dialPercent) + "%").attr({
                'font-family': params.textFontFamily,
                'font-size': params.textFontSize,
                'fill': params.textColor
            });

            // handle
            if(typeof(handle) != 'undefined')
                handle.remove();

            var handlePos = getDialCoords(params.dialPercent);
            handle = paper.circle(handlePos.x, handlePos.y, params.handleRadius).attr({
                'fill': params.handleColor,
                'stroke-width': 0,
                cursor: 'pointer'
            });

            // handle event handlers
            // ---------------------

            // start drag
            var startDrag = function() {
                this.ox = this.attr('cx');
                this.oy = this.attr('cy');

                if(typeof(params.onStartDrag) != 'undefined')
                    params.onStartDrag(params.dialPercent);
            };

            // dragging
            var drag = function(dx,dy) {

                var x = this.ox + dx;
                var y = this.oy + dy;

                // calculate angle from cursor position
                var angle = Math.atan2(y - params.centreY, x - params.centreX) + Math.PI / 2;
                var percent = angle / (Math.PI * 2) * 100;
                if(percent < 0)
                    percent = 100 + percent;
                
                // edge case: if you dragged past 0 or 100, cap it if within 15%
                if(arguments.callee.lastPercent > 85 && percent < 15)
                    percent = 100;
                if(arguments.callee.lastPercent < 15 && percent > 85)
                    percent = 0;
                arguments.callee.lastPercent = percent;

                // update params based on cursor position
                params.dialPercent = percent;

                // reposition handle
                var pos = getDialCoords(params.dialPercent);
                handle.attr({
                    'cx': pos.x,
                    'cy': pos.y
                });

                dial.attr('path', getDialPathString()); // update dial to new values
                text.attr('text', Math.round(params.dialPercent) + "%");

                if(typeof(params.onDrag) != 'undefined')
                    params.onDrag(params.dialPercent);
            };

            // end drag
            var endDrag = function() {
                if(typeof(params.onEndDrag) != 'undefined')
                    params.onEndDrag(params.dialPercent);
            };

            // add event handlers
            handle.drag(drag, startDrag, endDrag);

        }

        // set dial color
        var setDialColor = function(color)
        {
            params.dialColor = color;
            dial.attr('stroke', color);
        }

        // set dial percent
        var setDialPercent = function(percent)
        {
            params.dialPercent = percent;
            var pos = getDialCoords(params.dialPercent);
            handle.attr({
                'cx': pos.x,
                'cy': pos.y
            });

            dial.attr('path', getDialPathString()); // update dial to new values
            text.attr('text', Math.round(params.dialPercent) + "%");
        }

        // draw everything
        refresh();

        // expose public methods and properties
        return {
            params: params,
            refresh: refresh,
            setDialColor: setDialColor,
            setDialPercent: setDialPercent
        };

    })();
    
    return dial;
};

And throw some script into index.html:

<html>
    <head>
        <script src="node_modules/raphael/raphael.min.js"></script>
        <script src="dial.js"></script>
        <link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
    </head>
    <body>
        <div id="dial" style="width: 500px; height: 500px"></div>
        <script>
            var paper = new Raphael('dial');
            var dial = paper.dial();
        </script>
    </body>
</html>

If you want to see how easy this is to extend, try this instead:

<html>
    <head>
        <script src="node_modules/raphael/raphael.min.js"></script>
        <script src="dial.js"></script>
        <link href="https://fonts.googleapis.com/css?family=Roboto:100" rel="stylesheet">
    </head>
    <body>
        <div id="dial" style="width: 500px; height: 500px"></div>
        <script>
            var paper = new Raphael('dial');
            var dial = paper.dial({
                dialColor: Raphael.hsb(0.1, 1, 0.9),
                onDrag: function(percent){
                    var h = percent / 300;
                    dial.setDialColor(Raphael.hsb(h, 1, 0.9));
                }
            });
        </script>
    </body>
</html>

In the second example, I've added a color change effect in the Red to Green part of the Hue spectrum.

In Summary

Hopefully this illustrates just how easy it is to build modular components in RaphaëlJS. This example can be easily extended to include additional public methods or configuration parameters, or to include a second drag handle for the "Start Percent" with only a few lines of code.

It can also be seamlessly dropped into an existing RaphaëlJS project.