Refactors to allow UX streamlining

Factoring out destinations component
Refactoring destination controls to allow decoupling from maps
Factoring out message UI to top level
Using string keys for non-camel-case API members per convention mentioned by
  nlacasse@

Change-Id: I634adade0bf4b32d45768201aefc054d07d6ee58
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index bdb8674..45e07ac 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -41,6 +41,8 @@
 
 var Map = defineClass({
   publics: {
+    getBounds: function(){},
+
     registerInfoWindow: function(wnd) {
       this.infoWindows.push(wnd);
     },
@@ -61,11 +63,8 @@
   constants: [ 'controls' ],
 
   events: {
-    //some maps API members are lower_underscore
-    /* jshint camelcase: false */
-    bounds_changed: 'public',
+    'bounds_changed': 'public',
     click: 'public'
-    /* jshint camelcase: true */
   },
 
   init: function(canvas) {
@@ -98,6 +97,8 @@
       return this.map;
     },
 
+    setTitle: function(){},
+
     toString: function() { return 'mock Marker'; }
   },
 
@@ -112,14 +113,12 @@
 
 var SearchBox = defineClass({
   publics: {
+    setBounds: function(){},
     toString: function() { return 'mock SearchBox'; }
   },
 
   events: {
-    //some maps API members are lower_underscore
-    /* jshint camelcase: false */
-    places_changed: 'public'
-    /* jshint camelcase: true */
+    'places_changed': 'public'
   }
 });
 
diff --git a/src/components/destination-control.js b/src/components/destination-control.js
new file mode 100644
index 0000000..797143a
--- /dev/null
+++ b/src/components/destination-control.js
@@ -0,0 +1,184 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var strings = require('../strings').currentLocale;
+
+var DestinationControl = defineClass({
+  publics: {
+    focus: function() {
+      this.$.find('input:visible').focus();
+    },
+
+    hasFocus: function() {
+      return this.$.find(':focus').length > 0;
+    },
+
+    setSearchBounds: function(bounds) {
+      this.searchBox.setBounds(bounds);
+    },
+
+    selectControl: function() {
+      if (this.destination) {
+        this.destination.select();
+      }
+    },
+
+    deselectControl: function() {
+      if (this.destination) {
+        this.destination.deselect();
+      }
+    },
+
+    bindDestination: function(destination) {
+      if (this.destination) {
+        this.destination.onPlaceChange.remove(this.handlePlaceChange);
+        this.destination.onSelect.remove(this.handleSelect);
+        this.destination.onDeselect.remove(this.handleDeselect);
+        this.destination.onOrdinalChange.remove(this.updateOrdinal);
+      }
+
+      this.destination = destination;
+
+      if (destination) {
+        destination.onPlaceChange.add(this.handlePlaceChange);
+        destination.onSelect.add(this.handleSelect);
+        destination.onDeselect.add(this.handleDeselect);
+        destination.onOrdinalChange.add(this.updateOrdinal);
+      }
+
+      this.updateOrdinal();
+      this.handlePlaceChange(destination && destination.getPlace());
+      if (destination && destination.isSelected()) {
+        this.handleSelect();
+      } else {
+        this.handleDeselect();
+      }
+    }
+  },
+
+  privates: {
+    handlePlaceChange: function(place) {
+      this.setAutocomplete(!place);
+
+      var newValue;
+      if (place) {
+        newValue = place.getSingleLine();
+      } else if (!this.hasFocus()) {
+        newValue = '';
+      }
+      if (newValue !== undefined) {
+        this.$searchBox.prop('value', newValue);
+      }
+    },
+
+    updateOrdinal: function() {
+      var placeholder;
+      var destination = this.destination;
+      if (destination) {
+        if (!destination.hasPrevious()) {
+          placeholder = strings['Origin'];
+        } else if (destination.getIndex() === 1 && !destination.hasNext()) {
+          placeholder = strings['Destination'];
+        } else {
+          placeholder = strings.destination(destination.getIndex());
+        }
+      }
+
+      this.$searchBox.attr('placeholder', placeholder);
+    },
+
+    handleSelect: function() {
+      this.$.addClass('selected');
+    },
+
+    handleDeselect: function() {
+      this.$.removeClass('selected');
+    },
+
+    /**
+     * This is a bit of a hack; Maps API does not include functionality to
+     * disable autocomplete.
+     */
+    setAutocomplete: function(autocomplete) {
+      /* True boolean comparison. We could coerce the input to boolean, but
+       * this is less impactful. */
+      /* jshint eqeqeq: false */
+      if (this.autocomplete != autocomplete) {
+      /* jshint eqeqeq: true */
+        this.autocomplete = autocomplete;
+
+        var oldBox = this.$searchBox[autocomplete? 1 : 0];
+        var newBox = this.$searchBox[autocomplete? 0 : 1];
+
+        newBox.value = oldBox.value;
+        var active = global.document &&
+          global.document.activeElement === oldBox;
+        if (newBox.setSelectionRange) {
+          //non-universal browser support
+          newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
+        }
+
+        if (autocomplete) {
+          this.$.addClass('autocomplete');
+        } else {
+          this.$.removeClass('autocomplete');
+        }
+
+        if (active) {
+          $(newBox).focus();
+        }
+      }
+    }
+  },
+
+  events: [
+    /**
+     * @param event jQuery Event object for text box focus event.
+     */
+    'onFocus',
+    /**
+     * @param places (array of places)
+     */
+    'onSearch'
+  ],
+
+  constants: ['$'],
+
+  init: function(maps) {
+    var self = this;
+
+    var $searchBox = $.merge($('<input>'), $('<input>'))
+      .attr('type', 'text')
+      //to make dummy box consistent with search
+      .attr('autocomplete', 'off');
+    this.$searchBox = $searchBox;
+
+    $searchBox[0].className = 'autocomplete';
+
+    $searchBox.focus(this.onFocus);
+    $searchBox.on('input', function() {
+      if (self.destination) {
+        self.destination.setPlace(null);
+      }
+    });
+
+    this.$ = $('<div>')
+      .addClass('destination')
+      .addClass('autocomplete')
+      .append($searchBox);
+
+    this.searchBox = new maps.places.SearchBox($searchBox[0]);
+
+    this.autocomplete = true;
+
+    maps.event.addListener(this.searchBox, 'places_changed', function() {
+      self.onSearch(self.searchBox.getPlaces());
+    });
+  }
+});
+
+module.exports = DestinationControl;
\ No newline at end of file
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
index 8f83a1c..e5a4634 100644
--- a/src/components/destination-info.js
+++ b/src/components/destination-info.js
@@ -5,86 +5,18 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-/**
- * Given a Maps API address_components array, return an array of formatted
- * 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? We should find a formatter.
- */
-function formatAddress(details) {
-  //some maps API members are lower_underscore
-  /* jshint camelcase: false */
-  var addr = details && details.formatted_address;
-  /* jshint camelcase: true */
-  if (!addr) {
-    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(', ');
-  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) {
+function render(place) {
   var $info = $('<div>').addClass('destination-info');
 
-  if (details && details.name) {
-    $info.append($('<div>')
-      .addClass('title')
-      .text(details.name));
-  }
+  if (place) {
+    var details = place.getDetails();
+    if (details && details.name) {
+      $info.append($('<div>')
+        .addClass('title')
+        .text(details.name));
+    }
 
-  var addressLines = formatAddress(details);
-  if (addressLines) {
-    $.each(addressLines,
+    $.each(place.getMultiLine(),
       function(i, line) {
         $info.append($('<div>')
           .addClass('address-line')
@@ -105,18 +37,18 @@
       this.infoWindow.open(this.map, marker);
     },
 
-    setDetails: function(details) {
-      this.infoWindow.setContent(render(details));
-      this.infoWindow.setPosition(details && details.geometry.location);
+    setPlace: function(place) {
+      this.infoWindow.setContent(render(place));
+      this.infoWindow.setPosition(place && place.getLocation());
     }
   },
 
-  init: function(maps, map, details) {
+  init: function(maps, map, place) {
     this.map = map;
 
     this.infoWindow = new maps.InfoWindow({
-      content: render(details),
-      position: details && details.geometry.location
+      content: render(place),
+      position: place && place.getLocation()
     });
   }
 });
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index 4def0c4..bba109c 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -4,45 +4,34 @@
 
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
+var strings = require('../strings').currentLocale;
 
-function markerIcon(color) {
-  return 'http://maps.google.com/mapfiles/ms/icons/' + color + '-dot.png';
-}
-
-function deriveTitle(normalizedPlace) {
-  return normalizedPlace.details && normalizedPlace.details.name ||
-    //some maps API members are lower_underscore
-    /* jshint camelcase: false */
-    normalizedPlace.formatted_address;
-    /* jshint camelcase: true */
+function markerIcon(opts) {
+  if (opts.icon) {
+    return 'http://chart.apis.google.com/chart?chst=d_map_pin_icon&chld=' +
+      opts.icon + '|' + opts.color;
+  } else {
+    return 'http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=' +
+      (opts.label || '•') + '|' + opts.color;
+  }
 }
 
 var DestinationMarker = defineClass({
   statics: {
     color: {
-      RED: 'red',
-      ORANGE: 'orange',
-      YELLOW: 'yellow',
-      GREEN: 'green',
-      LIGHT_BLUE: 'ltblue',
-      BLUE: 'blue',
-      PURPLE: 'purple',
-      PINK: 'pink'
-    }
-  },
-
-  privates: {
-    refreshClickability: function() {
-      this.marker.setClickable(this.onClick.has());
+      RED: 'FC6355',
+      ORANGE: 'FF8000', //TODO(rosswang): tune
+      YELLOW: 'FFFF00', //TODO(rosswang): tune
+      GREEN: '00E73D',
+      LIGHT_BLUE: '8080FF', //TODO(rosswang): tune
+      BLUE: '7090FC', // originally '5781FC',
+      PURPLE: '8000FF', //TODO(rosswang): tune
+      PINK: 'FF8080' //TODO(rosswang): tune
     },
 
-    topClient: function() {
-      return this.clients[this.clients.length - 1];
-    },
-
-    updateColor: function() {
-      var color = this.topClient().color;
-      this.marker.setIcon(markerIcon(color));
+    icon: {
+      ORIGIN: 'home',
+      DESTINATION: 'flag'
     }
   },
 
@@ -52,9 +41,13 @@
     },
 
     pushClient: function(client, color) {
-      color = color || this.topClient().color;
-      this.clients.push({ client: client, color: color, listeners: [] });
-      this.updateColor();
+      this.clients.push($.extend({}, this.topClient(), {
+        client: client,
+        color: color,
+        listeners: []
+      }));
+      this.updateIcon();
+      this.updateTitle();
     },
 
     removeClient: function(client) {
@@ -74,18 +67,63 @@
       if (!this.clients.length) {
         this.clear();
       } else {
-        this.updateColor();
+        this.updateIcon();
+        this.updateTitle();
       }
     },
 
     setColor: function(color) {
       this.topClient().color = color;
-      this.updateColor();
+      this.updateIcon();
+    },
+
+    setDestinationLabel: function(destinationLabel) {
+      this.topClient().destinationLabel = destinationLabel;
+      this.updateTitle();
+    },
+
+    setLabel: function(label) {
+      var client = this.topClient();
+      client.label = label;
+      client.icon = null;
+      this.updateIcon();
+    },
+
+    setIcon: function(icon) {
+      var client = this.topClient();
+      client.icon = icon;
+      client.label = null;
+      this.updateIcon();
+    }
+  },
+
+  privates: {
+    refreshClickability: function() {
+      this.marker.setClickable(this.onClick.has());
+    },
+
+    topClient: function() {
+      return this.clients[this.clients.length - 1];
+    },
+
+    getIcon: function() {
+      return markerIcon(this.topClient());
+    },
+
+    updateIcon: function() {
+      this.marker.setIcon(this.getIcon());
+    },
+
+    updateTitle: function() {
+      var destLabel = this.topClient().destinationLabel;
+      this.marker.setTitle(destLabel?
+        strings.label(this.topClient().destinationLabel, this.title) :
+        this.title);
     }
   },
 
   events: [ 'onClick' ],
-  constants: [ 'marker', 'normalizedPlace' ],
+  constants: [ 'marker', 'place' ],
 
   /**
    * A note on clients: destination markers can be shared between multiple use
@@ -97,18 +135,23 @@
    * when the client is removed the corresponding click handlers are removed as
    * well.
    */
-  init: function(maps, map, normalizedPlace, client, color) {
+  init: function(maps, map, place, client, color) {
     var self = this;
 
     this.map = map;
-    this.normalizedPlace = normalizedPlace;
+    this.place = place;
     this.clients = [{ client: client, color: color, listeners: [] }];
 
+    this.icon = null;
+    this.label = '';
+
+    this.title = place.getName();
+
     this.marker = new maps.Marker({
-      icon: markerIcon(color),
+      icon: this.getIcon(),
       map: map,
-      place: normalizedPlace.place,
-      title: deriveTitle(normalizedPlace),
+      place: place.getPlaceObject(),
+      title: this.title,
       clickable: false
     });
 
@@ -130,7 +173,7 @@
       self.refreshClickability();
     });
 
-    maps.event.addListener(this.marker, 'click', $.proxy(this, 'onClick'));
+    maps.event.addListener(this.marker, 'click', this.onClick);
   }
 });
 
diff --git a/src/components/destination.js b/src/components/destination.js
deleted file mode 100644
index 13b1a3b..0000000
--- a/src/components/destination.js
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var $ = require('../util/jquery');
-var defineClass = require('../util/define-class');
-
-var Destination = defineClass({
-  statics: {
-    normalizeDestination: function(desc) {
-      if (!desc) {
-        return null;
-
-      } else if (desc.geometry) {
-        var place = { location: desc.geometry.location };
-
-        //some maps API members are lower_underscore
-        /* jshint camelcase: false */
-        if (desc.place_id !== undefined) {
-          place.placeId = desc.place_id;
-        } else {
-          place.query = desc.formatted_address;
-        }
-
-        var display = desc.name &&
-          desc.name !== desc.formatted_address.split(', ')[0]?
-          desc.name + ', ' + desc.formatted_address : desc.formatted_address;
-        /* jshint camelcase: true */
-
-        return {
-          place: place,
-          details: desc,
-          display: display
-        };
-
-      } else {
-        return {
-          place: desc,
-          display : desc.query || desc.location.toString()
-        };
-      }
-    }
-  },
-
-  publics: {
-    focus: function() {
-      this.$.find('input:visible').focus();
-    },
-
-    setSearchBounds: function(bounds) {
-      this.searchBox.setBounds(bounds);
-    },
-
-    setPlaceholder: function(placeholder) {
-      this.$searchBox.attr('placeholder', placeholder);
-    },
-
-    selectControl: function() {
-      this.$.addClass('selected');
-    },
-
-    deselectControl: function() {
-      this.$.removeClass('selected');
-    },
-
-    getPlace: function() {
-      return this.place;
-    },
-
-    set: function(placeDesc, updateSearchBox) {
-      var prev = this.place;
-      var normalized = this.normalizeDestination(placeDesc);
-
-      this.setAutocomplete(!normalized);
-
-      if (normalized && updateSearchBox !== false) {
-        this.$searchBox.prop('value', normalized.display);
-      }
-
-      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);
-      }
-    }
-  },
-
-  privates: {
-    /**
-     * This is a bit of a hack; Maps API does not include functionality to
-     * disable autocomplete.
-     */
-    setAutocomplete: function(autocomplete) {
-      if (this.autocomplete !== autocomplete) {
-        this.autocomplete = autocomplete;
-
-        var oldBox = this.$searchBox[autocomplete? 1 : 0],
-            newBox = this.$searchBox[autocomplete? 0 : 1];
-
-        newBox.value = oldBox.value;
-        var active = global.document &&
-          global.document.activeElement === oldBox;
-        newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
-
-        if (autocomplete) {
-          this.$.addClass('autocomplete');
-        } else {
-          this.$.removeClass('autocomplete');
-        }
-
-        if (active) {
-          $(newBox).focus();
-        }
-      }
-    }
-  },
-
-  events: [
-    /**
-     * @param event jQuery Event object for text box focus event.
-     */
-    'onFocus',
-    /**
-     * @param places (array of places)
-     */
-    'onSearch',
-    /**
-     * 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'
-  ],
-
-  constants: ['$'],
-
-  init: function(maps, placeholder, initial) {
-    var destination = this;
-
-    var $searchBox = $.merge($('<input>'), $('<input>'))
-      .attr('type', 'text')
-      //to make dummy box consistent with search
-      .attr('autocomplete', 'off');
-    this.$searchBox = $searchBox;
-
-    $searchBox[0].className = 'autocomplete';
-
-    this.setPlaceholder(placeholder);
-
-    if (initial) {
-      $searchBox.prop('value', initial);
-    }
-
-    $searchBox.focus(this.onFocus);
-    $searchBox.on('input', function() {
-      destination.set(null, false);
-    });
-
-    this.$ = $('<div>')
-      .addClass('destination')
-      .addClass('autocomplete')
-      .append($searchBox);
-
-    this.searchBox = new maps.places.SearchBox($searchBox[0]);
-
-    this.autocomplete = true;
-
-    maps.event.addListener(this.searchBox, 'places_changed', function() {
-      destination.onSearch(destination.searchBox.getPlaces());
-    });
-  }
-});
-
-module.exports = Destination;
\ No newline at end of file
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 3e677b9..a1ca01d 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -5,68 +5,25 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var strings = require('../strings').currentLocale;
-
-var Destination = require('./destination');
+var DestinationControl = require('./destination-control');
 
 var Destinations = defineClass({
   publics: {
-    append: function(destinationName) {
-      var placeholder;
-      switch (this.destinations.length) {
-        case 0:
-          placeholder = strings['Origin'];
-          break;
-        case 1:
-          placeholder = strings['Destination'];
-          break;
-        case 2:
-          this.destinations[1].setPlaceholder(strings.destination(1));
-          /* falls through */
-        default:
-          placeholder = strings.destination(this.destinations.length);
-      }
+    append: function() {
+      var controls = this.controls;
 
-      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);
+      var destinationControl = new DestinationControl(this.maps);
+      this.$destContainer.append(destinationControl.$);
+      controls.push(destinationControl);
 
-      return destination;
-    },
-
-    /**
-     * @param handler callback receiving a <code>Destination</code> instance
-     *  each time a <code>Destination</code> is added. On initial add, the
-     *  callback is called with all current <code>Destination</code>s.
-     */
-    addDestinationBindingHandler: function(handler) {
-      this.onDestinationAdded.add(handler);
-      $.each(this.destinations, function(i, destination) {
-        handler(destination);
-      });
-    },
-
-    getDestinations: function() {
-      return this.destinations.slice(0);
+      return destinationControl;
     }
   },
 
-  events: {
-    /**
-     * @param destination Destination instance
-     */
-    onDestinationAdded: 'private'
-  },
+  constants: [ '$' ],
+  events: [ 'onAddClick' ],
 
-  constants: ['$'],
-
-  init: function(maps, initial) {
+  init: function(maps) {
     var self = this;
 
     this.maps = maps;
@@ -78,17 +35,11 @@
       .addClass('add-bn')
       .text('+')
       .click(function() {
-        self.append().focus();
+        self.onAddClick();
       })
       .appendTo(this.$);
 
-    this.destinations = [];
-
-    initial = initial || [];
-
-    for (var i = 0; i < Math.max(initial.length, 2); i++) {
-      this.append(initial[i]);
-    }
+    this.controls = [];
   }
 });
 
diff --git a/src/components/map.js b/src/components/map.js
index b1f6444..f28f0e6 100644
--- a/src/components/map.js
+++ b/src/components/map.js
@@ -5,18 +5,53 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var Destinations = require('./destinations');
+var Destination = require('../destination');
+var Place = require('../place');
 var DestinationInfo = require('./destination-info');
 var DestinationMarker = require('./destination-marker');
-var Messages = require('./messages');
 
-var normalizeDestination = require('./destination').normalizeDestination;
+var strings = require('../strings').currentLocale;
 
 //named destination marker clients
 var SEARCH_CLIENT = 'search';
 
-var Widget = defineClass({
+var Map = defineClass({
   publics: {
+    getBounds: function() {
+      return this.map.getBounds();
+    },
+
+    addControls: function(controlPosition, $controls) {
+      var controls = this.map.controls[controlPosition];
+      $controls.each(function() {
+        controls.push(this);
+      });
+    },
+
+    addDestination: function() {
+      var self = this;
+
+      var destination = new Destination();
+      if (!this.origin) {
+        this.finalDestination = this.origin = destination;
+      } else {
+        this.finalDestination.bindNext(destination);
+        this.finalDestination = destination;
+      }
+
+      destination.onPlaceChange.add(function(place) {
+        self.handleDestinationPlaceChange(destination, place);
+      });
+      destination.onDeselect.add(function() {
+        self.handleDestinationDeselect(destination);
+      });
+      destination.onSelect.add(function() {
+        self.handleDestinationSelect(destination);
+      });
+
+      return destination;
+    },
+
     clearSearchMarkers: function() {
       $.each(this.searchMarkers, function() {
         this.removeClient(SEARCH_CLIENT);
@@ -30,146 +65,212 @@
       }
     },
 
-    deselectDestinationControl: function(closeInfoWindow) {
-      if (this.selectedDestinationControl) {
-        this.selectedDestinationControl.deselectControl();
-        this.selectedDestinationControl = null;
-        this.disableLocationSelection();
-        this.clearSearchMarkers();
+    deselectDestination: function() {
+      if (this.selectedDestination) {
+        this.selectedDestination.deselect();
+      }
+    },
 
-        if (closeInfoWindow !== false) {
-          this.closeActiveInfoWindow();
+    fitAll: function() {
+      var geoms = [];
+      var dest = this.origin;
+
+      function addToGeoms() {
+        geoms.push({ location: this });
+      }
+
+      while (dest) {
+        if (dest.hasPlace()) {
+          if (dest.leg && dest.leg.sync) {
+            $.each(dest.leg.sync.routes[0]['overview_path'], addToGeoms);
+          }
+          geoms.push(dest.getPlace().getGeometry());
         }
+        dest = dest.getNext();
       }
+
+      this.ensureGeomsVisible(geoms);
     },
 
-    fitAllDestinations: function() {
-      var points = this.destinations.getDestinations()
-        .map(function(dest) { return dest.getPlace(); })
-        .filter(function(place) { return place; })
-        .reduce(function(acc, place) {
-          acc.push(place.place.location);
-          return acc;
-        }, []);
-
-      var curBounds = this.map.getBounds();
-      if (points.every(function(point) { return curBounds.contains(point); })) {
-        return;
-      }
-
-      if (points.length === 1) {
-        this.map.panTo(points[0]);
-      } else if (points.length > 1) {
-        this.map.fitBounds(points.reduce(function(acc, point) {
-          acc.extend(point);
-          return acc;
-        }, new this.maps.LatLngBounds()));
-      }
+    ensureVisible: function(place) {
+      this.ensureGeomsVisible([place.getGeometry()]);
     },
 
-    message: function(message) {
-      this.messages.push(message);
+    showSearchResults: function(results) {
+      var self = this;
+
+      this.clearSearchMarkers();
+      this.closeActiveInfoWindow();
+
+      this.fitGeoms(results.map(function(result) {
+        return result.geometry;
+      }));
+
+      if (results.length === 1) {
+        var place = new Place(results[0]);
+        /* It would be nice if we could distinguish between an autocomplete
+         * click and a normal search so that we don't overwrite the search box
+         * text for the autocomplete click.*/
+        var dest = this.selectedDestination;
+        if (dest) {
+          dest.setPlace(place);
+          self.createDestinationMarker(dest);
+        }
+      } else if (results.length > 1) {
+        $.each(results, function(i, result) {
+          var place = new Place(result);
+
+          var marker = self.createMarker(place, SEARCH_CLIENT,
+            DestinationMarker.color.RED);
+          self.searchMarkers.push(marker);
+
+          marker.onClick.add(function() {
+            var dest = self.selectedDestination;
+            if (dest) {
+              dest.setPlace(place);
+              self.associateDestinationMarker(dest, marker);
+            }
+          });
+        });
+      }
     }
   },
 
   privates: {
-    createMarker: function(normalizedPlace, client, color) {
-      var marker = new DestinationMarker(this.maps, this.map, normalizedPlace,
+    createMarker: function(place, client, color) {
+      var self = this;
+
+      var marker = new DestinationMarker(this.maps, this.map, place,
         client, color);
 
-      if (normalizedPlace.details) {
-        marker.onClick.add($.proxy(this, 'showDestinationInfo', marker), true);
+      if (place.hasDetails()) {
+        marker.onClick.add(function() {
+          self.showDestinationInfo(marker);
+        }, true);
       }
 
       return marker;
     },
 
-    createDestinationMarker: function(normalizedPlace, destinationControl) {
-      var widget = this;
+    createDestinationMarker: function(destination) {
+      var marker = this.createMarker(destination.getPlace(), destination,
+        this.getAppropriateDestinationMarkerColor(destination));
 
-      var marker = this.createMarker(normalizedPlace, destinationControl,
-        this.getAppropriateDestinationMarkerColor(destinationControl));
-      destinationControl.marker = marker;
-
-      marker.onClick.add(function() {
-        widget.selectDestinationControl(destinationControl, false);
-      });
+      this.bindDestinationMarker(destination, marker);
 
       return marker;
     },
 
+    associateDestinationMarker: function(destination, marker) {
+      if (!marker.onClick.has(destination.select)) {
+        marker.pushClient(destination,
+          this.getAppropriateDestinationMarkerColor(destination));
+
+        this.bindDestinationMarker(destination, marker);
+      }
+    },
+
+    bindDestinationMarker: function(destination, marker) {
+      var self = this;
+
+      marker.onClick.add(destination.select);
+      function handleSelection() {
+        marker.setColor(self.getAppropriateDestinationMarkerColor(destination));
+      }
+      destination.onSelect.add(handleSelection);
+      destination.onDeselect.add(handleSelection);
+
+      function handleOrdinalChange() {
+        var destLabel;
+        if (!destination.hasPrevious()) {
+          destLabel = strings['Origin'];
+          marker.setIcon(DestinationMarker.icon.ORIGIN);
+        } else if (!destination.hasNext()) {
+          destLabel = strings[destination.getIndex() === 1?
+            'Destination' : 'Final destination'];
+          marker.setIcon(DestinationMarker.icon.DESTINATION);
+        } else {
+          destLabel = strings.destination(destination.getIndex());
+          marker.setLabel(destination.getIndex());
+        }
+
+        marker.setDestinationLabel(destLabel);
+      }
+
+      destination.onOrdinalChange.add(handleOrdinalChange);
+      handleOrdinalChange();
+
+      function handlePlaceChange() {
+        marker.removeClient(destination);
+        marker.onClick.remove(destination.select);
+        destination.onSelect.remove(handleSelection);
+        destination.onDeselect.remove(handleSelection);
+        destination.onOrdinalChange.remove(handleOrdinalChange);
+        destination.onPlaceChange.remove(handlePlaceChange);
+      }
+
+      destination.onPlaceChange.add(handlePlaceChange);
+    },
+
+    getAppropriateDestinationMarkerColor: function(destination) {
+      return destination.isSelected()?
+        DestinationMarker.color.GREEN : DestinationMarker.color.BLUE;
+    },
+
     showDestinationInfo: function(destinationMarker) {
       if (!this.info) {
         this.info = new DestinationInfo(
-          this.maps, this.map, destinationMarker.normalizedPlace.details);
+          this.maps, this.map, destinationMarker.place);
       } else {
-        this.info.setDetails(destinationMarker.normalizedPlace.details);
+        this.info.setPlace(destinationMarker.place);
       }
 
       this.info.show(destinationMarker.marker);
     },
 
-    getAppropriateDestinationMarkerColor: function(destination) {
-      return destination === this.selectedDestinationControl?
-        DestinationMarker.color.GREEN : DestinationMarker.color.BLUE;
-    },
-
-    associateDestinationMarker: function(destination, marker) {
-      if (destination.marker === marker) {
-        return;
-      }
-
-      var widget = this;
-
-      if (destination.marker) {
-        destination.marker.removeClient(destination);
-      }
-
-      destination.marker = marker;
-
-      if (marker) {
-        marker.pushClient(destination,
-          this.getAppropriateDestinationMarkerColor(destination));
-        marker.onClick.add(function() {
-          widget.selectDestinationControl(destination, false);
-        });
-      }
-    },
-
-    handleDestinationSet: function(destination, normalizedPlace) {
-      if (destination.marker) {
-        if (!normalizedPlace) {
-          this.associateDestinationMarker(destination, null);
-          this.enableLocationSelection();
-        }
-        /* Else assume we've just updated the marker explicitly via
-         * associateDestationMarker. Corollary: be sure to call that... */
-      } else if (normalizedPlace) {
-        this.createDestinationMarker(normalizedPlace, destination);
-      }
-
-      if (normalizedPlace) {
-        this.disableLocationSelection();
-      }
-
+    handleDestinationPlaceChange: function(destination, place) {
       if (destination.getPrevious()) {
         this.updateLeg(destination);
       }
-
       if (destination.getNext()) {
         this.updateLeg(destination.getNext());
       }
     },
 
-    updateLeg: function(destinationControl) {
-      var widget = this;
+    handleDestinationDeselect: function(destination) {
+      this.selectedDestination = null;
+      destination.onPlaceChange.remove(
+        this.handleSelectedDestinationPlaceChange);
+      this.disableLocationSelection();
+      this.clearSearchMarkers();
+    },
+
+    handleDestinationSelect: function(destination) {
+      this.deselectDestination();
+
+      this.selectedDestination = destination;
+      destination.onPlaceChange.add(this.handleSelectedDestinationPlaceChange);
+      this.handleSelectedDestinationPlaceChange(destination.getPlace());
+    },
+
+    handleSelectedDestinationPlaceChange: function(place) {
+      if (place) {
+        this.disableLocationSelection();
+        this.ensureVisible(place);
+      } else {
+        this.enableLocationSelection();
+      }
+    },
+
+    updateLeg: function(destination) {
+      var self = this;
       var maps = this.maps;
       var map = this.map;
 
-      var origin = destinationControl.getPrevious().getPlace();
-      var destination = destinationControl.getPlace();
+      var a = destination.getPrevious().getPlace();
+      var b = destination.getPlace();
 
-      var leg = destinationControl.leg;
+      var leg = destination.leg;
       if (leg) {
         if (leg.async) {
           leg.async.reject();
@@ -181,13 +282,13 @@
           preserveViewport: true,
           suppressMarkers: true
         });
-        destinationControl.leg = leg = { renderer: renderer };
+        destination.leg = leg = { renderer: renderer };
       }
 
-      if (origin && destination) {
+      if (a && b) {
         var request = {
-          origin: origin.place.location,
-          destination: destination.place.location,
+          origin: a.getLocation(),
+          destination: b.getLocation(),
           travelMode: maps.TravelMode.DRIVING // TODO(rosswang): user choice
         };
 
@@ -196,21 +297,27 @@
         this.directionsService.route(request, function(result, status) {
           if (status === maps.DirectionsStatus.OK) {
             leg.async.resolve(result);
+            leg.sync = result;
           } else {
-            widget.onError({ directionsStatus: status });
+            self.onError({ directionsStatus: status });
             leg.async.reject(status);
           }
         });
 
-        leg.async.done(function(route) {
-          leg.renderer.setDirections(route);
+        leg.async.done(function(result) {
+          leg.renderer.setDirections(result);
           leg.renderer.setMap(map);
+
+          self.ensureGeomsVisible(result.routes[0]['overview_path'].map(
+            function(point) {
+              return { location: point };
+            }));
         });
       }
     },
 
     centerOnCurrentLocation: function() {
-      var widget = this;
+      var self = this;
       var maps = this.maps;
       var map = this.map;
 
@@ -221,40 +328,53 @@
             position.coords.latitude, position.coords.longitude);
           map.setCenter(latLng);
 
-          widget.geocoder.geocode({ location: latLng },
+          self.geocoder.geocode({ location: latLng },
             function(results, status) {
-              if (status === maps.GeocoderStatus.OK) {
-                var result = results[0];
-                var origin = widget.destinations.getDestinations()[0];
-                var marker = widget.createDestinationMarker(
-                  normalizeDestination(result), origin);
-
-                marker.onClick.add(function listener() {
-                  origin.set(result);
-                  marker.onClick.remove(listener);
-                });
+              if (status === maps.GeocoderStatus.OK &&
+                  self.origin && !self.origin.hasPlace()) {
+                self.origin.setPlace(new Place(results[0]));
+                self.createDestinationMarker(self.origin);
               }
             });
           });
       }
     },
 
-    bindDestinationControl: function (destination) {
-      var widget = this;
-      var maps = this.maps;
-      var map = this.map;
+    ensureGeomsVisible: function(geoms) {
+      var curBounds = this.map.getBounds();
+      if (!geoms.every(function(geom) {
+            return curBounds.contains(geom.location);
+          })) {
+        this.fitGeoms(geoms);
+      }
+    },
 
-      maps.event.addListener(map, 'bounds_changed', function() {
-        destination.setSearchBounds(map.getBounds());
-      });
+    fitGeoms: function(geoms) {
+      var curBounds = this.map.getBounds();
+      var curSize = curBounds.toSpan();
+      function wontShrink(proposed) {
+        var size = proposed.toSpan();
+        return size.lat() >= curSize.lat() || size.lng() >= curSize.lng();
+      }
 
-      destination.onFocus.add(function() {
-        widget.selectDestinationControl(destination);
-      });
-      destination.onSearch.add(
-        $.proxy(this, 'showDestinationSearchResults', destination));
-      destination.onSet.add(
-        $.proxy(this, 'handleDestinationSet', destination));
+      if (geoms.length === 1) {
+        var geom = geoms[0];
+        if (geom.viewport && wontShrink(geom.viewport)) {
+          this.map.fitBounds(geom.viewport);
+        } else {
+          this.map.panTo(geom.location);
+        }
+
+      } else if (geoms.length > 1) {
+        this.map.fitBounds(geoms.reduce(function(acc, geom) {
+          if (geom.viewport) {
+            acc.union(geom.viewport);
+          } else {
+            acc.extend(geom.location);
+          }
+          return acc;
+        }, new this.maps.LatLngBounds()));
+      }
     },
 
     enableLocationSelection: function() {
@@ -267,88 +387,17 @@
       this.locationSelectionEnabled = false;
     },
 
-    selectDestinationControl: function(dest, closeInfoWindow) {
-      if (dest !== this.selectedDestinationControl) {
-        var prevDest = this.selectedDestinationControl;
-        if (prevDest && prevDest.marker) {
-          prevDest.marker.setColor(DestinationMarker.color.BLUE);
-        }
-        this.deselectDestinationControl(closeInfoWindow);
-
-        this.selectedDestinationControl = dest;
-        dest.selectControl();
-
-        if (dest.marker) {
-          dest.marker.setColor(DestinationMarker.color.GREEN);
-        }
-
-        var place = dest.getPlace();
-        if (place) {
-          this.fitAllDestinations();
-        } else {
-          this.enableLocationSelection();
-        }
-      }
-    },
-
-    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();
-
-      if (places.length === 1) {
-        var place = places[0];
-        this.map.panTo(place.geometry.location);
-        /* It would be nice if we could distinguish between an autocomplete
-         * click and a normal search so that we don't overwrite the search box
-         * text for the autocomplete click.*/
-        var dest = this.selectedDestinationControl;
-        if (dest) {
-          dest.set(place);
-        }
-      } else if (places.length > 1) {
-        var bounds = new this.maps.LatLngBounds();
-
-        $.each(places, function(i, place) {
-          var marker = widget.createMarker(normalizeDestination(place),
-            SEARCH_CLIENT, DestinationMarker.color.RED);
-          widget.searchMarkers.push(marker);
-
-          marker.onClick.add(function() {
-            var dest = widget.selectedDestinationControl;
-            if (dest && dest.marker !== marker) {
-              widget.associateDestinationMarker(dest, marker);
-              dest.set(place);
-            }
-          });
-
-          bounds.extend(place.geometry.location);
-        });
-
-        this.map.fitBounds(bounds);
-      }
-    },
-
     selectLocation: function(latLng) {
-      var widget = this;
+      var self = this;
       var maps = this.maps;
 
-      var dest = this.selectedDestinationControl;
+      var dest = this.selectedDestination;
       if (dest && this.locationSelectionEnabled) {
-        widget.geocoder.geocode({ location: latLng },
+        self.geocoder.geocode({ location: latLng },
           function(results, status) {
             if (status === maps.GeocoderStatus.OK) {
-              widget.associateDestinationMarker(dest, null);
-              dest.set(results[0]);
+              dest.setPlace(new Place(results[0]));
+              self.createDestinationMarker(dest);
             }
           });
       }
@@ -361,13 +410,17 @@
      * @param error A union with one of the following keys:
      *  directionsStatus
      */
-    onError: 'memory'
+    onError: 'memory',
+    /**
+     * @param bounds
+     */
+    onBoundsChange: ''
   },
 
   // https://developers.google.com/maps/documentation/javascript/tutorial
   init: function(opts) {
     opts = opts || {};
-    var widget = this;
+    var self = this;
 
     var maps = opts.maps || global.google.maps;
     this.maps = maps;
@@ -387,22 +440,15 @@
     var map = new maps.Map(this.$[0], this.initialConfig);
     this.map = map;
 
-    this.messages = new Messages();
-    this.destinations = new Destinations(maps);
-
-    this.destinations.addDestinationBindingHandler(
-      $.proxy(this, 'bindDestinationControl'));
-
     maps.event.addListener(map, 'click', function(e) {
-      widget.selectLocation(e.latLng);
+      self.selectLocation(e.latLng);
+    });
+    maps.event.addListener(map, 'bounds_changed', function() {
+      self.onBoundsChange(map.getBounds());
     });
 
     this.centerOnCurrentLocation();
-
-    var controls = map.controls;
-    controls[maps.ControlPosition.LEFT_TOP].push(this.destinations.$[0]);
-    controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
   }
 });
 
-module.exports = Widget;
+module.exports = Map;
diff --git a/src/components/message.js b/src/components/message.js
index e51c489..1c72faf 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -5,88 +5,85 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var INFO = 'INFO';
-var ERROR = 'ERROR';
+var Message = defineClass({
+  statics: {
+    INFO: 'INFO',
+    ERROR: 'ERROR',
 
-function info(text) {
-  return {
-    type: INFO,
-    text: text
-  };
-}
+    info: function(text) {
+      return {
+        type: Message.INFO,
+        text: text
+      };
+    },
 
-function error(text) {
-  return {
-    type: ERROR,
-    text: text
-  };
-}
+    error: function(text) {
+      return {
+        type: Message.ERROR,
+        text: text
+      };
+    }
+  },
 
-module.exports = {
-  INFO: INFO,
-  ERROR: ERROR,
-  info: info,
-  error: error,
-
-  Message: defineClass({
-    publics: {
-      setType: function(type) {
-        switch (type) {
-          case INFO:
-            this.$.attr('class', 'info');
-            break;
-          case ERROR:
-            this.$.attr('class', 'error');
-            break;
-          default:
-            throw 'Invalid message type ' + type;
-        }
-      },
-
-      setText: function(text) {
-        this.$.text(text);
-      },
-
-      set: function(message) {
-        if (!message) {
-          this.onLowerPriority();
-          return;
-        }
-
-        if (typeof message === 'string') {
-          message = info(message);
-        }
-
-        var self = this;
-
-        this.setType(message.type);
-        this.setText(message.text);
-
-        if (message.promise) {
-          message.promise.then(function(message) {
-            self.set(message);
-          }, function(err) {
-            self.set(error(err));
-          });
-        } else {
-          this.onLowerPriority();
-        }
+  publics: {
+    setType: function(type) {
+      switch (type) {
+        case Message.INFO:
+          this.$.attr('class', 'info');
+          break;
+        case Message.ERROR:
+          this.$.attr('class', 'error');
+          break;
+        default:
+          throw 'Invalid message type ' + type;
       }
     },
 
-    constants: [ '$' ],
-    events: {
-      /**
-       * Event raised when the message is no longer pending user action.
-       */
-      onLowerPriority: 'memory once'
+    setText: function(text) {
+      this.$.text(text);
     },
 
-    init: function(initial) {
-      this.$ = $('<li>');
-      if (initial) {
-        this.set(initial);
+    set: function(message) {
+      if (!message) {
+        this.onLowerPriority();
+        return;
+      }
+
+      if (typeof message === 'string') {
+        message = Message.info(message);
+      }
+
+      var self = this;
+
+      this.setType(message.type);
+      this.setText(message.text);
+
+      if (message.promise) {
+        message.promise.then(function(message) {
+          self.set(message);
+        }, function(err) {
+          self.set(Message.error(err));
+        });
+      } else {
+        this.onLowerPriority();
       }
     }
-  })
-};
+  },
+
+  constants: [ '$' ],
+  events: {
+    /**
+     * Event raised when the message is no longer pending user action.
+     */
+    onLowerPriority: 'memory once'
+  },
+
+  init: function(initial) {
+    this.$ = $('<li>');
+    if (initial) {
+      this.set(initial);
+    }
+  }
+});
+
+module.exports = Message;
diff --git a/src/components/messages.js b/src/components/messages.js
index 5c5c956..2d8555c 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -5,7 +5,7 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var message = require('./message');
+var Message = require('./message');
 
 var Messages = defineClass({
   statics: {
@@ -87,7 +87,7 @@
     push: function(messageData) {
       var self = this;
 
-      var messageObject = new message.Message(messageData);
+      var messageObject = new Message(messageData);
       this.$messages.append(messageObject.$);
 
       if (this.isOpen()) {
@@ -149,7 +149,7 @@
   init: function() {
     this.$handle = $('<div>')
       .addClass('handle')
-      .click(this.toggle.bind(this));
+      .click(this.toggle);
 
     this.$messages = $('<ul>');
 
diff --git a/src/destination.js b/src/destination.js
new file mode 100644
index 0000000..aa98a21
--- /dev/null
+++ b/src/destination.js
@@ -0,0 +1,138 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var defineClass = require('./util/define-class');
+
+var Destination = defineClass({
+  publics: {
+    getIndex: function() {
+      return this.index;
+    },
+
+    getPlace: function() {
+      return this.place;
+    },
+
+    hasPlace: function() {
+      return !!this.place;
+    },
+
+    setPlace: function(place) {
+      var prev = this.place;
+      if (prev !== place) {
+        this.place = place;
+        this.onPlaceChange(place, prev);
+      }
+    },
+
+    isSelected: function() {
+      return this.selected;
+    },
+
+    select: function() {
+      if (!this.selected) {
+        this.selected = true;
+        this.onSelect();
+      }
+    },
+
+    deselect: function() {
+      if (this.selected) {
+        this.selected = false;
+        this.onDeselect();
+      }
+    },
+
+    hasNext: function() {
+      return !!this.next;
+    },
+
+    getNext: function() {
+      return this.next;
+    },
+
+    bindNext: function(next) {
+      var oldNext = this.next;
+      if (oldNext !== next) {
+        if (oldNext) {
+          oldNext.bindPrev(null);
+        }
+
+        this.next = next;
+
+        if (next) {
+          next.bindPrevious(this.ifc);
+        }
+
+        if (!(oldNext && next)) {
+          this.onOrdinalChange(); //changed to or from last
+        }
+      }
+    },
+
+    hasPrevious: function() {
+      return !!this.prev;
+    },
+
+    getPrevious: function() {
+      return this.prev;
+    },
+
+    bindPrevious: function(prev) {
+      if (this.prev !== prev) {
+        if (this.prev) {
+          this.prev.onOrdinalChange.remove(this.updateIndex);
+          this.prev.bindNext(null);
+        }
+
+        this.prev = prev;
+
+        if (prev) {
+          prev.bindNext(this.ifc);
+          prev.onOrdinalChange.add(this.updateIndex);
+        }
+
+        this.updateIndex();
+      }
+    }
+  },
+
+  privates: {
+    updateIndex: function() {
+      var oldIndex = this.index;
+      if (this.prev) {
+        this.index = this.prev.getIndex() + 1;
+      } else {
+        this.index = 0;
+      }
+      if (oldIndex !== this.index) {
+        this.onOrdinalChange();
+      }
+    }
+  },
+
+  events: [
+    /**
+     * Fired when properties related to the ordering of this destination with
+     * respect to other destinations have changed. Such properties include
+     * whether this destination is or last and its index number.
+     */
+    'onOrdinalChange',
+    /**
+     * 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.
+     */
+    'onPlaceChange',
+    'onSelect',
+    'onDeselect'
+  ],
+
+  init: function() {
+    this.selected = false;
+    this.index = 0;
+  }
+});
+
+module.exports = Destination;
\ No newline at end of file
diff --git a/src/place.js b/src/place.js
new file mode 100644
index 0000000..d961d08
--- /dev/null
+++ b/src/place.js
@@ -0,0 +1,151 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var defineClass = require('./util/define-class');
+
+var Place = defineClass({
+  publics: {
+    getDetails: function() {
+      return this.details;
+    },
+
+    hasDetails: function() {
+      return !!this.details;
+    },
+
+    getGeometry: function() {
+      return this.details && this.details.geometry || {
+        location: this.getLocation()
+      };
+    },
+
+    getLocation: function() {
+      return this.placeObj.location;
+    },
+
+    getName: function() {
+      var details = this.details;
+      return details && details.name ||
+        /[^,]*/.exec(details['formatted_address'])[0];
+    },
+
+    getPlaceObject: function() {
+      return this.placeObj;
+    },
+
+    getSingleLine: function() {
+      var details = this.details;
+
+      if (this.singleLine) {
+        return this.singleLine;
+
+      } else if (details) {
+        this.singleLine = details.name &&
+          details.name !== details['formatted_address'].split(', ')[0]?
+            details.name + ', ' + details['formatted_address'] :
+            details['formatted_address'];
+        return this.singleLine;
+
+      } else { // not preferred
+        return this.placeObj.query || this.placeObj.location.toString();
+      }
+    },
+
+    /**
+     * This code is highly fragile and heaven help the poor soul who needs to
+     * localize it.
+     *
+     * TODO(rosswang): Is this really the best way? We should find a formatter.
+     *
+     * @param name optional place name to omit from the address. Defaults to the
+     *   name in the details; pass null to override.
+     * @return an array of formatted address lines.
+     */
+    getMultiLine: function(name) {
+      var details = this.details;
+
+      var addr = details && details['formatted_address'];
+      if (!addr) {
+        return [];
+      }
+
+      if (name === undefined) {
+        name = details.name;
+      }
+
+      /* If at any point the first line/atom will echo the place name, leave it
+       * out. */
+
+      var parts = addr.split(', ');
+      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] === 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] === name? lines.slice(1) : lines;
+    }
+  },
+
+  /**
+   * @param desc place object, place details result, or search result.
+   *
+   * TODO(rosswang): lazy fetch details if not given.
+   */
+  init: function(desc) {
+    if (desc.geometry) {
+      var placeObj = this.placeObj = { location: desc.geometry.location };
+      this.details = desc;
+
+      if (desc['place_id'] !== undefined) {
+        placeObj.placeId = desc['place_id'];
+      } else {
+        placeObj.query = desc['formatted_address'];
+      }
+    } else {
+      this.placeObj = desc;
+    }
+  }
+});
+
+module.exports = Place;
\ No newline at end of file
diff --git a/src/strings.js b/src/strings.js
index f73188a..0f03161 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -18,6 +18,10 @@
       REQUEST_DENIED: 'Request denied',
       UNKNOWN_ERROR: 'Server error'
     },
+    'Final destination': 'Final destination',
+    label: function(label, details) {
+      return label + ': ' + details;
+    },
     'Origin': 'Origin',
     'Travel Planner': 'Travel Planner',
     'Unknown error': 'Unknown error'
diff --git a/src/travel.js b/src/travel.js
index 939bca6..1e0a4e0 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -4,7 +4,9 @@
 
 var $ = require('./util/jquery');
 
-var message = require('./components/message');
+var Destinations = require('./components/destinations');
+var Messages = require('./components/messages');
+var Message = require('./components/message');
 var vanadiumWrapperDefault = require('./vanadium-wrapper');
 
 var defineClass = require('./util/define-class');
@@ -25,34 +27,78 @@
 
 var Travel = defineClass({
   publics: {
+    addDestination: function() {
+      var map = this.map;
+
+      var destination = map.addDestination();
+      var control = this.destinations.append();
+      control.bindDestination(destination);
+
+      control.setSearchBounds(map.getBounds());
+      map.onBoundsChange.add(control.setSearchBounds);
+
+      control.onFocus.add(function() {
+        if (!destination.isSelected()) {
+          map.closeActiveInfoWindow();
+          destination.select();
+        }
+      });
+
+      control.onSearch.add(function(results) {
+        map.showSearchResults(results);
+
+        /* 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. */
+         control.focus();
+      });
+
+      return control;
+    },
+
     error: function (err) {
-      this.map.message(message.error(err.toString()));
+      this.messages.push(Message.error(
+        err.message || err.msg || err.toString()));
     },
 
     info: function (info, promise) {
-      var messageData = message.info(info);
+      var messageData = Message.info(info);
       messageData.promise = promise;
-      this.map.message(messageData);
+      this.messages.push(messageData);
     }
   },
 
   init: function (opts) {
+    var self = this;
+
     opts = opts || {};
     var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
-    var travel = this;
 
-    this.map = new Map(opts);
-    this.sync = new TravelSync();
+    var map = this.map = new Map(opts);
+    var maps = map.maps;
 
-    var reportError = $.proxy(this, 'error');
+    var messages = this.messages = new Messages();
+    var destinations = this.destinations = new Destinations(maps);
+
+    var sync = this.sync = new TravelSync();
+
+    var error = this.error;
+
+    map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
+    map.addControls(maps.ControlPosition.LEFT_TOP, destinations.$);
+
+    destinations.onAddClick.add(function() {
+      self.addDestination().focus();
+    });
 
     this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
       .then(function(wrapper) {
-        wrapper.onCrash.add(reportError);
+        wrapper.onCrash.add(error);
 
         var identity = new Identity(wrapper.getAccountName());
         identity.mountName = makeMountName(identity);
-        return travel.sync.start(identity.mountName, wrapper);
+        return sync.start(identity.mountName, wrapper);
       }).then(function() {
         return strings['Connected to all services.'];
       }, function(err) {
@@ -61,17 +107,20 @@
       }));
 
     var directionsServiceStatusStrings = buildStatusErrorStringMap(
-      this.map.maps.DirectionsStatus, strings.DirectionsStatus);
+      maps.DirectionsStatus, strings.DirectionsStatus);
 
-    this.map.onError.add(function(err) {
+    map.onError.add(function(err) {
       var message = directionsServiceStatusStrings[err.directionsStatus] ||
         strings['Unknown error'];
 
-      reportError(message);
+      error(message);
     });
 
     var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
-    $domRoot.append(travel.map.$);
+    $domRoot.append(map.$);
+
+    this.addDestination();
+    this.addDestination();
   }
 });
 
diff --git a/src/travelsync.js b/src/travelsync.js
index 1717000..3f458b3 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -18,7 +18,7 @@
         function(syncbase) {
           self.syncbase = syncbase;
           syncbase.onError.add(self.onError);
-          syncbase.onUpdate.add(self.processUpdates.bind(self));
+          syncbase.onUpdate.add(self.processUpdates);
         });
 
       return Promise.all([
diff --git a/src/util/define-class.js b/src/util/define-class.js
index ef0b221..ba2074b 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -32,6 +32,10 @@
  *    public interface.
  * </ul>
  *
+ * <p>Furthermore, all functions and events are thus bound statically to the
+ * appropriate instance, and so can be passed as callbacks without ad-hoc
+ * proxying/binding.
+ *
  * <p>Care should be taken not to be tempted to declare instance constants
  * within <code>private</code>, as any instantiations done on the initial
  * values is done at class definition time rather than class instantiation
@@ -47,7 +51,14 @@
         ifc: ifc //expose reflexive public interface for private use
       },
       //extend in inverse precedence
-      def.statics, def.publics, def.privates);
+      def.statics);
+    if (def.publics) {
+      polyBind(pthis, pthis, def.publics, false);
+    }
+
+    if (def.privates) {
+      polyBind(pthis, pthis, def.privates, false);
+    }
 
     if (def.events) {
       if ($.isArray(def.events)) {
@@ -72,7 +83,7 @@
     }
 
     if (def.publics) {
-      polyProxy(ifc, pthis, def.publics, true);
+      polyBind(ifc, pthis, def.publics, true);
     }
 
     if (def.constants) {
@@ -116,25 +127,22 @@
 
 /**
  * Late-bind proxies to maximize flexibility at negligible performance cost.
- * However, a word of caution: although normal jQuery proxies are identifiable
- * as equivalent to their originals for the purposes of callback binding, these
- * will not be.
  */
-function lateProxy(context, name) {
+function lateBind(context, name) {
   return function() {
     return context[name].apply(context, arguments);
   };
 }
 
-function polyProxy(proxy, context, members, lateBinding) {
+function polyBind(proxy, context, members, lateBinding) {
   $.each(members, $.isArray(members)?
     function() {
       proxy[this] =
-        lateBinding? lateProxy(context, this) : $.proxy(context, this);
+        lateBinding? lateBind(context, this) : this.bind(context);
     } :
     function(name, member) {
       proxy[name] =
-        lateBinding? lateProxy(context, name) : $.proxy(member, context);
+        lateBinding? lateBind(context, name) : member.bind(context);
     });
   return proxy;
 }
@@ -142,7 +150,7 @@
 /**
  * Replaces "this" returns with proxy.
  */
-function polyReflexiveLateProxy(proxy, context, members) {
+function polyReflexiveLateBind(proxy, context, members) {
   $.each(members, function(i, name) {
     proxy[name] = function() {
       context[name].apply(context, arguments);
@@ -154,8 +162,8 @@
 
 function defineEvent(pthis, ifc, name, flags) {
   var dispatcher = $.Callbacks(flags);
-  //Use polyProxy on function that fires to add the callable syntactic sugar
-  var callableDispatcher = pthis[name] = polyProxy(function() {
+  //Use polyBind on function that fires to add the callable syntactic sugar
+  var callableDispatcher = pthis[name] = polyBind(function() {
     dispatcher.fireWith.call(dispatcher, ifc, arguments);
   }, dispatcher, dispatcher, false);
 
@@ -170,9 +178,9 @@
     /* We'll want the context to actually be callableDispatcher even though
      * the interface and functionality of dispatcher suffice so that we can
      * late-bind to the instance exposed to private this. */
-    polyProxy(publicEvent, callableDispatcher,
+    polyBind(publicEvent, callableDispatcher,
       ['disabled', 'fired', 'has', 'locked'], true);
-    polyReflexiveLateProxy(publicEvent, callableDispatcher,
+    polyReflexiveLateBind(publicEvent, callableDispatcher,
       ['add', 'disable', 'empty', 'lock', 'remove']);
 
     ifc[name] = publicEvent;
diff --git a/test/components/destination-info.js b/test/components/destination-info.js
index 035cf63..27923f0 100644
--- a/test/components/destination-info.js
+++ b/test/components/destination-info.js
@@ -6,12 +6,13 @@
 var $ = require('../../src/util/jquery');
 
 var DestinationInfo = require('../../src/components/destination-info');
+var Place = require('../../src/place');
 var mockMaps = require('../../mocks/google-maps');
 
 function setUpWithCanvas() {
   var map = new mockMaps.Map($('<div>')[0]);
   var info = new DestinationInfo(mockMaps, map,
-    mockMaps.places.mockPlaceResult);
+    new Place(mockMaps.places.mockPlaceResult));
   return {
     map: map,
     info: info
diff --git a/test/components/destination-marker.js b/test/components/destination-marker.js
index 4cfedde..23ec75b 100644
--- a/test/components/destination-marker.js
+++ b/test/components/destination-marker.js
@@ -6,15 +6,13 @@
 var $ = require('../../src/util/jquery');
 
 var DestinationMarker = require('../../src/components/destination-marker');
-var normalizeDestination =
-  require('../../src/components/destination').normalizeDestination;
+var Place = require('../../src/place');
 var mockMaps = require('../../mocks/google-maps');
 
 function mockMarker(client, color) {
   var map = new mockMaps.Map($('<div>')[0]);
   return new DestinationMarker(mockMaps, map,
-    normalizeDestination(mockMaps.places.mockPlaceResult),
-    client, color);
+    new Place(mockMaps.places.mockPlaceResult), client, color);
 }
 
 test('client events', function(t) {
diff --git a/test/components/map.js b/test/components/map.js
index 62f483a..340e935 100644
--- a/test/components/map.js
+++ b/test/components/map.js
@@ -4,28 +4,18 @@
 
 var test = require('tape');
 
-var $ = require('../../src/util/jquery');
-
 var Map = require('../../src/components/map');
-var message = require ('../../src/components/message');
-
 var mockMaps = require('../../mocks/google-maps');
 
-test('message display', function(t) {
-  var map = new Map({
-    maps: mockMaps
+test('instantiation', function(t) {
+  t.doesNotThrow(function() {
+    //instantiation smoke test
+    /* jshint -W031 */
+    new Map({
+      maps: mockMaps
+    });
+    /* jshint +W031 */
   });
 
-  var $messages = $('.messages ul', map.$);
-  t.ok($messages.length, 'message display exists');
-  t.equals($messages.children().length, 0, 'message display is empty');
-
-  map.message(message.info('Test message.'));
-
-  var $messageItem = $messages.children();
-  t.equals($messageItem.length, 1, 'message display shows 1 message');
-  t.equals($messageItem.text(), 'Test message.',
-    'message displays message text');
-
   t.end();
 });
diff --git a/test/components/message.js b/test/components/message.js
index 865c477..9dbd1da 100644
--- a/test/components/message.js
+++ b/test/components/message.js
@@ -4,21 +4,21 @@
 
 var test = require('tape');
 
-var message = require('../../src/components/message');
+var Message = require('../../src/components/message');
 
 test('init', function(t) {
-  t.ok(new message.Message(), 'default instantiation');
+  t.ok(new Message(), 'default instantiation');
   t.end();
 });
 
 test('dom', function(t) {
-  var msg = new message.Message(message.info('Hello, world!'));
+  var msg = new Message(Message.info('Hello, world!'));
   t.equal(msg.$.length, 1, 'unique element');
   t.equal(msg.$[0].tagName, 'LI', 'tag name');
   t.assert(msg.$.hasClass('info'), 'class info');
   t.equal(msg.$.text(), 'Hello, world!', 'text');
 
-  msg.setType(message.ERROR);
+  msg.setType(Message.ERROR);
   t.notOk(msg.$.hasClass('info'), 'class not info');
   t.assert(msg.$.hasClass('error'), 'class error');
 
diff --git a/test/travel.js b/test/travel.js
index b4d3ded..48a936d 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -14,16 +14,6 @@
   $('body').empty();
 }
 
-test('init', function(t) {
-  /* jshint -W031 */ //instantiation smoke test
-  new Travel({
-    maps: mockMaps
-  });
-  /* jshint +W031 */
-  t.end();
-  cleanDom();
-});
-
 test('domRoot', function(t) {
   var $root = $('<div>');
   var root = $root[0];
@@ -41,4 +31,25 @@
 
   t.end();
   cleanDom();
+});
+
+test('messages', function(t) {
+  var travel = new Travel({
+    maps: mockMaps,
+    vanadiumWrapper: mockVanadiumWrapper
+  });
+
+  var $messages = $('.messages ul');
+  t.ok($messages.length, 'message display exists');
+  var $messageItems = $messages.children();
+  t.equals($messageItems.length, 1,
+    'message display has initial status message');
+
+  travel.info('Test message.');
+
+  $messageItems = $messages.children();
+  t.equals($messageItems.length, 2, 'message display shows 2 messages');
+  t.equals($($messageItems[1]).text(), 'Test message.',
+    'message displays message text');
+  t.end();
 });
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
index c3868d9..e3eeeac 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -73,6 +73,57 @@
   t.end();
 });
 
+test('member bindings', function(t) {
+  var seen;
+
+  var foreignContext = {
+    a: 0
+  };
+
+  var TestClass = defineClass({
+    publics: {
+      seePublic: function() {
+        seen = this.a++;
+      }
+    },
+    privates: {
+      seePrivate: function() {
+        seen = this.a++;
+      }
+    },
+    events: {
+      onPrivate: 'public'
+    },
+
+    init: function() {
+      this.a = 42;
+      this.onPrivate.add(this.seePrivate);
+
+      foreignContext.privateEvent = this.onPrivate;
+      foreignContext.privatePrivate = this.seePrivate;
+      foreignContext.privatePublic = this.seePublic;
+    }
+  });
+
+  var testInstance = new TestClass();
+
+  foreignContext.publicEvent = testInstance.onPrivate;
+  foreignContext.publicPublic = testInstance.seePublic;
+
+  foreignContext.privateEvent();
+  t.equal(seen, 42, 'event privately instance-bound');
+  foreignContext.privatePrivate();
+  t.equal(seen, 43, 'private method privately instance-bound');
+  foreignContext.privatePublic();
+  t.equal(seen, 44, 'public method privately instance-bound');
+  foreignContext.publicEvent();
+  t.equal(seen, 45, 'event publicly instance-bound');
+  foreignContext.publicPublic();
+  t.equal(seen, 46, 'public method publicly instance-bound');
+
+  t.end();
+});
+
 test('events object', function(t) {
   var TestClass = defineClass({
     init: function() {