Travel app UX updates
Rebranded classic routing UI to "Timeline" and put it in a collapsible left
panel.
Introducing minimal UI for streamlined common-case destination addition and
editing.
Implementing auto-remove of unused tail destinations.
Change-Id: I65b0d58d0edf1da535a83c27757d678c44d4442c
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 45e07ac..3ef6412 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -6,9 +6,10 @@
var defineClass = require('../src/util/define-class');
var ControlPosition = {
+ LEFT_CENTER: 'lc',
LEFT_TOP: 'lt',
- TOP_LEFT: 'tl',
- TOP_CENTER: 'tc'
+ TOP_CENTER: 'tc',
+ TOP_LEFT: 'tl'
};
var ControlPanel = defineClass({
@@ -68,10 +69,11 @@
},
init: function(canvas) {
+ var self = this;
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);
+ $.each(ControlPosition, function() {
+ self.controls[this] = new ControlPanel(canvas);
+ });
this.infoWindows = [];
}
diff --git a/package.json b/package.json
index 9a0ee71..5045df4 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,10 @@
"tape": "^4.0.0"
},
"dependencies": {
+ "es6-promisify": "^2.0.0",
"global": "^4.3.0",
"jquery": "^2.1.4",
- "es6-promisify": "^2.0.0",
+ "raf": "^3.1.0",
"uuid": "^2.0.1"
}
}
diff --git a/src/components/add-button.js b/src/components/add-button.js
new file mode 100644
index 0000000..9f17a14
--- /dev/null
+++ b/src/components/add-button.js
@@ -0,0 +1,42 @@
+// 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 AddButton = defineClass({
+ publics: {
+ disable: function() {
+ this.$.addClass('disabled');
+ },
+
+ enable: function() {
+ this.$.removeClass('disabled');
+ },
+
+ isEnabled: function() {
+ return !this.$.hasClass('disabled');
+ }
+ },
+
+ constants: [ '$' ],
+ events: [ 'onClick' ],
+
+ init: function(maps) {
+ var self = this;
+
+ this.$ = $('<div>')
+ .addClass('add-bn')
+ .click(function() {
+ if (self.isEnabled()) {
+ self.onClick();
+ }
+ })
+ .append($('<div>')
+ .addClass('vertical-middle')
+ .text('+'));
+ }
+});
+
+module.exports = AddButton;
\ No newline at end of file
diff --git a/src/components/destination-control.js b/src/components/destination-control.js
deleted file mode 100644
index 797143a..0000000
--- a/src/components/destination-control.js
+++ /dev/null
@@ -1,184 +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 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-marker.js b/src/components/destination-marker.js
index bba109c..9f4319f 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -72,6 +72,10 @@
}
},
+ getClient: function() {
+ return this.topClient().client;
+ },
+
setColor: function(color) {
this.topClient().color = color;
this.updateIcon();
@@ -89,6 +93,16 @@
this.updateIcon();
},
+ restrictListenerToClient: function(callback, client) {
+ var self = this;
+ client = client || this.getClient();
+ return function() {
+ if (self.getClient() === client) {
+ return callback.apply(this, arguments);
+ }
+ };
+ },
+
setIcon: function(icon) {
var client = this.topClient();
client.icon = icon;
@@ -155,6 +169,11 @@
clickable: false
});
+ /* Override onClick.add to keep a record of which listeners are bound to
+ * which clients to remove listeners on client removal. This does not
+ * however implicitly restrict such listeners; that must be left to the
+ * caller so as to allow the caller to later pre-emptively remove the
+ * listener if desired. */
defineClass.decorate(this.onClick, 'add', function(listener, global) {
if (!global) {
/* Per jQuery, listener can also be an array; however, there seems to
@@ -169,7 +188,7 @@
listeners.push(listener);
}
}
-
+ }, function() {
self.refreshClickability();
});
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
new file mode 100644
index 0000000..7323794
--- /dev/null
+++ b/src/components/destination-search.js
@@ -0,0 +1,173 @@
+// 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 DestinationSearch = defineClass({
+ publics: {
+ clear: function() {
+ this.setPlace(null);
+ this.$searchBox.prop('value', '');
+ },
+
+ enable: function() {
+ this.$searchBox.removeAttr('disabled');
+ },
+
+ disable: function() {
+ this.$searchBox.attr('disabled', 'disabled');
+ },
+
+ focus: function() {
+ this.$.find('input:visible').focus();
+ },
+
+ hasFocus: function() {
+ return this.$.find(':focus').length > 0;
+ },
+
+ setSearchBounds: function(bounds) {
+ this.searchBox.setBounds(bounds);
+ },
+
+ select: function() {
+ this.$.addClass('selected');
+ },
+
+ deselect: function() {
+ if (this.isSelected()) {
+ this.$.removeClass('selected');
+ this.onDeselect();
+ }
+ },
+
+ isSelected: function() {
+ return this.$.hasClass('selected');
+ },
+
+ getPlace: function() {
+ return this.place;
+ },
+
+ setPlace: function(place) {
+ var prev = this.place;
+ if (prev !== place) {
+ this.place = place;
+ this.setAutocomplete(!place);
+
+ var newValue;
+ if (place) {
+ newValue = place.getSingleLine();
+ } else if (!this.hasFocus()) {
+ newValue = '';
+ }
+ if (newValue !== undefined) {
+ this.$searchBox.prop('value', newValue);
+ }
+
+ this.onPlaceChange(place, prev);
+ }
+ },
+
+ setPlaceholder: function(placeholder) {
+ this.$searchBox.attr('placeholder', placeholder);
+ }
+ },
+
+ privates: {
+ /**
+ * 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;
+
+ /* Restrict selection restoration to active elements because
+ * setSelectionRange apparently takes keyboard focus away from the
+ * currently focused element without actually setting it to anything,
+ * and trying to restore focus afterwards doesn't work. */
+ if (active && 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 place
+ * @param previous
+ */
+ 'onPlaceChange',
+
+ /**
+ * @param places (array of places)
+ */
+ 'onSearch',
+
+ 'onDeselect'
+ ],
+
+ 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() {
+ self.setPlace(null);
+ });
+
+ this.$ = $('<div>')
+ .addClass('destination 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 = DestinationSearch;
\ No newline at end of file
diff --git a/src/components/destinations.js b/src/components/destinations.js
deleted file mode 100644
index a1ca01d..0000000
--- a/src/components/destinations.js
+++ /dev/null
@@ -1,46 +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 DestinationControl = require('./destination-control');
-
-var Destinations = defineClass({
- publics: {
- append: function() {
- var controls = this.controls;
-
- var destinationControl = new DestinationControl(this.maps);
- this.$destContainer.append(destinationControl.$);
- controls.push(destinationControl);
-
- return destinationControl;
- }
- },
-
- constants: [ '$' ],
- events: [ 'onAddClick' ],
-
- init: function(maps) {
- 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.onAddClick();
- })
- .appendTo(this.$);
-
- this.controls = [];
- }
-});
-
-module.exports = Destinations;
\ No newline at end of file
diff --git a/src/components/map.js b/src/components/map.js
index f28f0e6..24ab5db 100644
--- a/src/components/map.js
+++ b/src/components/map.js
@@ -5,12 +5,12 @@
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var Destination = require('../destination');
+var Destinations = require('../destinations');
var Place = require('../place');
var DestinationInfo = require('./destination-info');
var DestinationMarker = require('./destination-marker');
-var strings = require('../strings').currentLocale;
+var describeDestination = require('../describe-destination');
//named destination marker clients
var SEARCH_CLIENT = 'search';
@@ -28,16 +28,10 @@
});
},
- addDestination: function() {
+ addDestination: function(index) {
var self = this;
- var destination = new Destination();
- if (!this.origin) {
- this.finalDestination = this.origin = destination;
- } else {
- this.finalDestination.bindNext(destination);
- this.finalDestination = destination;
- }
+ var destination = this.destinations.add(index);
destination.onPlaceChange.add(function(place) {
self.handleDestinationPlaceChange(destination, place);
@@ -52,6 +46,19 @@
return destination;
},
+ getDestination: function(index) {
+ return this.destinations.get(index);
+ },
+
+ removeDestination: function(index) {
+ //TODO(rosswang): clear any rendered legs
+ return this.destinations.remove(index);
+ },
+
+ getSelectedDestination: function() {
+ return this.selectedDestination;
+ },
+
clearSearchMarkers: function() {
$.each(this.searchMarkers, function() {
this.removeClient(SEARCH_CLIENT);
@@ -73,21 +80,19 @@
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);
+ this.destinations.each(function() {
+ if (this.hasPlace()) {
+ if (this.leg && this.leg.sync) {
+ $.each(this.leg.sync.routes[0]['overview_path'], addToGeoms);
}
- geoms.push(dest.getPlace().getGeometry());
+ geoms.push(this.getPlace().getGeometry());
}
- dest = dest.getNext();
- }
+ });
this.ensureGeomsVisible(geoms);
},
@@ -96,6 +101,28 @@
this.ensureGeomsVisible([place.getGeometry()]);
},
+ invalidateSize: function() {
+ this.maps.event.trigger(this.map, 'resize');
+ },
+
+ /**
+ * @return whether or not location selection is now enabled. Location
+ * selection can only be enabled when a destination slot has been selected.
+ */
+ enableLocationSelection: function() {
+ if (this.selectedDestination) {
+ this.map.setOptions({ draggableCursor: 'auto' });
+ this.locationSelectionEnabled = true;
+ return true;
+ }
+ return false;
+ },
+
+ disableLocationSelection: function() {
+ this.map.setOptions({ draggableCursor: null });
+ this.locationSelectionEnabled = false;
+ },
+
showSearchResults: function(results) {
var self = this;
@@ -106,17 +133,14 @@
return result.geometry;
}));
- if (results.length === 1) {
- var place = new Place(results[0]);
+ var dest = this.selectedDestination;
+ if (results.length === 1 && dest) {
/* 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) {
+ dest.setPlace(new Place(results[0]));
+ self.createDestinationMarker(dest);
+ } else if (results.length > 0) {
$.each(results, function(i, result) {
var place = new Place(result);
@@ -124,13 +148,13 @@
DestinationMarker.color.RED);
self.searchMarkers.push(marker);
- marker.onClick.add(function() {
+ marker.onClick.add(marker.restrictListenerToClient(function() {
var dest = self.selectedDestination;
if (dest) {
dest.setPlace(place);
self.associateDestinationMarker(dest, marker);
}
- });
+ }));
});
}
}
@@ -181,20 +205,7 @@
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);
+ describeDestination.decorateMarker(marker, destination);
}
destination.onOrdinalChange.add(handleOrdinalChange);
@@ -242,7 +253,6 @@
destination.onPlaceChange.remove(
this.handleSelectedDestinationPlaceChange);
this.disableLocationSelection();
- this.clearSearchMarkers();
},
handleDestinationSelect: function(destination) {
@@ -330,10 +340,11 @@
self.geocoder.geocode({ location: latLng },
function(results, status) {
+ var origin = self.destinations.get(0);
if (status === maps.GeocoderStatus.OK &&
- self.origin && !self.origin.hasPlace()) {
- self.origin.setPlace(new Place(results[0]));
- self.createDestinationMarker(self.origin);
+ origin && !origin.hasPlace()) {
+ origin.setPlace(new Place(results[0]));
+ self.createDestinationMarker(origin);
}
});
});
@@ -377,29 +388,28 @@
}
},
- enableLocationSelection: function() {
- this.map.setOptions({ draggableCursor: 'auto' });
- this.locationSelectionEnabled = true;
- },
-
- disableLocationSelection: function() {
- this.map.setOptions({ draggableCursor: null });
- this.locationSelectionEnabled = false;
- },
-
selectLocation: function(latLng) {
var self = this;
var maps = this.maps;
var dest = this.selectedDestination;
- if (dest && this.locationSelectionEnabled) {
- self.geocoder.geocode({ location: latLng },
- function(results, status) {
- if (status === maps.GeocoderStatus.OK) {
- dest.setPlace(new Place(results[0]));
- self.createDestinationMarker(dest);
- }
- });
+ if (dest) {
+ if (this.locationSelectionEnabled) {
+ self.geocoder.geocode({ location: latLng },
+ function(results, status) {
+ if (status === maps.GeocoderStatus.OK) {
+ dest.setPlace(new Place(results[0]));
+ self.createDestinationMarker(dest);
+
+ /* If we've just picked a location like this, we probably don't
+ * care about search results anymore. */
+ self.clearSearchMarkers();
+ }
+ });
+ } else {
+ dest.deselect();
+ this.closeActiveInfoWindow();
+ }
}
}
},
@@ -407,14 +417,15 @@
constants: ['$', 'maps'],
events: {
/**
+ * @param bounds
+ */
+ onBoundsChange: '',
+
+ /**
* @param error A union with one of the following keys:
* directionsStatus
*/
- onError: 'memory',
- /**
- * @param bounds
- */
- onBoundsChange: ''
+ onError: 'memory'
},
// https://developers.google.com/maps/documentation/javascript/tutorial
@@ -427,6 +438,7 @@
this.navigator = opts.navigator || global.navigator;
this.geocoder = new maps.Geocoder();
this.directionsService = new maps.DirectionsService();
+ this.destinations = new Destinations();
this.$ = $('<div>').addClass('map-canvas');
diff --git a/src/components/message.js b/src/components/message.js
index 1c72faf..a2e5062 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -17,10 +17,18 @@
};
},
- error: function(text) {
+ error: function(err) {
+ if (typeof err !== 'string') {
+ console.error(err);
+ }
+
+ while (err.message) {
+ err = err.message; //ExtensionCrashError.message.message = ...
+ }
+
return {
type: Message.ERROR,
- text: text
+ text: err.msg || err.toString()
};
}
},
diff --git a/src/components/messages.js b/src/components/messages.js
index 2d8555c..27d49a5 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -148,16 +148,14 @@
init: function() {
this.$handle = $('<div>')
- .addClass('handle')
+ .addClass('handle no-select')
.click(this.toggle);
this.$messages = $('<ul>');
this.$ = $('<div>')
- .addClass('messages')
- .addClass('headlines')
- .append(this.$handle)
- .append(this.$messages);
+ .addClass('messages headlines')
+ .append(this.$handle, this.$messages);
}
});
diff --git a/src/components/timeline.js b/src/components/timeline.js
new file mode 100644
index 0000000..24df0ab
--- /dev/null
+++ b/src/components/timeline.js
@@ -0,0 +1,71 @@
+// 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 AddButton = require('./add-button');
+var DestinationSearch = require('./destination-search');
+
+var Timeline = defineClass({
+ publics: {
+ disableAdd: function() {
+ this.addButton.disable();
+ },
+
+ enableAdd: function() {
+ this.addButton.enable();
+ },
+
+ append: function() {
+ var controls = this.controls;
+
+ var destinationSearch = new DestinationSearch(this.maps);
+ this.$destContainer.append(destinationSearch.$);
+ controls.push(destinationSearch);
+
+ return destinationSearch;
+ },
+
+ get: function(i) {
+ if (i === undefined) {
+ return this.controls.slice(0);
+ } else if (i >= 0) {
+ return this.controls[i];
+ } else if (i < 0) {
+ return this.controls[this.controls.length + i];
+ }
+ },
+
+ remove: function(i) {
+ if (i >= 0) {
+ this.controls.splice(i, 1)[0].$.remove();
+ } else if (i < 0) {
+ this.controls.splice(this.controls.length + i, 1)[0].$.remove();
+ }
+ }
+ },
+
+ constants: [ '$' ],
+ events: [ 'onAddClick' ],
+
+ init: function(maps) {
+ this.maps = maps;
+
+ this.addButton = new AddButton();
+ this.addButton.onClick.add(this.onAddClick);
+
+ this.$ = $('<form>')
+ .addClass('timeline no-select')
+ .append(this.$destContainer = $('<div>'), //for easier appending
+ this.addButton.$,
+ //get the scroll region to include the add button
+ $('<div>')
+ .addClass('clear-float'));
+
+ this.controls = [];
+ }
+});
+
+module.exports = Timeline;
\ No newline at end of file
diff --git a/src/debug.js b/src/debug.js
index 1d04f70..12da771 100644
--- a/src/debug.js
+++ b/src/debug.js
@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+var $ = require('./util/jquery');
+
/**
* Global variable exports for console debug.
*/
module.exports = function(app) {
global.travel = app;
+ global.$ = $;
};
\ No newline at end of file
diff --git a/src/describe-destination.js b/src/describe-destination.js
new file mode 100644
index 0000000..1d5310f
--- /dev/null
+++ b/src/describe-destination.js
@@ -0,0 +1,68 @@
+// 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 strings = require('./strings').currentLocale;
+var DestinationMarker = require('./components/destination-marker');
+
+var ORIGIN = 0;
+var INTERMEDIATE = 1;
+var TERMINAL = 2;
+var TERMINAL_DIRECT = 3;
+
+function classify(destination) {
+ if (!destination.hasPrevious()) {
+ return ORIGIN;
+ } else if (!destination.hasNext()) {
+ return destination.getIndex() === 1? TERMINAL_DIRECT : TERMINAL;
+ } else {
+ return INTERMEDIATE;
+ }
+}
+
+function description(destination) {
+ switch (classify(destination)) {
+ case ORIGIN:
+ return strings['Origin'];
+ case INTERMEDIATE:
+ return strings.destination(destination.getIndex());
+ case TERMINAL:
+ return strings['Final destination'];
+ case TERMINAL_DIRECT:
+ return strings['Destination'];
+ }
+}
+
+function descriptionOpenEnded(destination) {
+ switch (classify(destination)) {
+ case ORIGIN:
+ return strings['Origin'];
+ case TERMINAL:
+ case INTERMEDIATE:
+ return strings.destination(destination.getIndex());
+ case TERMINAL_DIRECT:
+ return strings['Destination'];
+ }
+}
+
+function decorateMarker(marker, destination) {
+ switch (classify(destination)) {
+ case ORIGIN:
+ marker.setIcon(DestinationMarker.icon.ORIGIN);
+ break;
+ case INTERMEDIATE:
+ marker.setLabel(destination.getIndex());
+ break;
+ case TERMINAL:
+ case TERMINAL_DIRECT:
+ marker.setIcon(DestinationMarker.icon.DESTINATION);
+ break;
+ }
+ marker.setDestinationLabel(description(destination));
+}
+
+module.exports = {
+ description: description,
+ descriptionOpenEnded: descriptionOpenEnded,
+ decorateMarker: decorateMarker
+};
diff --git a/src/destination.js b/src/destination.js
index aa98a21..25c609c 100644
--- a/src/destination.js
+++ b/src/destination.js
@@ -45,78 +45,37 @@
},
hasNext: function() {
- return !!this.next;
+ return this.index < this.list.count() - 1;
},
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
- }
- }
+ return this.list.get(this.index + 1);
},
hasPrevious: function() {
- return !!this.prev;
+ return this.index > 0;
},
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();
- }
+ return this.hasPrevious()? this.list.get(this.index - 1) : null;
}
},
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();
- }
+ setIndex: function(index) {
+ this.index = index;
}
},
events: [
/**
* Fired when properties related to the ordering of this destination with
- * respect to other destinations have changed. Such properties include
+ * respect to other timeline have changed. Such properties include
* whether this destination is or last and its index number.
+ *
+ * @param index the new index, which may not have changed. If the index has
+ * not changed, then this event is in response to the destination changing
+ * to or from last.
*/
'onOrdinalChange',
/**
@@ -129,9 +88,13 @@
'onDeselect'
],
- init: function() {
+ init: function(list, index, callbacks) {
+ this.list = list;
this.selected = false;
- this.index = 0;
+ this.index = index;
+
+ callbacks.ordinalChange = this.onOrdinalChange;
+ this.onOrdinalChange.add(this.setIndex);
}
});
diff --git a/src/destinations.js b/src/destinations.js
new file mode 100644
index 0000000..2679a4e
--- /dev/null
+++ b/src/destinations.js
@@ -0,0 +1,112 @@
+// 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 = require('./destination');
+
+var Destinations = defineClass({
+ publics: {
+ add: function(index) {
+ index = index || this.destinations.length;
+
+ var isLast = index === this.destinations.length;
+
+ var callbacks = {};
+ var destination = new Destination(this.ifc, index, callbacks);
+
+ this.destinations.splice(index, 0, {
+ callbacks: callbacks,
+ destination: destination
+ });
+
+ this.onAdd(destination);
+
+ if (isLast && index > 0) {
+ //old last is no longer last
+ this.destinations[index - 1].callbacks.ordinalChange(index - 1);
+ }
+ for (var i = index + 1; i < this.destinations.length; i++) {
+ this.destinations[i].callbacks.ordinalChange(i);
+ }
+
+ return destination;
+ },
+
+ get: function(index) {
+ if (index === undefined) {
+ return this.destinations.map(function(record) {
+ return record.destination;
+ });
+ }
+
+ var record;
+ if (index >= 0) {
+ record = this.destinations[index];
+ } else if (index < 0) {
+ record = this.destinations[this.destinations.length + index];
+ }
+
+ return record && record.destination;
+ },
+
+ count: function() {
+ return this.destinations.length;
+ },
+
+ remove: function(i) {
+ if (typeof i !== 'number') {
+ return;
+ }
+
+ if (i < 0) {
+ i += this.destinations.length;
+ }
+
+ var removed = this.destinations.splice(i, 1)[0];
+ if (removed) {
+ this.onRemove(removed);
+
+ if (i === this.destinations.length && i > 0) {
+ //new last
+ this.destinations[i - 1].callbacks.ordinalChange(i - 1);
+ }
+ for (var j = i; j < this.destinations.length; j++) {
+ this.destinations[j].callbacks.ordinalChange(j);
+ }
+
+ return removed.destination;
+ }
+ },
+
+ /**
+ * Behaves like jQuery each.
+ */
+ each: function(callback) {
+ $.each(this.destinations, function(i, elem) {
+ callback.call(this.destination, i, elem.destination);
+ });
+ }
+ },
+
+ events: [
+ /**
+ * @param destination. The index on the destination is reflective of its
+ * insertion index.
+ */
+ 'onAdd',
+
+ /**
+ * @param destination. The index on the destination is reflective of its
+ * index prior to removal.
+ */
+ 'onRemove'
+ ],
+
+ init: function() {
+ this.destinations = [];
+ }
+});
+
+module.exports = Destinations;
\ No newline at end of file
diff --git a/src/static/index.css b/src/static/index.css
index f7dad01..5b69c32 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -7,28 +7,47 @@
font-family: Arial, sans-serif;
}
-.destinations {
- margin: 2em 3em;
- width: 30em;
+.add-bn {
+ border-radius: 24px;
+ box-shadow: 0 0 4px rgba(0,0,0,.14), 0 4px 8px rgba(0,0,0,.28);
+ float: right;
+ width: 48px;
+ height: 48px;
+ cursor: pointer;
+ color: white;
+ background-color: #db4437;
+ text-align: center;
+ margin-top: 12px;
+ font-size: 27px;
+ font-weight: 100;
+ transform-origin: 100% 0;
+ transition: box-shadow .15s, transform .2s .1s;
}
-.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;
+.collapsed .add-bn {
+ transform: scaleX(0);
+ transition: transform .2s .2s;
+}
+
+.add-bn:hover {
+ box-shadow: 2px 4px 4px rgba(0,0,0,.14), 2px 8px 8px rgba(0,0,0,.28);
+ transition: box-shadow .15s;
+}
+
+.add-bn.disabled {
+ transform: scaleY(0);
+ transition: transform .2s .1s;
+ /* Delay is just to style consistently with .add-bn transition so
+ * we don't have to create a dummy parent just for the different transition.*/
+}
+
+.clear-float {
+ clear: both;
}
.destination {
- width: 100%;
position: relative;
+ width: 100%;
}
.destination input {
@@ -58,8 +77,12 @@
font-size: 14px;
}
+.selected {
+ box-shadow: 0 0 8px #05f;
+ z-index: 1;
+}
+
.map-canvas {
- width: 100%;
height: 100%;
}
@@ -81,13 +104,6 @@
cursor: pointer;
text-align: center;
transition: background-color .2s, border-bottom .2s;
-
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- -khtml-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
}
.messages.headlines .handle {
@@ -153,7 +169,94 @@
color: red;
}
-.selected {
- box-shadow: 0 0 8px #05f;
- z-index: 1;
+.mini-search {
+ overflow: hidden;
+ vertical-align: middle;
+ transform-origin: 0 0;
+ transition: transform .5s;
+}
+
+.mini-search.collapsed {
+ transform: translateX(-200%);
+ /* keep this in sync with js search disable delay */
+ transition: transform .5s;
+}
+
+.mini-search > * {
+ display: inline-block;
+ margin-left: 1em;
+}
+
+.mini-search .add-bn {
+ float: none;
+ margin-bottom: 12px;
+ transform-origin: 0 0;
+}
+
+.mini-search.collapsed .add-bn {
+ transform: initial;
+}
+
+.mini-search .destination {
+ width: 32em;
+}
+
+.no-select {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.timeline {
+ padding: 1em;
+ margin: 0;
+}
+
+.timeline-container {
+ box-shadow: inset -7px 0 7px -7px gray;
+ float: left;
+ height: 100%;
+ overflow-y: auto;
+ width: 25em;
+ transition: width .5s;
+}
+
+.timeline-container.collapsed {
+ overflow: hidden;
+ width: 0;
+ transition: width .5s;
+}
+
+.toggle-timeline {
+ background-color: #f0f0f0;
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 0 4px gray;
+ cursor: pointer;
+ letter-spacing: .5px;
+ padding: 4px 1em;
+ transform: rotate(90deg) translateY(-50%);
+ transform-origin: 0 50%;
+}
+
+.toggle-timeline:after {
+ content: '▼';
+ font-size: 6pt;
+ padding-left: 1em;
+}
+
+.toggle-timeline.collapsed {
+ background-color: white;
+}
+
+.toggle-timeline.collapsed:after {
+ content: '▲';
+}
+
+.vertical-middle {
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
}
diff --git a/src/strings.js b/src/strings.js
index 0f03161..bdd3a06 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -4,6 +4,10 @@
function getStrings(locale) {
return {
+ 'Add destination': 'Add destination',
+ change: function(object) {
+ return 'Change ' + object.toLowerCase();
+ },
'Connected to all services.': 'Connected to all services.',
'Connecting...': 'Connecting...',
'Destination': 'Destination',
@@ -23,6 +27,8 @@
return label + ': ' + details;
},
'Origin': 'Origin',
+ 'Search': 'Search',
+ 'Timeline': 'Timeline',
'Travel Planner': 'Travel Planner',
'Unknown error': 'Unknown error'
};
diff --git a/src/travel.js b/src/travel.js
index 1e0a4e0..953f7f3 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -3,19 +3,57 @@
// license that can be found in the LICENSE file.
var $ = require('./util/jquery');
-
-var Destinations = require('./components/destinations');
-var Messages = require('./components/messages');
-var Message = require('./components/message');
-var vanadiumWrapperDefault = require('./vanadium-wrapper');
-
+var raf = require('raf');
var defineClass = require('./util/define-class');
-var Map = require('./components/map');
-var TravelSync = require('./travelsync');
+var AddButton = require('./components/add-button');
+var DestinationSearch = require('./components/destination-search');
var Identity = require('./identity');
+var Map = require('./components/map');
+var Messages = require('./components/messages');
+var Message = require('./components/message');
+var Timeline = require('./components/timeline');
+var TravelSync = require('./travelsync');
+
+var vanadiumWrapperDefault = require('./vanadium-wrapper');
var strings = require('./strings').currentLocale;
+var describeDestination = require('./describe-destination');
+
+function bindControlToDestination(control, destination) {
+ function updateOrdinal() {
+ handleDestinationOrdinalUpdate(control, destination);
+ }
+
+ if (destination) {
+ destination.onPlaceChange.add(control.setPlace);
+ destination.onSelect.add(control.select);
+ destination.onDeselect.add(control.deselect);
+ destination.onOrdinalChange.add(updateOrdinal);
+ control.setPlace(destination.getPlace());
+ /* Since these controls are 1:1 with destinations, we don't want to stay in
+ * a state where the control has invalid text but the destination is still
+ * valid; that would be confusing to the user (e.g. abandoned query string
+ * "restaurants" for destination 4 Privet Drive.) */
+ control.onPlaceChange.add(destination.setPlace);
+ }
+
+ updateOrdinal();
+
+ if (destination && destination.isSelected()) {
+ control.select();
+ } else {
+ control.deselect();
+ }
+
+ return destination? function unbind() {
+ destination.onPlaceChange.remove(control.setPlace);
+ destination.onSelect.remove(control.select);
+ destination.onDeselect.remove(control.deselect);
+ destination.onOrdinalChange.remove(updateOrdinal);
+ control.onPlaceChange.remove(destination.setPlace);
+ } : $.noop;
+}
function buildStatusErrorStringMap(statusClass, stringGroup) {
var dict = {};
@@ -25,14 +63,19 @@
return dict;
}
+function handleDestinationOrdinalUpdate(control, destination) {
+ control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
+}
+
var Travel = defineClass({
publics: {
addDestination: function() {
var map = this.map;
var destination = map.addDestination();
- var control = this.destinations.append();
- control.bindDestination(destination);
+ var control = this.timeline.append();
+
+ bindControlToDestination(control, destination);
control.setSearchBounds(map.getBounds());
map.onBoundsChange.add(control.setSearchBounds);
@@ -45,21 +88,32 @@
});
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();
+ control.focus();
+
+ map.showSearchResults(results);
});
- return control;
+ this.timeline.disableAdd();
+ var oldLast = this.timeline.get(-2);
+ if (oldLast) {
+ this.unbindLastDestinationSearchEvents(oldLast);
+ }
+ this.bindLastDestinationSearchEvents(control);
+
+ this.bindMiniFeedback(destination);
+
+ return {
+ destination: destination,
+ control: control
+ };
},
error: function (err) {
- this.messages.push(Message.error(
- err.message || err.msg || err.toString()));
+ this.messages.push(Message.error(err));
},
info: function (info, promise) {
@@ -69,6 +123,144 @@
}
},
+ privates: {
+ /**
+ * Handles destination addition via the mini-UI.
+ */
+ addDestinationMini: function() {
+ this.miniDestinationSearch.clear();
+ this.map.closeActiveInfoWindow();
+
+ var destination = this.addDestination().destination;
+ destination.select();
+ this.miniDestinationSearch.focus();
+ this.miniDestinationSearch.setPlaceholder(strings['Add destination']);
+ },
+
+ bindMiniFeedback: function(destination) {
+ var mf = this.miniFeedback;
+
+ destination.onSelect.add(mf.handleSelect);
+ destination.onDeselect.add(mf.handleDeselect);
+ },
+
+ initMiniFeedback: function() {
+ var self = this;
+
+ //context: destination
+ function handlePlaceChange(place) {
+ self.miniDestinationSearch.setPlace(place);
+ self.miniDestinationSearch.setPlaceholder(
+ strings.change(describeDestination.description(this)));
+ }
+
+ //context: destination.
+ function handleSelect() {
+ handlePlaceChange.call(this, this.getPlace());
+ this.onPlaceChange.add(handlePlaceChange);
+ }
+
+ function handleDeselect() {
+ this.onPlaceChange.remove(handlePlaceChange);
+ if (self.miniDestinationSearch.getPlace()) {
+ self.miniDestinationSearch.clear();
+ }
+ self.miniDestinationSearch.setPlaceholder(strings['Search']);
+ }
+
+ this.miniFeedback = {
+ handleSelect: handleSelect,
+ handleDeselect: handleDeselect,
+ handlePlaceChange: handlePlaceChange
+ };
+ },
+
+ showTimeline: function() {
+ if (this.$timelineContainer.hasClass('collapsed')) {
+ this.$toggleTimeline.removeClass('collapsed');
+ this.$timelineContainer.removeClass('collapsed');
+ this.$minPanel.addClass('collapsed');
+ //disable the control, but wait until offscreen to avoid distraction
+ this.$minPanel.one('transitionend', this.miniDestinationSearch.disable);
+ this.watchMapResizes();
+ }
+ },
+
+ collapseTimeline: function() {
+ if (!this.$timelineContainer.hasClass('collapsed')) {
+ this.$toggleTimeline.addClass('collapsed');
+ this.$timelineContainer.addClass('collapsed');
+ this.$minPanel.removeClass('collapsed');
+ this.miniDestinationSearch.enable();
+ if (!this.miniDestinationSearch.getPlace()) {
+ this.miniDestinationSearch.focus();
+ }
+ this.watchMapResizes();
+ }
+ },
+
+ bindLastDestinationSearchEvents: function(control) {
+ control.onPlaceChange.add(this.handleLastPlaceChange);
+ control.onDeselect.add(this.handleLastPlaceDeselected);
+ },
+
+ unbindLastDestinationSearchEvents: function(control) {
+ control.onPlaceChange.remove(this.handleLastPlaceChange);
+ control.onDeselect.remove(this.handleLastPlaceDeselected);
+ },
+
+ handleLastPlaceChange: function(place) {
+ if (place) {
+ this.timeline.enableAdd();
+ } else {
+ this.timeline.disableAdd();
+ }
+ },
+
+ handleLastPlaceDeselected: function() {
+ var self = this;
+ /* Wait until next frame to allow selection/focus to update; we don't want
+ * to remove a box that has just received focus. */
+ raf(function() {
+ var lastControl = self.timeline.get(-1);
+ var oldLast = lastControl;
+
+ while (!lastControl.getPlace() && !lastControl.isSelected() &&
+ self.timeline.get().length > 1) {
+ self.timeline.remove(-1);
+ self.map.removeDestination(-1);
+ lastControl = self.timeline.get(-1);
+ }
+
+ if (oldLast !== lastControl) {
+ self.bindLastDestinationSearchEvents(lastControl);
+ self.handleLastPlaceChange(lastControl.getPlace());
+ }
+ });
+ },
+
+ /**
+ * The map widget isn't very sensitive to size updates, so we need to
+ * continuously invalidate during animations.
+ */
+ watchMapResizes: function() {
+ var newWidth = this.map.$.width();
+ if (newWidth !== this.mapWidth) {
+ this.widthStable = 0;
+
+ this.mapWidth = newWidth;
+ this.map.invalidateSize();
+ raf(this.watchMapResizes);
+
+ } else if (this.widthStable < 5) {
+ raf(this.watchMapResizes);
+ this.widthStable++;
+ } else {
+ this.mapWidth = null;
+ }
+ }
+ },
+
init: function (opts) {
var self = this;
@@ -79,19 +271,12 @@
var maps = map.maps;
var messages = this.messages = new Messages();
- var destinations = this.destinations = new Destinations(maps);
+ var timeline = this.timeline = new Timeline(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(error);
@@ -101,9 +286,6 @@
return sync.start(identity.mountName, wrapper);
}).then(function() {
return strings['Connected to all services.'];
- }, function(err) {
- console.error(err);
- throw err;
}));
var directionsServiceStatusStrings = buildStatusErrorStringMap(
@@ -116,11 +298,69 @@
error(message);
});
+ timeline.onAddClick.add(function() {
+ self.addDestination().control.focus();
+ });
+
+ var miniAddButton = this.miniAddButton = new AddButton();
+ var miniDestinationSearch = this.miniDestinationSearch =
+ new DestinationSearch(maps);
+
+ miniAddButton.onClick.add(this.addDestinationMini);
+
+ miniDestinationSearch.setPlaceholder(strings['Search']);
+ miniDestinationSearch.setSearchBounds(map.getBounds());
+ map.onBoundsChange.add(miniDestinationSearch.setSearchBounds);
+
+ miniDestinationSearch.onSearch.add(function(results) {
+ if (results.length > 0) {
+ /* If we've searched for a location via the minibox, any subsequent
+ * map click is probably intended to deselect the destination rather
+ * than pick by clicking. This differs from the timeline behavior since
+ * when we invalidate a timeline location, we delete the destination
+ * place and so must pick a new one. */
+ map.disableLocationSelection();
+ }
+ map.showSearchResults(results);
+ });
+
+ miniDestinationSearch.onPlaceChange.add(function(place) {
+ if (!place) {
+ self.map.enableLocationSelection();
+ }
+ });
+
+ var $miniPanel = this.$minPanel = $('<div>')
+ .addClass('mini-search')
+ .append(miniAddButton.$,
+ miniDestinationSearch.$);
+
+ /* This container lets us collapse the destination panel even though it has
+ * padding, without resorting to transform: scaleX which would
+ * unnecessarily distort the text (which is an effect that is nice for the
+ * add button, so that gets it explicitly). */
+ var $timelineContainer = this.$timelineContainer = $('<div>')
+ .addClass('timeline-container collapsed')
+ .append(timeline.$);
+
+ var $toggleTimeline = this.$toggleTimeline = $('<div>')
+ .addClass('toggle-timeline no-select collapsed')
+ .text(strings['Timeline'])
+ .mouseenter(this.showTimeline)
+ .click(this.collapseTimeline);
+
+ map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
+ map.addControls(maps.ControlPosition.LEFT_TOP, $miniPanel);
+ map.addControls(maps.ControlPosition.LEFT_CENTER, $toggleTimeline);
+
var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
- $domRoot.append(map.$);
+
+ $domRoot.append($timelineContainer, map.$);
+
+ this.initMiniFeedback();
this.addDestination();
- this.addDestination();
+ miniDestinationSearch.focus();
}
});
diff --git a/src/util/define-class.js b/src/util/define-class.js
index ba2074b..eede6b6 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -114,14 +114,20 @@
};
/**
- * Decorates a member function with a like-signatured function to be called
- * prior to the main invocation.
+ * Decorates a member function with like-signatured functions to be called
+ * before and/or after the main invocation.
*/
-defineClass.decorate = function(context, name, before) {
+defineClass.decorate = function(context, name, before, after) {
var proto = context[name];
context[name] = function() {
- before.apply(context, arguments);
- return proto.apply(context, arguments);
+ if (before) {
+ before.apply(context, arguments);
+ }
+ var ret = proto.apply(context, arguments);
+ if (after) {
+ after.apply(context, arguments);
+ }
+ return ret;
};
};
diff --git a/src/util/jquery.js b/src/util/jquery.js
index 049a576..4f35251 100644
--- a/src/util/jquery.js
+++ b/src/util/jquery.js
@@ -5,10 +5,13 @@
var jq = require('jquery');
var window = require('global/window');
+var $;
if (window.document) {
- module.exports = jq;
+ $ = jq;
} else {
var jsdom = require('jsdom').jsdom;
window = jsdom().parentWindow;
- module.exports = jq(window);
-}
\ No newline at end of file
+ $ = jq(window);
+}
+
+module.exports = $;
\ No newline at end of file