function dataSrc(url, config) {
  config = config || {};
  url = url || [
    location.protocol == 'https:' ? 'wss://' : 'ws://',
    location.hostname,
    location.protocol == 'https:' ? ':443' : ':80',
    '/data/stream.ws'
  ].join('');
  var ctx = {
    'url': url,
    'ws': {},
    'proto': 'binary',
    'buffer': '',
    'headers': [],
    'data': [],
    'separatorRows': config.separatorRows || "\n",
    'separatorCols': config.separatorCols || "\t",
    'ping': (function() {
      var ac = new(window.AudioContext || window.webkitAudioContext)();
      var osc = ac.createOscillator();
      var gn = ac.createGain();
      osc.connect(gn);
      gn.connect(ac.destination);
      gn.gain.setTargetAtTime(0.0, ac.currentTime, 0.0);
      osc.start(ac.currentTime);
      return function(freq) {
        var d = [0.001, 0.01, 0.1]; // relative decay constants: freq < rise <  fall e.g. [0.001, 0.1, 0.2]
        var t = 0.100 // 100 ms target time
        var v = 0.25 // volume
        function energy(channel) {
          var c = [405, 4095];
          var e = [29.5503, 3628.78];
          return e[0] + (e[1] - e[0]) * (channel - c[0]) / (c[1] - c[0]);
        };
        var f = energy(freq);
        osc.frequency.setTargetAtTime(f, ac.currentTime + d[0] * t, d[0] * t);
        gn.gain.setTargetAtTime(v, ac.currentTime + d[1] * t, d[1] * t);
        gn.gain.setTargetAtTime(0.0, ac.currentTime + d[2] * t, d[2] * t);
      }
    })()
  };

  function connect() {
    if (ctx.proto == 'binary') {
      ctx.ws = new WebSocket(ctx.url, 'binary');
      ctx.ws.binaryType = 'arraybuffer';
    } else {
      ctx.ws = new WebSocket(ctx.url);
    }
    ctx.ws.onerror = function(evt) {
      console.log('WS ERROR: ' + evt.data);
    };
    ctx.ws.onopen = function(evt) {
      console.log('WS OPENED: ' + ctx.url)
    };
    ctx.ws.onmessage = function(evt) {
      //console.log('WS DATA:');
      ctx.buffer += ctx.proto == 'binary' ? String.fromCharCode.apply(null, new Uint8Array(evt.data)) : evt.data;
      var rows = ctx.buffer.split(ctx.separatorRows);
      ctx.buffer = rows.pop(); // buffer incomplete rows
      for (var r = 0; r < rows.length; r++) {
        var cols = rows[r].split(ctx.separatorCols);
        var row = [];
        for (var c = 0; c < cols.length; c++) {
          row.push((function(str) {
            var num = parseFloat(str);
            c > 0 ? ctx.ping(num) : true;
            return isNaN(num) ? str : num;
          })(cols[c]));
        }
        if (ctx.headers.length == 0) {
          ctx.headers = row;
        } else {
          ctx.data.push(row);
        }
      }
    };
    ctx.ws.onclose = function(evt) {
      console.log('WS CLOSED:' + evt.code);
      window.setTimeout(connect, 1000); // retry every 1000 ms
    };
  }
  connect();
  this.getHeaders = function() { // get headers strings array
    return ctx.headers;
  };
  this.getData = function(len) { // get len datasets and delete old data
    return ctx.data.splice(0).slice(-1 * (len || 1));
  };
  return this;
}


function makeData(src, len) {
  //console.log('makeData(src,' + len + ')');
  len = len || 100;
  var rows = src.getData(len);
  var cols = src.getHeaders();
  var data = [];
  var vals = [];
  for (var c = 0; c < cols.length; c++) {
    vals = [];
    for (var r = 0; r < rows.length; r++) {
      var val = typeof(rows[r][c]) != 'undefined' ? rows[r][c] : null
      //console.log( cols[c] + '[' + r + '] = ' + val);
      vals.push(val);
    }
    data.push(vals);
  }
  var time = data && data.length > 0 ? data.shift() : null;
  return {
    'x': (function(n) {
      for (x = []; n--; x.push(time));
      return x
    })(cols.length - 1),
    'y': data,
    'i': (function(n) {
      for (a = []; n--; a[n] = n);
      return a;
    })(cols.length - 1)
  };
}


function formatNames(str, find, replace) {
  str = str.toString();
  find = find || '_';
  replace = replace || ' ';
  return str.replace(
    new RegExp(
      find.replace(
        new RegExp(
          '([.*+?^=!:${}()|\[\]\/\\])',
          'g'
        ),
        '\\$1'
      ),
      'g'
    ),
    replace
  );
}


