Skip to content

Instantly share code, notes, and snippets.

@kenpenn
Last active July 5, 2016 01:47
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 kenpenn/ec90d195a2dc8bde85ed981928fabc63 to your computer and use it in GitHub Desktop.
Save kenpenn/ec90d195a2dc8bde85ed981928fabc63 to your computer and use it in GitHub Desktop.
disco lights
license: gpl-3.0
height: 500
// adapted from http://www.sitepoint.com/creating-accurate-timers-in-javascript/ ,
// https://bl.ocks.org/mbostock/5872848, Dispatching Events
// and https://bl.ocks.org/mbostock/1166403, Axis Component
(function () {
var svg = d3.select('svg')
.attr('width', window.innerWidth - 20)
.attr('height', 432) // 500 - buttons
.attr('viewbox', '0 0 ' + (window.innerWidth - 20) + ' ' + 432)
.attr('class', 'black-bg');
var beats = {
tick: 0,
start: 0,
beat: 4,
measure: 4,
measures: 0,
max: 0,
bpm: 60000 / 128, // 128 bpm, ~469 ms
len: 253,
toLoop: '',
play: function () {
var sync = this.tick && this.measures ?
Math.round( this.bpm * (this.tick % this.measures) ):
0;
this.start = Date.now() - sync;
this.emit();
},
emit: function () {
var real = Math.round(this.tick * this.bpm);
var ideal = Date.now() - this.start;
var diff = ideal - real;
var td = function (type) {
return {
type: type,
beat: this.tick,
measure: this.measures,
diff: diff,
to: this.bpm - diff,
max: this.max
}
}.bind(this);
this.tick += 1;
if (diff > this.max) { this.max = diff; }
if (this.tick % this.beat === 0) {
this.measures += 1;
evts.measure(td('measure'));
} else {
evts.beat(td('beat'));
}
clearTimeout(this.toLoop);
this.toLoop = setTimeout(function (self) {
self.emit();
}, this.bpm - diff, this );
},
stop: function () {
clearTimeout(this.toLoop);
this.tick = 0;
this.measures = 0;
},
pause: function () {
clearTimeout(this.toLoop);
this.tick = this.tick % this.beat;
}
};
var evts = d3.dispatch('beat', 'measure', 'play', 'pause', 'stop' );
evts.on('measure', function (td) {
lights.measure();
plot.append(td);
plot.update(td);
metronome.tick(td);
log(adjLog, td.beat, td.diff, td.max);
});
evts.on('beat', function (td) {
lights.beat();
plot.append(td);
plot.update(td);
metronome.tick(td);
log(adjLog, td.beat, td.diff, td.max)
});
evts.on('play', function () {
beats.play();
plot.play();
uncorrected.play();
});
evts.on('pause', function () {
beats.pause();
uncorrected.stop();
});
evts.on('stop', function () {
beats.stop();
uncorrected.stop();
});
var lights = {
rad: 0,
spRad: 0,
flashRad: 0,
grp: [],
nodes: [],
flashers: [],
colors: [
'hsl(341, 100%, 50%)', 'hsl(359, 100%, 50%)', 'hsl(18, 100%, 50%)', 'hsl(35, 100%, 50%)', 'hsl(52, 100%, 50%)',
'hsl(83, 100%, 50%)', 'hsl(127, 100%, 50%)', 'hsl(160, 100%, 50%)', 'hsl(190, 100%, 50%)', 'hsl(212, 100%, 50%)',
'hsl(227, 100%, 50%)', 'hsl(242, 100%, 50%)', 'hsl(259, 100%, 50%)', 'hsl(273, 100%, 50%)', 'hsl(296, 100%, 50%)'
],
init: function () {
var spacing = svg.attr('width') / (this.colors.length + 1);
this.rad = spacing / 4;
this.spRad = this.rad * 1.25;
this.flashRad = this.rad * 4;
this.grp = d3.select('#lights-grp')
this.nodes = this.grp
.selectAll('circle.light')
.data(this.colors);
this.nodes.enter()
.append('circle')
.attr('class', 'light')
.attr('cx', function(d, i) { return ( i + 1 ) * spacing })
.attr('cy', this.spRad + 10)
.attr('r', this.rad)
.attr('fill', function(d) { return d })
.datum(function (d, i) { return { color: d, idx: i }; } );
this.flashers = this.grp
.selectAll('circle.flasher')
.data(this.colors);
this.flashers.enter()
.append('circle')
.attr('class', 'flasher')
.attr('cx', function(d, i) { return ( i + 1 ) * spacing })
.attr('cy', this.spRad + 10)
.attr('r', this.rad)
.attr('fill', function(d) { return d })
.datum(function (d, i) { return { color: d, idx: i }; } );
},
flash: function (flasher) {
var self = this;
flasher
.transition()
.duration(beats.bpm - 100)
.attr('r', self.flashRad)
.attr('opacity', 0)
.each('end', function() {
flasher
.attr('r', self.rad)
.attr('opacity', 1)
});
},
beat: function () {
var self = this;
this.flashers.each(function(d,i) {
self.flash(d3.select(this));
});
},
measure: function () {
var self = this;
this.nodes.each(function(d,i) {
self.nextColor(d3.select(this));
});
this.flashers.each(function(d,i) {
var flasher = d3.select(this);
self.nextColor(flasher);
self.flash(flasher);
});
},
nextColor : function (light) {
var nextIdx = light.datum().idx + 1 >= this.colors.length ? 0: light.datum().idx + 1;
var nextColor = this.colors[nextIdx];
light.attr('fill', nextColor)
.datum( { color: nextColor, idx: nextIdx } );
}
};
var metronome = {
grp: {},
init: function() {
this.grp = d3.select('#metronome');
var metroDims = this.grp.node().getBoundingClientRect();
var svgDims = svg.node().getBoundingClientRect();
var lightDims = d3.select('#lights-grp').node().getBoundingClientRect();
this.grp.attr('transform', function () {
return 'translate(' +
( (svgDims.width - metroDims.width) * .5) + ',' + // left
( lightDims.bottom - svgDims.top + 20 ) + ')'; // top
})
.style('opacity', 1);
this.pendulum = this.grp.select('#pendulum')
this.pendL = this.pendulum.attr('d').substring(6)
this.arcPendulum = this.grp.select('#arc-pendulum');
this.arcPendulumLength = this.arcPendulum.node().getTotalLength();
this.arcPendulumMove = this.arcPendulumLength / beats.beat;
this.weight = this.grp.select('#weight');
this.arcWeight = this.grp.select('#arc-weight');
this.arcWeightLength = this.arcWeight.node().getTotalLength()
this.arcWeightMove = this.arcWeightLength / beats.beat;
this.direction = 1;
},
tick: function (td) {
var weightPt, pendulumPt, pathD, cx, cy, circle;
var beat = td.beat % beats.beat;
var self = this;
circle = this.grp.append('circle')
.attr('r', 5)
.attr('fill', '#dadada')
.attr('cx', function () { return self.weight.attr('cx') })
.attr('cy', function () { return self.weight.attr('cy') })
.transition()
.delay(td.to)
.duration(td.to)
.attr('opacity', .5)
.each('end', function () { d3.select(this).remove() });
if (td.type === 'beat') {
if (this.direction === -1) {
beat = beats.beat - beat;
}
pendulumPt = this.arcPendulum.node()
.getPointAtLength(this.arcPendulumLength - (beat * this.arcPendulumMove));
weightPt = this.arcWeight.node()
.getPointAtLength(this.arcWeightLength - (beat * this.arcWeightMove));
}
if (td.type === 'measure') {
if (this.direction === 1) {
pendulumPt = this.arcPendulum.node().getPointAtLength(0);
weightPt = this.arcWeight.node().getPointAtLength(0);
this.direction = -1;
} else {
pendulumPt = this.arcPendulum.node().getPointAtLength(this.arcPendulumLength);
weightPt = this.arcWeight.node().getPointAtLength(this.arcWeightLength);
this.direction = 1;
}
}
cx = weightPt.x;
cy = weightPt.y;
pathD = 'M' + pendulumPt.x + ',' + pendulumPt.y + ' ' + this.pendL;
this.pendulum.transition()
.duration(td.to)
.ease('linear')
.attr('d', pathD);
this.weight.transition()
.duration(td.to)
.ease('linear')
.attr('cx', cx)
.attr('cy', cy);
}
};
var plot = {
init: function () {
var now = Date.now() - beats.bpm;
var margins = {top: 6, right: 20, bottom: 20, left: 30};
var svgDims = svg.node().getBoundingClientRect();
var metroDims = d3.select('#metronome').node().getBoundingClientRect();
var height = svgDims.height - metroDims.bottom - margins.bottom - margins.top;
var width = svgDims.width - margins.right - margins.left;
d3.select('#plot-clip rect')
.attr('width', width)
.attr('height', height)
this.xScale = d3.time.scale()
.range([0, width]);
this.yScale = d3.scale.linear()
.range([height, 0])
.domain([-5, 30]);
this.grp = svg.append('g')
.attr('class', 'plot')
.attr('transform', 'translate(' + margins.left + ',' + (metroDims.bottom + margins.top) + ')');
this.grp.append('text')
.text('setTimeout deviation in ms')
.attr('x', 20)
.attr('y', 15);
this.xAxis = this.grp.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.attr('opacity', 0)
.call(this.xScale.axis = d3.svg.axis().scale(this.xScale).orient('bottom'));
this.yAxis = this.grp.append('g')
.attr('class', 'y axis')
.attr('transform', 'translate(0,' + (margins.top - 5) + ')')
.call(this.yScale.axis = d3.svg.axis().scale(this.yScale).orient('left'));
this.dots = this.grp.append('g')
.attr('id', 'dot-clip')
.attr('clip-path', 'url(#plot-clip)')
.append('g')
.attr('id', 'dots');
},
append: function (td) {
var plot = this;
var last = this.xScale.domain()[1];
var datum = td;
datum.then = Date.now();
this.dots.append('circle')
.attr('class', 'dot ' + datum.beat + ' ' + datum.type )
.attr('cx', plot.xScale(last))
.attr('cy', plot.yScale(datum.diff))
.datum(datum);
},
// http://bl.ocks.org/mbostock/1166403
update: function(td) {
var plot = this;
// update the x domain
var now = Date.now();
this.xScale.domain([now - (beats.len - 2) * beats.bpm, now - beats.bpm]);
// slide the x-axis left
var trans = this.grp.transition().duration(td.to).ease('linear');
trans.select('.x.axis').call(plot.xScale.axis);
this.dots.selectAll('.dot')
.transition()
.ease('linear')
.duration(td.to)
.attr('cx', function (d, i) {
return plot.xScale(d.then - td.to);
})
var goneDots = this.dots.selectAll('circle.dot')
.filter(function () {
var cx = parseInt(this.getAttribute('cx'), 10)
return cx < 0;
});
if (!goneDots.empty()) {
goneDots.remove();
};
},
play: function () {
var now = Date.now();
if (beats.measures === 0) {
this.dots.selectAll('.dot').remove();
}
this.xScale.domain([now - (beats.len - 2) * beats.bpm, now - beats.bpm]);
this.xAxis.attr('opacity', 1)
},
};
var playPauseCtrl = document.getElementById('play-pause-ctrl');
playPauseCtrl.addEventListener('click', function () {
if ( this.classList.contains('play') ) {
this.classList.add('pause');
this.classList.remove('play');
evts.play();
} else if ( this.classList.contains('pause') ) {
this.classList.remove('pause');
this.classList.add('play');
evts.pause();
}
});
var stopCtrl = document.getElementById('stop-ctrl');
stopCtrl.addEventListener('click', function () {
playPauseCtrl.classList.add('play');
playPauseCtrl.classList.remove('pause');
evts.stop();
});
var adjLog = d3.select('#adjusted');
var uncLog = d3.select('#uncorrected');
var log = function (selection, count, dev, max) {
var countEl = selection.select('.count').text(count);
var devEl = selection.select('.dev').text(dev);
var maxEl = selection.select('.max').text(max);
};
var uncorrected = {
tick: 0,
start: 0,
bpm: 60000 / 128, // 128 bpm, ~469 ms
toLoop: '',
max: 0,
play: function () {
this.start = Date.now();
this.tick = 0;
this.loop();
},
loop: function () {
var real = Math.round(this.tick * this.bpm);
var ideal = Date.now() - this.start;
var diff = ideal - real;
this.tick += 1;
if (diff > this.max ) { this.max = diff; }
log(uncLog, this.tick, diff, this.max);
clearTimeout(this.toLoop);
this.toLoop = setTimeout(function (self) {
self.loop();
}, this.bpm, this);
},
stop: function () {
clearTimeout(this.toLoop);
this.tick = 0;
}
};
lights.init();
metronome.init();
plot.init();
}());
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>disco lights</title>
<style>
body {
font-family: sans-serif;
margin: 0;
min-height: 500px;
}
svg { margin: 10px; }
.x.axis, y.axis, .plot-line {
stroke: white;
fill: white;
}
.black-bg { background: hsl(0, 0%, 0%); }
.axis { font-size: 12px; }
.axis path, .axis line, .tick line {
fill: none;
stroke: lawngreen;
shape-rendering: crispEdges;
stroke-width: 1.5px;
}
.plot text {
fill: #dadada;
stroke: none;
}
.tick text {
fill: lawngreen;
stroke: none;
}
.dot {
stroke: none;
r: 2;
}
.dot.beat { fill: lawngreen; }
.dot.measure { fill: crimson; }
.ctrl {
background: #dadada;
border-radius: 6px;
display: inline-block;
margin: 0 0 0 10px;
padding: 6px;
}
#play-icon {
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
border-left: 24px solid #777;
margin: 2px 2px 2px 6px;
width: 0;
height: 0;
}
#pause-left, #pause-right, #stop-icon {
background: #777;
}
#pause-left, #pause-right {
float: left;
margin-top: 4px;
margin-bottom: 4px;
width: 8px;
height: 24px;
}
#pause-left {
margin-left: 6px;
margin-right: 4px;
}
#pause-right {
margin-right: 6px;
}
#play-pause-ctrl.play #pause-icon { display: none; }
#play-pause-ctrl.pause #play-icon { display: none; }
#stop-icon {
border-radius: 3px;
margin: 6px;
width: 20px;
height: 20px;
}
#logs {
display:inline-block;
vertical-align: top;
}
.log {
margin: 0 0 4px 40px;
}
#adjusted {
margin-left: 65px;
}
.log div {
display: inline-block;
text-align: right;
}
.log .label {
margin-left: 20px;
}
.log .num {
width: 45px;
}
</style>
</head>
<body>
<svg>
<defs>
<clipPath id="plot-clip"><rect></rect></clipPath>
</defs>
<g id="lights-grp"></g>
<g id="metronome" style="opacity:0">
<path id="arc-weight" fill="none" stroke="none" d="M108.975346,35.3880198 C94.4537153,26.878235 77.5460263,22 59.4989645,22 C41.4425468,22 24.5266835,26.8832943 10,35.4012576"></path>
<path id="arc-pendulum" fill="none" stroke="none" d="M117.930342,20.6200429 C100.924281,10.6899236 81.1395384,5 60.0258433,5 C38.8619106,5 19.0332625,10.7170328 2,20.6909975"></path>
<polygon id="metro-body" fill="darkgoldenrod" points="45 5 60 0 75 5 85 190 35 190"></polygon>
<path id="pendulum" stroke="crimson" stroke-width="2" stroke-linecap="round" d="M2,21 L60,120"></path>
<circle id="weight" stroke="#979797" stroke-width="0.5" fill="white" cx="10" cy="35" r="5"></circle>
</g>
</svg>
<div>
<div id="play-pause-ctrl" class="play ctrl">
<div id="play-icon"></div>
<div id="pause-icon">
<div id="pause-left"></div>
<div id="pause-right"></div>
</div>
</div>
<div id="stop-ctrl" class="ctrl">
<div id="stop-icon"></div>
</div>
<div id="logs">
<div id="uncorrected" class="log">
Uncorrected loops: <div class="count num">0</div><div class="label">deviation: </div><div class="dev num">0</div>ms<div class="label">max: </div><div class="max num">0</div>ms
</div>
<div id="adjusted" class="log">
Adjusted loops: <div class="count num">0</div><div class="label">deviation: </div><div class="dev num">0</div>ms<div class="label">max: </div><div class="max num">0</div>ms
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<script src="beats.js"></script>
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment