Created
December 16, 2015 15:19
-
-
Save janhebnes/67d882fa0c066b28f3cd to your computer and use it in GitHub Desktop.
D3 visualization of Steam Group relations between friends and games using Steam API - see the results at http://netbyte.dk/steam/games/ and http://netbyte.dk/steam/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/> | |
<link type="text/css" rel="stylesheet" href="style.css"/> | |
<style type="text/css"> | |
path.arc { | |
cursor: move; | |
fill: #fff; | |
} | |
.node { | |
font-size: 10px; | |
} | |
.node:hover { | |
fill: #1f77b4; | |
} | |
.link { | |
fill: none; | |
stroke: #1f77b4; | |
stroke-opacity: .4; | |
pointer-events: none; | |
} | |
.link.source, .link.target { | |
stroke-opacity: 1; | |
stroke-width: 2px; | |
} | |
.node.target { | |
fill: #d62728 !important; | |
} | |
.link.source { | |
stroke: #d62728; | |
} | |
.node.source { | |
fill: #2ca02c; | |
} | |
.link.target { | |
stroke: #2ca02c; | |
} | |
</style> | |
</head> | |
<body> | |
<h2> | |
Flare imports<br> | |
hierarchical edge bundling | |
</h2> | |
<div style="position:absolute;bottom:0;font-size:18px;">tension: <input style="position:relative;top:3px;" type="range" min="0" max="100" value="85"></div> | |
<script type="text/javascript" src="d3/d3.js"></script> | |
<script type="text/javascript" src="d3/d3.layout.js"></script> | |
<script type="text/javascript" src="packages.js"></script> | |
<script type="text/javascript"> | |
var w = 1920, | |
h = 1600, | |
rx = w / 2, | |
ry = h / 2, | |
m0, | |
rotate = 0; | |
var splines = []; | |
var cluster = d3.layout.cluster() | |
.size([360, ry - 120]) | |
.sort(function(a, b) { return d3.ascending(a.key, b.key); }); | |
var bundle = d3.layout.bundle(); | |
var line = d3.svg.line.radial() | |
.interpolate("bundle") | |
.tension(.85) | |
.radius(function(d) { return d.y; }) | |
.angle(function(d) { return d.x / 180 * Math.PI; }); | |
// Chrome 15 bug: <http://code.google.com/p/chromium/issues/detail?id=98951> | |
var div = d3.select("body").insert("div", "h2") | |
.style("top", "-80px") | |
.style("left", "-160px") | |
.style("width", w + "px") | |
.style("height", w + "px") | |
.style("position", "absolute") | |
.style("-webkit-backface-visibility", "hidden"); | |
var svg = div.append("svg:svg") | |
.attr("width", w) | |
.attr("height", w) | |
.append("svg:g") | |
.attr("transform", "translate(" + rx + "," + ry + ")"); | |
svg.append("svg:path") | |
.attr("class", "arc") | |
.attr("d", d3.svg.arc().outerRadius(ry - 120).innerRadius(0).startAngle(0).endAngle(2 * Math.PI)) | |
.on("mousedown", mousedown); | |
d3.json("flare-steamgamerelations.txt", function (classes) { | |
var nodes = cluster.nodes(packages.root(classes)), | |
links = packages.imports(nodes), | |
splines = bundle(links); | |
var path = svg.selectAll("path.link") | |
.data(links) | |
.enter().append("svg:path") | |
.attr("class", function (d) { | |
if ((typeof d != 'undefined') && (typeof d.source != 'undefined') && (typeof d.target != 'undefined')) { | |
return "link source-" + d.source.key + " target-" + d.target.key; | |
}; | |
return ""; | |
}) | |
.attr("d", function(d, i) { return line(splines[i]); }); | |
svg.selectAll("g.node") | |
.data(nodes.filter(function(n) { return !n.children; })) | |
.enter().append("svg:g") | |
.attr("class", "node") | |
.attr("id", function(d) { return "node-" + d.key; }) | |
.attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")"; }) | |
.append("svg:text") | |
.attr("dx", function(d) { return d.x < 180 ? 8 : -8; }) | |
.attr("dy", ".31em") | |
.attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; }) | |
.attr("transform", function(d) { return d.x < 180 ? null : "rotate(180)"; }) | |
.text(function(d) { return d.alias; }) | |
.on("mouseover", mouseover) | |
.on("mouseout", mouseout); | |
d3.select("input[type=range]").on("change", function() { | |
line.tension(this.value / 100); | |
path.attr("d", function(d, i) { return line(splines[i]); }); | |
}); | |
}); | |
d3.select(window) | |
.on("mousemove", mousemove) | |
.on("mouseup", mouseup); | |
function mouse(e) { | |
return [e.pageX - rx, e.pageY - ry]; | |
} | |
function mousedown() { | |
m0 = mouse(d3.event); | |
d3.event.preventDefault(); | |
} | |
function mousemove() { | |
if (m0) { | |
var m1 = mouse(d3.event), | |
dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI; | |
div.style("-webkit-transform", "translateY(" + (ry - rx) + "px)rotateZ(" + dm + "deg)translateY(" + (rx - ry) + "px)"); | |
} | |
} | |
function mouseup() { | |
if (m0) { | |
var m1 = mouse(d3.event), | |
dm = Math.atan2(cross(m0, m1), dot(m0, m1)) * 180 / Math.PI; | |
rotate += dm; | |
if (rotate > 360) rotate -= 360; | |
else if (rotate < 0) rotate += 360; | |
m0 = null; | |
div.style("-webkit-transform", null); | |
svg | |
.attr("transform", "translate(" + rx + "," + ry + ")rotate(" + rotate + ")") | |
.selectAll("g.node text") | |
.attr("dx", function(d) { return (d.x + rotate) % 360 < 180 ? 8 : -8; }) | |
.attr("text-anchor", function(d) { return (d.x + rotate) % 360 < 180 ? "start" : "end"; }) | |
.attr("transform", function(d) { return (d.x + rotate) % 360 < 180 ? null : "rotate(180)"; }); | |
} | |
} | |
function mouseover(d) { | |
svg.selectAll("path.link.target-" + d.key) | |
.classed("target", true) | |
.each(updateNodes("source", true)); | |
svg.selectAll("path.link.source-" + d.key) | |
.classed("source", true) | |
.each(updateNodes("target", true)); | |
} | |
function mouseout(d) { | |
svg.selectAll("path.link.source-" + d.key) | |
.classed("source", false) | |
.each(updateNodes("target", false)); | |
svg.selectAll("path.link.target-" + d.key) | |
.classed("target", false) | |
.each(updateNodes("source", false)); | |
} | |
function updateNodes(name, value) { | |
return function(d) { | |
if (value) this.parentNode.appendChild(this); | |
svg.select("#node-" + d[name].key).classed(name, value); | |
}; | |
} | |
function cross(a, b) { | |
return a[0] * b[1] - a[1] * b[0]; | |
} | |
function dot(a, b) { | |
return a[0] * b[0] + a[1] * b[1]; | |
} | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function() { | |
packages = { | |
// Lazily construct the package hierarchy from class names. | |
root: function(classes) { | |
var map = {}; | |
function find(name, data) { | |
var node = map[name], i; | |
if (!node) { | |
node = map[name] = data || {name: name, children: []}; | |
if (name.length) { | |
node.parent = find(name.substring(0, i = name.lastIndexOf("."))); | |
node.parent.children.push(node); | |
node.key = name.substring(i + 1); | |
} | |
} | |
return node; | |
} | |
classes.forEach(function(d) { | |
find(d.name, d); | |
}); | |
return map[""]; | |
}, | |
// Return a list of imports for the given array of nodes. | |
imports: function(nodes) { | |
var map = {}, | |
imports = []; | |
// Compute a map from name to node. | |
nodes.forEach(function(d) { | |
map[d.name] = d; | |
}); | |
// For each import, construct a link from the source to target node. | |
nodes.forEach(function(d) { | |
if (d.imports) d.imports.forEach(function(i) { | |
imports.push({source: map[d.name], target: map[i]}); | |
}); | |
}); | |
return imports; | |
} | |
}; | |
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.IO; | |
namespace ConsoleApplication4_steamapi | |
{ | |
using System; | |
using System.Collections.Generic; | |
using System.Globalization; | |
using System.Linq; | |
using System.Security.Policy; | |
using System.Text; | |
using System.Threading.Tasks; | |
using System.Xml.Linq; | |
using System.Xml.Serialization; | |
class Program | |
{ | |
#region Fields | |
private static string groupname = "netbyte"; | |
private static string key = "xxx"; | |
#endregion Fields | |
#region Methods | |
private static IEnumerable<Friend> GetFriendList(string steamplayerid) | |
{ | |
string url = string.Format("http://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key={0}&steamid={1}&relationship=friend&format=xml", key, steamplayerid); | |
var memberslistxml = XDocument.Load(url); | |
var element = memberslistxml.Element("friendslist"); | |
if (element == null) yield break; | |
var xElement = element.Element("friends"); | |
if (xElement == null) yield break; | |
foreach (var node in xElement.Descendants("friend")) | |
{ | |
var friend = new Friend(); | |
var steamid = node.Element("steamid"); | |
if (steamid != null) friend.SteamId = steamid.Value; | |
var relationship = node.Element("relationship"); | |
if (relationship != null) friend.Relationship = relationship.Value; | |
var friendsince = node.Element("friend_since"); | |
if (friendsince != null) friend.FriendSince = Convert.ToInt32(friendsince.Value); | |
if (friend.SteamId != null && friend.Relationship != null && friend.FriendSince.HasValue) | |
{ | |
yield return friend; | |
} | |
} | |
} | |
private static IEnumerable<string> GetGroupMembers(string groupname) | |
{ | |
string url = string.Format("http://steamcommunity.com/groups/{0}/memberslistxml/?xml=1", groupname); | |
var memberslistxml = XDocument.Load(url); | |
var element = memberslistxml.Element("memberList"); | |
if (element == null) return null; | |
var xElement = element.Element("members"); | |
if (xElement == null) return null; | |
var memberslist = xElement.Descendants(); | |
return memberslist.Select(d=>d.Value.ToString(CultureInfo.InvariantCulture)); | |
} | |
// http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=A13AE8E10434F74C0D3B3D1A86A9AF98&steamid=76561198005179566&include_appinfo=1&format=xml optional: include_played_free_games=1 | |
//<response> | |
//<game_count>79</game_count> | |
//<games> | |
// <message> | |
// <appid>570</appid> | |
// <name>Dota 2</name> | |
// <playtime_2weeks>578</playtime_2weeks> | |
// <playtime_forever>63771</playtime_forever> | |
// <img_icon_url>0bbb630d63262dd66d2fdd0f7d37e8661a410075</img_icon_url> | |
// <img_logo_url>d4f836839254be08d8e9dd333ecc9a01782c26d2</img_logo_url> | |
// </message> | |
// <message> | |
// <appid>80</appid> | |
// <playtime_forever>1249</playtime_forever> | |
// </message> | |
// <message> | |
// <appid>100</appid> | |
// <playtime_forever>18</playtime_forever> | |
// </message> | |
private static IEnumerable<Game> GetPlayerOwnedGames(string steamplayerid) | |
{ | |
string url = string.Format("http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={0}&steamid={1}&include_appinfo=1&format=xml", key, steamplayerid); | |
var memberslistxml = XDocument.Load(url); | |
var element = memberslistxml.Element("response"); | |
if (element == null) yield break; | |
var xElement = element.Element("games"); | |
if (xElement == null) yield break; | |
foreach (var node in xElement.Descendants("message")) | |
{ | |
var game = new Game(); | |
var appid = node.Element("appid"); | |
if (appid != null) game.AppId = appid.Value; | |
var name = node.Element("name"); | |
if (name != null) game.Name = name.Value; | |
var playtime_2weeks = node.Element("playtime_2weeks"); | |
if (playtime_2weeks != null) game.Playtime2weeks = Convert.ToInt32(playtime_2weeks.Value); | |
var playtime_forever = node.Element("playtime_forever"); | |
if (playtime_forever != null) game.PlaytimeForever = Convert.ToInt32(playtime_forever.Value); | |
if (game.AppId != null && game.Name != null && game.PlaytimeForever != 0) | |
{ | |
yield return game; | |
} | |
} | |
} | |
// http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=A13AE8E10434F74C0D3B3D1A86A9AF98&steamids=76561197960435530&format=xml | |
// <response> | |
// - <players> | |
// - <player> | |
// <steamid>76561197960435530</steamid> | |
// <communityvisibilitystate>3</communityvisibilitystate> | |
// <profilestate>1</profilestate> | |
// <personaname>Robin</personaname> | |
// <lastlogoff>1411922954</lastlogoff> | |
// <profileurl>http://steamcommunity.com/id/robinwalker/</profileurl> | |
// <avatar>http://media.steampowered.com/steamcommunity/public/images/avatars/f1/f1dd60a188883caf82d0cbfccfe6aba0af1732d4.jpg</avatar> | |
// <avatarmedium>http://media.steampowered.com/steamcommunity/public/images/avatars/f1/f1dd60a188883caf82d0cbfccfe6aba0af1732d4_medium.jpg</avatarmedium> | |
// <avatarfull>http://media.steampowered.com/steamcommunity/public/images/avatars/f1/f1dd60a188883caf82d0cbfccfe6aba0af1732d4_full.jpg</avatarfull> | |
// <personastate>0</personastate> | |
// <realname>Robin Walker</realname> | |
// <primaryclanid>103582791429521412</primaryclanid> | |
// <timecreated>1063407589</timecreated> | |
// <personastateflags>0</personastateflags> | |
// <loccountrycode>US</loccountrycode> | |
// <locstatecode>WA</locstatecode> | |
// <loccityid>3961</loccityid> | |
// </player> | |
// </players> | |
//</response> | |
private static IEnumerable<Player> GetPlayerSummaries(string steamplayerids) | |
{ | |
string url = string.Format("http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={0}&steamids={1}&format=xml", key, steamplayerids); | |
var memberslistxml = XDocument.Load(url); | |
var element = memberslistxml.Element("response"); | |
if (element == null) yield break; | |
var xElement = element.Element("players"); | |
if (xElement == null) yield break; | |
foreach (var node in xElement.Descendants("player")) | |
{ | |
var friend = new Player(); | |
var steamid = node.Element("steamid"); | |
if (steamid != null) friend.SteamId = steamid.Value; | |
var personaname = node.Element("personaname"); | |
if (personaname != null) friend.PersonaName = personaname.Value; | |
var realname = node.Element("realname"); | |
if (realname != null) friend.RealName = realname.Value; | |
var avatar = node.Element("avatar"); | |
if (avatar != null) friend.Avatar = avatar.Value; | |
if (friend.SteamId != null && friend.PersonaName != null && friend.RealName != null && friend.Avatar != null) | |
{ | |
yield return friend; | |
} | |
} | |
} | |
static void Main(string[] args) | |
{ | |
// Generate Group Memberlist | |
List<string> memberlist = null; | |
try | |
{ | |
memberlist = GetGroupMembers(groupname).ToList(); | |
} | |
catch (Exception e) | |
{ | |
Console.WriteLine(e.Message); | |
return; | |
} | |
// Map relation to friend in first level | |
var playerRelations = new List<PlayerRelation>(); | |
Console.WriteLine(string.Format("Found {0} members in steam group {1}", memberlist.Count(), groupname)); | |
foreach (var member in memberlist) | |
{ | |
Console.WriteLine("Looking up relations for member " + member); | |
var playerRel = new PlayerRelation(member); | |
try | |
{ | |
var friends = GetFriendList(member); | |
playerRel.Friends = friends.ToList(); | |
Console.WriteLine("Found {0} friends", playerRel.Friends.Count()); | |
} | |
catch (Exception e) | |
{ | |
Console.WriteLine("No friends found. Error '{0}'", e.Message); | |
} | |
try | |
{ | |
var games = GetPlayerOwnedGames(member); | |
playerRel.Games = games.ToList(); | |
Console.WriteLine("Found {0} games", playerRel.Games.Count()); | |
} | |
catch (Exception e) | |
{ | |
Console.WriteLine("No games found. Error '{0}'", e.Message); | |
} | |
playerRelations.Add(playerRel); | |
} | |
Console.WriteLine("Ready.\n\nPress enter to save to disk "); | |
Console.ReadKey(); | |
// Generate player and friend information | |
var playerSteamIds = string.Join(",", playerRelations.Select(d => d.SteamId)); | |
var summary = GetPlayerSummaries(playerSteamIds); | |
foreach (var player in summary) | |
{ | |
var playerRel = playerRelations.FirstOrDefault(p => p.SteamId == player.SteamId); | |
if (playerRel == null) continue; | |
playerRel.RealName = player.RealName; | |
playerRel.PersonaName = player.PersonaName; | |
playerRel.Avatar = player.Avatar; | |
var playerFriendRel = playerRelations.Where(p => p.Friends != null && p.Friends.Any()).SelectMany(p => p.Friends.FindAll(o => o.SteamId == player.SteamId)); | |
foreach (var friend in playerFriendRel) | |
{ | |
friend.RealName = player.RealName; | |
friend.PersonaName = player.PersonaName; | |
friend.Avatar = player.Avatar; | |
} | |
} | |
// Generate output to Flare Format | |
// We need to generate | |
// {"name":"flare.analytics.cluster.AgglomerativeCluster","size":3938,"imports":["flare.animate.Transitioner","flare.vis.data.DataList","flare.util.math.IMatrix","flare.analytics.cluster.MergeEdge","flare.analytics.cluster.HierarchicalCluster","flare.vis.data.Data"]} | |
var outputFlare = new StringBuilder(); | |
bool insertSeparator = false; | |
foreach (var playerRelation in playerRelations.OrderBy(s=>s.Games.Sum(d=>d.PlaytimeForever))) | |
{ | |
if (insertSeparator) | |
outputFlare.Append(","); | |
else | |
insertSeparator = true; | |
var friendlist = string.Empty; | |
var friendcount = 5; | |
if (playerRelation.Friends != null && playerRelation.Friends.Any()) | |
{ | |
friendlist = string.Join(",", playerRelation.Friends.Select(f => string.Format("{0}", System.Web.Helpers.Json.Encode(f.SteamId)))); | |
friendcount = playerRelation.Friends.Count*10; | |
} | |
var playtime = playerRelation.Games.Sum(d => d.PlaytimeForever) / 60; | |
outputFlare.AppendFormat("{{\"name\":{0},\"alias\":{1},\"size\":{2},\"imports\":[{3}]}}\n", System.Web.Helpers.Json.Encode(playerRelation.SteamId), System.Web.Helpers.Json.Encode(playerRelation.ToString() + " played " + playtime + "h"), friendcount, friendlist); | |
} | |
File.WriteAllText("flare-steamfriendrelations.txt", "[\n" + outputFlare + "]"); | |
Console.WriteLine("Saved friend relations\n\n"); | |
outputFlare = new StringBuilder(); | |
insertSeparator = false; | |
var gameRepository = new List<Game>(); | |
foreach (var playerRelation in playerRelations.OrderBy(s => s.Games.Sum(d => d.PlaytimeForever))) | |
{ | |
if (insertSeparator) | |
outputFlare.Append(","); | |
else | |
insertSeparator = true; | |
var gamelist = string.Empty; | |
var playtime = 0; | |
if (playerRelation.Games != null && playerRelation.Games.Any()) | |
{ | |
gamelist = string.Join(",", playerRelation.Games.Select(f => string.Format("{0}", System.Web.Helpers.Json.Encode(f.AppId)))); | |
playerRelation.Games.ForEach(g => | |
{ | |
if (!gameRepository.Exists(x => x.AppId == g.AppId)) | |
{ | |
g.TotalGroupPlaytime = g.PlaytimeForever; | |
gameRepository.Add(g); | |
} | |
else | |
{ | |
gameRepository.Find(x => x.AppId == g.AppId).TotalGroupPlaytime += g.PlaytimeForever; | |
} | |
}); | |
playtime = playerRelation.Games.Sum(d => d.PlaytimeForever) / 60; | |
} | |
outputFlare.AppendFormat("{{\"name\":{0},\"alias\":{1},\"size\":{2},\"imports\":[{3}]}}\n", System.Web.Helpers.Json.Encode(playerRelation.SteamId), System.Web.Helpers.Json.Encode(playerRelation.ToString() + " played " + playtime + " h"), playtime, gamelist); | |
} | |
// list games for allowing link generation | |
var gamejson = new List<string>(); | |
foreach (var game in gameRepository.OrderByDescending(g => g.TotalGroupPlaytime).Take(120)) | |
{ | |
gamejson.Add(string.Format("{{\"name\":{0},\"alias\":{1},\"size\":{2},\"imports\":[{3}]}}\n", System.Web.Helpers.Json.Encode(game.AppId), System.Web.Helpers.Json.Encode(game.Name + " " + game.TotalGroupPlaytime + " h"), game.TotalGroupPlaytime, string.Empty)); | |
} | |
outputFlare.Append(","); | |
outputFlare.Append(string.Join(",", gamejson)); | |
File.WriteAllText("flare-steamgamerelations.txt", "[\n" + outputFlare + "]"); | |
Console.WriteLine("Saved game relations\n\n"); | |
Console.WriteLine("Done.\n\nPress enter to exit"); | |
Console.ReadKey(); | |
} | |
#endregion Methods | |
#region Nested Types | |
public class Game | |
{ | |
public Game() | |
{ | |
PlaytimeForever = 0; | |
TotalGroupPlaytime = 0; | |
Playtime2weeks = 0; | |
} | |
public int Playtime2weeks; | |
public string AppId { get; set; } | |
public int PlaytimeForever { get; set; } | |
public string Name { get; set; } | |
public int TotalGroupPlaytime { get; set; } | |
} | |
public class Friend : Player | |
{ | |
#region Fields | |
public int? FriendSince; | |
public string Relationship; | |
#endregion Fields | |
} | |
public class Player | |
{ | |
#region Fields | |
public string Avatar; | |
public string PersonaName; | |
public string RealName; | |
public string SteamId; | |
#endregion Fields | |
public override string ToString() | |
{ | |
if (!string.IsNullOrWhiteSpace(PersonaName) && !string.IsNullOrWhiteSpace(RealName)) | |
{ | |
return string.Format("{0} aka {1}", PersonaName.Trim(), RealName.Trim()); | |
} | |
else if (!string.IsNullOrWhiteSpace(PersonaName)) | |
{ | |
return string.Format("{0}", PersonaName.Trim()); | |
} | |
else if (!string.IsNullOrWhiteSpace(RealName)) | |
{ | |
return string.Format("{0}", RealName.Trim()); | |
} | |
else | |
{ | |
return SteamId; | |
} | |
} | |
} | |
private class PlayerRelation : Player | |
{ | |
#region Fields | |
#endregion Fields | |
#region Constructors | |
public PlayerRelation(string steamId) | |
{ | |
this.SteamId = steamId; | |
} | |
public PlayerRelation(string steamId, List<Friend> friends) | |
{ | |
this.SteamId = steamId; | |
Friends = friends; | |
} | |
#endregion Constructors | |
#region Properties | |
public Player Player { get; set; } | |
public List<Friend> Friends { get; set; } | |
public List<Game> Games { get; set; } | |
#endregion Properties | |
} | |
#endregion Nested Types | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment