|
<!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> |