Calendar Heatmap in JavaScript

Using ApexCharts with JavaScript

This Calendar Heatmap example uses ApexCharts.js directly in JavaScript, with no wrapper component.

Install it with npm install apexcharts, then mount the chart with new ApexCharts(element, options).render().

JavaScript installation guide
// A contribution-style calendar heatmap built on the standard heatmap: 7 rows
// (one weekday each) x ~53 columns (one week each). The trick that makes the
// columns line up is that every cell in a week shares the SAME x = the date of
// that week's Sunday. Because the x axis is datetime, the cells sit on a real
// time scale and the axis labels the months for us (continuous-x heatmap).
//
// The year runs from ~52 weeks ago through today, and a cell is emitted only
// for real in-range days: the first and last weeks are partial, so their
// off-range corners are simply left blank (the "hanging" edges of a year view)
// rather than padded. In-range days with zero activity still get a cell (the
// lightest color); only days outside the range are absent. All dates are in
// UTC so a day is always exactly 86400000 ms (no daylight-saving drift that
// would shift a cell into the wrong weekday).
var DAY = 86400000

var calNow = new Date()
var calEnd = Date.UTC(
  calNow.getUTCFullYear(),
  calNow.getUTCMonth(),
  calNow.getUTCDate(),
)
var calStart = calEnd - 364 * DAY

// The Sunday that starts the week containing a given day = that week's column x.
function weekStartOf(ms) {
  return ms - new Date(ms).getUTCDay() * DAY
}

function buildCalendar() {
  var weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
  var byDay = weekdays.map(function (n) {
    return { name: n, data: [] }
  })

  // Deterministic PRNG so the demo looks the same on every reload.
  var seed = 20240407
  function rand() {
    seed = (seed * 1103515245 + 12345) & 0x7fffffff
    return seed / 0x7fffffff
  }

  var totalWeeks = Math.round(
    (weekStartOf(calEnd) - weekStartOf(calStart)) / (7 * DAY),
  )
  for (var t = calStart; t <= calEnd; t += DAY) {
    var dow = new Date(t).getUTCDay()
    var wk = Math.round((weekStartOf(t) - weekStartOf(calStart)) / (7 * DAY))
    // A gentle seasonal wave so some months look busier than others.
    var season = 0.5 + 0.5 * Math.sin((wk / totalWeeks) * Math.PI * 2 - 1)
    var count = 0
    // ~55% of days have activity; weekends are lighter.
    if (rand() < 0.35 + 0.4 * season) {
      var bias = dow === 0 || dow === 6 ? 0.5 : 1
      // rand()*rand() skews toward the low buckets, like real activity.
      count = Math.round(rand() * rand() * 16 * bias) + 1
    }
    // x is the week's Sunday (so the column lines up); the cell's true date is
    // stashed on the datum for the tooltip (x can't carry it: all 7 weekdays in
    // a column share the same x).
    byDay[dow].data.push({ x: weekStartOf(t), y: count, date: t })
  }

  // Rows read Sun (top) -> Sat (bottom). ApexCharts draws the last series on
  // top, so reverse the weekday order before returning.
  return byDay.reverse()
}

var calendarData = buildCalendar()
// Pad half a week on each side so the first and last columns are full width.
var calMinX = weekStartOf(calStart) - 3.5 * DAY
var calMaxX = weekStartOf(calEnd) + 3.5 * DAY

var options = {
  series: calendarData,
  chart: {
    height: 186,
    width: '100%',
    type: 'heatmap',
    toolbar: { show: false },
    animations: { enabled: false },
  },
  title: {
    text: 'Contribution activity',
    align: 'center',
    style: { fontSize: '14px', fontWeight: 600 },
  },
  dataLabels: { enabled: false },
  // A small light gap between cells, like a contributions calendar.
  stroke: { width: 3, colors: ['#fff'] },
  legend: { show: false },
  states: {
    active: {
      filter: {
        type: 'none',
      },
    },
  },
  plotOptions: {
    heatmap: {
      radius: 2,
      // Flat bucket colors (no within-range shading), so each level is one color.
      enableShades: false,
      colorScale: {
        ranges: [
          { from: 0, to: 0, name: '0', color: '#ebedf0' },
          { from: 1, to: 3, name: '1-3', color: '#9be9a8' },
          { from: 4, to: 7, name: '4-7', color: '#40c463' },
          { from: 8, to: 11, name: '8-11', color: '#30a14e' },
          { from: 12, to: 100, name: '12+', color: '#216e39' },
        ],
      },
    },
  },
  xaxis: {
    type: 'datetime',
    min: calMinX,
    max: calMaxX,
    position: 'top',
    labels: {
      format: 'MMM',
      datetimeUTC: false,
      style: { colors: '#767676', fontSize: '12px' },
    },
    axisBorder: { show: false },
    axisTicks: { show: false },
    tooltip: { enabled: false },
    crosshairs: {
      show: false,
    },
  },
  yaxis: {
    // Show only alternate weekday labels (Mon / Wed / Fri), like the original.
    labels: {
      formatter: function (val) {
        return ['Mon', 'Wed', 'Fri'].indexOf(val) >= 0 ? val : ''
      },
      style: { colors: ['#767676'], fontSize: '12px' },
    },
  },
  grid: {
    yaxis: {
      lines: {
        show: false,
      },
    },
  },
  tooltip: {
    // Custom tooltip so it can show the cell's real date (stashed on the datum)
    // plus the count, e.g. "5 contributions on Monday, January 8, 2024".
    custom: function (opts) {
      var pt = opts.w.config.series[opts.seriesIndex].data[opts.dataPointIndex]
      var n = pt.y
      var when = new Date(pt.date).toLocaleDateString('en-US', {
        dateStyle: 'medium',
        timeZone: 'UTC',
      })
      var count =
        n === 0
          ? 'No contributions'
          : n + (n === 1 ? ' contribution' : ' contributions')
      return (
        '<div style="padding:6px 10px;font-size:13px">' +
        '<b>' +
        count +
        '</b> on ' +
        when +
        '</div>'
      )
    },
  },
}

var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()