A better algorithm for paths with gradients

This article will present an algorithm, and code, to create the effect in the image below, where a gradient follows along a shape, a "path" in SVG parlance.

This is not the first attempt at solving this problem, thus the "better" part of the title, but I found earlier available code to have some very specific flaws, especially with complex paths.

By default, SVG provides for two ways to do gradients, linearGradient and radialGradient. As the names imply, the gradient for those either follows a straight line or a circular radius. In particular, there is no way in SVG to have a gradient evolve along a path.

 
Linear Gradient
Radial Gradient
If you try to apply either gradient to a path, you will see that it is just applied along a line or along a radius.
 
Linear Gradient
Radial Gradient

The solution

This section will describe the algorithm used for achieving gradients along a path.

From a high level the algorithm work by splitting a given path into short segments and give each segment its own colour, and this is not that different from other algorithms. The difference is in the details.

SVG allows code to sample a path and with those sample points it is possible to create a poly-line. This shows an example in low resolution, just to make it obvious what happens.

For each segment on the poly line my code will create a polygon with a specified width and a specified colour. The first step is to create a rectangular of the specified width.

As you can see, this creates both gaps and overlap, so the next step is to fit the edge of the rectangles together. The normal way is to extend or shorten the rectangle edges so that they match the preceding or following rectangle, which will no longer be a rectangle. This can easily be done with a standard line intersection algorithm, well known from linear algebra.

There are two special cases here: Parallel rectangles and rectangles joining each other at extremely acute angles

Parallel rectangles

Parallel rectangles, i.e. a path that does not bend or does a 180 degree turn can be handled by leaving the original edge of the rectangle. When neighbouring rectangles are parallel, either because the path turns around or because it keeps going, the edges will naturally match without modifications.

Extremely acute angles

When a line makes an abrupt turn, but not quite a 180 degree turn, the edges of the two rectangles will intersect somewhere far in the distance. Such abrupt turns happen naturally when sampling a noisy path with a high resolution and unless they are handled, the result will have spikes and other unwanted artifacts.

In this example the intended path to follow is red and the acute angle creates an extension, a miter, that extends far out from the corner.

To avoid this long extrusion, alternative line joins are somtimes used. Rounded and beveled line joins being the most common. In this code, a miter limit is implemented and if that is exceeded the miter is cut off, creating a beveled corner.

An alternative would be to implement a rounded corner but that is harder to do with an SVG polygon and in typical polygon sizes, the difference would not be possible to see.

The next step is to fill the polygons with a color from a color scale. To do that, the code tracks how far along the path a polygon is and chooses that color by calling a color function.

It might be a bit hard to see, but there is one remaining problem. Even if two polygons are tight against each other mathematically, once they have been rendered to a pixel grid, it is quite likely that there will be gaps or overlap. The gaps can be seen as thin white or transparent lines between the polygons. To avoid those, each polygon is optionally expanded by 1 pixel to cover those gaps. That will further increase the overlap, but overlap is only visible when using transparent colours, where the overlap will make the colours darker.

By increasing the resolution (decreasing the width of each rectangle), we can get the result we want.

Code

Usage

There are three steps to creating the gradiented path.

Step 1 of 3 is to load the scripts. Currently the code depends on d3 but that might change in the future since that is just a convenience.

<script src="https://d3js.org/d3.v6.min.js"></script> <script src="https://sarasas.se/strokegradient/strokegradient.min.js"></script>

Step 2 of 3 is to define a path for the gradient to follow and a location for the created polygons. They need to be close to each other so that they are influenced by the same display transforms. The path used in the example was created with Inkscape but the tool used to create it does not matter. <svg height="1200" width="900" viewBox="0 0 200 150"> <path id="infinity" stroke-width="14" fill="none" d="M 35.1323,10.1980 C 65.7565,7.9152 74.5081,65.7829 105.1323,63.5 c 34.4404,-6.4283 27.1535,-50.2570 0,-53.3020 C 74.5081,7.9152 65.7565,65.7829 35.1323,63.5 c -34.4404,-6.4282 -27.1535,-50.257 0,-53.302 z" /> <g id="stroked-path"></g> </svg>

Step 3 of 3 is to run the code that converts the path into a coloured polygon sequence. In the example below all parameters are commented one by one but this could of course be much more compact. The colour function (colorFunction) is here created with d3, but any function that takes a number and returns a color description would work. <script> const originalPath = document.getElementById("infinity"); const polygonLocation = document.getElementById("stroked-path"); const strokeWidth = 14; // 14 units wide coloured polygons const resolution = 1; // 1 unit long coloured polygons. const useStroke = true; // Hides gaps. const gradientSteps = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]; const gradientColors = gradientSteps.map(s => d3.hsl(s * 360, 1, 0.6)); const colorFunction = d3.scaleLinear().domain(gradientSteps).range(gradientColors); makePathGradient(originalPath, polygonLocation, colorFunction, resolution, strokeWidth, useStroke) </script>

Source

The code is on public display on GitHub. Minimized with https://javascript-minifier.com it is currently 2461 bytes.

Performance

While this is fast by many metrics, it is far from instant. Sampling a complex path, creating many polygons and rendering many polygons can use several milliseconds. To make it as fast as possible I have some advice:

  • Avoid rounded edges on the path. All non-straight lines slows down path sampling (getPointAtLength) a lot.
  • Select as large polygons, i.e. as high resolution number, as possible. The number just has to be small enough to not give obvious edges or colour shifts. The exact number will depend on the shape of the path as well as the colours used.

Better

Compared to the most used algorithm by the great Mike Bostock this algorithm:
  • Handles sharp corners without visual artifacts.
  • Handles closed paths.
  • Allows for turning off the gap filers which makes transparent colours work better.
  • Minimizes visual artifacts by taking HiDPI screens into account, and not making the gap filler larger than necessary.
The current version depends on d3.js, but that is something I might change to make the code even more flexible and easy to use.

If all this made you want to know more about creating polygons along paths, I recommend this text by Philip Jägenstedt. It is a thesis about how to create stroked fonts (ignore the preamble in Swedish, the report is in English) and has both background and depth.

Comments

Gianna said…
Interresting read