{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streamgraph",
  "type": "registry:ui",
  "title": "Streamgraph",
  "description": "Layered streams on a wiggle baseline that morph between ranges, with a layer you can isolate and read week by week.",
  "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/streamgraph/streamgraph.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\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 { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface StreamgraphSeries {\n  /** Stable identity. A layer keeps its place and color across data changes, so switching datasets morphs it. */\n  key: string;\n  /** Name in the legend, the direct label, the tooltip, and the data table. */\n  label: string;\n  /** Any CSS color. Defaults to the chart series for the first four layers (the first in the accent), then neutral steps. */\n  color?: string;\n}\n\nexport interface StreamgraphDatum {\n  /** Stable identity, such as an ISO week. */\n  key: string;\n  /** Full label for the tooltip and screen readers, such as \"Week of Sep 14\". */\n  label: string;\n  /** Short label on the time axis. Leave it out to keep the axis quiet at that point. */\n  axisLabel?: string;\n  /** One value per series key. Missing values count as zero; negatives are treated as zero. */\n  values: Record<string, number | undefined>;\n}\n\n/** Use a streamgraph for how the mix of several parts changes over time, when the overall shape matters more than exact values. */\nexport interface StreamgraphProps {\n  data: StreamgraphDatum[];\n  /** Layers from the centre out: the first runs through the middle of the stream, the rest alternate above and below it. */\n  series: StreamgraphSeries[];\n  /** What is measured, such as \"Support tickets by topic\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each value, such as \"tickets\". */\n  unit?: string;\n  /** Plot height in pixels. The width follows the container. */\n  height?: number;\n  /** \"wiggle\" minimises layer slopes for a flowing stream, \"silhouette\" centres the stack, \"zero\" stacks from a flat baseline. */\n  offset?: \"wiggle\" | \"silhouette\" | \"zero\";\n  formatValue?: (value: number, series: StreamgraphSeries) => string;\n  /** Controlled hidden layer keys. A hidden layer thins to nothing and the rest reflow. */\n  hiddenSeries?: string[];\n  defaultHiddenSeries?: string[];\n  onHiddenSeriesChange?: (hidden: string[]) => void;\n  /** Called as the reading moves, and with nulls when it leaves. */\n  onActiveChange?: (index: number | null, seriesKey: string | null) => void;\n  /** Layer toggles above the plot. */\n  legend?: boolean;\n  /** Names each layer inside its widest stretch when it is thick enough to hold the text. */\n  directLabels?: boolean;\n  /** Header of the first column in the screen reader table. */\n  categoryLabel?: string;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Hover = { index: number; key: string | null; y: number | null };\ntype Frame = { lower: number[][]; upper: number[][]; toY: (value: number) => number; center: number[] };\n\nconst { spring } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.0005) => {\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 swell = physical({ visualDuration: 0.9, bounce: 0 });\nconst glide = physical(spring.snappy, 0.01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: 0.01 };\n\n/** Samples across the plot: every layer is resampled onto the same grid, so datasets of any length morph into each other. */\nconst N = 161;\nconst PAD = 10,\n  GAP = 14;\n/** Neutral steps by layer. Stacked from the centre out, neighbours always sit at least ten steps apart; the accent is kept for the layer in focus. */\n/** The first four layers take the chart series in order (the first in the accent); later layers fall back to neutral steps that alternate in lightness. */\nconst SHADES = [30, 18, 24, 14];\nconst colorOf = (item: StreamgraphSeries, index: number) =>\n  item.color ??\n  (index < 4\n    ? `var(--series-${index + 1})`\n    : `color-mix(in oklab, var(--ink) ${SHADES[(index - 4) % SHADES.length]}%, var(--surface))`);\n/** The first layer sits in the middle of the stream and the rest alternate above and below it; a flat baseline stacks them in order. */\nfunction stackOrder(count: number, mode: StreamgraphProps[\"offset\"]) {\n  const order = Array.from({ length: count }, (_, index) => index);\n  if (mode === \"zero\") return order;\n  return order.reduce<number[]>(\n    (list, index) => (index === 0 ? [0] : index % 2 ? [...list, index] : [index, ...list]),\n    [],\n  );\n}\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\n/** Resamples values onto the shared grid with monotone cubic tangents, so curves never swing past the data or below zero. */\nfunction resample(raw: number[]): number[] {\n  const values = raw.map((value) => Math.max(0, value || 0));\n  const out = Array.from({ length: N }, () => 0);\n  if (!values.length) return out.fill(0);\n  if (values.length === 1) return out.fill(values[0]!);\n  const last = values.length - 1;\n  const slopes = values.slice(1).map((value, index) => value - values[index]!);\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] = tangents[1] = slopes[0]!;\n  }\n  for (let k = 0; k < N; k++) {\n    const at = (k / (N - 1)) * last,\n      seg = Math.min(last - 1, Math.floor(at)),\n      t = at - seg,\n      t2 = t * t,\n      t3 = t2 * t;\n    const a = values[seg]!,\n      b = values[seg + 1]!;\n    const value =\n      (2 * t3 - 3 * t2 + 1) * a +\n      (t3 - 2 * t2 + t) * tangents[seg]! +\n      (3 * t2 - 2 * t3) * b +\n      (t3 - t2) * tangents[seg + 1]!;\n    out[k] = clamp(value, Math.min(a, b), Math.max(a, b));\n  }\n  return out;\n}\n\n/** The baseline under the stack. Wiggle follows Byron and Wattenberg, then removes its linear drift so the stream stays level. */\nfunction baseline(layers: number[][], mode: StreamgraphProps[\"offset\"]): number[] {\n  const g = Array.from({ length: N }, () => 0);\n  const totals = g.map((_, j) => layers.reduce((sum, layer) => sum + layer[j]!, 0));\n  if (mode === \"zero\" || !layers.length) return g;\n  if (mode === \"silhouette\") return totals.map((total) => -total / 2);\n  for (let j = 1; j < N; j++) {\n    let s1 = 0,\n      s2 = 0,\n      below = 0;\n    for (const layer of layers) {\n      const change = layer[j]! - layer[j - 1]!;\n      s1 += layer[j]!;\n      s2 += (below + change / 2) * layer[j]!;\n      below += change;\n    }\n    g[j] = g[j - 1]! - (s1 ? s2 / s1 : 0);\n  }\n  // Least squares line through the centre of the stream, subtracted so it never tilts off the plot.\n  const centers = g.map((value, j) => value + totals[j]! / 2);\n  const mx = (N - 1) / 2,\n    my = centers.reduce((sum, value) => sum + value, 0) / N;\n  let num = 0,\n    den = 0;\n  centers.forEach((value, j) => {\n    num += (j - mx) * (value - my);\n    den += (j - mx) ** 2;\n  });\n  const slope = den ? num / den : 0;\n  return g.map((value, j) => value - (my + slope * (j - mx)));\n}\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\n/** Axis labels that fit: at most one per 72 pixels, counted back from the latest. */\nfunction axisPicks(data: StreamgraphDatum[], width: number) {\n  const labeled = data.flatMap((item, index) => (item.axisLabel ? [index] : []));\n  if (!width || !labeled.length) return [];\n  const stride = Math.ceil(labeled.length / Math.max(2, Math.floor(width / 72)));\n  return labeled\n    .reverse()\n    .filter((_, rank) => rank % stride === 0)\n    .reverse();\n}\n\nexport function Streamgraph({\n  data,\n  series,\n  label,\n  unit = \"\",\n  height = 260,\n  offset = \"wiggle\",\n  formatValue,\n  hiddenSeries,\n  defaultHiddenSeries,\n  onHiddenSeriesChange,\n  onActiveChange,\n  legend = true,\n  directLabels = true,\n  categoryLabel = \"Date\",\n  emptyLabel = \"No data for this range\",\n  ref,\n  className,\n}: StreamgraphProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const paths = useRef(new Map<string, SVGPathElement>());\n  const labels = useRef(new Map<string, SVGTextElement>());\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.3 });\n  const last = data.length - 1;\n  const empty = data.length === 0;\n\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\n  // Each layer becomes a dense curve on the shared grid; the painter blends from what is on screen to it.\n  const signature = `${series.map((item) => item.key).join(\",\")}|${data.map((item) => series.map((line) => item.values[line.key] ?? 0).join(\",\")).join(\";\")}`;\n  const targets = useMemo(\n    () => new Map(series.map((line) => [line.key, resample(data.map((item) => item.values[line.key] ?? 0))])),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [signature],\n  );\n\n  const [hover, setHover] = useState<Hover | null>(null);\n  const [seenSignature, setSeenSignature] = useState(signature);\n  if (seenSignature !== signature) {\n    setSeenSignature(signature);\n    setHover(null);\n  }\n  const index = hover && !empty ? Math.min(hover.index, last) : null;\n  const [legendKey, setLegendKey] = useState<string | null>(null);\n  const wanted = hover?.key ?? legendKey;\n  const activeKey = wanted && visible.some((item) => item.key === wanted) ? wanted : null;\n\n  const width = useRef(0);\n  const [plotWidth, setPlotWidth] = useState(0);\n  const shapes = useRef(new Map<string, number[]>());\n  const presence = useRef(new Map<string, { value: number; stop: (() => void) | null }>());\n  const frame = useRef<Frame | null>(null);\n  const spots = useRef(new Map<string, number>());\n  const grow = useMotionValue(0);\n  const live = useRef({ series, offset });\n  useLayoutEffect(() => {\n    live.current = { series, offset };\n  });\n\n  // One writer for every drawn attribute: morphs, toggles, the first swell, and resizes all repaint through here.\n  const paint = useCallback(() => {\n    const w = width.current;\n    if (!w) return;\n    const { series: order, offset: mode } = live.current;\n    const layers = order.map((line) => {\n      const shape = shapes.current.get(line.key);\n      const p = presence.current.get(line.key)?.value ?? 1;\n      return shape ? shape.map((value) => value * p) : Array.from({ length: N }, () => 0);\n    });\n    const stack = stackOrder(order.length, mode);\n    const g = baseline(\n      stack.map((i) => layers[i]!),\n      mode,\n    );\n    const lower = Array.from({ length: order.length }, (): number[] => []),\n      upper = Array.from({ length: order.length }, (): number[] => []);\n    let running = g.slice();\n    for (const i of stack) {\n      const layer = layers[i]!;\n      lower[i] = running;\n      running = running.map((value, j) => value + layer[j]!);\n      upper[i] = running;\n    }\n    let lo = Infinity,\n      hi = -Infinity;\n    for (let j = 0; j < N; j++) {\n      lo = Math.min(lo, g[j]!);\n      hi = Math.max(hi, running[j]!);\n    }\n    if (!Number.isFinite(lo) || hi - lo < 1e-9) {\n      lo = -1;\n      hi = 1;\n    }\n    const span = hi - lo,\n      s = grow.get();\n    const center = g.map((value, j) => (value + running[j]!) / 2);\n    const toY = (value: number) => PAD + (1 - (value - lo) / span) * (height - PAD * 2);\n    const yAt = (value: number, j: number) => toY(center[j]! + (value - center[j]!) * s);\n    frame.current = { lower, upper, toY, center };\n    const xs = Array.from({ length: N }, (_, j) => (j / (N - 1)) * w);\n    order.forEach((line, i) => {\n      const top = upper[i]!,\n        bottom = lower[i]!;\n      const node = paths.current.get(line.key);\n      if (node) {\n        let d = \"\";\n        for (let j = 0; j < N; j++) d += `${j ? \"L\" : \"M\"}${xs[j]!.toFixed(1)},${yAt(top[j]!, j).toFixed(1)}`;\n        for (let j = N - 1; j >= 0; j--) d += `L${xs[j]!.toFixed(1)},${yAt(bottom[j]!, j).toFixed(1)}`;\n        node.setAttribute(\"d\", `${d}Z`);\n      }\n      // The label sits where its layer is thickest, and holds its spot until another stretch is clearly thicker.\n      const text = labels.current.get(line.key);\n      if (!text) return;\n      let best = 4;\n      for (let j = 4; j < N - 4; j++) if (top[j]! - bottom[j]! > top[best]! - bottom[best]!) best = j;\n      const held = spots.current.get(line.key);\n      const j = held !== undefined && top[held]! - bottom[held]! >= (top[best]! - bottom[best]!) * 0.8 ? held : best;\n      spots.current.set(line.key, j);\n      const thickness = ((top[j]! - bottom[j]!) / span) * (height - PAD * 2) * s;\n      const room = text.getComputedTextLength?.() ?? 60;\n      const x = clamp(xs[j]!, room / 2 + 6, w - room / 2 - 6);\n      text.setAttribute(\"x\", x.toFixed(1));\n      text.setAttribute(\"y\", yAt((top[j]! + bottom[j]!) / 2, j).toFixed(1));\n      text.style.opacity = thickness >= 19 && room + 16 < w ? \"1\" : \"0\";\n    });\n  }, [grow, height]);\n\n  useLayoutEffect(() => {\n    const node = plot.current;\n    if (!node) return;\n    const read = () => {\n      width.current = node.clientWidth;\n      setPlotWidth(node.clientWidth);\n      paint();\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [paint]);\n\n  // New data blends every layer from the shape on screen; a layer that arrives grows out of the one below it.\n  const morph = useRef<{ stop: () => void } | null>(null);\n  useEffect(() => {\n    const moves: { key: string; from: number[]; to: number[] }[] = [];\n    for (const [key, to] of targets) {\n      const from = shapes.current.get(key);\n      if (!from || reduced) {\n        shapes.current.set(key, to);\n        continue;\n      }\n      moves.push({ key, from, to });\n    }\n    for (const key of shapes.current.keys()) if (!targets.has(key)) shapes.current.delete(key);\n    morph.current?.stop();\n    if (!moves.length) {\n      paint();\n      return;\n    }\n    morph.current = animate(0, 1, {\n      ...settle,\n      onUpdate: (t) => {\n        for (const move of moves)\n          shapes.current.set(\n            move.key,\n            move.from.map((value, j) => value + (move.to[j]! - value) * t),\n          );\n        paint();\n      },\n      onComplete: () => {\n        for (const move of moves) shapes.current.set(move.key, move.to);\n        paint();\n      },\n    });\n    return () => morph.current?.stop();\n  }, [paint, reduced, targets]);\n\n  // A hidden layer thins to nothing while the others close over it.\n  const hiddenKey = hidden.join(\"|\");\n  useEffect(() => {\n    for (const line of series) {\n      const to = hidden.includes(line.key) ? 0 : 1;\n      const track = presence.current.get(line.key);\n      if (!track) {\n        presence.current.set(line.key, { value: to, stop: null });\n        continue;\n      }\n      if (track.value === to) continue;\n      track.stop?.();\n      if (reduced) {\n        track.value = to;\n        track.stop = null;\n        continue;\n      }\n      const controls = animate(track.value, to, {\n        ...settle,\n        onUpdate: (value) => {\n          track.value = value;\n          paint();\n        },\n      });\n      track.stop = () => controls.stop();\n    }\n    paint();\n  }, [hiddenKey, paint, reduced, series.length]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The first time the chart is seen, the stream swells out of a hairline along its centre.\n  useEffect(() => {\n    if (empty) return;\n    if (reduced) {\n      grow.jump(1);\n      paint();\n      return;\n    }\n    if (!inView || grow.get() >= 1) return;\n    const controls = animate(grow, 1, { ...swell, onUpdate: paint });\n    return () => controls.stop();\n  }, [empty, grow, inView, paint, reduced]);\n\n  // The crosshair glides between points; the tooltip follows on its own spring and never leaves the plot.\n  const cursor = useMotionValue(0);\n  const cursorSpring = useSpring(cursor, follow);\n  const tipX = useMotionValue(0),\n    tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow),\n    tipSpringY = useSpring(tipY, follow);\n  const wasActive = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current,\n      w = width.current;\n    if (index === null || !bubble || !w) {\n      wasActive.current = false;\n      return;\n    }\n    const x = last > 0 ? (index / last) * w : w / 2;\n    let y = hover?.y ?? null;\n    const f = frame.current;\n    if (y === null && f && activeKey) {\n      const i = series.findIndex((line) => line.key === activeKey),\n        j = Math.round((x / w) * (N - 1));\n      if (i >= 0) y = f.toY((f.upper[i]![j]! + f.lower[i]![j]!) / 2);\n    }\n    const tw = bubble.offsetWidth,\n      th = bubble.offsetHeight;\n    let left = x + GAP;\n    if (left + tw > w) left = x - GAP - tw;\n    const top = clamp((y ?? height / 2) - th / 2, 0, Math.max(0, height - th));\n    const instant = !wasActive.current || reduced;\n    cursor.set(x);\n    tipX.set(clamp(left, 0, Math.max(0, w - tw)));\n    tipY.set(top);\n    if (instant) {\n      cursorSpring.jump(x);\n      tipSpringX.jump(tipX.get());\n      tipSpringY.jump(top);\n    }\n    wasActive.current = true;\n  });\n\n  const onActive = useRef(onActiveChange);\n  useLayoutEffect(() => {\n    onActive.current = onActiveChange;\n  });\n  useEffect(() => {\n    onActive.current?.(index, activeKey);\n  }, [index, activeKey]);\n\n  // The layer under the pointer, found in the frame that is on screen.\n  const read = (clientX: number, clientY: number): Hover | null => {\n    const rect = plot.current?.getBoundingClientRect(),\n      f = frame.current;\n    if (!rect?.width || empty) return null;\n    const share = clamp((clientX - rect.left) / rect.width, 0, 1),\n      y = clientY - rect.top;\n    const at = last > 0 ? Math.round(share * last) : 0,\n      j = Math.round(share * (N - 1));\n    let key: string | null = null;\n    if (f)\n      series.forEach((line, i) => {\n        if (hidden.includes(line.key)) return;\n        const top = f.toY(f.upper[i]![j]!),\n          bottom = f.toY(f.lower[i]![j]!);\n        if (y >= top - 1 && y <= bottom + 1) key = line.key;\n      });\n    return { index: at, key, y };\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" || event.buttons) setHover(read(event.clientX, event.clientY));\n  };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId);\n    setHover(read(event.clientX, event.clientY));\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.key === \"Escape\" && hover) {\n      event.preventDefault();\n      setHover(null);\n      return;\n    }\n    const from = index ?? last;\n    const keys = visible.map((line) => line.key),\n      at = activeKey ? keys.indexOf(activeKey) : -1;\n    const page = Math.max(1, Math.round(data.length / 6));\n    const moves: Record<string, () => Hover> = {\n      ArrowLeft: () => ({ index: clamp(from - 1, 0, last), key: activeKey, y: null }),\n      ArrowRight: () => ({ index: clamp(index === null ? last : from + 1, 0, last), key: activeKey, y: null }),\n      PageDown: () => ({ index: clamp(from - page, 0, last), key: activeKey, y: null }),\n      PageUp: () => ({ index: clamp(from + page, 0, last), key: activeKey, y: null }),\n      Home: () => ({ index: 0, key: activeKey, y: null }),\n      End: () => ({ index: last, key: activeKey, y: null }),\n      ArrowUp: () => ({\n        index: from,\n        key: keys[at < 0 ? keys.length - 1 : Math.min(keys.length - 1, at + 1)] ?? null,\n        y: null,\n      }),\n      ArrowDown: () => ({ index: from, key: keys[at < 0 ? 0 : Math.max(0, at - 1)] ?? null, y: null }),\n    };\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    setHover(move());\n  };\n\n  const format = (value: number, line: StreamgraphSeries) =>\n    `${formatValue ? formatValue(value, line) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n  const reading = index === null ? null : data[index];\n  const total = (datum: StreamgraphDatum) =>\n    visible.reduce((sum, line) => sum + Math.max(0, datum.values[line.key] ?? 0), 0);\n  const announce = reading\n    ? `${reading.label}. ${\n        activeKey\n          ? `${series.find((line) => line.key === activeKey)?.label} ${format(\n              reading.values[activeKey] ?? 0,\n              series.find((line) => line.key === activeKey)!,\n            )}, `\n          : \"\"\n      }${visible.length} layers, ${grouped.format(total(reading))}${unit ? ` ${unit}` : \"\"} in total`\n    : \"\";\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 tipLeft = reduced ? tipX : tipSpringX,\n    tipTop = reduced ? tipY : 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    >\n      {/* Layer toggles: a rect swatch mirrors the area it names. Hover or focus previews that layer in the plot. */}\n      {legend && series.length > 1 && (\n        <div\n          className=\"-mx-2 flex min-w-0 flex-wrap gap-x-1 gap-y-0.5\"\n          role=\"group\"\n          aria-label={`${label} layers`}\n          onPointerLeave={() => setLegendKey(null)}\n        >\n          {series.map((line, at) => {\n            const off = hidden.includes(line.key),\n              lit = activeKey === 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),opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:text-ink active:[transform:scale(.97)] motion-reduce:transition-none\",\n                  off ? \"text-ink-3\" : lit ? \"text-ink\" : \"text-ink-2\",\n                  lit && \"bg-[color-mix(in_oklab,var(--ink)_5%,transparent)]\",\n                  activeKey !== null && !lit && \"opacity-56\",\n                )}\n                aria-pressed={!off}\n                data-active={activeKey === line.key || undefined}\n                data-dim={(activeKey !== null && activeKey !== line.key) || undefined}\n                onClick={() => toggle(line.key)}\n                onPointerEnter={(event) => {\n                  if (event.pointerType === \"mouse\") setLegendKey(line.key);\n                }}\n                onFocus={(event) => {\n                  if (event.currentTarget.matches(\":focus-visible\")) setLegendKey(line.key);\n                }}\n                onBlur={() => setLegendKey(null)}\n                style={{ \"--series\": colorOf(line, at) } as CSSProperties}\n              >\n                <span\n                  className={cn(\n                    \"size-[10px] flex-none rounded-[3px] bg-(--series) [transition:transform_var(--duration-standard)_var(--ease-spring),opacity_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n                    off && \"opacity-35 [transform:scale(.6)]\",\n                  )}\n                  aria-hidden=\"true\"\n                />\n                <span\n                  className={cn(\n                    \"whitespace-nowrap\",\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      <div\n        className=\"relative grid min-w-0 grid-rows-[auto_26px]\"\n        data-active={index !== null || undefined}\n        data-layer={activeKey ? true : undefined}\n      >\n        {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the plot is a focusable chart widget explored by pointer and arrow keys; <fieldset> would add form semantics */}\n        <div\n          ref={plot}\n          className=\"relative min-w-0 cursor-crosshair touch-pan-y outline-none select-none [-webkit-tap-highlight-color:transparent]\"\n          style={{ height }}\n          role=\"group\"\n          tabIndex={empty ? -1 : 0}\n          aria-roledescription=\"stream graph\"\n          aria-label={`${label}. Use left and right arrows to move through time, up and down to change layer.`}\n          onPointerMove={onPointerMove}\n          onPointerDown={onPointerDown}\n          onPointerUp={(event) => {\n            if (event.pointerType !== \"mouse\") setHover(null);\n          }}\n          onPointerCancel={() => setHover(null)}\n          onPointerLeave={(event) => {\n            if (event.pointerType === \"mouse\") setHover(null);\n          }}\n          onKeyDown={onKeyDown}\n          onBlur={() => setHover(null)}\n          onFocus={(event) => {\n            if (event.currentTarget.matches(\":focus-visible\") && !empty)\n              setHover((current) => current ?? { index: last, key: null, y: null });\n          }}\n        >\n          <svg className=\"block overflow-visible\" width=\"100%\" height={height} aria-hidden=\"true\" focusable=\"false\">\n            {/* A 2px surface stroke is the gap between neighbouring layers. */}\n            {series.map((line, at) => (\n              <path\n                key={line.key}\n                ref={(node) => {\n                  if (node) paths.current.set(line.key, node);\n                  else paths.current.delete(line.key);\n                }}\n                className={cn(\n                  \"stroke-surface [stroke-width:2] [stroke-linejoin:round] [transition:opacity_var(--duration-fast)_var(--ease-standard),fill_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none\",\n                  activeKey === line.key\n                    ? \"fill-(--series)\"\n                    : \"fill-[color-mix(in_oklab,var(--series)_82%,var(--surface))]\",\n                  activeKey && activeKey !== line.key && \"opacity-34\",\n                )}\n                data-active={activeKey === line.key || undefined}\n                data-custom={line.color ? true : undefined}\n                style={{ \"--series\": colorOf(line, at) } as CSSProperties}\n              />\n            ))}\n            <motion.line\n              className={cn(\n                \"pointer-events-none stroke-ink [stroke-opacity:.5] [stroke-width:1] [shape-rendering:crispEdges] transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n                index !== null ? \"opacity-100\" : \"opacity-0\",\n              )}\n              x1={0}\n              x2={0}\n              y1={0}\n              y2={height}\n              style={{ x: reduced ? cursor : cursorSpring }}\n            />\n            {directLabels &&\n              series.map((line) => (\n                <text\n                  key={line.key}\n                  ref={(node) => {\n                    if (node) labels.current.set(line.key, node);\n                    else labels.current.delete(line.key);\n                  }}\n                  className={cn(\n                    \"pointer-events-none fill-ink stroke-surface text-xs font-medium [paint-order:stroke] [stroke-linejoin:round] [stroke-opacity:.85] [stroke-width:3px] [transition:opacity_var(--duration-standard)_var(--ease-standard),fill_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n                    (hidden.includes(line.key) || (activeKey && activeKey !== line.key)) && \"opacity-0!\",\n                  )}\n                  data-active={activeKey === line.key || undefined}\n                  data-custom={line.color ? true : undefined}\n                  data-hidden={hidden.includes(line.key) || undefined}\n                  data-dim={(activeKey !== null && activeKey !== line.key) || undefined}\n                  textAnchor=\"middle\"\n                  dominantBaseline=\"central\"\n                >\n                  {line.label}\n                </text>\n              ))}\n          </svg>\n          <motion.div\n            ref={tip}\n            className={cn(\n              \"pointer-events-none absolute top-0 left-0 z-1 grid max-w-[min(256px,100%)] min-w-[156px] 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              index !== null ? \"opacity-100 [scale:1]\" : \"opacity-0 [scale:.96]\",\n            )}\n            style={{ x: tipLeft, y: tipTop }}\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?.label ?? \"\"}\n            </p>\n            {stackOrder(series.length, offset)\n              .reverse()\n              .map((at) => series[at]!)\n              .filter((line) => !hidden.includes(line.key))\n              .map((line) => (\n                <p\n                  key={line.key}\n                  className={cn(\n                    \"m-0 flex min-w-0 items-center gap-2 text-sm leading-body transition-opacity duration-fast ease-standard motion-reduce:transition-none\",\n                    activeKey !== null && activeKey !== line.key && \"opacity-50\",\n                  )}\n                  data-active={activeKey === line.key || undefined}\n                  data-dim={(activeKey !== null && activeKey !== line.key) || undefined}\n                  style={{ \"--series\": colorOf(line, series.indexOf(line)) } as CSSProperties}\n                >\n                  <span className=\"h-[2.5px] w-[10px] flex-none rounded-[2px] bg-(--series)\" />\n                  <span className={cn(\"min-w-0 flex-1 truncate\", activeKey === line.key ? \"text-ink\" : \"text-ink-2\")}>\n                    {line.label}\n                  </span>\n                  <span className=\"ml-2 flex-none font-medium whitespace-nowrap text-ink tabular-nums\">\n                    {reading ? format(reading.values[line.key] ?? 0, line) : \"\"}\n                  </span>\n                </p>\n              ))}\n          </motion.div>\n          {(empty || visible.length === 0) && (\n            <p className=\"pointer-events-none absolute inset-0 m-0 grid place-items-center text-(length:--text-sm) text-ink-3\">\n              {empty ? emptyLabel : \"Choose a layer to show\"}\n            </p>\n          )}\n        </div>\n        <div className=\"relative min-w-0\" aria-hidden=\"true\">\n          {/* New labels fade in from their starting style while the stream morphs to a new range, instead of popping in a single frame. */}\n          {picks.map((at) => {\n            const share = last > 0 ? at / last : 0.5;\n            const item = data[at]!;\n            return (\n              <span\n                key={`${item.key}`}\n                className=\"absolute top-2 text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums transition-opacity duration-standard ease-standard starting:opacity-0 motion-reduce:transition-none\"\n                style={{ left: `${share * 100}%`, translate: `${-share * 100}% 0` }}\n              >\n                {item.axisLabel}\n              </span>\n            );\n          })}\n        </div>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announce}\n      </p>\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    </figure>\n  );\n}\n\nexport default Streamgraph;\n",
      "type": "registry:component",
      "target": "@components/iris/streamgraph/streamgraph.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/streamgraph",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "Streamgraph",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://irisui.dev/components/streamgraph"
  }
}
