{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap-chart",
  "type": "registry:component",
  "title": "Heatmap Chart",
  "description": "A calendar of bins, shaded by how much happened in each.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/panelui/heatmap-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/heatmap-chart/index.tsx",
      "content": "'use client';\n\n/**\n * HeatmapChart — a calendar of bins, shaded by how much happened in each.\n *\n * ```tsx\n * <HeatmapChart data={weeks}>\n *   <HeatmapChart.Header title=\"Contributions\" value=\"1,204\" legend />\n *   <HeatmapChart.YAxis />\n *   <HeatmapChart.XAxis />\n *   <HeatmapChart.Cells />\n *   <HeatmapChart.Tooltip />\n *   <HeatmapChart.Legend />\n * </HeatmapChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's HeatmapChart, so a chart written for\n * one reads the same in the other. What differs is what the web changes: a mouse reads\n * the cell under it, a click or a tap pins the reading, the arrow keys walk the grid,\n * and every day carries its own label for a screen reader.\n *\n * ## A real layout\n *\n * The parts sort themselves into a grid rather than stacking over the plot. The labels\n * and the legend sit beside and below the cells and take up room, and the cells are sized\n * with them accounted for.\n *\n * ## One colour\n *\n * The ramp is one colour at five opacities. A heatmap reads as more and less of one\n * thing, and five hues would read as five different things.\n */\nimport { defineChart, rect, ruleX } from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n  type RefObject,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport {\n  cellAtPoint,\n  defaultCellLabel,\n  deriveLevels,\n  heatmapCells,\n  heatmapGrid,\n  monthLabels,\n  moveCell,\n  normalizeWeekStart,\n  weekdayLabel,\n  type HeatmapCell,\n  type HeatmapColumn,\n  type HeatmapGrid,\n} from './heatmap-calendar';\n\nexport { buildHeatmapCalendar, type HeatmapBin, type HeatmapCell, type HeatmapColumn } from './heatmap-calendar';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type HeatmapLayout = 'fluid' | 'fill';\n\nconst heatmapChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-[auto_minmax(0,1fr)] text-foreground',\n    plot: 'relative col-start-2 row-start-3 rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n    chart: 'absolute! inset-0',\n    xAxis: 'relative col-start-2 row-start-2 h-5 text-xs text-muted-foreground',\n    yAxis: 'relative col-start-1 row-start-3 text-xs text-muted-foreground',\n    axisLabel: 'absolute whitespace-nowrap',\n    ring: 'pointer-events-none absolute col-start-2 row-start-3 self-start justify-self-start rounded-[3px] outline-2 outline-foreground',\n    tooltip:\n      'pointer-events-none absolute col-start-2 row-start-3 self-start justify-self-start rounded-lg border border-border bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground shadow-md',\n    legend: 'col-span-2 col-start-1 row-start-4 flex items-center justify-end gap-1.5 pt-2 text-xs text-muted-foreground',\n    swatch: 'shrink-0',\n    header: 'col-span-2 col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing: 'flex shrink items-center gap-1.5 pt-1 text-xs text-muted-foreground',\n  },\n});\n\n/** Opacity of the base colour at each level. Index 0 is \"nothing\", in its own colour. */\nconst LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1];\n\n/** Room left for the weekday labels when the y-axis does not ask for its own. */\nconst DEFAULT_AXIS_WIDTH = 26;\n\n/** Clearance between the readout and the cell it is reading. */\nconst TOOLTIP_GAP = 6;\n\n/** The grid's rows above this one are too near the top for a readout to sit over the cell. */\nconst FLIP_BELOW_ROW = 2;\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--chart-1)'],\n};\n\nconst ROLES = new Set(['primary', 'info', 'success', 'warning', 'destructive', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']);\n\n/** A theme role, a token by name, or anything else as the CSS colour it already is. */\nconst colorOf = (color: string) => (ROLES.has(color) ? `var(--${color})` : color.startsWith('--') ? `var(${color})` : color);\n\ninterface CellBox {\n  cell: HeatmapCell;\n  x1: number;\n  x2: number;\n  y1: number;\n  y2: number;\n}\n\n/** What the parts have asked for. */\ninterface Slots {\n  cells?: { cornerRadius: number };\n  separator?: { every: 'quarter' | number; color: string; dashArray: string | undefined };\n  axisWidth?: number;\n  tooltip?: true;\n}\n\ninterface HeatmapContextValue {\n  data: readonly HeatmapColumn[];\n  grid: HeatmapGrid;\n  ramp: readonly string[];\n  opacities: readonly number[];\n  cornerRadius: number;\n  weekStartDay: number;\n  activeCell: HeatmapCell | null;\n  formatCellLabel: HeatmapChartProps['formatCellLabel'];\n  /** Set while the keyboard is moving the reading, so the cell can scroll into view. */\n  fromKeyboard: RefObject<boolean>;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n}\n\nconst HeatmapContext = createContext<HeatmapContextValue | null>(null);\n\nfunction useHeatmap(component: string): HeatmapContextValue {\n  const context = useContext(HeatmapContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <HeatmapChart>`);\n  }\n  return context;\n}\n\n/**\n * The cell being read, for something rendered inside the chart. A readout outside it\n * uses `onActiveCellChange`.\n */\nexport function useHeatmapChart() {\n  const { activeCell } = useHeatmap('useHeatmapChart');\n  return { activeCell };\n}\n\nexport interface HeatmapChartProps extends Omit<ComponentProps<'div'>, 'color'> {\n  /** One column per period, with its row bins inside. `buildHeatmapCalendar` makes these from dated counts. */\n  data: readonly HeatmapColumn[];\n  /**\n   * `fluid` draws cells at `binSize`, and the chart is as wide as that needs. Put it in\n   * a horizontal scroller for a full year. `fill` divides the available width between\n   * the columns instead.\n   */\n  layout?: HeatmapLayout;\n  /** Side of one cell in `fluid` layout, in pixels. */\n  binSize?: number;\n  /** Space between cells, in pixels. */\n  gap?: number;\n  /** Corner radius of a cell. */\n  cornerRadius?: number;\n  /** Which weekday is the top row. `0` is Sunday. The labels follow it. */\n  weekStartDay?: number;\n  /** Rows per column. Seven for a calendar, twenty-four for a grid of hours. */\n  rows?: number;\n  /** The four counts at which the ramp steps up. Derived from the data's quartiles when omitted. */\n  levels?: readonly number[];\n  /** Five colours, empty first, that replace the derived ramp. */\n  levelColors?: readonly string[];\n  /**\n   * The colour of the busiest cells, with the quieter levels the same colour at lower\n   * opacity. A theme role, a token by name, or any CSS colour. Defaults to `chart-1`.\n   */\n  color?: string;\n  /** The colour of a cell with nothing in it. Takes the same values as `color`. Defaults to the muted surface. */\n  emptyColor?: string;\n  /** Opacity of the base colour at each of the five levels, quietest first. Ignored when `levelColors` is given. */\n  levelOpacity?: readonly number[];\n  /** Milliseconds for the reveal on mount. `0` draws the chart at once. */\n  animationDuration?: number;\n  /** Opacity of every cell other than the one being read. */\n  inactiveOpacity?: number;\n  /**\n   * The cell being read as it changes, and `null` when there is none. For a readout outside\n   * the chart. Reading needs `HeatmapChart.Tooltip`; `formatLabel={() => ''}` hides its line.\n   */\n  onActiveCellChange?: (cell: HeatmapCell | null) => void;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /**\n   * What a screen reader says for one cell, in the list of days and as the keyboard\n   * reaches it. Return nothing to leave a cell out of the list. Defaults to its count and\n   * date, and days outside the calendar's range are left out.\n   */\n  formatCellLabel?: (cell: HeatmapCell) => string | null | undefined;\n}\n\nconst HeatmapChartRoot = forwardRef<HTMLDivElement, HeatmapChartProps & RenderProp<'div'>>(function HeatmapChartRoot(\n  {\n    data,\n    layout = 'fluid',\n    binSize = 12,\n    gap = 3,\n    cornerRadius = 2,\n    weekStartDay: weekStartDayProp = 0,\n    rows = 7,\n    levels,\n    levelColors,\n    color,\n    emptyColor,\n    levelOpacity,\n    animationDuration = 900,\n    inactiveOpacity = 1,\n    onActiveCellChange,\n    label,\n    formatCellLabel,\n    className,\n    style,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = heatmapChartVariants();\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [rootWidth, setRootWidth] = useState<number | null>(null);\n  const [slots, setSlots] = useState<Slots>({});\n  const [activeAt, setActiveAt] = useState<{ column: number; row: number } | null>(null);\n  const pinned = useRef(false);\n  const fromKeyboard = useRef(false);\n  const revealed = useRef(false);\n\n  useEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n    // The content box, so padding on the root does not widen a `fill` grid past it.\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) setRootWidth(Math.floor(entry.contentRect.width));\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setSlot = useCallback<HeatmapContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => (current[slot] === value ? current : { ...current, [slot]: value })),\n    []\n  );\n\n  const weekStartDay = normalizeWeekStart(weekStartDayProp);\n  const axisWidth = slots.axisWidth ?? 0;\n  const measured = layout === 'fluid' || rootWidth !== null;\n  const grid = useMemo(\n    () => heatmapGrid(data.length, rows, rootWidth === null ? null : rootWidth - axisWidth, { layout, binSize, gap }),\n    [data.length, rows, rootWidth, axisWidth, layout, binSize, gap]\n  );\n\n  const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);\n  const cells = useMemo(() => heatmapCells(data, rows, thresholds), [data, rows, thresholds]);\n\n  const base = color ? colorOf(color) : 'var(--chart-1)';\n  const empty = emptyColor ? colorOf(emptyColor) : 'var(--muted)';\n  const ramp = useMemo(() => (levelColors ? [...levelColors] : [empty, base, base, base, base]), [levelColors, empty, base]);\n  // A colour somebody chose on purpose is not dimmed, so a supplied ramp is all ones.\n  const opacities = useMemo(\n    () => (levelColors ? levelColors.map(() => 1) : [...(levelOpacity ?? LEVEL_OPACITY)]),\n    [levelColors, levelOpacity]\n  );\n\n  const activeCell = useMemo(() => {\n    // Out of the grid's rows, the flat lookup below would land on some other cell.\n    if (!activeAt || activeAt.row >= rows) return null;\n    return cells[activeAt.column * rows + activeAt.row] ?? null;\n  }, [activeAt, cells, rows]);\n\n  const activeRef = useRef(activeCell);\n  const onChangeRef = useRef(onActiveCellChange);\n  // After commit, so a render React throws away leaves no reading the handlers would act on.\n  useLayoutEffect(() => {\n    activeRef.current = activeCell;\n    onChangeRef.current = onActiveCellChange;\n  });\n\n  const setActive = useCallback((at: { column: number; row: number } | null) => {\n    const current = activeRef.current;\n    if (current?.column === at?.column && current?.row === at?.row) return;\n    setActiveAt(at);\n  }, []);\n\n  // Reported by value, because data rebuilt on every render makes a new cell each time\n  // and a caller that keeps the cell would loop. New data under the same cell is news,\n  // and so is the cell no longer existing.\n  const activeId = activeCell\n    ? `${activeCell.column}:${activeCell.row}:${activeCell.count}:${activeCell.level}:${activeCell.date?.getTime()}`\n    : '';\n  const reportedId = useRef('');\n  useEffect(() => {\n    if (!activeCell) {\n      pinned.current = false;\n      setActiveAt(null);\n    }\n    if (reportedId.current === activeId) return;\n    reportedId.current = activeId;\n    onChangeRef.current?.(activeCell);\n  }, [activeId]);\n\n  const { cells: cellsSlot, separator, tooltip } = slots;\n  const reads = tooltip === true && cells.length > 0;\n\n  // The dimming is the only thing in the drawing that follows the reading. Leaving it\n  // out of the definition otherwise keeps a hover from rebuilding every cell.\n  const dimFrom = inactiveOpacity < 1 ? activeCell : null;\n\n  const definition = useMemo(() => {\n    const step = grid.size + grid.gap;\n    const boxes = cells.map<CellBox>((cell) => ({\n      cell,\n      x1: cell.column * step,\n      x2: cell.column * step + grid.size,\n      y1: cell.row * step,\n      y2: cell.row * step + grid.size,\n    }));\n    const radius = cellsSlot?.cornerRadius ?? cornerRadius;\n    const isDimmed = (box: CellBox) => dimFrom !== null && (box.cell.column !== dimFrom.column || box.cell.row !== dimFrom.row);\n    const marks = (dimmed: boolean) =>\n      ramp.flatMap((fill, level) => {\n        const members = boxes.filter((box) => box.cell.level === level && isDimmed(box) === dimmed);\n        if (!members.length) return [];\n        return [\n          rect(members, {\n            id: `level-${level}-${dimmed ? 'dim' : 'full'}`,\n            key: (box: CellBox) => `${box.cell.column}:${box.cell.row}`,\n            x1: 'x1',\n            x2: 'x2',\n            y1: 'y1',\n            y2: 'y2',\n            fill,\n            fillOpacity: (opacities[level] ?? 1) * (dimmed ? inactiveOpacity : 1),\n            radius,\n          }),\n        ];\n      });\n\n    const interval = separator ? (separator.every === 'quarter' ? 13 : separator.every) : 0;\n    const ruled: number[] = [];\n    if (interval > 0) {\n      for (let column = interval; column < grid.columns; column += interval) {\n        ruled.push(column * step - grid.gap / 2);\n      }\n    }\n\n    return defineChart({\n      theme: THEME,\n      marks: [\n        ...(ruled.length\n          ? [ruleX(ruled, { x: (value: number) => value, stroke: separator?.color, strokeWidth: 1, strokeDasharray: separator?.dashArray })]\n          : []),\n        ...(cellsSlot ? [...marks(false), ...marks(true)] : []),\n      ],\n      scales: {\n        x: { scale: scaleLinear([0, grid.width], [0, 1]), axis: false as const, grid: false as const },\n        y: { scale: scaleLinear([grid.height, 0], [0, 1]), axis: false as const, grid: false as const },\n      },\n      guides: false as const,\n      margin: 0,\n      focusRing: false,\n      keyboard: false,\n      pointer: false,\n      motion: false as const,\n    });\n  }, [cells, grid, ramp, opacities, cornerRadius, inactiveOpacity, cellsSlot, separator, dimFrom]);\n\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 0 } }), []);\n\n  // One clip wiping left to right, rather than an animation per cell: columns arrive in\n  // order, and a year costs one animation instead of three hundred and sixty-five.\n  useLayoutEffect(() => {\n    const node = plotRef.current;\n    if (!node || revealed.current || !measured || grid.width <= 0 || !cellsSlot) return;\n    revealed.current = true;\n    if (animationDuration <= 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n    node.animate([{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0 0 0)' }], {\n      duration: animationDuration,\n      easing: 'cubic-bezier(0.33, 1, 0.68, 1)',\n    });\n  }, [measured, grid.width, cellsSlot, animationDuration]);\n\n  const atPointer = (event: ReactMouseEvent<HTMLDivElement>) => {\n    const bounds = event.currentTarget.getBoundingClientRect();\n    return cellAtPoint(event.clientX - bounds.left, event.clientY - bounds.top, grid);\n  };\n\n  const summary = label ?? `Heatmap chart, ${data.length} columns`;\n  const spokenCells = cells.flatMap((cell) => {\n    const custom = formatCellLabel?.(cell);\n    if (formatCellLabel && !custom) return [];\n    // A cell outside the calendar's range is a place in the grid, with nothing to read.\n    if (!custom && !cell.date && cell.count <= 0) return [];\n    return [{ key: `${cell.column}:${cell.row}`, text: custom ?? defaultCellLabel(cell) }];\n  });\n\n  const context = useMemo<HeatmapContextValue>(\n    () => ({ data, grid, ramp, opacities, cornerRadius, weekStartDay, activeCell, formatCellLabel, fromKeyboard, setSlot }),\n    [data, grid, ramp, opacities, cornerRadius, weekStartDay, activeCell, formatCellLabel, setSlot]\n  );\n\n  const assignRoot = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n      if (typeof ref === 'function') ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref]\n  );\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    ref: assignRoot,\n    props: mergeProps(\n      {\n        'data-slot': 'heatmap-chart',\n        className: slotClasses.root({ className }),\n        // A `fluid` grid wider than the space it was given overflows as one block, so\n        // a horizontal scroller around the chart scrolls all of it, the legend included.\n        style: { ...style, minWidth: layout === 'fluid' ? axisWidth + grid.width : undefined },\n        children: (\n          <>\n            <ul aria-label={summary} className=\"sr-only\">\n              {spokenCells.map(({ key, text }) => (\n                <li key={key}>{text}</li>\n              ))}\n            </ul>\n            <div\n              ref={plotRef}\n              data-slot=\"heatmap-chart-plot\"\n              role=\"group\"\n              aria-label={summary}\n              tabIndex={reads ? 0 : undefined}\n              className={slotClasses.plot()}\n              style={\n                measured\n                  ? { width: grid.width, height: grid.height }\n                  : { width: '100%', aspectRatio: `${Math.max(data.length, 1)} / ${rows}` }\n              }\n              onPointerMove={(event) => {\n                if (!reads || event.pointerType !== 'mouse') return;\n                if (pinned.current && activeRef.current) return;\n                setActive(atPointer(event));\n              }}\n              onPointerLeave={() => {\n                if (!pinned.current || !activeRef.current) setActive(null);\n              }}\n              // A click rather than a pointer down, so that the start of a swipe across\n              // a scrolling year does not read the cell it started on.\n              onClick={(event) => {\n                if (!reads) return;\n                const at = atPointer(event);\n                const current = activeRef.current;\n                const same = current?.column === at?.column && current?.row === at?.row;\n                const clearing = !at || (same && pinned.current);\n                pinned.current = !clearing;\n                setActive(clearing ? null : at);\n              }}\n              onKeyDown={(event) => {\n                if (!reads) return;\n                if (event.key === 'Escape') {\n                  pinned.current = false;\n                  setActive(null);\n                  return;\n                }\n                const current = activeRef.current;\n                // The first key lands on the first day, rather than moving away from it.\n                const first = cells.find((cell) => cell.date) ?? { column: 0, row: 0 };\n                const moved = moveCell(current ?? first, event.key, grid);\n                const next = moved && !current ? first : moved;\n                if (!next) return;\n                event.preventDefault();\n                pinned.current = true;\n                fromKeyboard.current = true;\n                setActive({ column: next.column, row: next.row });\n              }}\n            >\n              {measured && grid.width > 0 ? (\n                <Chart\n                  definition={definition}\n                  renderer={renderer}\n                  ariaLabel={summary}\n                  aspectRatio={grid.width / grid.height}\n                  initialWidth={grid.width}\n                  className={slotClasses.chart()}\n                />\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <HeatmapContext.Provider value={context}>{element}</HeatmapContext.Provider>;\n});\nHeatmapChartRoot.displayName = 'HeatmapChart';\n\nexport interface HeatmapChartCellsProps {\n  /** Corner radius of a cell. Falls back to the chart's. */\n  cornerRadius?: number;\n}\n\n/**\n * The grid itself. Every row of every column is drawn, including the empty ones, because\n * a calendar with holes in it stops being a calendar.\n */\nfunction HeatmapChartCells({ cornerRadius }: HeatmapChartCellsProps) {\n  const { setSlot, cornerRadius: fallback } = useHeatmap('HeatmapChart.Cells');\n  const radius = cornerRadius ?? fallback;\n  const value = useMemo(() => ({ cornerRadius: radius }), [radius]);\n  useEffect(() => {\n    setSlot('cells', value);\n    return () => setSlot('cells', undefined);\n  }, [value, setSlot]);\n  return null;\n}\nHeatmapChartCells.displayName = 'HeatmapChart.Cells';\n\nexport interface HeatmapChartSeparatorProps {\n  /** `quarter` draws a rule every thirteen columns, and a number one every that many columns. */\n  every?: 'quarter' | number;\n  /** A theme role, a token by name, or any CSS colour. Defaults to the border colour. */\n  color?: string;\n  /** Dash pattern, such as `\"2,4\"`. Omit it for a solid rule. */\n  dashArray?: string;\n}\n\n/** Vertical rules grouping the columns: quarters, months, sprints. */\nfunction HeatmapChartSeparator({\n  every = 'quarter',\n  color,\n  dashArray,\n}: HeatmapChartSeparatorProps) {\n  const { setSlot } = useHeatmap('HeatmapChart.Separator');\n  const stroke = color ? colorOf(color) : 'var(--border)';\n  const value = useMemo(() => ({ every, color: stroke, dashArray }), [every, stroke, dashArray]);\n  useEffect(() => {\n    setSlot('separator', value);\n    return () => setSlot('separator', undefined);\n  }, [value, setSlot]);\n  return null;\n}\nHeatmapChartSeparator.displayName = 'HeatmapChart.Separator';\n\nexport interface HeatmapChartXAxisProps extends ComponentProps<'div'> {\n  /** Label a column, given the first dated bin in it. Return an empty string to leave it unlabelled. */\n  formatLabel?: (date: Date, column: number) => string;\n  /** Column labels, left to right, for a grid whose columns are not weeks. Overrides the month names. */\n  labels?: readonly string[];\n}\n\n/** Month labels above the grid, one where the month changes. A grid that passes `labels` gets one over each column. */\nfunction HeatmapChartXAxis({\n  formatLabel,\n  labels: given,\n  className,\n  render,\n  ...props\n}: HeatmapChartXAxisProps & RenderProp<'div'>) {\n  const { data, grid } = useHeatmap('HeatmapChart.XAxis');\n  const slots = heatmapChartVariants();\n  const step = grid.size + grid.gap;\n  const labels = given\n    ? given.slice(0, data.length).flatMap((label, column) => (label ? [{ column, label }] : []))\n    : monthLabels(data, formatLabel);\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'heatmap-chart-x-axis',\n        'aria-hidden': true,\n        className: slots.xAxis({ className }),\n        children: labels.map(({ column, label }) => (\n          <span key={`${column}-${label}`} className={slots.axisLabel()} style={{ left: column * step }}>\n            {label}\n          </span>\n        )),\n      },\n      props\n    ),\n  });\n}\nHeatmapChartXAxis.displayName = 'HeatmapChart.XAxis';\n\nexport interface HeatmapChartYAxisProps extends ComponentProps<'div'> {\n  /** Width reserved for the labels, in pixels. The grid is sized around it. */\n  width?: number;\n  /** Which rows get a label. Every other row is the usual choice. */\n  tickFilter?: 'all' | 'odd' | 'even';\n  /** `initial` is the single letter, and `full` the abbreviated name. */\n  labelFormat?: 'initial' | 'full';\n  /** Row labels, top to bottom, used as they are. For localised weekdays, or rows that are not days. */\n  labels?: readonly string[];\n}\n\n/** Weekday labels down the left of the grid. */\nfunction HeatmapChartYAxis({\n  width = DEFAULT_AXIS_WIDTH,\n  tickFilter = 'odd',\n  labelFormat = 'full',\n  labels,\n  className,\n  style,\n  render,\n  ...props\n}: HeatmapChartYAxisProps & RenderProp<'div'>) {\n  const { grid, weekStartDay, setSlot } = useHeatmap('HeatmapChart.YAxis');\n  const slots = heatmapChartVariants();\n  const step = grid.size + grid.gap;\n  // Before the first paint, so the cells are never sized for a chart without this axis.\n  useLayoutEffect(() => {\n    setSlot('axisWidth', width);\n    return () => setSlot('axisWidth', undefined);\n  }, [width, setSlot]);\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'heatmap-chart-y-axis',\n        'aria-hidden': true,\n        className: slots.yAxis({ className }),\n        style: { ...style, width, height: grid.height },\n        children: Array.from({ length: grid.rows }, (_unused, row) => {\n          if (tickFilter === 'odd' && row % 2 === 0) return null;\n          if (tickFilter === 'even' && row % 2 === 1) return null;\n          return (\n            <span\n              key={row}\n              className={slots.axisLabel()}\n              // Centred on the cell, so a label reads as belonging to the row beside it.\n              style={{ top: row * step + grid.size / 2, transform: 'translateY(-50%)' }}\n            >\n              {labels ? (labels[row] ?? '') : weekdayLabel(row, weekStartDay, labelFormat)}\n            </span>\n          );\n        }),\n      },\n      props\n    ),\n  });\n}\nHeatmapChartYAxis.displayName = 'HeatmapChart.YAxis';\n\nexport interface HeatmapChartTooltipProps {\n  /** The line shown for a cell, including a cell with no date. Defaults to the count and the date. */\n  formatLabel?: (cell: HeatmapCell) => string;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/**\n * What makes the grid readable. A mouse reads the cell under it, a click or a tap pins\n * the reading, the same cell again clears it, and the arrow keys walk the grid.\n */\nfunction HeatmapChartTooltip({\n  formatLabel,\n  className,\n}: HeatmapChartTooltipProps) {\n  const { grid, activeCell, formatCellLabel, fromKeyboard, setSlot } = useHeatmap('HeatmapChart.Tooltip');\n  const slots = heatmapChartVariants();\n  const node = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState<{ width: number; height: number } | null>(null);\n\n  useEffect(() => {\n    setSlot('tooltip', true);\n    return () => setSlot('tooltip', undefined);\n  }, [setSlot]);\n\n  const text = activeCell ? (formatLabel ? formatLabel(activeCell) : defaultCellLabel(activeCell)) : '';\n  // A hidden line is still read out: an empty `formatLabel` only silences the drawing.\n  // A `formatCellLabel` that returns nothing has said this cell is not worth reading.\n  const spoken = !activeCell\n    ? ''\n    : formatCellLabel\n      ? formatCellLabel(activeCell) || text\n      : text || defaultCellLabel(activeCell);\n\n  // Sized by its contents and placed once it knows its size, because a day and an\n  // amount in another language can be twice as wide as the default.\n  useLayoutEffect(() => {\n    if (!node.current) return;\n    const { offsetWidth, offsetHeight } = node.current;\n    setSize((current) =>\n      current?.width === offsetWidth && current?.height === offsetHeight ? current : { width: offsetWidth, height: offsetHeight }\n    );\n  }, [text]);\n\n  // Only for the keyboard: a mouse is already where it is looking.\n  useEffect(() => {\n    if (!fromKeyboard.current) return;\n    fromKeyboard.current = false;\n    ringRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });\n  }, [activeCell, fromKeyboard]);\n\n  const step = grid.size + grid.gap;\n  const left = activeCell ? activeCell.column * step : 0;\n  const top = activeCell ? activeCell.row * step : 0;\n  const width = size?.width ?? 0;\n  const height = size?.height ?? 0;\n  return (\n    <>\n      <div role=\"status\" className=\"sr-only\">\n        {spoken}\n      </div>\n      {activeCell ? (\n        <div\n          ref={ringRef}\n          aria-hidden\n          data-slot=\"heatmap-chart-active-cell\"\n          className={slots.ring()}\n          style={{ left, top, width: grid.size, height: grid.size }}\n        />\n      ) : null}\n      {activeCell && text ? (\n        <div\n          ref={node}\n          aria-hidden\n          data-slot=\"heatmap-chart-tooltip\"\n          className={slots.tooltip({ className })}\n          style={{\n            left: Math.max(0, Math.min(grid.width - width, left + grid.size / 2 - width / 2)),\n            top: activeCell.row < FLIP_BELOW_ROW ? top + grid.size + TOOLTIP_GAP : top - height - TOOLTIP_GAP,\n            visibility: size ? 'visible' : 'hidden',\n          }}\n        >\n          {text}\n        </div>\n      ) : null}\n    </>\n  );\n}\nHeatmapChartTooltip.displayName = 'HeatmapChart.Tooltip';\n\n/** The ramp as a row of swatches, between its two labels. */\nfunction Ramp({ lessLabel, moreLabel, size }: { lessLabel: string; moreLabel: string; size: number }) {\n  const { ramp, opacities, cornerRadius } = useHeatmap('HeatmapChart.Legend');\n  const slots = heatmapChartVariants();\n  return (\n    <>\n      <span>{lessLabel}</span>\n      {ramp.map((fill, level) => (\n        <span\n          key={level}\n          aria-hidden\n          className={slots.swatch()}\n          style={{ width: size, height: size, borderRadius: cornerRadius, background: fill, opacity: opacities[level] ?? 1 }}\n        />\n      ))}\n      <span>{moreLabel}</span>\n    </>\n  );\n}\n\nexport interface HeatmapChartLegendProps extends ComponentProps<'div'> {\n  /** Text at the low end of the ramp. */\n  lessLabel?: string;\n  /** Text at the high end. */\n  moreLabel?: string;\n  /** Side of a swatch, in pixels. Defaults to the chart's cell size, and at least 10. */\n  swatchSize?: number;\n}\n\n/** The key under the grid: `Less`, the five steps of the ramp, `More`. */\nfunction HeatmapChartLegend({\n  lessLabel = 'Less',\n  moreLabel = 'More',\n  swatchSize,\n  className,\n  render,\n  ...props\n}: HeatmapChartLegendProps & RenderProp<'div'>) {\n  const { grid } = useHeatmap('HeatmapChart.Legend');\n  const slots = heatmapChartVariants();\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'heatmap-chart-legend',\n        className: slots.legend({ className }),\n        children: <Ramp lessLabel={lessLabel} moreLabel={moreLabel} size={swatchSize ?? Math.max(grid.size, 10)} />,\n      },\n      props\n    ),\n  });\n}\nHeatmapChartLegend.displayName = 'HeatmapChart.Legend';\n\nexport interface HeatmapChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the grid is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a total, the cell being read. */\n  caption?: ReactNode;\n  /** Draw the ramp along the trailing edge. For a grid that scrolls, where a legend under the cells would scroll away. */\n  legend?: boolean;\n  /** Text at the low end of the ramp, when `legend` is set. */\n  lessLabel?: string;\n  /** Text at the high end. */\n  moreLabel?: string;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the grid. The value is not derived here: take it from\n * `onActiveCellChange` and pass the formatted string down, so one header can show a total\n * when nothing is being read and a day's own count when something is.\n */\nfunction HeatmapChartHeader({\n  title,\n  value,\n  caption,\n  legend = false,\n  lessLabel = 'Less',\n  moreLabel = 'More',\n  className,\n  children,\n  render,\n  ...props\n}: HeatmapChartHeaderProps & RenderProp<'div'>) {\n  const { grid } = useHeatmap('HeatmapChart.Header');\n  const slots = heatmapChartVariants();\n  const trailing =\n    children ?? (legend ? <Ramp lessLabel={lessLabel} moreLabel={moreLabel} size={Math.max(Math.min(grid.size, 12), 8)} /> : null);\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'heatmap-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value !== undefined && value !== null ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nHeatmapChartHeader.displayName = 'HeatmapChart.Header';\n\nexport const HeatmapChart = Object.assign(HeatmapChartRoot, {\n  Header: HeatmapChartHeader,\n  Cells: HeatmapChartCells,\n  Separator: HeatmapChartSeparator,\n  XAxis: HeatmapChartXAxis,\n  YAxis: HeatmapChartYAxis,\n  Tooltip: HeatmapChartTooltip,\n  Legend: HeatmapChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  HeatmapChartHeader,\n  HeatmapChartCells,\n  HeatmapChartSeparator,\n  HeatmapChartXAxis,\n  HeatmapChartYAxis,\n  HeatmapChartTooltip,\n  HeatmapChartLegend,\n};\n"
    },
    {
      "path": "components/panelui/heatmap-chart/heatmap-calendar.ts",
      "type": "registry:component",
      "target": "@components/panelui/heatmap-chart/heatmap-calendar.ts",
      "content": "/**\n * The arithmetic behind HeatmapChart: bucketing dated counts into weeks, the ramp's\n * thresholds, the grid's geometry, and walking it from the keyboard.\n */\n\nconst DAYS_IN_WEEK = 7;\n\nconst WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const;\nconst MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const;\n\n/** One bin inside a column, usually a single day. */\nexport interface HeatmapBin {\n  /** Row index within the column, from `0`. */\n  bin: number;\n  /** How much happened. The ramp is derived from these across the whole chart. */\n  count: number;\n  /** The day this bin stands for. Used by the axis labels and the tooltip. */\n  date?: Date;\n}\n\n/** One column, usually a week. Missing bins are drawn as empty cells. */\nexport interface HeatmapColumn {\n  /** Column index across the chart. */\n  bin: number;\n  bins: HeatmapBin[];\n}\n\n/** A cell resolved to its place in the grid, as the tooltip and the labels receive it. */\nexport interface HeatmapCell {\n  column: number;\n  row: number;\n  count: number;\n  /** Which step of the ramp it is drawn in, `0` for empty to `4` for the busiest. */\n  level: number;\n  date?: Date;\n}\n\n/** The measured grid, in pixels. */\nexport interface HeatmapGrid {\n  size: number;\n  gap: number;\n  columns: number;\n  rows: number;\n  width: number;\n  height: number;\n}\n\n/** Any whole number of days, wrapped into `0` to `6`. Anything else is Sunday. */\nexport function normalizeWeekStart(day: number): number {\n  if (!Number.isFinite(day)) return 0;\n  return ((Math.trunc(day) % DAYS_IN_WEEK) + DAYS_IN_WEEK) % DAYS_IN_WEEK;\n}\n\nconst startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());\nconst dayKey = (date: Date) => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n\n/**\n * Builds a calendar of columns from a flat list of dated counts. Days with no entry are\n * still cells, and the days before `start` and after `end` that complete a week are cells\n * with no date, so every row lines up with one weekday for the whole chart.\n */\nexport function buildHeatmapCalendar(\n  entries: readonly { date: Date; count: number }[],\n  options: { start?: Date; end?: Date; weekStartDay?: number } = {}\n): HeatmapColumn[] {\n  const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);\n  if (!entries.length && !options.start) return [];\n\n  // One pass rather than a spread, which throws on a long enough list of entries.\n  let first = Infinity;\n  let last = -Infinity;\n  for (const { date } of entries) {\n    const time = date.getTime();\n    if (time < first) first = time;\n    if (time > last) last = time;\n  }\n  // Both bounds at local midnight: a raw `end` would drop its own day whenever it\n  // carries a time earlier than the cell being tested.\n  const start = startOfDay(options.start ?? new Date(first));\n  const end = startOfDay(options.end ?? (entries.length ? new Date(last) : start));\n\n  const byDay = new Map<string, number>();\n  for (const { date, count } of entries) {\n    const key = dayKey(date);\n    byDay.set(key, (byDay.get(key) ?? 0) + count);\n  }\n\n  // Each day is built from the calendar rather than by stepping the last one: where a\n  // clock change skips midnight, a stepped date would keep the shifted hour from then on\n  // and fall past `end` a day early.\n  const lead = (start.getDay() - weekStartDay + DAYS_IN_WEEK) % DAYS_IN_WEEK;\n  const dayAt = (offset: number) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + offset - lead);\n\n  const columns: HeatmapColumn[] = [];\n  let column: HeatmapBin[] = [];\n  for (let offset = 0; dayAt(offset) <= end || column.length; offset += 1) {\n    const date = dayAt(offset);\n    const row = (date.getDay() - weekStartDay + DAYS_IN_WEEK) % DAYS_IN_WEEK;\n    const inRange = date >= start && date <= end;\n    column.push({ bin: row, count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0, date: inRange ? date : undefined });\n    if (row === DAYS_IN_WEEK - 1) {\n      columns.push({ bin: columns.length, bins: column });\n      column = [];\n    }\n  }\n  return columns;\n}\n\n/**\n * Four thresholds from the data's own quartiles, so single digits and thousands both use\n * the whole ramp. Only non-zero counts are measured: zero is its own level, and counting\n * it would drag every threshold down on a sparse chart.\n */\nexport function deriveLevels(data: readonly HeatmapColumn[]): number[] {\n  const counts = data.flatMap(({ bins }) => bins.flatMap(({ count }) => (count > 0 ? [count] : []))).sort((a, b) => a - b);\n  if (!counts.length) return [1, 2, 3, 4];\n  const at = (share: number) => counts[Math.min(counts.length - 1, Math.floor(counts.length * share))] ?? 0;\n  const thresholds = [Math.min(1, counts[0] ?? 1), at(0.25), at(0.5), at(0.75)];\n  // Ties would collapse the ramp, so each is nudged past the one below it.\n  for (let index = 1; index < thresholds.length; index += 1) {\n    const below = thresholds[index - 1] ?? 0;\n    if ((thresholds[index] ?? 0) <= below) thresholds[index] = below + 1;\n  }\n  return thresholds;\n}\n\n/** The step of the ramp a count is drawn in. */\nexport function levelFor(count: number, thresholds: readonly number[]): number {\n  if (count <= 0) return 0;\n  let level = 1;\n  thresholds.forEach((threshold, index) => {\n    if (index > 0 && count >= threshold) level = index + 1;\n  });\n  return Math.min(level, 4);\n}\n\n/** Every cell the grid draws, in column order. A missing bin is an empty cell, never a hole. */\nexport function heatmapCells(data: readonly HeatmapColumn[], rows: number, thresholds: readonly number[]): HeatmapCell[] {\n  return data.flatMap(({ bins }, column) =>\n    Array.from({ length: rows }, (_unused, row) => {\n      const bin = bins.find((candidate) => candidate.bin === row);\n      const count = bin?.count ?? 0;\n      return { column, row, count, level: levelFor(count, thresholds), date: bin?.date };\n    })\n  );\n}\n\n/**\n * The grid's geometry. `fill` divides the available width between the columns, and until\n * that width is known (`null`), or under `fluid`, a cell is `binSize`. In a space too narrow for\n * the gaps, the gaps give way first, so `fill` never overflows what it was given.\n */\nexport function heatmapGrid(\n  columns: number,\n  rows: number,\n  available: number | null,\n  { layout, binSize, gap: wantedGap }: { layout: 'fluid' | 'fill'; binSize: number; gap: number }\n): HeatmapGrid {\n  if (!columns) return { size: 0, gap: wantedGap, columns: 0, rows, width: 0, height: 0 };\n  if (layout === 'fluid' || available === null) {\n    const width = columns * binSize + (columns - 1) * wantedGap;\n    return { size: binSize, gap: wantedGap, columns, rows, width, height: rows * binSize + (rows - 1) * wantedGap };\n  }\n  const room = Math.max(0, available);\n  const gap = columns > 1 ? Math.min(wantedGap, Math.max(0, (room - columns) / (columns - 1))) : wantedGap;\n  const size = (room - (columns - 1) * gap) / columns;\n  return { size, gap, columns, rows, width: columns * size + (columns - 1) * gap, height: rows * size + (rows - 1) * gap };\n}\n\n/** The cell at a point in the grid. A gap belongs to the cell before it, so the reading never drops out between two. */\nexport function cellAtPoint(x: number, y: number, { size, gap, columns, rows }: HeatmapGrid): { column: number; row: number } | null {\n  const step = size + gap;\n  if (step <= 0) return null;\n  const column = Math.floor(x / step);\n  const row = Math.floor(y / step);\n  return column >= 0 && column < columns && row >= 0 && row < rows ? { column, row } : null;\n}\n\n/** Where an arrow, Home or End key lands from a cell. It stops at the edges rather than wrapping, as a grid does. */\nexport function moveCell(\n  { column, row }: { column: number; row: number },\n  key: string,\n  { columns, rows }: Pick<HeatmapGrid, 'columns' | 'rows'>\n): { column: number; row: number } | null {\n  switch (key) {\n    case 'ArrowRight':\n      return { column: Math.min(columns - 1, column + 1), row };\n    case 'ArrowLeft':\n      return { column: Math.max(0, column - 1), row };\n    case 'ArrowDown':\n      return { column, row: Math.min(rows - 1, row + 1) };\n    case 'ArrowUp':\n      return { column, row: Math.max(0, row - 1) };\n    case 'Home':\n      return { column: 0, row };\n    case 'End':\n      return { column: columns - 1, row };\n    default:\n      return null;\n  }\n}\n\n/**\n * The month labels: one where the month changes, because months are not the same length.\n * The first month is usually a stub of a week or two, and is dropped when its label would\n * run into the next.\n */\nexport function monthLabels(\n  data: readonly HeatmapColumn[],\n  formatLabel?: (date: Date, column: number) => string\n): { column: number; label: string }[] {\n  const labels: { column: number; label: string }[] = [];\n  let lastMonth = -1;\n  data.forEach(({ bins }, column) => {\n    const date = bins.find((bin) => bin.date)?.date;\n    if (!date || date.getMonth() === lastMonth) return;\n    lastMonth = date.getMonth();\n    const label = formatLabel ? formatLabel(date, column) : (MONTHS[lastMonth] ?? '');\n    if (label) labels.push({ column, label });\n  });\n  const [first, second] = labels;\n  if (first && second && second.column - first.column < 3) labels.shift();\n  return labels;\n}\n\n/** A row's weekday from where the week starts, as its initial or its short name. */\nexport function weekdayLabel(row: number, weekStartDay: number, format: 'initial' | 'full'): string {\n  const name = WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK] ?? '';\n  return format === 'initial' ? name.slice(0, 1) : name.slice(0, 3);\n}\n\n/** A cell in words. A cell with no date has a count and nothing else to say about it. */\nexport function defaultCellLabel({ count, date }: HeatmapCell): string {\n  if (!date) return `${count}`;\n  return `${count} ${count === 1 ? 'contribution' : 'contributions'} on ${MONTHS[date.getMonth()]} ${date.getDate()}`;\n}\n"
    }
  ]
}
