From 3a45b560f5e2afc731ae31c9303926ba218db015 Mon Sep 17 00:00:00 2001 From: Stefan Breunig Date: Mon, 11 Feb 2013 17:39:43 +0100 Subject: [PATCH] release 0.5 --- dist/autolink.js | 33 + dist/leaflet_google.js | 152 ++ dist/style.css | 604 ++++++ dist/total-conversion-build.user.js | 2722 +++++++++++++++++++++++++++ 4 files changed, 3511 insertions(+) create mode 100644 dist/autolink.js create mode 100644 dist/leaflet_google.js create mode 100644 dist/style.css create mode 100644 dist/total-conversion-build.user.js diff --git a/dist/autolink.js b/dist/autolink.js new file mode 100644 index 00000000..013025a7 --- /dev/null +++ b/dist/autolink.js @@ -0,0 +1,33 @@ +// Generated by CoffeeScript 1.4.0 +(function() { + var autoLink, + __slice = [].slice; + + autoLink = function() { + var callbackThunk, key, link_attributes, option, options, url_pattern, value; + options = 1 <= arguments.length ? __slice.call(arguments, 0) : []; + link_attributes = ''; + option = options[0]; + url_pattern = /(^|\s)(\b(https?|ftp):\/\/[\-A-Z0-9+\u0026@#\/%?=~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~_|])/gi; + if (!(options.length > 0)) { + return this.replace(url_pattern, "$1$2"); + } + if ((option['callback'] != null) && typeof option['callback'] === 'function') { + callbackThunk = option['callback']; + delete option['callback']; + } + for (key in option) { + value = option[key]; + link_attributes += " " + key + "='" + value + "'"; + } + return this.replace(url_pattern, function(match, space, url) { + var link, returnCallback; + returnCallback = callbackThunk && callbackThunk(url); + link = returnCallback || ("" + url + ""); + return "" + space + link; + }); + }; + + String.prototype['autoLink'] = autoLink; + +}).call(this); diff --git a/dist/leaflet_google.js b/dist/leaflet_google.js new file mode 100644 index 00000000..37d6dd8c --- /dev/null +++ b/dist/leaflet_google.js @@ -0,0 +1,152 @@ +/* + * L.TileLayer is used for standard xyz-numbered tile layers. + */ +L.Google = L.Class.extend({ + includes: L.Mixin.Events, + + options: { + minZoom: 0, + maxZoom: 18, + tileSize: 256, + subdomains: 'abc', + errorTileUrl: '', + attribution: '', + opacity: 1, + continuousWorld: false, + noWrap: false, + }, + + // Possible types: SATELLITE, ROADMAP, HYBRID, INGRESS + initialize: function(type, options, styles) { + L.Util.setOptions(this, options); + if(type === 'INGRESS') { + type = 'ROADMAP'; + this._styles = [{featureType:"all", elementType:"all", stylers:[{visibility:"on"}, {hue:"#0091ff"}, {invert_lightness:true}]}, {featureType:"water", elementType:"all", stylers:[{visibility:"on"}, {hue:"#005eff"}, {invert_lightness:true}]}, {featureType:"poi", stylers:[{visibility:"off"}]}, {featureType:"transit", elementType:"all", stylers:[{visibility:"off"}]}]; + } else { + this._styles = null; + } + this._type = google.maps.MapTypeId[type || 'SATELLITE']; + }, + + onAdd: function(map, insertAtTheBottom) { + this._map = map; + this._insertAtTheBottom = insertAtTheBottom; + + // create a container div for tiles + this._initContainer(); + this._initMapObject(); + + // set up events + map.on('viewreset', this._resetCallback, this); + + this._limitedUpdate = L.Util.limitExecByInterval(this._update, 150, this); + map.on('move', this._update, this); + //map.on('moveend', this._update, this); + + this._reset(); + this._update(); + }, + + onRemove: function(map) { + this._map._container.removeChild(this._container); + //this._container = null; + + this._map.off('viewreset', this._resetCallback, this); + + this._map.off('move', this._update, this); + //this._map.off('moveend', this._update, this); + }, + + getAttribution: function() { + return this.options.attribution; + }, + + setOpacity: function(opacity) { + this.options.opacity = opacity; + if (opacity < 1) { + L.DomUtil.setOpacity(this._container, opacity); + } + }, + + _initContainer: function() { + var tilePane = this._map._container + first = tilePane.firstChild; + + if (!this._container) { + this._container = L.DomUtil.create('div', 'leaflet-google-layer leaflet-top leaflet-left'); + this._container.id = "_GMapContainer"; + } + + if (true) { + tilePane.insertBefore(this._container, first); + + this.setOpacity(this.options.opacity); + var size = this._map.getSize(); + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + } + }, + + _initMapObject: function() { + this._google_center = new google.maps.LatLng(0, 0); + var map = new google.maps.Map(this._container, { + center: this._google_center, + zoom: 0, + styles: this._styles, + mapTypeId: this._type, + disableDefaultUI: true, + keyboardShortcuts: false, + draggable: false, + disableDoubleClickZoom: true, + scrollwheel: false, + streetViewControl: false + }); + + var _this = this; + this._reposition = google.maps.event.addListenerOnce(map, "center_changed", + function() { _this.onReposition(); }); + + map.backgroundColor = '#ff0000'; + this._google = map; + }, + + _resetCallback: function(e) { + this._reset(e.hard); + }, + + _reset: function(clearOldContainer) { + this._initContainer(); + }, + + _update: function() { + this._resize(); + + var bounds = this._map.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + var google_bounds = new google.maps.LatLngBounds( + new google.maps.LatLng(sw.lat, sw.lng), + new google.maps.LatLng(ne.lat, ne.lng) + ); + var center = this._map.getCenter(); + var _center = new google.maps.LatLng(center.lat, center.lng); + + this._google.setCenter(_center); + this._google.setZoom(this._map.getZoom()); + //this._google.fitBounds(google_bounds); + }, + + _resize: function() { + var size = this._map.getSize(); + if (this._container.style.width == size.x && + this._container.style.height == size.y) + return; + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + google.maps.event.trigger(this._google, "resize"); + }, + + onReposition: function() { + //google.maps.event.trigger(this._google, "resize"); + } +}); diff --git a/dist/style.css b/dist/style.css new file mode 100644 index 00000000..81f8dcc0 --- /dev/null +++ b/dist/style.css @@ -0,0 +1,604 @@ +/* general rules ******************************************************/ + +html, body, #map { + height: 100%; +} + +body { + font-size: 14px; + font-family: "coda",arial,helvetica,sans-serif; + margin: 0; +} + +#scrollwrapper { + overflow: hidden; + position: fixed; + right: -38px; + top: 0; + width: 340px; + bottom: 45px; +} + +#sidebar { + background-color: rgba(8, 48, 78, 0.9); + border-left: 1px solid #20A8B1; + color: #888; + position: relative; + left: 0; + top: 0; + max-height: 100%; + overflow-y:scroll; + overflow-x:hidden; + z-index: 3000; +} + +#sidebartoggle { + display: block; + padding: 20px 5px; + margin-top: -31px; + line-height: 10px; + position: absolute; + top: 50%; + z-index: 3001; + background-color: rgba(8, 48, 78, 0.9); + color: #FFCE00; + border: 1px solid #20A8B1; + border-right: none; + border-radius: 5px 0 0 5px; + text-decoration: none; +} + +.enl { + color: #03fe03 !important; +} + +.res { + color: #00c5ff !important; +} + +.none { + color: #fff; +} + +a { + color: #ffce00; + cursor: pointer; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* map display, required because GMaps uses a high z-index which is + * normally above Leaflet’s vector pane */ +.leaflet-map-pane { + z-index: 1000; +} + +.leaflet-control-layers-overlays label.disabled { + text-decoration: line-through; + cursor: help; +} + +.help { + cursor: help; +} + +.toggle { + display: block; + height: 0; + width: 0; +} + + +/* chat ***************************************************************/ + +#chatcontrols { + color: #FFCE00; + background: rgba(8, 48, 78, 0.9); + position: absolute; + left: 0; + z-index: 3001; + height: 26px; + padding-left:1px; +} + +#chatcontrols.expand { + top: 0; + bottom: auto; +} + +#chatcontrols a { + margin-left: -1px; + display: inline-block; + width: 94px; + text-align: center; + height: 24px; + line-height: 24px; + border: 1px solid #20A8B1; + vertical-align: top; +} + +#chatcontrols a:first-child { + letter-spacing:-1px; + text-decoration: none !important; +} + +#chatcontrols a.active { + border-color: #FFCE00; + border-bottom-width:0px; + font-weight:bold +} + +#chatcontrols a.active + a { + border-left-color: #FFCE00 +} + + +#chatcontrols .toggle { + border-left: 10px solid transparent; + border-right: 10px solid transparent; + margin: 6px auto auto; +} + +#chatcontrols .expand { + border-bottom: 10px solid #FFCE00; +} + +#chatcontrols .shrink { + border-top: 10px solid #FFCE00; +} + + +#chat { + position: absolute; + width: 708px; + bottom: 24px; + left: 0; + z-index: 3000; + background: rgba(8, 48, 78, 0.9); + font-size: 12.6px; + color: #eee; + border: 1px solid #20A8B1; + border-bottom: 0; +} + +em { + color: red; + font-style: normal; +} + +#chat.expand { + height:auto; + top: 25px; +} + +#chatpublic, #chatautomated { + display: none; +} + +#chat > div { + overflow-x:hidden; + overflow-y:scroll; + height: 100%; /* fallback for Opera which doesn’t support calc */ + height: calc(100% - 4px); + height: -webkit-calc(100% - 4px); + height: -moz-calc(100% - 4px); + padding: 2px; + position:relative; +} + +#chat p { + display: block; + padding: 1px 2px; + margin:0; +} + +#chat time { + cursor: help; +} + +time, mark, #chat span, #chat a { + font-family: Verdana, sans-serif; + font-size: 12.6px; + vertical-align: top; +} + +time { + display: inline-block; + width: 44px; + color: #bbb; +} + +mark { + display: inline-block; + width: 91px; + margin-right:4px; + overflow:hidden; + vertical-align: top; + background: transparent; +} + +summary { + color: #bbb; + display: inline-block; + font-family: Verdana,sans-serif; + height: 16px; + overflow: hidden; + padding: 0 2px; + white-space: nowrap; + width: 683px; +} + +#chat span { + display: inline-block; + width: 540px; +} + +#chatinput { + line-height:22px; + padding: 0 4px; + position: absolute; + bottom: 0; + left: 0; + background: rgba(8, 48, 78, 0.9); + width: 700px; + border: 1px solid #20A8B1; + z-index: 3001; +} + +#chat .invisibleseparator { + color: rgba(8, 48, 78, 0.0); + overflow: hidden; + width: 0px; +} + + +#chatinput span { + font-family: Verdana,sans-serif; + display: inline-block; + font-size: 12.6px; + width: 84px; + color: red; + padding: 0 4px 0 1px; + width: 85px; +} + +#chatinput input { + background: transparent; + font-size: 12.6px; + font-family: Verdana,sans-serif; + color: #EEEEEE; + width: 558px +} + + + +/* sidebar ************************************************************/ + +#sidebar > * { + border-bottom: 1px solid #20A8B1; +} + + + +#sidebartoggle .toggle { + border-bottom: 10px solid transparent; + border-top: 10px solid transparent; +} + +#sidebartoggle .open { + border-right: 10px solid #FFCE00; +} + +#sidebartoggle .close { + border-left: 10px solid #FFCE00; +} + +/* player stats */ +#playerstat { + height: 30px; +} + +h2 { + color: #ffce00; + font-size: 21px; + padding: 0 4px; + margin: 0; + cursor:help; +} + +h2 span { + display: inline-block; + overflow: hidden; + text-overflow: "~"; + vertical-align: top; + white-space: nowrap; + width: 205px; +} + +h2 div { + float: right; + height: 100%; + overflow: hidden; +} + +h2 sup, h2 sub { + display: block; + font-size: 11px; + margin-bottom: -1px; +} + + +/* gamestats */ +#gamestat, #gamestat span { + height: 22px; + line-height: 22px; +} + +#gamestat span { + display: block; + float: left; + font-weight: bold; + cursor:help; +} + +#gamestat .res { + background: #005684; + text-align: right; +} + +#gamestat .enl { + background: #017f01; +} + + +/* geosearch input, and others */ +input { + background-color: rgba(0, 0, 0, 0.3); + color: #ffce00; + height: 22px; + line-height: 22px; + padding: 0 4px; + font-size: 14px; + border:0; + font-family:inherit; +} + +::-webkit-input-placeholder { + font-style: italic; +} + +:-moz-placeholder { + font-style: italic; +} + +::-moz-placeholder { + font-style: italic; +} + + +/* portal title and image */ +h3 { + font-size: 17px; + padding: 0 4px; + margin:0; + height: 25px; + width: 100%; + overflow:hidden; + text-overflow: "~"; + white-space: nowrap; +} + +.imgpreview { + height: 200px; + overflow: hidden; +} + +.imgpreview img { + cursor: help; +} + +#level { + font-size: 40px; + position: absolute; + right: 10px; + text-shadow: -1px -1px #000, 1px -1px #000, -1px 1px #000, 1px 1px #000, 0 0 5px #fff; + top: 100px; +} + +/* portal mods */ +.mods { + margin-bottom: 1px; + margin-top: 5px; + height: 75px; +} + +.mods span { + background-color: rgba(0, 0, 0, 0.3); + /* can’t use inline-block because Webkit’s implementation is buggy and + * introduces additional margins in random cases. No clear necessary, + * as that’s solved by setting height on .mods. */ + display: block; + float:left; + height: 63.7px; + margin-left: 4px; + overflow: hidden; + padding: 2px; + text-align: center; + width: 63.7px; + cursor:help; + border: 1px solid #666; +} + +.mods span[title=""] { + cursor: auto; +} + +.res .mods span, .res .meter { + border: 1px solid #0076b6; +} +.enl .mods span, .enl .meter { + border: 1px solid #017f01; +} + +/* random details list */ +#randdetails { + margin: 0 4px; + margin-top: 11px; +} + +aside { + display: inline-block; + width: 140px; +} + +aside span { + overflow: hidden; + text-overflow: "~"; + white-space: nowrap; + width: 74px; +} + +aside:nth-child(odd) { + margin-right: 4px; + text-align: right; +} + +aside:nth-child(even) { + margin-left: 4px; + text-align: left; +} + +aside:nth-child(even) span { + float: right; + padding-left: 4px; + text-align: left; +} + +aside:nth-child(odd) span { + float: left; + padding-right: 4px; + text-align: right; +} + +#randdetails tt { + font-family: inherit; + cursor: help; +} + + +/* resonators */ +#resodetails { + white-space: nowrap; + margin: 16px 0; + -moz-column-gap: 10px; + -moz-column-width: 141px; + -webkit-column-gap: 10px; + -webkit-column-width: 141px; +} + +.meter { + background: #000; + cursor: help; + display: inline-block; + height: 14px; + padding: 1px; + width: 58px; +} + +.meter-text { + display: inline-block; + height: 18px; + margin: 0 4px; + overflow: hidden; + text-overflow: "~"; + vertical-align: top; + white-space: nowrap; + width: 75px; +} + +.meter-text.left { + text-align: right; +} + +.meter span { + display: block; + height: 14px; +} + +.meter-rel { + position: relative; + left: 0; + top: 0; +} + +.meter-level { + position: absolute; + top: -2px; + left: 25px; + text-shadow: 0.0em 0.0em 0.3em #808080; +} +/* links below resos */ + +.linkdetails { + text-align: center; + margin-bottom: 10px; +} +.linkdetails aside { + margin: 0 4px; + width: 140px; +} + +#toolbox { + padding: 4px; + font-size:90%; +} + +#toolbox > a { + padding: 5px; +} + +#spacer { + height: 10px; +} + +/* a common portal display takes this much space (prevents moving + * content when first selecting a portal) */ + +#portaldetails { + min-height: 553px; +} + + +/* update status */ +#updatestatus { + background-color: rgba(8, 48, 78, 1); + border-bottom: 0; + border-top: 1px solid #20A8B1; + border-left: 1px solid #20A8B1; + bottom: 0; + color: #ffce00; + font-size:13px; + padding: 4px; + position: fixed; + right: 0; + z-index:3002; +} + + +/* preview */ + +#largepreview { + left: 50%; + position: fixed; + top: 50%; + z-index: 2000; +} +#largepreview img { + box-shadow: 0 0 40px #000; +} +#largepreview img { + border: 2px solid #f8ff5e; +} diff --git a/dist/total-conversion-build.user.js b/dist/total-conversion-build.user.js new file mode 100644 index 00000000..0c2d8ac8 --- /dev/null +++ b/dist/total-conversion-build.user.js @@ -0,0 +1,2722 @@ +// ==UserScript== +// @id ingress-intel-total-conversion@breunigs +// @name intel map total conversion +// @version 0.5-2013-02-11-173638 +// @namespace https://github.com/breunigs/ingress-intel-total-conversion +// @updateURL https://raw.github.com/breunigs/ingress-intel-total-conversion/gh-pages/dist/total-conversion-build.user.js +// @downloadURL https://raw.github.com/breunigs/ingress-intel-total-conversion/gh-pages/dist/total-conversion-build.user.js +// @description total conversion for the ingress intel map. +// @include http://www.ingress.com/intel* +// @match http://www.ingress.com/intel* +// ==/UserScript== + + +// REPLACE ORIG SITE /////////////////////////////////////////////////// +if(document.getElementsByTagName('html')[0].getAttribute('itemscope') != null) + throw('Ingress Intel Website is down, not a userscript issue.'); + +// disable vanilla JS +window.onload = function() {}; + +// rescue user data from original page +var scr = document.getElementsByTagName('script'); +for(var x in scr) { + var s = scr[x]; + if(s.src) continue; + if(s.type !== 'text/javascript') continue; + var d = s.innerHTML.split('\n'); + break; +} + + +if(!d) { + // page doesn’t have a script tag with player information. + if(document.getElementById('header_email')) { + // however, we are logged in. + setTimeout('location.reload();', 10*1000); + throw('Page doesn’t have player data, but you are logged in. Reloading in 10s.'); + } + // FIXME: handle nia takedown in progress + throw('Couldn’t retrieve player data. Are you logged in?'); +} + + +for(var i = 0; i < d.length; i++) { + if(!d[i].match('var PLAYER = ')) continue; + eval(d[i].match(/^var /, 'window.')); + break; +} +// player information is now available in a hash like this: +// window.PLAYER = {"ap": "123", "energy": 123, "available_invites": 123, "nickname": "somenick", "team": "ALIENS||RESISTANCE"}; + +// remove complete page. We only wanted the user-data and the page’s +// security context so we can access the API easily. Setup as much as +// possible without requiring scripts. +document.getElementsByTagName('head')[0].innerHTML = '' + //~ + '' + + 'Ingress Intel Map' + + '' + + '' + + ''; + +document.getElementsByTagName('body')[0].innerHTML = '' + + '
Loading, please wait
' + + '' + + '' + + '' + + '' + + '
' // enable scrolling for small screens + + ' ' + + '
' + + '
'; + +// 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( + '
' + img + '
' + ); + $('#largepreview').click(function() { $(this).remove() }); + $('#largepreview img').attr('title', ''); + }); +} + + +window.setupStyles = function() { + $('head').append(''); +} + +window.setupMap = function() { + $('#map').text(''); + + var osmOpt = {attribution: 'Map data © OpenStreetMap contributors', maxZoom: 18}; + var osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', osmOpt); + + var cmOpt = {attribution: 'Map data © OpenStreetMap contributors, Imagery © CloudMade', maxZoom: 18}; + var cmMin = new L.TileLayer('http://{s}.tile.cloudmade.com/654cef5fd49a432ab81267e200ecc502/22677/256/{z}/{x}/{y}.png', cmOpt); + var cmMid = new L.TileLayer('http://{s}.tile.cloudmade.com/654cef5fd49a432ab81267e200ecc502/999/256/{z}/{x}/{y}.png', cmOpt); + + var views = [cmMid, cmMin, osm, new L.Google('INGRESS'), new L.Google('ROADMAP'), + new L.Google('SATELLITE'), new L.Google('HYBRID')]; + + + window.map = new L.Map('map', $.extend(getPosition(), + {zoomControl: !(localStorage['iitc.zoom.buttons'] === 'false')} + )); + + try { + map.addLayer(views[readCookie('ingress.intelmap.type')]); + } catch(e) { map.addLayer(views[0]); } + + var addLayers = {}; + + portalsLayers = []; + for(var i = 0; i <= 8; i++) { + portalsLayers[i] = L.layerGroup([]); + map.addLayer(portalsLayers[i]); + var t = (i === 0 ? 'Unclaimed' : 'Level ' + i) + ' Portals'; + addLayers[t] = portalsLayers[i]; + } + + fieldsLayer = L.layerGroup([]); + map.addLayer(fieldsLayer, true); + addLayers['Fields'] = fieldsLayer; + + linksLayer = L.layerGroup([]); + map.addLayer(linksLayer, true); + addLayers['Links'] = linksLayer; + + map.addControl(new L.Control.Layers({ + 'OSM Cloudmade Midnight': views[0], + 'OSM Cloudmade Minimal': views[1], + 'OSM Mapnik': views[2], + 'Google Roads Ingress Style': views[3], + 'Google Roads': views[4], + 'Google Satellite': views[5], + 'Google Hybrid': views[6] + }, addLayers)); + map.attributionControl.setPrefix(''); + // listen for changes and store them in cookies + map.on('moveend', window.storeMapPosition); + map.on('zoomend', function() { + window.storeMapPosition; + + // remove all resonators if zoom out to < RESONATOR_DISPLAY_ZOOM_LEVEL + if(isResonatorsShow()) return; + for(var i = 1; i < portalsLayers.length; i++) { + portalsLayers[i].eachLayer(function(item) { + var itemGuid = item.options.guid; + // check if 'item' is a resonator + if(getTypeByGuid(itemGuid) != TYPE_RESONATOR) return true; + portalsLayers[i].removeLayer(item); + }); + } + + console.log('Remove all resonators'); + }); + $("[name='leaflet-base-layers']").change(function () { + writeCookie('ingress.intelmap.type', $(this).parent().index()); + }); + + // map update status handling + map.on('movestart zoomstart', function() { window.mapRunsUserAction = true }); + map.on('moveend zoomend', function() { window.mapRunsUserAction = false }); + + // update map hooks + map.on('movestart zoomstart', window.requests.abort); + map.on('moveend zoomend', function() { window.startRefreshTimeout(500) }); + + // run once on init + window.requestData(); + window.startRefreshTimeout(); + + window.addResumeFunction(window.requestData); + window.requests.addRefreshFunction(window.requestData); +}; + +// renders player details into the website. Since the player info is +// included as inline script in the original site, the data is static +// and cannot be updated. +window.setupPlayerStat = function() { + var level; + var ap = parseInt(PLAYER.ap); + for(level = 0; level < MIN_AP_FOR_LEVEL.length; level++) { + if(ap < MIN_AP_FOR_LEVEL[level]) break; + } + + var thisLvlAp = MIN_AP_FOR_LEVEL[level-1]; + var nextLvlAp = MIN_AP_FOR_LEVEL[level] || ap; + var lvlUpAp = digits(nextLvlAp-ap); + var lvlApProg = Math.round((ap-thisLvlAp)/(nextLvlAp-thisLvlAp)*100); + + + var xmMax = MAX_XM_PER_LEVEL[level]; + var xmRatio = Math.round(PLAYER.energy/xmMax*100); + + var cls = PLAYER.team === 'ALIENS' ? 'enl' : 'res'; + + + var t = 'Level:\t\t' + level + '\n' + + 'XM:\t\t\t' + PLAYER.energy + ' / ' + xmMax + '\n' + + 'AP:\t\t\t' + digits(ap) + '\n' + + (level < 8 ? 'level up in:\t' + lvlUpAp + ' AP' : 'Congrats! (neeeeerd)') + + '\n\Invites:\t\t'+PLAYER.available_invites; + + '\n\nNote: your player stats can only be updated by a full reload (F5)'; + + $('#playerstat').html('' + + '

