{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"area-chart","type":"registry:component","title":"Area chart","description":"Several series stacked, overlapping or as shares: every band morphs between layouts, and the tiles are the legend, the switches and the readout.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/area-chart.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * AREA CHART: several series, stacked, overlapping or as shares\n *\n *   arrive    the first time it's on screen the chart draws\n *             itself from left to right and every figure rolls\n *             up from zero\n *   layout    Stacked, Overlap, 100%: the pill is thrown to the\n *             choice and every band morphs to its new place, the\n *             scale rolling to counts or percentages\n *   toggle    the tiles are the legend: press one and its band\n *             shrinks away while the others settle into the room,\n *             press it again and it grows back. Colours stay with\n *             their series\n *   hover     a hairline follows the pointer with a dot on each\n *             band, and the tiles become the readout: the period\n *             morphs to that week and every figure rolls to it\n *\n * The plot is one tab stop: arrow keys move between points and\n * the readout is announced. The tiles are toggle buttons. Every\n * figure is also in a table for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface AreaSeries {\n  id: string;\n  label: string;\n  /** One value per label. */\n  values: number[];\n}\n\nexport type AreaLayout = \"stacked\" | \"overlap\" | \"percent\";\n\nexport interface AreaChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  title?: string;\n  /** Under the total, e.g. \"orders in the last 12 weeks\". */\n  caption?: string;\n  /** Along the bottom, one per point. */\n  labels: string[];\n  /** In the readout, e.g. \"Week of 28 Sept\". Defaults to labels. */\n  titles?: string[];\n  /** Up to five, in a fixed colour order (--chart-1 to --chart-5). */\n  series: AreaSeries[];\n  layout?: AreaLayout;\n  defaultLayout?: AreaLayout;\n  onLayoutChange?: (layout: AreaLayout) => void;\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst PLOT = 200;\nconst TOP = 10;\nconst GUTTER = 44;\nconst LABELS = 24;\nconst PAD = 6;\nconst STEPS = 4;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst COLORS = [\"var(--chart-1, var(--primary))\", \"var(--chart-2, #e8743b)\", \"var(--chart-3, #1baf7a)\", \"var(--chart-4, #8b6fe8)\", \"var(--chart-5, #e0a21b)\"];\nconst LAYOUTS: { id: AreaLayout; label: string }[] = [\n  { id: \"stacked\", label: \"Stacked\" },\n  { id: \"overlap\", label: \"Overlap\" },\n  { id: \"percent\", label: \"100%\" },\n];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\nfunction niceTop(value: number) {\n  if (value <= 0) return STEPS;\n  const raw = value / STEPS;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  return step * STEPS;\n}\n\n/** A smooth line through the points that never overshoots them (monotone cubic). Works left to right or right to left. */\nfunction monotone(points: [number, number][], move = true) {\n  const n = points.length;\n  if (!n) return \"\";\n  const r = (v: number) => Math.round(v * 100) / 100;\n  const head = `${move ? \"M\" : \"L\"}${r(points[0][0])},${r(points[0][1])}`;\n  if (n === 1) return head;\n  const m: number[] = [];\n  for (let i = 0; i < n - 1; i++) m.push((points[i + 1][1] - points[i][1]) / (points[i + 1][0] - points[i][0] || 1));\n  const t: number[] = [m[0]];\n  for (let i = 1; i < n - 1; i++) t.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2);\n  t.push(m[n - 2]);\n  for (let i = 0; i < n - 1; i++) {\n    if (m[i] === 0) {\n      t[i] = 0;\n      t[i + 1] = 0;\n      continue;\n    }\n    const a = t[i] / m[i];\n    const b = t[i + 1] / m[i];\n    const s = a * a + b * b;\n    if (s > 9) {\n      const k = 3 / Math.sqrt(s);\n      t[i] = k * a * m[i];\n      t[i + 1] = k * b * m[i];\n    }\n  }\n  let d = head;\n  for (let i = 0; i < n - 1; i++) {\n    const [x0, y0] = points[i];\n    const [x1, y1] = points[i + 1];\n    const dx = (x1 - x0) / 3;\n    d += `C${r(x0 + dx)},${r(y0 + t[i] * dx)} ${r(x1 - dx)},${r(y1 - t[i + 1] * dx)} ${r(x1)},${r(y1)}`;\n  }\n  return d;\n}\n\n/** Tweens a list of numbers toward a target, so bands reshape instead of being replaced. */\nfunction useTween(target: number[], reduced: boolean, duration = 620) {\n  const [value, setValue] = React.useState(target);\n  const from = React.useRef(target);\n  React.useEffect(() => {\n    if (reduced) {\n      from.current = target;\n      setValue(target);\n      return;\n    }\n    const start = from.current;\n    const begin = performance.now();\n    let frame = 0;\n    const step = (now: number) => {\n      const t = Math.min(1, (now - begin) / duration);\n      const e = 1 - Math.pow(1 - t, 4);\n      const next = target.map((v, i) => (start[i] ?? 0) + (v - (start[i] ?? 0)) * e);\n      from.current = next;\n      setValue(next);\n      if (t < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [target.join(\",\"), reduced]);\n  return value;\n}\n\nexport function AreaChart({\n  title = \"Orders\",\n  caption,\n  labels,\n  titles,\n  series: allSeries,\n  layout: layoutProp,\n  defaultLayout = \"stacked\",\n  onLayoutChange,\n  format,\n  locales,\n  className = \"\",\n  ...props\n}: AreaChartProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const series = allSeries.slice(0, COLORS.length);\n  const [ownLayout, setOwnLayout] = React.useState<AreaLayout>(defaultLayout);\n  const layout = layoutProp ?? ownLayout;\n  const [hidden, setHidden] = React.useState<Set<string>>(() => new Set());\n  const [visible, setVisible] = React.useState(false);\n  const [plotH, setPlotH] = React.useState(PLOT);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [keyboard, setKeyboard] = React.useState(false);\n  const [width, setWidth] = React.useState(0);\n  const [pill, setPill] = React.useState({ left: 0, width: 0, ready: false });\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const chartRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const n = labels.length;\n  const on = (s: AreaSeries) => !hidden.has(s.id);\n  // Each series as a band [lower, upper] at every point, in the layout's units; a hidden series is a band of no height.\n  const bands = React.useMemo(() => {\n    const shown = series.map((s) => (on(s) ? s.values : s.values.map(() => 0)));\n    const sums = labels.map((_, i) => shown.reduce((sum, v) => sum + (v[i] ?? 0), 0));\n    let running = labels.map(() => 0);\n    return shown.map((values) => {\n      if (layout === \"overlap\") return { lower: labels.map(() => 0), upper: labels.map((_, i) => values[i] ?? 0) };\n      const lower = running;\n      const upper = labels.map((_, i) => lower[i] + (layout === \"percent\" ? (sums[i] ? (values[i] ?? 0) / sums[i] : 0) : (values[i] ?? 0)));\n      running = upper;\n      return { lower, upper };\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [series, labels, layout, hidden]);\n  const max = layout === \"percent\" ? 1 : niceTop(Math.max(0, ...bands.flatMap((b) => b.upper)));\n  const flat = useTween([max, ...bands.flatMap((b) => [...b.lower, ...b.upper])], reduced);\n  const drawnMax = flat[0] || 1;\n  const drawn = bands.map((_, s) => ({\n    lower: flat.slice(1 + s * 2 * n, 1 + s * 2 * n + n),\n    upper: flat.slice(1 + s * 2 * n + n, 1 + (s + 1) * 2 * n),\n  }));\n\n  // The plot's height is set by the card's width in CSS; read it so the drawing fits.\n  React.useLayoutEffect(() => {\n    const el = chartRef.current;\n    if (!el) return;\n    const measure = () =>\n      setPlotH((h) => {\n        const next = el.offsetHeight - LABELS;\n        return next > 0 && next !== h ? next : h;\n      });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const el = plotRef.current;\n    if (!el) return;\n    const measure = () => setWidth((w) => (w === el.offsetWidth ? w : el.offsetWidth));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const el = chipRefs.current[LAYOUTS.findIndex((l) => l.id === layout)];\n    if (!el) return;\n    setPill((p) => (p.left === el.offsetLeft && p.width === el.offsetWidth ? p : { left: el.offsetLeft, width: el.offsetWidth, ready: p.width > 0 }));\n  }, [layout]);\n\n  const x = (i: number) => PAD + (n > 1 ? (i * (width - PAD * 2)) / (n - 1) : (width - PAD * 2) / 2);\n  const y = (v: number) => TOP + (plotH - TOP) * (1 - v / drawnMax);\n\n  const choose = (next: AreaLayout) => {\n    if (next === layout) return;\n    if (layoutProp === undefined) setOwnLayout(next);\n    onLayoutChange?.(next);\n  };\n  const toggle = (sid: string) =>\n    setHidden((h) => {\n      const next = new Set(h);\n      if (next.has(sid)) next.delete(sid);\n      // Keep at least one series showing.\n      else if (series.filter((s) => !next.has(s.id)).length > 1) next.add(sid);\n      return next;\n    });\n  const pointAt = (clientX: number) => {\n    const r = plotRef.current?.getBoundingClientRect();\n    if (!r || n < 2) return null;\n    return Math.min(n - 1, Math.max(0, Math.round(((clientX - r.left - PAD) / (r.width - PAD * 2)) * (n - 1))));\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!n) return;\n    const from = active ?? n - 1;\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: n - 1 };\n    if (e.key === \"Escape\") return setActive(null);\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    setKeyboard(true);\n    setActive(Math.min(n - 1, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const fmt = (v: number) => new Intl.NumberFormat(locales, format).format(v);\n  const totalOf = (s: AreaSeries) => (active !== null ? (s.values[active] ?? 0) : s.values.reduce((sum, v) => sum + v, 0));\n  const shownTotal = series.filter(on).reduce((sum, s) => sum + totalOf(s), 0);\n  const scaleFormat: Intl.NumberFormatOptions =\n    layout === \"percent\" ? { style: \"percent\" } : { ...format, notation: \"compact\", minimumFractionDigits: 0, maximumFractionDigits: 1 };\n  const every = width && n ? Math.max(1, Math.ceil(40 / (width / n))) : 1;\n  // Labels thin out on narrow screens; the hovered one always shows, and its close neighbours step aside for it.\n  const label = (i: number) => active === i || ((n - 1 - i) % every === 0 && (active === null || every === 1 || Math.abs(i - active) >= every));\n  const name = (i: number) => titles?.[i] ?? labels[i];\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5 [--gutter:30px] @md:[--gutter:40px] [--plot:160px] @md:[--plot:200px]\">\n        {/* The title and its control share a row; the figures sit under both. */}\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n          <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">{title}</p>\n          <div role=\"radiogroup\" aria-label=\"Layout\" className=\"relative -my-1 inline-flex shrink-0 rounded-full bg-muted p-0.5\">\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n              style={{\n                width: pill.width,\n                transform: `translateX(${pill.left}px)`,\n                transition: pill.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\",\n              }}\n            />\n            {LAYOUTS.map((l, i) => {\n              const selected = l.id === layout;\n              return (\n                <button\n                  key={l.id}\n                  ref={(el) => {\n                    chipRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={selected}\n                  tabIndex={selected ? 0 : -1}\n                  onClick={() => choose(l.id)}\n                  onKeyDown={(e) => {\n                    if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n                    e.preventDefault();\n                    const next = (i + (e.key === \"ArrowRight\" ? 1 : -1) + LAYOUTS.length) % LAYOUTS.length;\n                    choose(LAYOUTS[next].id);\n                    chipRefs.current[next]?.focus();\n                  }}\n                  className={`relative h-6 whitespace-nowrap rounded-full px-2.5 text-[11.5px] font-medium @md:h-7 @md:px-3 @md:text-[12.5px] transition-colors duration-300 ${FOCUS} ${\n                    selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n                  }`}\n                >\n                  {l.label}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n        <p className=\"mt-0.5 text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px] text-foreground\">\n          <NumberRoll value={visible ? shownTotal : 0} format={format} locales={locales} duration={active !== null ? 600 : 1000} />\n        </p>\n        <p className=\"mt-1 whitespace-nowrap text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n          <TextMorph>{active !== null ? name(active) : (caption ?? \" \")}</TextMorph>\n        </p>\n\n        <div ref={chartRef} className=\"relative mt-4 @md:mt-5\" style={{ height: `calc(var(--plot) + ${LABELS}px)` }}>\n          {Array.from({ length: STEPS + 1 }, (_, k) => 1 - k / STEPS).map((f) => (\n            <div key={f} aria-hidden=\"true\" className=\"absolute inset-x-0\" style={{ top: TOP + (plotH - TOP) * (1 - f) }}>\n              <span\n                className=\"absolute left-0 top-0 -translate-y-1/2 text-[10px] leading-none tabular-nums text-muted-foreground/80\"\n                style={{ width: \"calc(var(--gutter) - 8px)\", textAlign: \"right\" }}\n              >\n                <NumberRoll value={visible ? max * f : 0} format={scaleFormat} locales={locales} duration={700} />\n              </span>\n              <div className=\"border-t border-border\" style={{ marginLeft: \"var(--gutter)\" }} />\n            </div>\n          ))}\n\n          <div\n            ref={plotRef}\n            role=\"group\"\n            tabIndex={0}\n            aria-label={`${title}. Use the arrow keys to read each point.`}\n            onKeyDown={onKeyDown}\n            onBlur={() => keyboard && (setActive(null), setKeyboard(false))}\n            onPointerMove={(e) => {\n              setKeyboard(false);\n              setActive(pointAt(e.clientX));\n            }}\n            onPointerDown={(e) => setActive(pointAt(e.clientX))}\n            onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}\n            className={`absolute inset-y-0 right-0 touch-pan-y select-none rounded-lg ${FOCUS}`}\n            style={{ left: \"var(--gutter)\" }}\n          >\n            <div\n              className=\"absolute inset-x-0 top-0\"\n              style={{\n                height: plotH,\n                clipPath: visible ? \"inset(-8px -8px -8px -8px)\" : \"inset(-8px 100% -8px -8px)\",\n                transition: reduced ? \"none\" : `clip-path 1200ms ${EASE}`,\n              }}\n            >\n              <svg aria-hidden=\"true\" width={width} height={plotH} className=\"absolute inset-0 overflow-visible\">\n                {width > 0 &&\n                  drawn.map((b, s) => {\n                    const upper = b.upper.map((v, i) => [x(i), y(v)] as [number, number]);\n                    const lower = b.lower.map((v, i) => [x(i), y(v)] as [number, number]).reverse();\n                    const top = monotone(upper);\n                    const fill = `${top}${monotone(lower, false)}Z`;\n                    const shown = on(series[s]);\n                    // Overlap fills lightly so the bands behind still show; stacked bands fill a little more.\n                    const wash = layout === \"overlap\" ? 0.1 : 0.2;\n                    return (\n                      <g key={series[s].id} style={{ opacity: shown ? 1 : 0, transition: reduced ? \"none\" : `opacity 300ms ${EASE}` }}>\n                        <path d={fill} fill={COLORS[s]} fillOpacity={wash} />\n                        <path d={top} fill=\"none\" stroke={COLORS[s]} strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                      </g>\n                    );\n                  })}\n              </svg>\n            </div>\n\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-0 w-px bg-foreground/20\"\n              style={{\n                height: plotH,\n                left: active !== null ? x(active) : 0,\n                opacity: active !== null ? 1 : 0,\n                transition: reduced ? \"none\" : `left 260ms ${THROW}, opacity 200ms ${EASE}`,\n              }}\n            />\n            {series.map((s, si) => {\n              const showDot = active !== null && on(s);\n              const v = drawn[si]?.upper[active ?? n - 1] ?? 0;\n              return (\n                <span\n                  key={s.id}\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[0_0_0_2px_var(--background)]\"\n                  style={{\n                    left: x(active ?? n - 1),\n                    top: y(v),\n                    background: COLORS[si],\n                    opacity: showDot ? 1 : 0,\n                    scale: showDot ? \"1\" : \"0.4\",\n                    transition: reduced ? \"none\" : `left 260ms ${THROW}, top 260ms ${THROW}, opacity 200ms ${EASE}, scale 260ms ${EASE}`,\n                  }}\n                />\n              );\n            })}\n\n            {labels.map((l, i) => (\n              <span\n                key={l + i}\n                aria-hidden=\"true\"\n                className={`absolute -translate-x-1/2 whitespace-nowrap text-[11px] leading-none transition-colors duration-200 ${active === i ? \"text-foreground\" : \"text-muted-foreground\"}`}\n                style={{ left: x(i), top: plotH + 9, opacity: label(i) ? 1 : 0 }}\n              >\n                {l}\n              </span>\n            ))}\n          </div>\n        </div>\n\n        {/* The tiles: the legend, the switches, and the readout while you point. */}\n        {/* The legend as a compact strip: each series is a toggle with its figure and share. */}\n        <div\n          className=\"mt-4 grid gap-x-1 border-t border-border pt-2.5 @md:mt-5 @md:pt-3\"\n          style={{ gridTemplateColumns: `repeat(${Math.min(series.length, 3)}, minmax(0, 1fr))` }}\n        >\n          {series.map((s, si) => {\n            const shown = on(s);\n            const value = totalOf(s);\n            const share = shownTotal ? value / shownTotal : 0;\n            return (\n              <button\n                key={s.id}\n                type=\"button\"\n                aria-pressed={shown}\n                onClick={() => toggle(s.id)}\n                className={`-mx-1.5 flex min-w-0 flex-col items-start rounded-lg px-1.5 py-1 text-left transition-[opacity,background-color] duration-300 hover:bg-accent ${FOCUS} ${\n                  shown ? \"\" : \"opacity-45\"\n                }`}\n              >\n                <span className=\"flex max-w-full items-center gap-1.5 text-[11px] text-muted-foreground @md:text-[11.5px]\">\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"size-[7px] shrink-0 rounded-full transition-[background-color,box-shadow] duration-300\"\n                    style={{ background: shown ? COLORS[si] : \"transparent\", boxShadow: `inset 0 0 0 1.5px ${COLORS[si]}` }}\n                  />\n                  <span className=\"truncate\">{s.label}</span>\n                </span>\n                <span className=\"mt-0.5 flex items-baseline gap-1\">\n                  <span className=\"text-[13.5px] font-medium tracking-[-0.01em] text-foreground @md:text-[14.5px]\">\n                    <NumberRoll value={visible && shown ? value : 0} format={format} locales={locales} duration={600} />\n                  </span>\n                  <span className=\"text-[10.5px] text-muted-foreground @md:text-[11px]\">\n                    <NumberRoll value={visible && shown ? share : 0} format={{ style: \"percent\" }} duration={600} />\n                  </span>\n                </span>\n              </button>\n            );\n          })}\n        </div>\n\n        <p aria-live=\"polite\" className=\"sr-only\">\n          {keyboard && active !== null\n            ? `${name(active)}: ${series\n                .filter(on)\n                .map((s) => `${s.label} ${fmt(s.values[active] ?? 0)}`)\n                .join(\", \")}`\n            : \"\"}\n        </p>\n        <table className=\"sr-only\">\n          <caption>\n            {title}\n            {caption ? `, ${caption}` : \"\"}\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Point</th>\n              {series.map((s) => (\n                <th key={s.id} scope=\"col\">\n                  {s.label}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {labels.map((l, i) => (\n              <tr key={l + i} id={`${id}-${i}`}>\n                <th scope=\"row\">{name(i)}</th>\n                {series.map((s) => (\n                  <td key={s.id}>{fmt(s.values[i] ?? 0)}</td>\n                ))}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/area-chart"}