diff --git a/code/artifact.js b/code/artifact.js index f1e5b874..28b61b3a 100644 --- a/code/artifact.js +++ b/code/artifact.js @@ -35,7 +35,7 @@ window.artifact.requestData = function() { if (isIdle()) { artifact.idle = true; } else { - window.postAjax('artifacts', {}, artifact.handleSuccess, artifact.handleError); + window.postAjax('getArtifactPortals', {}, artifact.handleSuccess, artifact.handleError); } } @@ -65,34 +65,16 @@ window.artifact.handleFailure = function(data) { window.artifact.processData = function(data) { - if (data.error || !data.artifacts) { - console.warn('Failed to find artifacts in artifact response'); + if (data.error || !data.result) { + console.warn('Failed to find result in getArtifactPortals response'); return; } + var oldArtifacts = artifact.entities; artifact.clearData(); - $.each (data.artifacts, function(i,artData) { - // if we have no descriptions for a type, we don't know about it - if (!artifact.getArtifactDescriptions(artData.artifactId)) { - // jarvis and amar artifacts - fragmentInfos and targetInfos - // (future types? completely unknown at this time!) - console.warn('Note: unknown artifactId '+artData.artifactId+' - guessing how to handle it'); - } - - artifact.artifactTypes[artData.artifactId] = artData.artifactId; - - if (artData.fragmentInfos) { - artifact.processFragmentInfos (artData.artifactId, artData.fragmentInfos); - } - - if (artData.targetInfos) { - artifact.processTargetInfos (artData.artifactId, artData.targetInfos); - } - - // other data in future? completely unknown! - }); - + artifact.processResult(data.result); + runHooks('artifactsUpdated', {old: oldArtifacts, 'new': artifact.entities}); // redraw the artifact layer artifact.updateLayer(); @@ -101,38 +83,52 @@ window.artifact.processData = function(data) { window.artifact.clearData = function() { - artifact.portalInfo = {}; artifact.artifactTypes = {}; + + artifact.entities = []; } -window.artifact.processFragmentInfos = function (id, fragments) { - $.each(fragments, function(i, fragment) { - if (!artifact.portalInfo[fragment.portalGuid]) { - artifact.portalInfo[fragment.portalGuid] = { _entityData: fragment.portalInfo }; + +window.artifact.processResult = function (portals) { + // portals is an object, keyed from the portal GUID, containing the portal entity array + + for (var guid in portals) { + var ent = portals[guid]; + var data = decodeArray.portalSummary(ent); + + // we no longer know the faction for the target portals, and we don't know which fragment numbers are at the portals + // all we know, from the portal summary data, for each type of artifact, is that each artifact portal is + // - a target portal or not - no idea for which faction + // - has one (or more) fragments, or not + + if (!artifact.portalInfo[guid]) artifact.portalInfo[guid] = {}; + + // store the decoded data - needed for lat/lng for layer markers + artifact.portalInfo[guid]._data = data; + + for(var type in data.artifactBrief.target) { + if (!artifact.artifactTypes[type]) artifact.artifactTypes[type] = {}; + + if (!artifact.portalInfo[guid][type]) artifact.portalInfo[guid][type] = {}; + + artifact.portalInfo[guid][type].target = TEAM_NONE; // as we no longer know the team... } - if (!artifact.portalInfo[fragment.portalGuid][id]) artifact.portalInfo[fragment.portalGuid][id] = {}; + for(var type in data.artifactBrief.fragment) { + if (!artifact.artifactTypes[type]) artifact.artifactTypes[type] = {}; - if (!artifact.portalInfo[fragment.portalGuid][id].fragments) artifact.portalInfo[fragment.portalGuid][id].fragments = []; + if (!artifact.portalInfo[guid][type]) artifact.portalInfo[guid][type] = {}; - $.each(fragment.fragments, function(i,f) { - artifact.portalInfo[fragment.portalGuid][id].fragments.push(f); - }); - - }); -} - -window.artifact.processTargetInfos = function (id, targets) { - $.each(targets, function(i, target) { - if (!artifact.portalInfo[target.portalGuid]) { - artifact.portalInfo[target.portalGuid] = { _entityData: target.portalInfo }; + artifact.portalInfo[guid][type].fragments = true; //as we no longer have a list of the fragments there } - if (!artifact.portalInfo[target.portalGuid][id]) artifact.portalInfo[target.portalGuid][id] = {}; - artifact.portalInfo[target.portalGuid][id].target = target.team === 'RESISTANCE' ? TEAM_RES : TEAM_ENL; - }); + // let's pre-generate the entities needed to render the map - array of [guid, timestamp, ent_array] + artifact.entities.push ( [guid, data.timestamp, ent] ); + + } + } window.artifact.getArtifactTypes = function() { @@ -143,30 +139,9 @@ window.artifact.isArtifact = function(type) { return type in artifact.artifactTypes; } -window.artifact.getArtifactDescriptions = function(type) { - var descriptions = { - 'jarvis': { 'title': "Jarvis Shards", 'fragmentName': "shards" }, - 'amar': { 'title': "Amar Artifacts", 'fragmentName': "artifacts" }, - 'helios': { 'title': "Helios Artifacts", 'fragmentName': "artifacts" }, - 'shonin': { 'title': "Sh\u014Dnin Shards", 'fragmentName': "shards" }, - 'lightman': { 'title': "Lightman Shards", 'fragmentName': "shards" }, - }; - - return descriptions[type]; -} - // used to render portals that would otherwise be below the visible level window.artifact.getArtifactEntities = function() { - var entities = []; - - // create fake entities from the artifact data - $.each (artifact.portalInfo, function(guid,data) { - var timestamp = 0; // we don't have a valid timestamp - so let's use 0 - var ent = [ guid, timestamp, data._entityData ]; - entities.push(ent); - }); - - return entities; + return artifact.entities; } window.artifact.getInterestingPortals = function() { @@ -187,99 +162,53 @@ window.artifact.updateLayer = function() { artifact._layer.clearLayers(); $.each(artifact.portalInfo, function(guid,data) { - var latlng = L.latLng ([data._entityData[2]/1E6, data._entityData[3]/1E6]); + var latlng = L.latLng ([data._data.latE6/1E6, data._data.lngE6/1E6]); - // jarvis shard icon - var iconUrl = undefined; - var iconSize = 0; - var opacity = 1.0; + $.each(data, function(type,detail) { - // redundant as of 2014-02-05 - jarvis shards removed - if (data.jarvis) { - if (data.jarvis.target) { - // target portal - show the target marker. use the count of fragments at the target to pick the right icon - it has segments that fill up + // we'll construct the URL form the type - stock seems to do that now - var count = data.jarvis.fragments ? data.jarvis.fragments.length : 0; + var iconUrl; + if (data[type].target !== undefined) { + // target portal + var iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/'+type+'_shard_target.png' + var iconSize = 100/2; + var opacity = 1.0; + + var icon = L.icon({ + iconUrl: iconUrl, + iconSize: [iconSize,iconSize], + iconAnchor: [iconSize/2,iconSize/2], + className: 'no-pointer-events' // the clickable: false below still blocks events going through to the svg underneath + }); + + var marker = L.marker (latlng, {icon: icon, clickable: false, keyboard: false, opacity: opacity }); + + artifact._layer.addLayer(marker); + + } else if (data[type].fragments) { + // fragment(s) at portal + + var iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/'+type+'_shard.png' + var iconSize = 60/2; + var opacity = 0.6; + + var icon = L.icon({ + iconUrl: iconUrl, + iconSize: [iconSize,iconSize], + iconAnchor: [iconSize/2,iconSize/2], + className: 'no-pointer-events' // the clickable: false below still blocks events going through to the svg underneath + }); + + var marker = L.marker (latlng, {icon: icon, clickable: false, keyboard: false, opacity: opacity }); + + artifact._layer.addLayer(marker); - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/jarvis_shard_target_'+count+'.png'; - iconSize = 100/2; // 100 pixels - half that size works better - } else if (data.jarvis.fragments) { - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/jarvis_shard.png'; - iconSize = 60/2; // 60 pixels - half that size works better - opacity = 0.6; // these often hide portals - let's make them semi transparent } - } - // 2014-02-06: a guess at whats needed for the new artifacts - if (data.amar) { - if (data.amar.target) { - // target portal - show the target marker. use the count of fragments at the target to pick the right icon - it has segments that fill up + }); //end $.each(data, function(type,detail) - var count = data.amar.fragments ? data.amar.fragments.length : 0; - - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/amar_shard_target_'+count+'.png'; - iconSize = 100/2; // 100 pixels - half that size works better - } else if (data.amar.fragments) { - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/amar_shard.png'; - iconSize = 60/2; // 60 pixels - half that size works better - opacity = 0.6; // these often hide portals - let's make them semi transparent - } - } - - // 2014-08-09 - helios artifacts. original guess was slightly wrong - if (data.helios) { - if (data.helios.target) { - // target portal - show the target marker. helios target marker doesn't fill like the earlier jarvis/amar targets - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/helios_shard_target.png'; - iconSize = 100/2; // 100 pixels - half that size works better - } else if (data.helios.fragments) { - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/helios_shard.png'; - iconSize = 60/2; // 60 pixels - half that size works better - opacity = 0.6; // these often hide portals - let's make them semi transparent - } - } - - // 2015-03-05 - shonin shards - if (data.shonin) { - if (data.shonin.target) { - // target portal - show the target marker. - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/shonin_shard_target.png'; - iconSize = 100/2; // 100 pixels - half that size works better - } else if (data.shonin.fragments) { - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/shonin_shard.png'; - iconSize = 60/2; // 60 pixels - half that size works better - opacity = 0.6; // these often hide portals - let's make them semi transparent - } - } - - // 2015-04-22 - lightman fragments (guessed) - if (data.lightman) { - if (data.lightman.target) { - // target portal - show the target marker. - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/lightman_shard_target.png'; - iconSize = 100/2; // 100 pixels - half that size works better - } else if (data.lightman.fragments) { - iconUrl = '//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/lightman_shard.png'; - iconSize = 60/2; // 60 pixels - half that size works better - opacity = 0.6; // these often hide portals - let's make them semi transparent - } - } - - if (iconUrl) { - var icon = L.icon({ - iconUrl: iconUrl, - iconSize: [iconSize,iconSize], - iconAnchor: [iconSize/2,iconSize/2], - className: 'no-pointer-events' // the clickable: false below still blocks events going through to the svg underneath - }); - - var marker = L.marker (latlng, {icon: icon, clickable: false, keyboard: false, opacity: opacity }); - - artifact._layer.addLayer(marker); - } else { - console.warn('Oops! no URL for artifact portal icon?!'); - } - }); + }); //end $.each(artifact.portalInfo, function(guid,data) } @@ -293,9 +222,9 @@ window.artifact.showArtifactList = function() { var first = true; $.each(artifact.artifactTypes, function(type,type2) { - var description = artifact.getArtifactDescriptions(type); - - var name = description ? description.title : ('unknown artifact type: '+type); + // no nice way to convert the Niantic internal name into the correct display name + // (we do get the description string once a portal with that shard type is selected - could cache that somewhere?) + var name = type.capitalize() + ' shards'; if (!first) html += '
In Summer 2015, Niantic changed the data format for artifact portals. We no longer know:
" + + "You can select a portal and the detailed data contains the list of shard numbers, but there's still no" + + " more information on targets.
"; + dialog({ title: 'Artifacts', html: html, diff --git a/code/boot.js b/code/boot.js index 4ebce81f..905182cb 100644 --- a/code/boot.js +++ b/code/boot.js @@ -519,7 +519,11 @@ window.setupLayerChooserApi = function() { var baseLayersJSON = JSON.stringify(baseLayers); if (typeof android !== 'undefined' && android && android.setLayers) { - android.setLayers(baseLayersJSON, overlayLayersJSON); + if(this.androidTimer) clearTimeout(this.androidTimer); + this.androidTimer = setTimeout(function() { + this.androidTimer = null; + android.setLayers(baseLayersJSON, overlayLayersJSON); + }, 1000); } return { @@ -561,6 +565,27 @@ window.setupLayerChooserApi = function() { } return true; + }; + + var _update = window.layerChooser._update; + window.layerChooser._update = function() { + // update layer menu in IITCm + try { + if(typeof android != 'undefined') + window.layerChooser.getLayers(); + } catch(e) { + console.error(e); + } + // call through + return _update.apply(this, arguments); + } + // as this setupLayerChooserApi function is called after the layer menu is populated, we need to also get they layers once + // so they're passed through to the android app + try { + if(typeof android != 'undefined') + window.layerChooser.getLayers(); + } catch(e) { + console.error(e); } } @@ -586,7 +611,6 @@ function boot() { }}); window.extractFromStock(); - window.iitc_bg.init(); //NOTE: needs to be early (before any requests sent), but after extractFromStock() window.setupIdle(); window.setupTaphold(); window.setupStyles(); diff --git a/code/botguard_interface.js b/code/botguard_interface.js deleted file mode 100644 index e960723b..00000000 --- a/code/botguard_interface.js +++ /dev/null @@ -1,326 +0,0 @@ -// interface to the use of the google 'botguard' javascript added to the intel site - - -iitc_bg = Object(); - -iitc_bg.DISABLED = false; //if set, botguard is disabld. no b/c params sent in requests, no processing of responses - -iitc_bg.init = function() { - if (iitc_bg.DISABLED) return; - -// stock site - 'ad.e()' constructor -//function Ad() { -// this.Eb = {}; // a map, indexed by 'group-[ab]-actions', each entry containing an array of 'yd' objects (simple object, with 'gf' and 'cb' members). a queue of data to process? -// this.Oa = {}; // a map, indexed by 'group-[ab]-actions', each entry containing an object with an 'invoke' method -// this.Zc = {}; // a map, indexed by group, witn constructors for botguard -// this.eb = ""; // the 'key' - B -// this.Kh = e; // e is defined in the main web page as "var e = function(w) {eval(w);};" -//} - - var botguard_eval = e; - - iitc_bg.data_queue = {}; //.lb - indexed by group - iitc_bg.instance_queue = {}; //.ya - indexed by group - iitc_bg.key = ""; //.cb - iitc_bg.botguard = {}; //.qc - indexed by key - iitc_bg.evalFunc = botguard_eval; - -// stock site code -//Ad.prototype.U = function(a, b) { -// Bd(this, a); -// for (var c in b) if ("group-b-actions" == c || "group-a-actions" == c) { -// for (var d = 0; d < b[c].length; ++d) Dd(this, c, new Ed(b[c][d], a)); -// Fd(this, c); -// } -//}; -// and.. Ed - a simple object that holds it's parameters and the timestamp it was created -//function Ed(a, b) { -// var c = w(); -// this.mg = a; -// this.eb = b; -// this.Ki = c; -//} - - - // to initialise, we need four things - // B - a key(?). set in the main web page HTML, name isn't changed on site updates - // CS - initialisation data for botguard - again in the main page, again name is constant - - - var botguard_key = B; - var botguard_data = CS; - - iitc_bg.process_key(botguard_key); - - for (var group in botguard_data) { - // TODO? filter this loop by group-[ab]-actions only? the stock site does, but this seems unnecessary - - // the stock site has code to create the emtpy arrays with the group index as and when needed - // however, it makes more sense to do it here just once, rather than testing every time - iitc_bg.data_queue[group] = []; - iitc_bg.instance_queue[group] = []; - - for (var i=0; i < botguard_data[group].length; i++) { - iitc_bg.push_queue(group, botguard_data[group][i], botguard_key); - } - - iitc_bg.process_queue(group); - } -}; - -//TODO: better name - will do for now... -iitc_bg.push_queue = function(group, data, key) { -//stock site code -//a=='this', b==group-[ab]-actions, c==object with .mg==data, .eb==key, .Ki==current timestamp -//function Dd(a, b, c) { -// var d = c.eb && a.Zc[c.eb]; -// if ("dkxm" == c.mg || d) a.Eb[b] || (a.Eb[b] = []), a.Eb[b].push(c); -//} - - // Niantic have changed how the server returns data items to the client a few times, which cause - // bad non-string items to come into here. this breaks things badly - if (typeof data !== "string") throw "Error: iitc_bg.process_queue got dodgy data - expected a string"; - - var botguard_instance = iitc_bg.key && iitc_bg.botguard[iitc_bg.key]; - - if (data == "dkxm" || botguard_instance) { - // NOTE: we pre-create empty per-group arrays on init - iitc_bg.data_queue[group].push( {data: data, key: key, time: Date.now()} ); - } -}; - - -// called both on initialisation and on processing responses from the server -// -iitc_bg.process_key = function(key,serverEval) { - if (iitc_bg.DISABLED) return; - - -// stock site code -//function Bd(a, b, c) { -// if (a.Zc[b]) a.eb = b; else { -// var d = !0; -// if (c) try { -// a.Kh(c); -// } catch (f) { -// d = !1; -// } -// d && (a.Zc[b] = botguard.bg, a.eb = b); -// } -//} - - if (iitc_bg.botguard[key]) { - iitc_bg.key = key; - } else { - var noCatch = true; - - if (serverEval) { - // server wants us to eval some code! risky, and impossible to be certain we can do it safely - // seems to always be the same javascript as already found in the web page source. - try { - console.warn('botguard: Server-generated javascript eval requested:\n'+serverEval); - iitc_bg.evalFunc(serverEval); - console.log('botguard: Server-generated javascript ran OK'); - } catch(e) { - console.warn('botguard: Server-generated javascript - threw an exception'); - console.warn(e); - noCatch = false; - } - } - if (noCatch) { - iitc_bg.botguard[key] = botguard.bg; - iitc_bg.key = key; - } - } -}; - - -//convert a method name to the group-[ab]-actions value, or return 'undefined' for no group -//NOTE: the stock code separates the 'in any group' and 'which group' test, but it's cleaner to combine them -//UPDATE: the 'not in any group' case was removed from the stock site logic -iitc_bg.get_method_group = function(method) { -//stock site -//function Cd(a) { -// return -1 != ig.indexOf(a) ? "group-a-actions" : "group-b-actions"; -//} - - if (window.niantic_params.botguard_method_group_flag[method] === undefined) { - throw 'Error: method '+method+' not found in the botguard_method_group_flag object'; - } - - if (window.niantic_params.botguard_method_group_flag[method]) { - return "ingress-a-actions"; - } else { - return "ingress-b-actions"; - } -}; - - - - -// returns the extra parameters to add to any JSON request -iitc_bg.extra_request_params = function(method) { - if (iitc_bg.DISABLED) return {}; - - - var extra = {}; - extra.b = iitc_bg.key; - extra.c = iitc_bg.get_request_data(method); - - return extra; -}; - -iitc_bg.get_request_data = function(method) { -//function Id(a, b) { -// var c = "mxkd", d = Cd(b); -// a.Oa[d] && 0 < a.Oa[d].length && a.Oa[d].shift().invoke(function(a) { -// c = a; -// }); -// Fd(a, d); -// return c; -//} - - - var group = iitc_bg.get_method_group(method); - if (!group) { - return ""; - } - - // this seems to be some kind of 'flag' string - and is either "mxkd" or "dkxm". it can be returned from the - // server, so we stick with the same string rather than something more sensibly named - var data = "mxkd"; - - if (iitc_bg.instance_queue[group] && iitc_bg.instance_queue[group].length > 0) { - var instance = iitc_bg.instance_queue[group].shift(); - instance.invoke(function(a) { data=a; }); - }; - - iitc_bg.process_queue(group); - - if (data.indexOf('undefined is not a function') != -1) { - // there's been cases of something going iffy in the botguard code, or IITC's interface to it. in this case, - // instead of the correct encoded string, the data contains an error message along the lines of - // "E:undefined is not a function:TypeError: undefined is not a function"[...] - // in this case, better to just stop with some kind of error than send the string to the server - debugger; - throw ('Error: iitc_bg.get_request_data got bad data - cannot safely continue'); - } - - return data; -}; - -// stock site - 'dummy' botguard object -//function Sf() {} -//Sf.prototype.invoke = function(a) { -// a("dkxm"); -//}; - -iitc_bg.dummy_botguard = function() {}; -iitc_bg.dummy_botguard.prototype.invoke = function(callback) { - callback("dkxm"); -}; - - -iitc_bg.process_queue = function(group) { -//stock site -//function Fd(a, b) { -// if (a.Eb[b]) for (; 0 < a.Eb[b].length; ) { -// var c = a.Eb[b].shift(), d = c.mg, f = c.Ki + 717e4, g; -// "dkxm" == d ? g = new jg : w() < f && (g = new a.Zc[c.eb](d)); -// if (g) { -// c = a; -// d = b; -// c.Oa[d] || (c.Oa[d] = []); -// c.Oa[d].push(g); -// break; -// } -// } -//} - -// processes an entry in the queue for the specified group - - while (iitc_bg.data_queue[group] && iitc_bg.data_queue[group].length > 0) { - var item = iitc_bg.data_queue[group].shift(); - var obj = undefined; - - if (item.data == "dkxm") { - obj = new iitc_bg.dummy_botguard; - } else if (Date.now() < item.time + 7170000) { - obj = new iitc_bg.botguard[item.key](item.data); - } - - // note: we pre-create empty per-group arrays on init - if (obj) { - iitc_bg.instance_queue[group].push(obj); - break; - } - } - -}; - - -iitc_bg.process_response_params = function(method,data) { - if (iitc_bg.DISABLED) { - // the rest of IITC won't expect these strange a/b/c params in the response data - // (e.g. it's pointless to keep in the cache, etc), so clear them - delete data.a; - delete data.b; - delete data.c; - return; - } - -// stock site: response processing -//yd.prototype.vi = function(a, b) { -// var c = b.target; -// if (cd(c)) { -// this.Ib.reset(); -// var d = a.hg, f = JSON.parse(ed(c)); -// if (f.c && 0 < f.c.length) { -// var g = Ad.e(), h = a.getMethodName(), l = f.a, m = f.b, r = f.c[0]; -// m && l && Bd(g, m, l); -// h = Cd(h); -// if (r && m && 0 < r.length) for (l = 0; l < r.length; ++l) Dd(g, h, new Ed(r[l], m)); -// g.Oa[h] && 0 != g.Oa[h].length || Fd(g, h); -// } -// "error" in f && "out of date" == f.error ? (d = rd.e(), Gd(!1), d.ie = !0, Hd("Please refresh for the latest version.")) : "error" in f && "RETRY" == f.error ? (this.Da.ja(1, a), td(this.Ib)) : n.setTimeout(pa(d, f), 0); -// } else this.Ib.Ec = !0, d = a.cg, ha(d) && (f = { -// error: dd(c) || "unknown", -// respStatus: c.getStatus() -// }, n.setTimeout(pa(d, f), 0)); -// d = this.Te; -// d.Aa.remove(c) && d.zc(c); -//}; - - - if (data.c && data.c.length > 0) { - - if (data.b && data.a) { - // in this case, we *EVAL* the 'data.a' string from the server! - // however, it's not a case that's been ever triggered in normal use, as far as I know - iitc_bg.process_key(data.b, data.a); - } - - var group = iitc_bg.get_method_group(method); - -//NOTE: I missed a change here a while ago. originally data.c was a single-level array of items to push on the queue, -//but now it's a two-dimensional array, and it's only index zero that's used! - var data_items = data.c[0]; - - if (data_items && data.b && data_items.length > 0) { - for (var i=0; iIITC failed to extract the required parameters from the intel site
' diff --git a/code/hooks.js b/code/hooks.js index 2fd82477..9629647e 100644 --- a/code/hooks.js +++ b/code/hooks.js @@ -18,6 +18,7 @@ // portalSelected: called when portal on map is selected/unselected. // Provide guid of selected and unselected portal. // mapDataRefreshStart: called when we start refreshing map data +// mapDataEntityInject: called just as we start to render data. has callback to inject cached entities into the map render // mapDataRefreshEnd: called when we complete the map data load // portalAdded: called when a portal has been received and is about to // be added to its layer group. Note that this does NOT @@ -51,11 +52,13 @@ // this only selects the current chat pane; on mobile, it // also switches between map, info and other panes defined // by plugins +// artifactsUpdated: called when the set of artifacts (including targets) +// has changed. Parameters names are old, new. window._hooks = {} window.VALID_HOOKS = [ - 'portalSelected', 'portalDetailsUpdated', - 'mapDataRefreshStart', 'mapDataRefreshEnd', + 'portalSelected', 'portalDetailsUpdated', 'artifactsUpdated', + 'mapDataRefreshStart', 'mapDataEntityInject', 'mapDataRefreshEnd', 'portalAdded', 'linkAdded', 'fieldAdded', 'publicChatDataAvailable', 'factionChatDataAvailable', 'requestFinished', 'nicknameClicked', @@ -103,3 +106,16 @@ window.addHook = function(event, callback) { else _hooks[event].push(callback); } + +// callback must the SAME function to be unregistered. +window.removeHook = function(event, callback) { + if (typeof callback !== 'function') throw('Callback must be a function.'); + + if (_hooks[event]) { + var index = _hooks[event].indexOf(callback); + if(index == -1) + console.warn('Callback wasn\'t registered for this event.'); + else + _hooks[event].splice(index, 1); + } +} diff --git a/code/map_data_calc_tools.js b/code/map_data_calc_tools.js index 34636500..54736bb2 100755 --- a/code/map_data_calc_tools.js +++ b/code/map_data_calc_tools.js @@ -12,12 +12,18 @@ window.setupDataTileParams = function() { // default values - used to fall back to if we can't detect those used in stock intel - var DEFAULT_ZOOM_TO_TILES_PER_EDGE = [256, 256, 256, 256, 512, 512, 512, 2048, 2048, 2048, 4096, 4096, 6500, 6500, 6500, 18e3, 18e3, 36e3]; - var DEFAULT_ZOOM_TO_LEVEL = [ 8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 4, 3, 2, 2, 1, 1 ]; + var DEFAULT_ZOOM_TO_TILES_PER_EDGE = [1,1,1,40,40,80,80,320,1000,2000,2000,4000,8000,16000,16000,32000]; + var DEFAULT_ZOOM_TO_LEVEL = [8,8,8,8,7,7,7,6,6,5,4,4,3,2,2,1,1]; + // stock intel doesn't have this array (they use a switch statement instead), but this is far neater + var DEFAULT_ZOOM_TO_LINK_LENGTH = [200000,200000,200000,200000,200000,60000,60000,10000,5000,2500,2500,800,300,0,0]; window.TILE_PARAMS = {}; + // not in stock to detect - we'll have to assume the above values... + window.TILE_PARAMS.ZOOM_TO_LINK_LENGTH = DEFAULT_ZOOM_TO_LINK_LENGTH; + + if (niantic_params.ZOOM_TO_LEVEL && niantic_params.TILES_PER_EDGE) { window.TILE_PARAMS.ZOOM_TO_LEVEL = niantic_params.ZOOM_TO_LEVEL; window.TILE_PARAMS.TILES_PER_EDGE = niantic_params.TILES_PER_EDGE; @@ -25,22 +31,56 @@ window.setupDataTileParams = function() { // lazy numerical array comparison if ( JSON.stringify(niantic_params.ZOOM_TO_LEVEL) != JSON.stringify(DEFAULT_ZOOM_TO_LEVEL)) { - console.warn('Tile parameter ZOOM_TO_LEVEL have changed in stock intel. Detectec correct values, but code should be updated'); + console.warn('Tile parameter ZOOM_TO_LEVEL have changed in stock intel. Detected correct values, but code should be updated'); debugger; } if ( JSON.stringify(niantic_params.TILES_PER_EDGE) != JSON.stringify(DEFAULT_ZOOM_TO_TILES_PER_EDGE)) { - console.warn('Tile parameter ZOOM_TO_LEVEL have changed in stock intel. Detectec correct values, but code should be updated'); + console.warn('Tile parameter TILES_PER_EDGE have changed in stock intel. Detected correct values, but code should be updated'); debugger; } } else { - console.warn('Failed to detect both ZOOM_TO_LEVEL and TILES_PER_EDGE in the stock intel site - using internal defaults'); - debugger; + dialog({ + title: 'IITC Warning', + html: "IITC failed to detect the ZOOM_TO_LEVEL and/or TILES_PER_EDGE settings from the stock intel site.
" + +"IITC is now using fallback default values. However, if detection has failed it's likely the values have changed." + +" IITC may not load the map if these default values are wrong.
", + }); window.TILE_PARAMS.ZOOM_TO_LEVEL = DEFAULT_ZOOM_TO_LEVEL; window.TILE_PARAMS.TILES_PER_EDGE = DEFAULT_ZOOM_TO_TILES_PER_EDGE; } + // 2015-07-01: niantic added code to the stock site that overrides the min zoom level for unclaimed portals to 15 and above + // instead of updating the zoom-to-level array. makes no sense really.... + // we'll just chop off the array at that point, so the code defaults to level 0 (unclaimed) everywhere... + window.TILE_PARAMS.ZOOM_TO_LEVEL = window.TILE_PARAMS.ZOOM_TO_LEVEL.slice(0,15); + +} + + +window.debugMapZoomParameters = function() { + + //for debug purposes, log the tile params used for each zoom level + console.log('DEBUG: Map Zoom Parameters'); + var doneZooms = {}; + for (var z=MIN_ZOOM; z<=21; z++) { + var ourZoom = getDataZoomForMapZoom(z); + console.log('DEBUG: map zoom '+z+': IITC requests '+ourZoom+(ourZoom!=z?' instead':'')); + if (!doneZooms[ourZoom]) { + var params = getMapZoomTileParameters(ourZoom); + var msg = 'DEBUG: data zoom '+ourZoom; + if (params.hasPortals) { + msg += ' has portals, L'+params.level+'+'; + } else { + msg += ' NO portals (was L'+params.level+'+)'; + } + msg += ', minLinkLength='+params.minLinkLength; + msg += ', tiles per edge='+params.tilesPerEdge; + console.log(msg); + doneZooms[ourZoom] = true; + } + } } @@ -54,12 +94,12 @@ window.getMapZoomTileParameters = function(zoom) { var level = window.TILE_PARAMS.ZOOM_TO_LEVEL[zoom] || 0; // default to level 0 (all portals) if not in array - if (window.CONFIG_ZOOM_SHOW_LESS_PORTALS_ZOOMED_OUT) { - if (level <= 7 && level >= 4) { - // reduce portal detail level by one - helps reduce clutter - level = level+1; - } - } +// if (window.CONFIG_ZOOM_SHOW_LESS_PORTALS_ZOOMED_OUT) { +// if (level <= 7 && level >= 4) { +// // reduce portal detail level by one - helps reduce clutter +// level = level+1; +// } +// } var maxTilesPerEdge = window.TILE_PARAMS.TILES_PER_EDGE[window.TILE_PARAMS.TILES_PER_EDGE.length-1]; @@ -67,6 +107,8 @@ window.getMapZoomTileParameters = function(zoom) { level: level, maxLevel: window.TILE_PARAMS.ZOOM_TO_LEVEL[zoom] || 0, // for reference, for log purposes, etc tilesPerEdge: window.TILE_PARAMS.TILES_PER_EDGE[zoom] || maxTilesPerEdge, + minLinkLength: window.TILE_PARAMS.ZOOM_TO_LINK_LENGTH[zoom] || 0, + hasPortals: zoom >= window.TILE_PARAMS.ZOOM_TO_LINK_LENGTH.length, // no portals returned at all when link length limits things zoom: zoom // include the zoom level, for reference }; } @@ -83,16 +125,6 @@ window.getDataZoomForMapZoom = function(zoom) { zoom = 21; } - if (window.CONFIG_ZOOM_SHOW_MORE_PORTALS) { - // slightly unfriendly to the servers, requesting more, but smaller, tiles, for the 'unclaimed' level of detail - // however, server load issues are all related to the map area in view, with no real issues related to detail level - // therefore, I believel we can get away with these smaller tiles for one or two further zoom levels without issues - - if (zoom == 16) { - zoom = 17; - } - } - if (!window.CONFIG_ZOOM_DEFAULT_DETAIL_LEVEL) { @@ -104,7 +136,11 @@ window.getDataZoomForMapZoom = function(zoom) { while (zoom > MIN_ZOOM) { var newTileParams = getMapZoomTileParameters(zoom-1); - if (newTileParams.tilesPerEdge != origTileParams.tilesPerEdge || newTileParams.level != origTileParams.level) { + + if ( newTileParams.tilesPerEdge != origTileParams.tilesPerEdge + || newTileParams.hasPortals != origTileParams.hasPortals + || newTileParams.level*newTileParams.hasPortals != origTileParams.level*origTileParams.hasPortals // multiply by 'hasPortals' bool - so comparison does not matter when no portals available + ) { // switching to zoom-1 would result in a different detail level - so we abort changing things break; } else { diff --git a/code/map_data_render.js b/code/map_data_render.js index a113620b..d3811a15 100644 --- a/code/map_data_render.js +++ b/code/map_data_render.js @@ -4,7 +4,6 @@ window.Render = function() { - this.portalMarkerScale = undefined; } @@ -25,7 +24,7 @@ window.Render.prototype.startRenderPass = function(level,bounds) { // this will just avoid a few entity removals at start of render when they'll just be added again var paddedBounds = bounds.pad(0.1); - this.clearPortalsBelowLevelOrOutsideBounds(level,paddedBounds); + this.clearPortalsOutsideBounds(paddedBounds); this.clearLinksOutsideBounds(paddedBounds); this.clearFieldsOutsideBounds(paddedBounds); @@ -34,12 +33,12 @@ window.Render.prototype.startRenderPass = function(level,bounds) { this.rescalePortalMarkers(); } -window.Render.prototype.clearPortalsBelowLevelOrOutsideBounds = function(level,bounds) { +window.Render.prototype.clearPortalsOutsideBounds = function(bounds) { var count = 0; for (var guid in window.portals) { var p = portals[guid]; - // clear portals below specified level - unless it's the selected portal, or it's relevant to artifacts - if ((parseInt(p.options.level) < level || !bounds.contains(p.getLatLng())) && guid !== selectedPortal && !artifact.isInterestingPortal(guid)) { + // clear portals outside visible bounds - unless it's the selected portal, or it's relevant to artifacts + if (!bounds.contains(p.getLatLng()) && guid !== selectedPortal && !artifact.isInterestingPortal(guid)) { this.deletePortalEntity(guid); count++; } @@ -110,7 +109,7 @@ window.Render.prototype.processDeletedGameEntityGuids = function(deleted) { } -window.Render.prototype.processGameEntities = function(entities,ignoreLevel) { +window.Render.prototype.processGameEntities = function(entities) { // we loop through the entities three times - for fields, links and portals separately // this is a reasonably efficient work-around for leafletjs limitations on svg render order @@ -131,27 +130,13 @@ window.Render.prototype.processGameEntities = function(entities,ignoreLevel) { } } - // 2015-03-12 - Niantic have been returning all mission portals to the client, ignoring portal level - // and density filtering usually in use. this makes things unusable when viewing the global view, so we - // filter these out - var minLevel = ignoreLevel ? 0 : this.level; - var ignoredCount = 0; - for (var i in entities) { var ent = entities[i]; if (ent[2][0] == 'p' && !(ent[0] in this.deletedGuid)) { - var portalLevel = ent[2][1] == 'N' ? 0 : parseInt(ent[2][4]); - if (portalLevel >= minLevel) { - this.createPortalEntity(ent); - } else { - ignoredCount++; - } - + this.createPortalEntity(ent); } } - - if (ignoredCount) console.log('Render: ignored '+ignoredCount+' portals below the level requested from the server'); } @@ -263,6 +248,39 @@ window.Render.prototype.deleteFieldEntity = function(guid) { } +window.Render.prototype.createPlaceholderPortalEntity = function(guid,latE6,lngE6,team) { + // intel no longer returns portals at anything but the closest zoom + // stock intel creates 'placeholder' portals from the data in links/fields - IITC needs to do the same + // we only have the portal guid, lat/lng coords, and the faction - no other data + // having the guid, at least, allows the portal details to be loaded once it's selected. however, + // no highlighters, portal level numbers, portal names, useful counts of portals, etc are possible + + + var ent = [ + guid, //ent[0] = guid + 0, //ent[1] = timestamp - zero will mean any other source of portal data will have a higher timestamp + //ent[2] = an array with the entity data + [ 'p', //0 - a portal + team, //1 - team + latE6, //2 - lat + lngE6 //3 - lng + ] + ]; + + // placeholder portals don't have a useful timestamp value - so the standard code that checks for updated + // portal details doesn't apply + // so, check that the basic details are valid and delete the existing portal if out of date + if (guid in window.portals) { + var p = window.portals[guid]; + if (team != p.options.data.team || latE6 != p.options.data.latE6 || lngE6 != p.options.data.lngE6) { + // team or location have changed - delete existing portal + this.deletePortalEntity(guid); + } + } + + this.createPortalEntity(ent); + +} window.Render.prototype.createPortalEntity = function(ent) { @@ -288,7 +306,7 @@ window.Render.prototype.createPortalEntity = function(ent) { this.deletePortalEntity(ent[0]); } - var portalLevel = parseInt(ent[2][4]); + var portalLevel = parseInt(ent[2][4])||0; var team = teamStringToId(ent[2][1]); // the data returns unclaimed portals as level 1 - but IITC wants them treated as level 0 if (team == TEAM_NONE) portalLevel = 0; @@ -350,6 +368,18 @@ window.Render.prototype.createPortalEntity = function(ent) { window.Render.prototype.createFieldEntity = function(ent) { this.seenFieldsGuid[ent[0]] = true; // flag we've seen it + var data = { +// type: ent[2][0], + team: ent[2][1], + points: ent[2][2].map(function(arr) { return {guid: arr[0], latE6: arr[1], lngE6: arr[2] }; }) + }; + + //create placeholder portals for field corners. we already do links, but there are the odd case where this is useful + for (var i=0; i<3; i++) { + var p=data.points[i]; + this.createPlaceholderPortalEntity(p.guid, p.latE6, p.lngE6, data.team); + } + // check if entity already exists if(ent[0] in window.fields) { // yes. in theory, we should never get updated data for an existing field. they're created, and they're destroyed - never changed @@ -364,12 +394,6 @@ window.Render.prototype.createFieldEntity = function(ent) { this.deleteFieldEntity(ent[0]); // option 2, for now } - var data = { -// type: ent[2][0], - team: ent[2][1], - points: ent[2][2].map(function(arr) { return {guid: arr[0], latE6: arr[1], lngE6: arr[2] }; }) - }; - var team = teamStringToId(ent[2][1]); var latlngs = [ L.latLng(data.points[0].latE6/1E6, data.points[0].lngE6/1E6), @@ -399,8 +423,31 @@ window.Render.prototype.createFieldEntity = function(ent) { } window.Render.prototype.createLinkEntity = function(ent,faked) { + // Niantic have been faking link entities, based on data from fields + // these faked links are sent along with the real portal links, causing duplicates + // the faked ones all have longer GUIDs, based on the field GUID (with _ab, _ac, _bc appended) + var fakedLink = new RegExp("^[0-9a-f]{32}\.b_[ab][bc]$"); //field GUIDs always end with ".b" - faked links append the edge identifier + if (fakedLink.test(ent[0])) return; + + this.seenLinksGuid[ent[0]] = true; // flag we've seen it + var data = { // TODO add other properties and check correction direction +// type: ent[2][0], + team: ent[2][1], + oGuid: ent[2][2], + oLatE6: ent[2][3], + oLngE6: ent[2][4], + dGuid: ent[2][5], + dLatE6: ent[2][6], + dLngE6: ent[2][7] + }; + + // create placeholder entities for link start and end points (before checking if the link itself already exists + this.createPlaceholderPortalEntity(data.oGuid, data.oLatE6, data.oLngE6, data.team); + this.createPlaceholderPortalEntity(data.dGuid, data.dLatE6, data.dLngE6, data.team); + + // check if entity already exists if (ent[0] in window.links) { // yes. now, as sometimes links are 'faked', they have incomplete data. if the data we have is better, replace the data @@ -415,17 +462,6 @@ window.Render.prototype.createLinkEntity = function(ent,faked) { this.deleteLinkEntity(ent[0]); // option 2 - for now } - var data = { // TODO add other properties and check correction direction -// type: ent[2][0], - team: ent[2][1], - oGuid: ent[2][2], - oLatE6: ent[2][3], - oLngE6: ent[2][4], - dGuid: ent[2][5], - dLatE6: ent[2][6], - dLngE6: ent[2][7] - }; - var team = teamStringToId(ent[2][1]); var latlngs = [ L.latLng(data.oLatE6/1E6, data.oLngE6/1E6), @@ -469,12 +505,12 @@ window.Render.prototype.rescalePortalMarkers = function() { // add the portal to the visible map layer window.Render.prototype.addPortalToMapLayer = function(portal) { - portalsFactionLayers[parseInt(portal.options.level)][portal.options.team].addLayer(portal); + portalsFactionLayers[parseInt(portal.options.level)||0][portal.options.team].addLayer(portal); } window.Render.prototype.removePortalFromMapLayer = function(portal) { //remove it from the portalsLevels layer - portalsFactionLayers[parseInt(portal.options.level)][portal.options.team].removeLayer(portal); + portalsFactionLayers[parseInt(portal.options.level)||0][portal.options.team].removeLayer(portal); } diff --git a/code/map_data_request.js b/code/map_data_request.js index 12ad93c8..514eabd8 100644 --- a/code/map_data_request.js +++ b/code/map_data_request.js @@ -57,7 +57,7 @@ window.MapDataRequest = function() { // render queue // number of items to process in each render pass. there are pros and cons to smaller and larger values // (however, if using leaflet canvas rendering, it makes sense to push as much as possible through every time) - this.RENDER_BATCH_SIZE = L.Path.CANVAS ? 1E9 : 500; + this.RENDER_BATCH_SIZE = L.Path.CANVAS ? 1E9 : 1500; // delay before repeating the render loop. this gives a better chance for user interaction this.RENDER_PAUSE = (typeof android === 'undefined') ? 0.1 : 0.2; //100ms desktop, 200ms mobile @@ -69,6 +69,13 @@ window.MapDataRequest = function() { // ensure we have some initial map status this.setStatus ('startup', undefined, -1); + + // add a portalDetailLoaded hook, so we can use the exteneed details to update portals on the map + var _this = this; + addHook('portalDetailLoaded',function(data){ + _this.render.processGameEntities([data.ent]); + }); + } @@ -232,8 +239,11 @@ window.MapDataRequest.prototype.refresh = function() { this.render.startRenderPass(tileParams.level, dataBounds); + var _render = this.render; + window.runHooks ('mapDataEntityInject', {callback: function(ents) { _render.processGameEntities(ents);}}); - this.render.processGameEntities(artifact.getArtifactEntities(),true); + + this.render.processGameEntities(artifact.getArtifactEntities()); var logMessage = 'requesting data tiles at zoom '+dataZoom; if (tileParams.level != tileParams.maxLevel) { diff --git a/code/ornaments.js b/code/ornaments.js index efcbdad0..e5b35633 100644 --- a/code/ornaments.js +++ b/code/ornaments.js @@ -33,16 +33,18 @@ window.ornaments.addPortal = function(portal) { var size = window.ornaments.OVERLAY_SIZE; var latlng = portal.getLatLng(); - window.ornaments._portals[guid] = portal.options.data.ornaments.map(function(ornament) { - var icon = L.icon({ - iconUrl: "//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/"+ornament+".png", - iconSize: [size, size], - iconAnchor: [size/2,size/2], - className: 'no-pointer-events' // the clickable: false below still blocks events going through to the svg underneath - }); + if (portal.options.data.ornaments) { + window.ornaments._portals[guid] = portal.options.data.ornaments.map(function(ornament) { + var icon = L.icon({ + iconUrl: "//commondatastorage.googleapis.com/ingress.com/img/map_icons/marker_images/"+ornament+".png", + iconSize: [size, size], + iconAnchor: [size/2,size/2], + className: 'no-pointer-events' // the clickable: false below still blocks events going through to the svg underneath + }); - return L.marker(latlng, {icon: icon, clickable: false, keyboard: false, opacity: window.ornaments.OVERLAY_OPACITY }).addTo(window.ornaments._layer); - }); + return L.marker(latlng, {icon: icon, clickable: false, keyboard: false, opacity: window.ornaments.OVERLAY_OPACITY }).addTo(window.ornaments._layer); + }); + } } window.ornaments.removePortal = function(portal) { diff --git a/code/portal_detail.js b/code/portal_detail.js index 8e8f80a7..55400098 100644 --- a/code/portal_detail.js +++ b/code/portal_detail.js @@ -37,8 +37,12 @@ var handleResponse = function(guid, data, success) { } if (success) { + var dict = decodeArray.portalDetail(data.result); + // entity format, as used in map data + var ent = [guid,dict.timestamp,data.result]; + cache.store(guid,dict); //FIXME..? better way of handling sidebar refreshing... @@ -47,7 +51,7 @@ var handleResponse = function(guid, data, success) { renderPortalDetails(guid); } - window.runHooks ('portalDetailLoaded', {guid:guid, success:success, details:dict}); + window.runHooks ('portalDetailLoaded', {guid:guid, success:success, details:dict, ent:ent}); } else { if (data && data.error == "RETRY") { diff --git a/code/portal_detail_display.js b/code/portal_detail_display.js index 3c51cb80..dc4793df 100644 --- a/code/portal_detail_display.js +++ b/code/portal_detail_display.js @@ -41,46 +41,13 @@ window.renderPortalDetails = function(guid) { var img = fixPortalImageUrl(details ? details.image : data.image); - var title = data.title; + var title = (details && details.title) || (data && data.title) || '(untitled)'; var lat = data.latE6/1E6; var lng = data.lngE6/1E6; - var imgTitle = details ? getPortalDescriptionFromDetails(details) : data.title; - imgTitle += '\n\nClick to show full image.'; - var portalDetailObj = details ? window.getPortalDescriptionFromDetailsExtended(details) : undefined; + var imgTitle = title+'\n\nClick to show full image.'; - var portalDetailedDescription = ''; - - if(portalDetailObj) { - portalDetailedDescription = 'Photo by: | ' + submitterSpan - + escapeHtmlSpecialChars(portalDetailObj.submitter.name) + ''+(portalDetailObj.submitter.voteCount !== undefined ? ' (' + portalDetailObj.submitter.voteCount + ' votes)' : '')+' |
---|---|
Photo from: | ' + escapeHtmlSpecialChars(portalDetailObj.submitter.link) + ' |
Description: | ' + escapeHtmlSpecialChars(portalDetailObj.description) + ' |
Address: | ' + escapeHtmlSpecialChars(d.descriptiveText.map.ADDRESS) + ' |
IITC is using an outdated version code. This will happen when Niantic update the standard intel site.
' + html: 'IITC is using an outdated version code. This will happen when Niantic updates the standard intel site.
' +'You need to reload the page to get the updated changes.
' +'If you have just reloaded the page, then an old version of the standard site script is cached somewhere.' +'In this case, try clearing your cache, or waiting 15-30 minutes for the stale data to expire.
', diff --git a/code/status_bar.js b/code/status_bar.js index 8aef2bb3..97703792 100644 --- a/code/status_bar.js +++ b/code/status_bar.js @@ -7,15 +7,32 @@ window.renderUpdateStatusTimer_ = undefined; window.renderUpdateStatus = function() { var progress = 1; - // portal level display - var t = ''; - if(!window.isSmartphone()) // space is valuable - t += 'portals: '; - var minlvl = getMinPortalLevel(); - if(minlvl === 0) - t+= 'all'; - else - t+= 'L'+minlvl+(minlvl<8?'+':'') + ''; + // portal/limk level display + + var zoom = map.getZoom(); + zoom = getDataZoomForMapZoom(zoom); + var tileParams = getMapZoomTileParameters(zoom); + + var t = ''; + + if (tileParams.hasPortals) { + // zoom level includes portals (and also all links/fields) + if(!window.isSmartphone()) // space is valuable + t += 'portals: '; + if(tileParams.level === 0) + t += 'all'; + else + t += 'L'+tileParams.level+(tileParams.level<8?'+':'') + ''; + } else { + if(!window.isSmartphone()) // space is valuable + t += 'links: '; + + if (tileParams.minLinkLength > 0) + t += '>'+(tileParams.minLinkLength>1000?tileParams.minLinkLength/1000+'km':tileParams.minLinkLength+'m')+''; + else + t += 'all links'; + } + t +=''; diff --git a/code/utils_misc.js b/code/utils_misc.js index f2f09ccb..b3009a58 100644 --- a/code/utils_misc.js +++ b/code/utils_misc.js @@ -335,10 +335,11 @@ window.uniqueArray = function(arr) { window.genFourColumnTable = function(blocks) { var t = $.map(blocks, function(detail, index) { if(!detail) return ''; + var title = detail[2] ? ' title="'+escapeHtmlSpecialChars(detail[2]) + '"' : ''; if(index % 2 === 0) - return '