block by kenpenn ec90d195a2dc8bde85ed981928fabc63

disco lights

Full Screen

An expansion of Self-adjusting setTimeout loop in javascript

Uses a self-adjusting timeout loop to trigger events, at somewhat consistent intervals, to drive animation.

Uncorrected loops shows a setTimeout with a fixed delay, the same delay as the adjusted setTimeout target.

Metronome drawn with Sketch

adapted from:

index.html

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

beats.js

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