{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-heatmap",
  "title": "Chart Heatmap",
  "description": "GitHub-style contribution calendar with hatched empty days, a diagonal reveal wave, and hover tooltips.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-heatmap/chart-heatmap.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { AnimatePresence, animate as animateValue, motion } from \"motion/react\"\nimport {\n  CHART_ACCENT,\n  ChartCard,\n  chartTheme,\n  createFormatter,\n  toNumber,\n  useChartReveal,\n  type ChartBaseProps,\n  type ChartDatum,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartHeatmapProps extends ChartBaseProps {\n  /** Rows like { date: \"2025-06-01\", count: 4 }. Missing days count as zero. */\n  data: ChartDatum[]\n  /** Key holding an ISO date (YYYY-MM-DD) or a timestamp. Default: \"date\" */\n  dateKey?: string\n  /** Key holding the value for that day. Default: \"count\" */\n  valueKey?: string\n  /** Weeks shown, ending at the latest date. Default: 20 */\n  weeks?: number\n  /** Color steps, including the empty step. Default: 5 */\n  levels?: number\n  /** Cell size in px. Default: 12 */\n  cellSize?: number\n  /** Space between cells in px. Default: 3 */\n  cellGap?: number\n  /** Cell corner radius in px. Default: 3 */\n  cellRadius?: number\n  /** First day of the week: 0 = Sunday, 1 = Monday. Default: 0 */\n  weekStart?: 0 | 1\n  /** Show month names above the grid. Default: true */\n  showMonthLabels?: boolean\n  /** Show Mon / Wed / Fri beside the grid. Default: true */\n  showDayLabels?: boolean\n  /** Fill of days with no activity. Default: \"hatch\" */\n  emptyTexture?: \"solid\" | \"hatch\"\n  /** \"quantile\" spreads colors evenly across your data; \"linear\" scales to the busiest day. Default: \"linear\" */\n  scale?: \"linear\" | \"quantile\"\n  /** Show a tooltip on hover. Default: true */\n  showTooltip?: boolean\n  /** Show the Less / More key. Default: true */\n  showLegend?: boolean\n  /** Show the total for the range as the headline. Default: true */\n  showTotal?: boolean\n  /** Word used in the tooltip. Default: \"contributions\" */\n  unit?: string\n}\n\nconst DAY = 86_400_000\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\nconst DAY_NAMES = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"]\n\nfunction dayNumber(v: string | number | undefined) {\n  if (typeof v === \"number\") return Math.floor(v / DAY)\n  if (!v) return null\n  const m = /^(\\d{4})-(\\d{1,2})-(\\d{1,2})/.exec(v)\n  const ms = m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : Date.parse(v)\n  return Number.isNaN(ms) ? null : Math.floor(ms / DAY)\n}\n\nconst dow = (day: number) => (((day + 4) % 7) + 7) % 7\n\nfunction dateLabel(day: number) {\n  const d = new Date(day * DAY)\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`\n}\n\nfunction useCountUp(\n  target: number,\n  format: (n: number) => string,\n  { shown, still, duration, delay }: { shown: boolean; still: boolean; duration: number; delay: number }\n) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const current = useRef(0)\n  useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    if (still) {\n      current.current = target\n      el.textContent = format(target)\n      return\n    }\n    el.textContent = format(current.current)\n    if (!shown) return\n    const controls = animateValue(current.current, target, {\n      duration,\n      delay,\n      ease: [0.22, 1, 0.36, 1],\n      onUpdate: (v) => {\n        current.current = v\n        el.textContent = format(v)\n      },\n    })\n    return () => controls.stop()\n  }, [target, format, shown, still, duration, delay])\n  return ref\n}\n\nfunction GridGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"currentColor\">\n      <rect x=\"2\" y=\"2\" width=\"5\" height=\"5\" rx=\"1.2\" />\n      <rect x=\"9\" y=\"2\" width=\"5\" height=\"5\" rx=\"1.2\" fillOpacity=\"0.4\" />\n      <rect x=\"2\" y=\"9\" width=\"5\" height=\"5\" rx=\"1.2\" fillOpacity=\"0.4\" />\n      <rect x=\"9\" y=\"9\" width=\"5\" height=\"5\" rx=\"1.2\" />\n    </svg>\n  )\n}\n\ninterface Cell {\n  day: number\n  col: number\n  row: number\n  value: number\n  level: number\n}\n\nexport function ChartHeatmap({\n  data,\n  dateKey = \"date\",\n  valueKey = \"count\",\n  weeks = 20,\n  levels = 5,\n  cellSize = 12,\n  cellGap = 3,\n  cellRadius = 3,\n  weekStart = 0,\n  showMonthLabels = true,\n  showDayLabels = true,\n  emptyTexture = \"hatch\",\n  scale = \"linear\",\n  showTooltip = true,\n  showLegend = true,\n  showTotal = true,\n  unit = \"contributions\",\n  title,\n  description,\n  titleSize,\n  surface = \"dark\",\n  accent = CHART_ACCENT,\n  palette,\n  icon,\n  radius,\n  bare,\n  animate = true,\n  duration = 1.2,\n  delay = 0,\n  once = true,\n  valueFormat = \"number\",\n  currency,\n  decimals,\n  className,\n}: ChartHeatmapProps) {\n  const theme = chartTheme(surface, accent)\n  const color = palette?.[0] ?? accent\n  const { ref, shown, still } = useChartReveal(once, animate)\n  const format = useMemo(() => createFormatter(valueFormat, { currency, decimals }), [valueFormat, currency, decimals])\n  const id = useId().replace(/[^a-zA-Z0-9_-]/g, \"\")\n  const [hover, setHover] = useState<{ cell: Cell; x: number; y: number } | null>(null)\n\n  const cols = Math.max(1, Math.round(weeks))\n  const steps = Math.max(2, Math.round(levels))\n\n  const { cells, total, months } = useMemo(() => {\n    const byDay = new Map<number, number>()\n    let end = -Infinity\n    for (const row of data) {\n      const day = dayNumber(row[dateKey])\n      if (day === null) continue\n      byDay.set(day, (byDay.get(day) ?? 0) + toNumber(row[valueKey]))\n      end = Math.max(end, day)\n    }\n    if (!Number.isFinite(end)) end = Math.floor(Date.UTC(2025, 0, 1) / DAY)\n    const rowOf = (day: number) => (dow(day) - weekStart + 7) % 7\n    const start = end - rowOf(end) - (cols - 1) * 7\n\n    const list: Omit<Cell, \"level\">[] = []\n    for (let col = 0; col < cols; col++) {\n      for (let row = 0; row < 7; row++) {\n        const day = start + col * 7 + row\n        if (day > end) break\n        list.push({ day, col, row, value: byDay.get(day) ?? 0 })\n      }\n    }\n\n    const active = list.map((c) => c.value).filter((v) => v > 0).sort((a, b) => a - b)\n    const max = active[active.length - 1] ?? 0\n    const levelOf = (v: number) => {\n      if (v <= 0 || !max) return 0\n      if (steps === 2) return 1\n      if (scale === \"quantile\") {\n        let below = 0\n        while (below < active.length && active[below] < v) below++\n        return Math.min(steps - 1, 1 + Math.floor((below / active.length) * (steps - 1)))\n      }\n      return Math.max(1, Math.min(steps - 1, Math.ceil((v / max) * (steps - 1))))\n    }\n\n    const labels: { col: number; text: string }[] = []\n    let last = -1\n    for (let col = 0; col < cols; col++) {\n      const month = new Date((start + col * 7) * DAY).getUTCMonth()\n      if (month !== last) {\n        if (col > 0 || new Date(start * DAY).getUTCDate() <= 7) labels.push({ col, text: MONTHS[month] })\n        last = month\n      }\n    }\n    const spaced = labels.filter((l, i) => {\n      const next = labels[i + 1]\n      return (!next || next.col - l.col >= 3) && l.col <= cols - 2\n    })\n\n    return {\n      cells: list.map((c) => ({ ...c, level: levelOf(c.value) })),\n      total: list.reduce((a, c) => a + c.value, 0),\n      months: spaced,\n    }\n  }, [data, dateKey, valueKey, cols, steps, scale, weekStart])\n\n  const countRef = useCountUp(total, format, { shown, still, duration, delay })\n\n  const step = cellSize + cellGap\n  const left = showDayLabels ? 30 : 0\n  const topPad = showMonthLabels ? 18 : 0\n  const gridW = cols * step - cellGap\n  const gridH = 7 * step - cellGap\n  const width = left + gridW\n  const height = topPad + gridH\n\n  const fillOf = (level: number) => {\n    if (level === 0) return emptyTexture === \"hatch\" ? `url(#${id}-hatch)` : theme.track\n    const share = steps === 2 ? 100 : Math.round(28 + (72 * (level - 1)) / (steps - 2))\n    return share >= 100 ? color : `color-mix(in oklab, ${color} ${share}%, ${theme.track})`\n  }\n  const wave = (duration * 0.75) / (cols + 3)\n\n  const onEnter = (cell: Cell, e: React.PointerEvent<SVGRectElement>) => {\n    const box = ref.current?.getBoundingClientRect()\n    const r = e.currentTarget.getBoundingClientRect()\n    if (!box) return\n    const x = r.left + r.width / 2 - box.left\n    setHover({ cell, x: Math.max(96, Math.min(box.width - 96, x)), y: r.top - box.top })\n  }\n\n  const dayRows = [1, 3, 5].map((d) => ({ row: (d - weekStart + 7) % 7, text: DAY_NAMES[d] }))\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      value={\n        showTotal && !bare ? (\n          <span ref={countRef}>{format(still ? total : 0)}</span>\n        ) : undefined\n      }\n      icon={icon === undefined ? <GridGlyph /> : icon}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div ref={ref} className=\"relative\">\n        <div className=\"-m-1.5 overflow-x-auto p-1.5 [scrollbar-width:none]\">\n          <svg\n            width={width}\n            height={height}\n            viewBox={`0 0 ${width} ${height}`}\n            className=\"block overflow-visible\"\n            onPointerLeave={() => setHover(null)}\n          >\n            <defs>\n              <pattern id={`${id}-hatch`} width=\"3.5\" height=\"3.5\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\">\n                <rect width=\"3.5\" height=\"3.5\" fill={theme.track} />\n                <line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"3.5\" stroke={theme.subtle} strokeWidth=\"1.2\" />\n              </pattern>\n            </defs>\n\n            {showMonthLabels &&\n              months.map((m) => (\n                <text key={m.col} x={left + m.col * step} y={10} fontSize={10.5} fill={theme.muted}>\n                  {m.text}\n                </text>\n              ))}\n            {showDayLabels &&\n              dayRows.map((d) => (\n                <text key={d.text} x={0} y={topPad + d.row * step + cellSize / 2} dominantBaseline=\"central\" fontSize={10.5} fill={theme.muted}>\n                  {d.text}\n                </text>\n              ))}\n\n            {cells.map((c) => (\n              <motion.rect\n                key={c.day}\n                x={left + c.col * step}\n                y={topPad + c.row * step}\n                width={cellSize}\n                height={cellSize}\n                rx={Math.min(cellRadius, cellSize / 2)}\n                fill={fillOf(c.level)}\n                stroke={hover?.cell.day === c.day ? theme.fg : \"none\"}\n                strokeWidth={1.5}\n                style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                initial={still ? false : { opacity: 0, scale: 0.3 }}\n                animate={shown ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.3 }}\n                whileHover={still ? undefined : { scale: 1.3, transition: { type: \"spring\", stiffness: 600, damping: 22 } }}\n                transition={\n                  still\n                    ? { duration: 0 }\n                    : { type: \"spring\", stiffness: 380, damping: 24, delay: delay + (c.col + c.row * 0.6) * wave }\n                }\n                onPointerEnter={showTooltip ? (e) => onEnter(c, e) : undefined}\n              />\n            ))}\n          </svg>\n        </div>\n\n        {showLegend && (\n          <div className=\"mt-4 flex items-center justify-end gap-1.5 text-[11px]\" style={{ color: theme.muted }}>\n            <span className=\"mr-0.5\">Less</span>\n            {Array.from({ length: steps }, (_, level) => (\n              <svg key={level} width={cellSize} height={cellSize} className=\"block\">\n                <rect width={cellSize} height={cellSize} rx={Math.min(cellRadius, cellSize / 2)} fill={fillOf(level)} />\n              </svg>\n            ))}\n            <span className=\"ml-0.5\">More</span>\n          </div>\n        )}\n\n        <AnimatePresence>\n          {showTooltip && hover && (\n            <motion.div\n              key=\"tip\"\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0, left: hover.x, top: hover.y }}\n              exit={{ opacity: 0, y: 4 }}\n              transition={{ type: \"spring\", stiffness: 520, damping: 38, opacity: { duration: 0.12 } }}\n              className=\"pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-[calc(100%+8px)] whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-[12px] shadow-xl\"\n              style={{ background: theme.tooltipBg, borderColor: theme.border, color: theme.muted }}\n            >\n              <span className=\"font-semibold tabular-nums\" style={{ color: theme.fg }}>\n                {hover.cell.value ? format(hover.cell.value) : \"No\"} {unit}\n              </span>{\" \"}\n              on {dateLabel(hover.cell.day)}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </ChartCard>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-heatmap",
  "type": "registry:component"
}