Skip to content

Instantly share code, notes, and snippets.

@mbostock
Last active September 30, 2024 14:43
Show Gist options
  • Save mbostock/4163057 to your computer and use it in GitHub Desktop.
Save mbostock/4163057 to your computer and use it in GitHub Desktop.
Gradient Along Stroke
license: gpl-3.0

This example demonstrates how to create a gradient that follows a stroke. This technique is sometimes used to indicate directionality along a curved edge, such as with hierarchical edge bundling.

To start, take any SVG path element and uniformly sample points along the path using getPointAtLength. (This method can also be used for path tweening.) Then, for each segment between adjacent points, compute the miter joint via line-line intersection. Lastly fill each segment by interpolating the start and end colors, here green to red, using the normalized length t along the path. Although each segment is a constant color, there are many segments to give the appearance of a continuous gradient.

This example uses a thin stroke in addition to filling the segments. This avoids antialiasing artifacts due to most web browsers not implementing full-scene antialiasing.

<!DOCTYPE html>
<meta charset="utf-8">
<svg width="960" height="500">
<path fill="none" stroke-width="10" d="
M86,388
L203,330
C320,272,554,156,673.8333333333334,165.83333333333334
C793.6666666666666,175.66666666666666,799.3333333333334,311.3333333333333,683.5,316.6666666666667
C567.6666666666666,322,330.3333333333333,197,211.66666666666666,134.5
L93,72"></path>
</svg>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
var color = d3.interpolateRainbow;
var path = d3.select("path").remove();
d3.select("svg").selectAll("path")
.data(quads(samples(path.node(), 8)))
.enter().append("path")
.style("fill", function(d) { return color(d.t); })
.style("stroke", function(d) { return color(d.t); })
.attr("d", function(d) { return lineJoin(d[0], d[1], d[2], d[3], 32); });
// Sample the SVG path uniformly with the specified precision.
function samples(path, precision) {
var n = path.getTotalLength(), t = [0], i = 0, dt = precision;
while ((i += dt) < n) t.push(i);
t.push(n);
return t.map(function(t) {
var p = path.getPointAtLength(t), a = [p.x, p.y];
a.t = t / n;
return a;
});
}
// Compute quads of adjacent points [p0, p1, p2, p3].
function quads(points) {
return d3.range(points.length - 1).map(function(i) {
var a = [points[i - 1], points[i], points[i + 1], points[i + 2]];
a.t = (points[i].t + points[i + 1].t) / 2;
return a;
});
}
// Compute stroke outline for segment p12.
function lineJoin(p0, p1, p2, p3, width) {
var u12 = perp(p1, p2),
r = width / 2,
a = [p1[0] + u12[0] * r, p1[1] + u12[1] * r],
b = [p2[0] + u12[0] * r, p2[1] + u12[1] * r],
c = [p2[0] - u12[0] * r, p2[1] - u12[1] * r],
d = [p1[0] - u12[0] * r, p1[1] - u12[1] * r];
if (p0) { // clip ad and dc using average of u01 and u12
var u01 = perp(p0, p1), e = [p1[0] + u01[0] + u12[0], p1[1] + u01[1] + u12[1]];
a = lineIntersect(p1, e, a, b);
d = lineIntersect(p1, e, d, c);
}
if (p3) { // clip ab and dc using average of u12 and u23
var u23 = perp(p2, p3), e = [p2[0] + u23[0] + u12[0], p2[1] + u23[1] + u12[1]];
b = lineIntersect(p2, e, a, b);
c = lineIntersect(p2, e, d, c);
}
return "M" + a + "L" + b + " " + c + " " + d + "Z";
}
// Compute intersection of two infinite lines ab and cd.
function lineIntersect(a, b, c, d) {
var x1 = c[0], x3 = a[0], x21 = d[0] - x1, x43 = b[0] - x3,
y1 = c[1], y3 = a[1], y21 = d[1] - y1, y43 = b[1] - y3,
ua = (x43 * (y1 - y3) - y43 * (x1 - x3)) / (y43 * x21 - x43 * y21);
return [x1 + ua * x21, y1 + ua * y21];
}
// Compute unit vector perpendicular to p01.
function perp(p0, p1) {
var u01x = p0[1] - p1[1], u01y = p1[0] - p0[0],
u01d = Math.sqrt(u01x * u01x + u01y * u01y);
return [u01x / u01d, u01y / u01d];
}
</script>
@trusktr
Copy link

trusktr commented Oct 22, 2016

Oh, I guess it makes sense mecause of the m -75, 0 command, but when I draw the arc without applying the interpolation, it looks like this:

  <path fill="none" stroke-width="10" stroke="blue" d="
            M 100, 100
            m -75, 0
            a 75,75 0 1,1 150,0
            a 75,75 0 1,1 -150,0
      "></path>

screen shot 2016-10-21 at 7 06 52 pm

It also appears that the resulting rainbow gradient is thicker than the original path (compare screenshots).

@trusktr
Copy link

trusktr commented Oct 22, 2016

If I change it to the following with only a single move command, it works fine, although the thickness is still too big:

  <path fill="none" stroke-width="1" stroke="blue" d="
            m 100, 100
            a 75,75 0 1,1 150,0
            a 75,75 0 1,1 -150,0
      "></path>

screen shot 2016-10-21 at 7 20 13 pm


EDIT: Ah, the width is the final parameter tolineJoin. I suppose it would be possible to make this detect the width instead of having us passing it in (which is redundant since we chose the width in the original path already).

@isaironi
Copy link

isaironi commented Nov 9, 2018

Hi,
I used this to make two figure eights, one flipped over the y axis of the other (they move in the opposite direction) .
It works like a charm but I'm a little confused about the color coding. In the doc you said it was green-to-red. It seems to me to be going purple to red to green to blue. It also seems to me like your original image here and the one on display at https://bl.ocks.org/mbostock/4163057 are not the same - the one here is indeed green-to-red, the one there (and the svgs produced by that code) are purple-pink-oranger-green-blue.
Am I right or did I do something wrong in generating my .htmls ? Thanks so much for this script, it worked like a charm :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment