Adding navigation and additional destinations

Various UX improvements, including moving the search boxes in response
to a maps UI update

Fixing an edge case, seemingly a bug in the maps API, though I don't
know how on Earth it's occurring, where clicking on a search completion
in autocomplete (rather than a location suggestion) results any input
box under the autocomplete UI gaining focus.

Change-Id: I8946a7baee8b5ec1e1c6a3da64d75003515231f8
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 8e3b5b2..de5cad3 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -2,6 +2,7 @@
 var defineClass = require('../src/util/define-class');
 
 var ControlPosition = {
+  LEFT_TOP: 'lt',
   TOP_LEFT: 'tl',
   TOP_CENTER: 'tc'
 };
@@ -65,6 +66,7 @@
 
   init: function(canvas) {
     this.controls = {};
+    this.controls[ControlPosition.LEFT_TOP] = new ControlPanel(canvas);
     this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
     this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
 
@@ -119,6 +121,8 @@
 
 module.exports = {
   ControlPosition: ControlPosition,
+  DirectionsService: function(){},
+  DirectionsStatus: {},
   Geocoder: function(){},
   InfoWindow: InfoWindow,
   LatLng: function(){},
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
index 6f648bc..da818ac 100644
--- a/src/components/destination-info.js
+++ b/src/components/destination-info.js
@@ -3,9 +3,10 @@
 
 /**
  * Given a Maps API address_components array, return an array of formatted
- * address lines.
+ * address lines. This code is highly fragile and heaven help the poor soul who
+ * needs to localize it.
  *
- * TODO(rosswang): Is this really the best way?
+ * TODO(rosswang): Is this really the best way? We should find a formatter.
  */
 function formatAddress(details) {
   //some maps API members are lower_underscore
@@ -16,22 +17,56 @@
     return [];
   }
 
+  /* If at any point the first line/atom will echo the place name/search query,
+   * leave it out, as it will be the title of the info box anyway. */
+
   var parts = addr.split(', ');
-  switch (parts.length) {
-    case 1:
-      return [addr];
-    case 2:
-      return parts.join(', ');
-    case 3:
-      return parts[0] === details.name?
-        [parts[1] + ', ' + parts[2]] : [parts[0] + ', ' + parts[1]];
-    case 4:
-      var line1 = parts[0];
-      var line2 = parts[1] + ', ' + parts[2];
-      return line1 === details.name? [line2] : [line1, line2];
-    default:
-      return parts;
-  }
+  var lines = (function() {
+    switch (parts.length) {
+      case 2:
+        // ex. WA, USA => WA, USA
+        return [parts.join(', ')];
+      case 3:
+        // ex. Seattle, WA, USA => Seattle, WA || WA, USA
+        // (if Seattle was the search query, format as if it were WA, USA)
+        return parts[0] === details.name?
+          [parts[1] + ', ' + parts[2]] : [parts[0] + ', ' + parts[1]];
+      case 4: {
+        /* ex. Amphitheatre Pkwy, Mountain View, CA 94043, USA:
+         *
+         * Amphitheatre Pkwy
+         * Mountain View, CA 94043
+         */
+        return [parts[0], parts[1] + ', ' + parts[2]];
+      }
+      case 5: {
+        /* ex. Fort Mason, 2 Marina Blvd, San Francisco, CA 94123, USA
+         *
+         * Fort Mason
+         * 2 Marina Blvd
+         * San Francisco, CA 94123
+         */
+        return [parts[0], parts[1], parts[2] + ', ' + parts[3]];
+      }
+      case 6: {
+        /* ex. A, Fort Mason, 2 Marina Blvd, San Francisco, CA 94123, USA
+         *
+         * A, Fort Mason
+         * 2 Marina Blvd
+         * San Francisco, CA 94123
+         */
+        return [
+          parts[0] + ', ' + parts[1],
+          parts[2],
+          parts[3] + ', ' + parts[4]
+        ];
+      }
+      default:
+        return parts;
+    }
+  })();
+
+  return lines[0] === details.name? lines.slice(1) : lines;
 }
 
 function render(details) {
diff --git a/src/components/destination.js b/src/components/destination.js
index 8071162..a2ca3bc 100644
--- a/src/components/destination.js
+++ b/src/components/destination.js
@@ -39,6 +39,10 @@
   },
 
   publics: {
+    focus: function() {
+      this.$.find('input:visible').focus();
+    },
+
     setSearchBounds: function(bounds) {
       this.searchBox.setBounds(bounds);
     },
@@ -60,6 +64,7 @@
     },
 
     set: function(placeDesc, updateSearchBox) {
+      var prev = this.place;
       var normalized = this.normalizeDestination(placeDesc);
 
       this.setAutocomplete(!normalized);
@@ -68,8 +73,30 @@
         this.$searchBox.prop('value', normalized.display);
       }
 
-      this.place = normalized && normalized.place;
-      this.onSet(normalized);
+      this.place = normalized && normalized;
+      this.onSet(normalized, prev);
+    },
+
+    getNext: function() {
+      return this.next;
+    },
+
+    bindNext: function(next) {
+      if (this.next !== next) {
+        this.next = next;
+        next.bindPrevious(this.ifc);
+      }
+    },
+
+    getPrevious: function() {
+      return this.prev;
+    },
+
+    bindPrevious: function(prev) {
+      if (this.prev !== prev) {
+        this.prev = prev;
+        prev.bindNext(this.ifc);
+      }
     }
   },
 
@@ -115,6 +142,7 @@
     /**
      * fired when the destination has been set to a place, or cleared.
      * @param place the new destination, as a normalized place.
+     * @param previous the old destination, as a normalized place.
      */
     'onSet'
   ],
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 852cf2a..866a5ee 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -23,9 +23,17 @@
           placeholder = strings.destination(this.destinations.length);
       }
 
-      var destination = this.addDestination(placeholder, destinationName);
-      this.$.append(destination.$);
+      var destination = new Destination(
+        this.maps, placeholder, destinationName);
+      this.$destContainer.append(destination.$);
       this.destinations.push(destination);
+      var prev = this.destinations[this.destinations.length - 2];
+      if (prev) {
+        prev.bindNext(destination);
+      }
+      this.onDestinationAdded(destination);
+
+      return destination;
     },
 
     /**
@@ -45,15 +53,6 @@
     }
   },
 
-  privates: {
-    addDestination: function(placeholder, destinationName) {
-      var destination = new Destination(this.maps, placeholder,
-        destinationName);
-      this.onDestinationAdded(destination);
-      return destination;
-    }
-  },
-
   events: {
     /**
      * @param destination Destination instance
@@ -64,8 +63,20 @@
   constants: ['$'],
 
   init: function(maps, initial) {
+    var self = this;
+
     this.maps = maps;
     this.$ = $('<form>').addClass('destinations');
+    this.$destContainer = $('<div>');
+    this.$.append(this.$destContainer);
+
+    $('<div>')
+      .addClass('add-bn')
+      .text('+')
+      .click(function() {
+        self.append().focus();
+      })
+      .appendTo(this.$);
 
     this.destinations = [];
 
diff --git a/src/components/maps.js b/src/components/map.js
similarity index 78%
rename from src/components/maps.js
rename to src/components/map.js
index 83a4131..ee3a07e 100644
--- a/src/components/maps.js
+++ b/src/components/map.js
@@ -44,7 +44,7 @@
         .map(function(dest) { return dest.getPlace(); })
         .filter(function(place) { return place; })
         .reduce(function(acc, place) {
-          acc.push(place.location);
+          acc.push(place.place.location);
           return acc;
         }, []);
 
@@ -147,6 +147,62 @@
       if (normalizedPlace) {
         this.disableLocationSelection();
       }
+
+      if (destination.getPrevious()) {
+        this.updateLeg(destination);
+      }
+
+      if (destination.getNext()) {
+        this.updateLeg(destination.getNext());
+      }
+    },
+
+    updateLeg: function(destinationControl) {
+      var widget = this;
+      var maps = this.maps;
+      var map = this.map;
+
+      var origin = destinationControl.getPrevious().getPlace();
+      var destination = destinationControl.getPlace();
+
+      var leg = destinationControl.leg;
+      if (leg) {
+        if (leg.async) {
+          leg.async.reject();
+        }
+        // setMap(null) seems to be the best way to clear the nav route
+        leg.renderer.setMap(null);
+      } else {
+        var renderer = new maps.DirectionsRenderer({
+          preserveViewport: true,
+          suppressMarkers: true
+        });
+        destinationControl.leg = leg = { renderer: renderer };
+      }
+
+      if (origin && destination) {
+        var request = {
+          origin: origin.place.location,
+          destination: destination.place.location,
+          travelMode: maps.TravelMode.DRIVING // TODO(rosswang): user choice
+        };
+
+        leg.async = $.Deferred();
+
+        this.directionsService.route(request, function(result, status) {
+          if (status === maps.DirectionsStatus.OK) {
+            leg.async.resolve(result);
+          } else {
+            widget.onError({ directionsStatus: status });
+            leg.async.reject(status);
+          }
+        });
+
+        leg.async.done(function(route) {
+          leg.renderer.setDirections(route);
+          leg.renderer.setMap(map);
+        });
+      }
     },
 
     centerOnCurrentLocation: function() {
@@ -191,8 +247,10 @@
       destination.onFocus.add(function() {
         widget.selectDestinationControl(destination);
       });
-      destination.onSearch.add($.proxy(this, 'showDestinationSearchResults'));
-      destination.onSet.add($.proxy(this, 'handleDestinationSet', destination));
+      destination.onSearch.add(
+        $.proxy(this, 'showDestinationSearchResults', destination));
+      destination.onSet.add(
+        $.proxy(this, 'handleDestinationSet', destination));
     },
 
     enableLocationSelection: function() {
@@ -229,9 +287,17 @@
       }
     },
 
-    showDestinationSearchResults: function(places) {
+    showDestinationSearchResults: function(destination, places) {
       var widget = this;
 
+      if (destination !== this.selectedDestinationControl) {
+        /* There seems to be a bug where if you click a search suggestion (for
+         * a query, not a resolved location) in autocomplete, the input box
+         * under it gets clicked and focused... I haven't been able to figure
+         * out why. */
+         destination.focus();
+      }
+
       this.clearSearchMarkers();
       this.closeActiveInfoWindow();
 
@@ -255,7 +321,7 @@
 
           marker.onClick.add(function() {
             var dest = widget.selectedDestinationControl;
-            if (dest) {
+            if (dest && dest.marker !== marker) {
               widget.associateDestinationMarker(dest, marker);
               dest.set(place);
             }
@@ -286,6 +352,13 @@
   },
 
   constants: ['$', 'maps'],
+  events: {
+    /**
+     * @param error A union with one of the following keys:
+     *  directionsStatus
+     */
+    onError: 'memory'
+  },
 
   // https://developers.google.com/maps/documentation/javascript/tutorial
   init: function(opts) {
@@ -296,11 +369,11 @@
     this.maps = maps;
     this.navigator = opts.navigator || global.navigator;
     this.geocoder = new maps.Geocoder();
+    this.directionsService = new maps.DirectionsService();
 
     this.$ = $('<div>').addClass('map-canvas');
 
     this.searchMarkers = [];
-    this.route = {};
 
     this.initialConfig = {
       center: new maps.LatLng(37.4184, -122.0880), //Googleplex
@@ -323,7 +396,7 @@
     this.centerOnCurrentLocation();
 
     var controls = map.controls;
-    controls[maps.ControlPosition.TOP_LEFT].push(this.destinations.$[0]);
+    controls[maps.ControlPosition.LEFT_TOP].push(this.destinations.$[0]);
     controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
   }
 });
diff --git a/src/static/index.css b/src/static/index.css
index 70e3946..52b7cae 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -8,6 +8,20 @@
   width: 30em;
 }
 
+.add-bn {
+  border-radius: 16px;
+  border: 1px solid #aaa;
+  width: 32px;
+  height: 32px;
+  cursor: pointer;
+  color: #aaa;
+  background-color: white;
+  text-align: center;
+  margin-top: 4px;
+  font-size: 27px;
+  font-weight: lighter;
+}
+
 .destination {
   width: 100%;
   position: relative;
diff --git a/src/strings.js b/src/strings.js
index d4e99fe..5dfc3f9 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -4,8 +4,17 @@
     destination: function(n) {
       return 'Destination ' + n;
     },
+    DirectionsStatus: {
+      NOT_FOUND: 'Location not found',
+      ZERO_RESULTS: 'No route to destination',
+      MAX_WAYPOINTS_EXCEEDED: 'Maximum number of waypoints exceeded',
+      OVER_QUERY_LIMIT: 'Request rate exceeded',
+      REQUEST_DENIED: 'Request denied',
+      UNKNOWN_ERROR: 'Server error'
+    },
     'Origin': 'Origin',
-    'Travel Planner': 'Travel Planner'
+    'Travel Planner': 'Travel Planner',
+    'Unknown error': 'Unknown error'
   };
 }
 
diff --git a/src/travel.js b/src/travel.js
index 25e2ad4..a4e6fcd 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -5,18 +5,28 @@
 
 var defineClass = require('./util/define-class');
 
-var Maps = require('./components/maps');
+var Map = require('./components/map');
 var TravelSync = require('./travelsync');
 var Identity = require('./identity');
 
+var strings = require('./strings').currentLocale;
+
+function buildStatusErrorStringMap(statusClass, stringGroup) {
+  var dict = {};
+  $.each(statusClass, function(name, value) {
+    dict[value] = stringGroup[name];
+  });
+  return dict;
+}
+
 var Travel = defineClass({
   publics: {
     error: function (err) {
-      this.maps.message(message.error(err.toString()));
+      this.map.message(message.error(err.toString()));
     },
 
     info: function (info) {
-      this.maps.message(message.info(info));
+      this.map.message(message.info(info));
     }
   },
 
@@ -38,9 +48,20 @@
         travel.sync.start(identity.mountName, wrapper).catch(reportError);
       }, reportError);
 
-    this.maps = new Maps(opts);
+    this.map = new Map(opts);
+
+    var directionsServiceStatusStrings = buildStatusErrorStringMap(
+      this.map.maps.DirectionsStatus, strings.DirectionsStatus);
+
+    this.map.onError.add(function(err) {
+      var message = directionsServiceStatusStrings[err.directionsStatus] ||
+        strings['Unknown error'];
+
+      reportError(message);
+    });
+
     var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
-    $domRoot.append(travel.maps.$);
+    $domRoot.append(travel.map.$);
   }
 });
 
diff --git a/test/components/maps.js b/test/components/map.js
similarity index 78%
rename from test/components/maps.js
rename to test/components/map.js
index c527473..6b32bc8 100644
--- a/test/components/maps.js
+++ b/test/components/map.js
@@ -2,21 +2,21 @@
 
 var $ = require('../../src/util/jquery');
 
-var Maps = require('../../src/components/maps');
+var Map = require('../../src/components/map');
 var message = require ('../../src/components/message');
 
 var mockMaps = require('../../mocks/google-maps');
 
 test('message display', function(t) {
-  var maps = new Maps({
+  var map = new Map({
     maps: mockMaps
   });
 
-  var $messages = $('.messages', maps.$);
+  var $messages = $('.messages', map.$);
   t.ok($messages.length, 'message display exists');
   t.equals($messages.children().length, 0, 'message display is empty');
 
-  maps.message(message.info('Test message.'));
+  map.message(message.info('Test message.'));
 
   var $messageItem = $messages.children();
   t.equals($messageItem.length, 1, 'message display shows 1 message');