Adding place details panel

The panel is called "suggestions" as it will primarily be used to
launch suggestions.

Change-Id: If801d49e72bd0470c8d9e2a401b0a1954afd922b
diff --git a/package.json b/package.json
index ced45b4..4ab107a 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "htmlencode": "^0.0.4",
     "jquery": "^2.1.4",
     "lodash": "^3.10.1",
+    "mercury": "^14.0.0",
     "multimap": "^0.1.1",
     "query-string": "^2.4.0",
     "raf": "^3.1.0",
diff --git a/src/components/suggestion.js b/src/components/suggestion.js
new file mode 100644
index 0000000..f28f3b2
--- /dev/null
+++ b/src/components/suggestion.js
@@ -0,0 +1,56 @@
+// 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.
+
+require('es6-shim');
+
+var hg = require('mercury');
+var h = hg.h;
+
+var $ = require('../util/jquery');
+var strings = require('../strings').currentLocale;
+
+module.exports = Suggestion;
+
+function Suggestion(suggestion) {
+  return hg.state({
+    placeId: hg.value(suggestion.placeId),
+    placeName: hg.value(suggestion.placeName),
+    photoUrl: hg.value(suggestion.photoUrl),
+    iconUrl: hg.value(suggestion.iconUrl),
+    rating: hg.value(suggestion.rating),
+    priceLevel: hg.value(suggestion.priceLevel),
+
+    hovered: hg.value(suggestion.hovered),
+    selected: hg.value(suggestion.selected),
+
+    channels: {
+      toggleSelect: toggleSelect
+    }
+  });
+}
+
+function toggleSelect(state) {
+  state.selected.set(!state.selected());
+}
+
+Suggestion.render = function(state) {
+  var elems = [
+    h('.img-container', state.photoUrl? h('img.photo', { src: state.photoUrl })
+                      : state.iconUrl ? h('img.icon', { src: state.iconUrl })
+                      : ['?']),
+    h('.name', state.placeName)
+  ];
+  if (state.rating !== undefined && state.rating !== null) {
+    elems.push(h('.rating', [
+      state.rating .toString(),
+      'TODO: stars'
+    ]));
+  }
+  if ($.isNumeric(state.priceLevel)) {
+    elems.push(h('.price-level', state.priceLevel?
+      strings.priceLevelUnit.repeat(state.priceLevel) : strings['Free']));
+  }
+  elems.push(h('.clear-float'));
+  return h('.suggestion', elems);
+};
\ No newline at end of file
diff --git a/src/components/suggestions.js b/src/components/suggestions.js
new file mode 100644
index 0000000..8ad1f45
--- /dev/null
+++ b/src/components/suggestions.js
@@ -0,0 +1,24 @@
+// 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('lodash');
+var hg = require('mercury');
+var h = hg.h;
+
+var Suggestion = require('./suggestion');
+
+module.exports = Suggestions;
+
+function Suggestions(suggestions) {
+  return hg.state({
+    suggestions: hg.varhash(suggestions || {}, Suggestion)
+  });
+}
+
+Suggestions.render = function(state) {
+  return h('.suggestions', _.toArray(state.suggestions).map(
+    function(suggestion) {
+      return hg.partial(Suggestion.render, suggestion);
+    }));
+};
\ No newline at end of file
diff --git a/src/ifc/conversions.js b/src/ifc/conversions.js
index f83c60c..6a2b8ba 100644
--- a/src/ifc/conversions.js
+++ b/src/ifc/conversions.js
@@ -8,6 +8,13 @@
 
 var Place = require('../place');
 
+/* TODO(rosswang): We can remote getUrl out as an RPC, at least for RPC-based
+ * casting. We'd need some fancy footwork with the Syncbase approach, with no
+ * guarantee of resolution. */
+var PLACE_PHOTO_OPTS = {
+  maxHeight: 96
+};
+
 var x = {
   box: function(i) {
     return i === undefined || i === null? i : new vdlTravel.Int16({ value: i });
@@ -25,7 +32,13 @@
         viewport: x.toLatLngBounds(maps, ifc.viewport)
       },
       'formatted_address': ifc.formattedAddress,
-      name: ifc.name
+      name: ifc.name,
+      photos: ifc.photoUrl? [{
+        getUrl: function() { return ifc.photoUrl; }
+      }] : [],
+      icon: ifc.iconUrl,
+      rating: ifc.rating,
+      priceLevel: ifc.priceLevel
     });
   },
 
@@ -42,7 +55,12 @@
       viewport: place.getGeometry().viewport,
       formattedAddress: details && details['formatted_address'] ||
         placeObj.query,
-      name: details && details.name
+      name: details && details.name,
+      photoUrl: details.photos[0]?
+        details.photos[0].getUrl(PLACE_PHOTO_OPTS) : '',
+      iconUrl: details.icon || '',
+      rating: details.rating,
+      priceLevel: details.priceLevel
     });
   },
 
diff --git a/src/ifc/types.vdl b/src/ifc/types.vdl
index 506d826..dc5f4ab 100644
--- a/src/ifc/types.vdl
+++ b/src/ifc/types.vdl
@@ -40,6 +40,10 @@
 
   FormattedAddress string
   Name string
+  PhotoUrl string
+  IconUrl string
+  Rating float32
+  PriceLevel byte
 }
 
 type Event struct {
diff --git a/src/static/index.css b/src/static/index.css
index b6d591e..66fa046 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -327,6 +327,56 @@
   content: '▲';
 }
 
+.suggestions-container {
+  float: right;
+  overflow-y: auto;
+  height: 100%;
+  width: 25em;
+}
+
+.suggestion {
+  border: 1px solid #ccc;
+  font-family: Roboto, Arial, sans-serif;
+  padding: .5em;
+  margin: 0 0 -1px 0;
+}
+
+.suggestion .img-container {
+  float: left;
+  margin-right: 8px;
+  height: 92px;
+  width: 80px;
+  text-align: center;
+  font-size: 80px;
+  color: gray;
+}
+
+.suggestion img {
+  height: 100%;
+  width: 100%;
+}
+
+.suggestion img.photo {
+  object-fit: cover;
+}
+
+.suggestion img.icon {
+  object-fit: scale-down;
+  background-color: #eee;
+}
+
+.suggestion .rating {
+  color: #e7711b;
+  font-size: 13px;
+  line-height: 16px;
+}
+
+.suggestion .price-level {
+  color: #8c8c8c;
+  font-size: 13px;
+  line-height: 16px;
+}
+
 .vertical-middle {
   position: relative;
   top: 50%;
diff --git a/src/strings.js b/src/strings.js
index 8327e1f..2580ece 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -50,6 +50,7 @@
       UNKNOWN_ERROR: 'Server error'
     },
     'Final destination': 'Final destination',
+    'Free': 'Free',
     label: function(label, details) {
       return label + ': ' + details;
     },
@@ -81,6 +82,7 @@
       return username + ' is not reachable or is not a Travel Planner user.';
     },
     'Origin': 'Origin',
+    priceLevelUnit: '$',
     'Search': 'Search',
     sendingInvite: function(username) {
       return 'Inviting ' + username + ' to join the trip...';
diff --git a/src/travel.js b/src/travel.js
index 9ddbbdb..8c92b59 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -4,6 +4,7 @@
 
 require('es6-shim');
 
+var hg = require('mercury');
 var queryString = require('query-string');
 var raf = require('raf');
 
@@ -15,6 +16,7 @@
 var MapWidget = require('./components/map-widget');
 var Messages = require('./components/messages');
 var Message = require('./components/message');
+var Suggestions = require('./components/suggestions');
 var Timeline = require('./components/timeline');
 var TimelineClient = require('./components/timeline-client');
 var TimelineService = require('./components/timeline-server');
@@ -45,6 +47,9 @@
 }
 
 var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
+var SUGGESTION_PHOTO_OPTS = {
+  maxHeight: 96
+};
 
 var Travel = defineClass({
   publics: {
@@ -207,7 +212,7 @@
            * out why. */
           self.trap(control.focus)();
 
-          self.map.showSearchResults(results);
+          self.handleSearchResults(results);
         });
 
         if (!destination.hasNext()) {
@@ -520,6 +525,60 @@
       }
     },
 
+    handleSearchResults: function(results) {
+      this.map.showSearchResults(results);
+      if (results.length > 1) {
+        this.hgState.suggestions.set({
+          suggestions: results.map(function(place) {
+            var details = place.getDetails();
+            var photoUrl;
+
+            if (details.photos && details.photos[0]) {
+              photoUrl = details.photos[0].getUrl(SUGGESTION_PHOTO_OPTS);
+            }
+
+            return {
+              placeId: place.toObject().placeId,
+              placeName: place.getName(),
+              photoUrl: photoUrl,
+              iconUrl: details.icon,
+              rating: details.rating,
+              priceLevel: details['price_level']
+            };
+          })
+        });
+        this.showSuggestions();
+      } else {
+        this.dismissSuggestions();
+      }
+    },
+
+    showSuggestions: function() {
+      if (!this.$hgSuggestionsRoot) {
+        this.$hgSuggestionsRoot = $('<div>')
+          .addClass('suggestions-container')
+          .insertBefore(this.map.$);
+        this.dismissHgSuggestions = hg.app(
+          this.$hgSuggestionsRoot[0], this.hgState, this.hgRenderSuggestions);
+        this.map.invalidateSize();
+      }
+      this.$hgSuggestionsRoot.prop('scrollTop', 0);
+    },
+
+    hgRenderSuggestions: function(state) {
+      return Suggestions.render(state.suggestions);
+    },
+
+    dismissSuggestions: function() {
+      if (this.dismissHgSuggestions) {
+        this.dismissHgSuggestions();
+        delete this.dismissHgSuggestions;
+        this.$hgSuggestionsRoot.remove();
+        delete this.$hgSuggestionsRoot;
+        this.map.invalidateSize();
+      }
+    },
+
     /**
      * The map widget isn't very sensitive to size updates, so we need to
      * continuously invalidate during animations.
@@ -542,12 +601,18 @@
     }
   },
 
+  constants: [ 'hgState' ],
+
   init: function (opts) {
     var self = this;
 
     opts = opts || {};
     var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
 
+    this.hgState = hg.state({
+      suggestions: new Suggestions()
+    });
+
     var destinations = this.destinations = new Destinations();
     destinations.onAdd.add(this.handleDestinationAdd);
     destinations.onRemove.add(this.handleDestinationRemove);
@@ -642,7 +707,7 @@
          * place and so must pick a new one. */
         map.disableLocationSelection();
       }
-      map.showSearchResults(results);
+      self.handleSearchResults(results);
     });
 
     miniDestinationSearch.onPlaceChange.add(function(place) {