Photo of source code from this site

Animated Text Arc

Posted on:
April 4, 2015
Posted in:
JavaScript, Programming, Web Dev

Below I’m going to present a method for creating animated text that “bounces” along an arc that I recently created for work. Before you start looking at the code, though, you should have a look at the page.

Original Magazine Layout Web Layout

So the inspiration here is pretty obvious – the print designer laid her text out on an arc and, upon seeing that, I asked “what if that animated in somehow?”

A Note on “Pure CSS”

A lot of web developers put a lot of pride in doing “pure CSS” design and animation. While I understand the value (any standards-compliant browser will display it identically, the browser will hardware accelerate any animations it can, etc …) a lot of developers are a little too willing to just accept the limitations.

For example, CSS Tricks has an excellent example of a pure CSS way to set text on a circle. They also explain a lot of the geometry pretty well.

CSS Tricks

Here’s where I had to diverge from them:

Let’s make sure each box is the same exact size by using a monospace font.

This meant I couldn’t use their technique since I had to adhere to a print design that didn’t use a monospace font. I needed to get a little bit inventive.

The CSS Portion

The CSS portion of this is incredibly important. Just because it isn’t the total solution doesn’t mean it isn’t a huge part.

.page-middle .header-image .header-replacement {
	font-size: 8em;
	font-weight: 700;
	height: 1em;
	position: absolute;
	bottom: 1.5em;
	left: 50%;
	text-transform: uppercase;
	letter-spacing: -0.05em;
	text-shadow: 0em 0.05em 0.05em rgba(0,0,0,0.75);
}

.page-middle .header-image .header-replacement span {
	display: block;
	position: absolute;
	transform-origin: left center;
	-o-transform-origin: left center;
	-ms-transform-origin: left center;
	-webkit-transform-origin: left center;
	-moz-transform-origin: left center;
}

.header-replacement is a DIV element that gets substituted in place of the H1 tag at page load. We position it (within the header image) at dead center, 1.5em from the bottom. Inside .header-replacement we’re going to make a bunch of SPAN tags – one for each letter of the title. Those are also positioned absolutely, but since they don’t have any left, right, top, or bottom, they just sit at the top-left of .header-replacement.

In order to get the arc, I use a similar technique to CSS Tricks, but instead of making a very tall box and transforming about the bottom, I translate the letter to the middle of the circle, rotate it to its position, then translate it back out to the perimeter.

The JavaScript

/**
singleton   MSU_BumperCropTitle
    description     Dices the page's title into individual letters, then creates
                    the an arc out of them.
**/
window.MSU_BumperCropTitle = new function () {
    this.headerNode =           null;
    this.replacementNode =      null;
    this.letterArray =          null;
    this.radius =               null;
    this.spread =               0.7;    // the spread of the text in radians
    this.animationOffset =      75;     // offset from one letter's animation to the next in ms
    this.animationStartTime =   null;   // the time the animation starts
    this.lastFrameTime =        null;   // the time of the last frame
    this.springConstant =       0.015;  // spring constant K
    this.dampening =            0.1;    // a wind resistance force
    this.anyFinished =          false;  // a flag that's true when the first letter finishes
    this.timeSliceLength =      1/60;   // time slice length = 1/60 second

In order to keep everything contained I decided to make a singleton that manages the title and animation. Most of these variables are self explanatory, and those that aren’t are better explained when you see them used.

    /**
    function updatePositions
        arguments       none
        returns         none
        description     updates the current position of each node based on the
                        current font size and animation
    **/
    this.updatePositions = function () {
        // find the rendered length of the string
        var renderedWidth = 0;
        for (i=0; i<this.letterArray.length; i++) {
            this.letterArray[i].width = $(this.letterArray[i].node).outerWidth();
            renderedWidth += this.letterArray[i].width;
        }
        
        // now we can get our radius
        this.radius = renderedWidth/this.spread;
        
        // iterate through the array and apply the appropriate transformation
        for (i=0; i<this.letterArray.length; i++) {
            var currentPos = this.letterArray[i].currentPos;
            
            var transformations = new Array();
            transformations.push("translate(-"+(this.letterArray[i].width/2)+"px, 0px)");   // center letter
            transformations.push("translate(0px, -"+this.radius+"px)");                     // move to the middle of the circle
            transformations.push("rotate("+currentPos+"rad)");                              // rotate to correct angle
            transformations.push("translate(0px, "+this.radius+"px)");                      // push out to edge of circle
            
            transformations.reverse();
            
            $(this.letterArray[i].node).css("transform", transformations.join(" "));
            $(this.letterArray[i].node).css("-o-transform", transformations.join(" "));
            $(this.letterArray[i].node).css("-ms-transform", transformations.join(" "));
            $(this.letterArray[i].node).css("-webkit-transform", transformations.join(" "));
            $(this.letterArray[i].node).css("-moz-transform", transformations.join(" "));
        }
    };

Much like a video game, I built a simple loop. The loop checks the amount of time passed from the last loop, executes as many frames of simulation it needs to catch up to “now,” then draws the results to the screen. This is my drawing function.

Remember that I’m using a variable width font that resizes as the viewport resizes (and, since it’s a dynamic font, it might even load after the animation begins). Because of that I need to do some calculation up-front.

I want my text to occupy a certain “spread” (in this case, it’s 0.7 radians – a number I got from measuring the print layout in Photoshop). Once I have that length, I can use the arc length formula to figure out what radius circle I’d need for that length to occupy my desired spread.

With this I then iterate through each letter, generate the letter’s transformation, then apply it as inline CSS. Remember, CSS expects your transformations in reverse order. I find it easier to simply write them in regular order and then reverse them using JavaScript instead.

    /**
    function handleResize
        arguments       none
        returns         none
        description     reflows layout when the browser resizes
    **/
    this.handleResize = function () {
        this.updatePositions();
    };

On resize, I update positions again. Whenever the font size changes, the circle radius will change.

    /**
    function animationLoop
        arguments       none
        returns         none
        description     handles one frame of animation
    **/
    this.animationLoop = function() {
        var anyUnfinished = false;
        var now = (new Date()).getTime();
        var deltaTime = (now - this.lastFrameTime)/1000;
        var distance = 0;
        var force = 0;
        var resistance = 0;
        var anyFinishedThisFrame = false;
        
        // find out how many time slices have elapsed
        var nTimeSlices = Math.floor(deltaTime/this.timeSliceLength);
        console.log("nTimeSlices: "+nTimeSlices);
        
        // iterate through time slices
        for (var ts=0; ts<nTimeSlices; ts++) {
            // initialize some things
            anyFinishedThisFrame = false;
        
            // iterate through each item
            for (var i=0; i<this.letterArray.length; i++) {

                // if the item is eligible for animation
                if (now > this.letterArray[i].startTime) {

                    // calculate distance from end position
                    distance = this.letterArray[i].endPos - this.letterArray[i].currentPos;

                    // if the velocity and distance are NOT negligible
                    if (Math.abs(distance) > 0.001 || Math.abs(this.letterArray[i].vel) > 0.001) {

                        // calculate the force applied by the virtual spring
                        force = this.springConstant*distance;

                        // accelerate according to force
                        resistance = -this.letterArray[i].vel*this.dampening;
                        force += resistance;
                        this.letterArray[i].vel += force*this.timeSliceLength; // for simplicity, let's assume mass = 1

                        // update position
                        this.letterArray[i].currentPos += this.letterArray[i].vel*this.timeSliceLength;
                        
                        // note that we're not done
                        anyUnfinished = true;

                    // if it's negligible, 
                    } else {

                        // move to the final position
                        this.letterArray[i].currentPos = this.letterArray[i].endPos;
                        this.letterArray[i].vel = 0;
                        anyFinishedThisFrame = true;
                    }
                // otherwise
                } else {
                    // make note that we're not done
                    anyUnfinished = true;
                }
            }

            // if this frame is the first one where a letter stops,
            if (anyFinishedThisFrame && !this.anyFinished) {
                // fade in sub-head
                $(".header-image .sub-head").fadeIn(400, function () {
                    // fade in byline
                    $(".header-image .byline").fadeIn(400);
                });
            }

            // update whether or not any have finished
            this.anyFinished = this.anyFinished | anyFinishedThisFrame;
        }
        
        // if this callback resulted in any work
        if (nTimeSlices>0) {
            // check to see if that work leads to completion
            
            // advance the timer so that we can get our delta
            this.lastFrameTime = this.lastFrameTime + nTimeSlices*this.timeSliceLength;
            
            // update DOM objects with their new positions
            this.updatePositions();
            
            // if there are any UNFINISHED
            if (anyUnfinished) {
                // continue animation
                setTimeout(this.animationLoop.bind(this), 10);
            } else {
            }
        } else {
            // If there was no work done, just schedule a callback
            setTimeout(this.animationLoop.bind(this), 10);
        }
    };

The animation loop here is a little wonky and, had I started off thinking I’d be doing the animation this way (instead of spectacularly failing at keyframe animation) I would’ve broken this into two functions – the loop and a simulation function.

The actual math isn’t very complicated – it’s a combination of two very simple Physics I principles. I use a spring formula to “pull” the letters into place, and a simplistic wind resistance formula to slow the letters down. Eventually the potential energy from the springs bleeds out of the system through the wind resistance and the letters reach equilibrium.

Since there’s no actual end-point to the animation, we have to watch the letters to figure out when they’ve reached equilibrium (or at least close enough). That’s done by looking for any point where the letter has effectively stopped and is also at the end-point (not just bouncing through). When the first letter to stop actually does stop, we trigger a fade-in animation for the sub header. When all of the letters have stopped, we end the simulation (by not calling our loop again).

In order to get a consistent animation across platforms, I do the simulation in discrete time slices. The browser is going to call the callback relatively steadily, but there will be some fluctuation. So when we get our callback, we have to figure out how many time slices have occurred since the last callback and then iterate through that many slices of animation.

Finally, when the simulation has caught up to “now,” we call the drawing function.

	/**
	function startAnimation
		arguments		none
		returns			none
		description		starts the animation loop
	**/
	this.startAnimation = function () {
		for (var i=0; i<this.letterArray.length; i++) {
			this.letterArray[i].startTime = (new Date()).getTime() + (this.letterArray.length-i-1)*this.animationOffset;
			this.letterArray[i].currentPos = this.letterArray[i].startPos;
		}
		this.animationStartTime = (new Date()).getTime();
		this.lastFrameTime = this.animationStartTime;
		this.anyFinished = false;
		this.animationLoop();
	};

startAnimation is pretty straightforward. It initializes several variables and initiates the animation loop.

	/**
	function init
		arguments		inNode		the node we're tracking
		returns			none
		description		initializes the object, sets starting and ending angles
	**/
	this.init = function(inNode) {
		// hide sub-head and byline
		$(".header-image .sub-head").hide();
		$(".header-image .byline").hide();
	
		// save the node and get the string of letters
		this.headerNode = inNode;
		
		// get the title string
		var titleString = $(this.headerNode).text();
		
		// create the replacement node
		this.replacementNode = document.createElement("DIV");
		this.replacementNode.className = "header-replacement";
		
		// init letter array, and create an entry for each letter
		this.letterArray = new Array();
		for (var i=0; i<titleString.length; i++) {
			// create a structure to hold the data we want to track
			var tmp = {
				node: document.createElement("SPAN"),
				letter: titleString.charAt(i),
				width: null,
				startPos: -0.5*Math.PI,
				endPos: null,
				currentPos: null,
				vel: 0,
				startTime:null
			};
			tmp.node.innerHTML = tmp.letter==" "?"&nbsp;":tmp.letter;
			
			// put the node in the replacementNode
			this.replacementNode.appendChild(tmp.node);
			
			// store data in array
			this.letterArray.push(tmp);
		}
		
		// hide the header node and put in the replacement node
		$(this.headerNode).css("top", "-1000px");
		this.headerNode.parentNode.insertBefore(this.replacementNode, this.headerNode);
		
		// find the rendered length of the string
		var renderedWidth = 0;
		for (i=0; i<this.letterArray.length; i++) {
			this.letterArray[i].width = $(this.letterArray[i].node).outerWidth();
			renderedWidth += this.letterArray[i].width;
		}
		
		// now we can get our radius
		this.radius = renderedWidth/this.spread;
		
		// assign ending positions for each
		var offsetWidth = 0;
		for (i=0; i<this.letterArray.length; i++) {
			var pct = (offsetWidth + this.letterArray[i].width/2)/renderedWidth;
			offsetWidth += this.letterArray[i].width;
			this.letterArray[i].endPos = (pct-0.5)*(this.spread);
		}

		// bind the resize handler
		$(window).resize(this.handleResize.bind(this));
		this.handleResize();
		
		// queue up the starting animation
		$(window).load(this.startAnimation.bind(this));
	};

	return this;
};

The last function in our singleton is the init function. The title for the story is actually stored in a search-engine friendly H1 tag, so we have to generate a substitute that can be animated. It would be ridiculous to sacrifice SEO for an animation. I also make sure to save all of the spans we create into a member array so that all of the member functions can access them. This makes much more sense than querying the DOM every frame.

All in all, I’m pretty happy with the way this turned out. It also gave me some insight into strategies for writing a fixed-framerate game when you can’t guarantee the framerate.