Skip to content

Instantly share code, notes, and snippets.

@nbremer
Last active May 26, 2017 08:12
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nbremer/6599644129c034d0cb17fcdc452c310b to your computer and use it in GitHub Desktop.
Save nbremer/6599644129c034d0cb17fcdc452c310b to your computer and use it in GitHub Desktop.
LotR words - Who's speaking in Middle Earth
license: gpl-3.0
height: 1050

This is a more advanced (with interactions) version of the basic Lord of the Rings loom layout. Read more about the loom chart layout / plugin for d3 here

The idea

About a month ago I got an email from Christian Wisniewski with a sketch that looked a bit like a Chord diagram but with "nodes" in the center. It seemed very intriguing and since I have a fond history of hacking the chord diagram for other purposes I wanted to try to create my own version of Christian's idea at some point and when I came across the LotR word count data I thought that would fit the layout very well

The layout

To build this layout I used d3's chord and ribbon functions as my basis but then started systematically making alterations to the chords and adding another level of data through the inner labels. I'll create a blog on the layout in case you might be interested in re-using it for something else.

I got many great suggestions for naming the layout, butterfly, Labrys, Ginko leaf and eye of Sauron, I eventually chose the one that would best fit the shape even for different data (this data just happened to be symmetrical, but that is not a prerequesite). Also, just like d3.chord has d3.ribbon, I actually needed 2 names for these functions. So I went for loom() and strings()

The colors

The colors are based on picking colors from screenshots of that location in the movie (and my own personal feeling on what color represented my memory of that location) and sometimes made a bit more vibrant

Data collection & preparation

In original dataset I had information on the number of words spoken by each character by scene and what race that character is. However, I found scenes to be a bit arbitrary. They are attached to the making of the movie, not the movie experience perse. So instead I went ahead and manually added an on-screen location to each of the ±800 rows of data. Besides a map of Middle-Earth, I relied heavily on the Age of the Ring scripts of the extended editions and the original scripts of the non-extended editions found on IMSDb. These scripts sometimes mention the location when they talk about the scene in general. And of course, I used my own memory of watching the movies time and time again.

I made two columns, one with a broad location (Gondor for example) and an extra with a more detailed location (Minas Tirith). I feel that the general location was straightforward to find. The only issue was that I didn't quite know where to group certain very specific location to. The Grey Havens for example; not the Shire or Rivendell... But it is too small to really are its own broad location. The detailed location, on the other hand, was sometimes just not known, too general or I couldn't find any mention of it in the scripts, but I think that about 90% have good detailed locations. However, during the buld of this visual I saw early on that there were already too many strings for the general location, so I aggregated the dataset on character and location.

Eventually this was a labor of love, during a lovely Sunday, and I tried my very best to add the right data to each scene.

Built with blockbuilder.org

<!DOCTYPE html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The words of LotR</title>
<meta name="author" content="Nadieh Bremer">
<meta name="description" content="Data Sketches - July - Movies - Nadieh - The words in LotR">
<meta name="keywords" content="data, visualization, visualisation, data visualization, data visualisation, information, information visualization, information visualisation, dataviz, datavis, infoviz, infovis, collaboration, data art">
<!-- Google fonts -->
<link href="https://fonts.googleapis.com/css?family=Macondo+Swash+Caps|Macondo" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Cormorant:300,400" rel="stylesheet">
<!-- Styling -->
<link href="style.css" rel="stylesheet">
<!-- D3 v4 -->
<script src="https://d3js.org/d3.v4.min.js"></script>
<!-- Custom "chord" and "ribbon" functions -->
<script src="loom.js"></script>
<script src="string.js"></script>
</head>
<body>
<div class="lotr-content-wrapper">
<h1 id="lotr-title">Who's speaking in <span class="MiddleEarth">Middle Earth</span></h1>
<h4 id="lotr-subtitle">How many words have the members of the Fellowship spoken across Middle Earth during all 3 extended editions of the Lord of the Rings</h4>
<div id="lotr-intro">
In more than 11 hours of the LotR trilogy all characters combined speak approximately 32,000 words. The 9 members of the Fellowship alone take up about 17,000 of these words, a bit more than half. In the visualization below you can find out how many words a member has spoken at each general location throughout the trilogy.<br>The members have been sorted from Gandalf who had the most lines, to Legolas, who spoke even less than Boromir, even though the latter was only in one movie (and some extended scenes).</div>
<div id="lotr-note">Hover over characters or locations to get a more detailed overview</div>
</div>
<div id="lotr-chart"></div>
<div class="lotr-content-wrapper">
<div id="lotr-footer">
<div id="lotr-credit">Created by Nadieh Bremer | <a href="http://www.visualcinnamon.com/">VisualCinnamon.com</a></div>
<div id="lotr-sources">Based on a <a href="https://github.com/jennybc/lotr">many eyes dataset</a> that showed the number of words per character per scene. I then manually added the location per scene by combining movie scripts, maps and my own memory</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
/*Based on the d3v4 d3.chord() function by Mike Bostock
** Adjusted by Nadieh Bremer - July 2016 */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-collection'), require('d3-array'), require('d3-interpolate'), require('d3-path')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-collection', 'd3-array', 'd3-interpolate', 'd3-path'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3,global.d3,global.d3,global.d3));
}(this, function (exports,d3Collection,d3Array,d3Interpolate,d3Path) { 'use strict';
function loom(data) {
var pi$3 = Math.PI;
var tau$3 = pi$3 * 2;
var max$1 = Math.max;
var padAngle = 0,
sortGroups = null,
sortSubgroups = null,
sortLooms = null,
emptyPerc = 0.2,
heightInner = 20,
widthInner = function() { return 30; },
value = function(d) { return d.value; },
inner = function(d) { return d.inner; },
outer = function(d) { return d.outer; };
function loom(data) {
//Nest the data on the outer variable
data = d3.nest().key(outer).entries(data);
var n = data.length,
groupSums = [],
groupIndex = d3.range(n),
subgroupIndex = [],
looms = [],
groups = looms.groups = new Array(n),
subgroups,
numSubGroups,
uniqueInner = looms.innergroups = [],
uniqueCheck = [],
emptyk,
k,
x,
x0,
dx,
i,
j,
l,
m,
s,
v,
sum,
counter,
reverseOrder = false,
approxCenter;
//Loop over the outer groups and sum the values
k = 0;
numSubGroups = 0;
for(i = 0; i < n; i++) {
v = data[i].values.length;
sum = 0;
for(j = 0; j < v; j++) {
sum += value(data[i].values[j]);
}//for j
groupSums.push(sum);
subgroupIndex.push(d3.range(v));
numSubGroups += v;
k += sum;
}//for i
// Sort the groups…
if (sortGroups)
groupIndex.sort(function(a, b) { return sortGroups(groupSums[a], groupSums[b]); });
// Sort subgroups…
if (sortSubgroups)
subgroupIndex.forEach(function(d, i) {
d.sort(function(a, b) { return sortSubgroups( inner(data[i].values[a]), inner(data[i].values[b]) ); });
});
//After which group are we past the center
//TODO: make something for if there is no nice split in two...
l = 0;
for(i = 0; i < n; i++) {
l += groupSums[groupIndex[i]];
if(l > k/2) {
approxCenter = groupIndex[i];
break;
}//if
}//for i
//How much should be added to k to make the empty part emptyPerc big of the total
emptyk = k * emptyPerc / (1 - emptyPerc);
k += emptyk;
// Convert the sum to scaling factor for [0, 2pi].
k = max$1(0, tau$3 - padAngle * n) / k;
dx = k ? padAngle : tau$3 / n;
// Compute the start and end angle for each group and subgroup.
// Note: Opera has a bug reordering object literal properties!
subgroups = new Array(numSubGroups);
x = emptyk * 0.25 * k; //quarter of the empty part //0;
counter = 0;
for(i = 0; i < n; i++) {
var di = groupIndex[i],
outername = data[di].key;
if(approxCenter === di) {
x = x + emptyk * 0.5 * k;
}//if
x0 = x;
//If you've crossed the bottom, reverse the order of the inner strings
if(x > pi$3) reverseOrder = true;
s = subgroupIndex[di].length;
for(j = 0; j < s; j++) {
var dj = reverseOrder ? subgroupIndex[di][(s-1)-j] : subgroupIndex[di][j],
v = value(data[di].values[dj]),
innername = inner(data[di].values[dj]),
a0 = x,
a1 = x += v * k;
subgroups[counter] = {
index: di,
subindex: dj,
startAngle: a0,
endAngle: a1,
value: v,
outername: outername,
innername: innername
};
//Check and save the unique inner names
if( !uniqueCheck[innername] ) {
uniqueCheck[innername] = true;
uniqueInner.push({name: innername});
}//if
counter += 1;
}//for j
groups[di] = {
index: di,
startAngle: x0,
endAngle: x,
value: groupSums[di],
outername: outername
};
x += dx;
}//for i
//Sort the inner groups in the same way as the strings
uniqueInner.sort(function(a, b) { return sortSubgroups( a.name, b.name ); });
//Find x and y locations of the inner categories
//TODO: make x depend on length of inner name
m = uniqueInner.length
for(i = 0; i < m; i++) {
uniqueInner[i].x = 0;
uniqueInner[i].y = -m*heightInner/2 + i*heightInner;
uniqueInner[i].offset = widthInner(uniqueInner[i].name, i, uniqueInner);
}//for i
//Generate bands for each (non-empty) subgroup-subgroup link
counter = 0;
for(i = 0; i < n; i++) {
var di = groupIndex[i];
s = subgroupIndex[di].length;
for(j = 0; j < s; j++) {
var outerGroup = subgroups[counter];
var innerTerm = outerGroup.innername;
//Find the correct inner object based on the name
var innerGroup = searchTerm(innerTerm, "name", uniqueInner);
if (outerGroup.value) {
looms.push({inner: innerGroup, outer: outerGroup});
}//if
counter +=1;
}//for j
}//for i
return sortLooms ? looms.sort(sortLooms) : looms;
}//function loom
function searchTerm(term, property, arrayToSearch){
for (var i=0; i < arrayToSearch.length; i++) {
if (arrayToSearch[i][property] === term) {
return arrayToSearch[i];
}//if
}//for i
}//searchTerm
function constant$11(x) {
return function() { return x; };
}
loom.padAngle = function(_) {
return arguments.length ? (padAngle = max$1(0, _), loom) : padAngle;
};
loom.inner = function(_) {
return arguments.length ? (inner = _, loom) : inner;
};
loom.outer = function(_) {
return arguments.length ? (outer = _, loom) : outer;
};
loom.value = function(_) {
return arguments.length ? (value = _, loom) : value;
};
loom.heightInner = function(_) {
return arguments.length ? (heightInner = _, loom) : heightInner;
};
loom.widthInner = function(_) {
return arguments.length ? (widthInner = typeof _ === "function" ? _ : constant$11(+_), loom) : widthInner;
};
loom.emptyPerc = function(_) {
return arguments.length ? (emptyPerc = _ < 1 ? max$1(0, _) : max$1(0, _*0.01), loom) : emptyPerc;
};
loom.sortGroups = function(_) {
return arguments.length ? (sortGroups = _, loom) : sortGroups;
};
loom.sortSubgroups = function(_) {
return arguments.length ? (sortSubgroups = _, loom) : sortSubgroups;
};
loom.sortLooms = function(_) {
return arguments.length ? (_ == null ? sortLooms = null : (sortLooms = compareValue(_))._ = _, loom) : sortLooms && sortLooms._;
};
return loom;
}//loom
function string() {
var slice$5 = Array.prototype.slice;
var cos = Math.cos;
var sin = Math.sin;
var pi$3 = Math.PI;
var halfPi$2 = pi$3 / 2;
var tau$3 = pi$3 * 2;
var max$1 = Math.max;
var inner = function (d) { return d.inner; },
outer = function (d) { return d.outer; },
radius = function (d) { return 100; },
startAngle = function (d) { return d.startAngle; },
endAngle = function (d) { return d.endAngle; },
x = function (d) { return d.x; },
y = function (d) { return d.y; },
offset = function (d) { return d.offset; },
pullout = 50,
thicknessInner = 0,
context = null;
function string() {
var buffer,
argv = slice$5.call(arguments),
out = outer.apply(this, argv),
inn = inner.apply(this, argv),
sr = +radius.apply(this, (argv[0] = out, argv)),
sa0 = startAngle.apply(this, argv) - halfPi$2,
sa1 = endAngle.apply(this, argv) - halfPi$2,
sx0 = sr * cos(sa0),
sy0 = sr * sin(sa0),
sx1 = sr * cos(sa1),
sy1 = sr * sin(sa1),
tr = +radius.apply(this, (argv[0] = inn, argv)),
tx = x.apply(this, argv),
ty = y.apply(this, argv),
toffset = offset.apply(this, argv),
theight,
xco,
yco,
xci,
yci,
leftHalf,
pulloutContext;
//Does the group lie on the left side
leftHalf = sa0+halfPi$2 > pi$3 && sa0+halfPi$2 < tau$3;
//If the group lies on the other side, switch the inner point offset
if(leftHalf) toffset = -toffset;
tx = tx + toffset;
//And the height of the end point
theight = leftHalf ? -thicknessInner : thicknessInner;
if (!context) context = buffer = d3.path();
//Change the pullout based on where the string is
pulloutContext = (leftHalf ? -1 : 1 ) * pullout;
sx0 = sx0 + pulloutContext;
sx1 = sx1 + pulloutContext;
//Start at smallest angle of outer arc
context.moveTo(sx0, sy0);
//Circular part along the outer arc
context.arc(pulloutContext, 0, sr, sa0, sa1);
//From end outer arc to center (taking into account the pullout)
xco = d3.interpolateNumber(pulloutContext, sx1)(0.5);
yco = d3.interpolateNumber(0, sy1)(0.5);
if( (!leftHalf && sx1 < tx) || (leftHalf && sx1 > tx) ) {
//If the outer point lies closer to the center than the inner point
xci = tx + (tx - sx1)/2;
yci = d3.interpolateNumber(ty + theight/2, sy1)(0.5);
} else {
xci = d3.interpolateNumber(tx, sx1)(0.25);
yci = ty + theight/2;
}//else
context.bezierCurveTo(xco, yco, xci, yci, tx, ty + theight/2);
//Draw a straight line up/down (depending on the side of the circle)
context.lineTo(tx, ty - theight/2);
//From center (taking into account the pullout) to start of outer arc
xco = d3.interpolateNumber(pulloutContext, sx0)(0.5);
yco = d3.interpolateNumber(0, sy0)(0.5);
if( (!leftHalf && sx0 < tx) || (leftHalf && sx0 > tx) ) {
//If the outer point lies closer to the center than the inner point
xci = tx + (tx - sx0)/2;
yci = d3.interpolateNumber(ty - theight/2, sy0)(0.5);
} else {
xci = d3.interpolateNumber(tx, sx0)(0.25);
yci = ty - theight/2;
}//else
context.bezierCurveTo(xci, yci, xco, yco, sx0, sy0);
//Close path
context.closePath();
if (buffer) return context = null, buffer + "" || null;
}//function string
function constant$11(x) {
return function() { return x; };
}//constant$11
string.radius = function(_) {
return arguments.length ? (radius = typeof _ === "function" ? _ : constant$11(+_), string) : radius;
};
string.startAngle = function(_) {
return arguments.length ? (startAngle = typeof _ === "function" ? _ : constant$11(+_), string) : startAngle;
};
string.endAngle = function(_) {
return arguments.length ? (endAngle = typeof _ === "function" ? _ : constant$11(+_), string) : endAngle;
};
string.x = function(_) {
return arguments.length ? (x = _, string) : x;
};
string.y = function(_) {
return arguments.length ? (y = _, string) : y;
};
string.offset = function(_) {
return arguments.length ? (offset = _, string) : offset;
};
string.thicknessInner = function(_) {
return arguments.length ? (thicknessInner = _, string) : thicknessInner;
};
string.inner = function(_) {
return arguments.length ? (inner = _, string) : inner;
};
string.outer = function(_) {
return arguments.length ? (outer = _, string) : outer;
};
string.pullout = function(_) {
return arguments.length ? (pullout = _, string) : pullout;
};
string.context = function(_) {
return arguments.length ? ((context = _ == null ? null : _), string) : context;
};
return string;
}//string
exports.loom = loom;
exports.string = string;
Object.defineProperty(exports, '__esModule', { value: true });
}));
[
{
"location": "The Shire",
"character": "Frodo",
"words": 679
},
{
"location": "The Shire",
"character": "Pippin",
"words": 124
},
{
"location": "The Shire",
"character": "Sam",
"words": 239
},
{
"location": "The Shire",
"character": "Gandalf",
"words": 1064
},
{
"location": "The Shire",
"character": "Merry",
"words": 173
},
{
"location": "Bree",
"character": "Aragorn",
"words": 258
},
{
"location": "Bree",
"character": "Frodo",
"words": 125
},
{
"location": "Bree",
"character": "Merry",
"words": 56
},
{
"location": "Bree",
"character": "Pippin",
"words": 76
},
{
"location": "Bree",
"character": "Sam",
"words": 71
},
{
"location": "Isengard",
"character": "Aragorn",
"words": 3
},
{
"location": "Isengard",
"character": "Pippin",
"words": 108
},
{
"location": "Isengard",
"character": "Gimli",
"words": 45
},
{
"location": "Isengard",
"character": "Gandalf",
"words": 224
},
{
"location": "Isengard",
"character": "Merry",
"words": 116
},
{
"location": "Rivendell",
"character": "Frodo",
"words": 153
},
{
"location": "Rivendell",
"character": "Boromir",
"words": 259
},
{
"location": "Rivendell",
"character": "Gimli",
"words": 38
},
{
"location": "Rivendell",
"character": "Legolas",
"words": 34
},
{
"location": "Rivendell",
"character": "Sam",
"words": 105
},
{
"location": "Rivendell",
"character": "Gandalf",
"words": 276
},
{
"location": "Rivendell",
"character": "Aragorn",
"words": 232
},
{
"location": "Rivendell",
"character": "Merry",
"words": 29
},
{
"location": "Rivendell",
"character": "Pippin",
"words": 27
},
{
"location": "Misty Mountains",
"character": "Legolas",
"words": 11
},
{
"location": "Misty Mountains",
"character": "Merry",
"words": 17
},
{
"location": "Misty Mountains",
"character": "Pippin",
"words": 10
},
{
"location": "Misty Mountains",
"character": "Sam",
"words": 3
},
{
"location": "Misty Mountains",
"character": "Aragorn",
"words": 42
},
{
"location": "Misty Mountains",
"character": "Boromir",
"words": 76
},
{
"location": "Misty Mountains",
"character": "Gandalf",
"words": 86
},
{
"location": "Misty Mountains",
"character": "Gimli",
"words": 66
},
{
"location": "Misty Mountains",
"character": "Frodo",
"words": 6
},
{
"location": "Moria",
"character": "Gandalf",
"words": 762
},
{
"location": "Moria",
"character": "Gimli",
"words": 102
},
{
"location": "Moria",
"character": "Legolas",
"words": 19
},
{
"location": "Moria",
"character": "Merry",
"words": 17
},
{
"location": "Moria",
"character": "Pippin",
"words": 21
},
{
"location": "Moria",
"character": "Sam",
"words": 32
},
{
"location": "Moria",
"character": "Frodo",
"words": 90
},
{
"location": "Moria",
"character": "Boromir",
"words": 55
},
{
"location": "Moria",
"character": "Aragorn",
"words": 98
},
{
"location": "Lothlorien",
"character": "Legolas",
"words": 68
},
{
"location": "Lothlorien",
"character": "Sam",
"words": 64
},
{
"location": "Lothlorien",
"character": "Merry",
"words": 11
},
{
"location": "Lothlorien",
"character": "Frodo",
"words": 36
},
{
"location": "Lothlorien",
"character": "Pippin",
"words": 1
},
{
"location": "Lothlorien",
"character": "Aragorn",
"words": 55
},
{
"location": "Lothlorien",
"character": "Boromir",
"words": 176
},
{
"location": "Lothlorien",
"character": "Gimli",
"words": 165
},
{
"location": "Parth Galen",
"character": "Sam",
"words": 89
},
{
"location": "Parth Galen",
"character": "Frodo",
"words": 129
},
{
"location": "Parth Galen",
"character": "Pippin",
"words": 17
},
{
"location": "Parth Galen",
"character": "Boromir",
"words": 398
},
{
"location": "Parth Galen",
"character": "Aragorn",
"words": 319
},
{
"location": "Parth Galen",
"character": "Gimli",
"words": 60
},
{
"location": "Parth Galen",
"character": "Legolas",
"words": 52
},
{
"location": "Parth Galen",
"character": "Merry",
"words": 20
},
{
"location": "Emyn Muil",
"character": "Sam",
"words": 347
},
{
"location": "Emyn Muil",
"character": "Frodo",
"words": 223
},
{
"location": "Rohan",
"character": "Aragorn",
"words": 907
},
{
"location": "Rohan",
"character": "Legolas",
"words": 407
},
{
"location": "Rohan",
"character": "Pippin",
"words": 203
},
{
"location": "Rohan",
"character": "Merry",
"words": 281
},
{
"location": "Rohan",
"character": "Gandalf",
"words": 671
},
{
"location": "Rohan",
"character": "Gimli",
"words": 607
},
{
"location": "Fangorn",
"character": "Gandalf",
"words": 524
},
{
"location": "Fangorn",
"character": "Legolas",
"words": 73
},
{
"location": "Fangorn",
"character": "Merry",
"words": 297
},
{
"location": "Fangorn",
"character": "Pippin",
"words": 276
},
{
"location": "Fangorn",
"character": "Aragorn",
"words": 108
},
{
"location": "Fangorn",
"character": "Gimli",
"words": 89
},
{
"location": "Gondor",
"character": "Boromir",
"words": 132
},
{
"location": "Gondor",
"character": "Sam",
"words": 822
},
{
"location": "Gondor",
"character": "Frodo",
"words": 491
},
{
"location": "Gondor",
"character": "Gandalf",
"words": 1155
},
{
"location": "Gondor",
"character": "Pippin",
"words": 386
},
{
"location": "Gondor",
"character": "Aragorn",
"words": 175
},
{
"location": "Gondor",
"character": "Gimli",
"words": 72
},
{
"location": "Gondor",
"character": "Merry",
"words": 97
},
{
"location": "Gondor",
"character": "Legolas",
"words": 8
},
{
"location": "Mordor",
"character": "Legolas",
"words": 8
},
{
"location": "Mordor",
"character": "Frodo",
"words": 361
},
{
"location": "Mordor",
"character": "Aragorn",
"words": 128
},
{
"location": "Mordor",
"character": "Gandalf",
"words": 32
},
{
"location": "Mordor",
"character": "Gimli",
"words": 21
},
{
"location": "Mordor",
"character": "Merry",
"words": 3
},
{
"location": "Mordor",
"character": "Pippin",
"words": 12
},
{
"location": "Mordor",
"character": "Sam",
"words": 753
}
]
var margin = {left:80, top:40, right:120, bottom:50},
width = Math.max( Math.min(window.innerWidth, 1100) - margin.left - margin.right - 20, 400),
height = Math.max( Math.min(window.innerHeight - 250, 900) - margin.top - margin.bottom - 20, 400),
innerRadius = Math.min(width * 0.33, height * .45),
outerRadius = innerRadius * 1.05;
//Recalculate the width and height now that we know the radius
width = outerRadius * 2 + margin.right + margin.left;
height = outerRadius * 2 + margin.top + margin.bottom;
//Reset the overall font size
var newFontSize = Math.min(70, Math.max(40, innerRadius * 62.5 / 250));
d3.select("html").style("font-size", newFontSize + "%");
////////////////////////////////////////////////////////////
////////////////// Set-up Chord parameters /////////////////
////////////////////////////////////////////////////////////
var pullOutSize = 20 + 30/135 * innerRadius;
var numFormat = d3.format(",.0f");
var defaultOpacity = 0.85,
fadeOpacity = 0.075;
var loom = d3.loom()
.padAngle(0.05)
//.sortSubgroups(sortAlpha)
//.heightInner(28)
.emptyPerc(0.2)
.widthInner(30)
//.widthInner(function(d) { return 6 * d.length; })
.value(function(d) { return d.words; })
.inner(function(d) { return d.character; })
.outer(function(d) { return d.location; });
var arc = d3.arc()
.innerRadius(innerRadius*1.01)
.outerRadius(outerRadius);
var string = d3.string()
.radius(innerRadius)
.pullout(pullOutSize);
////////////////////////////////////////////////////////////
//////////////////// Character notes ///////////////////////
////////////////////////////////////////////////////////////
var characterNotes = [];
characterNotes["Gandalf"] = "Speaking almost twice as many words as the second most abundant speaker, Gandalf is taking up a large portion of dialogue in almost every location he's in, but stays rather quiet in Mordor";
characterNotes["Sam"] = "An unexpected runner up to having spoken the most words, Sam flourishes after the battle at Amon Hen, taking up a considerable portion of the words said in both Mordor and Gondor";
characterNotes["Aragorn"] = "Although eventually being crowned in Minas Tirith, Gondor, Aragorn is by far most talkative in that other human region, Rohan, fighting a battle at Helm's Deep and convincing an army of dead";
characterNotes["Frodo"] = "Frodo seems most comfortable speaking in the Shire, (mostly) when still an innocent youth, but he feels the burden of the ring increasingly towards the end and leaves the talking to his best friend Sam";
characterNotes["Gimli"] = "Gimli is a quiet character at practically all locations until he reaches Rohan, where he speaks almost half of all his lines";
characterNotes["Pippin"] = "Like Merry, Pippin is also seen saying something at all locations, but his presence is mostly felt when he sings his song in Minas Tirith, serving the steward of Gondor, Denethor";
characterNotes["Merry"] = "Merry manages to say an average sentence worth of words at all locations, but is most active during his time with Treebeard in Fangorn forest and bonding with Eowyn in Rohan";
characterNotes["Boromir"] = "Boromir speaks his finest lines during the march up Caradhras in the Misty Mountains and right before the Uruk-hai battle at Amon Hen, Parth Galen, taking up a large portion of the total number of words spoken at those locations";
characterNotes["Legolas"] = "Although a very memorable presence throughout the movies, Legolas speaks even less in 3 movies than Boromir, who is alive in only the first movie";
////////////////////////////////////////////////////////////
////////////////////// Create SVG //////////////////////////
////////////////////////////////////////////////////////////
var svg = d3.select("#lotr-chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
////////////////////////////////////////////////////////////
///////////////////// Read in data /////////////////////////
////////////////////////////////////////////////////////////
d3.json('lotr_words_location.json', function (error, dataAgg) {
////////////////////////////////////////////////////////////
///////////////////// Prepare the data /////////////////////
////////////////////////////////////////////////////////////
//Sort the inner characters based on the total number of words spoken
//Find the total number of words per character
var dataChar = d3.nest()
.key(function(d) { return d.character; })
.rollup(function(leaves) { return d3.sum(leaves, function(d) { return d.words; }); })
.entries(dataAgg)
.sort(function(a, b){ return d3.descending(a.value, b.value); });
//Unflatten the result
var characterOrder = dataChar.map(function(d) { return d.key; });
//Sort the characters on a specific order
function sortCharacter(a, b) {
return characterOrder.indexOf(a) - characterOrder.indexOf(b);
}//sortCharacter
//Set more loom functions
loom
.sortSubgroups(sortCharacter)
.heightInner(innerRadius*0.75/characterOrder.length);
////////////////////////////////////////////////////////////
///////////////////////// Colors ///////////////////////////
////////////////////////////////////////////////////////////
//Color for the unique locations
var locations = ["Bree", "Emyn Muil", "Fangorn", "Gondor", "Isengard", "Lothlorien", "Misty Mountains", "Mordor", "Moria", "Parth Galen", "Rivendell", "Rohan", "The Shire"];
var colors = ["#5a3511", "#47635f", "#223e15", "#C6CAC9", "#0d1e25", "#53821a", "#4387AA", "#770000", "#373F41", "#602317", "#8D9413", "#c17924", "#3C7E16"];
var color = d3.scaleOrdinal()
.domain(locations)
.range(colors);
//Create a group that already holds the data
var g = svg.append("g")
.attr("transform", "translate(" + (width/2 + margin.left) + "," + (height/2 + margin.top) + ")")
.datum(loom(dataAgg));
////////////////////////////////////////////////////////////
///////////////////// Set-up title /////////////////////////
////////////////////////////////////////////////////////////
var titles = g.append("g")
.attr("class", "texts")
.style("opacity", 0);
titles.append("text")
.attr("class", "name-title")
.attr("x", 0)
.attr("y", -innerRadius*5/6);
titles.append("text")
.attr("class", "value-title")
.attr("x", 0)
.attr("y", -innerRadius*5/6 + 25);
//The character pieces
titles.append("text")
.attr("class", "character-note")
.attr("x", 0)
.attr("y", innerRadius/2)
.attr("dy", "0.35em");
////////////////////////////////////////////////////////////
////////////////////// Draw outer arcs /////////////////////
////////////////////////////////////////////////////////////
var arcs = g.append("g")
.attr("class", "arcs")
.selectAll("g")
.data(function(s) {
return s.groups;
})
.enter().append("g")
.attr("class", "arc-wrapper")
.each(function(d) {
d.pullOutSize = (pullOutSize * ( d.startAngle > Math.PI + 1e-2 ? -1 : 1))
})
.on("mouseover", function(d) {
//Hide all other arcs
d3.selectAll(".arc-wrapper")
.transition()
.style("opacity", function(s) { return s.outername === d.outername ? 1 : 0.5; });
//Hide all other strings
d3.selectAll(".string")
.transition()
.style("opacity", function(s) { return s.outer.outername === d.outername ? 1 : fadeOpacity; });
//Find the data for the strings of the hovered over location
var locationData = loom(dataAgg).filter(function(s) { return s.outer.outername === d.outername; });
//Hide the characters who haven't said a word
d3.selectAll(".inner-label")
.transition()
.style("opacity", function(s) {
//Find out how many words the character said at the hovered over location
var char = locationData.filter(function(c) { return c.outer.innername === s.name; });
return char.length === 0 ? 0.1 : 1;
});
})
.on("mouseout", function(d) {
//Sjow all arc labels
d3.selectAll(".arc-wrapper")
.transition()
.style("opacity", 1);
//Show all strings again
d3.selectAll(".string")
.transition()
.style("opacity", defaultOpacity);
//Show all characters again
d3.selectAll(".inner-label")
.transition()
.style("opacity", 1);
});
var outerArcs = arcs.append("path")
.attr("class", "arc")
.style("fill", function(d) { return color(d.outername); })
.attr("d", arc)
.attr("transform", function(d, i) { //Pull the two slices apart
return "translate(" + d.pullOutSize + ',' + 0 + ")";
});
////////////////////////////////////////////////////////////
//////////////////// Draw outer labels /////////////////////
////////////////////////////////////////////////////////////
//The text needs to be rotated with the offset in the clockwise direction
var outerLabels = arcs.append("g")
.each(function(d) { d.angle = ((d.startAngle + d.endAngle) / 2); })
.attr("class", "outer-labels")
.attr("text-anchor", function(d) { return d.angle > Math.PI ? "end" : null; })
.attr("transform", function(d,i) {
var c = arc.centroid(d);
return "translate(" + (c[0] + d.pullOutSize) + "," + c[1] + ")"
+ "rotate(" + (d.angle * 180 / Math.PI - 90) + ")"
+ "translate(" + 26 + ",0)"
+ (d.angle > Math.PI ? "rotate(180)" : "")
})
//The outer name
outerLabels.append("text")
.attr("class", "outer-label")
.attr("dy", ".35em")
.text(function(d,i){ return d.outername; });
//The value below it
outerLabels.append("text")
.attr("class", "outer-label-value")
.attr("dy", "1.5em")
.text(function(d,i){ return numFormat(d.value) + " words"; });
////////////////////////////////////////////////////////////
////////////////// Draw inner strings //////////////////////
////////////////////////////////////////////////////////////
var strings = g.append("g")
.attr("class", "stringWrapper")
.style("isolation", "isolate")
.selectAll("path")
.data(function(strings) {
return strings;
})
.enter().append("path")
.attr("class", "string")
.style("mix-blend-mode", "multiply")
.attr("d", string)
.style("fill", function(d) { return d3.rgb( color(d.outer.outername) ).brighter(0.2) ; })
.style("opacity", defaultOpacity);
////////////////////////////////////////////////////////////
//////////////////// Draw inner labels /////////////////////
////////////////////////////////////////////////////////////
//The text also needs to be displaced in the horizontal directions
//And also rotated with the offset in the clockwise direction
var innerLabels = g.append("g")
.attr("class","inner-labels")
.selectAll("text")
.data(function(s) {
return s.innergroups;
})
.enter().append("text")
.attr("class", "inner-label")
.attr("x", function(d,i) { return d.x; })
.attr("y", function(d,i) { return d.y; })
.style("text-anchor", "middle")
.attr("dy", ".35em")
.text(function(d,i) { return d.name; })
.on("mouseover", function(d) {
//Show all the strings of the highlighted character and hide all else
d3.selectAll(".string")
.transition()
.style("opacity", function(s) {
return s.outer.innername !== d.name ? fadeOpacity : 1;
});
//Update the word count of the outer labels
var characterData = loom(dataAgg).filter(function(s) { return s.outer.innername === d.name; });
d3.selectAll(".outer-label-value")
.text(function(s,i){
//Find which characterData is the correct one based on location
var loc = characterData.filter(function(c) { return c.outer.outername === s.outername; });
if(loc.length === 0) {
var value = 0;
} else {
var value = loc[0].outer.value;
}
return numFormat(value) + (value === 1 ? " word" : " words");
});
//Hide the arc where the character hasn't said a thing
d3.selectAll(".arc-wrapper")
.transition()
.style("opacity", function(s) {
//Find which characterData is the correct one based on location
var loc = characterData.filter(function(c) { return c.outer.outername === s.outername; });
return loc.length === 0 ? 0.1 : 1;
});
//Update the title to show the total word count of the character
d3.selectAll(".texts")
.transition()
.style("opacity", 1);
d3.select(".name-title")
.text(d.name);
d3.select(".value-title")
.text(function() {
var words = dataChar.filter(function(s) { return s.key === d.name; });
return numFormat(words[0].value);
});
//Show the character note
d3.selectAll(".character-note")
.text(characterNotes[d.name])
.call(wrap, 2.25*pullOutSize);
})
.on("mouseout", function(d) {
//Put the string opacity back to normal
d3.selectAll(".string")
.transition()
.style("opacity", defaultOpacity);
//Return the word count to what it was
d3.selectAll(".outer-label-value")
.text(function(s,i){ return numFormat(s.value) + " words"; });
//Show all arcs again
d3.selectAll(".arc-wrapper")
.transition()
.style("opacity", 1);
//Hide the title
d3.selectAll(".texts")
.transition()
.style("opacity", 0);
});
});//d3.csv
////////////////////////////////////////////////////////////
///////////////////// Extra functions //////////////////////
////////////////////////////////////////////////////////////
//Sort alphabetically
function sortAlpha(a, b){
if(a < b) return -1;
if(a > b) return 1;
return 0;
}//sortAlpha
//Sort on the number of words
function sortWords(a, b){
if(a.words < b.words) return -1;
if(a.words > b.words) return 1;
return 0;
}//sortWords
/*Taken from http://bl.ocks.org/mbostock/7555321
//Wraps SVG text*/
function wrap(text, width) {
text.each(function() {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.2, // ems
y = parseFloat(text.attr("y")),
x = parseFloat(text.attr("x")),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", x).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", x).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
}
}
});
}//wrap
html { font-size: 62.5%; }
body {
font-family: 'Cormorant', serif;
font-size: 1.2rem;
fill: #b9b9b9;
}
.lotr-content-wrapper {
max-width: 900px;
margin: 0 auto;
}
#lotr-title {
font-size: 42px;
font-weight: 300;
margin: 40px 30px 0px 30px;
color: #272727;
}
#lotr-subtitle {
font-size: 14px;
color: #b1b1b1;
margin: 0px 30px 20px 30px;
font-weight: 300;
}
#lotr-intro {
font-size: 16px;
margin: 0px 30px 10px 30px;
max-width: 800px;
}
#lotr-note {
font-size: 14px;
margin: 0px 30px 10px 30px;
max-width: 800px;
color: #b1b1b1;
font-weight: 300;
}
#lotr-chart {
text-align: center;
}
#lotr-credit {
font-size: 14px;
margin: 10px 30px 5px 30px;
}
#lotr-sources {
font-size: 11px;
max-width: 300px;
margin: 15px 30px 5px 30px;
color: #9e9e9e;
font-weight: 300;
padding-bottom: 20px;
}
a:hover {
text-decoration: none;
border-bottom: 1px solid black;
}
a, a:link, a:visited, a:active {
text-decoration: none;
color: black;
border-bottom: 1px dotted rgba(0, 0, 0, .5);
}
.MiddleEarth {
font-family: 'Macondo', cursive;
color: #53821a;
}
/*--- chart ---*/
.name-title {
font-family: 'Macondo Swash Caps', cursive;
font-size: 2.8rem;
fill: #232323;
cursor: default;
text-anchor: middle;
}
.value-title {
text-anchor: middle;
font-size: 1.8rem;
fill: #b9b9b9;
}
.character-note {
text-anchor: middle;
font-size: 1.4rem;
fill: #232323;
/* font-weight: 300;*/
}
.inner-label {
font-family: 'Macondo Swash Caps', cursive;
font-size: 1.4rem;
fill: #232323;
cursor: default;
}
.outer-label {
font-family: 'Macondo', cursive;
font-size: 1.6rem;
fill: #5f5f5f;
cursor: default;
}
.outer-label-value {
font-size: 1.2rem;
fill: #b9b9b9;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment