Click-to-select location and other location-related features

Change-Id: Id5c3d1df3eb77d2a949f2ce30b5df70730c50374
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 272aab7..8e3b5b2 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -19,20 +19,130 @@
   }
 });
 
-module.exports = {
-  Map: function(canvas) {
+var InfoWindow = defineClass({
+  publics: {
+    open: function(map, marker) {
+      this.map = map;
+      map.registerInfoWindow(this.ifc);
+    },
+
+    close: function() {
+      this.map.unregisterInfoWindow(this.ifc);
+    },
+
+    toString: function() { return 'mock InfoWindow'; }
+  }
+});
+
+var Map = defineClass({
+  publics: {
+    registerInfoWindow: function(wnd) {
+      this.infoWindows.push(wnd);
+    },
+
+    unregisterInfoWindow: function(wnd) {
+      this.infoWindows = this.infoWindows.filter(function(elem) {
+        return elem !== wnd;
+      });
+    },
+
+    hasInfoWindow: function(wnd) {
+      return wnd? wnd in this.infoWindows : this.infoWindows.length > 0;
+    },
+
+    toString: function() { return 'mock Map'; }
+  },
+
+  constants: [ 'controls' ],
+
+  events: {
+    //some maps API members are lower_underscore
+    /* jshint camelcase: false */
+    bounds_changed: 'public',
+    click: 'public'
+    /* jshint camelcase: true */
+  },
+
+  init: function(canvas) {
     this.controls = {};
     this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
     this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
-  },
-  LatLng: function(){},
-  ControlPosition: ControlPosition,
 
-  places: {
-    SearchBox: function(){}
+    this.infoWindows = [];
+  }
+});
+
+var Marker = defineClass({
+  publics: {
+    setClickable: function(){},
+
+    setIcon: function(icon) {
+      this.icon = icon;
+    },
+
+    getIcon: function() {
+      return this.icon;
+    },
+
+    setMap: function(map) {
+      this.map = map;
+    },
+
+    getMap: function() {
+      return this.map;
+    },
+
+    toString: function() { return 'mock Marker'; }
   },
 
+  events: {
+    click: 'public'
+  },
+
+  init: function(opts) {
+    $.extend(this, opts);
+  }
+});
+
+var SearchBox = defineClass({
+  publics: {
+    toString: function() { return 'mock SearchBox'; }
+  },
+
+  events: {
+    //some maps API members are lower_underscore
+    /* jshint camelcase: false */
+    places_changed: 'public'
+    /* jshint camelcase: true */
+  }
+});
+
+module.exports = {
+  ControlPosition: ControlPosition,
+  Geocoder: function(){},
+  InfoWindow: InfoWindow,
+  LatLng: function(){},
+  Map: Map,
+  Marker: Marker,
+
   event: {
-    addListener: function(){}
+    addListener: function(instance, eventName, handler){
+      if (eventName in instance) {
+        instance[eventName].add(handler);
+      } else {
+        throw instance + ' does not mock event ' + eventName;
+      }
+    },
+    trigger: function(instance, eventName) {
+      instance[eventName].apply(instance,
+        Array.prototype.slice.call(arguments, 2));
+    }
+  },
+
+  places: {
+    SearchBox: SearchBox,
+    mockPlaceResult: {
+      geometry: {}
+    }
   }
 };
\ No newline at end of file
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
new file mode 100644
index 0000000..6f648bc
--- /dev/null
+++ b/src/components/destination-info.js
@@ -0,0 +1,85 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+/**
+ * Given a Maps API address_components array, return an array of formatted
+ * address lines.
+ *
+ * TODO(rosswang): Is this really the best way?
+ */
+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 [];
+  }
+
+  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;
+  }
+}
+
+function render(details) {
+  var $info = $('<div>').addClass('destination-info');
+
+  if (details && details.name) {
+    $info.append($('<div>')
+      .addClass('title')
+      .text(details.name));
+  }
+
+  var addressLines = formatAddress(details);
+  if (addressLines) {
+    $.each(addressLines,
+      function(i, line) {
+        $info.append($('<div>')
+          .addClass('address-line')
+          .text(line));
+      });
+  }
+
+  return $info[0];
+}
+
+var DestinationInfo = defineClass({
+  publics: {
+    close: function() {
+      this.infoWindow.close();
+    },
+
+    show: function(marker) {
+      this.infoWindow.open(this.map, marker);
+    },
+
+    setDetails: function(details) {
+      this.infoWindow.setContent(render(details));
+      this.infoWindow.setPosition(details && details.geometry.location);
+    }
+  },
+
+  init: function(maps, map, details) {
+    this.map = map;
+
+    this.infoWindow = new maps.InfoWindow({
+      content: render(details),
+      position: details && details.geometry.location
+    });
+  }
+});
+
+module.exports = DestinationInfo;
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
new file mode 100644
index 0000000..8e24baf
--- /dev/null
+++ b/src/components/destination-marker.js
@@ -0,0 +1,133 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+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 */
+}
+
+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());
+    },
+
+    topClient: function() {
+      return this.clients[this.clients.length - 1];
+    },
+
+    updateColor: function() {
+      var color = this.topClient().color;
+      this.marker.setIcon(markerIcon(color));
+    }
+  },
+
+  publics: {
+    clear: function() {
+      this.marker.setMap(null);
+    },
+
+    pushClient: function(client, color) {
+      color = color || this.topClient().color;
+      this.clients.push({ client: client, color: color, listeners: [] });
+      this.updateColor();
+    },
+
+    removeClient: function(client) {
+      var onClick = this.onClick;
+      this.clients = this.clients.filter(function(entry) {
+        var match = entry.client === client;
+        if (match) {
+          $.each(entry.listeners, function() {
+            onClick.remove(this);
+          });
+        }
+        return !match;
+      });
+
+      this.refreshClickability();
+
+      if (!this.clients.length) {
+        this.clear();
+      } else {
+        this.updateColor();
+      }
+    },
+
+    setColor: function(color) {
+      this.topClient().color = color;
+      this.updateColor();
+    }
+  },
+
+  events: [ 'onClick' ],
+  constants: [ 'marker', 'normalizedPlace' ],
+
+  /**
+   * A note on clients: destination markers can be shared between multiple use
+   * cases, ex. search and multiple actual destination associations. A marker
+   * is removed when all of its clients have been removed. The latest client
+   * determines the color of the marker. Click event handlers are added per
+   * client, unless they're added as global (a second argument to
+   * `Callbacks.add`); all remain active while their client is registered, but
+   * when the client is removed the corresponding click handlers are removed as
+   * well.
+   */
+  init: function(maps, map, normalizedPlace, client, color) {
+    var self = this;
+
+    this.map = map;
+    this.normalizedPlace = normalizedPlace;
+    this.clients = [{ client: client, color: color, listeners: [] }];
+
+    this.marker = new maps.Marker({
+      icon: markerIcon(color),
+      map: map,
+      place: normalizedPlace.place,
+      title: deriveTitle(normalizedPlace),
+      clickable: false
+    });
+
+    defineClass.decorate(this.onClick, 'add', function(listener, global) {
+      if (!global) {
+        /* Per jQuery, listener can also be an array; however, there seems to
+         * be a bug in jQuery at this time where remove will not remove arrays,
+         * only individual functions, so let's flatten here. */
+        var listeners = self.topClient().listeners;
+        if ($.isArray(listener)) {
+          $.each(listener, function() {
+            listeners.push(this);
+          });
+        } else {
+          listeners.push(listener);
+        }
+      }
+
+      self.refreshClickability();
+    });
+
+    maps.event.addListener(this.marker, 'click', $.proxy(this, 'onClick'));
+  }
+});
+
+module.exports = DestinationMarker;
diff --git a/src/components/destination.js b/src/components/destination.js
index 7d0e411..8a3933a 100644
--- a/src/components/destination.js
+++ b/src/components/destination.js
@@ -2,6 +2,42 @@
 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: {
     setSearchBounds: function(bounds) {
       this.searchBox.setBounds(bounds);
@@ -9,14 +45,46 @@
 
     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 normalized = this.normalizeDestination(placeDesc);
+
+      if (normalized && updateSearchBox !== false) {
+        this.$searchBox.prop('value', normalized.display);
+      }
+
+      this.place = normalized && normalized.place;
+      this.onSet(normalized);
     }
   },
 
   events: [
     /**
+     * @param event jQuery Event object for text box focus event.
+     */
+    'onFocus',
+    /**
      * @param places (array of places)
      */
-    'onSearch'
+    'onSearch',
+    /**
+     * fired when the destination has been set to a place, or cleared.
+     * @param place the new destination, as a normalized place.
+     */
+    'onSet'
   ],
 
   constants: ['$'],
@@ -34,6 +102,11 @@
       $searchBox.prop('value', initial);
     }
 
+    $searchBox.focus(this.onFocus);
+    $searchBox.on('input', function() {
+      destination.set(null, false);
+    });
+
     this.$ = $('<div>').addClass('destination')
       .append($searchBox);
 
@@ -42,6 +115,10 @@
     maps.event.addListener(this.searchBox, 'places_changed', function() {
       destination.onSearch(destination.searchBox.getPlaces());
     });
+
+    /* TODO(rosswang): can we for the love of squirrels stop the autocomplete
+     * from popping up after a location has been selected through a map click?
+     */
   }
 });
 
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 088e02c..852cf2a 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -38,6 +38,10 @@
       $.each(this.destinations, function(i, destination) {
         handler(destination);
       });
+    },
+
+    getDestinations: function() {
+      return this.destinations.slice(0);
     }
   },
 
diff --git a/src/components/maps.js b/src/components/maps.js
index 122a628..f63f893 100644
--- a/src/components/maps.js
+++ b/src/components/maps.js
@@ -2,23 +2,62 @@
 var defineClass = require('../util/define-class');
 
 var Destinations = require('./destinations');
+var DestinationInfo = require('./destination-info');
+var DestinationMarker = require('./destination-marker');
 var Messages = require('./messages');
 
+var normalizeDestination = require('./destination').normalizeDestination;
+
+//named destination marker clients
+var SEARCH_CLIENT = 'search';
+
 var Widget = defineClass({
   publics: {
-    clearMarkers: function() {
-      var markers = this.markers;
-      this.markers = [];
-      $.each(markers, function(i, marker) {
-        marker.setMap(null);
+    clearSearchMarkers: function() {
+      $.each(this.searchMarkers, function() {
+        this.removeClient(SEARCH_CLIENT);
       });
+      this.searchMarkers = [];
     },
 
     closeActiveInfoWindow: function() {
-      if (this.activeInfoWindow) {
-        this.activeInfoWindow.close();
+      if (this.info) {
+        this.info.close();
       }
-      this.activeInfoWindow = null;
+    },
+
+    deselectDestinationControl: function() {
+      if (this.selectedDestinationControl) {
+        this.selectedDestinationControl.deselectControl();
+        this.selectedDestinationControl = null;
+        this.disableLocationSelection();
+        this.clearSearchMarkers();
+        this.closeActiveInfoWindow();
+      }
+    },
+
+    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.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()));
+      }
     },
 
     message: function(message) {
@@ -27,71 +66,110 @@
   },
 
   privates: {
-    destinationSelectionWindow: defineClass.innerClass({
-      privates: {
-        renderInfo: function() {
-          var $info = $('<div>').addClass('destination-info');
+    createMarker: function(normalizedPlace, client, color) {
+      var marker = new DestinationMarker(this.maps, this.map, normalizedPlace,
+        client, color);
 
-          $info.append($('<div>')
-            .addClass('title')
-            .text(this.place.name));
-
-          return $info[0];
-        }
-      },
-
-      init: function(place, createMarker) {
-        var widget = this.outer;
-        var maps = widget.maps;
-        var map = widget.map;
-
-        this.place = place;
-
-        var infoWindow = new maps.InfoWindow({
-          content: this.renderInfo(),
-          position: place.geometry.location
-        });
-
-        var marker;
-        if (createMarker) {
-          marker = new maps.Marker({
-            map: map,
-            title: place.name,
-            position: place.geometry.location
-          });
-
-          maps.event.addListener(marker, 'click', function() {
-            widget.setActiveInfoWindow(infoWindow, marker);
-          });
-
-          widget.markers.push(marker);
-        } else {
-          widget.setActiveInfoWindow(infoWindow);
-        }
+      if (normalizedPlace.details) {
+        marker.onClick.add($.proxy(this, 'showDestinationInfo', marker), true);
       }
-    }),
 
-    setActiveInfoWindow: function(infoWindow, marker) {
-      this.closeActiveInfoWindow();
-      this.activeInfoWindow = infoWindow;
-      infoWindow.open(this.map, marker);
+      return marker;
+    },
+
+    createDestinationMarker: function(normalizedPlace, destinationControl) {
+      var marker = this.createMarker(normalizedPlace, destinationControl,
+        this.getAppropriateDestinationMarkerColor(destinationControl));
+      destinationControl.marker = marker;
+      marker.onClick.add(
+        $.proxy(this, 'selectDestinationControl', destinationControl));
+
+      return marker;
+    },
+
+    showDestinationInfo: function(destinationMarker) {
+      if (!this.info) {
+        this.info = new DestinationInfo(
+          this.maps, this.map, destinationMarker.normalizedPlace.details);
+      } else {
+        this.info.setDetails(destinationMarker.normalizedPlace.details);
+      }
+
+      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;
+      }
+
+      if (destination.marker) {
+        destination.marker.removeClient(destination);
+      }
+
+      destination.marker = marker;
+
+      if (marker) {
+        marker.pushClient(destination,
+          this.getAppropriateDestinationMarkerColor(destination));
+        marker.onClick.add(
+          $.proxy(this, 'selectDestinationControl', destination));
+      }
+    },
+
+    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();
+      }
     },
 
     centerOnCurrentLocation: function() {
+      var widget = this;
       var maps = this.maps;
       var map = this.map;
 
       // https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
       if (global.navigator && global.navigator.geolocation) {
         global.navigator.geolocation.getCurrentPosition(function(position) {
-          map.setCenter(new maps.LatLng(position.coords.latitude,
-            position.coords.longitude));
-        });
+          var latLng = new maps.LatLng(
+            position.coords.latitude, position.coords.longitude);
+          map.setCenter(latLng);
+
+          widget.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);
+                });
+              }
+            });
+          });
       }
     },
 
     bindDestinationControl: function (destination) {
-      var widget = this;
       var maps = this.maps;
       var map = this.map;
 
@@ -99,57 +177,140 @@
         destination.setSearchBounds(map.getBounds());
       });
 
-      destination.onSearch.add(function(places) {
-        widget.clearMarkers();
-        widget.closeActiveInfoWindow();
-        var bounds = new maps.LatLngBounds();
+      destination.onFocus.add(
+        $.proxy(this, 'selectDestinationControl', destination));
+      destination.onSearch.add($.proxy(this, 'showDestinationSearchResults'));
+      destination.onSet.add($.proxy(this, 'handleDestinationSet', destination));
+    },
 
-        if (places.length === 1) {
-          var place = places[0];
-          widget.destinationSelectionWindow(place, false);
+    enableLocationSelection: function() {
+      this.map.setOptions({ draggableCursor: 'auto' });
+      this.locationSelectionEnabled = true;
+    },
 
-          map.setCenter(place.geometry.location);
-        } else if (places.length > 1) {
-          $.each(places, function(i, place) {
-            widget.destinationSelectionWindow(place, true);
-            bounds.extend(place.geometry.location);
+    disableLocationSelection: function() {
+      this.map.setOptions({ draggableCursor: null });
+      this.locationSelectionEnabled = false;
+    },
+
+    selectDestinationControl: function(dest) {
+      if (dest !== this.selectedDestinationControl) {
+        var prevDest = this.selectedDestinationControl;
+        if (prevDest && prevDest.marker) {
+          prevDest.marker.setColor(DestinationMarker.color.BLUE);
+        }
+        this.deselectDestinationControl();
+
+        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(places) {
+      var widget = this;
+
+      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) {
+              widget.associateDestinationMarker(dest, marker);
+              dest.set(place);
+            }
           });
 
-          map.fitBounds(bounds);
-        }
-      });
+          bounds.extend(place.geometry.location);
+        });
+
+        this.map.fitBounds(bounds);
+      }
+    },
+
+    selectLocation: function(latLng) {
+      var widget = this;
+      var maps = this.maps;
+
+      var dest = this.selectedDestinationControl;
+      if (dest && this.locationSelectionEnabled) {
+        widget.geocoder.geocode({ location: latLng },
+          function(results, status) {
+            if (status === maps.GeocoderStatus.OK) {
+              widget.associateDestinationMarker(dest, null);
+              dest.set(results[0]);
+            }
+          });
+      }
     }
   },
 
   constants: ['$', 'maps'],
 
   // https://developers.google.com/maps/documentation/javascript/tutorial
-  init: function(maps) {
-    this.maps = maps = maps || global.google.maps;
+  init: function(opts) {
+    opts = opts || {};
+    var widget = this;
+
+    var maps = opts.maps || global.google.maps;
+    this.maps = maps;
+    this.navigator = opts.navigator || global.navigator;
+    this.geocoder = new maps.Geocoder();
 
     this.$ = $('<div>').addClass('map-canvas');
 
-    this.markers = [];
+    this.searchMarkers = [];
     this.route = {};
 
+    this.initialConfig = {
+      center: new maps.LatLng(37.4184, -122.0880), //Googleplex
+      zoom: 11
+    };
+
+    var map = new maps.Map(this.$[0], this.initialConfig);
+    this.map = map;
+
     this.messages = new Messages();
     this.destinations = new Destinations(maps);
 
-    var config = {
-      zoom: 11,
-      center: new maps.LatLng(37.4184, -122.0880) //Googleplex
-    };
+    this.destinations.addDestinationBindingHandler(
+      $.proxy(this, 'bindDestinationControl'));
 
-    var map = new maps.Map(this.$[0], config);
-    this.map = map;
+    maps.event.addListener(map, 'click', function(e) {
+      widget.selectLocation(e.latLng);
+    });
 
     this.centerOnCurrentLocation();
 
     var controls = map.controls;
-
-    this.destinations.addDestinationBindingHandler(
-      $.proxy(this, 'bindDestinationControl'));
-
     controls[maps.ControlPosition.TOP_LEFT].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 e6f76af..5b4a733 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -10,6 +10,7 @@
 
 .destination {
   width: 100%;
+  position: relative;
 }
 
 .destination input {
@@ -18,6 +19,11 @@
   padding: 8px 16px;
 }
 
+.destination-info .title {
+  font-weight: 500;
+  font-size: 14px;
+}
+
 .map-canvas {
   width: 100%;
   height: 100%;
@@ -52,3 +58,8 @@
   content: "x ";
   color: red;
 }
+
+.selected {
+  box-shadow: 0 0 8px #05f;
+  z-index: 1;
+}
diff --git a/src/travel.js b/src/travel.js
index d6f864b..25e2ad4 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -38,7 +38,7 @@
         travel.sync.start(identity.mountName, wrapper).catch(reportError);
       }, reportError);
 
-    this.maps = new Maps(opts.maps);
+    this.maps = new Maps(opts);
     var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
     $domRoot.append(travel.maps.$);
   }
diff --git a/src/util/define-class.js b/src/util/define-class.js
index 8df6c29..66b2fed 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -38,8 +38,12 @@
 
 function defineClass(def) {
   var constructor = function() {
-    var pthis = $.extend({}, def.privates, def.publics, def.statics);
     var ifc = this;
+    var pthis = $.extend({
+        ifc: ifc //expose reflexive public interface for private use
+      },
+      //extend in inverse precedence
+      def.statics, def.publics, def.privates);
 
     if (def.events) {
       if ($.isArray(def.events)) {
@@ -64,7 +68,7 @@
     }
 
     if (def.publics) {
-      polyProxy(ifc, pthis, def.publics);
+      polyProxy(ifc, pthis, def.publics, true);
     }
 
     if (def.constants) {
@@ -94,18 +98,52 @@
   };
 };
 
-function polyProxy(proxy, context, members) {
-  $.each(members, function(name, member) {
-    proxy[name] = $.proxy(member, context);
-  });
+/**
+ * Decorates a member function with a like-signatured function to be called
+ * prior to the main invocation.
+ */
+defineClass.decorate = function(context, name, before)  {
+  var proto = context[name];
+  context[name] = function() {
+    before.apply(context, arguments);
+    return proto.apply(context, arguments);
+  };
+};
+
+/**
+ * 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) {
+  return function() {
+    return context[name].apply(context, arguments);
+  };
+}
+
+function polyProxy(proxy, context, members, lateBinding) {
+  $.each(members, $.isArray(members)?
+    function() {
+      proxy[this] =
+        lateBinding? lateProxy(context, this) : $.proxy(context, this);
+    } :
+    function(name, member) {
+      proxy[name] =
+        lateBinding? lateProxy(context, name) : $.proxy(member, context);
+    });
   return proxy;
 }
 
-function filterProxy(proxy, context, nameFilter) {
-  $.each(context, function(name, member) {
-    if (nameFilter(name)) {
-      proxy[name] = $.proxy(member, context);
-    }
+/**
+ * Replaces "this" returns with proxy.
+ */
+function polyReflexiveLateProxy(proxy, context, members) {
+  $.each(members, function(i, name) {
+    proxy[name] = function() {
+      context[name].apply(context, arguments);
+      return proxy;
+    };
   });
   return proxy;
 }
@@ -113,8 +151,9 @@
 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($.proxy(dispatcher, 'fire'), dispatcher, dispatcher);
+  var callableDispatcher = pthis[name] = polyProxy(function() {
+    dispatcher.fireWith.call(dispatcher, ifc, arguments);
+  }, dispatcher, dispatcher, false);
 
   if (flags && flags.indexOf('private') > -1) {
     return;
@@ -123,9 +162,16 @@
   if (flags && flags.indexOf('public') > -1) {
     ifc[name] = callableDispatcher;
   } else {
-    ifc[name] = filterProxy({}, dispatcher, function(name) {
-      return name !== 'fire' && name !== 'fireWith';
-    });
+    var publicEvent = {};
+    /* 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,
+      ['disabled', 'fired', 'has', 'locked'], true);
+    polyReflexiveLateProxy(publicEvent, callableDispatcher,
+      ['add', 'disable', 'empty', 'lock', 'remove']);
+
+    ifc[name] = publicEvent;
   }
 }
 
diff --git a/test/components/destination-info.js b/test/components/destination-info.js
new file mode 100644
index 0000000..8f2d72f
--- /dev/null
+++ b/test/components/destination-info.js
@@ -0,0 +1,24 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var DestinationInfo = require('../../src/components/destination-info');
+var mockMaps = require('../../mocks/google-maps');
+
+function setUpWithCanvas() {
+  var map = new mockMaps.Map($('<div>')[0]);
+  var info = new DestinationInfo(mockMaps, map,
+    mockMaps.places.mockPlaceResult);
+  return {
+    map: map,
+    info: info
+  };
+}
+
+test('lifecycle', function(t) {
+  var tc = setUpWithCanvas();
+  tc.info.show();
+  t.ok(tc.map.hasInfoWindow(), 'infoWindow opened');
+  tc.info.close();
+  t.notOk(tc.map.hasInfoWindow(), 'infoWindow closed');
+  t.end();
+});
diff --git a/test/components/destination-marker.js b/test/components/destination-marker.js
new file mode 100644
index 0000000..8da7a08
--- /dev/null
+++ b/test/components/destination-marker.js
@@ -0,0 +1,95 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var DestinationMarker = require('../../src/components/destination-marker');
+var normalizeDestination =
+  require('../../src/components/destination').normalizeDestination;
+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);
+}
+
+test('client events', function(t) {
+  var CLIENT1 = {};
+  var CLIENT2 = {};
+  var counts = [];
+  var h = [];
+
+  function makeHandler(i) {
+    return function() {
+      counts[i]++;
+    };
+  }
+
+  for (var i = 0; i < 6; i++) {
+    counts.push(0);
+    h.push(makeHandler(i));
+  }
+
+  var marker = mockMarker(CLIENT1, DestinationMarker.color.RED);
+
+  marker.onClick.add(h[0]);
+  marker.onClick.add([ h[1], h[2] ]);
+  marker.onClick.add(h[3]);
+  marker.pushClient(CLIENT2);
+  marker.onClick.add(h[4]);
+  marker.pushClient(CLIENT1);
+  marker.onClick.add(h[5]);
+
+  marker.marker.click();
+  t.deepEqual(counts, [1, 1, 1, 1, 1, 1], 'all handlers triggered');
+  marker.removeClient(CLIENT1);
+  marker.onClick.remove([ h[1], h[2] ]);
+  marker.marker.click();
+  t.deepEqual(counts, [1, 1, 1, 1, 2, 1], 'event handlers for CLIENT1 removed');
+
+  t.end();
+});
+
+test('colors', function(t) {
+  var marker = mockMarker('c1', DestinationMarker.color.RED);
+  var redIcon = marker.marker.getIcon();
+
+  marker.pushClient('c2', DestinationMarker.color.ORANGE);
+  var orangeIcon = marker.marker.getIcon();
+  t.notEqual(redIcon, orangeIcon, 'color changed with new client');
+  marker.setColor(DestinationMarker.color.YELLOW);
+  var yellowIcon = marker.marker.getIcon();
+  t.notEqual(orangeIcon, yellowIcon, 'color changed via setColor');
+
+  marker.pushClient('c3', DestinationMarker.color.GREEN);
+  var greenIcon = marker.marker.getIcon();
+  t.notEqual(yellowIcon, greenIcon,
+    'color changed with new client after setColor');
+  marker.setColor(DestinationMarker.color.BLUE);
+  var blueIcon = marker.marker.getIcon();
+
+  marker.removeClient('c2');
+  t.equal(marker.marker.getIcon(), blueIcon,
+    'color not changed with earlier client removed');
+
+  marker.pushClient('c4', DestinationMarker.color.PURPLE);
+  var purpleIcon = marker.marker.getIcon();
+  t.notEqual(blueIcon, purpleIcon,
+    'color changed with new client after earlier removal');
+
+  marker.removeClient('c4');
+  t.equal(marker.marker.getIcon(), blueIcon,
+    'color restored after client removal');
+
+  marker.removeClient('c3');
+  t.equal(marker.marker.getIcon(), redIcon,
+    'color restored to original after client removal with earlier removal of ' +
+    'intermediate client');
+
+  t.ok(marker.marker.getMap(), 'marker still attached to map');
+
+  marker.removeClient('c1');
+  t.notOk(marker.marker.getMap(), 'marker detached from map');
+
+  t.end();
+});
diff --git a/test/components/maps.js b/test/components/maps.js
index 013661b..c527473 100644
--- a/test/components/maps.js
+++ b/test/components/maps.js
@@ -8,7 +8,9 @@
 var mockMaps = require('../../mocks/google-maps');
 
 test('message display', function(t) {
-  var maps = new Maps(mockMaps);
+  var maps = new Maps({
+    maps: mockMaps
+  });
 
   var $messages = $('.messages', maps.$);
   t.ok($messages.length, 'message display exists');
@@ -23,5 +25,3 @@
 
   t.end();
 });
-
-module.exports = mockMaps;
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
index 68c6382..4657f7a 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -36,12 +36,14 @@
   t.ok(testInstance, 'instance instantiated');
 
   var queried = 0, queriedOnce = 0;
-  testInstance.stringQueried.add(function(value) {
+  t.notOk(testInstance.stringQueried.add(function(value) {
     t.equal(value, 'world', 'event argument');
     queried++;
-  });
+  }).fire, 'callback accessibility leak');
+  t.ok(testInstance.stringQueried.has(), 'callback proxied accessor');
   testInstance.stringQueriedOnce.add(function(value) {
     t.equal(value, 'world', 'event argument');
+    t.equal(this, testInstance, 'event context');
     queriedOnce++;
   });
 
@@ -186,3 +188,86 @@
 
   t.end();
 });
+
+test('late binding of public members', function(t) {
+  var TestClass = defineClass({
+    publics: {
+      getValue: function() {
+        return 'a';
+      },
+      rebind: function() {
+        this.getValue = function() {
+          return 'b';
+        };
+      }
+    }
+  });
+  var testInstance = new TestClass();
+  testInstance.rebind();
+  t.equal(testInstance.getValue(), 'b',
+    'public interface should late-bind to private');
+  t.end();
+});
+
+test('late binding of event members', function(t) {
+  var fireCount = 0;
+
+  function listener() {
+    fireCount++;
+  }
+
+  var TestClass = defineClass({
+    publics: {
+      addA: function() {
+        this.onA.add(listener);
+      },
+
+      a: function() {
+        this.onA();
+      },
+
+      b: function() {
+        this.onB();
+      },
+
+      getListenerCount: function() {
+        return this.listenerCount;
+      }
+    },
+
+    events: {
+      onA: 'private',
+      onB: '',
+      onC: 'public'
+    },
+
+    init: function() {
+      var self = this;
+      this.listenerCount = 0;
+
+      function decorateEvent(event) {
+        defineClass.decorate(event, 'add', function() {
+          self.listenerCount++;
+        });
+      }
+
+      decorateEvent(this.onA);
+      decorateEvent(this.onB);
+      decorateEvent(this.onC);
+    }
+  });
+
+  var testInstance = new TestClass();
+  testInstance.addA();
+  t.equal(testInstance.getListenerCount(), 1, 'events decorated');
+  testInstance.onB.add(listener);
+  t.equal(testInstance.getListenerCount(), 2, 'events decorated');
+  testInstance.onC.add(listener);
+  t.equal(testInstance.getListenerCount(), 3, 'events decorated');
+  testInstance.a();
+  testInstance.b();
+  testInstance.onC();
+  t.equal(fireCount, 3, 'events still work');
+
+  t.end();
+});