Refactors to allow UX streamlining
Factoring out destinations component
Refactoring destination controls to allow decoupling from maps
Factoring out message UI to top level
Using string keys for non-camel-case API members per convention mentioned by
nlacasse@
Change-Id: I634adade0bf4b32d45768201aefc054d07d6ee58
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index bdb8674..45e07ac 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -41,6 +41,8 @@
var Map = defineClass({
publics: {
+ getBounds: function(){},
+
registerInfoWindow: function(wnd) {
this.infoWindows.push(wnd);
},
@@ -61,11 +63,8 @@
constants: [ 'controls' ],
events: {
- //some maps API members are lower_underscore
- /* jshint camelcase: false */
- bounds_changed: 'public',
+ 'bounds_changed': 'public',
click: 'public'
- /* jshint camelcase: true */
},
init: function(canvas) {
@@ -98,6 +97,8 @@
return this.map;
},
+ setTitle: function(){},
+
toString: function() { return 'mock Marker'; }
},
@@ -112,14 +113,12 @@
var SearchBox = defineClass({
publics: {
+ setBounds: function(){},
toString: function() { return 'mock SearchBox'; }
},
events: {
- //some maps API members are lower_underscore
- /* jshint camelcase: false */
- places_changed: 'public'
- /* jshint camelcase: true */
+ 'places_changed': 'public'
}
});
diff --git a/src/components/destination-control.js b/src/components/destination-control.js
new file mode 100644
index 0000000..797143a
--- /dev/null
+++ b/src/components/destination-control.js
@@ -0,0 +1,184 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var strings = require('../strings').currentLocale;
+
+var DestinationControl = defineClass({
+ publics: {
+ focus: function() {
+ this.$.find('input:visible').focus();
+ },
+
+ hasFocus: function() {
+ return this.$.find(':focus').length > 0;
+ },
+
+ setSearchBounds: function(bounds) {
+ this.searchBox.setBounds(bounds);
+ },
+
+ selectControl: function() {
+ if (this.destination) {
+ this.destination.select();
+ }
+ },
+
+ deselectControl: function() {
+ if (this.destination) {
+ this.destination.deselect();
+ }
+ },
+
+ bindDestination: function(destination) {
+ if (this.destination) {
+ this.destination.onPlaceChange.remove(this.handlePlaceChange);
+ this.destination.onSelect.remove(this.handleSelect);
+ this.destination.onDeselect.remove(this.handleDeselect);
+ this.destination.onOrdinalChange.remove(this.updateOrdinal);
+ }
+
+ this.destination = destination;
+
+ if (destination) {
+ destination.onPlaceChange.add(this.handlePlaceChange);
+ destination.onSelect.add(this.handleSelect);
+ destination.onDeselect.add(this.handleDeselect);
+ destination.onOrdinalChange.add(this.updateOrdinal);
+ }
+
+ this.updateOrdinal();
+ this.handlePlaceChange(destination && destination.getPlace());
+ if (destination && destination.isSelected()) {
+ this.handleSelect();
+ } else {
+ this.handleDeselect();
+ }
+ }
+ },
+
+ privates: {
+ handlePlaceChange: function(place) {
+ this.setAutocomplete(!place);
+
+ var newValue;
+ if (place) {
+ newValue = place.getSingleLine();
+ } else if (!this.hasFocus()) {
+ newValue = '';
+ }
+ if (newValue !== undefined) {
+ this.$searchBox.prop('value', newValue);
+ }
+ },
+
+ updateOrdinal: function() {
+ var placeholder;
+ var destination = this.destination;
+ if (destination) {
+ if (!destination.hasPrevious()) {
+ placeholder = strings['Origin'];
+ } else if (destination.getIndex() === 1 && !destination.hasNext()) {
+ placeholder = strings['Destination'];
+ } else {
+ placeholder = strings.destination(destination.getIndex());
+ }
+ }
+
+ this.$searchBox.attr('placeholder', placeholder);
+ },
+
+ handleSelect: function() {
+ this.$.addClass('selected');
+ },
+
+ handleDeselect: function() {
+ this.$.removeClass('selected');
+ },
+
+ /**
+ * This is a bit of a hack; Maps API does not include functionality to
+ * disable autocomplete.
+ */
+ setAutocomplete: function(autocomplete) {
+ /* True boolean comparison. We could coerce the input to boolean, but
+ * this is less impactful. */
+ /* jshint eqeqeq: false */
+ if (this.autocomplete != autocomplete) {
+ /* jshint eqeqeq: true */
+ this.autocomplete = autocomplete;
+
+ var oldBox = this.$searchBox[autocomplete? 1 : 0];
+ var newBox = this.$searchBox[autocomplete? 0 : 1];
+
+ newBox.value = oldBox.value;
+ var active = global.document &&
+ global.document.activeElement === oldBox;
+ if (newBox.setSelectionRange) {
+ //non-universal browser support
+ newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
+ }
+
+ if (autocomplete) {
+ this.$.addClass('autocomplete');
+ } else {
+ this.$.removeClass('autocomplete');
+ }
+
+ if (active) {
+ $(newBox).focus();
+ }
+ }
+ }
+ },
+
+ events: [
+ /**
+ * @param event jQuery Event object for text box focus event.
+ */
+ 'onFocus',
+ /**
+ * @param places (array of places)
+ */
+ 'onSearch'
+ ],
+
+ constants: ['$'],
+
+ init: function(maps) {
+ var self = this;
+
+ var $searchBox = $.merge($('<input>'), $('<input>'))
+ .attr('type', 'text')
+ //to make dummy box consistent with search
+ .attr('autocomplete', 'off');
+ this.$searchBox = $searchBox;
+
+ $searchBox[0].className = 'autocomplete';
+
+ $searchBox.focus(this.onFocus);
+ $searchBox.on('input', function() {
+ if (self.destination) {
+ self.destination.setPlace(null);
+ }
+ });
+
+ this.$ = $('<div>')
+ .addClass('destination')
+ .addClass('autocomplete')
+ .append($searchBox);
+
+ this.searchBox = new maps.places.SearchBox($searchBox[0]);
+
+ this.autocomplete = true;
+
+ maps.event.addListener(this.searchBox, 'places_changed', function() {
+ self.onSearch(self.searchBox.getPlaces());
+ });
+ }
+});
+
+module.exports = DestinationControl;
\ No newline at end of file
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
index 8f83a1c..e5a4634 100644
--- a/src/components/destination-info.js
+++ b/src/components/destination-info.js
@@ -5,86 +5,18 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-/**
- * Given a Maps API address_components array, return an array of formatted
- * 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? We should find a formatter.
- */
-function formatAddress(details) {
- //some maps API members are lower_underscore
- /* jshint camelcase: false */
- var addr = details && details.formatted_address;
- /* jshint camelcase: true */
- if (!addr) {
- return [];
- }
-
- /* 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(', ');
- 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) {
+function render(place) {
var $info = $('<div>').addClass('destination-info');
- if (details && details.name) {
- $info.append($('<div>')
- .addClass('title')
- .text(details.name));
- }
+ if (place) {
+ var details = place.getDetails();
+ if (details && details.name) {
+ $info.append($('<div>')
+ .addClass('title')
+ .text(details.name));
+ }
- var addressLines = formatAddress(details);
- if (addressLines) {
- $.each(addressLines,
+ $.each(place.getMultiLine(),
function(i, line) {
$info.append($('<div>')
.addClass('address-line')
@@ -105,18 +37,18 @@
this.infoWindow.open(this.map, marker);
},
- setDetails: function(details) {
- this.infoWindow.setContent(render(details));
- this.infoWindow.setPosition(details && details.geometry.location);
+ setPlace: function(place) {
+ this.infoWindow.setContent(render(place));
+ this.infoWindow.setPosition(place && place.getLocation());
}
},
- init: function(maps, map, details) {
+ init: function(maps, map, place) {
this.map = map;
this.infoWindow = new maps.InfoWindow({
- content: render(details),
- position: details && details.geometry.location
+ content: render(place),
+ position: place && place.getLocation()
});
}
});
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index 4def0c4..bba109c 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -4,45 +4,34 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
+var strings = require('../strings').currentLocale;
-function markerIcon(color) {
- return 'http://maps.google.com/mapfiles/ms/icons/' + color + '-dot.png';
-}
-
-function deriveTitle(normalizedPlace) {
- return normalizedPlace.details && normalizedPlace.details.name ||
- //some maps API members are lower_underscore
- /* jshint camelcase: false */
- normalizedPlace.formatted_address;
- /* jshint camelcase: true */
+function markerIcon(opts) {
+ if (opts.icon) {
+ return 'http://chart.apis.google.com/chart?chst=d_map_pin_icon&chld=' +
+ opts.icon + '|' + opts.color;
+ } else {
+ return 'http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=' +
+ (opts.label || '•') + '|' + opts.color;
+ }
}
var DestinationMarker = defineClass({
statics: {
color: {
- RED: 'red',
- ORANGE: 'orange',
- YELLOW: 'yellow',
- GREEN: 'green',
- LIGHT_BLUE: 'ltblue',
- BLUE: 'blue',
- PURPLE: 'purple',
- PINK: 'pink'
- }
- },
-
- privates: {
- refreshClickability: function() {
- this.marker.setClickable(this.onClick.has());
+ RED: 'FC6355',
+ ORANGE: 'FF8000', //TODO(rosswang): tune
+ YELLOW: 'FFFF00', //TODO(rosswang): tune
+ GREEN: '00E73D',
+ LIGHT_BLUE: '8080FF', //TODO(rosswang): tune
+ BLUE: '7090FC', // originally '5781FC',
+ PURPLE: '8000FF', //TODO(rosswang): tune
+ PINK: 'FF8080' //TODO(rosswang): tune
},
- topClient: function() {
- return this.clients[this.clients.length - 1];
- },
-
- updateColor: function() {
- var color = this.topClient().color;
- this.marker.setIcon(markerIcon(color));
+ icon: {
+ ORIGIN: 'home',
+ DESTINATION: 'flag'
}
},
@@ -52,9 +41,13 @@
},
pushClient: function(client, color) {
- color = color || this.topClient().color;
- this.clients.push({ client: client, color: color, listeners: [] });
- this.updateColor();
+ this.clients.push($.extend({}, this.topClient(), {
+ client: client,
+ color: color,
+ listeners: []
+ }));
+ this.updateIcon();
+ this.updateTitle();
},
removeClient: function(client) {
@@ -74,18 +67,63 @@
if (!this.clients.length) {
this.clear();
} else {
- this.updateColor();
+ this.updateIcon();
+ this.updateTitle();
}
},
setColor: function(color) {
this.topClient().color = color;
- this.updateColor();
+ this.updateIcon();
+ },
+
+ setDestinationLabel: function(destinationLabel) {
+ this.topClient().destinationLabel = destinationLabel;
+ this.updateTitle();
+ },
+
+ setLabel: function(label) {
+ var client = this.topClient();
+ client.label = label;
+ client.icon = null;
+ this.updateIcon();
+ },
+
+ setIcon: function(icon) {
+ var client = this.topClient();
+ client.icon = icon;
+ client.label = null;
+ this.updateIcon();
+ }
+ },
+
+ privates: {
+ refreshClickability: function() {
+ this.marker.setClickable(this.onClick.has());
+ },
+
+ topClient: function() {
+ return this.clients[this.clients.length - 1];
+ },
+
+ getIcon: function() {
+ return markerIcon(this.topClient());
+ },
+
+ updateIcon: function() {
+ this.marker.setIcon(this.getIcon());
+ },
+
+ updateTitle: function() {
+ var destLabel = this.topClient().destinationLabel;
+ this.marker.setTitle(destLabel?
+ strings.label(this.topClient().destinationLabel, this.title) :
+ this.title);
}
},
events: [ 'onClick' ],
- constants: [ 'marker', 'normalizedPlace' ],
+ constants: [ 'marker', 'place' ],
/**
* A note on clients: destination markers can be shared between multiple use
@@ -97,18 +135,23 @@
* when the client is removed the corresponding click handlers are removed as
* well.
*/
- init: function(maps, map, normalizedPlace, client, color) {
+ init: function(maps, map, place, client, color) {
var self = this;
this.map = map;
- this.normalizedPlace = normalizedPlace;
+ this.place = place;
this.clients = [{ client: client, color: color, listeners: [] }];
+ this.icon = null;
+ this.label = '';
+
+ this.title = place.getName();
+
this.marker = new maps.Marker({
- icon: markerIcon(color),
+ icon: this.getIcon(),
map: map,
- place: normalizedPlace.place,
- title: deriveTitle(normalizedPlace),
+ place: place.getPlaceObject(),
+ title: this.title,
clickable: false
});
@@ -130,7 +173,7 @@
self.refreshClickability();
});
- maps.event.addListener(this.marker, 'click', $.proxy(this, 'onClick'));
+ maps.event.addListener(this.marker, 'click', this.onClick);
}
});
diff --git a/src/components/destination.js b/src/components/destination.js
deleted file mode 100644
index 13b1a3b..0000000
--- a/src/components/destination.js
+++ /dev/null
@@ -1,193 +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 $ = require('../util/jquery');
-var defineClass = require('../util/define-class');
-
-var Destination = defineClass({
- statics: {
- normalizeDestination: function(desc) {
- if (!desc) {
- return null;
-
- } else if (desc.geometry) {
- var place = { location: desc.geometry.location };
-
- //some maps API members are lower_underscore
- /* jshint camelcase: false */
- if (desc.place_id !== undefined) {
- place.placeId = desc.place_id;
- } else {
- place.query = desc.formatted_address;
- }
-
- var display = desc.name &&
- desc.name !== desc.formatted_address.split(', ')[0]?
- desc.name + ', ' + desc.formatted_address : desc.formatted_address;
- /* jshint camelcase: true */
-
- return {
- place: place,
- details: desc,
- display: display
- };
-
- } else {
- return {
- place: desc,
- display : desc.query || desc.location.toString()
- };
- }
- }
- },
-
- publics: {
- focus: function() {
- this.$.find('input:visible').focus();
- },
-
- setSearchBounds: function(bounds) {
- this.searchBox.setBounds(bounds);
- },
-
- setPlaceholder: function(placeholder) {
- this.$searchBox.attr('placeholder', placeholder);
- },
-
- selectControl: function() {
- this.$.addClass('selected');
- },
-
- deselectControl: function() {
- this.$.removeClass('selected');
- },
-
- getPlace: function() {
- return this.place;
- },
-
- set: function(placeDesc, updateSearchBox) {
- var prev = this.place;
- var normalized = this.normalizeDestination(placeDesc);
-
- this.setAutocomplete(!normalized);
-
- if (normalized && updateSearchBox !== false) {
- this.$searchBox.prop('value', normalized.display);
- }
-
- 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);
- }
- }
- },
-
- privates: {
- /**
- * This is a bit of a hack; Maps API does not include functionality to
- * disable autocomplete.
- */
- setAutocomplete: function(autocomplete) {
- if (this.autocomplete !== autocomplete) {
- this.autocomplete = autocomplete;
-
- var oldBox = this.$searchBox[autocomplete? 1 : 0],
- newBox = this.$searchBox[autocomplete? 0 : 1];
-
- newBox.value = oldBox.value;
- var active = global.document &&
- global.document.activeElement === oldBox;
- newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
-
- if (autocomplete) {
- this.$.addClass('autocomplete');
- } else {
- this.$.removeClass('autocomplete');
- }
-
- if (active) {
- $(newBox).focus();
- }
- }
- }
- },
-
- events: [
- /**
- * @param event jQuery Event object for text box focus event.
- */
- 'onFocus',
- /**
- * @param places (array of places)
- */
- 'onSearch',
- /**
- * 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'
- ],
-
- constants: ['$'],
-
- init: function(maps, placeholder, initial) {
- var destination = this;
-
- var $searchBox = $.merge($('<input>'), $('<input>'))
- .attr('type', 'text')
- //to make dummy box consistent with search
- .attr('autocomplete', 'off');
- this.$searchBox = $searchBox;
-
- $searchBox[0].className = 'autocomplete';
-
- this.setPlaceholder(placeholder);
-
- if (initial) {
- $searchBox.prop('value', initial);
- }
-
- $searchBox.focus(this.onFocus);
- $searchBox.on('input', function() {
- destination.set(null, false);
- });
-
- this.$ = $('<div>')
- .addClass('destination')
- .addClass('autocomplete')
- .append($searchBox);
-
- this.searchBox = new maps.places.SearchBox($searchBox[0]);
-
- this.autocomplete = true;
-
- maps.event.addListener(this.searchBox, 'places_changed', function() {
- destination.onSearch(destination.searchBox.getPlaces());
- });
- }
-});
-
-module.exports = Destination;
\ No newline at end of file
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 3e677b9..a1ca01d 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -5,68 +5,25 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var strings = require('../strings').currentLocale;
-
-var Destination = require('./destination');
+var DestinationControl = require('./destination-control');
var Destinations = defineClass({
publics: {
- append: function(destinationName) {
- var placeholder;
- switch (this.destinations.length) {
- case 0:
- placeholder = strings['Origin'];
- break;
- case 1:
- placeholder = strings['Destination'];
- break;
- case 2:
- this.destinations[1].setPlaceholder(strings.destination(1));
- /* falls through */
- default:
- placeholder = strings.destination(this.destinations.length);
- }
+ append: function() {
+ var controls = this.controls;
- 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);
+ var destinationControl = new DestinationControl(this.maps);
+ this.$destContainer.append(destinationControl.$);
+ controls.push(destinationControl);
- return destination;
- },
-
- /**
- * @param handler callback receiving a <code>Destination</code> instance
- * each time a <code>Destination</code> is added. On initial add, the
- * callback is called with all current <code>Destination</code>s.
- */
- addDestinationBindingHandler: function(handler) {
- this.onDestinationAdded.add(handler);
- $.each(this.destinations, function(i, destination) {
- handler(destination);
- });
- },
-
- getDestinations: function() {
- return this.destinations.slice(0);
+ return destinationControl;
}
},
- events: {
- /**
- * @param destination Destination instance
- */
- onDestinationAdded: 'private'
- },
+ constants: [ '$' ],
+ events: [ 'onAddClick' ],
- constants: ['$'],
-
- init: function(maps, initial) {
+ init: function(maps) {
var self = this;
this.maps = maps;
@@ -78,17 +35,11 @@
.addClass('add-bn')
.text('+')
.click(function() {
- self.append().focus();
+ self.onAddClick();
})
.appendTo(this.$);
- this.destinations = [];
-
- initial = initial || [];
-
- for (var i = 0; i < Math.max(initial.length, 2); i++) {
- this.append(initial[i]);
- }
+ this.controls = [];
}
});
diff --git a/src/components/map.js b/src/components/map.js
index b1f6444..f28f0e6 100644
--- a/src/components/map.js
+++ b/src/components/map.js
@@ -5,18 +5,53 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var Destinations = require('./destinations');
+var Destination = require('../destination');
+var Place = require('../place');
var DestinationInfo = require('./destination-info');
var DestinationMarker = require('./destination-marker');
-var Messages = require('./messages');
-var normalizeDestination = require('./destination').normalizeDestination;
+var strings = require('../strings').currentLocale;
//named destination marker clients
var SEARCH_CLIENT = 'search';
-var Widget = defineClass({
+var Map = defineClass({
publics: {
+ getBounds: function() {
+ return this.map.getBounds();
+ },
+
+ addControls: function(controlPosition, $controls) {
+ var controls = this.map.controls[controlPosition];
+ $controls.each(function() {
+ controls.push(this);
+ });
+ },
+
+ addDestination: function() {
+ var self = this;
+
+ var destination = new Destination();
+ if (!this.origin) {
+ this.finalDestination = this.origin = destination;
+ } else {
+ this.finalDestination.bindNext(destination);
+ this.finalDestination = destination;
+ }
+
+ destination.onPlaceChange.add(function(place) {
+ self.handleDestinationPlaceChange(destination, place);
+ });
+ destination.onDeselect.add(function() {
+ self.handleDestinationDeselect(destination);
+ });
+ destination.onSelect.add(function() {
+ self.handleDestinationSelect(destination);
+ });
+
+ return destination;
+ },
+
clearSearchMarkers: function() {
$.each(this.searchMarkers, function() {
this.removeClient(SEARCH_CLIENT);
@@ -30,146 +65,212 @@
}
},
- deselectDestinationControl: function(closeInfoWindow) {
- if (this.selectedDestinationControl) {
- this.selectedDestinationControl.deselectControl();
- this.selectedDestinationControl = null;
- this.disableLocationSelection();
- this.clearSearchMarkers();
+ deselectDestination: function() {
+ if (this.selectedDestination) {
+ this.selectedDestination.deselect();
+ }
+ },
- if (closeInfoWindow !== false) {
- this.closeActiveInfoWindow();
+ fitAll: function() {
+ var geoms = [];
+ var dest = this.origin;
+
+ function addToGeoms() {
+ geoms.push({ location: this });
+ }
+
+ while (dest) {
+ if (dest.hasPlace()) {
+ if (dest.leg && dest.leg.sync) {
+ $.each(dest.leg.sync.routes[0]['overview_path'], addToGeoms);
+ }
+ geoms.push(dest.getPlace().getGeometry());
}
+ dest = dest.getNext();
}
+
+ this.ensureGeomsVisible(geoms);
},
- fitAllDestinations: function() {
- var points = this.destinations.getDestinations()
- .map(function(dest) { return dest.getPlace(); })
- .filter(function(place) { return place; })
- .reduce(function(acc, place) {
- acc.push(place.place.location);
- return acc;
- }, []);
-
- var curBounds = this.map.getBounds();
- if (points.every(function(point) { return curBounds.contains(point); })) {
- return;
- }
-
- if (points.length === 1) {
- this.map.panTo(points[0]);
- } else if (points.length > 1) {
- this.map.fitBounds(points.reduce(function(acc, point) {
- acc.extend(point);
- return acc;
- }, new this.maps.LatLngBounds()));
- }
+ ensureVisible: function(place) {
+ this.ensureGeomsVisible([place.getGeometry()]);
},
- message: function(message) {
- this.messages.push(message);
+ showSearchResults: function(results) {
+ var self = this;
+
+ this.clearSearchMarkers();
+ this.closeActiveInfoWindow();
+
+ this.fitGeoms(results.map(function(result) {
+ return result.geometry;
+ }));
+
+ if (results.length === 1) {
+ var place = new Place(results[0]);
+ /* It would be nice if we could distinguish between an autocomplete
+ * click and a normal search so that we don't overwrite the search box
+ * text for the autocomplete click.*/
+ var dest = this.selectedDestination;
+ if (dest) {
+ dest.setPlace(place);
+ self.createDestinationMarker(dest);
+ }
+ } else if (results.length > 1) {
+ $.each(results, function(i, result) {
+ var place = new Place(result);
+
+ var marker = self.createMarker(place, SEARCH_CLIENT,
+ DestinationMarker.color.RED);
+ self.searchMarkers.push(marker);
+
+ marker.onClick.add(function() {
+ var dest = self.selectedDestination;
+ if (dest) {
+ dest.setPlace(place);
+ self.associateDestinationMarker(dest, marker);
+ }
+ });
+ });
+ }
}
},
privates: {
- createMarker: function(normalizedPlace, client, color) {
- var marker = new DestinationMarker(this.maps, this.map, normalizedPlace,
+ createMarker: function(place, client, color) {
+ var self = this;
+
+ var marker = new DestinationMarker(this.maps, this.map, place,
client, color);
- if (normalizedPlace.details) {
- marker.onClick.add($.proxy(this, 'showDestinationInfo', marker), true);
+ if (place.hasDetails()) {
+ marker.onClick.add(function() {
+ self.showDestinationInfo(marker);
+ }, true);
}
return marker;
},
- createDestinationMarker: function(normalizedPlace, destinationControl) {
- var widget = this;
+ createDestinationMarker: function(destination) {
+ var marker = this.createMarker(destination.getPlace(), destination,
+ this.getAppropriateDestinationMarkerColor(destination));
- var marker = this.createMarker(normalizedPlace, destinationControl,
- this.getAppropriateDestinationMarkerColor(destinationControl));
- destinationControl.marker = marker;
-
- marker.onClick.add(function() {
- widget.selectDestinationControl(destinationControl, false);
- });
+ this.bindDestinationMarker(destination, marker);
return marker;
},
+ associateDestinationMarker: function(destination, marker) {
+ if (!marker.onClick.has(destination.select)) {
+ marker.pushClient(destination,
+ this.getAppropriateDestinationMarkerColor(destination));
+
+ this.bindDestinationMarker(destination, marker);
+ }
+ },
+
+ bindDestinationMarker: function(destination, marker) {
+ var self = this;
+
+ marker.onClick.add(destination.select);
+ function handleSelection() {
+ marker.setColor(self.getAppropriateDestinationMarkerColor(destination));
+ }
+ destination.onSelect.add(handleSelection);
+ destination.onDeselect.add(handleSelection);
+
+ function handleOrdinalChange() {
+ var destLabel;
+ if (!destination.hasPrevious()) {
+ destLabel = strings['Origin'];
+ marker.setIcon(DestinationMarker.icon.ORIGIN);
+ } else if (!destination.hasNext()) {
+ destLabel = strings[destination.getIndex() === 1?
+ 'Destination' : 'Final destination'];
+ marker.setIcon(DestinationMarker.icon.DESTINATION);
+ } else {
+ destLabel = strings.destination(destination.getIndex());
+ marker.setLabel(destination.getIndex());
+ }
+
+ marker.setDestinationLabel(destLabel);
+ }
+
+ destination.onOrdinalChange.add(handleOrdinalChange);
+ handleOrdinalChange();
+
+ function handlePlaceChange() {
+ marker.removeClient(destination);
+ marker.onClick.remove(destination.select);
+ destination.onSelect.remove(handleSelection);
+ destination.onDeselect.remove(handleSelection);
+ destination.onOrdinalChange.remove(handleOrdinalChange);
+ destination.onPlaceChange.remove(handlePlaceChange);
+ }
+
+ destination.onPlaceChange.add(handlePlaceChange);
+ },
+
+ getAppropriateDestinationMarkerColor: function(destination) {
+ return destination.isSelected()?
+ DestinationMarker.color.GREEN : DestinationMarker.color.BLUE;
+ },
+
showDestinationInfo: function(destinationMarker) {
if (!this.info) {
this.info = new DestinationInfo(
- this.maps, this.map, destinationMarker.normalizedPlace.details);
+ this.maps, this.map, destinationMarker.place);
} else {
- this.info.setDetails(destinationMarker.normalizedPlace.details);
+ this.info.setPlace(destinationMarker.place);
}
this.info.show(destinationMarker.marker);
},
- getAppropriateDestinationMarkerColor: function(destination) {
- return destination === this.selectedDestinationControl?
- DestinationMarker.color.GREEN : DestinationMarker.color.BLUE;
- },
-
- associateDestinationMarker: function(destination, marker) {
- if (destination.marker === marker) {
- return;
- }
-
- var widget = this;
-
- if (destination.marker) {
- destination.marker.removeClient(destination);
- }
-
- destination.marker = marker;
-
- if (marker) {
- marker.pushClient(destination,
- this.getAppropriateDestinationMarkerColor(destination));
- marker.onClick.add(function() {
- widget.selectDestinationControl(destination, false);
- });
- }
- },
-
- handleDestinationSet: function(destination, normalizedPlace) {
- if (destination.marker) {
- if (!normalizedPlace) {
- this.associateDestinationMarker(destination, null);
- this.enableLocationSelection();
- }
- /* Else assume we've just updated the marker explicitly via
- * associateDestationMarker. Corollary: be sure to call that... */
- } else if (normalizedPlace) {
- this.createDestinationMarker(normalizedPlace, destination);
- }
-
- if (normalizedPlace) {
- this.disableLocationSelection();
- }
-
+ handleDestinationPlaceChange: function(destination, place) {
if (destination.getPrevious()) {
this.updateLeg(destination);
}
-
if (destination.getNext()) {
this.updateLeg(destination.getNext());
}
},
- updateLeg: function(destinationControl) {
- var widget = this;
+ handleDestinationDeselect: function(destination) {
+ this.selectedDestination = null;
+ destination.onPlaceChange.remove(
+ this.handleSelectedDestinationPlaceChange);
+ this.disableLocationSelection();
+ this.clearSearchMarkers();
+ },
+
+ handleDestinationSelect: function(destination) {
+ this.deselectDestination();
+
+ this.selectedDestination = destination;
+ destination.onPlaceChange.add(this.handleSelectedDestinationPlaceChange);
+ this.handleSelectedDestinationPlaceChange(destination.getPlace());
+ },
+
+ handleSelectedDestinationPlaceChange: function(place) {
+ if (place) {
+ this.disableLocationSelection();
+ this.ensureVisible(place);
+ } else {
+ this.enableLocationSelection();
+ }
+ },
+
+ updateLeg: function(destination) {
+ var self = this;
var maps = this.maps;
var map = this.map;
- var origin = destinationControl.getPrevious().getPlace();
- var destination = destinationControl.getPlace();
+ var a = destination.getPrevious().getPlace();
+ var b = destination.getPlace();
- var leg = destinationControl.leg;
+ var leg = destination.leg;
if (leg) {
if (leg.async) {
leg.async.reject();
@@ -181,13 +282,13 @@
preserveViewport: true,
suppressMarkers: true
});
- destinationControl.leg = leg = { renderer: renderer };
+ destination.leg = leg = { renderer: renderer };
}
- if (origin && destination) {
+ if (a && b) {
var request = {
- origin: origin.place.location,
- destination: destination.place.location,
+ origin: a.getLocation(),
+ destination: b.getLocation(),
travelMode: maps.TravelMode.DRIVING // TODO(rosswang): user choice
};
@@ -196,21 +297,27 @@
this.directionsService.route(request, function(result, status) {
if (status === maps.DirectionsStatus.OK) {
leg.async.resolve(result);
+ leg.sync = result;
} else {
- widget.onError({ directionsStatus: status });
+ self.onError({ directionsStatus: status });
leg.async.reject(status);
}
});
- leg.async.done(function(route) {
- leg.renderer.setDirections(route);
+ leg.async.done(function(result) {
+ leg.renderer.setDirections(result);
leg.renderer.setMap(map);
+
+ self.ensureGeomsVisible(result.routes[0]['overview_path'].map(
+ function(point) {
+ return { location: point };
+ }));
});
}
},
centerOnCurrentLocation: function() {
- var widget = this;
+ var self = this;
var maps = this.maps;
var map = this.map;
@@ -221,40 +328,53 @@
position.coords.latitude, position.coords.longitude);
map.setCenter(latLng);
- widget.geocoder.geocode({ location: latLng },
+ self.geocoder.geocode({ location: latLng },
function(results, status) {
- if (status === maps.GeocoderStatus.OK) {
- var result = results[0];
- var origin = widget.destinations.getDestinations()[0];
- var marker = widget.createDestinationMarker(
- normalizeDestination(result), origin);
-
- marker.onClick.add(function listener() {
- origin.set(result);
- marker.onClick.remove(listener);
- });
+ if (status === maps.GeocoderStatus.OK &&
+ self.origin && !self.origin.hasPlace()) {
+ self.origin.setPlace(new Place(results[0]));
+ self.createDestinationMarker(self.origin);
}
});
});
}
},
- bindDestinationControl: function (destination) {
- var widget = this;
- var maps = this.maps;
- var map = this.map;
+ ensureGeomsVisible: function(geoms) {
+ var curBounds = this.map.getBounds();
+ if (!geoms.every(function(geom) {
+ return curBounds.contains(geom.location);
+ })) {
+ this.fitGeoms(geoms);
+ }
+ },
- maps.event.addListener(map, 'bounds_changed', function() {
- destination.setSearchBounds(map.getBounds());
- });
+ fitGeoms: function(geoms) {
+ var curBounds = this.map.getBounds();
+ var curSize = curBounds.toSpan();
+ function wontShrink(proposed) {
+ var size = proposed.toSpan();
+ return size.lat() >= curSize.lat() || size.lng() >= curSize.lng();
+ }
- destination.onFocus.add(function() {
- widget.selectDestinationControl(destination);
- });
- destination.onSearch.add(
- $.proxy(this, 'showDestinationSearchResults', destination));
- destination.onSet.add(
- $.proxy(this, 'handleDestinationSet', destination));
+ if (geoms.length === 1) {
+ var geom = geoms[0];
+ if (geom.viewport && wontShrink(geom.viewport)) {
+ this.map.fitBounds(geom.viewport);
+ } else {
+ this.map.panTo(geom.location);
+ }
+
+ } else if (geoms.length > 1) {
+ this.map.fitBounds(geoms.reduce(function(acc, geom) {
+ if (geom.viewport) {
+ acc.union(geom.viewport);
+ } else {
+ acc.extend(geom.location);
+ }
+ return acc;
+ }, new this.maps.LatLngBounds()));
+ }
},
enableLocationSelection: function() {
@@ -267,88 +387,17 @@
this.locationSelectionEnabled = false;
},
- selectDestinationControl: function(dest, closeInfoWindow) {
- if (dest !== this.selectedDestinationControl) {
- var prevDest = this.selectedDestinationControl;
- if (prevDest && prevDest.marker) {
- prevDest.marker.setColor(DestinationMarker.color.BLUE);
- }
- this.deselectDestinationControl(closeInfoWindow);
-
- this.selectedDestinationControl = dest;
- dest.selectControl();
-
- if (dest.marker) {
- dest.marker.setColor(DestinationMarker.color.GREEN);
- }
-
- var place = dest.getPlace();
- if (place) {
- this.fitAllDestinations();
- } else {
- this.enableLocationSelection();
- }
- }
- },
-
- showDestinationSearchResults: function(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();
-
- if (places.length === 1) {
- var place = places[0];
- this.map.panTo(place.geometry.location);
- /* It would be nice if we could distinguish between an autocomplete
- * click and a normal search so that we don't overwrite the search box
- * text for the autocomplete click.*/
- var dest = this.selectedDestinationControl;
- if (dest) {
- dest.set(place);
- }
- } else if (places.length > 1) {
- var bounds = new this.maps.LatLngBounds();
-
- $.each(places, function(i, place) {
- var marker = widget.createMarker(normalizeDestination(place),
- SEARCH_CLIENT, DestinationMarker.color.RED);
- widget.searchMarkers.push(marker);
-
- marker.onClick.add(function() {
- var dest = widget.selectedDestinationControl;
- if (dest && dest.marker !== marker) {
- widget.associateDestinationMarker(dest, marker);
- dest.set(place);
- }
- });
-
- bounds.extend(place.geometry.location);
- });
-
- this.map.fitBounds(bounds);
- }
- },
-
selectLocation: function(latLng) {
- var widget = this;
+ var self = this;
var maps = this.maps;
- var dest = this.selectedDestinationControl;
+ var dest = this.selectedDestination;
if (dest && this.locationSelectionEnabled) {
- widget.geocoder.geocode({ location: latLng },
+ self.geocoder.geocode({ location: latLng },
function(results, status) {
if (status === maps.GeocoderStatus.OK) {
- widget.associateDestinationMarker(dest, null);
- dest.set(results[0]);
+ dest.setPlace(new Place(results[0]));
+ self.createDestinationMarker(dest);
}
});
}
@@ -361,13 +410,17 @@
* @param error A union with one of the following keys:
* directionsStatus
*/
- onError: 'memory'
+ onError: 'memory',
+ /**
+ * @param bounds
+ */
+ onBoundsChange: ''
},
// https://developers.google.com/maps/documentation/javascript/tutorial
init: function(opts) {
opts = opts || {};
- var widget = this;
+ var self = this;
var maps = opts.maps || global.google.maps;
this.maps = maps;
@@ -387,22 +440,15 @@
var map = new maps.Map(this.$[0], this.initialConfig);
this.map = map;
- this.messages = new Messages();
- this.destinations = new Destinations(maps);
-
- this.destinations.addDestinationBindingHandler(
- $.proxy(this, 'bindDestinationControl'));
-
maps.event.addListener(map, 'click', function(e) {
- widget.selectLocation(e.latLng);
+ self.selectLocation(e.latLng);
+ });
+ maps.event.addListener(map, 'bounds_changed', function() {
+ self.onBoundsChange(map.getBounds());
});
this.centerOnCurrentLocation();
-
- var controls = map.controls;
- controls[maps.ControlPosition.LEFT_TOP].push(this.destinations.$[0]);
- controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
}
});
-module.exports = Widget;
+module.exports = Map;
diff --git a/src/components/message.js b/src/components/message.js
index e51c489..1c72faf 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -5,88 +5,85 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var INFO = 'INFO';
-var ERROR = 'ERROR';
+var Message = defineClass({
+ statics: {
+ INFO: 'INFO',
+ ERROR: 'ERROR',
-function info(text) {
- return {
- type: INFO,
- text: text
- };
-}
+ info: function(text) {
+ return {
+ type: Message.INFO,
+ text: text
+ };
+ },
-function error(text) {
- return {
- type: ERROR,
- text: text
- };
-}
+ error: function(text) {
+ return {
+ type: Message.ERROR,
+ text: text
+ };
+ }
+ },
-module.exports = {
- INFO: INFO,
- ERROR: ERROR,
- info: info,
- error: error,
-
- Message: defineClass({
- publics: {
- setType: function(type) {
- switch (type) {
- case INFO:
- this.$.attr('class', 'info');
- break;
- case ERROR:
- this.$.attr('class', 'error');
- break;
- default:
- throw 'Invalid message type ' + type;
- }
- },
-
- setText: function(text) {
- this.$.text(text);
- },
-
- set: function(message) {
- if (!message) {
- this.onLowerPriority();
- return;
- }
-
- if (typeof message === 'string') {
- message = info(message);
- }
-
- var self = this;
-
- this.setType(message.type);
- this.setText(message.text);
-
- if (message.promise) {
- message.promise.then(function(message) {
- self.set(message);
- }, function(err) {
- self.set(error(err));
- });
- } else {
- this.onLowerPriority();
- }
+ publics: {
+ setType: function(type) {
+ switch (type) {
+ case Message.INFO:
+ this.$.attr('class', 'info');
+ break;
+ case Message.ERROR:
+ this.$.attr('class', 'error');
+ break;
+ default:
+ throw 'Invalid message type ' + type;
}
},
- constants: [ '$' ],
- events: {
- /**
- * Event raised when the message is no longer pending user action.
- */
- onLowerPriority: 'memory once'
+ setText: function(text) {
+ this.$.text(text);
},
- init: function(initial) {
- this.$ = $('<li>');
- if (initial) {
- this.set(initial);
+ set: function(message) {
+ if (!message) {
+ this.onLowerPriority();
+ return;
+ }
+
+ if (typeof message === 'string') {
+ message = Message.info(message);
+ }
+
+ var self = this;
+
+ this.setType(message.type);
+ this.setText(message.text);
+
+ if (message.promise) {
+ message.promise.then(function(message) {
+ self.set(message);
+ }, function(err) {
+ self.set(Message.error(err));
+ });
+ } else {
+ this.onLowerPriority();
}
}
- })
-};
+ },
+
+ constants: [ '$' ],
+ events: {
+ /**
+ * Event raised when the message is no longer pending user action.
+ */
+ onLowerPriority: 'memory once'
+ },
+
+ init: function(initial) {
+ this.$ = $('<li>');
+ if (initial) {
+ this.set(initial);
+ }
+ }
+});
+
+module.exports = Message;
diff --git a/src/components/messages.js b/src/components/messages.js
index 5c5c956..2d8555c 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -5,7 +5,7 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var message = require('./message');
+var Message = require('./message');
var Messages = defineClass({
statics: {
@@ -87,7 +87,7 @@
push: function(messageData) {
var self = this;
- var messageObject = new message.Message(messageData);
+ var messageObject = new Message(messageData);
this.$messages.append(messageObject.$);
if (this.isOpen()) {
@@ -149,7 +149,7 @@
init: function() {
this.$handle = $('<div>')
.addClass('handle')
- .click(this.toggle.bind(this));
+ .click(this.toggle);
this.$messages = $('<ul>');
diff --git a/src/destination.js b/src/destination.js
new file mode 100644
index 0000000..aa98a21
--- /dev/null
+++ b/src/destination.js
@@ -0,0 +1,138 @@
+// 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 defineClass = require('./util/define-class');
+
+var Destination = defineClass({
+ publics: {
+ getIndex: function() {
+ return this.index;
+ },
+
+ getPlace: function() {
+ return this.place;
+ },
+
+ hasPlace: function() {
+ return !!this.place;
+ },
+
+ setPlace: function(place) {
+ var prev = this.place;
+ if (prev !== place) {
+ this.place = place;
+ this.onPlaceChange(place, prev);
+ }
+ },
+
+ isSelected: function() {
+ return this.selected;
+ },
+
+ select: function() {
+ if (!this.selected) {
+ this.selected = true;
+ this.onSelect();
+ }
+ },
+
+ deselect: function() {
+ if (this.selected) {
+ this.selected = false;
+ this.onDeselect();
+ }
+ },
+
+ hasNext: function() {
+ return !!this.next;
+ },
+
+ getNext: function() {
+ return this.next;
+ },
+
+ bindNext: function(next) {
+ var oldNext = this.next;
+ if (oldNext !== next) {
+ if (oldNext) {
+ oldNext.bindPrev(null);
+ }
+
+ this.next = next;
+
+ if (next) {
+ next.bindPrevious(this.ifc);
+ }
+
+ if (!(oldNext && next)) {
+ this.onOrdinalChange(); //changed to or from last
+ }
+ }
+ },
+
+ hasPrevious: function() {
+ return !!this.prev;
+ },
+
+ getPrevious: function() {
+ return this.prev;
+ },
+
+ bindPrevious: function(prev) {
+ if (this.prev !== prev) {
+ if (this.prev) {
+ this.prev.onOrdinalChange.remove(this.updateIndex);
+ this.prev.bindNext(null);
+ }
+
+ this.prev = prev;
+
+ if (prev) {
+ prev.bindNext(this.ifc);
+ prev.onOrdinalChange.add(this.updateIndex);
+ }
+
+ this.updateIndex();
+ }
+ }
+ },
+
+ privates: {
+ updateIndex: function() {
+ var oldIndex = this.index;
+ if (this.prev) {
+ this.index = this.prev.getIndex() + 1;
+ } else {
+ this.index = 0;
+ }
+ if (oldIndex !== this.index) {
+ this.onOrdinalChange();
+ }
+ }
+ },
+
+ events: [
+ /**
+ * Fired when properties related to the ordering of this destination with
+ * respect to other destinations have changed. Such properties include
+ * whether this destination is or last and its index number.
+ */
+ 'onOrdinalChange',
+ /**
+ * 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.
+ */
+ 'onPlaceChange',
+ 'onSelect',
+ 'onDeselect'
+ ],
+
+ init: function() {
+ this.selected = false;
+ this.index = 0;
+ }
+});
+
+module.exports = Destination;
\ No newline at end of file
diff --git a/src/place.js b/src/place.js
new file mode 100644
index 0000000..d961d08
--- /dev/null
+++ b/src/place.js
@@ -0,0 +1,151 @@
+// 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 defineClass = require('./util/define-class');
+
+var Place = defineClass({
+ publics: {
+ getDetails: function() {
+ return this.details;
+ },
+
+ hasDetails: function() {
+ return !!this.details;
+ },
+
+ getGeometry: function() {
+ return this.details && this.details.geometry || {
+ location: this.getLocation()
+ };
+ },
+
+ getLocation: function() {
+ return this.placeObj.location;
+ },
+
+ getName: function() {
+ var details = this.details;
+ return details && details.name ||
+ /[^,]*/.exec(details['formatted_address'])[0];
+ },
+
+ getPlaceObject: function() {
+ return this.placeObj;
+ },
+
+ getSingleLine: function() {
+ var details = this.details;
+
+ if (this.singleLine) {
+ return this.singleLine;
+
+ } else if (details) {
+ this.singleLine = details.name &&
+ details.name !== details['formatted_address'].split(', ')[0]?
+ details.name + ', ' + details['formatted_address'] :
+ details['formatted_address'];
+ return this.singleLine;
+
+ } else { // not preferred
+ return this.placeObj.query || this.placeObj.location.toString();
+ }
+ },
+
+ /**
+ * This code is highly fragile and heaven help the poor soul who needs to
+ * localize it.
+ *
+ * TODO(rosswang): Is this really the best way? We should find a formatter.
+ *
+ * @param name optional place name to omit from the address. Defaults to the
+ * name in the details; pass null to override.
+ * @return an array of formatted address lines.
+ */
+ getMultiLine: function(name) {
+ var details = this.details;
+
+ var addr = details && details['formatted_address'];
+ if (!addr) {
+ return [];
+ }
+
+ if (name === undefined) {
+ name = details.name;
+ }
+
+ /* If at any point the first line/atom will echo the place name, leave it
+ * out. */
+
+ var parts = addr.split(', ');
+ 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] === 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] === name? lines.slice(1) : lines;
+ }
+ },
+
+ /**
+ * @param desc place object, place details result, or search result.
+ *
+ * TODO(rosswang): lazy fetch details if not given.
+ */
+ init: function(desc) {
+ if (desc.geometry) {
+ var placeObj = this.placeObj = { location: desc.geometry.location };
+ this.details = desc;
+
+ if (desc['place_id'] !== undefined) {
+ placeObj.placeId = desc['place_id'];
+ } else {
+ placeObj.query = desc['formatted_address'];
+ }
+ } else {
+ this.placeObj = desc;
+ }
+ }
+});
+
+module.exports = Place;
\ No newline at end of file
diff --git a/src/strings.js b/src/strings.js
index f73188a..0f03161 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -18,6 +18,10 @@
REQUEST_DENIED: 'Request denied',
UNKNOWN_ERROR: 'Server error'
},
+ 'Final destination': 'Final destination',
+ label: function(label, details) {
+ return label + ': ' + details;
+ },
'Origin': 'Origin',
'Travel Planner': 'Travel Planner',
'Unknown error': 'Unknown error'
diff --git a/src/travel.js b/src/travel.js
index 939bca6..1e0a4e0 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -4,7 +4,9 @@
var $ = require('./util/jquery');
-var message = require('./components/message');
+var Destinations = require('./components/destinations');
+var Messages = require('./components/messages');
+var Message = require('./components/message');
var vanadiumWrapperDefault = require('./vanadium-wrapper');
var defineClass = require('./util/define-class');
@@ -25,34 +27,78 @@
var Travel = defineClass({
publics: {
+ addDestination: function() {
+ var map = this.map;
+
+ var destination = map.addDestination();
+ var control = this.destinations.append();
+ control.bindDestination(destination);
+
+ control.setSearchBounds(map.getBounds());
+ map.onBoundsChange.add(control.setSearchBounds);
+
+ control.onFocus.add(function() {
+ if (!destination.isSelected()) {
+ map.closeActiveInfoWindow();
+ destination.select();
+ }
+ });
+
+ control.onSearch.add(function(results) {
+ map.showSearchResults(results);
+
+ /* 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. */
+ control.focus();
+ });
+
+ return control;
+ },
+
error: function (err) {
- this.map.message(message.error(err.toString()));
+ this.messages.push(Message.error(
+ err.message || err.msg || err.toString()));
},
info: function (info, promise) {
- var messageData = message.info(info);
+ var messageData = Message.info(info);
messageData.promise = promise;
- this.map.message(messageData);
+ this.messages.push(messageData);
}
},
init: function (opts) {
+ var self = this;
+
opts = opts || {};
var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
- var travel = this;
- this.map = new Map(opts);
- this.sync = new TravelSync();
+ var map = this.map = new Map(opts);
+ var maps = map.maps;
- var reportError = $.proxy(this, 'error');
+ var messages = this.messages = new Messages();
+ var destinations = this.destinations = new Destinations(maps);
+
+ var sync = this.sync = new TravelSync();
+
+ var error = this.error;
+
+ map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
+ map.addControls(maps.ControlPosition.LEFT_TOP, destinations.$);
+
+ destinations.onAddClick.add(function() {
+ self.addDestination().focus();
+ });
this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
.then(function(wrapper) {
- wrapper.onCrash.add(reportError);
+ wrapper.onCrash.add(error);
var identity = new Identity(wrapper.getAccountName());
identity.mountName = makeMountName(identity);
- return travel.sync.start(identity.mountName, wrapper);
+ return sync.start(identity.mountName, wrapper);
}).then(function() {
return strings['Connected to all services.'];
}, function(err) {
@@ -61,17 +107,20 @@
}));
var directionsServiceStatusStrings = buildStatusErrorStringMap(
- this.map.maps.DirectionsStatus, strings.DirectionsStatus);
+ maps.DirectionsStatus, strings.DirectionsStatus);
- this.map.onError.add(function(err) {
+ map.onError.add(function(err) {
var message = directionsServiceStatusStrings[err.directionsStatus] ||
strings['Unknown error'];
- reportError(message);
+ error(message);
});
var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
- $domRoot.append(travel.map.$);
+ $domRoot.append(map.$);
+
+ this.addDestination();
+ this.addDestination();
}
});
diff --git a/src/travelsync.js b/src/travelsync.js
index 1717000..3f458b3 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -18,7 +18,7 @@
function(syncbase) {
self.syncbase = syncbase;
syncbase.onError.add(self.onError);
- syncbase.onUpdate.add(self.processUpdates.bind(self));
+ syncbase.onUpdate.add(self.processUpdates);
});
return Promise.all([
diff --git a/src/util/define-class.js b/src/util/define-class.js
index ef0b221..ba2074b 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -32,6 +32,10 @@
* public interface.
* </ul>
*
+ * <p>Furthermore, all functions and events are thus bound statically to the
+ * appropriate instance, and so can be passed as callbacks without ad-hoc
+ * proxying/binding.
+ *
* <p>Care should be taken not to be tempted to declare instance constants
* within <code>private</code>, as any instantiations done on the initial
* values is done at class definition time rather than class instantiation
@@ -47,7 +51,14 @@
ifc: ifc //expose reflexive public interface for private use
},
//extend in inverse precedence
- def.statics, def.publics, def.privates);
+ def.statics);
+ if (def.publics) {
+ polyBind(pthis, pthis, def.publics, false);
+ }
+
+ if (def.privates) {
+ polyBind(pthis, pthis, def.privates, false);
+ }
if (def.events) {
if ($.isArray(def.events)) {
@@ -72,7 +83,7 @@
}
if (def.publics) {
- polyProxy(ifc, pthis, def.publics, true);
+ polyBind(ifc, pthis, def.publics, true);
}
if (def.constants) {
@@ -116,25 +127,22 @@
/**
* Late-bind proxies to maximize flexibility at negligible performance cost.
- * However, a word of caution: although normal jQuery proxies are identifiable
- * as equivalent to their originals for the purposes of callback binding, these
- * will not be.
*/
-function lateProxy(context, name) {
+function lateBind(context, name) {
return function() {
return context[name].apply(context, arguments);
};
}
-function polyProxy(proxy, context, members, lateBinding) {
+function polyBind(proxy, context, members, lateBinding) {
$.each(members, $.isArray(members)?
function() {
proxy[this] =
- lateBinding? lateProxy(context, this) : $.proxy(context, this);
+ lateBinding? lateBind(context, this) : this.bind(context);
} :
function(name, member) {
proxy[name] =
- lateBinding? lateProxy(context, name) : $.proxy(member, context);
+ lateBinding? lateBind(context, name) : member.bind(context);
});
return proxy;
}
@@ -142,7 +150,7 @@
/**
* Replaces "this" returns with proxy.
*/
-function polyReflexiveLateProxy(proxy, context, members) {
+function polyReflexiveLateBind(proxy, context, members) {
$.each(members, function(i, name) {
proxy[name] = function() {
context[name].apply(context, arguments);
@@ -154,8 +162,8 @@
function defineEvent(pthis, ifc, name, flags) {
var dispatcher = $.Callbacks(flags);
- //Use polyProxy on function that fires to add the callable syntactic sugar
- var callableDispatcher = pthis[name] = polyProxy(function() {
+ //Use polyBind on function that fires to add the callable syntactic sugar
+ var callableDispatcher = pthis[name] = polyBind(function() {
dispatcher.fireWith.call(dispatcher, ifc, arguments);
}, dispatcher, dispatcher, false);
@@ -170,9 +178,9 @@
/* We'll want the context to actually be callableDispatcher even though
* the interface and functionality of dispatcher suffice so that we can
* late-bind to the instance exposed to private this. */
- polyProxy(publicEvent, callableDispatcher,
+ polyBind(publicEvent, callableDispatcher,
['disabled', 'fired', 'has', 'locked'], true);
- polyReflexiveLateProxy(publicEvent, callableDispatcher,
+ polyReflexiveLateBind(publicEvent, callableDispatcher,
['add', 'disable', 'empty', 'lock', 'remove']);
ifc[name] = publicEvent;
diff --git a/test/components/destination-info.js b/test/components/destination-info.js
index 035cf63..27923f0 100644
--- a/test/components/destination-info.js
+++ b/test/components/destination-info.js
@@ -6,12 +6,13 @@
var $ = require('../../src/util/jquery');
var DestinationInfo = require('../../src/components/destination-info');
+var Place = require('../../src/place');
var mockMaps = require('../../mocks/google-maps');
function setUpWithCanvas() {
var map = new mockMaps.Map($('<div>')[0]);
var info = new DestinationInfo(mockMaps, map,
- mockMaps.places.mockPlaceResult);
+ new Place(mockMaps.places.mockPlaceResult));
return {
map: map,
info: info
diff --git a/test/components/destination-marker.js b/test/components/destination-marker.js
index 4cfedde..23ec75b 100644
--- a/test/components/destination-marker.js
+++ b/test/components/destination-marker.js
@@ -6,15 +6,13 @@
var $ = require('../../src/util/jquery');
var DestinationMarker = require('../../src/components/destination-marker');
-var normalizeDestination =
- require('../../src/components/destination').normalizeDestination;
+var Place = require('../../src/place');
var mockMaps = require('../../mocks/google-maps');
function mockMarker(client, color) {
var map = new mockMaps.Map($('<div>')[0]);
return new DestinationMarker(mockMaps, map,
- normalizeDestination(mockMaps.places.mockPlaceResult),
- client, color);
+ new Place(mockMaps.places.mockPlaceResult), client, color);
}
test('client events', function(t) {
diff --git a/test/components/map.js b/test/components/map.js
index 62f483a..340e935 100644
--- a/test/components/map.js
+++ b/test/components/map.js
@@ -4,28 +4,18 @@
var test = require('tape');
-var $ = require('../../src/util/jquery');
-
var Map = require('../../src/components/map');
-var message = require ('../../src/components/message');
-
var mockMaps = require('../../mocks/google-maps');
-test('message display', function(t) {
- var map = new Map({
- maps: mockMaps
+test('instantiation', function(t) {
+ t.doesNotThrow(function() {
+ //instantiation smoke test
+ /* jshint -W031 */
+ new Map({
+ maps: mockMaps
+ });
+ /* jshint +W031 */
});
- var $messages = $('.messages ul', map.$);
- t.ok($messages.length, 'message display exists');
- t.equals($messages.children().length, 0, 'message display is empty');
-
- map.message(message.info('Test message.'));
-
- var $messageItem = $messages.children();
- t.equals($messageItem.length, 1, 'message display shows 1 message');
- t.equals($messageItem.text(), 'Test message.',
- 'message displays message text');
-
t.end();
});
diff --git a/test/components/message.js b/test/components/message.js
index 865c477..9dbd1da 100644
--- a/test/components/message.js
+++ b/test/components/message.js
@@ -4,21 +4,21 @@
var test = require('tape');
-var message = require('../../src/components/message');
+var Message = require('../../src/components/message');
test('init', function(t) {
- t.ok(new message.Message(), 'default instantiation');
+ t.ok(new Message(), 'default instantiation');
t.end();
});
test('dom', function(t) {
- var msg = new message.Message(message.info('Hello, world!'));
+ var msg = new Message(Message.info('Hello, world!'));
t.equal(msg.$.length, 1, 'unique element');
t.equal(msg.$[0].tagName, 'LI', 'tag name');
t.assert(msg.$.hasClass('info'), 'class info');
t.equal(msg.$.text(), 'Hello, world!', 'text');
- msg.setType(message.ERROR);
+ msg.setType(Message.ERROR);
t.notOk(msg.$.hasClass('info'), 'class not info');
t.assert(msg.$.hasClass('error'), 'class error');
diff --git a/test/travel.js b/test/travel.js
index b4d3ded..48a936d 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -14,16 +14,6 @@
$('body').empty();
}
-test('init', function(t) {
- /* jshint -W031 */ //instantiation smoke test
- new Travel({
- maps: mockMaps
- });
- /* jshint +W031 */
- t.end();
- cleanDom();
-});
-
test('domRoot', function(t) {
var $root = $('<div>');
var root = $root[0];
@@ -41,4 +31,25 @@
t.end();
cleanDom();
+});
+
+test('messages', function(t) {
+ var travel = new Travel({
+ maps: mockMaps,
+ vanadiumWrapper: mockVanadiumWrapper
+ });
+
+ var $messages = $('.messages ul');
+ t.ok($messages.length, 'message display exists');
+ var $messageItems = $messages.children();
+ t.equals($messageItems.length, 1,
+ 'message display has initial status message');
+
+ travel.info('Test message.');
+
+ $messageItems = $messages.children();
+ t.equals($messageItems.length, 2, 'message display shows 2 messages');
+ t.equals($($messageItems[1]).text(), 'Test message.',
+ 'message displays message text');
+ t.end();
});
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
index c3868d9..e3eeeac 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -73,6 +73,57 @@
t.end();
});
+test('member bindings', function(t) {
+ var seen;
+
+ var foreignContext = {
+ a: 0
+ };
+
+ var TestClass = defineClass({
+ publics: {
+ seePublic: function() {
+ seen = this.a++;
+ }
+ },
+ privates: {
+ seePrivate: function() {
+ seen = this.a++;
+ }
+ },
+ events: {
+ onPrivate: 'public'
+ },
+
+ init: function() {
+ this.a = 42;
+ this.onPrivate.add(this.seePrivate);
+
+ foreignContext.privateEvent = this.onPrivate;
+ foreignContext.privatePrivate = this.seePrivate;
+ foreignContext.privatePublic = this.seePublic;
+ }
+ });
+
+ var testInstance = new TestClass();
+
+ foreignContext.publicEvent = testInstance.onPrivate;
+ foreignContext.publicPublic = testInstance.seePublic;
+
+ foreignContext.privateEvent();
+ t.equal(seen, 42, 'event privately instance-bound');
+ foreignContext.privatePrivate();
+ t.equal(seen, 43, 'private method privately instance-bound');
+ foreignContext.privatePublic();
+ t.equal(seen, 44, 'public method privately instance-bound');
+ foreignContext.publicEvent();
+ t.equal(seen, 45, 'event publicly instance-bound');
+ foreignContext.publicPublic();
+ t.equal(seen, 46, 'public method publicly instance-bound');
+
+ t.end();
+});
+
test('events object', function(t) {
var TestClass = defineClass({
init: function() {