Skip to content

Instantly share code, notes, and snippets.

@gilmoreorless
Last active July 30, 2019 10:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gilmoreorless/253f0419ffcf08e94163fdb7bc0d552b to your computer and use it in GitHub Desktop.
Save gilmoreorless/253f0419ffcf08e94163fdb7bc0d552b to your computer and use it in GitHub Desktop.
Gradient line to circle
license: cc-by-4.0

This is an attempt to get a straight line to curl around into a circle, while still keeping its fill gradient flowing along the length of the line.

Ideally this would be solved by using a single path with a conic gradient fill, with the radius of the line and the gradient changing together. Unfortunately conic gradients don’t currently exist in canvas or SVG. The workaround is to manually draw a series of tiny arc segments to fake the gradient fill.


This is part one of a 4-part series of experiments:

1. Gradient line to circle | 2. Flatten a circle | 3. Arc fill clipping | 4. Twist a gradient-filled circle

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
font-family: sans-serif;
margin: auto;
position: relative;
width: 600px;
}
.controls {
left: 0;
position: absolute;
top: 2em;
}
label {
display: block;
margin-top: 0.25em;
}
#curvey {
width: 300px;
}
</style>
<body>
<div class="controls">
<label>Curvey-ness: <input type="range" id="curvey" min="0" max="1" step="0.01" value="0" /></label>
<label><input type="checkbox" id="animate" /> Animate</label>
<label><input type="checkbox" id="trippy" /> “Artistic” mode</label>
</div>
<canvas id="drawing"></canvas>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
// Config
var width = 600,
height = 500,
mainRadius = 80,
lineThickness = 40,
segments = 180;
// Derived values
var TAU = Math.PI * 2,
TAU_4 = TAU / 4,
circumference = mainRadius * TAU,
cx = width / 2 - 120,
cy = height / 2 + 50;
// State
var isAnimating = false,
shouldClear = true;
/**
* NOTES ON INCONSISTENT ANGLES:
*
* - Canvas2DContext.arc() => clockwise from [1, 0]
* - d3.arc() => clockwise from [-1, 0]
* - Math.sin() / Math.cos() => anti-clockwise from [0, 1]
*/
// Normalise all angles so input starts from [-1, 0] and runs anti-clockwise.
var _angleDomain = [0, TAU];
var angleCanvas = d3.scaleLinear()
.domain(_angleDomain)
.range([TAU / 2, -TAU / 2]);
var angleD3 = d3.scaleLinear()
.domain(_angleDomain)
.range([TAU * 0.75, -TAU / 4]);
var angleSinCos = d3.scaleLinear()
.domain(_angleDomain)
.range([-TAU / 4, TAU * 0.75]);
// Canvas setup
var canvas = document.getElementById('drawing');
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d');
ctx.translate(cx, cy);
function hue(h) {
return 'hsl(' + h + ', 70%, 50%)';
}
function clamp(num, min, max) {
return Math.min(Math.max(min, +num), max);
}
/**
* Draw a fixed-width line with full hue gradient from start to finish.
* Line always has y=mainRadius at x=0, with parameterised curve.
*
* `curveFactor` is a float in range [0, 1] that defines how curved the line is.
* 0 = no curve (flat horizontal)
* 1 = full curve (circular)
*/
function drawGradientLine(curveFactor) {
curveFactor = clamp(curveFactor, 0, 1);
if (curveFactor < 0.008) curveFactor = 0;
// Angles measured anti-clockwise from [-1, 0]
var startAngle = curveFactor > 0 ? TAU_4 - (TAU_4 * curveFactor) : TAU_4;
var endAngle = curveFactor > 0 ? TAU_4 + (TAU_4 * 3 * curveFactor) : TAU_4;
// Derived values
var outerRadius = 1 / curveFactor * mainRadius;
var innerRadius = outerRadius - lineThickness;
var midRadius = innerRadius + lineThickness / 2;
var derivedY = mainRadius - outerRadius;
var segmentAngleFull = (endAngle - startAngle) / segments;
var segmentAngle = segmentAngleFull / 2;
var p2 = 0.5 / segments;
var fudgeFactor = curveFactor > 0 ? TAU / (180 * outerRadius / mainRadius) : 0.005;
var grad, midAngle, gradAngle1, gradAngle2, p, p3, x1, x2, xw, y1, y2, i;
var arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius)
.context(ctx);
ctx.save();
if (curveFactor > 0) {
ctx.translate(0, derivedY);
}
for (i = 0; i < segments; i++) {
p = (i + 0.5) / segments;
ctx.beginPath();
if (curveFactor > 0) {
midAngle = startAngle + segmentAngleFull * i + segmentAngle;
gradAngle1 = angleSinCos(midAngle - segmentAngle);
gradAngle2 = angleSinCos(midAngle + segmentAngle);
x1 = Math.sin(gradAngle1) * midRadius;
y1 = Math.cos(gradAngle1) * midRadius;
x2 = Math.sin(gradAngle2) * midRadius;
y2 = Math.cos(gradAngle2) * midRadius;
arc({
startAngle: angleD3(midAngle - segmentAngle),
endAngle: angleD3(midAngle + segmentAngle + (i === segments - 1 ? 0 : fudgeFactor))
});
} else {
p3 = circumference * p - circumference / 4;
x1 = p3 - p2 * circumference;
x2 = p3 + p2 * circumference;
y1 = y2 = 0;
xw = p2 * 2 * circumference + (i === segments - 1 ? 0 : fudgeFactor * circumference);
ctx.rect(x1, mainRadius - lineThickness, xw, lineThickness);
}
grad = ctx.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, hue((p - p2) * 360));
grad.addColorStop(1, hue((p + p2) * 360));
ctx.fillStyle = grad;
ctx.fill();
}
ctx.restore();
// Show reference point and curve factor
ctx.save();
ctx.strokeStyle = '#666';
ctx.fillStyle = '#666';
ctx.beginPath();
ctx.moveTo(-mainRadius, mainRadius + .5);
ctx.lineTo(mainRadius, mainRadius + .5);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, mainRadius + .5, 2, 0, TAU);
ctx.fill();
ctx.restore();
}
var slider = document.getElementById('curvey');
var ease = d3.easeCubicInOut;
var duration = 4000;
var timer;
function showIt(t) {
if (shouldClear) {
ctx.clearRect(-width / 2, -height / 2, width * 2, height);
}
drawGradientLine(t);
ctx.fillText(t, 0, mainRadius + 20);
slider.value = t;
}
function tick(elapsed) {
var t = ease(1 - Math.abs((elapsed % duration) / duration - .5) * 2);
showIt(t);
}
// Handle controls
slider.addEventListener('input', function (e) {
showIt(this.value);
}, false);
document.getElementById('animate').addEventListener('click', function (e) {
isAnimating = this.checked;
if (isAnimating) {
if (!timer) {
timer = d3.timer(tick);
} else {
timer.restart(tick);
}
} else {
timer && timer.stop();
}
}, false);
document.getElementById('trippy').addEventListener('click', function (e) {
shouldClear = !this.checked;
}, false);
// Setup
showIt(0);
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment