Note: still a work in progress
In his heat-histogram, Adam Pierce shows how using masks lets you draw an area with multiple colors using just one path.
Here this method is applied to explore the weekly mileage I ran in 2014. Instead of updating the mask itself like in Adam's bl.ocks, the underlying masked paths are redrawn depending on the threshold values from the inputs, so the path can be selectively colored.
The bars are the distance (yellow) and elevation (blue) of the individual runs, hover on them to get more details.
forked from gcalmettes's block: A year of weekly running mileage
xxxxxxxxxx
<meta charset="utf-8">
<style>
div.tooltip {
color: black;
position: absolute;
text-align: left;
width: auto;
height: auto;
padding: 5px;
font-family: Futura;
font: 12px sans-serif ;
background: #589772;
border: 0px;
border-radius: 8px;
pointer-events: none;
}
.movingSum {
stroke: black;
stroke-width: 2px;
fill: none;
}
.lowArc {
fill: #fee0d2;
}
.middleArc {
fill: #fc9272;
}
.highArc {
fill: #de2d26;
}
.axisCircle{
fill: none;
stroke: lightgray;
stroke-width: 1;
}
.axisLabel{
font-family: sans-serif;
font-size: 0.75em;
fill: lightgray;
}
.monthLine{
stroke: lightgray;
}
.monthLabel{
font-family: sans-serif;
font-size: 1em;
fill: lightgray;
}
.yearLabel{
font-family: sans-serif;
font-size: 2.5em;
fill: black;
}
.summaryNumber{
font-family: sans-serif;
font-size: 1em;
}
.distance{
fill: #fc9272;
}
.distanceLine{
stroke-width: 1.5px;
stroke: yellow;
}
.elevation{
fill: #51aae8;
}
.elevationLine{
stroke-width: 1.5px;
stroke: #51aae8;
}
.selected{
stroke-width: 5px;
stroke: #624D9A;
}
</style>
<body>
<div>
<div>
<span>
<svg width=35 height=12>
<rect x=0 y=0 width=12 height=12 class="lowArc" />
<line x1=14 y1=12 x2=16 y2=0 style="stroke: black; stroke-width: 1" />
<rect x=18 y=0 width=12 height=12 class="middleArc" />
</svg>
transition
</span>
<input type="range" min="0" max="95" value="40" step="1" id="lowTransition"/>
<div id="lowTransition-value" style="display: inline-block; width: 25px">40</div>
miles/week
</div>
<div>
<span>
<svg width=35 height=12>
<rect x=0 y=0 width=12 height=12 class="middleArc" />
<line x1=14 y1=12 x2=16 y2=0 style="stroke: black; stroke-width: 1"/>
<rect x=18 y=0 width=12 height=12 class="highArc" />
</svg>
transition
</span>
<input type="range" min="40" max=200 value="95" step="1" id="highTransition"/>
<div id="highTransition-value" style="display: inline-block; width: 25px">95</div>
miles/week
</div>
</div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-range/3.0.3/moment-range.min.js"></script>
<script>
//extend moment.js with moment-range.js
window['moment-range'].extendMoment(moment);
//weekly volume thresholds (for colors)
let lowDist = 0,
middleDist = 40,
highDist = 95,
movingSumArray,
arczone,
currentYear
d3.json("runs2014.json", data => {
activities = data.allActivities
activities.forEach(d => {
d.date = moment(d.date),
d.id = +d.id,
d.distanceMi = d.distanceKm * 0.621371, //km/miles conversion
d.elevationUpFt = d.elevationUpM * 3.28084, //m/ft conversion
d.elevationDownFt = d.elevationDownM * 3.28084//m/ft conversion
})
radialPlot(activities, 2014)
})
//low transition input
d3.select("#lowTransition")
.on("input", function () {
//update displayed value
d3.select("#lowTransition-value").text(+this.value);
//adjust min of highTransition
d3.select("#highTransition")
.attr("min", +this.value)
middleDist = +this.value,
updateZonesIndicesLimits(movingSumArray, lowDist, middleDist, highDist)
});
//high transition input
d3.select("#highTransition")
.on("input", function () {
//update displayed value
d3.select("#highTransition-value").text(+this.value);
//adjust max of lowTransition
d3.select("#lowTransition")
.attr("max", +this.value)
highDist = +this.value,
updateZonesIndicesLimits(movingSumArray, lowDist, middleDist, highDist)
});
function radialPlot(data, year){
currentYear = year//in global scope
/////////////////////////
//data munging
const yearTimeRange = moment.range(new Date(year, 0, 1), new Date(year, 11, 31))
data = data.filter(d => d.date.year() == year)
//mileage by 7-days window
movingSumArray = Array.from(yearTimeRange.by("day")).map(d => {
return {date: d,
distanceMi: getMovingSum(d, data, undefined, undefined,"distanceMi"),
elevationUpFt: getMovingSum(d, data, undefined, undefined, "elevationUpFt")
}
})
/////////////////////////
//D3 computation-related stuffs
const margin = {top: 50, right: 50, bottom: 50, left: 50},
width = 600 - margin.left - margin.right,
height = 600 - margin.top - margin.bottom
const angleScale = d3.scaleTime()
.domain([moment(new Date(year, 0, 1)), moment(new Date(year, 11, 31))])
.range([0, 1.9*Math.PI])
const radiusScale = d3.scaleLinear()
.domain([0, 180])
.range([140, height/2])
//radial projection, with starting position at Pi/2
xScale = (day, distance) => Math.cos(angleScale(day)-Math.PI/2)*radiusScale(distance)
yScale = (day, distance) => Math.sin(angleScale(day)-Math.PI/2)*radiusScale(distance)
const lineDistance = d3.line()
.x(d => xScale(d.date, d.distanceMi))
.y(d => yScale(d.date, d.distanceMi))
arcZone = d3.arc()
.innerRadius(radiusScale(0))
.outerRadius(radiusScale(radiusScale.domain()[1]))
.startAngle(d => angleScale(movingSumArray[d.startIndice].date))
.endAngle(d => angleScale(movingSumArray[d.endIndice].date));
/////////////////////////
//D3 DOM-related stuffs
const svg = d3.select("body")
.append("svg")
.attr("id", "mainSVG")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`)
//add mask of weekly volume path that will be used to mask drawn arcs
//see https://bl.ocks.org/1wheel/76a07ca0d23f616d29349f7dd7857ca5
const defs = svg.append('defs');
defs.append('mask')
.attr('id', `movingSumMask-${year}`)
.append('path')
.datum(movingSumArray)
.attr('d', lineDistance.curve(d3.curveCatmullRom))
.attr("fill", "#fff");
gMovingSum = svg.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`)
.attr("class", "gMovingSum")
//black stroke
gMovingSum.append("path")
.datum(movingSumArray)
.attr("d", lineDistance.curve(d3.curveCatmullRom))
.attr("class", "movingSum")
//tooltip
const tooltip = d3.select("body")
.append("div")
.attr("class", "tooltip")
.style("opacity", 0);
//draw arcs colored depending on weekly mileage and masked
//using above mask
gMovingSum.selectAll(".arcZone")
.data(getZonesIndicesLimits(movingSumArray, lowDist, middleDist, highDist))
.enter()
.append("path")
.attr("class", d => `arcZone ${d.zone}Arc`)
.attr("d", arcZone)
.attr("mask", `url(#movingSumMask-${year})`)
//individual runs distance
svg.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`)
.attr("class", "gElevation")
.selectAll(".distanceLine")
.data(data)
.enter()
.append("line")
.attr("class", "distanceLine")
.attr("x1", d => xScale(d.date, 0))
.attr("x2", d => xScale(d.date, d.distanceMi))
.attr("y1", d => yScale(d.date, 0))
.attr("y2", d => yScale(d.date, d.distanceMi))
.on("mouseover", tooltipOn)
.on("mouseout", tooltipOff)
//individual runs elevation
svg.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`)
.attr("class", "gElevation")
.selectAll(".elevationLine")
.data(data)
.enter()
.append("line")
.attr("class", "elevationLine")
.attr("x1", d => xScale(d.date, 0))
.attr("x2", d => xScale(d.date, -d.elevationUpFt/250))
.attr("y1", d => yScale(d.date, 0))
.attr("y2", d => yScale(d.date, -d.elevationUpFt/250))
.on("mouseover", tooltipOn)
.on("mouseout", tooltipOff)
//axes
gAxis = svg.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`)
gAxis.selectAll(".monthLine")
.data(Array.from(yearTimeRange.by("month")))
.enter()
.append("line")
.attr("class", "monthLine")
.attr("x1", d => xScale(d, -120))
.attr("x2", d => xScale(d, radiusScale.domain()[1] + 20))
.attr("y1", d => yScale(d, -120))
.attr("y2", d => yScale(d, radiusScale.domain()[1]+ 20))
gAxis.selectAll(".monthLabel")
.data(Array.from(yearTimeRange.by("month")), d => d.add(15, "days"))
.enter()
.append("text")
.attr("class", "monthLabel")
.attr("x", d => xScale(d, radiusScale.domain()[1]+20))
.attr("y", d => yScale(d, radiusScale.domain()[1]+20))
.attr("text-anchor", "middle")
.html(d => d.format("MMM"))
//axis mileage circles
let circleAxis = [30, 60, 90, 120, 150]
let pathAxis = circleAxis.map(d => {
return Array.from(yearTimeRange.by("days"))
.map(day => {return {date: day, distanceMi: d} })
})
gAxis.selectAll(".axisCircle")
.data(pathAxis)
.enter()
.append("path")
.attr('d', lineDistance)
.attr('class', 'axisCircle')
//axis mileage labels
gAxis.selectAll('.axisLabel')
.data(circleAxis)
.enter()
.append('text')
.attr('class', 'axisLabel')
.attr("text-anchor", "start")
.attr("transform", d => `rotate(-10, ${xScale(moment("2014-12-31"), d) + 7}, ${yScale(moment("2014-12-31"), d)})`)
.attr('x', d => xScale(moment("2014-12-31"), d) + 7)
.attr('y', d => yScale(moment("2014-12-31"), d))
.text(d => `${d}mi`);
gAxis.append("text")
.attr("class", "yearLabel")
.attr("y", -10)
.attr("text-anchor", "middle")
.text(year)
let totalDistance = Math.floor(d3.sum(data, d => d.distanceMi))
let totalElevation = Math.floor(d3.sum(data, d => d.elevationUpFt))
gAxis.append("text")
.attr("class", "summaryNumber distance")
.attr("y", 10)
.attr("text-anchor", "middle")
.text(`${totalDistance} miles`)
gAxis.append("text")
.attr("class", "summaryNumber elevation")
.attr("y", 30)
.attr("text-anchor", "middle")
.text(`${totalElevation} ft`)
}
//calculate moving sum distance array
function getTimeRange(day, n, type="days"){
//moment .subtract mutates original moment so need to clone
let startInterval = day.clone().subtract(n, type)
return moment.range(startInterval, day)
}
function getTimeRangeActivities(data, range){
return data.filter(d => range.contains(d.date))
}
function getMovingSum(day, data, n=6, type="days", variable="distanceMi"){
let timeRange = this.getTimeRange(day, n, type)
let timeRangeActivities = this.getTimeRangeActivities(data, timeRange)
return d3.sum(timeRangeActivities, d => d[variable])
}
function getZonesIndicesLimits(movingSumArray, lowThreshold, middleThreshold, highThreshold){
//detect zones of weekly distance
let zonesIndices = {low: [],
middle: [],
high: []
}
//populate zoneIndices array
movingSumArray.forEach((d,i) => {
if (d.distanceMi >= highThreshold) zonesIndices.high.push(i)
if (d.distanceMi < highThreshold && d.distanceMi >= middleThreshold) zonesIndices.middle.push(i)
else zonesIndices.low.push(i)
})
//gather consecutive indices
for (let i=0; i<Object.keys(zonesIndices).length; i++) {
let array = zonesIndices[Object.keys(zonesIndices)[i]]
let result = [], temp = [], difference;
for (let i = 0; i < array.length; i += 1) {
if (difference !== (array[i] - i)) {
if (difference !== undefined) {
result.push(temp);
temp = [];
}
difference = array[i] - i;
}
temp.push(array[i]);
}
if (temp.length) {
result.push(temp);
}
zonesIndices[Object.keys(zonesIndices)[i]] = result
}
//extract first/last indices for each consecutive series
let zonesLimits = [];
Object.keys(zonesIndices).map(zoneName => {
zonesIndices[zoneName].map((indicesArray,i) => {
let limits = [indicesArray[0], indicesArray[indicesArray.length-1]]
if (limits[0]!==0) limits[0]=limits[0]-1
zonesLimits.push(
{zone: zoneName,
startIndice: limits[0],
endIndice: limits[1]})
})
})
return zonesLimits
}
function updateZonesIndicesLimits(movingSumArray, lowThreshold, middleThreshold, highThreshold) {
let newZonesIndicesLimits = getZonesIndicesLimits(movingSumArray, lowDist, middleDist, highDist)
zones = gMovingSum.selectAll(".arcZone")
.data(newZonesIndicesLimits)
zones.exit().remove()
zones.enter()
.append("path")
.attr("class", d => `arcZone ${d.zone}Arc`)
.attr("d", arcZone)
.attr("mask", `url(#movingSumMask-${currentYear})`)
.merge(zones)
.attr("class", d => `arcZone ${d.zone}Arc`)
.attr("d", arcZone)
}
function tooltipOn(d) {
const tooltip = d3.select(".tooltip")
const width = +d3.select("#mainSVG").attr("width"),
height = +d3.select("#mainSVG").attr("height")
d3.select(this)
.classed("selected", true)
tooltip.transition()
.duration(200)
.style("opacity", .9);
const xPos = this.getAttribute("class").includes("elevationLine")? `${+d3.select(this).attr("x2") - 100 + height/2}px` : `${+d3.select(this).attr("x2")+ 20 + height/2}px`
const yPos = this.getAttribute("class").includes("elevationLine")? `${+d3.select(this).attr("y2")+ 80 + height/2}px` : `${+d3.select(this).attr("y2")+ - 10 + height/2}px`
tooltip
.html(`${d.date.format("ddd DD MMM YYYY")}
<br/>${d.name}
<br/>${Math.floor(d.distanceMi*10)/10} miles / ${Math.floor(d.elevationUpFt)} ft`)
.style("left", xPos)
.style("top", yPos);
}//tooltipOn
function tooltipOff(d) {
const tooltip = d3.select(".tooltip")
d3.select(this)
.classed("selected", false);
tooltip.transition()
.duration(500)
.style("opacity", 0);
}//tooltipOff
</script>
</body>
https://d3js.org/d3.v4.min.js
https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js
https://cdnjs.cloudflare.com/ajax/libs/moment-range/3.0.3/moment-range.min.js