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/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="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> |