Skip to content

Instantly share code, notes, and snippets.

@gilmoreorless
Last active September 6, 2016 21:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gilmoreorless/2ca51832212747a46872812c07744fd6 to your computer and use it in GitHub Desktop.
Save gilmoreorless/2ca51832212747a46872812c07744fd6 to your computer and use it in GitHub Desktop.
Twist a gradient-filled circle
license: cc-by-4.0

A solid circle with a left-to-right gradient smoothly twists and morphs into a ring, with the gradient now moving around the ring.

This is the final combination of 3 previous experiments. The arc fill clipping method is applied to a series of curved gradient fill sections. The shape of the arc is determined by a combination of the gradient line to circle and flatten a circle experiments, so the circle is squashed and twisted simultaneously. The same drawing caveats from the arc fill clipping experiment apply here, where different browsers have different approaches to smoothing (or not) the shape edges.

Conceptually, this is also similar to the changing of flow direction in directional ring of nodes.

<!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]);
// Set up some basic size interpolators
var _posDomain = [0, 1];
var scaleLength = d3.scaleLinear()
.domain(_posDomain)
.range([mainRadius * 2, mainRadius * TAU]);
var scaleHeight = d3.scaleLinear()
.domain(_posDomain)
.range([mainRadius * 2, lineThickness]);
var scaleCorner = d3.scaleLinear()
.domain(_posDomain)
.range([mainRadius, 0]);
var scaleAnchorAngle = d3.scaleLinear()
.domain(_posDomain);
// 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 shape with full hue gradient from start to finish.
* Shape always has y=mainRadius at x=0, with parameterised curve.
*
* `curveFactor` is a float in range [0, 1] that defines how curved the shape+fill is.
* 0 = no curve (filled circle, gradient left-to-right)
* 1 = full curve (circular line, gradient follows line)
*/
function drawMainShape(curveFactor) {
curveFactor = clamp(curveFactor, 0, 1);
if (curveFactor < 0.005) curveFactor = 0;
ctx.save();
ctx.beginPath();
// Draw the shape path
var shapeLength, shapeHeight, cornerRadius, arc, lengthRatio,
totalAngle, quarterAngle, startAngle, endAngle, origMidAngle, anchorAngle,
outerRadius, innerRadius, midRadius, offsetY;
if (curveFactor === 0) {
ctx.arc(0, 0, mainRadius, 0, TAU);
} else {
/**
* Four things need to change based on curveFactor:
*
* 1. Width / length (mainRadius * 2 => mainRadius * TAU)
* 2. Height (mainRadius * 2 => lineThickness)
* 3. Corner radius (mainRadius => 0)
* 4. Line curve (0 => TAU)
*/
// Simple interpolations
shapeLength = scaleLength(curveFactor);
shapeHeight = scaleHeight(curveFactor);
cornerRadius = scaleCorner(curveFactor);
// Angular calculations
lengthRatio = shapeLength / circumference;
totalAngle = TAU * curveFactor * lengthRatio;
quarterAngle = TAU_4 * curveFactor;
origMidAngle = TAU * curveFactor * (scaleLength(0) / circumference) / 2;
// Adjust the start angle a bit for a smooth visual transition from a perfect circle
anchorAngle = scaleAnchorAngle.range([origMidAngle, quarterAngle])(curveFactor);
startAngle = TAU_4 - anchorAngle;
endAngle = startAngle + totalAngle;
outerRadius = 1 / curveFactor * mainRadius;
innerRadius = outerRadius - shapeHeight;
midRadius = outerRadius - shapeHeight / 2;
offsetY = mainRadius - outerRadius;
arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius)
.cornerRadius(cornerRadius)
.context(ctx);
ctx.translate(0, offsetY);
arc({
startAngle: angleD3(startAngle),
endAngle: angleD3(endAngle)
});
ctx.clip();
arc.cornerRadius(0);
}
// Draw the gradient fill
var fudgeFactor = 0.05;
var halfPerc = 0.5 / segments;
var segmentAngle = totalAngle / segments;
var halfSegmentAngle = segmentAngle / 2;
var grad, midAngle, gradAngle1, gradAngle2, perc, i, x1, x2, y1, y2, c1, c2;
for (i = 0; i < segments; i++) {
perc = (i + 0.5) / segments;
c1 = hue((perc - halfPerc) * 360);
c2 = hue((perc + halfPerc) * 360);
// At c=0, we can use a single horizontal gradient from left to right
if (curveFactor === 0) {
if (!grad) {
grad = ctx.createLinearGradient(-mainRadius, 0, mainRadius, 0);
grad.addColorStop(0, c1);
}
grad.addColorStop(perc + halfPerc, c2);
// Otherwise, use a series of differently-angled gradients for the fill
} else {
midAngle = startAngle + totalAngle * perc;
gradAngle1 = angleSinCos(midAngle - halfSegmentAngle);
gradAngle2 = angleSinCos(midAngle + halfSegmentAngle);
x1 = Math.sin(gradAngle1) * midRadius;
y1 = Math.cos(gradAngle1) * midRadius;
x2 = Math.sin(gradAngle2) * midRadius;
y2 = Math.cos(gradAngle2) * midRadius;
grad = ctx.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
ctx.beginPath();
arc({
startAngle: angleD3(midAngle - halfSegmentAngle),
endAngle: angleD3(midAngle + halfSegmentAngle + (i === segments - 1 ? 0 : fudgeFactor))
});
ctx.fillStyle = grad;
ctx.fill();
}
}
if (curveFactor === 0) {
ctx.fillStyle = grad;
ctx.fill();
}
ctx.restore();
// Show reference point
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.easePolyInOut;
var duration = 4000;
var timer;
function showIt(t) {
if (shouldClear) {
ctx.clearRect(-width / 2, -height / 2, width * 2, height);
}
drawMainShape(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