Geolocated panel casting framework.

Change-Id: I489487b56da9606f8e7c0f42fc39cb54f5e046e1
diff --git a/package.json b/package.json
index ef22340..ced45b4 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
     "htmlencode": "^0.0.4",
     "jquery": "^2.1.4",
     "lodash": "^3.10.1",
+    "multimap": "^0.1.1",
     "query-string": "^2.4.0",
     "raf": "^3.1.0",
     "uuid": "^2.0.1"
diff --git a/src/casting-manager.js b/src/casting-manager.js
new file mode 100644
index 0000000..4b861c5
--- /dev/null
+++ b/src/casting-manager.js
@@ -0,0 +1,185 @@
+// 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 $ = require('./util/jquery');
+var defineClass = require('./util/define-class');
+
+var Multimap = require('multimap');
+
+var DeviceSync = require('./sync-util/device-sync');
+
+/**
+ * TODO(rosswang): The future of this helper method is a bit unclear given that
+ * someday we will want to support actual vectors.
+ */
+function getGestureDirection(v) {
+    if (v.y < 0 && -v.y > Math.abs(v.x)) {
+      return DeviceSync.UP;
+    }
+    if (v.y > 0 && v.y > Math.abs(v.x)) {
+      return DeviceSync.DOWN;
+    }
+    if (v.x < 0) {
+      return DeviceSync.LEFT;
+    }
+    if (v.x > 0) {
+      return DeviceSync.RIGHT;
+    }
+}
+
+/**
+ * Multimap union.
+ */
+function union(a, b) {
+  var result = new Multimap();
+  function add(value, key) {
+    if (!result.has(key, value)) {
+      result.set(key, value);
+    }
+  }
+  a.forEach(add);
+  b.forEach(add);
+  return result;
+}
+
+/**
+ * Multimap difference.
+ */
+function difference(u, c) {
+  var result = new Multimap();
+  u.forEach(function(value, key) {
+    if (!c.has(key, value)) {
+      result.set(key, value);
+    }
+  });
+  return result;
+}
+
+var CastingManager = defineClass({
+  publics: {
+    /* TODO(rosswang): For now, let's do two-button click and drag as the
+     * casting gesture. Eventually, we'll want to evaluate and support others.
+     */
+    makeCastable: function($handle, opts) {
+      var self = this;
+
+      var castHandler = {
+        buttons: 0
+      };
+
+      $handle.mousedown(function(e) {
+        castHandler.buttons |= 1 << (e.which - 1);
+        if (castHandler.buttons === 5 || castHandler.buttons === 2) {
+          castHandler.origin = {
+            x: e.pageX,
+            y: e.pageY
+          };
+        }
+      });
+
+      function processMouseUpdate(e) {
+        if (castHandler.buttons === 0 && castHandler.origin) {
+          self.interpretCastVector({
+            x: e.pageX - castHandler.origin.x,
+            y: e.pageY - castHandler.origin.y
+          }, opts.spec);
+          delete castHandler.origin;
+        }
+      }
+
+      $(global.document).mousemove(function(e) {
+        if (e.which === 0) {
+          castHandler.buttons = 0;
+          processMouseUpdate(e);
+        }
+      }).mouseup(function(e) {
+        castHandler.buttons &= ~(1 << (e.which - 1));
+        processMouseUpdate(e);
+      });
+    }
+  },
+
+  privates: {
+    getRelatedDevices: function(direction) {
+      switch(direction) {
+      case DeviceSync.UP:
+        return union(this.travelSync.getRelatedDevices(DeviceSync.UP),
+          this.travelSync.getRelatedDevices(DeviceSync.FORWARDS));
+      case DeviceSync.DOWN:
+        return union(this.travelSync.getRelatedDevices(DeviceSync.DOWN),
+          this.travelSync.getRelatedDevices(DeviceSync.BACKWARDS));
+      default:
+        return this.travelSync.getRelatedDevices(direction);
+      }
+    },
+
+    interpretCastVector: function(v, spec) {
+      var self = this;
+
+      var direction = getGestureDirection(v);
+      var related = this.getRelatedDevices(direction);
+      if (related.size === 1) {
+        related.forEach(function(deviceName, owner) {
+          self.cast(owner, deviceName, spec).catch(self.onError);
+        });
+      } else {
+        var unknown = this.travelSync.getUnconnectedCastTargets();
+
+        if (related.size === 0 && unknown.size === 1) {
+          unknown.forEach(function(deviceName, owner) {
+            Promise.all([
+              self.cast(owner, deviceName, spec),
+              self.travelSync.relateDevice(owner, deviceName, {
+                direction: direction,
+                magnitude: DeviceSync.NEAR
+              })
+            ]).catch(self.onError);
+          });
+        } else {
+          var all = this.travelSync.getPossibleCastTargets();
+          var other = difference(all, related);
+
+          if (related.size > 0 || unknown.size > 0 || other.size > 0) {
+            this.onAmbiguousCast(related, unknown, other);
+          } else {
+            this.onNoNearbyDevices();
+          }
+        }
+      }
+    },
+
+    cast: function(targetOwner, targetDeviceName, spec) {
+      var self = this;
+
+      return this.travelSync.cast(targetOwner, targetDeviceName, spec)
+        .then(function() {
+          self.onCast(spec);
+        }, this.onError);
+    }
+  },
+
+  events: {
+    onCast: '',
+    /**
+     * @param related owner => device multimap of related cast candidates
+     * @param unknown owner => device multimap of unconnected cast candidates
+     * @param other owner => device multimap of unrelated connected cast
+     *  candidates
+     */
+    onAmbiguousCast: '',
+    /**
+     * Triggered when a cast is attempted but there are no known nearby devices.
+     */
+    onNoNearbyDevices: '',
+    onError: 'memory'
+  },
+
+  init: function(travelSync, domRoot) {
+    this.travelSync = travelSync;
+  }
+});
+
+module.exports = CastingManager;
\ No newline at end of file
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index b89c528..6659ad3 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -85,6 +85,10 @@
       this.ensureGeomsVisible(geoms);
     },
 
