galdecoa.com

Creating a SVG circle with a perimetral gradient in JavaScript

📅

Some time ago, I programmed in JavaScript a gauge that was basically a circular arc with a colour gradient along its path. It used the canvas element, which was convenient and adequate, but had a drawback: the graphics did not scale well. The gauges were contained in a dashboard that had a fixed layout, therefore resizing was not a requirement nor an improvement that would have had any use. I filed away the idea and did not pursue it any further.

Cut to a couple of weeks back, I was looking into abandoned project ideas when it came back to me. The scope was smaller than what I was looking for and I could not foresee being of interest to anybody else, but I wanted to scratch that itch. So I decided to revisit the concept using SVG.

And here we are:

A circle with a colour gradient along its perimeter

The logic behind this result is rather simple: concatenate a series of circular arcs with the same radius, each one of a slightly different colour to its adjacent segments. It requires solving two problems.

Generating a looping colour gradient

The perimeter of the circle is coloured in a sequence that look continuous and cyclical, in a chromatic sense. This is managed by making values of the red, green and blue component of the sequence be samples of a continuos and periodic function. Each of the colour components is represented by a non-negative eight bit number, so their values can go between 0 and 255 (2⁸ - 1). A sine wave between 0 and 2π can be used to get values in the specified range. For example:

( function( ) {
	const numberOfSteps = 12;
	const theta = 2 * Math.PI / numberOfSteps;
	const sequence = new Array( numberOfSteps );
	for ( let s = numberOfSteps - 1; s >= 0; s = s - 1 ) {
		const angle = theta * s;
		sequence[ s ] = Math.round( Math.sin( angle ) * 127.5 + 127 );
	}
	console.log( sequence.join( " " ) );
} )( );

outputs

127 191 237 255 237 191 127 63 17 0 17 63

As required, this sequence can be looped preserving the progression of the values. There can be infrequent off-by-one anomalies due to numeric precision, but they never fall out of range and are imperceptible in the final result, as far as I can tell.

Since there are three colour components, there have to be three value sequences. Using sine waves out of phase by 120° produces samples from the circle of a colour wheel, as explained by Jim Bumgardner. As a function:

/**
 * Generate a sequence of colours in which the chromatic progression is 
 * continuous even if looped.
 * It is achieved using three 120° out-of-phase sine waves to produce colours 
 * such that their hue travels around the circle of a colour wheel.
 * @see {@link https://krazydad.com/tutorials/makecolors.php|"Tutorial: Making annoying rainbows and other color cycles in Javascript" by Jim Bumgardner}
 * @param   {number}		numberOfColours 
 * @returns {string[]}
 */
function generateColourCycle( numberOfColours ) {
	const twoPi = 2 * Math.PI;
	const colours = new Array( numberOfColours ); 
	const theta = twoPi / numberOfColours ;
	const phase1 = 0;					   //   0°
	const phase2 = twoPi / 3;	   // 120°
	const phase3 = 2 * phase2;  // 240°
	for ( let c = numberOfColours - 1; c >= 0; c = c - 1 ) {
			const frequency = theta * c;
			const r = Math.round( Math.sin( frequency + phase1 ) * 127.5 + 127 );
			const g = Math.round( Math.sin( frequency + phase2 ) * 127.5 + 127 );
			const b = Math.round( Math.sin( frequency + phase3 ) * 127.5 + 127 );
			// Convert to integer, using the corresponding powers of 16, then to hex
			const hexColour = ( r * 65536 + g * 256 + b ).toString( 16 );
			// Add zero padding and number sign prefix
			colours[ c ] = "#" + ( "00000" + hexColour ).slice( -6 );
	}
	return colours;
}

On an auto-biographical note, I came across this way of generating colour cycles when programming a Xonix clone in Turbo Pascal. The assignment had no need for this, but I included a demoscene-inspired title screen with a fire effect (that required a white to red colour gradient) using palette shifting. Someone familiar with nineties videogame graphics might know about this technique, also called colour cycling. As they wrote in EffectGames.com:

This was a technology often used in 8-bit video games of the era, to achieve interesting visual effects by cycling (shifting) the color palette. Back then video cards could only render 256 colors at a time, so a palette of selected colors was used. But the programmer could change this palette at will, and all the onscreen colors would instantly change to match. It was fast, and took virtually no memory.

Most games used the technique to animate water, fire or other environmental effects. […]

In case anyone is curious or nostalgic, there are quite a few clones and emulated versions of the original Xonix. They are not hard to find.

The conversion to hexadecimal code is done to have a string representation of the colour. It would be just as valid to use rgb() syntax, but hex codes are more compact. Also, the logic is pretty straightforward. First, the colour components are used to compose an integer using the adequate powers of sixteen: 1 (16⁰) for the blue value, 256 (16²) for the green value, and 65536 (16⁴) for the red value. Then, the resulting value is converted to hexadecimal. Finally, this converted string is zero-padded and prefixed with the required number sign.

Creating arcs in SVG, the too-clever-for-your-own-good way

The smart thing to do would be to use path elements, which is precisely what Gradient Path does. However, for this specific case there is another way.

For a circle element, stroke-dasharray can be used to render just a portion of its perimeter. A simple pattern, with a dash the length of the desired arc followed by a space covering the rest of the circle, does the trick. For a circle with radius 50, the total perimeter will be 2×π×r ≈ 314.16. Setting the stroke-dasharray to half and half (“157.08 157.08”), a third and two thirds (“104.72 209.44”), or a fourth a three fourths (“78.54 235.62”) will paint half a circle, a third of a circle, and a fourth of a circle, respectively:

Half of a red circle A third of a green circle A fourth of a blue circle

The three previous examples have the same markup, except for the values of stroke-dasharray and the stroke colour. In the case of the third of a circle:

<svg 
	xmlns="http://www.w3.org/2000/svg" 
	width="200" height="200" viewBox="-5 -5 110 110"
>
	<circle 
		cx="50" cy="50" r="50" fill="none" stroke-width="10" 
		stroke-dasharray="104.72 209.44" stroke="green" 
	/>
</svg>

Note that the viewBox accounts for the stroke-width value. A “rounder” viewBox, like “0 0 100 100”, could be defined adjusting the stroke-width and r value. This, in turn, would require recalculating the perimeter and the stroke-dasharray values. I can live with an “imperfect” viewBox.

Considering all this, there are at least two ways of composing a circle.

Rotating

Three arcs will compose a circle by rotating one of them by 0° (no rotation), the next one by 120°, and the last one by 240°.

A circle made out of a cyan arc, a magenta arc, and a yellow arc

Some of the presentation attributes have the same value, so they can be defined in a CSS rule within a style tag. Unfortunately, the geometry properties (cx, cy, and r) cannot be defined in the same way if there is no SVG2 support. On the plus side, there usually is support for accessibility features, so they should be included as well:

<svg 
	xmlns="http://www.w3.org/2000/svg" 
	width="300" height="300" viewBox="-5 -5 110 110" 
	id="cmy-circle"
	role="img" aria-labelledby="cmy-circle-title" 
>
	<title id="cmy-circle-title">A circle made out of a cyan arc, a magenta arc, and a yellow arc</title>
	<style>
		#cmy-circle circle { 
			fill: none; 
			stroke-width: 10; 
			stroke-dasharray: 104.72 209.44; 
		}
	</style>
	<circle cx="50" cy="50" r="50" stroke="cyan" />
	<circle cx="50" cy="50" r="50" stroke="magenta" transform="rotate(120,50,50)" />
	<circle cx="50" cy="50" r="50" stroke="yellow" transform="rotate(240,50,50)" />
</svg>

Stacking

Stacking arcs of decreasing length also results in a multi-coloured circle. In a three arc case, the bottom arc covers the whole perimeter, the one above it covers two thirds and the one on top covers one third:

A circle made out of a cyan arc, a magenta arc, and a yellow arc

It should be noted that the order of the colours of the arcs is reversed when comparing it with the rotation method. Just like in that case, a style element is used for convenience, and the markup includes accessibility elements:

<svg 
	xmlns="http://www.w3.org/2000/svg" 
	width="300" height="300" viewBox="-5 -5 110 110" 
	id="cmy-stacked-circle"
	role="img" aria-labelledby="cmy-stacked-circle-title" 
>
	<title id="cmy-stacked-circle-title">A circle made out of a cyan arc, a magenta arc, and a yellow arc</title>
	<style>
		#cmy-stacked-circle circle { 
			fill: none; 
			stroke-width: 10; 
		}
	</style>
	<circle cx="50" cy="50" r="50" stroke="cyan" />
	<circle cx="50" cy="50" r="50" stroke="magenta" stroke-dasharray="209.44 104.72" />
	<circle cx="50" cy="50" r="50" stroke="yellow" stroke-dasharray="104.72 209.44" />
</svg>

Putting it all together

