Skip to content

Instantly share code, notes, and snippets.

@tomshanley
Last active February 1, 2018 10:08
Show Gist options
  • Save tomshanley/b3802d703376e185eefeec95278df421 to your computer and use it in GitHub Desktop.
Save tomshanley/b3802d703376e185eefeec95278df421 to your computer and use it in GitHub Desktop.
Spiral heatmap (with coil padding)
license: mit
height: 800

A spiral heatmap, showing the number of tourist nights in Berlin (from the Makeover Monday series.

This is based in on the spiral heatmap from this stackoverflow question, for R.

Draws inspiration from Robert Kosara's example, and the research papers linked from that blog posting.

Within the code, you can adjust

  • the number arcs per coil (one rotation around the spiral). In this example, it is set to 12, being the number of months
  • the radius of the chart
  • the size of the hole in the middle
  • you can have straight edged arcs, but why would you :)
  • padding between the coils

Built with blockbuilder.org

forked from tomshanley's block: Spiral heatmap

year month value
2010 1 2335397
2010 2 2394876
2010 3 3309510
2010 4 3369962
2010 5 3863966
2010 6 3927567
2010 7 4072487
2010 8 4205120
2010 9 4063860
2010 10 4169066
2010 11 3104760
2010 12 2774376
2011 1 2440457
2011 2 2493872
2011 3 3213992
2011 4 4040004
2011 5 4105650
2011 6 4144832
2011 7 4423201
2011 8 4525518
2011 9 4270850
2011 10 4485682
2011 11 3341252
2011 12 3232990
2012 1 2735178
2012 2 2863226
2012 3 3748931
2012 4 4385322
2012 5 4490444
2012 6 4457876
2012 7 4980034
2012 8 5220816
2012 9 4661730
2012 10 4861788
2012 11 3769699
2012 12 3616842
2013 1 2988211
2013 2 3115077
2013 3 4385888
2013 4 4488448
2013 5 5022604
2013 6 4766948
2013 7 5244336
2013 8 5781458
2013 9 4913966
2013 10 5248558
2013 11 3963402
2013 12 3965234
2014 1 3230740
2014 2 3349810
2014 3 4239302
2014 4 4984130
2014 5 5356133
2014 6 5181812
2014 7 5602302
2014 8 5996980
2014 9 5177832
2014 10 5524850
2014 11 4373110
2014 12 4360364
2015 1 3467560
2015 2 3638130
2015 3 4616402
2015 4 5024624
2015 5 5522012
2015 6 5361016
2015 7 6215340
2015 8 6055644
2015 9 5656188
2015 10 5812280
2015 11 4585641
2015 12 4545268
2016 1 3684249
2016 2 4045746
2016 3 4939946
2016 4 5093966
2016 5 5582962
2016 6 5496828
2016 7 6299835
2016 8 5829658
2016 9 5677217
2016 10 5864422
2016 11 4822030
2016 12 4798530
<head>
<meta charset="utf-8">
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v0.3.min.js"></script>
<link href="https://fonts.googleapis.com/css?family=Catamaran" rel="stylesheet">
<style>
body {
font-family: 'Catamaran', sans-serif;
margin: 20px;
top: 20px;
right: 20px;
bottom: 20px;
left: 20px;
}
line {
stroke: lightgray;
}
.year-label {
fill: white;
}
</style>
</head>
<body>
<h1>Spiral heatmap</h1>
<p>An example of a spiral heatmap, showing the number of tourist nights in Berlin (from the Makeover Monday series).</p>
<div id="chart"></div>
<div id="legend"></div>
<script>
const radians = 0.0174532925;
//CHART CONSTANTS
const chartRadius = 250;
const chartWidth = chartRadius * 2;
const chartHeight = chartRadius * 2;
const margin = { "top": 40, "bottom": 40, "left": 40, "right": 40 };
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
//CHART OPTIONS
const holeRadiusProportion = 0.3; //fraction of chartRadius. 0 gives you some pointy arcs in the centre.
const holeRadius = holeRadiusProportion * chartRadius;
const segmentsPerCoil = 12; //number of coils. for this example, I have 12 months per year. But you change to whatever suits your data.
const segmentAngle = 360 / segmentsPerCoil;
var coils; //number of coils, based on data.length / segmentsPerCoil
var coilWidth; //remaining chartRadius (after holeRadius removed), divided by coils + 1. I add 1 as the end of the coil moves out by 1 each time
const coilPadding = 0.2; //Proportion of coilWidth that will be padding between coils
//SCALES
var colour = d3.scaleSequential(d3.interpolateViridis);
//CREATE SVG AND A G PLACED IN THE CENTRE OF THE SVG
const svg = d3.select("#chart")
.append("svg")
.attr("width", chartWidth + margin.left + margin.right)
.attr("height", chartHeight + margin.top + margin.bottom);
const g = svg.append("g")
.attr("transform", "translate("
+ (margin.left + chartRadius)
+ ","
+ (margin.top + chartRadius) + ")");
//LOAD THE DATA
d3.csv("data.csv", convertTextToNumbers, function (error, data) {
if (error) { throw error; };
//ENSURE THE DATA IS SORTED CORRECTLY, IN THIS CASE BY YEAR AND MONTH
//THE SPIRAL WILL START IN THE MIDDLE AND WORK OUTWARDS
data.sort(function (a, b) {
return a.year - b.year || a.month - b.month;
});
//CALCULATE AND STORE THE REMAING
var dataLength = data.length;
coils = Math.ceil(dataLength / segmentsPerCoil);
coilWidth = (chartRadius * (1 - holeRadiusProportion)) / (coils + 1);
//console.log("coilWidth: " + coilWidth);
var dataExtent = d3.extent(data, function (d) { return d.value; });
colour.domain(dataExtent);
//ADD LABELS AND GRIDS FOR EACH MONTH FIRST
//SO THE GRID LINES APPEAR BEHIND THE SPIRAL
var monthLabels = g.selectAll(".month-label")
.data(months)
.enter()
.append("g")
.attr("class", "month-label");
monthLabels.append("text")
.text(function (d) { return d; })
.attr("x", function (d, i) {
let labelAngle = (i * segmentAngle) + (segmentAngle / 2);
let labelRadius = chartRadius + 5;
return x(labelAngle, labelRadius);
})
.attr("y", function (d, i) {
let labelAngle = (i * segmentAngle) + (segmentAngle / 2);
let labelRadius = chartRadius + 5;
return y(labelAngle, labelRadius);
})
.style("text-anchor", function (d, i) {
return i < (months.length / 2) ? "start" : "end";
});
monthLabels.append("line")
.attr("x2", function (d, i) {
let lineAngle = (i * segmentAngle);
let lineRadius = chartRadius + 10;
return x(lineAngle, lineRadius);
})
.attr("y2", function (d, i) {
let lineAngle = (i * segmentAngle);
let lineRadius = chartRadius + 10;
return y(lineAngle, lineRadius);
});
//ASSUMING DATA IS SORTED, CALCULATE EACH DATA POINT'S SEGMENT VERTICES
data.forEach(function (d, i) {
let coil = Math.floor(i / segmentsPerCoil);
let position = i - (coil * segmentsPerCoil);
//console.log("positions: " + i + " " + coil + " " + position);
let startAngle = position * segmentAngle;
let endAngle = (position + 1) * segmentAngle;
//console.log("angles: " + startAngle + " " + endAngle);
//console.log(holeRadius + " " + segmentsPerCoil + " " + coilWidth)
let startInnerRadius = holeRadius + ((i / segmentsPerCoil) * coilWidth)
let startOuterRadius = holeRadius + ((i / segmentsPerCoil) * coilWidth) + (coilWidth * (1 - coilPadding));
let endInnerRadius = holeRadius + (((i + 1) / segmentsPerCoil) * coilWidth)
let endOuterRadius = holeRadius + (((i + 1) / segmentsPerCoil) * coilWidth) + (coilWidth * (1 - coilPadding));
//console.log("start radi: " + startInnerRadius + " " + startOuterRadius);
//console.log("end radi: " + endInnerRadius + " " + endOuterRadius);
//vertices of each segment
d.x1 = x(startAngle, startInnerRadius);
d.y1 = y(startAngle, startInnerRadius);
d.x2 = x(endAngle, endInnerRadius);
d.y2 = y(endAngle, endInnerRadius);
d.x3 = x(endAngle, endOuterRadius);
d.y3 = y(endAngle, endOuterRadius);
d.x4 = x(startAngle, startOuterRadius);
d.y4 = y(startAngle, startOuterRadius);
//CURVE CONTROL POINTS
let midAngle = startAngle + (segmentAngle / 2)
let midInnerRadius = holeRadius + (((i + 0.5) / segmentsPerCoil) * coilWidth)
let midOuterRadius = holeRadius + (((i + 0.5) / segmentsPerCoil) * coilWidth) + (coilWidth * (1 - coilPadding));
//MID POINTS, WHERE THE CURVE WILL PASS THRU
d.mid1x = x(midAngle, midInnerRadius);
d.mid1y = y(midAngle, midInnerRadius);
d.mid2x = x(midAngle, midOuterRadius);
d.mid2y = y(midAngle, midOuterRadius);
//FROM https://stackoverflow.com/questions/5634460/quadratic-b%C3%A9zier-curve-calculate-points
d.controlPoint1x = (d.mid1x - (0.25 * d.x1) - (0.25 * d.x2)) / 0.5;
d.controlPoint1y = (d.mid1y - (0.25 * d.y1) - (0.25 * d.y2)) / 0.5;
d.controlPoint2x = (d.mid2x - (0.25 * d.x3) - (0.25 * d.x4)) / 0.5;
d.controlPoint2y = (d.mid2y - (0.25 * d.y3) - (0.25 * d.y4)) / 0.5;
//console.log(d);
});
var arcs = g.selectAll(".arc")
.data(data)
.enter()
.append("g")
.attr("class", "arc");
//STRAIGHT EDGES
/*
arcs.append("path")
.attr("d", function (d) {
let M = "M " + d.x1 + " " + d.y1;
let L1 = "L " + d.x2 + " " + d.y2;
let L2 = "L " + d.x3 + " " + d.y3;
let L3 = "L " + d.x4 + " " + d.y4;
return M + " " + L1 + " " + L2 + " " + L3 + " Z"
})
//.style("fill", function (d) { return colour(d.value); })
.style("fill", "white")
.style("stroke", "white")
*/
//CURVED EDGES
arcs.append("path")
.attr("d", function (d) {
//start at vertice 1
let start = "M " + d.x1 + " " + d.y1;
//inner curve to vertice 2
let side1 = " Q " + d.controlPoint1x + " " + d.controlPoint1y + " " + d.x2 + " " + d.y2;
//straight line to vertice 3
let side2 = "L " + d.x3 + " " + d.y3;
//outer curve vertice 4
let side3 = " Q " + d.controlPoint2x + " " + d.controlPoint2y + " " + d.x4 + " " + d.y4;
//combine into string, with closure (Z) to vertice 1
return start + " " + side1 + " " + side2 + " " + side3 + " Z"
})
.style("fill", function (d) { return colour(d.value); })
.style("stroke", "white")
//ADD LABELS FOR THE YEAR AT THE START OF EACH COIL (IE THE FIRST MONTH)
var yearLabels = arcs.filter(function (d) { return d.month == 1; }).raise();
yearLabels.append("path")
.attr("id", function (d) { return "path-" + d.year; })
.attr("d", function (d) {
//start at vertice 1
let start = "M " + d.x1 + " " + d.y1;
//inner curve to vertice 2
let side1 = " Q " + d.controlPoint1x + " " + d.controlPoint1y + " " + d.x2 + " " + d.y2;
return start + side1;
})
.style("fill", "none")
//.style("opacity", 0);
yearLabels.append("text")
.attr("class", "year-label")
.attr("x", 3)
.attr("dy", -4)
.append("textPath")
.attr("xlink:href", function (d) {
return "#path-" + d.year;
})
.text(function (d) { return d.year; })
//DRAW LEGEND
const legendWidth = chartRadius;
const legendHeight = 20;
const legendPadding = 40;
var legendSVG = d3.select("#legend")
.append("svg")
.attr("width", legendWidth + legendPadding + legendPadding)
.attr("height", legendHeight + legendPadding + legendPadding);
var defs = legendSVG.append("defs");
var legendGradient = defs.append("linearGradient")
.attr("id", "linear-gradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "100%")
.attr("y2", "0%");
let noOfSamples = 20;
let dataRange = dataExtent[1] - dataExtent[0];
let stepSize = dataRange / noOfSamples;
for (i = 0; i < noOfSamples; i++) {
legendGradient.append("stop")
.attr("offset", (i / (noOfSamples - 1)))
.attr("stop-color", colour(dataExtent[0] + (i * stepSize)));
}
var legendG = legendSVG.append("g")
.attr("class", "legendLinear")
.attr("transform", "translate(" + legendPadding + "," + legendPadding + ")");
legendG.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", legendWidth)
.attr("height", legendHeight)
.style("fill", "url(#linear-gradient)");
legendG.append("text")
.text("Fewer nights")
.attr("x", 0)
.attr("y", legendHeight - 35)
.style("font-size", "12px");
legendG.append("text")
.text("More nights")
.attr("x", legendWidth)
.attr("y", legendHeight - 35)
.style("text-anchor", "end")
.style("font-size", "12px");
});
function x(angle, radius) {
//change to clockwise
let a = 360 - angle;
//start from 12 o'clock
a = a + 180;
return radius * Math.sin(a * radians);
};
function y(angle, radius) {
//change to clockwise
let a = 360 - angle;
//start from 12 o'clock
a = a + 180;
return radius * Math.cos(a * radians);
};
function convertTextToNumbers(d) {
d.year = +d.year;
d.month = +d.month;
d.value = +d.value;
return d;
};
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment