{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap",
  "type": "registry:ui",
  "title": "Heatmap",
  "description": "A dot matrix whose depth says how much: usage per day in a month, deploys per day over a year, mail per hour of the week. One data hue in four steps over an empty dot, row labels and totals in mono, a tooltip on hover, touch and focus, and a legend from less to more. A wide grid scrolls sideways inside its own box.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/heatmap.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/heatmap 2026.10.26. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Heatmap: a dot matrix whose depth says how much. One data hue (R7) in four steps over an empty dot, so\n * the eye reads where the weight sits: usage per day in a month, deploys per day over a year, mail per hour\n * of the week. Plain DOM and CSS, no chart library.\n *\n * - Color (components.md, Heatmap): zero is an empty dot at fg 8%. The four steps are the hue's mark tone over\n *   transparent at 30, 55, 80 and 100%, so one hue carries one quantity (R7) and the dots fit any surface.\n *   `null` (no data: a day still to come, a day the month does not have) draws nothing. The default steps are\n *   the quartiles of the non-zero values, so the four levels hold about the same number of dots. The legend\n *   under the grid is \"Less\", the five levels as dots, \"More\".\n * - Anatomy: circles on a pitch of the dot plus 4px of air (12px for the default 8px dot). Each row has a label\n *   at the start (caption, fg-subtle, in a column as wide as the longest label) and an optional total at\n *   the end (small, fg, tabular, end-aligned). Optional ticks over chosen columns name them (month names\n *   over a year of weeks). A label needs 16px of height, so when rows are closer than that (the default pitch)\n *   every second row shows its label and its total, counted from the first, like the chart's every nth axis\n *   label; every row keeps its name and its total for a screen reader.\n * - Fit: dots keep their size. The label and total columns stay put and the dots between them scroll sideways\n *   inside their own box, starting at the end so the latest column shows, and staying at the end when the box\n *   is resized. The page itself never scrolls sideways. Padding inside the scroll box keeps the focus ring\n *   from being clipped, and an 8px fade on an edge with more dots beyond it (a mask, static) says which way\n *   to scroll, so a clipped dot is not left half drawn.\n * - Interaction: one pointer layer covers the dots, as in the chart, because a 12px dot is far smaller than a\n *   finger. The dot under a mouse is ringed with a 1px fg outline and shows the Tooltip's look above it (solid\n *   surface-3, shadow-litt-tooltip): the name in fg-muted and the value in tabular figures, kept inside the heatmap and\n *   following the pointer from dot to dot with no re-entry. A dot with no data does not react to a pointer.\n *   Touch: when the grid fits, a finger scrubs across the dots (touch-action pan-y), and the dot under it is\n *   active and shows its tooltip, which stays until the next touch elsewhere. When the grid scrolls, a drag\n *   must scroll it (pan-x pan-y), so a tap picks the dot instead. Hover is a color change on the instant tier.\n * - Keyboard: role=\"grid\" with rows and gridcells, one tab stop (roving tabindex) on the last dot of the first\n *   row, which is in view. Arrows move in two dimensions without wrapping, Home and End go to the row's ends,\n *   Ctrl+Home and Ctrl+End to the grid's ends, Esc hides the tooltip without moving focus. A focused dot gets\n *   a 2px focus ring, 2px away, when :focus-visible matches, and its tooltip.\n * - Accessibility: the grid is named by `label` and described by a summary (\"Deploys per day, 214 in all, most\n *   on Wed, week of 12 Oct with 31\"). Each dot is named \"Wed, week of 12 Oct, 31 deploys\" (\"no data\" for\n *   null), so color is never the only signal. The visible row labels and totals are drawn in their own columns\n *   and hidden from screen readers; the same words sit in each row as a hidden row header and cell.\n * - Motion: none beyond the tooltip's responsive entrance, the chart's. Reduced motion is the hooks' job.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on):\n *   - Arrival, in two beats: the empty grid pops in on a diagonal wave, then the data fills in by level, the quietest\n *     dots first and the busiest last, and totals that are plain counts count up as their rows fill. It is drawn on one\n *     canvas over the grid, on fun mode's own springs, because hundreds of animated dots would each need a compositing\n *     layer; the real grid waits under it (transparent, still focusable) and is what remains when the last dot lands.\n *   - A lens follows the pointer, as on the Apple Watch's app grid: dots near it swell and ease their neighbours aside,\n *     falling off over two and a half dots, deeper while pressed. It writes transforms itself, once a frame, to the\n *     dots it reaches, and lets go of each one as it leaves. The ring and the tooltip work as they always do.\n *   At rest it is the static Heatmap exactly. Reduced motion turns all of it off.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { animate, AnimatePresence, motion, spring, useMotionValue, useMotionValueEvent, useSpring } from \"motion/react\";\nimport { countText, FUN_SPRINGS, useExit, useFun, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- types */\n\n/** The five data hues, in their fixed order (R7). */\nexport type HeatmapHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** One row of dots: a label, one value per column, and an optional total. */\nexport type HeatmapRow = {\n  /** What the row is, in a word or two: \"Mon\", \"August\". It sits at the start of the row and names every dot in it. */\n  label: string;\n  /** One value per column, oldest first. Zero is an empty dot and `null` draws nothing, such as a day still to come. */\n  values: (number | null)[];\n  /** A total shown at the end of the row, already formatted: \"25,998\". */\n  total?: string;\n};\n\nexport type HeatmapProps = {\n  /** The grid's name and what it shows, in a few words: \"Deploys per day, last 53 weeks\". It labels the grid and starts the summary. */\n  label: string;\n  /** The rows, top to bottom. Each has a label, its values and an optional total. */\n  rows: HeatmapRow[];\n  /** One name per column, shared by every row, used in the tooltip and each dot's name: \"week of 12 Oct\", \"14:00\". Leave it out and columns are numbered. */\n  columns?: string[];\n  /** Labels over chosen columns, such as month names over a year of weeks: `{ index: 4, label: \"Nov\" }`. Keep them at least a label's width apart. */\n  columnTicks?: { index: number; label: string }[];\n  /** The one data hue the steps are made of. Pick it for what the grid is about and keep it the same for the same thing across a view. */\n  hue?: HeatmapHue;\n  /** Where each of the four levels starts, lowest first. Zero is always the empty dot, and a value under the first step is still the lightest level. The default is the quartiles of the non-zero values. */\n  steps?: [number, number, number, number];\n  /** Turns a value into text for the tooltip, the dots' names and the summary: `(v) => \\`${v} s\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after the number in the tooltip and the dots' names: \"deploys\". Pass [\"deploy\", \"deploys\"] to say the first for exactly 1. Leave it out when the label says it. */\n  unit?: string | [one: string, other: string];\n  /** The dot's diameter in px. The pitch is this plus 4px of air, and below a 12px dot only every second row shows its label and its total. Use 12 when every row must be named. */\n  cell?: number;\n  /** Replaces the generated summary that describes the grid, such as \"Deploys per day, 214 in all, most on 12 Oct with 31\". */\n  summary?: string;\n  /** Extra classes for the heatmap's box, such as a top margin. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- tokens and helpers */\n\nconst GAP = 4; // air between dots\nconst LABEL_H = 16; // a row label needs this much height\nconst TICK_H = 16; // the row of column ticks\nconst TICK_GAP = 8; // between the ticks and the first row\nconst FIGURE = 7.2; // advance of a 12px tabular figure in Geist (letters run narrower)\nconst FADE = 8; // the soft edge where more dots lie beyond the scroll box\n\n/* Fun mode's lens: how far it reaches, in dots, how much the centre swells and how far neighbours step aside, in px. */\nconst LENS_REACH = 2.6;\nconst LENS_SWELL = 0.55;\nconst LENS_PUSH = 3;\n/* Fun mode's arrival: when the data starts filling in after the grid, and the extra beat per level, in seconds. */\nconst DATA_AT = 0.32;\nconst DATA_PER_LEVEL = 0.11;\n\n/** An empty dot: the foreground at 8%. */\nconst EMPTY = \"bg-[color-mix(in_oklab,var(--litt-fg)_8%,transparent)]\";\n\n/** The four steps of each hue as classes, 30, 55, 80 and 100% of the mark tone over transparent. Literal strings, so Tailwind sees them. */\nconst HUE: Record<HeatmapHue, readonly [string, string, string, string]> = {\n  blue: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_80%,transparent)]\",\n    \"bg-litt-data-blue\",\n  ],\n  amber: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_80%,transparent)]\",\n    \"bg-litt-data-amber\",\n  ],\n  teal: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_80%,transparent)]\",\n    \"bg-litt-data-teal\",\n  ],\n  violet: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_80%,transparent)]\",\n    \"bg-litt-data-violet\",\n  ],\n  rose: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_80%,transparent)]\",\n    \"bg-litt-data-rose\",\n  ],\n};\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\ntype Cell = { r: number; c: number };\nconst keyOf = (a: Cell | null) => (a ? `${a.r}:${a.c}` : \"\");\nconst same = (a: Cell | null, b: Cell | null) => keyOf(a) === keyOf(b);\n\n/** The value at a fraction of the sorted list, interpolating between neighbours. */\nfunction quantile(sorted: number[], p: number) {\n  if (sorted.length === 0) return 0;\n  const at = (sorted.length - 1) * p;\n  const lo = Math.floor(at);\n  const hi = Math.ceil(at);\n  return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo);\n}\n\n/** The quartiles of the non-zero values: the level 1 start, then the first, second and third quartile. */\nfunction quartileSteps(rows: HeatmapRow[]): [number, number, number, number] {\n  const values = rows.flatMap((r) => r.values).filter((v): v is number => v !== null && Number.isFinite(v) && v > 0);\n  if (values.length === 0) return [1, 2, 3, 4];\n  const sorted = values.sort((a, b) => a - b);\n  return [sorted[0]!, quantile(sorted, 0.25), quantile(sorted, 0.5), quantile(sorted, 0.75)];\n}\n\n/** 0 for an empty dot, then 1 to 4. A non-zero value under the first step is still level 1. */\nfunction levelOf(value: number, steps: readonly number[]) {\n  if (!(value > 0)) return 0;\n  let level = 1;\n  for (let k = 1; k < 4; k++) if (value >= steps[k]!) level = k + 1;\n  return level;\n}\n\n/* ----------------------------------------------------------------- the heatmap */\n\n/** A dot matrix of rows and columns whose depth says how much: one data hue in four steps over an empty dot. */\nexport function Heatmap({\n  label,\n  rows,\n  columns,\n  columnTicks,\n  hue = \"blue\",\n  steps,\n  formatValue,\n  unit,\n  cell: cellProp = 8,\n  summary,\n  className = \"\",\n}: HeatmapProps) {\n  const fmt = formatValue ?? groupDigits;\n  const cell = Math.max(4, Math.round(cellProp));\n  const pitch = cell + GAP;\n  /* Labels are 16px tall, so rows closer than that name every nth row, counted from the first. */\n  const labelEvery = Math.max(1, Math.ceil(LABEL_H / pitch));\n  const cols = Math.max(0, ...rows.map((r) => r.values.length));\n  const gridW = Math.max(0, cols * pitch - GAP);\n  const gridH = Math.max(0, rows.length * pitch - GAP);\n  const hasTicks = !!columnTicks && columnTicks.length > 0;\n  const hasTotals = rows.some((r) => r.total !== undefined);\n  const lead = hasTicks ? TICK_H + TICK_GAP : 0;\n  const scale = steps ?? quartileSteps(rows);\n\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n  const withUnit = (v: number) => fmt(v) + (unit ? ` ${unitFor(v)}` : \"\");\n  const colName = (c: number) => columns?.[c] ?? `column ${c + 1}`;\n  const cellName = (r: number, c: number) => [rows[r]?.label, colName(c)].filter(Boolean).join(\", \");\n  const valueText = (r: number, c: number) => {\n    const v = rows[r]?.values[c];\n    return v === null || v === undefined ? \"no data\" : withUnit(v);\n  };\n\n  const sumId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const cells = useRef(new Map<string, HTMLDivElement>());\n\n  /* ---------------------------------------------------------- fun mode */\n\n  const fun = useFun();\n  /* The pointer in dots, smoothed so the lens glides. Strength 0 is no lens, 1 is a hover, more is a press. */\n  const rawX = useMotionValue(-10);\n  const rawY = useMotionValue(-10);\n  const lensX = useSpring(rawX, { visualDuration: FUN_SPRINGS.follow.time, bounce: FUN_SPRINGS.follow.bounce });\n  const lensY = useSpring(rawY, { visualDuration: FUN_SPRINGS.follow.time, bounce: FUN_SPRINGS.follow.bounce });\n  const strength = useMotionValue(0);\n  const lensed = useRef(new Set<HTMLDivElement>());\n  const lensFrame = useRef(0);\n  const paintLens = () => {\n    lensFrame.current = 0;\n    const x = lensX.get();\n    const y = lensY.get();\n    const s = strength.get();\n    const touched = new Set<HTMLDivElement>();\n    if (s > 0.001) {\n      const reach = Math.ceil(LENS_REACH);\n      for (let r = Math.max(0, Math.floor(y) - reach); r <= Math.min(rows.length - 1, Math.ceil(y) + reach); r++) {\n        for (let c = Math.max(0, Math.floor(x) - reach); c <= Math.min(cols - 1, Math.ceil(x) + reach); c++) {\n          const el = cells.current.get(`${r}:${c}`);\n          if (!el) continue;\n          const dx = c - x;\n          const dy = r - y;\n          const d = Math.hypot(dx, dy);\n          const f = Math.max(0, 1 - d / LENS_REACH) ** 2 * s;\n          if (f < 0.002) continue;\n          const push = d > 0.001 ? (LENS_PUSH * f) / d : 0;\n          el.style.transform = `translate(${(dx * push).toFixed(2)}px, ${(dy * push).toFixed(2)}px) scale(${(1 + LENS_SWELL * f).toFixed(3)})`;\n          touched.add(el);\n        }\n      }\n    }\n    /* The dots the lens just left go back to their own, untransformed markup. */\n    lensed.current.forEach((el) => !touched.has(el) && (el.style.transform = \"\"));\n    lensed.current = touched;\n  };\n  const scheduleLens = () => {\n    if (!lensFrame.current) lensFrame.current = requestAnimationFrame(paintLens);\n  };\n  useMotionValueEvent(lensX, \"change\", scheduleLens);\n  useMotionValueEvent(lensY, \"change\", scheduleLens);\n  useMotionValueEvent(strength, \"change\", scheduleLens);\n  useEffect(() => {\n    if (fun) return;\n    /* Fun mode turned off: let go of every dot. */\n    strength.jump(0);\n    lensed.current.forEach((el) => (el.style.transform = \"\"));\n    lensed.current.clear();\n  }, [fun, strength]);\n  useEffect(() => () => cancelAnimationFrame(lensFrame.current), []);\n  /* The pointer in the grid's own units, so the lens stays exact inside a scaled parent. */\n  const lensAt = (e: PointerEvent<HTMLDivElement>) => {\n    const box = overlayRef.current?.getBoundingClientRect();\n    if (!fun || !box || !box.width) return;\n    const k = gridW / box.width;\n    const x = ((e.clientX - box.left) * k + GAP / 2) / pitch - 0.5;\n    const y = ((e.clientY - box.top) * k + GAP / 2) / pitch - 0.5;\n    if (strength.get() === 0) {\n      /* A fresh entry: the lens starts where the pointer is, never sliding in from where it last left. */\n      lensX.jump(x);\n      lensY.jump(y);\n    }\n    rawX.set(x);\n    rawY.set(y);\n    if (strength.get() < 1 && !strength.isAnimating()) animate(strength, 1, fun.follow);\n  };\n\n  /* Arrival: one canvas draws the grid's two beats while the real grid waits under it. */\n  const [arriving, setArriving] = useState(() => !!fun);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const probeRef = useRef<HTMLSpanElement>(null);\n  useEffect(() => {\n    if (!arriving) return;\n    const el = canvasRef.current;\n    const ctx = el?.getContext(\"2d\");\n    const probe = probeRef.current;\n    if (!fun || !el || !ctx || !probe) return setArriving(false);\n    /* The tokens, resolved by the browser: the empty dot and the four steps, in the current theme. */\n    const colors = [EMPTY, ...HUE[hue]].map((cls) => {\n      probe.className = cls + \" hidden\";\n      return getComputedStyle(probe).backgroundColor;\n    });\n    /* Drawn at the size it shows, so the dots are crisp in a scaled parent too. */\n    const shown = el.getBoundingClientRect().width / Math.max(1, gridW);\n    const px = Math.max(1, devicePixelRatio * shown);\n    el.width = Math.round(gridW * px);\n    el.height = Math.round(gridH * px);\n    ctx.setTransform(px, 0, 0, px, 0, 0);\n    const pop = spring({ keyframes: [0, 1], visualDuration: FUN_SPRINGS.pop.time, bounce: FUN_SPRINGS.pop.bounce });\n    const at = (t: number) => (t <= 0 ? 0 : pop.next(t * 1000).value);\n    const clamp = (v: number) => Math.max(0, Math.min(1, v));\n    const half = cell / 2;\n    const levels = rows.map((row) => row.values.map((v) => (v === null ? -1 : levelOf(v, scale))));\n    const end = Math.max((rows.length + cols) * fun.wave, DATA_AT + 3 * DATA_PER_LEVEL + cols * fun.wave * 0.5) + 1.2;\n    let frame = 0;\n    const began = performance.now();\n    const draw = (now: number) => {\n      const t = (now - began) / 1000;\n      ctx.clearRect(0, 0, gridW, gridH);\n      const lx = lensX.get();\n      const ly = lensY.get();\n      const ls = strength.get();\n      levels.forEach((row, r) =>\n        row.forEach((level, c) => {\n          if (level < 0) return;\n          const a = at(t - (r + c) * fun.wave); // the diagonal wave of the empty grid\n          if (a <= 0) return;\n          const data = DATA_AT + (level - 1) * DATA_PER_LEVEL + c * fun.wave * 0.5; // then the data, peaks last\n          const dx = c - lx;\n          const dy = r - ly;\n          const d = Math.hypot(dx, dy);\n          const f = ls > 0 ? Math.max(0, 1 - d / LENS_REACH) ** 2 * ls : 0;\n          const push = d > 0.001 ? (LENS_PUSH * f) / d : 0;\n          const cx = c * pitch + half + dx * push;\n          const cy = r * pitch + half + dy * push;\n          const size = a * (1 + LENS_SWELL * f);\n          const emptyAlpha = level === 0 ? 1 : 1 - clamp((t - data) / 0.2);\n          if (emptyAlpha > 0) {\n            ctx.globalAlpha = clamp(a) * emptyAlpha;\n            ctx.fillStyle = colors[0]!;\n            ctx.beginPath();\n            ctx.arc(cx, cy, Math.max(0, half * size), 0, Math.PI * 2);\n            ctx.fill();\n          }\n          if (level > 0) {\n            const g = at(t - data);\n            if (g > 0) {\n              ctx.globalAlpha = clamp(a) * clamp(g);\n              ctx.fillStyle = colors[level]!;\n              ctx.beginPath();\n              ctx.arc(cx, cy, Math.max(0, half * size * (0.4 + 0.6 * g)), 0, Math.PI * 2);\n              ctx.fill();\n            }\n          }\n        }),\n      );\n      ctx.globalAlpha = 1;\n      if (t < end) frame = requestAnimationFrame(draw);\n      else setArriving(false);\n    };\n    frame = requestAnimationFrame(draw);\n    return () => cancelAnimationFrame(frame);\n  }, [arriving]); // eslint-disable-line react-hooks/exhaustive-deps -- one arrival; the rest is read as it draws\n\n  /* Which dot is active: a hovered one, else a focused one, else a pinned one (a touch). */\n  const [hover, setHover] = useState<Cell | null>(null);\n  const [focus, setFocus] = useState<Cell | null>(null);\n  const [pinned, setPinned] = useState<Cell | null>(null);\n  const [dismissed, setDismissed] = useState(false);\n  const active = dismissed ? null : (hover ?? focus ?? pinned);\n  const activeKey = keyOf(active);\n  useEffect(() => setDismissed(false), [keyOf(hover), keyOf(focus), keyOf(pinned)]);\n\n  /* The one tab stop: the last dot of the first row that has data, which is in view when the grid starts at the end. */\n  const [tab, setTab] = useState<Cell | null>(null);\n  const stop = useMemo<Cell | null>(() => {\n    const first = rows[0];\n    if (!first) return null;\n    if (tab && rows[tab.r] && tab.c < rows[tab.r]!.values.length) return tab;\n    let c = first.values.length - 1;\n    while (c > 0 && first.values[c] === null) c--;\n    return { r: 0, c: Math.max(0, c) };\n  }, [rows, tab]);\n\n  /* Sideways scroll: start at the end, and stay there on resize until the person scrolls away. */\n  const stick = useRef(true);\n  const [overflowing, setOverflowing] = useState(false);\n  /* Which edges have more dots beyond them: those edges fade, so a half-clipped dot reads as \"more this way\". */\n  const [edges, setEdges] = useState({ start: false, end: false });\n  const [tick, setTick] = useState(0);\n  const readEdges = (el: HTMLElement) => {\n    const start = el.scrollLeft > 1;\n    const end = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n    setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));\n  };\n  useLayoutEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const sync = () => {\n      if (stick.current) el.scrollLeft = el.scrollWidth;\n      setOverflowing(el.scrollWidth > el.clientWidth + 1);\n      readEdges(el);\n      setTick((t) => t + 1);\n    };\n    sync();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(sync);\n    ro.observe(el);\n    if (el.firstElementChild) ro.observe(el.firstElementChild);\n    return () => ro.disconnect();\n  }, [gridW]);\n  const onScroll = () => {\n    const el = scrollRef.current;\n    if (!el) return;\n    stick.current = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2;\n    readEdges(el);\n    if (active) setTick((t) => t + 1);\n  };\n\n  /* A touch pins the tooltip; the next touch anywhere else clears it. */\n  useEffect(() => {\n    if (!pinned) return;\n    const away = (e: globalThis.PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setPinned(null);\n    };\n    document.addEventListener(\"pointerdown\", away, true);\n    return () => document.removeEventListener(\"pointerdown\", away, true);\n  }, [pinned]);\n\n  /* Where the tooltip goes: above the active dot, in the heatmap's own coordinates. */\n  const [tip, setTip] = useState<{ x: number; top: number; width: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = active ? cells.current.get(activeKey) : null;\n    const root = rootRef.current;\n    const scroller = scrollRef.current;\n    if (!el || !root || !scroller) return setTip(null);\n    const c = el.getBoundingClientRect();\n    const s = scroller.getBoundingClientRect();\n    const r = root.getBoundingClientRect();\n    const x = c.left + c.width / 2;\n    if (x < s.left || x > s.right) return setTip(null);\n    setTip({ x: x - r.left, top: c.top - r.top, width: r.width });\n  }, [activeKey, tick, cell]);\n\n  /* The dot under a pointer, or null in the air around the grid or on a dot with no data. */\n  const cellAt = (clientX: number, clientY: number): Cell | null => {\n    const box = overlayRef.current?.getBoundingClientRect();\n    if (!box) return null;\n    const c = Math.floor((clientX - box.left) / pitch);\n    const r = Math.floor((clientY - box.top) / pitch);\n    const row = rows[r];\n    if (!row || c < 0 || c >= row.values.length || row.values[c] === null) return null;\n    return { r, c };\n  };\n\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    lensAt(e);\n    const next = cellAt(e.clientX, e.clientY);\n    if (e.pointerType === \"touch\") {\n      if (e.buttons && !overflowing) setPinned(next);\n    } else setHover((prev) => (same(prev, next) ? prev : next));\n  };\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (fun) animate(strength, 1.35, fun.pop);\n    /* While the grid scrolls, a drag is a scroll, so a touch picks its dot on release. */\n    if (e.pointerType !== \"touch\" || overflowing) return;\n    const next = cellAt(e.clientX, e.clientY);\n    setPinned((p) => (same(p, next) ? null : next));\n  };\n  const onPointerUp = (e: PointerEvent<HTMLDivElement>) => {\n    if (fun) animate(strength, e.pointerType === \"touch\" ? 0 : 1, fun.pop);\n    if (e.pointerType !== \"touch\" || !overflowing) return;\n    const next = cellAt(e.clientX, e.clientY);\n    setPinned((p) => (same(p, next) ? null : next));\n  };\n\n  /* Keyboard: arrows in two dimensions, Home and End along a row, Ctrl with them across the grid. */\n  const cellFrom = (target: EventTarget | null): Cell | null => {\n    const el = (target as HTMLElement | null)?.closest?.(\"[data-r]\") as HTMLElement | null;\n    return el ? { r: Number(el.dataset.r), c: Number(el.dataset.c) } : null;\n  };\n  const focusCell = (to: Cell) => cells.current.get(keyOf(to))?.focus();\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\") {\n      if (active) setDismissed(true);\n      return;\n    }\n    const from = cellFrom(e.target);\n    if (!from) return;\n    const len = (r: number) => Math.max(1, rows[r]?.values.length ?? 1);\n    const lastRow = rows.length - 1;\n    const across = e.ctrlKey || e.metaKey;\n    let to: Cell | null = null;\n    if (e.key === \"ArrowRight\") to = { r: from.r, c: Math.min(len(from.r) - 1, from.c + 1) };\n    else if (e.key === \"ArrowLeft\") to = { r: from.r, c: Math.max(0, from.c - 1) };\n    else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      const r = Math.max(0, Math.min(lastRow, from.r + (e.key === \"ArrowDown\" ? 1 : -1)));\n      to = { r, c: Math.min(from.c, len(r) - 1) };\n    } else if (e.key === \"Home\") to = across ? { r: 0, c: 0 } : { r: from.r, c: 0 };\n    else if (e.key === \"End\") to = across ? { r: lastRow, c: len(lastRow) - 1 } : { r: from.r, c: len(from.r) - 1 };\n    if (!to) return;\n    e.preventDefault();\n    focusCell(to);\n  };\n\n  const text = useMemo(() => {\n    if (summary) return summary;\n    let total = 0;\n    let best = 0;\n    let at: Cell | null = null;\n    rows.forEach((row, r) =>\n      row.values.forEach((v, c) => {\n        if (v === null || !Number.isFinite(v)) return;\n        total += v;\n        if (v > best) {\n          best = v;\n          at = { r, c };\n        }\n      }),\n    );\n    const head = `${label}, ${fmt(total)} in all`;\n    const where = at as Cell | null;\n    return where ? `${head}, most on ${cellName(where.r, where.c)} with ${fmt(best)}` : head;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [summary, label, rows, columns, fmt]);\n\n  if (rows.length === 0 || cols === 0) return null;\n\n  const tickLeft = (index: number, text: string) => {\n    const x = index * pitch;\n    return x + text.length * FIGURE > gridW ? { right: 0 } : { left: x };\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={\"relative grid w-fit max-w-full grid-cols-[auto_minmax(0,max-content)_auto] \" + className}\n    >\n      {/* Row labels: their own column, so they stay put while the dots scroll. The same words sit in each row as a hidden header. */}\n      <div aria-hidden=\"true\" className=\"pt-1 pr-4 text-litt-caption font-normal whitespace-nowrap text-litt-fg-subtle\">\n        {lead > 0 && <div style={{ height: lead }} />}\n        <div className=\"flex flex-col\" style={{ gap: GAP }}>\n          {rows.map((row, r) => (\n            <div key={r} className=\"flex items-center\" style={{ height: cell }}>\n              <span className={\"litt-trim\" + (r % labelEvery === 0 ? \"\" : \" invisible\")}>{row.label}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div\n        ref={scrollRef}\n        onScroll={onScroll}\n        className=\"min-w-0 scroll-px-3 overflow-x-auto overflow-y-hidden px-1 py-1 [scrollbar-color:var(--litt-line-strong)_transparent] [scrollbar-width:thin]\"\n        style={\n          edges.start || edges.end\n            ? {\n                maskImage: `linear-gradient(to right, ${edges.start ? \"transparent\" : \"black\"} 0, black ${FADE}px, black calc(100% - ${FADE}px), ${edges.end ? \"transparent\" : \"black\"} 100%)`,\n              }\n            : undefined\n        }\n      >\n        <div className=\"relative\" style={{ width: gridW }}>\n          {hasTicks && (\n            <div\n              aria-hidden=\"true\"\n              className=\"relative text-litt-caption font-normal whitespace-nowrap text-litt-fg-subtle\"\n              style={{ height: TICK_H, marginBottom: TICK_GAP }}\n            >\n              {columnTicks!.map((t) => (\n                <span key={t.index} className=\"absolute top-0 flex items-center\" style={{ height: TICK_H, ...tickLeft(t.index, t.label) }}>\n                  <span className=\"litt-trim\">{t.label}</span>\n                </span>\n              ))}\n            </div>\n          )}\n\n          <div role=\"grid\" aria-label={label} aria-describedby={sumId} onKeyDown={onKeyDown} onFocus={(e) => {\n            const c = cellFrom(e.target);\n            if (c) {\n              setFocus(c);\n              setTab(c);\n            }\n          }} onBlur={(e) => {\n            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocus(null);\n          }} className=\"flex flex-col\" style={arriving ? { gap: GAP, opacity: 0 } : { gap: GAP }}>\n            {rows.map((row, r) => (\n              <div key={r} role=\"row\" className=\"flex items-center\" style={{ gap: GAP, height: cell }}>\n                <span role=\"rowheader\" className=\"sr-only\">\n                  {row.label}\n                </span>\n                {row.values.map((v, c) => {\n                  const level = v === null ? -1 : levelOf(v, scale);\n                  const ringed = active !== null && active.r === r && active.c === c;\n                  return (\n                    <div\n                      key={c}\n                      ref={(el) => {\n                        if (el) cells.current.set(`${r}:${c}`, el);\n                        else cells.current.delete(`${r}:${c}`);\n                      }}\n                      role=\"gridcell\"\n                      data-r={r}\n                      data-c={c}\n                      tabIndex={stop && stop.r === r && stop.c === c ? 0 : -1}\n                      aria-label={`${cellName(r, c)}, ${valueText(r, c)}`}\n                      style={{ width: cell, height: cell }}\n                      className={\n                        \"shrink-0 rounded-litt-pill transition-[background-color] duration-(--litt-dur-instant) ease-litt \" +\n                        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n                        (ringed ? \"outline-1 outline-offset-1 outline-litt-fg \" : \"\") +\n                        (level < 0 ? \"\" : level === 0 ? EMPTY : HUE[hue][level - 1])\n                      }\n                    />\n                  );\n                })}\n                {row.total !== undefined && (\n                  <span role=\"gridcell\" className=\"sr-only\">\n                    Total {row.total}\n                  </span>\n                )}\n              </div>\n            ))}\n          </div>\n\n          {/* One layer takes the pointer, so the target is the whole grid and not a 12px dot. When the grid fits, a touch\n              scrubs across it (pan-y); when it scrolls, a drag has to scroll it, so a touch picks a dot on release. */}\n          {arriving && (\n            <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute left-0\" style={{ top: lead, width: gridW, height: gridH }} />\n          )}\n          {arriving && <span ref={probeRef} className=\"hidden\" />}\n          <div\n            ref={overlayRef}\n            aria-hidden=\"true\"\n            className={\"absolute inset-x-0 select-none \" + (overflowing ? \"touch-pan-x touch-pan-y\" : \"touch-pan-y\")}\n            style={{ top: lead, height: gridH }}\n            onPointerMove={onPointerMove}\n            onPointerLeave={(e) => {\n              if (fun) animate(strength, 0, fun.follow);\n              if (e.pointerType !== \"touch\") setHover(null);\n            }}\n            onPointerDown={onPointerDown}\n            onPointerUp={onPointerUp}\n          />\n        </div>\n      </div>\n\n      {/* Totals: their own column at the end of the rows. */}\n      {hasTotals && (\n        <div aria-hidden=\"true\" className=\"pt-1 pl-4 text-right text-litt-small whitespace-nowrap text-litt-fg tabular-nums\">\n          {lead > 0 && <div style={{ height: lead }} />}\n          <div className=\"flex flex-col\" style={{ gap: GAP }}>\n            {rows.map((row, r) => (\n              <div key={r} className=\"flex items-center justify-end\" style={{ height: cell }}>\n                {/* The same rule as the labels: a total is a line of text, so rows closer than one show every nth. */}\n                <span className={\"litt-trim\" + (r % labelEvery === 0 ? \"\" : \" invisible\")}>\n                  {fun && arriving && row.total ? <CountingTotal text={row.total} /> : row.total}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      <Legend hue={hue} cell={cell} />\n\n      <AnimatePresence>\n        {active && tip && (\n          <Tip key=\"tip\" cx={tip.x} markTop={tip.top} width={tip.width}>\n            <div className=\"flex items-center gap-3\">\n              <span className=\"text-litt-fg-muted\">{cellName(active.r, active.c)}</span>\n              <span className=\"text-litt-caption font-normal tabular-nums\">{valueText(active.r, active.c)}</span>\n            </div>\n          </Tip>\n        )}\n      </AnimatePresence>\n\n      <span id={sumId} className=\"sr-only\">\n        {text}\n      </span>\n    </div>\n  );\n}\n\n/** Fun mode's arrival: a total that is a plain count (\"1,204\") counts up as its row fills; any other total shows as it is. */\nfunction CountingTotal({ text }: { text: string }) {\n  const fun = useFun();\n  const n = /^[\\d,]+$/.test(text) ? Number(text.replace(/,/g, \"\")) : NaN;\n  const [shown, setShown] = useState(Number.isFinite(n) ? 0 : n);\n  useEffect(() => {\n    if (!fun || !Number.isFinite(n)) return;\n    const run = animate(0, n, { ...fun.draw, delay: DATA_AT, onUpdate: (v) => setShown(v) });\n    return () => run.stop();\n  }, [fun, n]);\n  return <>{Number.isFinite(n) ? countText(text, n, shown) : text}</>;\n}\n\n/* ----------------------------------------------------------------- legend */\n\nfunction Legend({ hue, cell }: { hue: HeatmapHue; cell: number }) {\n  return (\n    <div aria-hidden=\"true\" className=\"col-start-2 col-end-3 mt-3 flex items-center gap-2 justify-self-end text-litt-caption font-normal whitespace-nowrap text-litt-fg-subtle\">\n      <span>Less</span>\n      <span className=\"flex items-center\" style={{ gap: GAP }}>\n        {[EMPTY, ...HUE[hue]].map((fill, i) => (\n          <span key={i} className={\"rounded-litt-pill \" + fill} style={{ width: cell, height: cell }} />\n        ))}\n      </span>\n      <span>More</span>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- tooltip */\n\n/** The chart's tooltip look: solid surface-3, shadow-litt-tooltip, above the dot and kept inside the heatmap. */\nfunction Tip({ cx, markTop, width, children }: { cx: number; markTop: number; width: number; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const ref = useRef<HTMLDivElement>(null);\n  const [w, setW] = useState(0);\n  const measure = useCallback(() => setW(ref.current?.offsetWidth ?? 0), []);\n  useLayoutEffect(measure);\n  const half = w / 2;\n  const left = Math.max(half, Math.min(width - half, cx));\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full\" style={{ left, top: markTop - 8 }}>\n      <motion.div\n        ref={ref}\n        className=\"origin-bottom rounded-litt-control bg-litt-surface-3 px-2 py-1.5 text-litt-small whitespace-nowrap text-litt-fg shadow-litt-tooltip inset-shadow-litt-highlight\"\n        initial={{ opacity: 0, scale: 0.98, y: travel / 4 }}\n        animate={{ opacity: 1, scale: 1, y: 0, transition: enter }}\n        exit={{ opacity: 0, scale: 0.98, transition: exit }}\n      >\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n",
      "target": "components/litt/heatmap.tsx"
    }
  ],
  "docs": "Import Heatmap from components/litt/heatmap. Pass a label, rows of { label, values, total? } and columns that name each column, and pick a hue. Use null for a day with no data. The dots are a grid you can arrow through.",
  "meta": {
    "version": "2026.10.26",
    "released": "2026-10-26",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
