{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap-chart",
  "title": "Heatmap Chart",
  "description": "A matrix heatmap with a row per line and a column per label, e.g. weekday by hour, with hover highlighting and tooltips.",
  "registryDependencies": [
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/ui/heatmap-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nexport interface HeatmapRow {\n  label: string\n  /** One value per column, in column order. Missing entries read as 0. */\n  values: number[]\n}\n\n/** A cell's position by row and column index. */\nexport interface HeatmapCell {\n  row: number\n  column: number\n}\n\nexport interface HeatmapDatum extends HeatmapCell {\n  rowLabel: string\n  columnLabel: string\n  value: number\n}\n\nexport interface HeatmapChartProps extends React.ComponentProps<\"div\"> {\n  /** One row per line of the matrix, top to bottom. */\n  rows: HeatmapRow[]\n  /** Column labels, left to right. Sets the number of columns. */\n  columns: string[]\n  /** Any CSS color. Cells mix this into `--muted` by value. Defaults to chart-1. */\n  color?: string\n  /** Value drawn fully saturated. Defaults to the largest value. */\n  max?: number\n  /** How values map to color. `sqrt` spreads out small values. */\n  scale?: \"linear\" | \"sqrt\"\n  /** Quantize the fill into this many steps instead of a continuous ramp. */\n  levels?: number\n  /** Gap between cells in px. Cells themselves stretch to fill the container. */\n  gap?: number\n  valueFormatter?: (value: number) => string\n  /** Label for the value in the tooltip, e.g. \"sessions\". */\n  unit?: string\n  showLegend?: boolean\n  showRowLabels?: boolean\n  showColumnLabels?: boolean\n  /** Show every nth column label. Defaults to whatever keeps about 12 labels. */\n  columnLabelEvery?: number\n  /** Replace the tooltip body. Return `null` to hide it for that cell. */\n  renderTooltip?: (datum: HeatmapDatum) => React.ReactNode\n  onCellClick?: (datum: HeatmapDatum) => void\n  /** The hovered cell, to control it from outside. */\n  activeCell?: HeatmapCell | null\n  /** Called when the hovered cell changes, with null when the pointer leaves. */\n  onActiveCellChange?: (cell: HeatmapCell | null) => void\n}\n\n/** Where a value sits between the muted track (0) and the accent (1). */\nfunction fractionFor(\n  value: number,\n  max: number,\n  scale: \"linear\" | \"sqrt\",\n  levels?: number\n) {\n  if (value <= 0 || max <= 0) return 0\n  let t = Math.min(1, value / max)\n  if (scale === \"sqrt\") t = Math.sqrt(t)\n  if (levels && levels > 1) {\n    t = Math.max(1, Math.ceil(t * (levels - 1))) / (levels - 1)\n  }\n  return t\n}\n\nfunction fillFor(color: string, fraction: number) {\n  if (fraction <= 0) return \"var(--muted)\"\n  // Floor the mix so the smallest non-zero values still read against the track.\n  const pct = Math.round(12 + fraction * 88)\n  return `color-mix(in oklab, ${color} ${pct}%, var(--muted))`\n}\n\n/**\n * A matrix heatmap with a row per line and a column per label, e.g. weekday by hour.\n * Cells stretch to the container width. Hovering a cell rings it, lights up its row\n * and column labels, and shows its value.\n */\nfunction HeatmapChart({\n  rows,\n  columns,\n  color = \"var(--chart-1)\",\n  max,\n  scale = \"linear\",\n  levels,\n  gap = 4,\n  valueFormatter = (value) => formatNumber(value),\n  unit,\n  showLegend = true,\n  showRowLabels = true,\n  showColumnLabels = true,\n  columnLabelEvery,\n  renderTooltip,\n  onCellClick,\n  activeCell,\n  onActiveCellChange,\n  className,\n  ...props\n}: HeatmapChartProps) {\n  const [internal, setInternal] = React.useState<HeatmapCell | null>(null)\n  const active = activeCell === undefined ? internal : activeCell\n  const setActive = (cell: HeatmapCell | null) => {\n    setInternal(cell)\n    onActiveCellChange?.(cell)\n  }\n\n  const top = React.useMemo(\n    () =>\n      max ??\n      rows.reduce(\n        (acc, row) => row.values.reduce((m, v) => Math.max(m, v), acc),\n        0\n      ),\n    [rows, max]\n  )\n\n  const every = columnLabelEvery ?? Math.max(1, Math.ceil(columns.length / 12))\n  const steps = levels && levels > 1 ? levels : 5\n  const swatches = Array.from({ length: steps }, (_, i) =>\n    fillFor(color, i / (steps - 1))\n  )\n\n  const withUnit = (value: number) =>\n    unit ? `${valueFormatter(value)} ${unit}` : valueFormatter(value)\n\n  return (\n    <div\n      data-slot=\"heatmap-chart\"\n      className={cn(\"flex min-w-0 flex-col gap-2 text-xs\", className)}\n      {...props}\n    >\n      <TooltipProvider delayDuration={100} skipDelayDuration={0}>\n        <div\n          role=\"group\"\n          aria-label=\"Heatmap\"\n          className=\"grid min-w-0\"\n          style={{\n            gap,\n            gridTemplateColumns: `${showRowLabels ? \"auto \" : \"\"}repeat(${columns.length}, minmax(0, 1fr))`,\n          }}\n          onPointerLeave={() => setActive(null)}\n        >\n          {rows.map((row, r) => (\n            <React.Fragment key={r}>\n              {showRowLabels ? (\n                <div\n                  data-slot=\"heatmap-chart-row-label\"\n                  data-active={active?.row === r}\n                  onPointerEnter={() => setActive(null)}\n                  className=\"text-muted-foreground data-[active=true]:text-foreground flex items-center pr-1.5 leading-none whitespace-nowrap transition-colors\"\n                >\n                  {row.label}\n                </div>\n              ) : null}\n              {columns.map((column, c) => {\n                const value = row.values[c] ?? 0\n                const datum: HeatmapDatum = {\n                  row: r,\n                  column: c,\n                  rowLabel: row.label,\n                  columnLabel: column,\n                  value,\n                }\n                const isActive = active?.row === r && active?.column === c\n                const body = renderTooltip ? (\n                  renderTooltip(datum)\n                ) : (\n                  <>\n                    <span className=\"font-medium tabular-nums\">{withUnit(value)}</span>\n                    <span className=\"text-background/70 block\">\n                      {row.label} · {column}\n                    </span>\n                  </>\n                )\n\n                const cell = (\n                  <button\n                    type=\"button\"\n                    aria-label={`${withUnit(value)}, ${row.label} ${column}`}\n                    data-slot=\"heatmap-chart-cell\"\n                    data-row={r}\n                    data-column={c}\n                    data-active={isActive}\n                    onPointerEnter={() => setActive({ row: r, column: c })}\n                    onFocus={() => setActive({ row: r, column: c })}\n                    onBlur={(event) => {\n                      // Keep the highlight while focus moves between cells so the readout does not flash.\n                      const next = event.relatedTarget\n                      if (next instanceof HTMLElement && next.dataset.slot === \"heatmap-chart-cell\") return\n                      setActive(null)\n                    }}\n                    onClick={onCellClick ? () => onCellClick(datum) : undefined}\n                    className={cn(\n                      \"inset-ring-foreground/6 aspect-square w-full rounded-sm outline-none inset-ring transition-[box-shadow] duration-100\",\n                      \"data-[active=true]:inset-ring-foreground/45 data-[active=true]:inset-ring-2\",\n                      \"focus-visible:inset-ring-ring focus-visible:inset-ring-2\",\n                      onCellClick ? \"cursor-pointer\" : \"cursor-default\"\n                    )}\n                    style={{\n                      backgroundColor: fillFor(\n                        color,\n                        fractionFor(value, top, scale, levels)\n                      ),\n                    }}\n                  />\n                )\n\n                if (body === null) {\n                  return <React.Fragment key={c}>{cell}</React.Fragment>\n                }\n\n                return (\n                  <Tooltip key={c}>\n                    <TooltipTrigger asChild>{cell}</TooltipTrigger>\n                    <TooltipContent sideOffset={4} className=\"text-left\">\n                      {body}\n                    </TooltipContent>\n                  </Tooltip>\n                )\n              })}\n            </React.Fragment>\n          ))}\n\n          {showColumnLabels ? (\n            <>\n              {showRowLabels ? <div aria-hidden /> : null}\n              {columns.map((column, c) => (\n                <div\n                  key={c}\n                  data-slot=\"heatmap-chart-column-label\"\n                  data-active={active?.column === c}\n                  onPointerEnter={() => setActive(null)}\n                  className=\"text-muted-foreground data-[active=true]:text-foreground min-w-0 pt-0.5 text-center leading-none whitespace-nowrap transition-colors\"\n                >\n                  {c % every === 0 ? column : \"\"}\n                </div>\n              ))}\n            </>\n          ) : null}\n        </div>\n      </TooltipProvider>\n\n      {showLegend ? (\n        <div className=\"text-muted-foreground flex items-center justify-end gap-1\">\n          <span className=\"mr-0.5\">Less</span>\n          {swatches.map((fill, index) => (\n            <span\n              key={index}\n              className=\"inset-ring-foreground/6 size-3 rounded-[3px] inset-ring\"\n              style={{ backgroundColor: fill }}\n            />\n          ))}\n          <span className=\"ml-0.5\">More</span>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { HeatmapChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}