Gradual improvements:
  * More than one plot (any field vs. secs)
  * Add crosshairs

Change-Id: I811aa79930e2ba224cbf2ec560eb1907cdd90c4c
diff --git a/app.py b/app.py
index 59573ad..b0971da 100644
--- a/app.py
+++ b/app.py
@@ -21,8 +21,8 @@
 import time
 import traceback
 import urllib
-import tornado.template
 import webapp2
+import tornado.template
 import wifipacket
 from google.appengine.api import memcache
 from google.appengine.api import users
@@ -42,6 +42,17 @@
 loader = tornado.template.Loader('.')
 
 
+def _Esc(s):
+  """Like tornado.escape.url_escape, but only escapes &, #, %, and =."""
+  out = []
+  for c in s:
+    if c in ['&', '#', '%', '=']:
+      out.append('%%%02X' % ord(c))
+    else:
+      out.append(c)
+  return ''.join(out)
+
+
 def AllowedEmails():
   try:
     return open('email-allow.txt').read().split()
@@ -236,7 +247,9 @@
     capdefault.put()
     pcapdata.put()
 
-    self.render('d3viz.html')
+    self.redirect('/d3viz.html?key=%s&to_plot=%s'
+                  % (_Esc(str(blob_info.key())),
+                     _Esc(','.join(pcapdata.show_fields))))
 
 
 def _MaybeCache(update_cache, blob_info, pcapdata):
diff --git a/app.yaml b/app.yaml
index 7cfd469..eb42e3d 100644
--- a/app.yaml
+++ b/app.yaml
@@ -5,5 +5,8 @@
 threadsafe: true
 
 handlers:
+- url: /d3viz.html(.*)
+  static_files: d3viz.html
+  upload: d3viz.html
 - url: /(.*)
   script: app.wsgi_app
diff --git a/d3viz.html b/d3viz.html
index 9a66991..0d66aa8 100644
--- a/d3viz.html
+++ b/d3viz.html
@@ -1,161 +1,328 @@
-{% extends 'index.html' %}
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>WiFi packets as seen from the space</title>
+  </head>
+  <body>
 
-{% block body %}
+  <script type='text/javascript'
+        src='//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js'></script>
+      <script type='text/javascript' src='http://d3js.org/d3.v3.min.js'></script>
+      <script type='text/javascript'>
+        // TODO(katepek): Move into a separate JS file
 
-    <script type='text/javascript'
-      src='//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js'></script>
-    <script type='text/javascript' src='http://d3js.org/d3.v3.min.js'></script>
-    <script type='text/javascript'>
-      // TODO(katepek): Move into a separate JS file
-
-      var debug = true;
-      function log(o) {
-        if (debug) {
-          console.log(o);
-        }
-      }
-
-      // TODO(katepek): Find better values; currently for 13" screen.
-      var width = 1350;
-      var height = 650;
-      var padding = 20;
-
-      // TODO(katepek): Pull in some standard library for this
-      var pair_colours =
-        ['black', 'red', 'blue', 'green', 'magenta',
-         'gray', 'hotpink', 'chocolate', 'deepskyblue', 'gold'];
-
-      var dataset; // all packets
-      var streams; // pairs of (transmitter, receiver)
-
-      var pcapSecsScale; // x
-      var seqScale;      // y
-
-      try {
-        $.getJSON('/json/' + get_key(), function(json) {
-            begin = new Date().getTime();
-
-            init_data(JSON.stringify(json));
-            init_scales();
-            visualize();
-
-            end = new Date().getTime();
-            log('Spent on visualization ' + ((end - begin) / 1000) + ' sec.');
-        });
-      } catch (error) {
-        console.log(error);
-      }
-
-      function get_key() {
-        parts = window.location.pathname.split('/');
-        return parts[parts.length - 1];
-      }
-
-      function init_data(json_string) {
-        // TODO(katepek): Should sanitize here? E.g., discard bad packets?
-        // Packets w/o seq?
-        js_objects = JSON.parse(json_string);
-        dataset = JSON.parse(js_objects['js_packets']);
-        streams = JSON.parse(js_objects['js_streams']);
-      }
-
-      function raw_seq(d) {
-        return Number(d['seq']);
-      }
-
-      function raw_pcapSecs(d) {
-        return parseFloat(d['pcap_secs']);
-      }
-
-      function init_scales() {
-        // Prepare scale for X axis
-        pcapSecsScale = d3.scale
-          .linear()
-          .domain([d3.min(dataset, raw_pcapSecs),
-            d3.max(dataset, raw_pcapSecs)])
-          .range([2 * padding, width - 2 * padding]);
-
-        // Prepare scale for X axis
-        seqScale = d3.scale
-          .linear()
-          .domain([d3.min(dataset, raw_seq),
-            d3.max(dataset, raw_seq)])
-          .range([height - padding, padding]);
-      }
-
-      function seq(d) {
-        var seq = seqScale(Number(d['seq']));
-        return seq;
-      }
-
-      function pcapSecs(d) {
-        var pcapSecs = pcapSecsScale(parseFloat(d['pcap_secs']));
-        return pcapSecs;
-      }
-
-      function visualize() {
-        var svg = d3
-          .select('body')
-          .append('svg')
-          .attr('width', width)
-          .attr('height', height)
-          .style('border', '1px solid black');
-
-        // TODO(katepek): Show a summary somewhere as a legend
-        // which pair corresponds to which colour
-        for (i = 0; i < streams.length; i++) {
-          log('pcap_vs_seq' + i);
-          log(get_colour(i));
-          svg
-            .selectAll('pcap_vs_seq' + i)
-            .data(dataset)
-            .enter()
-            .append('circle')
-            .filter(function(d) {
-              return d['ta'] == streams[i]['ta'] &&
-                     d['ra'] == streams[i]['ra'];
-            })
-            .attr('cx', pcapSecs)
-            .attr('cy', seq)
-            .attr('r', 1)
-            .attr('fill', get_colour(i))
-            .append('title')
-            .text(
-               function(d) {
-                 return d['typestr'] +
-                   ': pcapSecs=' + d['pcap_secs'] +
-                   '; seq=' + d['seq'] +
-                   '\n(ta=' + d['ta'] + ',' + 'ra=' + d['ra'] + ')';
-               }
-            );
+        var debug = true;
+        function log(o) {
+          if (debug) {
+            console.log(o);
+          }
         }
 
-        // TODO(katepek): Axes seem to show range, not the domain
-        var pcapSecsAxis = d3.svg.axis()
-          .scale(pcapSecsScale)
-          .orient('bottom')
-          .ticks(5);
-        var seqAxis = d3.svg.axis()
-          .scale(seqScale)
-          .orient('right')
-          .ticks(5);
+        var w = window,
+            d = document,
+            e = d.documentElement,
+            g = d.getElementsByTagName('body')[0];
 
-        svg.append('g')
-          .attr('class', 'axis')
-          .attr('transform', 'translate(0,' + (height - padding) + ')')
-          .call(pcapSecsAxis);
-        svg.append('g')
-          .attr('class', 'axis')
-          .attr('transform', 'translate(' + (width - 2 * padding) + ',0)')
-          .call(seqAxis);
-      }
+        var total_width = w.innerWidth || e.clientWidth || g.clientWidth;
+        var total_height = w.innerHeight || e.clientHeight || g.clientHeight;
+        var width;  // of a plot
+        var height; // of a plot
+        var padding = 20;
 
-      function get_colour(i) {
-        if (i < pair_colours.length)
-          return pair_colours[i];
-        return pair_colours[i % pair_colours.length];
-      }
+        // TODO(katepek): Pull in some standard library for this
+        var stream_colours =
+          ['black', 'red', 'blue', 'green', 'magenta',
+           'gray', 'hotpink', 'chocolate', 'deepskyblue', 'gold'];
 
-    </script>
+        var field_settings = {
+          'pcap_secs': {
+            'parser': parseFloat,
+            'scale_type': d3.scale.linear(),
+          },
+          'seq': {
+            'parser': Number,
+            'scale_type': d3.scale.linear(),
+          },
+          'rate': {
+            'parser': Number,
+            'scale_type': d3.scale.log(),
+          },
+          'default': {
+            'parser': parseFloat,
+            'scale_type': d3.scale.linear(),
+          }
+        }
 
-{% end %}
+        function settings(field) {
+          if (field_settings.hasOwnProperty(field))
+            return field_settings[field];
+          return field_settings['default'];
+        }
+
+        findIdxByPcapSecs = d3.bisector(raw('pcap_secs')).left;
+
+        var to_plot = []; // fields to be plotted against X axis (time)
+        var scales  = {}; // dict[field; scale], incl. X axis
+        var reticle = {}; // dict[field; crosshair]
+
+        var dataset; // all packets
+        var streams; // pairs of (transmitter, receiver)
+
+        try {
+          $.getJSON('/json/' + get_query_param('key')[0], function(json) {
+              begin = new Date().getTime();
+
+              init(JSON.stringify(json));
+              for (idx in to_plot) {
+                visualize(to_plot[idx]);
+              }
+
+              end = new Date().getTime();
+              log('Spent on visualization ' + ((end - begin) / 1000) + ' sec.');
+          });
+        } catch (error) {
+          console.log(error);
+        }
+
+        function get_key() {
+          parts = window.location.pathname.split('/');
+          return parts[parts.length - 1];
+        }
+
+        function get_query_param(param) {
+         params = window.location.search.substring(1).split('&');
+         for (idx in params) {
+           if (params[idx].indexOf(param + '=') == 0)
+             return params[idx].split('=')[1].split(',');
+         }
+         return [];
+        }
+
+        function init(json_string) {
+          // TODO(katepek): Should sanitize here? E.g., discard bad packets?
+          // Packets w/o seq?
+          js_objects = JSON.parse(json_string);
+          dataset = JSON.parse(js_objects['js_packets']);
+          streams = JSON.parse(js_objects['js_streams']);
+
+          to_plot = get_query_param('to_plot');
+
+          // Leave only packets that have all the fields that we want to plot
+          // and the values there are positive
+          filter_dataset();
+          // Descending
+          dataset = dataset.sort(function(x, y) {
+            return raw('pcap_secs')(x) - raw('pcap_secs')(y);
+          });
+
+          height = (total_height - 4 * padding) / to_plot.length;
+          width = total_width - 4 * padding;
+
+          var x_range = [2 * padding, width - 2 * padding];
+          var y_range = [height - padding, padding];
+
+          log('total_height = ' + total_height);
+          log('height = ' + height);
+
+          add_scale('pcap_secs', x_range);
+          for (idx in to_plot) {
+            add_scale(to_plot[idx], y_range);
+          }
+        }
+
+        function filter_dataset() {
+          log('Before filtering: ' + dataset.length);
+          dataset = dataset.filter(function(d) {
+            if (!d.hasOwnProperty('pcap_secs')) return false;
+            if (raw('pcap_secs')(d) <= 0) return false;
+
+            for (idx in to_plot) {
+              if (!d.hasOwnProperty(to_plot[idx])) return false;
+              if (raw(to_plot[idx])(d) <= 0) return false;
+            }
+            return true;
+          });
+          log('After filtering: ' + dataset.length);
+        }
+
+        function add_scale(field, range) {
+          scales[field] = settings(field)['scale_type']
+            .domain([d3.min(dataset, raw(field)),
+              d3.max(dataset, raw(field))])
+            .range(range);
+        }
+
+        function raw(name) {
+          return function(d) {
+            return settings(name)['parser'](d[name]);
+          }
+        }
+
+        function scaled(name) {
+          return function(d) {
+            return scales[name](settings(name)['parser'](d[name]));
+          }
+        }
+
+        function visualize(field) {
+          log('About to visualize ' + field);
+
+          var svg = d3
+            .select('body')
+            .append('svg')
+            .attr('width', width)
+            .attr('height', height)
+            .style('border', '1px solid black');
+
+          var focus = svg.append('g') .style('display', null);
+          reticle[field] = focus;
+
+          // TODO(katepek): Show a summary somewhere as a legend
+          // which pair corresponds to which colour
+          for (i = 0; i < streams.length; i++) {
+            log('pcap_vs_' + field + '_' + i + ' (' + get_colour(i) + ')');
+
+            svg
+              .selectAll('pcap_vs_' + field + '_' + i)
+              .data(dataset)
+              .enter()
+              .append('circle')
+              .filter(function(d) {
+                return d['ta'] == streams[i]['ta'] &&
+                       d['ra'] == streams[i]['ra'];
+              })
+              .attr('cx', scaled('pcap_secs'))
+              .attr('cy', scaled(field))
+              .attr('r', 2)
+              .attr('fill', get_colour(i));
+          }
+
+          // TODO(katepek): Axes seem to show range, not the domain
+          var pcapSecsAxis = d3.svg.axis()
+            .scale(scales['pcap_secs'])
+            .orient('bottom')
+            .ticks(5);
+          var yAxis = d3.svg.axis()
+            .scale(scales[field])
+            .orient('right')
+            .ticks(5);
+
+          svg.append('g')
+            .attr('class', 'axis')
+            .attr('transform', 'translate(0,' + (height - padding) + ')')
+            .call(pcapSecsAxis);
+          svg.append('g')
+            .attr('class', 'axis')
+            .attr('transform', 'translate(' + (width - 2 * padding) + ',0)')
+            .call(yAxis);
+
+          // Add crosshairs
+          focus.append('line')
+            .attr('class', 'x')
+            .style('stroke', 'black')
+            .style('stroke-width', '3')
+            .style('stroke-dasharray', '13,13')
+            .style('opacity', 0.5)
+            .attr('y1', 0)
+            .attr('y2', height);
+
+          focus.append('line')
+            .attr('class', 'y')
+            .style('stroke', 'blue')
+            .style('stroke-width', '3')
+            .style('stroke-dasharray', '13,13')
+            .style('opacity', 0.5)
+            .attr('x1', 0)
+            .attr('x2', width);
+
+          focus.append('circle')
+            .attr('class', 'y')
+            .style('fill', 'none')
+            .style('stroke', 'blue')
+            .style('stroke-width', '3')
+            .attr('r', 4);
+
+          focus.append('text')
+            .attr('class', 'y1')
+            .attr('font-family', 'sans-serif')
+            .attr('font-size', '20px')
+            .attr('fill', 'black')
+            .attr('dx', 8)
+            .attr('dy', '-.5em');
+
+          // append the rectangle to capture mouse movements
+          svg.append('rect')
+              .attr('width', width)
+              .attr('height', height)
+              .style('fill', 'none')
+              .style('pointer-events', 'all')
+              .on('mouseover', function() {
+                for (i in Object.keys(reticle)) {
+                  current = reticle[Object.keys(reticle)[i]];
+                  current.style('display', null);
+                  current.select('.y').style('display', null);
+                  current.select('circle.y').style('display', null);
+                  current.select('text.y1').style('display', null);
+                }
+              })
+              .on('mouseout', function() {
+                x = d3.mouse(this)[0];
+                if (x < scales['pcap_secs'].range()[0] ||
+                    x > scales['pcap_secs'].range()[1]) {
+                  for (i in Object.keys(reticle)) {
+                    reticle[Object.keys(reticle)[i]].style('display', 'none');
+                  }
+                }
+              })
+              .on('mousemove', function() {
+                var x = d3.mouse(this)[0],
+                    y = d3.mouse(this)[1];
+
+                if (x < scales['pcap_secs'].range()[0] ||
+                    x > scales['pcap_secs'].range()[1] ||
+                    y > total_height)
+                  return;
+
+                pcap_secs = scales['pcap_secs'].invert(x);
+                idx = findIdxByPcapSecs(dataset, pcap_secs, 1);
+                d0 = dataset[idx - 1];
+                d1 = dataset[idx];
+                d = Math.abs(x - scaled('pcap_secs')(d0)) >
+                    Math.abs(x - scaled('pcap_secs')(d1)) ?
+                    d1 : d0;
+
+                for (i in Object.keys(reticle)) {
+                  r_field = Object.keys(reticle)[i];
+
+                  closest_x = scaled('pcap_secs')(d);
+                  closest_y = scaled(r_field)(d);
+
+                  reticle[r_field].select('.x')
+                    .attr('transform', 'translate(' + closest_x + ',0)');
+                  reticle[r_field].select('.y')
+                    .attr('transform', 'translate(0,' + closest_y + ')');
+
+                  reticle[r_field].select('circle.y')
+                      .attr('transform',
+                            'translate(' + closest_x + ',' + closest_y + ')');
+                  reticle[r_field].select('text.y1')
+                    .attr('transform',
+                          'translate(' + closest_x + ',' + closest_y + ')')
+                    .text('secs=' + d['pcap_secs'] +
+                      '; ' + r_field + '=' + d[r_field]);
+                }
+              });
+        }
+
+        function get_colour(i) {
+          if (i < stream_colours.length)
+            return stream_colours[i];
+          return stream_colours[i % stream_colours.length];
+        }
+
+      </script>
+
+  </body>
+</html>
+
diff --git a/sandbox/D3.READS b/sandbox/D3.READS
new file mode 100644
index 0000000..d2aac59
--- /dev/null
+++ b/sandbox/D3.READS
@@ -0,0 +1,2 @@
+http://chimera.labs.oreilly.com/books/1230000000345/index.html
+https://leanpub.com/D3-Tips-and-Tricks