function makePlot(ctx, hdr) {
  //console.log(JSON.stringify(hdr, null, "\t"));
  var data = [];
  var cmap = chroma.scale(chroma.bezier(ctx.data.line.color)).mode('lab').correctLightness(false).colors(hdr.length - 1, null);
  for (var i = 1; i < hdr.length; i++) {
    var trace = JSON.parse(JSON.stringify(ctx.data))
    trace['type'] = 'scatter';
    trace['mode'] = 'lines';
    trace['fill'] = 'tonexty'; // 'tozeroy';
    trace['connectgaps'] = true;
    trace['name'] = formatNames(hdr[i]);
    trace['x'] = new Array(ctx.maxPoints);
    trace['y'] = new Array(ctx.maxPoints);
    trace['line']['width'] = 1;
    trace['line']['color'] = cmap[i - 1].css();
    trace['fillcolor'] = cmap[i - 1].alpha(0.2).css()
    data.push(trace);
  }
  document.title = ctx.layout.title;
  return Plotly.plot(ctx.id, data, ctx.layout, ctx.config); // https://github.com/plotly/plotly.js/blob/master/src/plot_api/plot_api.js#L53
}


function makeConfig(id, ctx) {
  var cmap = chroma.bezier(ctx.colors.graph).scale().colors(5);
  var data = {
    'name': 'trace', // replaced by tsv column headers
    'type': 'scatter',
    'mode': 'lines', // 'lines+markers',
    'line': {
      'shape': ctx.shape,
      'smoothing': ctx.smoothing,
      'color': ctx.colors.lines, // interpolated by colormap function
      'width': ctx.size
    },
    'marker': {
      'color': cmap[2],
      'size': 1.5 * ctx.size
    },
    'y': [0] // replaced by tsv column data
  };
  var layout = {
    'title': ctx.title.plot,
    'titlefont': {
      'family': 'Courier New, monospace',
      'size': 24,
      'color': cmap[0]
    },
    'autosize': true,
    // 'width': window.innerWidth,
    // 'height': window.innerHeight,
    'plot_bgcolor': cmap[4],
    'paper_bgcolor': cmap[4],
    'xaxis': {
      'title': ctx.title.xaxis,
      'range': [0, 3650],
      'color': cmap[1],
      'titlefont': {
        'family': 'Courier New, monospace',
        'size': 18,
        'color': cmap[0]
      },
      'tickwidth': ctx.size,
      'tickcolor': cmap[1],
      'linecolor': cmap[1],
      'gridcolor': cmap[3],
      'zerolinecolor': cmap[1],
      'mirror': true
    },
    'yaxis': {
      'title': ctx.title.yaxis,
      'type': 'log',
      'autorange': true,
      'color': cmap[1],
      'titlefont': {
        'family': 'Courier New, monospace',
        'size': 18,
        'color': cmap[0]
      },
      'tickwidth': ctx.size,
      'tickcolor': cmap[1],
      'linecolor': cmap[1],
      'gridcolor': cmap[3],
      'zerolinecolor': cmap[1],
      'mirror': true
    },
    'margin': {
      'l': 64,
      'r': 32,
      'b': 64,
      't': 80,
      'pad': 0
    }
  };
  var config = {
    'scrollZoom': true,
    'showLink': false,
    'displaylogo': false,
    'modeBarButtonsToAdd': [
      {
        'name': 'Toggle Lin/Log',
        'click': function(gd) {
          Plotly.relayout(gd, 'yaxis.type', gd.layout.yaxis.type == 'log' ? 'linear' : 'log');
        },
        'icon': {
          'width': 1000,
          'ascent': 850,
          'descent': 0,
          'path': 'm 598.75596,109.5 c -8.63281,0 -15.62503,-6.99222 -15.62503,-15.624987 V 62.624975 c 0,-8.632812 6.99222,-15.624987 15.62503,-15.624987 H 770.63093 V -77.999996 H 598.75596 c -8.63281,0 -15.62503,-6.99221 -15.62503,-15.62499 v -31.250024 c 0,-8.63283 6.99222,-15.625 15.62503,-15.625 H 770.63093 V -265.49999 H 598.75596 c -8.63281,0 -15.62503,-6.99222 -15.62503,-15.625 v -31.25002 c 0,-8.63283 6.99222,-15.625 15.62503,-15.625 H 770.63093 V -452.99999 H 598.75596 c -8.63281,0 -15.62503,-6.99222 -15.62503,-15.625 v -31.25002 c 0,-8.63283 6.99222,-15.62499 15.62503,-15.62499 H 770.63093 V -640.49999 C 770.63093,-675.01175 742.64268,-703 708.13096,-703 h -375 c -34.51171,0 -62.50001,27.98825 -62.50001,62.50001 v 874.99998 c 0,34.51171 27.9883,62.50001 62.50001,62.50001 h 375 c 34.51172,0 62.49997,-27.9883 62.49997,-62.50001 V 109.5 Z'
        }
      }
    ],
    'mmodeBarButtonsToRemove': ['sendDataToCloud']
  };
  return {
    'id': id,
    'data': data,
    'layout': layout,
    'config': config,
    'maxPoints': ctx.maxPoints
  };
}


