heatmap

[github]

contribution calendar, a day at a time, weeks scrolling left

data 66×12 10 fps 3.9 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="heatmap" options='{"unit":"contributions","seed":7}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { heatmap } from "ascii.rest/pieces";

<Ascii piece={heatmap} options={{"unit":"contributions","seed":7}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="heatmap", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="heatmap" options={{"unit":"contributions","seed":7}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { heatmap } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, heatmap, {"unit":"contributions","seed":7});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

heatmap.ts

[js] [github]
/*
 * heatmap: a contribution calendar, weeks across and weekdays down, shaded by
 * how busy each day was. Days fill in one at a time; a full week moves it left.
 */
import type { Frame, Meta } from "../types.ts";

export interface HeatmapOptions {
  [key: string]: unknown;
  unit: string;
  seed: number;
}

export const meta = {
  name: "heatmap",
  category: "data",
  note: "contribution calendar, a day at a time, weeks scrolling left",
  cols: 66,
  rows: 12,
  fps: 10,
  options: { unit: "contributions", seed: 7 },
} satisfies Meta<HeatmapOptions>;

const WEEKS = 30;
const STEP = 0.5; // seconds per day
const START = 20150; // today at t = 0, as days since 1970-01-01
const LEVELS = "·░▒▓█";
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const DAYS = ["mon", "", "wed", "", "fri", "", ""];

function mulberry32(a: number) {
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Month (0 to 11) of a day counted from 1970-01-01, after Howard Hinnant.
function monthOf(z: number) {
  const doe = (((z + 719468) % 146097) + 146097) % 146097;
  const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
  const mp = Math.floor((5 * doy + 2) / 153);
  return (mp + 2) % 12;
}

export default function heatmap({ unit = meta.options.unit, seed = meta.options.seed }: Partial<HeatmapOptions> = {}): Frame {
  const { cols, rows } = meta;
  const left = 6; // weekday labels
  const hash = (d: number, k: number) => mulberry32(seed * 7919 + d * 104729 + k * 15485863)();
  // Busy and quiet stretches: value noise over weeks, eased between knots.
  const busy = (d: number) => {
    const w = d / 7 / 3;
    const i = Math.floor(w), f = w - i, e = f * f * (3 - 2 * f);
    return hash(i, 9) * (1 - e) + hash(i + 1, 9) * e;
  };
  const count = (d: number) => {
    const weekend = (d + 3) % 7 >= 5;
    const a = (0.08 + 1.05 * busy(d) ** 1.4) * (weekend ? 0.3 : 1);
    if (hash(d, 1) > a + 0.08) return 0;
    return Math.max(1, Math.round(a * (3 + 14 * hash(d, 2))));
  };
  const level = (n: number) => (n === 0 ? 0 : n < 4 ? 1 : n < 8 ? 2 : n < 12 ? 3 : 4);

  return (t) => {
    const today = START + Math.floor(t / STEP);
    const monday = today - ((today + 3) % 7);
    const first = monday - (WEEKS - 1) * 7;
    const grid = Array.from({ length: 7 }, (_, r) => (DAYS[r].padEnd(left - 2) + "  ").split(""));
    let total = 0;
    for (let w = 0; w < WEEKS; w++) {
      for (let r = 0; r < 7; r++) {
        const d = first + w * 7 + r;
        let ch = " ";
        if (d <= today) {
          const n = count(d);
          total += n;
          ch = LEVELS[level(n)];
        }
        grid[r].push(ch, " ");
      }
    }
    // A month's name sits over the first week that starts in it.
    const head: string[] = new Array(cols).fill(" ");
    let free = 0;
    for (let w = 0; w < WEEKS; w++) {
      const m = monthOf(first + w * 7);
      if (w > 0 && m === monthOf(first + (w - 1) * 7)) continue;
      const x = left + w * 2;
      if (x < free || x + 3 > cols) continue;
      if (w === 0 && monthOf(first + 14) !== m) continue;
      for (let k = 0; k < 3; k++) head[x + k] = MONTHS[m][k];
      free = x + 5;
    }
    const lines = ["", head.join("")];
    for (let r = 0; r < 7; r++) lines.push(grid[r].join(""));
    lines.push("");
    const sum = String(total).replace(/\B(?=(\d{3})+$)/g, ",") + " " + unit + " in the last " + WEEKS + " weeks";
    const key = "less " + [...LEVELS].join(" ") + " more";
    lines.push(" ".repeat(left) + sum.padEnd(cols - left - key.length - 1) + key);
    lines.push("");
    return lines.map((l) => l.slice(0, cols).padEnd(cols)).join("\n");
  };
}