{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-chart-core",
  "title": "Chart core primitive",
  "description": "The engine under every Eased chart: nice scales, container sizing, path morphs on shared x samples, a slider-model scrub with a trailing tooltip, sliding gridlines, a hidden data table, ChartRangeContext and the validated --eased-chart-1…6 palette.",
  "dependencies": [
    "motion",
    "d3-scale"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/chart-core/scales.ts",
      "content": "import { scaleLinear } from \"d3-scale\";\n\n/** A value domain, low to high. */\nexport type Domain = readonly [number, number];\n\nexport type NiceScaleOptions = {\n  /** Keep zero inside the domain. Bars and areas need it; lines may drop it. Default true. */\n  zero?: boolean;\n  /** Roughly how many gridlines. Default 4. */\n  ticks?: number;\n};\n\n/**\n * A clean value domain and its gridline values for a set of numbers: rounded to tick steps\n * (0 / 2K / 4K …), zero kept by default, and a flat or empty series still gets a usable range.\n */\nexport function niceScale(values: Iterable<number>, { zero = true, ticks = 4 }: NiceScaleOptions = {}) {\n  let lo = Infinity;\n  let hi = -Infinity;\n  for (const v of values) {\n    if (!Number.isFinite(v)) continue;\n    if (v < lo) lo = v;\n    if (v > hi) hi = v;\n  }\n  if (lo === Infinity) [lo, hi] = [0, 1];\n  if (zero) [lo, hi] = [Math.min(0, lo), Math.max(0, hi)];\n  if (lo === hi) [lo, hi] = lo === 0 ? [0, 1] : [lo - Math.abs(lo) * 0.1, hi + Math.abs(hi) * 0.1];\n  // d3's tick count is a hint; step it down until there are at most `ticks + 1` lines.\n  for (let count = ticks; ; count--) {\n    const scale = scaleLinear().domain([lo, hi]).nice(count);\n    const t = scale.ticks(count);\n    if (t.length <= ticks + 1 || count <= 1) {\n      const [a, b] = scale.domain();\n      return { domain: [a, b] as Domain, ticks: t };\n    }\n  }\n}\n\n/** Maps a value in `domain` to a pixel in `range`; `range` may run high to low (y grows down). */\nexport function linear(domain: Domain, range: Domain) {\n  const [d0, d1] = domain;\n  const [r0, r1] = range;\n  const k = d1 === d0 ? 0 : (r1 - r0) / (d1 - d0);\n  return (v: number) => r0 + (v - d0) * k;\n}\n\nexport const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\n/**\n * Which of `count` evenly spaced x labels to show at `width`: at most one per `minGap` px\n * (Arc's 76), always keeping the newest, then stepping back from it so the gaps stay even.\n */\nexport function thinLabels(count: number, width: number, minGap = 76): number[] {\n  if (count <= 0) return [];\n  if (count === 1) return [0];\n  const spacing = width / (count - 1);\n  const step = Math.max(1, Math.ceil(minGap / Math.max(spacing, 0.0001)));\n  const out: number[] = [];\n  for (let i = count - 1; i >= 0; i -= step) out.unshift(i);\n  return out;\n}\n\n/** Evenly spaced bands for bars: the band width, each band's left edge, and a bar width capped at 24px with ≥ 2px air. */\nexport function bands(count: number, width: number, { maxBar = 24, gap = 0.32 }: { maxBar?: number; gap?: number } = {}) {\n  const band = count > 0 ? width / count : width;\n  const bar = Math.max(1, Math.min(maxBar, band - Math.max(2, band * gap)));\n  return { band, bar, left: (i: number) => i * band, center: (i: number) => i * band + band / 2 };\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/scales.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/use-chart-size.ts",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\nexport type ChartSize = { width: number; height: number };\n\n/**\n * The content-box size of an element, kept current with a ResizeObserver and rounded to\n * whole pixels so sub-pixel jitter never re-renders a chart. 0 × 0 until measured (and on the\n * server), so draw nothing until `width > 0`.\n */\nexport function useChartSize<T extends HTMLElement = HTMLDivElement>(): [RefObject<T | null>, ChartSize] {\n  const ref = useRef<T>(null);\n  const [size, setSize] = useState<ChartSize>({ width: 0, height: 0 });\n\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const update = (width: number, height: number) => {\n      const w = Math.round(width);\n      const h = Math.round(height);\n      setSize((s) => (s.width === w && s.height === h ? s : { width: w, height: h }));\n    };\n    const rect = el.getBoundingClientRect();\n    update(rect.width, rect.height);\n    const ro = new ResizeObserver(([entry]) => update(entry.contentRect.width, entry.contentRect.height));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  return [ref, size];\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/use-chart-size.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/morph.ts",
      "content": "\"use client\";\n\nimport { animate, type AnimationPlaybackControls, type Transition } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { smooth } from \"../../lib/motion\";\nimport { lerp, type Domain } from \"./scales\";\n\nexport type Curve = \"monotone\" | \"linear\";\n\n/* ---------- Sampling ------------------------------------------------------- */\n\n// d3-shape's curveMonotoneX tangents (Steffen 1990) for evenly spaced points, so a sampled\n// curve is the exact curve d3 would draw. With x evenly spaced the Bézier's x is linear in t,\n// so each segment is a cubic Hermite in y and can be evaluated at any x directly.\nfunction tangents(v: readonly number[]) {\n  const n = v.length;\n  const t = new Float64Array(n);\n  if (n < 2) return t;\n  const s = (i: number) => v[i + 1] - v[i]; // secant over one step\n  for (let i = 1; i < n - 1; i++) {\n    const s0 = s(i - 1);\n    const s1 = s(i);\n    const p = (s0 + s1) / 2;\n    t[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0;\n  }\n  t[0] = n > 2 ? (3 * s(0) - t[1]) / 2 : s(0);\n  t[n - 1] = n > 2 ? (3 * s(n - 2) - t[n - 2]) / 2 : s(0);\n  return t;\n}\n\n/**\n * Samples a series of evenly spaced values at `count` evenly spaced positions across the\n * same span, along a monotone (d3 `curveMonotoneX`) or straight curve. Two series with\n * different lengths sampled at the same `count` share their x positions: that is what lets\n * a 7-point line morph into a 30-point one without inventing or dropping vertices.\n */\nexport function sampleSeries(values: readonly number[], count: number, curve: Curve = \"monotone\"): Float64Array {\n  const out = new Float64Array(count);\n  const n = values.length;\n  if (n === 0) return out;\n  if (n === 1 || count === 1) return out.fill(n === 1 ? values[0] : values[n - 1]);\n  const t = curve === \"monotone\" ? tangents(values) : null;\n  for (let k = 0; k < count; k++) {\n    const pos = (k / (count - 1)) * (n - 1);\n    const i = Math.min(n - 2, Math.floor(pos));\n    const u = pos - i;\n    const y0 = values[i];\n    const y1 = values[i + 1];\n    if (!t) {\n      out[k] = y0 + (y1 - y0) * u;\n      continue;\n    }\n    const u2 = u * u;\n    const u3 = u2 * u;\n    out[k] = (2 * u3 - 3 * u2 + 1) * y0 + (u3 - 2 * u2 + u) * t[i] + (-2 * u3 + 3 * u2) * y1 + (u3 - u2) * t[i + 1];\n  }\n  return out;\n}\n\n/** The sampled value at `u` ∈ [0, 1] along a sampled series (linear between samples). */\nexport function sampleAt(samples: ArrayLike<number>, u: number) {\n  const n = samples.length;\n  if (n === 0) return 0;\n  if (n === 1) return samples[0];\n  const pos = Math.max(0, Math.min(1, u)) * (n - 1);\n  const i = Math.min(n - 2, Math.floor(pos));\n  return lerp(samples[i], samples[i + 1], pos - i);\n}\n\nfunction resample(samples: Float64Array, count: number) {\n  if (samples.length === count) return samples;\n  const out = new Float64Array(count);\n  for (let k = 0; k < count; k++) out[k] = sampleAt(samples, count === 1 ? 1 : k / (count - 1));\n  return out;\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** An SVG path through the samples. `x(u)` maps 0–1 across the plot, `y(v)` a value to a pixel. */\nexport function samplesToPath(samples: ArrayLike<number>, x: (u: number) => number, y: (v: number) => number) {\n  const n = samples.length;\n  if (n === 0) return \"\";\n  let d = \"\";\n  for (let k = 0; k < n; k++) d += `${k ? \"L\" : \"M\"}${round(x(n === 1 ? 1 : k / (n - 1)))},${round(y(samples[k]))}`;\n  return d;\n}\n\n/** The same line closed down to `baseY`, for an area fill. */\nexport function samplesToArea(samples: ArrayLike<number>, x: (u: number) => number, y: (v: number) => number, baseY: number) {\n  if (samples.length === 0) return \"\";\n  return `${samplesToPath(samples, x, y)}L${round(x(1))},${round(baseY)}L${round(x(0))},${round(baseY)}Z`;\n}\n\n/** Where a series rests when it enters or leaves: zero if the domain holds it, else the nearer edge. */\nexport const baselineOf = ([lo, hi]: Domain) => Math.min(hi, Math.max(lo, 0));\n\n/* ---------- useMorphPath --------------------------------------------------- */\n\n/** What the chart should show: each series' values (evenly spaced in x) and the value domain. */\nexport type MorphShape = { series: Readonly<Record<string, readonly number[]>>; domain: Domain };\n/** What is on screen this frame: every series sampled on shared x positions, and the domain. */\nexport type MorphFrame = { series: Record<string, Float64Array>; domain: Domain };\n\nexport type UseMorphPathOptions = {\n  /** Draws a frame: write `d` attributes and move gridlines here. Called outside React render. */\n  onFrame: (frame: MorphFrame) => void;\n  /** Shared x samples per series. Default 160; keep ≥ 8 per data segment for smooth curves. */\n  samples?: number;\n  curve?: Curve;\n  /** The morph's transition. Default `smooth` (no overshoot: data should not wobble past its value). */\n  transition?: Transition;\n  /** False jumps to every new shape (reduced motion, scrubbing a slider). Default true. */\n  animate?: boolean;\n};\n\nconst signature = (shape: MorphShape, count: number, curve: Curve) => {\n  let s = `${count}|${curve}|${shape.domain[0]},${shape.domain[1]}`;\n  for (const key of Object.keys(shape.series)) s += `|${key}:${shape.series[key].join(\",\")}`;\n  return s;\n};\n\n/**\n * Morphs a set of paths between shapes the way Arc does, without React re-rendering a frame:\n * every series is resampled onto the same x positions, so any shape tweens into any other\n * point for point, and the value domain rides the same progress so gridlines and lines stay\n * in register. A change mid-morph starts from what is on screen. Series that appear grow out\n * of the baseline; series that leave flatten into it and are then dropped from the frame.\n *\n *   const morph = useMorphPath({ series: { revenue }, domain }, { onFrame: (f) => {\n *     line.current?.setAttribute(\"d\", samplesToPath(f.series.revenue, x, y));\n *   }});\n *\n * Returns `get()` (the current frame) and `redraw()` (re-emit it, e.g. after a resize).\n */\nexport function useMorphPath(shape: MorphShape, { onFrame, samples = 160, curve = \"monotone\", transition = smooth, animate: animated = true }: UseMorphPathOptions) {\n  const frame = useRef<MorphFrame | null>(null);\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const draw = useRef(onFrame);\n  const shapeRef = useRef(shape);\n  const options = useRef({ samples, curve, transition, animated });\n  useLayoutEffect(() => {\n    draw.current = onFrame;\n    shapeRef.current = shape;\n    options.current = { samples, curve, transition, animated };\n  });\n\n  const key = signature(shape, samples, curve);\n  useLayoutEffect(() => {\n    const { samples: count, curve: c, transition: tr, animated: anime } = options.current;\n    const next = shapeRef.current;\n    const target: MorphFrame = { series: {}, domain: next.domain };\n    for (const k of Object.keys(next.series)) target.series[k] = sampleSeries(next.series[k], count, c);\n\n    const from = frame.current;\n    anim.current?.stop();\n    if (!from || !anime) {\n      frame.current = target;\n      draw.current(target);\n      return;\n    }\n\n    const keys = new Set([...Object.keys(from.series), ...Object.keys(target.series)]);\n    const a: Record<string, Float64Array> = {};\n    const b: Record<string, Float64Array> = {};\n    for (const k of keys) {\n      a[k] = from.series[k] ? resample(from.series[k], count) : new Float64Array(count).fill(baselineOf(from.domain));\n      b[k] = target.series[k] ?? new Float64Array(count).fill(baselineOf(target.domain));\n    }\n    const live: MorphFrame = { series: {}, domain: from.domain };\n    for (const k of keys) live.series[k] = new Float64Array(count);\n\n    anim.current = animate(0, 1, {\n      ...tr,\n      onUpdate: (p) => {\n        for (const k of keys) {\n          const out = live.series[k];\n          const s0 = a[k];\n          const s1 = b[k];\n          for (let i = 0; i < count; i++) out[i] = s0[i] + (s1[i] - s0[i]) * p;\n        }\n        live.domain = [lerp(from.domain[0], target.domain[0], p), lerp(from.domain[1], target.domain[1], p)];\n        frame.current = live;\n        draw.current(live);\n      },\n      onComplete: () => {\n        frame.current = target;\n        draw.current(target);\n      },\n    });\n  }, [key]);\n\n  useLayoutEffect(() => () => anim.current?.stop(), []);\n\n  const [api] = useState(() => ({\n    get: () => frame.current,\n    redraw: () => {\n      if (frame.current) draw.current(frame.current);\n    },\n  }));\n  return api;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/morph.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/scrub.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue, type MotionValue, type Transition } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { enter, exitFast, instant, smooth, snappy } from \"../../lib/motion\";\n\n/* ---------- Active index store --------------------------------------------- */\n\n/**\n * The scrubbed index, outside React state: the plot never re-renders while you scrub.\n * Only the pieces that show the value (tooltip, headline) subscribe.\n */\nexport type ActiveStore = {\n  get(): number | null;\n  set(index: number | null): void;\n  subscribe(listener: () => void): () => void;\n};\n\nexport function createActiveStore(): ActiveStore {\n  let value: number | null = null;\n  const listeners = new Set<() => void>();\n  return {\n    get: () => value,\n    set(next) {\n      if (next === value) return;\n      value = next;\n      for (const l of listeners) l();\n    },\n    subscribe(l) {\n      listeners.add(l);\n      return () => listeners.delete(l);\n    },\n  };\n}\n\n/** One store per chart, stable across renders. */\nexport function useActiveStore() {\n  const [store] = useState(createActiveStore);\n  return store;\n}\n\n/** Subscribe a component to the scrubbed index (re-renders only that component). */\nexport function useActiveIndex(store: ActiveStore) {\n  return useSyncExternalStore(store.subscribe, store.get, () => null);\n}\n\n/* ---------- Scrub: pointer, keyboard, crosshair ---------------------------- */\n\nexport type UseChartScrubOptions = {\n  /** Number of points (or bars). */\n  count: number;\n  store: ActiveStore;\n  /** The pixel x of point `i`, relative to the scrub surface. */\n  xOf: (index: number) => number;\n  /** The nearest index to a pixel x on the scrub surface. */\n  indexAt: (x: number) => number;\n  /** What a screen reader hears for point `i`: date, then every visible value. */\n  valueText: (index: number) => string;\n  /** The figure's name, e.g. \"Revenue, last 30 days\". */\n  label: string;\n  onActiveChange?: (index: number | null) => void;\n  reduced?: boolean;\n  /** How the crosshair glides between points. Default `snappy`. */\n  transition?: Transition;\n};\n\n/**\n * The interaction model every Eased chart shares (Arc's, kept): the plot is one\n * `role=\"slider\"` whose value is the scrubbed point. Pointer and touch scrub it (vertical\n * page scroll still works); arrows step, PageUp/PageDown jump a tenth, Home/End go to the\n * ends, Escape lets go. `aria-valuetext` reads the date and every value, updated in place.\n *\n * Returns props to spread on the scrub surface (a div exactly over the plot) and the\n * crosshair's motion values: `x` glides to the active point, `opacity` fades it in and out.\n */\nexport function useChartScrub({ count, store, xOf, indexAt, valueText, label, onActiveChange, reduced = false, transition = snappy }: UseChartScrubOptions) {\n  const ref = useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const opacity = useMotionValue(0);\n  const latest = useRef({ count, xOf, valueText, onActiveChange, reduced, transition });\n  useLayoutEffect(() => {\n    latest.current = { count, xOf, valueText, onActiveChange, reduced, transition };\n  });\n\n  // Follow the store: glide the crosshair, keep ARIA current, notify.\n  useEffect(() => {\n    let shown = store.get() !== null;\n    const sync = () => {\n      const i = store.get();\n      const { xOf, valueText, onActiveChange, reduced, transition, count } = latest.current;\n      const el = ref.current;\n      if (el && count > 0) {\n        const now = i ?? count - 1;\n        el.setAttribute(\"aria-valuenow\", String(now));\n        el.setAttribute(\"aria-valuetext\", valueText(now));\n      }\n      if (i === null) {\n        animate(opacity, 0, reduced ? instant : exitFast);\n        shown = false;\n      } else {\n        const target = xOf(i);\n        if (!shown || reduced) x.jump(target);\n        else animate(x, target, transition);\n        animate(opacity, 1, reduced ? instant : { duration: 0.12 });\n        shown = true;\n      }\n      onActiveChange?.(i);\n    };\n    return store.subscribe(sync);\n  }, [store, x, opacity]);\n\n  // Data or size changed under an active point: clamp it and re-seat the crosshair.\n  useLayoutEffect(() => {\n    const i = store.get();\n    if (i === null) return;\n    if (i > count - 1) store.set(count > 0 ? count - 1 : null);\n    else animate(x, xOf(i), reduced ? instant : smooth);\n  }, [count, xOf, store, x, reduced]);\n\n  const fromPointer = useCallback(\n    (e: PointerEvent<HTMLDivElement>) => {\n      const rect = e.currentTarget.getBoundingClientRect();\n      const i = indexAt(e.clientX - rect.left);\n      store.set(Math.max(0, Math.min(count - 1, i)));\n    },\n    [count, indexAt, store],\n  );\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (count === 0) return;\n    const i = store.get() ?? count - 1;\n    const page = Math.max(1, Math.round(count / 10));\n    const next: Record<string, number | null> = {\n      ArrowRight: i + 1,\n      ArrowUp: i + 1,\n      ArrowLeft: i - 1,\n      ArrowDown: i - 1,\n      PageUp: i + page,\n      PageDown: i - page,\n      Home: 0,\n      End: count - 1,\n      Escape: null,\n    };\n    if (!(e.key in next)) return;\n    e.preventDefault();\n    const n = next[e.key];\n    store.set(n === null ? null : Math.max(0, Math.min(count - 1, n)));\n  };\n\n  const last = Math.max(0, count - 1);\n  const props = {\n    ref,\n    role: \"slider\",\n    tabIndex: count > 0 ? 0 : -1,\n    \"aria-label\": label,\n    \"aria-valuemin\": 0,\n    \"aria-valuemax\": last,\n    \"aria-valuenow\": last,\n    \"aria-valuetext\": count > 0 ? valueText(last) : undefined,\n    \"aria-orientation\": \"horizontal\" as const,\n    onKeyDown,\n    onFocus: (e: FocusEvent<HTMLDivElement>) => {\n      // Keyboard focus lands on the newest point; pointer focus is handled by the pointer.\n      if (e.currentTarget.matches(\":focus-visible\") && store.get() === null && count > 0) store.set(count - 1);\n    },\n    onBlur: () => store.set(null),\n    onPointerDown: fromPointer,\n    onPointerMove: fromPointer,\n    onPointerLeave: () => store.set(null),\n    onPointerCancel: () => store.set(null),\n    style: { touchAction: \"pan-y\" } as CSSProperties,\n  };\n\n  return { props, x, opacity };\n}\n\n/* ---------- Tooltip -------------------------------------------------------- */\n\nexport type ChartTooltipProps = {\n  store: ActiveStore;\n  /** The pixel x of point `i`, in the same coordinates as the tooltip's container. */\n  xOf: (index: number) => number;\n  /** The container's width; the tooltip flips to the left of the point before it overflows. */\n  width: number;\n  /** Gap between the point and the tooltip, px. Default 12. */\n  offset?: number;\n  /** Distance from the container's top, px. Default 0. */\n  top?: number;\n  reduced?: boolean;\n  /** The content for point `i`. Re-rendered only while scrubbing, and only here. */\n  children: (index: number) => ReactNode;\n  className?: string;\n};\n\n/**\n * A floating readout that follows the scrubbed point on its own `smooth` spring, a beat\n * behind the crosshair, and flips sides before it would leave the plot. It keeps showing the\n * last point while it fades out, so content never blanks mid-exit. `aria-hidden`: the slider\n * already reads the same values.\n */\nexport function ChartTooltip({ store, xOf, width, offset = 12, top = 0, reduced = false, children, className = \"\" }: ChartTooltipProps) {\n  const index = useActiveIndex(store);\n  const [shown, setShown] = useState<number | null>(null);\n  const el = useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const visible = useRef(false);\n  if (index !== null && index !== shown) setShown(index);\n\n  useLayoutEffect(() => {\n    if (index === null || !el.current) {\n      visible.current = false;\n      return;\n    }\n    const w = el.current.offsetWidth;\n    const px = xOf(index);\n    const right = px + offset + w <= width;\n    const target = Math.max(0, Math.min(width - w, right ? px + offset : px - offset - w));\n    if (!visible.current || reduced) x.jump(target);\n    else animate(x, target, smooth);\n    visible.current = true;\n  }, [index, xOf, width, offset, reduced, x]);\n\n  return (\n    <motion.div\n      ref={el}\n      aria-hidden\n      className={`pointer-events-none absolute left-0 z-10 rounded-control border border-line bg-surface-raised px-3 py-2 text-ui-xs text-text shadow-floating ${className}`}\n      style={{ x, top }}\n      initial={false}\n      animate={index === null ? { opacity: 0, scale: 0.97 } : { opacity: 1, scale: 1 }}\n      transition={reduced ? instant : index === null ? exitFast : enter}\n    >\n      {shown !== null && children(shown)}\n    </motion.div>\n  );\n}\n\n/** The crosshair's motion values, for a vertical rule in the plot's `<svg>`. */\nexport function Crosshair({ x, opacity, height, top = 0 }: { x: MotionValue<number>; opacity: MotionValue<number>; height: number; top?: number }) {\n  return (\n    <motion.line\n      aria-hidden\n      x1={0}\n      x2={0}\n      y1={top}\n      y2={height}\n      stroke=\"var(--eased-chart-cursor)\"\n      strokeWidth={1}\n      style={{ x, opacity }}\n      shapeRendering=\"crispEdges\"\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/scrub.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/grid.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useMotionValueEvent, type MotionValue, type Transition } from \"motion/react\";\nimport { useLayoutEffect, useRef } from \"react\";\nimport { exitFast, fade, instant } from \"../../lib/motion\";\nimport { linear, type Domain } from \"./scales\";\n\nexport type ChartGridProps = {\n  /** Gridline values, usually `niceScale(...).ticks`. */\n  ticks: readonly number[];\n  /** The live value domain. Gridlines ride it, so they slide while the scale springs. */\n  domain: MotionValue<Domain>;\n  /** Full width, px, gutter included. */\n  width: number;\n  /** Left gutter for the labels, px (see `gutterFor`). Lines start after it. Default 0: labels sit above the lines. */\n  gutter?: number;\n  /** Pixel range the domain maps to, top to bottom: `[top, bottom]`. */\n  range: Domain;\n  /** Tick label text. Leave out for unlabelled gridlines. */\n  format?: (value: number) => string;\n  reduced?: boolean;\n};\n\n/** A label gutter wide enough for the widest tick label (11px tabular figures), plus an 8px gap. */\nexport function gutterFor(labels: readonly string[]) {\n  const longest = labels.reduce((m, l) => Math.max(m, l.length), 0);\n  return longest ? Math.ceil(longest * 6.4) + 8 : 0;\n}\n\n/**\n * Hairline gridlines with their labels left-aligned on the chart's leading edge (the same x\n * as the headline and legend above it): in a gutter beside the lines, or just above them. When the domain changes, lines that\n * stay slide with the springing scale; new values fade in and retired ones fade out while\n * still sliding, so nothing pops. SVG; render it inside the plot's `<svg>`.\n */\nexport function ChartGrid({ ticks, domain, width, gutter = 0, range, format, reduced = false }: ChartGridProps) {\n  return (\n    <g aria-hidden>\n      <AnimatePresence initial={false}>\n        {ticks.map((t) => (\n          <Tick key={t} value={t} domain={domain} width={width} gutter={gutter} range={range} label={format?.(t)} reduced={reduced} />\n        ))}\n      </AnimatePresence>\n    </g>\n  );\n}\n\nfunction Tick({ value, domain, width, gutter, range, label, reduced }: { value: number; domain: MotionValue<Domain>; width: number; gutter: number; range: Domain; label?: string; reduced: boolean }) {\n  const ref = useRef<SVGGElement>(null);\n  const place = (d: Domain) => {\n    const y = Math.round(linear(d, [range[1], range[0]])(value)) + 0.5;\n    ref.current?.setAttribute(\"transform\", `translate(0 ${y})`);\n  };\n  useLayoutEffect(() => place(domain.get()));\n  useMotionValueEvent(domain, \"change\", place);\n\n  return (\n    <motion.g\n      ref={ref}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1, transition: reduced ? instant : fade }}\n      exit={{ opacity: 0, transition: reduced ? instant : exitFast }}\n    >\n      <line x1={gutter} x2={width} stroke=\"var(--eased-chart-grid)\" strokeWidth={1} shapeRendering=\"crispEdges\" />\n      {label !== undefined && (\n        <text y={gutter ? 0 : -6} dominantBaseline={gutter ? \"central\" : undefined} className=\"fill-text-muted tabular-nums\" style={{ fontSize: 11 }}>\n          {label}\n        </text>\n      )}\n    </motion.g>\n  );\n}\n\nexport type ChartXAxisProps = {\n  /** The labels to show (already thinned), each with its bucket's key and pixel x. */\n  items: readonly { key: string; x: number; text: string; align?: \"start\" | \"center\" | \"end\" }[];\n  /** Morph transition for labels that stay. */\n  transition: Transition;\n  reduced?: boolean;\n  /** Left offset of the row, px: the plot's gutter. */\n  left?: number;\n  className?: string;\n};\n\n/**\n * X labels in an HTML row under the plot. Labels keyed by their bucket slide to their new\n * position when the range changes; others fade. The first label starts on the plot's left\n * line and the last ends on its right line (`align`), so neither clips and both edges stay true.\n */\nexport function ChartXAxis({ items, transition, reduced = false, left = 0, className = \"\" }: ChartXAxisProps) {\n  return (\n    <div aria-hidden className={`pointer-events-none h-4 text-ui-2xs text-text-muted tabular-nums ${className}`} style={{ left }}>\n      <AnimatePresence initial={false}>\n        {items.map((item) => {\n          const shift = item.align === \"start\" ? \"\" : item.align === \"end\" ? \"-translate-x-full\" : \"-translate-x-1/2\";\n          return (\n            <motion.span\n              key={item.key}\n              className={`absolute top-0 left-0 whitespace-nowrap ${shift}`}\n              initial={{ x: item.x, opacity: 0 }}\n              animate={{ x: item.x, opacity: 1, transition: reduced ? instant : { x: transition, opacity: fade } }}\n              exit={{ opacity: 0, transition: reduced ? instant : exitFast }}\n            >\n              {item.text}\n            </motion.span>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/grid.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/legend.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { press } from \"../../lib/press\";\n\nexport type ChartKey = {\n  key: string;\n  label: string;\n  /** CSS colour, usually `chartColor(n)`. */\n  color: string;\n  /** Previous-period style: a dashed line key. */\n  dashed?: boolean;\n};\n\n/** A series' identity mark: a short line key for lines, a rounded square for bars and areas. */\nexport function ChartSwatch({ color, shape = \"square\", dashed = false, off = false }: { color: string; shape?: \"line\" | \"square\"; dashed?: boolean; off?: boolean }) {\n  if (shape === \"line\") {\n    return (\n      <svg aria-hidden width={12} height={8} viewBox=\"0 0 12 8\" className=\"shrink-0 overflow-visible\">\n        <line x1={1} x2={11} y1={4} y2={4} stroke={color} strokeWidth={2} strokeLinecap=\"round\" strokeDasharray={dashed ? \"2 3\" : undefined} opacity={off ? 0.35 : dashed ? 0.6 : 1} />\n      </svg>\n    );\n  }\n  return (\n    <span\n      aria-hidden\n      className=\"size-2 shrink-0 rounded-[calc(var(--eased-radius-control-2xs)/2)] transition-opacity duration-150 ease-standard\"\n      style={{ background: off ? \"transparent\" : color, boxShadow: off ? `inset 0 0 0 1.5px ${color}` : undefined, opacity: off ? 0.6 : 1 }}\n    />\n  );\n}\n\nexport type ChartLegendProps = {\n  items: readonly ChartKey[];\n  /** Keys currently hidden. */\n  hidden?: ReadonlySet<string>;\n  /** Makes each key a toggle button. Leave out for a static legend. */\n  onToggle?: (key: string) => void;\n  /** Hovering or focusing a key previews it: the chart dims the others. */\n  onPreview?: (key: string | null) => void;\n  shape?: \"line\" | \"square\";\n  /** Extra content on the right, e.g. a total. */\n  trailing?: ReactNode;\n  className?: string;\n};\n\n/**\n * The legend: always present for two or more series, so identity never rests on colour.\n * As toggles, each key is a pressed/unpressed button; the row is pulled left by the buttons'\n * padding so the first swatch sits on the content's left line (rule 104).\n */\nexport function ChartLegend({ items, hidden, onToggle, onPreview, shape = \"square\", trailing, className = \"\" }: ChartLegendProps) {\n  return (\n    <div className={`flex min-w-0 items-center justify-between gap-3 ${className}`}>\n      <ul className={`flex min-w-0 flex-wrap items-center ${onToggle ? \"-ml-2 gap-x-0.5\" : \"gap-x-4\"} gap-y-1`}>\n        {items.map((item) => {\n          const off = hidden?.has(item.key) ?? false;\n          const body = (\n            <>\n              <ChartSwatch color={item.color} shape={shape} dashed={item.dashed} off={off} />\n              <span className=\"truncate\">{item.label}</span>\n            </>\n          );\n          return (\n            <li key={item.key} className=\"min-w-0\">\n              {onToggle ? (\n                <button\n                  type=\"button\"\n                  aria-pressed={!off}\n                  onClick={() => onToggle(item.key)}\n                  onPointerEnter={(e) => e.pointerType === \"mouse\" && onPreview?.(item.key)}\n                  onPointerLeave={() => onPreview?.(null)}\n                  onFocus={() => onPreview?.(item.key)}\n                  onBlur={() => onPreview?.(null)}\n                  className={`${press} inline-flex h-6 max-w-full cursor-pointer items-center gap-1.5 rounded-control-xs px-2 text-ui-xs hover-fine:bg-surface-hover ${off ? \"text-text-muted\" : \"text-text-secondary hover-fine:text-text\"}`}\n                >\n                  {body}\n                </button>\n              ) : (\n                <span className=\"inline-flex h-6 items-center gap-1.5 text-ui-xs text-text-secondary\">{body}</span>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n      {trailing}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/legend.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/data-table.tsx",
      "content": "import type { ReactNode } from \"react\";\n\nexport type ChartDataColumn<D> = {\n  /** Column header, e.g. the series label. */\n  label: string;\n  /** The cell's text for a row. */\n  value: (datum: D, index: number) => ReactNode;\n};\n\nexport type ChartDataTableProps<D> = {\n  /** What the table lists, e.g. \"Revenue by day\". */\n  caption: string;\n  data: readonly D[];\n  /** The row header (usually the formatted date or category). */\n  rowLabel: (datum: D, index: number) => ReactNode;\n  /** Header of the row-label column. Default \"Date\". */\n  rowLabelHeader?: string;\n  columns: readonly ChartDataColumn<D>[];\n};\n\n/**\n * Every point of a chart as a real `<table>`, visually hidden. Screen reader users get the\n * whole dataset in table navigation, and anyone who cannot tell two hues apart has the\n * numbers. Pair it with an `aria-hidden` SVG.\n */\nexport function ChartDataTable<D>({ caption, data, rowLabel, rowLabelHeader = \"Date\", columns }: ChartDataTableProps<D>) {\n  return (\n    <table className=\"sr-only\">\n      <caption>{caption}</caption>\n      <thead>\n        <tr>\n          <th scope=\"col\">{rowLabelHeader}</th>\n          {columns.map((c) => (\n            <th key={c.label} scope=\"col\">\n              {c.label}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {data.map((d, i) => (\n          <tr key={i}>\n            <th scope=\"row\">{rowLabel(d, i)}</th>\n            {columns.map((c) => (\n              <td key={c.label}>{c.value(d, i)}</td>\n            ))}\n          </tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/data-table.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/draw-in.ts",
      "content": "\"use client\";\n\nimport { animate, useInView, useMotionValue } from \"motion/react\";\nimport { useEffect, type RefObject } from \"react\";\nimport { ease } from \"../../lib/motion\";\n\nexport type DrawInOptions = {\n  reduced?: boolean;\n  /** Seconds. Default 0.9: a chart is read left to right, so it is drawn at reading pace. */\n  duration?: number;\n  delay?: number;\n  /** Share of the chart that must be on screen. Default 0.35. */\n  amount?: number;\n};\n\n/**\n * 0 → 1 once, the first time `ref` scrolls into view; then never again (rule 83: entrances\n * run once per mount). Drive a left-to-right clip, bar heights or dot opacity from it.\n * Reduced motion starts at 1.\n */\nexport function useDrawIn(ref: RefObject<Element | null>, { reduced = false, duration = 0.9, delay = 0, amount = 0.35 }: DrawInOptions = {}) {\n  const progress = useMotionValue(0);\n  const inView = useInView(ref, { once: true, amount });\n  useEffect(() => {\n    if (reduced) {\n      progress.jump(1);\n      return;\n    }\n    if (!inView) return;\n    const controls = animate(progress, 1, { duration, delay, ease: ease.inOut });\n    return () => controls.stop();\n  }, [inView, reduced, progress, duration, delay]);\n  return progress;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/draw-in.ts"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/range.tsx",
      "content": "\"use client\";\n\nimport type { Transition } from \"motion/react\";\nimport { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from \"react\";\nimport { smooth } from \"../../lib/motion\";\n\n/**\n * The shared date range of a page of charts. Changing it is one coordinated beat: every chart\n * under the provider morphs on the same transition, starting in the same frame.\n *\n * Frozen contract (lane 5 → Range Picker, Metrics Dashboard):\n *   range       the selected range key, e.g. \"7d\"\n *   setRange    select another range\n *   beat        increments on every change; key per-change effects off it\n *   transition  the morph every chart under the provider uses\n *   compare     whether a previous-period series is shown\n *   setCompare  toggle it\n */\nexport type ChartRangeValue<R extends string = string> = {\n  range: R;\n  setRange: (range: R) => void;\n  beat: number;\n  transition: Transition;\n  compare: boolean;\n  setCompare: (compare: boolean) => void;\n};\n\nexport const ChartRangeContext = createContext<ChartRangeValue | null>(null);\n\nexport type ChartRangeProviderProps<R extends string> = {\n  /** Controlled range. */\n  value?: R;\n  /** Uncontrolled initial range. */\n  defaultValue?: R;\n  onValueChange?: (range: R) => void;\n  compare?: boolean;\n  defaultCompare?: boolean;\n  onCompareChange?: (compare: boolean) => void;\n  /** The morph shared by every chart below. Default `smooth`. */\n  transition?: Transition;\n  children: ReactNode;\n};\n\nexport function ChartRangeProvider<R extends string>({\n  value,\n  defaultValue,\n  onValueChange,\n  compare: compareProp,\n  defaultCompare = false,\n  onCompareChange,\n  transition = smooth,\n  children,\n}: ChartRangeProviderProps<R>) {\n  const [inner, setInner] = useState(defaultValue as R);\n  const [innerCompare, setInnerCompare] = useState(defaultCompare);\n  const [beat, setBeat] = useState(0);\n  const range = value ?? inner;\n  const compare = compareProp ?? innerCompare;\n\n  const setRange = useCallback(\n    (next: R) => {\n      if (next === range) return;\n      setInner(next);\n      setBeat((b) => b + 1);\n      onValueChange?.(next);\n    },\n    [range, onValueChange],\n  );\n  const setCompare = useCallback(\n    (next: boolean) => {\n      setInnerCompare(next);\n      onCompareChange?.(next);\n    },\n    [onCompareChange],\n  );\n\n  const ctx = useMemo(\n    () => ({ range, setRange, beat, transition, compare, setCompare }) as unknown as ChartRangeValue,\n    [range, setRange, beat, transition, compare, setCompare],\n  );\n  return <ChartRangeContext.Provider value={ctx}>{children}</ChartRangeContext.Provider>;\n}\n\n/** The surrounding range, or null outside a `ChartRangeProvider`. */\nexport function useChartRange<R extends string = string>() {\n  return useContext(ChartRangeContext) as ChartRangeValue<R> | null;\n}\n\n/** The morph transition a chart should use: the provider's, else `fallback`. */\nexport function useChartTransition(fallback: Transition = smooth) {\n  return useContext(ChartRangeContext)?.transition ?? fallback;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/range.tsx"
    },
    {
      "path": "src/registry/eased/_primitives/chart-core/tokens.tsx",
      "content": "/**\n * The chart palette: six categorical hues in a fixed order, stepped separately for light and\n * dark and validated as a set (adjacent CVD ΔE ≥ 8.4, normal-vision ΔE ≥ 19.3, both themes).\n * Slots 3–5 sit under 3:1 on a white surface, which is why every chart ships a data table and\n * a legend: identity is never colour alone.\n *\n * They live here until `eased.css` carries them. Declared at zero specificity, so the same\n * variables in `eased.css` (or your own theme) win the moment they exist.\n */\nconst LIGHT = [\n  \"oklch(57.5% 0.163 255.5)\", // blue\n  \"oklch(67.1% 0.175 40.6)\", // orange\n  \"oklch(66.9% 0.141 162.1)\", // aqua\n  \"oklch(76.4% 0.161 75.1)\", // amber\n  \"oklch(71.6% 0.141 357.4)\", // pink\n  \"oklch(52.7% 0.197 283.4)\", // violet\n];\nconst DARK = [\n  \"oklch(62.2% 0.161 255.1)\",\n  \"oklch(62.2% 0.173 40.1)\",\n  \"oklch(62.1% 0.128 163.1)\",\n  \"oklch(67% 0.143 73.2)\",\n  \"oklch(62.2% 0.171 0.8)\",\n  \"oklch(67% 0.145 286.8)\",\n];\n\nconst vars = (colors: string[]) => colors.map((c, i) => `--eased-chart-${i + 1}:${c};`).join(\"\");\n\n/** The CSS the charts inject; also what to paste into `eased.css` to make the tokens global. */\nexport const CHART_TOKENS_CSS =\n  `:where(:root){${vars(LIGHT)}--eased-chart-grid:var(--eased-line-subtle);--eased-chart-cursor:var(--eased-line-strong);}` +\n  `:where(.dark,[data-theme=\"dark\"]){${vars(DARK)}}`;\n\n/**\n * Renders the chart tokens once per document (React 19 hoists and dedupes a `<style>` with\n * an `href`). Every chart renders it; you never need to.\n */\nexport function ChartTokens() {\n  return (\n    <style href=\"eased-chart-tokens\" precedence=\"low\">\n      {CHART_TOKENS_CSS}\n    </style>\n  );\n}\n\n/** Number of categorical slots. A seventh series is not a generated hue: fold it into \"Other\". */\nexport const CHART_SLOTS = 6;\n\n/**\n * The colour for series slot `n` (1-based) as a CSS variable. Slots past six fall back to\n * muted text rather than cycling, so two series never share a hue silently.\n */\nexport function chartColor(n: number) {\n  return n >= 1 && n <= CHART_SLOTS ? `var(--eased-chart-${n})` : \"var(--eased-text-muted)\";\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/chart-core/tokens.tsx"
    }
  ],
  "type": "registry:ui"
}