{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"revenue-chart","type":"registry:component","title":"Revenue chart","description":"This year as an area, last year behind it; point at a month and the header becomes the readout, switch the measure and the lines reshape.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/revenue-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 * REVENUE CHART: this year as an area, last year behind it\n *\n *   arrive    the first time it's on screen the chart draws\n *             itself from left to right and the total rolls up\n *             from zero\n *   hover     a hairline follows the pointer from month to month\n *             with a dot on each line, and the header becomes the\n *             readout: the title morphs to \"Revenue in August\",\n *             the total rolls to that month, the chip and the\n *             last-year line follow\n *   metric    Revenue, Orders, Average order: the pill is thrown\n *             to the choice and both lines morph into their new\n *             shapes while the scale's labels and the figures roll\n *   partial   a year still under way ends its line with a dot at\n *             the latest month\n *\n * The plot is one tab stop: arrow keys move between months and\n * the readout is announced. Every figure is also in a table for\n * screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface RevenueMetric {\n  id: string;\n  /** In the switch and the title, e.g. \"Revenue\". */\n  label: string;\n  /** This year, one value per label; may stop early for a year still under way. */\n  current: number[];\n  /** Last year, one value per label. */\n  previous?: number[];\n  /** Intl.NumberFormat options, e.g. { style: \"currency\", currency: \"USD\" }. */\n  format?: Intl.NumberFormatOptions;\n  /** How the headline adds the months up. */\n  total?: \"sum\" | \"average\";\n}\n\nexport interface RevenueChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Along the bottom, e.g. [\"Jan\", \"Feb\", …]. */\n  labels: string[];\n  /** In the readout, e.g. [\"January\", …]. Defaults to labels. */\n  titles?: string[];\n  /** One or more; more than one shows a switch. */\n  metrics: RevenueMetric[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** The two lines' names, for the legend and the readout. */\n  series?: [string, string];\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; // room above the highest point\nconst GUTTER = 44;\nconst LABELS = 24;\nconst PAD = 6;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst LINE = \"var(--chart-1, var(--primary))\";\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\nconst STEPS = 4; // gridlines above the baseline\n\n/** A top for the scale that divides into four round steps: 20,600 → 24,000 (6K a step). */\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, as in d3's curveMonotoneX). */\nfunction monotone(points: [number, number][]) {\n  const n = points.length;\n  if (!n) return \"\";\n  if (n === 1) return `M${points[0][0]},${points[0][1]}`;\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  const r = (v: number) => Math.round(v * 100) / 100;\n  let d = `M${r(points[0][0])},${r(points[0][1])}`;\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 a new metric reshapes the line instead of replacing it. */\nfunction useTween(target: number[], reduced: boolean, duration = 640) {\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] ?? start[start.length - 1] ?? 0) + (v - (start[i] ?? start[start.length - 1] ?? 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 RevenueChart({\n  labels,\n  titles,\n  metrics,\n  value,\n  defaultValue,\n  onValueChange,\n  series = [\"This year\", \"Last year\"],\n  locales,\n  className = \"\",\n  ...props\n}: RevenueChartProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue ?? metrics[0]?.id);\n  const metricId = value ?? own;\n  const metric = metrics.find((m) => m.id === metricId) ?? metrics[0];\n  const current = metric?.current ?? [];\n  const previous = metric?.previous ?? [];\n  const format = metric?.format;\n\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 max = niceTop(Math.max(0, ...current, ...previous));\n  const drawnMax = useTween([max], reduced)[0];\n  const cur = useTween(current, reduced);\n  const prev = useTween(previous, reduced);\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[metrics.findIndex((m) => m.id === metricId)];\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  }, [metricId, metrics]);\n\n  const n = labels.length;\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 || 1));\n  const curPoints = cur.map((v, i) => [x(i), y(v)] as [number, number]);\n  const prevPoints = prev.map((v, i) => [x(i), y(v)] as [number, number]);\n  const curLine = width ? monotone(curPoints) : \"\";\n  const prevLine = width ? monotone(prevPoints) : \"\";\n  const area = curLine && curPoints.length > 1 ? `${curLine}L${curPoints[curPoints.length - 1][0]},${plotH}L${curPoints[0][0]},${plotH}Z` : \"\";\n\n  const add = (list: number[]) =>\n    metric?.total === \"average\" ? (list.length ? list.reduce((s, v) => s + v, 0) / list.length : 0) : list.reduce((s, v) => s + v, 0);\n  const lastIndex = current.length - 1;\n  // Last year over the same months as this year, so a year under way compares like with like.\n  const prevSame = previous.slice(0, current.length);\n  const headline = active !== null ? (current[active] ?? 0) : add(current);\n  const before = active !== null ? previous[active] : prevSame.length ? add(prevSame) : undefined;\n  const change = before ? (headline - before) / before : null;\n  const [lastChange, setLastChange] = React.useState(change ?? 0);\n  if (change !== null && change !== lastChange) setLastChange(change);\n  const up = (change ?? lastChange) >= 0;\n  const fmt = (v: number) => new Intl.NumberFormat(locales, format).format(v);\n  const monthName = (i: number) => titles?.[i] ?? labels[i];\n  // \"last year\" reads as words; \"2025\" reads as \"in 2025\".\n  const then = (label: string) => (/^\\d/.test(label) ? `in ${label}` : label.toLowerCase());\n  const compact = { ...format, notation: \"compact\" as const, minimumFractionDigits: 0, maximumFractionDigits: 1 };\n  const every = width && n ? Math.max(1, Math.ceil(34 / (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\n  const choose = (next: string) => {\n    if (next === metricId) return;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n  const pointAt = (clientX: number) => {\n    const r = plotRef.current?.getBoundingClientRect();\n    if (!r || !current.length || n < 2) return null;\n    const i = Math.round(((clientX - r.left - PAD) / (r.width - PAD * 2)) * (n - 1));\n    return Math.min(lastIndex, Math.max(0, i));\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!current.length) return;\n    const from = active ?? lastIndex;\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: lastIndex };\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(lastIndex, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const dot = (i: number | null, values: number[], color: string, key: string) => {\n    const on = i !== null && values[i] !== undefined;\n    const v = on ? values[i] : (values[values.length - 1] ?? 0);\n    return (\n      <span\n        key={key}\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(on ? (i as number) : Math.max(0, values.length - 1)),\n          top: y(v),\n          background: color,\n          opacity: on ? 1 : 0,\n          scale: on ? \"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  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        <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-3\">\n          <div className=\"min-w-0\">\n            <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">\n              <TextMorph>{active !== null ? `${metric?.label} in ${monthName(active)}` : (metric?.label ?? \"\")}</TextMorph>\n            </p>\n            <p className=\"mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1\">\n              <span className=\"text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px] text-foreground\">\n                <NumberRoll value={visible ? headline : 0} format={format} locales={locales} duration={active !== null ? 600 : 1000} />\n              </span>\n              <span\n                className=\"grid\"\n                style={{\n                  gridTemplateColumns: change !== null ? \"1fr\" : \"0fr\",\n                  opacity: change !== null ? 1 : 0,\n                  transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 240ms ${EASE}`,\n                }}\n              >\n                <span className=\"min-w-0 [clip-path:inset(-4px_-2px)]\">\n                  <span\n                    className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-px text-[12.5px] font-medium transition-colors duration-300 ${\n                      up ? \"bg-emerald-500/10 text-emerald-600\" : \"bg-red-500/10 text-red-600\"\n                    }`}\n                  >\n                    <svg\n                      viewBox=\"0 0 12 12\"\n                      aria-hidden=\"true\"\n                      className=\"size-3\"\n                      style={{ transform: up ? \"none\" : \"rotate(180deg)\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }}\n                    >\n                      <path\n                        d=\"M6 9.5V2.5M2.8 5.6 6 2.5l3.2 3.1\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.5\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                      />\n                    </svg>\n                    <NumberRoll value={visible ? Math.abs(change ?? lastChange) : 0} format={{ style: \"percent\", maximumFractionDigits: 1 }} duration={700} />\n                  </span>\n                </span>\n              </span>\n            </p>\n            {before !== undefined && (\n              <p className=\"mt-1 flex items-baseline gap-[0.3em] whitespace-nowrap text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n                <NumberRoll value={visible ? before : 0} format={format} locales={locales} duration={700} />\n                <TextMorph>\n                  {active !== null ? `in ${monthName(active)} ${/^\\d/.test(series[1]) ? series[1] : series[1].toLowerCase()}` : then(series[1])}\n                </TextMorph>\n              </p>\n            )}\n          </div>\n\n          {metrics.length > 1 && (\n            <div role=\"radiogroup\" aria-label=\"Measure\" className=\"relative 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              {metrics.map((m, i) => {\n                const selected = m.id === metricId;\n                return (\n                  <button\n                    key={m.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(m.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) + metrics.length) % metrics.length;\n                      choose(metrics[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                    {m.label}\n                  </button>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        {/* The legend: a line key for each, so the two never rely on colour alone. */}\n        <div className=\"mt-3 flex items-center gap-4 text-[11.5px] text-muted-foreground @md:mt-4 @md:text-[12px]\">\n          <span className=\"flex items-center gap-1.5\">\n            <span aria-hidden=\"true\" className=\"h-0.5 w-3.5 rounded-full\" style={{ background: LINE }} />\n            {series[0]}\n          </span>\n          {previous.length > 0 && (\n            <span className=\"flex items-center gap-1.5\">\n              <svg aria-hidden=\"true\" width=\"14\" height=\"2\" className=\"text-foreground/35\">\n                <line x1=\"1\" y1=\"1\" x2=\"13\" y2=\"1\" stroke=\"currentColor\" strokeWidth=\"2\" strokeDasharray=\"3 3\" strokeLinecap=\"round\" />\n              </svg>\n              {series[1]}\n            </span>\n          )}\n        </div>\n\n        <div ref={chartRef} className=\"relative mt-3\" style={{ height: `calc(var(--plot) + ${LABELS}px)` }}>\n          {/* The scale: rolling labels in the gutter and solid hairlines. */}\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={compact} 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={`${metric?.label ?? \"\"}, ${series[0]} against ${series[1]}. Use the arrow keys to read each month.`}\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            {/* Everything drawn reveals left to right the first time it's seen. */}\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                <defs>\n                  <linearGradient id={`${id}-wash`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                    <stop offset=\"0%\" stopColor={LINE} stopOpacity=\"0.14\" />\n                    <stop offset=\"100%\" stopColor={LINE} stopOpacity=\"0\" />\n                  </linearGradient>\n                </defs>\n                {prevLine && (\n                  <path\n                    d={prevLine}\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeDasharray=\"4 5\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    className=\"text-foreground/30\"\n                  />\n                )}\n                {area && <path d={area} fill={`url(#${id}-wash)`} />}\n                {curLine && <path d={curLine} fill=\"none\" stroke={LINE} strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />}\n              </svg>\n              {/* The end of a year still under way. */}\n              {current.length > 0 && current.length < n && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"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(lastIndex),\n                    top: y(cur[lastIndex] ?? 0),\n                    background: LINE,\n                    opacity: active === null ? 1 : 0,\n                    transition: `opacity 200ms ${EASE}`,\n                  }}\n                />\n              )}\n            </div>\n\n            {/* The hovered month: a hairline down to the baseline and a dot on each line. */}\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            {previous.length > 0 && dot(active, prev, \"color-mix(in oklab, var(--foreground) 45%, var(--background))\", \"prev\")}\n            {dot(active, cur, LINE, \"cur\")}\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        <p aria-live=\"polite\" className=\"sr-only\">\n          {keyboard && active !== null\n            ? `${monthName(active)}: ${fmt(current[active] ?? 0)}${previous[active] !== undefined ? `, ${series[1].toLowerCase()} ${fmt(previous[active])}` : \"\"}`\n            : \"\"}\n        </p>\n        <table className=\"sr-only\">\n          <caption>\n            {metric?.label}: {fmt(add(current))} {series[0].toLowerCase()}\n            {prevSame.length ? `, ${fmt(add(prevSame))} ${series[1].toLowerCase()} over the same months` : \"\"}\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Month</th>\n              <th scope=\"col\">{series[0]}</th>\n              {previous.length > 0 && <th scope=\"col\">{series[1]}</th>}\n            </tr>\n          </thead>\n          <tbody>\n            {labels.map((l, i) => (\n              <tr key={l + i}>\n                <th scope=\"row\">{monthName(i)}</th>\n                <td>{current[i] !== undefined ? fmt(current[i]) : \"–\"}</td>\n                {previous.length > 0 && <td>{previous[i] !== undefined ? fmt(previous[i]) : \"–\"}</td>}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/revenue-chart"}