├── .gitignore ├── lib ├── google-maps-shim.js ├── base-overlay-factory.js ├── base-overlay.js └── map-loader.js ├── .npmignore ├── .travis.yml ├── conf.json ├── .editorconfig ├── test ├── google-maps-shim.spec.js ├── helpers │ ├── fake-google-maps.js │ ├── sync-promise.js │ └── jsdom.js ├── base-overlay-factory.spec.js ├── base-overlay.spec.js └── map-loader.spec.js ├── index.js ├── package.json ├── LICENSE ├── README.md └── .eslintrc /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | node_modules 4 | -------------------------------------------------------------------------------- /lib/google-maps-shim.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = global.google; 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | .idea 3 | .travis.yml 4 | .editorconfig 5 | .eslintrc 6 | .gitignore 7 | conf.json 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" 4 | env: 5 | global: 6 | - NODE_ENV=travisci 7 | -------------------------------------------------------------------------------- /conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "opts": { 3 | "destination": "./docs/", 4 | "recurse": true 5 | }, 6 | "plugins": [ 7 | "plugins/markdown" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [package.json] 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /test/google-maps-shim.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect, 4 | fakeGoogleMapsApi = require('./helpers/fake-google-maps.js'), 5 | googleMaps = require('../lib/google-maps-shim'); 6 | 7 | describe('Google Maps Global Shim Test Suite', function () { 8 | it('should shim the google global so that it can be `require`\'d', function () { 9 | expect(googleMaps).to.equal(fakeGoogleMapsApi); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @author zach pratt 5 | * @licence MIT 6 | * @module async-google-maps 7 | */ 8 | 9 | var MapLoader = require('./lib/map-loader'), 10 | BaseOverlayFactory = require('./lib/base-overlay-factory'); 11 | 12 | module.exports = { 13 | /** 14 | * See {@link async-google-maps/map-loader} 15 | * */ 16 | MapLoader: MapLoader, 17 | /** 18 | * See {@link async-google-maps/base-overlay-factory} 19 | * */ 20 | BaseOverlayFactory: BaseOverlayFactory 21 | }; 22 | -------------------------------------------------------------------------------- /test/helpers/fake-google-maps.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function OverlayView() { 4 | return; 5 | } 6 | 7 | OverlayView.prototype.getPanes = function () { 8 | return; 9 | }; 10 | 11 | OverlayView.prototype.getProjection = function () { 12 | return; 13 | }; 14 | 15 | var fakeGoogleMapsApi = { 16 | maps: { 17 | Map: function () { 18 | return; 19 | }, 20 | event: { 21 | addListenerOnce: function () { 22 | return; 23 | } 24 | }, 25 | LatLng: function () { 26 | 27 | }, 28 | OverlayView: OverlayView 29 | } 30 | }; 31 | 32 | global.google = fakeGoogleMapsApi; 33 | 34 | module.exports = fakeGoogleMapsApi; 35 | -------------------------------------------------------------------------------- /test/helpers/sync-promise.js: -------------------------------------------------------------------------------- 1 | var sandbox; 2 | 3 | function Promise(resolver) { 4 | var thenCallbacks = [], 5 | resolved; 6 | 7 | this.reject = sandbox.spy(); 8 | this.resolve = sandbox.spy(function () { 9 | resolved = true; 10 | }); 11 | 12 | this.then = function (callback) { 13 | thenCallbacks.push(callback); 14 | 15 | if (resolved) { 16 | callback(); 17 | } 18 | }; 19 | 20 | resolver(this.resolve, this.reject); 21 | } 22 | 23 | module.exports = { 24 | Promise: Promise, 25 | setSandbox: function (suppliedSandbox) { 26 | sandbox = suppliedSandbox; 27 | }, 28 | unsetSandbox: function () { 29 | sandbox = null; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-google-maps", 3 | "version": "0.2.3", 4 | "description": "Asynchronous loading and creation of google maps", 5 | "engines": { 6 | "node": ">=0.12" 7 | }, 8 | "main": "index.js", 9 | "scripts": { 10 | "docs": "rm -rf docs && node_modules/.bin/jsdoc -P package.json -R README.md -c conf.json index.js lib/*.js", 11 | "test": "node_modules/.bin/eslint test/ lib/ && node_modules/.bin/mocha -r ./test/helpers/jsdom.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/zpratt/async-google-maps.git" 16 | }, 17 | "keywords": [ 18 | "google", 19 | "maps" 20 | ], 21 | "author": "zach pratt", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/zpratt/async-google-maps/issues" 25 | }, 26 | "homepage": "https://github.com/zpratt/async-google-maps", 27 | "devDependencies": { 28 | "chai": "^2.1.1", 29 | "eslint": "^0.18.0", 30 | "jsdoc": "^3.3.0-beta3", 31 | "jsdom": "^5.0.0", 32 | "mocha": "^2.2.1", 33 | "proxyquire": "^1.4.0", 34 | "sinon": "^1.14.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Zach Pratt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /lib/base-overlay-factory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @module async-google-maps/base-overlay-factory 5 | */ 6 | 7 | var MapLoader = require('./map-loader'); 8 | 9 | module.exports = { 10 | /** 11 | * Creates an instance of a custom overlay that will position itself on the map based on the overlay dimensions 12 | * and the provided point. 13 | * 14 | * @param options - Options passed to the base overlay constructor 15 | * @param options.el - The element that the overlay will use to append into the overlay pane. 16 | * @param options.point - The lat/lng that the overlay will use to position itself on the map. 17 | * @param options.cacheDimensions - Instruct the Overlay to cache its dimensions after the first time it is drawn 18 | */ 19 | create: function (options) { 20 | MapLoader.loaded.then(function () { 21 | var BaseOverlay = require('./base-overlay'), 22 | overlayInstance = new BaseOverlay(options), 23 | shouldCacheDimensions = options.cacheDimensions; 24 | 25 | overlayInstance.setMap(options.map); 26 | 27 | if (shouldCacheDimensions) { 28 | overlayInstance.cacheDimensions(); 29 | } 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /test/helpers/jsdom.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var jsdom = require('jsdom'); 4 | 5 | /** 6 | * Borrowed from: https://github.com/tmpvar/jsdom/issues/135#issuecomment-68191941 7 | */ 8 | function applyJsdomWorkaround(window) { 9 | Object.defineProperties(window.HTMLElement.prototype, { 10 | offsetLeft: { 11 | get: function () { 12 | return parseFloat(window.getComputedStyle(this).marginLeft) || 0; 13 | } 14 | }, 15 | offsetTop: { 16 | get: function () { 17 | return parseFloat(window.getComputedStyle(this).marginTop) || 0; 18 | } 19 | }, 20 | offsetHeight: { 21 | get: function () { 22 | return parseFloat(window.getComputedStyle(this).height) || 0; 23 | } 24 | }, 25 | offsetWidth: { 26 | get: function () { 27 | return parseFloat(window.getComputedStyle(this).width) || 0; 28 | } 29 | } 30 | }); 31 | } 32 | 33 | function setupDom() { 34 | var baseMarkup = '', 35 | window = jsdom.jsdom(baseMarkup).defaultView; 36 | 37 | global.window = window; 38 | global.document = window.document; 39 | global.navigator = window.navigator; 40 | applyJsdomWorkaround(window); 41 | } 42 | 43 | setupDom(); 44 | -------------------------------------------------------------------------------- /lib/base-overlay.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var google = global.google; 4 | 5 | function getOffsetHeightAndWidth() { 6 | return { 7 | height: this.el.offsetHeight, 8 | width: this.el.offsetWidth 9 | }; 10 | } 11 | function getDimensions() { 12 | if (this.useCache) { 13 | if (!this.dimensions) { 14 | this.dimensions = getOffsetHeightAndWidth.call(this); 15 | } 16 | 17 | return this.dimensions; 18 | } 19 | 20 | return getOffsetHeightAndWidth.call(this); 21 | } 22 | 23 | function positionOverlayByDimensions(projectedLatLng) { 24 | var dimensions = getDimensions.call(this), 25 | offsetHeight = dimensions.height, 26 | offsetWidth = dimensions.width; 27 | 28 | this.el.style.top = projectedLatLng.y - offsetHeight + 'px'; 29 | this.el.style.left = projectedLatLng.x - Math.floor(offsetWidth / 2) + 'px'; 30 | } 31 | 32 | function draw() { 33 | var projection = this.getProjection(), 34 | projectedLatLng = projection.fromLatLngToDivPixel(this.point); 35 | 36 | positionOverlayByDimensions.call(this, projectedLatLng); 37 | } 38 | 39 | function onRemove() { 40 | var parentEl = this.el.parentNode; 41 | 42 | parentEl.removeChild(this.el); 43 | } 44 | 45 | function onAdd() { 46 | var panes = this.getPanes(); 47 | 48 | panes.overlayLayer.appendChild(this.el); 49 | } 50 | 51 | function cacheDimensions() { 52 | this.useCache = true; 53 | } 54 | 55 | function BaseOverlay(options) { 56 | var point = options.point; 57 | 58 | this.el = options.el; 59 | this.point = new google.maps.LatLng(point.lat, point.lng); 60 | 61 | this.el.style.position = 'absolute'; 62 | } 63 | 64 | BaseOverlay.prototype = Object.create(google.maps.OverlayView.prototype); 65 | BaseOverlay.prototype.constructor = BaseOverlay; 66 | 67 | BaseOverlay.prototype.onAdd = onAdd; 68 | BaseOverlay.prototype.onRemove = onRemove; 69 | BaseOverlay.prototype.draw = draw; 70 | BaseOverlay.prototype.cacheDimensions = cacheDimensions; 71 | 72 | module.exports = BaseOverlay; 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Asynchronous loading and creation of google maps 2 | 3 | [![Build Status](https://travis-ci.org/zpratt/async-google-maps.svg)](https://travis-ci.org/zpratt/async-google-maps) 4 | [![npm](https://img.shields.io/npm/v/async-google-maps.svg)](https://www.npmjs.com/package/async-google-maps) 5 | 6 | ## Why use this module? 7 | 8 | I wrote this module to have the simplest possible public API and to handle the common case of waiting to add things to the map until it is `idle`. It is also targeted at making the creation of multiple map instances easier to manage, since each call to `create` will return a promise. There is [another library](https://github.com/sakren/node-google-maps) you can use if you don't like mine. 9 | 10 | Additionally, I have focused on code readability, test quality, and attempted to make this an easy module to TDD with as a dependency. You can easily stub the API, instead of having to verify properties of the module itself. TDD is a passion of mine. 11 | 12 | ### Not fully baked yet 13 | 14 | I have yet to include the options for the business version, but I expect to include that in the next release. 15 | 16 | ## Running tests 17 | 18 | This module is 100% test-driven. Please feel free to run the tests and critique them. 19 | 20 | 1. Install io.js (needed for jsdom) 21 | 2. `git clone` ... 22 | 3. `npm i` 23 | 4. `npm test` 24 | 25 | ## Usage 26 | 27 | This is a pure commonjs module with no production dependencies. I recommend using either [webpack](http://webpack.github.io/docs/) or [browserify](https://github.com/substack/node-browserify) to build. 28 | 29 | See the [wiki](https://github.com/zpratt/async-google-maps/wiki) for usage examples. 30 | 31 | ## Documentation 32 | 33 | API documentation can be found [here](http://zpratt.github.io/async-google-maps/docs/async-google-maps/0.2.2/index.html). 34 | 35 | ### Promises 36 | 37 | This library leverages the ES6 promise implementation, which is currently [available in most modern browsers](http://caniuse.com/#feat=promises). If you wish to use it with browsers that do not support the ES6 promise implementation, then I recommend using [a polyfill](https://github.com/jakearchibald/es6-promise). 38 | 39 | ### Why? 40 | 41 | I have intentionally limited the production dependencies of this module, so you are not forced to use a particular library. 42 | -------------------------------------------------------------------------------- /test/base-overlay-factory.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var BaseOverlayFactory, 4 | 5 | proxyquire = require('proxyquire'), 6 | sinon = require('sinon'), 7 | 8 | fakeMap, 9 | fakeOptions, 10 | 11 | mapLoadedSpy, 12 | BaseOverlaySpy, 13 | sandbox; 14 | 15 | function resolveMapPromiseAndGetOverlayInstance() { 16 | mapLoadedSpy.firstCall.yield(); 17 | 18 | return BaseOverlaySpy.returnValues[0]; 19 | } 20 | describe('Base Overlay Factory Test Suite', function () { 21 | beforeEach(function () { 22 | sandbox = sinon.sandbox.create(); 23 | 24 | fakeMap = {}; 25 | fakeOptions = { 26 | map: fakeMap 27 | }; 28 | 29 | mapLoadedSpy = sandbox.spy(); 30 | BaseOverlaySpy = sandbox.spy(function () { 31 | return { 32 | setMap: sandbox.spy(), 33 | cacheDimensions: sandbox.spy() 34 | }; 35 | }); 36 | 37 | BaseOverlayFactory = proxyquire('../lib/base-overlay-factory', { 38 | './base-overlay': BaseOverlaySpy, 39 | './map-loader': { 40 | loaded: { 41 | then: mapLoadedSpy 42 | } 43 | } 44 | }); 45 | }); 46 | 47 | afterEach(function () { 48 | sandbox.restore(); 49 | }); 50 | 51 | it('should create an instance of the overlay after the google maps API has loaded', function () { 52 | BaseOverlayFactory.create(fakeOptions); 53 | sinon.assert.notCalled(BaseOverlaySpy); 54 | 55 | mapLoadedSpy.firstCall.yield(); 56 | 57 | sinon.assert.calledOnce(BaseOverlaySpy); 58 | sinon.assert.calledWith(BaseOverlaySpy, fakeOptions); 59 | sinon.assert.calledWithNew(BaseOverlaySpy); 60 | }); 61 | 62 | it('should set the map on the overlay instance after creating it', function () { 63 | var fakeOverlayInstance; 64 | 65 | BaseOverlayFactory.create(fakeOptions); 66 | fakeOverlayInstance = resolveMapPromiseAndGetOverlayInstance(); 67 | 68 | sinon.assert.calledOnce(fakeOverlayInstance.setMap); 69 | sinon.assert.calledWith(fakeOverlayInstance.setMap, fakeMap); 70 | }); 71 | 72 | it('should tell the overlay instance to cache its dimensions when the option is set', function () { 73 | var fakeOverlayInstance; 74 | 75 | fakeOptions.cacheDimensions = true; 76 | 77 | BaseOverlayFactory.create(fakeOptions); 78 | fakeOverlayInstance = resolveMapPromiseAndGetOverlayInstance(); 79 | sinon.assert.calledOnce(fakeOverlayInstance.cacheDimensions); 80 | }); 81 | 82 | it('should not tell the overlay instance to cache its dimensions when the option is not set', function () { 83 | var fakeOverlayInstance; 84 | 85 | BaseOverlayFactory.create(fakeOptions); 86 | fakeOverlayInstance = resolveMapPromiseAndGetOverlayInstance(); 87 | sinon.assert.notCalled(fakeOverlayInstance.cacheDimensions); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "browser": true, 5 | "mocha": true, 6 | "es6": true 7 | }, 8 | "ecmaFeatures": { 9 | "modules": true 10 | }, 11 | "rules": { 12 | "no-alert": 2, 13 | "no-array-constructor": 2, 14 | "no-caller": 2, 15 | "no-bitwise": 2, 16 | "no-catch-shadow": 2, 17 | "no-console": 2, 18 | "no-comma-dangle": 2, 19 | "no-control-regex": 2, 20 | "no-debugger": 2, 21 | "no-div-regex": 2, 22 | "no-dupe-keys": 2, 23 | "no-else-return": 2, 24 | "no-empty": 2, 25 | "no-empty-class": 2, 26 | "no-eq-null": 2, 27 | "no-eval": 2, 28 | "no-ex-assign": 2, 29 | "no-extra-semi": 2, 30 | "no-func-assign": 2, 31 | "no-floating-decimal": 2, 32 | "no-implied-eval": 2, 33 | "no-with": 2, 34 | "no-fallthrough": 2, 35 | "no-unreachable": 2, 36 | "no-undef": 2, 37 | "no-undef-init": 2, 38 | "no-unused-expressions": 2, 39 | "no-octal": 2, 40 | "no-octal-escape": 2, 41 | "no-obj-calls": 2, 42 | "no-multi-str": 2, 43 | "no-new-wrappers": 2, 44 | "no-new": 2, 45 | "no-new-func": 2, 46 | "no-native-reassign": 2, 47 | "no-plusplus": 2, 48 | "no-delete-var": 2, 49 | "no-return-assign": 2, 50 | "no-new-object": 2, 51 | "no-label-var": 2, 52 | "no-ternary": 0, 53 | "no-self-compare": 2, 54 | "no-sync": 2, 55 | "no-underscore-dangle": 2, 56 | "no-loop-func": 2, 57 | "no-empty-label": 2, 58 | "no-unused-vars": 2, 59 | "no-script-url": 2, 60 | "no-proto": 2, 61 | "no-iterator": 2, 62 | "no-mixed-requires": [0, false], 63 | "no-wrap-func": 2, 64 | "no-shadow": 2, 65 | "no-use-before-define": 2, 66 | "no-redeclare": 2, 67 | "no-regex-spaces": 2, 68 | "brace-style": [2, "1tbs"], 69 | "block-scoped-var": 0, 70 | "camelcase": 2, 71 | "complexity": [0, 4], 72 | "curly": 2, 73 | "dot-notation": 2, 74 | "eqeqeq": 2, 75 | "global-strict": 0, 76 | "guard-for-in": 0, 77 | "max-depth": [0, 4], 78 | "max-len": [0, 80, 4], 79 | "max-params": [0, 3], 80 | "max-statements": [0, 10], 81 | "new-cap": 2, 82 | "new-parens": 2, 83 | "one-var": 2, 84 | "quotes": [2, "single"], 85 | "quote-props": 0, 86 | "radix": 0, 87 | "semi": 2, 88 | "space-after-keywords": [2, "always", { "checkFunctionKeyword": true } ], 89 | "space-before-blocks": [2, "always"], 90 | "space-in-parens": [2, "never"], 91 | "strict": 2, 92 | "unnecessary-strict": 0, 93 | "use-isnan": 2, 94 | "valid-typeof": 0, 95 | "wrap-iife": 2, 96 | "wrap-regex": 0, 97 | "yoda": "always" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/map-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @module async-google-maps/map-loader 5 | */ 6 | 7 | var URL_PREFIX = '//maps.googleapis.com/maps/api/js', 8 | CALLBACK_IDENTIFIER = 'mapLoaded', 9 | 10 | scriptLoaded = false, 11 | mapLoadedDeferred, 12 | mapLoadedPromise; 13 | 14 | global[CALLBACK_IDENTIFIER] = function mapLoaded() { 15 | mapLoadedDeferred.resolve(); 16 | }; 17 | 18 | function buildUrl(options) { 19 | var mapsUrl = URL_PREFIX 20 | + '?v=' + options.version; 21 | 22 | if (options.client && options.channel) { 23 | mapsUrl += '&client=' + options.client 24 | + '&channel=' + options.channel; 25 | } else { 26 | mapsUrl += '&key=' + options.key; 27 | } 28 | 29 | mapsUrl += '&callback=' + CALLBACK_IDENTIFIER; 30 | 31 | return mapsUrl; 32 | } 33 | 34 | function createDeferredToAllowForResolutionAfterGoogleMapsLoaderFinishes(resolve, reject) { 35 | mapLoadedDeferred = { 36 | resolve: resolve, 37 | reject: reject 38 | }; 39 | } 40 | 41 | function createMapLoadedPromise() { 42 | mapLoadedPromise = new Promise(function (resolve, reject) { 43 | createDeferredToAllowForResolutionAfterGoogleMapsLoaderFinishes(resolve, reject); 44 | }); 45 | } 46 | 47 | function appendScript(scriptEl) { 48 | document.head.appendChild(scriptEl); 49 | scriptLoaded = true; 50 | } 51 | 52 | function appendScriptWhenDocumentReady(scriptEl) { 53 | if (!scriptLoaded && document.readyState !== 'loading') { 54 | appendScript(scriptEl); 55 | } else { 56 | document.addEventListener('DOMContentLoaded', function () { 57 | appendScript(scriptEl); 58 | }); 59 | } 60 | } 61 | 62 | function injectGoogleMapsScript(options) { 63 | var scriptEl = document.createElement('script'); 64 | scriptEl.src = buildUrl(options); 65 | scriptEl.type = 'text/javascript'; 66 | 67 | appendScriptWhenDocumentReady(scriptEl); 68 | } 69 | 70 | function init() { 71 | createMapLoadedPromise(); 72 | } 73 | 74 | init(); 75 | 76 | module.exports = { 77 | /** 78 | * Asynchronously loads the google maps javascript library, given the supplied options. 79 | * Returns a promise that will be resolved once the google maps loader has finished. 80 | * Once the promise resolves, it is safe to reference anything under the `google.maps` namespace. 81 | * This method should only be called once for a given application. 82 | * 83 | * @param {Object} options - The configuration used to load the google maps API 84 | * @param {string} options.version - The version of the google maps API to load 85 | * @param {string} options.key - The API key of the consuming application 86 | * @returns {Promise} 87 | * */ 88 | load: function loadGoogleMapsAsync(options) { 89 | injectGoogleMapsScript(options); 90 | 91 | return mapLoadedPromise; 92 | }, 93 | 94 | /** 95 | * @property {Promise} loaded - A reference to a promise that is resolved once the google maps API is ready. 96 | */ 97 | loaded: (function () { 98 | return mapLoadedPromise; 99 | }()), 100 | 101 | /** 102 | * Creates a map instance given the supplied options. The options will be passed into the `google.maps.Map` constructor, 103 | * therefore, all options from the [google maps api](https://developers.google.com/maps/documentation/javascript/reference#MapOptions) can be used. 104 | * This function returns a promise which will be resolved once the newly created map instance is in the `idle` state, 105 | * which is the point at which overlays, markers, and geometries can be added to the map. 106 | * 107 | * @param mapContainer - The element to attach the map instance to 108 | * @param options - Options passed to the google Map constructor 109 | * @returns {Promise} 110 | * */ 111 | create: function createMap(mapContainer, options) { 112 | return new Promise(function mapIdleResolver(resolve) { 113 | mapLoadedPromise.then(function whenMapHasLoaded() { 114 | var googleMaps = require('./google-maps-shim.js'), 115 | mapInstance = new googleMaps.maps.Map(mapContainer, options); 116 | 117 | googleMaps.maps.event.addListenerOnce(mapInstance, 'idle', function mapIdleHandler() { 118 | resolve(mapInstance); 119 | }); 120 | }); 121 | }); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /test/base-overlay.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fakeGoogleMaps = require('./helpers/fake-google-maps'), 4 | BaseOverlay = require('../lib/base-overlay'), 5 | 6 | expect = require('chai').expect, 7 | sinon = require('sinon'), 8 | 9 | sandbox, 10 | 11 | fakeMapInstance, 12 | latLngLiteral, 13 | fakeLatLng, 14 | projectedLatLng, 15 | 16 | initialOffsetHeight, 17 | initialOffsetWidth, 18 | 19 | overlayPaneElement, 20 | overlayElement, 21 | overlayOptions, 22 | overlayInstance; 23 | 24 | function setDimensionsOfOverlayElement() { 25 | overlayElement.style.height = initialOffsetHeight + 'px'; 26 | overlayElement.style.width = initialOffsetWidth + 'px'; 27 | } 28 | 29 | function givenTheOverlayIsAddedToTheDom() { 30 | overlayInstance.onAdd(); 31 | setDimensionsOfOverlayElement(); 32 | } 33 | 34 | function calculateTop() { 35 | return projectedLatLng.y - initialOffsetHeight + 'px'; 36 | } 37 | 38 | function calculateLeft() { 39 | return projectedLatLng.x - Math.floor(initialOffsetWidth / 2) + 'px'; 40 | } 41 | 42 | function randomInt(min, max) { 43 | return Math.floor(Math.random() * (max - min)) + min; 44 | } 45 | 46 | function generateHeightAndWidth() { 47 | initialOffsetHeight = randomInt(20, 1000); 48 | initialOffsetWidth = randomInt(20, 1000); 49 | } 50 | 51 | function setFakeData() { 52 | generateHeightAndWidth(); 53 | 54 | latLngLiteral = { 55 | lat: 0.0, 56 | lng: 0.0 57 | }; 58 | 59 | fakeLatLng = {}; 60 | fakeMapInstance = {}; 61 | 62 | projectedLatLng = { 63 | x: initialOffsetWidth + randomInt(1, 100), 64 | y: initialOffsetHeight + randomInt(1, 100) 65 | }; 66 | } 67 | 68 | function setupDom() { 69 | overlayPaneElement = document.createElement('div'); 70 | overlayElement = document.createElement('div'); 71 | } 72 | 73 | function setupStubs() { 74 | sandbox.stub(fakeGoogleMaps.maps.OverlayView.prototype, 'getPanes') 75 | .returns({ 76 | overlayLayer: overlayPaneElement 77 | }); 78 | 79 | sandbox.stub(fakeGoogleMaps.maps.OverlayView.prototype, 'getProjection') 80 | .returns({ 81 | fromLatLngToDivPixel: function (latLng) { 82 | if (latLng === fakeLatLng) { 83 | return projectedLatLng; 84 | } 85 | } 86 | }); 87 | 88 | sandbox.stub(fakeGoogleMaps.maps, 'LatLng', function (lat, lng) { 89 | if (lat === latLngLiteral.lat && lng === latLngLiteral.lng) { 90 | return fakeLatLng; 91 | } 92 | }); 93 | } 94 | 95 | describe('Base Overlay Test Suite', function () { 96 | beforeEach(function () { 97 | sandbox = sinon.sandbox.create(); 98 | 99 | setFakeData(); 100 | setupDom(); 101 | setupStubs(); 102 | 103 | overlayOptions = { 104 | point: latLngLiteral, 105 | el: overlayElement, 106 | map: fakeMapInstance 107 | }; 108 | 109 | overlayInstance = new BaseOverlay(overlayOptions); 110 | }); 111 | 112 | afterEach(function () { 113 | sandbox.restore(); 114 | }); 115 | 116 | it('should be an instance of a google maps overlay view', function () { 117 | expect(overlayInstance).to.be.an.instanceOf(fakeGoogleMaps.maps.OverlayView); 118 | }); 119 | 120 | it('should absolutely position the overlay', function () { 121 | expect(overlayElement.style.position).to.equal('absolute'); 122 | }); 123 | 124 | it('should append the provided element into the DOM', function () { 125 | expect(overlayPaneElement.contains(overlayElement)).to.equal(false); 126 | 127 | overlayInstance.onAdd(); 128 | 129 | expect(overlayPaneElement.contains(overlayElement)).to.equal(true); 130 | }); 131 | 132 | it('should position the overlay once the map is idle', function () { 133 | var expectedTop = calculateTop(), 134 | expectedLeft = calculateLeft(); 135 | 136 | expect(overlayElement.style.top).to.equal(''); 137 | expect(overlayElement.style.left).to.equal(''); 138 | 139 | givenTheOverlayIsAddedToTheDom(); 140 | 141 | overlayInstance.draw(); 142 | 143 | expect(overlayElement.style.top).to.equal(expectedTop); 144 | expect(overlayElement.style.left).to.equal(expectedLeft); 145 | }); 146 | 147 | it('should remove itself from the DOM when its map is unset', function () { 148 | overlayInstance.onAdd(); 149 | 150 | overlayInstance.onRemove(); 151 | 152 | expect(overlayPaneElement.contains(overlayElement)).to.equal(false); 153 | }); 154 | 155 | it('should cache the dimensions of the associated DOM element', function () { 156 | var expectedTop = calculateTop(), 157 | expectedLeft = calculateLeft(); 158 | 159 | overlayInstance.cacheDimensions(); 160 | 161 | givenTheOverlayIsAddedToTheDom(); 162 | overlayInstance.draw(); 163 | 164 | generateHeightAndWidth(); 165 | setDimensionsOfOverlayElement(); 166 | 167 | overlayInstance.draw(); 168 | expect(overlayElement.style.top).to.equal(expectedTop); 169 | expect(overlayElement.style.left).to.equal(expectedLeft); 170 | }); 171 | 172 | it('should not cache the dimensions until after the overlay has been added to the DOM and drawn', function () { 173 | var expectedTop = calculateTop(), 174 | expectedLeft = calculateLeft(); 175 | 176 | overlayInstance.cacheDimensions(); 177 | overlayInstance.draw(); 178 | 179 | expect(overlayElement.style.top).to.not.equal(expectedTop); 180 | expect(overlayElement.style.left).to.not.equal(expectedLeft); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /test/map-loader.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fakeGoogle = require('./helpers/fake-google-maps.js'), 4 | MapLoader, 5 | 6 | SyncPromise = require('./helpers/sync-promise.js'), 7 | path = require('path'), 8 | 9 | expect = require('chai').expect, 10 | sinon = require('sinon'), 11 | 12 | EXPECTED_GLOBAL_CALLBACK = 'mapLoaded', 13 | EXPECTED_KEY_URL_TEMPLATE = '//maps.googleapis.com/maps/api/js?v={VERSION}&key={KEY}&callback={CALLBACK}', 14 | EXPECTED_CLIENT_CHANNEL_URL_TEMPLATE = '//maps.googleapis.com/maps/api/js?v={VERSION}&client={CLIENT}&channel={CHANNEL}&callback={CALLBACK}', 15 | 16 | sandbox, 17 | 18 | fakeMapInstance, 19 | 20 | expectedVersion, 21 | expectedKey, 22 | expectedClient, 23 | expectedChannel, 24 | expectedKeyUrl, 25 | expectedClientChannelUrl, 26 | loadOptions, 27 | loadClientChannelOptions, 28 | fakeMapOptions, 29 | mapContainer, 30 | 31 | documentReadyCallback, 32 | mapIdleCallback; 33 | 34 | function triggerDocumentReady() { 35 | documentReadyCallback(); 36 | } 37 | 38 | function givenGoogleMapsLibHasLoaded() { 39 | MapLoader.load(loadOptions); 40 | 41 | global[EXPECTED_GLOBAL_CALLBACK](); 42 | } 43 | 44 | function defineStubs() { 45 | sandbox.stub(global, 'Promise', function (resolver) { 46 | return new SyncPromise.Promise(resolver); 47 | }); 48 | 49 | sandbox.stub(fakeGoogle.maps, 'Map') 50 | .returns(fakeMapInstance); 51 | 52 | sandbox.stub(fakeGoogle.maps.event, 'addListenerOnce', function (mapInstance, eventName, callback) { 53 | if (mapInstance === fakeMapInstance && eventName === 'idle') { 54 | mapIdleCallback = callback; 55 | } 56 | }); 57 | 58 | sandbox.stub(document, 'addEventListener', function (event, callback) { 59 | if (event === 'DOMContentLoaded') { 60 | documentReadyCallback = callback; 61 | } 62 | }); 63 | } 64 | 65 | function defineFakeData() { 66 | fakeMapInstance = {}; 67 | fakeMapOptions = {}; 68 | mapContainer = {}; 69 | 70 | expectedVersion = '3.20'; 71 | expectedKey = 'somekey'; 72 | expectedClient = 'myclient'; 73 | expectedChannel = 'mychannel'; 74 | 75 | expectedKeyUrl = EXPECTED_KEY_URL_TEMPLATE 76 | .replace('{VERSION}', expectedVersion) 77 | .replace('{KEY}', expectedKey) 78 | .replace('{CALLBACK}', EXPECTED_GLOBAL_CALLBACK); 79 | 80 | expectedClientChannelUrl = EXPECTED_CLIENT_CHANNEL_URL_TEMPLATE 81 | .replace('{VERSION}', expectedVersion) 82 | .replace('{CLIENT}', expectedClient) 83 | .replace('{CHANNEL}', expectedChannel) 84 | .replace('{CALLBACK}', EXPECTED_GLOBAL_CALLBACK); 85 | 86 | loadOptions = { 87 | version: expectedVersion, 88 | key: expectedKey 89 | }; 90 | 91 | loadClientChannelOptions = { 92 | version: expectedVersion, 93 | client: expectedClient, 94 | channel: expectedChannel 95 | }; 96 | } 97 | 98 | describe('Google Maps Loader Test Suite', function () { 99 | beforeEach(function () { 100 | sandbox = sinon.sandbox.create(); 101 | SyncPromise.setSandbox(sandbox); 102 | 103 | defineFakeData(); 104 | defineStubs(); 105 | }); 106 | 107 | afterEach(function () { 108 | var cachedModule = path.join(__dirname, '..', 'lib', 'map-loader.js'); 109 | 110 | sandbox.restore(); 111 | 112 | SyncPromise.unsetSandbox(); 113 | mapIdleCallback = null; 114 | documentReadyCallback = null; 115 | document.head.innerHTML = ''; 116 | 117 | delete require.cache[cachedModule]; 118 | }); 119 | 120 | describe('When loaded before DOMContentLoaded event has fired', function () { 121 | beforeEach(function () { 122 | document.readyState = 'loading'; 123 | 124 | MapLoader = require('../lib/map-loader'); 125 | }); 126 | 127 | it('should add the google maps key url script to the dom by default', function () { 128 | var scriptEl; 129 | 130 | MapLoader.load(loadOptions); 131 | expect(document.head.querySelector('script')).to.equal(null); 132 | 133 | triggerDocumentReady(); 134 | 135 | scriptEl = document.head.querySelector('script'); 136 | expect(scriptEl.src).to.equal(expectedKeyUrl); 137 | expect(scriptEl.type).to.equal('text/javascript'); 138 | }); 139 | 140 | it('should add the google maps client/channel url script to the dom if client/channel found', function () { 141 | var scriptEl; 142 | 143 | MapLoader.load(loadClientChannelOptions); 144 | expect(document.head.querySelector('script')).to.equal(null); 145 | 146 | triggerDocumentReady(); 147 | 148 | scriptEl = document.head.querySelector('script'); 149 | expect(scriptEl.src).to.equal(expectedClientChannelUrl); 150 | expect(scriptEl.type).to.equal('text/javascript'); 151 | }); 152 | 153 | it('should resolve the promise once google maps is loaded and the global callback is invoked', function () { 154 | var mapLoadPromise = MapLoader.load(loadOptions); 155 | 156 | sinon.assert.notCalled(mapLoadPromise.resolve); 157 | global[EXPECTED_GLOBAL_CALLBACK](); 158 | 159 | sinon.assert.calledOnce(mapLoadPromise.resolve); 160 | }); 161 | 162 | it('should resolve the promise once google maps is loaded and the global callback is invoked', function () { 163 | var mapLoadPromise = MapLoader.load(loadOptions); 164 | 165 | sinon.assert.notCalled(mapLoadPromise.resolve); 166 | global[EXPECTED_GLOBAL_CALLBACK](); 167 | 168 | sinon.assert.calledOnce(mapLoadPromise.resolve); 169 | }); 170 | 171 | it('should create a map instance with the supplied options', function () { 172 | sinon.assert.notCalled(fakeGoogle.maps.Map); 173 | givenGoogleMapsLibHasLoaded(); 174 | 175 | MapLoader.create(mapContainer, fakeMapOptions); 176 | 177 | sinon.assert.calledOnce(fakeGoogle.maps.Map); 178 | sinon.assert.calledWith(fakeGoogle.maps.Map, mapContainer, fakeMapOptions); 179 | sinon.assert.calledWithNew(fakeGoogle.maps.Map); 180 | }); 181 | 182 | it('should resolve with a google maps instance once the map is idle', function () { 183 | var mapIdlePromise; 184 | 185 | givenGoogleMapsLibHasLoaded(); 186 | mapIdlePromise = MapLoader.create(fakeMapOptions); 187 | 188 | sinon.assert.notCalled(mapIdlePromise.resolve); 189 | 190 | mapIdleCallback(); 191 | 192 | sinon.assert.calledOnce(mapIdlePromise.resolve); 193 | sinon.assert.calledWith(mapIdlePromise.resolve, fakeMapInstance); 194 | }); 195 | 196 | it('should expose a hook to know when the google maps API is ready', function () { 197 | var mapLoadedPromise = MapLoader.loaded, 198 | promiseFromLoad = MapLoader.load(loadOptions); 199 | 200 | expect(mapLoadedPromise).to.equal(promiseFromLoad); 201 | }); 202 | 203 | it('should only load the google maps API once', function () { 204 | sinon.assert.calledOnce(global.Promise); 205 | global.Promise.reset(); 206 | 207 | MapLoader.load(loadOptions); 208 | triggerDocumentReady(); 209 | MapLoader.load(loadOptions); 210 | 211 | sinon.assert.notCalled(global.Promise); 212 | expect(document.getElementsByTagName('script')).to.have.length(1); 213 | }); 214 | }); 215 | 216 | describe('When loaded after DOMContentLoaded event has fired', function () { 217 | beforeEach(function () { 218 | document.readyState = 'interactive'; 219 | 220 | MapLoader = require('../lib/map-loader'); 221 | }); 222 | 223 | it('should add the google maps script to the dom immediately', function () { 224 | var scriptEl; 225 | 226 | expect(document.head.querySelector('script')).to.equal(null); 227 | MapLoader.load(loadOptions); 228 | 229 | scriptEl = document.head.querySelector('script'); 230 | expect(scriptEl.src).to.equal(expectedKeyUrl); 231 | expect(scriptEl.type).to.equal('text/javascript'); 232 | }); 233 | 234 | it('should only load the google maps API once', function () { 235 | sinon.assert.calledOnce(global.Promise); 236 | global.Promise.reset(); 237 | 238 | MapLoader.load(loadOptions); 239 | MapLoader.load(loadOptions); 240 | 241 | sinon.assert.notCalled(global.Promise); 242 | expect(document.getElementsByTagName('script')).to.have.length(1); 243 | }); 244 | }); 245 | }); 246 | --------------------------------------------------------------------------------