'
+ + '';
+
+// putting everything in a wrapper function that in turn is placed in a
+// script tag on the website allows us to execute in the site’s context
+// instead of in the Greasemonkey/Extension/etc. context.
+function wrapper() {
+
+// LEAFLET PREFER CANVAS ///////////////////////////////////////////////
+// Set to true if Leaflet should draw things using Canvas instead of SVG
+// Disabled for now because it has several bugs: flickering, constant
+// CPU usage and it continuously fires the moveend event.
+L_PREFER_CANVAS = false;
+
+// CONFIG OPTIONS ////////////////////////////////////////////////////
+var REFRESH = 30; // refresh view every 30s (base time)
+var ZOOM_LEVEL_ADJ = 5; // add 5 seconds per zoom level
+var REFRESH_GAME_SCORE = 5*60; // refresh game score every 5 minutes
+var MAX_IDLE_TIME = 4; // stop updating map after 4min idling
+var PRECACHE_PLAYER_NAMES_ZOOM = 17; // zoom level to start pre-resolving player names
+var HIDDEN_SCROLLBAR_ASSUMED_WIDTH = 20;
+var SIDEBAR_WIDTH = 300;
+// chat messages are requested for the visible viewport. On high zoom
+// levels this gets pretty pointless, so request messages in at least a
+// X km radius.
+var CHAT_MIN_RANGE = 6;
+// this controls how far data is being drawn outside the viewport. Set
+// it 0 to only draw entities that intersect the current view. A value
+// of one will render an area twice the size of the viewport (or some-
+// thing like that, Leaflet doc isn’t too specific). Setting it too low
+// makes the missing data on move/zoom out more obvious. Setting it too
+// high causes too many items to be drawn, making drag&drop sluggish.
+var VIEWPORT_PAD_RATIO = 0.3;
+
+// how many items to request each query
+var CHAT_PUBLIC_ITEMS = 200
+var CHAT_FACTION_ITEMS = 50
+
+// Leaflet will get very slow for MANY items. It’s better to display
+// only some instead of crashing the browser.
+var MAX_DRAWN_PORTALS = 1000;
+var MAX_DRAWN_LINKS = 400;
+var MAX_DRAWN_FIELDS = 200;
+
+
+var COLOR_SELECTED_PORTAL = '#f00';
+var COLORS = ['#FFCE00', '#0088FF', '#03FE03']; // none, res, enl
+var COLORS_LVL = ['#000', '#FECE5A', '#FFA630', '#FF7315', '#E40000', '#FD2992', '#EB26CD', '#C124E0', '#9627F4'];
+var COLORS_MOD = {VERY_RARE: '#F78AF6', RARE: '#AD8AFF', COMMON: '#84FBBD'};
+
+
+// circles around a selected portal that show from where you can hack
+// it and how far the portal reaches (i.e. how far links may be made
+// from this portal)
+var ACCESS_INDICATOR_COLOR = 'orange';
+var RANGE_INDICATOR_COLOR = 'red';
+
+// INGRESS CONSTANTS /////////////////////////////////////////////////
+// http://decodeingress.me/2012/11/18/ingress-portal-levels-and-link-range/
+var RESO_NRG = [0, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000];
+var MAX_XM_PER_LEVEL = [0, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000];
+var MIN_AP_FOR_LEVEL = [0, 10000, 30000, 70000, 150000, 300000, 600000, 1200000];
+var HACK_RANGE = 40; // in meters, max. distance from portal to be able to access it
+var OCTANTS = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE'];
+var DEFAULT_PORTAL_IMG = 'http://commondatastorage.googleapis.com/ingress/img/default-portal-image.png';
+var DESTROY_RESONATOR = 75; //AP for destroying portal
+var DESTROY_LINK = 187; //AP for destroying link
+var DESTROY_FIELD = 750; //AP for destroying field
+
+// OTHER MORE-OR-LESS CONSTANTS //////////////////////////////////////
+var NOMINATIM = 'http://nominatim.openstreetmap.org/search?format=json&limit=1&q=';
+var DEG2RAD = Math.PI / 180;
+var TEAM_NONE = 0, TEAM_RES = 1, TEAM_ENL = 2;
+var TEAM_TO_CSS = ['none', 'res', 'enl'];
+var TYPE_UNKNOWN = 0, TYPE_PORTAL = 1, TYPE_LINK = 2, TYPE_FIELD = 3, TYPE_PLAYER = 4, TYPE_CHAT = 5, TYPE_RESONATOR = 6;
+// make PLAYER variable available in site context
+var PLAYER = window.PLAYER;
+var CHAT_SHRINKED = 60;
+
+// Minimum zoom level resonator will display
+var RESONATOR_DISPLAY_ZOOM_LEVEL = 17;
+
+// Constants for resonator positioning
+var SLOT_TO_LAT = [0, Math.sqrt(2)/2, 1, Math.sqrt(2)/2, 0, -Math.sqrt(2)/2, -1, -Math.sqrt(2)/2];
+var SLOT_TO_LNG = [1, Math.sqrt(2)/2, 0, -Math.sqrt(2)/2, -1, -Math.sqrt(2)/2, 0, Math.sqrt(2)/2];
+var EARTH_RADIUS=6378137;
+
+// STORAGE ///////////////////////////////////////////////////////////
+// global variables used for storage. Most likely READ ONLY. Proper
+// way would be to encapsulate them in an anonymous function and write
+// getters/setters, but if you are careful enough, this works.
+var refreshTimeout;
+var urlPortal = null;
+window.playersToResolve = [];
+window.playersInResolving = [];
+window.selectedPortal = null;
+window.portalRangeIndicator = null;
+window.portalAccessIndicator = null;
+window.mapRunsUserAction = false;
+var portalsLayers, linksLayer, fieldsLayer;
+
+// contain references to all entities shown on the map. These are
+// automatically kept in sync with the items on *sLayer, so never ever
+// write to them.
+window.portals = {};
+window.links = {};
+window.fields = {};
+window.resonators = {};
+
+// plugin framework. Plugins may load earlier than iitc, so don’t
+// overwrite data
+if(typeof window.plugin !== 'function') window.plugin = function() {};
+
+
+
+
+// PLUGIN HOOKS ////////////////////////////////////////////////////////
+// Plugins may listen to any number of events by specifying the name of
+// the event to listen to and handing a function that should be exe-
+// cuted when an event occurs. Callbacks will receive additional data
+// the event created as their first parameter. The value is always a
+// hash that contains more details.
+//
+// For example, this line will listen for portals to be added and print
+// the data generated by the event to the console:
+// window.addHook('portalAdded', function(data) { console.log(data) });
+//
+// Boot hook: booting is handled differently because IITC may not yet
+// be available. Have a look at the plugins in plugins/. All
+// code before “// PLUGIN START” and after “// PLUGIN END” os
+// required to successfully boot the plugin.
+//
+// Here’s more specific information about each event:
+// portalAdded: called when a portal has been received and is about to
+// be added to its layer group. Note that this does NOT
+// mean it is already visible or will be, shortly after.
+// If a portal is added to a hidden layer it may never be
+// shown at all. Injection point is in
+// code/map_data.js#renderPortal near the end. Will hand
+// the Leaflet CircleMarker for the portal in "portal" var.
+
+window._hooks = {}
+window.VALID_HOOKS = ['portalAdded'];
+
+window.runHooks = function(event, data) {
+ if(VALID_HOOKS.indexOf(event) === -1) throw('Unknown event type: ' + event);
+
+ if(!_hooks[event]) return;
+ $.each(_hooks[event], function(ind, callback) {
+ callback(data);
+ });
+}
+
+
+window.addHook = function(event, callback) {
+ if(VALID_HOOKS.indexOf(event) === -1) throw('Unknown event type: ' + event);
+ if(typeof callback !== 'function') throw('Callback must be a function.');
+
+ if(!_hooks[event])
+ _hooks[event] = [callback];
+ else
+ _hooks[event].push(callback);
+}
+
+
+
+// MAP DATA //////////////////////////////////////////////////////////
+// these functions handle how and which entities are displayed on the
+// map. They also keep them up to date, unless interrupted by user
+// action.
+
+
+// requests map data for current viewport. For details on how this
+// works, refer to the description in “MAP DATA REQUEST CALCULATORS”
+window.requestData = function() {
+ console.log('refreshing data');
+ requests.abort();
+ cleanUp();
+
+ var magic = convertCenterLat(map.getCenter().lat);
+ var R = calculateR(magic);
+
+ var bounds = map.getBounds();
+ // convert to point values
+ topRight = convertLatLngToPoint(bounds.getNorthEast(), magic, R);
+ bottomLeft = convertLatLngToPoint(bounds.getSouthWest() , magic, R);
+ // how many quadrants intersect the current view?
+ quadsX = Math.abs(bottomLeft.x - topRight.x);
+ quadsY = Math.abs(bottomLeft.y - topRight.y);
+
+ // will group requests by second-last quad-key quadrant
+ tiles = {};
+
+ // walk in x-direction, starts right goes left
+ for(var i = 0; i <= quadsX; i++) {
+ var x = Math.abs(topRight.x - i);
+ var qk = pointToQuadKey(x, topRight.y);
+ var bnds = convertPointToLatLng(x, topRight.y, magic, R);
+ if(!tiles[qk.slice(0, -1)]) tiles[qk.slice(0, -1)] = [];
+ tiles[qk.slice(0, -1)].push(generateBoundsParams(qk, bnds));
+
+ // walk in y-direction, starts top, goes down
+ for(var j = 1; j <= quadsY; j++) {
+ var qk = pointToQuadKey(x, topRight.y + j);
+ var bnds = convertPointToLatLng(x, topRight.y + j, magic, R);
+ if(!tiles[qk.slice(0, -1)]) tiles[qk.slice(0, -1)] = [];
+ tiles[qk.slice(0, -1)].push(generateBoundsParams(qk, bnds));
+ }
+ }
+
+ // finally send ajax requests
+ $.each(tiles, function(ind, tls) {
+ data = { minLevelOfDetail: -1 };
+ data.boundsParamsList = tls;
+ window.requests.add(window.postAjax('getThinnedEntitiesV2', data, window.handleDataResponse));
+ });
+}
+
+// works on map data response and ensures entities are drawn/updated.
+window.handleDataResponse = function(data, textStatus, jqXHR) {
+ // remove from active ajax queries list
+ if(!data || !data.result) {
+ window.failedRequestCount++;
+ console.warn(data);
+ return;
+ }
+
+ var portalUpdateAvailable = false;
+ var portalInUrlAvailable = false;
+ var m = data.result.map;
+ // defer rendering of portals because there is no z-index in SVG.
+ // this means that what’s rendered last ends up on top. While the
+ // portals can be brought to front, this costs extra time. They need
+ // to be in the foreground, or they cannot be clicked. See
+ // https://github.com/Leaflet/Leaflet/issues/185
+ var ppp = [];
+ var p2f = {};
+ $.each(m, function(qk, val) {
+ $.each(val.deletedGameEntityGuids, function(ind, guid) {
+ if(getTypeByGuid(guid) === TYPE_FIELD && window.fields[guid] !== undefined) {
+ $.each(window.fields[guid].options.vertices, function(ind, vertex) {
+ if(window.portals[vertex.guid] === undefined) return true;
+ fieldArray = window.portals[vertex.guid].options.portalV2.linkedFields;
+ fieldArray.splice($.inArray(guid, fieldArray), 1);
+ });
+ }
+ window.removeByGuid(guid);
+ });
+
+ $.each(val.gameEntities, function(ind, ent) {
+ // ent = [GUID, id(?), details]
+ // format for links: { controllingTeam, creator, edge }
+ // format for portals: { controllingTeam, turret }
+
+ if(ent[2].turret !== undefined) {
+ if(selectedPortal == ent[0]) portalUpdateAvailable = true;
+ if(urlPortal && ent[0] == urlPortal) portalInUrlAvailable = true;
+
+ var latlng = [ent[2].locationE6.latE6/1E6, ent[2].locationE6.lngE6/1E6];
+ if(!window.getPaddedBounds().contains(latlng)
+ && selectedPortal != ent[0]
+ && urlPortal != ent[0]
+ ) return;
+
+
+
+ ppp.push(ent); // delay portal render
+ } else if(ent[2].edge !== undefined) {
+ renderLink(ent);
+ } else if(ent[2].capturedRegion !== undefined) {
+ $.each(ent[2].capturedRegion, function(ind, vertex) {
+ if(p2f[vertex.guid] === undefined)
+ p2f[vertex.guid] = new Array();
+ p2f[vertex.guid].push(ent[0]);
+ });
+ renderField(ent);
+ } else {
+ throw('Unknown entity: ' + JSON.stringify(ent));
+ }
+ });
+ });
+
+ $.each(ppp, function(ind, portal) {
+ if(portal[2].portalV2['linkedFields'] === undefined) {
+ portal[2].portalV2['linkedFields'] = [];
+ }
+ if(p2f[portal[0]] !== undefined) {
+ $.merge(p2f[portal[0]], portal[2].portalV2['linkedFields']);
+ portal[2].portalV2['linkedFields'] = uniqueArray(p2f[portal[0]]);
+ }
+ });
+
+ $.each(ppp, function(ind, portal) { renderPortal(portal); });
+ if(portals[selectedPortal]) {
+ try {
+ portals[selectedPortal].bringToFront();
+ } catch(e) { /* portal is now visible, catch Leaflet error */ }
+ }
+
+ if(portalInUrlAvailable) {
+ renderPortalDetails(urlPortal);
+ urlPortal = null; // select it only once
+ }
+
+ if(portalUpdateAvailable) renderPortalDetails(selectedPortal);
+ resolvePlayerNames();
+}
+
+// removes entities that are still handled by Leaflet, although they
+// do not intersect the current viewport.
+window.cleanUp = function() {
+ var cnt = [0,0,0];
+ var b = getPaddedBounds();
+ var minlvl = getMinPortalLevel();
+ for(var i = 0; i < portalsLayers.length; i++) {
+ // i is also the portal level
+ portalsLayers[i].eachLayer(function(item) {
+ var itemGuid = item.options.guid;
+ // check if 'item' is a portal
+ if(getTypeByGuid(itemGuid) != TYPE_PORTAL) return true;
+ // portal must be in bounds and have a high enough level. Also don’t
+ // remove if it is selected.
+ if(itemGuid == window.selectedPortal ||
+ (b.contains(item.getLatLng()) && i >= minlvl)) return true;
+ cnt[0]++;
+ portalsLayers[i].removeLayer(item);
+ });
+ }
+ linksLayer.eachLayer(function(link) {
+ if(b.intersects(link.getBounds())) return;
+ cnt[1]++;
+ linksLayer.removeLayer(link);
+ });
+ fieldsLayer.eachLayer(function(field) {
+ if(b.intersects(field.getBounds())) return;
+ cnt[2]++;
+ fieldsLayer.removeLayer(field);
+ });
+ console.log('removed out-of-bounds: '+cnt[0]+' portals, '+cnt[1]+' links, '+cnt[2]+' fields');
+}
+
+
+// removes given entity from map
+window.removeByGuid = function(guid) {
+ switch(getTypeByGuid(guid)) {
+ case TYPE_PORTAL:
+ if(!window.portals[guid]) return;
+ var p = window.portals[guid];
+ for(var i = 0; i < portalsLayers.length; i++)
+ portalsLayers[i].removeLayer(p);
+ break;
+ case TYPE_LINK:
+ if(!window.links[guid]) return;
+ linksLayer.removeLayer(window.links[guid]);
+ break;
+ case TYPE_FIELD:
+ if(!window.fields[guid]) return;
+ fieldsLayer.removeLayer(window.fields[guid]);
+ break;
+ case TYPE_RESONATOR:
+ if(!window.resonators[guid]) return;
+ var r = window.resonators[guid];
+ for(var i = 1; i < portalsLayers.length; i++)
+ portalsLayers[i].removeLayer(r);
+ break;
+ default:
+ console.warn('unknown GUID type: ' + guid);
+ //window.debug.printStackTrace();
+ }
+}
+
+
+
+// renders a portal on the map from the given entity
+window.renderPortal = function(ent) {
+ if(Object.keys(portals).length >= MAX_DRAWN_PORTALS && ent[0] != selectedPortal)
+ return removeByGuid(ent[0]);
+
+ // hide low level portals on low zooms
+ var portalLevel = getPortalLevel(ent[2]);
+ if(portalLevel < getMinPortalLevel() && ent[0] != selectedPortal)
+ return removeByGuid(ent[0]);
+
+ var team = getTeam(ent[2]);
+
+ // do nothing if portal did not change
+ var layerGroup = portalsLayers[parseInt(portalLevel)];
+ var old = findEntityInLeaflet(layerGroup, window.portals, ent[0]);
+ if(old) {
+ var oo = old.options;
+ var u = oo.team !== team;
+ u = u || oo.level !== portalLevel;
+ // nothing for the portal changed, so don’t update. Let resonators
+ // manage themselves if they want to be updated.
+ if(!u) return renderResonators(ent);
+ removeByGuid(ent[0]);
+ }
+
+ // there were changes, remove old portal
+ removeByGuid(ent[0]);
+
+ var latlng = [ent[2].locationE6.latE6/1E6, ent[2].locationE6.lngE6/1E6];
+
+ // pre-loads player names for high zoom levels
+ loadPlayerNamesForPortal(ent[2]);
+
+
+ var lvWeight = Math.max(2, portalLevel / 1.5);
+ var lvRadius = Math.max(portalLevel + 3, 5);
+
+ var p = L.circleMarker(latlng, {
+ radius: lvRadius,
+ color: ent[0] == selectedPortal ? COLOR_SELECTED_PORTAL : COLORS[team],
+ opacity: 1,
+ weight: lvWeight,
+ fillColor: COLORS[team],
+ fillOpacity: 0.5,
+ clickable: true,
+ level: portalLevel,
+ team: team,
+ details: ent[2],
+ guid: ent[0]});
+
+ p.on('remove', function() {
+ var portalGuid = this.options.guid
+
+ // remove attached resonators, skip if
+ // all resonators have already removed by zooming
+ if(isResonatorsShow()) {
+ for(var i = 0; i <= 7; i++)
+ removeByGuid(portalResonatorGuid(portalGuid,i));
+ }
+ delete window.portals[portalGuid];
+ if(window.selectedPortal === portalGuid) {
+ window.unselectOldPortal();
+ window.map.removeLayer(window.portalAccessIndicator);
+ window.portalAccessIndicator = null;
+ }
+ });
+
+ p.on('add', function() {
+ // enable for debugging
+ if(window.portals[this.options.guid]) throw('duplicate portal detected');
+ window.portals[this.options.guid] = this;
+ // handles the case where a selected portal gets removed from the
+ // map by hiding all portals with said level
+ if(window.selectedPortal != this.options.guid)
+ window.portalResetColor(this);
+ });
+
+ p.on('click', function() { window.renderPortalDetails(ent[0]); });
+ p.on('dblclick', function() {
+ window.renderPortalDetails(ent[0]);
+ window.map.setView(latlng, 17);
+ });
+
+ window.renderResonators(ent);
+
+ window.runHooks('portalAdded', {portal: p});
+
+ // portalLevel contains a float, need to round down
+ p.addTo(layerGroup);
+}
+
+window.renderResonators = function(ent) {
+ var portalLevel = getPortalLevel(ent[2]);
+ if(portalLevel < getMinPortalLevel() && ent[0] != selectedPortal) return;
+
+ if(!isResonatorsShow()) return;
+
+ for(var i=0; i < ent[2].resonatorArray.resonators.length; i++) {
+ var rdata = ent[2].resonatorArray.resonators[i];
+
+ if(rdata == null) continue;
+
+ if(window.resonators[portalResonatorGuid(ent[0],i)]) continue;
+
+ // offset in meters
+ var dn = rdata.distanceToPortal*SLOT_TO_LAT[rdata.slot];
+ var de = rdata.distanceToPortal*SLOT_TO_LNG[rdata.slot];
+
+ // Coordinate offset in radians
+ var dLat = dn/EARTH_RADIUS;
+ var dLon = de/(EARTH_RADIUS*Math.cos(Math.PI/180*(ent[2].locationE6.latE6/1E6)));
+
+ // OffsetPosition, decimal degrees
+ var lat0 = ent[2].locationE6.latE6/1E6 + dLat * 180/Math.PI;
+ var lon0 = ent[2].locationE6.lngE6/1E6 + dLon * 180/Math.PI;
+ var Rlatlng = [lat0, lon0];
+ var r = L.circleMarker(Rlatlng, {
+ radius: 3,
+ // #AAAAAA outline seems easier to see the fill opacity
+ color: '#AAAAAA',
+ opacity: 1,
+ weight: 1,
+ fillColor: COLORS_LVL[rdata.level],
+ fillOpacity: rdata.energyTotal/RESO_NRG[rdata.level],
+ clickable: false,
+ level: rdata.level,
+ details: rdata,
+ pDetails: ent[2],
+ guid: portalResonatorGuid(ent[0],i) });
+
+ r.on('remove', function() { delete window.resonators[this.options.guid]; });
+ r.on('add', function() { window.resonators[this.options.guid] = this; });
+
+ r.addTo(portalsLayers[parseInt(portalLevel)]);
+ }
+}
+
+// append portal guid with -resonator-[slot] to get guid for resonators
+window.portalResonatorGuid = function(portalGuid, slot) {
+ return portalGuid + '-resonator-' + slot;
+}
+
+window.isResonatorsShow = function() {
+ return map.getZoom() >= RESONATOR_DISPLAY_ZOOM_LEVEL;
+}
+
+window.portalResetColor = function(portal) {
+ portal.setStyle({color: portal.options.fillColor});
+}
+
+// renders a link on the map from the given entity
+window.renderLink = function(ent) {
+ if(Object.keys(links).length >= MAX_DRAWN_LINKS)
+ return removeByGuid(ent[0]);
+
+ // assume that links never change. If they do, they will have a
+ // different ID.
+ if(findEntityInLeaflet(linksLayer, links, ent[0])) return;
+
+ var team = getTeam(ent[2]);
+ var edge = ent[2].edge;
+ var latlngs = [
+ [edge.originPortalLocation.latE6/1E6, edge.originPortalLocation.lngE6/1E6],
+ [edge.destinationPortalLocation.latE6/1E6, edge.destinationPortalLocation.lngE6/1E6]
+ ];
+ var poly = L.polyline(latlngs, {
+ color: COLORS[team],
+ opacity: 1,
+ weight:2,
+ clickable: false,
+ guid: ent[0],
+ smoothFactor: 10
+ });
+
+ if(!getPaddedBounds().intersects(poly.getBounds())) return;
+
+ poly.on('remove', function() { delete window.links[this.options.guid]; });
+ poly.on('add', function() {
+ // enable for debugging
+ if(window.links[this.options.guid]) throw('duplicate link detected');
+ window.links[this.options.guid] = this;
+ this.bringToBack();
+ });
+ poly.addTo(linksLayer);
+}
+
+// renders a field on the map from a given entity
+window.renderField = function(ent) {
+ if(Object.keys(fields).length >= MAX_DRAWN_FIELDS)
+ return window.removeByGuid(ent[0]);
+
+ // assume that fields never change. If they do, they will have a
+ // different ID.
+ if(findEntityInLeaflet(fieldsLayer, fields, ent[0])) return;
+
+ var team = getTeam(ent[2]);
+ var reg = ent[2].capturedRegion;
+ var latlngs = [
+ [reg.vertexA.location.latE6/1E6, reg.vertexA.location.lngE6/1E6],
+ [reg.vertexB.location.latE6/1E6, reg.vertexB.location.lngE6/1E6],
+ [reg.vertexC.location.latE6/1E6, reg.vertexC.location.lngE6/1E6]
+ ];
+ var poly = L.polygon(latlngs, {
+ fillColor: COLORS[team],
+ fillOpacity: 0.25,
+ stroke: false,
+ clickable: false,
+ smoothFactor: 10,
+ vertices: ent[2].capturedRegion,
+ lastUpdate: ent[1],
+ guid: ent[0]});
+
+ if(!getPaddedBounds().intersects(poly.getBounds())) return;
+
+ poly.on('remove', function() { delete window.fields[this.options.guid]; });
+ poly.on('add', function() {
+ // enable for debugging
+ if(window.fields[this.options.guid]) console.warn('duplicate field detected');
+ window.fields[this.options.guid] = this;
+ this.bringToBack();
+ });
+ poly.addTo(fieldsLayer);
+}
+
+
+// looks for the GUID in either the layerGroup or entityHash, depending
+// on which is faster. Will either return the Leaflet entity or null, if
+// it does not exist.
+// For example, to find a field use the function like this:
+// field = findEntityInLeaflet(fieldsLayer, fields, 'asdasdasd');
+window.findEntityInLeaflet = function(layerGroup, entityHash, guid) {
+ // fast way
+ if(map.hasLayer(layerGroup)) return entityHash[guid] || null;
+
+ // slow way in case the layer is currently hidden
+ var ent = null;
+ layerGroup.eachLayer(function(entity) {
+ if(entity.options.guid !== guid) return true;
+ ent = entity;
+ return false;
+ });
+ return ent;
+}
+
+
+
+// REQUEST HANDLING //////////////////////////////////////////////////
+// note: only meant for portal/links/fields request, everything else
+// does not count towards “loading”
+
+window.activeRequests = [];
+window.failedRequestCount = 0;
+
+window.requests = function() {}
+
+window.requests.add = function(ajax) {
+ window.activeRequests.push(ajax);
+ renderUpdateStatus();
+}
+
+window.requests.remove = function(ajax) {
+ window.activeRequests.splice(window.activeRequests.indexOf(ajax), 1);
+ renderUpdateStatus();
+}
+
+window.requests.abort = function() {
+ $.each(window.activeRequests, function(ind, actReq) {
+ if(actReq) actReq.abort();
+ });
+
+ window.activeRequests = [];
+ window.failedRequestCount = 0;
+ window.chat._requestOldPublicRunning = false;
+ window.chat._requestNewPublicRunning = false;
+ window.chat._requestOldFactionRunning = false;
+ window.chat._requestNewFactionRunning = false;
+
+ renderUpdateStatus();
+}
+
+// gives user feedback about pending operations. Draws current status
+// to website. Updates info in layer chooser.
+window.renderUpdateStatus = function() {
+ var t = 'map status: ';
+ if(mapRunsUserAction)
+ t += 'paused during interaction';
+ else if(isIdle())
+ t += 'Idle, not updating.';
+ else if(window.activeRequests.length > 0)
+ t += window.activeRequests.length + ' requests running.';
+ else
+ t += 'Up to date.';
+
+ if(renderLimitReached())
+ t += ' RENDER LIMIT '
+
+ if(window.failedRequestCount > 0)
+ t += ' ' + window.failedRequestCount + ' failed.'
+
+ t += ' (';
+ var minlvl = getMinPortalLevel();
+ if(minlvl === 0)
+ t += 'loading all portals';
+ else
+ t+= 'only loading portals with level '+minlvl+' and up';
+ t += ')';
+
+ var portalSelection = $('.leaflet-control-layers-overlays label');
+ portalSelection.slice(0, minlvl+1).addClass('disabled').attr('title', 'Zoom in to show those.');
+ portalSelection.slice(minlvl, 8).removeClass('disabled').attr('title', '');
+
+
+ $('#updatestatus').html(t);
+}
+
+
+// sets the timer for the next auto refresh. Ensures only one timeout
+// is queued. May be given 'override' in milliseconds if time should
+// not be guessed automatically. Especially useful if a little delay
+// is required, for example when zooming.
+window.startRefreshTimeout = function(override) {
+ // may be required to remove 'paused during interaction' message in
+ // status bar
+ window.renderUpdateStatus();
+ if(refreshTimeout) clearTimeout(refreshTimeout);
+ var t = 0;
+ if(override) {
+ t = override;
+ } else {
+ t = REFRESH*1000;
+ var adj = ZOOM_LEVEL_ADJ * (18 - window.map.getZoom());
+ if(adj > 0) t += adj*1000;
+ }
+ var next = new Date(new Date().getTime() + t).toLocaleTimeString();
+ console.log('planned refresh: ' + next);
+ refreshTimeout = setTimeout(window.requests._callOnRefreshFunctions, t);
+}
+
+window.requests._onRefreshFunctions = [];
+window.requests._callOnRefreshFunctions = function() {
+ startRefreshTimeout();
+
+ if(isIdle()) {
+ console.log('user has been idle for ' + idleTime + ' minutes. Skipping refresh.');
+ renderUpdateStatus();
+ return;
+ }
+
+ console.log('refreshing');
+
+ $.each(window.requests._onRefreshFunctions, function(ind, f) {
+ f();
+ });
+}
+
+
+// add method here to be notified of auto-refreshes
+window.requests.addRefreshFunction = function(f) {
+ window.requests._onRefreshFunctions.push(f);
+}
+
+
+
+
+// UTILS + MISC ///////////////////////////////////////////////////////
+
+// retrieves parameter from the URL?query=string.
+window.getURLParam = function(param) {
+ var v = document.URL;
+ var i = v.indexOf(param);
+ if(i <= -1) return '';
+ v = v.substr(i);
+ i = v.indexOf("&");
+ if(i >= 0) v = v.substr(0, i);
+ return v.replace(param+"=","");
+}
+
+// read cookie by name.
+// http://stackoverflow.com/a/5639455/1684530 by cwolves
+var cookies;
+window.readCookie = function(name,c,C,i){
+ if(cookies) return cookies[name];
+ c = document.cookie.split('; ');
+ cookies = {};
+ for(i=c.length-1; i>=0; i--){
+ C = c[i].split('=');
+ cookies[C[0]] = unescape(C[1]);
+ }
+ return cookies[name];
+}
+
+window.writeCookie = function(name, val) {
+ document.cookie = name + "=" + val + '; expires=Thu, 31 Dec 2020 23:59:59 GMT; path=/';
+}
+
+// add thousand separators to given number.
+// http://stackoverflow.com/a/1990590/1684530 by Doug Neiner.
+window.digits = function(d) {
+ return (d+"").replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1 ");
+}
+
+// posts AJAX request to Ingress API.
+// action: last part of the actual URL, the rpc/dashboard. is
+// added automatically
+// data: JSON data to post. method will be derived automatically from
+// action, but may be overridden. Expects to be given Hash.
+// Strings are not supported.
+// success: method to call on success. See jQuery API docs for avail-
+// able arguments: http://api.jquery.com/jQuery.ajax/
+// error: see above. Additionally it is logged if the request failed.
+window.postAjax = function(action, data, success, error) {
+ data = JSON.stringify($.extend({method: 'dashboard.'+action}, data));
+ var remove = function(data, textStatus, jqXHR) { window.requests.remove(jqXHR); };
+ var errCnt = function(jqXHR) { window.failedRequestCount++; window.requests.remove(jqXHR); };
+ return $.ajax({
+ url: 'rpc/dashboard.'+action,
+ type: 'POST',
+ data: data,
+ dataType: 'json',
+ success: [remove, success],
+ error: error ? [errCnt, error] : errCnt,
+ contentType: 'application/json; charset=utf-8',
+ beforeSend: function(req) {
+ req.setRequestHeader('X-CSRFToken', readCookie('csrftoken'));
+ }
+ });
+}
+
+// converts unix timestamps to HH:mm:ss format if it was today;
+// otherwise it returns YYYY-MM-DD
+window.unixTimeToString = function(time, full) {
+ if(!time) return null;
+ var d = new Date(typeof time === 'string' ? parseInt(time) : time);
+ var time = d.toLocaleTimeString();
+ var date = d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate();
+ if(typeof full !== 'undefined' && full) return date + ' ' + time;
+ if(d.toDateString() == new Date().toDateString())
+ return time;
+ else
+ return date;
+}
+
+window.unixTimeToHHmm = function(time) {
+ if(!time) return null;
+ var d = new Date(typeof time === 'string' ? parseInt(time) : time);
+ var h = '' + d.getHours(); h = h.length === 1 ? '0' + h : h;
+ var s = '' + d.getMinutes(); s = s.length === 1 ? '0' + s : s;
+ return h + ':' + s;
+}
+
+window.rangeLinkClick = function() {
+ if(window.portalRangeIndicator)
+ window.map.fitBounds(window.portalRangeIndicator.getBounds());
+}
+
+window.reportPortalIssue = function(info) {
+ var t = 'Redirecting you to a Google Help Page. Once there, click on “Contact Us” in the upper right corner.\n\nThe text box contains all necessary information. Press CTRL+C to copy it.';
+ var d = window.portals[window.selectedPortal].options.details;
+
+ var info = 'Your Nick: ' + PLAYER.nickname + ' '
+ + 'Portal: ' + d.portalV2.descriptiveText.TITLE + ' '
+ + 'Location: ' + d.portalV2.descriptiveText.ADDRESS
+ +' (lat ' + (d.locationE6.latE6/1E6) + '; lng ' + (d.locationE6.lngE6/1E6) + ')';
+
+ //codename, approx addr, portalname
+ if(prompt(t, info) !== null)
+ location.href = 'https://support.google.com/ingress?hl=en';
+}
+
+window._storedPaddedBounds = undefined;
+window.getPaddedBounds = function() {
+ if(_storedPaddedBounds === undefined) {
+ map.on('zoomstart zoomend movestart moveend', function() {
+ window._storedPaddedBounds = null;
+ });
+ }
+ if(window._storedPaddedBounds) return window._storedPaddedBounds;
+
+ var p = window.map.getBounds().pad(VIEWPORT_PAD_RATIO);
+ window._storedPaddedBounds = p;
+ return p;
+}
+
+window.renderLimitReached = function() {
+ if(Object.keys(portals).length >= MAX_DRAWN_PORTALS) return true;
+ if(Object.keys(links).length >= MAX_DRAWN_LINKS) return true;
+ if(Object.keys(fields).length >= MAX_DRAWN_FIELDS) return true;
+ return false;
+}
+
+window.getMinPortalLevel = function() {
+ var z = map.getZoom();
+ if(z >= 16) return 0;
+ var conv = ['impossible', 8,7,7,6,6,5,5,4,4,3,3,2,2,1,1];
+ return conv[z];
+}
+
+// returns number of pixels left to scroll down before reaching the
+// bottom. Works similar to the native scrollTop function.
+window.scrollBottom = function(elm) {
+ if(typeof elm === 'string') elm = $(elm);
+ return elm.get(0).scrollHeight - elm.innerHeight() - elm.scrollTop();
+}
+
+window.zoomToAndShowPortal = function(guid, latlng) {
+ map.setView(latlng, 17);
+ // if the data is available, render it immediately. Otherwise defer
+ // until it becomes available.
+ if(window.portals[guid])
+ renderPortalDetails(guid);
+ else
+ urlPortal = guid;
+}
+
+// translates guids to entity types
+window.getTypeByGuid = function(guid) {
+ // portals end in “.11” or “.12“, links in “.9", fields in “.b”
+ // .11 == portals
+ // .12 == portals
+ // .9 == links
+ // .b == fields
+ // .c == player/creator
+ // .d == chat messages
+ //
+ // others, not used in web:
+ // .5 == resources (burster/resonator)
+ // .6 == XM
+ // .4 == media items, maybe all droppped resources (?)
+ // resonator guid is [portal guid]-resonator-[slot]
+ switch(guid.slice(33)) {
+ case '11':
+ case '12':
+ return TYPE_PORTAL;
+
+ case '9':
+ return TYPE_LINK;
+
+ case 'b':
+ return TYPE_FIELD;
+
+ case 'c':
+ return TYPE_PLAYER;
+
+ case 'd':
+ return TYPE_CHAT;
+
+ default:
+ if(guid.slice(-11,-2) == 'resonator') return TYPE_RESONATOR;
+ return TYPE_UNKNOWN;
+ }
+}
+
+String.prototype.capitalize = function() {
+ return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase();
+}
+
+// http://stackoverflow.com/a/646643/1684530 by Bergi and CMS
+if (typeof String.prototype.startsWith !== 'function') {
+ String.prototype.startsWith = function (str){
+ return this.slice(0, str.length) === str;
+ };
+}
+
+window.prettyEnergy = function(nrg) {
+ return nrg> 1000 ? Math.round(nrg/1000) + ' k': nrg;
+}
+
+window.setPermaLink = function(elm) {
+ var c = map.getCenter();
+ var lat = Math.round(c.lat*1E6);
+ var lng = Math.round(c.lng*1E6);
+ var qry = 'latE6='+lat+'&lngE6='+lng+'&z=' + map.getZoom();
+ $(elm).attr('href', 'http://www.ingress.com/intel?' + qry);
+}
+
+window.uniqueArray = function(arr) {
+ return $.grep(arr, function(v, i) {
+ return $.inArray(v, arr) === i;
+ });
+}
+
+
+
+
+// SETUP /////////////////////////////////////////////////////////////
+// these functions set up specific areas after the boot function
+// created a basic framework. All of these functions should only ever
+// be run once.
+
+window.setupLargeImagePreview = function() {
+ $('#portaldetails').on('click', '.imgpreview img', function() {
+ var ex = $('#largepreview');
+ if(ex.length > 0) {
+ ex.remove();
+ return;
+ }
+ var img = $(this).parent().html();
+ var w = $(this)[0].naturalWidth/2;
+ var h = $(this)[0].naturalHeight/2;
+ var c = $('#portaldetails').attr('class');
+ $('body').append(
+ '