function createGraph(id, ctx) {
  ctx = { // sane defaults
    'src': {
      'stream': ctx.src.stream || '/data/stream.ws',
      'init': ctx.src.init || '/data/hist.tsv'
    },
    'title': {
      'plot': ctx.title.plot || 'Plot Title',
      'xaxis': ctx.title.xaxis || 'Time / s',
      'yaxis': ctx.title.yaxis || 'Measured Entity / a.u.'
    },
    'colors': {
      'graph': ctx.colors.graph || ['black', 'white'],
      'lines': ctx.colors.lines || ['blue', 'cyan', 'lightgreen', 'yellow', 'red']
    },
    'maxPoints': ctx.maxPoints || 100,
    'size': ctx.size || 2,
    'scale': ctx.scale || 1.5,
    'shape': ctx.shape || 'linear',
    'smoothing': ctx.smoothing || 1
  };

  var src = dataSrc([
    location.protocol == 'https:' ? 'wss://' : 'ws://',
    location.hostname,
    location.protocol == 'https:' ? ':443' : ':80',
    ctx.src.stream
  ].join(''));

  var setup = window.setInterval(function() { // wait for headers to arrive
    var headers = src.getHeaders();
    if (headers.length > 0) {
      clearInterval(setup);
      makePlot(makeConfig(id, ctx), headers);

      function loadInitData(dataUrl) {
        var xmlhttp = new XMLHttpRequest();
        xmlhttp.open("GET", dataUrl); // xmlhttp.open("GET", url, false);
        xmlhttp.onreadystatechange = function() {
          if ((xmlhttp.status == 200) && (xmlhttp.readyState == 4)) {
            var maxPoints = ctx.maxPoints || 4096;

            function energy(channel) {
              var c = [405, 4095];
              var e = [29.5503, 3628.78];
              return e[0] + (e[1] - e[0]) * (channel - c[0]) / (c[1] - c[0]);
            };

            var data = {
              'x': [(function(n) {
                for (a = []; n--; a[n] = energy(n));
                return a;
              })(maxPoints)],
              'y': [
                []
              ],
              'i': [],
              't': 0,
              'e': 0
            };

            var initData = xmlhttp.responseText.split("\n").slice(0, maxPoints).map(parseFloat);
            for (i = 0; i < maxPoints; i++) { // prefill initial data
              var val = initData[i];
              if (isNaN(val)) {
                data.y[0][i] = 0;
              } else {
                data.y[0][i] = val;
                data.e += val;
              }
            }
            //console.log(data);

            var res = true;
            res &= Plotly.relayout(id, {
              'title': ctx.title.plot + ' ' + data.t + 's',
              'yaxis.title': ctx.title.yaxis + ' / ' + data.e
            }).catch(function(e) {
              //console.log('ERROR[Plotly.relayout()]: ' + e);
            });

            res &= Plotly.extendTraces(id, {
              'x': data.x,
              'y': data.y
            }, [0], maxPoints).catch(function(e) {
              //console.log('ERROR[Plotly.extendTraces()]: ' + e);
            });

            var update = window.setInterval(function() { // update plot in realtime

              var rawdata = makeData(src, 1024 * 1024 * 1024); // get at most 1 GB of events

              data.i = (function(n) {
                for (a = []; n--; a[n] = n);
                return a;
              })(rawdata.y.length);

              for (i = 0; i < rawdata.y.length; i++) {
                for (j = 0; j < rawdata.y[i].length; j++) {
                  data.y[i][rawdata.y[i][j]] += 1;
                  data.e += 1
                }
              }

              var evtTime = rawdata.x[0][rawdata.x[0].length - 1];
              data.t = evtTime ? evtTime : data.t;

              return data.i.length > 0 ? (function() {
                var res = true;
                res &= Plotly.relayout(id, {
                  'title': ctx.title.plot + ' ' + data.t + 's',
                  'yaxis.title': ctx.title.yaxis + ' / ' + data.e
                }).catch(function(e) {
                  //console.log('ERROR[Plotly.relayout()]: ' + e);
                });

                res &= Plotly.extendTraces(id, {
                  'x': data.x,
                  'y': data.y
                }, data.i, maxPoints).catch(function(e) {
                  //console.log('ERROR[Plotly.extendTraces()]: ' + e);
                });
                return res;

              })() : true;
            }, 500); // time resolution 500ms
          }
        };
        xmlhttp.send();
      }
      loadInitData(ctx.src.init);
    }
  }, 500);
}
