Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Concept Map playlist visualization generated by Exaile 3.4 beta3

At work, I've been playing a little bit with visualization of data. After hours, I DJ at local Lindy Hop dance events, and it occurred to me recently that it could be interesting to visualize my playlists to understand more about what I actually played. Using Exaile, I'm able to easily add a lot of metadata to the tracks in my collection, and its plugin framework made it easy to take that data and do something interesting with it.

From that initial investigation, I've built in a playlist visualization templating engine plugin for Exaile that can do some very simple visualization stuff, and I'm planning on extending the types of things I can do with it. Here's an interesting one I created from a recent playlist. This visualization was inspired by The Concept Map, except the source code for this isn't minified. :)

The HTML/js was generated by Exaile 3.4-beta3.

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="name" content="Concept Map" />
<meta name="description" content="An abstract mapping for parameters. Works best if first tag is 'unique' among the tracklist, and the second tag applies to multiple tracks"/>
<meta name="mintags" content="2" />
<meta name="maxtags" content="2" />
<title>Jam Cellar 2014-05-06</title>
<style>
svg {
font: 12px sans-serif;
}
text {
pointer-events: none;
}
.inner_node rect {
pointer-events: all;
}
.inner_node rect.highlight {
stroke: #315B7E;
stroke-width: 2px;
}
.outer_node circle {
fill: #fff;
stroke: steelblue;
stroke-width: 1.5px;
pointer-events: all;
}
.outer_node circle.highlight
{
stroke: #315B7E;
stroke-width: 2px;
}
.link {
fill: none;
}
</style>
</head>
<body>
<script src="https://d3js.org/d3.v3.min.js"></script>
<script>
//
// Generated by the Exaile Playlist Analyzer plugin.
// (C) 2014 Dustin Spicuzza <dustin@virtualroadside.com>
//
// This work is licensed under the Creative Commons Attribution 4.0
// International License. To view a copy of this license, visit
// http://creativecommons.org/licenses/by/4.0/.
//
// Inspired by http://www.findtheconversation.com/concept-map/
// Loosely based on http://bl.ocks.org/mbostock/4063550
//
var data = [[120, ["like", "call response", "dramatic intro", "has breaks", "male vocalist", "silly", "swing"]], [150, ["brassy", "like", "calm energy", "female vocalist", "swing", "fun"]], [170, ["calm energy", "instrumental", "swing", "like", "happy"]], [140, ["has breaks", "male vocalist", "swing", "piano", "banjo", "chill"]], [160, ["calm energy", "instrumental", "swing", "like", "interesting"]], [140, ["brassy", "like", "energy", "dramatic intro", "male vocalist", "baseball", "swing"]], [170, ["instrumental", "interesting", "high energy", "like", "swing"]], [140, ["instrumental", "energy", "like", "swing"]], [200, ["instrumental", "brassy", "dramatic intro", "like", "swing"]], [160, ["male vocalist", "brassy", "swing", "like", "my favorites"]], [130, ["like", "interesting", "dramatic intro", "male vocalist", "silly", "swing", "gospel"]], [160, ["like", "long intro", "announcer", "energy", "swing", "female vocalist"]], [170, ["instrumental", "swing", "bass", "like"]], [150, ["like", "interesting", "has breaks", "instrumental", "chunky", "swing", "banjo", "trumpet"]], [170, ["like", "has breaks", "male vocalist", "silly", "swing", "banjo"]], [190, ["instrumental", "banjo", "swing"]], [130, ["instrumental", "brassy", "banjo", "like", "swing"]], [160, ["brassy", "like", "energy", "instrumental", "big band", "jam", "swing"]], [150, ["like", "male vocalist", "live", "swing", "piano", "banjo", "chill"]], [150, ["like", "trick ending", "instrumental", "chunky", "swing", "chill"]], [120, ["brassy", "like", "female vocalist", "swing", "chill", "energy buildup"]], [150, ["brassy", "like", "interesting", "instrumental", "swing", "piano"]], [190, ["brassy", "like", "long intro", "energy", "baseball", "swing", "female vocalist"]], [180, ["calm energy", "female vocalist", "live", "like", "swing"]], [200, ["banjo", "like", "long intro", "interesting", "energy", "my favorites", "male vocalist", "silly", "swing", "fun", "balboa"]], [150, ["brassy", "calm energy", "chunky", "instrumental", "old-timey", "live", "swing"]], [160, ["like", "call response", "interesting", "instrumental", "calm energy", "swing"]], [180, ["interesting", "swing", "fast", "male vocalist"]], [150, ["calm energy", "chunky", "swing", "female vocalist", "like"]], [180, ["like", "has breaks", "male vocalist", "chunky", "silly", "swing"]], [140, ["instrumental", "brassy", "dramatic intro", "swing", "chill"]], [150, ["male vocalist", "trumpet", "like", "swing"]], [150, ["instrumental", "energy", "like", "has breaks", "swing"]], [180, ["brassy", "like", "energy", "has breaks", "instrumental", "has calm", "swing"]], [150, ["female vocalist", "swing"]], [170, ["instrumental", "brassy", "energy", "swing"]], [170, ["calm energy", "instrumental", "energy", "like", "swing"]], [190, ["brassy", "like", "instrumental", "high energy", "swing", "trumpet"]], [160, ["male vocalist", "energy", "swing", "old-timey"]], [170, ["like", "oldies", "my favorites", "fast", "male vocalist", "high energy", "swing"]]];
// transform the data into a useful representation
// 1 is inner, 2, is outer
// need: inner, outer, links
//
// inner:
// links: { inner: outer: }
var outer = d3.map();
var inner = [];
var links = [];
var outerId = [0];
data.forEach(function(d){
if (d == null)
return;
i = { id: 'i' + inner.length, name: d[0], related_links: [] };
i.related_nodes = [i.id];
inner.push(i);
if (!Array.isArray(d[1]))
d[1] = [d[1]];
d[1].forEach(function(d1){
o = outer.get(d1);
if (o == null)
{
o = { name: d1, id: 'o' + outerId[0], related_links: [] };
o.related_nodes = [o.id];
outerId[0] = outerId[0] + 1;
outer.set(d1, o);
}
// create the links
l = { id: 'l-' + i.id + '-' + o.id, inner: i, outer: o }
links.push(l);
// and the relationships
i.related_nodes.push(o.id);
i.related_links.push(l.id);
o.related_nodes.push(i.id);
o.related_links.push(l.id);
});
});
data = {
inner: inner,
outer: outer.values(),
links: links
}
// sort the data -- TODO: have multiple sort options
outer = data.outer;
data.outer = Array(outer.length);
var i1 = 0;
var i2 = outer.length - 1;
for (var i = 0; i < data.outer.length; ++i)
{
if (i % 2 == 1)
data.outer[i2--] = outer[i];
else
data.outer[i1++] = outer[i];
}
console.log(data.outer.reduce(function(a,b) { return a + b.related_links.length; }, 0) / data.outer.length);
// from d3 colorbrewer:
// This product includes color specifications and designs developed by Cynthia Brewer (http://colorbrewer.org/).
var colors = ["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"]
var color = d3.scale.linear()
.domain([60, 220])
.range([colors.length-1, 0])
.clamp(true);
var diameter = 960;
var rect_width = 40;
var rect_height = 14;
var link_width = "1px";
var il = data.inner.length;
var ol = data.outer.length;
var inner_y = d3.scale.linear()
.domain([0, il])
.range([-(il * rect_height)/2, (il * rect_height)/2]);
mid = (data.outer.length/2.0)
var outer_x = d3.scale.linear()
.domain([0, mid, mid, data.outer.length])
.range([15, 170, 190 ,355]);
var outer_y = d3.scale.linear()
.domain([0, data.outer.length])
.range([0, diameter / 2 - 120]);
// setup positioning
data.outer = data.outer.map(function(d, i) {
d.x = outer_x(i);
d.y = diameter/3;
return d;
});
data.inner = data.inner.map(function(d, i) {
d.x = -(rect_width / 2);
d.y = inner_y(i);
return d;
});
function get_color(name)
{
var c = Math.round(color(name));
if (isNaN(c))
return '#dddddd'; // fallback color
return colors[c];
}
// Can't just use d3.svg.diagonal because one edge is in normal space, the
// other edge is in radial space. Since we can't just ask d3 to do projection
// of a single point, do it ourselves the same way d3 would do it.
function projectX(x)
{
return ((x - 90) / 180 * Math.PI) - (Math.PI/2);
}
var diagonal = d3.svg.diagonal()
.source(function(d) { return {"x": d.outer.y * Math.cos(projectX(d.outer.x)),
"y": -d.outer.y * Math.sin(projectX(d.outer.x))}; })
.target(function(d) { return {"x": d.inner.y + rect_height/2,
"y": d.outer.x > 180 ? d.inner.x : d.inner.x + rect_width}; })
.projection(function(d) { return [d.y, d.x]; });
var svg = d3.select("body").append("svg")
.attr("width", diameter)
.attr("height", diameter)
.append("g")
.attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");
// links
var link = svg.append('g').attr('class', 'links').selectAll(".link")
.data(data.links)
.enter().append('path')
.attr('class', 'link')
.attr('id', function(d) { return d.id })
.attr("d", diagonal)
.attr('stroke', function(d) { return get_color(d.inner.name); })
.attr('stroke-width', link_width);
// outer nodes
var onode = svg.append('g').selectAll(".outer_node")
.data(data.outer)
.enter().append("g")
.attr("class", "outer_node")
.attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")"; })
.on("mouseover", mouseover)
.on("mouseout", mouseout);
onode.append("circle")
.attr('id', function(d) { return d.id })
.attr("r", 4.5);
onode.append("circle")
.attr('r', 20)
.attr('visibility', 'hidden');
onode.append("text")
.attr('id', function(d) { return d.id + '-txt'; })
.attr("dy", ".31em")
.attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
.attr("transform", function(d) { return d.x < 180 ? "translate(8)" : "rotate(180)translate(-8)"; })
.text(function(d) { return d.name; });
// inner nodes
var inode = svg.append('g').selectAll(".inner_node")
.data(data.inner)
.enter().append("g")
.attr("class", "inner_node")
.attr("transform", function(d, i) { return "translate(" + d.x + "," + d.y + ")"})
.on("mouseover", mouseover)
.on("mouseout", mouseout);
inode.append('rect')
.attr('width', rect_width)
.attr('height', rect_height)
.attr('id', function(d) { return d.id; })
.attr('fill', function(d) { return get_color(d.name); });
inode.append("text")
.attr('id', function(d) { return d.id + '-txt'; })
.attr('text-anchor', 'middle')
.attr("transform", "translate(" + rect_width/2 + ", " + rect_height * .75 + ")")
.text(function(d) { return d.name; });
// need to specify x/y/etc
d3.select(self.frameElement).style("height", diameter - 150 + "px");
function mouseover(d)
{
// bring to front
d3.selectAll('.links .link').sort(function(a, b){ return d.related_links.indexOf(a.id); });
for (var i = 0; i < d.related_nodes.length; i++)
{
d3.select('#' + d.related_nodes[i]).classed('highlight', true);
d3.select('#' + d.related_nodes[i] + '-txt').attr("font-weight", 'bold');
}
for (var i = 0; i < d.related_links.length; i++)
d3.select('#' + d.related_links[i]).attr('stroke-width', '5px');
}
function mouseout(d)
{
for (var i = 0; i < d.related_nodes.length; i++)
{
d3.select('#' + d.related_nodes[i]).classed('highlight', false);
d3.select('#' + d.related_nodes[i] + '-txt').attr("font-weight", 'normal');
}
for (var i = 0; i < d.related_links.length; i++)
d3.select('#' + d.related_links[i]).attr('stroke-width', link_width);
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment