From a07ee0cc3db3e839e006c122b6a2d9553e9074fb Mon Sep 17 00:00:00 2001 From: Stefan Breunig Date: Fri, 1 Feb 2013 13:11:14 +0100 Subject: [PATCH] so others may contribute --- LICENSE | 15 + README.md | 51 ++ build.rb | 9 + code/boot.js | 169 ++++ code/debugging.js | 43 + code/entity_info.js | 15 + code/game_status.js | 20 + code/geosearch.js | 18 + code/idle.js | 18 + code/location.js | 37 + code/map_data.js | 263 ++++++ code/map_data_calc_tools.js | 79 ++ code/player_names.js | 43 + code/portal_detail_display.js | 108 +++ code/portal_detail_display_tools.js | 117 +++ code/portal_info.js | 41 + code/redeeming.js | 22 + code/request_handling.js | 49 + code/utils_misc.js | 91 ++ json_examples/portal_json.js | 162 ++++ leaflet_google.js | 152 ++++ main.js | 136 +++ style.css | 317 +++++++ total-conversion-build.user.js | 1303 +++++++++++++++++++++++++++ 24 files changed, 3278 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build.rb create mode 100644 code/boot.js create mode 100644 code/debugging.js create mode 100644 code/entity_info.js create mode 100644 code/game_status.js create mode 100644 code/geosearch.js create mode 100644 code/idle.js create mode 100644 code/location.js create mode 100644 code/map_data.js create mode 100644 code/map_data_calc_tools.js create mode 100644 code/player_names.js create mode 100644 code/portal_detail_display.js create mode 100644 code/portal_detail_display_tools.js create mode 100644 code/portal_info.js create mode 100644 code/redeeming.js create mode 100644 code/request_handling.js create mode 100644 code/utils_misc.js create mode 100644 json_examples/portal_json.js create mode 100644 leaflet_google.js create mode 100644 main.js create mode 100644 style.css create mode 100644 total-conversion-build.user.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d66cfaed --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +Copyright © 2013 Stefan Breunig + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all +copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA +OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..a64652a3 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +**NOTEL: this is a work in progress and not yet finished. ** + + +ingress.com/intel total conversion +================================== + +It’s annoying to extend the intel page with new features because the minified code makes it hard to grasp what’s going on. Also, one has to play catch up each time Ninantic put up a new version because all the variables might get new names. + +So instead, here’s a userscript that starts from scratch. + + +Install +------- + +Currently only works in Firefox with [Greasemonkey](https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/) or [Scriptish](https://addons.mozilla.org/en-US/firefox/addon/scriptish/). + +If one of these addons is installed, clicking this should work: + + +[**INSTALL**](https://raw.github.com/breunigs/ingress-intel-total-conversion/master/total-conversion-build.user.js) + + + +Contributing +------------ + +Please do! + +(Obviously, Resistance folks must send in complete patches while Enlightenment gals and guys may just open feature request ☺) + + +Hacking +------- + +Execute `./build.js` to effectively concatenate `main.js` with all the files in `code/`. It generates the user script which may be installed into your browser. + +`style.css` contains most styles required for the user-script. The extra ones can be found in `code/boot.js#window.setupStyles`. Only CSS rules that depend on config variables should be defined there. + +`leaflet_google.js` contains some code to display Google Maps imagery with Leaflet, which is a slightly modified version [of this gist](https://gist.github.com/4504864). I tried to track down the original author, but failed. + + +Attribution & License +--------------------- + +This project is licensed under the permissive ISC license. Parts imported from other projects remain under their respective licenses: + +- [load.js by Chris O'Hara; MIT](https://github.com/chriso/load.js) +- [leaflet.js; custom license (but appears free)](http://leafletjs.com/) +- `leaflet_google.js`; unknown +- StackOverflow-CopyPasta is attributed in the source; [CC-Wiki](https://creativecommons.org/licenses/by-sa/3.0/) +- all Ingress/Ninantic related stuff obviously remains non-free and is still copyrighted by Ninantic/Google diff --git a/build.rb b/build.rb new file mode 100755 index 00000000..81789aea --- /dev/null +++ b/build.rb @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +# encoding: utf-8 + +c = Dir.glob('code/*').map { |f| File.read(f) } +n = Time.now.strftime('%Y-%m-%d-%H%M%S') +m = File.read('main.js').gsub('@@BUILDDATE@@', n) +m = m.split('@@INJECTHERE@@') +t = m.insert(1, c).flatten.join("\n\n") +File.open('total-conversion-build.user.js', 'w') {|f| f.write(t) } diff --git a/code/boot.js b/code/boot.js new file mode 100644 index 00000000..166d142f --- /dev/null +++ b/code/boot.js @@ -0,0 +1,169 @@ + + +// 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() }); + }); +} + +window.setupStyles = function() { + $('head').append(''); +} + +window.setupMap = function() { + 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')]; + + portalsLayer = L.layerGroup([]); + linksLayer = L.layerGroup([]); + fieldsLayer = L.layerGroup([]); + window.map = new L.Map('map', $.extend(getPosition(), {zoomControl: false})); + try { + map.addLayer(views[readCookie('ingress.intelmap.type')]); + } catch(e) { map.addLayer(views[0]); } + map.addLayer(portalsLayer); + map.addLayer(fieldsLayer, true); + map.addLayer(linksLayer, true); + 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] + }, { + 'Portals': portalsLayer, + 'Links': linksLayer, + 'Fields': fieldsLayer + })); + map.attributionControl.setPrefix(''); + // listen for changes and store them in cookies + map.on('moveend', window.storeMapPosition); + map.on('zoomend', window.storeMapPosition); + $("[name='leaflet-base-layers']").change(function () { + writeCookie('ingress.intelmap.type', $(this).parent().index()); + }); + + // map update status handling + map.on('zoomstart', function() { window.mapRunsUserAction = true }); + map.on('movestart', function() { window.mapRunsUserAction = true }); + map.on('zoomend', function() { window.mapRunsUserAction = false }); + map.on('moveend', function() { window.mapRunsUserAction = false }); + + + // update map hooks + map.on('zoomstart', window.requests.abort); + map.on('zoomend', function() { window.startRefreshTimeout(500) }); + map.on('movestart', window.requests.abort ); + map.on('moveend', function() { window.startRefreshTimeout(500) }); + + // run once on init + window.requestData(); + window.startRefreshTimeout(); +}; + +// 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 < 7; 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') + '' + + '
' + + '

' + ); +} + + +// BOOTING /////////////////////////////////////////////////////////// + +function boot() { + console.log('loading done, booting'); + window.setupStyles(); + window.setupMap(); + window.setupGeosearch(); + window.setupRedeem(); + window.setupLargeImagePreview(); + window.updateGameScore(); + window.setupPlayerStat(); + // read here ONCE, so the URL is only evaluated one time after the + // necessary data has been loaded. + urlPortal = getURLParam('pguid'); + + $('#sidebar').show(); +} + +// 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'+Math.round(rp)+'% '; + var es = ' '+Math.round(ep)+'%'; + $('#gamestat').html(rs+es).one('click', function() { window.updateGameScore() }); + $('#gamestat').attr('title', 'Resistance:\t\t'+r+' MindUnits\nEnlightenment:\t'+e+' MindUnits'); + + window.setTimeout('window.updateGameScore', REFRESH_GAME_SCORE*1000); +} diff --git a/code/geosearch.js b/code/geosearch.js new file mode 100644 index 00000000..054f18a5 --- /dev/null +++ b/code/geosearch.js @@ -0,0 +1,18 @@ + +// GEOSEARCH ///////////////////////////////////////////////////////// + +window.setupGeosearch = function() { + $('#geosearch').keypress(function(e) { + if((e.keyCode ? e.keyCode : e.which) != 13) return; + $.getJSON(NOMINATIM + escape($(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(); + }); +} diff --git a/code/idle.js b/code/idle.js new file mode 100644 index 00000000..666098b7 --- /dev/null +++ b/code/idle.js @@ -0,0 +1,18 @@ +// IDLE HANDLING ///////////////////////////////////////////////////// + +window.idleTime = 0; // in minutes + +setInterval('window.idleTime += 1', 60*1000); +var idleReset = function (e) { + // update immediately when the user comes back + if(isIdle()) { + window.idleTime = 0; + window.requestData(); + } + window.idleTime = 0; +}; +$('body').mousemove(idleReset).keypress(idleReset); + +window.isIdle = function() { + return window.idleTime >= MAX_IDLE_TIME; +} diff --git a/code/location.js b/code/location.js new file mode 100644 index 00000000..7258ff83 --- /dev/null +++ b/code/location.js @@ -0,0 +1,37 @@ + +// 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}; +} diff --git a/code/map_data.js b/code/map_data.js new file mode 100644 index 00000000..53ebeff1 --- /dev/null +++ b/code/map_data.js @@ -0,0 +1,263 @@ + +// 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. + + +// 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) { + if(refreshTimeout) clearTimeout(refreshTimeout); + if(override) { + console.log('refreshing in ' + override + 'ms'); + refreshTimeout = setTimeout(window.requestData, override); + return; + } + var t = REFRESH*1000; + var adj = ZOOM_LEVEL_ADJ * (18 - window.map.getZoom()); + if(adj > 0) t += adj*1000; + console.log("next auto refresh in " + t/1000 + " seconds."); + refreshTimeout = setTimeout(window.requestData, t); +} + +// requests map data for current viewport. For details on how this +// works, refer to the description in “MAP DATA REQUEST CALCULATORS” +window.requestData = function() { + if(window.idleTime >= MAX_IDLE_TIME) { + console.log('user has been idle for ' + idleTime + ' minutes. Skipping refresh.'); + renderUpdateStatus(); + return; + } + + 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 + window.requests.remove(jqXHR); + if(!data || !data.result) { + console.warn(data); + return; + } + + var portalUpdateAvailable = false; + var m = data.result.map; + $.each(m, function(qk, val) { + $.each(val.deletedGameEntityGuids, function(ind, guid) { + 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; + + portalsDetail[ent[0]] = ent[2]; + // immediately render portal details if selected by URL + if(urlPortal && ent[0] == urlPortal && !selectedPortal) { + urlPortal = null; // only pre-select it once + window.renderPortalDetails(ent[0]); + } + renderPortal(ent); + } else if(ent[2].edge !== undefined) + renderLink(ent); + else if(ent[2].capturedRegion !== undefined) + renderField(ent); + else + throw('Unknown entity: ' + JSON.stringify(ent)); + }); + }); + + $.each(portals, function(ind, portal) { + // otherwise some portals will not be clickable. See + // https://github.com/Leaflet/Leaflet/issues/185 + portal.bringToFront(); + }); + + if(portals[selectedPortal]) portals[selectedPortal].bringToFront(); + + 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 = map.getBounds(); + portalsLayer.eachLayer(function(portal) { + if(b.contains(portal.getLatLng())) return; + cnt[0]++; + portalsLayer.removeLayer(portal); + }); + 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) { + // portals end in “.11” or “.12“, links in “.9", fields in “.b” + // .c == player/creator + switch(guid.slice(33)) { + case '11': + case '12': + if(!window.portals[guid]) return; + portalsLayer.removeLayer(window.portals[guid]); + break; + case '9': + if(!window.links[guid]) return; + linksLayer.removeLayer(window.links[guid]); + break; + case 'b': + if(!window.fields[guid]) return; + fieldsLayer.removeLayer(window.fields[guid]); + 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) { + removeByGuid(ent[0]); + var latlng = [ent[2].locationE6.latE6/1E6, ent[2].locationE6.lngE6/1E6]; + if(!map.getBounds().contains(latlng)) return; + + // pre-load player names for high zoom levels + if(map.getZoom() >= PRECACHE_PLAYER_NAMES_ZOOM) { + if(ent[2].captured && ent[2].captured.capturingPlayerId) + getPlayerName(ent[2].captured.capturingPlayerId); + if(ent[2].resonatorArray && ent[2].resonatorArray.resonators) + $.each(ent[2].resonatorArray.resonators, function(ind, reso) { + if(reso) getPlayerName(reso.ownerGuid); + }); + } + + var team = getTeam(ent[2]); + + var p = L.circleMarker(latlng, { + radius: 7, + color: ent[0] == selectedPortal ? COLOR_SELECTED_PORTAL : COLORS[team], + opacity: 1, + weight: 3, + fillColor: COLORS[team], + fillOpacity: 0.5, + clickable: true, + guid: ent[0]}); + + p.on('remove', function() { delete window.portals[this.options.guid]; }); + p.on('add', function() { window.portals[this.options.guid] = this; }); + p.on('click', function() { window.renderPortalDetails(ent[0]); }); + p.on('dblclick', function() { + window.renderPortalDetails(ent[0]); + window.map.setView(latlng, 17); + }); + p.addTo(portalsLayer); +} + +// renders a link on the map from the given entity +window.renderLink = function(ent) { + removeByGuid(ent[0]); + 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: 0.5, + weight:2, + clickable: false, + guid: ent[0] + }); + + if(!map.getBounds().intersects(poly.getBounds())) return; + + poly.on('remove', function() { delete window.links[this.options.guid]; }); + poly.on('add', function() { window.links[this.options.guid] = this; }); + poly.addTo(linksLayer); +} + +// renders a field on the map from a given entity +window.renderField = function(ent) { + window.removeByGuid(ent[0]); + 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, + guid: ent[0]}); + + if(!map.getBounds().intersects(poly.getBounds())) return; + + poly.on('remove', function() { delete window.fields[this.options.guid]; }); + poly.on('add', function() { window.fields[this.options.guid] = this; }); + poly.addTo(fieldsLayer); +} diff --git a/code/map_data_calc_tools.js b/code/map_data_calc_tools.js new file mode 100644 index 00000000..2f383197 --- /dev/null +++ b/code/map_data_calc_tools.js @@ -0,0 +1,79 @@ + + +// 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) + }; +} diff --git a/code/player_names.js b/code/player_names.js new file mode 100644 index 00000000..50ec9bb8 --- /dev/null +++ b/code/player_names.js @@ -0,0 +1,43 @@ +// 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) { + localStorage[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); + }); +} diff --git a/code/portal_detail_display.js b/code/portal_detail_display.js new file mode 100644 index 00000000..4a8699e6 --- /dev/null +++ b/code/portal_detail_display.js @@ -0,0 +1,108 @@ + +// 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 update = selectPortal(guid); + var d = portalsDetail[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']++; + }); + var linksText = 'links: ↳ ' + 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; + + // collect and html-ify random data + var randDetails = [playerText, sinceText, getRangeText(d), getEnergyText(d), linksText, getAvgResoDistText(d)]; + randDetails = randDetails.map(function(e) { + if(!e) return ''; + e = e.split(':'); + e = ''; + return e; + }).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+'

' + + '
' + + ''+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) + oldPortal.setStyle({color: oldPortal.options.fillColor}); + + selectedPortal = guid; + + if(portals[guid]) + portals[guid].bringToFront().setStyle({color: COLOR_SELECTED_PORTAL}); + + return update; +} diff --git a/code/portal_detail_display_tools.js b/code/portal_detail_display_tools.js new file mode 100644 index 00000000..21ecedad --- /dev/null +++ b/code/portal_detail_display_tools.js @@ -0,0 +1,117 @@ + +// 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 = []; + $.each(d.portalV2.linkedModArray, function(ind, mod) { + if(!mod) { + mods.push(''); + modsTitle.push(''); + } else if(mod.type === 'RES_SHIELD') { + mods.push(mod.rarity + ' ' + mod.displayName); + modsTitle.push(mod.rarity + ' ' + mod.displayName + '\ninstalled by: '+getPlayerName(mod.installingUser)); + } else { + mods.push(mod.type); + modsTitle.push('Unknown mod. No further details available.'); + } + }); + + var t = ''+mods[0]+'' + + ''+mods[1]+'' + + ''+mods[2]+'' + + ''+mods[3]+'' + + return t; +} + +window.getEnergyText = function(d) { + var nrg = getPortalEnergy(d); + return 'energy: ' + (nrg > 1000 ? Math.round(nrg/1000) +' k': nrg); +} + +window.getAvgResoDistText = function(d) { + var avgDist = Math.round(10*getAvgResoDist(d))/10; + return '⌀ res dist: ' + avgDist + ' m'; +} + +window.getReportIssueInfoText = function(d) { + return ('Your Nick: '+PLAYER.nickname+' ' + + 'Portal: '+d.portalV2.descriptiveText.TITLE+' ' + + 'Location: '+d.portalV2.descriptiveText.ADDRESS + +' (lat '+(d.locationE6.latE6/1E6)+'; lng '+(d.locationE6.latE6/1E6)+')' + ).replace(/['"]/, ''); +} + +window.getResonatorDetails = function(d) { + console.log('rendering reso details'); + var resoDetails = ''; + var slotsFilled = 0; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) { + resoDetails += renderResonatorDetails(slotsFilled++, 0); + return true; + } + + var l = parseInt(reso.level); + var v = parseInt(reso.energyTotal); + var nick = window.getPlayerName(reso.ownerGuid); + var dist = reso.distanceToPortal; + + slotsFilled++; + resoDetails += renderResonatorDetails(parseInt(reso.slot), l, v, dist, nick); + }); + 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) { + if(level == 0) { + var meter = ''; + } else { + var max = RESO_NRG[level]; + var fillGrade = nrg/max*100; + + var inf = 'energy:\t\t' + nrg + ' / ' + max + '\n' + + 'level:\t\t' + level +'\n' + + 'distance:\t' + dist + 'm\n' + + 'owner:\t\t' + nick + '\n' + + 'cardinal:\t' + SLOT_TO_CARDINAL[slot]; + + var style = 'width:'+fillGrade+'%; background:'+COLORS_LVL[level]+'; color:'+COLORS_LVL[level]; + var fill = ''; + var meter = '' + + fill + ''; + } + var cls = slot <= 3 ? 'left' : 'right'; + var text = ''+(nick||'')+''; + return (slot <= 3 ? text+meter : meter+text) + '
'; +} diff --git a/code/portal_info.js b/code/portal_info.js new file mode 100644 index 00000000..3a4aa908 --- /dev/null +++ b/code/portal_info.js @@ -0,0 +1,41 @@ + + +// 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; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + lvl += parseInt(reso.level); + }); + return lvl/8; +} + +window.getPortalEnergy = 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/ + 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; +} diff --git a/code/redeeming.js b/code/redeeming.js new file mode 100644 index 00000000..623ea218 --- /dev/null +++ b/code/redeeming.js @@ -0,0 +1,22 @@ + + +// REDEEMING ///////////////////////////////////////////////////////// + +window.handleRedeemResponse = function(data, textStatus, jqXHR) { + if(data.error) { + alert('Couldn’t redeem code. It may be used up, invalid or you have redeemed it already. (Code: '+data.error+')'); + return; + } + + var text = 'Success! However, pretty display is not implemented.\nMaybe you can make sense of the following:\n'; + alert(text + JSON.stringify(data)); +} + +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. Maybe try again?'); }); + }); +} diff --git a/code/request_handling.js b/code/request_handling.js new file mode 100644 index 00000000..3ee17d5c --- /dev/null +++ b/code/request_handling.js @@ -0,0 +1,49 @@ + +// 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; + + startRefreshTimeout(); + renderUpdateStatus(); +} + +// gives user feedback about pending operations. Draws current status +// to website. +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(window.failedRequestCount > 0) + t += ' ' + window.failedRequestCount + ' requests failed.' + + $('#updatestatus').html(t); +} diff --git a/code/utils_misc.js b/code/utils_misc.js new file mode 100644 index 00000000..5226e4b6 --- /dev/null +++ b/code/utils_misc.js @@ -0,0 +1,91 @@ + + +// 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)); + return $.ajax({ + url: 'rpc/dashboard.'+action, + type: 'POST', + data: data, + dataType: 'json', + success: success, + error: [ + function(jqXHR) { window.failedRequestCount++; window.requests.remove(jqXHR); }, + error || null + ], + 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) { + if(!time) return null; + var d = new Date(typeof time === 'string' ? parseInt(time) : time); + if(d.toDateString() == new Date().toDateString()) + return d.toLocaleTimeString(); + else + return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate(); +} + + + +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.'; + //codename, approx addr, portalname + if(prompt(t, info) !== null) + location.href = 'https://support.google.com/ingress?hl=en'; +} diff --git a/json_examples/portal_json.js b/json_examples/portal_json.js new file mode 100644 index 00000000..863c4561 --- /dev/null +++ b/json_examples/portal_json.js @@ -0,0 +1,162 @@ + + +[ + "2c124b4e99074660a72668c888093834.11", + 1359563604824, + { + "turret": { + + }, + "resonatorArray": { + "resonators": [ + { + "slot": 0, + "level": 5, + "energyTotal": 2825, + "distanceToPortal": 35, + "id": "26da7ed9-9025-4974-b62f-495750b2d888", + "ownerGuid": "a1dc117457954b99b104be8c85e16276.c" + }, + { + "slot": 1, + "level": 6, + "energyTotal": 3725, + "distanceToPortal": 25, + "id": "f7f3312c-7440-4edf-95b9-9e416af1f399", + "ownerGuid": "a1dc117457954b99b104be8c85e16276.c" + }, + { + "slot": 2, + "level": 5, + "energyTotal": 2825, + "distanceToPortal": 31, + "id": "50627504-1efb-445d-84b0-f014948aedfb", + "ownerGuid": "c4981119edfb488a9fd19d99eb540449.c" + }, + { + "slot": 3, + "level": 6, + "energyTotal": 3725, + "distanceToPortal": 31, + "id": "06ee68fe-80e4-491b-a99d-aa126c9740d6", + "ownerGuid": "ef9657c5cc2743afa5e1953dd06df307.c" + }, + { + "slot": 4, + "level": 6, + "energyTotal": 3725, + "distanceToPortal": 30, + "id": "df80cd1f-d347-4dc5-a31b-deb8fb9f978d", + "ownerGuid": "ef9657c5cc2743afa5e1953dd06df307.c" + }, + { + "slot": 5, + "level": 5, + "energyTotal": 2825, + "distanceToPortal": 24, + "id": "c8c474ec-8deb-4218-8b7b-ea2a7ea3e4ac", + "ownerGuid": "a0c2e839783f4e889ca570e2cd5c7819.c" + }, + { + "slot": 6, + "level": 5, + "energyTotal": 2825, + "distanceToPortal": 29, + "id": "e57ea7b1-f3f7-4a62-a060-c794f85246e1", + "ownerGuid": "a0c2e839783f4e889ca570e2cd5c7819.c" + }, + { + "slot": 7, + "level": 5, + "energyTotal": 2825, + "distanceToPortal": 31, + "id": "d08b9027-88f3-4808-bd3f-41acce7e195a", + "ownerGuid": "c4981119edfb488a9fd19d99eb540449.c" + } + ] + }, + "imageByUrl": { + "imageUrl": "http:\/\/lh3.ggpht.com\/oh3OKN-Vc-GbS3hfp4ZaCB_ND6hmkfnp-gAQmCtfmmrj9R94eeiVAAh9HLd5gO-JV8rBGchle8CXLbr_bmK8sR-PdRYS1yCNHS48wf98RelEVjOe" + }, + "controllingTeam": { + "team": "RESISTANCE" + }, + "defaultActionRange": { + + }, + "portalV2": { + "linkedEdges": [ + { + "otherPortalGuid": "a38615195c7547639dfb77bfe3a700a8.11", + "edgeGuid": "29c13e7a9ba149008aa6355e201158f1.9", + "isOrigin": false + }, + { + "otherPortalGuid": "4dd1ede906a44420aa2b052a1363f170.11", + "edgeGuid": "cecc7c69103d4678aeb7172a1688e2b5.9", + "isOrigin": false + }, + { + "otherPortalGuid": "9d1fa3603e8c4363b3cd2448277a906b.11", + "edgeGuid": "ed61e4455e574a1c906778f29307a7b4.9", + "isOrigin": true + }, + { + "otherPortalGuid": "20c14bddc1384ee99dfe77c10737e842.11", + "edgeGuid": "0d63bde0620a4a89ad4a23ce8ef29c63.9", + "isOrigin": false + } + ], + "linkedModArray": [ + { + "installingUser": "a1dc117457954b99b104be8c85e16276.c", + "type": "RES_SHIELD", + "stats": { + "MITIGATION": "8" + }, + "displayName": "Portal Shield", + "rarity": "RARE" + }, + { + "installingUser": "a1dc117457954b99b104be8c85e16276.c", + "type": "RES_SHIELD", + "stats": { + "MITIGATION": "8" + }, + "displayName": "Portal Shield", + "rarity": "RARE" + }, + { + "installingUser": "a0c2e839783f4e889ca570e2cd5c7819.c", + "type": "RES_SHIELD", + "stats": { + "MITIGATION": "8" + }, + "displayName": "Portal Shield", + "rarity": "RARE" + }, + { + "installingUser": "ef9657c5cc2743afa5e1953dd06df307.c", + "type": "RES_SHIELD", + "stats": { + "MITIGATION": "6" + }, + "displayName": "Portal Shield", + "rarity": "COMMON" + } + ], + "descriptiveText": { + "TITLE": "M\u00f6biusband", + "ADDRESS": "Im Neuenheimer Feld 306, 69120 Heidelberg, Germany" + } + }, + "locationE6": { + "lngE6": 8672899, + "latE6": 49417115 + }, + "captured": { + "capturedTime": "1359390335006", + "capturingPlayerId": "a1dc117457954b99b104be8c85e16276.c" + } + } +] diff --git a/leaflet_google.js b/leaflet_google.js new file mode 100644 index 00000000..37d6dd8c --- /dev/null +++ b/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/main.js b/main.js new file mode 100644 index 00000000..2c1b310a --- /dev/null +++ b/main.js @@ -0,0 +1,136 @@ +// ==UserScript== +// @id www.ingress.com-4e3ebacd-9e95-410c-8b88-6f3647f6a7a8@scriptish +// @name intel map total conversion +// @version 0.1-@@BUILDDATE@@ +// @namespace https://github.com/breunigs/ingress-intel-total-conversion +// @updateURL https://raw.github.com/breunigs/ingress-intel-total-conversion/master/total-conversion-build.user.js +// @downloadURL https://raw.github.com/breunigs/ingress-intel-total-conversion/master/total-conversion-build.user.js +// @description total conversion for the ingress intel map. +// @include 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; +} + +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 = + '' + '' + + ''; + +document.getElementsByTagName('body')[0].innerHTML = + '
Loading, please wait
' + + ''; + +// 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 SIDEBAR_WIDTH = 300; + + +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']; +// 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 = 35; // in meters, max. distance from portal to be able to access it +var SLOT_TO_CARDINAL = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE']; +var DEFAULT_PORTAL_IMG = 'http://commondatastorage.googleapis.com/ingress/img/default-portal-image.png'; + +// 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']; +// make PLAYER variable available in site context +var PLAYER = window.PLAYER; + +// 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 portalsLayer, linksLayer, fieldsLayer; +var portalsDetail = {}; + +// 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 = {}; + + + +@@INJECTHERE@@ + + +} // 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); diff --git a/style.css b/style.css new file mode 100644 index 00000000..c4822127 --- /dev/null +++ b/style.css @@ -0,0 +1,317 @@ +/* general rules ******************************************************/ +* { + border: 0; + font-family: "coda",arial,helvetica,sans-serif; + margin: 0; + padding: 0; +} + +html, body, #map { + height: 100%; +} + +body { + font-size: 14px; +} + +#map { + width: calc(100% - 302px); +} + +#sidebar { + background: #000; + border-left: 2px solid #c3c3c3; + color: #888; + height: 100%; + position: fixed; + right: 0; + top: 0; +} + +.enl { + color: #03fe03 !important; +} + +.res { + color: #00c5ff !important; +} + +.none { + color: #fff; +} + +a { + color: #ffce00; + cursor: pointer; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +[title] { + cursor: help; +} + +[title=""] { + cursor: auto !important; +} + +/* map display, required because GMaps uses a high z-index which is + * normally above Leaflet’s vector pane */ +.leaflet-map-pane { + z-index: 1000; +} + + +/* sidebar ************************************************************/ + +#sidebar > * { + border-bottom: 1px solid #c3c3c3; +} + + +/* player stats */ +#playerstat { + height: 30px; +} + +h2 { + color: #ffce00; + font-size: 21px; + padding: 0 4px; +} + +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; +} + +#gamestat .res { + background: #005684; + text-align: right; +} + +#gamestat .enl { + background: #017f01; +} + + +/* geosearch input, and others */ +input { + background: #313131; + color: #ffce00; + height: 22px; + line-height: 22px; + padding: 0 4px; +} + +::-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; +} + +.imgpreview { + height: 200px; + overflow: hidden; +} + +#level { + font-size: 40px; + position: absolute; + right: 10px; + text-shadow: 0 0 2px #000000, 0 0 5px #ffffff; + top: 100px; +} + +/* portal mods */ +.mods { + margin-bottom: 1px; + margin-top: 5px; +} + +.mods span { + background: #313131; + display: inline-block; + height: 63.7px; + margin-left: 4px; + overflow: hidden; + padding: 2px; + text-align: center; + width: 63.7px; +} + +.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; +} + + +/* resonators */ +#resodetails { + 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; +} + +/* links below resos */ + +.linkdetails { + text-align: center; +} +.linkdetails aside { + margin: 0 4px; + width: 140px; +} + +/* a common portal display takes this much space (prevents moving + * content when first selecting a portal) */ + +#portaldetails { + min-height: 532px; +} + + +/* update status */ +#updatestatus { + border-bottom: 0; + border-top: 1px solid #c3c3c3; + bottom: 0; + color: #ffce00; + padding: 4px; + position: fixed; + right: 0; +} + + +/* 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/total-conversion-build.user.js b/total-conversion-build.user.js new file mode 100644 index 00000000..d17f782a --- /dev/null +++ b/total-conversion-build.user.js @@ -0,0 +1,1303 @@ +// ==UserScript== +// @id www.ingress.com-4e3ebacd-9e95-410c-8b88-6f3647f6a7a8@scriptish +// @name intel map total conversion +// @version 0.1-2013-02-01-131053 +// @namespace https://github.com/breunigs/ingress-intel-total-conversion +// @updateURL https://raw.github.com/breunigs/ingress-intel-total-conversion/master/total-conversion-build.user.js +// @downloadURL https://raw.github.com/breunigs/ingress-intel-total-conversion/master/total-conversion-build.user.js +// @description total conversion for the ingress intel map. +// @include 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; +} + +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 = + '' + '' + + ''; + +document.getElementsByTagName('body')[0].innerHTML = + '
Loading, please wait
' + + ''; + +// 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 SIDEBAR_WIDTH = 300; + + +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']; +// 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 = 35; // in meters, max. distance from portal to be able to access it +var SLOT_TO_CARDINAL = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE']; +var DEFAULT_PORTAL_IMG = 'http://commondatastorage.googleapis.com/ingress/img/default-portal-image.png'; + +// 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']; +// make PLAYER variable available in site context +var PLAYER = window.PLAYER; + +// 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 portalsLayer, linksLayer, fieldsLayer; +var portalsDetail = {}; + +// 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 = {}; + + + + + + +// 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. + + +// 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) { + if(refreshTimeout) clearTimeout(refreshTimeout); + if(override) { + console.log('refreshing in ' + override + 'ms'); + refreshTimeout = setTimeout(window.requestData, override); + return; + } + var t = REFRESH*1000; + var adj = ZOOM_LEVEL_ADJ * (18 - window.map.getZoom()); + if(adj > 0) t += adj*1000; + console.log("next auto refresh in " + t/1000 + " seconds."); + refreshTimeout = setTimeout(window.requestData, t); +} + +// requests map data for current viewport. For details on how this +// works, refer to the description in “MAP DATA REQUEST CALCULATORS” +window.requestData = function() { + if(window.idleTime >= MAX_IDLE_TIME) { + console.log('user has been idle for ' + idleTime + ' minutes. Skipping refresh.'); + renderUpdateStatus(); + return; + } + + 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 + window.requests.remove(jqXHR); + if(!data || !data.result) { + console.warn(data); + return; + } + + var portalUpdateAvailable = false; + var m = data.result.map; + $.each(m, function(qk, val) { + $.each(val.deletedGameEntityGuids, function(ind, guid) { + 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; + + portalsDetail[ent[0]] = ent[2]; + // immediately render portal details if selected by URL + if(urlPortal && ent[0] == urlPortal && !selectedPortal) { + urlPortal = null; // only pre-select it once + window.renderPortalDetails(ent[0]); + } + renderPortal(ent); + } else if(ent[2].edge !== undefined) + renderLink(ent); + else if(ent[2].capturedRegion !== undefined) + renderField(ent); + else + throw('Unknown entity: ' + JSON.stringify(ent)); + }); + }); + + $.each(portals, function(ind, portal) { + // otherwise some portals will not be clickable. See + // https://github.com/Leaflet/Leaflet/issues/185 + portal.bringToFront(); + }); + + if(portals[selectedPortal]) portals[selectedPortal].bringToFront(); + + 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 = map.getBounds(); + portalsLayer.eachLayer(function(portal) { + if(b.contains(portal.getLatLng())) return; + cnt[0]++; + portalsLayer.removeLayer(portal); + }); + 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) { + // portals end in “.11” or “.12“, links in “.9", fields in “.b” + // .c == player/creator + switch(guid.slice(33)) { + case '11': + case '12': + if(!window.portals[guid]) return; + portalsLayer.removeLayer(window.portals[guid]); + break; + case '9': + if(!window.links[guid]) return; + linksLayer.removeLayer(window.links[guid]); + break; + case 'b': + if(!window.fields[guid]) return; + fieldsLayer.removeLayer(window.fields[guid]); + 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) { + removeByGuid(ent[0]); + var latlng = [ent[2].locationE6.latE6/1E6, ent[2].locationE6.lngE6/1E6]; + if(!map.getBounds().contains(latlng)) return; + + // pre-load player names for high zoom levels + if(map.getZoom() >= PRECACHE_PLAYER_NAMES_ZOOM) { + if(ent[2].captured && ent[2].captured.capturingPlayerId) + getPlayerName(ent[2].captured.capturingPlayerId); + if(ent[2].resonatorArray && ent[2].resonatorArray.resonators) + $.each(ent[2].resonatorArray.resonators, function(ind, reso) { + if(reso) getPlayerName(reso.ownerGuid); + }); + } + + var team = getTeam(ent[2]); + + var p = L.circleMarker(latlng, { + radius: 7, + color: ent[0] == selectedPortal ? COLOR_SELECTED_PORTAL : COLORS[team], + opacity: 1, + weight: 3, + fillColor: COLORS[team], + fillOpacity: 0.5, + clickable: true, + guid: ent[0]}); + + p.on('remove', function() { delete window.portals[this.options.guid]; }); + p.on('add', function() { window.portals[this.options.guid] = this; }); + p.on('click', function() { window.renderPortalDetails(ent[0]); }); + p.on('dblclick', function() { + window.renderPortalDetails(ent[0]); + window.map.setView(latlng, 17); + }); + p.addTo(portalsLayer); +} + +// renders a link on the map from the given entity +window.renderLink = function(ent) { + removeByGuid(ent[0]); + 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: 0.5, + weight:2, + clickable: false, + guid: ent[0] + }); + + if(!map.getBounds().intersects(poly.getBounds())) return; + + poly.on('remove', function() { delete window.links[this.options.guid]; }); + poly.on('add', function() { window.links[this.options.guid] = this; }); + poly.addTo(linksLayer); +} + +// renders a field on the map from a given entity +window.renderField = function(ent) { + window.removeByGuid(ent[0]); + 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, + guid: ent[0]}); + + if(!map.getBounds().intersects(poly.getBounds())) return; + + poly.on('remove', function() { delete window.fields[this.options.guid]; }); + poly.on('add', function() { window.fields[this.options.guid] = this; }); + poly.addTo(fieldsLayer); +} + + + +// 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; + + startRefreshTimeout(); + renderUpdateStatus(); +} + +// gives user feedback about pending operations. Draws current status +// to website. +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(window.failedRequestCount > 0) + t += ' ' + window.failedRequestCount + ' requests failed.' + + $('#updatestatus').html(t); +} + + + + +// 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)); + return $.ajax({ + url: 'rpc/dashboard.'+action, + type: 'POST', + data: data, + dataType: 'json', + success: success, + error: [ + function(jqXHR) { window.failedRequestCount++; window.requests.remove(jqXHR); }, + error || null + ], + 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) { + if(!time) return null; + var d = new Date(typeof time === 'string' ? parseInt(time) : time); + if(d.toDateString() == new Date().toDateString()) + return d.toLocaleTimeString(); + else + return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate(); +} + + + +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.'; + //codename, approx addr, portalname + if(prompt(t, info) !== null) + location.href = 'https://support.google.com/ingress?hl=en'; +} + + + + +// 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() }); + }); +} + +window.setupStyles = function() { + $('head').append(''); +} + +window.setupMap = function() { + 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')]; + + portalsLayer = L.layerGroup([]); + linksLayer = L.layerGroup([]); + fieldsLayer = L.layerGroup([]); + window.map = new L.Map('map', $.extend(getPosition(), {zoomControl: false})); + try { + map.addLayer(views[readCookie('ingress.intelmap.type')]); + } catch(e) { map.addLayer(views[0]); } + map.addLayer(portalsLayer); + map.addLayer(fieldsLayer, true); + map.addLayer(linksLayer, true); + 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] + }, { + 'Portals': portalsLayer, + 'Links': linksLayer, + 'Fields': fieldsLayer + })); + map.attributionControl.setPrefix(''); + // listen for changes and store them in cookies + map.on('moveend', window.storeMapPosition); + map.on('zoomend', window.storeMapPosition); + $("[name='leaflet-base-layers']").change(function () { + writeCookie('ingress.intelmap.type', $(this).parent().index()); + }); + + // map update status handling + map.on('zoomstart', function() { window.mapRunsUserAction = true }); + map.on('movestart', function() { window.mapRunsUserAction = true }); + map.on('zoomend', function() { window.mapRunsUserAction = false }); + map.on('moveend', function() { window.mapRunsUserAction = false }); + + + // update map hooks + map.on('zoomstart', window.requests.abort); + map.on('zoomend', function() { window.startRefreshTimeout(500) }); + map.on('movestart', window.requests.abort ); + map.on('moveend', function() { window.startRefreshTimeout(500) }); + + // run once on init + window.requestData(); + window.startRefreshTimeout(); +}; + +// 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 < 7; 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') + '' + + '
' + + '

' + ); +} + + +// BOOTING /////////////////////////////////////////////////////////// + +function boot() { + console.log('loading done, booting'); + window.setupStyles(); + window.setupMap(); + window.setupGeosearch(); + window.setupRedeem(); + window.setupLargeImagePreview(); + window.updateGameScore(); + window.setupPlayerStat(); + // read here ONCE, so the URL is only evaluated one time after the + // necessary data has been loaded. + urlPortal = getURLParam('pguid'); + + $('#sidebar').show(); +} + +// 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' + + (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 = []; + $.each(d.portalV2.linkedModArray, function(ind, mod) { + if(!mod) { + mods.push(''); + modsTitle.push(''); + } else if(mod.type === 'RES_SHIELD') { + mods.push(mod.rarity + ' ' + mod.displayName); + modsTitle.push(mod.rarity + ' ' + mod.displayName + '\ninstalled by: '+getPlayerName(mod.installingUser)); + } else { + mods.push(mod.type); + modsTitle.push('Unknown mod. No further details available.'); + } + }); + + var t = ''+mods[0]+'' + + ''+mods[1]+'' + + ''+mods[2]+'' + + ''+mods[3]+'' + + return t; +} + +window.getEnergyText = function(d) { + var nrg = getPortalEnergy(d); + return 'energy: ' + (nrg > 1000 ? Math.round(nrg/1000) +' k': nrg); +} + +window.getAvgResoDistText = function(d) { + var avgDist = Math.round(10*getAvgResoDist(d))/10; + return '⌀ res dist: ' + avgDist + ' m'; +} + +window.getReportIssueInfoText = function(d) { + return ('Your Nick: '+PLAYER.nickname+' ' + + 'Portal: '+d.portalV2.descriptiveText.TITLE+' ' + + 'Location: '+d.portalV2.descriptiveText.ADDRESS + +' (lat '+(d.locationE6.latE6/1E6)+'; lng '+(d.locationE6.latE6/1E6)+')' + ).replace(/['"]/, ''); +} + +window.getResonatorDetails = function(d) { + console.log('rendering reso details'); + var resoDetails = ''; + var slotsFilled = 0; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) { + resoDetails += renderResonatorDetails(slotsFilled++, 0); + return true; + } + + var l = parseInt(reso.level); + var v = parseInt(reso.energyTotal); + var nick = window.getPlayerName(reso.ownerGuid); + var dist = reso.distanceToPortal; + + slotsFilled++; + resoDetails += renderResonatorDetails(parseInt(reso.slot), l, v, dist, nick); + }); + 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) { + if(level == 0) { + var meter = ''; + } else { + var max = RESO_NRG[level]; + var fillGrade = nrg/max*100; + + var inf = 'energy:\t\t' + nrg + ' / ' + max + '\n' + + 'level:\t\t' + level +'\n' + + 'distance:\t' + dist + 'm\n' + + 'owner:\t\t' + nick + '\n' + + 'cardinal:\t' + SLOT_TO_CARDINAL[slot]; + + var style = 'width:'+fillGrade+'%; background:'+COLORS_LVL[level]+'; color:'+COLORS_LVL[level]; + var fill = ''; + var meter = '' + + fill + ''; + } + var cls = slot <= 3 ? 'left' : 'right'; + var text = ''+(nick||'')+''; + return (slot <= 3 ? text+meter : meter+text) + '
'; +} + + + +// 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() }); + $('#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 (e) { + // update immediately when the user comes back + if(isIdle()) { + window.idleTime = 0; + window.requestData(); + } + window.idleTime = 0; +}; +$('body').mousemove(idleReset).keypress(idleReset); + +window.isIdle = function() { + return window.idleTime >= MAX_IDLE_TIME; +} + + + +// 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 update = selectPortal(guid); + var d = portalsDetail[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']++; + }); + var linksText = 'links: ↳ ' + 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; + + // collect and html-ify random data + var randDetails = [playerText, sinceText, getRangeText(d), getEnergyText(d), linksText, getAvgResoDistText(d)]; + randDetails = randDetails.map(function(e) { + if(!e) return ''; + e = e.split(':'); + e = ''; + return e; + }).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+'

' + + '
' + + ''+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) + oldPortal.setStyle({color: oldPortal.options.fillColor}); + + selectedPortal = guid; + + if(portals[guid]) + portals[guid].bringToFront().setStyle({color: COLOR_SELECTED_PORTAL}); + + return update; +} + + + + +// REDEEMING ///////////////////////////////////////////////////////// + +window.handleRedeemResponse = function(data, textStatus, jqXHR) { + if(data.error) { + alert('Couldn’t redeem code. It may be used up, invalid or you have redeemed it already. (Code: '+data.error+')'); + return; + } + + var text = 'Success! However, pretty display is not implemented.\nMaybe you can make sense of the following:\n'; + alert(text + JSON.stringify(data)); +} + +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. Maybe 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) { + localStorage[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); + }); +} + + + +// 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() { + portalsLayer.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 + escape($(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; + $.each(d.resonatorArray.resonators, function(ind, reso) { + if(!reso) return true; + lvl += parseInt(reso.level); + }); + return lvl/8; +} + +window.getPortalEnergy = 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/ + 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);