Skip to content

Instantly share code, notes, and snippets.

@tonmcg
Last active August 18, 2017 00:54
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save tonmcg/6905234ea79bc839f2b6d1d61f7261bc to your computer and use it in GitHub Desktop.
Scotch Rankings
border: no
license: gpl-3.0

Based on Scott Gregoire's Scotch ranking methodogy.

The scatterplot and table feature:

  • Mike Bostock's work with scatterplots

  • Robert Grove's implementation of the force-directed layout

  • Responsive properties that allows the browser to resize the SVG through the ViewBox attribute

  • Table built using D3 and that has a fixed header with a scrollable y-axis

Both the scatterplot and the table are constructed from data directly on Scott's GitHub repository.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>An Interactive Ranking of Scotches</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora">
<style>
body {
font-family: 'Lora', serif;
}
/* Scrollable table
http://stackoverflow.com/questions/17584702/how-to-add-a-scrollbar-to-an-html5-table#answer-17585032 */
table.table {
display: table;
width: 100%;
}
table.table thead, table.table tbody {
float: left;
width: 100%;
}
table.table tbody {
overflow: auto;
height: 450px;
}
table.table tr {
width: 100%;
display: table;
text-align: left;
}
table.table th, table.table td {
width: 16.6666667%;
}
.tooltip {
top: 100px;
left: 100px;
-moz-border-radius: 5px;
border-radius: 5px;
/*border: 2px solid #000;*/
background: #333;
opacity: .9;
color: white;
padding: 10px;
min-width: 375px;
min-width: 36.75vmin;
font-size: 2.25vmin;
line-height: 24pt;
font-family: 'Lora', serif;
font-weight: lighter;
visibility: visible;
}
.tooltip.right::before {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
opacity: .9;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 8px solid #333;
right: -8px;
top: 85px;
/* arbitrarily set */
}
.tooltip.left::before {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
opacity: .9;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
left: -8px;
border-right: 8px solid #333;
top: 85px;
/* arbitrarily set */
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
g.x.axis text,
g.y.axis text {
font-size: 16px;
}
.dataDot,
.legendDot {
stroke: #000;
cursor: pointer;
}
/* SVG Scaling */
/*body,*/
/*svg {*/
/* height: 100%;*/
/* margin: 0;*/
/* width: 100%;*/
/* float: left;*/
/*}*/
.scaling-svg-container {
position: relative;
height: 0;
width: 100%;
padding: 0
}
.scaling-svg {
position: absolute;
height: 99%;
width: 100%;
left: 0;
top: 0
}
</style>
<!-- D3 -->
<script charset="utf-8" src="https://d3js.org/d3.v3.min.js" type="text/javascript"></script>
<!-- D3 Queue -->
<script src="https://d3js.org/d3-queue.v3.min.js"></script>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-12">
<div id="title" class="page-header">
<h2>The Ideal Scotch <small>A Simple, Unbiased Ranking of Scotches</small></h2>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 col-xs-12">
<header><h3>Scoring Results for All Scotches <a id="reset" href="javascript:resetItems();" class="hidden">reset</a></h3></header>
<div id="scotchScatterPlot" class="scaling-svg-container"></div>
<!-- scatterplot -->
</div>
</div>
<div class="row">
<div class="col-md-12 col-xs-12">
<header><h3>Top Ranked Scotches</h3></header>
<table id="scotchTable" class="table table-striped responsive" cellspacing="0">
</table>
<!-- table -->
</div>
</div>
<div class="row">
<div class="col-xs-12 col-md-12" id="source">
<span>
<small>The whiskies labeled "Best in Both Sorting Orders" are ranked in the top ten for both sorting orders; the whiskies labeled "Best in One Sorting Order" are ranked in the top ten in only one of the sorting orders. The table above lists the whiskies that rank in the top ten for both sorting orders. Put another way, the table contains the whiskies colored either dark or light green in the scatterplot. The table also contains the ranks of scotches under both sorting orders.
</br>For example, "Complexity then Balance Rank" lists the rank of a scotch when the scotches are sorted by "Complexity" first then "Balance." "Complexity" and "Balance" are the "Complexity" and "Balance" scores, respectively. In the table, notice that Bruichladdich, Old Pulteney, and Scapa are unambiguously the top three scotches.
</br><footnote>Scott Gregoire <a href="https://www.linkedin.com/pulse/interactive-ranking-scotches-tony-mcgovern">A Simple, Unbiased Ranking of Scotches</a></footnote>
</small>
</span>
</div>
</div>
</div>
<script defer type="text/javascript">
"use strict";
function getSize() {
let myWidth = 0,
myHeight = 0;
if (typeof(window.innerWidth) == 'number') {
//Non-IE
myWidth = window.innerWidth;
myHeight = window.innerHeight;
}
else if (document.documentElement && (document.documentElement.clientWidth || document.documentElement.clientHeight)) {
//IE 6+ in 'standards compliant mode'
myWidth = document.documentElement.clientWidth;
myHeight = document.documentElement.clientHeight;
}
else if (document.body && (document.body.clientWidth || document.body.clientHeight)) {
//IE 4 compatible
myWidth = document.body.clientWidth;
myHeight = document.body.clientHeight;
}
return {
"height": myHeight,
"width": myWidth
};
}
// create tooltip
let tooltip = d3.select("body").append("div").style({
"position": "absolute",
"z-index": "10",
"visibility": "hidden"
}).attr({
"class": "tooltip"
});
let width;
// dimensions for the charts
let dashboardHeight = getSize().height;
let headerHeight = document.querySelector('#title').offsetHeight;
let footerHeight = document.querySelector('#source > span').offsetHeight;
let height = (dashboardHeight - headerHeight - footerHeight) * 1 / 3;
width = (document.querySelector('#scotchScatterPlot').offsetWidth - 16);
const margin = {
top: 40,
right: 40,
bottom: 60,
left: 40
};
const delay = 250,
duration = 750;
let results = [];
// define table column headers
const columns = ['Distillery', 'Complexity then Balance', 'Balance then Complexity', 'Complexity', 'Balance', 'Category'];
// create table
let table = d3.select('table');
let thead = table.append('thead');
let tbody = table.append('tbody');
// get the data
function getData() {
let endpointUrl = 'https://raw.githubusercontent.com/volk0ff/linkedin/master/whisky_rating_post/top_scotch_table.csv';
d3.queue()
.defer(d3.csv, endpointUrl)
.await(createWhiskeyViz);
}
// draw the whiskey table
function drawTable(enterData) {
// JOIN
let tr = thead.selectAll('tr').data([columns]);
// ENTER
tr.enter().append('tr');
// EXIT
tr.exit().remove();
// JOIN
let th = tr.selectAll('th').data(function(d) {
return d;
});
// ENTER
th.enter().append('th');
// UPDATE
th.attr({'scope': 'col'}).style({"color":"#fff","border-color":"#fff"}).transition().duration(duration).style({"color":"#333","border-color":"#ddd"}).text(function(d) {
return d;
});
// EXIT
th.exit().remove();
// JOIN
let rows = tbody.selectAll('tr').data(enterData);
// ENTER
rows.enter().append('tr');
// EXIT
rows.exit().remove();
// JOIN
let cells = rows.selectAll("td")
.data(function(row) {
return columns.map(function(col) {
return row[col];
});
});
// ENTER
cells.enter().append("td");
// UPDATE
cells.style({"color":"#fff","border-color":"#fff"}).transition().duration(duration).style({"color":"#333","border-color":"#ddd"}).text(function(d) {
return d;
});
// EXIT
cells.exit().remove();
// transition Bootstrap background color for odd numbered rows
d3.selectAll('tbody tr:nth-of-type(odd)').style({"background-color":"#fff"}).transition().duration(duration).style({"background-color":"#f9f9f9"});
}
// draw the scatterplot
function drawScatter(results) {
let titlePadding = 24,
padding = 1, // separation between nodes
radius = 6;
let x = d3.scale.linear()
.range([0, width]);
let y = d3.scale.linear()
.range([height, 0]);
let color = d3.scale.ordinal()
.range(['#2ca25f', '#99d8c9', '#bdbdbd', '#3182bd']);
let xAxis = d3.svg.axis()
.scale(x)
.ticks(0)
.orient("bottom");
let yAxis = d3.svg.axis()
.scale(y)
.ticks(0)
.orient("left");
let canvas = d3.select("#scotchScatterPlot").append("svg")
.attr({
"width": width + margin.left + margin.right,
"height": height + margin.top + margin.bottom + titlePadding
});
let svg = canvas.append("g")
.attr({
"transform": "translate(" + margin.left + "," + (margin.top + titlePadding) + ")"
});
let force = d3.layout.force()
.nodes(results)
.size([width, height])
.on("tick", tick)
.charge(-1)
.gravity(0)
.chargeDistance(10);
x.domain(d3.extent(results, function(d) {
return d.Complexity;
})).nice();
y.domain(d3.extent(results, function(d) {
return d.Balance;
})).nice();
// Set initial positions
results.forEach(function(d) {
d.x = x(d.Complexity);
d.y = y(d.Balance);
d.color = color(d.Category);
d.radius = radius;
});
force.start();
svg.append("g")
.attr({
"class": "x axis",
"transform": "translate(0," + height + ")"
})
.call(xAxis)
.append("text")
.attr({
"class": "label",
"x": width / 2,
"y": 24
})
.style({
"text-anchor": "middle"
})
.text("Complexity");
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
.append("text")
.attr({
"class": "label",
"transform": "translate(" + -12 + "," + height / 2 + ")rotate(-90)",
"text-anchor": "middle"
})
.text("Balance");
let node = svg.selectAll(".dataDot")
.data(results)
.enter().append("circle")
.attr({
"class": "dataDot",
"r": radius,
"cx": function(d) {
return x(d.Complexity);
},
"cy": function(d) {
return y(d.Balance);
}
});
node.style("opacity", 0)
.transition()
.delay(delay)
.duration(duration)
.style({
"fill": function(d) {
return color(d.Category);
},
"opacity": 1
});
node.on("mouseover", showDetail).on("mouseout", hideDetail).on('click',highlightCategory);
let legend = svg.selectAll(".legend")
.data(color.domain())
.enter().append("g")
.attr({
"class": "legend",
"transform": function(d, i) {
return "translate(0," + i * 20 + ")";
}
});
let circle = legend.append("circle")
.attr({
"class": "legendDot",
"r": radius,
"cx": width / 12
});
circle.style("opacity", 0)
.transition()
.delay(delay)
.duration(duration)
.style({
"fill": color,
"opacity": 1
});
circle.on("click", highlightCategory);
legend.append("text")
.attr({
"x": width / 12 + 20,
"y": 0,
"dy": ".35em",
})
.style("text-anchor", "start")
.text(function(d) {
return d;
})
.style("opacity", 0)
.transition()
.delay(delay)
.duration(duration)
.style("opacity", 1);
function tick(e) {
node.each(moveTowardDataPosition(e.alpha));
node.each(collide(e.alpha));
node.attr("cx", function(d) {
return d.x;
})
.attr("cy", function(d) {
return d.y;
});
}
function moveTowardDataPosition(alpha) {
return function(d) {
d.x += (x(d.Complexity) - d.x) * 0.1 * alpha;
d.y += (y(d.Balance) - d.y) * 0.1 * alpha;
};
}
// Resolve collisions between nodes.
function collide(alpha) {
let quadtree = d3.geom.quadtree(results);
return function(d) {
let r = d.radius + radius + padding,
nx1 = d.x - r,
nx2 = d.x + r,
ny1 = d.y - r,
ny2 = d.y + r;
quadtree.visit(function(quad, x1, y1, x2, y2) {
if (quad.point && (quad.point !== d)) {
let x = d.x - quad.point.x,
y = d.y - quad.point.y,
l = Math.sqrt(x * x + y * y),
r = d.radius + quad.point.radius + (d.color !== quad.point.color) * padding;
if (l < r) {
l = (l - r) / l * alpha;
d.x -= x *= l;
d.y -= y *= l;
quad.point.x += x;
quad.point.y += y;
}
}
return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
});
};
}
}
// set full opacity to all circles
function resetItems() {
d3.selectAll('circle').classed('selected', false).transition().duration(duration).style("opacity", 1.0);
// get original data from target table
let tableData = d3.select('table tbody').datum()[0];
drawTable(tableData);
d3.select('#reset').classed('hidden',true);
}
// set lower opacity to all cirlces that do not meet the category criteria
function highlightCategory() {
let e = d3.select(this),
data = e.datum(),
fill = e.style("fill");
if(typeof data == 'object') {
data = data.Category;
}
e.classed('selected', true); // set class on selected category to 'selected'
// filter circles that do not meet the same fill criteria as selected category
let circles = d3.selectAll('circle').filter(function() {
let j = d3.select(this).style("fill");
return fill !== j;
});
d3.selectAll('circle').transition().duration(duration).style('opacity',1.0); // set opacity on all circles to 1 with a transition
e.style('opacity',1.0) // set the opacity to the selected circle immediately to 1
circles.transition().duration(duration).style({"opacity":0.1}); // set opacity on filtered circles to less than 1
// get original data from target table
let tableData = d3.select('table tbody').datum()[0];
// filter data based on category selection
let filteredData = tableData.filter(function(d, i) {
return d.Category == data;
});
drawTable(filteredData);
d3.select('#reset').classed('hidden',false);
}
// show tooltip on hover
function showDetail() {
// show hover text only on items within the selected category
// or all items if no category is selected
if (d3.select(this).style('opacity') < 1) {
return null;
}
// show tooltip with information from the __data__ property of the <circle> element
let d = this.__data__;
let content = '<b>Distillery: </b>' + d.Distillery + '<br/>' +
'<b>Complexity then Balance Rank: </b>' + d.ComplexityThenBalanceRank + '<br/>' +
'<b>Balance then Complexity Rank: </b>' + d.BalanceThenComplexityRank + '<br/>' +
'<b>Complexity: </b>' + d.Complexity + '<br/>' +
'<b>Balance: </b>' + d.Balance + '<br/>' +
'<b>Category: </b>' + d.Category + '<br/>';
let x_hover = 0;
let y_hover = 0;
let tooltipWidth = parseInt(tooltip.style('width'));
let tooltipHeight = parseInt(tooltip.style('height'));
let classed, notClassed;
if (event.pageX > document.body.clientWidth / 2) {
x_hover = tooltipWidth + 30;
classed = 'right';
notClassed = 'left';
}
else {
x_hover = -30;
classed = 'left';
notClassed = 'right';
}
y_hover = (document.body.clientHeight - event.pageY < (tooltipHeight + 4)) ? event.pageY - (tooltipHeight + 4) : event.pageY - tooltipHeight / 2;
return tooltip
.classed(classed, true)
.classed(notClassed, false)
.style("visibility", "visible")
.style("top", y_hover + "px")
.style("left", (event.pageX - x_hover) + "px")
.html(content);
}
// Hide tooltip on hover
function hideDetail() {
return tooltip.style("visibility", "hidden");
}
// Many browsers -- IE particularly -- will not auto-size inline SVG
// IE applies default width and height sizing
// padding-bottom hack on a container solves IE inconsistencies in size
// https://css-tricks.com/scale-svg/#article-header-id-10
function setResponsiveSVG() {
let width = +d3.select('svg').attr('width');
let height = +d3.select('svg').attr('height');
let calcString = +(height / width) * 100 + "%";
let svgElement = d3.select('svg');
let svgParent = d3.select(d3.select('svg').node().parentNode);
svgElement
.attr('class', 'scaling-svg')
.attr('preserveAspectRatio', 'xMinYMin')
.attr('viewBox', '0 0 ' + width + ' ' + height)
.attr('width', null)
.attr('height', null);
svgParent.style('padding-bottom', calcString);
}
function createWhiskeyViz(error, data) {
if (error) throw error;
results = data;
results.forEach(function(d) {
d['Complexity then Balance'] = d.ComplexityThenBalanceRank;
d['Balance then Complexity'] = d.BalanceThenComplexityRank;
d.Balance = +d.Balance;
d.Complexity = +d.Complexity;
});
d3.select('table tbody').datum([results]);
drawTable(results);
drawScatter(results);
setResponsiveSVG();
}
getData();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment