t

Trevor Blades

AboutProjectsLabOSS
Subscribe

Spiral Into Madness

Field notes from my journey to the land of geometry

Earlier this year, I thought it would be cool to try building a spiraling triangle of text using HTML and CSS. I suppose it had no practical benefit, but as I said, I thought it would be cool. And it was.


In this post, I'll take you through my process and introduce you to some of the mathematical friends I made along the way. Before I had any idea of the math I was going to have to do, I started sketching in my notebook to wrap my head around the ways in which I could achieve this effect. I landed on a technique that I'll call the "curly caterpillar" method. That's where our story begins...

The curly caterpillar

First, I lay out each of my triangle sides horizontally with each one nested within the side before it. Then I use a CSS transform to rotate() each of the sides around their left axis. Because the sides are nested, a rotation to one side will also rotate its children, causing a "curling" effect.

Try it out below! As you apply rotation to the joints between the three segments below, they curl inward to form a triangle.

first
second
third

In React, one of these segment components might look something like the following code sample. Notice the segment width is applied to the inner span and the rotation is applied to the outer flex parent. Each subsequent segment is nested within the previous one, after the span using the children prop.

function Segment({children, value, width, rotation}) {
  return (
    <div
      style={{
        display: 'flex',
        transformOrigin: 'left',
        transform: `rotate(${rotation}deg)`
      }}
    >
      <span
        style={{
          flexShrink: 0,
          width
        }}
      >
        {value}
      </span>
      {children}
    </div>
  );
}

To create a triangle, nest three Segment components within each other, and rotate all but the first segment 120°.

The triangle I'm trying to build is equilateral, meaning each of its side lengths and angles is the same. Since the sum of all interior angles in any triangle must equal 180°, each interior angle has to be 60°. Each exterior angle can be calculated by subtracting the interior angle from 180° (or π), meaning the exterior angle must be 120°. That's the amount that I need to rotate each segment to turn my flat line into a triangle.

To learn more about basic laws of triangles, check out this article that explains things much better and with diagrams 🔥

function Triangle() {
  const width = 100; // in pixels
  const interiorAngle = 180 / 3; // in degrees
  const exteriorAngle = 180 - interiorAngle;
  return (
    // the outermost segment gets no rotation
    <Segment value="first" width={width} rotation={0}>
      <Segment value="second" width={width} rotation={exteriorAngle}>
        {/* the innermost segment gets no further children */}
        <Segment value="third" width={width} rotation={exteriorAngle} />
      </Segment>
    </Segment>
  );
}

Math time

The story doesn't end here. I am trying to create a spiral triangle, after all. This means that after drawing the first three sides, the shape needs to continue spiraling inward as long as there's room to create new segments.

The amount of room needed to draw a segment is related to the desired amount of space between parallel lines in the spiral. Let's use the following spiral with simplified measurements as an example. Its first two segments are 100 units long, and it has 20 units of spacing between its parallel lines.

In order to continue drawing this spiral, I needed to figure out the length of its third side. At first glance, the answer might seem obvious: just subtract 20 from the original side length of 100, and we get a side length of 80.

Unfortunately, it's not that simple. This calculation results in a side that's slightly too long to ensure the subsequent sides are correctly spaced.

To find the appropriate length for side 3 and on, we first need to calculate the length of the piece that needs to be removed from the original side length. In the diagram below, I've labeled that part as aa.

If you look closely at side aa, it's the hypotenuse of a right triangle that has an opposite side length equal to the gap between parallel lines in the spiral (20). We also know that one of the angles is 60° because it has to be equivalent to the triangle's interior angle.

With this knowledge, we can apply trigonometry to solve for aa. Remember SohCahToa? Since we have the length of the opposite side (OO) and want to solve for the hypotenuse (HH), we must use the sine function in this calculation.

sinθ=OHsin60°=20aa=20sin60°23.09\begin{aligned} \sin\theta &= \frac{O}{H} \\ \sin60\degree &= \frac{20}{a} \\ a &= \frac{20}{\sin60\degree} \\ &\approx 23.09 \end{aligned}

In this post, I'll be referring to this side aa measurement as the inset. Now solving for bb is as easy as subtracting the inset aa from the original side length of 100.

b=100a10023.0976.91\begin{aligned} b &= 100 - a \\ &\approx 100 - 23.09 \\ &\approx 76.91 \end{aligned}

Insets and outsets

This process of subtracting the inset from the original side length continues for each subsequent side until there's no more room to draw additional sides. In the diagram below, you can see that each inset, marked as \vert, is the same length.

Calculating the length of side 33 is as simple as subtracting one inset from the original side length, but side 44 requires us to subtract one inset along with two additional lengths that I call outsets, marked as \Vert in the diagram. Side 55 is equal to the original side length minus two insets and two outsets, and so on.

The length of an outset can be calculated using another staple of trigonometry, the Pythagorean theorem. This equation, commonly expressed as a2+b2=c2a^2 + b^2 = c^2, lets us solve for one side of a right triangle, given that we know the length of the two other sides. In this case, the spacing between parallel lines is one side bb, and the inset is the other cc.

a2+b2=c2a=c2b2=23.09220211.54\begin{aligned} a^2 + b^2 &= c^2 \\ a &= \sqrt{c^2 - b^2} \\ &= \sqrt{23.09^2 - 20^2} \\ &\approx 11.54 \end{aligned}

Now that I know how to compute side lengths, I need to apply this technique recursively until all sides are drawn. To do this, I needed to find a pattern or formula to follow for each side.

I drew a big spiral in my notebook and counted the insets and outsets required for each side. I went 10 sides deep to see if any pattern would emerge. Here's what my results looked like:

SideInsetsOutsets
100
200
310
412
522
632
734
844
954
1056

The outsets column presents a clear pattern. For every three sides, the number of outsets increments by two. In mathematical notation, we could express this formula where OO is the number of outsets, SS is the current side, and NN is the total number of sides on the shape—3 in the case of a triangle.

O=2S1NO = 2\left\lfloor\frac{S - 1}{N}\right\rfloor

The x\lfloor{x}\rfloor notation above means "floor" or "round this number down"

The pattern for the insets was a lot more unclear, but after some trial and error, I came up with the following handy formula, where II is the number of insets, and the variables SS and NN are the same as above.

I=SN+max(S2,0)NI = \left\lfloor\frac{S}{N}\right\rfloor + \left\lfloor\frac{\max(S - 2, 0)}{N}\right\rfloor

In JavaScript, these relationships would look something like this:

const numInsets =
  Math.floor(side / numSides) + Math.floor(Math.max(side - 2, 0) / numSides);
const numOutsets = 2 * Math.floor((side - 1) / numSides);

There are three key values at play in both of these calculations:

  1. Math.floor(side / numSides)
  2. Math.floor((side - 1) / numSides)
  3. Math.floor(Math.max(side - 2, 0) / numSides)

Notice this progression of side, side - 1, and side - 2? We can make this code more concise by creating these values iteratively and combining them appropriately.

const [a, b, c] = Array.from({length: 3}, (_, index) =>
  Math.floor(Math.max(side - index, 0) / numSides)
);
const numInsets = a + c;
const numOutsets = 2 * b;

Drawing the spiral

So far in this post, I've explained how I was able to:

  • Render a triangle with CSS
  • Understand the relationships between side length, spacing, insets, and outsets
  • Calculate the value of insets and outsets
  • Determine the number of insets and outsets needed per side

Now it's time to put all of these tools together to draw a spiral.

I never discovered a formula for calculating the total number of segments needed in a spiral, so I opted for a different strategy here. Using a while loop, I calculated the width of one segment at a time, adding them to an array as long as they were "long enough".

How long is long enough? For this project, I decided that I didn't want any segment to be shorter than the length of the inset. This would ensure consistent spacing within the spiral, down to the very last segment.

const segments = [];

while (width > inset) {
  // get the current side based on the number of segments already created
  const side = segments.length + 1;

  // determine the number of insets and outsets for that side
  const [a, b, c] = Array.from({length: 3}, (_, index) =>
    Math.floor(Math.max(side - index, 0) / numSides)
  );
  const numInsets = a + c;
  const numOutsets = 2 * b;

  // calculate the side length by subtracting the appropriate number of insets
  // and outsets from the original side length
  const sideLength = width - inset * numInsets - outset * numOutsets;

  // if the calculated side length is less than the length of the inset, break
  // the loop
  if (sideLength < inset) {
    break;
  }

  // otherwise, add side length to the front of the array of segments
  segments.unshift(sideLength);
}

Remember that in order to use the curly caterpillar method, I need to nest my segments within one another. In React, it's common to map over an array and render components based on its items, but that would result in a flat list rather than the nested structure I'm after.

Instead, I opted to use Array.reduce to recursively wrap HTML elements around each previous wrapped element. Using this strategy, I start from the innermost segment and continue wrapping outward to the first segment in the spiral. This is why I used Array.unshift instead of Array.push in the code sample above. I needed to make sure my final array would be ordered from innermost at the start to outermost at the end.

<>
  {segments.reduce(
    (child, width, index, array) => (
      <div
        style={{
          display: 'flex',
          transformOrigin: 'left',
          transform:
            // don't rotate the first segment (last item in the array)
            index === array.length - 1 ? 'none' : `rotate(${exteriorAngle}rad)`
        }}
      >
        <span
          style={{
            flexShrink: 0,
            width,
            height: 2,
            backgroundColor: 'currentcolor'
          }}
        />
        {/* render the previous child within this segment */}
        {child}
      </div>
    ),
    null // previous child is null by default
  )}
</>

Et voilà! I now have a component that renders a beautiful triangular spiral given a width and spacing value. Play around with the demo below to see this method in action. Notice how new segments get added or subtracted as the spiral size and spacing change.

As you change the width of the spiral in the example above, you'll notice the height of its container changing as well. The height of a triangle like this one can be calculated by dividing the triangle in two and measuring the long vertical side indicated by the dashed line in the diagram below.

Each half is a right triangle, and we already know two of its side lengths and all interior angles, so we have two options for calculating the remaining side:

  1. Use Pythagorean theorem (a2+b2=c2a^2 + b^2 = c^2)
  2. Use the sine ratio (sinθ=OH\sin\theta = \frac{O}{H})
a2+b2=c2a=c2b2=100250286.60\begin{aligned} a^2 + b^2 &= c^2 \\ a &= \sqrt{c^2 - b^2} \\ &= \sqrt{100^2 - 50^2} \\ &\approx 86.60 \end{aligned}

Pick your poison—either method will yield the same result.

Adding the text

Drawing a spiral is cool, but my goal was to draw a spiral of text. Adding text to the segments is easy enough. I replace the height and background-color styles with text-align: center and add the text as children to the span.

<span
  style={{
    flexShrink: 0,
    width,
    // height: 2,
    // backgroundColor: 'currentcolor'
    textAlign: 'center'
  }}
>
  text goes here
</span>

Since most sides of the spiral have variable lengths, the next challenge was making sure that the amount of text in each side was proportional to the amount of space available. For example, a string of text that would fit nicely into the first side would exceed the length of sides 3 and greater.

My first idea was to split my desired spiral text into an array of strings, each one corresponding to a side. As I create my segments, I save the segment's computed width and text value and access them as properties on the segment object in the reduce function.

const text = [
  'this is some text',
  'that i think will',
  'fit into',
  'the sides',
  'of my',
  'spiral'
];

while (width > inset) {
  // calculate inset/outset count and side length as before
  segments.unshift({
    width: sideLength,
    text: text[side - 1]
  });
}

return (
  <>
    {segments.reduce((child, segment) => (
      <div style={/* segment wrapper styles */}>
        <span style={/* same as above */}>
          {/* add text as a child of the span */}
          {segment.text}
        </span>
        {child}
      </div>
    ))}
  </>
);

This strategy worked, but it required a lot of trial and error to come up with text strings that filled the space properly. It also meant that when I changed the size or spacing of my spiral, my carefully curated text strings would need to be updated to fit the new proportions. Super annoying. 🙄

I tried to imagine a better alternative. What if I could just pass a single string of text, and the spiral component would automatically distribute the words in the string across each side? If it runs out of words to fill space with, it would start over from the beginning of the string. Rinse and repeat this process until all the space is accounted for.

To do this, I need to understand how many characters will fit into each segment. If my spiral component accepts a fontSize prop, I can make an approximation of the average character width based on that. Then I divide the side length by this approximate character width, and I've got my character allowance.

const charWidth = fontSize / 1.5;
let numChars = Math.floor(sideLength / charWidth);

Then, I split the provided text (supplied as a children prop) into individual words, and use a while loop to add words to a segment until there's no room for more characters. Every time I take one word from the words array, I put it back on the end of the array for reuse later on.

const words = children
  .trim() // trim leading/trailing space
  .split(/\s+/); // split into array of words

let text = '';
while (numChars > 0) {
  const word = words.shift(); // get the first element
  words.push(word); // put it back on the end of the array
  text += word.text; // add it to the segment's text
  numChars -= word.text.length; // adjust the number of remaining characters
}

segment.unshift({
  width: sideLength,
  text
});

The only problem with this solution is it doesn't replace the spaces between words in my text string. Also, sometimes a single word is too long to fit into one side.

To account for this, I modified my while loop to insert a portion of a word if there aren't enough characters left to fit the whole thing. Then I put the remainder of the word back on the front of the words array so that it gets rendered first on the next segment, before continuing with the next word in the stack.

I need to be able to differentiate between full words and fragments to avoid pushing fragments onto the end of the words array as I'm doing with all words near the top of the while loop in the example above. Each word in the original array gets an isFullWord flag that I can check in the while loop to decide whether to push it back onto the words array.

const words = children
  // trim and split logic from before
  .map(text => ({
    text,
    isFullWord: true // mark as full word
  }));

After every full word, I add a space if there is more than one character slot remaining in that segment. This is to avoid having a space as the last character of any segment. Here's what the updated while loop body looks like now:

const word = words.shift(); // get the first element

// return it to the end of the stack if it's a full word
if (word.isFullWord) {
  words.push(word);
}

// if there isn't enough space for the full word
if (word.text.length > numChars) {
  // grab the part of it that will fit
  const fragment = word.text.slice(0, numChars);
  // and put the rest of it back into the front of the stack
  words.unshift({
    text: word.text.slice(numChars),
    isFullWord: false // not a full word
  });
  text += fragment;
  numChars -= fragment.length;
} else {
  // otherwise add the word to the segment
  text += word.text;
  numChars -= word.text.length;
  if (numChars > 1) {
    // add a space if there's room
    text += ' ';
    numChars -= 1;
  } else if (numChars) {
    // otherwise bail out and complete the segment
    break;
  }
}

Cosmetic upgrades 💅

I changed the way that I render the segment text so that its characters are spaced evenly within each segment, sort of like what text-align: justify does for words in a line. First I split the segment text into an array of characters, and render them each as separate span elements. Then I give their parent display: flex and justify-content: space-evenly, as well as white-space: pre to preserve the room that space characters occupy.

<span
  style={{
    flexShrink: 0,
    display: 'flex',
    whiteSpace: 'pre',
    justifyContent: 'space-evenly',
    width
  }}
>
  {text.split('').map((char: string, index: number) => (
    <span key={index}>{char}</span>
  ))}
</span>

I also added some padding on the start and end of each segment to prevent text from overlapping at the points of my shape. I found that two-thirds of a character width was an amount that worked well for this.

const padding = charWidth / 1.5; // same as charWidth * (2 / 3)

I refactored the code in my segment creation code to account for this padding's effect on the amount of space available for text, and thus the number of characters that would fit in each segment.

const innerWidth = sideLength - padding * 2; // subtract padding from each end
const numChars = Math.floor(innerWidth / charWidth);

Finally, I add the padding to my segment elements.

<span
  style={{
    // other segment text styles
    padding
  }}
>
  {/* render text */}
</>

At last, a triangle spiral of text. 😌 If you've made it this far in the post, I applaud you. 👏 Enjoy one last demo where you can experiment with all of the variables at play in this component.

change me ch
ange me chan
ge me chan
ge me ch
ange m
e cha
nge
m

Appendix

If I could make this work for a triangle, how hard could it be to make it work for other regular polygons?

A regular polygon is any polygon where all of its sides and angles are equal, like the equilateral triangle in this post, or a square, hexagon, etc.

It turns out that a lot of the fundamental parts of this implementation can be applied to shapes with more sides. There were only a handful of adjustments I had to make for my spiral component to be multi-polygonal .

Exterior angles

The angle that I used to rotate the segments in my triangle was fixed at 120°, but as the number of sides on a shape changes, so too must this angle. The formula to find a regular polygon's exterior angle is θ=2πN\theta = \frac{2\pi}{N} where NN is the number of sides.

const exteriorAngle = (Math.PI * 2) / numSides;

Shape height

Finding the height of a triangle was relatively easy; divide the triangle in two and measure the long vertical side. The process of measuring the height of a regular polygon is more difficult. The formula used differs depending on whether the polygon has an even number of sides, like a hexagon (6), or an odd number like a pentagon (5).

Imagine drawing a circle that touches every point on the polygon. This is called the circumcircle and it defines the center of the polygon. The radius of the circumcircle is called the circumradius, marked as RcR_c. The distance between the center of the circle and the midpoint of any side is called the inradius, marked as RiR_i.

Calculating the height of an evenly-sided polygon is as simple as multiplying the inradius by two. The height of an oddly-sided polygon is the sum of the circumradius and inradius, as visualized in the diagram below and the formula below that, where NN is the number of sides.

h={2RiN is evenRc+RiN is oddh = \left\{ \begin{array}{ll} 2R_i & N\text{ is even} \\ R_c + R_i & N\text{ is odd} \end{array} \right.

Calculating these radii is a complex task. If the number of sides is odd, the formula for the circumradius is as follows, where ww is the chosen width and NN is the number of sides as above.

Rc=w/sinπN12N/2R_c = w / \sin\pi\frac{N - 1}{2N} / 2

We can then calculate the inradius by multiplying the circumradius by cosπN\cos\frac{\pi}{N}.

Ri=RccosπNR_i = R_c\cos\frac{\pi}{N}

If the number of sides is even, the inradius can be calculated using the formula below. The equation used depends on whether half the number of sides (N/2N / 2) is even or odd. For example, a six-sided polygon would have an N/2N / 2 of 3 (odd), and an eight-sided polygon would have an N/2N / 2 of 4 (even).

Ri={w/2N/2 is evenw/2×cosπNN/2 is oddR_i = \left\{ \begin{array}{ll} w / 2 & N / 2\text{ is even} \\ w / 2 \times \cos\frac{\pi}{N} & N / 2\text{ is odd} \end{array} \right.

Side length

For squares and triangles, the original side length is equal to the width of the shape—easy! For shapes with more than four sides, I need to calculate the side length. If a shape has an odd number of sides, this can be calculated using the circumradius RcR_c and the exterior angle θ\theta.

a=2Rc2L=aa×cosθ\begin{aligned} a &= 2R_c^2 \\ L &= \sqrt{a - a \times \cos\theta} \end{aligned}

For shapes with an even number of sides, the side length calculation differs slightly depending on if N/2N / 2 is even or odd. In either case, I use the inradius RiR_i and the exterior angle θ\theta, only changing the trigonometric ratio used.

L={Ri×2tanθN/2 is evenRi×2sinθN/2 is oddL = \left\{ \begin{array}{ll} R_i \times 2\tan\theta & N / 2\text{ is even} \\ R_i \times 2\sin\theta & N / 2\text{ is odd} \end{array} \right.

Centering the shape

Now that I have the height of the shape, I would like to center it within a square bounding box. I create a div with equal width and height, provided by the component's boxSize prop. Then I find the difference between the box size and the shape height as calculated above. Divide that number by two, and that's the amount I need to offset my shape to vertically center it.

To horizontally center the shape, I need to move the first side into the middle of the box. I can do that by calculating the difference between the box size and side length and dividing by two as I did with the vertical padding.

<div
  style={{
    width: boxSize,
    height: boxSize,
    paddingTop: (boxSize - height) / 2,
    paddingLeft: (boxSize - sideLength) / 2
  }}
>
  {/* spiral elements go here */}
</div>

Inverting the outset

The last change I needed to make for this spiral to work with any regular polygon has to do with the calculation of inner side lengths—the outsets in particular. When I was rendering a triangle, I subtracted outsets from the original side length to end up with each inner side length. For shapes with more than four sides, I must instead add outsets to perform the calculation correctly.

Simple enough—I can perform a check after calculating my outset and invert the number if the shape has more than four sides.

let outset = Math.sqrt(inset ** 2 - spacing ** 2);
if (numSides > 4) {
  outset *= -1;
}

Resources

Ok, that's actually it. I learned a lot about math and geometry creating this component and writing this post, and I'm going to drop a few links below to some of the outstanding resources that I couldn't have done this without. I encourage you to check them out if you want to learn more about this sort of thing! 🌠


In case you want to use multi-polygonal text spirals in your own project, I published this component as an npm library. Check out the docs for usage instructions.

npm i react-spiral
Made with 🥥 in Burnaby, BC
© 2021 - Source code