'+level+' ' + + ''+PLAYER.nickname+'' + + '
' + + 'XM: '+xmRatio+'%' + + '' + (level < 8 ? 'level: '+lvlApProg+'%' : 'max level') + '' + + '
' + + '

' + ); +} + +window.setupSidebarToggle = function() { + $('#sidebartoggle').on('click', function() { + var toggle = $('#sidebartoggle'); + var sidebar = $('#sidebar'); + if(sidebar.is(':visible')) { + sidebar.hide(); + $('.leaflet-right').css('margin-right','0'); + toggle.html(''); + toggle.css('right', '0'); + } else { + sidebar.show(); + $('.leaflet-right').css('margin-right', SIDEBAR_WIDTH+1+'px'); + toggle.html(''); + toggle.css('right', SIDEBAR_WIDTH+1+'px'); + } + }); +} + + +// BOOTING /////////////////////////////////////////////////////////// + +function boot() { + console.log('loading done, booting'); + window.setupStyles(); + window.setupMap(); + window.setupGeosearch(); + window.setupRedeem(); + window.setupLargeImagePreview(); + window.setupSidebarToggle(); + window.updateGameScore(); + window.setupPlayerStat(); + window.chat.setup(); + // read here ONCE, so the URL is only evaluated one time after the + // necessary data has been loaded. + urlPortal = getURLParam('pguid'); + + // load only once + var n = window.PLAYER['nickname']; + window.PLAYER['nickMatcher'] = new RegExp('\\b('+n+')\\b', 'ig'); + + $('#sidebar').show(); + + if(window.bootPlugins) + $.each(window.bootPlugins, function(ind, ref) { ref(); }); + + window.iitcLoaded = true; +} + +// this is the minified load.js script that allows us to easily load +// further javascript files async as well as in order. +// https://github.com/chriso/load.js +// Copyright (c) 2010 Chris O'Hara . MIT Licensed +function asyncLoadScript(a){return function(b,c){var d=document.createElement("script");d.type="text/javascript",d.src=a,d.onload=b,d.onerror=c,d.onreadystatechange=function(){var a=this.readyState;if(a==="loaded"||a==="complete")d.onreadystatechange=null,b()},head.insertBefore(d,head.firstChild)}}(function(a){a=a||{};var b={},c,d;c=function(a,d,e){var f=a.halt=!1;a.error=function(a){throw a},a.next=function(c){c&&(f=!1);if(!a.halt&&d&&d.length){var e=d.shift(),g=e.shift();f=!0;try{b[g].apply(a,[e,e.length,g])}catch(h){a.error(h)}}return a};for(var g in b){if(typeof a[g]=="function")continue;(function(e){a[e]=function(){var g=Array.prototype.slice.call(arguments);if(e==="onError"){if(d)return b.onError.apply(a,[g,g.length]),a;var h={};return b.onError.apply(h,[g,g.length]),c(h,null,"onError")}return g.unshift(e),d?(a.then=a[e],d.push(g),f?a:a.next()):c({},[g],e)}})(g)}return e&&(a.then=a[e]),a.call=function(b,c){c.unshift(b),d.unshift(c),a.next(!0)},a.next()},d=a.addMethod=function(d){var e=Array.prototype.slice.call(arguments),f=e.pop();for(var g=0,h=e.length;g= 15) + r.shift(); +} + +window.chat.handleTabCompletion = function() { + var el = $('#chatinput input'); + var curPos = el.get(0).selectionStart; + var text = el.val(); + var word = text.slice(0, curPos).replace(/.*\b([a-z0-9-_])/, '$1').toLowerCase(); + + var list = window.chat._lastNicksForAutocomplete; + list = list[1].concat(list[0]); + + var nick = null; + for(var i = 0; i < list.length; i++) { + if(!list[i].toLowerCase().startsWith(word)) continue; + if(nick && nick !== list[i]) { + console.log('More than one nick matches, aborting. ('+list[i]+' vs '+nick+')'); + return; + } + nick = list[i]; + } + if(!nick) { + console.log('No matches for ' + word); + return; + } + + var posStart = curPos - word.length; + var newText = text.substring(0, posStart); + newText += nick + (posStart === 0 ? ': ' : ' '); + newText += text.substring(curPos); + el.val(newText); +} + +// +// timestamp and clear management +// + +window.chat._oldFactionTimestamp = -1; +window.chat._newFactionTimestamp = -1; +window.chat._oldPublicTimestamp = -1; +window.chat._newPublicTimestamp = -1; + +window.chat.getOldestTimestamp = function(public) { + return chat['_old'+(public ? 'Public' : 'Faction')+'Timestamp']; +} + +window.chat.getNewestTimestamp = function(public) { + return chat['_new'+(public ? 'Public' : 'Faction')+'Timestamp']; +} + +window.chat.clearIfRequired = function(elm) { + if(!elm.data('needsClearing')) return; + elm.data('ignoreNextScroll', true).data('needsClearing', false).html(''); +} + +window.chat._oldBBox = null; +window.chat.genPostData = function(public, getOlderMsgs) { + if(typeof public !== 'boolean') throw('Need to know if public or faction chat.'); + + chat._localRangeCircle.setLatLng(map.getCenter()); + var b = map.getBounds().extend(chat._localRangeCircle.getBounds()); + var ne = b.getNorthEast(); + var sw = b.getSouthWest(); + + // round bounds in order to ignore rounding errors + var bbs = $.map([ne.lat, ne.lng, sw.lat, sw.lng], function(x) { return Math.round(x*1E4) }).join(); + if(chat._oldBBox && chat._oldBBox !== bbs) { + $('#chat > div').data('needsClearing', true); + console.log('Bounding Box changed, chat will be cleared (old: '+chat._oldBBox+' ; new: '+bbs+' )'); + // need to reset these flags now because clearing will only occur + // after the request is finished – i.e. there would be one almost + // useless request. + chat._displayedFactionGuids = []; + chat._displayedPublicGuids = []; + chat._displayedPlayerActionTime = {}; + chat._oldFactionTimestamp = -1; + chat._newFactionTimestamp = -1; + chat._oldPublicTimestamp = -1; + chat._newPublicTimestamp = -1; + } + chat._oldBBox = bbs; + + var ne = b.getNorthEast(); + var sw = b.getSouthWest(); + var data = { + desiredNumItems: public ? CHAT_PUBLIC_ITEMS : CHAT_FACTION_ITEMS, + minLatE6: Math.round(sw.lat*1E6), + minLngE6: Math.round(sw.lng*1E6), + maxLatE6: Math.round(ne.lat*1E6), + maxLngE6: Math.round(ne.lng*1E6), + minTimestampMs: -1, + maxTimestampMs: -1, + factionOnly: !public + } + + if(getOlderMsgs) { + // ask for older chat when scrolling up + data = $.extend(data, {maxTimestampMs: chat.getOldestTimestamp(public)}); + } else { + // ask for newer chat + var min = chat.getNewestTimestamp(public); + // the inital request will have both timestamp values set to -1, + // thus we receive the newest desiredNumItems. After that, we will + // only receive messages with a timestamp greater or equal to min + // above. + // After resuming from idle, there might be more new messages than + // desiredNumItems. So on the first request, we are not really up to + // date. We will eventually catch up, as long as there are less new + // messages than desiredNumItems per each refresh cycle. + // A proper solution would be to query until no more new results are + // returned. Another way would be to set desiredNumItems to a very + // large number so we really get all new messages since the last + // request. Setting desiredNumItems to -1 does unfortunately not + // work. + // Currently this edge case is not handled. Let’s see if this is a + // problem in crowded areas. + $.extend(data, {minTimestampMs: min}); + } + return data; +} + + + +// +// requesting faction +// + +window.chat._requestOldFactionRunning = false; +window.chat.requestOldFaction = function(isRetry) { + if(chat._requestOldFactionRunning) return; + if(isIdle()) return renderUpdateStatus(); + chat._requestOldFactionRunning = true; + + var d = chat.genPostData(false, true); + var r = window.postAjax( + 'getPaginatedPlextsV2', + d, + chat.handleOldFaction, + isRetry + ? function() { window.chat._requestOldFactionRunning = false; } + : function() { window.chat.requestOldFaction(true) } + ); + + requests.add(r); +} + +window.chat._requestNewFactionRunning = false; +window.chat.requestNewFaction = function(isRetry) { + if(chat._requestNewFactionRunning) return; + if(window.isIdle()) return renderUpdateStatus(); + chat._requestNewFactionRunning = true; + + var d = chat.genPostData(false, false); + var r = window.postAjax( + 'getPaginatedPlextsV2', + d, + chat.handleNewFaction, + isRetry + ? function() { window.chat._requestNewFactionRunning = false; } + : function() { window.chat.requestNewFaction(true) } + ); + + requests.add(r); +} + + +// +// handle faction +// + +window.chat.handleOldFaction = function(data, textStatus, jqXHR) { + chat._requestOldFactionRunning = false; + chat.handleFaction(data, textStatus, jqXHR, true); +} + +window.chat.handleNewFaction = function(data, textStatus, jqXHR) { + chat._requestNewFactionRunning = false; + chat.handleFaction(data, textStatus, jqXHR, false); +} + + + +window.chat._displayedFactionGuids = []; +window.chat.handleFaction = function(data, textStatus, jqXHR, isOldMsgs) { + if(!data || !data.result) { + window.failedRequestCount++; + return console.warn('faction chat error. Waiting for next auto-refresh.'); + } + + var c = $('#chatfaction'); + chat.clearIfRequired(c); + + if(data.result.length === 0) return; + + chat._newFactionTimestamp = data.result[0][1]; + chat._oldFactionTimestamp = data.result[data.result.length-1][1]; + + var scrollBefore = scrollBottom(c); + chat.renderPlayerMsgsTo(true, data, isOldMsgs, chat._displayedFactionGuids); + chat.keepScrollPosition(c, scrollBefore, isOldMsgs); + + if(data.result.length >= CHAT_FACTION_ITEMS) chat.needMoreMessages(); +} + + + + +// +// requesting public +// + +window.chat._requestOldPublicRunning = false; +window.chat.requestOldPublic = function(isRetry) { + if(chat._requestOldPublicRunning) return; + if(isIdle()) return renderUpdateStatus(); + chat._requestOldPublicRunning = true; + + var d = chat.genPostData(true, true); + var r = window.postAjax( + 'getPaginatedPlextsV2', + d, + chat.handleOldPublic, + isRetry + ? function() { window.chat._requestOldPublicRunning = false; } + : function() { window.chat.requestOldPublic(true) } + ); + + requests.add(r); +} + +window.chat._requestNewPublicRunning = false; +window.chat.requestNewPublic = function(isRetry) { + if(chat._requestNewPublicRunning) return; + if(window.isIdle()) return renderUpdateStatus(); + chat._requestNewPublicRunning = true; + + var d = chat.genPostData(true, false); + var r = window.postAjax( + 'getPaginatedPlextsV2', + d, + chat.handleNewPublic, + isRetry + ? function() { window.chat._requestNewPublicRunning = false; } + : function() { window.chat.requestNewPublic(true) } + ); + + requests.add(r); +} + + +// +// handle public +// + + +window.chat.handleOldPublic = function(data, textStatus, jqXHR) { + chat._requestOldPublicRunning = false; + chat.handlePublic(data, textStatus, jqXHR, true); +} + +window.chat.handleNewPublic = function(data, textStatus, jqXHR) { + chat._requestNewPublicRunning = false; + chat.handlePublic(data, textStatus, jqXHR, false); +} + +window.chat._displayedPublicGuids = []; +window.chat._displayedPlayerActionTime = {}; +window.chat.handlePublic = function(data, textStatus, jqXHR, isOldMsgs) { + if(!data || !data.result) { + window.failedRequestCount++; + return console.warn('public chat error. Waiting for next auto-refresh.'); + } + + var ca = $('#chatautomated'); + var cp = $('#chatpublic'); + chat.clearIfRequired(ca); + chat.clearIfRequired(cp); + + if(data.result.length === 0) return; + + chat._newPublicTimestamp = data.result[0][1]; + chat._oldPublicTimestamp = data.result[data.result.length-1][1]; + + + var scrollBefore = scrollBottom(ca); + chat.handlePublicAutomated(data); + chat.keepScrollPosition(ca, scrollBefore, isOldMsgs); + + + var scrollBefore = scrollBottom(cp); + chat.renderPlayerMsgsTo(false, data, isOldMsgs, chat._displayedPublicGuids); + chat.keepScrollPosition(cp, scrollBefore, isOldMsgs); + + if(data.result.length >= CHAT_PUBLIC_ITEMS) chat.needMoreMessages(); +} + + +window.chat.handlePublicAutomated = function(data) { + $.each(data.result, function(ind, json) { // newest first! + var time = json[1]; + + // ignore player messages + var t = json[2].plext.plextType; + if(t !== 'SYSTEM_BROADCAST' && t !== 'SYSTEM_NARROWCAST') return true; + + var tmpmsg = '', nick = null, pguid, team; + + // each automated message is composed of many text chunks. loop + // over them to gather all necessary data. + $.each(json[2].plext.markup, function(ind, part) { + switch(part[0]) { + case 'PLAYER': + pguid = part[1].guid; + var lastAction = window.chat._displayedPlayerActionTime[pguid]; + // ignore older messages about player + if(lastAction && lastAction[0] > time) return false; + + nick = part[1].plain; + team = part[1].team === 'ALIENS' ? TEAM_ENL : TEAM_RES; + window.setPlayerName(pguid, nick); // free nick name resolves + if(ind > 0) tmpmsg += nick; // don’t repeat nick directly + break; + + case 'TEXT': + tmpmsg += part[1].plain; + break; + + case 'PORTAL': + var latlng = [part[1].latE6/1E6, part[1].lngE6/1E6]; + var js = 'window.zoomToAndShowPortal(\''+part[1].guid+'\', ['+latlng[0]+', '+latlng[1]+'])'; + tmpmsg += ''+part[1].name+''; + break; + } + }); + + // nick will only be set if we don’t have any info about that + // player yet. + if(nick) { + tmpmsg = chat.renderMsg(tmpmsg, nick, time, team); + window.chat._displayedPlayerActionTime[pguid] = [time, tmpmsg]; + }; + }); + + if(chat.getActive() === 'automated') + window.chat.renderAutomatedMsgsTo(); +} + +window.chat.renderAutomatedMsgsTo = function() { + var x = window.chat._displayedPlayerActionTime; + // we don’t care about the GUIDs anymore + var vals = $.map(x, function(v, k) { return [v]; }); + // sort them old to new + vals = vals.sort(function(a, b) { return a[0]-b[0]; }); + + var prevTime = null; + var msgs = $.map(vals, function(v) { + var nowTime = new Date(v[0]).toLocaleDateString(); + if(prevTime && prevTime !== nowTime) + var val = chat.renderDivider(nowTime) + v[1]; + else + var val = v[1]; + + prevTime = nowTime; + return val; + }).join('\n'); + + $('#chatautomated').html(msgs); +} + + + + +// +// common +// + + +window.chat.renderPlayerMsgsTo = function(isFaction, data, isOldMsgs, dupCheckArr) { + var msgs = ''; + var prevTime = null; + + $.each(data.result.reverse(), function(ind, json) { // oldest first! + if(json[2].plext.plextType !== 'PLAYER_GENERATED') return true; + + // avoid duplicates + if(dupCheckArr.indexOf(json[0]) !== -1) return true; + dupCheckArr.push(json[0]); + + var time = json[1]; + var team = json[2].plext.team === 'ALIENS' ? TEAM_ENL : TEAM_RES; + var msg, nick, pguid; + $.each(json[2].plext.markup, function(ind, markup) { + if(markup[0] === 'SENDER') { + nick = markup[1].plain.slice(0, -2); // cut “: ” at end + pguid = markup[1].guid; + window.setPlayerName(pguid, nick); // free nick name resolves + if(!isOldMsgs) window.chat.addNickForAutocomplete(nick, isFaction); + } + + if(markup[0] === 'TEXT') { + msg = markup[1].plain.autoLink(); + msg = msg.replace(window.PLAYER['nickMatcher'], '$1'); + } + + if(!isFaction && markup[0] === 'SECURE') { + nick = null; + return false; // aka break + } + }); + + if(!nick) return true; // aka next + + var nowTime = new Date(time).toLocaleDateString(); + if(prevTime && prevTime !== nowTime) + msgs += chat.renderDivider(nowTime); + + msgs += chat.renderMsg(msg, nick, time, team); + prevTime = nowTime; + }); + + var addTo = isFaction ? $('#chatfaction') : $('#chatpublic'); + + // if there is a change of day between two requests, handle the + // divider insertion here. + if(isOldMsgs) { + var ts = addTo.find('time:first').data('timestamp'); + var nextTime = new Date(ts).toLocaleDateString(); + if(prevTime && prevTime !== nextTime && ts) + msgs += chat.renderDivider(nextTime); + } + + if(isOldMsgs) + addTo.prepend(msgs); + else + addTo.append(msgs); +} + + +window.chat.renderDivider = function(text) { + return '─ '+text+' ────────────────────────────────────────────────────────────────────────────'; +} + + +window.chat.renderMsg = function(msg, nick, time, team) { + var ta = unixTimeToHHmm(time); + var tb = unixTimeToString(time, true); + // help cursor via “#chat time” + var t = ''; + var s = 'style="color:'+COLORS[team]+'"'; + var title = nick.length >= 8 ? 'title="'+nick+'" class="help"' : ''; + return '

