Skip to content

Instantly share code, notes, and snippets.

@nbremer
Last active May 26, 2017 08:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nbremer/4530f11952a3ef7e007ad6ef93d5adb3 to your computer and use it in GitHub Desktop.
Save nbremer/4530f11952a3ef7e007ad6ef93d5adb3 to your computer and use it in GitHub Desktop.
Basic loom and string layout
license: mit
height: 700

This is the default example of the loom plugin which creates a chart with a group of entities in the center and different group of entities on the outside. They are connected by strings where the thickness of the string on the outside represents the connection (i.e. value) of the inner and outer entity.

For example, in this case, the inner entities are the characters of the Fellowship in the Lord of the Rings movies. The outer entities are the locations in Middle Earth where the movie takes place. The connection/value is the number of words spoken by each character at each location.

Read more about the loom chart layout / plugin for d3 here

A more advanced version that also uses interactions can be found here

Built with blockbuilder.org

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<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">
<style>
html {
font-size: 61%;
}
body {
font-family: 'Cormorant', serif;
font-size: 1.2rem;
fill: #b9b9b9;
}
#chart {
text-align: center;
}
.string-wrapper {
isolation: isolate;
}
.string {
mix-blend-mode: multiply;
}
.inner-label {
font-family: 'Macondo Swash Caps', cursive;
font-size: 1.4rem;
fill: #232323;
cursor: default;
text-anchor: middle;
}
.outer-label {
font-family: 'Macondo', cursive;
font-size: 1.6rem;
fill: #5f5f5f;
cursor: default;
}
.outer-label-value {
font-size: 1.2rem;
fill: #b9b9b9;
}
</style>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="loom.js"></script>
</head>
<body>
<div id="chart"></div>
<script>
////////////////////////////////////////////////////////////
////////////////////// Create SVG //////////////////////////
////////////////////////////////////////////////////////////
var margin = {left:120, top:50, right:120, bottom:50},
width = 710,
height = 600,
innerRadius = 244,
outerRadius = innerRadius * 1.05;
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
////////////////////////////////////////////////////////////
/////////////////// Set-up Loom parameters /////////////////
////////////////////////////////////////////////////////////
//Some default parameters
var pullOutSize = 20 + 30/135 * innerRadius;
var numFormat = d3.format(",.0f");
//Manually sorted the inner characters based on the total number of words spoken
var characterOrder = ["Gandalf", "Sam", "Aragorn", "Frodo", "Gimli", "Pippin", "Merry", "Boromir", "Legolas"]
function sortCharacter(a, b) { return characterOrder.indexOf(a) - characterOrder.indexOf(b); }
//Initiate the loom function with all the options
var loom = d3.loom()
.padAngle(0.05)
.sortSubgroups(sortCharacter)
.heightInner(20)
.emptyPerc(0.2)
.widthInner(30)
.value(function(d) { return d.words; })
.inner(function(d) { return d.character; })
.outer(function(d) { return d.location; })
//Initiate the inner string function that belongs to the loom
var string = d3.string()
.radius(innerRadius)
.pullout(pullOutSize);
//Initiate an arc drawing function that is also needed
var arc = d3.arc()
.innerRadius(innerRadius*1.01)
.outerRadius(outerRadius);
////////////////////////////////////////////////////////////
///////////////////////// 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);
////////////////////////////////////////////////////////////
///////////////////// Read in data /////////////////////////
////////////////////////////////////////////////////////////
d3.json("lotr_words_location.json", function (error, data) {
//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(data));
////////////////////////////////////////////////////////////
////////////////////// Draw outer arcs /////////////////////
////////////////////////////////////////////////////////////
var arcGroup = g.append("g").attr("class", "arc-outer-wrapper");
//Create a group per outer arc, which will contain the arc path + the location name & number of words text
var arcs = arcGroup.selectAll(".arc-wrapper")
.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)) });
//Create the actual arc paths
var outerArcs = arcs.append("path")
.attr("class", "arc")
.style("fill", function(d) { return color(d.outername); })
.attr("d", arc)
.attr("transform", function(d, i) {
return "translate(" + d.pullOutSize + ',' + 0 + ")"; //Pull the two slices apart
});
////////////////////////////////////////////////////////////
//////////////////// 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 stringGroup = g.append("g").attr("class", "string-wrapper");
//Draw the paths of the inner strings
var strings = stringGroup.selectAll("path")
.data(function(strings) { return strings; })
.enter().append("path")
.attr("class", "string")
.style("fill", function(d) { return d3.rgb( color(d.outer.outername) ).brighter(0.2) ; })
.style("opacity", 0.85)
.attr("d", string);
////////////////////////////////////////////////////////////
//////////////////// Draw inner labels /////////////////////
////////////////////////////////////////////////////////////
var innerLabelGroup = g.append("g").attr("class","inner-label-wrapper");
//Place the inner text labels in the middle
var innerLabels = innerLabelGroup.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; })
.attr("dy", ".35em")
.text(function(d,i) { return d.name; });
});//d3.json
</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
}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment