{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap-chart-card",
  "title": "Heatmap Chart",
  "description": "Matrix heatmap with a theme-following accent ramp and less/more legend.",
  "registryDependencies": [
    "https://components.exe.xyz/r/chart-card.json",
    "https://components.exe.xyz/r/utils.json"
  ],
  "files": [
    {
      "path": "src/components/charts/heatmap-chart-card.tsx",
      "content": "import { useState } from \"react\"\n\nimport {\n  ChartCard,\n  formatNumber,\n  useActiveRange,\n} from \"@/components/charts/chart-card\"\nimport { cn } from \"@/lib/utils\"\n\nexport type HeatmapRow = { label: string; values: number[] }\n\nconst HOURS = [\n  \"00\",\n  \"02\",\n  \"04\",\n  \"06\",\n  \"08\",\n  \"10\",\n  \"12\",\n  \"14\",\n  \"16\",\n  \"18\",\n  \"20\",\n  \"22\",\n]\n\nfunction hourValue(day: number, hourIndex: number) {\n  const hour = hourIndex * 2\n  const work = hour >= 8 && hour <= 18\n  const weekend = day >= 5\n  let value = work\n    ? 28 + ((day * 7 + hourIndex * 3) % 22)\n    : 4 + ((day + hourIndex) % 9)\n  if (weekend) value = Math.round(value * 0.45)\n  if (hour >= 10 && hour <= 16 && !weekend) value += 12\n  return value\n}\n\nconst DEFAULT_ROWS: HeatmapRow[] = [\n  \"Mon\",\n  \"Tue\",\n  \"Wed\",\n  \"Thu\",\n  \"Fri\",\n  \"Sat\",\n  \"Sun\",\n].map((label, day) => ({\n  label,\n  values: HOURS.map((_, hour) => hourValue(day, hour)),\n}))\n\nconst REGION_ROWS: HeatmapRow[] = [\n  { label: \"EMEA\", values: [42, 38, 51, 47, 55, 49] },\n  { label: \"AMER\", values: [61, 58, 64, 70, 66, 72] },\n  { label: \"APAC\", values: [33, 41, 38, 44, 40, 46] },\n  { label: \"LATAM\", values: [18, 22, 19, 24, 21, 27] },\n]\n\ntype HeatmapRange = {\n  id: string\n  label: string\n  rows: HeatmapRow[]\n  columns?: string[]\n  max?: number\n  delta?: number\n  headline?: number\n}\n\nconst DEFAULT_RANGES: HeatmapRange[] = [\n  {\n    id: \"week\",\n    label: \"Last 7 days\",\n    columns: HOURS,\n    rows: DEFAULT_ROWS,\n    headline: 1892,\n    delta: 5.2,\n  },\n  {\n    id: \"regions\",\n    label: \"This quarter\",\n    columns: [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\"],\n    rows: REGION_ROWS,\n    headline: 1240,\n    delta: 3.1,\n  },\n]\n\nfunction cellFill(value: number, max: number, color: string) {\n  if (value <= 0) return \"var(--muted)\"\n  const t = Math.min(1, value / Math.max(max, 1))\n  const pct = Math.round(16 + t * 84)\n  return `color-mix(in srgb, ${color} ${pct}%, var(--card))`\n}\n\nfunction hourLabel(column: string) {\n  return column.includes(\":\") || column.length > 2 ? column : `${column}:00`\n}\n\nexport function HeatmapChartCard({\n  title = \"Active users\",\n  columns,\n  rows,\n  color = \"var(--chart-1)\",\n  max,\n  headline,\n  delta = 5.2,\n  range,\n  ranges,\n}: {\n  title?: string\n  columns?: string[]\n  rows?: HeatmapRow[]\n  color?: string\n  max?: number\n  headline?: number\n  delta?: number\n  range?: string\n  ranges?: HeatmapRange[]\n}) {\n  const usingCustom = ranges == null && (rows != null || columns != null)\n  const plotRanges = ranges ?? (usingCustom ? undefined : DEFAULT_RANGES)\n  const { id, setId, active } = useActiveRange<HeatmapRange>(plotRanges, range)\n  const plotColumns = active?.columns ?? columns ?? HOURS\n  const plotRows = active?.rows ?? rows ?? DEFAULT_ROWS\n  const peak =\n    active?.max ?? max ?? Math.max(1, ...plotRows.flatMap((row) => row.values))\n  const plotHeadline = active?.headline ?? headline ?? 1892\n  const plotDelta = active?.delta ?? delta\n  const [focus, setFocus] = useState({ r: 0, c: 0 })\n  const [tip, setTip] = useState<{\n    x: number\n    y: number\n    text: string\n  } | null>(null)\n  const summary = `${title} ${formatNumber(plotHeadline)}`\n\n  const moveFocus = (r: number, c: number) => {\n    const nextR = Math.min(plotRows.length - 1, Math.max(0, r))\n    const nextC = Math.min(plotColumns.length - 1, Math.max(0, c))\n    setFocus({ r: nextR, c: nextC })\n    requestAnimationFrame(() => {\n      document\n        .querySelector<HTMLButtonElement>(`[data-heat=\"${nextR}-${nextC}\"]`)\n        ?.focus()\n    })\n  }\n\n  return (\n    <ChartCard\n      title={title}\n      headline={plotHeadline}\n      delta={plotDelta}\n      period={plotRanges ? undefined : \"Last 7 days\"}\n      ranges={plotRanges}\n      range={id}\n      onRangeChange={setId}\n      summary={summary}\n    >\n      <div\n        className=\"relative overflow-x-auto\"\n        onMouseLeave={() => setTip(null)}\n      >\n        <div\n          role=\"grid\"\n          aria-label={summary}\n          className=\"min-w-0 sm:min-w-[28rem]\"\n        >\n          <div\n            role=\"row\"\n            className=\"grid gap-1\"\n            style={{\n              gridTemplateColumns: `2.5rem repeat(${plotColumns.length}, minmax(0, 1fr))`,\n            }}\n          >\n            <div />\n            {plotColumns.map((column) => (\n              <div\n                key={column}\n                role=\"columnheader\"\n                className=\"pb-1 text-center font-mono text-[9px] text-muted-foreground\"\n              >\n                {column}\n              </div>\n            ))}\n          </div>\n          {plotRows.map((row, rowIndex) => (\n            <div\n              key={row.label}\n              role=\"row\"\n              className=\"grid gap-1\"\n              style={{\n                gridTemplateColumns: `2.5rem repeat(${plotColumns.length}, minmax(0, 1fr))`,\n              }}\n            >\n              <div\n                role=\"rowheader\"\n                className=\"flex items-center font-mono text-[10px] text-muted-foreground\"\n              >\n                {row.label}\n              </div>\n              {plotColumns.map((column, colIndex) => {\n                const value = row.values[colIndex] ?? 0\n                const focused = focus.r === rowIndex && focus.c === colIndex\n                const text = `${row.label} ${hourLabel(column)} · ${formatNumber(value)}`\n                return (\n                  <button\n                    key={`${row.label}-${column}`}\n                    type=\"button\"\n                    role=\"gridcell\"\n                    tabIndex={focused ? 0 : -1}\n                    data-heat={`${rowIndex}-${colIndex}`}\n                    aria-label={text}\n                    className={cn(\n                      \"h-6 rounded-[2px] outline-none\",\n                      \"focus-visible:ring-2 focus-visible:ring-ring/50\"\n                    )}\n                    style={{ background: cellFill(value, peak, color) }}\n                    onFocus={() => setFocus({ r: rowIndex, c: colIndex })}\n                    onMouseEnter={(event) => {\n                      const host = event.currentTarget.closest(\".relative\")\n                      if (!(host instanceof HTMLElement)) return\n                      const box = host.getBoundingClientRect()\n                      setTip({\n                        x: event.clientX - box.left,\n                        y: event.clientY - box.top,\n                        text,\n                      })\n                      setFocus({ r: rowIndex, c: colIndex })\n                    }}\n                    onKeyDown={(event) => {\n                      if (event.key === \"ArrowRight\") {\n                        event.preventDefault()\n                        moveFocus(rowIndex, colIndex + 1)\n                      } else if (event.key === \"ArrowLeft\") {\n                        event.preventDefault()\n                        moveFocus(rowIndex, colIndex - 1)\n                      } else if (event.key === \"ArrowDown\") {\n                        event.preventDefault()\n                        moveFocus(rowIndex + 1, colIndex)\n                      } else if (event.key === \"ArrowUp\") {\n                        event.preventDefault()\n                        moveFocus(rowIndex - 1, colIndex)\n                      }\n                    }}\n                  />\n                )\n              })}\n            </div>\n          ))}\n        </div>\n        {tip ? (\n          <div\n            className=\"pointer-events-none absolute z-10 rounded-[2px] border border-border bg-card px-2 py-1 font-mono text-[11px] shadow-sm\"\n            style={{ left: tip.x + 8, top: tip.y - 28 }}\n          >\n            {tip.text}\n          </div>\n        ) : null}\n      </div>\n      <div className=\"mt-2 flex items-center justify-end gap-1.5\">\n        <span className=\"font-mono text-[9px] text-muted-foreground\">Less</span>\n        {[0.12, 0.32, 0.52, 0.72, 1].map((t) => (\n          <span\n            key={t}\n            className=\"size-2.5 rounded-[1px]\"\n            style={{ background: cellFill(t * peak, peak, color) }}\n          />\n        ))}\n        <span className=\"font-mono text-[9px] text-muted-foreground\">More</span>\n      </div>\n      <table className=\"sr-only\">\n        <caption>{title} data</caption>\n        <thead>\n          <tr>\n            <th>Row</th>\n            {plotColumns.map((column) => (\n              <th key={column}>{column}</th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {plotRows.map((row) => (\n            <tr key={row.label}>\n              <th>{row.label}</th>\n              {plotColumns.map((column, index) => (\n                <td key={column}>{row.values[index] ?? 0}</td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </ChartCard>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:block"
}