block by nbremer 4530f11952a3ef7e007ad6ef93d5adb3

Basic loom and string layout

Full Screen

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

index.html

<!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>

loom.js

/*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 });

}));

lotr_words_location.json

[
  {
    "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
  }
]