Create a gist now

Instantly share code, notes, and snippets.

@mbostock /.block
Last active Jul 15, 2017

What would you like to do?
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>

This is awesome

trusktr commented Oct 22, 2016 edited

@mbostock Hi, I tried to do this with a circle made from two arcs, and it almost works but make an extra segment in the middle, which is funky. The starting path is like this:

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

and the result looks like this:

screen shot 2016-10-21 at 7 03 29 pm

trusktr commented Oct 22, 2016 edited

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 commented Oct 22, 2016 edited

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).

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