d3.jsheatmap

create d3 heatmap showing days on one axis and hours on the other?


I want to create a heatmap with d3 showing days (January 1, January 2, etc.) on one axis, and hours (12am, 1am, 2am, etc.) on the other. I have managed to get the data to display, but I'm not sure if it's right. I also can't figure out how to get the ticks showing the days/hours. I know tickFormat should probably be used, but I'm not sure how. Any help would be supremely appreciated.

const margin = { top: 130, right: 30, bottom: 30, left: 130 },
            width = 650 - margin.left - margin.right,
            height = 650 - margin.top - margin.bottom;

        const svg = d3.select("#viz")
            .append("svg")
            .attr("width", width + margin.left + margin.right)
            .attr("height", height + margin.top + margin.bottom)
            .append("g")
            .attr("transform",
                "translate(" + margin.left + "," + margin.top + ")");

        const parseTime = d3.utcParse("%m/%d/%Y %I:%M:%S %p");
        const formatHours = d3.timeFormat("%H%M")
        const formatYears = d3.timeFormat("%m/%Y")

        let minDate, maxDate;

        const x = d3.scaleTime()
            .range([0, width])
        svg.append("g")
            .attr("transform", "translate(0,0)")
            .call(d3.axisTop(x))

        const y = d3.scaleTime()
            .range([0, height])
            .domain([0, 2359])

        svg.append("g")
            .call(d3.axisLeft(y));

        const color = d3.scaleOrdinal()
            .range(["#fff000", "purple", "blue", "green"])

        d3.csv("https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv").then((data) => {
            data.forEach((d) => {
                d.day = parseTime(d.Date)
                d.hour = +formatHours(d.day)
            })

            let allDates = d3.extent(data, d => d.day)

            x.domain(allDates)

            svg.selectAll()
                .data(data)
                .enter()
                .append("rect")
                .attr("x", d => x(d.day))
                .attr("y", d => y(d.hour))
                .attr("width", 2)
                .attr("height", 2)
                .style("fill", d => color(d.Affected))

        })
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<div id="viz"></div>


