famous-map
Version:
Map integration for Famo.us (google-maps, leaflet, open-layers & mapboxgl)
632 lines (576 loc) • 23.4 kB
JavaScript
/**
* This Source Code is licensed under the MIT license. If a copy of the
* MIT-license was not distributed with this file, You can obtain one at:
* http://opensource.org/licenses/mit-license.html.
*
* @author: Hein Rutjes (IjzerenHein)
* @license MIT
* @copyright Gloey Apps, 2014/2015
*/
/*global google, L, ol, mapboxgl*/
/**
* MapView encapsulates a Google maps view so it can be used with famo.us.
*
* Additionally it adds methods to set the position and zoom-factor of the map using transitions.
* Use `MapModifier` and `MapStateModifier` to place famo.us renderables on the map, much like google-maps markers.
*
* **Map-types**
*
* |Value|Description|
* |---|---|
* |MapType.GOOGLEMAPS (default)|Google-maps|
* |MapType.LEAFLET|Leaflet.js|
* |MapType.OPENLAYERS3|Open layers 3|
* |MapType.MAPBOXGL|Mapbox GL|
* @module
*/
define(function(require, exports, module) {
'use strict';
// import dependencies
var Surface = require('famous/core/Surface');
var SizeAwareView = require('famous/views/SizeAwareView');
var Transitionable = require('famous/transitions/Transitionable');
var MapUtility = require('./MapUtility');
var MapPositionTransitionable = require('./MapPositionTransitionable');
var MapTransition = require('./MapTransition');
Transitionable.registerMethod('map-speed', MapTransition);
var globalMapViewId = 1;
/*
* Map-type
* @enum {Number}
* @alias module:MapView.MapType
*/
var MapType = {
GOOGLEMAPS: 1,
LEAFLET: 2,
OPENLAYERS3: 3,
MAPBOXGL: 4
};
/**
* @class
* @param {Object} options Options.
* @param {MapType} options.type Map-type (e.g. MapView.MapType.GOOGLEMAPS, MapView.MapType.LEAFLET, MapView.MapType.OPENLAYERS3).
* @param {Object} options.mapOptions Options that are passed directly to the Map object. The options should include the 'center' and 'zoom'.
* @param {String} [options.id] Id of the DOM-element to use. When ommitted, a DOM-element is created using a surface.
* @param {Transition} [options.zoomTransition] Transition to use for smoothly zooming renderables (by default a transition of 120 ms is used).
* @alias module:MapView
*/
function MapView() {
SizeAwareView.apply(this, arguments);
// Initialize
this.map = null;
this.mapType = this.options.type;
this._position = new MapPositionTransitionable(this.options.mapOptions.center);
this._zoom = {
center: new MapPositionTransitionable(this.options.mapOptions.center),
northEast: new MapPositionTransitionable(this.options.mapOptions.center),
southWest: new MapPositionTransitionable(this.options.mapOptions.center)
};
this._cache = {};
// Disable zoom-transitions for leaflet
if (this.mapType === MapType.LEAFLET) {
this.options.zoomTransition = {duration: 0};
}
// When a specific dom-id is specified, use that
if (this.options.mapOptions && this.options.id) {
this.mapId = this.options.id;
}
else {
// Otherwise generate unique id, and create the div ourselves
this.mapId = 'MapView' + globalMapViewId;
globalMapViewId++;
// Insert div into the DOM
var surface = new Surface({
classes: ['mapview', 'fm-mapview'], // mapview is included for backwards compat
content: '<div id="' + this.mapId + '" style="width: 100%; height: 100%;"></div>',
size: [undefined, undefined]
});
this.add(surface);
this._surface = surface;
}
}
MapView.prototype = Object.create(SizeAwareView.prototype);
MapView.prototype.constructor = MapView;
MapView.MapType = MapType;
MapView.DEFAULT_OPTIONS = {
type: MapType.GOOGLEMAPS,
mapOptions: {
zoom: 10,
center: {lat: 51.4400867, lng: 5.4782571}
},
id: null,
zoomTransition: {duration: 100}
};
/**
* Initializes the map (happens after the DOM element has been created).
*
* @private
* @ignore
*/
MapView.prototype._initMap = function() {
// Try to find DOM element
var elm = document.getElementById(this.mapId);
if (!elm) {
return;
}
// Supported map-types
var options;
switch (this.mapType) {
// Create google.maps.Map
case MapType.GOOGLEMAPS:
this.map = new google.maps.Map(elm, this.options.mapOptions);
// Listen for the first occurance of 'projection_changed', to ensure the map is full
// initialized.
var func = this.map.addListener('projection_changed', function() {
google.maps.event.removeListener(func);
this._initComplete = true;
}.bind(this));
break;
// Create leaflet Map
case MapType.LEAFLET:
this.map = L.map(elm, this.options.mapOptions);
this._initComplete = true;
break;
// Create open layers 3 Map
case MapType.OPENLAYERS3:
options = this.options.mapOptions;
var center = options.center;
this.map = new ol.Map({
target: elm,
controls: ol.control.defaults({attributionOptions: {collapsible: false}}),
view: new ol.View({
center: ol.proj.transform([MapUtility.lng(center), MapUtility.lat(center)], 'EPSG:4326', 'EPSG:3857'),
zoom: options.zoom
})
});
this._surface.on('resize', function() {
this.map.updateSize();
}.bind(this));
// When famo.us removes the OpenLayers div from the DOM, the canvas gets
// hidden and is not restored to its visible state when shown again.
// The following code, calls 'updateSize' whenever famo.us re-deploys
// the surface to the DOM, fixing this issue.
this._surface.on('deploy', function() {
if (this._initComplete) {
this.map.updateSize();
}
}.bind(this));
this.map.once('postrender', function() {
this._initComplete = true;
}.bind(this));
break;
// Create mapbox GL Map
case MapType.MAPBOXGL:
options = {};
for (var key in this.options.mapOptions) {
options[key] = this.options.mapOptions[key];
}
options.container = elm;
this.map = new mapboxgl.Map(options);
this._initComplete = true;
break;
}
};
/**
* Get the internal map-object. This object may not yet have been initialized, the map is only
* guarenteed to be valid after the 'load' event has been emited.
*
* @return {Map} Map object.
*/
MapView.prototype.getMap = function() {
return this.map;
};
/**
* Returns `true` when the map-view has been fully initialized.
*
* @return {Bool} true/false.
*/
MapView.prototype.isInitialized = function() {
return this._initComplete && this._loadEventEmitted;
};
/**
* Set the center of the map to the given geographical coordinates.
*
* @param {LatLng} position Position in geographical coordinates.
* @param {Transitionable} [transition] Transitionable.
* @param {Function} [callback] callback to call after transition completes.
*/
MapView.prototype.setPosition = function(position, transition, callback) {
this._position.set(position, transition, callback);
this._positionInvalidated = true;
return this;
};
/**
* Get the current center position of the map, in geographical coordinates.
*
* @return {LatLng} Position in geographical coordinates.
*/
MapView.prototype.getPosition = function() {
return this._zoom.center.get();
};
/**
* Get the destination center position of the map, in geographical coordinates.
*
* @return {LatLng} Position in geographical coordinates.
*/
MapView.prototype.getFinalPosition = function() {
return this._position.getFinal();
};
/**
* Get the current zoom-level of the map, taking into account smooth transition between zoom-levels.
* E.g., when zooming from zoom-level 4 to 5, this function returns an increasing value starting at 4 and ending
* at 5, over time. The used zoomTransition can be set as an option.
*
* @return {Number} Zoom-level.
*/
MapView.prototype.getZoom = function() {
return this._cache.zoom;
};
/**
* Get the rotation of the map. 0 means north-up.
* @return {Number} Rotation in radians.
*/
MapView.prototype.getRotation = function() {
switch (this.mapType) {
case MapType.GOOGLEMAPS:
case MapType.LEAFLET:
return 0;
case MapType.OPENLAYERS3:
return this.map.getView().getRotation();
case MapType.MAPBOXGL:
return (this.map.getBearing() * Math.PI) / -180;
}
};
/**
* Get the position in pixels (relative to the left-top of the container) for the given geographical position.
*
* @param {LatLng} position in geographical coordinates.
* @return {Point} Position in pixels, relative to the left-top of the mapView.
*/
MapView.prototype.pointFromPosition = function(position) {
var pnt;
switch (this.mapType) {
case MapType.GOOGLEMAPS:
if (!(position instanceof google.maps.LatLng)) {
position = new google.maps.LatLng(MapUtility.lat(position), MapUtility.lng(position), true);
}
var worldPoint = this.map.getProjection().fromLatLngToPoint(position);
return {
x: (worldPoint.x - this._cache.bottomLeft.x) * this._cache.scale,
y: (worldPoint.y - this._cache.topRight.y) * this._cache.scale
};
case MapType.LEAFLET:
// Note: smooth zooming is not yet supported for leaflet
pnt = this.map.latLngToContainerPoint(position);
return pnt;
case MapType.OPENLAYERS3:
// Note: updates during map interaction are not yet supported
pnt = this.map.getPixelFromCoordinate(ol.proj.transform([MapUtility.lng(position), MapUtility.lat(position)], 'EPSG:4326', 'EPSG:3857'));
return {x: pnt[0], y: pnt[1]};
case MapType.MAPBOXGL:
return this.map.project([MapUtility.lat(position), MapUtility.lng(position)]);
}
};
/**
* Get the geographical coordinates for a given position in pixels (relative to the left-top of the container).
*
* @param {Point} point Position in pixels, relative to the left-top of the mapView.
* @return {LatLng} Position in geographical coordinates.
*/
MapView.prototype.positionFromPoint = function(point) {
switch (this.mapType) {
case MapType.GOOGLEMAPS:
var worldPoint = new google.maps.Point(
(point.x / this._cache.scale) + this._cache.bottomLeft.x,
(point.y / this._cache.scale) + this._cache.topRight.y
);
return this.map.getProjection().fromPointToLatLng(worldPoint);
case MapType.LEAFLET:
// Note: smooth zooming is not yet supported for leaflet
return this.map.containerPointToLatLng(point);
case MapType.OPENLAYERS3:
// Note: updates during map interaction are not yet supported
var lonLat = ol.proj.transform(this.map.getCoordinateFromPixel([point.x, point.y]), 'EPSG:3857', 'EPSG:4326');
return {lat: lonLat[1], lng: lonLat[0]};
case MapType.MAPBOXGL:
return this.map.unproject(point);
}
};
/**
* Get the size of the map-view in pixels.
*
* @return {Array.Number} Size of the mapView.
*/
MapView.prototype.getSize = function() {
return this._cache.size;
};
/**
* Halts any pending transitions.
*/
MapView.prototype.halt = function() {
this._position.halt();
this._positionInvalidated = true;
};
/**
* Is there at least one action pending completion?
*
* @return {Bool} True when there are active transitions running.
*/
MapView.prototype.isActive = function() {
return this._position.isActive();
};
/**
* @private
* @ignore
*/
MapView.prototype._updateCache = function(zoom, northEast, southWest) {
// Store final data
this._cache.finalZoom = zoom;
this._cache.finalScale = Math.pow(2, this._cache.finalZoom);
this._cache.finalNorthEast = northEast;
this._cache.finalSouthWest = southWest;
// Calculate size of the MapView
switch (this.mapType) {
case MapType.GOOGLEMAPS:
if (!(northEast instanceof google.maps.LatLng)) {
northEast = new google.maps.LatLng(MapUtility.lat(northEast), MapUtility.lng(northEast), true);
}
if (!(southWest instanceof google.maps.LatLng)) {
southWest = new google.maps.LatLng(MapUtility.lat(southWest), MapUtility.lng(southWest), true);
}
var topRight = this.map.getProjection().fromLatLngToPoint(northEast);
var bottomLeft = this.map.getProjection().fromLatLngToPoint(southWest);
this._cache.size = [
(topRight.x - bottomLeft.x) * this._cache.finalScale,
(bottomLeft.y - topRight.y) * this._cache.finalScale
];
break;
case MapType.LEAFLET:
var point = this.map.getSize();
this._cache.size = [point.x, point.y];
break;
case MapType.OPENLAYERS3:
this._cache.size = this.map.getSize();
break;
case MapType.MAPBOXGL:
this._cache.size = this.getParentSize();
break;
}
// Calculate current world point edges and scale
switch (this.mapType) {
case MapType.GOOGLEMAPS:
northEast = this._zoom.northEast.get();
southWest = this._zoom.southWest.get();
if (!(northEast instanceof google.maps.LatLng)) {
northEast = new google.maps.LatLng(MapUtility.lat(northEast), MapUtility.lng(northEast), true);
}
if (!(southWest instanceof google.maps.LatLng)) {
southWest = new google.maps.LatLng(MapUtility.lat(southWest), MapUtility.lng(southWest), true);
}
this._cache.topRight = this.map.getProjection().fromLatLngToPoint(northEast);
this._cache.bottomLeft = this.map.getProjection().fromLatLngToPoint(southWest);
this._cache.scale = this._cache.size[0] / (this._cache.topRight.x - this._cache.bottomLeft.x);
this._cache.zoom = Math.log(this._cache.scale) / Math.log(2);
break;
case MapType.LEAFLET:
case MapType.OPENLAYERS3:
case MapType.MAPBOXGL:
// Note: smooth zooming is not yet supported for leaflet, and
// updates during map interaction are not yet supported for ol3
this._cache.zoom = zoom;
break;
}
};
/**
* Get map-information from the underlying map-provider, such as position, bounds, zoom-level...
*
* @private
* @ignore
*/
MapView.prototype._getMapInfo = function() {
var bounds;
var northEast;
var southWest;
var center;
var zoom;
switch (this.mapType) {
case MapType.GOOGLEMAPS:
// map.getBounds() returns the northEast and southWest in wrapped coordinates (between -180..180).
// This makes it difficult to create a linear coordinate space for converting world-coordinates
// into pixels. This function therefore 'unwraps' the northEast and southWest coordinates using
// * map.getCenter() (which does return unwrapped coordinates).
bounds = this.map.getBounds();
center = this.map.getCenter();
zoom = this.map.getZoom();
var centerLng = MapUtility.lng(center);
northEast = bounds.getNorthEast();
var northEastLng = northEast.lng();
while (northEastLng < centerLng) {
northEastLng += 360;
}
while (northEastLng > (centerLng + 360)) {
northEastLng -= 360;
}
southWest = bounds.getSouthWest();
var southWestLng = southWest.lng();
while (southWestLng < (centerLng - 360)) {
southWestLng += 360;
}
while (southWestLng > centerLng) {
southWestLng -= 360;
}
return {
zoom: zoom,
center: {lat: center.lat(), lng: center.lng()},
southWest: {lat: southWest.lat(), lng: southWestLng},
northEast: {lat: northEast.lat(), lng: northEastLng}
};
case MapType.LEAFLET:
bounds = this.map.getBounds();
southWest = bounds.getSouthWest();
northEast = bounds.getNorthEast();
center = this.map.getCenter();
zoom = this.map.getZoom();
return {
zoom: zoom,
center: {lat: center.lat, lng: center.lng},
southWest: {lat: southWest.lat, lng: southWest.lng},
northEast: {lat: northEast.lat, lng: northEast.lng}
};
case MapType.OPENLAYERS3:
var view = this.map.getView();
bounds = ol.proj.transformExtent(view.calculateExtent(this.map.getSize()), 'EPSG:3857', 'EPSG:4326');
center = ol.proj.transform(view.getCenter(), 'EPSG:3857', 'EPSG:4326');
zoom = view.getZoom();
return {
zoom: zoom,
center: {lat: center[1], lng: center[0]},
southWest: {lat: bounds[1], lng: bounds[0]},
northEast: {lat: bounds[3], lng: bounds[2]},
rotation: view.getRotation()
};
case MapType.MAPBOXGL:
bounds = this.map.getBounds();
return {
zoom: this.map.getZoom(),
center: this.map.getCenter(),
southWest: bounds.getSouthWest(),
northEast: bounds.getNorthEast(),
rotation: (this.map.getBearing() * Math.PI) / -180
};
}
};
/**
* Renders the view.
*
* @private
* @ignore
*/
MapView.prototype.render = function render() {
// Init the map (once)
if (!this.map) {
this._initMap();
}
if (this._initComplete) {
// When the zoom-level is changed by the map, start a transition
// that runs alongside.
var options;
var info = this._getMapInfo();
var invalidateCache = false;
if (info.zoom !== this._cache.finalZoom) {
this._zoom.northEast.halt();
this._zoom.southWest.halt();
this._zoom.center.halt();
this._zoom.northEast.set(info.northEast, this.options.zoomTransition);
this._zoom.southWest.set(info.southWest, this.options.zoomTransition);
this._zoom.center.set(info.center, this.options.zoomTransition);
invalidateCache = true;
}
else if (!this._zoom.northEast.isActive()) {
this._zoom.northEast.reset(info.northEast);
this._zoom.southWest.reset(info.southWest);
this._zoom.center.reset(info.center);
}
else {
this._zoom.northEast.get(); // ensure that .get() always gets called to ensure that isActive() works
invalidateCache = true;
}
// Update the cache
if (invalidateCache || (info.zoom !== this._cache.finalZoom) ||
!MapUtility.equals(info.northEast, this._cache.finalNorthEast) ||
!MapUtility.equals(info.southWest, this._cache.finalSouthWest)) {
//console.log('updating cache..');
this._updateCache(info.zoom, info.northEast, info.southWest);
}
// Get/set map center
if (this._position.isActive() || this._positionInvalidated) {
options = {
center: this._position.get()
};
this._positionInvalidated = false;
}
else {
this._position.reset(info.center);
}
if (options) {
switch (this.mapType) {
case MapType.GOOGLEMAPS:
this.map.setOptions(options);
break;
case MapType.LEAFLET:
this.map.panTo(options.center, {animate: false});
break;
case MapType.OPENLAYERS3:
this.map.getView().setCenter(ol.proj.transform([MapUtility.lng(options.center), MapUtility.lat(options.center)], 'EPSG:4326', 'EPSG:3857'));
break;
case MapType.MAPBOXGL:
this.map.setCenter([MapUtility.lat(options.center), MapUtility.lng(options.center)]);
break;
}
}
if (!this._loadEventEmitted) {
this._loadEventEmitted = true;
this._eventOutput.emit('load', this);
}
}
// Call super
return this._node.render();
};
/**
* Helper function that checks whether the given DOM element
* is a child of a MapView.
*/
function _elementIsChildOfMapView(element) {
if (element.className.indexOf('fm-mapview') >= 0) {
return true;
}
return element.parentElement ? _elementIsChildOfMapView(element.parentElement) : false;
}
/**
* Installs the selective touch-move handler so that Google Maps
* can be correctly used with touch events (mobile).
*
* Install this handler before creating the main Context:
* ```javascript
* var Engine = require('famous/core/Engine');
* var MapView = require('famous-map/MapView');
* var isMobile = require('ismobilejs');
*
* if (isMobile.any) {
* Engine.setOptions({appMode: false});
* MapView.installSelectiveTouchMoveHandler();
* }
*
* var mainContext = Engine.createContext();
* ...
* ```
*/
MapView.installSelectiveTouchMoveHandler = function() {
window.addEventListener('touchmove', function(event) {
if (!_elementIsChildOfMapView(event.target)) {
event.preventDefault();
}
});
};
module.exports = MapView;
});