Search code examples
javascriptjquerycssgeometrycss-transforms

Find a segment on a 3D cylinder/wheel based on a rotational angle using javascript


I have a 3D wheel that I am animating using javascript requestAnimationFrame() function.

The wheel looks like:

Static wheel visual

There are 4 main variables to concider:

  1. items The number of segments on the wheel.
  2. spinSpeed The spin speed modifier. Multiply the angle increase/decrease per frame by this value.
  3. spinDuration The length of the full speed spinning animation before decelerating to a stop.
  4. spinDirection The direction the wheel should spin. Accepts up or down.

Now I want to get the segment (where the red line intersects) from the DOM using the angle the wheel stopped on. The wheel segments have their arc start and end angles stored in data attributes. For example:

<div class="wheel__inner">
    <div class="wheel_segment" ... data-start-angle="0" data-end-angle="12.85">Item 1</div>
    <div class="wheel_segment" ... data-start-angle="12.85" data-end-angle="25.71">Item 2</div>
    <div class="wheel_segment" ... data-start-angle="25.71" data-end-angle="38.58">Item 3</div>
    ...
</div>

I am keeping track of the current wheel rotation by storing the modified angle on each tick. For example:

let wheelAngle = 0;

window.requestAnimationFrame( function tick() {

    if ( spinDirection === 'up' ) {
        wheelAngle += speedModifier;
    } else {
        wheelAngle -= speedModifier;
    }

   window.requestAnimationFrame( tick );
} );

When the animation comes to a stop, I attempt to get the segement by normalising the rotation and filtering segements using the start and end angles.

I normalise the rotation because it can go above 360° and below , I do this with the below function:

function normaliseAngle( angle ) {
    angle = Math.abs( angle ) % 360;
    angle = 360 - angle; // Invert
    return angle;
}

And filter elements using jQuery like so:

const $found = $wheel.find( '.wheel__segment' ).filter( function() {
    const startAngle = parseFloat( $( this ).data( 'start-angle' ) );
    const endAngle = parseFloat( $( this ).data( 'end-angle' ) );
    return angle >= startAngle && angle < endAngle;
} );

However, despite my best efforts I cannot get this to work. Please see my JSFiddle here: https://jsfiddle.net/thelevicole/ps04fnxm/2/

