{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"heatmap","type":"registry:ui","title":"Heatmap","description":"Activity per day as a calendar grid of weeks, like a contribution graph. Plain SVG.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/heatmap.tsx","type":"registry:file","target":"components/dashral/heatmap.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\nexport type HeatmapDatum = {\n  /** \"YYYY-MM-DD\". */\n  date: string\n  value: number\n}\n\ntype HeatmapProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  data: HeatmapDatum[]\n  /** The last day shown, \"YYYY-MM-DD\"; by default the latest day in the data. */\n  endDate?: string\n  /** How many weeks back from endDate. */\n  weeks?: number\n  /** Monday or Sunday at the top of each column. */\n  weekStartsOn?: 0 | 1\n  /** What a value counts, for tooltips and the summary: \"orders\", or [\"order\", \"orders\"] to get \"1 order\" right. */\n  unit?: string | [one: string, other: string]\n  /** BCP 47 locale for month and day names. */\n  locale?: string\n  /** Show the \"Less … More\" scale. */\n  showLegend?: boolean\n}\n\nconst DAY = 86_400_000\nconst CELL = 12\nconst GAP = 3\n/** Opacity of each level; level 0 is the empty track. */\nconst LEVELS = [0, 0.25, 0.45, 0.7, 1]\n\nconst toTime = (day: string) => Date.parse(`${day}T00:00:00Z`)\nconst toDay = (time: number) => new Date(time).toISOString().slice(0, 10)\n\n/**\n * Activity per day as a grid of weeks, like a contribution calendar: darker means more. Each square says its day and\n * value on hover, and the chart is summarized for screen readers. Color it with a text color (text-primary by default).\n */\nfunction Heatmap({\n  data,\n  endDate,\n  weeks = 26,\n  weekStartsOn = 1,\n  unit = \"events\",\n  locale: localeProp,\n  showLegend = true,\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: HeatmapProps) {\n  const { t, locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const [drawn, setDrawn] = React.useState(false)\n  React.useEffect(() => setDrawn(true), [])\n\n  const values = new Map(data.map((d) => [d.date, d.value]))\n  const last = endDate ?? data.map((d) => d.date).sort().at(-1) ?? toDay(Date.now())\n  const lastTime = toTime(last)\n  // Start on the week's first day, so every column is a whole week.\n  const lastWeekday = (new Date(lastTime).getUTCDay() - weekStartsOn + 7) % 7\n  const start = lastTime - (lastWeekday + (weeks - 1) * 7) * DAY\n  const days = Array.from({ length: weeks * 7 }, (_, i) => start + i * DAY).filter((t) => t <= lastTime)\n  const max = Math.max(0, ...days.map((t) => values.get(toDay(t)) ?? 0))\n  const level = (v: number) => (v <= 0 || max === 0 ? 0 : Math.min(4, Math.ceil((v / max) * 4)))\n\n  const dateLabel = new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" })\n  const monthLabel = new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" })\n  const weekdayLabel = new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" })\n  const number = new Intl.NumberFormat(locale)\n  const plural = new Intl.PluralRules(locale)\n  const count = (n: number) => `${number.format(n)} ${typeof unit === \"string\" ? unit : plural.select(n) === \"one\" ? unit[0] : unit[1]}`\n\n  // A month's name above the first column that starts in it.\n  const months: { column: number; label: string }[] = []\n  for (let column = 0; column < weeks; column++) {\n    const time = start + column * 7 * DAY\n    const date = new Date(time)\n    const previous = months.at(-1)\n    if (date.getUTCDate() <= 7 && (!previous || column - previous.column > 2)) months.push({ column, label: monthLabel.format(date) })\n  }\n\n  const total = days.reduce((sum, t) => sum + (values.get(toDay(t)) ?? 0), 0)\n  const busiest = days.reduce((best, t) => ((values.get(toDay(t)) ?? 0) > (values.get(toDay(best)) ?? 0) ? t : best), days[0] ?? lastTime)\n  const summary =\n    ariaLabel ??\n    t(\"heatmapSummary\", { count: count(total), weeks, date: dateLabel.format(new Date(lastTime)) }) +\n      (max > 0 ? t(\"heatmapBusiest\", { date: dateLabel.format(new Date(busiest)), value: number.format(max) }) : \".\")\n\n  const left = 28\n  const top = 16\n  const width = left + weeks * (CELL + GAP)\n  const height = top + 7 * (CELL + GAP)\n\n  return (\n    <div data-slot=\"heatmap\" className={cn(\"grid gap-2 text-primary\", className)} {...props}>\n      <div className=\"overflow-x-auto\">\n        <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role=\"img\" aria-label={summary} className=\"block\">\n          {months.map((m) => (\n            <text key={m.column} x={left + m.column * (CELL + GAP)} y={10} className=\"fill-muted-foreground text-[10px]\">\n              {m.label}\n            </text>\n          ))}\n          {[0, 2, 4].map((row) => (\n            <text key={row} x={0} y={top + row * (CELL + GAP) + CELL - 2} className=\"fill-muted-foreground text-[10px]\">\n              {weekdayLabel.format(new Date(start + row * DAY))}\n            </text>\n          ))}\n          {days.map((time, i) => {\n            const value = values.get(toDay(time)) ?? 0\n            const l = level(value)\n            return (\n              <rect\n                key={time}\n                x={left + Math.floor(i / 7) * (CELL + GAP)}\n                y={top + (i % 7) * (CELL + GAP)}\n                width={CELL}\n                height={CELL}\n                rx={2}\n                fill={l === 0 ? \"var(--muted)\" : \"currentColor\"}\n                className=\"transition-opacity duration-(--duration-considered) ease-enter motion-reduce:transition-none\"\n                style={{ opacity: l === 0 ? 1 : drawn ? LEVELS[l] : 0, transitionDelay: `${Math.floor(i / 7) * 12}ms` }}\n              >\n                <title>{`${count(value)} on ${dateLabel.format(new Date(time))}`}</title>\n              </rect>\n            )\n          })}\n        </svg>\n      </div>\n      {showLegend && (\n        // Only the words take the muted color: the squares keep the chart's own (currentColor).\n        <div className=\"flex items-center justify-end gap-1 text-[11px]\" aria-hidden>\n          <span className=\"me-0.5 text-muted-foreground\">{t(\"less\")}</span>\n          {LEVELS.map((opacity, l) => (\n            <span\n              key={l}\n              className=\"size-3 rounded-[2px]\"\n              style={l === 0 ? { background: \"var(--muted)\" } : { background: \"currentColor\", opacity }}\n            />\n          ))}\n          <span className=\"ms-0.5 text-muted-foreground\">{t(\"more\")}</span>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { Heatmap }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}