Click-to-select location and other location-related features
Change-Id: Id5c3d1df3eb77d2a949f2ce30b5df70730c50374
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 272aab7..8e3b5b2 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -19,20 +19,130 @@
}
});
-module.exports = {
- Map: function(canvas) {
+var InfoWindow = defineClass({
+ publics: {
+ open: function(map, marker) {
+ this.map = map;
+ map.registerInfoWindow(this.ifc);
+ },
+
+ close: function() {
+ this.map.unregisterInfoWindow(this.ifc);
+ },
+
+ toString: function() { return 'mock InfoWindow'; }
+ }
+});
+
+var Map = defineClass({
+ publics: {
+ registerInfoWindow: function(wnd) {
+ this.infoWindows.push(wnd);
+ },
+
+ unregisterInfoWindow: function(wnd) {
+ this.infoWindows = this.infoWindows.filter(function(elem) {
+ return elem !== wnd;
+ });
+ },
+
+ hasInfoWindow: function(wnd) {
+ return wnd? wnd in this.infoWindows : this.infoWindows.length > 0;
+ },
+
+ toString: function() { return 'mock Map'; }
+ },
+
+ constants: [ 'controls' ],
+
+ events: {
+ //some maps API members are lower_underscore
+ /* jshint camelcase: false */
+ bounds_changed: 'public',
+ click: 'public'
+ /* jshint camelcase: true */
+ },
+
+ init: function(canvas) {
this.controls = {};
this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
- },
- LatLng: function(){},
- ControlPosition: ControlPosition,
- places: {
- SearchBox: function(){}
+ this.infoWindows = [];
+ }
+});
+
+var Marker = defineClass({
+ publics: {
+ setClickable: function(){},
+
+ setIcon: function(icon) {
+ this.icon = icon;
+ },
+
+ getIcon: function() {
+ return this.icon;
+ },
+
+ setMap: function(map) {
+ this.map = map;
+ },
+
+ getMap: function() {
+ return this.map;
+ },
+
+ toString: function() { return 'mock Marker'; }
},
+ events: {
+ click: 'public'
+ },
+
+ init: function(opts) {
+ $.extend(this, opts);
+ }
+});
+
+var SearchBox = defineClass({
+ publics: {
+ toString: function() { return 'mock SearchBox'; }
+ },
+
+ events: {
+ //some maps API members are lower_underscore
+ /* jshint camelcase: false */
+ places_changed: 'public'
+ /* jshint camelcase: true */
+ }
+});
+
+module.exports = {
+ ControlPosition: ControlPosition,
+ Geocoder: function(){},
+ InfoWindow: InfoWindow,
+ LatLng: function(){},
+ Map: Map,
+ Marker: Marker,
+
event: {
- addListener: function(){}
+ addListener: function(instance, eventName, handler){
+ if (eventName in instance) {
+ instance[eventName].add(handler);
+ } else {
+ throw instance + ' does not mock event ' + eventName;
+ }
+ },
+ trigger: function(instance, eventName) {
+ instance[eventName].apply(instance,
+ Array.prototype.slice.call(arguments, 2));
+ }
+ },
+
+ places: {
+ SearchBox: SearchBox,
+ mockPlaceResult: {
+ geometry: {}
+ }
}
};
\ No newline at end of file
diff --git a/src/components/destination-info.js b/src/components/destination-info.js
new file mode 100644
index 0000000..6f648bc
--- /dev/null
+++ b/src/components/destination-info.js
@@ -0,0 +1,85 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+/**
+ * Given a Maps API address_components array, return an array of formatted
+ * address lines.
+ *
+ * TODO(rosswang): Is this really the best way?
+ */
+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 [];
+ }
+
+ var parts = addr.split(', ');
+ switch (parts.length) {
+ case 1:
+ return [addr];
+ case 2:
+ return parts.join(', ');
+ case 3:
+ return parts[0] === details.name?
+ [parts[1] + ', ' + parts[2]] : [parts[0] + ', ' + parts[1]];
+ case 4:
+ var line1 = parts[0];
+ var line2 = parts[1] + ', ' + parts[2];
+ return line1 === details.name? [line2] : [line1, line2];
+ default:
+ return parts;
+ }
+}
+
+function render(details) {
+ var $info = $('<div>').addClass('destination-info');
+
+ if (details && details.name) {
+ $info.append($('<div>')
+ .addClass('title')
+ .text(details.name));
+ }
+
+ var addressLines = formatAddress(details);
+ if (addressLines) {
+ $.each(addressLines,
+ function(i, line) {
+ $info.append($('<div>')
+ .addClass('address-line')
+ .text(line));
+ });
+ }
+
+ return $info[0];
+}
+
+var DestinationInfo = defineClass({
+ publics: {
+ close: function() {
+ this.infoWindow.close();
+ },
+
+ show: function(marker) {
+ this.infoWindow.open(this.map, marker);
+ },
+
+ setDetails: function(details) {
+ this.infoWindow.setContent(render(details));
+ this.infoWindow.setPosition(details && details.geometry.location);
+ }
+ },
+
+ init: function(maps, map, details) {
+ this.map = map;
+
+ this.infoWindow = new maps.InfoWindow({
+ content: render(details),
+ position: details && details.geometry.location
+ });
+ }
+});
+
+module.exports = DestinationInfo;
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
new file mode 100644
index 0000000..8e24baf
--- /dev/null
+++ b/src/components/destination-marker.js
@@ -0,0 +1,133 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+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 */
+}
+
+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());
+ },
+
+ topClient: function() {
+ return this.clients[this.clients.length - 1];
+ },
+
+ updateColor: function() {
+ var color = this.topClient().color;
+ this.marker.setIcon(markerIcon(color));
+ }
+ },
+
+ publics: {
+ clear: function() {
+ this.marker.setMap(null);
+ },
+
+ pushClient: function(client, color) {
+ color = color || this.topClient().color;
+ this.clients.push({ client: client, color: color, listeners: [] });
+ this.updateColor();
+ },
+
+ removeClient: function(client) {
+ var onClick = this.onClick;
+ this.clients = this.clients.filter(function(entry) {
+ var match = entry.client === client;
+ if (match) {
+ $.each(entry.listeners, function() {
+ onClick.remove(this);
+ });
+ }
+ return !match;
+ });
+
+ this.refreshClickability();
+
+ if (!this.clients.length) {
+ this.clear();
+ } else {
+ this.updateColor();
+ }
+ },
+
+ setColor: function(color) {
+ this.topClient().color = color;
+ this.updateColor();
+ }
+ },
+
+ events: [ 'onClick' ],
+ constants: [ 'marker', 'normalizedPlace' ],
+
+ /**
+ * A note on clients: destination markers can be shared between multiple use
+ * cases, ex. search and multiple actual destination associations. A marker
+ * is removed when all of its clients have been removed. The latest client
+ * determines the color of the marker. Click event handlers are added per
+ * client, unless they're added as global (a second argument to
+ * `Callbacks.add`); all remain active while their client is registered, but
+ * when the client is removed the corresponding click handlers are removed as
+ * well.
+ */
+ init: function(maps, map, normalizedPlace, client, color) {
+ var self = this;
+
+ this.map = map;
+ this.normalizedPlace = normalizedPlace;
+ this.clients = [{ client: client, color: color, listeners: [] }];
+
+ this.marker = new maps.Marker({
+ icon: markerIcon(color),
+ map: map,
+ place: normalizedPlace.place,
+ title: deriveTitle(normalizedPlace),
+ clickable: false
+ });
+
+ defineClass.decorate(this.onClick, 'add', function(listener, global) {
+ if (!global) {
+ /* Per jQuery, listener can also be an array; however, there seems to
+ * be a bug in jQuery at this time where remove will not remove arrays,
+ * only individual functions, so let's flatten here. */
+ var listeners = self.topClient().listeners;
+ if ($.isArray(listener)) {
+ $.each(listener, function() {
+ listeners.push(this);
+ });
+ } else {
+ listeners.push(listener);
+ }
+ }
+
+ self.refreshClickability();
+ });
+
+ maps.event.addListener(this.marker, 'click', $.proxy(this, 'onClick'));
+ }
+});
+
+module.exports = DestinationMarker;
diff --git a/src/components/destination.js b/src/components/destination.js
index 7d0e411..8a3933a 100644
--- a/src/components/destination.js
+++ b/src/components/destination.js
@@ -2,6 +2,42 @@
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: {
setSearchBounds: function(bounds) {
this.searchBox.setBounds(bounds);
@@ -9,14 +45,46 @@
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 normalized = this.normalizeDestination(placeDesc);
+
+ if (normalized && updateSearchBox !== false) {
+ this.$searchBox.prop('value', normalized.display);
+ }
+
+ this.place = normalized && normalized.place;
+ this.onSet(normalized);
}
},
events: [
/**
+ * @param event jQuery Event object for text box focus event.
+ */
+ 'onFocus',
+ /**
* @param places (array of places)
*/
- 'onSearch'
+ 'onSearch',
+ /**
+ * fired when the destination has been set to a place, or cleared.
+ * @param place the new destination, as a normalized place.
+ */
+ 'onSet'
],
constants: ['$'],
@@ -34,6 +102,11 @@
$searchBox.prop('value', initial);
}
+ $searchBox.focus(this.onFocus);
+ $searchBox.on('input', function() {
+ destination.set(null, false);
+ });
+
this.$ = $('<div>').addClass('destination')
.append($searchBox);
@@ -42,6 +115,10 @@
maps.event.addListener(this.searchBox, 'places_changed', function() {
destination.onSearch(destination.searchBox.getPlaces());
});
+
+ /* TODO(rosswang): can we for the love of squirrels stop the autocomplete
+ * from popping up after a location has been selected through a map click?
+ */
}
});
diff --git a/src/components/destinations.js b/src/components/destinations.js
index 088e02c..852cf2a 100644
--- a/src/components/destinations.js
+++ b/src/components/destinations.js
@@ -38,6 +38,10 @@
$.each(this.destinations, function(i, destination) {
handler(destination);
});
+ },
+
+ getDestinations: function() {
+ return this.destinations.slice(0);
}
},
diff --git a/src/components/maps.js b/src/components/maps.js
index 122a628..f63f893 100644
--- a/src/components/maps.js
+++ b/src/components/maps.js
@@ -2,23 +2,62 @@
var defineClass = require('../util/define-class');
var Destinations = require('./destinations');
+var DestinationInfo = require('./destination-info');
+var DestinationMarker = require('./destination-marker');
var Messages = require('./messages');
+var normalizeDestination = require('./destination').normalizeDestination;
+
+//named destination marker clients
+var SEARCH_CLIENT = 'search';
+
var Widget = defineClass({
publics: {
- clearMarkers: function() {
- var markers = this.markers;
- this.markers = [];
- $.each(markers, function(i, marker) {
- marker.setMap(null);
+ clearSearchMarkers: function() {
+ $.each(this.searchMarkers, function() {
+ this.removeClient(SEARCH_CLIENT);
});
+ this.searchMarkers = [];
},
closeActiveInfoWindow: function() {
- if (this.activeInfoWindow) {
- this.activeInfoWindow.close();
+ if (this.info) {
+ this.info.close();
}
- this.activeInfoWindow = null;
+ },
+
+ deselectDestinationControl: function() {
+ if (this.selectedDestinationControl) {
+ this.selectedDestinationControl.deselectControl();
+ this.selectedDestinationControl = null;
+ this.disableLocationSelection();
+ this.clearSearchMarkers();
+ this.closeActiveInfoWindow();
+ }
+ },
+
+ 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.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()));
+ }
},
message: function(message) {
@@ -27,71 +66,110 @@
},
privates: {
- destinationSelectionWindow: defineClass.innerClass({
- privates: {
- renderInfo: function() {
- var $info = $('<div>').addClass('destination-info');
+ createMarker: function(normalizedPlace, client, color) {
+ var marker = new DestinationMarker(this.maps, this.map, normalizedPlace,
+ client, color);
- $info.append($('<div>')
- .addClass('title')
- .text(this.place.name));
-
- return $info[0];
- }
- },
-
- init: function(place, createMarker) {
- var widget = this.outer;
- var maps = widget.maps;
- var map = widget.map;
-
- this.place = place;
-
- var infoWindow = new maps.InfoWindow({
- content: this.renderInfo(),
- position: place.geometry.location
- });
-
- var marker;
- if (createMarker) {
- marker = new maps.Marker({
- map: map,
- title: place.name,
- position: place.geometry.location
- });
-
- maps.event.addListener(marker, 'click', function() {
- widget.setActiveInfoWindow(infoWindow, marker);
- });
-
- widget.markers.push(marker);
- } else {
- widget.setActiveInfoWindow(infoWindow);
- }
+ if (normalizedPlace.details) {
+ marker.onClick.add($.proxy(this, 'showDestinationInfo', marker), true);
}
- }),
- setActiveInfoWindow: function(infoWindow, marker) {
- this.closeActiveInfoWindow();
- this.activeInfoWindow = infoWindow;
- infoWindow.open(this.map, marker);
+ return marker;
+ },
+
+ createDestinationMarker: function(normalizedPlace, destinationControl) {
+ var marker = this.createMarker(normalizedPlace, destinationControl,
+ this.getAppropriateDestinationMarkerColor(destinationControl));
+ destinationControl.marker = marker;
+ marker.onClick.add(
+ $.proxy(this, 'selectDestinationControl', destinationControl));
+
+ return marker;
+ },
+
+ showDestinationInfo: function(destinationMarker) {
+ if (!this.info) {
+ this.info = new DestinationInfo(
+ this.maps, this.map, destinationMarker.normalizedPlace.details);
+ } else {
+ this.info.setDetails(destinationMarker.normalizedPlace.details);
+ }
+
+ 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;
+ }
+
+ if (destination.marker) {
+ destination.marker.removeClient(destination);
+ }
+
+ destination.marker = marker;
+
+ if (marker) {
+ marker.pushClient(destination,
+ this.getAppropriateDestinationMarkerColor(destination));
+ marker.onClick.add(
+ $.proxy(this, 'selectDestinationControl', destination));
+ }
+ },
+
+ 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();
+ }
},
centerOnCurrentLocation: function() {
+ var widget = this;
var maps = this.maps;
var map = this.map;
// https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
if (global.navigator && global.navigator.geolocation) {
global.navigator.geolocation.getCurrentPosition(function(position) {
- map.setCenter(new maps.LatLng(position.coords.latitude,
- position.coords.longitude));
- });
+ var latLng = new maps.LatLng(
+ position.coords.latitude, position.coords.longitude);
+ map.setCenter(latLng);
+
+ widget.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);
+ });
+ }
+ });
+ });
}
},
bindDestinationControl: function (destination) {
- var widget = this;
var maps = this.maps;
var map = this.map;
@@ -99,57 +177,140 @@
destination.setSearchBounds(map.getBounds());
});
- destination.onSearch.add(function(places) {
- widget.clearMarkers();
- widget.closeActiveInfoWindow();
- var bounds = new maps.LatLngBounds();
+ destination.onFocus.add(
+ $.proxy(this, 'selectDestinationControl', destination));
+ destination.onSearch.add($.proxy(this, 'showDestinationSearchResults'));
+ destination.onSet.add($.proxy(this, 'handleDestinationSet', destination));
+ },
- if (places.length === 1) {
- var place = places[0];
- widget.destinationSelectionWindow(place, false);
+ enableLocationSelection: function() {
+ this.map.setOptions({ draggableCursor: 'auto' });
+ this.locationSelectionEnabled = true;
+ },
- map.setCenter(place.geometry.location);
- } else if (places.length > 1) {
- $.each(places, function(i, place) {
- widget.destinationSelectionWindow(place, true);
- bounds.extend(place.geometry.location);
+ disableLocationSelection: function() {
+ this.map.setOptions({ draggableCursor: null });
+ this.locationSelectionEnabled = false;
+ },
+
+ selectDestinationControl: function(dest) {
+ if (dest !== this.selectedDestinationControl) {
+ var prevDest = this.selectedDestinationControl;
+ if (prevDest && prevDest.marker) {
+ prevDest.marker.setColor(DestinationMarker.color.BLUE);
+ }
+ this.deselectDestinationControl();
+
+ 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(places) {
+ var widget = this;
+
+ 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) {
+ widget.associateDestinationMarker(dest, marker);
+ dest.set(place);
+ }
});
- map.fitBounds(bounds);
- }
- });
+ bounds.extend(place.geometry.location);
+ });
+
+ this.map.fitBounds(bounds);
+ }
+ },
+
+ selectLocation: function(latLng) {
+ var widget = this;
+ var maps = this.maps;
+
+ var dest = this.selectedDestinationControl;
+ if (dest && this.locationSelectionEnabled) {
+ widget.geocoder.geocode({ location: latLng },
+ function(results, status) {
+ if (status === maps.GeocoderStatus.OK) {
+ widget.associateDestinationMarker(dest, null);
+ dest.set(results[0]);
+ }
+ });
+ }
}
},
constants: ['$', 'maps'],
// https://developers.google.com/maps/documentation/javascript/tutorial
- init: function(maps) {
- this.maps = maps = maps || global.google.maps;
+ init: function(opts) {
+ opts = opts || {};
+ var widget = this;
+
+ var maps = opts.maps || global.google.maps;
+ this.maps = maps;
+ this.navigator = opts.navigator || global.navigator;
+ this.geocoder = new maps.Geocoder();
this.$ = $('<div>').addClass('map-canvas');
- this.markers = [];
+ this.searchMarkers = [];
this.route = {};
+ this.initialConfig = {
+ center: new maps.LatLng(37.4184, -122.0880), //Googleplex
+ zoom: 11
+ };
+
+ var map = new maps.Map(this.$[0], this.initialConfig);
+ this.map = map;
+
this.messages = new Messages();
this.destinations = new Destinations(maps);
- var config = {
- zoom: 11,
- center: new maps.LatLng(37.4184, -122.0880) //Googleplex
- };
+ this.destinations.addDestinationBindingHandler(
+ $.proxy(this, 'bindDestinationControl'));
- var map = new maps.Map(this.$[0], config);
- this.map = map;
+ maps.event.addListener(map, 'click', function(e) {
+ widget.selectLocation(e.latLng);
+ });
this.centerOnCurrentLocation();
var controls = map.controls;
-
- this.destinations.addDestinationBindingHandler(
- $.proxy(this, 'bindDestinationControl'));
-
controls[maps.ControlPosition.TOP_LEFT].push(this.destinations.$[0]);
controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
}
diff --git a/src/static/index.css b/src/static/index.css
index e6f76af..5b4a733 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -10,6 +10,7 @@
.destination {
width: 100%;
+ position: relative;
}
.destination input {
@@ -18,6 +19,11 @@
padding: 8px 16px;
}
+.destination-info .title {
+ font-weight: 500;
+ font-size: 14px;
+}
+
.map-canvas {
width: 100%;
height: 100%;
@@ -52,3 +58,8 @@
content: "x ";
color: red;
}
+
+.selected {
+ box-shadow: 0 0 8px #05f;
+ z-index: 1;
+}
diff --git a/src/travel.js b/src/travel.js
index d6f864b..25e2ad4 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -38,7 +38,7 @@
travel.sync.start(identity.mountName, wrapper).catch(reportError);
}, reportError);
- this.maps = new Maps(opts.maps);
+ this.maps = new Maps(opts);
var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
$domRoot.append(travel.maps.$);
}
diff --git a/src/util/define-class.js b/src/util/define-class.js
index 8df6c29..66b2fed 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -38,8 +38,12 @@
function defineClass(def) {
var constructor = function() {
- var pthis = $.extend({}, def.privates, def.publics, def.statics);
var ifc = this;
+ var pthis = $.extend({
+ ifc: ifc //expose reflexive public interface for private use
+ },
+ //extend in inverse precedence
+ def.statics, def.publics, def.privates);
if (def.events) {
if ($.isArray(def.events)) {
@@ -64,7 +68,7 @@
}
if (def.publics) {
- polyProxy(ifc, pthis, def.publics);
+ polyProxy(ifc, pthis, def.publics, true);
}
if (def.constants) {
@@ -94,18 +98,52 @@
};
};
-function polyProxy(proxy, context, members) {
- $.each(members, function(name, member) {
- proxy[name] = $.proxy(member, context);
- });
+/**
+ * Decorates a member function with a like-signatured function to be called
+ * prior to the main invocation.
+ */
+defineClass.decorate = function(context, name, before) {
+ var proto = context[name];
+ context[name] = function() {
+ before.apply(context, arguments);
+ return proto.apply(context, arguments);
+ };
+};
+
+/**
+ * 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) {
+ return function() {
+ return context[name].apply(context, arguments);
+ };
+}
+
+function polyProxy(proxy, context, members, lateBinding) {
+ $.each(members, $.isArray(members)?
+ function() {
+ proxy[this] =
+ lateBinding? lateProxy(context, this) : $.proxy(context, this);
+ } :
+ function(name, member) {
+ proxy[name] =
+ lateBinding? lateProxy(context, name) : $.proxy(member, context);
+ });
return proxy;
}
-function filterProxy(proxy, context, nameFilter) {
- $.each(context, function(name, member) {
- if (nameFilter(name)) {
- proxy[name] = $.proxy(member, context);
- }
+/**
+ * Replaces "this" returns with proxy.
+ */
+function polyReflexiveLateProxy(proxy, context, members) {
+ $.each(members, function(i, name) {
+ proxy[name] = function() {
+ context[name].apply(context, arguments);
+ return proxy;
+ };
});
return proxy;
}
@@ -113,8 +151,9 @@
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($.proxy(dispatcher, 'fire'), dispatcher, dispatcher);
+ var callableDispatcher = pthis[name] = polyProxy(function() {
+ dispatcher.fireWith.call(dispatcher, ifc, arguments);
+ }, dispatcher, dispatcher, false);
if (flags && flags.indexOf('private') > -1) {
return;
@@ -123,9 +162,16 @@
if (flags && flags.indexOf('public') > -1) {
ifc[name] = callableDispatcher;
} else {
- ifc[name] = filterProxy({}, dispatcher, function(name) {
- return name !== 'fire' && name !== 'fireWith';
- });
+ var publicEvent = {};
+ /* 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,
+ ['disabled', 'fired', 'has', 'locked'], true);
+ polyReflexiveLateProxy(publicEvent, callableDispatcher,
+ ['add', 'disable', 'empty', 'lock', 'remove']);
+
+ ifc[name] = publicEvent;
}
}
diff --git a/test/components/destination-info.js b/test/components/destination-info.js
new file mode 100644
index 0000000..8f2d72f
--- /dev/null
+++ b/test/components/destination-info.js
@@ -0,0 +1,24 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var DestinationInfo = require('../../src/components/destination-info');
+var mockMaps = require('../../mocks/google-maps');
+
+function setUpWithCanvas() {
+ var map = new mockMaps.Map($('<div>')[0]);
+ var info = new DestinationInfo(mockMaps, map,
+ mockMaps.places.mockPlaceResult);
+ return {
+ map: map,
+ info: info
+ };
+}
+
+test('lifecycle', function(t) {
+ var tc = setUpWithCanvas();
+ tc.info.show();
+ t.ok(tc.map.hasInfoWindow(), 'infoWindow opened');
+ tc.info.close();
+ t.notOk(tc.map.hasInfoWindow(), 'infoWindow closed');
+ t.end();
+});
diff --git a/test/components/destination-marker.js b/test/components/destination-marker.js
new file mode 100644
index 0000000..8da7a08
--- /dev/null
+++ b/test/components/destination-marker.js
@@ -0,0 +1,95 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var DestinationMarker = require('../../src/components/destination-marker');
+var normalizeDestination =
+ require('../../src/components/destination').normalizeDestination;
+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);
+}
+
+test('client events', function(t) {
+ var CLIENT1 = {};
+ var CLIENT2 = {};
+ var counts = [];
+ var h = [];
+
+ function makeHandler(i) {
+ return function() {
+ counts[i]++;
+ };
+ }
+
+ for (var i = 0; i < 6; i++) {
+ counts.push(0);
+ h.push(makeHandler(i));
+ }
+
+ var marker = mockMarker(CLIENT1, DestinationMarker.color.RED);
+
+ marker.onClick.add(h[0]);
+ marker.onClick.add([ h[1], h[2] ]);
+ marker.onClick.add(h[3]);
+ marker.pushClient(CLIENT2);
+ marker.onClick.add(h[4]);
+ marker.pushClient(CLIENT1);
+ marker.onClick.add(h[5]);
+
+ marker.marker.click();
+ t.deepEqual(counts, [1, 1, 1, 1, 1, 1], 'all handlers triggered');
+ marker.removeClient(CLIENT1);
+ marker.onClick.remove([ h[1], h[2] ]);
+ marker.marker.click();
+ t.deepEqual(counts, [1, 1, 1, 1, 2, 1], 'event handlers for CLIENT1 removed');
+
+ t.end();
+});
+
+test('colors', function(t) {
+ var marker = mockMarker('c1', DestinationMarker.color.RED);
+ var redIcon = marker.marker.getIcon();
+
+ marker.pushClient('c2', DestinationMarker.color.ORANGE);
+ var orangeIcon = marker.marker.getIcon();
+ t.notEqual(redIcon, orangeIcon, 'color changed with new client');
+ marker.setColor(DestinationMarker.color.YELLOW);
+ var yellowIcon = marker.marker.getIcon();
+ t.notEqual(orangeIcon, yellowIcon, 'color changed via setColor');
+
+ marker.pushClient('c3', DestinationMarker.color.GREEN);
+ var greenIcon = marker.marker.getIcon();
+ t.notEqual(yellowIcon, greenIcon,
+ 'color changed with new client after setColor');
+ marker.setColor(DestinationMarker.color.BLUE);
+ var blueIcon = marker.marker.getIcon();
+
+ marker.removeClient('c2');
+ t.equal(marker.marker.getIcon(), blueIcon,
+ 'color not changed with earlier client removed');
+
+ marker.pushClient('c4', DestinationMarker.color.PURPLE);
+ var purpleIcon = marker.marker.getIcon();
+ t.notEqual(blueIcon, purpleIcon,
+ 'color changed with new client after earlier removal');
+
+ marker.removeClient('c4');
+ t.equal(marker.marker.getIcon(), blueIcon,
+ 'color restored after client removal');
+
+ marker.removeClient('c3');
+ t.equal(marker.marker.getIcon(), redIcon,
+ 'color restored to original after client removal with earlier removal of ' +
+ 'intermediate client');
+
+ t.ok(marker.marker.getMap(), 'marker still attached to map');
+
+ marker.removeClient('c1');
+ t.notOk(marker.marker.getMap(), 'marker detached from map');
+
+ t.end();
+});
diff --git a/test/components/maps.js b/test/components/maps.js
index 013661b..c527473 100644
--- a/test/components/maps.js
+++ b/test/components/maps.js
@@ -8,7 +8,9 @@
var mockMaps = require('../../mocks/google-maps');
test('message display', function(t) {
- var maps = new Maps(mockMaps);
+ var maps = new Maps({
+ maps: mockMaps
+ });
var $messages = $('.messages', maps.$);
t.ok($messages.length, 'message display exists');
@@ -23,5 +25,3 @@
t.end();
});
-
-module.exports = mockMaps;
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
index 68c6382..4657f7a 100644
--- a/test/util/define-class.js
+++ b/test/util/define-class.js
@@ -36,12 +36,14 @@
t.ok(testInstance, 'instance instantiated');
var queried = 0, queriedOnce = 0;
- testInstance.stringQueried.add(function(value) {
+ t.notOk(testInstance.stringQueried.add(function(value) {
t.equal(value, 'world', 'event argument');
queried++;
- });
+ }).fire, 'callback accessibility leak');
+ t.ok(testInstance.stringQueried.has(), 'callback proxied accessor');
testInstance.stringQueriedOnce.add(function(value) {
t.equal(value, 'world', 'event argument');
+ t.equal(this, testInstance, 'event context');
queriedOnce++;
});
@@ -186,3 +188,86 @@
t.end();
});
+
+test('late binding of public members', function(t) {
+ var TestClass = defineClass({
+ publics: {
+ getValue: function() {
+ return 'a';
+ },
+ rebind: function() {
+ this.getValue = function() {
+ return 'b';
+ };
+ }
+ }
+ });
+ var testInstance = new TestClass();
+ testInstance.rebind();
+ t.equal(testInstance.getValue(), 'b',
+ 'public interface should late-bind to private');
+ t.end();
+});
+
+test('late binding of event members', function(t) {
+ var fireCount = 0;
+
+ function listener() {
+ fireCount++;
+ }
+
+ var TestClass = defineClass({
+ publics: {
+ addA: function() {
+ this.onA.add(listener);
+ },
+
+ a: function() {
+ this.onA();
+ },
+
+ b: function() {
+ this.onB();
+ },
+
+ getListenerCount: function() {
+ return this.listenerCount;
+ }
+ },
+
+ events: {
+ onA: 'private',
+ onB: '',
+ onC: 'public'
+ },
+
+ init: function() {
+ var self = this;
+ this.listenerCount = 0;
+
+ function decorateEvent(event) {
+ defineClass.decorate(event, 'add', function() {
+ self.listenerCount++;
+ });
+ }
+
+ decorateEvent(this.onA);
+ decorateEvent(this.onB);
+ decorateEvent(this.onC);
+ }
+ });
+
+ var testInstance = new TestClass();
+ testInstance.addA();
+ t.equal(testInstance.getListenerCount(), 1, 'events decorated');
+ testInstance.onB.add(listener);
+ t.equal(testInstance.getListenerCount(), 2, 'events decorated');
+ testInstance.onC.add(listener);
+ t.equal(testInstance.getListenerCount(), 3, 'events decorated');
+ testInstance.a();
+ testInstance.b();
+ testInstance.onC();
+ t.equal(fireCount, 3, 'events still work');
+
+ t.end();
+});