Hour Slider

This is a quick implementation based on the visual and interaction design of Stan Yakusevich which I stumbled upon on Dribbble. The playful concept and design made me curious as to how the interaction would feel when using it. Here's the code to make it work:


HourSlider = function( container, width, height )
{
    var _knob = { x: 20, y: height - 60, width: 40, height: 40, size: 20 };

    var _minK;
    var _maxK;
    var _value = 0.0;
    var _valueOld = 0.0;

    var _infoOffset = 90;
    var _infoOffsetOld = 0;

    var _hoursPart = [ 1, 3, 6, 12, 24 ];
    var _hoursFull = [];
    var _index = 0;

    //...

    _minK = _knob.size * 2;
    _maxK = _width - ( 2 * _minK );

    var hx = _width / 2;
    var hy = _height / 3;

    for( var i = 0; i < 24; ++i )
    {
        _hoursFull.push( {
            hour: ( i + 1 ),
            cx: hx, cy: hy, cs: 100, ca: 0,
            tx: hx, ty: hy, ts: 100, ta: 0
        } );
    }

    //...

    function render()
    {
        var kx = _knob.x;
        var ky = _knob.y;

        // clear all
        _context.clearRect( 0, 0, _width, _height );

        // background
        var gradient = _context.createLinearGradient( _width, 0, _width - ( _width / 4 ), _height );
        gradient.addColorStop( 0.00, hsv2rgb( 0.58 + ( ( 1 - _value ) * 0.52 ), 0.82, 1.00 ) );
        gradient.addColorStop( 1.00, hsv2rgb( 0.70 + ( ( 1 - _value ) * 0.32 ), 0.65, 1.00 ) );

        _context.beginPath();
        _context.rect( 0, 0, _width, _height );
        _context.fillStyle = gradient;
        _context.fill();

        // white panel with little wave
        _context.beginPath();
        _context.rect( 0, _height - 50, _width, 50 );
        _context.fillStyle = "#ffffff";
        _context.fill();

        _context.beginPath();
        _context.moveTo( kx - 90, ky + 10 );
        _context.bezierCurveTo( kx - 10, ky + 10, kx - 10, ky - 10, kx + 20, ky - 10 );
        _context.moveTo( kx + 20, ky - 10 );
        _context.bezierCurveTo( kx + 50, ky - 10, kx + 50, ky + 10, kx + 130, ky + 10 );
        _context.lineTo( kx - 90, ky + 10 );
        _context.fillStyle = "#ffffff";
        _context.fill();

        // slider track
        _context.beginPath();
        _context.moveTo( _minK, _knob.y + _knob.size );
        _context.lineTo( _minK + _maxK, _knob.y + _knob.size );
        _context.strokeStyle = "#dddddd";
        _context.stroke();

        // slider knob
        _context.save();
        _context.beginPath();
        _context.arc( _knob.x + _knob.size, _knob.y + _knob.size, _knob.size, 0, Math.PI * 2 );
        _context.shadowColor = "rgba(0, 0, 0, 0.3)";
        _context.shadowBlur = 8;
        _context.shadowOffsetX = 0;
        _context.shadowOffsetY = 3;
        _context.fillStyle = "#ffffff";
        _context.fill();
        _context.restore();

        // slider hours
        _context.textBaseline = "bottom";
        _context.textAlign = "center";
        _context.fillStyle = "#ffffff";
        _context.strokeStyle = "#ffffff";

        var step = _maxK / 4;

        for( var i = 0; i <= 4; ++i )
        {
            var p = { x: _minK + ( i * step ), y: _knob.y - 15 };
            var d = dist( p, _knob, 1.5 );
            var s = Math.floor( 12 + d.d * 2 );

            _context.beginPath();
            _context.moveTo( d.x, d.y + 6 );
            _context.lineTo( d.x, d.y );
            _context.stroke();

            _context.font = "100 " + s + "px sans-serif";
            _context.fillText( _hoursPart[ i ], Math.floor( p.x ), Math.floor( p.y - 10 ) );
        }

        // main hour value
        _context.textBaseline = "middle";
        _context.textAlign = "center";

        for( var j = 0; j < _hoursFull.length; ++j )
        {
            var h = _hoursFull[ j ];

            h.cx += ( h.tx - h.cx ) * 0.152;
            h.cy += ( h.ty - h.cy ) * 0.152;
            h.cs += ( h.ts - h.cs ) * 0.152;
            h.ca += ( h.ta - h.ca ) * 0.1;

            if( h.ca > 1 )
            {
                _context.font = "700 " + Math.floor( h.cs ) + "px sans-serif";
                _context.fillStyle = "rgba(255, 255, 255, " + ( h.ca / 100.0 ) + ")";
                _context.fillText( h.hour, h.cx, h.cy );
            }
        }

        // main hour info
        _context.font = "100 14px sans-serif";
        _context.textBaseline = "middle";
        _context.fillStyle = "#ffffff";
        _context.textAlign = "right";
        _context.fillText( "Every", _width * 0.5 - _infoOffsetOld, _height / 3 );
        _context.textAlign = "left";
        _context.fillText( "hours", _width * 0.5 + _infoOffsetOld, _height / 3 );

        // frame
        _context.beginPath();
        _context.rect( 0, 0, _width, _height );
        _context.strokeStyle = "#999";
        _context.stroke();

        _infoOffsetOld += ( _infoOffset - _infoOffsetOld ) * 0.0652;

        requestAnimationFrame( render );
    }

    function update( v )
    {
        _value = Math.max( Math.min( ( ( v - _minK ) / _maxK ), 1.0 ), 0.0 );

        var h = Math.floor( _value * ( _hoursPart.length - 1) );
        var n = _hoursPart.length - 1;
        var t = _value * 400;

        _knob.x = _minK + ( _value * _maxK ) - _knob.size;

        _index = Math.round( larp(
                        _hoursPart[ Math.min( h, n ) ],
                        _hoursPart[ Math.min( h + 1, n ) ],
                        ( t % 100 ) / 100
                        ) ) - 1;

        var hx = _width / 2;
        var hy = _height / 3;

        for( var i = 0; i < _hoursFull.length; ++i )
        {
            var hf = _hoursFull[ i ];
            var o = Math.max( Math.min( i - _index, 1 ), -1 );

            hf.tx = hx;
            hf.ts = 140;
            hf.ty = hy;
            hf.ta = ( ( i == _index ) ? 100.0 : 0.0 );

            if( o < 0 )
            {
                hf.tx -= o * 50;
                hf.ts -= Math.exp( o ) * 100;
            }
            else if( o > 0 )
            {
                hf.tx -= o * 200;
                hf.ts += Math.exp( o ) * 100;
            }
        }
    }

    function hsv2rgb( h, s, v )
    {
        s = Math.min( 1.0, s );
        v = Math.min( 1.0, v );

        var r, g, b, i, f, p, q, t;
        i = Math.floor( h * 6 );
        f = h * 6 - i;
        p = v * (1 - s);
        q = v * (1 - f * s);
        t = v * (1 - (1 - f) * s);
        switch( i % 6 )
        {
            case 0:
                r = v;
                g = t;
                b = p;
                break;
            case 1:
                r = q;
                g = v;
                b = p;
                break;
            case 2:
                r = p;
                g = v;
                b = t;
                break;
            case 3:
                r = p;
                g = q;
                b = v;
                break;
            case 4:
                r = t;
                g = p;
                b = v;
                break;
            case 5:
                r = v;
                g = p;
                b = q;
                break;
        }

        return "rgba(" + ( (r * 255) >> 0 ) + "," + ( (g * 255) >> 0 ) + "," + ( (b * 255) >> 0 ) + ", 1)";
    }

    function larp( a, b, p )
    {
        return (b - a) * p + a;
    }

    function dist( p, center, pow )
    {
        var radius = 120;

        var dx = p.x - center.x;
        var dy = p.y - center.y;
        var dd = Math.sqrt( dx * dx + dy * dy );

        if( dd >= radius )
        {
            return { x: p.x, y: p.y, d: 0 };
        }

        var e = Math.exp( pow );
        var k = ( e / (e - 1) * radius ) * (1 - Math.exp( -dd * ( pow / radius ) )) / dd * .75 + .25;
        var y = center.y + dy * k;

        return { x: p.x, y: y, d: Math.abs( p.y - y ) };
    }

    //...

};