Building a calendar with D3

Charts and data visualization are fun, and understandably what people tend to imagine when thinking about D3. But it turns out you can use it for other stuff, too.

Since D3 lives up to its name of data driven documents, any scenario where you need to sort of transform, or project your data into markup can be a good fit. People have taken this approach when documenting things like laws and ordinances, inventories, etc. Anything where you've got a clear hierarchy can work really well.

In this tip we're going to build a calendar UI with about 30 lines of code, and when we're done it will look like this.

D3 Calendar

Data and helpers

To keep things simple and self contained we're just going to define some things right in our code.

// October 2017
// generated with https://nodei.co/npm/calendar-matrix/
const month = [
  [1, 2, 3, 4, 5, 6, 7],
  [8, 9, 10, 11, 12, 13, 14],
  [15, 16, 17, 18, 19, 20, 21],
  [22, 23, 24, 25, 26, 27, 28],
  [29, 30, 31, -1, -2, -3, -4]
];
const monthNames = [
  'January', 'February', 'March',
  'April',   'May',      'June',
  'July',    'August',   'September',
  'October', 'November', 'December'
];
const dayNames = [
  'Sunday', 'Monday',
  'Tuesday', 'Wednesday',
  'Thursday', 'Friday', 'Saturday'
];
const monthNum = 9;

So in addition to our month and day names we have a couple of things worth explaining. Our month constant is a two-dimensional array representing the days of October 2017. As the comment above it mentions, that data was generated with a package (that I wrote) called calendar-matrix.

We also specified a monthNum property to indicate the zero-based index of the month we're displaying. In a real application the "active" month would be dynamic, the matrix data would be generated dynamically from the active month (and year), and the month and day names would be referenced from somewhere else.

This is a demo so we've simplified things a bit. 😄

From data to document

The code for this exercise is surprisingly straightforward. It assumes that our document contains <table id="calendar"></table> somewhere in its markup and that's it.

Since we're starting from an essentially blank slate, the first thing we do is get a reference to our target element and create its top level children.

const table = d3.select('#calendar');
const header = table.append('thead');
const body = table.append('tbody');

So we've selected the table, and we add a header and body to it because we care about semantic markup. The next thing we want to do is display the name of the month at the top of the chart.

header
  .append('tr')
  .append('td')
  .attr('colspan', 7)
  .style('text-align', 'center')
  .append('h2')
  .text(monthNames[monthNum]);

We add a row and a single cell to the header, and use the colspan attribute to ensure this table cell fills the whole width of our table. Lastly we look up the month name and display it as a center-aligned header. Done and done.

Next we need to label the days of the week.

header
  .append('tr')
  .selectAll('td')
  .data(dayNames)
  .enter()
  .append('td')
    .style('text-align', 'center')
    .text(d => d);

So here we add another row to our header, but then perform a standard data join. We ask D3 to select all the table cells in our row (that we just created, so we know it's empty) and then join our list of day names to that selection. Remember the enter selection contains each piece of data without existing UI, which in our case is all of them, so we're going to append a new td for each day of the week. We tell it to center-align the text again, and use the day names as the text content of each td.

Now we're ready to create the day cells

Remember month is our two-dimensional array of calendar data. Each item in the month array is itself another array, holding the days of the month for a given week. Therefore, we start our code with month.forEach which allows us to deal with the data one week, or row, at a time.

month.forEach(week => {
  body
    .append('tr')
    .selectAll('td')
    .data(week)
    .enter()
    .append('td')
      .attr('class', function (d) {
        return d > 0 ? '' : 'empty';
      })
      .text(function (d) {
        return d > 0 ? d : '';
      })
});

What's happening here is that, for each week we create a new row in the table's tbody (which is what that body prop refers to). Once the row is created we do another data join. This data join is merging the days in a given week (like [8, 9, 10, 11, 12, 13, 14] in week 2) with the (empty again) selection of cells in our new row.

For each cell/day, we specify a class attribute and the text content. In both cases we use a function that checks to see if the day number is greater than zero. This is because our data represents days from the surrounding months as negative numbers, which is handy for styling them differently.

In our case the cells with negative numbers won't have any text content and they get a CSS class of empty, which just sets border: none so they're effectively hidden.

That's it!

Not too bad, right? You can check out the whole thing running on CodePen here. Happy hacking!