block by 1wheel 46874895034f5bded13c97097bf25a83

australian-fires

Full Screen

The New York Times and the Washington Post both published similar looking Australian fire line charts last month with differences I didn’t notice until looking at them side by side.

The Times groups fires by the calendar year, obscuring an important pattern: fire season in Australia starts in June or July.¹ The Post’s chart nicely adjusts for this by zooming into fire season and avoiding the awkward two highlighted lines that mid-January updates to the Times’ version added.

Romain Vuillemot shifted the Times’ chart by six months to account for this. I was curious how smaller changes to the start date for the year would change the shape of the lines; try mousing over my version above to see them move.

Both the Times and the Post also don’t show all the fires NASA FIRMS has detected in Australia since launching MODIS 20 years ago.

The Times only includes fires in New South Wales² while the Post has all of Australia but only the last seven years of data. The Times backs up their selection of New South Wales with maps showing how close the fires got to cities. The Post seems to have only gone back to 2013 because there were significantly more fires across all of Australia in 2012 than in 2019.

I’m not sure if there’s a bright light between cherrypicking date ranges and focusing on impacted regions, but the Post’s arbitrary cutoff makes me a little uncomfortable. While every chart can’t include every data point, getting this right is especially important with unusual weather. Climate change denialists have spent the last decade complaining about a single graph.

¹ I wasn’t able to find an good reference for the start of fire season; this does make it a little harder to justify switching from the calendar year. And with only 2019 data, calendar years are easier to label. The trend is so dramatic that the chart doesn’t change much.

² Mapshaper makes this surprisingly easy! I’m not sure I got it completely right through; the Post has significantly more fires than I counted.

2020-12-21 update Ned Cooper writes:

I note you weren’t able to find a good reference for the start of fire season. For NSW the statutory Bush Fire Danger Period is 1 October to 31 March. However the NSW Rural Fire Services Commissioner may declare the start of the fire season earlier for certain council areas (one level of government lower than the states i.e. NSW). For some council areas in northern NSW the bushfire season may start as early as 1 August, as it did this year and last year.

In Queensland the bushfire season usually starts earlier as the state is further north. From memory it officially starts on 1 August, and may start earlier for certain council areas based on local conditions. Further information may be available here.

script.js

console.clear()
d3.select('body').selectAppend('div.tooltip.tooltip-hidden')
var isoFmt = d3.timeFormat('%Y-%m-%d')

window.state = window.state || {
  offset: 0,
  isYearFilter: false,
}

if (state.cache){
  var allData = state.cache.allData
  var nswData = state.cache.nswData

  updateByYear()
  setTimeout(reindex, 1)
} else {
  var allData = makeData(false)
  var nswData = makeData(true)
  state.data = nswData
  state.cache = {nswData, allData}

  window.months = d3.nestBy(allData.filter(d => d.year == 2003), d => d.month)
    .map((d, i) => {

      var monthStrs = 'Jan. Feb. March April May June July Aug. Sept. Oct. Nov. Dec.'.split(' ')
      return {dayOfYear: d[0].dayOfYear, str: monthStrs[i]}
    })

  updateByYear()

d3.loadData('nsw-nrt.csv', 'nsw-archive.csv', 'all-nrt.csv', 'all-archive.csv', (err, res) => {
    function joinData(data){
      var date2count = {}
      ;(data.isNSW ? res[0].concat(res[1]) : res[2].concat(res[3])).forEach(d => {
        date2count[d.acq_date] = +d.n
      })

      data.forEach(d => {
        d.count = date2count[d.str] || 0
      })
    }
    joinData(allData)
    joinData(nswData)

    reindex()
  })

  function makeData(isNSW){
    var rv = d3.timeDay.range(new Date('2000-11-01'), new Date('2020-02-22'))
      .map(date => {
        return {
          date,
          year: date.getFullYear(),
          month: date.getMonth(),
          dayOfYear: +d3.timeFormat('%j')(date) - 1,
          str: isoFmt(date),
          count: 0,
        }
        return rv
      })
      // .filter(d => d.year > 2001)

    rv.isNSW = isNSW
    rv.name = isNSW ? 'New South Wales' : 'Australia'
    return rv
  }
}




var sel = d3.select('#graph').html('')
var c = d3.conventions({sel, margin: {left: 50, top: 14, right: 45, bottom: 100}, totalWidth: 960, totalHeight: 600, layers: 'sd'})

// add buttons
!(function(){
  var divSel = c.layers[1]

  var buttonAreaSel = divSel.append('div')
    .st({width: 400, position: 'absolute'})
    .translate([Math.round(c.width/2 - 300), c.height + 30])
    .appendMany('div.button', ['New South Wales', 'Australia'])
    .text(d => d)
    .on('click', d => {
      state.data = d == 'Australia' ? allData : nswData
      buttonAreaSel.classed('active', d => d == state.data.name)
      updateDataExtent()
    })
    .classed('active', d => d == state.data.name)

  var buttonYearSel = divSel.append('div')
    .st({width: 400, position: 'absolute'})
    .translate([Math.round(c.width/2 + 50), c.height + 30])
    .appendMany('div.button', ['2003-2020', '2013-2020'])
    .text(d => d)
    .on('click', (d, i) => {
      state.isYearFilter = i
      buttonYearSel.classed('active', (d, i) => i == state.isYearFilter)
      updateDataExtent()
    })
    .classed('active', (d, i) => i == state.isYearFilter)

})()

c.svg
  .on('mousemove touchmove', function(){
    var [x] = d3.mouse(this)
    // reindex(d3.clamp(0, c.x.invert(x), 363))
    reindex((365 + c.x.invert(x)) % 365)
  })
  .append('rect')
  .at({width: c.totalWidth, x: -c.margin.left, height: c.height, opacity: 0})


c.x.domain([0, 365])
setYDomain()
function setYDomain(){
  c.y.domain([0, state.data.isNSW ? 95000 : state.isYearFilter ? 500000 : 500000])
}

c.xAxis
c.yAxis.ticks(5).tickSize(c.width)
d3.drawAxis(c)

c.svg.select('.x').remove()
var monthSel = c.svg.append('g.axis.x')
  .translate([.5, c.height])
  .appendMany('g', months)

var yAxisSel = c.svg.select('.y').translate(c.width, 0)
yAxisSel.selectAll('text').filter(d => d == 0).st({opacity: 0})

var fireLabelSel = c.svg.append('g.axis').appendMany('g.fire-label', [80000, 500000]).call(posFireLabelSel)
fireLabelSel.append('rect').at({width: 88, height: 16, y: -8, fill: '#fff'})
fireLabelSel.append('text').text('detected fires')
  .at({dy: '.32em'})

c.svg.append('defs').append('linearGradient#gradient')
  .append('stop').at({offset: '  0%', stopColor: 'rgba(255,255,255,1)'}).parent()
  .append('stop').at({offset: ' 90%', stopColor: 'rgba(255,255,255,1)'}).parent()
  .append('stop').at({offset: '100%', stopColor: 'rgba(255,255,255,.2)'})

c.svg.append('rect')
  .at({width: 50, height: 20, y: c.height + 2, x: -20, fill: 'url(#gradient)'})
c.svg.append('line').at({y1: 2, y2: 6, stroke: '#000'}).translate([.5, c.height])
var indexSel = c.svg.append('g.axis').append('text.index-label').translate(c.height, 1)
  .at({textAnchor: 'middle', dy: 15})


indexSel.text('Dec. 31')

function posFireLabelSel(sel){
  sel
    .st({opacity: (d, i) => i == state.data.isNSW ? 0 : 1})
    .translate(d => c.y(d) + .5, 1)
}

monthSel.append('text')
  .text(d => d.str)
  .at({textAnchor: 'middle', dy: 15})

monthSel.append('line')
  .at({y1: 2, y2: 6, stroke: '#000'})


var line = d3.line()
  .x(d => c.x((d.dayOfYear + state.offset) % 365))
  .y(d => c.y(d.totalCount))
  // .curve(d3.curveStepAfter)

var yearSel = c.svg.appendMany('g.year', d3.range(2001, 2021))
  .st({opacity: d => state.isYearFilter && d.year < 2013 ? 0 : .3})
  // .call(d3.attachTooltip)

var lineSel = yearSel.append('path')//.at({d: line})

var labelSel = yearSel
  .append('text')
  .at({dy: '.33em'})

var transitionSel = d3.selectAll('.year *')


function updateDataExtent(){
  setYDomain()

  var dur = 1000
  reindex(state.offset, dur)

  yAxisSel.transition().duration(dur)
    .call(c.yAxis)

  fireLabelSel.transition().duration(dur)
    .call(posFireLabelSel)
}


function reindex(offset=state.offset, dur=0){
  lineSel.data().forEach(d => d.isOld = true)
  updateByYear(Math.round(offset))

  indexSel.text(d3.timeFormat('%b %e')(d3.timeParse('%j')(366 - Math.round(offset))))

  yearSel.data(byYear)
  yearSel.st({'display': d => d.isOld ? 'none' : d.length > 5 ? '' : 'none'})

  yearSel.classed('this-year', d => d.year == 2019)
  monthSel.translate(d => c.x((offset + d.dayOfYear) % 365), 0)

  // sloppy, but .transition().duration(0) drops frames
  if (dur){
    lineSel.data(byYear).transition().duration(dur)
      .at({d: d => line(d).replace('M', 'M 0 ' + c.height +  'L')})

    labelSel.data(byYear).transition().duration(dur)
      .translate(d => [line.x()(d.last), c.y(d.last.totalCount)])

    yearSel.transition().duration(dur)
      .st({opacity: d => state.isYearFilter && d.year < 2013 ? 0 : .3})
  } else {
    lineSel.data(byYear)
      .at({d: d => line(d).replace('M', 'M 0 ' + c.height +  'L')})

    labelSel.data(byYear)
      .translate(d => [line.x()(d.last), c.y(d.last.totalCount)])
      
    transitionSel.transition()
  }

  labelSel.text(d => d.year + '-' + d3.format('02')(((d.year + 1) % 100)))

}


function updateByYear(offset=121){
  state.data.forEach(d => {
    d.yearDelta = d.year + Math.floor((offset + d.dayOfYear)/365)
    d.offset = Math.floor((offset + d.dayOfYear)/365)
  })

  var newByYear = d3.nestBy(state.data, d => d.yearDelta)
  newByYear.forEach(year => {
    year.year = year[0].year

    var totalCount = 0
    year.forEach((d, i) => {
      d.totalCount = totalCount += d.count
      d.yearObj = year
    })

    year.totalCount = totalCount
    year.year = year[0].year
    year.last = _.last(year)
  })
  window.byYear = newByYear
    .filter(d => d.key > 2015 || d.length > 360)

  var lastYear = _.last(window.byYear)
  // console.log(lastYear.year, lastYear[0].str, _.last(lastYear).str)


  state.offset = offset
}






index.html

<!DOCTYPE html>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">

<div id='graph'></div>

<script src='d3_.js'></script>
<script src='script.js'></script>

all-nrt.csv

acq_date,n
2020-01-01,3686
2020-01-02,4389
2020-01-03,4257
2020-01-04,7350
2020-01-05,913
2020-01-06,931
2020-01-07,1242
2020-01-08,1026
2020-01-09,773
2020-01-10,575
2020-01-11,1200
2020-01-12,963
2020-01-13,1973
2020-01-14,1030
2020-01-15,304
2020-01-16,250
2020-01-17,154
2020-01-18,178
2020-01-19,68
2020-01-20,74
2020-01-21,122
2020-01-22,167
2020-01-23,197
2020-01-24,177
2020-01-25,235
2020-01-26,468
2020-01-27,202
2020-01-28,411
2020-01-29,674
2020-01-30,960
2020-01-31,1980
2020-02-01,2026
2020-02-02,328
2020-02-03,625
2020-02-04,655
2020-02-05,473
2020-02-06,89
2020-02-07,250
2020-02-08,419
2020-02-09,204
2020-02-10,210
2020-02-11,105
2020-02-12,134
2020-02-13,182
2020-02-14,87
2020-02-15,94
2020-02-16,177
2020-02-17,143
2020-02-18,130
2020-02-19,60
2020-02-20,117
2020-02-21,53
2020-02-22,39

nsw-nrt.csv

acq_date,n
2020-01-01,1071
2020-01-02,1104
2020-01-03,1665
2020-01-04,4392
2020-01-05,351
2020-01-06,115
2020-01-07,72
2020-01-08,156
2020-01-09,221
2020-01-10,298
2020-01-11,619
2020-01-12,279
2020-01-13,924
2020-01-14,360
2020-01-15,140
2020-01-16,9
2020-01-17,18
2020-01-18,31
2020-01-19,4
2020-01-20,1
2020-01-21,22
2020-01-22,105
2020-01-23,147
2020-01-24,102
2020-01-25,114
2020-01-26,374
2020-01-27,78
2020-01-28,53
2020-01-29,257
2020-01-30,309
2020-01-31,731
2020-02-01,1209
2020-02-02,73
2020-02-03,209
2020-02-04,298
2020-02-05,257
2020-02-06,10
2020-02-07,20
2020-02-08,1
2020-02-10,3
2020-02-11,3
2020-02-12,1
2020-02-13,3
2020-02-14,2
2020-02-15,11
2020-02-16,4
2020-02-17,10
2020-02-18,4
2020-02-19,5
2020-02-20,8
2020-02-21,3

style.css

body{
  font-family: menlo, Consolas, 'Lucida Console', monospace; 
  margin: 0px;
}

.tooltip {
  top: -1000px;
  position: fixed;
  padding: 10px;
  background: rgba(255, 255, 255, .90);
  border: 1px solid lightgray;
  pointer-events: none;
}
.tooltip-hidden{
  opacity: 0;
  transition: all .3s;
  transition-delay: .1s;
}

@media (max-width: 590px){
  div.tooltip{
    bottom: -1px;
    width: calc(100%);
    left: -1px !important;
    right: -1px !important;
    top: auto !important;
    width: auto !important;
  }
}

svg{
  overflow: hidden;
}

.domain{
  display: none;
}

text{
  text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}

text.fire-label{
  fill: rgba(0,0,0,.5);
}

.axis text.index-label{
  /*text-shadow: 0 20px 0 #fff, 20px 0 0 #fff, 0 -20px 0 #fff, -20px 0 0 #fff;*/
  /*stroke: red;*/
  stroke-width: 5;
  opacity: 1;
}

.axis text{
  font-size: 10px;
  font-family: sans-serif;
  font-family: menlo, Consolas, 'Lucida Console', monospace; 

  opacity: .5;
}
.y line, .x line{
  opacity: .1;
}



.year{
  opacity: .3;
}
.year path{
  stroke: #000;
  fill: none;
  stroke-width: 1.5px;
}

.year text{
  font-size: 10px;
}

.year:hover{
  opacity: 1;
}

.year.this-year{
  opacity: 1 !important;
}
.year.this-year path{
  stroke: #B53023;
}
.year.this-year text{
  fill: #B53023;
}

.x .domain{
  display: none;
}



.button{
  cursor: pointer;
  display: inline-block;
  margin: 10px;
  padding: 5px;
  background: #fff;
  border-radius: 0px;
  outline: .5px solid #aaa;
  color: #bbb;
  margin-right: -9px;
  position: relative;
}

.button:hover{
  outline: 1px solid #000;
  z-index: 1000;
}

.button.active{
  color: #000;
  background: #ddd;
}

#graph > div{
  width: 0px !important;
  height: 0px !important;
  overflow: visible;
}