How to make a text-filled circle

The Objective

Draw lines of text in the shape of a circle, using JavaScript.

The Circle Math

I had to look this up. It's been a very long time since junior high, when I took geometry. I vaguely remembered that there was a way to calculate the width of a circle at any given point, and that's all I really need.

  • Radius: line running from the middle of the circle to any point on the boundary of the circle.
  • Chord: line from any one point on the boundary of a circle to any other point. Diameter is a chord, the longest possible chord.
  • Apothem: line formed from the middle of the circle to a point on the chord that is perpendicular to that line.

If you know two of the three (apothem, chord, radius) parameters, you can calculated the last one. We need the formula for calculating the chord , given the apothem and the radius. In JavaScriptese:

chord = Math.floor(2*(Math.sqrt(Math.pow(radius,2) - Math.pow(apothem,2))));

Drawing a circle with lines

First try the circle maker:

Enter diameter (max 800):
px

Then, read the code:

var circleCalc = {

  apothem:0,
  chord:0,
  diameter:0,
  radius:0,
  
  init: function(diam) {
    this.diameter = diam || 300; 
    this.radius = Math.floor(this.diameter/2);
    this.apothem = this.radius - 1;
    this.setChord();
  },
  
  setChord: function() {
    this.chord = Math.floor(2*(Math.sqrt(Math.pow(this.radius,2) -
                               Math.pow(this.apothem,2))));
  },
  
  setApothem: function(apo) {
    this.apothem = apo;
  },
  
  getApothem: function() {
    return this.apothem;
  },
  
  getChord: function() {
    return this.chord;
  },
  
  getRadius: function() {
    return this.radius;
  }
  
};

function circleDraw( ) {
      // Our own circleCalc object to play with.
      // Object.create() doesn't work in IE <9.  Fix is provided at the bottom
  var drawCircleCalc = Object.create(circleCalc),
  
      cirHtml = "",
      // Default diameter for falsey values
      diameter = parseInt(document.getElementById("diamInput").value) || 800;
  
  // diameter forced at 800 max
  if (diameter>=800) {
  
    diameter = 800;
    document.getElementById("diamInput").value = 800;
    
  }
  
  // Initializes drawCircleCalc object
  drawCircleCalc.init(diameter);
  
  // * Notes below
  while (Math.abs(drawCircleCalc.getApothem())<drawCircleCalc.getRadius()) {
   
    cirHtml += "<div style='margin-left:"
    + -(Math.ceil(drawCircleCalc.getChord() / 2)) + "px;" +
    "width:" + drawCircleCalc.getChord() + "px;
    border-top:1px solid;'> </div>";
    
    drawCircleCalc.setApothem( drawCircleCalc.getApothem() - 1 );
    drawCircleCalc.setChord();
  }
  
  document.getElementById("drawnCircle").innerHTML = cirHtml;
}
* while loop notes
  • Loops until the apothem is equal to or bigger than the radius.
  • Works because initial apothem is set at one less than radius
  • Apothem may be negative. Although apothems cannot be negative, per se, it doesn't hurt the chord calculations, and it might be helpful to determine which hemisphere you're in
** html notes
  • Negative margin is always half the width (chord), aligning the center of each line.
  • Make sure you style the containing blocks to accomodate the large negative margin
  • To draw a 1px line, have empty boxes with a 1px border-top or border-bottom, making the total height 1px. See Box Model if this is confusing.
  • To draw an empty circle, set the height at 1px and set left and right borders

There isn't much to this code. If the circleCalc code looks foreign to you, you might look at the articles on Object-Oriented JavaScript and Closure. I don't always think it's necessary to write an object-oriented rendition of a solution. It really depends on what you're using the code for, how you plan to use it in the future and what your codebase looks like right now.

This time, though, I just HAD to. Why? Because circles are the classic example of OOP, and I've never stumbled upon a programming problem that requires an object representation of a circle. Until now.

Filling the circle with text

All that is left to do is fill up the boxes we've drawn with text. Vertically, we just have to figure out how tall the text is, make the boxes that high, and decrement the apothem by that number each time through the loop. Finding the height of a particular font is implemented in String.getHeight in the code below. It turns out the actual rendered height of the same font may be different in different browsers.

Horizontally, it's not so simple. In any given div, you declare text-align:justify to have the browser use its justification algorithm to split the sentences into lines and spread the lines out to touch both sides, instead of the jagged right edge we normally use. However, we're creating lots of different divs, forcing us to split the sentences into one-line boxes manually.

First part is to determine how many characters fit into each box. If we were to use only monospace type fonts, like Courier, we could divide the width of the line by the uniform font width to determine the number of characters. But since I'm using the "Comic Sans MS", which is anything but uniform in width, and since I'd like to accommodate all fonts and sizes, I had to come up with a different method.

These are the steps:
1) Approximate a character count
a. Get an average width for font (implemented in String.getAvgWidth)
b. Divide line width by a)
2) Measure the actual width of approximate character count (implemented in String.getWidth)
3a) If too small, add the next character from the string, measure and compare to line width, repeat until the actual width is too big or exactly the same as the line width.
3b) If too large, take away a character from the string, measure and compare to line width, repeat until the actual width is too small or exactly the same as the line width.
textCircle.procCharCount in the code below implements these steps.

Second part is to cut the line off at the end of the last full word - "some string".lastIndexOf(" ") - when the line isn't already at the end of a word. The line is already at the end of a word when a) the last character is " " b) the character immediately after the last character is " " c) the last character or the character right after the last character doesn't exist, signifying that the string is over. This is implemented in textCircle.procSubString.

After we split the text block into one-line boxes, we can use the browser to justify the text in each box. One additional problem is that justification only takes place if there are more than one lines in a box. In order to trick the browsers into believing there is another line, to kick in the justification, we append <span style='display:inline-block;visibility:hidden;width:100%;'></span> to our one line.

That's pretty much it. First, try the text circle maker:

Enter diameter (max 800):
px

All the code:

var textCircle = ( function() {

  var text = '',
      cirHtml = '',
      lineHeight = 0,
      textCircleCalc = Object.create(circleCalc),
      testElement = {},
      lastLine = false,
      charCount = 0;
  
  //  Constructs an HTML with invisible, trick second line
  function addHtml( substr ) {
    
    cirHtml += '
' + substr.trim(); if (!lastLine) { cirHtml += ' '; } cirHtml += '
'; } // Counts the characters that fit into the given width function procCharCount() { text = text.trim(); // Sets starting point with avg font width charCount = Math.floor( textCircleCalc.getChord() / String.getAvgWidth( testElement ) ); var stringWidth = text.substr( 0, charCount ).getWidth(testElement), targetWidth = textCircleCalc.getChord(), initDir = 0, nextDir = 0; if ( stringWidth > targetWidth ) { initDir = nextDir = -1; } else if ( stringWidth < targetWidth ) { initDir = nextDir = 1; } // while going the same direction on the string while ( nextDir * initDir > 0 ) { charCount += nextDir; stringWidth = text.substr( 0 , charCount ).getWidth( testElement ); if ( stringWidth > targetWidth ) { nextDir = -1; if ( nextDir * initDir < 0) { charCount = charCount - 1; } } else if ( stringWidth < targetWidth ) { // No more string if ( text.charAt( charCount ) === '' ) { lastLine = true; break; } nextDir = 1; } else { nextDir = 0; } } } // Cut the line off at the end of the last full word function procSubString() { // if the substring is cut in the middle of a word, if (text.charAt( charCount ) !== ' ' && text.charAt( charCount - 1 ) !== ' ' && text.charAt( charCount - 1 ) !== '' && text.charAt( charCount ) !== '') { charCount = text.substr( 0, charCount ).lastIndexOf( ' ' )+1; } } // Primary Text controller function procString() { while ( Math.abs( textCircleCalc.getApothem() ) < textCircleCalc.getRadius() && !lastLine ) { procCharCount(); procSubString(); addHtml( text.substr(0,charCount) ); text = text.substring( charCount ).trim(); textCircleCalc.setApothem( textCircleCalc.getApothem() - lineHeight ); textCircleCalc.setChord(); } } function init( diam, str, testEle ) { textCircleCalc.init(diam); text = str.trim(); lineHeight = String.getHeight(testEle); testElement = testEle; cirHtml = '', lastLine = false } return { // Only accessible public function makeTextCircle: function( diam, str, testEle) { init(diam, str, testEle); procString(); return cirHtml; } }; // end return block }() ); var circleCalc = { apothem:0, chord:0, diameter:0, radius:0, init: function(diam) { this.diameter = diam || 300; this.radius = Math.floor(this.diameter/2); this.apothem = this.radius - 1; this.setChord(); }, setChord: function() { this.chord = Math.floor(2*(Math.sqrt(Math.pow(this.radius,2) - Math.pow(this.apothem,2)))); }, setApothem: function(apo) { this.apothem = apo; }, getApothem: function() { return this.apothem; }, getChord: function() { return this.chord; }, getRadius: function() { return this.radius; } }; /****************************** String handling ********************************/ String.prototype.trim = function() { return this.replace(/^\s+|\s+$/g,''); } /**************************************************** Returns the measured width of a String, given the element string will be tested. Implementation: 'Some String'.getWidth(someElement); *****************************************************/ String.prototype.getWidth = function(ele) { var element = document.createElement('div'); element.style.visibility = 'hidden'; element.style.width = 'auto'; element.style.height = 'auto'; element.style.position = 'absolute'; element.appendChild(document.createTextNode(this)); ele.appendChild(element); var returnNumber = element.clientWidth; ele.removeChild(element); return returnNumber; } /**************************************************** Returns the measured height of a String, given the element string will be tested. Implementation: String.getHeight(someElement); *****************************************************/ String.getHeight = function(ele) { var element = document.createElement('div'); element.style.visibility = 'hidden'; element.style.width = 'auto'; element.style.height = 'auto'; element.style.position = 'absolute'; element.appendChild(document.createTextNode('abc')); ele.appendChild(element); var returnNumber = element.clientHeight; ele.removeChild(element); return returnNumber; } /**************************************************** Returns the average width of a character in a given element. Implementation: String.getAvgWidth(someElement); *****************************************************/ String.getAvgWidth = function(ele) { var element = document.createElement('div'); var testString = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; element.style.visibility = 'hidden'; element.style.width = 'auto'; element.style.height = 'auto'; element.style.position = 'absolute'; element.appendChild(document.createTextNode(testString)); ele.appendChild(element); var returnNumber = Math.floor(element.clientWidth/testString.length); ele.removeChild(element); return returnNumber; } /****************************** Object handling ********************************/ /**************************************************** Handles native function Object.create in <IE9 *****************************************************/ if(!Object.create) { Object.create = function (o) { function F() {}; F.prototype = o; return new F(); }; } // Calling function function circleDrawText() { var diameter = parseInt(document.getElementById('diamInputText').value) || 800; if (diameter>=800) { diameter = 800; document.getElementById('diamInputText').value = 800; } document.getElementById('drawnCircleText').innerHTML = textCircle.makeTextCircle( diameter, document.getElementById('bigSquareText').innerHTML, document.getElementById('stringTestStrip') ); }

I like the mostly private design of textCircle because the implementation of the functions have to go in a particular order and can be easily confused. In a moment of rebellion, I made init() also private, leaving only one accessible, public function - makeTextCircle(). It's a good way to protect myself from myself, who will certainly misuse and abuse whatever she can, whenever she can.

I tried not to use JQuery or Prototype here because it's not really necessary. Again, if the invocation code - (function() {}()) - or the concept of privacy looks foreign, check out the articles on Object-Oriented JavaScript and Closure.