d3-bboxCollide allows for rectangular collision detection. This means we can do word cloud-like visualizations with more rules and constraints. In this case, the word clouds are split into columns based on speaker and laid out down the y-axis based on the segment of the debate for the term density of that word.
The data comes from the first Presidential debate of 2016, processed using Voyant Tools with custom stop words removed and then processed for term density into the 20 bins.
<html>
<head>
<title>Word Trails</title>
<meta charset="utf-8" />
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="collide.js"></script>
<script src="clinton.js"></script>
<script src="trump.js"></script>
<script src="holt.js"></script>
<link href="https://fonts.googleapis.com/css?family=Oxygen+Mono" rel="stylesheet">
</head>
<style>
body {
font-family: 'Oxygen Mono', monospace;
}
svg {
height: 1000px;
width: 1000px;
border: 1px solid lightgray;
}
</style>
<body>
<div id="viz">
<svg class="main">
</svg>
</div>
</body>
<footer>
<script>
function rectWidth(word, value) {
return word.length * value
}
valueScale = d3.scaleLinear().domain([1,40]).range([2,80])
var data = [{segment: -1.5, word: "Trump", value: 40, speaker: "trump"},
{segment: -1.5, word: "Holt", value: 40, speaker: "holt"},
{segment: -1.5, word: "Clinton", value: 40, speaker: "clinton"}]
holt.documentTerms.terms.forEach(function (d) {
d.distributions.forEach(function (dist, i) {
if (dist !== 0 && d.term !== "bar" && dist > 3) {
data.push({ segment: i, word: d.term, value: valueScale(dist), speaker: "holt" })
}
})
})
trump.documentTerms.terms.forEach(function (d) {
d.distributions.forEach(function (dist, i) {
if (dist !== 0 && d.term !== "bar" && dist > 3) {
data.push({ segment: i, word: d.term, value: valueScale(dist), speaker: "trump" })
}
})
})
clinton.documentTerms.terms.forEach(function (d) {
d.distributions.forEach(function (dist, i) {
if (dist !== 0 && d.term !== "bar" && dist > 3) {
data.push({ segment: i, word: d.term, value: valueScale(dist), speaker: "clinton" })
}
})
})
var speakerHash = {
holt: "#222127",
trump: "#d4324a",
clinton: "#6475bb"
}
var speakerColumn = {
holt: 1,
trump: 2,
clinton: 0
}
var widthMod = 1
var heightMod = 0.5
var networkCenter = d3.forceCenter().x(400).y(480);
var forceX = d3
// .forceX(function (d) {return 250})
.forceX(function (d) {return speakerColumn[d.speaker] * 200 + 50})
.strength(1)
var forceY = d3.forceY(function (d) {return d.segment * 40})
.strength(1)
var collide = d3.bboxCollide(function (d,i) {
var width = rectWidth(d.word, d.value)
return [[-width / 2, -d.value * heightMod],[width / 2, d.value * heightMod]]
})
.strength(1)
.iterations(2)
var color = d3.scaleOrdinal(d3.schemeCategory20b)
var force = d3.forceSimulation(data)
.velocityDecay(0.6)
.force("center", networkCenter)
.force("x", forceX)
.force("y", forceY)
.force("collide", collide)
.on("tick", updateNetwork);
var nodeEnter = d3.select("svg.main")
.append("g")
.selectAll("g.node")
.data(data)
.enter()
.append("g")
.attr("class", "node")
nodeEnter.append("text")
.style("font-size", function (d) {return d.value})
.style("text-anchor", "middle")
.attr("y", function (d) {return d.value / 4})
.style("fill", function (d, i) {return speakerHash[d.speaker]})
.style("font-weight", 600)
.text(function (d) {return d.word})
function updateNetwork() {
d3.select("svg.main").selectAll("g.node")
.attr("transform", function (d) {return "translate(" + d.x + "," + d.y + ")"})
}
</script>
</footer>
</html>
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-quadtree')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-quadtree'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3));
}(this, function (exports,d3Quadtree) { 'use strict';
function bboxCollide (bbox) {
function x (d) {
return d.x + d.vx;
}
function y (d) {
return d.y + d.vy;
}
function constant (x) {
return function () {
return x;
};
}
var nodes,
boundingBoxes,
strength = 1,
iterations = 1;
if (typeof bbox !== "function") {
bbox = constant(bbox === null ? [[0,0][1,1]] : bbox)
}
function force () {
var i,
tree,
node,
xi,
yi,
bbi,
nx1,
ny1,
nx2,
ny2
var cornerNodes = []
nodes.forEach(function (d, i) {
cornerNodes.push({node: d, vx: d.vx, vy: d.vy, x: d.x + (boundingBoxes[i][1][0] + boundingBoxes[i][0][0]) / 2, y: d.y + (boundingBoxes[i][0][1] + boundingBoxes[i][1][1]) / 2})
cornerNodes.push({node: d, vx: d.vx, vy: d.vy, x: d.x + boundingBoxes[i][0][0], y: d.y + boundingBoxes[i][0][1]})
cornerNodes.push({node: d, vx: d.vx, vy: d.vy, x: d.x + boundingBoxes[i][0][0], y: d.y + boundingBoxes[i][1][1]})
cornerNodes.push({node: d, vx: d.vx, vy: d.vy, x: d.x + boundingBoxes[i][1][0], y: d.y + boundingBoxes[i][0][1]})
cornerNodes.push({node: d, vx: d.vx, vy: d.vy, x: d.x + boundingBoxes[i][1][0], y: d.y + boundingBoxes[i][1][1]})
})
var cn = cornerNodes.length
for (var k = 0; k < iterations; ++k) {
tree = d3Quadtree.quadtree(cornerNodes, x, y).visitAfter(prepareCorners);
for (i = 0; i < cn; ++i) {
var nodeI = ~~(i / 5);
node = nodes[nodeI]
bbi = boundingBoxes[nodeI]
xi = node.x + node.vx
yi = node.y + node.vy
nx1 = xi + bbi[0][0]
ny1 = yi + bbi[0][1]
nx2 = xi + bbi[1][0]
ny2 = yi + bbi[1][1]
tree.visit(apply);
}
}
function apply (quad, x0, y0, x1, y1) {
var data = quad.data
if (data) {
var bWidth = bbLength(bbi, 0),
bHeight = bbLength(bbi, 1);
if (data.node.index !== nodeI) {
var dataNode = data.node
var bbj = boundingBoxes[dataNode.index],
dnx1 = dataNode.x + dataNode.vx + bbj[0][0],
dny1 = dataNode.y + dataNode.vy + bbj[0][1],
dnx2 = dataNode.x + dataNode.vx + bbj[1][0],
dny2 = dataNode.y + dataNode.vy + bbj[1][1],
dWidth = bbLength(bbj, 0),
dHeight = bbLength(bbj, 1)
if (nx1 <= dnx2 && dnx1 <= nx2 && ny1 <= dny2 && dny1 <= ny2) {
var xSize = [Math.min.apply(null, [dnx1, dnx2, nx1, nx2]), Math.max.apply(null, [dnx1, dnx2, nx1, nx2])]
var ySize = [Math.min.apply(null, [dny1, dny2, ny1, ny2]), Math.max.apply(null, [dny1, dny2, ny1, ny2])]
var xOverlap = bWidth + dWidth - (xSize[1] - xSize[0])
var yOverlap = bHeight + dHeight - (ySize[1] - ySize[0])
var xBPush = xOverlap * strength * (yOverlap / bHeight)
var yBPush = yOverlap * strength * (xOverlap / bWidth)
var xDPush = xOverlap * strength * (yOverlap / dHeight)
var yDPush = yOverlap * strength * (xOverlap / dWidth)
if ((nx1 + nx2) / 2 < (dnx1 + dnx2) / 2) {
node.vx -= xBPush
dataNode.vx += xDPush
}
else {
node.vx += xBPush
dataNode.vx -= xDPush
}
if ((ny1 + ny2) / 2 < (dny1 + dny2) / 2) {
node.vy -= yBPush
dataNode.vy += yDPush
}
else {
node.vy += yBPush
dataNode.vy -= yDPush
}
}
}
return;
}
return x0 > nx2 || x1 < nx1 || y0 > ny2 || y1 < ny1;
}
}
function prepareCorners (quad) {
if (quad.data) {
return quad.bb = boundingBoxes[quad.data.node.index]
}
quad.bb = [[0,0],[0,0]]
for (var i = 0; i < 4; ++i) {
if (quad[i] && quad[i].bb[0][0] < quad.bb[0][0]) {
quad.bb[0][0] = quad[i].bb[0][0]
}
if (quad[i] && quad[i].bb[0][1] < quad.bb[0][1]) {
quad.bb[0][1] = quad[i].bb[0][1]
}
if (quad[i] && quad[i].bb[1][0] > quad.bb[1][0]) {
quad.bb[1][0] = quad[i].bb[1][0]
}
if (quad[i] && quad[i].bb[1][1] > quad.bb[1][1]) {
quad.bb[1][1] = quad[i].bb[1][1]
}
}
}
function bbLength (bbox, heightWidth) {
return bbox[1][heightWidth] - bbox[0][heightWidth]
}
force.initialize = function (_) {
var i, n = (nodes = _).length; boundingBoxes = new Array(n);
for (i = 0; i < n; ++i) boundingBoxes[i] = bbox(nodes[i], i, nodes);
};
force.iterations = function (_) {
return arguments.length ? (iterations = +_, force) : iterations;
};
force.strength = function (_) {
return arguments.length ? (strength = +_, force) : strength;
};
force.bbox = function (_) {
return arguments.length ? (bbox = typeof _ === "function" ? _ : constant(+_), force) : bbox;
};
return force;
}
exports.bboxCollide = bboxCollide;
Object.defineProperty(exports, '__esModule', { value: true });
}));