{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ridgeline",
  "type": "registry:ui",
  "title": "Ridgeline",
  "description": "Overlapping distributions, one ridge per group: hover to lift a ridge and read its quartiles, switch datasets and every curve morphs.",
  "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/ridgeline/ridgeline.tsx",
      "content": "\"use client\";\n\nimport {\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 RidgelineSeries {\n  /** Stable identity. A ridge that survives a data change morphs into its new shape. */\n  id: string;\n  label: string;\n  /** Raw observations. The ridge is a smoothed density of these values. */\n  values: number[];\n}\n\n/** Use a ridgeline to compare the shape of many distributions at once, such as daily temperatures by month or response times by region. */\nexport interface RidgelineProps {\n  /** One ridge per series, drawn top to bottom. */\n  series: RidgelineSeries[];\n  /** What is measured, such as \"Daily highs in Zurich\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each value, such as \"°C\". */\n  unit?: string;\n  formatValue?: (value: number) => string;\n  /** Value range of the axis. Defaults to the data with a little room on each side. */\n  domain?: [number, number];\n  /** How far a ridge may rise into the rows above, in row heights. */\n  overlap?: number;\n  /** Height of one row in pixels. */\n  rowHeight?: number;\n  /** Smoothing in value units. Defaults to Silverman's rule per series. */\n  bandwidth?: number;\n  /** Shades each ridge by its median, from a light tint to the full first series color, and shows the scale under the axis. */\n  tint?: boolean;\n  /** Controlled id of the lifted ridge. */\n  active?: string | null;\n  defaultActive?: string | null;\n  onActiveChange?: (id: string | null) => void;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Stats = { q1: number; median: number; q3: number; min: number; max: number; n: number; sorted: number[] };\ntype Shape = { xs: number[]; ys: number[]; row: number; q1: number; median: number; q3: 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 morph = physical({ visualDuration: 0.7, bounce: 0.08 });\nconst reveal = physical({ visualDuration: 1, bounce: 0 });\nconst glide = physical(spring.snappy, 0.01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: 0.01 };\nconst SAMPLES = 96,\n  AXIS = 28,\n  TIP = 14,\n  LIFT = 6;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst percent = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 0 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t;\n\nfunction quantile(sorted: number[], q: number) {\n  if (!sorted.length) return 0;\n  const at = (sorted.length - 1) * q,\n    low = Math.floor(at),\n    high = Math.ceil(at);\n  return sorted[low]! + (sorted[high]! - sorted[low]!) * (at - low);\n}\nfunction statsOf(values: number[]): Stats {\n  const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);\n  return {\n    sorted,\n    n: sorted.length,\n    min: sorted[0] ?? 0,\n    max: sorted[sorted.length - 1] ?? 0,\n    q1: quantile(sorted, 0.25),\n    median: quantile(sorted, 0.5),\n    q3: quantile(sorted, 0.75),\n  };\n}\nfunction silverman(stats: Stats) {\n  if (stats.n < 2) return 1;\n  const mean = stats.sorted.reduce((sum, value) => sum + value, 0) / stats.n;\n  const sd = Math.sqrt(stats.sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (stats.n - 1));\n  const spread = Math.min(sd, (stats.q3 - stats.q1) / 1.34 || sd);\n  return Math.max(1e-6, 0.9 * (spread || 1) * Math.pow(stats.n, -0.2));\n}\nfunction niceStep(span: number, count: number) {\n  const raw = span / Math.max(1, count) || 1,\n    power = Math.pow(10, Math.floor(Math.log10(raw))),\n    unit = raw / power;\n  return (unit >= 5 ? 5 : unit >= 2 ? 2 : 1) * power;\n}\n/** Samples a Gaussian kernel density across the domain. */\nfunction density(stats: Stats, lo: number, hi: number, bandwidth: number) {\n  const xs: number[] = [],\n    ys: number[] = [];\n  const norm = 1 / (Math.max(1, stats.n) * bandwidth * Math.sqrt(2 * Math.PI));\n  for (let i = 0; i < SAMPLES; i++) {\n    const x = lo + ((hi - lo) * i) / (SAMPLES - 1);\n    let sum = 0;\n    for (const value of stats.sorted) {\n      const u = (x - value) / bandwidth;\n      if (u > -5 && u < 5) sum += Math.exp(-0.5 * u * u);\n    }\n    xs.push(x);\n    ys.push(sum * norm);\n  }\n  return { xs, ys };\n}\nconst heightAt = (shape: Shape, x: number) => {\n  const { xs, ys } = shape;\n  if (x <= xs[0]! || x >= xs[xs.length - 1]!) return 0;\n  let i = 1;\n  while (i < xs.length - 1 && xs[i]! < x) i++;\n  const t = (x - xs[i - 1]!) / (xs[i]! - xs[i - 1]! || 1);\n  return mix(ys[i - 1]!, ys[i]!, t);\n};\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function Ridgeline({\n  series,\n  label,\n  unit = \"\",\n  formatValue,\n  domain,\n  overlap = 2.4,\n  rowHeight = 30,\n  bandwidth,\n  tint = true,\n  active,\n  defaultActive = null,\n  onActiveChange,\n  emptyLabel = \"No data yet\",\n  ref,\n  className,\n}: RidgelineProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const fills = useRef(new Map<string, SVGPathElement>());\n  const lines = useRef(new Map<string, SVGPathElement>());\n  const medians = useRef(new Map<string, SVGLineElement>());\n  const lifted = useRef<{\n    fill: SVGPathElement | null;\n    band: SVGPathElement | null;\n    line: SVGPathElement | null;\n    median: SVGLineElement | null;\n    group: SVGGElement | null;\n  }>({ fill: null, band: null, line: null, median: null, group: null });\n  const tickRefs = useRef(new Map<number, HTMLDivElement>());\n  const cross = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.3 });\n  // A true minus sign keeps negative values tight and lined up with the tabular digits.\n  const format = (value: number) =>\n    formatValue ? formatValue(value) : `${grouped.format(value).replace(\"-\", \"\\u2212\")}${unit}`;\n\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const node = plot.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setWidth(node.clientWidth));\n    observer.observe(node);\n    setWidth(node.clientWidth);\n    return () => observer.disconnect();\n  }, []);\n\n  // Targets: one density per series on a shared domain, scaled so the tallest peak of all rises `overlap` rows.\n  const signature = JSON.stringify([series, domain, bandwidth]);\n  const model = useMemo(() => {\n    const stats = series.map((entry) => statsOf(entry.values));\n    const widths = stats.map((entry) => bandwidth ?? silverman(entry));\n    const filled = stats.map((entry, index) => ({ entry, room: 2.5 * widths[index]! })).filter(({ entry }) => entry.n);\n    let lo = domain?.[0] ?? Math.min(...filled.map(({ entry, room }) => entry.min - room));\n    let hi = domain?.[1] ?? Math.max(...filled.map(({ entry, room }) => entry.max + room));\n    if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) {\n      lo = 0;\n      hi = 1;\n    }\n    if (!domain) {\n      const step = niceStep(hi - lo, 5);\n      lo = Math.floor(lo / step) * step;\n      hi = Math.ceil(hi / step) * step;\n    }\n    const curves = stats.map((entry, index) =>\n      entry.n\n        ? density(entry, lo, hi, widths[index]!)\n        : {\n            xs: Array.from({ length: SAMPLES }, (_, i) => lo + ((hi - lo) * i) / (SAMPLES - 1)),\n            ys: Array.from({ length: SAMPLES }, () => 0),\n          },\n    );\n    const peak = Math.max(1e-9, ...curves.flatMap((curve) => curve.ys));\n    const shapes = new Map<string, Shape>(\n      series.map((entry, index) => {\n        const curve = curves[index]!,\n          stat = stats[index]!;\n        return [\n          entry.id,\n          {\n            xs: curve.xs,\n            ys: curve.ys.map((y) => y / peak),\n            row: index,\n            q1: stat.q1,\n            median: stat.median,\n            q3: stat.q3,\n          },\n        ];\n      }),\n    );\n    return { stats: new Map(series.map((entry, index) => [entry.id, stats[index]!])), shapes, lo, hi };\n  }, [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const [ownActive, setOwnActive] = useState<string | null>(defaultActive);\n  const current = active !== undefined ? active : ownActive;\n  const setCurrent = (id: string | null) => {\n    if (active === undefined) setOwnActive(id);\n    if (id !== current) onActiveChange?.(id);\n  };\n  const [cursor, setCursor] = useState<{ value: number; pointerY: number | null } | null>(null);\n\n  const narrow = width > 0 && width < 420;\n  const gutter = narrow ? 36 : 52;\n  const top = Math.ceil((overlap - 1) * rowHeight) + LIFT + 8;\n  const height = top + series.length * rowHeight + AXIS;\n  const plotWidth = Math.max(40, width - gutter - 4);\n  const baselineOf = (row: number) => top + (row + 1) * rowHeight;\n\n  // Drawn geometry lives outside React; a spring interpolates it from what is on screen to the new targets.\n  const drawn = useRef({ shapes: new Map<string, Shape>(), lo: 0, hi: 1 });\n  const live = useRef({ gutter, plotWidth, rowHeight, overlap, top, current });\n  useLayoutEffect(() => {\n    live.current = { gutter, plotWidth, rowHeight, overlap, top, current };\n  });\n  const xOf = (value: number) => {\n    const d = drawn.current,\n      L = live.current;\n    return L.gutter + ((value - d.lo) / (d.hi - d.lo || 1)) * L.plotWidth;\n  };\n\n  const paint = () => {\n    const d = drawn.current,\n      L = live.current;\n    const sx = (value: number) => L.gutter + ((value - d.lo) / (d.hi - d.lo || 1)) * L.plotWidth;\n    const lift = L.rowHeight * L.overlap;\n    const curvePath = (shape: Shape, closed: boolean, from = -Infinity, to = Infinity) => {\n      const base = L.top + (shape.row + 1) * L.rowHeight;\n      const points: string[] = [];\n      const push = (x: number, y: number) => points.push(`${sx(x).toFixed(2)},${(base - y * lift).toFixed(2)}`);\n      if (from > -Infinity) push(from, heightAt(shape, from));\n      shape.xs.forEach((x, i) => {\n        if (x > from && x < to) push(x, shape.ys[i]!);\n      });\n      if (to < Infinity) push(to, heightAt(shape, to));\n      if (!points.length) return \"\";\n      const first = from > -Infinity ? from : shape.xs[0]!,\n        last = to < Infinity ? to : shape.xs[shape.xs.length - 1]!;\n      return closed\n        ? `M${sx(first).toFixed(2)},${base}L${points.join(\"L\")}L${sx(last).toFixed(2)},${base}Z`\n        : `M${points.join(\"L\")}`;\n    };\n    for (const [id, shape] of d.shapes) {\n      const fill = fills.current.get(id),\n        line = lines.current.get(id),\n        median = medians.current.get(id);\n      fill?.setAttribute(\"d\", curvePath(shape, true));\n      line?.setAttribute(\"d\", curvePath(shape, false));\n      if (median) {\n        const x = sx(shape.median),\n          base = L.top + (shape.row + 1) * L.rowHeight;\n        median.setAttribute(\"x1\", x.toFixed(2));\n        median.setAttribute(\"x2\", x.toFixed(2));\n        median.setAttribute(\"y1\", String(base));\n        median.setAttribute(\"y2\", (base - heightAt(shape, shape.median) * lift).toFixed(2));\n      }\n    }\n    const shape = L.current ? d.shapes.get(L.current) : undefined,\n      lens = lifted.current;\n    if (shape && lens.fill && lens.line && lens.band && lens.median) {\n      lens.fill.setAttribute(\"d\", curvePath(shape, true));\n      lens.line.setAttribute(\"d\", curvePath(shape, false));\n      lens.band.setAttribute(\"d\", curvePath(shape, true, shape.q1, shape.q3));\n      const x = sx(shape.median),\n        base = L.top + (shape.row + 1) * L.rowHeight;\n      lens.median.setAttribute(\"x1\", x.toFixed(2));\n      lens.median.setAttribute(\"x2\", x.toFixed(2));\n      lens.median.setAttribute(\"y1\", String(base));\n      lens.median.setAttribute(\"y2\", (base - heightAt(shape, shape.median) * lift).toFixed(2));\n    }\n    for (const [value, tick] of tickRefs.current) tick.style.transform = `translate3d(${sx(value).toFixed(2)}px, 0, 0)`;\n  };\n  const paintRef = useRef(paint);\n  useLayoutEffect(() => {\n    paintRef.current = paint;\n  });\n\n  const shown = inView || reduced;\n  useEffect(() => {\n    if (!shown) return;\n    const d = drawn.current;\n    const first = d.shapes.size === 0;\n    const from = new Map<string, Shape>();\n    for (const [id, target] of model.shapes) {\n      const now = d.shapes.get(id);\n      from.set(\n        id,\n        now\n          ? { ...now, xs: [...now.xs], ys: [...now.ys] }\n          : { ...target, ys: target.ys.map(() => 0), q1: target.median, q3: target.median },\n      );\n    }\n    const fromLo = first ? model.lo : d.lo,\n      fromHi = first ? model.hi : d.hi;\n    const finish = () => {\n      d.shapes = new Map([...model.shapes].map(([id, shape]) => [id, { ...shape }]));\n      d.lo = model.lo;\n      d.hi = model.hi;\n      paintRef.current();\n    };\n    if (reduced) {\n      finish();\n      return;\n    }\n    d.shapes = new Map([...from].map(([id, shape]) => [id, { ...shape, xs: [...shape.xs], ys: [...shape.ys] }]));\n    const controls = animate(0, 1, {\n      ...(first ? reveal : morph),\n      onUpdate: (t) => {\n        d.lo = mix(fromLo, model.lo, t);\n        d.hi = mix(fromHi, model.hi, t);\n        for (const [id, to] of model.shapes) {\n          const f = from.get(id)!,\n            out = d.shapes.get(id)!;\n          // Stagger the reveal a little from the top row down, so the ridges roll in like a wave.\n          const local = first ? clamp((t - to.row * 0.025) / (1 - Math.min(0.5, model.shapes.size * 0.025)), 0, 1) : t;\n          for (let i = 0; i < SAMPLES; i++) {\n            out.xs[i] = mix(f.xs[i]!, to.xs[i]!, local);\n            out.ys[i] = mix(f.ys[i]!, to.ys[i]!, local);\n          }\n          out.row = mix(f.row, to.row, local);\n          out.q1 = mix(f.q1, to.q1, local);\n          out.median = mix(f.median, to.median, local);\n          out.q3 = mix(f.q3, to.q3, local);\n        }\n        paintRef.current();\n      },\n      onComplete: finish,\n    });\n    return () => controls.stop();\n  }, [shown, model, reduced]);\n  useLayoutEffect(() => {\n    paintRef.current();\n  });\n\n  // Axis ticks for the target domain; their positions follow the drawn domain every frame.\n  const tickCount = Math.max(2, Math.floor(plotWidth / 72));\n  const step = niceStep(model.hi - model.lo, tickCount);\n  const ticks: number[] = [];\n  for (let value = Math.ceil(model.lo / step) * step; value <= model.hi + 1e-9; value += step)\n    ticks.push(Math.round(value * 1e6) / 1e6 || 0);\n\n  const activeStats = current ? model.stats.get(current) : undefined;\n  const activeSeries = current ? series.find((entry) => entry.id === current) : undefined;\n  const reading =\n    cursor && activeStats\n      ? {\n          value: cursor.value,\n          below: activeStats.n ? activeStats.sorted.filter((value) => value <= cursor.value).length / activeStats.n : 0,\n        }\n      : null;\n  // The tooltip keeps its last content while it fades out, so it never collapses to an empty box on leave.\n  const [held, setHeld] = useState<{ id: string; value: number | null } | null>(null);\n  if (current && (held?.id !== current || held.value !== (reading?.value ?? null)))\n    setHeld({ id: current, value: reading?.value ?? null });\n  const tipSeries = held ? series.find((entry) => entry.id === held.id) : undefined;\n  const tipStats = held ? model.stats.get(held.id) : undefined;\n  const tipBelow =\n    held && held.value !== null && tipStats?.n\n      ? tipStats.sorted.filter((value) => value <= held.value!).length / tipStats.n\n      : 0;\n\n  // The tooltip trails the pointer or the keyboard cursor and stays inside the chart.\n  const tipX = useMotionValue(0),\n    tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow),\n    tipSpringY = useSpring(tipY, follow);\n  const wasOn = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current,\n      shape = current ? model.shapes.get(current) : undefined;\n    if (cross.current)\n      cross.current.style.transform = `translate3d(${reading ? xOf(reading.value).toFixed(2) : 0}px, 0, 0)`;\n    if (!bubble || !shape || !width) {\n      wasOn.current = false;\n      return;\n    }\n    const tw = bubble.offsetWidth,\n      th = bubble.offsetHeight;\n    const ax = reading ? xOf(reading.value) : xOf(shape.median);\n    const ay = cursor?.pointerY ?? baselineOf(shape.row) - heightAt(shape, shape.median) * rowHeight * overlap - LIFT;\n    let left = ax + TIP;\n    if (left + tw > width) left = ax - TIP - tw;\n    let y = ay - th - TIP;\n    if (y < 0) y = ay + TIP;\n    tipX.set(clamp(left, 0, Math.max(0, width - tw)));\n    tipY.set(clamp(y, 0, Math.max(0, height - th)));\n    if (!wasOn.current || reduced) {\n      tipSpringX.jump(tipX.get());\n      tipSpringY.jump(tipY.get());\n    }\n    wasOn.current = true;\n  });\n\n  const valueAt = (x: number) => {\n    const d = drawn.current;\n    return d.lo + clamp((x - gutter) / plotWidth, 0, 1) * (d.hi - d.lo);\n  };\n  const pick = (x: number, y: number) => {\n    const value = valueAt(x),\n      lift = rowHeight * overlap;\n    let best: Shape | null = null,\n      bestId: string | null = null;\n    for (const [id, shape] of drawn.current.shapes) {\n      const base = baselineOf(shape.row),\n        h = heightAt(shape, value) * lift;\n      if (y <= base + 2 && y >= base - h - 4 && (!best || shape.row > best.row)) {\n        best = shape;\n        bestId = id;\n      }\n    }\n    if (!bestId) {\n      const row = clamp(Math.floor((y - top) / rowHeight), 0, series.length - 1);\n      bestId = series[row]?.id ?? null;\n    }\n    return { id: bestId, value };\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect(),\n      x = event.clientX - rect.left,\n      y = event.clientY - rect.top;\n    if (x < gutter - 8 || y > height - AXIS + 4) {\n      if (event.pointerType === \"mouse\") {\n        setCurrent(null);\n        setCursor(null);\n      }\n      return;\n    }\n    const hit = pick(x, y);\n    setCurrent(hit.id);\n    setCursor({ value: hit.value, pointerY: y });\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const at = current ? series.findIndex((entry) => entry.id === current) : -1;\n    const stats = (id: string | undefined) => (id ? model.stats.get(id) : undefined);\n    const choose = (index: number) => {\n      event.preventDefault();\n      const next = series[clamp(index, 0, series.length - 1)];\n      if (!next) return;\n      setCurrent(next.id);\n      setCursor({ value: cursor?.value ?? stats(next.id)?.median ?? model.lo, pointerY: null });\n    };\n    const nudge = (direction: number) => {\n      event.preventDefault();\n      if (!current) {\n        choose(0);\n        return;\n      }\n      const base = cursor?.value ?? stats(current)?.median ?? model.lo;\n      const next = clamp((Math.round(base / step) + direction) * step, model.lo, model.hi);\n      setCursor({ value: next, pointerY: null });\n    };\n    if (event.key === \"ArrowDown\") choose(at + 1);\n    else if (event.key === \"ArrowUp\") choose(at < 0 ? 0 : at - 1);\n    else if (event.key === \"Home\") choose(0);\n    else if (event.key === \"End\") choose(series.length - 1);\n    else if (event.key === \"ArrowRight\") nudge(1);\n    else if (event.key === \"ArrowLeft\") nudge(-1);\n    else if (event.key === \"Escape\" && current) {\n      event.preventDefault();\n      setCurrent(null);\n      setCursor(null);\n    }\n  };\n\n  const empty = !series.length || series.every((entry) => !entry.values.length);\n  /** Ridge tone: one hue, the first series color, from a light tint (low median) to full strength (high median), so the scale never passes through a muddy middle. */\n  const toneOf = (t: number) =>\n    `color-mix(in oklab, var(--series-1) ${Math.round(38 + clamp(t, 0, 1) * 62)}%, var(--surface))`;\n  const ridgeColor = (value: number) =>\n    tint ? toneOf((value - model.lo) / (model.hi - model.lo || 1)) : \"var(--series-1)\";\n  const describe = (entry: RidgelineSeries) => {\n    const s = model.stats.get(entry.id)!;\n    return `${entry.label}: median ${format(s.median)}, middle half ${format(s.q1)} to ${format(s.q3)}, range ${format(s.min)} to ${format(s.max)}, ${s.n} values`;\n  };\n\n  return (\n    <figure\n      ref={figure}\n      className={cn(\"m-0 grid min-w-0 font-body tracking-body text-ink\", className)}\n      aria-label={label}\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        data-active={current ? true : undefined}\n        data-cursor={reading ? true : undefined}\n        role=\"group\"\n        tabIndex={empty ? -1 : 0}\n        aria-roledescription=\"ridgeline chart\"\n        aria-label={`${label}. Up and down arrows choose a ridge, left and right arrows move along the values.`}\n        onPointerMove={onPointerMove}\n        onPointerDown={onPointerMove}\n        onPointerLeave={(event) => {\n          if (event.pointerType === \"mouse\") {\n            setCurrent(null);\n            setCursor(null);\n          }\n        }}\n        onKeyDown={onKeyDown}\n        onBlur={() => {\n          setCurrent(null);\n          setCursor(null);\n        }}\n      >\n        <div\n          className=\"pointer-events-none absolute top-0 right-0 left-0\"\n          aria-hidden=\"true\"\n          style={{ height: height - AXIS + 22 }}\n        >\n          {ticks.map((value) => (\n            <div\n              key={value}\n              ref={(element) => {\n                if (element) tickRefs.current.set(value, element);\n                else tickRefs.current.delete(value);\n              }}\n              className=\"absolute bottom-0 left-0 w-px animate-ridgeline-tick-in bg-line-soft will-change-transform motion-reduce:animate-none\"\n              style={{ top: top - rowHeight * 0.5 }}\n            >\n              <span className=\"absolute bottom-0 left-0 -translate-x-1/2 text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums\">\n                {format(value)}\n              </span>\n            </div>\n          ))}\n        </div>\n        <div\n          ref={cross}\n          className={cn(\n            \"pointer-events-none absolute left-0 w-px bg-ink transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n            reading ? \"opacity-35\" : \"opacity-0\",\n          )}\n          style={{ top: top - rowHeight * 0.5, height: height - AXIS - top + rowHeight * 0.5 }}\n          aria-hidden=\"true\"\n        />\n        <svg\n          className=\"absolute inset-0 block overflow-visible\"\n          width={width || undefined}\n          height={height}\n          viewBox={`0 0 ${Math.max(1, width)} ${height}`}\n          aria-hidden=\"true\"\n          focusable=\"false\"\n        >\n          {/* Ridges are opaque, so each one hides the valleys of the row behind it. The lifted one hides while its copy rises above the rest. */}\n          {series.map((entry) => (\n            <g\n              key={entry.id}\n              className={cn(\n                \"transition-opacity duration-standard ease-standard motion-reduce:transition-none\",\n                current === entry.id ? \"opacity-0 duration-instant\" : current && current !== entry.id && \"opacity-38\",\n              )}\n              data-dim={(current !== null && current !== entry.id) || undefined}\n              data-lifted={current === entry.id || undefined}\n              style={{ \"--ridge\": ridgeColor(model.stats.get(entry.id)?.median ?? model.lo) } as CSSProperties}\n            >\n              <path\n                ref={(element) => {\n                  if (element) fills.current.set(entry.id, element);\n                  else fills.current.delete(entry.id);\n                }}\n                className=\"fill-[color-mix(in_oklab,var(--ridge)_34%,var(--surface))] stroke-none transition-[fill] duration-considered ease-standard\"\n              />\n              <line\n                ref={(element) => {\n                  if (element) medians.current.set(entry.id, element);\n                  else medians.current.delete(entry.id);\n                }}\n                className=\"stroke-[color-mix(in_oklab,var(--ridge)_55%,var(--surface))] [stroke-width:1] transition-[stroke] duration-considered ease-standard\"\n              />\n              <path\n                ref={(element) => {\n                  if (element) lines.current.set(entry.id, element);\n                  else lines.current.delete(entry.id);\n                }}\n                className=\"fill-none stroke-(--ridge) [stroke-width:1.5] [stroke-linecap:round] [stroke-linejoin:round] transition-[stroke] duration-considered ease-standard\"\n              />\n            </g>\n          ))}\n          {/* The lifted ridge is a copy drawn above the rest, so it can rise over its neighbours. */}\n          <g\n            ref={(element) => {\n              lifted.current.group = element;\n            }}\n            className={cn(\n              \"pointer-events-none [transition:opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:transition-none\",\n              current ? \"opacity-100 [transform:translateY(-6px)]\" : \"opacity-0 [transform:translateY(0)]\",\n            )}\n            data-on={current ? true : undefined}\n            style={tipStats ? ({ \"--ridge\": ridgeColor(tipStats.median) } as CSSProperties) : undefined}\n          >\n            <path\n              ref={(element) => {\n                lifted.current.fill = element;\n              }}\n              className=\"fill-[color-mix(in_oklab,var(--ridge)_18%,var(--surface))] stroke-none\"\n            />\n            <path\n              ref={(element) => {\n                lifted.current.band = element;\n              }}\n              className=\"fill-[color-mix(in_oklab,var(--ridge)_48%,var(--surface))] stroke-none\"\n            />\n            <line\n              ref={(element) => {\n                lifted.current.median = element;\n              }}\n              className=\"stroke-ink [stroke-width:1.5] [stroke-linecap:round]\"\n            />\n            <path\n              ref={(element) => {\n                lifted.current.line = element;\n              }}\n              className=\"fill-none stroke-(--ridge) [stroke-width:2.25] [stroke-linecap:round] [stroke-linejoin:round]\"\n            />\n          </g>\n        </svg>\n        <div className=\"pointer-events-none absolute inset-0\" aria-hidden=\"true\">\n          {series.map((entry, index) => (\n            <span\n              key={entry.id}\n              className={cn(\n                \"absolute left-0 truncate text-(length:--text-xs) leading-[18px] transition-[color] duration-fast ease-standard motion-reduce:transition-none\",\n                current === entry.id ? \"font-medium text-ink\" : \"text-ink-2\",\n              )}\n              data-active={current === entry.id || undefined}\n              style={{ top: baselineOf(index) - 18, width: gutter - 10 }}\n            >\n              {entry.label}\n            </span>\n          ))}\n        </div>\n        {empty && (\n          <p className=\"pointer-events-none absolute inset-0 m-0 grid place-items-center text-(length:--text-sm) text-ink-3\">\n            {emptyLabel}\n          </p>\n        )}\n        <motion.div\n          ref={tip}\n          className={cn(\n            \"pointer-events-none absolute top-0 left-0 z-2 grid max-w-[min(240px,100%)] min-w-[170px] gap-0.5 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            current ? \"opacity-100 [scale:1]\" : \"opacity-0 [scale:.96]\",\n          )}\n          style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }}\n          aria-hidden=\"true\"\n        >\n          {tipSeries && tipStats && (\n            <>\n              <p className=\"m-0 mb-0.5 text-xs leading-body text-ink-2\">{tipSeries.label}</p>\n              <p className=\"m-0 flex items-baseline justify-between gap-3 text-sm leading-[1.4] font-medium whitespace-nowrap text-ink tabular-nums\">\n                <span className=\"text-(length:--text-xs) font-normal text-ink-3\">Median</span>\n                <span>{format(tipStats.median)}</span>\n              </p>\n              <p className=\"m-0 flex items-baseline justify-between gap-3 text-xs leading-body whitespace-nowrap text-ink tabular-nums\">\n                <span className=\"font-normal text-ink-3\">Middle half</span>\n                <span>\n                  {format(tipStats.q1)} to {format(tipStats.q3)}\n                </span>\n              </p>\n              <p className=\"m-0 flex items-baseline justify-between gap-3 text-xs leading-body whitespace-nowrap text-ink tabular-nums\">\n                <span className=\"font-normal text-ink-3\">Range</span>\n                <span>\n                  {format(tipStats.min)} to {format(tipStats.max)}\n                </span>\n              </p>\n              {held?.value != null && (\n                <p className=\"mx-0 mt-1 mb-0 text-xs leading-body text-ink-3 tabular-nums\">\n                  {percent.format(tipBelow)} at or below {format(held.value)}\n                </p>\n              )}\n            </>\n          )}\n        </motion.div>\n      </div>\n      {tint && !empty && (\n        <div\n          className=\"mt-3 flex flex-wrap items-center justify-end gap-2 text-xs leading-body text-ink-3 tabular-nums\"\n          aria-hidden=\"true\"\n        >\n          <span className=\"mr-1 text-ink-2\">Median</span>\n          <span>{format(model.lo)}</span>\n          <span className=\"inline-flex gap-0.5\">\n            {[0, 0.25, 0.5, 0.75, 1].map((step) => (\n              <span\n                key={step}\n                className=\"h-[10px] w-[18px] rounded-[3px] bg-(--ridge)\"\n                style={{ \"--ridge\": toneOf(step) } as CSSProperties}\n              />\n            ))}\n          </span>\n          <span>{format(model.hi)}</span>\n        </div>\n      )}\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {activeSeries\n          ? `${describe(activeSeries)}${reading ? `. ${percent.format(reading.below)} at or below ${format(reading.value)}` : \"\"}`\n          : \"\"}\n      </p>\n      {!empty && (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{label}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Series</th>\n                <th scope=\"col\">Median</th>\n                <th scope=\"col\">First quartile</th>\n                <th scope=\"col\">Third quartile</th>\n                <th scope=\"col\">Lowest</th>\n                <th scope=\"col\">Highest</th>\n                <th scope=\"col\">Count</th>\n              </tr>\n            </thead>\n            <tbody>\n              {series.map((entry) => {\n                const s = model.stats.get(entry.id)!;\n                return (\n                  <tr key={entry.id}>\n                    <th scope=\"row\">{entry.label}</th>\n                    <td>{format(s.median)}</td>\n                    <td>{format(s.q1)}</td>\n                    <td>{format(s.q3)}</td>\n                    <td>{format(s.min)}</td>\n                    <td>{format(s.max)}</td>\n                    <td>{s.n}</td>\n                  </tr>\n                );\n              })}\n            </tbody>\n          </table>\n        </div>\n      )}\n    </figure>\n  );\n}\n\nexport default Ridgeline;\n",
      "type": "registry:component",
      "target": "@components/iris/ridgeline/ridgeline.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/ridgeline",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "Ridgeline",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://irisui.dev/components/ridgeline"
  }
}
