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