├── 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 | * - Procure Google Maps API key
11 | * - Load the google maps script with the key as a parameter
12 | * - Initialize the map
13 | * - Initialize the drawing manager for the map
14 | * - Initialize the controls for interacting with the map
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 |
14 |
15 |
16 |
54 |
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;
--------------------------------------------------------------------------------