diff --git a/geo.js b/geo.js index 5ac775d257..83b187a7b9 100644 --- a/geo.js +++ b/geo.js @@ -97,7 +97,7 @@ geo.registerRenderer = function (name, func) { * Create new instance of the renderer */ ////////////////////////////////////////////////////////////////////////////// -geo.createRenderer = function (name, layer, canvas, options) { +geo.createRenderer = function (name, layer, canvas, options) { 'use strict'; if (geo.renderers.hasOwnProperty(name)) { @@ -110,6 +110,42 @@ geo.createRenderer = function (name, layer, canvas, options) { return null; }; +////////////////////////////////////////////////////////////////////////////// +/** + * Check if the named renderer is supported. If not, display a warning and get + * the name of a fallback renderer. Ideally, we would pass a list of desired + * features, and, if the renderer is unavailable, this would choose a fallback + * that would support those features. + * + * @params {string|null} name name of the desired renderer + * @params {boolean} noFallack if true, don't recommend a fallback + * @return {string|null|false} the name of the renderer that should be used + * of false if no valid renderer can be determined. + */ +////////////////////////////////////////////////////////////////////////////// +geo.checkRenderer = function (name, noFallback) { + 'use strict'; + if (name === null) { + return name; + } + if (geo.renderers.hasOwnProperty(name)) { + var ren = geo.renderers[name]; + if (!ren.supported || ren.supported()) { + return name; + } + if (!ren.fallback || noFallback) { + return false; + } + var fallback = geo.checkRenderer(ren.fallback(), true); + if (fallback !== false) { + console.warn(name + ' renderer is unavailable, using ' + fallback + + ' renderer instead'); + } + return fallback; + } + return false; +}; + ////////////////////////////////////////////////////////////////////////////// /** * Register a new feature type @@ -303,7 +339,7 @@ Math.sinh = Math.sinh || function (x) { /*global geo*/ -geo.version = "0.6.0"; +geo.version = "0.7.0"; ////////////////////////////////////////////////////////////////////////////// /** @@ -13046,6 +13082,18 @@ vgl.DataBuffers = function (initialSize) { return a; }, + /** + * Compare two arrays and return if their contents are equal. + * @param {array} a1 first array to compare + * @param {array} a2 second array to compare + * @returns {boolean} true if the contents of the arrays are equal. + */ + compareArrays: function (a1, a2) { + return (a1.length === a2.length && a1.every(function (el, idx) { + return el === a2[idx]; + })); + }, + /** * Create a vec3 that is always an array. This should only be used if it * will not be used in a WebGL context. Plain arrays usually use 64-bit @@ -14104,7 +14152,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 'use strict'; geo.util.scale = { - d3: d3.scale + d3: typeof d3 !== 'undefined' ? d3.scale : undefined }; })(); @@ -15114,11 +15162,11 @@ inherit(geo.transform, geo.object); * camera provides a methods for converting between a map's coordinate system * to display pixel coordinates. * - * For the moment, all camera trasforms are assumed to be expressible as + * For the moment, all camera transforms are assumed to be expressible as * 4x4 matrices. More general cameras may follow that break this assumption. * * The interface for the camera is relatively stable for "map-like" views, - * i.e. when the camera is pointing in the direction [0, 0, -1], and placed + * e.g. when the camera is pointing in the direction [0, 0, -1], and placed * above the z=0 plane. More general view changes and events have not yet * been defined. * @@ -15134,11 +15182,13 @@ inherit(geo.transform, geo.object); * * {@link geo.event.camera.viewport} when the viewport changes * * By convention, protected methods do not update the internal matrix state, - * public methods do. For now, there are two primary methods that are - * inteded to be used by external classes to mutate the internal state: + * public methods do. There are a few primary methods that are intended to + * be used by external classes to mutate the internal state: * * * bounds: Set the visible bounds (for initialization and zooming) * * pan: Translate the camera in x/y by an offset (for panning) + * * viewFromCenterSizeRotation: set the camera view based on a center + * point, boundary size, and rotation angle. * * @class * @extends geo.object @@ -15628,22 +15678,21 @@ inherit(geo.transform, geo.object); * @returns {object} bounds object */ this._getBounds = function () { - var pt, bds = {}; - - - // get lower bounds - pt = this.displayToWorld({ - x: 0, y: this._viewport.height + var ul, ur, ll, lr, bds = {}; + + // get corners + ul = this.displayToWorld({x: 0, y: 0}); + ur = this.displayToWorld({x: this._viewport.width, y: 0}); + ll = this.displayToWorld({x: 0, y: this._viewport.height}); + lr = this.displayToWorld({ + x: this._viewport.width, + y: this._viewport.height }); - bds.left = pt.x; - bds.bottom = pt.y; - // get upper bounds - pt = this.displayToWorld({ - x: this._viewport.width, y: 0 - }); - bds.right = pt.x; - bds.top = pt.y; + bds.left = Math.min(ul.x, ur.x, ll.x, lr.x); + bds.bottom = Math.min(ul.y, ur.y, ll.y, lr.y); + bds.right = Math.max(ul.x, ur.x, ll.x, lr.x); + bds.top = Math.max(ul.y, ur.y, ll.y, lr.y); return bds; }; @@ -15665,19 +15714,45 @@ inherit(geo.transform, geo.object); * @return {this} Chainable */ this._setBounds = function (bounds) { + var size = { + width: bounds.right - bounds.left, + height: bounds.top - bounds.bottom + }; + var center = { + x: (bounds.left + bounds.right) / 2, + y: (bounds.bottom + bounds.top) / 2 + }; + + this._viewFromCenterSizeRotation(center, size, 0); + return this; + }; + /** + * Sets the view matrix so that the given world center is centered, at + * least a certain width and height are visible, and a rotation is applied. + * The resulting bounds may be larger in width or height than the values if + * the viewport is a different aspect ratio. + * + * @protected + * @param {object} center + * @param {number} center.x + * @param {number} center.y + * @param {object} size + * @param {number} size.width + * @param {number} size.height + * @param {number} rotation in clockwise radians. Optional + * @return {this} Chainable + */ + this._viewFromCenterSizeRotation = function (center, size, rotation) { var translate = geo.util.vec3AsArray(), scale = geo.util.vec3AsArray(), c_ar, v_ar, w, h; - bounds.near = bounds.near || 0; - bounds.far = bounds.far || 1; - // reset view to the identity this._resetView(); - w = Math.abs(bounds.right - bounds.left); - h = Math.abs(bounds.top - bounds.bottom); + w = Math.abs(size.width); + h = Math.abs(size.height); c_ar = w / h; v_ar = this._viewport.width / this._viewport.height; @@ -15696,12 +15771,26 @@ inherit(geo.transform, geo.object); scale[2] = 1; this._scale(scale); + if (rotation) { + this._rotate(rotation); + } + // translate to the new center. - translate[0] = -(bounds.left + bounds.right) / 2; - translate[1] = -(bounds.bottom + bounds.top) / 2; + translate[0] = -center.x; + translate[1] = -center.y; translate[2] = 0; this._translate(translate); + + return this; + }; + + /** + * Public exposure of the viewFromCenterSizeRotation function. + */ + this.viewFromCenterSizeRotation = function (center, size, rotation) { + this._viewFromCenterSizeRotation(center, size, rotation); + this._update(); return this; }; @@ -15731,6 +15820,9 @@ inherit(geo.transform, geo.object); * @param {number} zoom The zoom scale to apply */ this.zoom = function (zoom) { + if (zoom === 1) { + return; + } mat4.scale(this._view, this._view, [ zoom, zoom, @@ -15739,6 +15831,29 @@ inherit(geo.transform, geo.object); this._update(); }; + /** + * Rotate the view matrix by the given amount. + * + * @param {number} rotation Counter-clockwise rotation angle in radians. + * @param {object} center Center of rotation in world space coordinates. + * @param {vec3} axis acis of rotation. Defaults to [0, 0, -1] + */ + this._rotate = function (rotation, center, axis) { + if (!rotation) { + return; + } + axis = axis || [0, 0, -1]; + if (!center) { + center = [0, 0, 0]; + } else if (center.x !== undefined) { + center = [center.x || 0, center.y || 0, center.z || 0]; + } + var invcenter = [-center[0], -center[1], -center[2]]; + mat4.translate(this._view, this._view, center); + mat4.rotate(this._view, this._view, rotation, axis); + mat4.translate(this._view, this._view, invcenter); + }; + /** * Returns a CSS transform that converts (by default) from world coordinates * into display coordinates. This allows users of this module to @@ -15994,6 +16109,8 @@ geo.layer = function (arg) { m_attribution = arg.attribution || null, m_zIndex; + m_rendererName = geo.checkRenderer(m_rendererName); + if (!m_map) { throw new Error('Layers must be initialized on a map.'); } @@ -16338,6 +16455,10 @@ geo.layer = function (arg) { m_this._update({event: event}); }); + m_this.geoOn(geo.event.rotate, function (event) { + m_this._update({event: event}); + }); + m_this.geoOn(geo.event.zoom, function (event) { m_this._update({event: event}); }); @@ -16475,7 +16596,9 @@ geo.layer.create = function (map, spec) { } spec.renderer = spec.renderer || 'vgl'; - if (spec.renderer !== 'd3' && spec.renderer !== 'vgl') { + spec.renderer = geo.checkRenderer(spec.renderer); + + if (!spec.renderer) { console.warn('Invalid renderer'); return null; } @@ -16600,9 +16723,13 @@ geo.featureLayer = function (arg) { /// Bind events to handlers m_this.geoOn(geo.event.resize, function (event) { - m_this.renderer()._resize(event.x, event.y, event.width, event.height); - m_this._update({event: event}); - m_this.renderer()._render(); + if (m_this.renderer()) { + m_this.renderer()._resize(event.x, event.y, event.width, event.height); + m_this._update({event: event}); + m_this.renderer()._render(); + } else { + m_this._update({event: event}); + } }); m_this.geoOn(geo.event.pan, function (event) { @@ -16612,6 +16739,13 @@ geo.featureLayer = function (arg) { } }); + m_this.geoOn(geo.event.rotate, function (event) { + m_this._update({event: event}); + if (m_this.renderer()) { + m_this.renderer()._render(); + } + }); + m_this.geoOn(geo.event.zoom, function (event) { m_this._update({event: event}); if (m_this.renderer()) { @@ -16750,7 +16884,6 @@ geo.event = {}; // // geo.event.update = 'geo_update'; // geo.event.opacityUpdate = 'geo_opacityUpdate'; -// geo.event.layerToggle = 'geo_layerToggle'; // geo.event.layerSelect = 'geo_layerSelect'; // geo.event.layerUnselect = 'geo_layerUnselect'; // geo.event.query = 'geo_query'; @@ -16789,8 +16922,8 @@ geo.event.zoom = 'geo_zoom'; ////////////////////////////////////////////////////////////////////////////// /** - * Triggered when the map is rotated around the map center (pointing downward - * so that positive angles are clockwise rotations). + * Triggered when the map is rotated around the current map center (pointing + * downward so that positive angles are clockwise rotations). * * @property {number} angle The angle of the rotation in radians */ @@ -17107,6 +17240,7 @@ geo.mapInteractor = function (args) { // copy the options object with defaults m_options = $.extend( true, + {}, { throttle: 30, discreteZoom: false, @@ -17114,13 +17248,18 @@ geo.mapInteractor = function (args) { panMoveModifiers: {}, zoomMoveButton: 'right', zoomMoveModifiers: {}, + rotateMoveButton: 'left', + rotateMoveModifiers: {'ctrl': true}, panWheelEnabled: false, panWheelModifiers: {}, zoomWheelEnabled: true, zoomWheelModifiers: {}, + rotateWheelEnabled: true, + rotateWheelModifiers: {'ctrl': true}, wheelScaleX: 1, wheelScaleY: 1, zoomScale: 1, + rotateWheelScale: 6 * Math.PI / 180, selectionButton: 'left', selectionModifiers: {'shift': true}, momentum: { @@ -17165,6 +17304,12 @@ geo.mapInteractor = function (args) { // // modifier keys that must be pressed to initiate a zoom on mousemove // zoomMoveModifiers: { 'ctrl' | 'alt' | 'meta' | 'shift' } // + // // button that must be pressed to initiate a rotate on mousedown + // rotateMoveButton: 'right' | 'left' | 'middle' + // + // // modifier keys that must be pressed to initiate a rotate on mousemove + // rotateMoveModifiers: { 'ctrl' | 'alt' | 'meta' | 'shift' } + // // // enable or disable panning with the mouse wheel // panWheelEnabled: true | false // @@ -17177,6 +17322,12 @@ geo.mapInteractor = function (args) { // // modifier keys that must be pressed to trigger a zoom on wheel // zoomWheelModifiers: {...} // + // // enable or disable rotation with the mouse wheel + // rotateWheelEnabled: true | false + // + // // modifier keys that must be pressed to trigger a rotate on wheel + // rotateWheelModifiers: {...} + // // // wheel scale factor to change the magnitude of wheel interactions // wheelScaleX: 1 // wheelScaleY: 1 @@ -17184,6 +17335,9 @@ geo.mapInteractor = function (args) { // // zoom scale factor to change the magnitude of zoom move interactions // zoomScale: 1 // + // // scale factor to change the magnitude of wheel rotation interactions + // rotateWheelScale: 1 + // // // button that must be pressed to enable drag selection // selectionButton: 'right' | 'left' | 'middle' // @@ -17343,7 +17497,7 @@ geo.mapInteractor = function (args) { // core browser events. // // i.e. - // { + // { // 'action': 'pan', // an ongoing pan event // 'origin': {...}, // mouse object at the start of the action // 'delta': {x: *, y: *} // mouse movement since action start @@ -17356,6 +17510,13 @@ geo.mapInteractor = function (args) { // } // // { + // 'action': 'rotate', // an ongoing rotate event + // 'origin': {...}, // mouse object at the start of the action + // 'delta': {x: *, y: *} // mouse movement since action start + // // not including the current event + // } + // + // { // 'acton': 'select', // 'origin': {...}, // 'delta': {x: *, y: *} @@ -17408,7 +17569,9 @@ geo.mapInteractor = function (args) { // Disable dragging images and such $node.on('dragstart', function () { return false; }); if (m_options.panMoveButton === 'right' || - m_options.zoomMoveButton === 'right') { + m_options.zoomMoveButton === 'right' || + m_options.rotateMoveButton === 'right' || + m_options.selectionButton === 'right') { $node.on('contextmenu.geojs', function () { return false; }); } return m_this; @@ -17650,6 +17813,8 @@ geo.mapInteractor = function (args) { action = 'pan'; } else if (eventMatch(m_options.zoomMoveButton, m_options.zoomMoveModifiers)) { action = 'zoom'; + } else if (eventMatch(m_options.rotateMoveButton, m_options.rotateMoveModifiers)) { + action = 'rotate'; } else if (eventMatch(m_options.selectionButton, m_options.selectionModifiers)) { action = 'select'; } @@ -17779,6 +17944,16 @@ geo.mapInteractor = function (args) { m_this.map().pan({x: dx, y: dy}); } else if (m_state.action === 'zoom') { m_callZoom(-dy * m_options.zoomScale / 120, m_state); + } else if (m_state.action === 'rotate') { + var cx, cy; + if (m_state.origin.rotation === undefined) { + cx = m_state.origin.map.x - m_this.map().size().width / 2; + cy = m_state.origin.map.y - m_this.map().size().height / 2; + m_state.origin.rotation = m_this.map().rotation() - Math.atan2(cy, cx); + } + cx = m_mouse.map.x - m_this.map().size().width / 2; + cy = m_mouse.map.y - m_this.map().size().height / 2; + m_this.map().rotation(m_state.origin.rotation + Math.atan2(cy, cx)); } else if (m_state.action === 'select') { // Get the bounds of the current selection selectionObj = m_this._getSelection(); @@ -18091,6 +18266,12 @@ geo.mapInteractor = function (args) { zoomFactor = -m_queue.scroll.y; m_callZoom(zoomFactor, m_mouse); + } else if (m_options.rotateWheelEnabled && + eventMatch('wheel', m_options.rotateWheelModifiers)) { + m_this.map().rotation( + m_this.map().rotation() + + m_queue.scroll.y * m_options.rotateWheelScale, + m_mouse); } // reset the queue @@ -19047,8 +19228,11 @@ inherit(geo.clock, geo.object); /** * Add a tile to the cache. * @param {geo.tile} tile + * @param {function} removeFunc if specified and tiles must be purged from + * the cache, call this function on each tile before purging. + * @param {boolean} noPurge if true, don't purge tiles. */ - this.add = function (tile) { + this.add = function (tile, removeFunc, noPurge) { // remove any existing tiles with the same hash this.remove(tile); var hash = tile.toString(); @@ -19057,9 +19241,24 @@ inherit(geo.clock, geo.object); this._cache[hash] = tile; this._atime.unshift(hash); - // purge a tile from the cache if necessary + if (!noPurge) { + this.purge(removeFunc); + } + }; + + /** + * Purge tiles from the cache if it is full. + * @param {function} removeFunc if specified and tiles must be purged from + * the cache, call this function on each tile before purging. + */ + this.purge = function (removeFunc) { + var hash; while (this._atime.length > this.size) { hash = this._atime.pop(); + var tile = this._cache[hash]; + if (removeFunc) { + removeFunc(tile); + } delete this._cache[hash]; } }; @@ -19156,10 +19355,6 @@ inherit(geo.clock, geo.object); * uses more memory but results in smoother transitions. * @param {bool} [options.wrapX=true] Wrap in the x-direction * @param {bool} [options.wrapY=false] Wrap in the y-direction - * @param {number} [options.minX=0] The minimum world coordinate in X - * @param {number} [options.maxX=255] The maximum world coordinate in X - * @param {number} [options.minY=0] The minimum world coordinate in Y - * @param {number} [options.maxY=255] The maximum world coordinate in Y * @param {function|string} [options.url=null] * A function taking the current tile indices and returning a URL or jquery * ajax config to be passed to the {geo.tile} constructor. @@ -19469,13 +19664,14 @@ inherit(geo.clock, geo.object); * @param {number} source.x * @param {number} source.y * @param {number} source.level + * @param {boolean} delayPurge If true, don't purge tiles from the cache * @returns {geo.tile} */ - this._getTileCached = function (index, source) { + this._getTileCached = function (index, source, delayPurge) { var tile = this.cache.get(this._tileHash(index)); if (tile === null) { tile = this._getTile(index, source); - this.cache.add(tile); + this.cache.add(tile, this.remove.bind(this), delayPurge); } return tile; }; @@ -19587,10 +19783,15 @@ inherit(geo.clock, geo.object); this._queue.batch(true); } } + if (this.cache.size < tiles.length) { + console.log('Increasing cache size to ' + tiles.length); + this.cache.size = tiles.length; + } /* Actually get the tiles. */ for (i = 0; i < tiles.length; i += 1) { - tiles[i] = this._getTileCached(tiles[i].index, tiles[i].source); + tiles[i] = this._getTileCached(tiles[i].index, tiles[i].source, true); } + this.cache.purge(this.remove.bind(this)); return tiles; }; @@ -19812,15 +20013,17 @@ inherit(geo.clock, geo.object); zoom = this._options.tileRounding(mapZoom), scale = Math.pow(2, mapZoom - zoom), size = map.size(); - var ul = this.displayToLevel({x: 0, y: 0}); - var lr = this.displayToLevel({x: size.width, y: size.height}); + var ul = this.displayToLevel({x: 0, y: 0}), + ur = this.displayToLevel({x: size.width, y: 0}), + ll = this.displayToLevel({x: 0, y: size.height}), + lr = this.displayToLevel({x: size.width, y: size.height}); return { level: zoom, scale: scale, - left: ul.x, - right: lr.x, - bottom: lr.y, - top: ul.y + left: Math.min(ul.x, ur.x, ll.x, lr.x), + right: Math.max(ul.x, ur.x, ll.x, lr.x), + top: Math.min(ul.y, ur.y, ll.y, lr.y), + bottom: Math.max(ul.y, ur.y, ll.y, lr.y) }; }; @@ -19831,9 +20034,11 @@ inherit(geo.clock, geo.object); * @protected * @param {number} zoom Tiles (in bounds) at this zoom level will be kept * @param {boolean} doneLoading If true, allow purging additional tiles. + * @param {object} bounds view bounds. If not specified, this is + * obtained from _getViewBounds(). */ - this._purge = function (zoom, doneLoading) { - var tile, hash, bounds = {}; + this._purge = function (zoom, doneLoading, bounds) { + var tile, hash; // Don't purge tiles in an active update if (this._updating) { @@ -19841,7 +20046,9 @@ inherit(geo.clock, geo.object); } // get the view bounds - bounds = this._getViewBounds(); + if (!bounds) { + bounds = this._getViewBounds(); + } for (hash in this._activeTiles) {// jshint ignore: line @@ -19939,7 +20146,11 @@ inherit(geo.clock, geo.object); if (!node) { node = $( '
' - ).css('transform-origin', '0px').get(0); + ).css({ + 'transform-origin': '0px 0px', + 'line-height': 0, + 'font-size': 0 + }).get(0); this.canvas().append(node); } return node; @@ -19961,9 +20172,10 @@ inherit(geo.clock, geo.object); Math.abs(lasty - view.top) < 65536) { return {x: lastx, y: lasty}; } - var to = this._tileOffset(level), - x = parseInt(view.left) + to.x, - y = parseInt(view.top) + to.y; + var map = this.map(), + to = this._tileOffset(level), + x = parseInt((view.left + view.right - map.size().width) / 2 + to.x), + y = parseInt((view.top + view.bottom - map.size().height) / 2 + to.y); canvas.find('.geo-tile-layer').each(function (idx, el) { var $el = $(el), layer = parseInt($el.data('tileLayer')); @@ -19992,8 +20204,10 @@ inherit(geo.clock, geo.object); * @returns {this} Chainable */ this._update = function (evt) { - /* Ignore zoom events, as they are ALWAYS followed by a pan event */ - if (evt && evt.event && evt.event.event === geo.event.zoom) { + /* Ignore zoom and rotate events, as they are ALWAYS followed by a pan + * event */ + if (evt && evt.event && (evt.event.event === geo.event.zoom || + evt.event.event === geo.event.rotate)) { return; } var map = this.map(), @@ -20013,20 +20227,24 @@ inherit(geo.clock, geo.object); var to = this._tileOffset(zoom); if (this.renderer() === null) { - this.canvas().css( - 'transform-origin', - 'center center' - ); - this.canvas().css( - 'transform', - 'scale(' + (Math.pow(2, mapZoom - zoom)) + ')' + - 'translate(' + - (-to.x + -(view.left + view.right) / 2 + map.size().width / 2 + - offset.x) + 'px' + ',' + - (-to.y + -(view.bottom + view.top) / 2 + map.size().height / 2 + - offset.y) + 'px' + ')' + - '' - ); + var scale = Math.pow(2, mapZoom - zoom), + rotation = map.rotation(), + rx = -to.x + -(view.left + view.right) / 2 + offset.x, + ry = -to.y + -(view.bottom + view.top) / 2 + offset.y, + dx = (rx + map.size().width / 2) * scale, + dy = (ry + map.size().height / 2) * scale; + + this.canvas().css({ + 'transform-origin': '' + + -rx + 'px ' + + -ry + 'px' + }); + var transform = 'translate(' + dx + 'px' + ',' + dy + 'px' + ')' + + 'scale(' + scale + ')'; + if (rotation) { + transform += 'rotate(' + (rotation * 180 / Math.PI) + 'deg)'; + } + this.canvas().css('transform', transform); } /* Set some attributes that can be used by non-css based viewers. This * doesn't include the map center, as that may need to be handled @@ -20036,7 +20254,8 @@ inherit(geo.clock, geo.object); dx: -to.x + -(view.left + view.right) / 2, dy: -to.y + -(view.bottom + view.top) / 2, offsetx: offset.x, - offsety: offset.y + offsety: offset.y, + rotation: map.rotation() }); } @@ -20056,34 +20275,44 @@ inherit(geo.clock, geo.object); // mark the tile as covered this._setTileTree(tile); } else { - tile.then(function () { - if (m_exited) { - /* If we have disconnected the renderer, do nothing. This - * happens when the layer is being deleted. */ - return; - } - if (tile !== this.cache.get(tile.toString())) { - /* If the tile has fallen out of the cache, don't draw it -- it - * is untracked. This may be an indication that a larger cache - * should have been used. */ - return; - } - /* Check if a tile is still desired. Don't draw it if it isn't. */ - var mapZoom = map.zoom(), - zoom = this._options.tileRounding(mapZoom), - view = this._getViewBounds(); - if (this._canPurge(tile, view, zoom)) { - this.remove(tile); - return; - } + if (!tile._queued) { + tile.then(function () { + if (m_exited) { + /* If we have disconnected the renderer, do nothing. This + * happens when the layer is being deleted. */ + return; + } + if (tile !== this.cache.get(tile.toString())) { + /* If the tile has fallen out of the cache, don't draw it -- it + * is untracked. This may be an indication that a larger cache + * should have been used. */ + return; + } + /* Check if a tile is still desired. Don't draw it if it isn't. */ + var mapZoom = map.zoom(), + zoom = this._options.tileRounding(mapZoom), + view = this._getViewBounds(); + if (this._canPurge(tile, view, zoom)) { + this.remove(tile); + return; + } - this.drawTile(tile); + this.drawTile(tile); - // mark the tile as covered - this._setTileTree(tile); - }.bind(this)); + // mark the tile as covered + this._setTileTree(tile); + }.bind(this)); - this.addPromise(tile); + this.addPromise(tile); + tile._queued = true; + } else { + /* If we are using a fetch queue, tell the queue so this tile can + * be reprioritized. */ + var pos = this._queue ? this._queue.get(tile) : -1; + if (pos >= 0) { + this._queue.add(tile); + } + } } }.bind(this)); @@ -20387,10 +20616,6 @@ inherit(geo.clock, geo.object); wrapY: false, url: null, subdomains: 'abc', - minX: 0, - maxX: 255, - minY: 0, - maxY: 255, tileOffset: function (level) { void(level); return {x: 0, y: 0}; @@ -20542,6 +20767,15 @@ inherit(geo.clock, geo.object); } }; + /** + * Get the position of a deferred object in the queue. + * @param {Deferred} defer Deferred object to get the position of. + * @returns {number} -1 if not in the queue, or the position in the queue. + */ + this.get = function (defer) { + return $.inArray(defer, this._queue); + }; + /** * Remove a Deferred object from the queue. * @param {Deferred} defer Deferred object to add to the queue. @@ -21040,6 +21274,7 @@ geo.registerFileReader('jsonReader', geo.jsonReader); * @param {object?} center Map center * @param {number} [center.x=0] * @param {number} [center.y=0] + * @param {number} [rotation=0] Clockwise rotation in radians * @param {number?} width The map width (default node width) * @param {number?} height The map height (default node height) * @@ -21049,6 +21284,10 @@ geo.registerFileReader('jsonReader', geo.jsonReader); * @param {number} [max=16] Maximum zoom level * @param {boolean} [discreteZoom=false] True to only allow integer zoom * levels. False for any zoom level. + * @param {boolean} [allowRotation=true] False prevents rotation, true allows + * any rotation. If a function, the function is called with a rotation + * (angle in radians) and returns a valid rotation (this can be used to + * constrain the rotation to a range or specific values). * * *** Advanced parameters *** * @param {geo.camera?} camera The camera to control the view @@ -21071,6 +21310,12 @@ geo.map = function (arg) { return new geo.map(arg); } arg = arg || {}; + + if (arg.node === undefined || arg.node === null) { + console.warn('map creation requires a node'); + return this; + } + geo.sceneObject.call(this, arg); //////////////////////////////////////////////////////////////////////////// @@ -21092,6 +21337,7 @@ geo.map = function (arg) { m_ingcs = arg.ingcs === undefined ? 'EPSG:4326' : arg.ingcs, m_center = {x: 0, y: 0}, m_zoom = arg.zoom === undefined ? 4 : arg.zoom, + m_rotation = 0, m_fileReader = null, m_interactor = null, m_validZoomRange = {min: 0, max: 16, origMin: 0}, @@ -21099,6 +21345,9 @@ geo.map = function (arg) { m_queuedTransition = null, m_clock = null, m_discreteZoom = arg.discreteZoom ? true : false, + m_allowRotation = (typeof arg.allowRotation === 'function' ? + arg.allowRotation : (arg.allowRotation === undefined ? + true : !!arg.allowRotation)), m_maxBounds = arg.maxBounds || {}, m_camera = arg.camera || geo.camera(), m_unitsPerPixel, @@ -21106,7 +21355,7 @@ geo.map = function (arg) { m_clampBoundsY, m_clampZoom, m_origin, - m_scale = {x: 1, y: 1, z: 1}; // constant for the moment + m_scale = {x: 1, y: 1, z: 1}; // constant and ignored for the moment /* Compute the maximum bounds on our map projection. By default, x ranges * from [-180, 180] in the interface projection, and y matches the x range in @@ -21217,6 +21466,28 @@ geo.map = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Get/set the allowRotation setting. If changed, adjust the map as needed. + * + * @param {boolean|function} allowRotation the new allowRotation value. + * @returns {boolean|function|this} + */ + //////////////////////////////////////////////////////////////////////////// + this.allowRotation = function (allowRotation) { + if (allowRotation === undefined) { + return m_allowRotation; + } + if (typeof allowRotation !== 'function') { + allowRotation = !!allowRotation; + } + if (allowRotation !== m_allowRotation) { + m_allowRotation = allowRotation; + m_this.rotation(m_rotation); + } + return m_this; + }; + //////////////////////////////////////////////////////////////////////////// /** * Get the map's world coordinate origin in gcs coordinates @@ -21314,10 +21585,10 @@ geo.map = function (arg) { m_zoom = val; - bounds = m_this.boundsFromZoomAndCenter(val, m_center, null); + bounds = m_this.boundsFromZoomAndCenter(val, m_center, m_rotation, null); m_this.modified(); - camera_bounds(bounds); + camera_bounds(bounds, m_rotation); evt = { geo: {}, zoomLevel: m_zoom, @@ -21351,16 +21622,21 @@ geo.map = function (arg) { unit = m_this.unitsPerPixel(m_zoom); + var sinr = Math.sin(m_rotation), cosr = Math.cos(m_rotation); m_camera.pan({ - x: delta.x * unit, - y: -delta.y * unit + x: (delta.x * cosr - (-delta.y) * sinr) * unit, + y: (delta.x * sinr + (-delta.y) * cosr) * unit }); /* If m_clampBounds* is true, clamp the pan */ - var bounds = fix_bounds(m_camera.bounds); + var bounds = fix_bounds(m_camera.bounds, m_rotation); if (bounds !== m_camera.bounds) { var panPos = m_this.gcsToDisplay({ x: m_camera.bounds.left, y: m_camera.bounds.top}, null); - camera_bounds(bounds); + bounds = m_this.boundsFromZoomAndCenter(m_zoom, { + x: (bounds.left + bounds.right) / 2, + y: (bounds.top + bounds.bottom) / 2 + }, m_rotation, null); + camera_bounds(bounds, m_rotation); var clampPos = m_this.gcsToDisplay({ x: m_camera.bounds.left, y: m_camera.bounds.top}, null); evt.screenDelta.x += clampPos.x - panPos.x; @@ -21378,6 +21654,53 @@ geo.map = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Get/set the map rotation. The rotation is performed around the current + * view center. + * + * @param {Object} rotation angle in radians (positive is clockwise) + * @param {Object} origin is specified, rotate about this origin + * @param {boolean} ignoreRotationFunc if true, don't constrain the rotation. + * @returns {geo.map} + */ + //////////////////////////////////////////////////////////////////////////// + this.rotation = function (rotation, origin, ignoreRotationFunc) { + if (rotation === undefined) { + return m_rotation; + } + rotation = fix_rotation(rotation, ignoreRotationFunc); + if (rotation === m_rotation) { + return m_this; + } + m_rotation = rotation; + + var bounds = m_this.boundsFromZoomAndCenter( + m_zoom, m_center, m_rotation, null); + m_this.modified(); + + camera_bounds(bounds, m_rotation); + + var evt = { + geo: {}, + rotation: m_rotation, + screenPosition: origin ? origin.map : undefined + }; + + m_this.geoTrigger(geo.event.rotate, evt); + + if (origin && origin.geo && origin.map) { + var shifted = m_this.gcsToDisplay(origin.geo); + m_this.pan({x: origin.map.x - shifted.x, y: origin.map.y - shifted.y}); + } else { + m_this.pan({x: 0, y: 0}); + } + /* Changing the rotation can change our minimum zoom */ + reset_minimum_zoom(); + m_this.zoom(m_zoom); + return m_this; + }; + //////////////////////////////////////////////////////////////////////////// /** * Set center of the map to the given geographic coordinates, or get the @@ -21401,7 +21724,8 @@ geo.map = function (arg) { // get the screen coordinates of the new center m_center = $.extend({}, m_this.gcsToWorld(coordinates, gcs)); - camera_bounds(m_this.boundsFromZoomAndCenter(m_zoom, m_center, null)); + camera_bounds(m_this.boundsFromZoomAndCenter( + m_zoom, m_center, m_rotation, null), m_rotation); // trigger a pan event m_this.geoTrigger( geo.event.pan, @@ -21454,7 +21778,6 @@ geo.map = function (arg) { if (layer !== null && layer !== undefined) { layer._exit(); - m_this.removeChild(layer); m_this.modified(); @@ -21474,45 +21797,46 @@ geo.map = function (arg) { //////////////////////////////////////////////////////////////////////////// /** - * Toggle visibility of a layer + * Get or set the size of the map. * - * @param {geo.layer} layer - * @returns {Boolean} + * @param {Object?} arg + * @param {Number} arg.width width in pixels + * @param {Number} arg.height height in pixels + * @returns {Object} An object containing width and height as keys */ //////////////////////////////////////////////////////////////////////////// - this.toggle = function (layer) { - if (layer !== null && layer !== undefined) { - layer.visible(!layer.visible()); - m_this.modified(); - - m_this.geoTrigger(geo.event.layerToggle, { - type: geo.event.layerToggle, - target: m_this, - layer: layer - }); + this.size = function (arg) { + if (arg === undefined) { + return { + width: m_width, + height: m_height + }; } + m_this.resize(0, 0, arg.width, arg.height); return m_this; }; //////////////////////////////////////////////////////////////////////////// /** - * Get or set the size of the map. + * Get the rotated size of the map. This is the width and height of the + * non-rotated area necessary to enclose the rotated area in pixels. * - * @param {Object?} arg - * @param {Number} arg.width width in pixels - * @param {Number} arg.height height in pixels * @returns {Object} An object containing width and height as keys */ //////////////////////////////////////////////////////////////////////////// - this.size = function (arg) { - if (arg === undefined) { + this.rotatedSize = function () { + if (!this.rotation()) { return { width: m_width, height: m_height }; } - m_this.resize(0, 0, arg.width, arg.height); - return m_this; + var bds = rotate_bounds_center( + {x: 0, y: 0}, {width: m_width, height: m_height}, this.rotation()); + return { + width: Math.abs(bds.right - bds.left), + height: Math.abs(bds.top - bds.bottom) + }; }; //////////////////////////////////////////////////////////////////////////// @@ -21871,13 +22195,18 @@ geo.map = function (arg) { //////////////////////////////////////////////////////////////////////////// /** - * Start an animated zoom/pan. + * Start an animated zoom/pan/rotate. If a second transition is requested + * while a transition is already in progress, a new transition is created + * that is functionally from whereever the map has moved to (possibly partway + * through the first transition) going to the end point of the new + * transition. * * Options: *
    *   opts = {
    *     center: { x: ... , y: ... } // the new center
    *     zoom: ... // the new zoom level
+   *     rotation: ... // the new rotation angle
    *     duration: ... // the duration (in ms) of the transition
    *     ease: ... // an easing function [0, 1] -> [0, 1]
    *   }
