{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel-chart",
  "type": "registry:component",
  "title": "Funnel Chart",
  "description": "Where a population drained away, one step at a time.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/funnel-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/funnel-chart/index.tsx",
      "content": "'use client';\n\n/**\n * FunnelChart — how many were left at each step, and where the rest went.\n *\n * ```tsx\n * <FunnelChart data={checkout}>\n *   <FunnelChart.Header title=\"Checkout\" value=\"41,800\" />\n *   <FunnelChart.Stages />\n *   <FunnelChart.Labels />\n * </FunnelChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's FunnelChart, so a chart written\n * for one reads the same in the other. What differs is what the web changes: a\n * colour is named by the theme's role, a stage is read by hovering, dragging or\n * the arrow keys, and a click pins the reading.\n *\n * ## The order is the caller's\n *\n * Every stage is a subset of the one above it, so stages are never sorted and\n * nothing is normalised to a total. A stage larger than the one above it is drawn\n * as given, because that is a real problem in the data.\n *\n * ## One ribbon, drawn in rings\n *\n * Each band is as tall as its value where it starts and as tall as the next\n * stage's where it ends. It is drawn several times concentrically, from a wide\n * faint ring to a tight near-solid core, so the edge falls off and text can sit on\n * the low-opacity fill. Each ring is one area mark over pixel scales.\n *\n * ## Or stacked bars\n *\n * `shape=\"bars\"` lays the stages down the page instead: one row each, the name\n * at the start, a bar centred in the middle as wide as its value against the\n * largest, and the shares at the end. A tapered neck joins each bar to the next,\n * so the drop-off reads from both sides. The rows are drawn in the page rather\n * than in the chart, because each is text as much as a shape, and the height is\n * the rows', not `height`.\n *\n * ## The whole column is the target\n *\n * A late stage can be a sliver, so reading is by column rather than by shape, and\n * the plot handles it directly: the chart's own focus works from a mark's points,\n * and these areas have only their two ends.\n */\nimport { areaY, defineChart, type ChartMotionContext } from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { funnelBars, funnelLayout, labelFits, ribbonCurve, rowAt, stageAt, type FunnelLayout } from './funnel-layout';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type FunnelChartStatus = 'loading' | 'ready';\n\n/** Whether the sides of a band are drawn as curves or as straight diagonals. */\nexport type FunnelEdges = 'curved' | 'straight';\n\n/** A ribbon along the width, or one centred bar per row down the page. */\nexport type FunnelShape = 'ribbon' | 'bars';\n\n/** Which conversion a stage's pill reports. */\nexport type FunnelShare = 'previous' | 'top' | 'none';\n\n/** How the key under the run is arranged. */\nexport type FunnelLegendLayout = 'list' | 'inline';\n\n/** Which of the theme's roles a stage is drawn in. */\nexport type FunnelChartColor =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\n\nconst COLOR_VAR: Record<FunnelChartColor, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\n/** One step of the process. */\nexport interface FunnelDatum {\n  /** Name of the step, for the label, the legend and the screen reader. */\n  label: string;\n  /** How many were left at it. Negatives are treated as zero. */\n  value: number;\n  /** Which of the theme's roles to draw it in, at full strength instead of the faded hue. */\n  color?: FunnelChartColor;\n}\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'],\n};\n\nconst funnelChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    // `pan-y` hands a vertical drag to the page and a sideways one to the chart.\n    plot: 'relative col-start-1 row-start-2 w-full touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring',\n    chart: 'absolute! inset-0',\n    column:\n      'absolute inset-y-0 flex cursor-pointer flex-col items-center outline-none transition-opacity duration-150 motion-reduce:transition-none data-dimmed:opacity-50',\n    columnStrip: 'flex w-full min-w-0 flex-col items-center',\n    columnValue: 'max-w-full truncate pb-1 text-sm font-semibold tabular-nums',\n    columnMiddle: 'flex min-h-0 w-full flex-1 items-center justify-center',\n    // A percentage is never cut: on a narrow column the pill overhangs instead.\n    columnPill:\n      'shrink-0 whitespace-nowrap rounded-full border border-border bg-background px-1.5 py-0.5 text-xs font-bold tabular-nums',\n    columnName: 'max-w-full truncate pt-1 text-xs font-medium text-muted-foreground',\n    // The stacked-bars shape: a name, a bar and the shares on each row.\n    bars: 'absolute inset-0 grid grid-cols-[minmax(0,max-content)_minmax(0,1fr)_minmax(0,max-content)] gap-x-5',\n    barsName:\n      'col-start-1 flex min-w-0 items-center truncate text-sm font-medium transition-opacity duration-150 motion-reduce:transition-none data-dimmed:opacity-40',\n    barsTrack: 'relative col-start-2 row-start-1 min-w-0',\n    barsNecks: 'absolute inset-0 overflow-visible',\n    bar: [\n      'absolute flex cursor-pointer items-center justify-center rounded-[min(14px,30%)] text-sm font-semibold tabular-nums outline-none',\n      'origin-center transition-opacity duration-150 motion-reduce:transition-none data-dimmed:opacity-30',\n      '[[data-revealing]_&]:animate-[panelui-funnel-bar_var(--funnel-grow)_cubic-bezier(0.2,0.7,0.2,1)_both] [[data-revealing]_&]:[animation-delay:calc(var(--funnel-stage)*var(--funnel-stagger))] motion-reduce:animate-none!',\n    ],\n    barValueOutside: 'absolute top-1/2 -translate-y-1/2 whitespace-nowrap text-sm font-semibold tabular-nums',\n    barsShare:\n      'col-start-3 flex flex-col items-end justify-center text-end transition-opacity duration-150 motion-reduce:transition-none data-dimmed:opacity-40',\n    barsShareTop: 'text-sm font-semibold tabular-nums',\n    barsShareStep: 'text-xs tabular-nums text-muted-foreground',\n    legend: 'col-start-1 row-start-3 flex w-full pt-3',\n    legendList: 'flex-col gap-1.5',\n    legendInline: 'flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5',\n    legendRow:\n      'flex cursor-pointer items-center gap-1.5 rounded-sm text-start text-xs outline-none transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none data-dimmed:opacity-40',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    legendName: 'truncate text-muted-foreground',\n    legendValue: 'font-medium tabular-nums',\n    legendShare: 'tabular-nums',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing: 'flex max-w-[55%] shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n  },\n});\n\n/** Milliseconds for a stage to dim and swell as another is read, and to settle on new data. */\nconst SELECT_DURATION = 180;\n\n/** How far in the innermost ring is drawn from the outermost. */\nconst RING_INSET = 0.35;\n\n/** Opacity of the outermost ring, and of the innermost. */\nconst RING_FAINTEST = 0.18;\nconst RING_STRONGEST = 0.83;\n\n/** How much further the core spreads when its stage is read. */\nconst SELECT_SPREAD = 0.12;\n\n/** Past a half, the sides' control points overshoot each other. See `ribbonCurve`. */\nconst CURVE = 0.55;\n\n/** How far the placeholder ribbon narrows along the run. */\nconst SKELETON_TAPER = 0.4;\n\nconst SKELETON_MARK = 'funnel-chart-skeleton';\n\nconst STAGE_MARK = 'funnel-chart-stage-';\n\n/*\n * Each stage grows out of the centre line, one after another. An area has no\n * entrance of its own to delay, so this runs in CSS on the marks' groups: every\n * ring of a stage shares its delay, through a variable the stage's rule sets.\n */\nconst KEYFRAMES = `@keyframes panelui-funnel-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}@keyframes panelui-funnel-grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}\n@media (prefers-reduced-motion:no-preference){[data-slot=funnel-chart-plot][data-revealing] [data-ts-key^='${STAGE_MARK}']{transform-box:fill-box;transform-origin:center;animation:panelui-funnel-grow var(--funnel-grow) cubic-bezier(.33,1,.68,1) calc(var(--funnel-stage,0)*var(--funnel-stagger)) both}}`;\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\n\n/** One end of a ring's band. */\ninterface RingRow {\n  stage: number;\n  x: number;\n  y1: number;\n  y2: number;\n}\n\n/** What the parts have asked for. Formatters are read when the readings render, so a new one costs nothing. */\ninterface Slots {\n  stages?: { dimOpacity: number };\n  skeleton?: { color: FunnelChartColor | undefined };\n  labels?: FunnelChartLabelsProps;\n}\n\ninterface FunnelChartContextValue {\n  data: readonly FunnelDatum[];\n  layout: FunnelLayout;\n  colors: readonly string[];\n  activeIndex: number;\n  /** A press on a stage's row or column: pins it, or lets it go. */\n  press: (index: number) => void;\n  /** A pointer over a stage's row, without a press. `-1` when it leaves. */\n  preview: (index: number) => void;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n}\n\nconst FunnelChartContext = createContext<FunnelChartContextValue | null>(null);\n\nfunction useChart(component: string): FunnelChartContextValue {\n  const context = useContext(FunnelChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <FunnelChart>`);\n  }\n  return context;\n}\n\n/** The stage being read and how it converted, for something rendered inside the chart. */\nexport function useFunnelChart() {\n  const { data, layout, activeIndex } = useChart('useFunnelChart');\n  return {\n    activeIndex,\n    activeStage: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,\n    /** Its share of the first stage, 0 to 1. */\n    activeShare: activeIndex >= 0 ? (layout.shares[activeIndex] ?? 0) : 0,\n    /** Its share of the stage above it, 0 to 1. */\n    activeStep: activeIndex >= 0 ? (layout.steps[activeIndex] ?? 0) : 0,\n  };\n}\n\nexport interface FunnelChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The steps, in the order they happen. Never reordered. */\n  data: readonly FunnelDatum[];\n  /**\n   * `ribbon` runs the stages along the width as one tapering band. `bars` puts\n   * each stage on a row of its own, as a centred bar with its name before it and\n   * its shares after it, for names too long to sit under a ribbon. Default `ribbon`.\n   */\n  shape?: FunnelShape;\n  /** Height of one stage's row with `shape=\"bars\"`, in pixels. Default `56`. */\n  rowHeight?: number;\n  /**\n   * How tall the run is drawn, in pixels. The width is the container's, and\n   * nothing in the data says how deep the ribbon should taper. With\n   * `shape=\"bars\"` the rows set the height instead.\n   */\n  height?: number;\n  /** How wide one stage is, in pixels. Left unset, the stages divide the width between them. */\n  stageSize?: number;\n  /** Space between one stage and the next, in pixels. */\n  gap?: number;\n  /** Concentric rings drawn per stage. `1` draws the band once, flat. */\n  layers?: number;\n  /** Whether the sides of a band are curves or straight diagonals. */\n  edges?: FunnelEdges;\n  /**\n   * The shortest a non-zero stage is drawn, as a share of the tallest. A genuine\n   * zero is still drawn as nothing.\n   */\n  minWidth?: number;\n  /** The funnel's hue, as one of the theme's roles. */\n  color?: FunnelChartColor;\n  /** Milliseconds for one stage to grow. */\n  animationDuration?: number;\n  /** Milliseconds between one stage starting and the next. `0` for all at once. */\n  staggerDelay?: number;\n  /** `loading` holds the stages back and grows them in when it turns `ready`. */\n  status?: FunnelChartStatus;\n  /** The stage being read. Leave unset to let the chart track it. */\n  activeIndex?: number;\n  /**\n   * The stage being read, and `-1`/`null` when there is none. Fires when the\n   * index changes, not per frame.\n   */\n  onActiveIndexChange?: (index: number, stage: FunnelDatum | null) => void;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one stage. Defaults to its name, count and share of the first. */\n  labelForDatum?: (stage: FunnelDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the entrance, for a \"replay\" control. */\nexport interface FunnelChartHandle {\n  replay: () => void;\n}\n\nconst FunnelChartRoot = forwardRef<FunnelChartHandle, FunnelChartProps & RenderProp<'div'>>(function FunnelChartRoot(\n  {\n    data,\n    shape = 'ribbon',\n    rowHeight = 56,\n    height = 200,\n    stageSize,\n    gap = 4,\n    layers = 3,\n    edges = 'curved',\n    minWidth = 0.1,\n    color = 'chart-1',\n    animationDuration = 700,\n    staggerDelay = 90,\n    status = 'ready',\n    activeIndex: activeIndexProp,\n    onActiveIndexChange,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = funnelChartVariants();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState({ width: 0, rtl: false });\n  const [slots, setSlots] = useState<Slots>({});\n  const [internalActive, setInternalActive] = useState(-1);\n  const [revision, setRevision] = useState(0);\n\n  const loading = status === 'loading';\n  const stacked = shape === 'bars';\n  const { width, rtl } = size;\n  const { stages, skeleton, labels } = slots;\n  const controlled = activeIndexProp !== undefined;\n  const active = loading ? -1 : controlled ? activeIndexProp : internalActive;\n\n  /*\n   * The chart mounts once its width is known. Mounted at a guess, the first\n   * measurement is a resize, and a resize ends the entrance before it is seen.\n   */\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const read = () => {\n      const next = { width: node.clientWidth, rtl: getComputedStyle(node).direction === 'rtl' };\n      setSize((current) => (current.width === next.width && current.rtl === next.rtl ? current : next));\n    };\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setSlot = useCallback<FunnelChartContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n    []\n  );\n\n  useImperativeHandle(ref, () => ({ replay: () => setRevision((current) => current + 1) }), []);\n\n  const layout = useMemo(\n    () => funnelLayout(data, { width, height, gap, stageSize, minWidth, mirrored: rtl }),\n    [data, width, height, gap, stageSize, minWidth, rtl]\n  );\n\n  const hue = COLOR_VAR[color];\n  const colors = useMemo(() => data.map((stage) => (stage.color ? COLOR_VAR[stage.color] : hue)), [data, hue]);\n  const rings = Math.max(1, Math.round(layers));\n  const curve = useMemo(() => ribbonCurve(edges === 'curved' ? CURVE : 0), [edges]);\n\n  const definition = useMemo(() => {\n    const dimOpacity = stages?.dimOpacity ?? 0.3;\n    const drawn = stages && !loading ? layout.bands : [];\n    const marks = drawn.flatMap((band, stage) => {\n      if (band.head <= 0 && band.tail <= 0) return [];\n      const selected = active === stage;\n      const dimmed = active >= 0 && !selected;\n      return Array.from({ length: rings }, (_unused, ring) => {\n        // A lone ring is the core, not the faint edge of a halo it does not have.\n        const depth = rings > 1 ? ring / (rings - 1) : 1;\n        // The outermost ring is the tallest and faintest, so the fill falls off\n        // towards the edge. Reading a stage pushes the core out past the halo, so\n        // the band swells rather than resizes.\n        const reach = (1 - (ring / rings) * RING_INSET) * (1 + (selected ? depth * SELECT_SPREAD : 0));\n        const rows: RingRow[] = [\n          { stage, x: band.offset, y1: layout.middle - band.head * reach, y2: layout.middle + band.head * reach },\n          {\n            stage,\n            x: band.offset + band.length,\n            y1: layout.middle - band.tail * reach,\n            y2: layout.middle + band.tail * reach,\n          },\n        ];\n        return areaY(rows, {\n          id: `${STAGE_MARK}${stage}-${ring}`,\n          x: 'x',\n          y1: 'y1',\n          y2: 'y2',\n          curve,\n          fill: colors[stage] ?? hue,\n          fillOpacity:\n            (layout.strengths[stage] ?? 1) *\n            (RING_FAINTEST + depth * (RING_STRONGEST - RING_FAINTEST)) *\n            (dimmed ? dimOpacity : 1),\n        });\n      });\n    });\n\n    const extent = Math.max(0, layout.middle - layout.textBand);\n    const placeholder =\n      loading && skeleton\n        ? [\n            areaY(\n              [\n                { stage: 0, x: 0, y1: layout.middle - extent, y2: layout.middle + extent },\n                {\n                  stage: 0,\n                  x: width,\n                  y1: layout.middle - extent * SKELETON_TAPER,\n                  y2: layout.middle + extent * SKELETON_TAPER,\n                },\n              ] satisfies RingRow[],\n              {\n                id: SKELETON_MARK,\n                x: 'x',\n                y1: 'y1',\n                y2: 'y2',\n                curve,\n                fill: skeleton.color ? COLOR_VAR[skeleton.color] : 'var(--skeleton, var(--muted))',\n                fillOpacity: 1,\n                motion: false,\n              }\n            ),\n          ]\n        : [];\n\n    const axis = { axis: false as const, grid: false as const };\n    return defineChart({\n      theme: THEME,\n      marks: [...placeholder, ...marks],\n      // The rows are already in pixels. The ribbon is symmetrical about the middle,\n      // so it does not matter which way up the height runs.\n      scales: {\n        x: { ...axis, scale: scaleLinear([0, Math.max(width, 1)], [0, 1]) },\n        y: { ...axis, scale: scaleLinear([0, Math.max(height, 1)], [0, 1]) },\n      },\n      guides: false,\n      margin: 0,\n      focusRing: false,\n      keyboard: false,\n      pointer: false,\n      focus: false,\n      motion: (context: ChartMotionContext<RingRow>) =>\n        context.phase === 'enter'\n          ? (false as const)\n          : { transition: { type: 'tween' as const, duration: SELECT_DURATION, easing: 'ease-out' as const } },\n    });\n  }, [\n    layout,\n    stages,\n    skeleton,\n    loading,\n    active,\n    rings,\n    curve,\n    colors,\n    hue,\n    width,\n    height,\n  ]);\n\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: SELECT_DURATION, easing: 'ease-out' } }), []);\n\n  /*\n   * A hover or a key previews, and a press pins. Everything shown is asked for\n   * through `read`: a controlled chart shows only what its parent keeps. A change\n   * this chart did not report came from outside, and counts as a pin.\n   */\n  const pinned = useRef(controlled ? activeIndexProp : -1);\n  const pointerStage = useRef(-1);\n  const reported = useRef(active);\n  const dragged = useRef(false);\n  const latest = useRef({ data, onActiveIndexChange, controlled });\n  useEffect(() => {\n    latest.current = { data, onActiveIndexChange, controlled };\n  });\n\n  const read = useCallback((index: number) => {\n    if (reported.current === index) return;\n    reported.current = index;\n    const { data: stages, onActiveIndexChange: notify, controlled: parentOwned } = latest.current;\n    if (!parentOwned) setInternalActive(index);\n    notify?.(index, index >= 0 ? (stages[index] ?? null) : null);\n  }, []);\n\n  useEffect(() => {\n    if (!controlled || activeIndexProp === reported.current) return;\n    pinned.current = activeIndexProp;\n    reported.current = activeIndexProp;\n  }, [controlled, activeIndexProp]);\n\n  const press = useCallback(\n    (index: number) => {\n      pinned.current = pinned.current === index ? -1 : index;\n      read(pinned.current >= 0 ? pinned.current : pointerStage.current);\n    },\n    [read]\n  );\n\n  const preview = useCallback(\n    (index: number) => {\n      pointerStage.current = index;\n      read(index >= 0 ? index : pinned.current);\n    },\n    [read]\n  );\n\n  // A remounted chart says nothing about what it was reading, and new data may not have that stage.\n  const count = data.length;\n  const lifecycle = `${revision}:${status}:${count}`;\n  const seenLifecycle = useRef(lifecycle);\n  useEffect(() => {\n    if (seenLifecycle.current === lifecycle) return;\n    seenLifecycle.current = lifecycle;\n    pinned.current = -1;\n    pointerStage.current = -1;\n    read(-1);\n  }, [lifecycle, read]);\n\n  // A pinned reading lasts until the next press elsewhere.\n  useEffect(() => {\n    if (active < 0) return;\n    const onPress = (event: PointerEvent) => {\n      if (rootRef.current?.contains(event.target as Node) || pinned.current < 0) return;\n      pinned.current = -1;\n      read(-1);\n    };\n    document.addEventListener('pointerdown', onPress, { capture: true });\n    return () => document.removeEventListener('pointerdown', onPress, { capture: true });\n  }, [active, read]);\n\n  const stageUnder = (event: ReactPointerEvent<HTMLDivElement> | ReactMouseEvent<HTMLDivElement>) => {\n    const box = event.currentTarget.getBoundingClientRect();\n    return stacked\n      ? rowAt(event.clientY - box.top, rowHeight, layout.bands.length)\n      : stageAt(layout.bands, event.clientX - box.left);\n  };\n  const plotHeight = stacked ? Math.max(1, count) * rowHeight : height;\n\n  const interactive = !loading && layout.bands.length > 0;\n\n  // The attribute comes off once the last stage has grown, so a later rebuild cannot replay it.\n  const chartKey = `${revision}:${status}`;\n  const [revealedKey, setRevealedKey] = useState('');\n  const lastDrawn = layout.bands.map(({ head, tail }) => head > 0 || tail > 0).lastIndexOf(true);\n  const stageRules = layout.bands\n    .map((_band, index) => `[data-ts-key^='${STAGE_MARK}${index}-']{--funnel-stage:${index}}`)\n    .join('');\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const last = layout.bands.length - 1;\n    const forward = rtl ? 'ArrowLeft' : 'ArrowRight';\n    const backward = rtl ? 'ArrowRight' : 'ArrowLeft';\n    let next: number | undefined;\n    if (event.key === forward || event.key === 'ArrowDown') next = active < 0 ? 0 : Math.min(active + 1, last);\n    else if (event.key === backward || event.key === 'ArrowUp') next = active < 0 ? last : Math.max(active - 1, 0);\n    else if (event.key === 'Home') next = 0;\n    else if (event.key === 'End') next = last;\n    if (next !== undefined) {\n      event.preventDefault();\n      read(next);\n    } else if ((event.key === 'Enter' || event.key === ' ') && active >= 0) {\n      event.preventDefault();\n      press(active);\n    }\n  };\n\n  // On the root, so it also clears a reading pinned from a legend row.\n  const onRootKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'Escape') return;\n    pinned.current = -1;\n    pointerStage.current = -1;\n    read(-1);\n  };\n\n  const spoken = (stage: FunnelDatum, index: number) =>\n    labelForDatum?.(stage, index) ??\n    `${stage.label}, ${stage.value}, ${Math.round((layout.shares[index] ?? 0) * 100)} percent of the first stage`;\n  const summary = label ?? `Funnel chart, ${count} ${count === 1 ? 'stage' : 'stages'}`;\n  const activeStage = active >= 0 ? data[active] : undefined;\n\n  const context = useMemo<FunnelChartContextValue>(\n    () => ({ data, layout, colors, activeIndex: active, press, preview, setSlot }),\n    [data, layout, colors, active, press, preview, setSlot]\n  );\n\n  const element = useRender({\n    defaultTagName: 'div',\n    ref: rootRef,\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'funnel-chart',\n        onKeyDown: onRootKeyDown,\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((stage, index) => (\n                  <li key={index}>{spoken(stage, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div aria-live=\"polite\" className=\"sr-only\">\n              {activeStage ? spoken(activeStage, active) : ''}\n            </div>\n            <div\n              ref={plotRef}\n              data-slot=\"funnel-chart-plot\"\n              className={slotClasses.plot()}\n              style={{\n                height: plotHeight,\n                ['--funnel-grow' as string]: `${animationDuration}ms`,\n                ['--funnel-stagger' as string]: `${Math.max(0, staggerDelay)}ms`,\n              }}\n              data-revealing={!loading && revealedKey !== chartKey ? '' : undefined}\n              onAnimationEnd={(event) => {\n                const target = event.target as HTMLElement;\n                const key = target.getAttribute('data-ts-key');\n                const last = stacked ? target.dataset.stage === String(lastDrawn) : key?.startsWith(`${STAGE_MARK}${lastDrawn}-`);\n                if (last) setRevealedKey(chartKey);\n              }}\n              {...(interactive\n                ? {\n                    role: 'group',\n                    tabIndex: 0,\n                    'aria-label': `${summary}. Use the arrow keys to read a stage.`,\n                    onKeyDown,\n                    onBlur: () => read(pinned.current >= 0 ? pinned.current : pointerStage.current),\n                    onPointerDown: () => {\n                      dragged.current = false;\n                    },\n                    onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {\n                      if (event.pointerType === 'touch') dragged.current = true;\n                      preview(stageUnder(event));\n                    },\n                    onPointerLeave: (event: ReactPointerEvent<HTMLDivElement>) => {\n                      // A finger lifting is not a reader leaving: the last reading stays.\n                      if (event.pointerType !== 'touch') preview(-1);\n                    },\n                    onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => {\n                      if (event.pointerType !== 'touch' || !dragged.current) return;\n                      pinned.current = stageUnder(event);\n                      pointerStage.current = -1;\n                      read(pinned.current);\n                    },\n                    onPointerCancel: () => {\n                      pointerStage.current = -1;\n                      read(pinned.current);\n                    },\n                    onClick: (event: ReactMouseEvent<HTMLDivElement>) => {\n                      if (dragged.current) {\n                        dragged.current = false;\n                        return;\n                      }\n                      // A screen reader's activation has no coordinates, so a column names its own stage.\n                      const column = (event.target as Element).closest<HTMLElement>('[data-stage]');\n                      const index = column ? Number(column.dataset.stage) : stageUnder(event);\n                      if (index >= 0) press(index);\n                    },\n                  }\n                : {})}\n            >\n              <style>{KEYFRAMES + stageRules}</style>\n              {stacked && width > 0 ? (\n                <FunnelChartBars\n                  key={chartKey}\n                  data={data}\n                  colors={colors}\n                  strengths={layout.strengths}\n                  shares={layout.shares}\n                  steps={layout.steps}\n                  rowHeight={rowHeight}\n                  minWidth={minWidth}\n                  active={active}\n                  loading={loading}\n                  stages={stages}\n                  skeleton={skeleton}\n                  labels={labels}\n                />\n              ) : null}\n              {!stacked && width > 0 ? (\n                <Chart\n                  // Stages arriving after a load, or a replay, are a new chart.\n                  key={chartKey}\n                  definition={definition}\n                  renderer={renderer}\n                  ariaLabel={summary}\n                  height={height}\n                  initialWidth={width}\n                  className={slotClasses.chart()}\n                />\n              ) : null}\n              {labels && interactive && !stacked ? (\n                <FunnelChartColumns {...labels} data={data} layout={layout} active={active} />\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <FunnelChartContext.Provider value={context}>{element}</FunnelChartContext.Provider>;\n});\nFunnelChartRoot.displayName = 'FunnelChart';\n\n/** A share as a percent: whole from 10%, one decimal under it, where a whole number would round a real drop away. */\nconst percentOf = (ratio: number) => {\n  const percent = ratio * 100;\n  return `${percent >= 10 || percent === 0 ? Math.round(percent) : Number(percent.toFixed(1))}%`;\n};\n\n/** Relative luminance of a resolved CSS colour, read back through a one-pixel canvas. */\nfunction luminanceOf(color: string, context: CanvasRenderingContext2D): number | null {\n  context.clearRect(0, 0, 1, 1);\n  context.fillStyle = '#000';\n  context.fillStyle = color;\n  context.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;\n  if (a === 0) return null;\n  const linear = (channel: number) => {\n    const value = channel / 255;\n    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;\n  };\n  return 0.2126 * linear(r!) + 0.7152 * linear(g!) + 0.0722 * linear(b!);\n}\n\n/**\n * Which of the theme's foreground and background reads better on each bar.\n * The stage colours are theme tokens that are light in one theme and dark in the\n * other, so no one ink works for every bar; each bar's resolved fill is measured,\n * and measured again when the theme changes.\n */\nfunction useBarInk(bars: readonly (HTMLElement | null)[], deps: readonly unknown[]) {\n  const [ink, setInk] = useState<string[]>([]);\n  useEffect(() => {\n    if (typeof document === 'undefined') return;\n    const canvas = document.createElement('canvas');\n    canvas.width = 1;\n    canvas.height = 1;\n    const context = canvas.getContext('2d', { willReadFrequently: true });\n    if (!context) return;\n    const measure = () => {\n      const root = getComputedStyle(document.documentElement);\n      const foreground = luminanceOf(root.getPropertyValue('--foreground').trim(), context) ?? 0;\n      const background = luminanceOf(root.getPropertyValue('--background').trim(), context) ?? 1;\n      const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n      const next = bars.map((bar) => {\n        if (!bar) return 'var(--foreground)';\n        const fill = luminanceOf(getComputedStyle(bar).backgroundColor, context);\n        if (fill === null) return 'var(--foreground)';\n        return contrast(fill, foreground) >= contrast(fill, background) ? 'var(--foreground)' : 'var(--background)';\n      });\n      setInk((current) => (current.join() === next.join() ? current : next));\n    };\n    measure();\n    // A theme switch changes the tokens without a render.\n    const observer = new MutationObserver(measure);\n    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });\n    const scheme = window.matchMedia('(prefers-color-scheme: dark)');\n    scheme.addEventListener('change', measure);\n    return () => {\n      observer.disconnect();\n      scheme.removeEventListener('change', measure);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, deps);\n  return ink;\n}\n\n/**\n * The stacked-bars shape: a row per stage, its name, a centred bar and its\n * shares. The bars' column is measured on its own, since the names and the\n * shares take what they need and the bars have the rest.\n */\nfunction FunnelChartBars({\n  data,\n  colors,\n  strengths,\n  shares,\n  steps,\n  rowHeight,\n  minWidth,\n  active,\n  loading,\n  stages,\n  skeleton,\n  labels,\n}: {\n  data: readonly FunnelDatum[];\n  colors: readonly string[];\n  strengths: readonly number[];\n  shares: readonly number[];\n  steps: readonly number[];\n  rowHeight: number;\n  minWidth: number;\n  active: number;\n  loading: boolean;\n  stages: Slots['stages'];\n  skeleton: Slots['skeleton'];\n  labels: Slots['labels'];\n}) {\n  const slots = funnelChartVariants();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [track, setTrack] = useState(0);\n  useEffect(() => {\n    const node = trackRef.current;\n    if (!node) return;\n    const read = () => setTrack(node.clientWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const bars = useMemo(() => funnelBars(data, { width: track, rowHeight, minWidth }), [data, track, rowHeight, minWidth]);\n  const barRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const ink = useBarInk(barRefs.current, [colors, strengths, bars.length, track > 0]);\n  const format = labels?.formatValue ?? ((value: number) => COMPACT.format(value));\n  const formatPercent = labels?.formatShare ?? percentOf;\n  const formatStep = labels?.formatStep ?? ((ratio: number) => `${percentOf(ratio)} of previous`);\n  const share = labels?.share ?? 'top';\n  const dim = (index: number) => (active >= 0 && active !== index ? '' : undefined);\n  const rows = { gridTemplateRows: `repeat(${Math.max(1, data.length)}, ${rowHeight}px)` };\n\n  if (loading) {\n    // One plain block, not placeholder stages: an invented drop-off cannot be told from a real one.\n    return skeleton ? (\n      <div className={slots.bars()} style={rows} aria-hidden=\"true\">\n        <div\n          className=\"col-span-3 row-span-full my-2 animate-pulse rounded-2xl motion-reduce:animate-none\"\n          style={{ background: skeleton.color ? COLOR_VAR[skeleton.color] : 'var(--skeleton, var(--muted))' }}\n        />\n      </div>\n    ) : null;\n  }\n\n  return (\n    <div className={slots.bars()} style={rows}>\n      {labels && labels.showLabel !== false\n        ? data.map((stage, index) => (\n            <span\n              key={`name-${index}`}\n              className={slots.barsName()}\n              style={{ gridRow: index + 1 }}\n              data-dimmed={dim(index)}\n            >\n              {stage.label}\n            </span>\n          ))\n        : null}\n      <div ref={trackRef} className={slots.barsTrack()} style={{ gridRow: `1 / span ${Math.max(1, data.length)}` }}>\n        {stages ? (\n          <>\n            {/* The necks: from one bar's foot to the next one's head, faint, under the bars. */}\n            <svg className={slots.barsNecks()} aria-hidden=\"true\" focusable=\"false\">\n              {bars.slice(0, -1).map((bar, index) => {\n                const next = bars[index + 1]!;\n                if (bar.width <= 0 || next.width <= 0) return null;\n                const foot = bar.y + bar.height;\n                return (\n                  <polygon\n                    key={index}\n                    points={`${bar.x},${foot} ${bar.x + bar.width},${foot} ${next.x + next.width},${next.y} ${next.x},${next.y}`}\n                    style={{\n                      fill: colors[index],\n                      fillOpacity: 0.16 * (active >= 0 && active !== index && active !== index + 1 ? stages.dimOpacity : 1),\n                      transition: 'fill-opacity 150ms',\n                    }}\n                  />\n                );\n              })}\n            </svg>\n            {bars.map((bar, index) => {\n              const stage = data[index];\n              if (!stage || bar.width <= 0) return null;\n              const value = format(stage.value, stage);\n              const inside = labels?.showValue !== false && labelFits(value.length, bar.width);\n              return (\n                <button\n                  key={index}\n                  ref={(node) => {\n                    barRefs.current[index] = node;\n                  }}\n                  type=\"button\"\n                  tabIndex={-1}\n                  data-slot=\"funnel-chart-bar\"\n                  data-stage={index}\n                  data-dimmed={dim(index)}\n                  aria-pressed={active === index}\n                  aria-label={`${stage.label}, ${value}, ${formatPercent(shares[index] ?? 0, stage)}`}\n                  className={slots.bar()}\n                  style={{\n                    left: bar.x,\n                    top: bar.y,\n                    width: bar.width,\n                    height: bar.height,\n                    background: `color-mix(in oklab, ${colors[index]} ${Math.round((strengths[index] ?? 1) * 100)}%, var(--background))`,\n                    ['--funnel-stage' as string]: index,\n                  }}\n                >\n                  {inside ? (\n                    <span className=\"truncate px-2\" style={{ color: ink[index] ?? 'var(--foreground)' }}>\n                      {value}\n                    </span>\n                  ) : null}\n                  {!inside && labels?.showValue !== false ? (\n                    <span className={slots.barValueOutside()} style={{ left: `calc(100% + 10px)` }}>\n                      {value}\n                    </span>\n                  ) : null}\n                </button>\n              );\n            })}\n          </>\n        ) : null}\n      </div>\n      {labels && share !== 'none'\n        ? data.map((stage, index) => {\n            const main = share === 'top' ? shares[index] : steps[index];\n            const showMain = !(share === 'previous' && index === 0);\n            // The second stage's share of the first is its share of the previous one, so it is said once.\n            const showStep = share === 'top' && index >= 2;\n            return (\n              <span\n                key={`share-${index}`}\n                className={slots.barsShare()}\n                style={{ gridRow: index + 1 }}\n                data-dimmed={dim(index)}\n              >\n                {showMain ? <span className={slots.barsShareTop()}>{formatPercent(main ?? 0, stage)}</span> : null}\n                {showStep ? <span className={slots.barsShareStep()}>{formatStep(steps[index] ?? 0, stage)}</span> : null}\n              </span>\n            );\n          })\n        : null}\n    </div>\n  );\n}\n\n/**\n * The readings around the ribbon. Each column is a button for a screen reader's\n * cursor, and skipped by Tab, where the plot's arrow keys already walk the stages.\n * The press bubbles to the plot, which reads the stage from `data-stage`.\n */\nfunction FunnelChartColumns({\n  className,\n  formatValue,\n  formatShare,\n  share = 'top',\n  showValue = true,\n  showLabel = true,\n  data,\n  layout,\n  active,\n}: FunnelChartLabelsProps & { data: readonly FunnelDatum[]; layout: FunnelLayout; active: number }) {\n  const slots = funnelChartVariants();\n  const format = formatValue ?? ((value: number) => COMPACT.format(value));\n  const formatPercent = formatShare ?? ((ratio: number) => `${Math.round(ratio * 100)}%`);\n\n  return layout.bands.map((band, index) => {\n    const stage = data[index];\n    if (!stage) return null;\n    const ratio = share === 'top' ? layout.shares[index] : layout.steps[index];\n    const pill = share === 'none' || (share === 'previous' && index === 0) ? null : formatPercent(ratio ?? 0, stage);\n    const value = format(stage.value, stage);\n    return (\n      <button\n        key={index}\n        type=\"button\"\n        tabIndex={-1}\n        data-slot=\"funnel-chart-labels\"\n        data-stage={index}\n        data-dimmed={active >= 0 && active !== index ? '' : undefined}\n        aria-pressed={active === index}\n        aria-label={`${stage.label}, ${value}${pill ? `, ${pill}` : ''}`}\n        className={slots.column({ className })}\n        style={{ left: band.offset, width: band.length }}\n      >\n        <span className={slots.columnStrip({ className: 'justify-end' })} style={{ height: layout.textBand }}>\n          {showValue ? <span className={slots.columnValue()}>{value}</span> : null}\n        </span>\n        <span className={slots.columnMiddle()}>{pill ? <span className={slots.columnPill()}>{pill}</span> : null}</span>\n        <span className={slots.columnStrip()} style={{ height: layout.textBand }}>\n          {showLabel ? <span className={slots.columnName()}>{stage.label}</span> : null}\n        </span>\n      </button>\n    );\n  });\n}\n\nexport interface FunnelChartStagesProps {\n  /** Opacity of the stages that are not being read, once one is. */\n  dimOpacity?: number;\n}\n\n/**\n * Every stage, in the order the data lists them. One part rather than one per\n * stage, because a stage's near edge is the previous stage's far edge.\n */\nfunction FunnelChartStages({\n  dimOpacity = 0.3,\n}: FunnelChartStagesProps) {\n  const { setSlot } = useChart('FunnelChart.Stages');\n  useEffect(() => {\n    setSlot('stages', { dimOpacity });\n    return () => setSlot('stages', undefined);\n  }, [dimOpacity, setSlot]);\n  return null;\n}\nFunnelChartStages.displayName = 'FunnelChart.Stages';\n\nexport interface FunnelChartSkeletonProps {\n  /** Which of the theme's roles to draw the placeholder in. Defaults to the theme's skeleton colour. */\n  color?: FunnelChartColor;\n}\n\n/**\n * The loading state: one plain ribbon over the whole run, undivided. Placeholder\n * stages would be an invented drop-off, and a reader cannot tell one from a real one.\n */\nfunction FunnelChartSkeleton({\n  color,\n}: FunnelChartSkeletonProps) {\n  const { setSlot } = useChart('FunnelChart.Skeleton');\n  useEffect(() => {\n    setSlot('skeleton', { color });\n    return () => setSlot('skeleton', undefined);\n  }, [color, setSlot]);\n  return null;\n}\nFunnelChartSkeleton.displayName = 'FunnelChart.Skeleton';\n\nexport interface FunnelChartLabelsProps {\n  /** Added to each stage's column. */\n  className?: string;\n  /** Format the count. Defaults to a compact number. */\n  formatValue?: (value: number, stage: FunnelDatum) => string;\n  /** Format the conversion in the pill. Defaults to a whole percent. */\n  formatShare?: (share: number, stage: FunnelDatum) => string;\n  /**\n   * Which conversion the pill reports. `top` is the share of the first stage.\n   * `previous` is the share of the stage above, so the first stage has no pill.\n   */\n  share?: FunnelShare;\n  /** Show the count above the ribbon, or on the bar with `shape=\"bars\"`. */\n  showValue?: boolean;\n  /** Show the stage's name under the ribbon, or before the bar with `shape=\"bars\"`. */\n  showLabel?: boolean;\n  /**\n   * With `shape=\"bars\"`, the quieter line under a stage's share of the first,\n   * from the third stage on: its share of the stage above. Defaults to\n   * \"57% of previous\".\n   */\n  formatStep?: (step: number, stage: FunnelDatum) => string;\n}\n\n/**\n * The readings, arranged around the ribbon: the count above the band, the name\n * under it, and the conversion in a pill on the band. Split this way, each has\n * the stage's whole column, so a name stays whole at phone width.\n */\nfunction FunnelChartLabels({\n  className,\n  formatValue,\n  formatShare,\n  share,\n  showValue,\n  showLabel,\n  formatStep,\n}: FunnelChartLabelsProps) {\n  const { setSlot } = useChart('FunnelChart.Labels');\n  useEffect(() => {\n    setSlot('labels', { className, formatValue, formatShare, share, showValue, showLabel, formatStep });\n    return () => setSlot('labels', undefined);\n  }, [className, formatValue, formatShare, share, showValue, showLabel, formatStep, setSlot]);\n  return null;\n}\nFunnelChartLabels.displayName = 'FunnelChart.Labels';\n\nexport interface FunnelChartLegendProps extends ComponentProps<'div'> {\n  /**\n   * `list` gives every stage a row, with the numbers in a column. `inline` runs\n   * them together and wraps, which is denser where the names are short.\n   */\n  layout?: FunnelLegendLayout;\n  /** Show each stage's reading beside its name. */\n  showValue?: boolean;\n  /** Format the value in a `list` key. Defaults to a compact number. */\n  formatValue?: (value: number, stage: FunnelDatum) => string;\n}\n\n/** A swatch, a name and a reading per stage, under the run. For a funnel drawn without `Labels`. */\nfunction FunnelChartLegend({\n  layout: arrangement = 'list',\n  showValue = true,\n  formatValue,\n  className,\n  render,\n  ...props\n}: FunnelChartLegendProps & RenderProp<'div'>) {\n  const { data, layout, colors, activeIndex, press, preview } = useChart('FunnelChart.Legend');\n  const slots = funnelChartVariants();\n  const list = arrangement === 'list';\n  const format = formatValue ?? ((value: number) => COMPACT.format(value));\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'funnel-chart-legend',\n        className: slots.legend({ className: [list ? slots.legendList() : slots.legendInline(), className] }),\n        children: data.map((stage, index) => {\n          const percent = Math.round((layout.shares[index] ?? 0) * 100);\n          return (\n            <button\n              key={index}\n              type=\"button\"\n              aria-pressed={activeIndex === index}\n              aria-label={`${stage.label}, ${percent} percent of the first stage`}\n              data-dimmed={activeIndex >= 0 && activeIndex !== index ? '' : undefined}\n              className={slots.legendRow({ className: list ? 'w-full' : 'max-w-full' })}\n              onPointerEnter={(event) => {\n                if (event.pointerType !== 'touch') preview(index);\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType !== 'touch') preview(-1);\n              }}\n              onFocus={() => preview(index)}\n              onBlur={() => preview(-1)}\n              onClick={() => press(index)}\n            >\n              <span\n                className={slots.legendSwatch()}\n                // Mixed with the page rather than given an opacity: Chromium paints a wide-gamut\n                // colour with alpha wrongly inside a dimmed row.\n                style={{\n                  background: `color-mix(in oklab, ${colors[index]} ${(layout.strengths[index] ?? 1) * 100}%, var(--background))`,\n                }}\n              />\n              <span className={slots.legendName({ className: list ? 'flex-1' : 'shrink' })}>{stage.label}</span>\n              {showValue && list ? <span className={slots.legendValue()}>{format(stage.value, stage)}</span> : null}\n              {showValue ? (\n                <span\n                  className={slots.legendShare({\n                    className: list ? 'w-10 text-end text-muted-foreground' : 'font-medium',\n                  })}\n                >\n                  {percent}%\n                </span>\n              ) : null}\n            </button>\n          );\n        }),\n      },\n      props\n    ),\n  });\n\n  return data.length ? element : null;\n}\nFunnelChartLegend.displayName = 'FunnelChart.Legend';\n\nexport interface FunnelChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the funnel is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a caveat. */\n  caption?: ReactNode;\n  /** Trailing slot: a control, a badge, a range picker. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the run. The value is not derived, because 41800 is a count, a\n * currency or a rate depending on what was counted. A readout that follows the\n * pointer takes it from `onActiveIndexChange` and passes the string down.\n */\nfunction FunnelChartHeader({\n  title,\n  value,\n  caption,\n  className,\n  children,\n  render,\n  ...props\n}: FunnelChartHeaderProps & RenderProp<'div'>) {\n  const slots = funnelChartVariants();\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'funnel-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {children ? <div className={slots.headerTrailing()}>{children}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nFunnelChartHeader.displayName = 'FunnelChart.Header';\n\nexport const FunnelChart = Object.assign(FunnelChartRoot, {\n  Header: FunnelChartHeader,\n  Stages: FunnelChartStages,\n  Labels: FunnelChartLabels,\n  Legend: FunnelChartLegend,\n  Skeleton: FunnelChartSkeleton,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { FunnelChartHeader, FunnelChartStages, FunnelChartLabels, FunnelChartLegend, FunnelChartSkeleton };\n"
    },
    {
      "path": "components/panelui/funnel-chart/funnel-layout.ts",
      "type": "registry:component",
      "target": "@components/panelui/funnel-chart/funnel-layout.ts",
      "content": "/**\n * The arithmetic behind a funnel, apart from anything drawn: how far each stage\n * reaches from the centre line, what it converted at, and which column a pointer\n * is over. No imports with a runtime, so a test can run it directly.\n */\nimport type { ChartCurve } from '@tanstack/charts';\n\n/** How far the hue has faded by the end of the run. */\nconst FADE = 0.35;\n\n/** The share of the height kept above the ribbon for the count, and below it for the name. */\nconst TEXT_BAND = 0.15;\n\nexport interface FunnelLayoutStage {\n  value: number;\n  /** A stage with its own colour is drawn at full strength instead of the fade. */\n  color?: unknown;\n}\n\n/** One stage's band: where it sits along the run and how far it reaches at each end. */\nexport interface FunnelBand {\n  /** Where it starts along the run, in pixels from the left. */\n  offset: number;\n  /** How wide it is, in pixels. */\n  length: number;\n  /** Half-height at its left end, in pixels. */\n  head: number;\n  /** Half-height at its right end, in pixels. */\n  tail: number;\n}\n\nexport interface FunnelLayoutOptions {\n  width: number;\n  height: number;\n  /** Space between one stage and the next, in pixels. */\n  gap: number;\n  /** How wide one stage is. The stages divide the width when it is left out. */\n  stageSize?: number;\n  /** The shortest a non-zero stage is drawn, as a share of the tallest. */\n  minWidth: number;\n  /** Lay the bands from the right, for a right-to-left page. */\n  mirrored?: boolean;\n}\n\nexport interface FunnelLayout {\n  bands: FunnelBand[];\n  /** Each stage's share of the first stage, 0 to 1. */\n  shares: number[];\n  /** Each stage's share of the one above it, 0 to 1. The first is 1. */\n  steps: number[];\n  /** How far the hue has faded at each stage. `1` where a colour was given. */\n  strengths: number[];\n  /** Room kept above the ribbon and below it, in pixels. */\n  textBand: number;\n  /** The ribbon's centre line, in pixels. */\n  middle: number;\n}\n\nconst positive = (value: number) => (Number.isFinite(value) ? Math.max(0, value) : 0);\n\n/**\n * Lays the stages out in the order given, without sorting. Heights are measured\n * against the largest value rather than the first, so a stage larger than the one\n * above it is drawn as given. The floor applies only to stages with something in\n * them, and a stage above it keeps its true height.\n */\nexport function funnelLayout(\n  stages: readonly FunnelLayoutStage[],\n  { width, height, gap, stageSize, minWidth, mirrored = false }: FunnelLayoutOptions\n): FunnelLayout {\n  const count = stages.length;\n  const values = stages.map(({ value }) => positive(value));\n  const peak = Math.max(0, ...values);\n  const first = values[0] ?? 0;\n  const spacing = Math.max(0, gap);\n  const textBand = height * TEXT_BAND;\n  const reach = Math.max(0, height / 2 - textBand);\n  const floor = Math.max(0, Math.min(minWidth, 1));\n  const size = stageSize ?? (count > 0 ? Math.max(0, (width - (count - 1) * spacing) / count) : 0);\n  const extents = values.map((value) => (value > 0 && peak > 0 ? reach * Math.max(value / peak, floor) : 0));\n\n  return {\n    bands: extents.map((extent, index) => {\n      const offset = index * (size + spacing);\n      const next = extents[index + 1] ?? extent;\n      return {\n        offset: mirrored ? width - offset - size : offset,\n        length: size,\n        head: mirrored ? next : extent,\n        tail: mirrored ? extent : next,\n      };\n    }),\n    shares: values.map((value) => (first > 0 ? value / first : 0)),\n    steps: values.map((value, index) => {\n      if (index === 0) return 1;\n      const previous = values[index - 1] ?? 0;\n      return previous > 0 ? value / previous : 0;\n    }),\n    strengths: stages.map(({ color }, index) => {\n      if (color !== undefined || count < 2) return 1;\n      return 1 - (index / (count - 1)) * FADE;\n    }),\n    textBand,\n    middle: height / 2,\n  };\n}\n\n/** The stage whose column holds `x`. A pointer in a gap belongs to the nearer stage, and past the run to none. */\nexport function stageAt(bands: readonly FunnelBand[], x: number): number {\n  let nearest = -1;\n  let nearestGap = Infinity;\n  bands.forEach(({ offset, length }, index) => {\n    const distance = x < offset ? offset - x : x > offset + length ? x - offset - length : 0;\n    if (distance < nearestGap) {\n      nearest = index;\n      nearestGap = distance;\n    }\n  });\n  const spacing = bands.length > 1 ? Math.abs(bands[1]!.offset - bands[0]!.offset) - bands[0]!.length : 0;\n  return nearestGap <= spacing / 2 ? nearest : -1;\n}\n\n/**\n * Sides that reach past each other, so consecutive bands meet flush and the run\n * reads as one channel. Past a half the control points overshoot, which flattens\n * the ends of the join and puts the whole slope in the middle. `0` draws straight\n * diagonals.\n */\nexport function ribbonCurve(reach: number): ChartCurve {\n  const side = (from: readonly [number, number], to: readonly [number, number]) => {\n    if (!(reach > 0)) return `L${to[0]},${to[1]}`;\n    const pull = (to[0] - from[0]) * reach;\n    return `C${from[0] + pull},${from[1]} ${to[0] - pull},${to[1]} ${to[0]},${to[1]}`;\n  };\n  const run = (points: readonly (readonly [number, number])[]) =>\n    points.slice(1).map((point, index) => side(points[index]!, point)).join('');\n\n  return {\n    line: (points) => (points[0] ? `M${points[0][0]},${points[0][1]}${run(points)}` : ''),\n    area: (top, bottom) => {\n      const back = [...bottom].reverse();\n      if (!top[0] || !back[0]) return '';\n      return `M${top[0][0]},${top[0][1]}${run(top)}L${back[0][0]},${back[0][1]}${run(back)}Z`;\n    },\n  };\n}\n\n/** One stage in the stacked-bars shape: a bar centred in its row. */\nexport interface FunnelBar {\n  /** Left edge of the bar, in pixels from the left of the bars' column. */\n  x: number;\n  /** Width of the bar, in pixels. */\n  width: number;\n  /** Top of the bar, in pixels from the top of the plot. */\n  y: number;\n  /** Height of the bar, in pixels. */\n  height: number;\n}\n\nexport interface FunnelBarsOptions {\n  /** Width of the column the bars are centred in, in pixels. */\n  width: number;\n  /** Height of one stage's row, in pixels. */\n  rowHeight: number;\n  /** The narrowest a non-zero stage is drawn, as a share of the widest. */\n  minWidth: number;\n  /** How much of a row the bar fills; the rest is the neck to the next one. */\n  fill?: number;\n}\n\n/**\n * Lays the stages out as centred bars, one per row, each as wide as its value\n * against the largest. The bars are centred so the drop-off reads from both\n * sides, and the necks between them are the trapezoids from one bar's foot to\n * the next one's head.\n */\nexport function funnelBars(stages: readonly FunnelLayoutStage[], { width, rowHeight, minWidth, fill = 0.72 }: FunnelBarsOptions): FunnelBar[] {\n  const values = stages.map(({ value }) => positive(value));\n  const peak = Math.max(0, ...values);\n  const floor = Math.max(0, Math.min(minWidth, 1));\n  const span = Math.max(0, width);\n  const height = Math.max(0, rowHeight * Math.max(0, Math.min(fill, 1)));\n  return values.map((value, index) => {\n    const share = value > 0 && peak > 0 ? Math.max(value / peak, floor) : 0;\n    const barWidth = span * share;\n    return {\n      x: (span - barWidth) / 2,\n      width: barWidth,\n      y: index * rowHeight + (rowHeight - height) / 2,\n      height,\n    };\n  });\n}\n\n/** Whether a label of `characters` fits inside a bar `width` wide, with room either side. */\nexport function labelFits(characters: number, width: number, characterWidth = 8, padding = 24): boolean {\n  return characters * characterWidth + padding <= width;\n}\n\n/** The row under a pointer `y` pixels from the top, or `-1` outside the rows. */\nexport function rowAt(y: number, rowHeight: number, count: number): number {\n  if (!(rowHeight > 0) || y < 0) return -1;\n  const row = Math.floor(y / rowHeight);\n  return row < count ? row : -1;\n}\n"
    }
  ]
}
