From c33045859097324608624e69ed113bd205def2a3 Mon Sep 17 00:00:00 2001 From: Ant Zucaro Date: Sun, 7 Dec 2014 22:21:31 -0500 Subject: [PATCH] Use Google Charts instead of NVD3.js. The latter had some performance regressions after updating chrome. These regressions caused browsers to crash in the worse cases. Migrating to the Google Charts API provides a similar implementation with a much nicer API. We get: - Consistent colors for each weapon - Clickable data points (events on selection) - Tons of customization via a simple JSON options object - No additional source files - No need to buffer weapon stats JSON data with "zero" entries Right now the AJAX requests are still using D3.js style. The next step is to migrate them to jQuery or vanilla JS. --- xonstat/static/js/weaponCharts.js | 356 ++++++++++++++++++++--------- xonstat/templates/player_info.mako | 13 +- 2 files changed, 261 insertions(+), 108 deletions(-) diff --git a/xonstat/static/js/weaponCharts.js b/xonstat/static/js/weaponCharts.js index 8c49d8a..5760556 100644 --- a/xonstat/static/js/weaponCharts.js +++ b/xonstat/static/js/weaponCharts.js @@ -1,111 +1,263 @@ -var weaponColors = ["#ff5933", "#b2b2b2", "#66e559", "#ff2600", "#bfbf00", "#597fff", - "#d83fff", "#00e5ff", "#d87f59", "#ffbf33", "#7fff7f", "#a5a5ff", "#a5ffd8", - "#ffa533", "#ff5959", "#d87f3f", "#d87f3f", "#33ff33"]; - -var drawDamageChart = function(data) { - // the chart should fill the "damageChart" div - var width = document.getElementById("damageChart").offsetWidth; - - // transform the dataset into something nvd3 can use - var transformedData = d3.nest() - .key(function(d) { return d.weapon_cd; }).entries(data.weapon_stats); - - // transform games list into a map such that games[game_id] = linear sequence - var games = {}; - data.games.forEach(function(v,i){ games[v] = i; }); - - // margin model - var margin = {top: 20, right: 30, bottom: 30, left: 60}, - height = 300 - margin.top - margin.bottom; - - width -= margin.left - margin.right; - - nv.addGraph(function() { - chart = nv.models.stackedAreaChart() - .margin(margin) - .width(width) - .height(height) - .color(weaponColors) - .x(function(d) { return games[d.game_id] }) - .y(function(d) { return d.actual }) - .useInteractiveGuideline(true) - .controlsData(['Stacked','Expanded']) - .tooltips(true) - .tooltip(function(key, x, y, e, graph) { - return '

' + key + '

' + '

' + y + ' damage in game #' + x + '

'; - }); - - chart.xAxis.tickFormat(function(d) { return ''; }); - chart.yAxis.tickFormat(d3.format(',02d')); - - d3.select('#damageChartSVG') - .datum(transformedData) - .transition().duration(500).call(chart); - - nv.utils.windowResize(chart.update); - - return chart; - }); +// Colors assigned to the various weapons +var weaponColors = { + "laser": "#ff5933", + "shotgun": "#1f77b4", + "uzi": "#b9e659", + "grenadelauncher": "#ff2600", + "minelayer": "#bfbf00", + "electro": "#597fff", + "crylink": "#d940ff", + "nex": "#00e6ff", + "hagar": "#d98059", + "rocketlauncher": "#ffbf33", + "porto": "#7fff7f", + "minstanex": "#d62728", + "hook": "#a5ffd8", + "hlac": "#ffa533", + "seeker": "#ff5959", + "rifle": "#9467bd", + "tuba": "#d87f3f", + "fireball": "#33ff33" +}; + +// Flatten the existing weaponstats JSON requests +// to ease indexing +var flatten = function(weaponData) { + flattened = {} + + // each game is a key entry... + weaponData.games.forEach(function(e,i) { flattened[e] = {}; }); + + // ... with indexes by weapon_cd + weaponData.weapon_stats.forEach(function(e,i) { flattened[e.game_id][e.weapon_cd] = e; }); + + return flattened; +} + +// Calculate the Y value for a given weapon stat +function accuracyValue(gameWeaponStats, weapon) { + if (gameWeaponStats[weapon] == undefined) { + return null; + } + var ws = gameWeaponStats[weapon]; + var pct = ws.fired > 0 ? Math.round((ws.hit / ws.fired) * 100) : 0; + + return pct; +} + +// Calculate the tooltip text for a given weapon stat +function accuracyTooltip(weapon, pct, averages) { + if (pct == null) { + return null; + } + + var tt = weapon + ": " + pct.toString() + "%"; + if (averages[weapon] != undefined) { + return tt + " (" + averages[weapon].toString() + "% average)"; + } + + return tt; +} + +// Draw the accuracy chart in the "accuracyChart" div id +function drawAccuracyChart(weaponData) { + + var data = new google.visualization.DataTable(); + data.addColumn('string', 'X'); + data.addColumn('number', 'Shotgun'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Uzi'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Nex'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Minstanex'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Rifle'); + data.addColumn({type: 'string', role: 'tooltip'}); + + var flattened = flatten(weaponData); + + for(i in weaponData.games.slice(0,10)) { + var game_id = weaponData.games[i]; + var sg = accuracyValue(flattened[game_id], "shotgun"); + var sgTT = accuracyTooltip("shotgun", sg, weaponData.averages); + var uzi = accuracyValue(flattened[game_id], "uzi"); + var uziTT = accuracyTooltip("uzi", uzi, weaponData.averages); + var nex = accuracyValue(flattened[game_id], "nex"); + var nexTT = accuracyTooltip("nex", nex, weaponData.averages); + var mn = accuracyValue(flattened[game_id], "minstanex"); + var mnTT = accuracyTooltip("minstanex", mn, weaponData.averages); + var rifle = accuracyValue(flattened[game_id], "rifle"); + var rifleTT = accuracyTooltip("rifle", rifle, weaponData.averages); + + data.addRow([game_id.toString(), sg, sgTT, uzi, uziTT, nex, + nexTT, mn, mnTT, rifle, rifleTT]); + } + + var options = { + backgroundColor: { fill: 'transparent' }, + lineWidth: 2, + legend: { + textStyle: { color: "#666" } + }, + hAxis: { + title: 'Game ID', + titleTextStyle: { color: '#666' } + }, + vAxis: { + title: 'Percentage', + titleTextStyle: { color: '#666' }, + minValue: 0, + maxValue: 100, + baselineColor: '#333', + gridlineColor: '#333', + ticks: [20, 40, 60, 80, 100] + }, + series: { + 0: { color: weaponColors["shotgun"] }, + 1: { color: weaponColors["uzi"] }, + 2: { color: weaponColors["nex"] }, + 3: { color: weaponColors["minstanex"] }, + 4: { color: weaponColors["rifle"] } + } + }; + + var chart = new google.visualization.LineChart(document.getElementById('accuracyChart')); + + // a click on a point sends you to that games' page + var accuracySelectHandler = function(e) { + var selection = chart.getSelection()[0]; + if (selection != null && selection.row != null) { + var game_id = data.getFormattedValue(selection.row, 0); + window.location.href = "http://stats.xonotic.org/game/" + game_id.toString(); + } + }; + google.visualization.events.addListener(chart, 'select', accuracySelectHandler); + + chart.draw(data, options); } -var drawAccuracyChart = function(data) { - // the chart should fill the "accuracyChart" div - var width = document.getElementById("accuracyChart").offsetWidth; +// Calculate the damage Y value for a given weapon stat +function damageValue(gameWeaponStats, weapon) { + if (gameWeaponStats[weapon] == undefined) { + return null; + } + return gameWeaponStats[weapon].actual; +} - // get rid of empty values - data.weapon_stats = data.weapon_stats.filter(function(e){ return e.fired > 0; }); +// Calculate the damage tooltip text for a given weapon stat +function damageTooltip(weapon, dmg) { + if (dmg == null) { + return null; + } + return weapon + ": " + dmg.toString() + " HP damage"; +} + +// Draw the damage chart into the "damageChart" div id +function drawDamageChart(weaponData) { + + var data = new google.visualization.DataTable(); + data.addColumn('string', 'X'); + data.addColumn('number', 'Shotgun'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Uzi'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Nex'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Rifle'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Mortar'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Electro'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Crylink'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Hagar'); + data.addColumn({type: 'string', role: 'tooltip'}); + data.addColumn('number', 'Rocket Launcher'); + data.addColumn({type: 'string', role: 'tooltip'}); + + var flattened = flatten(weaponData); + + for(i in weaponData.games.slice(0,10)) { + var game_id = weaponData.games[i]; + var sg = damageValue(flattened[game_id], "shotgun"); + var sgTT = damageTooltip("shotgun", sg); + var uzi = damageValue(flattened[game_id], "uzi"); + var uziTT = damageTooltip("uzi", uzi); + var nex = damageValue(flattened[game_id], "nex"); + var nexTT = damageTooltip("nex", nex); + var mn = damageValue(flattened[game_id], "minstanex"); + var mnTT = damageTooltip("minstanex", mn); + var rifle = damageValue(flattened[game_id], "rifle"); + var rifleTT = damageTooltip("rifle", rifle); + var mortar = damageValue(flattened[game_id], "grenadelauncher"); + var mortarTT = damageTooltip("grenadelauncher", mortar); + var electro = damageValue(flattened[game_id], "electro"); + var electroTT = damageTooltip("electro", electro); + var crylink = damageValue(flattened[game_id], "crylink"); + var crylinkTT = damageTooltip("crylink", crylink); + var hagar = damageValue(flattened[game_id], "hagar"); + var hagarTT = damageTooltip("hagar", hagar); + var rl = damageValue(flattened[game_id], "rocketlauncher"); + var rlTT = damageTooltip("rocketlauncher", rl); + + data.addRow([ + game_id.toString(), + sg, sgTT, + uzi, uziTT, + nex, nexTT, + rifle, rifleTT, + mortar, mortarTT, + electro, electroTT, + crylink, crylinkTT, + hagar, hagarTT, + rl, rlTT + ]); + } + + var options = { + backgroundColor: { fill: 'transparent' }, + legend: { + position: 'top', + maxLines: 3, + textStyle: { color: "#666" } + }, + vAxis: { + title: 'HP Damage', + titleTextStyle: {color: '#666'}, + baselineColor: '#333', + gridlineColor: '#333', + }, + hAxis: { + title: 'Game ID', + titleTextStyle: { color: '#666' }, + }, + isStacked: true, + series: { + 0: { color: weaponColors["shotgun"] }, + 1: { color: weaponColors["uzi"] }, + 2: { color: weaponColors["nex"] }, + 3: { color: weaponColors["rifle"] }, + 4: { color: weaponColors["grenadelauncher"] }, + 5: { color: weaponColors["electro"] }, + 6: { color: weaponColors["crylink"] }, + 7: { color: weaponColors["hagar"] }, + 8: { color: weaponColors["rocketlauncher"] } + } + }; - // transform the dataset into something nvd3 can use - var transformedData = d3.nest() - .key(function(d) { return d.weapon_cd; }).entries(data.weapon_stats); + var chart = new google.visualization.ColumnChart(document.getElementById('damageChart')); - var findNumGames = function(weapon) { - var numGames = transformedData.filter(function(e){return e.key == weapon})[0].values.length; - if(numGames !== undefined) { - return numGames; - } else { - return 0; + // a click on a point sends you to that game's page + var damageSelectHandler = function(e) { + var selection = chart.getSelection()[0]; + if (selection != null && selection.row != null) { + var game_id = data.getFormattedValue(selection.row, 0); + window.location.href = "http://stats.xonotic.org/game/" + game_id.toString(); } }; + google.visualization.events.addListener(chart, 'select', damageSelectHandler); - // transform games list into a map such that games[game_id] = linear sequence - var games = {}; - data.games.forEach(function(v,i){ games[v] = i; }); - - // margin model - var margin = {top: 20, right: 30, bottom: 30, left: 40}, - height = 300 - margin.top - margin.bottom; - - width -= margin.left - margin.right; - - nv.addGraph(function() { - chart = nv.models.lineChart() - .margin(margin) - .width(width) - .height(height) - .color(weaponColors) - .forceY([0,1]) - .x(function(d) { return games[d.game_id] }) - .y(function(d) { return d.fired > 0 ? d.hit/d.fired : 0; }) - .useInteractiveGuideline(true) - .tooltips(true) - .tooltipContent(function(key, x, y, e, graph) { - return '

' + key + '

' + '

' + y + ' accuracy in game #' + x + '
' + data.averages[key] + '% average over ' + findNumGames(key) + ' games

'; - }); - - chart.xAxis.tickFormat(function(d) { return ''; }); - - var yScale = d3.scale.linear().domain([0,1]).range([0,height]); - chart.yAxis - .axisLabel('% Accuracy') - .tickFormat(d3.format('2%')); - - d3.select('#accuracyChartSVG') - .datum(transformedData) - .transition().duration(500).call(chart); - - nv.utils.windowResize(chart.update); - - return chart; - }); + chart.draw(data, options); } diff --git a/xonstat/templates/player_info.mako b/xonstat/templates/player_info.mako index 62e1389..790a6c1 100644 --- a/xonstat/templates/player_info.mako +++ b/xonstat/templates/player_info.mako @@ -14,7 +14,7 @@ ${parent.css()} @@ -23,7 +23,7 @@ ${parent.css()} <%block name="js"> ${parent.js()} - + @@ -40,10 +40,11 @@ $(function () { }) // weapon accuracy and damage charts -d3.json("${request.route_url('player_weaponstats_data_json', id=player.player_id, _query={'limit':30})}", function(err, data) { +google.load('visualization', '1.1', {packages: ['corechart']}); +d3.json("${request.route_url('player_weaponstats_data_json', id=player.player_id, _query={'limit':10})}", function(err, data) { if(data.games.length < 5) { - d3.select(".row #damageChartRow").remove(); - d3.select(".row #accuracyChartRow").remove(); + d3.select(".row #damageChart").remove(); + d3.select(".row #accuracyChart").remove(); } drawDamageChart(data); drawAccuracyChart(data); @@ -54,7 +55,7 @@ d3.select('.tab-${g.game_type_cd}').on("click", function() { // have to remove the chart each time d3.select('#damageChartSVG .nvd3').remove(); d3.select('#accuracyChartSVG .nvd3').remove(); - d3.json("${request.route_url('player_weaponstats_data_json', id=player.player_id, _query={'limit':30, 'game_type':g.game_type_cd})}", function(err, data) { + d3.json("${request.route_url('player_weaponstats_data_json', id=player.player_id, _query={'limit':10, 'game_type':g.game_type_cd})}", function(err, data) { drawDamageChart(data); drawAccuracyChart(data); }); -- 2.39.2