This is the code for the "linked beeswarm" chart I made for a story on the history of Hercule Poirot.
I added a checkbox to let you see the Voronoi diagram, which optimizes the size of the mouseover / tap area of each circle.
xxxxxxxxxx
<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">
<style>
body {
margin: 0;
font-family: "Helvetica Neue", sans-serif;
}
.cell path {
fill: none;
pointer-events: all;
}
.cell.selected circle {
stroke: #000;
stroke-width: 2px;
}
#linked-beeswarm {
max-width: 600px;
width: 100%;
margin: auto;
}
.intro {
max-width: 600px;
width: 100%;
margin: auto;
font-size: .9em;
margin-bottom: 20px;
}
#linked-beeswarm .axis .domain {
display: none;
}
#linked-beeswarm .axis .tick line {
stroke: #ccc;
stroke-dasharray: 5, 5;
}
#linked-beeswarm .axis .tick text {
fill: #888;
}
#linked-beeswarm .time-label {
font-size: .8em;
font-weight: bold;
fill: #888;
}
#linked-beeswarm .top-label {
font-weight: bold;
text-anchor: middle;
}
.count-label {
text-anchor: middle;
font-size: .8em;
}
.tip-line {
stroke: #000;
stroke-width: 1.5px;
fill: none;
}
.tip {
position: absolute;
top: 0;
left: 0;
text-align: center;
pointer-events: none;
text-shadow: -1px -1px 1px #ffffff, -1px 0px 1px #ffffff, -1px 1px 1px #ffffff, 0px -1px 1px #ffffff, 0px 1px 1px #ffffff, 1px -1px 1px #ffffff, 1px 0px 1px #ffffff, 1px 1px 1px #ffffff;
}
.tip .kill {
font-size: .8em;
}
.tip .book-name {
font-weight: bold;
font-size: .9em;
background: rgba(255, 255, 255, .8);
margin-bottom: 10px;
}
.tip .type {
font-size: .8em;
}
.show {
position: absolute;
font-size: .8em;
}
/*THE POINT AT WHICH THE TABLE IS TOO WIDE*/
@media only screen and (max-width: 600px) {
html, body {
max-width: 100%;
overflow-x: hidden;
}
.intro {
padding: 0px 20px;
width: auto;
}
}
</style>
</head>
<body>
<div class="show">Show voronoi <input type="checkbox"></div>
<div class="intro">Each <b>circle</b> represents a murderer or a victim. Earlier stories are on <b>top</b>; later ones are on <b>bottom</b>. <b><span class="hover-tap">Hover or tap</span></b> on a circle for more information.</div>
<div id="linked-beeswarm"></div>
<svg height="0">
<marker id="markerArrow" markerWidth="13" markerHeight="13" refX="2" refY="6" orient="auto">
<path d="M2,2 L2,11 L10,6 L2,2" style="fill: #000000;" />
</marker>
</svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://unpkg.com/d3-marcon@2.0.2/build/d3-marcon.min.js"></script>
<script src="https://unpkg.com/jeezy@1.12.10/lib/jeezy.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://www.hindustantimes.com/static/common/js/jquery.smartresize.js"></script>
<script>
var w = $(window).width();
$(document).ready(function(){
draw();
});
$(window).smartresize(function(){
// only on width change
if ($(window).width() != w){
draw();
w = $(window).width()
}
});
function draw(){
var first_draw = true;
$("#linked-beeswarm").empty();
$(".tip").remove();
// magic numbers
var ww = $(window).width();
var bp = 510;
// setup tip
var tip = d3.select("#linked-beeswarm").append("div")
.attr("class", "tip");
tip.append("div").attr("class", "book-name");
tip.append("div").attr("class", "type murderer");
tip.append("div").attr("class", "kill");
tip.append("div").attr("class", "type victim");
var colors = {red: "#df5a49", blue: "#2880b9"};
var color_names = {man: colors.blue, woman: colors.red};
var element = "#linked-beeswarm";
var margin = {left: 30, top: 60};
var setup = d3.marcon()
.element(element)
.width(+jz.str.keepNumber(d3.select(element).style("width")))
.height(ww < bp ? 400 : 600)
.left(margin.left)
.right(margin.left)
.top(margin.top)
.bottom(20);
setup.render();
var width = setup.innerWidth(), height = setup.innerHeight(), svg = setup.svg();
var x = d3.scaleBand()
.rangeRound([0, width]);
var y = d3.scaleLinear()
.rangeRound([0, height]);
var size = ww < bp ? 5 : 8;
d3.csv("data.csv", function(err, data){
// data types
data.forEach(function(d){
d.book_year = +d.book_year;
return d;
});
var genders = jz.arr.uniqueBy(data, "gender");
var types = jz.arr.uniqueBy(data, "type");
var books_data = jz.arr.uniqueBy(data, "book").map(function(book){
var lookup = data.filter(function(d){ return d.book == book; });
var this_data = [];
types.forEach(function(type){
genders.forEach(function(gender){
this_data.push({
type: type,
gender: gender,
count: lookup.filter(function(d){ return d.type == type && d.gender == gender}).length
});
});
});
function filter_facet(type, gender){
return this_data.filter(function(d){ return d.type == type && d.gender == gender; })[0].count;
}
return {
book: book,
murderer_man: filter_facet("murderer", "man"),
murderer_woman: filter_facet("murderer", "woman"),
victim_man: filter_facet("victim", "man"),
victim_woman: filter_facet("victim", "woman"),
}
});
// domains
x.domain(types);
y.domain(d3.extent(data, function(d){ return d.book_year; }));
// time label
svg.append("text")
.attr("class", "time-label")
.attr("x", ww < bp ? -margin.left + 4 : -margin.left)
.attr("y", 0)
.attr("dy", -10)
.text("Time ↓");
// top labels
var top_label = svg.selectAll(".top-label")
.data(types)
.enter().append("text")
.attr("class", "top-label")
.attr("x", function(d){ return x(d) + (x.bandwidth() / 2); })
.attr("y", -margin.top)
.attr("dy", 12)
.text(function(d){ return jz.str.toStartCase(d) + "s"; });
var types_data = types.map(function(d){
var match = data.filter(function(e){ return e.type == d; });
return {
type: d,
data: jz.arr.pivot(match, "gender")
}
});
var count_label = svg.selectAll(".count-label")
.data(types_data)
.enter().append("text")
.attr("class", "count-label")
.attr("x", function(d){ return x(d.type) + (x.bandwidth() / 2); })
.attr("y", -margin.top)
.attr("dy", 30)
.html(function(d){
return "<tspan style='fill: " + color_names.man + "'>" + d.data[0].count + " men</tspan> & <tspan style='fill: " + color_names.woman + "'>" + d.data[1].count + " women</tspan>";
});
var simulation = d3.forceSimulation(data)
.force("y", d3.forceY(function(d){ return y(d.book_year); }).strength(1))
.force("x", d3.forceX(function(d){ return x(d.type) + (x.bandwidth() / 2); }))
.force("collide", d3.forceCollide(size + 1))
.stop();
// 250 ticks
for (var i = 0; i < 250; ++i) simulation.tick();
// for loop for axes because you can't send different functions into .call()
types.forEach(function(type){
var axis = type == "murderer" ? d3.axisLeft(y) : d3.axisRight(y);
axis
.tickFormat(function(d){ return +d; })
.tickSizeOuter(0)
.tickSizeInner(type == "murderer" ? -width : 0);
svg.append("g")
.attr("class", "axis")
.attr("transform", "translate(" + (type == "murderer" ? 0 : width) + ", 0)")
.call(axis);
});
// JOIN
var cell = svg.append("g")
.attr("class", "cells")
.selectAll("g").data(d3.voronoi()
.extent([[0, 0], [width, height]])
.x(function(d) { return d.x; })
.y(function(d) { return d.y; })
.polygons(data))
.enter().append("g")
.attr("class", function(d){ return "cell " + d.data.type + " " + jz.str.toSlugCase(d.data.name) + " " +jz.str.toSlugCase(d.data.book); });
// voronoi
var voronoi = cell.append("path")
.attr("d", function(d) { return d == undefined ? null : "M" + d.join("L") + "Z"; });
// circle
cell.append("circle")
.attr("r", size)
.style("fill", function(d){ return color_names[d.data.gender]; })
.attr("cx", function(d) { return d == undefined ? null : d.data.x; })
.attr("cy", function(d) { return d == undefined ? null : d.data.y; });
svg.selectAll(".cell")
.on("mouseover", tipon);
function tipon(d){
d3.selectAll(".cell")
.classed("selected", false);
d3.selectAll(".cell." + jz.str.toSlugCase(d.data.book))
.classed("selected", true);
var book_lookup = books_data.filter(function(book_obj){
return book_obj.book == d.data.book;
})[0];
// content in the tip
d3.select(".tip .book-name").html(d.data.book + " (" + d.data.book_year + ")");
d3.select(".tip .type.murderer").html(makeHTML("murderer"));
d3.select(".tip .kill").html(d3.sum([book_lookup.murderer_man, book_lookup.murderer_woman]) == 1 ? "kills" : "kill")
d3.select(".tip .type.victim").html(makeHTML("victim"));
function makeHTML(type){
var man_html = makeGenderHTML(type, "man");
var woman_html = makeGenderHTML(type, "woman");
return book_lookup[type + "_man"] > 0 && book_lookup[type + "_woman"] > 0 ? man_html + " & " + woman_html :
book_lookup[type + "_man"] > 0 ? man_html :
woman_html;
}
function makeGenderHTML(type, gender){
return "<span style='color: " + color_names[gender] + "'>" + book_lookup[type + "_" + gender] + " " + (book_lookup[type + "_" + gender] == 1 ? gender : gender.replace("a", "e")) + "</span>";
}
var tip_pos = d3.select(".tip").node().getBoundingClientRect();
var window_padding = 40;
var y_pos = y(d.data.book_year);
var svg_offset = $("#linked-beeswarm svg").position();
var top = y_pos - (ww < bp ? tip_pos.height * .8 : tip_pos.height * 1.2) + svg_offset.top;
top = top < svg_offset.top ? svg_offset.top : top;
if (!first_draw){
top = top < $(window).scrollTop() + window_padding ? $(window).scrollTop() + window_padding : top;
} else {
first_draw = false;
}
d3.select(".tip")
.style("left", (ww / 2) - (tip_pos.width / 2) + "px")
.style("top", top + "px");
var lines_data = data.filter(function(r){ return r.book == d.data.book; });
lines_data.forEach(function(line){
var x1 = calcx1(line);
var x2 = calcx2(line);
var y1 = y(line.book_year);
var y2 = top - svg_offset.top;
var orient = line.book == "Murder on the Orient Express";
line.points = [
{
x: line.type == "murderer" ? x1 - size : x2,
y: line.type == "murderer" ? y1 - (orient ? 0 : size) : y2
}, {
x: line.type == "murderer" ? x2 : x1 + (y1 < 50 ? -size * 2 : 0),
y: line.type == "murderer" ? y2 : y1 + (y1 < 50 ? 0 : -size * 2)
}
];
if (ww < bp){
line.points[1].x += 5;
}
});
var line = svg.selectAll(".tip-line")
.data(lines_data, function(d){ return d.name; })
line.exit().remove();
var already_drew_murderer = false;
line.enter().append("path")
.attr("class", "tip-line")
.attr("d", function(d){
var dx = d.points[1].x - d.points[0].x,
dy = d.points[1].y - d.points[0].y,
dr = Math.sqrt(dx * dx + dy * dy);
return "M" + d.points[0].x + "," + d.points[0].y + "A" + dr + "," + dr + " 0 0,1 " + d.points[1].x + "," + d.points[1].y;
})
.attr("marker-end", function(d){
if (d.type == "murderer" && !already_drew_murderer){
already_drew_murderer = true;
return "url(#markerArrow)";
} else if (d.type !== "murderer") {
return "url(#markerArrow)";
} else {
return "";
}
});
function calcx1(d){
var relativePos = calcRelPos("#linked-beeswarm", ".cell." + jz.str.toSlugCase(d.name) + " circle");
return relativePos.left - margin.left + (d.type == "murderer" ? size * 2 : 0);
}
function calcx2(d){
return (d.type == "murderer" ? -50 : 50) + width / 2;
}
function calcRelPos(parent, child){
var parentPos = d3.select(parent).node().getBoundingClientRect(),
childrenPos = d3.select(child).node().getBoundingClientRect(),
relativePos = {};
relativePos.top = childrenPos.top - parentPos.top,
relativePos.right = childrenPos.right - parentPos.right,
relativePos.bottom = childrenPos.bottom - parentPos.bottom,
relativePos.left = childrenPos.left - parentPos.left;
return relativePos;
}
}
var starter = data.filter(function(d){ return d.book == "The Clocks"; })[0];
d3.timeout(function(){ tipon({data: starter})}, 2000);
});
}
$(".show input").change(function(){
$(".cell path").css("stroke", $(this).prop("checked") ? "#000" : "none");
});
</script>
</body>
</html>
Modified http://www.hindustantimes.com/static/common/js/jquery.smartresize.js to a secure url
https://d3js.org/d3.v4.min.js
https://unpkg.com/d3-marcon@2.0.2/build/d3-marcon.min.js
https://unpkg.com/jeezy@1.12.10/lib/jeezy.min.js
https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
https://www.hindustantimes.com/static/common/js/jquery.smartresize.js