|
<!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="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 ? 4 : 5; |
|
|
|
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>"; |
|
}); |
|
|
|
// fix y-coordinate for exact data-based encoding/positioning |
|
data.forEach(function(d){ d.fy = y(d.book_year); }); |
|
|
|
var simulation = d3.forceSimulation(data) |
|
.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> |