+    fitBounds: function(bounds) {
+      this.map.fitBounds(bounds);
+    },
+
     ensureVisible: function(place) {
       this.ensureGeomsVisible([place.getGeometry()]);
     },
diff --git a/src/ifc/ops.vdl b/src/ifc/ops.vdl
index 5cab7f3..55dbca2 100644
--- a/src/ifc/ops.vdl
+++ b/src/ifc/ops.vdl
@@ -4,51 +4,7 @@
 
 package ifc
 
-const (
-  Read = "R"
-  Write = "W"
-  Collaborate = "C"
-)
-
-// Stub multicast RPCs to mock Syncbase storage.
-// TODO: allow multiple trips (e.g. multiple planned trips).
-type TravelSync interface {
-  // Gets the current trip.
-  Get() (Trip | error) { Read }
-
-  // Pushes a trip plan to the server instance, optionally with a notification
-  // message (ex. "X has accepted Y's destination proposal.").
-  // To simplify the API, this is the sole API through which the trip plan may
-  // actually be altered.
-  UpdatePlan(plan TripPlan, message string) error { Write }
-
-  // Pushes the current trip status to the server instance, leaving the trip
-  // plan unchanged.
-  UpdateStatus(status TravellerStatus) error { Write }
-
-  // Posts a suggestion to add a destination to the trip plan.
-  SuggestDestinationAddition(
-    destination Destination, at TripStatus, message string) (
-    SuggestionId | error) { Collaborate }
-
-  // Posts a suggestion to add a waypoint to the trip plan.
-  SuggestWaypointAddition(
-    waypoint Waypoint, at TripStatus, message string) (
-    SuggestionId | error) { Collaborate }
-
-  // Posts a suggestion to remove a waypoint or destination from the trip plan.
-  SuggestRemoval(at TripStatus, message string) (
-    SuggestionId | error) { Collaborate }
-
-  // Comments on an existing suggestion.
-  Comment(suggestion SuggestionId, message string) error { Collaborate }
-
-  // Deletes an existing suggestion.
-  DeleteSuggestion(
-    suggestion SuggestionId, message string) error { Collaborate }
-}
-
 type Travel interface {
-  TravelSync
-  // TODO: casting if warranted
+  Cast(spec CastSpec) error
 }
+
diff --git a/src/ifc/types.vdl b/src/ifc/types.vdl
index c8d51c4..9dc1401 100644
--- a/src/ifc/types.vdl
+++ b/src/ifc/types.vdl
@@ -4,46 +4,7 @@
 
 package ifc
 
-type Location complex64
-type Waypoint Location
-type LegId int16
-type WaypointId int16
-type SuggestionId int16
-
-type Destination struct {
-  // TODO; may or may not have an embedded Waypoint
+type CastSpec struct {
+  PanelName string
 }
 
-/*
- * A leg of travel. Each leg has one destination of significance but may be
- * routed through any number of waypoints.
- */
-type Leg struct {
-  Waypoints []Waypoint
-  // Logically, the last waypoint. However, it contains metadata that other
-  // waypoints do not (see Destination struct).
-  Destination Destination
-  // TODO: timeline data
-}
-
-type TripPlan []Leg
-
-type TripStatus struct {
-  CurrentLeg LegId
-  // ID of the next waypoint on the current leg. The leg destination is
-  // considered the last waypoint.
-  NextWaypoint WaypointId
-}
-
-/*
- * Status actually varies by agent/user.
- */
-type TravellerStatus struct {
-  TripStatus TripStatus
-  Location Location
-}
-
-type Trip struct {
-  Plan TripPlan
-  Status map[string]TravellerStatus
-}
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
index f6e4cb7..00db106 100644
--- a/src/invitation-manager.js
+++ b/src/invitation-manager.js
@@ -10,35 +10,13 @@
 var defineClass = require('./util/define-class');
 var debug = require('./debug');
 
-// TODO(rosswang): generalize this
-var ESC = {
-  '_': '_',
-  '.': 'd',
-  '@': 'a'
-};
-
-var INV = {};
-$.each(ESC, function(k, v) {
-  INV[v] = k;
-});
-
-function escapeUsername(str) {
-  return str.replace(/_|\.|@/g, function(m) {
-    return '_' + ESC[m];
-  });
-}
-
-function unescapeUsername(str) {
-  return str.replace(/_(.)/g, function(m, p1) {
-    return INV[p1];
-  });
-}
+var SyncbaseWrapper = require('./vanadium-wrapper/syncbase-wrapper');
 
 function invitationKey(recipient, owner, tripId) {
   return [
     'invitations',
-    escapeUsername(recipient),
-    escapeUsername(owner),
+    SyncbaseWrapper.escapeKeyElement(recipient),
+    SyncbaseWrapper.escapeKeyElement(owner),
     tripId
   ];
 }
@@ -160,7 +138,7 @@
       this.manageTripSyncGroups(data.trips);
 
       var toMe = data.invitations &&
-        data.invitations[escapeUsername(this.username)];
+        data.invitations[SyncbaseWrapper.escapeKeyElement(this.username)];
       if (toMe) {
         $.each(toMe, function(owner, ownerRecords) {
           var ownerInvites = self.invitations[owner];
@@ -176,7 +154,7 @@
               record.seen = true;
             } else {
               if (!uOwner) {
-                uOwner = unescapeUsername(owner);
+                uOwner = SyncbaseWrapper.unescapeKeyElement(owner);
               }
 
               debug.log('Received invite from ' + sender + ' to ' + uOwner +
@@ -233,7 +211,8 @@
 
     sgmPromise.then(function(sgm) {
       sgm.createSyncGroup('invitations',
-          [['invitations', escapeUsername(self.username)]], ['...'])
+          [['invitations', SyncbaseWrapper.escapeKeyElement(self.username)]],
+          ['...'])
         .catch(self.onError);
     });
   }
diff --git a/src/naming.js b/src/naming.js
index cc371c6..02a23ef 100644
--- a/src/naming.js
+++ b/src/naming.js
@@ -18,11 +18,16 @@
   return vanadium.naming.join(appMount(username), deviceName);
 }
 
+function rpcMount(username, deviceName) {
+  return vanadium.naming.join(deviceMount(username, deviceName), 'rpc');
+}
+
 function mountNames(id) {
   return {
     user: userMount(id.username),
     app: appMount(id.username),
-    device: deviceMount(id.username, id.deviceName)
+    device: deviceMount(id.username, id.deviceName),
+    rpc: rpcMount(id.username, id.deviceName)
   };
 }
 
@@ -30,5 +35,6 @@
   userMount: userMount,
   appMount: appMount,
   deviceMount: deviceMount,
+  rpcMount: rpcMount,
   mountNames: mountNames
 };
diff --git a/src/strings.js b/src/strings.js
index 96f726d..6f4ac8d 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -26,6 +26,9 @@
     add: function(object) {
       return 'Add ' + object.toLowerCase();
     },
+    castingTooltip: 'To cast a panel to a nearby device, middle-click and ' +
+      'drag (or left-right-click and drag) the panel towards the target ' +
+      'device.',
     change: function(object) {
       return 'Change ' + object.toLowerCase();
     },
@@ -69,6 +72,7 @@
         sender + ' invited ' + recipient + ' to join the trip.' :
         'Invited ' + recipient + ' to join the trip.';
     },
+    noNearbyDevices: 'Cannot cast: no nearby devices.',
     'Not connected': 'Not connected',
     notReachable: function(username) {
       return username + ' is not reachable or is not a Travel Planner user.';
diff --git a/src/sync-util/deferred-sb-wrapper.js b/src/sync-util/deferred-sb-wrapper.js
index 9d1d105..8c268bc 100644
--- a/src/sync-util/deferred-sb-wrapper.js
+++ b/src/sync-util/deferred-sb-wrapper.js
@@ -28,6 +28,12 @@
       return this.sbPromise.then(function(syncbase) {
         return syncbase.getData();
       });
+    },
+
+    pull: function(prefix) {
+      return this.sbPromise.then(function(syncbase) {
+        return syncbase.pull(prefix);
+      });
     }
   },
 
diff --git a/src/sync-util/device-sync.js b/src/sync-util/device-sync.js
new file mode 100644
index 0000000..da1b06b
--- /dev/null
+++ b/src/sync-util/device-sync.js
@@ -0,0 +1,451 @@
+// 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 $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var _ = require('lodash');
+var Multimap = require('multimap');
+
+var marshalling = require('./marshalling');
+var SyncbaseWrapper = require('../vanadium-wrapper/syncbase-wrapper');
+var escape = SyncbaseWrapper.escapeKeyElement;
+var unescape = SyncbaseWrapper.unescapeKeyElement;
+
+var HEARTBEAT_PERIOD = 2500; //ms
+var DEVICE_SEEN_RECENTLY = 5000; //ms
+
+var LEFT = 'left';
+var RIGHT = 'right';
+var UP = 'up';
+var DOWN = 'down';
+var FORWARDS = 'forwards';
+var BACKWARDS = 'backwards';
+
+var NEAR = 'near';
+var FAR = 'far';
+
+var RE = 6.371e6;
+
+function cartesian(geo) {
+  var lat = geo.latitude * Math.PI / 180;
+  var lng = geo.longitude * Math.PI / 180;
+  var planeFactor = Math.cos(lng);
+
+  return {
+    x: RE * Math.cos(lat) * planeFactor,
+    y: RE * Math.sin(lat) * planeFactor,
+    z: RE * Math.sin(lng)
+  };
+}
+
+function negateVector(vector) {
+  return {
+    x: -vector.x,
+    y: -vector.y,
+    z: -vector.z
+  };
+}
+
+function negateDirection(direction) {
+  switch(direction) {
+  case LEFT:
+    return RIGHT;
+  case RIGHT:
+    return LEFT;
+  case UP:
+    return DOWN;
+  case DOWN:
+    return UP;
+  case FORWARDS:
+    return BACKWARDS;
+  case BACKWARDS:
+    return FORWARDS;
+  default:
+    return {
+      mean: negateVector(direction.mean),
+      margin: direction.margin
+    };
+  }
+}
+
+var DeviceSync = defineClass({
+  statics: {
+    LEFT: LEFT,
+    RIGHT: RIGHT,
+    UP: UP,
+    DOWN: DOWN,
+    FORWARDS: FORWARDS,
+    BACKWARDS: BACKWARDS,
+    NEAR: NEAR,
+    FAR: FAR,
+
+    deviceKey: function(owner, device) {
+      var mutableArgs = _.flattenDeep(arguments);
+      //arguments 0 and 1 are a username and device ID, respectively
+      //if present, arguments 3 and 4 are that way too
+      for (var i = 0; i <= 4; i++) {
+        if (mutableArgs[i] && i !== 2) {
+          mutableArgs[i] = escape(mutableArgs[i]);
+        }
+      }
+      return ['devices'].concat(mutableArgs);
+    },
+
+    negateRelativePosition: function(relativePosition) {
+      return {
+        direction: negateDirection(relativePosition.direction),
+        magnitude: relativePosition.magnitude
+      };
+    }
+  },
+
+  publics: {
+    relate: function(remoteOwner, remoteDevice, relativePosition) {
+      var self = this;
+
+      return this.identityPromise.then(function(identity) {
+        return self.sbw.batch(function(ops) {
+          return Promise.all([
+            self.relateUnidirectional(ops,
+              identity.username, identity.deviceName,
+              remoteOwner, remoteDevice,
+              relativePosition),
+            self.relateUnidirectional(ops,
+              remoteOwner, remoteDevice,
+              identity.username, identity.deviceName,
+              self.negateRelativePosition(relativePosition))]);
+        });
+      });
+    },
+
+    getRelatedDevices: function(direction) {
+      return this.relatedDevices.get(direction) || new Multimap();
+    },
+
+    getUnconnectedCastTargets: function() {
+      return this.unconnectedCastTargets;
+    },
+
+    getPossibleCastTargets: function() {
+      return this.possibleCastTargets;
+    },
+
+    processDevices: function(data) {
+      var self = this;
+      var now = Date.now();
+      var possibleCastTargets = new Multimap();
+      var unconnectedCastTargets = new Multimap();
+      var connections;
+
+      this.identityPromise.then(function(identity) {
+        var hasCastTargets;
+        $.each(data, function(owner, devices) {
+          owner = unescape(owner);
+
+          $.each(devices, function(deviceName, deviceData) {
+            deviceName = unescape(deviceName);
+            if (owner === identity.username &&
+                deviceName === identity.deviceName) {
+              connections = deviceData.connections;
+            } else if (now - deviceData.lastSeen <= DEVICE_SEEN_RECENTLY) {
+              var deviceLocation = marshalling.unmarshal(deviceData.location);
+              var isNearby = self.isNearby(deviceLocation);
+
+              if (isNearby !== false) {
+                hasCastTargets = true;
+                possibleCastTargets.set(owner, deviceName);
+                unconnectedCastTargets.set(owner, deviceName);
+              }
+            }
+          });
+        });
+
+        /* This could just be a multimap of direction => {owner, deviceName},
+         * but to keep the interface consistent we should have the RHS be a
+         * multimap of owner => deviceName. */
+        var relatedDevices = new Map();
+
+        if (connections) {
+          $.each(connections, function(owner, devices) {
+            owner = unescape(owner);
+            $.each(devices, function(deviceName, relPos) {
+              deviceName = unescape(deviceName);
+              if (unconnectedCastTargets.has(owner, deviceName)) {
+                relPos = marshalling.unmarshal(relPos);
+                // TODO(rosswang): handle vector directions
+                var bucket = relatedDevices.get(relPos.direction);
+                if (!bucket) {
+                  relatedDevices.set(relPos.direction, bucket = new Multimap());
+                }
+
+                bucket.set(owner, deviceName);
+                unconnectedCastTargets.delete(owner, deviceName);
+              }
+            });
+          });
+        }
+
+        if (hasCastTargets && !self.hasCastTargets) {
+          self.onPossibleNearbyDevices();
+        }
+
+        self.hasCastTargets = hasCastTargets;
+        self.possibleCastTargets = possibleCastTargets;
+        self.unconnectedCastTargets = unconnectedCastTargets;
+        self.relatedDevices = relatedDevices;
+      }).catch(this.onError);
+    },
+
+    /*
+     * Adds or updates an "extend" screencast with the given control points.
+     * @param remoteName the Vanadium name of the remote RPC server.
+     * @param adjustment normalized adjustment in local coordinates, { x, y }
+     *
+    extendTo: function(remoteName, adjustment) {
+      if (!this.deviceSetId) {
+        this.deviceSetId = uuid.v4();
+      }
+
+      return this.sbw.put(
+        this.deviceSetKey(this.deviceSetId, this.localName, remoteName),
+        marshalling.marshal(adjustment));
+    },
+
+    handleBoundsUpdate: function(newBounds) {
+      var self = this;
+      var deviceSetId = this.deviceSetId;
+      this.localBounds = newBounds;
+
+      if (this.currentBoundsUpdate) {
+        return this.currentBoundsUpdate;
+      } else {
+        if (deviceSetId) {
+          this.currentBoundsUpdate = this.runOrQueueBoundsUpdate()
+            .catch(function(err) {
+              delete self.currentBoundsUpdate;
+              throw err;
+            });
+          return this.currentBoundsUpdate;
+        } else {
+          return Promise.resolve();
+        }
+      }
+    }*/
+  },
+
+  privates: {
+    relateUnidirectional: function(dao, fromOwner, fromDevice,
+        toOwner, toDevice, relativePosition) {
+      return dao.put(this.deviceKey(
+        fromOwner, fromDevice, 'connections', toOwner, toDevice),
+        marshalling.marshal(relativePosition));
+    },
+
+    heartbeat: function() {
+      var self = this;
+      return this.identityPromise.then(function(identity) {
+        return self.sbw.put(
+          self.deviceKey(identity.username, identity.deviceName, 'lastSeen'),
+          Date.now());
+      });
+    },
+
+    updateGeolocation: function(geolocation) {
+      var self = this;
+
+      this.geolocation = geolocation;
+
+      return this.identityPromise.then(function(identity) {
+        return self.sbw.put(
+          self.deviceKey(identity.username, identity.deviceName, 'location'),
+          self.serializeGeolocation(geolocation));
+      });
+    },
+
+    /**
+     * The properties on these objects don't appear to be enumerable, so
+     * serialize them explicitly here.
+     */
+    serializeGeolocation: function(geolocation) {
+      var coords = geolocation.coords;
+      return marshalling.marshal({
+        coords: {
+          accuracy: coords.accuracy,
+          altitude: coords.altitude,
+          altitudeAccuracy: coords.altitudeAccuracy,
+          heading: coords.heading,
+          latitude: coords.latitude,
+          longitude: coords.longitude,
+          speed: coords.speed
+        },
+        timestamp: geolocation.timestamp
+      });
+    },
+
+    /**
+     * The true calculation here would involve the distance between two
+     * cylinders in spherical space... let's just punt on that for now.
+     *
+     * TODO(rosswang): find a library or factor this out.
+     * location-math seems ideal but doesn't seem to contain any code
+     * coordinate-systems may be suitable
+     *
+     * Also, if we keep doing this based on Cartesian coordinates, cache the
+     * current Cartesian location.
+     */
+    isNearby: function(geolocation) {
+      var a = this.geolocation && this.geolocation.coords || {};
+      var b = geolocation && geolocation.coords || {};
+
+      if (typeof a.altitude === 'number' && typeof b.altitude === 'number' &&
+          typeof a.altitudeAccuracy === 'number' &&
+          typeof b.altitudeAccuracy === 'number' &&
+          Math.abs(a.altitude - b.altitude) >
+          a.altitudeAccuracy + b.altitudeAccuracy + 50) {
+        return false;
+      }
+
+      if (typeof a.latitude === 'number' && typeof b.latitude === 'number' &&
+          typeof a.longitude === 'number' && typeof b.longitude === 'number' &&
+          typeof a.accuracy === 'number' && typeof b.accuracy === 'number') {
+        var va = cartesian(a);
+        var vb = cartesian(b);
+
+        var vd = {
+          x: va.x - vb.x,
+          y: va.y - vb.y,
+          z: va.z - vb.z
+        };
+        var tolerance = a.accuracy + b.accuracy + 50;
+
+        return vd.x * vd.x + vd.y * vd.y + vd.z * vd.z <= tolerance * tolerance;
+      }
+
+      //else return undefined
+    }
+
+    /*
+    runOrQueueBoundsUpdate: function() {
+      var self = this;
+      var delay = this.lastBoundsUpdate === undefined? 0 :
+        MIN_UPDATE_PERIOD - (Date.now() - this.lastBoundsUpdate);
+      console.debug(delay);
+      if (delay > 0) {
+        return new Promise(function(resolve, reject) {
+          setTimeout(function() {
+            resolve(self.runCurrentBoundsUpdate());
+          }, delay);
+        });
+      } else {
+        return this.runCurrentBoundsUpdate();
+      }
+    },
+
+    runCurrentBoundsUpdate: function() {
+      var self = this;
+      var deviceSetId = this.deviceSetId;
+      if (deviceSetId) {
+        console.debug('update');
+        this.lastBoundsUpdate = Date.now();
+
+        return this.sbw.pull(this.deviceSetKey(deviceSetId))
+        .then(function(data) {
+          var update = self.updateNeighborsBounds(
+            data.deviceSets[deviceSetId], self.localBounds);
+          delete self.currentBoundsUpdate;
+          return update;
+        });
+      } else {
+        delete this.currentBoundsUpdate;
+        return Promise.resolve();
+      }
+    },
+
+    updateNeighborsBounds: function(deviceSetData, bounds) {
+      var self = this;
+
+      var promises = [];
+
+      var lnEsc = SyncbaseWrapper.escapeKeyElement(this.localName);
+
+      var forwardsNeighbors = deviceSetData[lnEsc];
+      $.each(forwardsNeighbors, function(neighborName, adjustment) {
+        promises.push(self.updateNeighborBounds(
+          SyncbaseWrapper.unescapeKeyElement(neighborName),
+          bounds, marshalling.unmarshal(adjustment)));
+      });
+
+      $.each(deviceSetData, function(neighborName, neighborNeighbors) {
+        if (neighborName !== lnEsc) {
+          var invAdjustment = marshalling.unmarshal(neighborNeighbors[lnEsc]);
+          if (invAdjustment) {
+            var adjustment = {
+              x: -invAdjustment.x,
+              y: -invAdjustment.y
+            };
+            promises.push(self.updateNeighborBounds(
+              SyncbaseWrapper.unescapeKeyElement(neighborName),
+              bounds, adjustment));
+          }
+        }
+      });
+
+      return Promise.all(promises);
+    },
+
+    transform: function(bounds, adjustment) {
+      var sw = bounds.getSouthWest();
+      var ne = bounds.getNorthEast();
+      var span = bounds.toSpan();
+
+      var offset = new this.maps.LatLng(
+        adjustment.y * span.lat(),
+        adjustment.x * span.lng());
+
+      return new this.maps.LatLngBounds(
+        new this.maps.LatLng(sw.lat() + offset.lat(), sw.lng() + offset.lng()),
+        new this.maps.LatLng(ne.lat() + offset.lat(), ne.lng() + offset.lng()));
+    },
+
+    updateNeighborBounds: function(name, bounds, adjustment) {
+      var neighborBounds = this.transform(bounds, adjustment);
+      return this.updateRemoteBounds(name, neighborBounds);
+    },
+
+    handleMouseMove: function(e) {
+    }*/
+  },
+
+  events: {
+    onError: 'memory',
+    /**
+     * Triggered when devices are discovered (possibly) nearby, where none were
+     * present before.
+     */
+    onPossibleNearbyDevices: ''
+  },
+
+  init: function(maps, identityPromise, deferredSyncbaseWrapper) {
+    var self = this;
+
+    this.maps = maps;
+    this.identityPromise = identityPromise;
+    this.sbw = deferredSyncbaseWrapper;
+
+    function heartbeatLoop() {
+      self.heartbeat();
+      setTimeout(heartbeatLoop, HEARTBEAT_PERIOD);
+    }
+    process.nextTick(heartbeatLoop);
+
+    if (global.navigator && global.navigator.geolocation) {
+      global.navigator.geolocation.watchPosition(this.updateGeolocation);
+    }
+  }
+});
+
+module.exports = DeviceSync;
\ No newline at end of file
diff --git a/src/travel.js b/src/travel.js
index 8699200..6985013 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -16,6 +16,7 @@
 var Message = require('./components/message');
 var Timeline = require('./components/timeline');
 
+var CastingManager = require('./casting-manager');
 var Destinations = require('./destinations');
 var Identity = require('./identity');
 var TravelSync = require('./travelsync');
@@ -467,6 +468,9 @@
     });
 
     sync.onError.add(error);
+    sync.onPossibleNearbyDevices.add(function() {
+      self.info(strings.castingTooltip);
+    });
     sync.onMessages.add(function(messages) {
       self.messages.push.apply(self.messages, messages);
     });
@@ -545,6 +549,22 @@
 
     this.initMiniFeedback();
 
+    var castingManager = new CastingManager(sync);
+    castingManager.makeCastable($timelineContainer, {
+      spec: {
+        panelName: 'timeline'
+      }
+    });
+    castingManager.onAmbiguousCast.add(function(related, unknown) {
+      console.debug('ambiguous cast');
+      console.debug(related);
+      console.debug(unknown);
+    });
+    castingManager.onNoNearbyDevices.add(function() {
+      self.error(strings.noNearbyDevices);
+    });
+    castingManager.onError.add(error);
+
     destinations.add();
     miniDestinationSearch.focus();
 
diff --git a/src/travelsync.js b/src/travelsync.js
index 3c00064..cf80718 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -8,11 +8,14 @@
 
 var defineClass = require('./util/define-class');
 
+var naming = require('./naming');
+
 var SyncgroupManager = require('./syncgroup-manager');
 var InvitationManager = require('./invitation-manager');
 
 var DeferredSbWrapper = require('./sync-util/deferred-sb-wrapper');
 var DestinationSync = require('./sync-util/destination-sync');
+var DeviceSync = require('./sync-util/device-sync');
 var MessageSync = require('./sync-util/message-sync');
 var TripManager = require('./sync-util/trip-manager');
 
@@ -57,11 +60,40 @@
 
     joinTripSyncGroup: function(owner, tripId) {
       return this.tripManager.joinTripSyncGroup(owner, tripId);
+    },
+
+    getRelatedDevices: function(direction) {
+      return this.deviceSync.getRelatedDevices(direction);
+    },
+
+    getUnconnectedCastTargets: function() {
+      return this.deviceSync.getUnconnectedCastTargets();
+    },
+
+    getPossibleCastTargets: function() {
+      return this.deviceSync.getPossibleCastTargets();
+    },
+
+    relateDevice: function(owner, device, relativePosition) {
+      return this.deviceSync.relate(owner, device, relativePosition);
+    },
+
+    cast: function(owner, device, spec) {
+      var self = this;
+      return this.clientPromise(naming.rpcMount(owner, device))
+        .then(function(s) {
+          return self.vanadiumWrapperPromise.then(function(vanadiumWrapper) {
+            return s.cast(vanadiumWrapper.context(),
+              new vdlTravel.CastSpec(spec));
+          });
+        });
     }
   },
 
   privates: {
     processUpdates: function(data) {
+      this.deviceSync.processDevices(data.devices);
+
       this.tripManager.processTrips(data.user && data.user.tripMetadata,
         data.trips);
 
@@ -72,14 +104,16 @@
       this.tripManager.setUpstream();
     },
 
+    getRpcEndpoint: function(args) {
+      return vanadium.naming.join(args.mountNames.device, 'rpc');
+    },
+
     serve: function(args) {
       var self = this;
-      var mountNames = args.mountNames;
       var vanadiumWrapper = args.vanadiumWrapper;
 
       this.status.rpc = 'starting';
-      return vanadiumWrapper.server(
-          vanadium.naming.join(mountNames.device, 'rpc'), this.server)
+      return vanadiumWrapper.server(args.mountNames.rpc, this.server)
         .then(function(server) {
           self.status.rpc = 'ready';
           return server;
@@ -126,7 +160,66 @@
           self.status.userSyncGroup = 'failed';
           throw err;
         });
+    },
+
+    handleCast: function(ctx, serverCall, spec) {
+      console.debug('Cast target for ' + spec.panelName);
+    },
+
+    clientPromise: function(endpoint) {
+      var clientPromise = this.clients[endpoint];
+      if (!clientPromise) {
+        clientPromise = this.clients[endpoint] = this.vanadiumWrapperPromise
+          .then(function(vanadiumWrapper) {
+            return vanadiumWrapper.client(endpoint);
+          });
+      }
+
+      return clientPromise;
     }
+
+    /*
+    updateRemoteBounds: function(name, bounds) {
+      var self = this;
+      var client = this.clients[name];
+      if (!client) {
+        client = this.clients[name] = this.prereqs.then(function(args) {
+          return args.vanadiumWrapper.client(name);
+        });
+      }
+
+      var sw = bounds.getSouthWest();
+      var ne = bounds.getNorthEast();
+
+      return client.then(function(s) {
+        return self.prereqs.then(function(args) {
+          var vBounds = new vdlTravel.LatLngBounds({
+            southWest: new vdlTravel.LatLng({
+              lat: sw.lat(),
+              lng: sw.lng()
+            }),
+            northEast: new vdlTravel.LatLng({
+              lat: ne.lat(),
+              lng: ne.lng()
+            })
+          });
+
+          return s.setBounds(args.vanadiumWrapper.context(), vBounds);
+        });
+      });
+    },
+
+    rpcSetBounds: function(vBounds) {
+      var bounds = new this.maps.LatLngBounds(
+        new this.maps.LatLng(vBounds.southWest.lat,
+          vBounds.southWest.lng),
+        new this.maps.LatLng(vBounds.northEast.lat,
+          vBounds.northEast.lng));
+
+      console.debug('in: ' + bounds);
+
+      this.onSetBounds(bounds);
+    }*/
   },
 
   constants: [ 'invitationManager', 'startup', 'status' ],
@@ -145,6 +238,12 @@
     onError: 'memory',
 
     /**
+     * Triggered when devices are discovered (possibly) nearby, where none were
+     * present before.
+     */
+    onPossibleNearbyDevices: '',
+
+    /**
      * @param messages array of {content, timestamp} pair objects.
      */
     onMessages: '',
@@ -164,11 +263,16 @@
     var self = this;
 
     this.syncbaseName = syncbaseName;
+    this.maps = mapsDependencies.maps;
 
     this.tripStatus = {};
     this.status = {};
+    this.clients = {};
 
-    this.server = new vdlTravel.TravelSync();
+    this.server = new vdlTravel.Travel();
+    this.server.cast = function(ctx, serverCall, spec) {
+      self.handleCast(ctx, serverCall, spec);
+    };
     var startRpc = prereqs.then(this.serve);
     var startSyncbase = prereqs.then(this.connectSyncbase);
 
@@ -184,6 +288,9 @@
     var createPrimarySyncGroup = this.startSyncgroupManager
       .then(this.createPrimarySyncGroup);
 
+    this.vanadiumWrapperPromise = prereqs.then(function(args) {
+      return args.vanadiumWrapper;
+    });
     var usernamePromise = prereqs.then(function(args) {
       return args.identity.username;
     });
@@ -196,6 +303,12 @@
 
     this.messageSync.onMessages.add(this.onMessages);
 
+    this.deviceSync = new DeviceSync(mapsDependencies.maps,
+        prereqs.then(function(args) { return args.identity; }),
+        self.sbw);
+    this.deviceSync.onError.add(this.onError);
+    this.deviceSync.onPossibleNearbyDevices.add(this.onPossibleNearbyDevices);
+
     this.startup = Promise.all([
         startRpc,
         startSyncbase,
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index 45b4c0d..076d643 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -89,6 +89,10 @@
         this.runtime.getContext(), name, perms);
     },
 
+    context: function() {
+      return this.runtime.getContext();
+    },
+
     /**
      * @param endpoint Vanadium name
      * @returns a promise resolving to a client or rejecting with an error.
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 972d290..9143d7d 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -9,6 +9,7 @@
 var vanadium = require('vanadium');
 var verror = vanadium.verror;
 
+var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
 var debug = require('../debug');
@@ -75,6 +76,21 @@
 
 var SG_MEMBER_INFO = new syncbase.nosql.SyncGroupMemberInfo();
 
+// TODO(rosswang): generalize this
+// If this is updated, the regex in escapeKeyElement needs updating too.
+var ESC = {
+  '_': '_',
+  '.': 'd',
+  '@': 'a',
+  '/': 's',
+  ':': 'c'
+};
+
+var INV = {};
+$.each(ESC, function(k, v) {
+  INV[v] = k;
+});
+
 var SyncbaseWrapper = defineClass({
   statics: {
     start: function(context, mountName) {
@@ -85,6 +101,18 @@
       return setUp(context, app, db).then(function() {
         return new SyncbaseWrapper(context, db, mountName);
       });
+    },
+
+    escapeKeyElement: function(str) {
+      return str.replace(/_|\.|@|\/|:/g, function(m) {
+        return '_' + ESC[m];
+      });
+    },
+
+    unescapeKeyElement: function(str) {
+      return str.replace(/_(.)/g, function(m, p1) {
+        return INV[p1];
+      });
     }
   },
 
@@ -177,6 +205,75 @@
       return current;
     },
 
+    /**
+     * @see refresh
+     */
+    pull: function(prefix) {
+      var self = this;
+
+      function repull() {
+        return self.pull(prefix);
+      }
+
+      if (this.writes.size) {
+        debug.log('Syncbase: deferring refresh due to writes in progress');
+        return Promise.all(this.writes)
+          .then(repull, repull);
+
+      } else {
+        this.dirty = false;
+
+        return new Promise(function(resolve, reject) {
+          var newData = {};
+          var abort = false;
+
+          var isHeader = true;
+
+          var query = 'select k, v from t';
+          if (prefix) {
+            query += ' where k like "' + joinKey(prefix) + '%"';
+          }
+
+          self.db.exec(self.context, query, function(err) {
+            if (err) {
+              reject(err);
+            } else if (abort) {
+              //no-op
+            } else if (self.dirty) {
+              debug.log('Syncbase: aborting refresh due to writes');
+              resolve(repull()); //try/wait for idle again
+            } else {
+              resolve(newData);
+            }
+          }).on('data', function(row) {
+            if (isHeader) {
+              isHeader = false;
+              return;
+            }
+
+            if (abort) {
+              //no-op
+            } else if (self.dirty) {
+              abort = true;
+              debug.log('Syncbase: aborting refresh due to writes');
+              resolve(repull()); //try/wait for idle again
+              /* TODO(rosswang): can we abort this stream for real? We could
+               * detach this event handler but then we'd just buffer needless
+               * data until garbage collection, wouldn't we? */
+            } else {
+              recursiveSet(newData, row[0], row[1]);
+            }
+          }).on('error', reject);
+        }).catch(function(err) {
+          if (err instanceof verror.InternalError) {
+            console.error(err);
+          } else {
+            throw err;
+          }
+        });
+      }
+    },
+
     syncGroup: function(sgAdmin, name) {
       var self = this;
 
@@ -312,66 +409,6 @@
       k = joinKey(k);
       debug.log('Syncbase: delete ' + k);
       return fn(this.context, syncbase.nosql.rowrange.prefix(k));
-    },
-
-    /**
-     * @see refresh
-     */
-    pull: function() {
-      var self = this;
-
-      if (this.writes.size) {
-        debug.log('Syncbase: deferring refresh due to writes in progress');
-        return Promise.all(this.writes)
-          .then(this.pull, this.pull);
-
-      } else {
-        this.dirty = false;
-
-        return new Promise(function(resolve, reject) {
-          var newData = {};
-          var abort = false;
-
-          var isHeader = true;
-
-          self.db.exec(self.context, 'select k, v from t', function(err) {
-            if (err) {
-              reject(err);
-            } else if (abort) {
-              //no-op
-            } else if (self.dirty) {
-              debug.log('Syncbase: aborting refresh due to writes');
-              resolve(self.pull()); //try/wait for idle again
-            } else {
-              resolve(newData);
-            }
-          }).on('data', function(row) {
-            if (isHeader) {
-              isHeader = false;
-              return;
-            }
-
-            if (abort) {
-              //no-op
-            } else if (self.dirty) {
-              abort = true;
-              debug.log('Syncbase: aborting refresh due to writes');
-              resolve(self.pull()); //try/wait for idle again
-              /* TODO(rosswang): can we abort this stream for real? We could
-               * detach this event handler but then we'd just buffer needless
-               * data until garbage collection, wouldn't we? */
-            } else {
-              recursiveSet(newData, row[0], row[1]);
-            }
-          }).on('error', reject);
-        }).catch(function(err) {
-          if (err instanceof verror.InternalError) {
-            console.error(err);
-          } else {
-            throw err;
-          }
-        });
-      }
     }
   },
 
diff --git a/test/travel.js b/test/travel.js
index 01ee827..71da800 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -30,6 +30,7 @@
  */
 var STABLE_SLA = 2500;
 var SYNC_SLA = MockSyncbaseWrapper.SYNC_SLA;
+var DEVICE_DISCOVERY_SLA = 5000;
 
 function cleanDom() {
   $('body').empty();
@@ -227,6 +228,14 @@
   t.deepEqual(simplifyPlace(p2), simplifyPlace(p1), 'markers synced');
 }
 
+function getMessage(instance, index) {
+  var $messageItems = instance.$domRoot.find('.messages ul').children();
+  if (index < 0) {
+    index = $messageItems.length + index;
+  }
+  return $($messageItems[index]);
+}
+
 test('two devices', function(t) {
   failOnError(t);
 
@@ -238,6 +247,16 @@
     assertSameSingletonMarkers(t, ad1, ad2);
     t.equal(ad2.travel.getActiveTripId(), ad1.travel.getActiveTripId(),
       'trips synced');
+
+    setTimeout(afterDisco, DEVICE_DISCOVERY_SLA);
+  }
+
+  function afterDisco() {
+    t.equal(getMessage(ad2, -1).text(),
+      'To cast a panel to a nearby device, middle-click and drag ' +
+      '(or left-right-click and drag) the panel towards the target device.',
+      'casting prompt');
+
     t.end();
   }
 });
@@ -298,7 +317,7 @@
   timeoutify(Promise.all([
     startWithGeo(t, bd1, 'bob', PLACES.GOLDEN_GATE),
     startWithGeo(t, bd2, 'bob', PLACES.SPACE_NEEDLE)
-  ]), t, afterSync, SYNC_SLA);
+  ]), t, afterSync, DEVICE_DISCOVERY_SLA);
 
   function afterSync() {
     t.equal(bd1.map.markers.size, 1, 'one marker (no sync with Alice)');
@@ -307,14 +326,6 @@
   }
 });
 
-function getMessage(instance, index) {
-  var $messageItems = instance.$domRoot.find('.messages ul').children();
-  if (index < 0) {
-    index = $messageItems.length + index;
-  }
-  return $($messageItems[index]);
-}
-
 function invite(senderInstance, recipientUser) {
   senderInstance.$domRoot.find('.send input')
     .prop('value', '/invite ' + recipientUser + '@foogle.com')
@@ -326,7 +337,7 @@
 
   invite(ad2, 'bob');
 
-  t.equal(getMessage(ad2, 1).text(),
+  t.equal(getMessage(ad2, 2).text(),
     'Inviting bob@foogle.com to join the trip...',
     'local invite message');
 
@@ -374,7 +385,7 @@
   }
 
   function afterInvite2() {
-    $invite = getMessage(bd2, 2);
+    $invite = getMessage(bd2, 3);
     $invite.find('a[name=accept]').click();
 
     setTimeout(afterAccept, UI_SLA);
@@ -389,7 +400,7 @@
   }
 
   function afterAcceptSync() {
-    t.equal(getMessage(bd1, 2).text(),
+    t.equal(getMessage(bd1, 3).text(),
       'alice@foogle.com has invited you to join a trip. (Expired)',
       'user accept message');
 
diff --git a/test/travelsync.js b/test/travelsync.js
deleted file mode 100644
index 7721166..0000000
--- a/test/travelsync.js
+++ /dev/null
@@ -1,14 +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 test = require('tape');
-
-var Deferred = require('vanadium/src/lib/deferred');
-
-var TravelSync = require('../src/travelsync');
-
-test('init', function(t) {
-  t.ok(new TravelSync(new Deferred().promise), 'initializes');
-  t.end();
-});
\ No newline at end of file