{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-chart","type":"registry:component","title":"Morphing chart","description":"Bars, line and area as one set of marks: bars condense into beads, a thread is strung through them, the fill pours down. Drag the switch to scrub it.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/morphing-chart.tsx","type":"registry:component","content":"// Hairline UI · morphing-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * MORPHING CHART: one set of points, three shapes\n *\n *   bars → line    each bar condenses up into a bead on its\n *                  value; a thread is strung through the beads\n *                  from the left and they melt into it as it passes\n *   line → bars    the thread is pulled out to the right; each\n *                  bead swells back as the tail leaves it and drips\n *                  a stick to the axis that lands and spreads\n *   line → area    the fill pours down from the line, left to\n *                  right; back to line, it drains up into it\n *   bars → area    the bars widen until they touch and fade to a\n *                  wash; the steps they make melt into the curve\n *   scrub          drag the switch's thumb and the morph follows\n *                  your finger, frame by frame; let go and it runs\n *                  on to the nearer shape. Asking for the shape it\n *                  came from mid-morph runs it back from there\n *   data           new values: every mark eases to its new height a\n *                  beat after its neighbour and the scale rolls\n *   hover          bars dim but the one you're on; on the line and\n *                  the area a bead slides along the curve to the\n *                  point, and the figure and its label roll to it\n *\n * The plot is one tab stop: arrow keys read each point, and the\n * shape switch is a radio group. Every figure is also in a table\n * for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { NumberRoll } from './number-roll';\nimport { TextMorph } from './text-morph';\nimport { EASE_OUT, releaseVelocity, samplePointer, useInteractionReducedMotion, type PointerSample } from './ui-interaction-motion';\nimport { CHART_SHAPES, type ChartShape, useChartMorph } from './use-chart-morph';\n\nexport type { ChartShape };\n\nexport interface MorphingChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  title?: string;\n  /** Under the figure, e.g. \"Last 12 months\". */\n  caption?: string;\n  /** Along the bottom, one per value. */\n  labels: string[];\n  /** In the readout when a point is read, e.g. \"October 2026\". Defaults to labels. */\n  titles?: string[];\n  values: number[];\n  shape?: ChartShape;\n  defaultShape?: ChartShape;\n  onShapeChange?: (shape: ChartShape) => void;\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n}\n\nconst THROW = 'cubic-bezier(0.34,1.36,0.64,1)';\nconst TOP = 10;\nconst LABELS = 24;\nconst STEPS = 4;\nconst FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40';\nconst SHAPE_LABELS: Record<ChartShape, string> = { bars: 'Bars', line: 'Line', area: 'Area' };\n\nfunction niceTop(value: number) {\n  if (value <= 0) return STEPS;\n  const raw = value / STEPS;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  return step * STEPS;\n}\n\nexport function MorphingChart({\n  title = 'Revenue',\n  caption,\n  labels,\n  titles,\n  values,\n  shape: shapeProp,\n  defaultShape = 'bars',\n  onShapeChange,\n  format,\n  locales,\n  className = '',\n  ...props\n}: MorphingChartProps) {\n  const reduced = useInteractionReducedMotion();\n  const id = React.useId().replace(/[^\\w-]/g, '');\n  const n = labels.length;\n  const [ownShape, setOwnShape] = React.useState<ChartShape>(defaultShape);\n  const shape = shapeProp ?? ownShape;\n  const morph = useChartMorph({ count: n, shape, reduced });\n  const engine = React.useRef(morph);\n  engine.current = morph;\n  // The engine owns the wash from the first frame on; React only writes the starting value.\n  const [firstWash] = React.useState(() => shape === 'area');\n\n  const [visible, setVisible] = React.useState(false);\n  const [size, setSize] = React.useState({ width: 0, height: 0 });\n  const [active, setActive] = React.useState<number | null>(null);\n  const [keyboard, setKeyboard] = React.useState(false);\n  const [dragging, setDragging] = React.useState(false);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const chartRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLSpanElement>(null);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const placed = React.useRef(false);\n  const drag = React.useRef<{ id: number; x: number; from: number; p: number; near: number; moved: boolean; samples: PointerSample[] } | null>(null);\n  const swallowClick = React.useRef(false);\n\n  const clean = React.useMemo(() => labels.map((_, i) => Math.max(0, values[i] ?? 0)), [labels, values]);\n  const max = niceTop(Math.max(0, ...clean));\n  const total = clean.reduce((s, v) => s + v, 0);\n\n  // Measure the plot: its width from the card, its height from the --plot variable the container sets.\n  React.useLayoutEffect(() => {\n    const plot = plotRef.current, chart = chartRef.current;\n    if (!plot || !chart) return;\n    const measure = () =>\n      setSize((s) => {\n        const width = plot.offsetWidth, height = chart.offsetHeight - LABELS;\n        return width === s.width && height === s.height ? s : { width, height };\n      });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(plot);\n    observer.observe(chart);\n    return () => observer.disconnect();\n  }, []);\n  React.useLayoutEffect(() => {\n    if (size.width && size.height) engine.current.layout({ width: size.width, height: size.height, top: TOP });\n  }, [size]);\n\n  // Arrive once on screen: every mark rises from the axis.\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => {\n      if (entry.isIntersecting) { setVisible(true); observer.disconnect(); }\n    }, { rootMargin: '0px 0px -10% 0px' });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const arrived = React.useRef(false);\n  const dataKey = `${clean.join(',')}|${max}`;\n  React.useLayoutEffect(() => {\n    if (!visible || !size.width) {\n      engine.current.data(clean.map(() => 0), max, { animate: false });\n      return;\n    }\n    engine.current.data(clean, max, { animate: true, from: arrived.current ? undefined : 0 });\n    arrived.current = true;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [dataKey, visible, size.width > 0]);\n\n  const firstShape = React.useRef(true);\n  React.useEffect(() => {\n    if (firstShape.current) { firstShape.current = false; return; }\n    engine.current.shape(shape);\n  }, [shape]);\n  React.useEffect(() => engine.current.point(active), [active]);\n\n  // The thumb: equal segments, so it only ever translates. Written directly so a drag can carry it.\n  const geo = React.useRef({ left: 0, step: 0 });\n  const placeThumb = React.useCallback((p: number, animate: boolean) => {\n    const el = thumbRef.current, a = chipRefs.current[0], b = chipRefs.current[1];\n    if (!el || !a || !b) return;\n    geo.current = { left: a.offsetLeft, step: b.offsetLeft - a.offsetLeft };\n    el.style.width = `${a.offsetWidth}px`;\n    el.style.transition = animate && !reduced ? `translate 460ms ${THROW}` : 'none';\n    el.style.translate = `${geo.current.left + p * geo.current.step}px 0`;\n  }, [reduced]);\n  React.useLayoutEffect(() => {\n    if (drag.current?.moved) return;\n    placeThumb(CHART_SHAPES.indexOf(shape), placed.current);\n    placed.current = true;\n  }, [shape, placeThumb]);\n  const shapeNow = React.useRef(shape);\n  shapeNow.current = shape;\n  React.useLayoutEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    // The segments resize when the card crosses its breakpoint; the first call (on observe) is skipped.\n    let first = true;\n    const observer = new ResizeObserver(() => {\n      if (first) { first = false; return; }\n      if (!drag.current?.moved) placeThumb(CHART_SHAPES.indexOf(shapeNow.current), false);\n    });\n    observer.observe(track);\n    return () => observer.disconnect();\n  }, [placeThumb]);\n\n  const choose = (next: ChartShape) => {\n    if (next === shape) return;\n    if (shapeProp === undefined) setOwnShape(next);\n    onShapeChange?.(next);\n  };\n\n  // Scrub: drag the thumb and the morph is held at the frame under it. Nothing is measured or rendered\n  // per move: the segments are measured when the drag starts, and the thumb and the chart are written directly.\n  const tint = (near: number | null) =>\n    chipRefs.current.forEach((chip, k) => {\n      if (chip) chip.style.color = near === null ? '' : k === near ? 'var(--foreground)' : 'var(--muted-foreground)';\n    });\n  const onTrackPointerDown = (e: React.PointerEvent) => {\n    if (e.button !== 0 || reduced) return;\n    const chip = (e.target as Element).closest('[role=\"radio\"]');\n    const from = engine.current.position();\n    if (!chip || chip.getAttribute('aria-checked') !== 'true' || from === null) return;\n    drag.current = { id: e.pointerId, x: e.clientX, from, p: from, near: Math.round(from), moved: false, samples: [] };\n  };\n  const onTrackPointerMove = (e: React.PointerEvent) => {\n    const d = drag.current, thumb = thumbRef.current;\n    if (!d || d.id !== e.pointerId || !thumb) return;\n    const dx = e.clientX - d.x;\n    if (!d.moved) {\n      if (Math.abs(dx) < 4) return;\n      d.moved = true;\n      try { trackRef.current?.setPointerCapture(e.pointerId); } catch {}\n      placeThumb(d.from, false);\n      setDragging(true);\n    }\n    const { left, step } = geo.current;\n    d.p = Math.max(0, Math.min(CHART_SHAPES.length - 1, d.from + dx / (step || 1)));\n    samplePointer(d.samples, d.p, e.timeStamp);\n    engine.current.scrub(d.p);\n    thumb.style.translate = `${left + d.p * step}px 0`;\n    if (Math.round(d.p) !== d.near) tint((d.near = Math.round(d.p)));\n  };\n  const onTrackPointerUp = (e: React.PointerEvent) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    if (!d.moved) return;\n    // The click that follows this release isn't a choice; if none comes, stop waiting for it.\n    swallowClick.current = true;\n    window.setTimeout(() => { swallowClick.current = false; }, 0);\n    setDragging(false);\n    // Run on to the nearer neighbour, or the one it was flung toward.\n    const lo = Math.min(CHART_SHAPES.length - 2, Math.floor(d.p));\n    const ahead = d.p + releaseVelocity(d.samples, e.timeStamp) * 160;\n    const target = CHART_SHAPES[ahead - lo >= 0.5 ? lo + 1 : lo];\n    engine.current.release(target);\n    tint(null);\n    placeThumb(CHART_SHAPES.indexOf(target), true);\n    choose(target);\n  };\n\n  const band = n ? size.width / n : 0;\n  const pointAt = (clientX: number) => {\n    const r = plotRef.current?.getBoundingClientRect();\n    if (!r || !n) return null;\n    return Math.min(n - 1, Math.max(0, Math.floor(((clientX - r.left) / r.width) * n)));\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!n) return;\n    const from = active ?? n - 1;\n    const moves: Record<string, number> = { ArrowLeft: from - 1, ArrowRight: from + 1, Home: 0, End: n - 1 };\n    if (e.key === 'Escape') return setActive(null);\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    setKeyboard(true);\n    setActive(Math.min(n - 1, Math.max(0, active === null ? from : moves[e.key])));\n  };\n\n  const fmt = (v: number) => new Intl.NumberFormat(locales, format).format(v);\n  const name = (i: number) => titles?.[i] ?? labels[i];\n  const scaleFormat: Intl.NumberFormatOptions = { ...format, notation: 'compact', minimumFractionDigits: 0, maximumFractionDigits: 1 };\n  const every = size.width && n ? Math.max(1, Math.ceil(38 / (size.width / n))) : 1;\n  const label = (i: number) => active === i || ((n - 1 - i) % every === 0 && (active === null || every === 1 || Math.abs(i - active) >= every));\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background text-foreground shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5 [--gutter:30px] @md:[--gutter:40px] [--plot:150px] @md:[--plot:190px]\">\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n          <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">\n            <TextMorph>{title}</TextMorph>\n          </p>\n          <div\n            ref={trackRef}\n            role=\"radiogroup\"\n            aria-label=\"Chart shape\"\n            onPointerDown={onTrackPointerDown}\n            onPointerMove={onTrackPointerMove}\n            onPointerUp={onTrackPointerUp}\n            onPointerCancel={onTrackPointerUp}\n            onClickCapture={(e) => {\n              if (!swallowClick.current) return;\n              swallowClick.current = false;\n              e.stopPropagation();\n            }}\n            className={`relative -my-1 inline-grid shrink-0 touch-pan-y select-none auto-cols-fr grid-flow-col rounded-full bg-muted p-0.5 ${dragging ? 'cursor-grabbing' : ''}`}\n          >\n            <span ref={thumbRef} aria-hidden=\"true\" className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\" style={{ willChange: 'translate' }} />\n            {CHART_SHAPES.map((s, i) => {\n              const selected = s === shape;\n              return (\n                <button\n                  key={s}\n                  ref={(el) => { chipRefs.current[i] = el; }}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={selected}\n                  tabIndex={selected ? 0 : -1}\n                  onClick={() => choose(s)}\n                  onKeyDown={(e) => {\n                    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;\n                    e.preventDefault();\n                    const next = (i + (e.key === 'ArrowRight' ? 1 : -1) + CHART_SHAPES.length) % CHART_SHAPES.length;\n                    choose(CHART_SHAPES[next]);\n                    chipRefs.current[next]?.focus();\n                  }}\n                  className={`relative flex h-6 items-center justify-center gap-1 whitespace-nowrap rounded-full px-2 text-[11.5px] font-medium transition-colors duration-300 @md:h-7 @md:gap-1.5 @md:px-3 @md:text-[12.5px] ${FOCUS} ${\n                    selected ? `text-foreground ${dragging ? 'cursor-grabbing' : 'cursor-grab'}` : 'cursor-pointer text-muted-foreground hover:text-foreground'\n                  }`}\n                >\n                  <ShapeGlyph shape={s} />\n                  {SHAPE_LABELS[s]}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n        <p className=\"mt-0.5 text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px]\">\n          <NumberRoll value={visible ? (active !== null ? clean[active] : total) : 0} format={format} locales={locales} duration={active !== null ? 600 : 1000} />\n        </p>\n        <p className=\"mt-1 whitespace-nowrap text-[11.5px] text-muted-foreground @md:text-[12.5px]\">\n          <TextMorph>{active !== null ? name(active) : (caption ?? ' ')}</TextMorph>\n        </p>\n\n        <div ref={chartRef} className=\"relative mt-4 @md:mt-5\" style={{ height: `calc(var(--plot) + ${LABELS}px)` }}>\n          {Array.from({ length: STEPS + 1 }, (_, k) => 1 - k / STEPS).map((f) => (\n            <div key={f} aria-hidden=\"true\" className=\"absolute inset-x-0\" style={{ top: TOP + (size.height - TOP) * (1 - f) }}>\n              <span className=\"absolute left-0 top-0 -translate-y-1/2 text-[10px] leading-none tabular-nums text-muted-foreground/80\" style={{ width: 'calc(var(--gutter) - 8px)', textAlign: 'right' }}>\n                <NumberRoll value={visible ? max * f : 0} format={scaleFormat} locales={locales} duration={700} />\n              </span>\n              <div className=\"border-t border-border\" style={{ marginLeft: 'var(--gutter)' }} />\n            </div>\n          ))}\n\n          <div\n            ref={plotRef}\n            role=\"group\"\n            tabIndex={0}\n            aria-label={`${title}. Use the arrow keys to read each point.`}\n            onKeyDown={onKeyDown}\n            onBlur={() => keyboard && (setActive(null), setKeyboard(false))}\n            onPointerMove={(e) => { setKeyboard(false); setActive(pointAt(e.clientX)); }}\n            onPointerDown={(e) => setActive(pointAt(e.clientX))}\n            onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}\n            onPointerUp={(e) => e.pointerType !== 'mouse' && setActive(null)}\n            onPointerCancel={() => setActive(null)}\n            className={`absolute inset-y-0 right-0 touch-pan-y select-none rounded-lg ${FOCUS}`}\n            style={{ left: 'var(--gutter)' }}\n          >\n            <svg aria-hidden=\"true\" width={size.width} height={size.height} className=\"absolute left-0 top-0 overflow-visible will-change-transform\">\n              <defs>\n                <linearGradient id={`${id}-wash`} gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1={TOP} x2=\"0\" y2={size.height || 1}>\n                  <stop ref={(el) => { morph.stops.current[0] = el; }} offset=\"0\" stopColor=\"var(--chart-1, var(--primary))\" stopOpacity={firstWash ? 0.22 : 1} />\n                  <stop ref={(el) => { morph.stops.current[1] = el; }} offset=\"1\" stopColor=\"var(--chart-1, var(--primary))\" stopOpacity={firstWash ? 0.03 : 1} />\n                </linearGradient>\n              </defs>\n              <path ref={morph.area} fill={`url(#${id}-wash)`} />\n              <g ref={morph.columnGroup} data-wash={`url(#${id}-wash)`} fill=\"var(--chart-1, var(--primary))\">\n                {labels.map((l, i) => (\n                  <path\n                    key={`${l}-${i}`}\n                    ref={(el) => { morph.columns.current[i] = el; }}\n                    style={{ opacity: active === null || active === i || shape !== 'bars' ? 1 : 0.32, transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}` }}\n                  />\n                ))}\n              </g>\n              <path ref={morph.line} fill=\"none\" stroke=\"var(--chart-1, var(--primary))\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n\n            {/* The hover bead and its hairline: the engine slides them along the curve. */}\n            <div ref={morph.rule} aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 w-px bg-border opacity-0\" style={{ height: size.height, transition: reduced ? 'none' : `opacity 200ms ${EASE_OUT}` }} />\n            <div\n              ref={morph.bead}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute left-0 top-0 size-[9px] rounded-full bg-background opacity-0 shadow-[inset_0_0_0_2px_var(--chart-1,var(--primary))]\"\n              style={{ scale: 0.4, willChange: 'translate', transition: reduced ? 'none' : `opacity 200ms ${EASE_OUT}, scale 280ms ${THROW}` }}\n            />\n\n            {labels.map((l, i) => (\n              <span\n                key={`${l}-${i}`}\n                aria-hidden=\"true\"\n                className=\"absolute -translate-x-1/2 whitespace-nowrap text-[10px] leading-none tabular-nums transition-[opacity,color] duration-300\"\n                style={{ left: band * (i + 0.5), top: size.height + 9, opacity: label(i) ? 1 : 0, color: active === i ? 'var(--foreground)' : 'var(--muted-foreground)' }}\n              >\n                {l}\n              </span>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <table className=\"sr-only\">\n        <caption>{title}{caption ? `, ${caption}` : ''}</caption>\n        <tbody>\n          {labels.map((l, i) => (\n            <tr key={`${l}-${i}`}><th scope=\"row\">{name(i)}</th><td>{fmt(clean[i])}</td></tr>\n          ))}\n        </tbody>\n      </table>\n      <p className=\"sr-only\" aria-live=\"polite\">{keyboard && active !== null ? `${name(active)}: ${fmt(clean[active])}` : ''}</p>\n    </div>\n  );\n}\n\n/** A small drawing of each shape for the switch. */\nfunction ShapeGlyph({ shape }: { shape: ChartShape }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 14 14\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      {shape === 'bars' && <path d=\"M3 12V7M7 12V3M11 12V8\" />}\n      {shape === 'line' && <path d=\"M2 10.5 5.5 6l3 2.5L12 3.5\" />}\n      {shape === 'area' && <><path d=\"M2 10 5.5 6l3 2.5L12 4v8H2Z\" fill=\"currentColor\" fillOpacity=\"0.25\" stroke=\"none\" /><path d=\"M2 10 5.5 6l3 2.5L12 4\" /></>}\n    </svg>\n  );\n}\n"},{"path":"registry/use-chart-morph.ts","type":"registry:component","content":"// Hairline UI · morphing-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\n/*\n * The morphing chart's engine. Bars, beads, the line and the area are four drawings of the same\n * points, so a change of shape is a choreography over shared geometry, never a cross-fade:\n *\n *   bars → line    each bar condenses up into a bead on its value; a thread is strung through the\n *                  beads from the left, and each bead melts into it as the thread passes\n *   line → bars    the thread is pulled out to the right; each bead swells back as the tail leaves\n *                  it and drips a stick to the axis, which lands and spreads into its bar\n *   line → area    the fill pours down from the line to the axis, left to right\n *   area → line    it drains back up into the line\n *   bars → area    the bars widen until they touch and fade to the wash; the steps they make melt\n *                  into the curve and the thread runs along the top\n *   area → bars    the curve sets into steps, which part into bars and fill in\n *\n * Every change is a tape: a pose that is a pure function of time. One frame loop plays it forward\n * or backward (asking for the shape it came from runs it back from where it is), and a finger can\n * hold it at any frame (scrub). Changes of data and the hover bead ride the same loop, which writes\n * path data and transforms straight to the DOM and sleeps when nothing moves.\n */\n\nexport type ChartShape = 'bars' | 'line' | 'area';\n\nexport type ChartLayout = { width: number; height: number; top: number };\n\n/** The shapes in the switch's order: scrubbing runs along this axis. */\nexport const CHART_SHAPES: ChartShape[] = ['bars', 'line', 'area'];\n\nconst DOT = 7;\nconst STROKE = 2;\nconst WASH_TOP = 0.22;\nconst WASH_BOTTOM = 0.03;\nconst SAMPLES = 10;\nconst BEAD_TAU = 70;\n/** How far a bar bulges before it pinches (as a share of the pinch), and how high a bead hops as it closes. */\nconst BULGE = 0.18;\nconst HOP = 2.2;\n\nconst clamp01 = (n: number) => Math.max(0, Math.min(1, n));\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst inOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);\n/** The time at which inOut reaches y. */\nconst inOutAt = (y: number) => (y < 0.5 ? Math.cbrt(y / 4) : 1 - Math.cbrt(2 * (1 - y)) / 2);\nconst out = (t: number) => 1 - (1 - t) ** 3;\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\n/** Tangents of a monotone cubic through the points: smooth, never overshooting a value. */\nfunction tangents(xs: number[], ys: number[]) {\n  const n = xs.length;\n  if (n < 2) return xs.map(() => 0);\n  const m: number[] = [];\n  for (let i = 0; i < n - 1; i++) m.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i] || 1));\n  const t = [m[0]];\n  for (let i = 1; i < n - 1; i++) t.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2);\n  t.push(m[n - 2]);\n  for (let i = 0; i < n - 1; i++) {\n    if (m[i] === 0) { t[i] = 0; t[i + 1] = 0; continue; }\n    const a = t[i] / m[i], b = t[i + 1] / m[i], s = a * a + b * b;\n    if (s > 9) { const k = 3 / Math.sqrt(s); t[i] = k * a * m[i]; t[i + 1] = k * b * m[i]; }\n  }\n  return t;\n}\n\ntype Pt = [number, number];\nconst mix = (p: Pt, q: Pt, s: number): Pt => [lerp(p[0], q[0], s), lerp(p[1], q[1], s)];\n\n/** The piece of a cubic between s0 and s1 (de Casteljau twice). */\nfunction piece(p0: Pt, p1: Pt, p2: Pt, p3: Pt, s0: number, s1: number): [Pt, Pt, Pt, Pt] {\n  // Keep [0, s1].\n  const a = mix(p0, p1, s1), b = mix(p1, p2, s1), c = mix(p2, p3, s1), ab = mix(a, b, s1), bc = mix(b, c, s1), e = mix(ab, bc, s1);\n  if (s0 <= 0) return [p0, a, ab, e];\n  // Of that, keep [s0 / s1, 1].\n  const u = s0 / s1;\n  const f = mix(p0, a, u), g = mix(a, ab, u), h = mix(ab, e, u), fg = mix(f, g, u), gh = mix(g, h, u);\n  return [mix(fg, gh, u), gh, h, e];\n}\n\n/**\n * The curve between two x positions. Each segment's control points sit at thirds in x, so x runs\n * linearly with the curve's parameter and a cut at any x is exact.\n */\nfunction curveBetween(xs: number[], ys: number[], t: number[], xa: number, xb: number) {\n  let d = '';\n  for (let i = 0; i < xs.length - 1; i++) {\n    const h = xs[i + 1] - xs[i];\n    if (xs[i + 1] <= xa || xs[i] >= xb || h <= 0) continue;\n    const s0 = clamp01((xa - xs[i]) / h), s1 = clamp01((xb - xs[i]) / h);\n    if (s1 - s0 < 1e-4) continue;\n    const dx = h / 3;\n    const [q0, q1, q2, q3] = piece([xs[i], ys[i]], [xs[i] + dx, ys[i] + t[i] * dx], [xs[i + 1] - dx, ys[i + 1] - t[i + 1] * dx], [xs[i + 1], ys[i + 1]], s0, s1);\n    if (!d) d = `M${r2(q0[0])},${r2(q0[1])}`;\n    d += `C${r2(q1[0])},${r2(q1[1])} ${r2(q2[0])},${r2(q2[1])} ${r2(q3[0])},${r2(q3[1])}`;\n  }\n  return d;\n}\n\n/** The same curve, evaluated at x (cubic Hermite on the segment). */\nfunction curveAt(xs: number[], ys: number[], t: number[], x: number) {\n  const n = xs.length;\n  if (n === 1) return ys[0];\n  let i = Math.min(n - 2, Math.max(0, Math.floor(((x - xs[0]) / (xs[n - 1] - xs[0] || 1)) * (n - 1))));\n  while (i > 0 && x < xs[i]) i--;\n  while (i < n - 2 && x > xs[i + 1]) i++;\n  const h = xs[i + 1] - xs[i] || 1, s = clamp01((x - xs[i]) / h);\n  const h00 = 2 * s ** 3 - 3 * s ** 2 + 1, h10 = s ** 3 - 2 * s ** 2 + s, h01 = -2 * s ** 3 + 3 * s ** 2, h11 = s ** 3 - s ** 2;\n  return h00 * ys[i] + h10 * h * t[i] + h01 * ys[i + 1] + h11 * h * t[i + 1];\n}\n\n/** A box with its own top and bottom corner radii. */\nfunction box(l: number, t: number, r: number, b: number, rt: number, rb: number) {\n  const w = r - l, h = b - t;\n  if (w <= 0.01 || h <= 0.01) return '';\n  rt = Math.max(0, Math.min(rt, w / 2, h / 2));\n  rb = Math.max(0, Math.min(rb, w / 2, h - rt));\n  return `M${r2(l)},${r2(t + rt)}A${r2(rt)},${r2(rt)} 0 0 1 ${r2(l + rt)},${r2(t)}H${r2(r - rt)}A${r2(rt)},${r2(rt)} 0 0 1 ${r2(r)},${r2(t + rt)}V${r2(b - rb)}A${r2(rb)},${r2(rb)} 0 0 1 ${r2(r - rb)},${r2(b)}H${r2(l + rb)}A${r2(rb)},${r2(rb)} 0 0 1 ${r2(l)},${r2(b - rb)}Z`;\n}\n\ntype Route = { from: ChartShape; to: ChartShape; t: number; dir: 1 | -1 };\n\ntype Pose = {\n  /** Per point: widened into a step (merge), condensed into a bead (dot), melted into the thread (zip). */\n  m: Float32Array; d: Float32Array; z: Float32Array;\n  cols: boolean;\n  /** 0 solid, 1 the area's fading gradient. */\n  wash: number;\n  areaOn: boolean;\n  /** The area's top edge: 0 the curve, 1 steps. */\n  step: number;\n  /** The thread is drawn between these fractions of the x range. */\n  ta: number; tb: number;\n  /** How far the area's floor has poured: 'full', or pouring in / draining out at time t. */\n  pour: 'full' | 'in' | 'out'; pourT: number;\n};\n\nconst poseFor = (n: number): Pose => ({\n  m: new Float32Array(n), d: new Float32Array(n), z: new Float32Array(n),\n  cols: true, wash: 0, areaOn: false, step: 0, ta: 0, tb: 0, pour: 'full', pourT: 0,\n});\n\n/** Timings shared by the tapes; the stagger shrinks with more points so a sweep stays under ~300ms. */\nfunction timing(n: number) {\n  const stag = n > 1 ? Math.min(28, 300 / (n - 1)) : 0;\n  const spread = (n - 1) * stag;\n  const condense = 460;\n  const threadIn = condense * 0.9;\n  const sweep = Math.max(420, spread * 1.25 + 90);\n  const melt = 260;\n  const pull = Math.max(380, spread * 1.1 + 120);\n  const drip = 540;\n  const widen = 380;\n  const swap = widen + spread * 0.5;\n  return { stag, spread, condense, threadIn, sweep, melt, pull, drip, widen, swap };\n}\n\nexport function routeDuration(from: ChartShape, to: ChartShape, n: number) {\n  const k = timing(n);\n  switch (`${from}>${to}`) {\n    case 'bars>line': return k.threadIn + k.sweep + 40 + k.melt;\n    case 'line>bars': return k.pull + 40 + k.drip;\n    case 'line>area': return 760;\n    case 'area>line': return 620;\n    case 'bars>area': return k.swap + 620;\n    case 'area>bars': return 380 + 480 + k.spread * 0.6;\n    default: return 0;\n  }\n}\n\n/** The resting pose of a shape. */\nfunction settle(P: Pose, s: ChartShape) {\n  P.m.fill(s === 'area' ? 1 : 0);\n  P.d.fill(s === 'line' ? 1 : 0);\n  P.z.fill(s === 'line' ? 1 : 0);\n  P.cols = s !== 'area';\n  P.wash = s === 'area' ? 1 : 0;\n  P.areaOn = s === 'area';\n  P.step = 0;\n  P.ta = 0; P.tb = s === 'bars' ? 0 : 1;\n  P.pour = 'full'; P.pourT = 0;\n}\n\n/** The pose of a tape at time t. Every field is set, so the tape can run either way or be held. */\nfunction pose(P: Pose, from: ChartShape, to: ChartShape, t: number, n: number) {\n  const k = timing(n);\n  const u = (i: number) => (n > 1 ? i / (n - 1) : 0);\n  settle(P, from);\n  switch (`${from}>${to}`) {\n    case 'bars>line': {\n      // The thread's tip eases across; each bead melts once the tip has passed it.\n      const tip = inOut(clamp01((t - k.threadIn) / k.sweep));\n      for (let i = 0; i < n; i++) {\n        P.d[i] = clamp01((t - i * k.stag) / k.condense);\n        P.z[i] = clamp01((t - (k.threadIn + k.sweep * inOutAt(u(i)) + 40)) / k.melt);\n      }\n      P.ta = 0; P.tb = tip;\n      return;\n    }\n    case 'line>bars': {\n      // The tail is pulled to the right; each bead swells just before it's left, then drips its bar.\n      const tail = inOut(clamp01(t / k.pull));\n      for (let i = 0; i < n; i++) {\n        const leave = k.pull * inOutAt(u(i));\n        P.z[i] = 1 - out(clamp01((t - Math.max(0, leave - 140)) / 200));\n        P.d[i] = 1 - clamp01((t - leave - 40) / k.drip);\n      }\n      P.ta = tail; P.tb = 1;\n      return;\n    }\n    case 'line>area': {\n      P.cols = false; P.areaOn = t > 0; P.wash = 1; P.ta = 0; P.tb = 1;\n      P.pour = 'in'; P.pourT = t;\n      return;\n    }\n    case 'area>line': {\n      const end = routeDuration('area', 'line', n);\n      P.cols = false; P.areaOn = t < end; P.wash = 1; P.ta = 0; P.tb = 1;\n      P.pour = 'out'; P.pourT = t;\n      return;\n    }\n    case 'bars>area': {\n      for (let i = 0; i < n; i++) P.m[i] = out(clamp01((t - i * k.stag * 0.5) / k.widen));\n      P.wash = out(clamp01(t / k.swap));\n      if (t < k.swap) { P.step = 1; return; }\n      P.cols = false; P.areaOn = true;\n      P.step = 1 - inOut(clamp01((t - k.swap) / 460));\n      P.ta = 0; P.tb = inOut(clamp01((t - k.swap - 160) / 440));\n      return;\n    }\n    case 'area>bars': {\n      P.step = inOut(clamp01(t / 380));\n      P.ta = out(clamp01(t / 260)); P.tb = 1;\n      if (t < 380) return;\n      P.areaOn = false; P.cols = true;\n      for (let i = 0; i < n; i++) P.m[i] = 1 - out(clamp01((t - 380 - i * k.stag * 0.6) / 480));\n      P.wash = 1 - out(clamp01((t - 380) / (480 + k.spread * 0.6)));\n      return;\n    }\n  }\n}\n\nexport function useChartMorph({ count, shape, reduced, barWidth = 24 }: { count: number; shape: ChartShape; reduced: boolean; barWidth?: number }) {\n  const columns = React.useRef<(SVGPathElement | null)[]>([]);\n  const columnGroup = React.useRef<SVGGElement>(null);\n  const area = React.useRef<SVGPathElement>(null);\n  const line = React.useRef<SVGPathElement>(null);\n  const stops = React.useRef<(SVGStopElement | null)[]>([]);\n  const bead = React.useRef<HTMLDivElement>(null);\n  const rule = React.useRef<HTMLDivElement>(null);\n\n  const st = React.useRef<{\n    layout: ChartLayout; pose: Pose; shown: ChartShape; route: Route | null; queued: ChartShape | null; held: boolean;\n    vals: Float32Array; from: Float32Array; to: Float32Array; dataT0: number; delay: number; max: number; maxFrom: number; maxTo: number;\n    point: number | null; beadX: number; beadOn: boolean; last: number; n: number;\n  } | null>(null);\n  if (!st.current) {\n    const P = poseFor(count);\n    settle(P, shape);\n    st.current = {\n      layout: { width: 0, height: 0, top: 10 }, pose: P, shown: shape, route: null, queued: null, held: false,\n      vals: new Float32Array(count), from: new Float32Array(count), to: new Float32Array(count), dataT0: -1, delay: 25, max: 1, maxFrom: 1, maxTo: 1,\n      point: null, beadX: 0, beadOn: false, last: 0, n: count,\n    };\n  }\n  const S = st.current;\n  if (S.pose.m.length !== count) {\n    const keep = (a: Float32Array) => Float32Array.from({ length: count }, (_, i) => a[i] ?? a[a.length - 1] ?? 0);\n    S.vals = keep(S.vals); S.from = keep(S.from); S.to = keep(S.to);\n    if (S.route) S.shown = S.route.dir > 0 ? S.route.to : S.route.from;\n    S.pose = poseFor(count);\n    settle(S.pose, S.shown);\n    S.route = null; S.queued = null; S.held = false;\n    S.point = S.point !== null && S.point < count ? S.point : null;\n  }\n  S.n = count;\n\n  const frame = React.useRef(0);\n  const still = React.useRef(reduced);\n  still.current = reduced;\n\n  const paint = (dt = 0) => {\n    const P = S.pose, n = S.n;\n    const { width: W, height: H, top } = S.layout;\n    if (!W || !H || !n) return;\n    const band = W / n;\n    const bw = Math.min(barWidth, band * 0.56);\n    const xs = Array.from({ length: n }, (_, i) => band * (i + 0.5));\n    const max = S.max || 1;\n    const ys = Array.from(S.vals, (v) => top + (H - top) * (1 - Math.max(0, v) / max));\n    const x0 = xs[0], xN = xs[n - 1];\n    const tg = tangents(xs, ys);\n    const near = (x: number) => ys[Math.max(0, Math.min(n - 1, Math.round((x - x0) / band)))];\n\n    // Columns: bars, steps on their way to the area, or beads.\n    const g = columnGroup.current;\n    if (g) g.style.display = P.cols ? '' : 'none';\n    if (P.cols) {\n      for (let i = 0; i < n; i++) {\n        const el = columns.current[i];\n        if (!el) continue;\n        const x = xs[i], yt = ys[i], mi = P.m[i], di = P.d[i], zi = P.z[i];\n        let l = lerp(x - bw / 2, n > 1 ? Math.max(x0, x - band / 2) : x - band / 2, mi);\n        let r = lerp(x + bw / 2, n > 1 ? Math.min(xN, x + band / 2) : x + band / 2, mi);\n        // Condensing: the bar bulges a touch, pinches to a stick, then the stick retracts up into a bead\n        // on the value, which hops as it closes. Run backward, the same curves make a drip that lifts\n        // off, falls, and spreads past its width as it lands.\n        const ps = clamp01(di / 0.45), ls = clamp01((di - 0.3) / 0.7);\n        const pinch = inOut(ps) - BULGE * Math.sin(Math.PI * clamp01(ps / 0.45)), lift = inOut(ls);\n        const hop = HOP * Math.sin(Math.PI * clamp01((ls - 0.72) / 0.28));\n        const cx = (l + r) / 2, w = lerp(r - l, DOT, pinch);\n        l = cx - w / 2; r = cx + w / 2;\n        let t = yt - (DOT / 2) * lift - hop, b = lerp(H, yt + DOT / 2, lift) - hop;\n        // Melting: the bead shrinks to the thread's thickness, under it, and goes.\n        if (zi > 0) {\n          if (zi >= 1) { el.setAttribute('d', ''); continue; }\n          const k = lerp(1, STROKE / DOT, out(zi)), cy = (t + b) / 2, hw = (r - l) / 2, hh = (b - t) / 2;\n          l = cx - hw * k; r = cx + hw * k; t = cy - hh * k; b = cy + hh * k;\n        }\n        const rt = lerp(4 * (1 - mi), DOT / 2, Math.max(0, pinch));\n        const rb = (DOT / 2) * lift;\n        el.setAttribute('d', box(l, t, r, b, rt, rb));\n      }\n    }\n\n    // The wash: solid for bars, a fading gradient for the area. Columns paint with a plain fill until it starts.\n    const washing = P.wash > 0.001 ? '1' : '';\n    if (g && g.dataset.washing !== washing) {\n      g.dataset.washing = washing;\n      g.setAttribute('fill', washing ? (g.dataset.wash ?? '') : 'var(--chart-1, var(--primary))');\n    }\n    const [s0, s1] = stops.current;\n    if (s0) s0.setAttribute('stop-opacity', String(r2(lerp(1, WASH_TOP, P.wash))));\n    if (s1) s1.setAttribute('stop-opacity', String(r2(lerp(1, WASH_BOTTOM, P.wash))));\n\n    // The area: a top edge between the curve and steps, a floor that pours down to the axis.\n    const a = area.current;\n    if (a) {\n      a.style.display = P.areaOn && n > 1 ? '' : 'none';\n      if (P.areaOn && n > 1) {\n        if (P.step < 0.001 && P.pour === 'full') {\n          a.setAttribute('d', `${curveBetween(xs, ys, tg, x0, xN)}L${r2(xN)},${H}L${r2(x0)},${H}Z`);\n        } else {\n          const px: number[] = [];\n          for (let i = 0; i < n - 1; i++) {\n            for (let k = 0; k < SAMPLES; k++) px.push(xs[i] + (k / SAMPLES) * band);\n            const mid = xs[i] + band / 2;\n            px.push(mid - 0.01, mid + 0.01);\n          }\n          px.push(xN);\n          px.sort((p, q) => p - q);\n          const tops = px.map((x) => lerp(curveAt(xs, ys, tg, x), near(x), P.step));\n          const floor = (x: number) => {\n            const v = (x - x0) / (xN - x0 || 1);\n            if (P.pour === 'full') return 1;\n            if (P.pour === 'in') return out(clamp01((P.pourT - v * 300) / 460));\n            return 1 - inOut(clamp01((P.pourT - v * 240) / 380));\n          };\n          let d = `M${r2(px[0])},${r2(tops[0])}`;\n          for (let k = 1; k < px.length; k++) d += `L${r2(px[k])},${r2(tops[k])}`;\n          if (P.pour === 'full') d += `L${r2(xN)},${H}L${r2(x0)},${H}Z`;\n          else {\n            for (let k = px.length - 1; k >= 0; k--) d += `L${r2(px[k])},${r2(lerp(tops[k], H, floor(px[k])))}`;\n            d += 'Z';\n          }\n          a.setAttribute('d', d);\n        }\n      }\n    }\n\n    // The thread: the curve cut exactly between its tail and its tip.\n    const ln = line.current;\n    if (ln) {\n      const xa = lerp(x0, xN, P.ta), xb = lerp(x0, xN, P.tb);\n      const on = n > 1 && xb - xa > 0.5;\n      ln.style.display = on ? '' : 'none';\n      if (on) ln.setAttribute('d', curveBetween(xs, ys, tg, xa, xb));\n    }\n\n    // The hover bead slides along the thread (it follows the curve, not a straight line), with a hairline under it.\n    const bd = bead.current, rl = rule.current;\n    if (bd && rl) {\n      const showing = S.point !== null && n > 1 && (P.areaOn || P.tb - P.ta > 0.98);\n      if (S.point !== null) {\n        const target = xs[S.point];\n        S.beadX = !S.beadOn || still.current ? target : lerp(target, S.beadX, Math.exp(-dt / BEAD_TAU));\n        if (Math.abs(S.beadX - target) < 0.05) S.beadX = target;\n      }\n      if (showing !== S.beadOn) {\n        S.beadOn = showing;\n        bd.style.opacity = rl.style.opacity = showing ? '1' : '0';\n        bd.style.scale = showing ? '1' : '0.4';\n      }\n      const x = S.beadX, y = lerp(curveAt(xs, ys, tg, x), near(x), P.step);\n      bd.style.translate = `${r2(x - 4.5)}px ${r2(y - 4.5)}px`;\n      rl.style.translate = `${r2(x - 0.5)}px 0`;\n    }\n  };\n\n  const tick = (now: number) => {\n    frame.current = 0;\n    const dt = S.last ? Math.min(64, now - S.last) : 16;\n    S.last = now;\n    let moving = false;\n    // Data: each value eases to its new height, a little after its left neighbour.\n    if (S.dataT0 >= 0) {\n      const t = now - S.dataT0;\n      let done = true;\n      for (let i = 0; i < S.n; i++) {\n        const k = out(clamp01((t - i * S.delay) / 650));\n        if (k < 1) done = false;\n        S.vals[i] = lerp(S.from[i], S.to[i], k);\n      }\n      S.max = lerp(S.maxFrom, S.maxTo, out(clamp01(t / 650)));\n      if (done) { S.dataT0 = -1; S.max = S.maxTo; } else moving = true;\n    }\n    // Shape: play the tape the way it's heading; land, then take any shape asked for meanwhile.\n    const R = S.route;\n    if (R && !S.held) {\n      const end = routeDuration(R.from, R.to, S.n);\n      R.t = Math.max(0, Math.min(end, R.t + dt * R.dir));\n      if ((R.dir > 0 && R.t >= end) || (R.dir < 0 && R.t <= 0)) {\n        S.shown = R.dir > 0 ? R.to : R.from;\n        settle(S.pose, S.shown);\n        S.route = null;\n        const next = S.queued;\n        S.queued = null;\n        if (next && next !== S.shown) { S.route = { from: S.shown, to: next, t: 0, dir: 1 }; moving = true; }\n      } else {\n        pose(S.pose, R.from, R.to, R.t, S.n);\n        moving = true;\n      }\n    }\n    // The bead: eases toward its point.\n    if (S.point !== null && S.beadOn && Math.abs(S.beadX - (S.layout.width / S.n) * (S.point + 0.5)) > 0.05) moving = true;\n    paint(dt);\n    if (moving) frame.current = requestAnimationFrame(tick);\n    else S.last = 0;\n  };\n\n  const kick = () => {\n    if (!frame.current) frame.current = requestAnimationFrame(tick);\n  };\n\n  React.useEffect(() => () => { cancelAnimationFrame(frame.current); frame.current = 0; }, []);\n\n  /**\n   * Where the shape is on the switch's axis (0 bars, 1 line, 2 area). Null while a tape that isn't\n   * on that axis runs (line → bars, area → line, bars ↔ area are their own choreographies).\n   */\n  const position = () => {\n    const R = S.route;\n    if (!R) return CHART_SHAPES.indexOf(S.shown);\n    const along = R.t / (routeDuration(R.from, R.to, count) || 1);\n    if (R.from === 'bars' && R.to === 'line') return along;\n    if (R.from === 'line' && R.to === 'area') return 1 + along;\n    return null;\n  };\n\n  const shapeTo = (next: ChartShape) => {\n    const R = S.route;\n    if (still.current) {\n      S.route = null; S.queued = null; S.held = false;\n      S.shown = next;\n      settle(S.pose, next);\n      paint();\n      return;\n    }\n    if (R) {\n      S.held = false;\n      if (next === R.to) { R.dir = 1; S.queued = null; }\n      else if (next === R.from) { R.dir = -1; S.queued = null; }\n      else S.queued = next;\n      kick();\n      return;\n    }\n    if (next === S.shown) return;\n    S.route = { from: S.shown, to: next, t: 0, dir: 1 };\n    kick();\n  };\n\n  return {\n    columns, columnGroup, area, line, stops, bead, rule,\n    /** The plot's size changed: redraw where everything is. */\n    layout(next: ChartLayout) {\n      if (S.layout.width === next.width && S.layout.height === next.height && S.layout.top === next.top) return;\n      S.layout = next;\n      paint();\n    },\n    /** New values (and scale); from the values on screen, or from `from` for the first arrival. */\n    data(values: number[], max: number, opts: { animate: boolean; from?: number }) {\n      for (let i = 0; i < count; i++) {\n        S.from[i] = opts.from ?? S.vals[i];\n        S.to[i] = values[i] ?? 0;\n      }\n      S.maxFrom = opts.from !== undefined ? max : S.max;\n      S.maxTo = max;\n      if (!opts.animate || still.current) {\n        S.vals.set(S.to);\n        S.max = max;\n        S.dataT0 = -1;\n        paint();\n        return;\n      }\n      S.delay = count > 1 ? Math.min(40, 420 / (count - 1)) : 0;\n      S.dataT0 = performance.now();\n      kick();\n    },\n    /** Head for a shape: run the tape on, run it back if that's where it came from, or queue behind it. */\n    shape: shapeTo,\n    position,\n    /** Hold the tape at a point on the shape axis, 0 (bars) to 2 (area), between neighbours. */\n    scrub(p: number) {\n      if (still.current) return;\n      p = Math.max(0, Math.min(CHART_SHAPES.length - 1, p));\n      const i = Math.min(CHART_SHAPES.length - 2, Math.floor(p));\n      const from = CHART_SHAPES[i], to = CHART_SHAPES[i + 1];\n      S.queued = null;\n      S.held = true;\n      S.route = { from, to, t: (p - i) * routeDuration(from, to, count), dir: 1 };\n      pose(S.pose, from, to, S.route.t, count);\n      paint();\n    },\n    /** Let go of the tape: it runs on to `target`, one of the two shapes either side of it. */\n    release(target: ChartShape) {\n      const R = S.route;\n      S.held = false;\n      if (!R || still.current) return shapeTo(target);\n      if (target === R.from) R.dir = -1;\n      else if (target === R.to) R.dir = 1;\n      else S.queued = target;\n      kick();\n    },\n    /** The point the bead sits on, or null to hide it. */\n    point(i: number | null) {\n      if (i === S.point) return;\n      S.point = i;\n      kick();\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · morphing-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/morphing-chart. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}