Solution

  • I think you have two options.


    The first is to have the y-scale represent the number of seconds each data point is into its respective day. The y-scale then becomes a simple linear scale like:

    const y = d3.scaleLinear()
      .range([0, height])
      .domain([0, 86400]); //<-- number of seconds in a day
    

    For this to work, you'll need to add a property into the data for each point that calculates its "seconds into the day":

    data.forEach((d) => {
      d.day = parseTime(d.Date);
        d.numberOfSecondsIntoDay =
            d.day.getHours() * 60 * 60 +
            d.day.getMinutes() * 60 +
            d.day.getSeconds();
    });
    

    With this approach, by default, you'd end up with a y-axis showing an integer "number of seconds" value. So, you'd then need a custom formatter to convert that to HH:MM:SS (or something else). Code borrowed from here:

    svg.append('g').call(
      d3.axisLeft(y).tickFormat((v) => {
        return new Date(1000 * v).toISOString().substr(11, 8);
      })
    );
    

    Putting everything together:

    <!DOCTYPE html>
    
    <html>
      <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
        <div id="viz"></div>
    
        <script>
          const margin = { top: 60, right: 30, bottom: 30, left: 60 },
            width = 650 - margin.left - margin.right,
            height = 650 - margin.top - margin.bottom;
    
          const svg = d3
            .select('#viz')
            .append('svg')
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
    
          const parseTime = d3.utcParse('%m/%d/%Y %I:%M:%S %p');
    
          let minDate, maxDate;
    
          const x = d3.scaleTime().range([0, width]);
          svg.append('g').attr('transform', 'translate(0,0)').call(d3.axisTop(x));
    
          const y = d3.scaleLinear().range([0, height]).domain([0, 86400]);
    
          svg.append('g').call(
            d3.axisLeft(y).tickFormat((v) => {
              return new Date(1000 * v).toISOString().substr(11, 8);
            })
          );
    
          const color = d3
            .scaleOrdinal()
            .range(['#fff000', 'purple', 'blue', 'green']);
    
          d3.csv(
            'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv'
          ).then((data) => {
            data.forEach((d) => {
              d.day = parseTime(d.Date);
              d.numberOfSecondsIntoDay =
                d.day.getHours() * 60 * 60 +
                d.day.getMinutes() * 60 +
                d.day.getSeconds();
            });
    
            let allDates = d3.extent(data, (d) => d.day);
    
            x.domain(allDates);
    
            svg
              .selectAll()
              .data(data)
              .enter()
              .append('rect')
              .attr('x', (d) => x(d.day))
              .attr('y', (d) => y(d.numberOfSecondsIntoDay))
              .attr('width', 2)
              .attr('height', 2)
              .style('fill', (d) => color(d.Affected));
          });
        </script>
      </body>
    </html>

    I think this approach is programmatically cleaner but you lose the nice logical "time" ticks on the y-axis.


    The second is to continue to use a time scale on the y-axis but coerce all the date times to just times on a single day. In this example I lop off the time and make all the data points occur on the epoch day of Jan 1st, 1970. So the scale becomes:

    const y = d3.scaleTime()
      .range([0, height])
      .domain([new Date(0), new Date(86400 * 1000)]); //<-- first day of epoch
    

    The data manipulation is then:

    data.forEach((d) => {
      d.day = parseTime(d.Date);
      d.justTimePart = new Date(
        (d.day.getHours() * 60 * 60 +
         d.day.getMinutes() * 60 +
         d.day.getSeconds()) * 1000)
      });
    

    At this point, you'll see some strangeness with the default tick formatter. It'll show you the year and day on those boundaries. So again let's use a custom format:

     svg.append('g').call(
       d3.axisLeft(y).
         tickFormat(d3.utcFormat("%I %p")) //<-- American style AM/PM
     );
    

    Putting this one all together:

    <!DOCTYPE html>
    
    <html>
      <body>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
        <div id="viz"></div>
    
        <script>
          const margin = { top: 60, right: 30, bottom: 30, left: 60 },
            width = 650 - margin.left - margin.right,
            height = 650 - margin.top - margin.bottom;
    
          const svg = d3
            .select('#viz')
            .append('svg')
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
    
          const parseTime = d3.utcParse('%m/%d/%Y %I:%M:%S %p');
    
          let minDate, maxDate;
    
          const x = d3.scaleTime().range([0, width]);
          svg.append('g').attr('transform', 'translate(0,0)').call(d3.axisTop(x));
    
          const y = d3.scaleTime().range([0, height]).domain([new Date(0), new Date(86400 * 1000)]);
    
          svg.append('g').call(
            d3.axisLeft(y).
              tickFormat(d3.utcFormat("%I %p"))
          );
    
          const color = d3
            .scaleOrdinal()
            .range(['#fff000', 'purple', 'blue', 'green']);
    
          d3.csv(
            'https://raw.githubusercontent.com/sprucegoose1/sample-data/main/subway_delays.csv'
          ).then((data) => {
            data.forEach((d) => {
              d.day = parseTime(d.Date);
              d.justTimePart = new Date(
                (d.day.getHours() * 60 * 60 +
                d.day.getMinutes() * 60 +
                d.day.getSeconds()) * 1000)
            });
    
            let allDates = d3.extent(data, (d) => d.day);
    
            x.domain(allDates);
    
            svg
              .selectAll()
              .data(data)
              .enter()
              .append('rect')
              .attr('x', (d) => x(d.day))
              .attr('y', (d) => y(d.justTimePart))
              .attr('width', 2)
              .attr('height', 2)
              .style('fill', (d) => color(d.Affected));
          });
        </script>
      </body>
    </html>

    I prefer this approach as it retains the help d3 gives you on making a nicely formatted y-axis.