Adding navigation and additional destinations
Various UX improvements, including moving the search boxes in response
to a maps UI update
Fixing an edge case, seemingly a bug in the maps API, though I don't
know how on Earth it's occurring, where clicking on a search completion
in autocomplete (rather than a location suggestion) results any input
box under the autocomplete UI gaining focus.
Change-Id: I8946a7baee8b5ec1e1c6a3da64d75003515231f8
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 8e3b5b2..de5cad3 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -2,6 +2,7 @@
var defineClass = require('../src/util/define-class');
var ControlPosition = {
+ LEFT_TOP: 'lt',
TOP_LEFT: 'tl',
TOP_CENTER: 'tc'
};
@@ -65,6 +66,7 @@
init: function(canvas) {
this.controls = {};
+ this.controls[ControlPosition.LEFT_TOP] = new ControlPanel(canvas);
this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
@@ -119,6 +121,8 @@
module.exports = {
ControlPosition: ControlPosition,
+ DirectionsService: function(){},
+ DirectionsStatus: {},
Geocoder: function(){},
InfoWindow: InfoWindow,
LatLng: function(){},
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
index 6f648bc..da818ac 100644
--- a/src/components/destination-info.js
+++ b/src/components/destination-info.js
@@ -3,9 +3,10 @@
/**
* Given a Maps API address_components array, return an array of formatted
- * address lines.
+ * address lines. This code is highly fragile and heaven help the poor soul who
+ * needs to localize it.
*
- * TODO(rosswang): Is this really the best way?
+ * TODO(rosswang): Is this really the best way? We should find a formatter.
*/
function formatAddress(details) {
//some maps API members are lower_underscore
@@ -16,22 +17,56 @@
return [];
}
+ /* If at any point the first line/atom will echo the place name/search query,
+ * leave it out, as it will be the title of the info box anyway. */
+
var parts = addr.split(', ');
- 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;
- }
+ var lines = (function() {
+ switch (parts.length) {
+ case 2:
+ // ex. WA, USA => WA, USA
+ return [parts.join(', ')];
+ case 3:
+ // ex. Seattle, WA, USA => Seattle, WA || WA, USA
+ // (if Seattle was the search query, format as if it were WA, USA)
+ return parts[0] === details.name?
+ [parts[1] + ', ' + parts[2]] : [parts[0] + ', ' + parts[1]];
+ case 4: {
+ /* ex. Amphitheatre Pkwy, Mountain View, CA 94043, USA:
+ *
+ * Amphitheatre Pkwy
+ * Mountain View, CA 94043
+ */
+ return [parts[0], parts[1] + ', ' + parts[2]];
+ }
+ case 5: {
+ /* ex. Fort Mason, 2 Marina Blvd, San Francisco, CA 94123, USA
+ *
+ * Fort Mason
+ * 2 Marina Blvd
+ * San Francisco, CA 94123
+ */
+ return [parts[0], parts[1], parts[2] + ', ' + parts[3]];
+ }
+ case 6: {
+ /* ex. A, Fort Mason, 2 Marina Blvd, San Francisco, CA 94123, USA
+ *
+ * A, Fort Mason
+ * 2 Marina Blvd
+ * San Francisco, CA 94123
+ */
+ return [
+ parts[0] + ', ' + parts[1],
+ parts[2],
+ parts[3] + ', ' + parts[4]
+ ];
+ }
+ default:
+ return parts;
+ }
+ })();
+
+ return lines[0] === details.name? lines.slice(1) : lines;
}
function render(details) {
diff --git a/src/components/destination.js b/src/components/destination.js
index 8071162..a2ca3bc 100644
--- a/src/components/destination.js
+++ b/src/components/destination.js
@@ -39,6 +39,10 @@
},
publics: {
+ focus: function() {
+ this.$.find('input:visible').focus();
+ },
+
setSearchBounds: function(bounds) {
this.searchBox.setBounds(bounds);
},
@@ -60,6 +64,7 @@
},
set: function(placeDesc, updateSearchBox) {
+ var prev = this.place;
var normalized = this.normalizeDestination(placeDesc);
this.setAutocomplete(!normalized);
@@ -68,8 +73,30 @@
this.$searchBox.prop('value', normalized.display);
}
- this.place = normalized && normalized.place;
- this.onSet(normalized);
+ this.place = normalized && normalized;
+ this.onSet(normalized, prev);
+ },
+
+ getNext: function() {
+ return this.next;
+ },
+
+ bindNext: function(next) {
+ if (this.next !== next) {
+ this.next = next;
+ next.bindPrevious(this.ifc);
+ }
+ },
+
+ getPrevious: function() {
+ return this.prev;
+ },
+
+ bindPrevious: function(prev) {
+ if (this.prev !== prev) {
+ this.prev = prev;
+ prev.bindNext(this.ifc);
+ }
}
},
@@ -115,6 +142,7 @@
/**
* fired when the destination has been set to a place, or cleared.
* @param place the new destination, as a normalized place.
+ * @param previous the old destination, as a normalized place.
*/
'onSet'
],
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 852cf2a..866a5ee 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -23,9 +23,17 @@
placeholder = strings.destination(this.destinations.length);
}
- var destination = this.addDestination(placeholder, destinationName);
- this.$.append(destination.$);
+ var destination = new Destination(
+ this.maps, placeholder, destinationName);
+ this.$destContainer.append(destination.$);
this.destinations.push(destination);
+ var prev = this.destinations[this.destinations.length - 2];
+ if (prev) {
+ prev.bindNext(destination);
+ }
+ this.onDestinationAdded(destination);
+
+ return destination;
},
/**
@@ -45,15 +53,6 @@
}
},
- privates: {
- addDestination: function(placeholder, destinationName) {
- var destination = new Destination(this.maps, placeholder,
- destinationName);
- this.onDestinationAdded(destination);
- return destination;
- }
- },
-
events: {
/**
* @param destination Destination instance
@@ -64,8 +63,20 @@
constants: ['$'],
init: function(maps, initial) {
+ var self = this;
+
this.maps = maps;
this.$ = $('<form>').addClass('destinations');
+ this.$destContainer = $('<div>');
+ this.$.append(this.$destContainer);
+
+ $('<div>')
+ .addClass('add-bn')
+ .text('+')
+ .click(function() {
+ self.append().focus();
+ })
+ .appendTo(this.$);
this.destinations = [];
diff --git a/src/components/maps.js b/src/components/map.js
similarity index 78%
rename from src/components/maps.js
rename to src/components/map.js
index 83a4131..ee3a07e 100644
--- a/src/components/maps.js
+++ b/src/components/map.js
@@ -44,7 +44,7 @@
.map(function(dest) { return dest.getPlace(); })
.filter(function(place) { return place; })
.reduce(function(acc, place) {
- acc.push(place.location);
+ acc.push(place.place.location);
return acc;
}, []);
@@ -147,6 +147,62 @@
if (normalizedPlace) {
this.disableLocationSelection();
}
+
+ if (destination.getPrevious()) {
+ this.updateLeg(destination);
+ }
+
+ if (destination.getNext()) {
+ this.updateLeg(destination.getNext());
+ }
+ },
+
+ updateLeg: function(destinationControl) {
+ var widget = this;
+ var maps = this.maps;
+ var map = this.map;
+
+ var origin = destinationControl.getPrevious().getPlace();
+ var destination = destinationControl.getPlace();
+
+ var leg = destinationControl.leg;
+ if (leg) {
+ if (leg.async) {
+ leg.async.reject();
+ }
+ // setMap(null) seems to be the best way to clear the nav route
+ leg.renderer.setMap(null);
+ } else {
+ var renderer = new maps.DirectionsRenderer({
+ preserveViewport: true,
+ suppressMarkers: true
+ });
+ destinationControl.leg = leg = { renderer: renderer };
+ }
+
+ if (origin && destination) {
+ var request = {
+ origin: origin.place.location,
+ destination: destination.place.location,
+ travelMode: maps.TravelMode.DRIVING // TODO(rosswang): user choice
+ };
+
+ leg.async = $.Deferred();
+
+ this.directionsService.route(request, function(result, status) {
+ if (status === maps.DirectionsStatus.OK) {
+ leg.async.resolve(result);
+ } else {
+ widget.onError({ directionsStatus: status });
+ leg.async.reject(status);
+ }
+ });
+
+ leg.async.done(function(route) {
+ leg.renderer.setDirections(route);
+ leg.renderer.setMap(map);
+ });
+ }
},
centerOnCurrentLocation: function() {
@@ -191,8 +247,10 @@
destination.onFocus.add(function() {
widget.selectDestinationControl(destination);
});
- destination.onSearch.add($.proxy(this, 'showDestinationSearchResults'));
- destination.onSet.add($.proxy(this, 'handleDestinationSet', destination));
+ destination.onSearch.add(
+ $.proxy(this, 'showDestinationSearchResults', destination));
+ destination.onSet.add(
+ $.proxy(this, 'handleDestinationSet', destination));
},
enableLocationSelection: function() {
@@ -229,9 +287,17 @@
}
},
- showDestinationSearchResults: function(places) {
+ showDestinationSearchResults: function(destination, places) {
var widget = this;
+ if (destination !== this.selectedDestinationControl) {
+ /* There seems to be a bug where if you click a search suggestion (for
+ * a query, not a resolved location) in autocomplete, the input box
+ * under it gets clicked and focused... I haven't been able to figure
+ * out why. */
+ destination.focus();
+ }
+
this.clearSearchMarkers();
this.closeActiveInfoWindow();
@@ -255,7 +321,7 @@
marker.onClick.add(function() {
var dest = widget.selectedDestinationControl;
- if (dest) {
+ if (dest && dest.marker !== marker) {
widget.associateDestinationMarker(dest, marker);
dest.set(place);
}
@@ -286,6 +352,13 @@
},
constants: ['$', 'maps'],
+ events: {
+ /**
+ * @param error A union with one of the following keys:
+ * directionsStatus
+ */
+ onError: 'memory'
+ },
// https://developers.google.com/maps/documentation/javascript/tutorial
init: function(opts) {
@@ -296,11 +369,11 @@
this.maps = maps;
this.navigator = opts.navigator || global.navigator;
this.geocoder = new maps.Geocoder();
+ this.directionsService = new maps.DirectionsService();
this.$ = $('<div>').addClass('map-canvas');
this.searchMarkers = [];
- this.route = {};
this.initialConfig = {
center: new maps.LatLng(37.4184, -122.0880), //Googleplex
@@ -323,7 +396,7 @@
this.centerOnCurrentLocation();
var controls = map.controls;
- controls[maps.ControlPosition.TOP_LEFT].push(this.destinations.$[0]);
+ controls[maps.ControlPosition.LEFT_TOP].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 70e3946..52b7cae 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -8,6 +8,20 @@
width: 30em;
}
+.add-bn {
+ border-radius: 16px;
+ border: 1px solid #aaa;
+ width: 32px;
+ height: 32px;
+ cursor: pointer;
+ color: #aaa;
+ background-color: white;
+ text-align: center;
+ margin-top: 4px;
+ font-size: 27px;
+ font-weight: lighter;
+}
+
.destination {
width: 100%;
position: relative;
diff --git a/src/strings.js b/src/strings.js
index d4e99fe..5dfc3f9 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -4,8 +4,17 @@
destination: function(n) {
return 'Destination ' + n;
},
+ DirectionsStatus: {
+ NOT_FOUND: 'Location not found',
+ ZERO_RESULTS: 'No route to destination',
+ MAX_WAYPOINTS_EXCEEDED: 'Maximum number of waypoints exceeded',
+ OVER_QUERY_LIMIT: 'Request rate exceeded',
+ REQUEST_DENIED: 'Request denied',
+ UNKNOWN_ERROR: 'Server error'
+ },
'Origin': 'Origin',
- 'Travel Planner': 'Travel Planner'
+ 'Travel Planner': 'Travel Planner',
+ 'Unknown error': 'Unknown error'
};
}
diff --git a/src/travel.js b/src/travel.js
index 25e2ad4..a4e6fcd 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -5,18 +5,28 @@
var defineClass = require('./util/define-class');
-var Maps = require('./components/maps');
+var Map = require('./components/map');
var TravelSync = require('./travelsync');
var Identity = require('./identity');
+var strings = require('./strings').currentLocale;
+
+function buildStatusErrorStringMap(statusClass, stringGroup) {
+ var dict = {};
+ $.each(statusClass, function(name, value) {
+ dict[value] = stringGroup[name];
+ });
+ return dict;
+}
+
var Travel = defineClass({
publics: {
error: function (err) {
- this.maps.message(message.error(err.toString()));
+ this.map.message(message.error(err.toString()));
},
info: function (info) {
- this.maps.message(message.info(info));
+ this.map.message(message.info(info));
}
},
@@ -38,9 +48,20 @@
travel.sync.start(identity.mountName, wrapper).catch(reportError);
}, reportError);
- this.maps = new Maps(opts);
+ this.map = new Map(opts);
+
+ var directionsServiceStatusStrings = buildStatusErrorStringMap(
+ this.map.maps.DirectionsStatus, strings.DirectionsStatus);
+
+ this.map.onError.add(function(err) {
+ var message = directionsServiceStatusStrings[err.directionsStatus] ||
+ strings['Unknown error'];
+
+ reportError(message);
+ });
+
var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
- $domRoot.append(travel.maps.$);
+ $domRoot.append(travel.map.$);
}
});
diff --git a/test/components/maps.js b/test/components/map.js
similarity index 78%
rename from test/components/maps.js
rename to test/components/map.js
index c527473..6b32bc8 100644
--- a/test/components/maps.js
+++ b/test/components/map.js
@@ -2,21 +2,21 @@
var $ = require('../../src/util/jquery');
-var Maps = require('../../src/components/maps');
+var Map = require('../../src/components/map');
var message = require ('../../src/components/message');
var mockMaps = require('../../mocks/google-maps');
test('message display', function(t) {
- var maps = new Maps({
+ var map = new Map({
maps: mockMaps
});
- var $messages = $('.messages', maps.$);
+ var $messages = $('.messages', map.$);
t.ok($messages.length, 'message display exists');
t.equals($messages.children().length, 0, 'message display is empty');
- maps.message(message.info('Test message.'));
+ map.message(message.info('Test message.'));
var $messageItem = $messages.children();
t.equals($messageItem.length, 1, 'message display shows 1 message');