]> git.xonotic.org Git - xonotic/xonstat.git/commitdiff
Use Google Charts instead of NVD3.js.
authorAnt Zucaro <azucaro@gmail.com>
Mon, 8 Dec 2014 03:21:31 +0000 (22:21 -0500)
committerAnt Zucaro <azucaro@gmail.com>
Mon, 8 Dec 2014 03:21:31 +0000 (22:21 -0500)
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
xonstat/templates/player_info.mako

index 8c49d8a3d8006846723c4a632a42acd38e2c6036..57605568d0b534b049ab1f24e1b33449598bae9a 100644 (file)
-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 '<h3>' + key + '</h3>' + '<p>' +  y + ' damage in game #' + x + '</p>';
-      });
-
-    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 '<h3>' + key + '</h3>' + '<p>' +  y + ' accuracy in game #' + x + ' <br /> ' + data.averages[key]  + '% average over ' + findNumGames(key) + ' games</p>';
-      });
-
-    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);
 }
index 62e1389dcc4ec5e1588a3ea6b1e1c617f603d9fa..790a6c18aefe2f6d8255bb46b900ac877a737ee9 100644 (file)
@@ -14,7 +14,7 @@ ${parent.css()}
 <link href="/static/css/sprites.css" rel="stylesheet">
 <link href="/static/css/nv.d3.css" rel="stylesheet" type="text/css">
 <style>
-#damageChartSVG, #accuracyChartSVG {
+#damageChart, #accuracyChart {
   height: 250px;
 }
 </style>
@@ -23,7 +23,7 @@ ${parent.css()}
 <%block name="js">
 ${parent.js()}
 <script src="/static/js/d3.v3.min.js"></script>
-<script src="/static/js/nv.d3.min.js"></script>
+<script type="text/javascript" src="https://www.google.com/jsapi?autoload={'modules':[{'name':'visualization','version':'1','packages':['corechart']}]}"></script>
 <script src="/static/js/weaponCharts.js"></script>
 <script src="https://login.persona.org/include.js" type="text/javascript"></script>
 <script type="text/javascript">${request.persona_js}</script>
@@ -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);
   });