'+t+' <'+nick+'> '+msg+'

'; +} + + + +window.chat.getActive = function() { + return $('#chatcontrols .active').text(); +} + + +window.chat.toggle = function() { + var c = $('#chat, #chatcontrols'); + if(c.hasClass('expand')) { + $('#chatcontrols a:first').html(''); + c.removeClass('expand'); + var div = $('#chat > div:visible'); + div.data('ignoreNextScroll', true); + div.scrollTop(99999999); // scroll to bottom + $('.leaflet-control').css('margin-left', '13px'); + } else { + $('#chatcontrols a:first').html(''); + c.addClass('expand'); + $('.leaflet-control').css('margin-left', '720px'); + chat.needMoreMessages(); + } +} + + +window.chat.request = function() { + console.log('refreshing chat'); + chat.requestNewFaction(); + chat.requestNewPublic(); +} + + +// checks if there are enough messages in the selected chat tab and +// loads more if not. +window.chat.needMoreMessages = function() { + var activeChat = $('#chat > :visible'); + if(scrollBottom(activeChat) !== 0 || activeChat.scrollTop() !== 0) return; + console.log('no scrollbar in active chat, requesting more msgs'); + if($('#chatcontrols a:last.active').length) + chat.requestOldFaction(); + else + chat.requestOldPublic(); +} + + +window.chat.chooser = function(event) { + var t = $(event.target); + var tt = t.text(); + var span = $('#chatinput span'); + + $('#chatcontrols .active').removeClass('active'); + t.addClass('active'); + + $('#chat > div').hide(); + + var elm; + + switch(tt) { + case 'faction': + span.css('color', ''); + span.text('tell faction:'); + elm = $('#chatfaction'); + break; + + case 'public': + span.css('cssText', 'color: red !important'); + span.text('broadcast:'); + elm = $('#chatpublic'); + break; + + case 'automated': + span.css('cssText', 'color: #bbb !important'); + span.text('tell Jarvis:'); + chat.renderAutomatedMsgsTo(); + elm = $('#chatautomated'); + break; + } + + elm.show(); + if(elm.data('needsScrollTop')) { + elm.data('ignoreNextScroll', true); + elm.scrollTop(elm.data('needsScrollTop')); + elm.data('needsScrollTop', null); + } + + chat.needMoreMessages(); +} + + +// contains the logic to keep the correct scroll position. +window.chat.keepScrollPosition = function(box, scrollBefore, isOldMsgs) { + // If scrolled down completely, keep it that way so new messages can + // be seen easily. If scrolled up, only need to fix scroll position + // when old messages are added. New messages added at the bottom don’t + // change the view and enabling this would make the chat scroll down + // for every added message, even if the user wants to read old stuff. + + if(box.is(':hidden') && !isOldMsgs) { + box.data('needsScrollTop', 99999999); + return; + } + + if(scrollBefore === 0 || isOldMsgs) { + box.data('ignoreNextScroll', true); + box.scrollTop(box.scrollTop() + (scrollBottom(box)-scrollBefore)); + } +} + + + + +// +// setup +// + +window.chat.setup = function() { + window.chat._localRangeCircle = L.circle(map.getCenter(), CHAT_MIN_RANGE*1000); + + $('#chatcontrols, #chat, #chatinput').show(); + + $('#chatcontrols a:first').click(window.chat.toggle); + $('#chatcontrols a:not(:first)').click(window.chat.chooser); + + + $('#chatinput').click(function() { + $('#chatinput input').focus(); + }); + + window.chat.setupTime(); + window.chat.setupPosting(); + + $('#chatfaction').scroll(function() { + var t = $(this); + if(t.data('ignoreNextScroll')) return t.data('ignoreNextScroll', false); + if(t.scrollTop() < 200) chat.requestOldFaction(); + if(scrollBottom(t) === 0) chat.requestNewFaction(); + }); + + $('#chatpublic, #chatautomated').scroll(function() { + var t = $(this); + if(t.data('ignoreNextScroll')) return t.data('ignoreNextScroll', false); + if(t.scrollTop() < 200) chat.requestOldPublic(); + if(scrollBottom(t) === 0) chat.requestNewPublic(); + }); + + chat.request(); + window.addResumeFunction(chat.request); + window.requests.addRefreshFunction(chat.request); + + var cls = PLAYER.team === 'ALIENS' ? 'enl' : 'res'; + $('#chatinput span').addClass(cls) +} + + +window.chat.setupTime = function() { + var inputTime = $('#chatinput time'); + var updateTime = function() { + if(window.isIdle()) return; + var d = new Date(); + var h = d.getHours() + ''; if(h.length === 1) h = '0' + h; + var m = d.getMinutes() + ''; if(m.length === 1) m = '0' + m; + inputTime.text(h+':'+m); + // update ON the minute (1ms after) + setTimeout(updateTime, (60 - d.getSeconds()) * 1000 + 1); + }; + updateTime(); + window.addResumeFunction(updateTime); +} + + +// +// posting +// + + +window.chat.setupPosting = function() { + $('#chatinput input').keydown(function(event) { +try{ + + var kc = (event.keyCode ? event.keyCode : event.which); + if(kc === 13) { // enter + chat.postMsg(); + event.preventDefault(); + } else if (kc === 9) { // tab + event.preventDefault(); + window.chat.handleTabCompletion(); + } + + +} catch(error) { + console.log(error); + debug.printStackTrace(); +} + }); + + $('#chatinput').submit(function(event) { + chat.postMsg(); + event.preventDefault(); + }); +} + + +window.chat.postMsg = function() { + var c = chat.getActive(); + if(c === 'automated') return alert('Jarvis: A strange game. The only winning move is not to play. How about a nice game of chess?'); + + var msg = $.trim($('#chatinput input').val()); + if(!msg || msg === '') return; + + var public = c === 'public'; + var latlng = map.getCenter(); + + var data = {message: msg, + latE6: Math.round(latlng.lat*1E6), + lngE6: Math.round(latlng.lng*1E6), + factionOnly: !public}; + + window.postAjax('sendPlext', data, + function() { if(public) chat.requestNewPublic(); else chat.requestNewFaction(); }, + function() { + alert('Your message could not be delivered. You can copy&' + + 'paste it here and try again if you want:\n\n'+msg); + } + ); + + $('#chatinput input').val(''); +} + + + +// PORTAL DETAILS DISPLAY //////////////////////////////////////////// +// hand any of these functions the details-hash of a portal, and they +// will return pretty, displayable HTML or parts thereof. + +// returns displayable text+link about portal range +window.getRangeText = function(d) { + var range = getPortalRange(d); + return ['range', + + '' + + (range > 1000 + ? Math.round(range/1000) + ' km' + : Math.round(range) + ' m') + + '']; +} + +// generates description text from details for portal +window.getPortalDescriptionFromDetails = function(details) { + var descObj = details.portalV2.descriptiveText; + // FIXME: also get real description? + var desc = descObj.TITLE + '\n' + descObj.ADDRESS; + if(descObj.ATTRIBUTION) + desc += '\nby '+descObj.ATTRIBUTION+' ('+descObj.ATTRIBUTION_LINK+')'; + return desc; +} + + +// given portal details, returns html code to display mod details. +window.getModDetails = function(d) { + var mods = []; + var modsTitle = []; + var modsColor = []; + $.each(d.portalV2.linkedModArray, function(ind, mod) { + if(!mod) { + mods.push(''); + modsTitle.push(''); + modsColor.push('#000'); + } else if(mod.type === 'RES_SHIELD') { + + var title = mod.rarity.capitalize() + ' ' + mod.displayName + '\n'; + title += 'Installed by: '+ getPlayerName(mod.installingUser); + + title += '\nStats:'; + for (var key in mod.stats) { + if (!mod.stats.hasOwnProperty(key)) continue; + title += '\n+' + mod.stats[key] + ' ' + key.capitalize(); + } + + mods.push(mod.rarity.capitalize().replace('_', ' ') + ' ' + mod.displayName); + modsTitle.push(title); + modsColor.push(COLORS_MOD[mod.rarity]); + } else { + mods.push(mod.type); + modsTitle.push('Unknown mod. No further details available.'); + modsColor.push('#FFF'); + } + }); + + var t = ''+mods[0]+'' + + ''+mods[1]+'' + + ''+mods[2]+'' + + ''+mods[3]+'' + + return t; +} + +window.getEnergyText = function(d) { + var currentNrg = getCurrentPortalEnergy(d); + var totalNrg = getTotalPortalEnergy(d); + var inf = currentNrg + ' / ' + totalNrg; + var fill = prettyEnergy(currentNrg) + ' / ' + prettyEnergy(totalNrg) + return ['energy', '' + fill + '']; +} + +window.getAvgResoDistText = function(d) { + var avgDist = Math.round(10*getAvgResoDist(d))/10; + return ['⌀ res dist', avgDist + ' m']; +} + +window.getResonatorDetails = function(d) { + var resoDetails = ''; + // octant=slot: 0=E, 1=NE, 2=N, 3=NW, 4=W, 5=SW, 6=S, SE=7 + // resos in the display should be ordered like this: + // N NE Since the view is displayed in columns, they + // NW E need to be ordered like this: N, NW, W, SW, NE, + // W SE E, SE, S, i.e. 2 3 4 5 1 0 7 6 + // SW S + $.each([2, 3, 4, 5, 1, 0, 7, 6], function(ind, slot) { + var isLeft = slot >= 2 && slot <= 5; + var reso = d.resonatorArray.resonators[slot]; + if(!reso) { + resoDetails += renderResonatorDetails(slot, 0, 0, null, null, isLeft); + return true; + } + + var l = parseInt(reso.level); + var v = parseInt(reso.energyTotal); + var nick = window.getPlayerName(reso.ownerGuid); + var dist = reso.distanceToPortal; + // if array order and slot order drift apart, at least the octant + // naming will still be correct. + slot = parseInt(reso.slot); + + resoDetails += renderResonatorDetails(slot, l, v, dist, nick, isLeft); + }); + return resoDetails; +} + +// helper function that renders the HTML for a given resonator. Does +// not work with raw details-hash. Needs digested infos instead: +// slot: which slot this resonator occupies. Starts with 0 (east) and +// rotates clockwise. So, last one is 7 (southeast). +window.renderResonatorDetails = function(slot, level, nrg, dist, nick, isLeft) { + if(level === 0) { + var meter = ''; + } else { + var max = RESO_NRG[level]; + var fillGrade = nrg/max*100; + + var inf = 'energy:\t\t' + nrg + ' / ' + max + ' (' + Math.round(fillGrade) + '%)\n' + + 'level:\t\t' + level + '\n' + + 'distance:\t' + dist + 'm\n' + + 'owner:\t\t' + nick + '\n' + + 'octant:\t' + OCTANTS[slot]; + + var style = 'width:'+fillGrade+'%; background:'+COLORS_LVL[level]+';'; + + var color = (level < 3 ? "#9900FF" : "#FFFFFF"); + + var lbar = ' ' + level + ' '; + + var fill = ''; + + var meter = '' + fill + lbar + ''; + } + var cls = isLeft ? 'left' : 'right'; + var text = ''+(nick||'')+''; + return (isLeft ? text+meter : meter+text) + '
'; +} + +// calculate AP gain from destroying portal +// so far it counts only resonators + links +window.getDestroyAP = function(d) { + var resoCount = 0; + + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + resoCount += 1; + }); + + var linkCount = d.portalV2.linkedEdges ? d.portalV2.linkedEdges.length : 0; + var fieldCount = d.portalV2.linkedFields ? d.portalV2.linkedFields.length : 0; + + var resoAp = resoCount * DESTROY_RESONATOR; + var linkAp = linkCount * DESTROY_LINK; + var fieldAp = fieldCount * DESTROY_FIELD; + var sum = resoAp + linkAp + fieldAp; + + function tt(text) { + var t = 'Destroy:\n'; + t += resoCount + '×\tResonators\t= ' + digits(resoAp) + '\n'; + t += linkCount + '×\tLinks\t\t= ' + digits(linkAp) + '\n'; + t += fieldCount + '×\tFields\t\t= ' + digits(fieldAp) + '\n'; + t += 'Sum: ' + digits(sum) + ' AP'; + return '' + digits(text) + ''; + } + + return [tt('AP Gain'), tt(sum)]; +} + + + +// GAME STATUS /////////////////////////////////////////////////////// +// MindUnit display +window.updateGameScore = function(data) { + if(!data) { + window.postAjax('getGameScore', {}, window.updateGameScore); + return; + } + + var r = parseInt(data.result.resistanceScore), e = parseInt(data.result.alienScore); + var s = r+e; + var rp = r/s*100, ep = e/s*100; + r = digits(r), e = digits(e); + var rs = ''+Math.round(rp)+'% '; + var es = ' '+Math.round(ep)+'%'; + $('#gamestat').html(rs+es).one('click', function() { window.updateGameScore() }); + // help cursor via “#gamestat span” + $('#gamestat').attr('title', 'Resistance:\t\t'+r+' MindUnits\nEnlightenment:\t'+e+' MindUnits'); + + window.setTimeout('window.updateGameScore', REFRESH_GAME_SCORE*1000); +} + + + + +// MAP DATA REQUEST CALCULATORS ////////////////////////////////////// +// Ingress Intel splits up requests for map data (portals, links, +// fields) into tiles. To get data for the current viewport (i.e. what +// is currently visible) it first calculates which tiles intersect. +// For all those tiles, it then calculates the lat/lng bounds of that +// tile and a quadkey. Both the bounds and the quadkey are “somewhat” +// required to get complete data. No idea how the projection between +// lat/lng and tiles works. +// What follows now are functions that allow conversion between tiles +// and lat/lng as well as calculating the quad key. The variable names +// may be misleading. +// The minified source for this code was in gen_dashboard.js after the +// “// input 89” line (alternatively: the class was called “Xe”). + +window.convertCenterLat = function(centerLat) { + return Math.round(256 * 0.9999 * Math.abs(1 / Math.cos(centerLat * DEG2RAD))); +} + +window.calculateR = function(convCenterLat) { + return 1 << window.map.getZoom() - (convCenterLat / 256 - 1); +} + +window.convertLatLngToPoint = function(latlng, magic, R) { + var x = (magic/2 + latlng.lng * magic / 360)*R; + var l = Math.sin(latlng.lat * DEG2RAD); + var y = (magic/2 + 0.5*Math.log((1+l)/(1-l)) * -(magic / (2*Math.PI)))*R; + return {x: Math.floor(x/magic), y: Math.floor(y/magic)}; +} + +window.convertPointToLatLng = function(x, y, magic, R) { + var e = {}; + e.sw = { + // orig function put together from all over the place + // lat: (2 * Math.atan(Math.exp((((y + 1) * magic / R) - (magic/ 2)) / (-1*(magic / (2 * Math.PI))))) - Math.PI / 2) / (Math.PI / 180), + // shortened version by your favorite algebra program. + lat: (360*Math.atan(Math.exp(Math.PI - 2*Math.PI*(y+1)/R)))/Math.PI - 90, + lng: 360*x/R-180 + }; + e.ne = { + //lat: (2 * Math.atan(Math.exp(((y * magic / R) - (magic/ 2)) / (-1*(magic / (2 * Math.PI))))) - Math.PI / 2) / (Math.PI / 180), + lat: (360*Math.atan(Math.exp(Math.PI - 2*Math.PI*y/R)))/Math.PI - 90, + lng: 360*(x+1)/R-180 + }; + return e; +} + +// calculates the quad key for a given point. The point is not(!) in +// lat/lng format. +window.pointToQuadKey = function(x, y) { + var quadkey = []; + for(var c = window.map.getZoom(); c > 0; c--) { + // +-------+ quadrants are probably ordered like this + // | 0 | 1 | + // |---|---| + // | 2 | 3 | + // |---|---| + var quadrant = 0; + var e = 1 << c - 1; + (x & e) != 0 && quadrant++; // push right + (y & e) != 0 && (quadrant++, quadrant++); // push down + quadkey.push(quadrant); + } + return quadkey.join(""); +} + +// given quadkey and bounds, returns the format as required by the +// Ingress API to request map data. +window.generateBoundsParams = function(quadkey, bounds) { + return { + id: quadkey, + qk: quadkey, + minLatE6: Math.round(bounds.sw.lat * 1E6), + minLngE6: Math.round(bounds.sw.lng * 1E6), + maxLatE6: Math.round(bounds.ne.lat * 1E6), + maxLngE6: Math.round(bounds.ne.lng * 1E6) + }; +} + + + + +// ENTITY DETAILS TOOLS ////////////////////////////////////////////// +// hand any of these functions the details-hash of an entity (i.e. +// portal, link, field) and they will return useful data. + + +// given the entity detail data, returns the team the entity belongs +// to. Uses TEAM_* enum values. +window.getTeam = function(details) { + var team = TEAM_NONE; + if(details.controllingTeam.team === 'ALIENS') team = TEAM_ENL; + if(details.controllingTeam.team === 'RESISTANCE') team = TEAM_RES; + return team; +} + + +// IDLE HANDLING ///////////////////////////////////////////////////// + +window.idleTime = 0; // in minutes + +setInterval('window.idleTime += 1', 60*1000); +var idleReset = function () { + // update immediately when the user comes back + if(isIdle()) { + window.idleTime = 0; + $.each(window._onResumeFunctions, function(ind, f) { + f(); + }); + } + window.idleTime = 0; +}; +$('body').mousemove(idleReset).keypress(idleReset); + +window.isIdle = function() { + return window.idleTime >= MAX_IDLE_TIME; +} + +window._onResumeFunctions = []; + +// add your function here if you want to be notified when the user +// resumes from being idle +window.addResumeFunction = function(f) { + window._onResumeFunctions.push(f); +} + + + +// LOCATION HANDLING ///////////////////////////////////////////////// +// i.e. setting initial position and storing new position after moving + +// retrieves current position from map and stores it cookies +window.storeMapPosition = function() { + var m = window.map.getCenter(); + writeCookie('ingress.intelmap.lat', m['lat']); + writeCookie('ingress.intelmap.lng', m['lng']); + writeCookie('ingress.intelmap.zoom', window.map.getZoom()); +} + +// either retrieves the last shown position from a cookie, from the +// URL or if neither is present, via Geolocation. If that fails, it +// returns a map that shows the whole world. +window.getPosition = function() { + if(getURLParam('latE6') && getURLParam('lngE6')) { + console.log("mappos: reading URL params"); + var lat = parseInt(getURLParam('latE6'))/1E6 || 0.0; + var lng = parseInt(getURLParam('lngE6'))/1E6 || 0.0; + // google seems to zoom in far more than leaflet + var z = parseInt(getURLParam('z'))+1 || 17; + return {center: new L.LatLng(lat, lng), zoom: z > 18 ? 18 : z}; + } + + if(readCookie('ingress.intelmap.lat') && readCookie('ingress.intelmap.lng')) { + console.log("mappos: reading cookies"); + var lat = parseFloat(readCookie('ingress.intelmap.lat')) || 0.0; + var lng = parseFloat(readCookie('ingress.intelmap.lng')) || 0.0; + var z = parseInt(readCookie('ingress.intelmap.zoom')) || 17; + return {center: new L.LatLng(lat, lng), zoom: z > 18 ? 18 : z}; + } + + setTimeout("window.map.locate({setView : true, maxZoom: 13});", 50); + + return {center: new L.LatLng(0.0, 0.0), zoom: 1}; +} + + + +// PORTAL DETAILS MAIN /////////////////////////////////////////////// +// main code block that renders the portal details in the sidebar and +// methods that highlight the portal in the map view. + +window.renderPortalDetails = function(guid) { + var d = window.portals[guid].options.details; + if(!d) { + unselectOldPortal(); + urlPortal = guid; + return; + } + + var update = selectPortal(guid); + + // collect some random data that’s not worth to put in an own method + var links = {incoming: 0, outgoing: 0}; + if(d.portalV2.linkedEdges) $.each(d.portalV2.linkedEdges, function(ind, link) { + links[link.isOrigin ? 'outgoing' : 'incoming']++; + }); + function linkExpl(t) { return ''+t+''; } + var linksText = [linkExpl('links'), linkExpl(' ↳ ' + links.incoming+'  •  '+links.outgoing+' ↴')]; + + var player = d.captured && d.captured.capturingPlayerId + ? getPlayerName(d.captured.capturingPlayerId) + : null; + var playerText = player ? ['owner', player] : null; + + var time = d.captured ? unixTimeToString(d.captured.capturedTime) : null; + var sinceText = time ? ['since', time] : null; + + var linkedFields = ['fields', d.portalV2.linkedFields.length]; + + // collect and html-ify random data + var randDetails = [playerText, sinceText, getRangeText(d), getEnergyText(d), linksText, getAvgResoDistText(d), linkedFields, getDestroyAP(d)]; + randDetails = randDetails.map(function(detail) { + if(!detail) return ''; + detail = ''; + return detail; + }).join('\n'); + + // replacing causes flicker, so if the selected portal does not + // change, only update the data points that are likely to change. + if(update) { + console.log('Updating portal details'); + $('#level').text(Math.floor(getPortalLevel(d))); + $('.mods').html(getModDetails(d)); + $('#randdetails').html(randDetails); + $('#resodetails').html(getResonatorDetails(d)); + $('#portaldetails').attr('class', TEAM_TO_CSS[getTeam(d)]); + } else { + console.log('exchanging portal details'); + setPortalIndicators(d); + var img = d.imageByUrl && d.imageByUrl.imageUrl ? d.imageByUrl.imageUrl : DEFAULT_PORTAL_IMG; + + var lat = d.locationE6.latE6; + var lng = d.locationE6.lngE6; + var perma = 'http://ingress.com/intel?latE6='+lat+'&lngE6='+lng+'&z=17&pguid='+guid; + + $('#portaldetails') + .attr('class', TEAM_TO_CSS[getTeam(d)]) + .html('' + + '

