├── testing └── fixtures │ └── points │ ├── empty-array.json │ ├── point-two.json │ ├── point-one-without-comment.json │ ├── point-one.json │ └── points-one-and-two.json ├── .babelrc ├── .gitignore ├── frontend └── src │ ├── js │ ├── controls │ │ ├── controls.js │ │ ├── initialize-api-key-control.js │ │ ├── initialize-center-map-control.js │ │ ├── initialize-export-all-points-control.js │ │ ├── initialize-controls.js │ │ ├── initialize-load-and-merge-from-db-control.js │ │ └── initialize-load-and-merge-from-file-control.js │ ├── entry.js │ ├── map │ │ ├── load-google-maps-script.js │ │ ├── initialize-map.js │ │ ├── google-maps-api-key.js │ │ ├── initialize-drawing-manager.js │ │ └── points.js │ ├── data │ │ ├── points-db.js │ │ └── config-db.js │ └── elements.js │ ├── html │ └── index.html │ └── scss │ └── main.scss ├── webpack.config.js ├── backend ├── db-models │ ├── versions.js │ ├── scannedlocation.js │ ├── pokemon.js │ ├── pokestop.js │ └── gym.js └── spawnpoints-from-db.js ├── CHANGELOG.md ├── server.js ├── package.json ├── README.md ├── example.json └── definitions └── js-marker-clusterer.js /testing/fixtures/points/empty-array.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | frontend/dist/ 3 | .idea/ 4 | docs/ 5 | -------------------------------------------------------------------------------- /testing/fixtures/points/point-two.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "lat": 47.5942073342733, 4 | "lng": -122.33270326276, 5 | "time": 2059 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /testing/fixtures/points/point-one-without-comment.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "lat": 47.5942294808949, 4 | "lng": -122.33317612427, 5 | "time": 288 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /frontend/src/js/controls/controls.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | 3 | export default { 4 | closeMenus() { 5 | Array.from(ELEMENTS.CONTROLS.querySelectorAll('.expandable.control > input[type="checkbox"]')) 6 | .forEach(menuControl => menuControl.checked = false); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './frontend/src/js/entry.js', 3 | output: { 4 | path: './frontend/dist/js', 5 | filename: 'entry.js' 6 | }, 7 | module: { 8 | loaders: [{ 9 | test: /\.js$/, 10 | exclude: /node_modules/, 11 | loader: 'babel-loader' 12 | }] 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-api-key-control.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import googleMapsApiKey from '../map/google-maps-api-key'; 3 | 4 | /** 5 | * Initialize event listeners for clicking on the "API Key" button. 6 | * @module 7 | */ 8 | export default function () { 9 | ELEMENTS.API_KEY_CONTROL.addEventListener('click', googleMapsApiKey.clearKey); 10 | } 11 | -------------------------------------------------------------------------------- /backend/db-models/versions.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('versions', { 5 | key: { 6 | type: DataTypes.STRING, 7 | allowNull: false 8 | }, 9 | val: { 10 | type: DataTypes.INTEGER(11), 11 | allowNull: false 12 | } 13 | }, { 14 | tableName: 'versions' 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /testing/fixtures/points/point-one.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_comment": "Input files must be a valid JSON array of object literals. Each object literal must contain a 'lat' and 'lng' property with a numeric value. Each can have any number and type of addition properties such as this '_comment.' All properties of the object will be preserved when exporting.", 4 | "lat": 47.5942294808949, 5 | "lng": -122.33317612427, 6 | "time": 288 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /backend/db-models/scannedlocation.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('scannedlocation', { 5 | latitude: { 6 | type: 'DOUBLE', 7 | allowNull: false, 8 | primaryKey: true 9 | }, 10 | longitude: { 11 | type: 'DOUBLE', 12 | allowNull: false, 13 | primaryKey: true 14 | }, 15 | last_modified: { 16 | type: DataTypes.DATE, 17 | allowNull: false 18 | } 19 | }, { 20 | tableName: 'scannedlocation' 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /testing/fixtures/points/points-one-and-two.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_comment": "Input files must be a valid JSON array of object literals. Each object literal must contain a 'lat' and 'lng' property with a numeric value. Each can have any number and type of addition properties such as this '_comment.' All properties of the object will be preserved when exporting.", 4 | "lat": 47.5942294808949, 5 | "lng": -122.33317612427, 6 | "time": 288 7 | }, 8 | { 9 | "lat": 47.5942073342733, 10 | "lng": -122.33270326276, 11 | "time": 2059 12 | } 13 | ] 14 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-center-map-control.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import configDb from '../data/config-db'; 3 | 4 | export default function () { 5 | configDb.getPointsSettings() 6 | .then(pointsSettings => ELEMENTS.CENTER_MAP_CONTROL.checked = pointsSettings.centerOnLoad); 7 | 8 | ELEMENTS.CENTER_MAP_CONTROL.addEventListener('change', event => 9 | configDb.getPointsSettings() 10 | .then(pointsSettings => 11 | configDb.setPointsSettings(Object.assign({}, pointsSettings, { centerOnLoad: ELEMENTS.CENTER_MAP_CONTROL.checked })))); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-export-all-points-control.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import points from '../map/points'; 3 | 4 | export default function () { 5 | const downloadElement = document.createElement('a'); 6 | downloadElement.download = 'all-points.json'; 7 | 8 | ELEMENTS.EXPORT_ALL_POINTS_CONTROL.addEventListener('click', event => { 9 | points.getPoints() 10 | .then(points => { 11 | const url = URL.createObjectURL(new Blob([JSON.stringify(points)], {type: "application/json"})); 12 | downloadElement.href = url; 13 | downloadElement.click(); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /backend/db-models/pokemon.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('pokemon', { 5 | encounter_id: { 6 | type: DataTypes.STRING, 7 | allowNull: false, 8 | primaryKey: true 9 | }, 10 | spawnpoint_id: { 11 | type: DataTypes.STRING, 12 | allowNull: false 13 | }, 14 | pokemon_id: { 15 | type: DataTypes.INTEGER(11), 16 | allowNull: false 17 | }, 18 | latitude: { 19 | type: 'DOUBLE', 20 | allowNull: false 21 | }, 22 | longitude: { 23 | type: 'DOUBLE', 24 | allowNull: false 25 | }, 26 | disappear_time: { 27 | type: DataTypes.DATE, 28 | allowNull: false 29 | } 30 | }, { 31 | tableName: 'pokemon', 32 | timestamps: false 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-controls.js: -------------------------------------------------------------------------------- 1 | import initializeLoadAndMergeFromFileControl from './initialize-load-and-merge-from-file-control'; 2 | import initializeApiKeyControl from './initialize-api-key-control'; 3 | import initializeLoadAndMergeFromDbControl from './initialize-load-and-merge-from-db-control'; 4 | import initializeExportAllPointsControl from './initialize-export-all-points-control'; 5 | import initializeCenterMapControl from './initialize-center-map-control'; 6 | 7 | /** 8 | * Initialize the controls. Pretty much just delegate out to methods that attach event handlers. 9 | * @module 10 | */ 11 | export default function () { 12 | initializeLoadAndMergeFromFileControl(); 13 | initializeApiKeyControl(); 14 | initializeLoadAndMergeFromDbControl(); 15 | initializeExportAllPointsControl(); 16 | initializeCenterMapControl(); 17 | } 18 | -------------------------------------------------------------------------------- /backend/db-models/pokestop.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('pokestop', { 5 | pokestop_id: { 6 | type: DataTypes.STRING, 7 | allowNull: false, 8 | primaryKey: true 9 | }, 10 | enabled: { 11 | type: DataTypes.BOOLEAN, 12 | allowNull: false 13 | }, 14 | latitude: { 15 | type: 'DOUBLE', 16 | allowNull: false 17 | }, 18 | longitude: { 19 | type: 'DOUBLE', 20 | allowNull: false 21 | }, 22 | last_modified: { 23 | type: DataTypes.DATE, 24 | allowNull: false 25 | }, 26 | lure_expiration: { 27 | type: DataTypes.DATE, 28 | allowNull: true 29 | }, 30 | active_fort_modifier: { 31 | type: DataTypes.STRING, 32 | allowNull: true 33 | } 34 | }, { 35 | tableName: 'pokestop' 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/js/entry.js: -------------------------------------------------------------------------------- 1 | import mapsApiKey from './map/google-maps-api-key'; 2 | import loadGoogleMapsScript from './map/load-google-maps-script'; 3 | import initializeMap from './map/initialize-map'; 4 | import initializeDrawingManager from './map/initialize-drawing-manager'; 5 | import initializeControls from './controls/initialize-controls'; 6 | 7 | /** 8 | * All of the magic begins here. 9 | *
    10 | *
  1. Procure Google Maps API key
  2. 11 | *
  3. Load the google maps script with the key as a parameter
  4. 12 | *
  5. Initialize the map
  6. 13 | *
  7. Initialize the drawing manager for the map
  8. 14 | *
  9. Initialize the controls for interacting with the map
  10. 15 | *
16 | */ 17 | function entryPoint() { 18 | mapsApiKey.getKey() 19 | .then(loadGoogleMapsScript) 20 | .then(initializeMap) 21 | .then(initializeDrawingManager) 22 | .then(initializeControls); 23 | } 24 | entryPoint(); 25 | -------------------------------------------------------------------------------- /backend/db-models/gym.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('gym', { 5 | gym_id: { 6 | type: DataTypes.STRING, 7 | allowNull: false, 8 | primaryKey: true 9 | }, 10 | team_id: { 11 | type: DataTypes.INTEGER(11), 12 | allowNull: false 13 | }, 14 | guard_pokemon_id: { 15 | type: DataTypes.INTEGER(11), 16 | allowNull: false 17 | }, 18 | gym_points: { 19 | type: DataTypes.INTEGER(11), 20 | allowNull: false 21 | }, 22 | enabled: { 23 | type: DataTypes.BOOLEAN, 24 | allowNull: false 25 | }, 26 | latitude: { 27 | type: 'DOUBLE', 28 | allowNull: false 29 | }, 30 | longitude: { 31 | type: 'DOUBLE', 32 | allowNull: false 33 | }, 34 | last_modified: { 35 | type: DataTypes.DATE, 36 | allowNull: false 37 | } 38 | }, { 39 | tableName: 'gym' 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.0.2] 4 | ### Fixed 5 | - Scroll bars on the map. 6 | 7 | ### Added 8 | - "Merge From File" and "Merge From DB" controls under the points menu. 9 | - All merged points that have the exact same key/value pairs will be de-duplicated and only one 10 | will make it into the resulting set of points. 11 | - Some JSON files for testing. Right now testing is manual, but these are useful. 12 | 13 | ## [1.0.1] - 2016-09-01 14 | ### Added 15 | - This changelog, and a retroactive entry to describe the latest changes. 16 | 17 | ## [1.0.0] - 2016-09-01 18 | 19 | ### Removed 20 | - Set Location control. 21 | 22 | ### Added 23 | - "Center map when points load" control under "Points" 24 | - If this is toggled, the map will change the viewport to fit the points loaded. 25 | - Smart viewports 26 | - Your latest zoom and location will be saved and loaded when you leave and return. 27 | This behavior is overridden by the "Center map when points load" control. 28 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const multiparty = require('multiparty'); 3 | const spawnpointsFromDb = require('./backend/spawnpoints-from-db'); 4 | const server = express(); 5 | const PORT = process.env.npm_package_config_port; 6 | 7 | server.use('/static', express.static(__dirname + '/frontend/dist')); 8 | server.post('/load-from-db', loadFromDb); 9 | server.get('/', (req, res) => res.sendFile(__dirname + '/frontend/dist/index.html')); 10 | 11 | server.listen(PORT, () => console.log(`Listening on port: ${PORT}`)); 12 | 13 | 14 | function loadFromDb(req, res, next) { 15 | new multiparty.Form().parse(req, (err, fields) => { 16 | if (err) { 17 | res.status(500).send('Failed to load from the database.'); 18 | next(); 19 | } 20 | 21 | spawnpointsFromDb(fields) 22 | .then(results => { 23 | res.json(results) 24 | }) 25 | .catch(err => { 26 | res.status(500).send('Failed to load from the database.'); 27 | }) 28 | .then(next); 29 | }) 30 | } 31 | 32 | -------------------------------------------------------------------------------- /frontend/src/js/map/load-google-maps-script.js: -------------------------------------------------------------------------------- 1 | import configDb from '../data/config-db'; 2 | 3 | /** 4 | * Load the google maps api script using the key stored in settings (or inputted by the user) 5 | * @module 6 | * @param {String} apiKey The Google Maps API Key. See https://developers.google.com/maps/documentation/javascript/get-api-key for more information. 7 | * @returns {Promise} Resolves on success when the script has loaded. Rejects on failure. 8 | */ 9 | export default function (apiKey) { 10 | return new Promise((resolve, reject) => { 11 | window.gm_authFailure = () => configDb.setApiKey(''); 12 | window.__gmapsCallback = resolve; 13 | 14 | const scriptElement = document.createElement('script'); 15 | scriptElement.type = 'text/javascript'; 16 | scriptElement.onerror = () => reject(`Error loading google maps script: ${scriptElement.src}`); 17 | scriptElement.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=__gmapsCallback&libraries=drawing`; 18 | document.body.appendChild(scriptElement); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/js/data/points-db.js: -------------------------------------------------------------------------------- 1 | /** @type PouchDB */ 2 | import PouchDb from 'pouchdb'; 3 | const db = new PouchDb('pipoam'); 4 | 5 | /** 6 | * @private 7 | * @returns {Promise.<{_id: 'points', points: Array.<{lat: number, lng: number}>}>} 8 | */ 9 | const getPointsDocument = () => { 10 | return db.get('points').catch(function (err) { 11 | if (err.name === 'not_found') { 12 | return { 13 | _id: 'points', 14 | points: [] 15 | }; 16 | } else { 17 | throw err; 18 | } 19 | }); 20 | }; 21 | 22 | /** 23 | * Get and Set map points from the database. 24 | * @module 25 | */ 26 | export default Object.freeze({ 27 | /** 28 | * @static 29 | * @returns {Promise.>} 30 | */ 31 | getPoints() { 32 | return getPointsDocument().then(document => document.points); 33 | }, 34 | /** 35 | * @static 36 | * @param points 37 | * @returns {Promise.>} 38 | */ 39 | setPoints(points) { 40 | return getPointsDocument() 41 | .then(document => db.put(Object.assign({}, document, { points }))) 42 | .then(() => points); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /frontend/src/js/map/initialize-map.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import points from './points'; 3 | import configDb from '../data/config-db'; 4 | import debounce from 'debounce'; 5 | 6 | /** 7 | * Determine a starting location, instantiate the map, populate pre-existing points. 8 | * @module 9 | * @returns {Promise.>} 10 | */ 11 | export default function () { 12 | return configDb.getMapPosition() 13 | .then(initializeMap) 14 | .then(points.loadPoints); 15 | } 16 | 17 | /** 18 | * @private 19 | * @param {MapPosition} mapPosition 20 | * @returns {google.maps.Map} 21 | */ 22 | function initializeMap(mapPosition) { 23 | const mapInstance = new google.maps.Map(ELEMENTS.MAP, { 24 | center: { lat: mapPosition.lat, lng: mapPosition.lng }, 25 | mapTypeControl: true, 26 | zoom: mapPosition.zoom 27 | }); 28 | ELEMENTS.MAP.__mapInstance = mapInstance; 29 | 30 | mapInstance.addListener('bounds_changed', debounce(() => { 31 | const center = mapInstance.getCenter(); 32 | return configDb.setMapPosition({ 33 | lat: center.lat(), 34 | lng: center.lng(), 35 | zoom: mapInstance.getZoom() 36 | }) 37 | }, 1000)); 38 | return mapInstance; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/js/elements.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Store references to commonly used dom elements. 3 | * @module ELEMENTS 4 | */ 5 | export default Object.freeze({ 6 | /** 7 | * @type HTMLElement 8 | * @static 9 | */ 10 | MAP: document.getElementById('map'), 11 | 12 | /** 13 | * @type HTMLElement 14 | * @static 15 | */ 16 | CONTROLS: document.getElementById('controls'), 17 | 18 | /** 19 | * @type HTMLElement 20 | * @static 21 | */ 22 | LOAD_POINTS_FROM_FILE_CONTROL: document.getElementById('load-from-file-control'), 23 | 24 | /** 25 | * @type HTMLElement 26 | * @static 27 | */ 28 | MERGE_POINTS_FROM_FILE_CONTROL: document.getElementById('merge-from-file-control'), 29 | 30 | /** 31 | * @type HTMLElement 32 | * @static 33 | */ 34 | API_KEY_CONTROL: document.getElementById('api-key-control'), 35 | 36 | /** 37 | * @type HTMLElement 38 | * @static 39 | */ 40 | LOAD_FROM_DB_FORM: document.getElementById('load-from-db-form'), 41 | 42 | /** 43 | * @type HTMLElement 44 | * @static 45 | */ 46 | EXPORT_ALL_POINTS_CONTROL: document.getElementById('export-all-points-control'), 47 | 48 | /** 49 | * @type HTMLElement 50 | * @static 51 | */ 52 | CENTER_MAP_CONTROL: document.getElementById('center-map-control') 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/js/map/google-maps-api-key.js: -------------------------------------------------------------------------------- 1 | import configDb from '../data/config-db'; 2 | 3 | function getKey() { 4 | return configDb.getApiKey() 5 | .then(key => { 6 | if (key) { 7 | return key; 8 | } 9 | return promptForKey() 10 | .then(newKey => configDb.setApiKey(newKey)); 11 | }); 12 | } 13 | 14 | function clearKey() { 15 | configDb.setApiKey(null) 16 | .then(() => alert('Your API Key has been cleared. Please refresh your browser for changes to take affect.')); 17 | } 18 | 19 | function promptForKey() { 20 | return new Promise(resolve => { 21 | resolve(prompt('Please enter a google maps API key', '')); 22 | }); 23 | } 24 | 25 | /** 26 | * Get the google maps API key from settings or prompt the user to input a new key. 27 | * @module 28 | */ 29 | export default Object.freeze({ 30 | /** 31 | * Get the google maps API key from settings. 32 | * @static 33 | * @method 34 | * @returns {Promise.} 35 | */ 36 | getKey, 37 | 38 | /** 39 | * Prompt the user to enter a google maps api key. 40 | * @static 41 | * @method 42 | * @returns {Promise.} 43 | */ 44 | promptForKey, 45 | /** 46 | * Clear the api key from configuration and let the user know what's up. 47 | * @method 48 | * @static 49 | */ 50 | clearKey 51 | }); 52 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-load-and-merge-from-db-control.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import controls from './controls'; 3 | import points from '../map/points'; 4 | 5 | export default function () { 6 | ELEMENTS.LOAD_FROM_DB_FORM.addEventListener('submit', event => { 7 | if (!window.fetch) { 8 | alert('Sorry, your browser must support fetch to use this feature. See https://developer.mozilla.org/en/docs/Web/API/Fetch_API for more information.'); 9 | return; 10 | } 11 | 12 | event.preventDefault(); 13 | 14 | const formData = new FormData(ELEMENTS.LOAD_FROM_DB_FORM); 15 | fetch('/load-from-db', { 16 | method: 'POST', 17 | body: formData 18 | }) 19 | .then(response => { 20 | if (!response.ok) { 21 | throw new Error('Failed to load points from db'); 22 | } 23 | return response.json(); 24 | }) 25 | .then(pointsFromDb => { 26 | if(formData.get('mergePoints') === 'true') { 27 | return points.mergePoints(pointsFromDb); 28 | } else { 29 | return points.setPoints(pointsFromDb) 30 | } 31 | }) 32 | .then(() => { 33 | ELEMENTS.LOAD_FROM_DB_FORM.classList.remove('error'); 34 | controls.closeMenus(); 35 | }) 36 | .catch(handleFormError); 37 | }); 38 | } 39 | 40 | 41 | function handleFormError() { 42 | ELEMENTS.LOAD_FROM_DB_FORM.classList.add('error'); 43 | } 44 | -------------------------------------------------------------------------------- /backend/spawnpoints-from-db.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | 3 | /** 4 | * @param {{host: Array, dialect: Array, storage: Array, dbName: Array, username: Array, password: Array}} fields 5 | * @returns {Promise.} 6 | */ 7 | module.exports = function (fields) { 8 | const config = { 9 | host: fields.host[0], 10 | dialect: fields.dialect[0], 11 | storage: fields.storage[0], 12 | logging: false 13 | }; 14 | 15 | const sequelize = new Sequelize(fields.dbName[0], fields.username[0], fields.password[0], config); 16 | const Pogom = sequelize.import('./db-models/pokemon.js'); 17 | return Pogom 18 | .findAll({ group: 'spawnpoint_id' }) 19 | .then(convertResults); 20 | }; 21 | 22 | /** 23 | * @param {Array.} results 24 | * @returns {Array.<{lat: number, lng: number, spawnpoint_id: string, time: number}>} 25 | */ 26 | function convertResults(results) { 27 | return results 28 | .map(result => Object.freeze({ 29 | lat: result.dataValues.latitude, 30 | lng: result.dataValues.longitude, 31 | spawnpoint_id: result.dataValues.spawnpoint_id, 32 | time: convertDisappearTimeToTime(result.dataValues.disappear_time) 33 | })); 34 | } 35 | 36 | /** 37 | * Convert disappearTime, which is a date stored in a string, to seconds of the hour. 38 | * @param {String} disappearTime 39 | * @returns {number} 40 | */ 41 | function convertDisappearTimeToTime(disappearTime) { 42 | const disappearDate = new Date(disappearTime); 43 | return ((disappearDate.getMinutes() * 60 + disappearDate.getSeconds()) + 2701) % 3600; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/js/controls/initialize-load-and-merge-from-file-control.js: -------------------------------------------------------------------------------- 1 | import ELEMENTS from '../elements'; 2 | import points from '../map/points'; 3 | import controls from './controls'; 4 | 5 | /** 6 | * Initialize event listeners for clicking on the "Load Points" button. 7 | * @module 8 | */ 9 | export default function () { 10 | ELEMENTS.LOAD_POINTS_FROM_FILE_CONTROL.parentNode.addEventListener('change', pointsFileInputHandler); 11 | } 12 | 13 | /** 14 | * @private 15 | * @param {HTMLElement} inputElement The input element to attach the event listener to. 16 | */ 17 | function pointsFileInputHandler(event) { 18 | const inputElement = event.target; 19 | const files = inputElement.files; 20 | 21 | if (files.length < 1) { 22 | return; 23 | } 24 | 25 | const fileReader = new FileReader(); 26 | fileReader.onloadend = event => { 27 | if (event.target.error) { 28 | alert('There was an error loading your file'); 29 | } 30 | 31 | const results = event.target.result; 32 | let loadedPoints; 33 | try { 34 | loadedPoints = JSON.parse(results); 35 | } catch (err) { 36 | alert('Failed to parse JSON from the file you loaded.'); 37 | } 38 | if(inputElement === ELEMENTS.LOAD_POINTS_FROM_FILE_CONTROL) { 39 | points.setPoints(loadedPoints); 40 | } else if(inputElement === ELEMENTS.MERGE_POINTS_FROM_FILE_CONTROL) { 41 | points.mergePoints(loadedPoints); 42 | } 43 | 44 | controls.closeMenus(); 45 | }; 46 | fileReader.readAsText(files[0]); 47 | 48 | 49 | // Reset the value. Otherwise, if the user attempts to load the same file, the 50 | // change event will not fire. 51 | inputElement.value = null; 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/js/map/initialize-drawing-manager.js: -------------------------------------------------------------------------------- 1 | import points from './points'; 2 | import ELEMENTS from '../elements'; 3 | 4 | /** 5 | * Initialize a drawing manager that only allows polygons. 6 | * When a polygon is drawn, add a click event listener to it. 7 | * @module 8 | * @returns {google.maps.Map} 9 | */ 10 | export default function () { 11 | const map = ELEMENTS.MAP.__mapInstance; 12 | const drawingManager = new google.maps.drawing.DrawingManager({ 13 | drawingControl: true, 14 | drawingControlOptions: { 15 | position: google.maps.ControlPosition.TOP_CENTER, 16 | drawingModes: ['polygon'] 17 | }, 18 | polygonOptions: { 19 | editable: true 20 | } 21 | }); 22 | drawingManager.setMap(map); 23 | google.maps.event.addListener(drawingManager, 'polygoncomplete', function(polygon) { 24 | google.maps.event.addListener(polygon, 'click', createOverlayClickHandler(polygon)) 25 | }); 26 | return map; 27 | } 28 | 29 | /** 30 | * 31 | * @param {google.maps.Polygon} polygon The polygon that was clicked 32 | * @returns {Function} An overly complicated event handler function that needs to be improved 33 | */ 34 | function createOverlayClickHandler(polygon) { 35 | const downloadElement = document.createElement('a'); 36 | downloadElement.download = 'clipped-points.json'; 37 | return function () { 38 | points.getPoints() 39 | .then(points => { 40 | const clippedPoints = points.filter(point => google.maps.geometry.poly.containsLocation(new google.maps.LatLng(point.lat, point.lng), polygon)); 41 | const url = URL.createObjectURL(new Blob([JSON.stringify(clippedPoints)], {type: "application/json"})); 42 | downloadElement.href = url; 43 | downloadElement.click(); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pipoam", 3 | "version": "1.0.2", 4 | "description": "Points in Polygons on a Map", 5 | "main": "index.js", 6 | "config": { 7 | "port": 3000 8 | }, 9 | "scripts": { 10 | "clean": "rimraf frontend/dist", 11 | "transpile-js": "webpack", 12 | "transpile-scss": "node-sass --include-path frontend/src/scss frontend/src/scss/main.scss frontend/dist/css/main.css", 13 | "copy-html": "cpx ./frontend/src/html/**/*.html ./frontend/dist", 14 | "start-server": "node server.js", 15 | "dev-js": "webpack --watch", 16 | "dev-scss": "node-sass --watch --include-path frontend/src/scss frontend/src/scss/main.scss frontend/dist/css/main.css", 17 | "dev-html": "cpx ./frontend/src/html/**/*.html ./frontend/dist --watch", 18 | "start": "npm run clean && npm run transpile-js && npm run transpile-scss && npm run copy-html && npm run start-server", 19 | "start-dev": "npm run clean && concurrently \"npm run dev-js\" \"npm run transpile-scss\" \"npm run dev-scss\" \"npm run dev-html\" \"npm run start-server\"", 20 | "generate-docs": "rimraf docs && jsdoc -r frontend/src/js -d docs -t ./node_modules/minami", 21 | "test": "echo \"Error: no test specified\" && exit 1" 22 | }, 23 | "author": "Brandon Shults", 24 | "license": "ISC", 25 | "dependencies": { 26 | "babel-core": "^6.13.2", 27 | "babel-loader": "^6.2.4", 28 | "babel-preset-es2015": "^6.13.2", 29 | "concurrently": "^2.2.0", 30 | "cpx": "^1.3.2", 31 | "debounce": "^1.0.0", 32 | "exports-loader": "^0.6.3", 33 | "express": "^4.14.0", 34 | "imports-loader": "^0.6.5", 35 | "js-marker-clusterer": "^1.0.0", 36 | "multiparty": "^4.1.2", 37 | "mysql": "^2.11.1", 38 | "node-sass": "^3.8.0", 39 | "pouchdb": "^5.4.5", 40 | "rimraf": "^2.5.4", 41 | "sequelize": "^3.24.1", 42 | "sqlite3": "^3.1.4", 43 | "webpack": "^1.13.1" 44 | }, 45 | "devDependencies": { 46 | "jsdoc": "^3.4.0", 47 | "minami": "^1.1.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/js/data/config-db.js: -------------------------------------------------------------------------------- 1 | /** @type {PouchDB} */ 2 | import PouchDB from 'pouchdb'; 3 | 4 | const db = new PouchDB('pipoam'); 5 | 6 | /** 7 | * @typedef {{centerOnLoad: boolean}} PointsSettings 8 | * @type PointsSettings 9 | */ 10 | const DEFAULT_POINTS_SETTINGS = { 11 | centerOnLoad: false 12 | }; 13 | 14 | /** 15 | * @typedef {{lat: Number, lng: Number, zoom: Number}} MapPosition 16 | * @type MapPosition 17 | */ 18 | const DEFAULT_MAP_POSITION = { 19 | lat: 43.5992568, 20 | lng: -122.334228, 21 | zoom: 2 22 | }; 23 | 24 | /** 25 | * @typedef {Object} ConfigDocument 26 | * @property {String} _id 27 | * @property {String} apiKey 28 | * @property {PointsSettings} pointsSettings 29 | * @property {MapPosition} mapPosition 30 | */ 31 | /** 32 | * @private 33 | * @returns {Promise.} 34 | */ 35 | const getConfigDocument = () => { 36 | return db.get('config').catch(function (err) { 37 | if (err.name === 'not_found') { 38 | return { 39 | _id: 'config', 40 | apiKey: null, 41 | pointsSettings: DEFAULT_POINTS_SETTINGS, 42 | mapPosition: DEFAULT_MAP_POSITION 43 | }; 44 | } else { 45 | throw err; 46 | } 47 | }); 48 | }; 49 | 50 | /** 51 | * Get and Set configuration values from the database. 52 | * @module 53 | */ 54 | export default Object.freeze({ 55 | /** 56 | * @static 57 | * @returns {Promise.} 58 | */ 59 | getApiKey() { 60 | return getConfigDocument().then(configDocument => configDocument.apiKey); 61 | }, 62 | 63 | /** 64 | * @static 65 | * @param {String} apiKey 66 | * @returns {Promise.} 67 | */ 68 | setApiKey(apiKey) { 69 | return getConfigDocument() 70 | .then(document => db.put(Object.assign({}, document, { apiKey }))) 71 | .then(() => apiKey); 72 | }, 73 | 74 | /** 75 | * @static 76 | * @returns {Promise.} 77 | */ 78 | getPointsSettings() { 79 | return getConfigDocument().then(configDocument => configDocument.pointsSettings || DEFAULT_POINTS_SETTINGS); 80 | }, 81 | 82 | /** 83 | * @static 84 | * @param pointsSettings 85 | * @returns {Promise.} 86 | */ 87 | setPointsSettings(pointsSettings) { 88 | return getConfigDocument() 89 | .then(document => db.put(Object.assign({}, document, { pointsSettings }))) 90 | .then(() => pointsSettings); 91 | }, 92 | 93 | /** 94 | * @static 95 | * @returns {Promise.} 96 | */ 97 | getMapPosition() { 98 | return getConfigDocument().then(configDocument => configDocument.mapPosition || DEFAULT_MAP_POSITION); 99 | }, 100 | 101 | /** 102 | * @static 103 | * @param {MapPosition} mapPosition 104 | * @returns {Promise.} 105 | */ 106 | setMapPosition(mapPosition) { 107 | return getConfigDocument() 108 | .then(document => db.put(Object.assign({}, document, { mapPosition }))) 109 | .then(() => mapPosition); 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Points in Polygons on a Map 2 | A tool for clipping points on a map with polygons. 3 | 4 | **Important: If you imported from a DB before [cda2d83b](https://github.com/brandonshults/pipoam/commit/cda2d83bf653a58367585adeaf8b05a54acb1594) then you will need to re-import to fix a bug with how the time property was calculated.** 5 | #### To Run 6 | ``` 7 | npm install 8 | npm start 9 | ``` 10 | #### To Use 11 | Navigate to http://localhost:3000 in a modern browser. 12 | 13 | You will be prompted for a Google Maps Api Key. If you do not have one you can learn more about 14 | getting one here: https://developers.google.com/maps/documentation/javascript/get-api-key. 15 | If you would like to change the key you entered later, click the "API Key" button in the controls at 16 | the top of the page. 17 | 18 | You can load points from either a json file or a pogom mysql, mariadb, or sqlite database. 19 | To do so, click on the "Load Points" control. This will bring up a menu. Clicking the "Load from 20 | File" button will let you select a json file to load. Filling out the form and clicking the "Load 21 | from DB" button will pull point information from the database specified. 22 | 23 | You can draw polygons on the map with the very small and hard to see drawing controls located at the 24 | top center of your map. Click the polygon and then begin drawing on the map. Sometimes the google 25 | maps api makes it difficult to begin drawing a polygon. I have found that double clicking helps. 26 | 27 | Once you have a polygon, you can switch out of polygon mode by clicking on the hand tool next to the 28 | polygon tool. Now, if you click on the polygon your browser should download a json file containing 29 | only the points that exist within that polygon. If you have your browser set to automatically 30 | download then you will find a clipped-points.json file in your default download path. 31 | 32 | #### Configuration 33 | Configuration is handled through npm. From the command line set npm config with: 34 | ``` 35 | npm config set pipoam: 36 | ``` 37 | Available config parameters: 38 | ``` 39 | port - The port on which your local server will run. 40 | ``` 41 | #### To Develop 42 | I have created an npm task to launch the server with html, css, and js watchers for easier 43 | devloping. Note, there is no hot loading so you will still have to refresh after a change. 44 | ``` 45 | npm install 46 | npm run start-dev 47 | ``` 48 | 49 | #### To Do 50 | Between work and family, I don't have as much time as I would like to improve and maintain this 51 | project, but I would like to eventually get around to: 52 | * Tests 53 | * Style/Contributing guides 54 | * Server-side DB Integrations 55 | * Improved frontend styling 56 | * Replace alert and prompt 57 | * Save polygons 58 | * Cleanup Code 59 | * More documentation 60 | * Lots more... 61 | #### Contributing 62 | I will gladly look into your pull requests. While I would eventually like to impose style guidelines, there are none written in stone as of yet. That being said, at minimum pull requests should include jsdoc style comments and frontend code performing asynchronous tasks should do so with es2015 style promises rather than callbacks. 63 | -------------------------------------------------------------------------------- /frontend/src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Points in Polygons on a Map 5 | 6 | 7 | 8 | 9 |
10 |
11 |

API Key

12 |
13 | 55 |
56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /frontend/src/scss/main.scss: -------------------------------------------------------------------------------- 1 | $control-background-color: #4A4A4A; 2 | $control-title-color: #BF7900; 3 | $border-color: #808080; 4 | $blue: #4B83C4; 5 | $light-gray: #D6D6D6; 6 | 7 | html, body { 8 | color: $light-gray; 9 | display: flex; 10 | flex-direction: column; 11 | font-family: Arial, serif; 12 | font-size: 12px; 13 | height: 100%; 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | h2 { 19 | color: $control-title-color; 20 | font-family: Arial, serif; 21 | font-size: 18px; 22 | font-weight: normal; 23 | margin: 0; 24 | } 25 | 26 | .button { 27 | background-color: $light-gray; 28 | border: 1px solid $blue; 29 | border-radius: 7px; 30 | color: $blue; 31 | cursor: pointer; 32 | display: inline-block; 33 | font-family: Arial, serif; 34 | font-size: 12px; 35 | font-weight: bold; 36 | margin: 0; 37 | padding: 5px; 38 | } 39 | 40 | .interactive { 41 | cursor: pointer; 42 | } 43 | 44 | #controls { 45 | background-color: $control-background-color; 46 | display: flex; 47 | height: 40px; 48 | position: relative; 49 | z-index: 2; 50 | align-items: center; 51 | justify-content: space-around; 52 | 53 | > .control { 54 | position: relative; 55 | 56 | &.expandable { 57 | .menu { 58 | background-color: $control-background-color; 59 | display: none; 60 | left: 50%; 61 | list-style-type: none; 62 | margin: 0; 63 | max-width: 320px; 64 | padding: 20px; 65 | position: absolute; 66 | top: 100%; 67 | transform: translateX(-50%); 68 | width: auto; 69 | 70 | > li { 71 | border: 1px solid $border-color; 72 | display: block; 73 | margin-top: 10px; 74 | padding: 10px; 75 | &:first-child { 76 | margin-top: 0; 77 | } 78 | } 79 | } 80 | > input[type="checkbox"] { 81 | display: none; 82 | &:checked + .menu { 83 | display: block; 84 | } 85 | } 86 | } 87 | } 88 | 89 | #load-from-file-control, #merge-from-file-control { 90 | display: none; 91 | } 92 | 93 | #load-from-db-form { 94 | &.error:before { 95 | color: red; 96 | content: "Error. Please check your input."; 97 | display: block; 98 | margin-bottom: 5px; 99 | } 100 | .sqlite.text-options { 101 | display: none; 102 | } 103 | 104 | #sqlite-dialect-radio:checked + label { 105 | + .main.text-options { 106 | display: none; 107 | 108 | + .sqlite.text-options { 109 | display: block; 110 | } 111 | } 112 | } 113 | 114 | .button { 115 | margin-top: 10px; 116 | } 117 | } 118 | } 119 | 120 | .control-radio { 121 | display: none; 122 | 123 | + label { 124 | cursor: pointer; 125 | margin-left: 5px; 126 | padding-left: 15px; 127 | position: relative; 128 | 129 | &:before { 130 | border: 1px solid $border-color; 131 | border-radius: 10px; 132 | content: ' '; 133 | display: inline-block; 134 | height: 10px; 135 | left: 0; 136 | margin-right: 5px; 137 | position: absolute; 138 | top: 0; 139 | width: 10px; 140 | } 141 | } 142 | 143 | &:first-child + label { 144 | margin-left: 0; 145 | } 146 | 147 | &:checked + label:before { 148 | background: white radial-gradient(ellipse at center, white 0%, $border-color 100%); 149 | } 150 | } 151 | 152 | input[type="checkbox"] { 153 | line-height: 14px; 154 | vertical-align: middle; 155 | 156 | + label { 157 | line-height: 14px; 158 | vertical-align: middle; 159 | } 160 | } 161 | 162 | .text-options { 163 | display: table; 164 | width: 250px; 165 | 166 | > .text-input { 167 | display: table-row; 168 | > label, > input { 169 | display: table-cell; 170 | margin-top: 5px; 171 | } 172 | > label { 173 | padding-right: 5px; 174 | } 175 | > input { 176 | box-sizing: border-box; 177 | width: 100%; 178 | } 179 | } 180 | } 181 | 182 | #map { 183 | flex: 1; 184 | } 185 | 186 | #maps-key-prompt { 187 | display: none; 188 | padding: 25px; 189 | position: absolute; 190 | } 191 | -------------------------------------------------------------------------------- /frontend/src/js/map/points.js: -------------------------------------------------------------------------------- 1 | /** @type MarkerClusterer */ 2 | import MarkerClusterer from 'exports?MarkerClusterer!js-marker-clusterer'; 3 | import pointsDb from '../data/points-db'; 4 | import configDb from '../data/config-db'; 5 | import ELEMENTS from '../elements'; 6 | 7 | /** @typedef {{lat: number, lng: number, ?}} PipoamPoint */ 8 | 9 | /** 10 | * A module for working with points on the map. 11 | * @module 12 | */ 13 | export default Object.freeze({ 14 | /** 15 | * Load points from the db and add them to the map with clustering 16 | * @static 17 | * @returns {Promise.>} 18 | */ 19 | loadPoints() { 20 | let clusterer = getClusterer(ELEMENTS.MAP.__mapInstance); 21 | clusterer.clearMarkers(); 22 | return pointsDb.getPoints() 23 | .then(pointsFromDb => { 24 | if (pointsFromDb) { 25 | clusterer.addMarkers(pointsFromDb.map(convertPointToMarker)); 26 | } 27 | return centerOnLoad() 28 | .then(() => pointsFromDb); 29 | }) 30 | }, 31 | 32 | /** 33 | * @static 34 | * @returns {Promise.>} 35 | */ 36 | getPoints() { 37 | return pointsDb.getPoints(); 38 | }, 39 | 40 | /** 41 | * Given a list of points, set them in the db and then load them to the map 42 | * @static 43 | * @param {Array.} points 44 | * @returns {Promise.>} 45 | */ 46 | setPoints(points) { 47 | return pointsDb.setPoints(points) 48 | .then(this.loadPoints.bind(this)) 49 | }, 50 | 51 | /** 52 | * Reduce the points into an object whose keys are hashes created from the point's content 53 | * and whose values are the point itself. The hashes are created in such a way that any two points 54 | * with the same key/value pairs (regardless of order) will produce the same hash. 55 | * Because two equivalent points will produce the same hash, the second point will overwrite the 56 | * first. Now the values that are left in the object that is created are unique points, and can 57 | * be turned back into an array. 58 | * @static 59 | * @param {Array.} points 60 | * @returns {Array.} 61 | */ 62 | dedupePoints(points) { 63 | const dedupedObject = points.reduce((dedupeObject, point) => { 64 | const hashForPoint = Object.keys(point).sort().reduce((hash, pointKey, index) => `${index === 0 ? '' : hash};${pointKey}:${point[pointKey]}`, ''); 65 | dedupeObject[hashForPoint] = point; 66 | return dedupeObject; 67 | }, {}); 68 | 69 | return Object.keys(dedupedObject).map(dedupeKey => dedupedObject[dedupeKey]) 70 | }, 71 | 72 | /** 73 | * Combine a new set of points with the existing set and then dedupe them. 74 | * @static 75 | * @param newPoints 76 | * @returns {Promise.>} 77 | */ 78 | mergePoints(newPoints) { 79 | return this.getPoints().then(existingPoints => { 80 | return pointsDb.setPoints(this.dedupePoints([].concat(newPoints).concat(existingPoints))) 81 | .then(this.loadPoints.bind(this)); 82 | }); 83 | } 84 | }); 85 | 86 | /** @type {MarkerClusterer}*/ 87 | let markerClusterer = null; 88 | 89 | /** 90 | * Given a google map, get (and create if necessary) the associated clusterer. 91 | * Doing it this way opens up the concept of multiple google maps, but this is sloppy and should 92 | * be done in a better way 93 | * @private 94 | * @param {google.maps.Map} map 95 | * @returns {MarkerClusterer} 96 | */ 97 | function getClusterer(map) { 98 | if (markerClusterer === null) { 99 | markerClusterer = new MarkerClusterer(map, [], { 100 | maxZoom: 14, 101 | imagePath: 'https://raw.githubusercontent.com/googlemaps/js-marker-clusterer/gh-pages/images/m' 102 | }); 103 | } 104 | return markerClusterer; 105 | } 106 | 107 | /** 108 | * @private 109 | * @param {{lat: number, lng: number}} point 110 | * @returns {google.maps.Marker} 111 | */ 112 | function convertPointToMarker(point) { 113 | return new google.maps.Marker({ 114 | position: point, 115 | icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH1wgNDQ0t+VykQAAAAW1JREFUGNMt0D0vJAEAgOF3zIyxuzd2zH6Q9RkfxUUikUOIaC4K8QP8AY1E7io/QOkIQSU0SsVVhCsuLlu5XEK2sBGJhtizduwyztqdLzNXrP55m1cAgGW5ThRmunv0r6mU2vdYen24uvr73bayG7Bn1AyrgiSvLc7N/3avryuB6/rBQ9EJ1jcv/Yi6lIbBZgARPo8Nj37aXvgyoegxGQEBzxGJaXHh+SXWdZ7NKUFwdizJijI9PtIfur0BPwBVBduCfB4GPvbS2Ng5ZT5JK5KqKq1tbWECCYwSlMxa4HoQT8hoelQ3n+SkVC6X85ZboSHygbAKilJDlTI4nsu/Z6MKtlfnWMWDX+mz1/qQT1SDZDPoOoQjcPIni/l4mgH/TgT3PpeTmnxfHh0aSQmJpIhtv/Hj6IKdrd1b297/BnZGqC1KhGByLqr1zrZ3JFuMQtExCplz+LkN1UPAeocASBBqAa0DzDeo5oAC4AH8BzshkZVam56AAAAAAElFTkSuQmCC" 116 | }); 117 | } 118 | 119 | /** 120 | * Adjust the viewport to fit all of the points on the map 121 | * @private 122 | * @returns {Promise} 123 | */ 124 | function centerOnLoad() { 125 | return configDb.getPointsSettings() 126 | .then(pointsSettings => { 127 | if (pointsSettings.centerOnLoad) { 128 | const map = ELEMENTS.MAP.__mapInstance; 129 | const clusterer = getClusterer(map); 130 | const bounds = new google.maps.LatLngBounds(); 131 | clusterer.getMarkers().forEach(marker => bounds.extend(marker.getPosition())); 132 | map.fitBounds(bounds); 133 | } 134 | }); 135 | } 136 | 137 | -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_comment": "Input files must be a valid JSON array of object literals. Each object literal must contain a 'lat' and 'lng' property with a numeric value. Each can have any number and type of addition properties such as this '_comment.' All properties of the object will be preserved when exporting.", 4 | "lat": 47.5942294808949, 5 | "lng": -122.33317612427, 6 | "time": 288 7 | }, 8 | { 9 | "lat": 47.5942073342733, 10 | "lng": -122.33270326276, 11 | "time": 2059 12 | }, 13 | { 14 | "lat": 47.5944629852196, 15 | "lng": -122.332435289521, 16 | "time": 585 17 | }, 18 | { 19 | "lat": 47.5945630569657, 20 | "lng": -122.33211778713, 21 | "time": 2593 22 | }, 23 | { 24 | "lat": 47.5945686594629, 25 | "lng": -122.331828438564, 26 | "time": 907 27 | }, 28 | { 29 | "lat": 47.5945742610074, 30 | "lng": -122.331539090317, 31 | "time": 488 32 | }, 33 | { 34 | "lat": 47.5946353727659, 35 | "lng": -122.331616769505, 36 | "time": 493 37 | }, 38 | { 39 | "lat": 47.5948021542315, 40 | "lng": -122.331087594776, 41 | "time": 1347 42 | }, 43 | { 44 | "lat": 47.5947076858389, 45 | "lng": -122.331115751315, 46 | "time": 3262 47 | }, 48 | { 49 | "lat": 47.5946132176195, 50 | "lng": -122.331143907715, 51 | "time": 2732 52 | }, 53 | { 54 | "lat": 47.5942353464723, 55 | "lng": -122.331256531922, 56 | "time": 864 57 | }, 58 | { 59 | "lat": 47.594380175299, 60 | "lng": -122.328624275061, 61 | "time": 2854 62 | }, 63 | { 64 | "lat": 47.5946801529864, 65 | "lng": -122.32930199084, 66 | "time": 1930 67 | }, 68 | { 69 | "lat": 47.5945966576583, 70 | "lng": -122.330381700522, 71 | "time": 2154 72 | }, 73 | { 74 | "lat": 47.5950022886111, 75 | "lng": -122.330452581178, 76 | "time": 1724 77 | }, 78 | { 79 | "lat": 47.5949355775608, 80 | "lng": -122.330664252853, 81 | "time": 601 82 | }, 83 | { 84 | "lat": 47.5949633347347, 85 | "lng": -122.330847766865, 86 | "time": 242 87 | }, 88 | { 89 | "lat": 47.59539672507, 90 | "lng": -122.331102168918, 91 | "time": 2426 92 | }, 93 | { 94 | "lat": 47.5952133856546, 95 | "lng": -122.330869130906, 96 | "time": 436 97 | }, 98 | { 99 | "lat": 47.5953745669516, 100 | "lng": -122.330629299906, 101 | "time": 2722 102 | }, 103 | { 104 | "lat": 47.5953911247756, 105 | "lng": -122.33139152237, 106 | "time": 2681 107 | }, 108 | { 109 | "lat": 47.5958522726691, 110 | "lng": -122.331829452497, 111 | "time": 2981 112 | }, 113 | { 114 | "lat": 47.5959245889751, 115 | "lng": -122.331328419326, 116 | "time": 2870 117 | }, 118 | { 119 | "lat": 47.5958301183638, 120 | "lng": -122.331356576286, 121 | "time": 1033 122 | }, 123 | { 124 | "lat": 47.5962357584182, 125 | "lng": -122.331427468086, 126 | "time": 2348 127 | }, 128 | { 129 | "lat": 47.5962469586824, 130 | "lng": -122.330848749611, 131 | "time": 1450 132 | }, 133 | { 134 | "lat": 47.5956635736346, 135 | "lng": -122.330255470804, 136 | "time": 813 137 | }, 138 | { 139 | "lat": 47.5966637997371, 140 | "lng": -122.330340913665, 141 | "time": 3524 142 | }, 143 | { 144 | "lat": 47.5971860801922, 145 | "lng": -122.330856524701, 146 | "time": 811 147 | }, 148 | { 149 | "lat": 47.5971305633554, 150 | "lng": -122.330489477588, 151 | "time": 2815 152 | }, 153 | { 154 | "lat": 47.5976138888524, 155 | "lng": -122.331400307891, 156 | "time": 745 157 | }, 158 | { 159 | "lat": 47.5972805532606, 160 | "lng": -122.330828365536, 161 | "time": 116 162 | }, 163 | { 164 | "lat": 47.5974804543402, 165 | "lng": -122.331823676858, 166 | "time": 995 167 | }, 168 | { 169 | "lat": 47.5963913438204, 170 | "lng": -122.331476992982, 171 | "time": 2647 172 | }, 173 | { 174 | "lat": 47.5967636302653, 175 | "lng": -122.331653726829, 176 | "time": 201 177 | }, 178 | { 179 | "lat": 47.5967190656027, 180 | "lng": -122.33233829444, 181 | "time": 1771 182 | }, 183 | { 184 | "lat": 47.5963857420598, 185 | "lng": -122.331766353846, 186 | "time": 1841 187 | }, 188 | { 189 | "lat": 47.5962912704411, 190 | "lng": -122.331794510253, 191 | "time": 2875 192 | }, 193 | { 194 | "lat": 47.5964300439761, 195 | "lng": -122.332712122743, 196 | "time": 1971 197 | }, 198 | { 199 | "lat": 47.5965245161198, 200 | "lng": -122.332683968188, 201 | "time": 1411 202 | }, 203 | { 204 | "lat": 47.5966744956615, 205 | "lng": -122.333022862802, 206 | "time": 3560 207 | }, 208 | { 209 | "lat": 47.5966467422354, 210 | "lng": -122.332839337946, 211 | "time": 330 212 | }, 213 | { 214 | "lat": 47.5971412543227, 215 | "lng": -122.333171458832, 216 | "time": 2409 217 | }, 218 | { 219 | "lat": 47.596546663431, 220 | "lng": -122.333156855981, 221 | "time": 398 222 | }, 223 | { 224 | "lat": 47.5961965274807, 225 | "lng": -122.333452992806, 226 | "time": 2462 227 | }, 228 | { 229 | "lat": 47.5961466285836, 230 | "lng": -122.33279658557, 231 | "time": 3572 232 | }, 233 | { 234 | "lat": 47.596207741084, 235 | "lng": -122.332874269861, 236 | "time": 779 237 | }, 238 | { 239 | "lat": 47.5960187980763, 240 | "lng": -122.332930577486, 241 | "time": 1139 242 | }, 243 | { 244 | "lat": 47.5960577623053, 245 | "lng": -122.332535379723, 246 | "time": 3450 247 | }, 248 | { 249 | "lat": 47.59574099059, 250 | "lng": -122.33272568001, 251 | "time": 1688 252 | }, 253 | { 254 | "lat": 47.5956910865699, 255 | "lng": -122.332069283089, 256 | "time": 218 257 | }, 258 | { 259 | "lat": 47.5953244109279, 260 | "lng": -122.331603195506, 261 | "time": 2560 262 | }, 263 | { 264 | "lat": 47.5946463176532, 265 | "lng": -122.3326683322, 266 | "time": 1474 267 | }, 268 | { 269 | "lat": 47.5947018229149, 270 | "lng": -122.333035364268, 271 | "time": 2843 272 | }, 273 | { 274 | "lat": 47.5947629334636, 275 | "lng": -122.333113046439, 276 | "time": 97 277 | }, 278 | { 279 | "lat": 47.5952686390506, 280 | "lng": -122.332866447671, 281 | "time": 646 282 | }, 283 | { 284 | "lat": 47.5953297504335, 285 | "lng": -122.332944130535, 286 | "time": 2315 287 | }, 288 | { 289 | "lat": 47.5955629801716, 290 | "lng": -122.333833579539, 291 | "time": 934 292 | }, 293 | { 294 | "lat": 47.5947906854016, 295 | "lng": -122.333296563467, 296 | "time": 727 297 | }, 298 | { 299 | "lat": 47.5944795273153, 300 | "lng": -122.333197502511, 301 | "time": 3519 302 | }, 303 | { 304 | "lat": 47.597363561983, 305 | "lng": -122.333009309084, 306 | "time": 769 307 | }, 308 | { 309 | "lat": 47.597330201768, 310 | "lng": -122.333115150366, 311 | "time": 3160 312 | }, 313 | { 314 | "lat": 47.597919194499, 315 | "lng": -122.333419125747, 316 | "time": 3494 317 | }, 318 | { 319 | "lat": 47.5976469845512, 320 | "lng": -122.332924844576, 321 | "time": 1906 322 | }, 323 | { 324 | "lat": 47.5985308787124, 325 | "lng": -122.330935187888, 326 | "time": 2309 327 | }, 328 | { 329 | "lat": 47.59858639503, 330 | "lng": -122.33130224864, 331 | "time": 1826 332 | }, 333 | { 334 | "lat": 47.5987641429033, 335 | "lng": -122.331824684533, 336 | "time": 2440 337 | }, 338 | { 339 | "lat": 47.5987638696198, 340 | "lng": -122.333455111768, 341 | "time": 1730 342 | }, 343 | { 344 | "lat": 47.5989861851586, 345 | "lng": -122.333292956382, 346 | "time": 1748 347 | }, 348 | { 349 | "lat": 47.5989250697049, 350 | "lng": -122.333215266395, 351 | "time": 911 352 | }, 353 | { 354 | "lat": 47.5989306768312, 355 | "lng": -122.332925885994, 356 | "time": 1228 357 | }, 358 | { 359 | "lat": 47.5993865177525, 360 | "lng": -122.332022801984, 361 | "time": 979 362 | }, 363 | { 364 | "lat": 47.5993974567678, 365 | "lng": -122.333074487207, 366 | "time": 142 367 | }, 368 | { 369 | "lat": 47.5998642407877, 370 | "lng": -122.333223091509, 371 | "time": 2061 372 | }, 373 | { 374 | "lat": 47.5997420076482, 375 | "lng": -122.333067708933, 376 | "time": 1015 377 | }, 378 | { 379 | "lat": 47.5988527376833, 380 | "lng": -122.333716337374, 381 | "time": 3017 382 | }, 383 | { 384 | "lat": 47.5985081930231, 385 | "lng": -122.333723109628, 386 | "time": 571 387 | }, 388 | { 389 | "lat": 47.5986304220071, 390 | "lng": -122.333878490246, 391 | "time": 712 392 | }, 393 | { 394 | "lat": 47.5985359454911, 395 | "lng": -122.333906643997, 396 | "time": 2448 397 | }, 398 | { 399 | "lat": 47.5981468190998, 400 | "lng": -122.334598010643, 401 | "time": 1130 402 | }, 403 | { 404 | "lat": 47.5984523854257, 405 | "lng": -122.334986468931, 406 | "time": 965 407 | }, 408 | { 409 | "lat": 47.5998863860302, 410 | "lng": -122.333696018761, 411 | "time": 1901 412 | }, 413 | { 414 | "lat": 47.6002365492597, 415 | "lng": -122.33339985516, 416 | "time": 1352 417 | }, 418 | { 419 | "lat": 47.6004975478395, 420 | "lng": -122.334472955837, 421 | "time": 1357 422 | }, 423 | { 424 | "lat": 47.6006699772607, 425 | "lng": -122.333654315541, 426 | "time": 2003 427 | }, 428 | { 429 | "lat": 47.6008421062738, 430 | "lng": -122.334466188331, 431 | "time": 3292 432 | }, 433 | { 434 | "lat": 47.6002474715756, 435 | "lng": -122.334451569161, 436 | "time": 1304 437 | }, 438 | { 439 | "lat": 47.6002141075749, 440 | "lng": -122.334557417527, 441 | "time": 2747 442 | }, 443 | { 444 | "lat": 47.6001807434718, 445 | "lng": -122.334663265773, 446 | "time": 361 447 | }, 448 | { 449 | "lat": 47.6003251114917, 450 | "lng": -122.335291592457, 451 | "time": 3586 452 | }, 453 | { 454 | "lat": 47.6009587263166, 455 | "lng": -122.334910976671, 456 | "time": 2888 457 | }, 458 | { 459 | "lat": 47.6014311352552, 460 | "lng": -122.334770207174, 461 | "time": 1836 462 | }, 463 | { 464 | "lat": 47.601331039263, 465 | "lng": -122.335087760884, 466 | "time": 1882 467 | }, 468 | { 469 | "lat": 47.6012699226304, 470 | "lng": -122.335010063435, 471 | "time": 532 472 | }, 473 | { 474 | "lat": 47.6006194535715, 475 | "lng": -122.336258868137, 476 | "time": 1058 477 | }, 478 | { 479 | "lat": 47.6003693773129, 480 | "lng": -122.336237470499, 481 | "time": 2855 482 | }, 483 | { 484 | "lat": 47.6028923247526, 485 | "lng": -122.336924424337, 486 | "time": 22 487 | }, 488 | { 489 | "lat": 47.6027981802395, 490 | "lng": -122.335321962232, 491 | "time": 300 492 | }, 493 | { 494 | "lat": 47.6026369615657, 495 | "lng": -122.335561825247, 496 | "time": 225 497 | }, 498 | { 499 | "lat": 47.6030869164898, 500 | "lng": -122.336578708396, 501 | "time": 399 502 | }, 503 | { 504 | "lat": 47.6037430388451, 505 | "lng": -122.335040413207, 506 | "time": 3284 507 | }, 508 | { 509 | "lat": 47.6038652779929, 510 | "lng": -122.335195817237, 511 | "time": 2404 512 | }, 513 | { 514 | "lat": 47.6042990458947, 515 | "lng": -122.333819657938, 516 | "time": 1551 517 | }, 518 | { 519 | "lat": 47.6037767101162, 520 | "lng": -122.333303920026, 521 | "time": 1572 522 | }, 523 | { 524 | "lat": 47.6037823186589, 525 | "lng": -122.333014505612, 526 | "time": 306 527 | }, 528 | { 529 | "lat": 47.6026762516218, 530 | "lng": -122.333535967238, 531 | "time": 1062 532 | }, 533 | { 534 | "lat": 47.6031377406944, 535 | "lng": -122.332343393847, 536 | "time": 746 537 | }, 538 | { 539 | "lat": 47.6041714786183, 540 | "lng": -122.332323031718, 541 | "time": 1175 542 | }, 543 | { 544 | "lat": 47.6038658658453, 545 | "lng": -122.331934547284, 546 | "time": 1491 547 | }, 548 | { 549 | "lat": 47.6035992182934, 550 | "lng": -122.331150800312, 551 | "time": 3166 552 | }, 553 | { 554 | "lat": 47.6042491437803, 555 | "lng": -122.333163121526, 556 | "time": 977 557 | }, 558 | { 559 | "lat": 47.6021432396896, 560 | "lng": -122.330337948983, 561 | "time": 3512 562 | }, 563 | { 564 | "lat": 47.6022377221095, 565 | "lng": -122.330309784565, 566 | "time": 475 567 | }, 568 | { 569 | "lat": 47.6025710933009, 570 | "lng": -122.330881786983, 571 | "time": 3273 572 | }, 573 | { 574 | "lat": 47.6027045378936, 575 | "lng": -122.330458363781, 576 | "time": 3076 577 | }, 578 | { 579 | "lat": 47.6029212667046, 580 | "lng": -122.330585582845, 581 | "time": 858 582 | }, 583 | { 584 | "lat": 47.60279342163, 585 | "lng": -122.330719603494, 586 | "time": 2327 587 | }, 588 | { 589 | "lat": 47.6030323092955, 590 | "lng": -122.331319780732, 591 | "time": 1235 592 | }, 593 | { 594 | "lat": 47.6021208366247, 595 | "lng": -122.331495549475, 596 | "time": 2553 597 | }, 598 | { 599 | "lat": 47.6018870187259, 600 | "lng": -122.333867079194, 601 | "time": 2429 602 | }, 603 | { 604 | "lat": 47.6019646662603, 605 | "lng": -122.334707132066, 606 | "time": 2631 607 | }, 608 | { 609 | "lat": 47.6011978896553, 610 | "lng": -122.333880625346, 611 | "time": 2337 612 | }, 613 | { 614 | "lat": 47.6011535979534, 615 | "lng": -122.332934741565, 616 | "time": 140 617 | }, 618 | { 619 | "lat": 47.601120235114, 620 | "lng": -122.333040592751, 621 | "time": 1226 622 | }, 623 | { 624 | "lat": 47.6010591168694, 625 | "lng": -122.332962899447, 626 | "time": 1754 627 | }, 628 | { 629 | "lat": 47.6009534193807, 630 | "lng": -122.333569846898, 631 | "time": 3461 632 | }, 633 | { 634 | "lat": 47.6007089491471, 635 | "lng": -122.333259072475, 636 | "time": 2878 637 | }, 638 | { 639 | "lat": 47.6007423119527, 640 | "lng": -122.333153222427, 641 | "time": 3470 642 | }, 643 | { 644 | "lat": 47.6009646359585, 645 | "lng": -122.332991057191, 646 | "time": 93 647 | }, 648 | { 649 | "lat": 47.6008146439169, 650 | "lng": -122.332652128747, 651 | "time": 454 652 | }, 653 | { 654 | "lat": 47.6001922528494, 655 | "lng": -122.332453995907, 656 | "time": 509 657 | }, 658 | { 659 | "lat": 47.6004145731244, 660 | "lng": -122.332291829814, 661 | "time": 3129 662 | }, 663 | { 664 | "lat": 47.6003924201636, 665 | "lng": -122.33181890046, 666 | "time": 2872 667 | }, 668 | { 669 | "lat": 47.6007924928012, 670 | "lng": -122.332179193844, 671 | "time": 361 672 | }, 673 | { 674 | "lat": 47.6007260241599, 675 | "lng": -122.330760398532, 676 | "time": 1358 677 | }, 678 | { 679 | "lat": 47.60097610411, 680 | "lng": -122.330781762623, 681 | "time": 1439 682 | }, 683 | { 684 | "lat": 47.6010649839601, 685 | "lng": -122.331042992795, 686 | "time": 3563 687 | }, 688 | { 689 | "lat": 47.6013150655179, 690 | "lng": -122.331064358653, 691 | "time": 3230 692 | }, 693 | { 694 | "lat": 47.6015040277675, 695 | "lng": -122.331008034178, 696 | "time": 2500 697 | }, 698 | { 699 | "lat": 47.6019430754588, 700 | "lng": -122.330973074507, 701 | "time": 2183 702 | }, 703 | { 704 | "lat": 47.600904016848, 705 | "lng": -122.329652362518, 706 | "time": 1262 707 | }, 708 | { 709 | "lat": 47.5995421125991, 710 | "lng": -122.332072332205, 711 | "time": 3108 712 | }, 713 | { 714 | "lat": 47.5989589348458, 715 | "lng": -122.329848569165, 716 | "time": 63 717 | }, 718 | { 719 | "lat": 47.5990200534068, 720 | "lng": -122.329926253008, 721 | "time": 3557 722 | }, 723 | { 724 | "lat": 47.5990254169239, 725 | "lng": -122.331267296613, 726 | "time": 139 727 | }, 728 | { 729 | "lat": 47.598969900034, 730 | "lng": -122.330900232123, 731 | "time": 2949 732 | }, 733 | { 734 | "lat": 47.5984476014406, 735 | "lng": -122.330384599792, 736 | "time": 1458 737 | }, 738 | { 739 | "lat": 47.5988978162874, 740 | "lng": -122.329770885573, 741 | "time": 2376 742 | }, 743 | { 744 | "lat": 47.5998871306489, 745 | "lng": -122.328804636872, 746 | "time": 1535 747 | }, 748 | { 749 | "lat": 47.5983866984862, 750 | "lng": -122.328676528072, 751 | "time": 178 752 | }, 753 | { 754 | "lat": 47.5984200541613, 755 | "lng": -122.328570682696, 756 | "time": 225 757 | }, 758 | { 759 | "lat": 47.5974809082109, 760 | "lng": -122.32856296114, 761 | "time": 1538 762 | }, 763 | { 764 | "lat": 47.5977365641716, 765 | "lng": -122.328294945107, 766 | "time": 3149 767 | }, 768 | { 769 | "lat": 47.5972975543525, 770 | "lng": -122.328329926084, 771 | "time": 183 772 | } 773 | ] 774 | -------------------------------------------------------------------------------- /definitions/js-marker-clusterer.js: -------------------------------------------------------------------------------- 1 | // ==ClosureCompiler== 2 | // @compilation_level ADVANCED_OPTIMIZATIONS 3 | // @externs_url https://raw.githubusercontent.com/google/closure-compiler/master/contrib/externs/maps/google_maps_api_v3.js 4 | // ==/ClosureCompiler== 5 | 6 | /** 7 | * @name MarkerClusterer for Google Maps v3 8 | * @version version 1.0 9 | * @author Luke Mahe 10 | * @fileoverview 11 | * The library creates and manages per-zoom-level clusters for large amounts of 12 | * markers. 13 | *
14 | * This is a v3 implementation of the 15 | * v2 MarkerClusterer. 17 | */ 18 | 19 | /** 20 | * @license 21 | * Copyright 2010 Google Inc. All Rights Reserved. 22 | * 23 | * Licensed under the Apache License, Version 2.0 (the "License"); 24 | * you may not use this file except in compliance with the License. 25 | * You may obtain a copy of the License at 26 | * 27 | * http://www.apache.org/licenses/LICENSE-2.0 28 | * 29 | * Unless required by applicable law or agreed to in writing, software 30 | * distributed under the License is distributed on an "AS IS" BASIS, 31 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 32 | * See the License for the specific language governing permissions and 33 | * limitations under the License. 34 | */ 35 | 36 | 37 | /** 38 | * A Marker Clusterer that clusters markers. 39 | * 40 | * @param {google.maps.Map} map The Google map to attach to. 41 | * @param {Array.=} opt_markers Optional markers to add to 42 | * the cluster. 43 | * @param {Object=} opt_options support the following options: 44 | * 'gridSize': (number) The grid size of a cluster in pixels. 45 | * 'maxZoom': (number) The maximum zoom level that a marker can be part of a 46 | * cluster. 47 | * 'zoomOnClick': (boolean) Whether the default behaviour of clicking on a 48 | * cluster is to zoom into it. 49 | * 'averageCenter': (boolean) Whether the center of each cluster should be 50 | * the average of all markers in the cluster. 51 | * 'minimumClusterSize': (number) The minimum number of markers to be in a 52 | * cluster before the markers are hidden and a count 53 | * is shown. 54 | * 'styles': (object) An object that has style properties: 55 | * 'url': (string) The image url. 56 | * 'height': (number) The image height. 57 | * 'width': (number) The image width. 58 | * 'anchor': (Array) The anchor position of the label text. 59 | * 'textColor': (string) The text color. 60 | * 'textSize': (number) The text size. 61 | * 'backgroundPosition': (string) The position of the backgound x, y. 62 | * 'iconAnchor': (Array) The anchor position of the icon x, y. 63 | * @constructor 64 | * @extends google.maps.OverlayView 65 | */ 66 | function MarkerClusterer(map, opt_markers, opt_options) { 67 | // MarkerClusterer implements google.maps.OverlayView interface. We use the 68 | // extend function to extend MarkerClusterer with google.maps.OverlayView 69 | // because it might not always be available when the code is defined so we 70 | // look for it at the last possible moment. If it doesn't exist now then 71 | // there is no point going ahead :) 72 | this.extend(MarkerClusterer, google.maps.OverlayView); 73 | this.map_ = map; 74 | 75 | /** 76 | * @type {Array.} 77 | * @private 78 | */ 79 | this.markers_ = []; 80 | 81 | /** 82 | * @type {Array.} 83 | */ 84 | this.clusters_ = []; 85 | 86 | this.sizes = [53, 56, 66, 78, 90]; 87 | 88 | /** 89 | * @private 90 | */ 91 | this.styles_ = []; 92 | 93 | /** 94 | * @type {boolean} 95 | * @private 96 | */ 97 | this.ready_ = false; 98 | 99 | var options = opt_options || {}; 100 | 101 | /** 102 | * @type {number} 103 | * @private 104 | */ 105 | this.gridSize_ = options['gridSize'] || 60; 106 | 107 | /** 108 | * @private 109 | */ 110 | this.minClusterSize_ = options['minimumClusterSize'] || 2; 111 | 112 | 113 | /** 114 | * @type {?number} 115 | * @private 116 | */ 117 | this.maxZoom_ = options['maxZoom'] || null; 118 | 119 | this.styles_ = options['styles'] || []; 120 | 121 | /** 122 | * @type {string} 123 | * @private 124 | */ 125 | this.imagePath_ = options['imagePath'] || 126 | this.MARKER_CLUSTER_IMAGE_PATH_; 127 | 128 | /** 129 | * @type {string} 130 | * @private 131 | */ 132 | this.imageExtension_ = options['imageExtension'] || 133 | this.MARKER_CLUSTER_IMAGE_EXTENSION_; 134 | 135 | /** 136 | * @type {boolean} 137 | * @private 138 | */ 139 | this.zoomOnClick_ = true; 140 | 141 | if (options['zoomOnClick'] != undefined) { 142 | this.zoomOnClick_ = options['zoomOnClick']; 143 | } 144 | 145 | /** 146 | * @type {boolean} 147 | * @private 148 | */ 149 | this.averageCenter_ = false; 150 | 151 | if (options['averageCenter'] != undefined) { 152 | this.averageCenter_ = options['averageCenter']; 153 | } 154 | 155 | this.setupStyles_(); 156 | 157 | this.setMap(map); 158 | 159 | /** 160 | * @type {number} 161 | * @private 162 | */ 163 | this.prevZoom_ = this.map_.getZoom(); 164 | 165 | // Add the map event listeners 166 | var that = this; 167 | google.maps.event.addListener(this.map_, 'zoom_changed', function() { 168 | var zoom = that.map_.getZoom(); 169 | 170 | if (that.prevZoom_ != zoom) { 171 | that.prevZoom_ = zoom; 172 | that.resetViewport(); 173 | } 174 | }); 175 | 176 | google.maps.event.addListener(this.map_, 'idle', function() { 177 | that.redraw(); 178 | }); 179 | 180 | // Finally, add the markers 181 | if (opt_markers && opt_markers.length) { 182 | this.addMarkers(opt_markers, false); 183 | } 184 | } 185 | 186 | 187 | /** 188 | * The marker cluster image path. 189 | * 190 | * @type {string} 191 | * @private 192 | */ 193 | MarkerClusterer.prototype.MARKER_CLUSTER_IMAGE_PATH_ = '../images/m'; 194 | 195 | 196 | /** 197 | * The marker cluster image path. 198 | * 199 | * @type {string} 200 | * @private 201 | */ 202 | MarkerClusterer.prototype.MARKER_CLUSTER_IMAGE_EXTENSION_ = 'png'; 203 | 204 | 205 | /** 206 | * Extends a objects prototype by anothers. 207 | * 208 | * @param {Object} obj1 The object to be extended. 209 | * @param {Object} obj2 The object to extend with. 210 | * @return {Object} The new extended object. 211 | * @ignore 212 | */ 213 | MarkerClusterer.prototype.extend = function(obj1, obj2) { 214 | return (function(object) { 215 | for (var property in object.prototype) { 216 | this.prototype[property] = object.prototype[property]; 217 | } 218 | return this; 219 | }).apply(obj1, [obj2]); 220 | }; 221 | 222 | 223 | /** 224 | * Implementaion of the interface method. 225 | * @ignore 226 | */ 227 | MarkerClusterer.prototype.onAdd = function() { 228 | this.setReady_(true); 229 | }; 230 | 231 | /** 232 | * Implementaion of the interface method. 233 | * @ignore 234 | */ 235 | MarkerClusterer.prototype.draw = function() {}; 236 | 237 | /** 238 | * Sets up the styles object. 239 | * 240 | * @private 241 | */ 242 | MarkerClusterer.prototype.setupStyles_ = function() { 243 | if (this.styles_.length) { 244 | return; 245 | } 246 | 247 | for (var i = 0, size; size = this.sizes[i]; i++) { 248 | this.styles_.push({ 249 | url: this.imagePath_ + (i + 1) + '.' + this.imageExtension_, 250 | height: size, 251 | width: size 252 | }); 253 | } 254 | }; 255 | 256 | /** 257 | * Fit the map to the bounds of the markers in the clusterer. 258 | */ 259 | MarkerClusterer.prototype.fitMapToMarkers = function() { 260 | var markers = this.getMarkers(); 261 | var bounds = new google.maps.LatLngBounds(); 262 | for (var i = 0, marker; marker = markers[i]; i++) { 263 | bounds.extend(marker.getPosition()); 264 | } 265 | 266 | this.map_.fitBounds(bounds); 267 | }; 268 | 269 | 270 | /** 271 | * Sets the styles. 272 | * 273 | * @param {Object} styles The style to set. 274 | */ 275 | MarkerClusterer.prototype.setStyles = function(styles) { 276 | this.styles_ = styles; 277 | }; 278 | 279 | 280 | /** 281 | * Gets the styles. 282 | * 283 | * @return {Object} The styles object. 284 | */ 285 | MarkerClusterer.prototype.getStyles = function() { 286 | return this.styles_; 287 | }; 288 | 289 | 290 | /** 291 | * Whether zoom on click is set. 292 | * 293 | * @return {boolean} True if zoomOnClick_ is set. 294 | */ 295 | MarkerClusterer.prototype.isZoomOnClick = function() { 296 | return this.zoomOnClick_; 297 | }; 298 | 299 | /** 300 | * Whether average center is set. 301 | * 302 | * @return {boolean} True if averageCenter_ is set. 303 | */ 304 | MarkerClusterer.prototype.isAverageCenter = function() { 305 | return this.averageCenter_; 306 | }; 307 | 308 | 309 | /** 310 | * Returns the array of markers in the clusterer. 311 | * 312 | * @return {Array.} The markers. 313 | */ 314 | MarkerClusterer.prototype.getMarkers = function() { 315 | return this.markers_; 316 | }; 317 | 318 | 319 | /** 320 | * Returns the number of markers in the clusterer 321 | * 322 | * @return {Number} The number of markers. 323 | */ 324 | MarkerClusterer.prototype.getTotalMarkers = function() { 325 | return this.markers_.length; 326 | }; 327 | 328 | 329 | /** 330 | * Sets the max zoom for the clusterer. 331 | * 332 | * @param {number} maxZoom The max zoom level. 333 | */ 334 | MarkerClusterer.prototype.setMaxZoom = function(maxZoom) { 335 | this.maxZoom_ = maxZoom; 336 | }; 337 | 338 | 339 | /** 340 | * Gets the max zoom for the clusterer. 341 | * 342 | * @return {number} The max zoom level. 343 | */ 344 | MarkerClusterer.prototype.getMaxZoom = function() { 345 | return this.maxZoom_; 346 | }; 347 | 348 | 349 | /** 350 | * The function for calculating the cluster icon image. 351 | * 352 | * @param {Array.} markers The markers in the clusterer. 353 | * @param {number} numStyles The number of styles available. 354 | * @return {Object} A object properties: 'text' (string) and 'index' (number). 355 | * @private 356 | */ 357 | MarkerClusterer.prototype.calculator_ = function(markers, numStyles) { 358 | var index = 0; 359 | var count = markers.length; 360 | var dv = count; 361 | while (dv !== 0) { 362 | dv = parseInt(dv / 10, 10); 363 | index++; 364 | } 365 | 366 | index = Math.min(index, numStyles); 367 | return { 368 | text: count, 369 | index: index 370 | }; 371 | }; 372 | 373 | 374 | /** 375 | * Set the calculator function. 376 | * 377 | * @param {function(Array, number)} calculator The function to set as the 378 | * calculator. The function should return a object properties: 379 | * 'text' (string) and 'index' (number). 380 | * 381 | */ 382 | MarkerClusterer.prototype.setCalculator = function(calculator) { 383 | this.calculator_ = calculator; 384 | }; 385 | 386 | 387 | /** 388 | * Get the calculator function. 389 | * 390 | * @return {function(Array, number)} the calculator function. 391 | */ 392 | MarkerClusterer.prototype.getCalculator = function() { 393 | return this.calculator_; 394 | }; 395 | 396 | 397 | /** 398 | * Add an array of markers to the clusterer. 399 | * 400 | * @param {Array.} markers The markers to add. 401 | * @param {boolean=} opt_nodraw Whether to redraw the clusters. 402 | */ 403 | MarkerClusterer.prototype.addMarkers = function(markers, opt_nodraw) { 404 | for (var i = 0, marker; marker = markers[i]; i++) { 405 | this.pushMarkerTo_(marker); 406 | } 407 | if (!opt_nodraw) { 408 | this.redraw(); 409 | } 410 | }; 411 | 412 | 413 | /** 414 | * Pushes a marker to the clusterer. 415 | * 416 | * @param {google.maps.Marker} marker The marker to add. 417 | * @private 418 | */ 419 | MarkerClusterer.prototype.pushMarkerTo_ = function(marker) { 420 | marker.isAdded = false; 421 | if (marker['draggable']) { 422 | // If the marker is draggable add a listener so we update the clusters on 423 | // the drag end. 424 | var that = this; 425 | google.maps.event.addListener(marker, 'dragend', function() { 426 | marker.isAdded = false; 427 | that.repaint(); 428 | }); 429 | } 430 | this.markers_.push(marker); 431 | }; 432 | 433 | 434 | /** 435 | * Adds a marker to the clusterer and redraws if needed. 436 | * 437 | * @param {google.maps.Marker} marker The marker to add. 438 | * @param {boolean=} opt_nodraw Whether to redraw the clusters. 439 | */ 440 | MarkerClusterer.prototype.addMarker = function(marker, opt_nodraw) { 441 | this.pushMarkerTo_(marker); 442 | if (!opt_nodraw) { 443 | this.redraw(); 444 | } 445 | }; 446 | 447 | 448 | /** 449 | * Removes a marker and returns true if removed, false if not 450 | * 451 | * @param {google.maps.Marker} marker The marker to remove 452 | * @return {boolean} Whether the marker was removed or not 453 | * @private 454 | */ 455 | MarkerClusterer.prototype.removeMarker_ = function(marker) { 456 | var index = -1; 457 | if (this.markers_.indexOf) { 458 | index = this.markers_.indexOf(marker); 459 | } else { 460 | for (var i = 0, m; m = this.markers_[i]; i++) { 461 | if (m == marker) { 462 | index = i; 463 | break; 464 | } 465 | } 466 | } 467 | 468 | if (index == -1) { 469 | // Marker is not in our list of markers. 470 | return false; 471 | } 472 | 473 | marker.setMap(null); 474 | 475 | this.markers_.splice(index, 1); 476 | 477 | return true; 478 | }; 479 | 480 | 481 | /** 482 | * Remove a marker from the cluster. 483 | * 484 | * @param {google.maps.Marker} marker The marker to remove. 485 | * @param {boolean=} opt_nodraw Optional boolean to force no redraw. 486 | * @return {boolean} True if the marker was removed. 487 | */ 488 | MarkerClusterer.prototype.removeMarker = function(marker, opt_nodraw) { 489 | var removed = this.removeMarker_(marker); 490 | 491 | if (!opt_nodraw && removed) { 492 | this.resetViewport(); 493 | this.redraw(); 494 | return true; 495 | } else { 496 | return false; 497 | } 498 | }; 499 | 500 | 501 | /** 502 | * Removes an array of markers from the cluster. 503 | * 504 | * @param {Array.} markers The markers to remove. 505 | * @param {boolean=} opt_nodraw Optional boolean to force no redraw. 506 | */ 507 | MarkerClusterer.prototype.removeMarkers = function(markers, opt_nodraw) { 508 | var removed = false; 509 | 510 | for (var i = 0, marker; marker = markers[i]; i++) { 511 | var r = this.removeMarker_(marker); 512 | removed = removed || r; 513 | } 514 | 515 | if (!opt_nodraw && removed) { 516 | this.resetViewport(); 517 | this.redraw(); 518 | return true; 519 | } 520 | }; 521 | 522 | 523 | /** 524 | * Sets the clusterer's ready state. 525 | * 526 | * @param {boolean} ready The state. 527 | * @private 528 | */ 529 | MarkerClusterer.prototype.setReady_ = function(ready) { 530 | if (!this.ready_) { 531 | this.ready_ = ready; 532 | this.createClusters_(); 533 | } 534 | }; 535 | 536 | 537 | /** 538 | * Returns the number of clusters in the clusterer. 539 | * 540 | * @return {number} The number of clusters. 541 | */ 542 | MarkerClusterer.prototype.getTotalClusters = function() { 543 | return this.clusters_.length; 544 | }; 545 | 546 | 547 | /** 548 | * Returns the google map that the clusterer is associated with. 549 | * 550 | * @return {google.maps.Map} The map. 551 | */ 552 | MarkerClusterer.prototype.getMap = function() { 553 | return this.map_; 554 | }; 555 | 556 | 557 | /** 558 | * Sets the google map that the clusterer is associated with. 559 | * 560 | * @param {google.maps.Map} map The map. 561 | */ 562 | MarkerClusterer.prototype.setMap = function(map) { 563 | this.map_ = map; 564 | }; 565 | 566 | 567 | /** 568 | * Returns the size of the grid. 569 | * 570 | * @return {number} The grid size. 571 | */ 572 | MarkerClusterer.prototype.getGridSize = function() { 573 | return this.gridSize_; 574 | }; 575 | 576 | 577 | /** 578 | * Sets the size of the grid. 579 | * 580 | * @param {number} size The grid size. 581 | */ 582 | MarkerClusterer.prototype.setGridSize = function(size) { 583 | this.gridSize_ = size; 584 | }; 585 | 586 | 587 | /** 588 | * Returns the min cluster size. 589 | * 590 | * @return {number} The grid size. 591 | */ 592 | MarkerClusterer.prototype.getMinClusterSize = function() { 593 | return this.minClusterSize_; 594 | }; 595 | 596 | /** 597 | * Sets the min cluster size. 598 | * 599 | * @param {number} size The grid size. 600 | */ 601 | MarkerClusterer.prototype.setMinClusterSize = function(size) { 602 | this.minClusterSize_ = size; 603 | }; 604 | 605 | 606 | /** 607 | * Extends a bounds object by the grid size. 608 | * 609 | * @param {google.maps.LatLngBounds} bounds The bounds to extend. 610 | * @return {google.maps.LatLngBounds} The extended bounds. 611 | */ 612 | MarkerClusterer.prototype.getExtendedBounds = function(bounds) { 613 | var projection = this.getProjection(); 614 | 615 | // Turn the bounds into latlng. 616 | var tr = new google.maps.LatLng(bounds.getNorthEast().lat(), 617 | bounds.getNorthEast().lng()); 618 | var bl = new google.maps.LatLng(bounds.getSouthWest().lat(), 619 | bounds.getSouthWest().lng()); 620 | 621 | // Convert the points to pixels and the extend out by the grid size. 622 | var trPix = projection.fromLatLngToDivPixel(tr); 623 | trPix.x += this.gridSize_; 624 | trPix.y -= this.gridSize_; 625 | 626 | var blPix = projection.fromLatLngToDivPixel(bl); 627 | blPix.x -= this.gridSize_; 628 | blPix.y += this.gridSize_; 629 | 630 | // Convert the pixel points back to LatLng 631 | var ne = projection.fromDivPixelToLatLng(trPix); 632 | var sw = projection.fromDivPixelToLatLng(blPix); 633 | 634 | // Extend the bounds to contain the new bounds. 635 | bounds.extend(ne); 636 | bounds.extend(sw); 637 | 638 | return bounds; 639 | }; 640 | 641 | 642 | /** 643 | * Determins if a marker is contained in a bounds. 644 | * 645 | * @param {google.maps.Marker} marker The marker to check. 646 | * @param {google.maps.LatLngBounds} bounds The bounds to check against. 647 | * @return {boolean} True if the marker is in the bounds. 648 | * @private 649 | */ 650 | MarkerClusterer.prototype.isMarkerInBounds_ = function(marker, bounds) { 651 | return bounds.contains(marker.getPosition()); 652 | }; 653 | 654 | 655 | /** 656 | * Clears all clusters and markers from the clusterer. 657 | */ 658 | MarkerClusterer.prototype.clearMarkers = function() { 659 | this.resetViewport(true); 660 | 661 | // Set the markers a empty array. 662 | this.markers_ = []; 663 | }; 664 | 665 | 666 | /** 667 | * Clears all existing clusters and recreates them. 668 | * @param {boolean} opt_hide To also hide the marker. 669 | */ 670 | MarkerClusterer.prototype.resetViewport = function(opt_hide) { 671 | // Remove all the clusters 672 | for (var i = 0, cluster; cluster = this.clusters_[i]; i++) { 673 | cluster.remove(); 674 | } 675 | 676 | // Reset the markers to not be added and to be invisible. 677 | for (var i = 0, marker; marker = this.markers_[i]; i++) { 678 | marker.isAdded = false; 679 | if (opt_hide) { 680 | marker.setMap(null); 681 | } 682 | } 683 | 684 | this.clusters_ = []; 685 | }; 686 | 687 | /** 688 | * 689 | */ 690 | MarkerClusterer.prototype.repaint = function() { 691 | var oldClusters = this.clusters_.slice(); 692 | this.clusters_.length = 0; 693 | this.resetViewport(); 694 | this.redraw(); 695 | 696 | // Remove the old clusters. 697 | // Do it in a timeout so the other clusters have been drawn first. 698 | window.setTimeout(function() { 699 | for (var i = 0, cluster; cluster = oldClusters[i]; i++) { 700 | cluster.remove(); 701 | } 702 | }, 0); 703 | }; 704 | 705 | 706 | /** 707 | * Redraws the clusters. 708 | */ 709 | MarkerClusterer.prototype.redraw = function() { 710 | this.createClusters_(); 711 | }; 712 | 713 | 714 | /** 715 | * Calculates the distance between two latlng locations in km. 716 | * @see http://www.movable-type.co.uk/scripts/latlong.html 717 | * 718 | * @param {google.maps.LatLng} p1 The first lat lng point. 719 | * @param {google.maps.LatLng} p2 The second lat lng point. 720 | * @return {number} The distance between the two points in km. 721 | * @private 722 | */ 723 | MarkerClusterer.prototype.distanceBetweenPoints_ = function(p1, p2) { 724 | if (!p1 || !p2) { 725 | return 0; 726 | } 727 | 728 | var R = 6371; // Radius of the Earth in km 729 | var dLat = (p2.lat() - p1.lat()) * Math.PI / 180; 730 | var dLon = (p2.lng() - p1.lng()) * Math.PI / 180; 731 | var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + 732 | Math.cos(p1.lat() * Math.PI / 180) * Math.cos(p2.lat() * Math.PI / 180) * 733 | Math.sin(dLon / 2) * Math.sin(dLon / 2); 734 | var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 735 | var d = R * c; 736 | return d; 737 | }; 738 | 739 | 740 | /** 741 | * Add a marker to a cluster, or creates a new cluster. 742 | * 743 | * @param {google.maps.Marker} marker The marker to add. 744 | * @private 745 | */ 746 | MarkerClusterer.prototype.addToClosestCluster_ = function(marker) { 747 | var distance = 40000; // Some large number 748 | var clusterToAddTo = null; 749 | var pos = marker.getPosition(); 750 | for (var i = 0, cluster; cluster = this.clusters_[i]; i++) { 751 | var center = cluster.getCenter(); 752 | if (center) { 753 | var d = this.distanceBetweenPoints_(center, marker.getPosition()); 754 | if (d < distance) { 755 | distance = d; 756 | clusterToAddTo = cluster; 757 | } 758 | } 759 | } 760 | 761 | if (clusterToAddTo && clusterToAddTo.isMarkerInClusterBounds(marker)) { 762 | clusterToAddTo.addMarker(marker); 763 | } else { 764 | var cluster = new Cluster(this); 765 | cluster.addMarker(marker); 766 | this.clusters_.push(cluster); 767 | } 768 | }; 769 | 770 | 771 | /** 772 | * Creates the clusters. 773 | * 774 | * @private 775 | */ 776 | MarkerClusterer.prototype.createClusters_ = function() { 777 | if (!this.ready_) { 778 | return; 779 | } 780 | 781 | // Get our current map view bounds. 782 | // Create a new bounds object so we don't affect the map. 783 | var mapBounds = new google.maps.LatLngBounds(this.map_.getBounds().getSouthWest(), 784 | this.map_.getBounds().getNorthEast()); 785 | var bounds = this.getExtendedBounds(mapBounds); 786 | 787 | for (var i = 0, marker; marker = this.markers_[i]; i++) { 788 | if (!marker.isAdded && this.isMarkerInBounds_(marker, bounds)) { 789 | this.addToClosestCluster_(marker); 790 | } 791 | } 792 | }; 793 | 794 | 795 | /** 796 | * A cluster that contains markers. 797 | * 798 | * @param {MarkerClusterer} markerClusterer The markerclusterer that this 799 | * cluster is associated with. 800 | * @constructor 801 | * @ignore 802 | */ 803 | function Cluster(markerClusterer) { 804 | this.markerClusterer_ = markerClusterer; 805 | this.map_ = markerClusterer.getMap(); 806 | this.gridSize_ = markerClusterer.getGridSize(); 807 | this.minClusterSize_ = markerClusterer.getMinClusterSize(); 808 | this.averageCenter_ = markerClusterer.isAverageCenter(); 809 | this.center_ = null; 810 | this.markers_ = []; 811 | this.bounds_ = null; 812 | this.clusterIcon_ = new ClusterIcon(this, markerClusterer.getStyles(), 813 | markerClusterer.getGridSize()); 814 | } 815 | 816 | /** 817 | * Determins if a marker is already added to the cluster. 818 | * 819 | * @param {google.maps.Marker} marker The marker to check. 820 | * @return {boolean} True if the marker is already added. 821 | */ 822 | Cluster.prototype.isMarkerAlreadyAdded = function(marker) { 823 | if (this.markers_.indexOf) { 824 | return this.markers_.indexOf(marker) != -1; 825 | } else { 826 | for (var i = 0, m; m = this.markers_[i]; i++) { 827 | if (m == marker) { 828 | return true; 829 | } 830 | } 831 | } 832 | return false; 833 | }; 834 | 835 | 836 | /** 837 | * Add a marker the cluster. 838 | * 839 | * @param {google.maps.Marker} marker The marker to add. 840 | * @return {boolean} True if the marker was added. 841 | */ 842 | Cluster.prototype.addMarker = function(marker) { 843 | if (this.isMarkerAlreadyAdded(marker)) { 844 | return false; 845 | } 846 | 847 | if (!this.center_) { 848 | this.center_ = marker.getPosition(); 849 | this.calculateBounds_(); 850 | } else { 851 | if (this.averageCenter_) { 852 | var l = this.markers_.length + 1; 853 | var lat = (this.center_.lat() * (l-1) + marker.getPosition().lat()) / l; 854 | var lng = (this.center_.lng() * (l-1) + marker.getPosition().lng()) / l; 855 | this.center_ = new google.maps.LatLng(lat, lng); 856 | this.calculateBounds_(); 857 | } 858 | } 859 | 860 | marker.isAdded = true; 861 | this.markers_.push(marker); 862 | 863 | var len = this.markers_.length; 864 | if (len < this.minClusterSize_ && marker.getMap() != this.map_) { 865 | // Min cluster size not reached so show the marker. 866 | marker.setMap(this.map_); 867 | } 868 | 869 | if (len == this.minClusterSize_) { 870 | // Hide the markers that were showing. 871 | for (var i = 0; i < len; i++) { 872 | this.markers_[i].setMap(null); 873 | } 874 | } 875 | 876 | if (len >= this.minClusterSize_) { 877 | marker.setMap(null); 878 | } 879 | 880 | this.updateIcon(); 881 | return true; 882 | }; 883 | 884 | 885 | /** 886 | * Returns the marker clusterer that the cluster is associated with. 887 | * 888 | * @return {MarkerClusterer} The associated marker clusterer. 889 | */ 890 | Cluster.prototype.getMarkerClusterer = function() { 891 | return this.markerClusterer_; 892 | }; 893 | 894 | 895 | /** 896 | * Returns the bounds of the cluster. 897 | * 898 | * @return {google.maps.LatLngBounds} the cluster bounds. 899 | */ 900 | Cluster.prototype.getBounds = function() { 901 | var bounds = new google.maps.LatLngBounds(this.center_, this.center_); 902 | var markers = this.getMarkers(); 903 | for (var i = 0, marker; marker = markers[i]; i++) { 904 | bounds.extend(marker.getPosition()); 905 | } 906 | return bounds; 907 | }; 908 | 909 | 910 | /** 911 | * Removes the cluster 912 | */ 913 | Cluster.prototype.remove = function() { 914 | this.clusterIcon_.remove(); 915 | this.markers_.length = 0; 916 | delete this.markers_; 917 | }; 918 | 919 | 920 | /** 921 | * Returns the center of the cluster. 922 | * 923 | * @return {number} The cluster center. 924 | */ 925 | Cluster.prototype.getSize = function() { 926 | return this.markers_.length; 927 | }; 928 | 929 | 930 | /** 931 | * Returns the center of the cluster. 932 | * 933 | * @return {Array.} The cluster center. 934 | */ 935 | Cluster.prototype.getMarkers = function() { 936 | return this.markers_; 937 | }; 938 | 939 | 940 | /** 941 | * Returns the center of the cluster. 942 | * 943 | * @return {google.maps.LatLng} The cluster center. 944 | */ 945 | Cluster.prototype.getCenter = function() { 946 | return this.center_; 947 | }; 948 | 949 | 950 | /** 951 | * Calculated the extended bounds of the cluster with the grid. 952 | * 953 | * @private 954 | */ 955 | Cluster.prototype.calculateBounds_ = function() { 956 | var bounds = new google.maps.LatLngBounds(this.center_, this.center_); 957 | this.bounds_ = this.markerClusterer_.getExtendedBounds(bounds); 958 | }; 959 | 960 | 961 | /** 962 | * Determines if a marker lies in the clusters bounds. 963 | * 964 | * @param {google.maps.Marker} marker The marker to check. 965 | * @return {boolean} True if the marker lies in the bounds. 966 | */ 967 | Cluster.prototype.isMarkerInClusterBounds = function(marker) { 968 | return this.bounds_.contains(marker.getPosition()); 969 | }; 970 | 971 | 972 | /** 973 | * Returns the map that the cluster is associated with. 974 | * 975 | * @return {google.maps.Map} The map. 976 | */ 977 | Cluster.prototype.getMap = function() { 978 | return this.map_; 979 | }; 980 | 981 | 982 | /** 983 | * Updates the cluster icon 984 | */ 985 | Cluster.prototype.updateIcon = function() { 986 | var zoom = this.map_.getZoom(); 987 | var mz = this.markerClusterer_.getMaxZoom(); 988 | 989 | if (mz && zoom > mz) { 990 | // The zoom is greater than our max zoom so show all the markers in cluster. 991 | for (var i = 0, marker; marker = this.markers_[i]; i++) { 992 | marker.setMap(this.map_); 993 | } 994 | return; 995 | } 996 | 997 | if (this.markers_.length < this.minClusterSize_) { 998 | // Min cluster size not yet reached. 999 | this.clusterIcon_.hide(); 1000 | return; 1001 | } 1002 | 1003 | var numStyles = this.markerClusterer_.getStyles().length; 1004 | var sums = this.markerClusterer_.getCalculator()(this.markers_, numStyles); 1005 | this.clusterIcon_.setCenter(this.center_); 1006 | this.clusterIcon_.setSums(sums); 1007 | this.clusterIcon_.show(); 1008 | }; 1009 | 1010 | 1011 | /** 1012 | * A cluster icon 1013 | * 1014 | * @param {Cluster} cluster The cluster to be associated with. 1015 | * @param {Object} styles An object that has style properties: 1016 | * 'url': (string) The image url. 1017 | * 'height': (number) The image height. 1018 | * 'width': (number) The image width. 1019 | * 'anchor': (Array) The anchor position of the label text. 1020 | * 'textColor': (string) The text color. 1021 | * 'textSize': (number) The text size. 1022 | * 'backgroundPosition: (string) The background postition x, y. 1023 | * @param {number=} opt_padding Optional padding to apply to the cluster icon. 1024 | * @constructor 1025 | * @extends google.maps.OverlayView 1026 | * @ignore 1027 | */ 1028 | function ClusterIcon(cluster, styles, opt_padding) { 1029 | cluster.getMarkerClusterer().extend(ClusterIcon, google.maps.OverlayView); 1030 | 1031 | this.styles_ = styles; 1032 | this.padding_ = opt_padding || 0; 1033 | this.cluster_ = cluster; 1034 | this.center_ = null; 1035 | this.map_ = cluster.getMap(); 1036 | this.div_ = null; 1037 | this.sums_ = null; 1038 | this.visible_ = false; 1039 | 1040 | this.setMap(this.map_); 1041 | } 1042 | 1043 | 1044 | /** 1045 | * Triggers the clusterclick event and zoom's if the option is set. 1046 | * 1047 | * @param {google.maps.MouseEvent} event The event to propagate 1048 | */ 1049 | ClusterIcon.prototype.triggerClusterClick = function(event) { 1050 | var markerClusterer = this.cluster_.getMarkerClusterer(); 1051 | 1052 | // Trigger the clusterclick event. 1053 | google.maps.event.trigger(markerClusterer, 'clusterclick', this.cluster_, event); 1054 | 1055 | if (markerClusterer.isZoomOnClick()) { 1056 | // Zoom into the cluster. 1057 | this.map_.fitBounds(this.cluster_.getBounds()); 1058 | } 1059 | }; 1060 | 1061 | 1062 | /** 1063 | * Adding the cluster icon to the dom. 1064 | * @ignore 1065 | */ 1066 | ClusterIcon.prototype.onAdd = function() { 1067 | this.div_ = document.createElement('DIV'); 1068 | if (this.visible_) { 1069 | var pos = this.getPosFromLatLng_(this.center_); 1070 | this.div_.style.cssText = this.createCss(pos); 1071 | this.div_.innerHTML = this.sums_.text; 1072 | } 1073 | 1074 | var panes = this.getPanes(); 1075 | panes.overlayMouseTarget.appendChild(this.div_); 1076 | 1077 | var that = this; 1078 | var isDragging = false; 1079 | google.maps.event.addDomListener(this.div_, 'click', function(event) { 1080 | // Only perform click when not preceded by a drag 1081 | if (!isDragging) { 1082 | that.triggerClusterClick(event); 1083 | } 1084 | }); 1085 | google.maps.event.addDomListener(this.div_, 'mousedown', function() { 1086 | isDragging = false; 1087 | }); 1088 | google.maps.event.addDomListener(this.div_, 'mousemove', function() { 1089 | isDragging = true; 1090 | }); 1091 | }; 1092 | 1093 | 1094 | /** 1095 | * Returns the position to place the div dending on the latlng. 1096 | * 1097 | * @param {google.maps.LatLng} latlng The position in latlng. 1098 | * @return {google.maps.Point} The position in pixels. 1099 | * @private 1100 | */ 1101 | ClusterIcon.prototype.getPosFromLatLng_ = function(latlng) { 1102 | var pos = this.getProjection().fromLatLngToDivPixel(latlng); 1103 | 1104 | if (typeof this.iconAnchor_ === 'object' && this.iconAnchor_.length === 2) { 1105 | pos.x -= this.iconAnchor_[0]; 1106 | pos.y -= this.iconAnchor_[1]; 1107 | } else { 1108 | pos.x -= parseInt(this.width_ / 2, 10); 1109 | pos.y -= parseInt(this.height_ / 2, 10); 1110 | } 1111 | return pos; 1112 | }; 1113 | 1114 | 1115 | /** 1116 | * Draw the icon. 1117 | * @ignore 1118 | */ 1119 | ClusterIcon.prototype.draw = function() { 1120 | if (this.visible_) { 1121 | var pos = this.getPosFromLatLng_(this.center_); 1122 | this.div_.style.top = pos.y + 'px'; 1123 | this.div_.style.left = pos.x + 'px'; 1124 | } 1125 | }; 1126 | 1127 | 1128 | /** 1129 | * Hide the icon. 1130 | */ 1131 | ClusterIcon.prototype.hide = function() { 1132 | if (this.div_) { 1133 | this.div_.style.display = 'none'; 1134 | } 1135 | this.visible_ = false; 1136 | }; 1137 | 1138 | 1139 | /** 1140 | * Position and show the icon. 1141 | */ 1142 | ClusterIcon.prototype.show = function() { 1143 | if (this.div_) { 1144 | var pos = this.getPosFromLatLng_(this.center_); 1145 | this.div_.style.cssText = this.createCss(pos); 1146 | this.div_.style.display = ''; 1147 | } 1148 | this.visible_ = true; 1149 | }; 1150 | 1151 | 1152 | /** 1153 | * Remove the icon from the map 1154 | */ 1155 | ClusterIcon.prototype.remove = function() { 1156 | this.setMap(null); 1157 | }; 1158 | 1159 | 1160 | /** 1161 | * Implementation of the onRemove interface. 1162 | * @ignore 1163 | */ 1164 | ClusterIcon.prototype.onRemove = function() { 1165 | if (this.div_ && this.div_.parentNode) { 1166 | this.hide(); 1167 | this.div_.parentNode.removeChild(this.div_); 1168 | this.div_ = null; 1169 | } 1170 | }; 1171 | 1172 | 1173 | /** 1174 | * Set the sums of the icon. 1175 | * 1176 | * @param {Object} sums The sums containing: 1177 | * 'text': (string) The text to display in the icon. 1178 | * 'index': (number) The style index of the icon. 1179 | */ 1180 | ClusterIcon.prototype.setSums = function(sums) { 1181 | this.sums_ = sums; 1182 | this.text_ = sums.text; 1183 | this.index_ = sums.index; 1184 | if (this.div_) { 1185 | this.div_.innerHTML = sums.text; 1186 | } 1187 | 1188 | this.useStyle(); 1189 | }; 1190 | 1191 | 1192 | /** 1193 | * Sets the icon to the the styles. 1194 | */ 1195 | ClusterIcon.prototype.useStyle = function() { 1196 | var index = Math.max(0, this.sums_.index - 1); 1197 | index = Math.min(this.styles_.length - 1, index); 1198 | var style = this.styles_[index]; 1199 | this.url_ = style['url']; 1200 | this.height_ = style['height']; 1201 | this.width_ = style['width']; 1202 | this.textColor_ = style['textColor']; 1203 | this.anchor_ = style['anchor']; 1204 | this.textSize_ = style['textSize']; 1205 | this.backgroundPosition_ = style['backgroundPosition']; 1206 | this.iconAnchor_ = style['iconAnchor']; 1207 | }; 1208 | 1209 | 1210 | /** 1211 | * Sets the center of the icon. 1212 | * 1213 | * @param {google.maps.LatLng} center The latlng to set as the center. 1214 | */ 1215 | ClusterIcon.prototype.setCenter = function(center) { 1216 | this.center_ = center; 1217 | }; 1218 | 1219 | 1220 | /** 1221 | * Create the css text based on the position of the icon. 1222 | * 1223 | * @param {google.maps.Point} pos The position. 1224 | * @return {string} The css style text. 1225 | */ 1226 | ClusterIcon.prototype.createCss = function(pos) { 1227 | var style = []; 1228 | style.push('background-image:url(' + this.url_ + ');'); 1229 | var backgroundPosition = this.backgroundPosition_ ? this.backgroundPosition_ : '0 0'; 1230 | style.push('background-position:' + backgroundPosition + ';'); 1231 | 1232 | if (typeof this.anchor_ === 'object') { 1233 | if (typeof this.anchor_[0] === 'number' && this.anchor_[0] > 0 && 1234 | this.anchor_[0] < this.height_) { 1235 | style.push('height:' + (this.height_ - this.anchor_[0]) + 1236 | 'px; padding-top:' + this.anchor_[0] + 'px;'); 1237 | } else if (typeof this.anchor_[0] === 'number' && this.anchor_[0] < 0 && 1238 | -this.anchor_[0] < this.height_) { 1239 | style.push('height:' + this.height_ + 'px; line-height:' + (this.height_ + this.anchor_[0]) + 1240 | 'px;'); 1241 | } else { 1242 | style.push('height:' + this.height_ + 'px; line-height:' + this.height_ + 1243 | 'px;'); 1244 | } 1245 | if (typeof this.anchor_[1] === 'number' && this.anchor_[1] > 0 && 1246 | this.anchor_[1] < this.width_) { 1247 | style.push('width:' + (this.width_ - this.anchor_[1]) + 1248 | 'px; padding-left:' + this.anchor_[1] + 'px;'); 1249 | } else { 1250 | style.push('width:' + this.width_ + 'px; text-align:center;'); 1251 | } 1252 | } else { 1253 | style.push('height:' + this.height_ + 'px; line-height:' + 1254 | this.height_ + 'px; width:' + this.width_ + 'px; text-align:center;'); 1255 | } 1256 | 1257 | var txtColor = this.textColor_ ? this.textColor_ : 'black'; 1258 | var txtSize = this.textSize_ ? this.textSize_ : 11; 1259 | 1260 | style.push('cursor:pointer; top:' + pos.y + 'px; left:' + 1261 | pos.x + 'px; color:' + txtColor + '; position:absolute; font-size:' + 1262 | txtSize + 'px; font-family:Arial,sans-serif; font-weight:bold'); 1263 | return style.join(''); 1264 | }; 1265 | 1266 | 1267 | // Export Symbols for Closure 1268 | // If you are not going to compile with closure then you can remove the 1269 | // code below. 1270 | window['MarkerClusterer'] = MarkerClusterer; 1271 | MarkerClusterer.prototype['addMarker'] = MarkerClusterer.prototype.addMarker; 1272 | MarkerClusterer.prototype['addMarkers'] = MarkerClusterer.prototype.addMarkers; 1273 | MarkerClusterer.prototype['clearMarkers'] = 1274 | MarkerClusterer.prototype.clearMarkers; 1275 | MarkerClusterer.prototype['fitMapToMarkers'] = 1276 | MarkerClusterer.prototype.fitMapToMarkers; 1277 | MarkerClusterer.prototype['getCalculator'] = 1278 | MarkerClusterer.prototype.getCalculator; 1279 | MarkerClusterer.prototype['getGridSize'] = 1280 | MarkerClusterer.prototype.getGridSize; 1281 | MarkerClusterer.prototype['getExtendedBounds'] = 1282 | MarkerClusterer.prototype.getExtendedBounds; 1283 | MarkerClusterer.prototype['getMap'] = MarkerClusterer.prototype.getMap; 1284 | MarkerClusterer.prototype['getMarkers'] = MarkerClusterer.prototype.getMarkers; 1285 | MarkerClusterer.prototype['getMaxZoom'] = MarkerClusterer.prototype.getMaxZoom; 1286 | MarkerClusterer.prototype['getStyles'] = MarkerClusterer.prototype.getStyles; 1287 | MarkerClusterer.prototype['getTotalClusters'] = 1288 | MarkerClusterer.prototype.getTotalClusters; 1289 | MarkerClusterer.prototype['getTotalMarkers'] = 1290 | MarkerClusterer.prototype.getTotalMarkers; 1291 | MarkerClusterer.prototype['redraw'] = MarkerClusterer.prototype.redraw; 1292 | MarkerClusterer.prototype['removeMarker'] = 1293 | MarkerClusterer.prototype.removeMarker; 1294 | MarkerClusterer.prototype['removeMarkers'] = 1295 | MarkerClusterer.prototype.removeMarkers; 1296 | MarkerClusterer.prototype['resetViewport'] = 1297 | MarkerClusterer.prototype.resetViewport; 1298 | MarkerClusterer.prototype['repaint'] = 1299 | MarkerClusterer.prototype.repaint; 1300 | MarkerClusterer.prototype['setCalculator'] = 1301 | MarkerClusterer.prototype.setCalculator; 1302 | MarkerClusterer.prototype['setGridSize'] = 1303 | MarkerClusterer.prototype.setGridSize; 1304 | MarkerClusterer.prototype['setMaxZoom'] = 1305 | MarkerClusterer.prototype.setMaxZoom; 1306 | MarkerClusterer.prototype['onAdd'] = MarkerClusterer.prototype.onAdd; 1307 | MarkerClusterer.prototype['draw'] = MarkerClusterer.prototype.draw; 1308 | 1309 | Cluster.prototype['getCenter'] = Cluster.prototype.getCenter; 1310 | Cluster.prototype['getSize'] = Cluster.prototype.getSize; 1311 | Cluster.prototype['getMarkers'] = Cluster.prototype.getMarkers; 1312 | 1313 | ClusterIcon.prototype['onAdd'] = ClusterIcon.prototype.onAdd; 1314 | ClusterIcon.prototype['draw'] = ClusterIcon.prototype.draw; 1315 | ClusterIcon.prototype['onRemove'] = ClusterIcon.prototype.onRemove; --------------------------------------------------------------------------------