{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dot-matrix",
  "title": "Dot matrix",
  "description": "A grid of dots that grow with the value, such as payments per weekday and hour, with a now cell, keyboard reading and a table for screen readers.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/dot-matrix/dot-matrix.tsx",
      "content": "// A dot matrix: one dot per cell of a rows × columns grid (days × hours, say), where the dot grows and darkens\n// with the value, so the pattern reads without colour. One cell can be marked as now with the --primary signal.\n// Rows are named at the left and columns along the bottom, as many as fit.\n//\n// How it works: the grid is plain CSS grid with square cells, so its height follows its width and nothing jumps\n// while it is measured. Values fall into five levels (0 plus four steps, quartiles of the largest value unless\n// `levels` sets the steps) and each level is a fixed scale and opacity of the same INK dot. Which cell is under the\n// pointer is worked out from the position, so a mouse, a pen and a finger scrubbing across the grid all behave the\n// same, and the page still scrolls up and down under a finger.\n//\n// Screen readers: the grid is one image named by `label`, with a hint, and one Tab stop. Arrow keys walk the cells\n// in both directions, Home and End go to the ends of the row, Ctrl+Home and Ctrl+End to the corners, and Escape\n// lets go. Each step is read once (\"Thu 14:00–15:00, now: 3 payments\"). The whole grid is in a hidden table.\n//\n// Motion: on first view the dots grow in column by column (all done in under 600 ms); new values glide in 160 ms.\n// Only transform and opacity move. Under reduced motion every dot is drawn at its size at once.\nimport {\n  Fragment,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartTooltip, SrTable } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, INK, formatValue, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type DotMatrixLabels = {\n  /** Added to the current cell's name in the tooltip and when read: \"Thu 14:00–15:00 · now\". Default \"now\". */\n  current?: string\n  /** The first column of the screen reader table, naming the rows. Default \"Row\". */\n  row?: string\n  /** Read after the chart's name. Default \"Use the arrow keys to read each cell.\" */\n  hint?: string\n  /** The word after a value, muted in the tooltip and read aloud: (v) => v === 1 ? \"payment\" : \"payments\". Default none. */\n  unit?: (value: number) => string\n  /** A quiet line under the figure in the tooltip, such as \"Over 4 weeks\". Default none. */\n  note?: string\n  /** Read while loading. Default \"Loading\". */\n  loading?: string\n  /** Shown when there is nothing to draw and no `placeholder` is given. Default \"No data yet\". */\n  empty?: string\n}\n\nconst LABELS = {\n  current: 'now',\n  row: 'Row',\n  hint: 'Use the arrow keys to read each cell.',\n  loading: 'Loading',\n  empty: 'No data yet',\n}\n\nexport type DotMatrixProps = Omit<ComponentProps<'div'>, 'children'> & {\n  /** The values, one array per row with one number per column: `data[row][column]`. Missing cells count as 0. */\n  data: number[][]\n  /** The short row names at the left: \"Mon\" or \"M\". */\n  rows: string[]\n  /** The full row names for the tooltip, the table and screen readers: \"Mon\", \"Monday\". Default `rows`. */\n  rowTitles?: string[]\n  /** The short column names along the bottom: \"07\", \"08\". */\n  columns: string[]\n  /** The full column names for the tooltip, the table and screen readers: \"07:00–08:00\". Default `columns`. */\n  columnTitles?: string[]\n  /** The chart's accessible name, such as \"Card payments per hour over the last 4 weeks\". */\n  label: string\n  /** How values are written in the tooltip, the table and when read. Default a plain whole number. */\n  format?: ValueFormat\n  /**\n   * The upper bounds of levels 0 to 3, ascending; anything above the last is the top level. The mockup's\n   * `[0, 1, 3, 5]` puts 0 at the smallest dot, 1 next, 2–3, 4–5 and 6 or more at full size. Default: 0, then\n   * quarters of the largest value.\n   */\n  levels?: [number, number, number, number]\n  /** The cell marked as now in --primary, as `row * columns.length + column`. Default null, none. */\n  current?: number | null\n  /** Name every nth column, counted from the first. Default as many as fit, at most about six. */\n  labelEvery?: number\n  /** Show breathing skeleton dots instead of data. */\n  loading?: boolean\n  /** Shown over the empty grid when there is no data or every value is 0, such as a message and a next step. */\n  placeholder?: ReactNode\n  /** The highlighted cell as `row * columns.length + column`, or null. Pass it to control the highlight. */\n  activeIndex?: number | null\n  /** The highlighted cell to start with, when `activeIndex` is not controlled. Default null. */\n  defaultActiveIndex?: number | null\n  /** Called when hover, a finger or the keyboard picks another cell, and with null when it lets go. */\n  onActiveIndexChange?: (index: number | null) => void\n  /** \"compact\" draws smaller, flatter cells. Inside a compact DataTile this happens on its own. */\n  density?: 'comfortable' | 'compact'\n  /** Every visible and spoken string that is not data. */\n  labels?: DotMatrixLabels\n}\n\n/** Scale and opacity per level, as in the mockup. */\nconst SCALE = [0.22, 0.42, 0.6, 0.8, 1]\nconst OPACITY = [0.3, 0.5, 0.7, 0.88, 1]\n\nfunction useControllable(controlled: number | null | undefined, initial: number | null, onChange?: (v: number | null) => void) {\n  const [inner, setInner] = useState(initial)\n  const isControlled = controlled !== undefined\n  const value = isControlled ? controlled : inner\n  const set = useCallback(\n    (v: number | null) => {\n      if (v === value) return\n      if (!isControlled) setInner(v)\n      onChange?.(v)\n    },\n    [value, isControlled, onChange],\n  )\n  return [value, set] as const\n}\n\nfunction levelOf(v: number, steps: readonly number[]) {\n  if (!(v > steps[0])) return 0\n  for (let k = 1; k < steps.length; k++) if (v <= steps[k]) return k\n  return steps.length\n}\n\n/** Cell and dot sizes. The gutter for the row names narrows in a small container, as in the mockup under 560 px. */\nconst GRID_COLS = 'grid-cols-[28px_repeat(var(--dm-cols),minmax(0,1fr))] @min-[448px]:grid-cols-[34px_repeat(var(--dm-cols),minmax(0,1fr))]'\nconst COMPACT_COLS = 'grid-cols-[24px_repeat(var(--dm-cols),minmax(0,1fr))]'\n\n/** A heat map of dots in a rows × columns grid, where size and shade carry the value and one cell can be now. */\nexport function DotMatrix({\n  data,\n  rows,\n  rowTitles,\n  columns,\n  columnTitles,\n  label,\n  format,\n  levels,\n  current = null,\n  labelEvery,\n  loading = false,\n  placeholder,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  density,\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: DotMatrixProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const hintId = useId()\n  const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)\n\n  const nRows = rows.length\n  const nCols = columns.length\n  const count = nRows * nCols\n  const value = (r: number, c: number) => Math.max(0, data[r]?.[c] ?? 0)\n  const max = useMemo(() => Math.max(0, ...data.flatMap((r) => r.map((v) => (v > 0 ? v : 0)))), [data])\n  const steps = levels ?? [0, max / 4, max / 2, (max * 3) / 4]\n  const empty = !loading && (count === 0 || max === 0)\n  const compact = density === 'compact'\n\n  const at = active !== null && active >= 0 && active < count && !empty ? active : -1\n  const now = current !== null && current >= 0 && current < count ? current : -1\n\n  // First view grows the dots in; afterwards changes glide quickly.\n  const [settled, setSettled] = useState(false)\n  useEffect(() => {\n    if (!frame.drawn || settled) return\n    const t = window.setTimeout(() => setSettled(true), frame.reduced ? 0 : 620)\n    return () => window.clearTimeout(t)\n  }, [frame.drawn, settled, frame.reduced])\n\n  // A cell that no longer exists lets go.\n  useEffect(() => {\n    if (active !== null && (active < 0 || active >= count)) setActive(null)\n  }, [active, count, setActive])\n\n  const rowName = (r: number) => rowTitles?.[r] ?? rows[r]\n  const colName = (c: number) => columnTitles?.[c] ?? columns[c]\n  const name = (i: number) => `${rowName(Math.floor(i / nCols))} ${colName(i % nCols)}`\n  const spoken = (v: number) => {\n    const unit = labels.unit?.(v)\n    return unit ? `${formatValue(v, format)} ${unit}` : formatValue(v, format)\n  }\n  const describe = (i: number) => {\n    const v = value(Math.floor(i / nCols), i % nCols)\n    return `${name(i)}${i === now ? `, ${labels.current}` : ''}: ${spoken(v)}`\n  }\n  const pick = (i: number, announce: boolean) => {\n    setActive(i)\n    if (announce) say(describe(i))\n  }\n\n  // Geometry, from the measured width: the gutter is the first grid track.\n  const gridRef = useRef<HTMLDivElement | null>(null)\n  const [gut, setGut] = useState(34)\n  const width = frame.width\n  const cellW = nCols ? (width - gut) / nCols : 0\n  const cellH = nRows ? frame.height / nRows : 0\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    if (empty || loading || !nCols) return\n    const el = e.currentTarget\n    const g = parseFloat(getComputedStyle(el).gridTemplateColumns) || gut\n    if (g !== gut) setGut(g)\n    const r = el.getBoundingClientRect()\n    const x = e.clientX - r.left - g\n    if (x < 0) return\n    const c = Math.min(nCols - 1, Math.floor(x / ((r.width - g) / nCols)))\n    const row = Math.max(0, Math.min(nRows - 1, Math.floor((e.clientY - r.top) / (r.height / nRows))))\n    const i = row * nCols + c\n    if (i !== at) pick(i, false)\n  }\n  useEffect(() => {\n    // Keep the gutter in step with the container query and density that set it.\n    const node = gridRef.current\n    if (!node || !width) return\n    const g = parseFloat(getComputedStyle(node).gridTemplateColumns)\n    if (g && g !== gut) setGut(g)\n  }, [width, gut, compact])\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (empty || !count) return\n    if (e.key === 'Escape') {\n      if (at < 0) return\n      e.preventDefault()\n      setActive(null)\n      return\n    }\n    const from = at < 0 ? (now >= 0 ? now : 0) : at\n    let r = Math.floor(from / nCols)\n    let c = from % nCols\n    if (e.key === 'ArrowRight') c = Math.min(nCols - 1, c + 1)\n    else if (e.key === 'ArrowLeft') c = Math.max(0, c - 1)\n    else if (e.key === 'ArrowDown') r = Math.min(nRows - 1, r + 1)\n    else if (e.key === 'ArrowUp') r = Math.max(0, r - 1)\n    else if (e.key === 'Home') {\n      c = 0\n      if (e.ctrlKey || e.metaKey) r = 0\n    } else if (e.key === 'End') {\n      c = nCols - 1\n      if (e.ctrlKey || e.metaKey) r = nRows - 1\n    } else return\n    e.preventDefault()\n    pick(r * nCols + c, true)\n  }\n\n  // Column names: as many as fit, and no more than about six, counted from the first.\n  const longest = Math.max(1, ...columns.map((c) => c.length))\n  const fit = cellW > 0 ? Math.ceil((longest * 6.8 + 12) / cellW) : 1\n  const every = Math.max(1, labelEvery ?? Math.max(fit, Math.ceil(nCols / 6)))\n\n  const stagger = Math.min(16, 260 / Math.max(1, nCols - 1))\n  const dotStyle = (r: number, c: number, lvl: number): CSSProperties => {\n    const shown = frame.drawn\n    const isNow = r * nCols + c === now\n    return {\n      background: isNow ? 'var(--primary)' : INK,\n      // The now dot stays at full strength so it can be found even when its value is low.\n      opacity: shown ? (isNow ? 1 : OPACITY[lvl]) : 0,\n      transform: `scale(${shown ? SCALE[lvl] : 0})`,\n      transition: frame.reduced\n        ? 'none'\n        : settled\n          ? `transform 160ms ${EASE_CSS}, opacity 160ms ${EASE_CSS}`\n          : `transform 300ms ${EASE_CSS} ${Math.round(c * stagger)}ms, opacity 300ms ${EASE_CSS} ${Math.round(c * stagger)}ms`,\n    }\n  }\n\n  const cols = compact ? COMPACT_COLS : cn(GRID_COLS, 'group-data-[density=compact]/tile:grid-cols-[24px_repeat(var(--dm-cols),minmax(0,1fr))]')\n  const cellClass = cn(\n    // The height is capped so a wide tile keeps a compact grid instead of growing square cells.\n    'relative grid place-items-center min-h-3.5',\n    compact ? 'aspect-[5/4] max-h-9' : 'aspect-square max-h-12 group-data-[density=compact]/tile:aspect-[5/4] group-data-[density=compact]/tile:max-h-9',\n  )\n  const dotClass = cn(\n    'block aspect-square w-[76%] rounded-full',\n    compact ? 'max-w-3.5' : 'max-w-[18px] group-data-[density=compact]/tile:max-w-3.5',\n  )\n  const rowLabel = 'font-mono text-[11px] leading-none whitespace-nowrap text-muted-foreground'\n  const tipAt = at >= 0 && width > 0 ? at : -1\n  const tipR = Math.floor(tipAt / nCols)\n  const tipC = tipAt % nCols\n  const tipV = tipAt >= 0 ? value(tipR, tipC) : 0\n  const tipUnit = tipAt >= 0 ? labels.unit?.(tipV) : ''\n\n  return (\n    <div\n      className={cn('@container relative min-w-0', className)}\n      style={{ ['--dm-cols' as string]: loading && !nCols ? 16 : Math.max(1, nCols), ...style }}\n      aria-busy={loading || undefined}\n      {...rest}\n    >\n      <div\n        ref={(el) => {\n          frameRef(el)\n          gridRef.current = el\n        }}\n        tabIndex={empty || loading ? undefined : 0}\n        role={empty || loading ? undefined : 'img'}\n        aria-label={empty || loading ? undefined : label}\n        aria-describedby={empty || loading ? undefined : hintId}\n        aria-hidden={loading || undefined}\n        onPointerDown={fromPointer}\n        onPointerMove={fromPointer}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== 'mouse') return\n          if (!e.currentTarget.matches(':focus-visible')) setActive(null)\n        }}\n        onFocus={(e) => {\n          // A press focuses the grid too; only keyboard focus picks a cell.\n          if (!e.currentTarget.matches(':focus-visible')) return\n          if (at >= 0) {\n            say(describe(at))\n            return\n          }\n          pick(now >= 0 ? now : 0, true)\n        }}\n        onBlur={() => setActive(null)}\n        onKeyDown={onKeyDown}\n        className={cn(\n          'relative grid items-center rounded-[14px] outline-offset-4 select-none [touch-action:pan-y]',\n          cols,\n          !empty && !loading && 'cursor-crosshair',\n          loading && 'motion-safe:animate-pulse',\n        )}\n      >\n        {Array.from({ length: Math.max(nRows, loading ? 7 : 0) }, (_, r) => (\n          <Fragment key={r}>\n            <span aria-hidden className={cn(rowLabel, r === tipR && tipAt >= 0 && 'text-foreground')}>\n              {loading ? '' : rows[r]}\n            </span>\n            {Array.from({ length: Math.max(nCols, loading ? 16 : 0) }, (_, c) => {\n              const i = r * nCols + c\n              const lvl = loading || empty ? 0 : levelOf(value(r, c), steps)\n              return (\n                <span key={c} aria-hidden className={cellClass}>\n                  {loading ? (\n                    <i className={cn(dotClass, 'scale-[0.6] bg-muted')} />\n                  ) : (\n                    <i className={dotClass} style={empty ? { background: INK, opacity: 0.18, transform: 'scale(0.22)' } : dotStyle(r, c, lvl)} />\n                  )}\n                  {i === at && (\n                    <span className=\"pointer-events-none absolute top-1/2 left-1/2 aspect-square h-[min(100%,40px)] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_0_0_2px_var(--ring)]\" />\n                  )}\n                </span>\n              )\n            })}\n          </Fragment>\n        ))}\n        {empty && (\n          <div className=\"absolute inset-0 grid place-content-center justify-items-center gap-1.5 px-4 text-center\">\n            {placeholder ?? <span className=\"rounded-full bg-card px-3 py-1 text-[13.5px] text-muted-foreground\">{labels.empty}</span>}\n          </div>\n        )}\n      </div>\n      <span id={hintId} hidden>\n        {labels.hint}\n      </span>\n      <div aria-hidden className={cn('mt-1.5 grid h-[11px] group-data-[density=compact]/tile:mt-1', cols, compact && 'mt-1')}>\n        <span />\n        {!loading &&\n          width > 0 &&\n          columns.map((c, k) => (\n            <span\n              key={k}\n              className={cn(\n                'text-center font-mono text-[11px] leading-none whitespace-nowrap tabular-nums',\n                k === tipC && tipAt >= 0 ? 'text-foreground' : 'text-muted-foreground',\n              )}\n            >\n              {tipAt >= 0 && k === tipC ? c : k % every === 0 && !(tipAt >= 0 && Math.abs(k - tipC) < fit) ? c : ''}\n            </span>\n          ))}\n      </div>\n      {loading && <span className=\"sr-only\">{labels.loading}</span>}\n      {!loading && !empty && (\n        <>\n          <ChartTooltip\n            open={tipAt >= 0}\n            x={tipAt >= 0 ? gut + (tipC + 0.5) * cellW : 0}\n            y={tipAt >= 0 ? tipR * cellH + cellH * 0.14 : 0}\n            bounds={width}\n            title={tipAt >= 0 ? (tipAt === now ? `${name(tipAt)} · ${labels.current}` : name(tipAt)) : ''}\n            value={\n              tipAt >= 0 ? (\n                <>\n                  {formatValue(tipV, format)}\n                  {tipUnit && <span className=\"font-medium text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]\"> {tipUnit}</span>}\n                </>\n              ) : (\n                ''\n              )\n            }\n            rows={labels.note ? [{ label: labels.note, value: '' }] : []}\n          />\n          <SrTable\n            caption={label}\n            head={[labels.row, ...columns.map((_, c) => colName(c))]}\n            rows={rows.map((_, r) => [\n              rowName(r),\n              ...columns.map((_, c) => {\n                const v = spoken(value(r, c))\n                return r * nCols + c === now ? `${v} (${labels.current})` : v\n              }),\n            ])}\n          />\n          {region}\n        </>\n      )}\n    </div>\n  )\n}\n\n/** The key for a dot matrix, for the tile's title row: a small dot growing to a full one between two words. */\nexport function DotMatrixKey({ fewer = 'Fewer', more = 'More', className }: { fewer?: string; more?: string; className?: string }) {\n  const keys: [number, number][] = [\n    [0.3, 0.4],\n    [0.5, 0.6],\n    [0.7, 0.8],\n    [0.85, 0.92],\n    [1, 1],\n  ]\n  return (\n    <span aria-hidden className={cn('inline-flex items-center gap-1.5 text-[12.5px] text-muted-foreground', className)}>\n      {fewer}\n      {keys.map(([s, o]) => (\n        <i key={s} className=\"size-3 rounded-full\" style={{ background: INK, opacity: o, transform: `scale(${s})` }} />\n      ))}\n      {more}\n    </span>\n  )\n}\n\nexport default DotMatrix\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}