{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/ridgeline/ridgeline.tsx",
      "type": "registry:component",
      "target": "@components/xzero/ridgeline/ridgeline.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./ridgeline.module.css\";\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 = .0005) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst morph = physical({ visualDuration: .7, bounce: .08 });\nconst reveal = physical({ visualDuration: 1, bounce: 0 });\nconst glide = physical(spring.snappy, .01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\nconst SAMPLES = 96, AXIS = 28, TIP = 14, 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, low = Math.floor(at), 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 { sorted, n: sorted.length, min: sorted[0] ?? 0, max: sorted[sorted.length - 1] ?? 0, q1: quantile(sorted, .25), median: quantile(sorted, .5), q3: quantile(sorted, .75) };\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, .9 * (spread || 1) * Math.pow(stats.n, -.2));\n}\nfunction niceStep(span: number, count: number) {\n  const raw = span / Math.max(1, count) || 1, power = Math.pow(10, Math.floor(Math.log10(raw))), 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[] = [], 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) { const u = (x - value) / bandwidth; if (u > -5 && u < 5) sum += Math.exp(-.5 * u * u); }\n    xs.push(x); 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(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function Ridgeline({ series, label, unit = \"\", formatValue, domain, overlap = 2.4, rowHeight = 30, bandwidth, tint = true, active, defaultActive = null, onActiveChange, emptyLabel = \"No data yet\", ref, className }: 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<{ fill: SVGPathElement | null; band: SVGPathElement | null; line: SVGPathElement | null; median: SVGLineElement | null; group: SVGGElement | null }>({ 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: .3 });\n  // A true minus sign keeps negative values tight and lined up with the tabular digits.\n  const format = (value: number) => 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) { lo = 0; hi = 1; }\n    if (!domain) { const step = niceStep(hi - lo, 5); lo = Math.floor(lo / step) * step; hi = Math.ceil(hi / step) * step; }\n    const curves = stats.map((entry, index) => entry.n ? density(entry, lo, hi, widths[index]!) : { xs: Array.from({ length: SAMPLES }, (_, i) => lo + ((hi - lo) * i) / (SAMPLES - 1)), ys: new Array(SAMPLES).fill(0) });\n    const peak = Math.max(1e-9, ...curves.flatMap(curve => curve.ys));\n    const shapes = new Map<string, Shape>(series.map((entry, index) => { const curve = curves[index]!, stat = stats[index]!; return [entry.id, { xs: curve.xs, ys: curve.ys.map(y => y / peak), row: index, q1: stat.q1, median: stat.median, q3: stat.q3 }]; }));\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) => { if (active === undefined) setOwnActive(id); if (id !== current) onActiveChange?.(id); };\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(() => { live.current = { gutter, plotWidth, rowHeight, overlap, top, current }; });\n  const xOf = (value: number) => { const d = drawn.current, L = live.current; return L.gutter + ((value - d.lo) / (d.hi - d.lo || 1)) * L.plotWidth; };\n\n  const paint = () => {\n    const d = drawn.current, 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) => { if (x > from && x < to) push(x, shape.ys[i]!); });\n      if (to < Infinity) push(to, heightAt(shape, to));\n      if (!points.length) return \"\";\n      const first = from > -Infinity ? from : shape.xs[0]!, last = to < Infinity ? to : shape.xs[shape.xs.length - 1]!;\n      return closed ? `M${sx(first).toFixed(2)},${base}L${points.join(\"L\")}L${sx(last).toFixed(2)},${base}Z` : `M${points.join(\"L\")}`;\n    };\n    for (const [id, shape] of d.shapes) {\n      const fill = fills.current.get(id), line = lines.current.get(id), median = medians.current.get(id);\n      fill?.setAttribute(\"d\", curvePath(shape, true));\n      line?.setAttribute(\"d\", curvePath(shape, false));\n      if (median) { const x = sx(shape.median), base = L.top + (shape.row + 1) * L.rowHeight; median.setAttribute(\"x1\", x.toFixed(2)); median.setAttribute(\"x2\", x.toFixed(2)); median.setAttribute(\"y1\", String(base)); median.setAttribute(\"y2\", (base - heightAt(shape, shape.median) * lift).toFixed(2)); }\n    }\n    const shape = L.current ? d.shapes.get(L.current) : undefined, 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), base = L.top + (shape.row + 1) * L.rowHeight;\n      lens.median.setAttribute(\"x1\", x.toFixed(2)); lens.median.setAttribute(\"x2\", x.toFixed(2)); lens.median.setAttribute(\"y1\", String(base)); 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(() => { paintRef.current = paint; });\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(id, now ? { ...now, xs: [...now.xs], ys: [...now.ys] } : { ...target, ys: target.ys.map(() => 0), q1: target.median, q3: target.median });\n    }\n    const fromLo = first ? model.lo : d.lo, fromHi = first ? model.hi : d.hi;\n    const finish = () => { d.shapes = new Map([...model.shapes].map(([id, shape]) => [id, { ...shape }])); d.lo = model.lo; d.hi = model.hi; paintRef.current(); };\n    if (reduced) { finish(); return; }\n    d.shapes = new Map([...from].map(([id, shape]) => [id, { ...shape, xs: [...shape.xs], ys: [...shape.ys] }]));\n    const controls = animate(0, 1, { ...(first ? reveal : morph), onUpdate: t => {\n      d.lo = mix(fromLo, model.lo, t); d.hi = mix(fromHi, model.hi, t);\n      for (const [id, to] of model.shapes) {\n        const f = from.get(id)!, 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 * .025) / (1 - Math.min(.5, model.shapes.size * .025)), 0, 1) : t;\n        for (let i = 0; i < SAMPLES; i++) { out.xs[i] = mix(f.xs[i]!, to.xs[i]!, local); out.ys[i] = mix(f.ys[i]!, to.ys[i]!, local); }\n        out.row = mix(f.row, to.row, local); out.q1 = mix(f.q1, to.q1, local); out.median = mix(f.median, to.median, local); out.q3 = mix(f.q3, to.q3, local);\n      }\n      paintRef.current();\n    }, onComplete: finish });\n    return () => controls.stop();\n  }, [shown, model, reduced]);\n  useLayoutEffect(() => { paintRef.current(); });\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) 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 = cursor && activeStats ? { value: cursor.value, below: activeStats.n ? activeStats.sorted.filter(value => value <= cursor.value).length / activeStats.n : 0 } : 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))) 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 = held && held.value !== null && tipStats?.n ? tipStats.sorted.filter(value => value <= held.value!).length / tipStats.n : 0;\n\n  // The tooltip trails the pointer or the keyboard cursor and stays inside the chart.\n  const tipX = useMotionValue(0), tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n  const wasOn = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current, shape = current ? model.shapes.get(current) : undefined;\n    if (cross.current) cross.current.style.transform = `translate3d(${reading ? xOf(reading.value).toFixed(2) : 0}px, 0, 0)`;\n    if (!bubble || !shape || !width) { wasOn.current = false; return; }\n    const tw = bubble.offsetWidth, 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))); tipY.set(clamp(y, 0, Math.max(0, height - th)));\n    if (!wasOn.current || reduced) { tipSpringX.jump(tipX.get()); tipSpringY.jump(tipY.get()); }\n    wasOn.current = true;\n  });\n\n  const valueAt = (x: number) => { const d = drawn.current; return d.lo + clamp((x - gutter) / plotWidth, 0, 1) * (d.hi - d.lo); };\n  const pick = (x: number, y: number) => {\n    const value = valueAt(x), lift = rowHeight * overlap;\n    let best: Shape | null = null, bestId: string | null = null;\n    for (const [id, shape] of drawn.current.shapes) {\n      const base = baselineOf(shape.row), h = heightAt(shape, value) * lift;\n      if (y <= base + 2 && y >= base - h - 4 && (!best || shape.row > best.row)) { best = shape; bestId = id; }\n    }\n    if (!bestId) { const row = clamp(Math.floor((y - top) / rowHeight), 0, series.length - 1); bestId = series[row]?.id ?? null; }\n    return { id: bestId, value };\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect(), x = event.clientX - rect.left, y = event.clientY - rect.top;\n    if (x < gutter - 8 || y > height - AXIS + 4) { if (event.pointerType === \"mouse\") { setCurrent(null); setCursor(null); } return; }\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) => { event.preventDefault(); const next = series[clamp(index, 0, series.length - 1)]; if (!next) return; setCurrent(next.id); setCursor({ value: cursor?.value ?? stats(next.id)?.median ?? model.lo, pointerY: null }); };\n    const nudge = (direction: number) => { event.preventDefault(); if (!current) { choose(0); return; } const base = cursor?.value ?? stats(current)?.median ?? model.lo; const next = clamp((Math.round(base / step) + direction) * step, model.lo, model.hi); setCursor({ value: next, pointerY: null }); };\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) { event.preventDefault(); setCurrent(null); setCursor(null); }\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) => `color-mix(in oklab, var(--series-1) ${Math.round(38 + clamp(t, 0, 1) * 62)}%, var(--surface))`;\n  const ridgeColor = (value: number) => tint ? toneOf((value - model.lo) / (model.hi - model.lo || 1)) : \"var(--series-1)\";\n  const describe = (entry: RidgelineSeries) => { const s = model.stats.get(entry.id)!; 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  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label}>\n    <div ref={plot} className={styles.plot} style={{ height }} data-active={current ? true : undefined} data-cursor={reading ? true : undefined} role=\"group\" tabIndex={empty ? -1 : 0} 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} onPointerDown={onPointerMove} onPointerLeave={event => { if (event.pointerType === \"mouse\") { setCurrent(null); setCursor(null); } }} onKeyDown={onKeyDown} onBlur={() => { setCurrent(null); setCursor(null); }}>\n      <div className={styles.ticks} aria-hidden=\"true\" style={{ height: height - AXIS + 22 }}>\n        {ticks.map(value => <div key={value} ref={element => { if (element) tickRefs.current.set(value, element); else tickRefs.current.delete(value); }} className={styles.tick} style={{ top: top - rowHeight * .5 }}>\n          <span className={styles.tickLabel}>{format(value)}</span>\n        </div>)}\n      </div>\n      <div ref={cross} className={styles.crosshair} style={{ top: top - rowHeight * .5, height: height - AXIS - top + rowHeight * .5 }} aria-hidden=\"true\" />\n      <svg className={styles.svg} width={width || undefined} height={height} viewBox={`0 0 ${Math.max(1, width)} ${height}`} aria-hidden=\"true\" focusable=\"false\">\n        {series.map(entry => <g key={entry.id} className={styles.ridge} data-dim={current !== null && current !== entry.id || undefined} data-lifted={current === entry.id || undefined} style={{ \"--ridge\": ridgeColor(model.stats.get(entry.id)?.median ?? model.lo) } as CSSProperties}>\n          <path ref={element => { if (element) fills.current.set(entry.id, element); else fills.current.delete(entry.id); }} className={styles.fill} />\n          <line ref={element => { if (element) medians.current.set(entry.id, element); else medians.current.delete(entry.id); }} className={styles.median} />\n          <path ref={element => { if (element) lines.current.set(entry.id, element); else lines.current.delete(entry.id); }} className={styles.line} />\n        </g>)}\n        <g ref={element => { lifted.current.group = element; }} className={styles.lens} data-on={current ? true : undefined} style={tipStats ? { \"--ridge\": ridgeColor(tipStats.median) } as CSSProperties : undefined}>\n          <path ref={element => { lifted.current.fill = element; }} className={styles.lensFill} />\n          <path ref={element => { lifted.current.band = element; }} className={styles.lensBand} />\n          <line ref={element => { lifted.current.median = element; }} className={styles.lensMedian} />\n          <path ref={element => { lifted.current.line = element; }} className={styles.lensLine} />\n        </g>\n      </svg>\n      <div className={styles.labels} aria-hidden=\"true\">\n        {series.map((entry, index) => <span key={entry.id} className={styles.rowLabel} data-active={current === entry.id || undefined} style={{ top: baselineOf(index) - 18, width: gutter - 10 }}>{entry.label}</span>)}\n      </div>\n      {empty && <p className={styles.message}>{emptyLabel}</p>}\n      <motion.div ref={tip} className={styles.tooltip} style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }} aria-hidden=\"true\">\n        {tipSeries && tipStats && <>\n          <p className={styles.tipTitle}>{tipSeries.label}</p>\n          <p className={styles.tipValue}><span className={styles.tipKey}>Median</span><span>{format(tipStats.median)}</span></p>\n          <p className={styles.tipRow}><span className={styles.tipKey}>Middle half</span><span>{format(tipStats.q1)} to {format(tipStats.q3)}</span></p>\n          <p className={styles.tipRow}><span className={styles.tipKey}>Range</span><span>{format(tipStats.min)} to {format(tipStats.max)}</span></p>\n          {held?.value != null && <p className={styles.tipNote}>{percent.format(tipBelow)} at or below {format(held.value)}</p>}\n        </>}\n      </motion.div>\n    </div>\n    {tint && !empty && <div className={styles.scale} aria-hidden=\"true\">\n      <span className={styles.scaleLabel}>Median</span>\n      <span>{format(model.lo)}</span>\n      <span className={styles.steps}>{[0, .25, .5, .75, 1].map(step => <span key={step} className={styles.step} style={{ \"--ridge\": toneOf(step) } as CSSProperties} />)}</span>\n      <span>{format(model.hi)}</span>\n    </div>}\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{activeSeries ? `${describe(activeSeries)}${reading ? `. ${percent.format(reading.below)} at or below ${format(reading.value)}` : \"\"}` : \"\"}</p>\n    {!empty && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">Series</th><th scope=\"col\">Median</th><th scope=\"col\">First quartile</th><th scope=\"col\">Third quartile</th><th scope=\"col\">Lowest</th><th scope=\"col\">Highest</th><th scope=\"col\">Count</th></tr></thead>\n      <tbody>{series.map(entry => { const s = model.stats.get(entry.id)!; return <tr key={entry.id}><th scope=\"row\">{entry.label}</th><td>{format(s.median)}</td><td>{format(s.q1)}</td><td>{format(s.q3)}</td><td>{format(s.min)}</td><td>{format(s.max)}</td><td>{s.n}</td></tr>; })}</tbody>\n    </table></div>}\n  </figure>;\n}\n\nexport default Ridgeline;\n"
    },
    {
      "path": "registry/components/ridgeline/ridgeline.module.css",
      "type": "registry:component",
      "target": "@components/xzero/ridgeline/ridgeline.module.css",
      "content": ".figure { display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n.plot { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }\n.svg { position: absolute; inset: 0; display: block; overflow: visible; }\n\n/* Ridges are opaque, so each one hides the valleys of the row behind it. */\n.ridge { transition: opacity var(--duration-standard) var(--ease-standard); }\n.fill { fill: color-mix(in oklab, var(--ridge) 34%, var(--surface)); stroke: none; transition: fill var(--duration-considered) var(--ease-standard); }\n.line { fill: none; stroke: var(--ridge); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; transition: stroke var(--duration-considered) var(--ease-standard); }\n.median { stroke: color-mix(in oklab, var(--ridge) 55%, var(--surface)); stroke-width: 1; transition: stroke var(--duration-considered) var(--ease-standard); }\n.plot[data-active] .ridge[data-dim] { opacity: .38; }\n.ridge[data-lifted] { opacity: 0; transition-duration: var(--duration-instant); }\n\n/* The lifted ridge is a copy drawn above the rest, so it can rise over its neighbours. */\n.lens { opacity: 0; pointer-events: none; transform: translateY(0); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.lens[data-on] { opacity: 1; transform: translateY(-6px); }\n.lensFill { fill: color-mix(in oklab, var(--ridge) 18%, var(--surface)); stroke: none; }\n.lensBand { fill: color-mix(in oklab, var(--ridge) 48%, var(--surface)); stroke: none; }\n.lensMedian { stroke: var(--foreground); stroke-width: 1.5; stroke-linecap: round; }\n.lensLine { fill: none; stroke: var(--ridge); stroke-width: 2.25; stroke-linejoin: round; stroke-linecap: round; }\n\n.ticks { position: absolute; top: 0; right: 0; left: 0; pointer-events: none; }\n.tick { position: absolute; bottom: 0; left: 0; width: 1px; background: var(--border-subtle); will-change: transform; animation: tickIn var(--duration-standard) var(--ease-standard); }\n.tickLabel { position: absolute; bottom: 0; left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: -50% 0; }\n@keyframes tickIn { from { opacity: 0; } }\n\n.crosshair { position: absolute; left: 0; width: 1px; background: var(--foreground); opacity: 0; pointer-events: none; transition: opacity var(--duration-instant) var(--ease-standard); }\n.plot[data-cursor] .crosshair { opacity: .35; }\n\n.labels { position: absolute; inset: 0; pointer-events: none; }\n.rowLabel { position: absolute; left: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); line-height: 18px; text-overflow: ellipsis; white-space: nowrap; transition: color var(--duration-fast) var(--ease-standard); }\n.rowLabel[data-active] { color: var(--foreground); font-weight: 500; }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 170px; max-width: min(240px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.plot[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }\n.tipValue, .tipRow { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }\n.tipValue { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: 1.4; }\n.tipRow { color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }\n.tipKey { color: var(--text-muted); font-weight: 400; }\n.tipValue .tipKey { font-size: var(--text-xs); }\n.tipNote { margin: 4px 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n\n.scale { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: var(--space-3); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.scaleLabel { margin-right: 4px; color: var(--text-secondary); }\n.steps { display: inline-flex; gap: 2px; }\n.step { width: 18px; height: 10px; border-radius: 3px; background: var(--ridge); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .ridge, .lens, .crosshair, .tooltip, .rowLabel { transition: none; }\n  .tick { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/ridgeline",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/ridgeline",
    "markdown": "https://ui.xzero.co.uk/components/ridgeline/markdown"
  }
}