block by jeremycflin deabaec9c8170eaaafd1217187f59895

A year of weekly running mileage

Full Screen

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

index.html

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