/** 
 * Create a SVG circle with a colour gradient along its perimeter.
 * @param	   {string}			[id]									The unique identifier to assign.
 * @param	   {number}			[size=200]					  Width (and height) of the SVG.
 * @param	   {number}			[numberOfColours=360]   Number of colours to use. 
 *																						  @see generateColourCycle
 * @param	   {string}			[title]							 A descriptive title.
 * @param	   {bool}			  [stacked=false]			 How the arcs are built.
 * @returns {SVGElement}
 */
function createColourCircle( 
	id = "",
	size = 200, 
	numberOfColours = 360,
	title = "A circle with a colour gradient along its perimeter",
	stacked = false
) {

	if ( !id ) { 
		// Generate a random (unused) identifier
		do {
			id = ""
				+ "colour-circle-" 
				+ ( "00000" + Math.round( Math.random() * 1e6 ) ).slice( -6 )
			; 
		} while( document.querySelector( "#" + id ) );
	}

	// These could be function params
	const radius = 50;
	const strokeWidth = 10;
	
	const sVGNameSpace = "http://www.w3.org/2000/svg";
	// The viewBox must be adjusted, considering cx and cy will be equal to r
	const offset = -strokeWidth / 2; 
	const viewBoxSize = 2 * radius + strokeWidth;
	const viewBox = offset + " " + offset + " " + viewBoxSize + " " + viewBoxSize;

	// Create SVG and define basic attributes and elements
	const circle = document.createElementNS( sVGNameSpace, "svg" );
	circle.setAttribute( "id", id );
	// Geometry and layout
	circle.setAttribute( "width", size );
	circle.setAttribute( "height", size );
	circle.setAttribute( "viewBox", viewBox );
	// Accessibility
	circle.setAttribute( "role", "img" );
	if ( title ) {
		const titleId = id + "-title";
		const titleElement = document.createElement( "title" );
		titleElement.setAttribute( "id", titleId );
		titleElement.innerText = title;
		circle.appendChild( titleElement );
		circle.setAttribute( "aria-labelledby", titleId );
	}

	const twoPi = 2 * Math.PI;
	const perimeter = twoPi * radius;

	// Define the circles' common style
	const style = document.createElement( "style" );
	style.innerText = ""
		+ "#" + id + " circle{"
			+ "fill:none"
			+ ";stroke-width:" + strokeWidth
	;
	if ( !stacked ) {
		const dashScale = 1 + 0.05 * Math.floor( numberOfColours / 60 );
		const dashLength = ( perimeter / numberOfColours ).toFixed( 2 );
		const emptyLength = ( perimeter - dashLength ).toFixed( 2 );
		style.innerText += ""
			+   ";stroke-dasharray:" 
			+ ( dashLength * dashScale ) + " " + emptyLength
		;
	}
	style.innerText += "}";
	circle.appendChild( style );

	// Create the arcs
	const colours = generateColourCycle( numberOfColours );
	const thetaRadians = twoPi / numberOfColours;
	const thetaDegrees = 360 / numberOfColours;
	for ( let c = 0; c < numberOfColours; c = c + 1 ) {
		// Preserve visual order of colours
		const colourIndex = stacked ? ( numberOfColours - c - 1 ) : c;
		// Create coloured arc
		const arc = document.createElementNS( sVGNameSpace, "circle" );
		arc.setAttribute( "cx", radius );
		arc.setAttribute( "cy", radius );
		arc.setAttribute( "r", radius );
		arc.setAttribute( "stroke", colours[ colourIndex ] );
		// The first arc requires no transformation in any case
		if ( !c ) {
			circle.appendChild( arc );
			continue;
		}
		// Manipulate the arc according to the selected method
		if ( stacked ) {
			const dashLength = ( ( twoPi - thetaRadians * c ) * radius ).toFixed( 2 );
			const emptyLength = ( perimeter - dashLength ).toFixed( 2 );
			const strokeDashArray = dashLength + " " + emptyLength;
			arc.setAttribute( "stroke-dasharray", strokeDashArray );
		} else {
			const angle = thetaDegrees * c;
			const transform = "rotate(" + angle + "," + radius + "," + radius + ")";
			arc.setAttribute( "transform", transform );
		}
		circle.appendChild( arc );
	}

	return circle;

}

In this implementation, there is one detail I have not explained previously: when defining the stroke-dasharray values, the dash length is grown by a dashScale factor. This mitigates a visual artefact caused by the arcs not aligning perfectly. The formula has some logic behind it but the proportion values are mostly empirical. The stacking alternative has a different problem, where the rim of some arcs are sometimes visible. Having both options available allows to choose the best result on a case by case basis.

The colour circle at the beginning of this text is generated by the following call:

createColourCircle( "colour-circle", 600 );