( function( $ ) {

    // Settings
    const items = 28; // Segments on wheel
    const spinSpeed = randNumber( 1, 10 ); // Spin speed multiplier
    const spinDuration = randNumber( 2, 5 ); // In seconds
    const spinDirection = randNumber( 0, 1 ) ? 'up' : 'down'; // Animate up  or down
    
    // Vars
    const $wheel = $( '.wheel .wheel__inner' );
    const diameter = $wheel.height();
    const radius = diameter / 2;
    const angle = 360 / items;
    const circumference = Math.PI * diameter;
    const height = circumference / items;
    
    // Trackers
    let wheelAngle = 0;
    const wheelStarted = new Date();
    
    // Add segments to the wheel
    for ( let i = 0; i < items; i++ ) {
        var startAngle = angle * i;
        var endAngle = angle * ( i + 1 );
        var transform = `rotateX(${ startAngle }deg) translateZ(${ radius }px)`;

        var $segment = $( '<div>', {
            class: 'wheel__segment',
            html: `<span>Item ${ i }</span>` 
        } ).css( {
            'transform': transform,
            'height': height,
        } );
        
        // Add start and end angles for this segment
        $segment.attr( 'data-start-angle', startAngle );
        $segment.attr( 'data-end-angle', endAngle );
        
        $segment.appendTo( $wheel );
    }
    
    
    /**
     * Print debug info to DOM
     *
     * @param {object}
     */
    function logInfo( data ) {
        const $log = $( 'textarea#log' );
        let logString = '';
        
        logString += '-----' + "\n";
        for ( var key in data ) {
            logString += `${ key }: ${ data[ key ] }` + "\n";
        }
        logString += "\n";
        
        // Prepend log to last value
        logString += $log.val();
        
        // Update field value
        $log.val( logString );
    }
    
    /**
     * Get random number between min & max (inclusive)
     *
     * @param {number} min
     * @param {number} max
     * @returns {number}
     */
    function randNumber( min, max ) {
        min = Math.ceil( min );
        max = Math.floor( max );
        return Math.floor( Math.random() * ( max - min + 1 ) ) + min;
    }
    
    /**
     * Limit angles to 0 - 360
     *
     * @param {number}
     * @returns {number}
     */
    function normaliseAngle( angle ) {
        angle = Math.abs( angle ) % 360;
        angle = 360 - angle;
        return angle;
    }
    
    /**
     * Get the wheel segment at a specific angle
     *
     * @param {number} angle
     * @returns {jQuery}
     */
    function segmentAtAngle( angle ) {

        angle = normaliseAngle( angle );
    
        const $found = $wheel.find( '.wheel__segment' ).filter( function() {
            const startAngle = parseFloat( $( this ).data( 'start-angle' ) );
            const endAngle = parseFloat( $( this ).data( 'end-angle' ) );
            return angle >= startAngle && angle < endAngle;
        } );
        
        return $found;
    }
    
    /**
     * @var {integer} Unique ID of requestAnimationFrame callback
     */
    var animationId = window.requestAnimationFrame( function tick() {
    
        // Time passed since wheel started spinning (in seconds)
        const timePassed = ( new Date() - wheelStarted ) / 1000;
        
        // Speed modifier value (can't be zero)
        let speedModifier = parseInt( spinSpeed ) || 1;
        
        // Decelerate animation if we're over the animation duration
        if ( timePassed > spinDuration ) {

            const decelTicks = ( spinDuration - 1 ) * 60;
            const deceleration = Math.exp( Math.log( 0.0001 / speedModifier ) / decelTicks );
            const decelRate = ( 1 - ( ( timePassed - spinDuration ) / 10 ) ) * deceleration;

            speedModifier = speedModifier * decelRate;

            // Stop animation from going in reverse
            if ( speedModifier < 0 ) {
                speedModifier = 0;
            }
        }
        
        // Print debug info
        logInfo( {
            timePassed: timePassed,
            speedModifier: speedModifier,
            wheelAngle: wheelAngle,
            normalisedAngle: normaliseAngle( wheelAngle )
        } );
        
        // Wheel not moving, animation must have finished
        if ( speedModifier <= 0 ) {
            window.cancelAnimationFrame( animationId );

            const $stopped = segmentAtAngle( wheelAngle );
            alert( $stopped.text() );

            return;
        }
        
        // Increase wheel angle for animating upwards
        if ( spinDirection === 'up' ) {
            wheelAngle += speedModifier;
        }
        
        // Decrease wheel angle for animating downwards
        else {
            wheelAngle -= speedModifier;
        }
        
        // CSS transform value
        const transform = `rotateX(${wheelAngle}deg) scale3d(0.875, 0.875, 0.875)`;

        $wheel.css( {
            '-webkit-transform': transform,
            '-moz-transform': transform,
            '-ms-transform': transform,
            '-o-transform': transform,
            'transform': transform,
            'transform-origin': `50% calc(50% + ${height/2}px)`,
            'margin-top': `-${height}px`
        } );
    
        // New tick
        animationId = window.requestAnimationFrame( tick );
    } );
    
} )( jQuery );
*, *:before, *:after {
  box-sizing: border-box;
}

.app {
  display: flex;
  flex-direction: row;
  padding: 15px;
}

textarea#log {
  width: 300px;
}

.wheel {
  perspective: 1000px;
  border: 1px solid #333;
  margin: 0 25px;
  flex-grow: 1;
}
.wheel:after {
  content: '';
  display: block;
  position: absolute;
  top: 50%;
  left: 0;
  right: 0;
  height: 2px;
  background-color: red;
  transform: translateY(-50%);
}
.wheel .wheel__inner {
  position: relative;
  width: 200px;
  height: 350px;
  margin: 0 auto;
  transform-style: preserve-3d;
}
.wheel .wheel__inner .wheel__segment {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 40px;
  position: absolute;
  top: 50%;
  background-color: #ccc;
}
.wheel .wheel__inner .wheel__segment:nth-child(even) {
  background-color: #ddd;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="app">
    <textarea id="log"></textarea>
    <div class="wheel">
        <div class="wheel__inner">
        </div>
    </div>
</div>


Solution

  • There are two problems.

    The following figure shows the state at wheelAngle = 0: Wheel Angle = 0

    In your code, item 0 has startAngle = 0 and endAngle = some positive value. This does not correspond to what you see. Actually, item 0 should be centered around 0. So you need to offset your regions by half the angle width of an item:

    var rotateAngle = angle * i;        
    var transform = `rotateX(${ rotateAngle }deg) translateZ(${ radius }px)`;
    var startAngle = rotateAngle - angle / 2
    var endAngle = rotateAngle + angle / 2;
    

    The second problem is your normalize function. You take the absolute value and therefore lose any orientation information. Here is a better version of the function:

    function normaliseAngle( angle ) {
        angle = -angle;
        return angle - 360 * Math.floor(angle / 360);
    }