Skip to content

Instantly share code, notes, and snippets.

@nbremer
Last active November 17, 2024 06:58
Show Gist options
  • Save nbremer/d7071c6a5a7206701015 to your computer and use it in GitHub Desktop.
Save nbremer/d7071c6a5a7206701015 to your computer and use it in GitHub Desktop.
Spirograph drawer - Animating solid and dashed lines
height: 900

This is a random Spirograph drawing script, used to explain and show how solid and dashed lines can be animated through D3 in my blog "Animated (dashed) lines in d3.js with Spirographs"

The shape of the spirograph is random and the optional dash pattern is random as well. You can use the slider to make the animation go faster (which in counter-intuitive form happens when you slide to the left) or slower (when you slide right. See it as number of seconds of the animation, left is low, right is high). Remove all the spirographs to start anew by pressing “reset”.

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<title>Creating a Spirograph</title>
<!-- D3.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>
<!-- Google fonts -->
<link href='http://fonts.googleapis.com/css?family=Open+Sans:400' rel='stylesheet' type='text/css'>
<!-- Pym.js - iframe height handler for the Blog -->
<script src="pym.min.js"></script>
<!-- jQuery -->
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<!-- Latest compiled and minified JavaScript -->
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<style>
body {
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 400;
fill: #575757;
text-align: center;
background: #101420;
}
.spirograph {
fill: none;
stroke-width: 1px;
}
.rangeDiv {
color: #949494;
}
</style>
</head>
<body>
<div class="container-fluid">
<!-- The chart -->
<div class="row">
<div class="col-sm-12">
<div id="chart"></div>
</div>
</div>
<!-- The buttons -->
<div class="row">
<div class="col-sm-6 col-sm-offset-3" style="margin-top: 20px;">
<div id="button" class="btn-group" data-toggle="buttons">
<label id="addButton" class="btn btn-default"><input type="radio" class="btn-options"> Add spiro </label>
<label id="addDashedButton" class="btn btn-default"><input type="radio" class="btn-options"> Add dashed spiro </label>
<label id="resetButton" class="btn btn-default"><input type="radio" class="btn-options"> Reset </label>
</div>
</div>
</div>
<!-- The slider -->
<div class="row">
<div class="col-sm-6 col-sm-offset-3 rangeDiv" style="margin-top: 20px; margin-bottom: 20px;">
Adjust the duration of the next Spirograph you draw (left: faster | right: slower)
<input type="range" id="rangeSlider" min="1" max="120" step="1" value="20" onchange="updateSlider(this.value)">
</div>
</div>
</div>
<script>
////////////////////////////////////////////////////////////
//////////////////////// Set-up ////////////////////////////
////////////////////////////////////////////////////////////
var screenWidth = $(window).innerWidth(),
screenHeight = ( $(window).innerHeight() > 160 ? $(window).innerHeight() - 120 : screenWidth );
mobileScreen = (screenWidth > 500 ? false : true);
var margin = {left: 10, top: 10, right: 10, bottom: 10},
width = screenWidth - margin.left - margin.right - 30,
height = (mobileScreen ? 300 : screenHeight) - margin.top - margin.bottom - 30;
var maxSize = Math.min(width, height) / 2,
drawDuration = 20;
//d3.select("#rangeSlider").attr("value", drawDuration);
var svg = d3.select("#chart").append("svg")
.attr("width", (width + margin.left + margin.right))
.attr("height", (height + margin.top + margin.bottom))
.append("g").attr("class", "wrapper")
.attr("transform", "translate(" + (width / 2 + margin.left) + "," + (height / 2 + margin.top) + ")");
var line = d3.svg.line()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
////////////////////////////////////////////////////////////
//////////////////// Draw a Spirograph /////////////////////
////////////////////////////////////////////////////////////
var colors = ["#00AC93", "#EC0080", "#FFE763"];
var numColors = 3;
var startColor = getRandomNumber(0,numColors); //Loop through the colors, but the starting color is random
function addSpiro(doDash) {
var path = svg.append("path")
.attr("class", "spirograph")
.attr("d", line(plotSpiroGraph()) )
.style("stroke", colors[startColor]);
//.style("stroke", "hsla(" + startColor/numColors * 360 + ", 100%, 50%, " + 0.9 + ")");
var totalLength = path.node().getTotalLength();
if (doDash) {
//Adjusted from http://stackoverflow.com/questions/24021971/animate-the-drawing-of-a-dashed-svg-line
//The first number specifies the length of the visible part, the dash, the second number specifies the length of the white part
var dashing = getRandomNumber(1,10) + ", " + getRandomNumber(1,10); //Something such as "6,6" could happen
console.log("Dash pattern is: " + dashing);
//This returns the length of adding all of the numbers in dashing (the length of one pattern in essense)
//So for "6,6", for example, that would return 6+6 = 12
var dashLength =
dashing
.split(/[\s,]/)
.map(function (a) { return parseFloat(a) || 0 })
.reduce(function (a, b) { return a + b });
//How many of these dash patterns will fit inside the entire path?
var dashCount = Math.ceil( totalLength / dashLength );
//Create an array that holds the pattern as often so it will fill the entire path
var newDashes = new Array(dashCount).join( dashing + " " );
//Then add one more dash pattern, namely with a visible part of length 0 (so nothing) and a white part
//that is the same length as the entire path
var dashArray = newDashes + " 0, " + totalLength;
} else {
//For a solid looking line, create a dash pattern with a visible part and a white part
//that are the same length as the entire path
var dashArray = totalLength + " " + totalLength;
}
//Animate the path by offsetting the path so all you see is the white last bit of dashArray
//(which has a length that is the same length as the entire path), and then slowly move this
//out of the way so the rest of the path becomes visible (the stuff at the start of dashArray)
path
.attr("stroke-dasharray", dashArray)
.attr("stroke-dashoffset", totalLength)
.transition().duration(drawDuration * 1000).ease("linear")
.attr("stroke-dashoffset", 0);
}//function addSpiro
////////////////////////////////////////////////////////////
////////////////// Button Activity /////////////////////////
////////////////////////////////////////////////////////////
d3.select("#addButton").on("click", function () {
//Create and draw a spiro
addSpiro(false);
startColor = (startColor+1)%numColors;
//Make the button inactive again
setTimeout( function() { d3.select("#addButton").classed("active", false); }, 200);
});
d3.select("#addDashedButton").on("click", function () {
//Create and draw a dashed spiro
addSpiro(true);
startColor = (startColor+1)%numColors;
//Make the button inactive again
setTimeout( function() { d3.select("#addDashedButton").classed("active", false); }, 200);
});
d3.select("#resetButton").on("click", function () {
//Remove all spiros
d3.selectAll(".spirograph").remove();
getRandomNumber(0,numColors);
//Make the button inactive again
setTimeout( function() { d3.select("#resetButton").classed("active", false); }, 200);
});
function updateSlider(value) {
drawDuration = value;
}
//Start drawing one spirograph after 1 second after reload
setTimeout(function() {
addSpiro(false);
startColor = (startColor+1)%numColors;
}, 1000);
////////////////////////////////////////////////////////////
////////////////// Spirograph functions ////////////////////
////////////////////////////////////////////////////////////
function plotSpiroGraph() {
//Function adjusted from: https://github.com/alpha2k/HTML5Demos/blob/master/Canvas/spiroGraph.html
var R = getRandomNumber(60, maxSize);
var r = getRandomNumber(40, (R * 0.75));
var alpha = getRandomNumber(25, r);
var l = alpha / r;
var k = r / R;
//Create the x and y coordinates for the spirograph and put these in a variable
var lineData = [];
for(var theta=1; theta<=20000; theta += 1){
var t = ((Math.PI / 180) * theta);
var ang = ((l-k)/k) * t;
var x = R * ((1-k) * Math.cos(t) + ((l*k) * Math.cos(ang)));
var y = R * ((1-k) * Math.sin(t) - ((l*k) * Math.sin(ang)));
lineData.push({x: x, y: y});
}
//Output the variables of this spiro
console.log("R: " + R + ", r: " + r + ", alpha: " + alpha + ", l: " + l + ", k: " + k);
return lineData;
}
function getRandomNumber(start, end) {
return (Math.floor((Math.random() * (end-start))) + start);
}
//iFrame handler
var pymChild = new pym.Child();
pymChild.sendHeight();
//setTimeout(function() { pymChild.sendHeight(); }, 2000);
</script>
</body>
</html>
/*! pym.js - v0.4.4 - 2015-07-16 */
!function(a){"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&module.exports?module.exports=a():window.pym=a.call(this)}(function(){var a="xPYMx",b={},c=function(a){var b=new RegExp("[\\?&]"+a.replace(/[\[]/,"\\[").replace(/[\]]/,"\\]")+"=([^&#]*)"),c=b.exec(location.search);return null===c?"":decodeURIComponent(c[1].replace(/\+/g," "))},d=function(a,b){return"*"===b.xdomain||a.origin.match(new RegExp(b.xdomain+"$"))?!0:void 0},e=function(b,c,d){var e=["pym",b,c,d];return e.join(a)},f=function(b){var c=["pym",b,"(\\S+)","(.+)"];return new RegExp("^"+c.join(a)+"$")},g=function(){for(var a=document.querySelectorAll("[data-pym-src]:not([data-pym-auto-initialized])"),c=a.length,d=0;c>d;++d){var e=a[d];e.setAttribute("data-pym-auto-initialized",""),""===e.id&&(e.id="pym-"+d);var f=e.getAttribute("data-pym-src"),g=e.getAttribute("data-pym-xdomain"),h={};g&&(h.xdomain=g),new b.Parent(e.id,f,h)}};return b.Parent=function(a,b,c){this.id=a,this.url=b,this.el=document.getElementById(a),this.iframe=null,this.settings={xdomain:"*"},this.messageRegex=f(this.id),this.messageHandlers={},c=c||{},this._constructIframe=function(){var a=this.el.offsetWidth.toString();this.iframe=document.createElement("iframe");var b="",c=this.url.indexOf("#");c>-1&&(b=this.url.substring(c,this.url.length),this.url=this.url.substring(0,c)),this.url.indexOf("?")<0?this.url+="?":this.url+="&",this.iframe.src=this.url+"initialWidth="+a+"&childId="+this.id+"&parentUrl="+encodeURIComponent(window.location.href)+b,this.iframe.setAttribute("width","100%"),this.iframe.setAttribute("scrolling","no"),this.iframe.setAttribute("marginheight","0"),this.iframe.setAttribute("frameborder","0"),this.el.appendChild(this.iframe),window.addEventListener("resize",this._onResize)},this._onResize=function(){this.sendWidth()}.bind(this),this._fire=function(a,b){if(a in this.messageHandlers)for(var c=0;c<this.messageHandlers[a].length;c++)this.messageHandlers[a][c].call(this,b)},this.remove=function(){window.removeEventListener("message",this._processMessage),window.removeEventListener("resize",this._onResize),this.el.removeChild(this.iframe)},this._processMessage=function(a){if(d(a,this.settings)&&"string"==typeof a.data){var b=a.data.match(this.messageRegex);if(!b||3!==b.length)return!1;var c=b[1],e=b[2];this._fire(c,e)}}.bind(this),this._onHeightMessage=function(a){var b=parseInt(a);this.iframe.setAttribute("height",b+"px")},this._onNavigateToMessage=function(a){document.location.href=a},this.onMessage=function(a,b){a in this.messageHandlers||(this.messageHandlers[a]=[]),this.messageHandlers[a].push(b)},this.sendMessage=function(a,b){this.el.getElementsByTagName("iframe")[0].contentWindow.postMessage(e(this.id,a,b),"*")},this.sendWidth=function(){var a=this.el.offsetWidth.toString();this.sendMessage("width",a)};for(var g in c)this.settings[g]=c[g];return this.onMessage("height",this._onHeightMessage),this.onMessage("navigateTo",this._onNavigateToMessage),window.addEventListener("message",this._processMessage,!1),this._constructIframe(),this},b.Child=function(b){this.parentWidth=null,this.id=null,this.parentUrl=null,this.settings={renderCallback:null,xdomain:"*",polling:0},this.messageRegex=null,this.messageHandlers={},b=b||{},this.onMessage=function(a,b){a in this.messageHandlers||(this.messageHandlers[a]=[]),this.messageHandlers[a].push(b)},this._fire=function(a,b){if(a in this.messageHandlers)for(var c=0;c<this.messageHandlers[a].length;c++)this.messageHandlers[a][c].call(this,b)},this._processMessage=function(a){if(d(a,this.settings)&&"string"==typeof a.data){var b=a.data.match(this.messageRegex);if(b&&3===b.length){var c=b[1],e=b[2];this._fire(c,e)}}}.bind(this),this._onWidthMessage=function(a){var b=parseInt(a);b!==this.parentWidth&&(this.parentWidth=b,this.settings.renderCallback&&this.settings.renderCallback(b),this.sendHeight())},this.sendMessage=function(a,b){window.parent.postMessage(e(this.id,a,b),"*")},this.sendHeight=function(){var a=document.getElementsByTagName("body")[0].offsetHeight.toString();this.sendMessage("height",a)}.bind(this),this.scrollParentTo=function(a){this.sendMessage("navigateTo","#"+a)},this.navigateParentTo=function(a){this.sendMessage("navigateTo",a)},this.id=c("childId")||b.id,this.messageRegex=new RegExp("^pym"+a+this.id+a+"(\\S+)"+a+"(.+)$");var f=parseInt(c("initialWidth"));this.parentUrl=c("parentUrl"),this.onMessage("width",this._onWidthMessage);for(var g in b)this.settings[g]=b[g];return window.addEventListener("message",this._processMessage,!1),this.settings.renderCallback&&this.settings.renderCallback(f),this.sendHeight(),this.settings.polling&&window.setInterval(this.sendHeight,this.settings.polling),this},g(),b});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment