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