|
<!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> |