{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-heatmap",
  "type": "registry:block",
  "title": "Activity Heatmap",
  "description": "A contribution-style year heatmap whose squares ripple in on a spring, with one tooltip that springs from square to square and a legend that isolates an intensity when you hover it. It is a full keyboard grid, and on a phone it scrolls sideways inside itself, opened at the latest weeks.",
  "docs": "Activity Heatmap was added to public/vividsites/activity-heatmap.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/activity-heatmap",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/activity-heatmap.html",
      "type": "registry:file",
      "target": "public/vividsites/activity-heatmap.html",
      "content": "<!--\n  vs-activity-heatmap: a contribution-style calendar heatmap: 53 weeks by 7 days. When it scrolls\n  into view the squares ripple in diagonally on a real spring (scale .4 to 1, staggered by column\n  plus row) while the total counts up. One tooltip springs from square to square as you move, the\n  square under it grows on its own spring with a ring, and hovering a step of the legend dims every\n  other intensity so that level stands out (click a step to pin it). The whole grid is keyboard\n  navigable, and on a narrow screen it scrolls sideways inside itself, opened at the latest weeks.\n\n  Markup:\n    <div class=\"vs-activity-heatmap\">\n      <script type=\"application/json\">{ \"year\": 2025, \"unit\": [\"contribution\", \"contributions\"], \"seed\": 7, \"total\": 2184 }</script>\n    </div>\n  JSON:\n    year        the calendar year to draw\n    values      daily counts from 1 January, one number per day (365 or 366); or\n    counts      { \"2025-06-03\": 14, … } sparse counts by ISO date (missing days are 0)\n    seed/total  with neither of the above, a seeded demo year is generated (weekday rhythm, streaks,\n                a winter and a summer break) and scaled to exactly \"total\"\n    unit        [singular, plural] for the tooltip and summary (\"contribution\", \"contributions\")\n    levels      optional thresholds for steps 1–4, e.g. [1, 5, 10, 16]; default: quartiles of the\n                non-zero days\n    weekStart   0 = Sunday (default, GitHub style) or 1 = Monday;  locale  Intl locale for dates\n  Attributes:\n    data-vs-activity-heatmap-theme   light (default) | dark\n    data-vs-activity-heatmap-palette green | ember | ocean | graphite (or set the accent yourself)\n  Variables:\n    --vs-activity-heatmap-accent   the darkest step; the other steps are mixed from it   (#0f6b3c)\n    --vs-activity-heatmap-base     the empty-square colour the ramp starts from          (#e7ecea)\n    --vs-activity-heatmap-radius   square corner radius                                  (3px)\n    --vs-activity-heatmap-gap      gap between squares                                   (3px)\n    --vs-activity-heatmap-min      smallest square before the grid scrolls sideways      (11px)\n    --vs-activity-heatmap-ink / -surface / -font                                         text, card, face\n  JS: el.vsActivityHeatmap.replay() re-runs the ripple; .outro(ms) ripples the squares away; .focusDate(\"2025-06-03\") moves the cursor.\n  Events: \"vs-activity-heatmap-select\" bubbles on click / Enter with detail {date, count}.\n  Keyboard: Tab to the grid; arrows move a day (up/down) or a week (left/right); Home / End jump to\n    the ends of the row; Enter selects; Esc hides the tooltip. Touch: tap a square; press and hold\n    to scrub, swipe to scroll. The grid is an ARIA grid (weekday row headers, labelled cells), the\n    focused square is announced, and the tooltip is aria-hidden.\n  Reduced motion: no ripple or springs; everything renders in place.\n-->\n<style>\n  .vs-activity-heatmap {\n    --vs-activity-heatmap-accent: #0f6b3c;\n    --vs-activity-heatmap-base: #e7ecea;\n    --vs-activity-heatmap-radius: 3px;\n    --vs-activity-heatmap-gap: 3px;\n    --vs-activity-heatmap-min: 11px;\n    --vs-activity-heatmap-ink: #15201b;\n    --vs-activity-heatmap-surface: #ffffff;\n    --vs-activity-heatmap-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n    --vs-activity-heatmap-tip: #13201a;\n    --vs-activity-heatmap-tip-ink: #eef4f0;\n    --vs-activity-heatmap-top: var(--vs-activity-heatmap-accent);\n    --vs-activity-heatmap-mute: color-mix(in srgb, var(--vs-activity-heatmap-ink) 52%, transparent);\n    --vs-activity-heatmap-l0: color-mix(in oklab, var(--vs-activity-heatmap-accent) 7%, var(--vs-activity-heatmap-base));\n    /* the ramp keeps the accent's hue and chroma and only walks lightness, so the light steps stay fresh, not grey */\n    --vs-activity-heatmap-l1: oklch(from var(--vs-activity-heatmap-top) calc(l + (1 - l) * .66) calc(c * .5) h);\n    --vs-activity-heatmap-l2: oklch(from var(--vs-activity-heatmap-top) calc(l + (1 - l) * .42) calc(c * .85) h);\n    --vs-activity-heatmap-l3: oklch(from var(--vs-activity-heatmap-top) calc(l + (1 - l) * .2) c h);\n    --vs-activity-heatmap-l4: var(--vs-activity-heatmap-top);\n    --vs-activity-heatmap-pop: linear(0, .046, .165, .328, .509, .688, .85, .985, 1.087, 1.156, 1.194, 1.205, 1.196, 1.171, 1.138, 1.101, 1.064, 1.031, 1.003, .982, .968, .96, .958, .96, .965, .972, .979, .987, .994, .999, 1);\n    position: relative; box-sizing: border-box; min-width: 0; max-width: 100%; padding: 26px 28px 20px;\n    font: 400 12px/1.2 var(--vs-activity-heatmap-font); color: var(--vs-activity-heatmap-ink);\n    font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\" 1;\n    background: var(--vs-activity-heatmap-surface); border-radius: 18px;\n    box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-activity-heatmap-ink) 12%, transparent), inset 0 1px 0 rgba(255,255,255,.9),\n      0 1px 2px rgba(20,40,30,.05), 0 12px 32px -14px rgba(20,40,30,.16), 0 40px 80px -40px rgba(20,40,30,.18);\n    -webkit-font-smoothing: antialiased;\n  }\n  .vs-activity-heatmap[data-vs-activity-heatmap-palette=\"green\"] { --vs-activity-heatmap-accent: #0f6b3c; }\n  .vs-activity-heatmap[data-vs-activity-heatmap-palette=\"ember\"] { --vs-activity-heatmap-accent: #c2410c; }\n  .vs-activity-heatmap[data-vs-activity-heatmap-palette=\"ocean\"] { --vs-activity-heatmap-accent: #0b5fa5; }\n  .vs-activity-heatmap[data-vs-activity-heatmap-palette=\"graphite\"] { --vs-activity-heatmap-accent: #2a2f36; }\n  .vs-activity-heatmap[data-vs-activity-heatmap-theme=\"dark\"] {\n    --vs-activity-heatmap-ink: #e6ece9;\n    --vs-activity-heatmap-surface: #111614;\n    --vs-activity-heatmap-base: #1c2321;\n    --vs-activity-heatmap-tip: #f1f5f3;\n    --vs-activity-heatmap-tip-ink: #111614;\n    --vs-activity-heatmap-top: oklch(from var(--vs-activity-heatmap-accent) max(.76, l) calc(c * 1.5) h);\n    --vs-activity-heatmap-l0: color-mix(in oklab, var(--vs-activity-heatmap-accent) 6%, var(--vs-activity-heatmap-base));\n    --vs-activity-heatmap-l1: oklch(from var(--vs-activity-heatmap-top) calc(l * .42) calc(c * .42) h);\n    --vs-activity-heatmap-l2: oklch(from var(--vs-activity-heatmap-top) calc(l * .58) calc(c * .66) h);\n    --vs-activity-heatmap-l3: oklch(from var(--vs-activity-heatmap-top) calc(l * .77) calc(c * .86) h);\n    box-shadow: 0 0 0 .5px rgba(255,255,255,.08), inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.3), 0 30px 70px -30px rgba(0,0,0,.7);\n  }\n  @supports not (color: oklch(from red l c h)) {\n    .vs-activity-heatmap[data-vs-activity-heatmap-theme=\"dark\"] { --vs-activity-heatmap-top: color-mix(in oklab, var(--vs-activity-heatmap-accent), #fff 34%); }\n    .vs-activity-heatmap, .vs-activity-heatmap[data-vs-activity-heatmap-theme=\"dark\"] {\n      --vs-activity-heatmap-l1: color-mix(in oklab, var(--vs-activity-heatmap-top) 30%, var(--vs-activity-heatmap-l0));\n      --vs-activity-heatmap-l2: color-mix(in oklab, var(--vs-activity-heatmap-top) 54%, var(--vs-activity-heatmap-l0));\n      --vs-activity-heatmap-l3: color-mix(in oklab, var(--vs-activity-heatmap-top) 77%, var(--vs-activity-heatmap-l0));\n    }\n  }\n  .vs-activity-heatmap *, .vs-activity-heatmap *::before, .vs-activity-heatmap *::after { box-sizing: border-box; }\n\n  .vs-activity-heatmap-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px 32px; margin-bottom: 22px; }\n  .vs-activity-heatmap-total { font-size: 30px; line-height: 1; letter-spacing: -.03em; }\n  .vs-activity-heatmap-cap, .vs-activity-heatmap-stat span { font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--vs-activity-heatmap-mute); }\n  .vs-activity-heatmap-cap { margin-top: 9px; }\n  .vs-activity-heatmap-stats { display: flex; gap: 30px; }\n  .vs-activity-heatmap-stat { display: grid; gap: 8px; }\n  .vs-activity-heatmap-stat b { font-weight: 400; font-size: 15px; letter-spacing: -.01em; }\n\n  .vs-activity-heatmap-scroll {\n    overflow-x: auto; overflow-y: hidden; margin: 0 -6px; padding: 4px 6px 6px; overscroll-behavior-x: contain;\n    scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--vs-activity-heatmap-ink) 18%, transparent) transparent;\n  }\n  .vs-activity-heatmap-board { --lw: 34px; min-width: calc(var(--lw) + 53 * (var(--vs-activity-heatmap-min) + var(--vs-activity-heatmap-gap))); }\n  .vs-activity-heatmap-months, .vs-activity-heatmap-grid {\n    display: grid; grid-template-columns: var(--lw) repeat(53, minmax(0, 1fr)); column-gap: var(--vs-activity-heatmap-gap);\n  }\n  .vs-activity-heatmap-months { height: 18px; margin-bottom: 4px; font-size: 10.5px; letter-spacing: .06em; color: var(--vs-activity-heatmap-mute); }\n  .vs-activity-heatmap-months span { grid-row: 1; white-space: nowrap; }\n  .vs-activity-heatmap-grid { row-gap: var(--vs-activity-heatmap-gap); outline: none; border-radius: 6px; }\n  .vs-activity-heatmap-grid:focus-visible { box-shadow: 0 0 0 5px var(--vs-activity-heatmap-surface), 0 0 0 6.5px color-mix(in srgb, var(--vs-activity-heatmap-accent) 55%, transparent); }\n  .vs-activity-heatmap-row { display: contents; }\n  .vs-activity-heatmap-rh {\n    grid-column: 1; align-self: center; font-size: 10.5px; letter-spacing: .06em; color: var(--vs-activity-heatmap-mute);\n    position: sticky; left: 0; z-index: 4; background: var(--vs-activity-heatmap-surface); line-height: 1; padding-right: 6px;\n    box-shadow: -6px 0 0 var(--vs-activity-heatmap-surface);\n  }\n  .vs-activity-heatmap-rh.is-quiet { color: transparent; }\n  .vs-activity-heatmap-c {\n    aspect-ratio: 1; border-radius: var(--vs-activity-heatmap-radius); background: var(--vs-activity-heatmap-l0);\n    box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-activity-heatmap-ink) 5%, transparent);\n    transition: opacity .3s cubic-bezier(.2,0,0,1), background-color .4s; position: relative; will-change: transform;\n    -webkit-tap-highlight-color: transparent;\n  }\n  .vs-activity-heatmap-c[data-l=\"1\"] { background: var(--vs-activity-heatmap-l1); }\n  .vs-activity-heatmap-c[data-l=\"2\"] { background: var(--vs-activity-heatmap-l2); }\n  .vs-activity-heatmap-c[data-l=\"3\"] { background: var(--vs-activity-heatmap-l3); }\n  .vs-activity-heatmap-c[data-l=\"4\"] { background: var(--vs-activity-heatmap-l4); }\n  .vs-activity-heatmap-c[data-l]:not([data-l=\"0\"]) { box-shadow: inset 0 0 0 .5px rgba(0,0,0,.06), inset 0 .5px 0 rgba(255,255,255,.18); }\n  .vs-activity-heatmap-c.is-out { visibility: hidden; }\n  .vs-activity-heatmap .vs-activity-heatmap-grid .vs-activity-heatmap-c.is-hot { z-index: 3; box-shadow: 0 0 0 1.5px var(--vs-activity-heatmap-surface), 0 0 0 2.5px var(--vs-activity-heatmap-ink), 0 6px 14px -4px rgba(0,0,0,.35); }\n  .vs-activity-heatmap.is-pre .vs-activity-heatmap-c { opacity: 0; }\n  .vs-activity-heatmap[data-hl] .vs-activity-heatmap-c { opacity: .14; }\n  .vs-activity-heatmap[data-hl=\"0\"] .vs-activity-heatmap-c[data-l=\"0\"], .vs-activity-heatmap[data-hl=\"1\"] .vs-activity-heatmap-c[data-l=\"1\"],\n  .vs-activity-heatmap[data-hl=\"2\"] .vs-activity-heatmap-c[data-l=\"2\"], .vs-activity-heatmap[data-hl=\"3\"] .vs-activity-heatmap-c[data-l=\"3\"],\n  .vs-activity-heatmap[data-hl=\"4\"] .vs-activity-heatmap-c[data-l=\"4\"] { opacity: 1; }\n\n  .vs-activity-heatmap-foot { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; font-size: 11px; letter-spacing: .04em; color: var(--vs-activity-heatmap-mute); }\n  .vs-activity-heatmap-legend { display: flex; align-items: center; gap: 4px; }\n  .vs-activity-heatmap-legend > span { margin: 0 6px; }\n  .vs-activity-heatmap-legend button {\n    width: 20px; height: 20px; padding: 3px; border: 0; background: none; border-radius: 6px; cursor: pointer; -webkit-tap-highlight-color: transparent;\n    transition: transform .45s var(--vs-activity-heatmap-pop);\n  }\n  .vs-activity-heatmap-legend button i { display: block; width: 100%; height: 100%; border-radius: min(var(--vs-activity-heatmap-radius), 4px); background: var(--vs-activity-heatmap-l0); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-activity-heatmap-ink) 8%, transparent); }\n  .vs-activity-heatmap-legend button:nth-of-type(2) i { background: var(--vs-activity-heatmap-l1); }\n  .vs-activity-heatmap-legend button:nth-of-type(3) i { background: var(--vs-activity-heatmap-l2); }\n  .vs-activity-heatmap-legend button:nth-of-type(4) i { background: var(--vs-activity-heatmap-l3); }\n  .vs-activity-heatmap-legend button:nth-of-type(5) i { background: var(--vs-activity-heatmap-l4); }\n  .vs-activity-heatmap-legend button:hover, .vs-activity-heatmap-legend button[aria-pressed=\"true\"] { transform: scale(1.25); }\n  .vs-activity-heatmap-legend button[aria-pressed=\"true\"] i { box-shadow: 0 0 0 1px var(--vs-activity-heatmap-surface), 0 0 0 2px var(--vs-activity-heatmap-ink); }\n  .vs-activity-heatmap-legend button:active { transform: scale(1.05); transition-duration: .1s; }\n  .vs-activity-heatmap-legend button:focus-visible { outline: 1.5px solid var(--vs-activity-heatmap-accent); outline-offset: 1px; }\n\n  .vs-activity-heatmap-tip {\n    position: absolute; left: 0; top: 0; z-index: 10; pointer-events: none; white-space: nowrap;\n    padding: 7px 10px 7px; border-radius: 8px; font-size: 11px; letter-spacing: .02em; line-height: 1;\n    color: var(--vs-activity-heatmap-tip-ink); background: var(--vs-activity-heatmap-tip);\n    box-shadow: inset 0 .5px 0 rgba(255,255,255,.12), 0 2px 4px rgba(0,0,0,.12), 0 10px 24px -8px rgba(0,0,0,.35);\n    opacity: 0; transition: opacity .18s cubic-bezier(.2,0,0,1); will-change: transform;\n  }\n  .vs-activity-heatmap-tip::after { content: \"\"; position: absolute; left: var(--ax, 50%); bottom: -4px; width: 8px; height: 8px; margin-left: -4px; background: inherit; transform: rotate(45deg); border-radius: 0 0 2px 0; }\n  .vs-activity-heatmap-tip b { font-weight: 400; }\n  .vs-activity-heatmap-tip span { opacity: .6; }\n  .vs-activity-heatmap.is-tip .vs-activity-heatmap-tip { opacity: 1; }\n  .vs-activity-heatmap-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n  @media (max-width: 560px) {\n    .vs-activity-heatmap { padding: 20px 16px 16px; }\n    .vs-activity-heatmap-stats { gap: 20px; }\n    .vs-activity-heatmap-total { font-size: 26px; }\n  }\n</style>\n<div class=\"vs-activity-heatmap\">\n  <script type=\"application/json\">{ \"year\": 2025, \"unit\": [\"contribution\", \"contributions\"], \"seed\": 7, \"total\": 2184, \"locale\": \"en-GB\" }</script>\n</div>\n<script>\n(() => {\n  if (window.__vsActivityHeatmap) return; window.__vsActivityHeatmap = true;\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  const POP = 'linear(0, .046, .165, .328, .509, .688, .85, .985, 1.087, 1.156, 1.194, 1.205, 1.196, 1.171, 1.138, 1.101, 1.064, 1.031, 1.003, .982, .968, .96, .958, .96, .965, .972, .979, .987, .994, .999, 1)';\n  const hasLinear = window.CSS && CSS.supports('animation-timing-function', 'linear(0, 1)');\n  const spring = (x, k, c) => ({ x, v: 0, t: x, k, c });\n  const step = (s, dt) => { const a = -s.k * (s.x - s.t) - s.c * s.v; s.v += a * dt; s.x += s.v * dt; };\n  const still = (s, e = .01) => Math.abs(s.x - s.t) < e && Math.abs(s.v) < e * 10;\n  const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n  const mulberry = (a) => () => { a |= 0; 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; };\n  // a believable demo year: weekday rhythm, a slow upward season, a few streaks, a summer and a winter break\n  function demoYear(year, seed, total, N) {\n    const r = mulberry(seed || 1), wk = [.14, 1, 1.18, 1.1, 1.02, .8, .24], raw = [];\n    const bursts = []; for (let i = 0; i < 6; i++) bursts.push([Math.floor(r() * (N - 30)) + 10, 7 + Math.floor(r() * 14)]);\n    const dayOf = (m, d) => Math.round((new Date(year, m, d) - new Date(year, 0, 1)) / 864e5);\n    const quiet = [[0, 4], [dayOf(7, 4), dayOf(7, 17)], [dayOf(11, 20), N]];\n    for (let d = 0; d < N; d++) {\n      const w = new Date(year, 0, 1 + d).getDay(), f = d / N;\n      const burst = bursts.some(([s, l]) => d >= s && d < s + l) ? 1.9 : 1;\n      const season = .72 + .22 * Math.sin(f * Math.PI * 2 - 1.2) + .42 * f;\n      let p = burst > 1 ? .97 : wk[w] > .5 ? .74 : .18;\n      if (quiet.some(([a, b]) => d >= a && d <= b)) p = d % 11 === 3 ? .5 : 0;\n      raw.push(r() < p ? Math.max(wk[w], burst > 1 ? .6 : 0) * burst * season * Math.pow(.25 + r() * 1.3, 2) + .04 : 0);\n    }\n    const sum = raw.reduce((a, b) => a + b, 0), out = raw.map((v) => v ? Math.max(1, Math.round(v / sum * total)) : 0);\n    let diff = total - out.reduce((a, b) => a + b, 0), g = 0;\n    while (diff !== 0 && g++ < 1e5) { const i = Math.floor(r() * N); if (out[i] > 1 || (diff > 0 && out[i])) { out[i] += Math.sign(diff); diff -= Math.sign(diff); } }\n    return out;\n  }\n  let uid = 0;\n\n  function mount(root) {\n    if (root.vsActivityHeatmap) return;\n    const src = root.querySelector('script[type=\"application/json\"]');\n    let cfg = {}; try { cfg = JSON.parse(src.textContent); } catch (e) {}\n    const year = cfg.year || new Date().getFullYear(), loc = cfg.locale || undefined, ws = cfg.weekStart || 0;\n    const jan1 = new Date(year, 0, 1), N = Math.round((new Date(year + 1, 0, 1) - jan1) / 864e5);\n    const unit = cfg.unit || ['contribution', 'contributions'];\n    let vals;\n    if (Array.isArray(cfg.values)) vals = Array.from({ length: N }, (_, i) => +cfg.values[i] || 0);\n    else if (cfg.counts) vals = Array.from({ length: N }, (_, i) => +cfg.counts[iso(new Date(year, 0, 1 + i))] || 0);\n    else vals = demoYear(year, cfg.seed, cfg.total || 1800, N);\n    const nz = vals.filter((v) => v > 0).sort((a, b) => a - b), q = (p) => nz[Math.min(nz.length - 1, Math.floor(p * nz.length))] || 1;\n    const lv = cfg.levels || [1, Math.max(2, q(.3)), Math.max(3, q(.6)), Math.max(4, q(.86))];\n    const level = (v) => v <= 0 ? 0 : v >= lv[3] ? 4 : v >= lv[2] ? 3 : v >= lv[1] ? 2 : 1;\n    const total = vals.reduce((a, b) => a + b, 0);\n    let streak = 0, run = 0, best = 0; vals.forEach((v, i) => { run = v ? run + 1 : 0; streak = Math.max(streak, run); if (v > vals[best]) best = i; });\n    const nf = new Intl.NumberFormat(loc), dS = new Intl.DateTimeFormat(loc, { weekday: 'short', day: 'numeric', month: 'short' });\n    const dL = new Intl.DateTimeFormat(loc, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });\n    const mS = new Intl.DateTimeFormat(loc, { month: 'short' }), wS = new Intl.DateTimeFormat(loc, { weekday: 'short' }), wL = new Intl.DateTimeFormat(loc, { weekday: 'long' });\n    const u = (n) => n === 1 ? unit[0] : unit[1];\n    const off = (jan1.getDay() - ws + 7) % 7;                     // empty slots before 1 Jan in the first column\n    const cols = Math.ceil((off + N) / 7), id = 'vsah' + (++uid);\n\n    // ── DOM ──\n    const head = document.createElement('div'); head.className = 'vs-activity-heatmap-head';\n    head.innerHTML = `<div><div class=\"vs-activity-heatmap-total\">${nf.format(total)}</div><div class=\"vs-activity-heatmap-cap\">${unit[1]} in ${year}</div></div>\n      <div class=\"vs-activity-heatmap-stats\"><div class=\"vs-activity-heatmap-stat\"><span>Longest streak</span><b>${streak} days</b></div>\n      <div class=\"vs-activity-heatmap-stat\"><span>Best day</span><b>${nf.format(vals[best])} · ${dS.format(new Date(year, 0, 1 + best))}</b></div></div>`;\n    const totalEl = head.querySelector('.vs-activity-heatmap-total');\n    const scroll = document.createElement('div'); scroll.className = 'vs-activity-heatmap-scroll';\n    const board = document.createElement('div'); board.className = 'vs-activity-heatmap-board'; scroll.appendChild(board);\n    board.style.setProperty('min-width', `calc(var(--lw) + ${cols} * (var(--vs-activity-heatmap-min) + var(--vs-activity-heatmap-gap)))`);\n    const months = document.createElement('div'); months.className = 'vs-activity-heatmap-months'; months.setAttribute('aria-hidden', 'true');\n    const grid = document.createElement('div'); grid.className = 'vs-activity-heatmap-grid';\n    [months, grid].forEach((g) => g.style.gridTemplateColumns = `var(--lw) repeat(${cols}, minmax(0, 1fr))`);\n    grid.setAttribute('role', 'grid'); grid.setAttribute('tabindex', '0');\n    grid.setAttribute('aria-label', `${nf.format(total)} ${u(total)} in ${year}. Use the arrow keys to move between days.`);\n    board.append(months, grid);\n    // month labels sit over the first column whose week starts in that month (GitHub's rule)\n    let lastM = -1, lastC = -9;\n    for (let c = 0; c < cols; c++) {\n      const start = new Date(year, 0, 1 + c * 7 - off), d = start < jan1 ? jan1 : start;\n      if (d.getFullYear() !== year || d.getMonth() === lastM) continue;\n      lastM = d.getMonth();\n      if (c - lastC < 3) continue;\n      const s = document.createElement('span'); s.style.gridColumn = `${c + 2} / span 4`; s.textContent = mS.format(d); months.appendChild(s); lastC = c;\n    }\n    const cells = []; // by day index\n    for (let r = 0; r < 7; r++) {\n      const row = document.createElement('div'); row.className = 'vs-activity-heatmap-row'; row.setAttribute('role', 'row');\n      const wd = new Date(2023, 0, 1 + ((r + ws) % 7)); // 1 Jan 2023 was a Sunday\n      const rh = document.createElement('div'); rh.className = 'vs-activity-heatmap-rh' + (r % 2 ? '' : ' is-quiet'); rh.setAttribute('role', 'rowheader');\n      rh.style.gridRow = r + 1; rh.innerHTML = `<span aria-hidden=\"true\">${wS.format(wd)}</span><span class=\"vs-activity-heatmap-sr\">${wL.format(wd)}</span>`;\n      row.appendChild(rh);\n      for (let c = 0; c < cols; c++) {\n        const i = c * 7 + r - off, cell = document.createElement('span');\n        cell.className = 'vs-activity-heatmap-c'; cell.setAttribute('role', 'gridcell');\n        cell.style.gridArea = `${r + 1} / ${c + 2}`;\n        if (i < 0 || i >= N) { cell.className += ' is-out'; cell.setAttribute('aria-hidden', 'true'); }\n        else {\n          const d = new Date(year, 0, 1 + i), v = vals[i];\n          cell.id = `${id}-${i}`; cell.dataset.l = level(v); cell.dataset.i = i;\n          cell.setAttribute('aria-label', `${v ? nf.format(v) : 'No'} ${u(v)}, ${dL.format(d)}`);\n          cells[i] = { el: cell, r, c, v, d, s: spring(1, 380, 22) };\n        }\n        row.appendChild(cell);\n      }\n      grid.appendChild(row);\n    }\n    const foot = document.createElement('div'); foot.className = 'vs-activity-heatmap-foot';\n    const busiest = [0, 1, 2, 3, 4, 5, 6].map((w) => [w, vals.reduce((a, v, i) => a + (new Date(year, 0, 1 + i).getDay() === w ? v : 0), 0)]).sort((a, b) => b[1] - a[1])[0][0];\n    foot.innerHTML = `<span>${wL.format(new Date(2023, 0, 1 + busiest))}s are the busiest</span>`;\n    const legend = document.createElement('div'); legend.className = 'vs-activity-heatmap-legend'; legend.setAttribute('role', 'group'); legend.setAttribute('aria-label', 'Intensity');\n    const lvRange = (k) => k === 0 ? `No ${unit[1]}` : `${lv[k - 1]}${k === 4 ? '+' : lv[k] - 1 > lv[k - 1] ? '–' + (lv[k] - 1) : ''} ${u(k === 4 ? 2 : lv[k] - 1)}`;\n    const lvDays = [0, 0, 0, 0, 0]; vals.forEach((v) => lvDays[level(v)]++);\n    legend.innerHTML = `<span aria-hidden=\"true\">Less</span>` + [0, 1, 2, 3, 4].map((k) => `<button type=\"button\" aria-pressed=\"false\" data-k=\"${k}\" aria-label=\"Highlight ${lvRange(k)} (${lvDays[k]} days)\"><i></i></button>`).join('') + `<span aria-hidden=\"true\">More</span>`;\n    foot.appendChild(legend);\n    const tip = document.createElement('div'); tip.className = 'vs-activity-heatmap-tip'; tip.setAttribute('aria-hidden', 'true');\n    const live = document.createElement('div'); live.className = 'vs-activity-heatmap-sr'; live.setAttribute('aria-live', 'polite');\n    root.append(head, scroll, foot, tip, live);\n\n    // ── motion ──\n    let raf = 0, last = 0, visible = false, revealed = false, hot = null, active = null, tipOn = false, pinned = null, count = null;\n    const tx = spring(0, 420, 34), ty = spring(0, 420, 34), moving = new Set();\n    const frame = (now) => {\n      raf = 0; const dt = Math.min(.034, (now - (last || now)) / 1000); last = now;\n      for (let k = 0; k < 2; k++) { step(tx, dt / 2); step(ty, dt / 2); moving.forEach((c) => step(c.s, dt / 2)); }\n      tip.style.transform = `translate3d(${tx.x.toFixed(1)}px,${ty.x.toFixed(1)}px,0)`;\n      moving.forEach((c) => { if (still(c.s, .002)) { c.s.x = c.s.t; c.s.v = 0; moving.delete(c); } c.el.style.transform = c.s.x === 1 ? '' : `scale(${c.s.x.toFixed(4)})`; });\n      if (count) { const p = clamp((now - count.t0) / count.ms, 0, 1), e = 1 - Math.pow(1 - p, 3); totalEl.textContent = nf.format(Math.round(count.a + (count.b - count.a) * e)); if (p >= 1) count = null; }\n      if (moving.size || count || !still(tx, .05) || !still(ty, .05)) kick(); else last = 0;\n    };\n    const kick = () => { if (!raf && visible) raf = requestAnimationFrame(frame); };\n    const pop = (c, to) => { if (!c) return; c.s.t = to; if (RM.matches) { c.s.x = to; c.el.style.transform = to === 1 ? '' : `scale(${to})`; return; } moving.add(c); kick(); };\n\n    function place(target, text, snap) {\n      tip.innerHTML = text;\n      const rr = root.getBoundingClientRect(), b = target.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight;\n      const cxp = b.left + b.width / 2 - rr.left, x = clamp(cxp - tw / 2, 4, rr.width - tw - 4);\n      tip.style.setProperty('--ax', clamp(cxp - x, 10, tw - 10) + 'px');\n      tx.t = x; ty.t = b.top - rr.top - th - 9 - (hot && target === hot.el ? b.height * .22 : 0);\n      if (snap || !tipOn || RM.matches) { tx.x = tx.t; ty.x = ty.t; tx.v = ty.v = 0; tip.style.transform = `translate3d(${tx.x}px,${ty.x}px,0)`; }\n      tipOn = true; root.classList.add('is-tip'); kick();\n    }\n    const cellText = (c) => `<b>${c.v ? nf.format(c.v) : 'No'} ${u(c.v)}</b> <span>·</span> ${dS.format(c.d)}`;\n    function setHot(c, snap) {\n      if (hot === c) return;\n      if (hot) { hot.el.classList.remove('is-hot'); pop(hot, 1); }\n      hot = c;\n      if (c) { c.el.classList.add('is-hot'); pop(c, 1.42); place(c.el, cellText(c), snap); }\n    }\n    const hide = () => { setHot(null); tipOn = false; root.classList.remove('is-tip'); };\n    const fire = (c) => root.dispatchEvent(new CustomEvent('vs-activity-heatmap-select', { bubbles: true, detail: { date: iso(c.d), count: c.v } }));\n    const cellFrom = (t) => { const e = t && t.closest && t.closest('.vs-activity-heatmap-c'); return e && e.dataset.i != null ? cells[+e.dataset.i] : null; };\n\n    // pointer: mouse hovers; touch taps, or press-and-hold then drag to scrub (a plain swipe scrolls)\n    let scrub = false, holdT = 0, downXY = null, hideT = 0;\n    grid.addEventListener('pointermove', (e) => {\n      if (e.pointerType === 'mouse') { const c = cellFrom(e.target); if (c) setHot(c); return; }\n      if (downXY && !scrub && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) > 8) { clearTimeout(holdT); downXY = null; }\n      if (scrub) { const c = cellFrom(document.elementFromPoint(e.clientX, e.clientY)); if (c) setHot(c); }\n    });\n    grid.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') hide(); });\n    grid.addEventListener('pointerdown', (e) => {\n      if (e.pointerType === 'mouse') return;\n      clearTimeout(hideT); downXY = [e.clientX, e.clientY];\n      holdT = setTimeout(() => { scrub = true; const c = cellFrom(e.target); if (c) setHot(c); }, 260);\n    });\n    const up = (e) => {\n      if (e.pointerType === 'mouse') return;\n      clearTimeout(holdT);\n      if (downXY && !scrub) { const c = cellFrom(e.target); if (c) { setHot(c); fire(c); } }\n      scrub = false; downXY = null; hideT = setTimeout(hide, 2200);\n    };\n    grid.addEventListener('pointerup', up); grid.addEventListener('pointercancel', (e) => { clearTimeout(holdT); scrub = false; downXY = null; });\n    grid.addEventListener('touchmove', (e) => { if (scrub) e.preventDefault(); }, { passive: false });\n    grid.addEventListener('contextmenu', (e) => { if (scrub) e.preventDefault(); });\n    grid.addEventListener('click', (e) => { const c = cellFrom(e.target); if (c && e.pointerType === 'mouse') fire(c); });\n    scroll.addEventListener('scroll', () => { if (hot) place(hot.el, cellText(hot), true); }, { passive: true });\n\n    // keyboard (APG grid, single tab stop via aria-activedescendant)\n    const focusCell = (c, snap) => {\n      active = c; grid.setAttribute('aria-activedescendant', c.el.id);\n      const sb = scroll.getBoundingClientRect(), b = c.el.getBoundingClientRect(), lw = 40;\n      if (b.left < sb.left + lw) scroll.scrollLeft -= sb.left + lw - b.left + 8; else if (b.right > sb.right) scroll.scrollLeft += b.right - sb.right + 8;\n      setHot(c, snap);\n      live.textContent = c.el.getAttribute('aria-label');\n    };\n    grid.addEventListener('focus', () => { if (grid.matches(':focus-visible')) focusCell(active || cells[N - 1], true); });\n    grid.addEventListener('blur', () => { if (active) hide(); });\n    grid.addEventListener('keydown', (e) => {\n      const c = active || cells[N - 1]; let i = +c.el.dataset.i;\n      switch (e.key) {\n        case 'ArrowRight': i += 7; break; case 'ArrowLeft': i -= 7; break;\n        case 'ArrowDown': if (c.r < 6) i += 1; break; case 'ArrowUp': if (c.r > 0) i -= 1; break;\n        case 'Home': i = e.ctrlKey ? 0 : c.r - off < 0 ? c.r - off + 7 : c.r - off; break;\n        case 'End': i = e.ctrlKey ? N - 1 : (cols - 1) * 7 + c.r - off; if (i >= N) i -= 7; break;\n        case 'PageDown': i += 28; break; case 'PageUp': i -= 28; break;\n        case 'Enter': case ' ': e.preventDefault(); fire(c); return;\n        case 'Escape': hide(); return;\n        default: return;\n      }\n      e.preventDefault(); focusCell(cells[clamp(i, 0, N - 1)]);\n    });\n\n    // legend: hover / focus previews a level, click pins it\n    const btns = [...legend.querySelectorAll('button')];\n    const showLv = (k, b) => {\n      if (k == null) { root.removeAttribute('data-hl'); if (!hot) { tipOn = false; root.classList.remove('is-tip'); } return; }\n      root.setAttribute('data-hl', k); setHot(null);\n      place(b, `<b>${lvRange(k)}</b> <span>·</span> ${lvDays[k]} days`);\n    };\n    btns.forEach((b, k) => {\n      b.addEventListener('pointerenter', () => showLv(k, b));\n      b.addEventListener('focus', () => showLv(k, b));\n      const out = () => { if (pinned != null) { root.setAttribute('data-hl', pinned); tipOn = false; root.classList.remove('is-tip'); } else showLv(null); };\n      b.addEventListener('pointerleave', out); b.addEventListener('blur', out);\n      b.addEventListener('click', () => { pinned = pinned === k ? null : k; btns.forEach((x, j) => x.setAttribute('aria-pressed', j === pinned)); if (pinned == null) root.removeAttribute('data-hl'); else root.setAttribute('data-hl', k); });\n    });\n\n    // entrance: a diagonal ripple, each square on a spring (CSS linear() spring curve, compositor-driven)\n    function replay() {\n      root.classList.remove('is-pre'); revealed = true;\n      if (RM.matches) { totalEl.textContent = nf.format(total); return; }\n      count = { t0: performance.now(), ms: 1100, a: 0, b: total }; kick();\n      const easing = hasLinear ? POP : 'cubic-bezier(.34,1.56,.64,1)';\n      cells.forEach((c) => {\n        c.el.getAnimations().forEach((a) => a.cancel());\n        c.el.animate([{ transform: 'scale(.4)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }], { duration: 760, delay: (c.c + c.r) * 13, easing, fill: 'backwards' });\n      });\n    }\n    // the reverse, for looping demos: squares shrink away along the same diagonal, then wait for replay()\n    function outro(ms = 520) {\n      if (RM.matches) return; hide(); revealed = true;\n      count = { t0: performance.now(), ms, a: total, b: 0 }; kick();\n      const span = cols + 6, per = Math.max(0, (ms - 260) / span);\n      cells.forEach((c) => {\n        c.el.getAnimations().forEach((a) => a.cancel());\n        c.el.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(.4)', opacity: 0 }], { duration: 260, delay: (c.c + c.r) * per, easing: 'cubic-bezier(.5,0,.75,0)', fill: 'forwards' });\n      });\n      setTimeout(() => { root.classList.add('is-pre'); cells.forEach((c) => c.el.getAnimations().forEach((a) => a.cancel())); }, ms + 20);\n    }\n    if (!RM.matches) { root.classList.add('is-pre'); totalEl.textContent = '0'; }\n    new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting;\n      if (visible && !revealed && e.intersectionRatio > .2) replay();\n      if (visible) kick();\n    }, { threshold: [0, .25] }).observe(root);\n    // open at the latest weeks when the grid scrolls sideways\n    requestAnimationFrame(() => { scroll.scrollLeft = scroll.scrollWidth; });\n    new ResizeObserver(() => { if (hot) place(hot.el, cellText(hot), true); }).observe(root);\n    root.vsActivityHeatmap = { replay, outro, focusDate: (s) => { const i = Math.round((new Date(s + 'T00:00:00') - jan1) / 864e5); if (cells[i]) focusCell(cells[i]); }, hide, values: vals };\n  }\n  const init = () => document.querySelectorAll('.vs-activity-heatmap').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "heatmap",
      "contribution graph",
      "calendar",
      "activity",
      "streak",
      "chart",
      "dashboard",
      "tooltip",
      "keyboard",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#0f6b3c",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/activity-heatmap/index.html",
    "thumbnail": "https://vividsites.app/api/component/activity-heatmap/thumb.jpg",
    "video": "https://vividsites.app/api/component/activity-heatmap/thumb.mp4",
    "page": "https://vividsites.app/c/activity-heatmap"
  }
}