@@ -21909,11 +22238,11 @@ geo.map = function (arg) {
     }
     function defaultInterp(p0, p1) {
       return function (t) {
-        return [
-          interp1(p0[0], p1[0], t),
-          interp1(p0[1], p1[1], t),
-          interp1(p0[2], p1[2], t)
-        ];
+        var result = [];
+        $.each(p0, function (idx) {
+          result.push(interp1(p0[idx], p1[idx], t));
+        });
+        return result;
       };
     }
 
@@ -21930,6 +22259,7 @@ geo.map = function (arg) {
     var defaultOpts = {
       center: m_this.center(undefined, null),
       zoom: m_this.zoom(),
+      rotation: m_this.rotation(),
       duration: 1000,
       ease: function (t) {
         return t;
@@ -21941,53 +22271,60 @@ geo.map = function (arg) {
 
     if (opts.center) {
       gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
+      opts = $.extend(true, {}, opts);
       opts.center = geo.util.normalizeCoordinates(opts.center);
       if (gcs !== m_gcs) {
         opts.center = geo.transform.transformCoordinates(gcs, m_gcs, [
             opts.center])[0];
       }
     }
-    $.extend(defaultOpts, opts);
+    opts = $.extend(true, {}, defaultOpts, opts);
 
     m_transition = {
       start: {
         center: m_this.center(undefined, null),
-        zoom: m_this.zoom()
+        zoom: m_this.zoom(),
+        rotation: m_this.rotation()
       },
       end: {
-        center: defaultOpts.center,
-        zoom: fix_zoom(defaultOpts.zoom)
+        center: opts.center,
+        zoom: fix_zoom(opts.zoom),
+        rotation: fix_rotation(opts.rotation, undefined, true)
       },
-      ease: defaultOpts.ease,
-      zCoord: defaultOpts.zCoord,
-      done: defaultOpts.done,
-      duration: defaultOpts.duration
+      ease: opts.ease,
+      zCoord: opts.zCoord,
+      done: opts.done,
+      duration: opts.duration
     };
 
-    if (defaultOpts.zCoord) {
-      m_transition.interp = defaultOpts.interp(
+    if (opts.zCoord) {
+      m_transition.interp = opts.interp(
         [
           m_transition.start.center.x,
           m_transition.start.center.y,
-          zoom2z(m_transition.start.zoom)
+          zoom2z(m_transition.start.zoom),
+          m_transition.start.rotation
         ],
         [
           m_transition.end.center.x,
           m_transition.end.center.y,
-          zoom2z(m_transition.end.zoom)
+          zoom2z(m_transition.end.zoom),
+          m_transition.end.rotation
         ]
       );
     } else {
-      m_transition.interp = defaultOpts.interp(
+      m_transition.interp = opts.interp(
         [
           m_transition.start.center.x,
           m_transition.start.center.y,
-          m_transition.start.zoom
+          m_transition.start.zoom,
+          m_transition.start.rotation
         ],
         [
           m_transition.end.center.x,
           m_transition.end.center.y,
-          m_transition.end.zoom
+          m_transition.end.zoom,
+          m_transition.end.rotation
         ]
       );
     }
@@ -21998,18 +22335,19 @@ geo.map = function (arg) {
 
       if (!m_transition.start.time) {
         m_transition.start.time = time;
-        m_transition.end.time = time + defaultOpts.duration;
+        m_transition.end.time = time + opts.duration;
       }
       m_transition.time = time - m_transition.start.time;
       if (time >= m_transition.end.time || next) {
         if (!next) {
           m_this.center(m_transition.end.center, null);
           m_this.zoom(m_transition.end.zoom);
+          m_this.rotation(fix_rotation(m_transition.end.rotation));
         }
 
         m_transition = null;
 
-        m_this.geoTrigger(geo.event.transitionend, defaultOpts);
+        m_this.geoTrigger(geo.event.transitionend, opts);
 
         if (done) {
           done();
@@ -22024,7 +22362,7 @@ geo.map = function (arg) {
       }
 
       var z = m_transition.ease(
-        (time - m_transition.start.time) / defaultOpts.duration
+        (time - m_transition.start.time) / opts.duration
       );
 
       var p = m_transition.interp(z);
@@ -22040,18 +22378,20 @@ geo.map = function (arg) {
         m_center = m_this.gcsToWorld({x: p[0], y: p[1]}, null);
         m_this.zoom(p[2], undefined, true);
       }
+      m_this.rotation(p[3], undefined, true);
 
       window.requestAnimationFrame(anim);
     }
 
-    m_this.geoTrigger(geo.event.transitionstart, defaultOpts);
+    m_this.geoTrigger(geo.event.transitionstart, opts);
 
-    if (defaultOpts.cancelNavigation) {
-      m_this.geoTrigger(geo.event.transitionend, defaultOpts);
+    if (geo.event.cancelNavigation) {
+      m_transition = null;
+      m_this.geoTrigger(geo.event.transitionend, opts);
       return m_this;
-    } else if (defaultOpts.cancelAnimation) {
+    } else if (geo.event.cancelAnimation) {
       // run the navigation synchronously
-      defaultOpts.duration = 0;
+      opts.duration = 0;
       anim(0);
     } else {
       window.requestAnimationFrame(anim);
@@ -22062,10 +22402,11 @@ geo.map = function (arg) {
   ////////////////////////////////////////////////////////////////////////////
   /**
    * Get/set the locations of the current map corners as latitudes/longitudes.
-   * When provided the argument should be an object containing the keys
-   * lowerLeft and upperRight declaring the desired new map bounds.  The
-   * new bounds will contain at least the min/max lat/lngs provided.  In any
-   * case, the actual new bounds will be returned by this function.
+   * When provided the argument should be an object containing the keys left,
+   * top, right, bottom declaring the desired new map bounds.  The new bounds
+   * will contain at least the min/max lat/lngs provided modified by clamp
+   * settings.  In any case, the actual new bounds will be returned by this
+   * function.
    *
    * @param {geo.geoBounds} [bds] The requested map bounds
    * @param {string|geo.transform} [gcs] undefined to use the interface gcs,
@@ -22090,16 +22431,16 @@ geo.map = function (arg) {
           bottom: trans[1].y
         };
       }
-      bds = fix_bounds(bds);
-      nav = m_this.zoomAndCenterFromBounds(bds, null);
+      bds = fix_bounds(bds, m_rotation);
+      nav = m_this.zoomAndCenterFromBounds(bds, m_rotation, null);
 
-      // This might have concequences in terms of bounds/zoom clamping.
+      // This might have consequences in terms of bounds/zoom clamping.
       // What behavior do we expect from this method in that case?
       m_this.zoom(nav.zoom);
       m_this.center(nav.center, null);
     }
 
-    return m_this.boundsFromZoomAndCenter(m_zoom, m_center, gcs);
+    return m_this.boundsFromZoomAndCenter(m_zoom, m_center, m_rotation, gcs);
   };
 
   ////////////////////////////////////////////////////////////////////////////
@@ -22107,12 +22448,13 @@ geo.map = function (arg) {
    * Get the center zoom level necessary to display the given lat/lon bounds.
    *
    * @param {geo.geoBounds} [bds] The requested map bounds
+   * @param {number} rotation Rotation in clockwise radians.
    * @param {string|geo.transform} [gcs] undefined to use the interface gcs,
    *    null to use the map gcs, or any other transform.
    * @return {object} Object containing keys 'center' and 'zoom'
    */
   ////////////////////////////////////////////////////////////////////////////
-  this.zoomAndCenterFromBounds = function (bounds, gcs) {
+  this.zoomAndCenterFromBounds = function (bounds, rotation, gcs) {
     var center, zoom;
 
     gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
@@ -22131,10 +22473,10 @@ geo.map = function (arg) {
     }
 
     // calculate the zoom to fit the bounds
-    zoom = fix_zoom(calculate_zoom(bounds));
+    zoom = fix_zoom(calculate_zoom(bounds, rotation));
 
     // clamp bounds if necessary
-    bounds = fix_bounds(bounds);
+    bounds = fix_bounds(bounds, rotation);
 
     /* This relies on having the map projection coordinates be uniform
      * regardless of location.  If not, the center will not be correct. */
@@ -22143,7 +22485,9 @@ geo.map = function (arg) {
       x: (bounds.left + bounds.right) / 2 - m_origin.x,
       y: (bounds.top + bounds.bottom) / 2 - m_origin.y
     };
-
+    if (gcs !== m_gcs) {
+      center = geo.transform.transformCoordinates(m_gcs, gcs, [center])[0];
+    }
     return {
       zoom: zoom,
       center: center
@@ -22159,13 +22503,14 @@ geo.map = function (arg) {
    *
    * @param {number} zoom The requested zoom level
    * @param {geo.geoPosition} center The requested center
+   * @param {number} rotation The requested rotation
    * @param {string|geo.transform} [gcs] undefined to use the interface gcs,
    *    null to use the map gcs, or any other transform.
    * @return {geo.geoBounds}
    */
   ////////////////////////////////////////////////////////////////////////////
-  this.boundsFromZoomAndCenter = function (zoom, center, gcs) {
-    var width, height, bounds, units;
+  this.boundsFromZoomAndCenter = function (zoom, center, rotation, gcs) {
+    var width, height, halfw, halfh, bounds, units;
 
     gcs = (gcs === null ? m_gcs : (gcs === undefined ? m_ingcs : gcs));
     // preprocess the arguments
@@ -22174,21 +22519,46 @@ geo.map = function (arg) {
     center = m_this.gcsToWorld(center, gcs);
 
     // get half the width and height in world coordinates
-    width = m_width * units / 2;
-    height = m_height * units / 2;
+    width = m_width * units;
+    height = m_height * units;
+    halfw = width / 2;
+    halfh = height / 2;
 
     // calculate the bounds.  This is only valid if the map projection has
     // uniform units in each direction.  If not, then worldToGcs should be
     // used.
-    bounds = {
-      left: center.x - width + m_origin.x,
-      right: center.x + width + m_origin.x,
-      bottom: center.y - height + m_origin.y,
-      top: center.y + height + m_origin.y
-    };
 
-    // correct the bounds when clamping is enabled
-    return fix_bounds(bounds);
+    if (rotation) {
+      center.x += m_origin.x;
+      center.y += m_origin.y;
+      bounds = rotate_bounds_center(
+        center, {width: width, height: height}, rotation);
+      // correct the bounds when clamping is enabled
+      bounds.width = width;
+      bounds.height = height;
+      bounds = fix_bounds(bounds, rotation);
+    } else {
+      bounds = {
+        left: center.x - halfw + m_origin.x,
+        right: center.x + halfw + m_origin.x,
+        bottom: center.y - halfh + m_origin.y,
+        top: center.y + halfh + m_origin.y
+      };
+      // correct the bounds when clamping is enabled
+      bounds = fix_bounds(bounds, 0);
+    }
+    if (gcs !== m_gcs) {
+      var bds = geo.transform.transformCoordinates(
+        m_gcs, gcs,
+        [[bounds.left, bounds.top], [bounds.right, bounds.bottom]]);
+      bounds = {
+        left: bds[0][0], top: bds[0][1], right: bds[1][0], bottom: bds[1][1]
+      };
+    }
+    /* Add the original width and height of the viewport before rotation. */
+    bounds.width = width;
+    bounds.height = height;
+    return bounds;
   };
 
   ////////////////////////////////////////////////////////////////////////////
@@ -22321,6 +22691,51 @@ geo.map = function (arg) {
     return {x: sclx, y: scly};
   }
 
+  /**
+   * Adjust a set of bounds based on a rotation.
+   * @private.
+   */
+  function rotate_bounds(bounds, rotation) {
+    if (rotation) {
+      var center = {
+        x: (bounds.left + bounds.right) / 2,
+        y: (bounds.top + bounds.bottom) / 2
+      };
+      var size = {
+        width: Math.abs(bounds.left - bounds.right),
+        height: Math.abs(bounds.top - bounds.bottom)
+      };
+      bounds = rotate_bounds_center(center, size, rotation);
+    }
+    return bounds;
+  }
+
+  /**
+   * Generate a set of bounds based on a center point, a width and height, and
+   * a rotation.
+   * @private.
+   */
+  function rotate_bounds_center(center, size, rotation) {
+    // calculate the half width and height
+    var width = size.width / 2, height = size.height / 2;
+    var sinr = Math.sin(rotation), cosr = Math.cos(rotation);
+    var ul = {}, ur = {}, ll = {}, lr = {};
+    ul.x = center.x + (-width) * cosr - (-height) * sinr;
+    ul.y = center.y + (-width) * sinr + (-height) * cosr;
+    ur.x = center.x +   width  * cosr - (-height) * sinr;
+    ur.y = center.y +   width  * sinr + (-height) * cosr;
+    ll.x = center.x + (-width) * cosr -   height  * sinr;
+    ll.y = center.y + (-width) * sinr +   height  * cosr;
+    lr.x = center.x +   width  * cosr -   height  * sinr;
+    lr.y = center.y +   width  * sinr +   height  * cosr;
+    return {
+      left: Math.min(ul.x, ur.x, ll.x, lr.x),
+      right: Math.max(ul.x, ur.x, ll.x, lr.x),
+      bottom: Math.min(ul.y, ur.y, ll.y, lr.y),
+      top: Math.max(ul.y, ur.y, ll.y, lr.y)
+    };
+  }
+
   /**
    * Calculate the minimum zoom level to fit the given
    * bounds inside the view port using the view port size,
@@ -22329,7 +22744,11 @@ geo.map = function (arg) {
    * as the current zoom level to be within that range.
    * @private
    */
-  function calculate_zoom(bounds) {
+  function calculate_zoom(bounds, rotation) {
+    if (rotation === undefined) {
+      rotation = m_rotation;
+    }
+    bounds = rotate_bounds(bounds, rotation);
     // compare the aspect ratios of the viewport and bounds
     var scl = camera_scaling(bounds), z;
 
@@ -22386,22 +22805,95 @@ geo.map = function (arg) {
     return zoom;
   }
 
+  /**
+   * Return a valid rotation angle.
+   * @private
+   */
+  function fix_rotation(rotation, ignoreRotationFunc, noRangeLimit) {
+    if (!m_allowRotation) {
+      return 0;
+    }
+    if (!ignoreRotationFunc && typeof m_allowRotation === 'function') {
+      rotation = m_allowRotation(rotation);
+    }
+    /* Ensure that the rotation is in the range [0, 2pi) */
+    if (!noRangeLimit) {
+      var range = Math.PI * 2;
+      rotation = (rotation % range) + (rotation >= 0 ? 0 : range);
+      if (Math.min(Math.abs(rotation), Math.abs(rotation - range)) < 0.00001) {
+        rotation = 0;
+      }
+    }
+    return rotation;
+  }
+
   /**
    * Return the nearest valid bounds maintaining the
    * width and height. Does nothing if m_clampBounds* is
    * false.
    * @private
    */
-  function fix_bounds(bounds) {
-    var dx, dy;
+  function fix_bounds(bounds, rotation) {
+    if (!m_clampBoundsX && !m_clampBoundsY) {
+      return bounds;
+    }
+    var dx, dy, maxBounds = m_maxBounds;
+    if (rotation) {
+      maxBounds = $.extend({}, m_maxBounds);
+      /* When rotated, expand the maximum bounds so that they will allow the
+       * corners to be visible.  We know the rotated bounding box, plus the
+       * original maximum bounds.  To fit the corners of the maximum bounds, we
+       * can expand the total bounds by the same factor that the rotated
+       * bounding box is expanded from the non-rotated bounding box (for a
+       * small rotation, this is sin(rotation) * (original bounding box height)
+       * in the width).  This feels like appropriate behaviour with one of the
+       * two bounds clamped.  With both, it seems mildly peculiar. */
+      var bw = Math.abs(bounds.right - bounds.left),
+          bh = Math.abs(bounds.top - bounds.bottom),
+          absinr = Math.abs(Math.sin(rotation)),
+          abcosr = Math.abs(Math.cos(rotation)),
+          ow, oh;
+      if (bounds.width && bounds.height) {
+        ow = bounds.width;
+        oh = bounds.height;
+      } else if (Math.abs(absinr - abcosr) < 0.0005) {
+        /* If we are close to a 45 degree rotation, it is ill-determined to
+         * compute the original (pre-rotation) bounds width and height.  In
+         * this case, assume that we are using the map's aspect ratio. */
+        if (m_width && m_height) {
+          var aspect = Math.abs(m_width / m_height);
+          var fac = Math.pow(1 + Math.pow(aspect, 2), 0.5);
+          ow = Math.max(bw, bh) / fac;
+          oh = ow * aspect;
+        } else {
+          /* Fallback if we don't have width or height */
+          ow = bw * abcosr;
+          oh = bh * absinr;
+        }
+      } else {
+        /* Compute the pre-rotation (original) bounds width and height */
+        ow = (abcosr * bw - absinr * bh) / (abcosr * abcosr - absinr * absinr);
+        oh = (abcosr * bh - absinr * bw) / (abcosr * abcosr - absinr * absinr);
+      }
+      /* Our maximum bounds are expanded based on the projected length of a
+       * tilted side of the original bounding box in the rotated bounding box.
+       * To handle all rotations, take the minimum difference in width or
+       * height. */
+      var bdx = bw - Math.max(abcosr * ow, absinr * oh),
+          bdy = bh - Math.max(abcosr * oh, absinr * ow);
+      maxBounds.left -= bdx;
+      maxBounds.right += bdx;
+      maxBounds.top += bdy;
+      maxBounds.bottom -= bdy;
+    }
     if (m_clampBoundsX) {
-      if (bounds.right - bounds.left > m_maxBounds.right - m_maxBounds.left) {
-        dx = m_maxBounds.left - ((bounds.right - bounds.left - (
-          m_maxBounds.right - m_maxBounds.left)) / 2) - bounds.left;
-      } else if (bounds.left < m_maxBounds.left) {
-        dx = m_maxBounds.left - bounds.left;
-      } else if (bounds.right > m_maxBounds.right) {
-        dx = m_maxBounds.right - bounds.right;
+      if (bounds.right - bounds.left > maxBounds.right - maxBounds.left) {
+        dx = maxBounds.left - ((bounds.right - bounds.left - (
+          maxBounds.right - maxBounds.left)) / 2) - bounds.left;
+      } else if (bounds.left < maxBounds.left) {
+        dx = maxBounds.left - bounds.left;
+      } else if (bounds.right > maxBounds.right) {
+        dx = maxBounds.right - bounds.right;
       }
       if (dx) {
         bounds = {
@@ -22413,13 +22905,13 @@ geo.map = function (arg) {
       }
     }
     if (m_clampBoundsY) {
-      if (bounds.top - bounds.bottom > m_maxBounds.top - m_maxBounds.bottom) {
-        dy = m_maxBounds.bottom - ((bounds.top - bounds.bottom - (
-          m_maxBounds.top - m_maxBounds.bottom)) / 2) - bounds.bottom;
-      } else if (bounds.top > m_maxBounds.top) {
-        dy = m_maxBounds.top - bounds.top;
-      } else if (bounds.bottom < m_maxBounds.bottom) {
-        dy = m_maxBounds.bottom - bounds.bottom;
+      if (bounds.top - bounds.bottom > maxBounds.top - maxBounds.bottom) {
+        dy = maxBounds.bottom - ((bounds.top - bounds.bottom - (
+          maxBounds.top - maxBounds.bottom)) / 2) - bounds.bottom;
+      } else if (bounds.top > maxBounds.top) {
+        dy = maxBounds.top - bounds.top;
+      } else if (bounds.bottom < maxBounds.bottom) {
+        dy = maxBounds.bottom - bounds.bottom;
       }
       if (dy) {
         bounds = {
@@ -22438,8 +22930,17 @@ geo.map = function (arg) {
    * correct for the viewport aspect ratio.
    * @private
    */
-  function camera_bounds(bounds) {
-    m_camera.bounds = bounds;
+  function camera_bounds(bounds, rotation) {
+    m_camera.rotation = rotation || 0;
+    /* When dealing with rotation, use the original width and height of the
+     * bounds, as the rotation will have expanded them. */
+    if (bounds.width && bounds.height && rotation) {
+      var cx = (bounds.left + bounds.right) / 2,
+          cy = (bounds.top + bounds.bottom) / 2;
+      m_camera.viewFromCenterSizeRotation({x: cx, y: cy}, bounds, rotation);
+    } else {
+      m_camera.bounds = bounds;
+    }
   }
 
   ////////////////////////////////////////////////////////////////////////////
@@ -22455,6 +22956,7 @@ geo.map = function (arg) {
   // Fix the zoom level (minimum and initial)
   this.zoomRange(arg, true);
   m_zoom = fix_zoom(m_zoom);
+  m_rotation = fix_rotation(m_rotation);
   // Now update to the correct center and zoom level
   this.center($.extend({}, arg.center || m_center), undefined);
 
@@ -22499,7 +23001,9 @@ geo.map.create = function (spec) {
 
   var map = geo.map(spec);
 
-  if (!map) {
+  /* If the spec is bad, we still end up with an object, but it won't have a
+   * zoom function */
+  if (!map || !map.zoom) {
     console.warn('Could not create map.');
     return null;
   }
@@ -23334,6 +23838,7 @@ geo.pointFeature = function (arg) {
   ////////////////////////////////////////////////////////////////////////////
   this.pointSearch = function (p) {
     var min, max, data, idx = [], box, found = [], ifound = [], map, pt,
+        corners,
         stroke = m_this.style.get("stroke"),
         strokeWidth = m_this.style.get("strokeWidth"),
         radius = m_this.style.get("radius");
@@ -23352,18 +23857,21 @@ geo.pointFeature = function (arg) {
 
     map = m_this.layer().map();
     pt = map.gcsToDisplay(p);
-
-    // Get the upper right corner in geo coordinates
-    min = map.displayToGcs({
-      x: pt.x - m_maxRadius,
-      y: pt.y + m_maxRadius   // GCS coordinates are bottom to top
-    });
-
-    // Get the lower left corner in geo coordinates
-    max = map.displayToGcs({
-      x: pt.x + m_maxRadius,
-      y: pt.y - m_maxRadius
-    });
+    // check all corners to make sure we handle rotations
+    corners = [
+      map.displayToGcs({x: pt.x - m_maxRadius, y: pt.y - m_maxRadius}),
+      map.displayToGcs({x: pt.x + m_maxRadius, y: pt.y - m_maxRadius}),
+      map.displayToGcs({x: pt.x - m_maxRadius, y: pt.y + m_maxRadius}),
+      map.displayToGcs({x: pt.x + m_maxRadius, y: pt.y + m_maxRadius})
+    ];
+    min = {
+      x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
+      y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
+    };
+    max = {
+      x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
+      y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
+    };
 
     // Find points inside the bounding box
     box = new geo.util.Box(geo.util.vect(min.x, min.y), geo.util.vect(max.x, max.y));
@@ -23376,14 +23884,15 @@ geo.pointFeature = function (arg) {
     idx.forEach(function (i) {
       var d = data[i],
           p = m_this.position()(d, i),
-          dx, dy, rad;
+          dx, dy, rad, rad2;
 
       rad = radius(data[i], i);
       rad += stroke(data[i], i) ? strokeWidth(data[i], i) : 0;
+      rad2 = rad * rad;
       p = map.gcsToDisplay(p);
       dx = p.x - pt.x;
       dy = p.y - pt.y;
-      if (Math.sqrt(dx * dx + dy * dy) <= rad) {
+      if (dx * dx + dy * dy <= rad2) {
         found.push(d);
         ifound.push(i);
       }
@@ -25178,17 +25687,10 @@ inherit(geo.renderer, geo.object);
     }.bind(this);
   };
 
-  // Compute the circumference of the earth / 2 in meters for osm layer image bounds
-  var cEarth = Math.PI * geo.util.radiusEarth;
-
   /**
    * This object contains the default options used to initialize the osmLayer.
    */
   geo.osmLayer.defaults = $.extend({}, geo.tileLayer.defaults, {
-    minX: -cEarth,
-    maxX: cEarth,
-    minY: -cEarth,
-    maxY: cEarth,
     minLevel: 0,
     maxLevel: 18,
     tileOverlap: 0,
@@ -26705,10 +27207,12 @@ geo.gl.planeFeature = function (arg) {
       texture = vgl.texture();
       m_this.visible(false);
 
-      /// TODO: Is there a reliable way to make sure that image is loaded already?
       m_this.renderer().contextRenderer().addActor(m_actor);
 
-      if (image.complete) {
+      /* An image is already loaded if .complete is true and .naturalWidth
+       * and .naturalHeight are defined and non-zero (not falsy seems to be
+       * sufficient). */
+      if (image.complete && image.naturalWidth && image.naturalHeight) {
         texture.setImage(image);
         m_actor.material().addAttribute(texture);
         /// NOTE Currently we assume that we want to show the feature as
@@ -26720,7 +27224,6 @@ geo.gl.planeFeature = function (arg) {
         if (m_onloadCallback) {
           m_onloadCallback.call(m_this);
         }
-        //}
       } else {
         image.onload = function () {
           texture.setImage(image);
@@ -27564,6 +28067,7 @@ geo.gl.vglRenderer = function (arg) {
     var renderWindow = m_viewer.renderWindow(),
         map = m_this.layer().map(),
         camera = map.camera(),
+        rotation = map.rotation() || 0,
         view = camera.view,
         proj = camera.projectionMatrix;
     if (proj[15]) {
@@ -27575,13 +28079,20 @@ geo.gl.vglRenderer = function (arg) {
      * we can show z values from 0 to 1. */
     proj = mat4.translate(geo.util.mat4AsArray(), proj,
                           [0, 0, camera.constructor.bounds.far]);
-
+    /* Check if the rotation is a multiple of 90 */
+    var basis = Math.PI / 2,
+        angle = rotation % basis,  // move to range (-pi/2, pi/2)
+        ortho = (Math.min(Math.abs(angle), Math.abs(angle - basis)) < 0.00001);
     renderWindow.renderers().forEach(function (renderer) {
       var cam = renderer.camera();
+      if (geo.util.compareArrays(view, cam.viewMatrix()) &&
+          geo.util.compareArrays(proj, cam.projectionMatrix())) {
+        return;
+      }
       cam.setViewMatrix(view, true);
       cam.setProjectionMatrix(proj);
       if (proj[1] || proj[2] || proj[3] || proj[4] || proj[6] || proj[7] ||
-          proj[8] || proj[9] || proj[11] || proj[15] !== 1 ||
+          proj[8] || proj[9] || proj[11] || proj[15] !== 1 || !ortho ||
           (parseFloat(map.zoom().toFixed(6)) !==
            parseFloat(map.zoom().toFixed(0)))) {
         /* Don't align texels */
@@ -27617,6 +28128,12 @@ geo.gl.vglRenderer = function (arg) {
     m_this._updateRendererCamera();
   });
 
+  // Connect to rotation event
+  m_this.layer().geoOn(geo.event.rotate, function (evt) {
+    void(evt);
+    m_this._updateRendererCamera();
+  });
+
   // Connect to parallelprojection event
   m_this.layer().geoOn(geo.event.parallelprojection, function (evt) {
     var vglRenderer = m_this.contextRenderer(),
@@ -27641,6 +28158,49 @@ inherit(geo.gl.vglRenderer, geo.renderer);
 
 geo.registerRenderer('vgl', geo.gl.vglRenderer);
 
+(function () {
+  'use strict';
+
+  var checkedWebGL;
+
+  /**
+   * Report if the vgl renderer is supported.  This is just a check if webGL is
+   * supported and available.
+   *
+   * @returns {boolean} true if available.
+   */
+  geo.gl.vglRenderer.supported = function () {
+    if (checkedWebGL === undefined) {
+      /* This is extracted from what Modernizr uses. */
+      var canvas, ctx, exts;
+      try {
+        canvas = document.createElement('canvas');
+        ctx = (canvas.getContext('webgl') ||
+               canvas.getContext('experimental-webgl'));
+        exts = ctx.getSupportedExtensions();
+        checkedWebGL = true;
+      } catch (e) {
+        console.error('No webGL support');
+        checkedWebGL = false;
+      }
+      canvas = undefined;
+      ctx = undefined;
+      exts = undefined;
+    }
+    return checkedWebGL;
+  };
+
+  /**
+   * If the vgl renderer is not supported, supply the name of a renderer that
+   * should be used instead.  This asks for the null renderer.
+   *
+   * @returns null for the null renderer.
+   */
+  geo.gl.vglRenderer.fallback = function () {
+    return null;
+  };
+})();
+
 geo.gl.tileLayer = function () {
   'use strict';
   var m_this = this;
@@ -27912,9 +28472,9 @@ geo.d3.d3Renderer = function (arg) {
       m_corners = null,
       m_width = null,
       m_height = null,
+      m_diagonal = null,
       m_scale = 1,
-      m_dx = 0,
-      m_dy = 0,
+      m_transform = {dx: 0, dy: 0, rx: 0, ry: 0, rotation: 0},
       m_svg = null,
       m_defs = null;
 
@@ -28050,17 +28610,19 @@ geo.d3.d3Renderer = function (arg) {
   function initCorners() {
     var layer = m_this.layer(),
         map = layer.map(),
-        width = m_this.layer().map().size().width,
-        height = m_this.layer().map().size().height;
+        width = map.size().width,
+        height = map.size().height;
 
     m_width = width;
     m_height = height;
     if (!m_width || !m_height) {
       throw 'Map layer has size 0';
     }
+    m_diagonal = Math.pow(width * width + height * height, 0.5);
     m_corners = {
       upperLeft: map.displayToGcs({'x': 0, 'y': 0}, null),
-      lowerRight: map.displayToGcs({'x': width, 'y': height}, null)
+      lowerRight: map.displayToGcs({'x': width, 'y': height}, null),
+      center: map.displayToGcs({'x': width / 2, 'y': height / 2}, null)
     };
   }
 
@@ -28084,32 +28646,47 @@ geo.d3.d3Renderer = function (arg) {
         map = layer.map(),
         upperLeft = map.gcsToDisplay(m_corners.upperLeft, null),
         lowerRight = map.gcsToDisplay(m_corners.lowerRight, null),
+        center = map.gcsToDisplay(m_corners.center, null),
         group = getGroup(),
         canvas = m_this.canvas(),
-        dx, dy, scale;
+        dx, dy, scale, rotation, rx, ry;
 
     if (canvas.attr('scale') !== null) {
-      scale = canvas.attr('scale') || 1;
-      dx = (parseFloat(canvas.attr('dx') || 0) +
-            parseFloat(canvas.attr('offsetx') || 0)) * scale;
-      dy = (parseFloat(canvas.attr('dy') || 0) +
-            parseFloat(canvas.attr('offsety') || 0)) * scale;
-      dx += map.size().width / 2;
-      dy += map.size().height / 2;
+      scale = parseFloat(canvas.attr('scale') || 1);
+      rx = (parseFloat(canvas.attr('dx') || 0) +
+            parseFloat(canvas.attr('offsetx') || 0));
+      ry = (parseFloat(canvas.attr('dy') || 0) +
+            parseFloat(canvas.attr('offsety') || 0));
+      rotation = parseFloat(canvas.attr('rotation') || 0);
+      dx = scale * rx + map.size().width / 2;
+      dy = scale * ry + map.size().height / 2;
     } else {
+      scale = Math.sqrt(
+        Math.pow(lowerRight.y - upperLeft.y, 2) +
+        Math.pow(lowerRight.x - upperLeft.x, 2)) / m_diagonal;
       // calculate the translation
-      dx = upperLeft.x;
-      dy = upperLeft.y;
-      scale = (lowerRight.y - upperLeft.y) / m_height;
+      rotation = map.rotation();
+      rx = -m_width / 2;
+      ry = -m_height / 2;
+      dx = scale * rx + center.x;
+      dy = scale * ry + center.y;
     }
 
     // set the group transform property
-    group.attr('transform', 'matrix(' + [scale, 0, 0, scale, dx, dy].join() + ')');
+    var transform = 'matrix(' + [scale, 0, 0, scale, dx, dy].join() + ')';
+    if (rotation) {
+      transform += ' rotate(' + [
+        rotation * 180 / Math.PI, -rx, -ry].join() + ')';
+    }
+    group.attr('transform', transform);
 
     // set internal variables
     m_scale = scale;
-    m_dx = dx;
-    m_dy = dy;
+    m_transform.dx = dx;
+    m_transform.dy = dy;
+    m_transform.rx = rx;
+    m_transform.ry = ry;
+    m_transform.rotation = rotation;
   };
 
   ////////////////////////////////////////////////////////////////////////////
@@ -28120,10 +28697,20 @@ geo.d3.d3Renderer = function (arg) {
    */
   ////////////////////////////////////////////////////////////////////////////
   this.baseToLocal = function (pt) {
-    return {
-      x: (pt.x - m_dx) / m_scale,
-      y: (pt.y - m_dy) / m_scale
+    pt = {
+      x: (pt.x - m_transform.dx) / m_scale,
+      y: (pt.y - m_transform.dy) / m_scale
     };
+    if (m_transform.rotation) {
+      var sinr = Math.sin(-m_transform.rotation),
+          cosr = Math.cos(-m_transform.rotation);
+      var x = pt.x + m_transform.rx, y = pt.y + m_transform.ry;
+      pt = {
+        x: x * cosr - y * sinr - m_transform.rx,
+        y: x * sinr + y * cosr - m_transform.ry
+      };
+    }
+    return pt;
   };
 
   ////////////////////////////////////////////////////////////////////////////
@@ -28134,10 +28721,20 @@ geo.d3.d3Renderer = function (arg) {
    */
   ////////////////////////////////////////////////////////////////////////////
   this.localToBase = function (pt) {
-    return {
-      x: pt.x * m_scale + m_dx,
-      y: pt.y * m_scale + m_dy
+    if (m_transform.rotation) {
+      var sinr = Math.sin(m_transform.rotation),
+          cosr = Math.cos(m_transform.rotation);
+      var x = pt.x + m_transform.rx, y = pt.y + m_transform.ry;
+      pt = {
+        x: x * cosr - y * sinr - m_transform.rx,
+        y: x * sinr + y * cosr - m_transform.ry
+      };
+    }
+    pt = {
+      x: pt.x * m_scale + m_transform.dx,
+      y: pt.y * m_scale + m_transform.dy
     };
+    return pt;
   };
 
   ////////////////////////////////////////////////////////////////////////////
@@ -28277,6 +28874,10 @@ geo.d3.d3Renderer = function (arg) {
   this._exit = function () {
     m_features = {};
     m_this.canvas().remove();
+    m_svg.remove();
+    m_svg = undefined;
+    m_defs.remove();
+    m_defs = undefined;
     s_exit();
   };
 
@@ -28383,6 +28984,9 @@ geo.d3.d3Renderer = function (arg) {
   // connect to pan event
   this.layer().geoOn(geo.event.pan, m_this._setTransform);
 
+  // connect to rotate event
+  this.layer().geoOn(geo.event.rotate, m_this._setTransform);
+
   // connect to zoom event
   this.layer().geoOn(geo.event.zoom, function () {
     m_this._setTransform();
@@ -28402,6 +29006,30 @@ inherit(geo.d3.d3Renderer, geo.renderer);
 
 geo.registerRenderer('d3', geo.d3.d3Renderer);
 
+(function () {
+  'use strict';
+
+  /**
+   * Report if the d3 renderer is supported.  This is just a check if d3 is
+   * available.
+   *
+   * @returns {boolean} true if available.
+   */
+  geo.d3.d3Renderer.supported = function () {
+    return (typeof d3 !== 'undefined');
+  };
+
+  /**
+   * If the d3 renderer is not supported, supply the name of a renderer that
+   * should be used instead.  This asks for the null renderer.
+   *
+   * @returns null for the null renderer.
+   */
+  geo.d3.d3Renderer.fallback = function () {
+    return null;
+  };
+})();
+
 geo.d3.tileLayer = function () {
   'use strict';
   var m_this = this,
@@ -30757,5 +31385,11 @@ geo.registerWidget('dom', 'legend', geo.gui.legendWidget);
 
   };
 
+  /* Provide a method to reload the plugin in case jquery-ui is loaded after
+   * the plugin. */
+  geo.jqueryPlugin = {reload: load};
+
   $(load);
-})($ || window.$, geo || window.geo, d3 || window.d3);
+})(typeof $ !== 'undefined' ? $ : window.$,
+   typeof geo !== 'undefined' ? geo : window.geo,
+   typeof d3 !== 'undefined' ? d3 : window.d3);
diff --git a/package.json b/package.json
index 85a35651d6..b305bcf4cf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "geojs",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "description": "JavaScript Geo visualization and Analysis Library",
   "homepage": "https://github.com/OpenGeoscience/geojs",
   "license": "Apache-2.0",