implementing animation, color picker, and markers
diff --git a/js/app.js b/js/app.js
index 03b9fb6..f60ccf9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -47,6 +47,12 @@
   timeRange.domElement.style.display = 'none';
   timeRange.domElement.className = 'time-range';
 
+  // Set the default range colors.
+  overlay.setStartColor(controls.state.style.startColor);
+  overlay.setEndColor(controls.state.style.endColor);
+  timeRange.setStartColor(controls.state.style.startColor);
+  timeRange.setEndColor(controls.state.style.endColor);
+
   // Update the Overlay viewport when the map bounds change.
   map.setOverlayElement(overlay.domElement);
   map.onBoundsChanged(overlay.setBounds.bind(overlay));
@@ -60,16 +66,50 @@
   // Listen for settings and data events from the controls.
   controls.domElement.addEventListener('settings-changed', function(event) {
     var setting = event.detail.folderName + '/' + event.detail.optionName;
-    if (setting === 'data/multiplier') {
-      overlay.setMultiplier(event.detail.value);
-    } else if (setting === 'data/medianCorrection') {
-      overlay.setMedianCorrection(event.detail.value);
+    var value = event.detail.value;
+    switch (setting) {
+      case 'data/multiplier':
+        overlay.setMultiplier(value);
+        break;
+      case 'data/medianCorrection':
+        overlay.setMedianCorrection(value);
+        break;
+      case 'data/showMarkers':
+        map.setMarkerVisibility(value);
+        break;
+      case 'style/startColor':
+        overlay.setStartColor(value);
+        timeRange.setStartColor(value);
+        break;
+      case 'style/endColor':
+        overlay.setEndColor(value);
+        timeRange.setEndColor(value);
+        break;
+      case 'style/lineWidth':
+        overlay.setLineWidth(value);
+        break;
+      case 'animation/enabled':
+        value ? overlay.startAnimation() : overlay.stopAnimation();
+        break;
+      case 'animation/duration':
+        overlay.setAnimationDuration(value);
+        break;
+      case 'animation/delay':
+        overlay.setAnimationDelay(value);
+        break;
+      case 'animation/showStats':
+        value ? overlay.showStats() : overlay.hideStats();
+        break;
+      default:
+        throw Error('Unrecogrized setting: ' + setting);
+        break;
     }
   }, false);
 
   // Listen for data-ready events from the controls element, feed to components
   // that need to know.
   controls.domElement.addEventListener('data-ready', function(event) {
+    map.setData(event.detail);
     overlay.setData(event.detail);
   }, false);
 
diff --git a/js/controls.js b/js/controls.js
index 9c54a20..48bedff 100644
--- a/js/controls.js
+++ b/js/controls.js
@@ -89,7 +89,12 @@
     Object.keys(folderSettings.options).forEach(function(optionName) {
       var option = folderSettings.options[optionName];
       folderState[optionName] = option.defaultValue;
-      var ctrl = folder.add(folderState, optionName);
+      var ctrl;
+      if (option.type === 'color') {
+        ctrl = folder.addColor(folderState, optionName);
+      } else {
+        ctrl = folder.add(folderState, optionName);
+      }
       if ('min' in option) {
         ctrl = ctrl.min(option.min);
       }
diff --git a/js/map.js b/js/map.js
index 4cbeebe..4a18cac 100644
--- a/js/map.js
+++ b/js/map.js
@@ -16,10 +16,13 @@
  *  limitations under the License.
  */
 
-// Map requires google maps.
+// Map requires Google Maps and d3.
 if (typeof google === 'undefined' || typeof google.maps === 'undefined') {
   throw Error('Google Maps is required but missing.');
 }
+if (typeof d3 === 'undefined') {
+  throw Error('d3 is required but missing.');
+}
 
 var geovelo;
 geovelo = geovelo || {};
@@ -41,14 +44,20 @@
     containerElement.appendChild(this.domElement);
   }
 
+  // Array of Google Maps Markers. Will be created when data arrives.
+  this.markers = null;
+
+  // Whether markers should be visible.
+  this.markerVisibility = false;
+
   // Callback handlers for when the bounds change.
   this.boundsChangedHandlers = [];
 
-  // Initial coordinates.
+  // Initial coordinates. Will center on data once loaded.
   var initial = {
-    zoom: 5,
-    lon: 139.7667,
-    lat: 35.6833
+    zoom: 2,
+    lon: 0,
+    lat: 0,
   };
 
   // Google Map Stylers.
@@ -82,7 +91,7 @@
 
   // Create the Google Map
   var map = this.map = new google.maps.Map(this.domElement, {
-    zoom: 5,
+    zoom: initial.zoom,
     center: new google.maps.LatLng(initial.lat, initial.lon),
     mapTypeControl: false,
     mapTypeId: google.maps.MapTypeId.TERRAIN,
@@ -109,6 +118,137 @@
   //map.addListener('center_changed', emit);
   map.addListener('zoom_changed', emit);
   map.addListener('idle', emit);
+
+  // D3 selection which will be host to the clicked beacon's info.
+  this.infoContent = d3.select(document.createElement('div')).html(`
+      <h2 class="name"></h2>
+      <p>
+        Start: <span class="start"></span>
+      </p>
+      <p>
+        Lat, Lon: <span class="lat"></span>, <span class="lon"></span>
+      </p>
+  `);
+
+  // Info window to show data about a particular marker. Content is bound to
+  // the infoContent div.
+  this.infoWindow = new google.maps.InfoWindow({
+    content: this.infoContent[0][0]
+  });
+};
+
+/**
+ * New beacon data is available. Set up markers and zoom over there.
+ *
+ * @param {Array} beacons An array of data for the beacons.
+ */
+geovelo.Map.prototype.setData = function(beacons) {
+  // Desired map bounds based on min/max of east/west and south/north.
+  var bounds = {
+    east: -Infinity,
+    west: Infinity,
+    north: -90,
+    south: 90,
+  };
+
+  var markers = [];
+
+  // Create a marker for each beacon.
+  for (var i = 0; i < beacons.length; i++) {
+    var marker = this.createMarker(beacons[i]);
+    markers.push(marker);
+
+    var pos = marker.getPosition();
+    var lat = pos.lat();
+    var lon = pos.lng();
+
+    bounds.east = Math.max(bounds.east, lon);
+    bounds.west = Math.min(bounds.west, lon);
+    bounds.north = Math.max(bounds.north, lat);
+    bounds.south = Math.min(bounds.south, lat);
+  }
+
+  this.map.fitBounds(bounds);
+
+  this.markers = markers;
+};
+
+/**
+ * Helper function for creating a Google Maps marker from a beacon.
+ *
+ * @param {Object} beacon Data object representing a beacon.
+ */
+geovelo.Map.prototype.createMarker = function(beacon) {
+
+  // Keep track of the first, max and min lat and lon values.
+  var lat = null;
+  var lon = null;
+  var minLat = 90;
+  var maxLat = -90;
+  var minLon = Infinity;
+  var maxLon = -Infinity;
+
+  // Roll through the beacon's lat/lon pairs and take note.
+  for (var i = 0; i < beacon.lat.length; i++) {
+    var currentLat = beacon.lat[i];
+    var currentLon = beacon.lon[i];
+    if (lat === null && currentLat && currentLon) {
+      lat = currentLat;
+      lon = currentLon;
+    }
+    minLat = Math.min(minLat, currentLat);
+    minLat = Math.min(minLat, currentLat);
+  }
+
+  // If a non-missing lat/lon pair couldn't be found, that's an error.
+  if (!lat || !lon) {
+    throw Error('Beacon has no non-zero coordinates: ' + beacon.name);
+  }
+
+  var marker = new google.maps.Marker({
+    position: { lng: lon, lat: lat },
+    map: this.map,
+    title: beacon.name,
+    label: beacon.name,
+    visible: this.markerVisibility,
+  });
+
+  // Derived values from the beacon.
+  var startDate = new Date(beacon.start * 1000);
+
+  // When marker is clicked, update the Info Window content and show it.
+  marker.addListener('click', function() {
+    // This code makes heavy use of the d3 join/enter/update pattern.
+    // JOIN.
+    var content = this.infoContent.data([beacon]);
+
+    content.select('.name').text(beacon.name);
+    content.select('.start').text(startDate.toDateString());
+    content.select('.lat').text(lat.toFixed(3));
+    content.select('.lon').text(lon.toFixed(3));
+
+    this.infoWindow.open(this.map, marker);
+  }.bind(this));
+
+  return marker;
+};
+
+/**
+ * Show or hide beacon markers by setting their visibility.
+ *
+ * @param {boolean} visibility Whether to show (true) or hide (false) markers.
+ */
+geovelo.Map.prototype.setMarkerVisibility = function(visibility) {
+  this.markerVisibility = !!visibility;
+  if (!this.markers) {
+    return;
+  }
+  for (var i = 0; i < this.markers.length; i++) {
+    this.markers[i].setVisible(this.markerVisibility);
+  }
+  if (!this.markerVisibility) {
+    this.infoWindow.close();
+  }
 };
 
 /**
diff --git a/js/overlay.js b/js/overlay.js
index 1a45bd1..2337e60 100644
--- a/js/overlay.js
+++ b/js/overlay.js
@@ -34,6 +34,12 @@
  */
 geovelo.Overlay = function(containerElement) {
 
+  // Local reference to the geovelo settings object, throw if missing.
+  var settings = geovelo.settings;
+  if (!settings) {
+    throw Error('geovelo.settings is missing!');
+  }
+
   // DOM Element into which to insert content.
   this.domElement = document.createElement('div');
   this.domElement.style.pointerEvents = 'none';
@@ -83,12 +89,18 @@
     stats.domElement.style.position = 'absolute';
     stats.domElement.style.top = 0;
     stats.domElement.style.left = 0;
+    stats.domElement.style.display = 'none';
     this.domElement.appendChild(stats.domElement);
   }
 
+  // Whether to show stats.
+  if (settings.animation.options.showStats.defaultValue) {
+    this.showStats();
+  }
+
   // Set up the custom line shader material to use for drawing lines.
   this.material = new geovelo.LineShaderMaterial({
-    linewidth: 1
+    linewidth: settings.style.options.lineWidth.defaultValue
   });
 
   // Time in ms to allow processing to hold the thread before ceding to the UI.
@@ -99,6 +111,104 @@
 
   // Time in ms to wait before invoking functions in need of debouncing.
   this.debounceTimeout = 50;
+
+  // Animation duration and delay.
+  this.animationDuration = settings.animation.options.duration.defaultValue;
+  this.animationDelay = settings.animation.options.delay.defaultValue;
+
+  // Whether we're currently animating.
+  this.animating = false;
+  if (settings.animation.options.enabled.defaultValue) {
+    this.startAnimation();
+  }
+};
+
+/**
+ * Start animating.
+ */
+geovelo.Overlay.prototype.startAnimation = function() {
+  this.animating = true;
+
+  // The start timestamp for the current animation loop.
+  var start = null;
+
+  var animate = (function() {
+    // Short-circuit if animation has been turned off.
+    if (!this.animating) {
+      return;
+    }
+
+    var now = Date.now();
+
+    if (start === null) {
+      start = now;
+    }
+
+    var startTimestamp = this.material.uniforms.startTimestamp.value;
+    var endTimestamp = this.material.uniforms.endTimestamp.value;
+    var diff = endTimestamp - startTimestamp;
+
+    // If there's any time between the start and end timestamps, set the
+    // end animation clamp and render the scene.
+    if (diff) {
+      this.material.uniforms.endAnimationClamp.value =
+          startTimestamp + diff * (now - start) / this.animationDuration;
+      this.render();
+    }
+
+    // If we've finished the loop, pause for the delay, otherwise queue up the
+    // next frame.
+    if (now > start + this.animationDuration) {
+      start = null;
+      setTimeout(animate, this.animationDelay);
+    } else {
+      requestAnimationFrame(animate);
+    }
+  }).bind(this);
+
+  // Kick off animation loop.
+  requestAnimationFrame(animate);
+};
+
+/**
+ * Stop animating.
+ */
+geovelo.Overlay.prototype.stopAnimation = function() {
+  this.animating = false;
+  this.material.uniforms.endAnimationClamp.value = Infinity;
+  this.queueRender();
+};
+
+/**
+ * Set animation duration.
+ */
+geovelo.Overlay.prototype.setAnimationDuration = function(duration) {
+  this.animationDuration = duration;
+};
+
+/**
+ * Set animation delay.
+ */
+geovelo.Overlay.prototype.setAnimationDelay = function(delay) {
+  this.animationDelay = delay;
+};
+
+/**
+ * Show the FPS stats display.
+ */
+geovelo.Overlay.prototype.showStats = function() {
+  if (this.stats) {
+    this.stats.domElement.style.display = null;
+  }
+};
+
+/**
+ * Hide the FPS stats display.
+ */
+geovelo.Overlay.prototype.hideStats = function() {
+  if (this.stats) {
+    this.stats.domElement.style.display = 'none';
+  }
 };
 
 /**
@@ -175,7 +285,7 @@
 
 /**
  * New beacon data is available. Preprocess the data and reconstruct the scene.
- * @param {Array} beacons An array of data for the beacons.
+ * @param {Array} rawBeacons An array of data for the beacons.
  */
 geovelo.Overlay.prototype.setData = function(rawBeacons) {
 
@@ -533,6 +643,38 @@
 };
 
 /**
+ * Set the color to use for the start of the range.
+ *
+ * @param {string} startColor The color to set for the start of the range.
+ */
+geovelo.Overlay.prototype.setStartColor = function(startColor) {
+  var color = d3.rgb(startColor);
+  this.material.uniforms.startColor.value
+      .set(color.r / 255, color.g / 255, color.b / 255, 1);
+  this.queueRender();
+};
+
+/**
+ * Set the color to use for the end of the range.
+ *
+ * @param {string} endColor The color to set for the end of the range.
+ */
+geovelo.Overlay.prototype.setEndColor = function(endColor) {
+  var color = d3.rgb(endColor);
+  this.material.uniforms.endColor.value
+      .set(color.r / 255, color.g / 255, color.b / 255, 1);
+  this.queueRender();
+};
+
+/**
+ * Set the line width.
+ */
+geovelo.Overlay.prototype.setLineWidth = function(lineWidth) {
+  this.material.linewidth = lineWidth;
+  this.queueRender();
+};
+
+/**
  * Set the starting timestamp of the line shader material. This will map to the
  * start color and start opacity.
  *
diff --git a/js/settings.js b/js/settings.js
index b555a65..0343e37 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -31,6 +31,7 @@
  *  - description - Opitonal string describing this folder or option.
  *  - defaultValue - The starting value to use for this setting.
  *  - min, max - The smallest and largest allowed values.
+ *  - type - Number (default) or color picker.
  */
 geovelo.settings = {
 
@@ -47,7 +48,7 @@
         defaultValue: 5.5,
         min: 1,
         max: 8,
-        step: 0.01
+        step: 0.01,
       },
       medianCorrection: {
         displayName: 'median correction',
@@ -55,9 +56,76 @@
             'How much of the cumulative median movement to subtract out.',
         defaultValue: 1,
         min: 0,
-        max: 1
-      }
-    }
-  }
+        max: 1,
+      },
+      showMarkers: {
+        displayName: 'show markers',
+        description: 'Whether to show a Google Maps marker for each beacon.',
+        defaultValue: false,
+      },
+    },
+  },
+
+  style: {
+    displayName: 'Style',
+    description: 'Settings for the style and behavior of the visualization.',
+    open: true,
+    options: {
+      startColor: {
+        displayName: 'start color',
+        description: 'Color to use for the start of the time range.',
+        defaultValue: '#0000ff',
+        type: 'color',
+      },
+      endColor: {
+        displayName: 'end color',
+        description: 'Color to use for the end of the time range.',
+        defaultValue: '#ff0000',
+        type: 'color',
+      },
+      lineWidth: {
+        displayName: 'line width',
+        description: 'Width of line when rendering.',
+        defaultValue: 1,
+        min: 1,
+        max: 10,
+        step: 0.1,
+      },
+    },
+  },
+
+  animation: {
+    displayName: 'Animation',
+    description: 'Settings for the looping animation of lines.',
+    open: true,
+    options: {
+      enabled: {
+        displayName: 'enabled',
+        description: 'Whether animation is enabled.',
+        defaultValue: false,
+      },
+      duration: {
+        displayName: 'duration (ms)',
+        description: 'How long the animation loop takes to complete.',
+        defaultValue: 4000,
+        min: 500,
+        max: 10000,
+        step: 500,
+      },
+      delay: {
+        displayName: 'delay (ms)',
+        description: 'How long to wait before restarting a finished loop.',
+        defaultValue: 1000,
+        min: 0,
+        max: 3000,
+        step: 100,
+      },
+      showStats: {
+        displayName: 'show FPS',
+        description: 'Whether to show the FPS stats meter.',
+        defaultValue: false,
+      },
+    },
+  },
 
 };
diff --git a/js/timerange.js b/js/timerange.js
index 363ab7f..92ebfe3 100644
--- a/js/timerange.js
+++ b/js/timerange.js
@@ -53,6 +53,12 @@
   // Date object representing the position of the right (end) nub.
   this.rangeEnd = null;
 
+  // String representing the color to fill the start nub.
+  this.startColor = null;
+
+  // String representing the color to fill the end nub.
+  this.endColor = null;
+
   // D3 scale for the time range. Will be updated on draw to match parameters.
   var timeScale = this.timeScale = d3.time.scale();
 
@@ -68,7 +74,7 @@
   this.dragBehavior = d3.behavior.drag()
       .origin(function (d) {
         return {
-          x: timeScale(d),
+          x: timeScale(d.time),
           y: geovelo.TimeRange.margin.top
         };
       })
@@ -107,6 +113,30 @@
 };
 
 /**
+ * Set the color to use for the start of the range.
+ *
+ * @param {string} startColor The color to set for the start of the range.
+ */
+geovelo.TimeRange.prototype.setStartColor = function(startColor) {
+  this.startColor = startColor;
+  if (this.extentStart && this.extentEnd) {
+    this.drawNubs();
+  }
+};
+
+/**
+ * Set the color to use for the end of the range.
+ *
+ * @param {string} endColor The color to set for the end of the range.
+ */
+geovelo.TimeRange.prototype.setEndColor = function(endColor) {
+  this.endColor = endColor;
+  if (this.extentStart && this.extentEnd) {
+    this.drawNubs();
+  }
+};
+
+/**
  * Set the extent of the time range selector. If range has not been set, this
  * will initialize the range to match.
  *
@@ -274,7 +304,13 @@
 
   // Get the draggable nub elements.
   var nubs = this.svg.selectAll('.nub')
-      .data([this.rangeStart, this.rangeEnd]);
+      .data([{
+        time: this.rangeStart,
+        color: this.startColor,
+      }, {
+        time: this.rangeEnd,
+        color: this.endColor,
+      }]);
 
   // Insert DOM elements for the nubs if this is the first time.
   nubs.enter().append('g')
@@ -284,12 +320,13 @@
       .append('path')
         .attr('d', 'M -4,-16 v 8 l 4,6 l 4,-6 v -8 z');
 
-  // Move nubs into their correct positions.
+  // Move nubs into their correct positions and set color.
   var timeScale = this.timeScale;
   nubs.attr('transform', function(d) {
-    return 'translate(' + timeScale(d) + ',' +
+    return 'translate(' + timeScale(d.time) + ',' +
         geovelo.TimeRange.margin.top + ')';
-  });
+  }).select('.nub path')
+    .attr('fill', function(d) { return d.color; });
 };
 
 /**
diff --git a/js/web-mercator.js b/js/web-mercator.js
index 61376fa..5a9da99 100644
--- a/js/web-mercator.js
+++ b/js/web-mercator.js
@@ -25,7 +25,7 @@
 
 (function() {
 
-// Computational contstants.
+// Computational constants.
 var DEG_TO_RAD = Math.PI / 180;
 var ZOOM_FACTOR = 128 / Math.PI;
 var PI_OVER_FOUR = Math.PI / 4;