'+d.portalV2.descriptiveText.TITLE+'

' + // help cursor via “.imgpreview img” + + '
' + + ''+Math.floor(getPortalLevel(d))+'' + + '
'+getModDetails(d)+'
' + + '
'+randDetails+'
' + + '
'+getResonatorDetails(d)+'
' + + '
' + + '' + + '' + + '
' + ); + } + + // try to resolve names that were required for above functions, but + // weren’t available yet. + resolvePlayerNames(); +} + +// draws link-range and hack-range circles around the portal with the +// given details. +window.setPortalIndicators = function(d) { + if(portalRangeIndicator) map.removeLayer(portalRangeIndicator); + var range = getPortalRange(d); + var coord = [d.locationE6.latE6/1E6, d.locationE6.lngE6/1E6]; + portalRangeIndicator = (range > 0 + ? L.circle(coord, range, { fill: false, color: RANGE_INDICATOR_COLOR, weight: 3, clickable: false }) + : L.circle(coord, range, { fill: false, stroke: false, clickable: false }) + ).addTo(map); + if(!portalAccessIndicator) + portalAccessIndicator = L.circle(coord, HACK_RANGE, + { fill: false, color: ACCESS_INDICATOR_COLOR, weight: 2, clickable: false } + ).addTo(map); + else + portalAccessIndicator.setLatLng(coord); + +} + +// highlights portal with given GUID. Automatically clears highlights +// on old selection. Returns false if the selected portal changed. +// Returns true if it’s still the same portal that just needs an +// update. +window.selectPortal = function(guid) { + var update = selectedPortal === guid; + var oldPortal = portals[selectedPortal]; + if(!update && oldPortal) portalResetColor(oldPortal); + + selectedPortal = guid; + + if(portals[guid]) + portals[guid].bringToFront().setStyle({color: COLOR_SELECTED_PORTAL}); + + return update; +} + + +window.unselectOldPortal = function() { + var oldPortal = portals[selectedPortal]; + if(oldPortal) + oldPortal.setStyle({color: oldPortal.options.fillColor}); + selectedPortal = null; + $('#portaldetails').html(''); +} + + + + +// REDEEMING ///////////////////////////////////////////////////////// + +window.handleRedeemResponse = function(data, textStatus, jqXHR) { + if (data.error) { + var error = ''; + if (data.error === 'ALREADY_REDEEMED') { + error = 'The passcode has already been redeemed.'; + } else if (data.error === 'ALREADY_REDEEMED_BY_PLAYER') { + error = 'You have already redeemed this passcode.'; + } else if (data.error === 'INVALID_PASSCODE') { + error = 'This passcode is invalid.'; + } else { + error = 'The passcode cannot be redeemed.'; + } + alert("Error: " + data.error + "\n" + error); + } else if (data.result) { + var res_level = 0, res_count = 0; + var xmp_level = 0, xmp_count = 0; + var shield_rarity = '', shield_count = 0; + + // This assumes that each passcode gives only one type of resonator/XMP/shield. + // This may break at some point, depending on changes to passcode functionality. + for (var i in data.result.inventoryAward) { + var acquired = data.result.inventoryAward[i][2]; + if (acquired.modResource) { + if (acquired.modResource.resourceType === 'RES_SHIELD') { + shield_rarity = acquired.modResource.rarity.split('_').map(function (i) {return i[0]}).join(''); + shield_count++; + } + } else if (acquired.resourceWithLevels) { + if (acquired.resourceWithLevels.resourceType === 'EMITTER_A') { + res_level = acquired.resourceWithLevels.level; + res_count++; + } else if (acquired.resourceWithLevels.resourceType === 'EMP_BURSTER') { + xmp_level = acquired.resourceWithLevels.level; + xmp_count++; + } + } + } + + alert("Passcode redeemed!\n" + [data.result.apAward + 'AP', data.result.xmAward + 'XM', res_count + 'xL' + res_level + ' RES', xmp_count + 'xL' + xmp_level + ' XMP', shield_count + 'x' + shield_rarity + ' SHIELD'].join('/')); + } +} + +window.setupRedeem = function() { + $("#redeem").keypress(function(e) { + if((e.keyCode ? e.keyCode : e.which) != 13) return; + var data = {passcode: $(this).val()}; + window.postAjax('redeemReward', data, window.handleRedeemResponse, + function() { alert('HTTP request failed. Try again?'); }); + }); +} + + +// PLAYER NAMES ////////////////////////////////////////////////////// +// Player names are cached in local storage forever. There is no GUI +// element from within the total conversion to clean them, but you +// can run localStorage.clean() to reset it. + + +// retrieves player name by GUID. If the name is not yet available, it +// will be added to a global list of GUIDs that need to be resolved. +// The resolve method is not called automatically. +window.getPlayerName = function(guid) { + if(localStorage[guid]) return localStorage[guid]; + // only add to queue if it isn’t already + if(playersToResolve.indexOf(guid) === -1 && playersInResolving.indexOf(guid) === -1) { + console.log('resolving player guid=' + guid); + playersToResolve.push(guid); + } + return '{'+guid.slice(0, 12)+'}'; +} + +// resolves all player GUIDs that have been added to the list. Reruns +// renderPortalDetails when finished, so that then-unresolved names +// get replaced by their correct versions. +window.resolvePlayerNames = function() { + if(window.playersToResolve.length === 0) return; + var p = window.playersToResolve; + var d = {guids: p}; + playersInResolving = window.playersInResolving.concat(p); + playersToResolve = []; + postAjax('getPlayersByGuids', d, function(dat) { + $.each(dat.result, function(ind, player) { + window.setPlayerName(player.guid, player.nickname); + // remove from array + window.playersInResolving.splice(window.playersInResolving.indexOf(player.guid), 1); + }); + if(window.selectedPortal) + window.renderPortalDetails(window.selectedPortal); + }, + function() { + // append failed resolves to the list again + console.warn('resolving player guids failed: ' + p.join(', ')); + window.playersToResolve.concat(p); + }); +} + + +window.setPlayerName = function(guid, nick) { + localStorage[guid] = nick; +} + + +window.loadPlayerNamesForPortal = function(portal_details) { + if(map.getZoom() < PRECACHE_PLAYER_NAMES_ZOOM) return; + var e = portal_details; + + if(e.captured && e.captured.capturingPlayerId) + getPlayerName(e.captured.capturingPlayerId); + + if(!e.resonatorArray || !e.resonatorArray.resonators) return; + + $.each(e.resonatorArray.resonators, function(ind, reso) { + if(reso) getPlayerName(reso.ownerGuid); + }); +} + + + +// DEBUGGING TOOLS /////////////////////////////////////////////////// +// meant to be used from browser debugger tools and the like. + +window.debug = function() {} + +window.debug.renderDetails = function() { + console.log('portals: ' + Object.keys(portals).length); + console.log('links: ' + Object.keys(links).length); + console.log('fields: ' + Object.keys(fields).length); +} + +window.debug.printStackTrace = function() { + var e = new Error('dummy'); + console.log(e.stack); +} + +window.debug.clearPortals = function() { + for(var i = 0; i < portalsLayers.length; i++) + portalsLayers[i].clearLayers(); +} + +window.debug.clearLinks = function() { + linksLayer.clearLayers(); +} + +window.debug.clearFields = function() { + fieldsLayer.clearLayers(); +} + +window.debug.getFields = function() { + return fields; +} + +window.debug.forceSync = function() { + localStorage.clear(); + window.playersToResolve = []; + window.playersInResolving = []; + debug.clearFields(); + debug.clearLinks(); + debug.clearPortals(); + updateGameScore(); + requestData(); +} + + + +// GEOSEARCH ///////////////////////////////////////////////////////// + +window.setupGeosearch = function() { + $('#geosearch').keypress(function(e) { + if((e.keyCode ? e.keyCode : e.which) != 13) return; + $.getJSON(NOMINATIM + encodeURIComponent($(this).val()), function(data) { + if(!data || !data[0]) return; + var b = data[0].boundingbox; + if(!b) return; + var southWest = new L.LatLng(b[0], b[2]), + northEast = new L.LatLng(b[1], b[3]), + bounds = new L.LatLngBounds(southWest, northEast); + window.map.fitBounds(bounds); + }); + e.preventDefault(); + }); +} + + + + +// PORTAL DETAILS TOOLS ////////////////////////////////////////////// +// hand any of these functions the details-hash of a portal, and they +// will return useful, but raw data. + +// returns a float. Displayed portal level is always rounded down from +// that value. +window.getPortalLevel = function(d) { + var lvl = 0; + var hasReso = false; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + lvl += parseInt(reso.level); + hasReso = true; + }); + return hasReso ? Math.max(1, lvl/8) : 0; +} + +window.getTotalPortalEnergy = function(d) { + var nrg = 0; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + var level = parseInt(reso.level); + var max = RESO_NRG[level]; + nrg += max; + }); + return nrg; +} + +// For backwards compatibility +window.getPortalEnergy = window.getTotalPortalEnergy; + +window.getCurrentPortalEnergy = function(d) { + var nrg = 0; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + nrg += parseInt(reso.energyTotal); + }); + return nrg; +} + +window.getPortalRange = function(d) { + // formula by the great gals and guys at + // http://decodeingress.me/2012/11/18/ingress-portal-levels-and-link-range/ + + var lvl = 0; + var resoMissing = false; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) { + resoMissing = true; + return false; + } + lvl += parseInt(reso.level); + }); + if(resoMissing) return 0; + return 160*Math.pow(getPortalLevel(d), 4); +} + +window.getAvgResoDist = function(d) { + var sum = 0, resos = 0; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + sum += parseInt(reso.distanceToPortal); + resos++; + }); + return sum/resos; +} + + + + + +} // end of wrapper + +// inject code into site context +var script = document.createElement('script'); +script.appendChild(document.createTextNode('('+ wrapper +')();')); +(document.body || document.head || document.documentElement).appendChild(script);