forked from tomshanley's block: Spiral heatmap
With added mouseover to highlight that month across all years, and the preceding/subsequent months. The spiral lets you compare preceding/subsequent months across breaks in the year.
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
Built with blockbuilder.org
forked from tomshanley's block: Spiral heatmap (with mouseover)
xxxxxxxxxx
<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;
}
.arc path {
stroke: white;
}
.arc text {
fill: white;
stroke: none;
pointer-events: none;
}
.text-path {
stroke: none;
fill: none;
}
</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 labelRadius = chartRadius + 5;
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
//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);
return x(labelAngle, labelRadius);
})
.attr("y", function (d, i) {
let labelAngle = (i * segmentAngle) + (segmentAngle / 2);
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 AND CONTROL POINTS
data.forEach(function (d, i) { calculateArc(d, i); });
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("class", "arc-segment")
.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); })
.on("mouseover", function (d) {
//HIGHLIGHT ARCS FROM THE SAME MONTH, OR WITHIN A 6 MONTH PERIOD EITHER SIDE
let thisMonth = d.month;
let lowerLimit = d.index - 6;
let upperLimit = d.index + 6;
arcs.selectAll(".arc-segment")
.style("opacity", function (a) {
if (a.month == thisMonth) {
return 1;
} else if (a.index > lowerLimit && a.index < upperLimit) {
return 1;
} else {
return 0.1
};
});
})
.on("mouseout", function (d) {
arcs.selectAll(".arc-segment").style("opacity", 1);
});
//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("class", "text-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;
});
yearLabels.append("text")
.attr("class", "year-label")
.attr("x", 3)
.attr("dy", -5)
.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");
});
//RETURNS THE X COORDINATE GIVEN A CERTAIN RADIUS AND ANGLE
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);
};
//RETURNS THE Y COORDINATE GIVEN A CERTAIN RADIUS AND ANGLE
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);
};
//CALCULATES THE VERTICES AND CONTROL POINTS FOR DRAWING EACH ARC'S PATH
function calculateArc(d, i) {
d.index = 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;
let endInnerRadius = holeRadius + (((i + 1) / segmentsPerCoil) * coilWidth)
let endOuterRadius = holeRadius + (((i + 1) / segmentsPerCoil) * coilWidth) + coilWidth;
//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;
//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);
};
function convertTextToNumbers(d) {
d.year = +d.year;
d.month = +d.month;
d.value = +d.value;
return d;
};
</script>
</body>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-scale-chromatic.v0.3.min.js