{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "area-chart",
  "title": "Area chart",
  "description": "A smooth line over a soft tint for a value that moves day by day, with an optional dashed comparison line, a crosshair tooltip that reads the difference, and room under zero for a dip.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/area-chart/area-chart.tsx",
      "content": "// A smooth line over a soft tint for a value that moves day by day, such as an account balance. The line is a monotone\n// curve (it never overshoots a point), the tint fills down to zero, and an optional comparison series is drawn as a\n// dashed line in BASE. The current point (today) is marked with a dot in --primary. Gridlines are dashed with their\n// values at the right and the zero line is solid. A value below zero dips under the baseline into room the scale\n// makes for it, instead of running into the labels.\n// The plot is one Tab stop. A crosshair follows the pointer, a finger scrubbing sideways or the arrow keys (Home and\n// End jump to the ends, Page Up and Page Down by one label step), and the tooltip reads both series and the\n// difference; Escape lets go, and the page still scrolls up and down under a finger. Screen readers get the chart's\n// name, a hint, one sentence per step and the whole series in a hidden table.\n// Only transform and opacity move. On first view, and when the data changes, a clip wipes the lines in from the left\n// (300 ms), the tint fades up behind them and the end dot settles last (all done in 600 ms). Under reduced motion the\n// whole chart stands at once and the crosshair jumps instead of gliding.\nimport { useCallback, useEffect, useId, useMemo, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChartTooltip, SrTable, type TooltipRow } from '@/registry/manniche/chart-kit/chart-kit'\nimport {\n  BASE,\n  EASE_CSS,\n  GRID,\n  INK,\n  formatTick,\n  formatValue,\n  niceScale,\n  smoothPath,\n  stepIndex,\n  valueParts,\n  type ValueFormat,\n} from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame, useSvgId } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type AreaPoint = {\n  /** The short name on the axis: \"14 Sep\", \"Today\". */\n  label: string\n  /** The full name for the tooltip, the table and screen readers: \"Mon 14 Sep\". Defaults to `label`. */\n  title?: string\n  /** The value at this point. May be negative. */\n  value: number\n  /** The same point in the period before, drawn as the dashed line. */\n  previous?: number\n}\n\nexport type AreaChartLabels = {\n  /** Added to the current point's name in the tooltip and when read: \"Thu 8 Oct · today\". Default \"today\". */\n  current?: string\n  /** The main series in the table. Default \"This period\". */\n  value?: string\n  /** The comparison series in the tooltip, the table and when read. Default \"Period before\". */\n  previous?: string\n  /** The difference row in the tooltip. Default \"Difference\". */\n  difference?: string\n  /** The first column of the screen reader table. Default \"Day\". */\n  point?: string\n  /** Read after the chart's name. */\n  hint?: string\n  /** Read while loading. Default \"Loading\". */\n  loading?: string\n  /** Shown when there is no data and no `placeholder` is given. Default \"No data yet\". */\n  empty?: string\n}\n\nconst LABELS: Required<AreaChartLabels> = {\n  current: 'today',\n  value: 'This period',\n  previous: 'Period before',\n  difference: 'Difference',\n  point: 'Day',\n  hint: 'Use the arrow keys to read each point. Page Up and Page Down jump further.',\n  loading: 'Loading',\n  empty: 'No data yet',\n}\n\nexport type AreaChartProps = Omit<ComponentProps<'div'>, 'children'> & {\n  /** One object per point, oldest first: `{ label, title?, value, previous? }`. */\n  data: AreaPoint[]\n  /** The chart's accessible name, such as \"Everyday account balance, last 30 days\". */\n  label: string\n  /** How values are written in the tooltip, the table and on the axis. */\n  format?: ValueFormat\n  /** Draw each point's `previous` value as a dashed comparison line. Default on when any point has one. */\n  compare?: boolean\n  /** The index of the current point (today), marked with the signal dot. Default the last point; null for none. */\n  current?: number | null\n  /** The chart's height in px, axis labels included. Default 300, 200 when narrower than 560 px, less when compact. */\n  height?: number\n  /** Label every nth point, counted back from the last. Also the Page Up / Page Down step. Default as many as fit, about six. */\n  tickEvery?: number\n  /** Show a breathing skeleton instead of data. */\n  loading?: boolean\n  /** Shown over the empty frame when there is no data, such as a message and a next step. */\n  placeholder?: ReactNode\n  /** The highlighted point (an index into `data`), or null. */\n  activeIndex?: number | null\n  /** The highlighted point 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 point, and with null when it lets go. */\n  onActiveIndexChange?: (index: number | null) => void\n  /** Every visible and spoken string that is not data. */\n  labels?: AreaChartLabels\n}\n\n/** Room at the right for the value labels, above for the top dot and below for the date labels, as in the mockup. */\nconst GR = 52\nconst PT = 10\nconst PB = 24\nconst FILL = 'color-mix(in oklab, var(--foreground) 7%, transparent)'\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\n/** A smooth line chart with a soft fill, a dashed comparison line, a crosshair and the current point marked. */\nexport function AreaChart({\n  data,\n  label,\n  format,\n  compare: compareProp,\n  current,\n  height,\n  tickEvery,\n  loading = false,\n  placeholder,\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: AreaChartProps) {\n  const labels = { ...LABELS, ...labelsProp }\n  const { ref: frameRef, ...frame } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const hintId = useId()\n  const clipId = useSvgId('area-reveal')\n  const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)\n\n  const n = data.length\n  const compare = compareProp ?? data.some((d) => d.previous !== undefined)\n  const nowIndex = current === undefined ? n - 1 : current !== null && current >= 0 && current < n ? current : -1\n  const at = active !== null && active >= 0 && active < n ? active : -1\n  const empty = !loading && n === 0\n  const W = frame.width\n  const H = frame.height\n  const pw = Math.max(0, W - GR)\n  const ph = Math.max(0, H - PT - PB)\n\n  // The scale. Ticks only ever cover zero and up, unless the data is mostly below zero; a small dip gets room under\n  // the baseline instead of a whole empty tick.\n  const { lo, hi, ticks } = useMemo(() => {\n    const vals = data.flatMap((d) => (compare && d.previous !== undefined ? [d.value, d.previous] : [d.value]))\n    const top = Math.max(0, ...vals)\n    const bottom = Math.min(0, ...vals)\n    const up = niceScale(top || 1, 3)\n    if (bottom < 0 && (top === 0 || -bottom > up.max * 0.25)) {\n      const s = niceScale(top, 3, bottom)\n      return { lo: s.min, hi: s.max, ticks: s.ticks }\n    }\n    return { lo: bottom < 0 ? bottom - up.max * 0.04 : 0, hi: up.max, ticks: up.ticks }\n  }, [data, compare])\n\n  const X = (i: number) => (n > 1 ? (i / (n - 1)) * pw : pw / 2)\n  const Y = (v: number) => PT + (1 - (v - lo) / (hi - lo || 1)) * ph\n\n  const paths = useMemo(() => {\n    if (!pw || !ph || !n) return null\n    const xs = (i: number) => (n > 1 ? (i / (n - 1)) * pw : pw / 2)\n    const ys = (v: number) => PT + (1 - (v - lo) / (hi - lo || 1)) * ph\n    const a = data.map((d, i) => [xs(i), ys(d.value)] as [number, number])\n    const line = smoothPath(a)\n    const zero = ys(0)\n    const fill = `${line}L${a[a.length - 1][0]} ${zero}L${a[0][0]} ${zero}Z`\n    const withPrev = data.map((d, i) => (d.previous === undefined ? null : ([xs(i), ys(d.previous)] as [number, number])))\n    const b = compare ? smoothPath(withPrev.filter((p): p is [number, number] => p !== null)) : ''\n    return { line, fill, b }\n  }, [data, pw, ph, lo, hi, n, compare])\n\n  // The wipe replays whenever the drawing changes: first view, new data, compare on or off.\n  const shape = useMemo(() => `${compare}|${data.map((d) => `${d.value},${d.previous ?? ''}`).join(';')}`, [data, compare])\n  const [played, setPlayed] = useState<string | null>(null)\n  const ready = frame.drawn && W > 0\n  const shown = frame.reduced ? ready : played === shape\n  useEffect(() => {\n    if (!ready || played === shape || frame.reduced) return\n    let b = 0\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setPlayed(shape))\n    })\n    return () => {\n      cancelAnimationFrame(a)\n      cancelAnimationFrame(b)\n    }\n  }, [ready, played, shape, frame.reduced])\n\n  // A point that no longer exists lets go.\n  useEffect(() => {\n    if (active !== null && (active < 0 || active >= n)) setActive(null)\n  }, [active, n, setActive])\n\n  const title = (i: number) => data[i].title ?? data[i].label\n  const describe = (i: number) => {\n    const d = data[i]\n    const head = i === nowIndex ? `${title(i)}, ${labels.current}` : title(i)\n    const prev = compare && d.previous !== undefined ? ` ${labels.previous}: ${formatValue(d.previous, format)}.` : ''\n    return `${head}: ${formatValue(d.value, format)}.${prev}`\n  }\n  const pick = (i: number, announce: boolean) => {\n    setActive(i)\n    if (announce) say(describe(i))\n  }\n\n  // Axis labels: as many as fit, counted back from the last point so today is always named.\n  const longest = Math.max(1, ...data.map((d) => d.label.length))\n  const need = longest * 6.8 + 16\n  const dx = n > 1 ? pw / (n - 1) : pw\n  const every = Math.max(1, Math.ceil(need / Math.max(1, dx)), tickEvery ?? Math.ceil((n - 1) / 6))\n  const page = tickEvery ?? every\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    if (!n || !pw) return\n    const r = e.currentTarget.getBoundingClientRect()\n    const i = n > 1 ? Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / pw) * (n - 1)))) : 0\n    if (i !== at) pick(i, false)\n  }\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!n) 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 ? (nowIndex >= 0 ? nowIndex : n - 1) : at\n    const to =\n      e.key === 'PageUp' ? Math.max(0, from - page) : e.key === 'PageDown' ? Math.min(n - 1, from + page) : stepIndex(e.key, from, n)\n    if (to === null) return\n    e.preventDefault()\n    pick(to, true)\n  }\n\n  const tip = at >= 0 && paths ? data[at] : null\n  const tipRows: TooltipRow[] = []\n  if (tip && compare && tip.previous !== undefined) {\n    tipRows.push({ label: labels.previous, value: formatValue(tip.previous, format), color: BASE })\n    tipRows.push({ label: labels.difference, value: formatValue(tip.value - tip.previous, { ...format, sign: true }) })\n  }\n  const tipY = tip ? Math.min(Y(tip.value), compare && tip.previous !== undefined ? Y(tip.previous) : Infinity) - 6 : 0\n\n  const glide = frame.reduced ? 'none' : `transform 110ms ${EASE_CSS}`\n  const heightClass =\n    'h-[200px] @min-[560px]:h-[300px] group-data-[density=compact]/tile:h-[150px] @min-[560px]:group-data-[density=compact]/tile:h-[200px]'\n  const zeroY = Math.round(Y(0)) + 0.5\n\n  return (\n    <div className={cn('@container relative min-w-0', className)} style={style} aria-busy={loading || undefined} {...rest}>\n      {loading ? (\n        <div aria-hidden className={cn('relative', heightClass)} style={{ height }}>\n          <div className=\"absolute inset-x-0 border-t border-dashed\" style={{ top: PT, right: GR, borderColor: GRID }} />\n          <div\n            className=\"absolute left-0 h-[46%] rounded-t-[18px] bg-muted motion-safe:animate-pulse\"\n            style={{ right: GR, bottom: PB }}\n          />\n          <div className=\"absolute left-0 border-t\" style={{ right: GR, bottom: PB, borderColor: BASE }} />\n          <div className=\"absolute bottom-0 left-0 flex h-[11px] justify-between\" style={{ right: GR }}>\n            {[0, 1, 2, 3, 4].map((i) => (\n              <i key={i} className=\"block h-[9px] w-10 rounded-[10px] bg-muted motion-safe:animate-pulse\" />\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div\n          ref={frameRef}\n          tabIndex={empty ? undefined : 0}\n          role={empty ? undefined : 'img'}\n          aria-label={empty ? undefined : label}\n          aria-describedby={empty ? undefined : hintId}\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 plot too; only keyboard focus picks the current point.\n            if (!e.currentTarget.matches(':focus-visible')) return\n            if (at >= 0) say(describe(at))\n            else if (n) pick(nowIndex >= 0 ? nowIndex : n - 1, true)\n          }}\n          onBlur={() => setActive(null)}\n          onKeyDown={onKeyDown}\n          className={cn('relative rounded-[10px] outline-offset-4 select-none [touch-action:pan-y]', !empty && 'cursor-crosshair', heightClass)}\n          style={{ height }}\n        >\n          {W > 0 && H > 0 && (\n            <svg aria-hidden width={W} height={H} viewBox={`0 0 ${W} ${H}`} className=\"absolute inset-0 block overflow-visible\">\n              <defs>\n                <clipPath id={clipId}>\n                  <rect\n                    x={-8}\n                    y={-8}\n                    width={pw + 16}\n                    height={H + 16}\n                    style={{\n                      transformBox: 'fill-box',\n                      transformOrigin: '0 0',\n                      transform: `scaleX(${shown ? 1 : 0})`,\n                      transition: shown && !frame.reduced ? `transform 300ms ${EASE_CSS}` : 'none',\n                    }}\n                  />\n                </clipPath>\n              </defs>\n              {/* Gridlines with their values at the right; the zero line is solid. */}\n              {(empty ? [0, 0.5, 1].map((t) => lo + t * (hi - lo)) : ticks).map((t) => {\n                const y = Math.round(Y(t)) + 0.5\n                return (\n                  <g key={t}>\n                    <line x1={0} x2={pw} y1={y} y2={y} stroke={t === 0 ? BASE : GRID} strokeDasharray={t === 0 ? undefined : '2 4'} />\n                    {!empty && (\n                      <text x={pw + 10} y={y + 3.5} className=\"fill-muted-foreground font-mono text-[11px] tabular-nums\">\n                        {formatTick(t, format)}\n                      </text>\n                    )}\n                  </g>\n                )\n              })}\n              {!ticks.includes(0) && !empty && <line x1={0} x2={pw} y1={zeroY} y2={zeroY} stroke={BASE} />}\n              {paths && (\n                <g clipPath={`url(#${clipId})`}>\n                  <path\n                    d={paths.fill}\n                    fill={FILL}\n                    style={{\n                      opacity: shown ? 1 : 0,\n                      transition: shown && !frame.reduced ? `opacity 300ms ${EASE_CSS} 60ms` : 'none',\n                    }}\n                  />\n                  {paths.b && (\n                    <path d={paths.b} fill=\"none\" stroke={BASE} strokeWidth={1.75} strokeDasharray=\"5 5\" strokeLinecap=\"round\" />\n                  )}\n                  <path d={paths.line} fill=\"none\" stroke={INK} strokeWidth={2.25} strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n                </g>\n              )}\n              {/* The current point's signal dot settles once the line has reached it. */}\n              {paths && nowIndex >= 0 && (\n                <circle\n                  cx={X(nowIndex)}\n                  cy={Y(data[nowIndex].value)}\n                  r={5.5}\n                  fill=\"var(--primary)\"\n                  stroke=\"var(--card)\"\n                  strokeWidth={3}\n                  style={{\n                    transformBox: 'fill-box',\n                    transformOrigin: 'center',\n                    opacity: shown ? 1 : 0,\n                    transform: shown ? 'none' : 'scale(0.4)',\n                    transition: shown && !frame.reduced ? `opacity 300ms ${EASE_CSS} 260ms, transform 300ms ${EASE_CSS} 260ms` : 'none',\n                  }}\n                />\n              )}\n              {/* Date labels, counted back from the last point. Labels at the edges anchor inwards. */}\n              {pw > 0 &&\n                data.map((d, i) => {\n                  if ((n - 1 - i) % every !== 0) return null\n                  const x = X(i)\n                  const w = d.label.length * 6.8\n                  const anchor = n > 1 && x < w / 2 ? 'start' : n > 1 && x > pw - w / 2 ? 'end' : 'middle'\n                  return (\n                    <text\n                      key={i}\n                      x={x}\n                      y={H - 6}\n                      textAnchor={anchor}\n                      className={cn(\n                        'font-mono text-[11px] tabular-nums',\n                        i === nowIndex ? 'fill-foreground font-medium' : 'fill-muted-foreground',\n                      )}\n                    >\n                      {d.label}\n                    </text>\n                  )\n                })}\n              {/* The crosshair: a hairline and a dot on each series, gliding between points. */}\n              {paths && (\n                <g style={{ opacity: tip ? 1 : 0, transition: frame.reduced ? 'none' : `opacity 140ms ${EASE_CSS}` }}>\n                  <line\n                    x1={0}\n                    x2={0}\n                    y1={PT}\n                    y2={H - PB}\n                    stroke=\"var(--foreground)\"\n                    strokeOpacity={0.35}\n                    style={{ transform: `translateX(${X(Math.max(0, at))}px)`, transition: glide }}\n                  />\n                  {compare && tip?.previous !== undefined && (\n                    <circle\n                      r={4.5}\n                      fill=\"var(--card)\"\n                      stroke={BASE}\n                      strokeWidth={2}\n                      style={{ transform: `translate(${X(at)}px, ${Y(tip.previous)}px)`, transition: glide }}\n                    />\n                  )}\n                  <circle\n                    r={6}\n                    fill=\"var(--foreground)\"\n                    stroke=\"var(--card)\"\n                    strokeWidth={3}\n                    style={{ transform: `translate(${X(Math.max(0, at))}px, ${Y(tip ? tip.value : 0)}px)`, transition: glide }}\n                  />\n                </g>\n              )}\n            </svg>\n          )}\n          {empty && (\n            <div className=\"absolute inset-0 grid place-content-center justify-items-center gap-1.5 px-4 text-center\" style={{ bottom: PB }}>\n              {placeholder ?? <span className=\"text-[13.5px] text-muted-foreground\">{labels.empty}</span>}\n            </div>\n          )}\n        </div>\n      )}\n      <span id={hintId} hidden>\n        {labels.hint}\n      </span>\n      {loading && <span className=\"sr-only\">{labels.loading}</span>}\n      {!loading && !empty && (\n        <>\n          <ChartTooltip\n            open={!!tip}\n            x={tip ? X(at) : 0}\n            y={tipY}\n            below={tipY < 20}\n            bounds={W}\n            title={tip ? (at === nowIndex ? `${title(at)} · ${labels.current}` : title(at)) : ''}\n            value={tip ? <TipValue value={tip.value} format={format} /> : ''}\n            rows={tipRows}\n          />\n          <SrTable\n            caption={label}\n            head={compare ? [labels.point, labels.value, labels.previous] : [labels.point, labels.value]}\n            rows={data.map((d, i) => {\n              const name = i === nowIndex ? `${title(i)} (${labels.current})` : title(i)\n              return compare\n                ? [name, formatValue(d.value, format), d.previous === undefined ? '' : formatValue(d.previous, format)]\n                : [name, formatValue(d.value, format)]\n            })}\n          />\n          {region}\n        </>\n      )}\n    </div>\n  )\n}\n\n/** The tooltip figure with its unit set muted, as on the big number. */\nfunction TipValue({ value, format }: { value: number; format?: ValueFormat }) {\n  const p = valueParts(value, format)\n  const unit = <span className=\"font-medium text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]\">{p.unit}</span>\n  return (\n    <>\n      {p.sign}\n      {!p.unitAfter && unit}\n      {p.whole}\n      {p.fraction}\n      {p.unitAfter && <> {unit}</>}\n      {p.suffix}\n    </>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}