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.
license: gpl-3.0 | |
height: 650 |
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.
book | gender | name | book_year | type | |
---|---|---|---|---|---|
The Mysterious Affair at Styles | man | Alfred Inglethorp | 1920 | murderer | |
Murder on the Links | woman | Marthe Daubreuil | 1923 | murderer | |
The Murder of Roger Ackroyd | man | Dr James Sheppard | 1926 | murderer | |
The Big Four | man | Abe Rylan | 1927 | murderer | |
The Big Four | man | Claude Darrell | 1927 | murderer | |
The Big Four | man | Li Chang Yen | 1927 | murderer | |
The Big Four | woman | Madame Olivier | 1927 | murderer | |
The Mystery of the Blue Train | man | Major Knighton | 1928 | murderer | |
The Mystery of the Blue Train | woman | Ada Mason aka Kitty Kidd | 1928 | murderer | |
Peril at End House | woman | Magdala "Nick" Buckley | 1931 | murderer | |
Lord Edgware Dies | woman | Jane Wilkinson | 1933 | murderer | |
Murder on the Orient Express | woman | Mary Debenham | 1934 | murderer | |
Murder on the Orient Express | woman | Mrs. Hubbard | 1934 | murderer | |
Murder on the Orient Express | man | Colonel Arbuthnot | 1934 | murderer | |
Murder on the Orient Express | woman | Princess Dragomiroff | 1934 | murderer | |
Murder on the Orient Express | man | Hector McQueen | 1934 | murderer | |
Murder on the Orient Express | woman | Countess Andrenyi | 1934 | murderer | |
Murder on the Orient Express | man | Count Andrenyi | 1934 | murderer | |
Murder on the Orient Express | man | Cyrus Hardman | 1934 | murderer | |
Murder on the Orient Express | man | Antonoi Foscanelli | 1934 | murderer | |
Murder on the Orient Express | woman | Greta Ohlsson | 1934 | murderer | |
Murder on the Orient Express | woman | Hildegarde Schmidt | 1934 | murderer | |
Murder on the Orient Express | man | Pierre Michelle | 1934 | murderer | |
Three Act Tragedy | man | Charles Cartwright | 1934 | murderer | |
Death in The Clouds | man | Norman Gale | 1935 | murderer | |
Murder in Mesopotamia | man | Dr Erich Leidner aka Frederick Bosner | 1935 | murderer | |
The ABC Murders | man | Franklin Clarke | 1936 | murderer | |
Cards on the Table | man | Dr Geoffrey Roberts | 1936 | murderer | |
Dumb Witness | woman | Bella Tanios | 1937 | murderer | |
Death on the Nile | man | Simon Doyle | 1937 | murderer | |
Death on the Nile | woman | Jacqueline de Bellefort | 1937 | murderer | |
Appointment with Death | woman | Lady Westholme | 1937 | murderer | |
Hercule Poirot's Christmas | man | Superintendent Sugden | 1938 | murderer | |
Sad Cypress | woman | Jessie Hopkins | 1940 | murderer | |
Evil Under the Sun | man | Patrick Redfern | 1940 | murderer | |
Evil Under the Sun | woman | Christine Redfern | 1940 | murderer | |
One, Two, Buckle my Shoe | man | Martin Alistair Blunt | 1940 | murderer | |
Five Little Pigs | woman | Elsa Greer | 1941 | murderer | |
The Hollow | woman | Gerda Christow | 1946 | murderer | |
Taken at the Flood | man | David Hunter | 1948 | murderer | |
Mrs McGinty's Dead | man | Robin Upward | 1952 | murderer | |
After the Funeral | woman | Miss Gilchrist | 1953 | murderer | |
Hickory Dickory Dock | man | Nigel Chapman | 1955 | murderer | |
Dead Man's Folly | man | James | 1956 | murderer | |
Cat Among the Pigeons | woman | Ann Shapland | 1959 | murderer | |
The Clocks | man | Josiah Bland | 1963 | murderer | |
The Clocks | woman | Valerie Bland | 1963 | murderer | |
Third Girl | woman | Frances Cary | 1966 | murderer | |
Hallowe'en Party | woman | Rowena Drake | 1969 | murderer | |
Elephants can Remember | man | Alistair Ravenscroft | 1972 | murderer | |
Curtain | man | Hercule Poirot | 1975 | murderer | |
The Mysterious Affair at Styles | woman | Mrs Inglethorpe | 1920 | victim | |
Murder on the Links | man | Paul Renauld | 1923 | victim | |
The Murder of Roger Ackroyd | man | Roger Ackroyd | 1926 | victim | |
The Murder of Roger Ackroyd | woman | Mrs Ferrars | 1926 | victim | |
The Big Four | man | Mayerling | 1927 | victim | |
The Big Four | man | Mr Jonathan Whalley | 1927 | victim | |
The Big Four | man | Mr Paynter | 1927 | victim | |
The Big Four | man | Gilmour Wilson | 1927 | victim | |
The Big Four | man | John Ingles | 1927 | victim | |
The Big Four | woman | Miss Monro | 1927 | victim | |
The Mystery of the Blue Train | woman | Ruth Kettering | 1928 | victim | |
Peril at End House | woman | Maggie | 1931 | victim | |
Lord Edgware Dies | man | Lord Edgware | 1933 | victim | |
Lord Edgware Dies | man | Donald Ross | 1933 | victim | |
Lord Edgware Dies | woman | Carlotta Adams | 1933 | victim | |
Murder on the Orient Express | man | Ratchett/Cassetti | 1934 | victim | |
Three Act Tragedy | man | Reverend Babbington | 1934 | victim | |
Three Act Tragedy | man | Dr Strange | 1934 | victim | |
Three Act Tragedy | woman | Mrs De Rushbridger | 1934 | victim | |
Death in The Clouds | woman | Madame Giselle | 1935 | victim | |
Murder in Mesopotamia | woman | Louise Leidner | 1935 | victim | |
Murder in Mesopotamia | woman | Miss Johnson | 1935 | victim | |
The ABC Murders | woman | Alice Ascher | 1936 | victim | |
The ABC Murders | woman | Betty Barnard | 1936 | victim | |
The ABC Murders | man | Sir Carmichael Clarke | 1936 | victim | |
Cards on the Table | man | Mr Shaitana | 1936 | victim | |
Cards on the Table | woman | Mrs Lorrimer | 1936 | victim | |
Cards on the Table | woman | Anne | 1936 | victim | |
Dumb Witness | woman | Emily Arundell | 1937 | victim | |
Death on the Nile | woman | Linnet Doyle | 1937 | victim | |
Death on the Nile | woman | Louise | 1937 | victim | |
Death on the Nile | woman | Mrs Otterbourne | 1937 | victim | |
Appointment with Death | woman | Mrs Boynton | 1937 | victim | |
Hercule Poirot's Christmas | man | Simeon Lee | 1938 | victim | |
Sad Cypress | woman | Laura Welman | 1940 | victim | |
Sad Cypress | woman | Mary Gerrard | 1940 | victim | |
Evil Under the Sun | man | Henry Morley | 1940 | victim | |
One, Two, Buckle my Shoe | woman | Nameless | 1940 | victim | |
Five Little Pigs | man | Amyas Crale | 1941 | victim | |
The Hollow | man | John Christow | 1946 | victim | |
Taken at the Flood | man | Enoch Arden | 1948 | victim | |
Mrs McGinty's Dead | woman | Mrs McGinty | 1952 | victim | |
After the Funeral | man | Richard Abernethie | 1953 | victim | |
Hickory Dickory Dock | woman | Celia Austin | 1955 | victim | |
Dead Man's Folly | woman | Marlene Tucker | 1956 | victim | |
Cat Among the Pigeons | woman | Miss Springer | 1959 | victim | |
Cat Among the Pigeons | woman | Miss Vansittart | 1959 | victim | |
The Clocks | man | Merlina Rival | 1963 | victim | |
The Clocks | woman | Nameless dead man | 1963 | victim | |
Third Girl | man | David Baker | 1966 | victim | |
Hallowe'en Party | woman | Joyce Reynolds | 1969 | victim | |
Elephants can Remember | woman | Mrs Ravenscroft | 1972 | victim | |
Curtain | man | Stephen Norton | 1975 | victim |
<!DOCTYPE html> | |
<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/[email protected]/build/d3-marcon.min.js"></script> | |
<script src="https://unpkg.com/[email protected]/lib/jeezy.min.js"></script> | |
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> | |
<script src="http://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> |