|
// 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(); |
|
}()); |