<template>
<div>
<div id="chart">
<apexchart
type="heatmap"
height="186"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
// 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
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: calendarData,
chartOptions: {
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>'
)
},
},
},
}
},
}
</script>
<style>
#chart {
max-width: 810px;
margin: 20px auto;
}
</style>