{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-chart",
  "type": "registry:ui",
  "title": "Line chart",
  "description": "A multi-series line chart with a gliding crosshair, legend toggles, and paths that morph between ranges.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/line-chart/line-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type Ref,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface LineChartSeries {\n  /** Stable identity. A series keeps its line across data changes, so switching ranges morphs the path instead of redrawing it. */\n  key: string;\n  /** Name in the legend, the tooltip, and the data table. */\n  label: string;\n  /** Any CSS color. Defaults to the accent for the first series, then quieter neutral steps. */\n  color?: string;\n  /** Draws a dashed stroke, useful for a previous period or a target. */\n  dashed?: boolean;\n  /** Fills the area under the line with a faint tint of its color. Defaults to true for the first series. */\n  area?: boolean;\n}\n\nexport interface LineChartDatum {\n  /** Stable identity, such as an ISO date. */\n  key: string;\n  /** Full label for the tooltip and screen readers, such as \"Tue, Sep 15\". */\n  label: string;\n  /** Short label on the x axis, such as \"Sep 15\". Leave it out to keep the axis quiet at that point. */\n  axisLabel?: string;\n  /** One value per series key. A missing value counts as zero. */\n  values: Record<string, number | undefined>;\n}\n\n/** Use a line chart for one or more measures over time, when the shape of the trend matters more than any single value. */\nexport interface LineChartProps {\n  data: LineChartDatum[];\n  series: LineChartSeries[];\n  /** What is measured, such as \"Signups\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each value in the tooltip, such as \"ms\". */\n  unit?: string;\n  /** Plot height in pixels. The width follows the container. */\n  height?: number;\n  /** Formats values in the tooltip and data table. */\n  formatValue?: (value: number, series: LineChartSeries) => string;\n  /** Formats the value axis. Defaults to a compact number, such as 1.2K. */\n  formatTick?: (value: number) => string;\n  /** Controlled hidden series keys. */\n  hiddenSeries?: string[];\n  defaultHiddenSeries?: string[];\n  onHiddenSeriesChange?: (hidden: string[]) => void;\n  /** Called as the crosshair moves, and with null when it leaves. Use it to drive a headline readout. */\n  onActiveChange?: (index: number | null, datum: LineChartDatum | null) => void;\n  /** Keeps the current lines on screen, dimmed, while the next range loads. */\n  loading?: boolean;\n  /** Shown when there are no points. */\n  emptyLabel?: string;\n  /** Series toggles above the plot. Shown by default when there is more than one series. */\n  legend?: boolean;\n  /** Header of the first column in the data table read by screen readers. */\n  categoryLabel?: string;\n  /** Smooth monotone curves that never swing past the data, or straight segments. */\n  curve?: \"smooth\" | \"linear\";\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Point = [x: number, value: number];\ntype Track = { shape: Point[]; presence: number; fade: { stop: () => void } | null };\n\nconst { spring, duration, ease } = motionTokens;\n/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.001) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta,\n    restSpeed: restDelta * 2,\n  };\n};\nconst settle = physical(spring.smooth);\nconst glide = physical(spring.snappy, 0.0001);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: 0.01 };\nconst draw = { duration: duration.considered * 1.75, ease: [...ease.inOut] } as const;\nconst fadeFast = { duration: duration.instant, ease: [...ease.standard] } as const;\n\n/** Room above the top gridline, and the gap between the crosshair and its tooltip. */\nconst TOP = 12,\n  GAP = 14;\nconst PALETTE = [\n  \"var(--accent)\",\n  \"color-mix(in oklab, var(--ink) 46%, var(--surface))\",\n  \"color-mix(in oklab, var(--ink) 26%, var(--surface))\",\n];\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst compact = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst colorOf = (series: LineChartSeries, index: number) =>\n  series.color ?? PALETTE[Math.min(index, PALETTE.length - 1)];\nconst clean = (value: number) => Number(value.toPrecision(12));\n\n/** Clean gridlines: the smallest step of 1, 2, 2.5, or 5 that covers the data in four rows or fewer, from zero unless the data dips below it. */\nfunction niceScale(low: number, high: number) {\n  const lo = Math.min(0, low),\n    hi = Math.max(high, lo + 1);\n  const span = hi - lo,\n    magnitude = 10 ** Math.floor(Math.log10(span / 4));\n  for (const factor of [1, 2, 2.5, 5, 10]) {\n    const step = factor * magnitude,\n      bottom = Math.floor(lo / step) * step,\n      top = Math.ceil(hi / step) * step;\n    if ((top - bottom) / step <= 4 + 1e-9) {\n      const ticks: number[] = [];\n      for (let value = bottom; value <= top + step / 2; value += step) ticks.push(clean(value));\n      return { min: clean(bottom), max: clean(top), ticks };\n    }\n  }\n  return { min: lo, max: hi, ticks: [lo, hi] };\n}\n\n/** The line through every value, sampled densely on x from 0 to 1 in data units. Monotone cubic tangents keep a smooth curve from swinging past the data. */\nfunction curveFor(values: number[], smooth: boolean): Point[] {\n  if (values.length === 0) return [];\n  if (values.length === 1)\n    return [\n      [0, values[0]!],\n      [1, values[0]!],\n    ];\n  const last = values.length - 1,\n    step = 1 / last;\n  if (!smooth) return values.map((value, index) => [index * step, value]);\n  const slopes = values.slice(1).map((value, index) => (value - values[index]!) / step);\n  const tangents = values.map((_, index) =>\n    index === 0 || index === last\n      ? 0\n      : (Math.sign(slopes[index - 1]!) + Math.sign(slopes[index]!)) *\n          Math.min(\n            Math.abs(slopes[index - 1]!),\n            Math.abs(slopes[index]!),\n            Math.abs(slopes[index - 1]! + slopes[index]!) / 4,\n          ) || 0,\n  );\n  if (last > 1) {\n    tangents[0] = (3 * slopes[0]! - tangents[1]!) / 2;\n    tangents[last] = (3 * slopes[last - 1]! - tangents[last - 1]!) / 2;\n  } else {\n    tangents[0] = slopes[0]!;\n    tangents[1] = slopes[0]!;\n  }\n  const samples = Math.max(1, Math.min(16, Math.round(192 / last)));\n  const points: Point[] = [];\n  for (let index = 0; index < last; index++) {\n    const low = Math.min(values[index]!, values[index + 1]!),\n      high = Math.max(values[index]!, values[index + 1]!);\n    for (let sample = 0; sample < samples; sample++) {\n      const t = sample / samples,\n        t2 = t * t,\n        t3 = t2 * t;\n      const value =\n        (2 * t3 - 3 * t2 + 1) * values[index]! +\n        (t3 - 2 * t2 + t) * step * tangents[index]! +\n        (3 * t2 - 2 * t3) * values[index + 1]! +\n        (t3 - t2) * step * tangents[index + 1]!;\n      points.push([(index + t) * step, clamp(value, low, high)]);\n    }\n  }\n  points.push([1, values[last]!]);\n  return points;\n}\n\n/** Value of a sampled line at x, straight between samples. */\nfunction valueAt(shape: Point[], x: number) {\n  if (!shape.length) return 0;\n  let low = 0,\n    high = shape.length - 1;\n  if (x <= shape[low]![0]) return shape[low]![1];\n  if (x >= shape[high]![0]) return shape[high]![1];\n  while (high - low > 1) {\n    const middle = (low + high) >> 1;\n    if (shape[middle]![0] < x) low = middle;\n    else high = middle;\n  }\n  const [x0, y0] = shape[low]!,\n    [x1, y1] = shape[high]!;\n  return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);\n}\n\n/** Tracks are mutable animation state read by the painter, never by render. */\nconst update = (track: Track, patch: Partial<Track>) => Object.assign(track, patch);\n\nconst grid = Array.from({ length: 241 }, (_, index) => index / 240);\n\nconst subscribeNothing = () => () => {};\n/** Reduced motion is only known in the browser, so the first client render matches the server before it takes effect. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\ntype Scale = { min: MotionValue<number>; max: MotionValue<number> };\nfunction useRowY(value: number, scale: Scale, height: number) {\n  return useTransform(() => {\n    const span = scale.max.get() - scale.min.get() || 1;\n    return Math.round(TOP + (1 - (value - scale.min.get()) / span) * (height - TOP)) + 0.5;\n  });\n}\n\n/** A gridline rides the scale, so a taller range slides it down while new rows fade in above. */\nfunction Gridline({ value, scale, height }: { value: number; scale: Scale; height: number }) {\n  const y = useRowY(value, scale, height);\n  return (\n    <motion.line\n      className=\"stroke-line [stroke-width:1] [shape-rendering:crispEdges]\"\n      x1={0}\n      x2=\"100%\"\n      y1={y}\n      y2={y}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: fadeFast }}\n      transition={{ duration: duration.standard, ease: [...ease.standard] }}\n    />\n  );\n}\n\nfunction TickLabel({\n  value,\n  scale,\n  height,\n  format,\n}: {\n  value: number;\n  scale: Scale;\n  height: number;\n  format: (value: number) => string;\n}) {\n  const y = useRowY(value, scale, height);\n  return (\n    <motion.span\n      className=\"pointer-events-none absolute top-0 left-[10px] block h-0 whitespace-nowrap\"\n      style={{ y }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: fadeFast }}\n      transition={{ duration: duration.standard, ease: [...ease.standard] }}\n    >\n      <span className=\"block -translate-y-1/2 text-(length:--text-xs) leading-none text-ink-3 tabular-nums\">\n        {format(value)}\n      </span>\n    </motion.span>\n  );\n}\n\n/** Axis labels that fit: at most one per 76 pixels, counted back from the latest so the newest date always shows. */\nfunction axisPicks(data: LineChartDatum[], width: number) {\n  const labeled = data.flatMap((item, index) => (item.axisLabel ? [index] : []));\n  if (!width || !labeled.length) return [];\n  const room = Math.max(2, Math.floor(width / 76));\n  const stride = Math.ceil(labeled.length / room);\n  return labeled\n    .reverse()\n    .filter((_, rank) => rank % stride === 0)\n    .reverse();\n}\n\nexport function LineChart({\n  data,\n  series,\n  label,\n  unit = \"\",\n  height = 220,\n  formatValue,\n  formatTick = (value) => compact.format(value),\n  hiddenSeries,\n  defaultHiddenSeries,\n  onHiddenSeriesChange,\n  onActiveChange,\n  loading = false,\n  emptyLabel = \"No data for this range\",\n  legend,\n  categoryLabel = \"Date\",\n  curve = \"smooth\",\n  ref,\n  className,\n}: LineChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const clip = useRef<SVGRectElement>(null);\n  const crosshair = useRef<SVGLineElement>(null);\n  const lines = useRef(new Map<string, SVGPathElement>());\n  const areas = useRef(new Map<string, SVGPathElement>());\n  const dots = useRef(new Map<string, SVGCircleElement>());\n  const groups = useRef(new Map<string, SVGGElement>());\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.3 });\n  const uid = `line${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const last = data.length - 1;\n  const empty = data.length === 0;\n\n  // Hidden series, controlled or not.\n  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenSeries ?? []);\n  const hidden = hiddenSeries ?? ownHidden;\n  const toggle = (key: string) => {\n    const next = hidden.includes(key) ? hidden.filter((item) => item !== key) : [...hidden, key];\n    if (hiddenSeries === undefined) setOwnHidden(next);\n    onHiddenSeriesChange?.(next);\n  };\n  const visible = series.filter((item) => !hidden.includes(item.key));\n  const showLegend = legend ?? series.length > 1;\n\n  // Each series becomes a dense curve in data units; the scale fits only the series on show.\n  const signature = `${curve}|${series.map((item) => item.key).join(\",\")}|${data.map((item) => `${item.key}:${series.map((line) => item.values[line.key] ?? 0).join(\",\")}`).join(\";\")}`;\n  const targets = useMemo(\n    () =>\n      new Map(\n        series.map((line) => [\n          line.key,\n          curveFor(\n            data.map((item) => item.values[line.key] ?? 0),\n            curve === \"smooth\",\n          ),\n        ]),\n      ),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [signature],\n  );\n  let low = Infinity,\n    high = -Infinity;\n  for (const line of visible)\n    for (const item of data) {\n      const value = item.values[line.key] ?? 0;\n      low = Math.min(low, value);\n      high = Math.max(high, value);\n    }\n  const range = Number.isFinite(low) ? niceScale(low, high) : null;\n  const [steady, setSteady] = useState(() => range ?? niceScale(0, 100));\n  if (range && (range.min !== steady.min || range.max !== steady.max)) setSteady(range);\n  const scale = { min: useMotionValue(steady.min), max: useMotionValue(steady.max) };\n\n  // Scrubbing: a new range lets go of the crosshair.\n  const [active, setActive] = useState<number | null>(null);\n  const [seenSignature, setSeenSignature] = useState(signature);\n  if (seenSignature !== signature) {\n    setSeenSignature(signature);\n    setActive(null);\n  }\n  const index = active === null || empty ? null : Math.min(active, last);\n  const scrubbing = index !== null;\n\n  const width = useRef(0);\n  const [plotWidth, setPlotWidth] = useState(0);\n  const tipSize = useRef({ w: 0, h: 0 });\n  const tracks = useRef(new Map<string, Track>());\n  const drawn = useMotionValue(0);\n  const cursor = useMotionValue(1);\n  const tipTargetX = useMotionValue(0),\n    tipTargetY = useMotionValue(0);\n  const tipSpringX = useSpring(tipTargetX, follow),\n    tipSpringY = useSpring(tipTargetY, follow);\n  const live = useRef({ series, hidden });\n  useLayoutEffect(() => {\n    live.current = { series, hidden };\n  });\n\n  // One writer for every drawn attribute: morphs, toggles, the scale, the crosshair, and the measured size all repaint through here.\n  const paint = useCallback(() => {\n    const w = width.current;\n    if (!w) return;\n    const lo = scale.min.get(),\n      span = scale.max.get() - lo || 1;\n    const toY = (value: number) => TOP + (1 - (value - lo) / span) * (height - TOP);\n    const at = cursor.get();\n    const dotYs: number[] = [];\n    for (const line of live.current.series) {\n      const track = tracks.current.get(line.key);\n      if (!track) continue;\n      const p = track.presence;\n      const y = (value: number) => toY(lo + (value - lo) * p);\n      const path = track.shape.length\n        ? `M${track.shape.map(([x, value]) => `${(x * w).toFixed(1)},${y(value).toFixed(1)}`).join(\"L\")}`\n        : \"\";\n      lines.current.get(line.key)?.setAttribute(\"d\", path);\n      areas.current.get(line.key)?.setAttribute(\"d\", path ? `${path}L${w},${height}L0,${height}Z` : \"\");\n      const group = groups.current.get(line.key);\n      if (group) group.style.opacity = String(p);\n      const dot = dots.current.get(line.key);\n      if (dot && track.shape.length) {\n        const cy = y(valueAt(track.shape, at));\n        dot.setAttribute(\"cx\", (at * w).toFixed(1));\n        dot.setAttribute(\"cy\", cy.toFixed(1));\n        if (p > 0.5) dotYs.push(cy);\n      }\n    }\n    clip.current?.setAttribute(\"width\", String(drawn.get() * (w + 16)));\n    const cx = Math.round(at * w) + 0.5;\n    crosshair.current?.setAttribute(\"x1\", String(cx));\n    crosshair.current?.setAttribute(\"x2\", String(cx));\n    // The tooltip sits beside the crosshair, flips sides before it would leave the plot, and centres on the points it describes.\n    const { w: tw, h: th } = tipSize.current;\n    let x = cx + GAP;\n    if (x + tw > w) x = cx - GAP - tw;\n    tipTargetX.set(clamp(x, 0, Math.max(0, w - tw)));\n    const middle = dotYs.length ? dotYs.reduce((sum, value) => sum + value, 0) / dotYs.length : height / 2;\n    tipTargetY.set(clamp(middle - th / 2, 0, Math.max(0, height - th)));\n  }, [cursor, drawn, height, scale.max, scale.min, tipTargetX, tipTargetY]);\n  useMotionValueEvent(cursor, \"change\", paint);\n  useMotionValueEvent(drawn, \"change\", paint);\n  useMotionValueEvent(scale.min, \"change\", paint);\n  useMotionValueEvent(scale.max, \"change\", paint);\n  useLayoutEffect(paint);\n\n  // The plot draws in its own pixels: measure the width, follow resizes, and size the tooltip for its flip.\n  useLayoutEffect(() => {\n    const node = plot.current,\n      bubble = tip.current;\n    if (!node) return;\n    const read = () => {\n      width.current = node.clientWidth;\n      setPlotWidth(node.clientWidth);\n      if (bubble) tipSize.current = { w: bubble.offsetWidth, h: bubble.offsetHeight };\n      paint();\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    if (bubble) observer.observe(bubble);\n    return () => observer.disconnect();\n  }, [paint]);\n\n  // New data morphs every line from the shape on screen, sampled on shared x positions so a range that gains or loses points still flows.\n  const morph = useRef<{ stop: () => void } | null>(null);\n  useEffect(() => {\n    const moves: { track: Track; xs: number[]; from: number[]; to: number[]; target: Point[] }[] = [];\n    for (const [key, target] of targets) {\n      const track = tracks.current.get(key);\n      if (!track) {\n        tracks.current.set(key, { shape: target, presence: live.current.hidden.includes(key) ? 0 : 1, fade: null });\n        continue;\n      }\n      if (!track.shape.length || !target.length || reduced) {\n        update(track, { shape: target });\n        continue;\n      }\n      const xs = [...new Set([...grid, ...target.map(([x]) => x)])].sort((a, b) => a - b);\n      moves.push({\n        track,\n        xs,\n        from: xs.map((x) => valueAt(track.shape, x)),\n        to: xs.map((x) => valueAt(target, x)),\n        target,\n      });\n    }\n    morph.current?.stop();\n    if (!moves.length) {\n      paint();\n      return;\n    }\n    morph.current = animate(0, 1, {\n      ...settle,\n      restDelta: 0.0005,\n      onUpdate: (progress) => {\n        for (const move of moves)\n          update(move.track, {\n            shape: move.xs.map((x, at): Point => [x, move.from[at]! + (move.to[at]! - move.from[at]!) * progress]),\n          });\n        paint();\n      },\n      onComplete: () => {\n        for (const move of moves) update(move.track, { shape: move.target });\n        paint();\n      },\n    });\n    return () => morph.current?.stop();\n  }, [paint, reduced, targets]);\n\n  // A hidden series flattens into the baseline as it fades; the ones that stay rescale around it.\n  const hiddenKey = hidden.join(\"|\");\n  useEffect(() => {\n    for (const [key, track] of tracks.current) {\n      const to = hidden.includes(key) ? 0 : 1;\n      if (track.presence === to) continue;\n      track.fade?.stop();\n      if (reduced) {\n        update(track, { presence: to });\n        continue;\n      }\n      update(track, {\n        fade: animate(track.presence, to, {\n          ...settle,\n          onUpdate: (value) => {\n            update(track, { presence: value });\n            paint();\n          },\n        }),\n      });\n    }\n    paint();\n  }, [hiddenKey, paint, reduced]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (reduced) {\n      scale.min.jump(steady.min);\n      scale.max.jump(steady.max);\n      return;\n    }\n    const controls = [animate(scale.min, steady.min, settle), animate(scale.max, steady.max, settle)];\n    return () => controls.forEach((control) => control.stop());\n  }, [reduced, scale.max, scale.min, steady]);\n\n  // The lines draw left to right the first time the chart is seen with data.\n  useEffect(() => {\n    if (empty) return;\n    if (reduced) {\n      drawn.jump(1);\n      return;\n    }\n    if (!inView || drawn.get() >= 1) return;\n    const controls = animate(drawn, 1, draw);\n    return () => controls.stop();\n  }, [drawn, empty, inView, reduced]);\n\n  // The crosshair appears on the first scrubbed point and glides between the ones after it; the tooltip follows on its own spring.\n  const wasScrubbing = useRef(false);\n  useEffect(() => {\n    if (index === null) {\n      wasScrubbing.current = false;\n      return;\n    }\n    const to = last > 0 ? index / last : 0.5;\n    if (reduced || !wasScrubbing.current) {\n      cursor.jump(to);\n      tipSpringX.jump(tipTargetX.get());\n      tipSpringY.jump(tipTargetY.get());\n    }\n    wasScrubbing.current = true;\n    if (reduced) return;\n    const controls = animate(cursor, to, glide);\n    return () => controls.stop();\n  }, [cursor, index, last, reduced, tipSpringX, tipSpringY, tipTargetX, tipTargetY]);\n\n  const onActive = useRef(onActiveChange);\n  useLayoutEffect(() => {\n    onActive.current = onActiveChange;\n  });\n  useEffect(() => {\n    onActive.current?.(index, index === null ? null : (data[index] ?? null));\n  }, [index]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const pointAt = (clientX: number) => {\n    const rect = plot.current?.getBoundingClientRect();\n    if (!rect?.width || last < 1) return Math.max(0, last);\n    return clamp(Math.round(((clientX - rect.left) / rect.width) * last), 0, last);\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && scrubbing) {\n      event.preventDefault();\n      setActive(null);\n      return;\n    }\n    const from = index ?? last + 1,\n      page = Math.max(1, Math.round(data.length / 6));\n    const next = (\n      {\n        ArrowLeft: from - 1,\n        ArrowDown: from - 1,\n        ArrowRight: index === null ? last : from + 1,\n        ArrowUp: index === null ? last : from + 1,\n        PageDown: from - page,\n        PageUp: from + page,\n        Home: 0,\n        End: last,\n      } as Record<string, number>\n    )[event.key];\n    if (next === undefined || empty) return;\n    event.preventDefault();\n    setActive(clamp(next, 0, last));\n  };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId);\n    setActive(pointAt(event.clientX));\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (!empty && (event.pointerType === \"mouse\" || event.buttons)) setActive(pointAt(event.clientX));\n  };\n\n  const format = (value: number, line: LineChartSeries) =>\n    `${formatValue ? formatValue(value, line) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n  const reading = index === null ? null : data[index];\n  const valueText = reading\n    ? `${reading.label}: ${visible.map((line) => `${line.label} ${format(reading.values[line.key] ?? 0, line)}`).join(\", \")}`\n    : empty\n      ? emptyLabel\n      : `${visible.length} of ${series.length} series shown`;\n  const summary = empty\n    ? `${label}. ${emptyLabel}.`\n    : `${label}, ${data[0]!.label} to ${data[last]!.label}. ${visible.map((line) => `${line.label}, latest ${format(data[last]!.values[line.key] ?? 0, line)}`).join(\". \")}.`;\n  const picks = axisPicks(data, plotWidth);\n  const allHidden = !empty && visible.length === 0;\n  const tipX = reduced ? tipTargetX : tipSpringX,\n    tipY = reduced ? tipTargetY : tipSpringY;\n\n  return (\n    <figure\n      ref={figure}\n      className={cn(\"m-0 grid min-w-0 gap-4 font-body tracking-body text-ink\", className)}\n      aria-label={label}\n      aria-busy={loading || undefined}\n    >\n      {/* Series toggles: a plain line swatch beside each name. A hidden series keeps its place, its swatch empties and its name steps back. */}\n      {showLegend && (\n        <div className=\"-mx-2 flex min-w-0 flex-wrap gap-x-1 gap-y-0.5\" role=\"group\" aria-label={`${label} series`}>\n          {series.map((line, at) => {\n            const off = hidden.includes(line.key);\n            return (\n              <button\n                key={line.key}\n                type=\"button\"\n                className={cn(\n                  \"inline-flex min-h-8 cursor-pointer items-center gap-2 rounded-button border-0 bg-transparent py-0 pr-[10px] pl-2 text-sm leading-body [-webkit-tap-highlight-color:transparent] [transition:color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] hover:text-ink active:[transform:scale(.97)] motion-reduce:transition-none\",\n                  off ? \"text-ink-3\" : \"text-ink-2\",\n                )}\n                aria-pressed={!off}\n                onClick={() => toggle(line.key)}\n                style={{ \"--series\": colorOf(line, at) } as CSSProperties}\n              >\n                <span\n                  className={cn(\n                    \"relative h-[10px] w-[14px] flex-none before:absolute before:top-1/2 before:left-0 before:h-[2.5px] before:w-full before:origin-left before:rounded-[2px] before:content-[''] before:[transition:transform_var(--duration-standard)_var(--ease-spring),opacity_var(--duration-fast)_var(--ease-standard)] motion-reduce:before:transition-none\",\n                    line.dashed\n                      ? \"before:bg-[repeating-linear-gradient(to_right,var(--series)_0_4px,transparent_4px_7px)]\"\n                      : \"before:bg-(--series)\",\n                    off\n                      ? \"before:opacity-45 before:[transform:translateY(-50%)_scaleX(.35)]\"\n                      : \"before:[transform:translateY(-50%)_scaleX(1)]\",\n                  )}\n                  data-dashed={line.dashed || undefined}\n                  aria-hidden=\"true\"\n                />\n                <span\n                  className={cn(\n                    \"whitespace-nowrap transition-opacity duration-fast ease-standard\",\n                    off && \"line-through decoration-[color-mix(in_oklab,currentColor_55%,transparent)] opacity-72\",\n                  )}\n                >\n                  {line.label}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      )}\n      {/* Plot, right-hand value axis, and the date axis beneath. */}\n      <div\n        className=\"relative grid min-w-0 grid-cols-[minmax(0,1fr)_48px] grid-rows-[auto_26px]\"\n        data-scrubbing={scrubbing || undefined}\n        data-loading={loading || undefined}\n      >\n        <div\n          ref={plot}\n          className=\"relative min-w-0 touch-pan-y outline-none select-none [grid-area:1/1] [-webkit-tap-highlight-color:transparent]\"\n          style={{ height }}\n          role=\"slider\"\n          tabIndex={empty ? -1 : 0}\n          aria-label={`${label}, explore by ${categoryLabel.toLowerCase()}`}\n          aria-orientation=\"horizontal\"\n          aria-valuemin={1}\n          aria-valuemax={Math.max(1, data.length)}\n          aria-valuenow={(index ?? last) + 1}\n          aria-valuetext={valueText}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={(event) => {\n            if (event.pointerType !== \"mouse\") setActive(null);\n          }}\n          onPointerCancel={() => setActive(null)}\n          onPointerLeave={(event) => {\n            if (event.pointerType === \"mouse\") setActive(null);\n          }}\n          onKeyDown={onKeyDown}\n          onBlur={() => setActive(null)}\n          onFocus={(event) => {\n            if (event.currentTarget.matches(\":focus-visible\") && !empty) setActive((current) => current ?? last);\n          }}\n        >\n          <svg className=\"block overflow-visible\" width=\"100%\" height={height} aria-hidden=\"true\" focusable=\"false\">\n            <defs>\n              <clipPath id={`${uid}-draw`} clipPathUnits=\"userSpaceOnUse\">\n                <rect ref={clip} x={-8} y={-16} width={0} height={height + 32} />\n              </clipPath>\n            </defs>\n            <AnimatePresence initial={false}>\n              {steady.ticks.slice(1).map((value) => (\n                <Gridline key={value} value={value} scale={scale} height={height} />\n              ))}\n            </AnimatePresence>\n            <line\n              className=\"stroke-line-strong [stroke-width:1] [shape-rendering:crispEdges]\"\n              x1={0}\n              x2=\"100%\"\n              y1={height - 0.5}\n              y2={height - 0.5}\n            />\n            <line\n              ref={crosshair}\n              className={cn(\n                \"stroke-line-strong [stroke-width:1] [shape-rendering:crispEdges] transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n                scrubbing ? \"opacity-100\" : \"opacity-0\",\n              )}\n              y1={TOP - 4}\n              y2={height}\n            />\n            <g\n              className={cn(\n                \"transition-opacity duration-standard ease-standard motion-reduce:transition-none\",\n                loading && \"animate-line-chart-breathe opacity-32 motion-reduce:animate-none\",\n              )}\n              clipPath={`url(#${uid}-draw)`}\n            >\n              {series.map((line, at) => (\n                <g\n                  key={line.key}\n                  ref={(node) => {\n                    if (node) groups.current.set(line.key, node);\n                    else groups.current.delete(line.key);\n                  }}\n                  style={{ \"--series\": colorOf(line, at) } as CSSProperties}\n                >\n                  {(line.area ?? at === 0) && (\n                    <path\n                      ref={(node) => {\n                        if (node) areas.current.set(line.key, node);\n                        else areas.current.delete(line.key);\n                      }}\n                      className=\"fill-[color-mix(in_oklab,var(--series)_9%,transparent)] stroke-none\"\n                    />\n                  )}\n                  <path\n                    ref={(node) => {\n                      if (node) lines.current.set(line.key, node);\n                      else lines.current.delete(line.key);\n                    }}\n                    className={cn(\n                      \"fill-none stroke-(--series) [stroke-linecap:round] [stroke-linejoin:round] transition-[stroke] duration-standard ease-standard motion-reduce:transition-none\",\n                      line.dashed ? \"[stroke-dasharray:4_5] [stroke-width:1.75]\" : \"[stroke-width:2]\",\n                    )}\n                    data-dashed={line.dashed || undefined}\n                  />\n                </g>\n              ))}\n            </g>\n            {series.map((line, at) => (\n              <circle\n                key={line.key}\n                ref={(node) => {\n                  if (node) dots.current.set(line.key, node);\n                  else dots.current.delete(line.key);\n                }}\n                className={cn(\n                  \"fill-(--series) stroke-surface [stroke-width:2] transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n                  scrubbing && !hidden.includes(line.key) ? \"opacity-100\" : \"opacity-0\",\n                )}\n                r={4}\n                data-hidden={hidden.includes(line.key) || undefined}\n                style={{ \"--series\": colorOf(line, at) } as CSSProperties}\n              />\n            ))}\n          </svg>\n          {/* The tooltip floats over the plot, so it is the one layer here with a shadow. Springs carry its position; opacity and a small scale bring it in. */}\n          <motion.div\n            ref={tip}\n            className={cn(\n              \"pointer-events-none absolute top-0 left-0 z-1 grid max-w-[min(220px,100%)] min-w-[140px] gap-1 rounded-[14px] border border-line bg-surface px-3 py-[10px] shadow-floating [transition:opacity_var(--duration-fast)_var(--ease-standard),scale_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n              scrubbing ? \"opacity-100 [scale:1]\" : \"opacity-0 [scale:.96]\",\n            )}\n            style={{ x: tipX, y: tipY }}\n            aria-hidden=\"true\"\n          >\n            <p className=\"m-0 mb-0.5 text-xs leading-body whitespace-nowrap text-ink-2 tabular-nums\">\n              {(reading ?? data[last])?.label ?? \"\"}\n            </p>\n            {visible.map((line) => (\n              <p\n                key={line.key}\n                className=\"m-0 flex min-w-0 items-center gap-2 text-sm leading-body\"\n                style={{ \"--series\": colorOf(line, series.indexOf(line)) } as CSSProperties}\n              >\n                <span\n                  className={cn(\n                    \"h-[2.5px] w-[10px] flex-none rounded-[2px]\",\n                    line.dashed\n                      ? \"bg-[repeating-linear-gradient(to_right,var(--series)_0_3px,transparent_3px_5px)]\"\n                      : \"bg-(--series)\",\n                  )}\n                  data-dashed={line.dashed || undefined}\n                />\n                <span className=\"min-w-0 flex-1 truncate text-ink-2\">{line.label}</span>\n                <span className=\"ml-2 flex-none font-medium whitespace-nowrap text-ink tabular-nums\">\n                  {format((reading ?? data[last])?.values[line.key] ?? 0, line)}\n                </span>\n              </p>\n            ))}\n          </motion.div>\n          <AnimatePresence initial={false}>\n            {(empty || allHidden) && !loading && (\n              <motion.p\n                key={empty ? \"empty\" : \"hidden\"}\n                className=\"pointer-events-none absolute inset-0 m-0 grid place-items-center text-(length:--text-sm) text-ink-3\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: fadeFast }}\n              >\n                {empty ? emptyLabel : \"Choose a series to show\"}\n              </motion.p>\n            )}\n            {/* First load with nothing to keep on screen: a calm band that breathes where the lines will be. */}\n            {loading && empty && (\n              <motion.span\n                key=\"skeleton\"\n                className=\"absolute inset-x-0 top-[30%] bottom-[25%] animate-line-chart-breathe rounded-[12px] bg-[linear-gradient(to_bottom,transparent,color-mix(in_oklab,var(--ink)_5%,transparent))] motion-reduce:animate-none\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: fadeFast }}\n                aria-hidden=\"true\"\n              />\n            )}\n          </AnimatePresence>\n        </div>\n        <div className=\"relative min-w-0 [grid-area:1/2]\" aria-hidden=\"true\">\n          <AnimatePresence initial={false}>\n            {steady.ticks.map((value) => (\n              <TickLabel key={value} value={value} scale={scale} height={height} format={formatTick} />\n            ))}\n          </AnimatePresence>\n        </div>\n        <div className=\"relative min-w-0 [grid-area:2/1]\" aria-hidden=\"true\">\n          <AnimatePresence initial={false}>\n            {picks.map((at) => {\n              const share = last > 0 ? at / last : 0.5;\n              return (\n                <motion.span\n                  key={`${data[at]!.key}:${data[at]!.axisLabel}`}\n                  className=\"absolute top-2 text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums\"\n                  style={{ left: `${share * 100}%`, translateX: `${-share * 100}%` }}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: fadeFast }}\n                  transition={{ duration: duration.standard, ease: [...ease.standard] }}\n                >\n                  {data[at]!.axisLabel}\n                </motion.span>\n              );\n            })}\n          </AnimatePresence>\n        </div>\n      </div>\n      <p className=\"sr-only\">{summary}</p>\n      {!empty && (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{label}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">{categoryLabel}</th>\n                {series.map((line) => (\n                  <th key={line.key} scope=\"col\">\n                    {line.label}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {data.map((item) => (\n                <tr key={item.key}>\n                  <th scope=\"row\">{item.label}</th>\n                  {series.map((line) => (\n                    <td key={line.key}>{format(item.values[line.key] ?? 0, line)}</td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      )}\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {loading ? \"Loading\" : \"\"}\n      </p>\n    </figure>\n  );\n}\n\nexport default LineChart;\n",
      "type": "registry:component",
      "target": "@components/iris/line-chart/line-chart.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/line-chart",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "LineChart",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://irisui.dev/components/line-chart"
  }
}
