{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-waterfall-ascii-step",
  "title": "Chart Waterfall ASCII Step",
  "description": "ASCII-ramp waterfall chart where toggling a step excludes or restores its delta and every bar after it (plus the running total) visibly recomputes and re-animates, not just a hover tooltip.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-waterfall-ascii-step/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChartWaterfallAsciiStep — the registry's first waterfall chart. Nearest\n// neighbours: chart-funnel-stage-drop tapers a single monotonic total and\n// animates its OWN drop-off as falling ink; chart-bar-dither/-halftone are\n// one static series per bar with no relationship between bars. Neither\n// recomputes a chain — a waterfall's whole point is that every bar's\n// position depends on every bar before it.\n//\n// The mechanic: every intermediate step has a real toggle (click or\n// Enter/Space on its hit button, aria-pressed). Toggling excludes or\n// restores that step's delta, and every bar from that step onward — plus\n// the trailing running total — animates its floating top/bottom AND its\n// displayed cumulative number to the new value over ~420ms. This is a\n// genuine recompute rendered as motion, not a tooltip: excluding \"Refunds\"\n// visibly redraws the shape of every later step and the total. Hovering or\n// focusing (without toggling) shows a tooltip with that step's exact\n// contribution and its before/after cumulative, so the read-out is\n// available before committing to a change.\n//\n// Bars keep the family's shared 4x4-Bayer-equivalent ASCII ramp\n// (' .:-=+*#%@') as a redundant density channel tracking |delta| — pure\n// var(--foreground) ink, var(--ns-accent) reserved for the active/focused step.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\n\nexport interface WaterfallStepDatum {\n  label: string;\n  /** first entry is the starting balance; every following entry is a signed delta */\n  delta: number;\n}\n\nexport interface ChartWaterfallAsciiStepProps {\n  /** the steps, in order; the final bar is the running total */\n  data?: WaterfallStepDatum[];\n  /** heading above the chart */\n  title?: string;\n  /** label for the final running-total bar */\n  totalLabel?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_DATA: WaterfallStepDatum[] = [\n  { label: \"Start\", delta: 1000 },\n  { label: \"New signups\", delta: 420 },\n  { label: \"Upgrades\", delta: 180 },\n  { label: \"Churn\", delta: -260 },\n  { label: \"Refunds\", delta: -90 },\n  { label: \"Expansion\", delta: 140 },\n];\n\nconst BAR_W = 30;\nconst SLOT_W = 62;\nconst PLOT_H = 190;\nconst TOP_PAD = 30;\nconst AXIS_H = 8;\nconst LABEL_H = 32;\nconst LEFT_PAD = 12;\nconst RIGHT_PAD = 12;\nconst CELL = 4;\nconst RECOMPUTE_MS = 420;\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  muted: string;\n  border: string;\n  accent: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#171717\"),\n    bg: get(\"--background\", \"#ffffff\"),\n    muted: get(\"--ns-muted\", \"#4d4d4d\"),\n    border: get(\"--border\", \"#ebebeb\"),\n    accent: get(\"--ns-accent\", \"#006bff\"),\n  };\n}\n\nfunction useTokens(): Tokens {\n  const [tokens, setTokens] = useState<Tokens>(() =>\n    typeof document === \"undefined\"\n      ? { fg: \"#171717\", bg: \"#ffffff\", muted: \"#4d4d4d\", border: \"#ebebeb\", accent: \"#006bff\" }\n      : readTokens()\n  );\n  useEffect(() => {\n    const sync = () => setTokens(readTokens());\n    sync();\n    const mo = new MutationObserver(sync);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    return () => mo.disconnect();\n  }, []);\n  return tokens;\n}\n\nfunction easeOutCubic(t: number): number {\n  return 1 - Math.pow(1 - t, 3);\n}\n\nfunction formatValue(v: number): string {\n  const sign = v < 0 ? \"-\" : \"\";\n  const abs = Math.abs(v);\n  if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1).replace(/\\.0$/, \"\")}M`;\n  if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1).replace(/\\.0$/, \"\")}K`;\n  return `${sign}${Math.round(abs).toLocaleString()}`;\n}\n\ninterface Bar {\n  label: string;\n  index: number;\n  isStart: boolean;\n  isTotal: boolean;\n  toggleable: boolean;\n  excluded: boolean;\n  delta: number;\n  from: number;\n  to: number;\n}\n\nfunction paintDitherRect(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  yTop: number,\n  yBase: number,\n  w: number,\n  ch: string,\n  ink: string\n) {\n  if (ch === \" \") return;\n  ctx.save();\n  ctx.beginPath();\n  ctx.rect(x, Math.min(yTop, yBase), w, Math.abs(yBase - yTop));\n  ctx.clip();\n  ctx.fillStyle = ink;\n  ctx.font = `${CELL * 2.4}px \"GeistMono\", ui-monospace, monospace`;\n  ctx.textAlign = \"left\";\n  ctx.textBaseline = \"top\";\n  const rowH = CELL * 2.6;\n  const top = Math.min(yTop, yBase);\n  const bottom = Math.max(yTop, yBase);\n  for (let y = top; y < bottom; y += rowH) {\n    for (let cx = x; cx < x + w; cx += CELL * 2) {\n      ctx.fillText(ch, cx, y);\n    }\n  }\n  ctx.restore();\n}\n\nexport function ChartWaterfallAsciiStep({\n  data = DEFAULT_DATA,\n  title = \"Chart\",\n  totalLabel = \"Total\",\n  className = \"\",\n}: ChartWaterfallAsciiStepProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokens = useTokens();\n  const [excluded, setExcluded] = useState<Set<number>>(new Set());\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  // index 0 is always the start bar (display-only, never focusable), so the\n  // first real control is index 1\n  const [activeIndex, setActiveIndex] = useState(1);\n  // useId, not Math.random(): the id is serialized into element ids on the\n  // server and must match what the client renders, or React discards the\n  // subtree as a hydration mismatch\n  const uid = `cwas-${useId().replace(/:/g, \"\")}`;\n\n  const reducedRef = useRef(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  const bars: Bar[] = useMemo(() => {\n    let cumulative = 0;\n    const out: Bar[] = [];\n    data.forEach((d, i) => {\n      const isStart = i === 0;\n      const excludedNow = !isStart && excluded.has(i);\n      const from = cumulative;\n      const delta = excludedNow ? 0 : d.delta;\n      cumulative += delta;\n      out.push({\n        label: d.label,\n        index: i,\n        isStart,\n        isTotal: false,\n        toggleable: !isStart,\n        excluded: excludedNow,\n        delta: d.delta,\n        from,\n        to: cumulative,\n      });\n    });\n    out.push({\n      label: totalLabel,\n      index: data.length,\n      isStart: false,\n      isTotal: true,\n      toggleable: false,\n      excluded: false,\n      delta: cumulative,\n      from: 0,\n      to: cumulative,\n    });\n    return out;\n  }, [data, excluded, totalLabel]);\n\n  const maxAbs = useMemo(() => Math.max(1, ...bars.map((b) => Math.max(Math.abs(b.from), Math.abs(b.to)))), [bars]);\n  const n = bars.length;\n  const viewW = LEFT_PAD + n * SLOT_W + RIGHT_PAD;\n  const viewH = TOP_PAD + PLOT_H + AXIS_H + LABEL_H;\n  const zeroY = TOP_PAD + PLOT_H;\n  const valueToY = (v: number) => zeroY - (v / maxAbs) * PLOT_H;\n\n  // only the intermediate delta steps are real, focusable controls — the\n  // start and running-total bars are display-only, so roving tabindex must\n  // stay confined to this list or Tab can land on nothing (a disabled\n  // button ignores tabIndex entirely)\n  const toggleableIndices = useMemo(() => bars.filter((b) => b.toggleable).map((b) => b.index), [bars]);\n\n  useEffect(() => {\n    if (!toggleableIndices.includes(activeIndex) && toggleableIndices.length > 0) {\n      setActiveIndex(toggleableIndices[0]);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [toggleableIndices]);\n\n  const focusByOffset = (offset: number) => {\n    const from = toggleableIndices.indexOf(activeIndex);\n    const nextPos = Math.min(toggleableIndices.length - 1, Math.max(0, (from < 0 ? 0 : from) + offset));\n    const target = toggleableIndices[nextPos];\n    if (target === undefined) return;\n    setActiveIndex(target);\n    document.getElementById(`${uid}-hit-${target}`)?.focus();\n  };\n\n  const toggle = (b: Bar) => {\n    if (!b.toggleable) return;\n    setExcluded((cur) => {\n      const next = new Set(cur);\n      if (next.has(b.index)) next.delete(b.index);\n      else next.add(b.index);\n      return next;\n    });\n  };\n\n  // animated per-bar \"to\" value + displayed cumulative, lerped every frame\n  // toward the freshly computed target so a toggle reads as a live recompute\n  const displayToRef = useRef<number[]>([]);\n  const rafRef = useRef(0);\n\n  useEffect(() => {\n    if (displayToRef.current.length !== n) {\n      displayToRef.current = bars.map((b) => b.to);\n    }\n  }, [n, bars]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = viewW * dpr;\n    canvas.height = viewH * dpr;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const draw = () => {\n      ctx.clearRect(0, 0, viewW, viewH);\n\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      const zy = Math.round(valueToY(0)) + 0.5;\n      ctx.beginPath();\n      ctx.moveTo(LEFT_PAD, zy);\n      ctx.lineTo(viewW - RIGHT_PAD, zy);\n      ctx.stroke();\n\n      let stillAnimating = false;\n\n      bars.forEach((b, i) => {\n        const x = LEFT_PAD + i * SLOT_W + (SLOT_W - BAR_W) / 2;\n        const curTo = displayToRef.current[i] ?? b.to;\n        const nextTo = reducedRef.current ? b.to : curTo + (b.to - curTo) * 0.24;\n        displayToRef.current[i] = Math.abs(nextTo - b.to) < 0.5 ? b.to : nextTo;\n        if (Math.abs(displayToRef.current[i] - b.to) >= 0.5) stillAnimating = true;\n\n        const yFrom = valueToY(b.isTotal ? 0 : b.from);\n        const yTo = valueToY(b.isTotal ? displayToRef.current[i] : displayToRef.current[i]);\n\n        // hoverIndex alone carries real interaction (pointer hover AND\n        // keyboard focus both set it) — activeIndex is only roving-tabindex\n        // bookkeeping and must never tint a resting, unfocused bar\n        const isActive = hoverIndex === i;\n        const level = Math.min(\n          RAMP.length - 1,\n          Math.round((Math.abs(b.isTotal ? b.to : b.to - b.from) / maxAbs) * (RAMP.length - 1))\n        );\n        const ch = b.excluded ? RAMP[1] : RAMP[Math.max(level, 2)];\n        const ink = isActive ? tokens.accent : b.excluded ? tokens.muted : tokens.fg;\n\n        paintDitherRect(ctx, x, yFrom, yTo, BAR_W, ch, ink);\n        ctx.strokeStyle = ink;\n        ctx.lineWidth = 1;\n        ctx.strokeRect(x + 0.5, Math.min(yFrom, yTo) + 0.5, BAR_W, Math.abs(yTo - yFrom));\n\n        // connector to next bar\n        if (i < bars.length - 1) {\n          ctx.strokeStyle = tokens.border;\n          ctx.setLineDash([2, 2]);\n          const connY = valueToY(displayToRef.current[i]);\n          ctx.beginPath();\n          ctx.moveTo(x + BAR_W, connY);\n          ctx.lineTo(x + SLOT_W + (SLOT_W - BAR_W) / 2, connY);\n          ctx.stroke();\n          ctx.setLineDash([]);\n        }\n\n        ctx.font = `9.5px \"GeistMono\", ui-monospace, monospace`;\n        ctx.textAlign = \"center\";\n        ctx.fillStyle = ink;\n        const labelValue = b.isTotal ? displayToRef.current[i] : b.excluded ? 0 : b.delta;\n        const prefix = !b.isStart && !b.isTotal && labelValue >= 0 ? \"+\" : \"\";\n        ctx.fillText(`${prefix}${formatValue(labelValue)}`, x + BAR_W / 2, Math.min(yFrom, yTo) - 8);\n        ctx.fillStyle = tokens.muted;\n        ctx.fillText(b.label, x + BAR_W / 2, zeroY + AXIS_H + 12);\n        if (b.toggleable) {\n          ctx.fillText(b.excluded ? \"(excluded)\" : \"\", x + BAR_W / 2, zeroY + AXIS_H + 24);\n        }\n      });\n\n      if (stillAnimating && !reducedRef.current) {\n        rafRef.current = requestAnimationFrame(draw);\n      }\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [bars, tokens, viewW, viewH, maxAbs, hoverIndex, activeIndex, zeroY]);\n\n  const hovered = hoverIndex !== null ? bars[hoverIndex] : null;\n\n  return (\n    <figure className={`ns-cwas inline-block ${className}`} aria-label={`${title}, waterfall chart`}>\n      <style>{CSS}</style>\n      <div className=\"flex items-center justify-between gap-3 pb-2\">\n        <span className=\"font-mono text-xs tracking-widest text-ns-muted\">{title.toUpperCase()}</span>\n        <span className=\"font-mono text-[10px] tracking-widest text-ns-muted\">\n          {excluded.size > 0 ? `${excluded.size} STEP${excluded.size > 1 ? \"S\" : \"\"} EXCLUDED` : \"ALL STEPS ACTIVE\"}\n        </span>\n      </div>\n\n      <div className=\"relative\" style={{ width: viewW, maxWidth: \"100%\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"ns-cwas-canvas\"\n          style={{ width: viewW, height: viewH, maxWidth: \"100%\", display: \"block\" }}\n        />\n\n        {bars.map((b) =>\n          b.toggleable ? (\n            <button\n              key={b.index}\n              type=\"button\"\n              id={`${uid}-hit-${b.index}`}\n              tabIndex={activeIndex === b.index ? 0 : -1}\n              aria-label={`${b.label}: ${formatValue(b.delta)}${b.excluded ? \", excluded\" : \"\"}`}\n              aria-pressed={!b.excluded}\n              className=\"ns-cwas-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none\"\n              style={{\n                left: LEFT_PAD + b.index * SLOT_W,\n                top: TOP_PAD - 16,\n                width: SLOT_W,\n                height: PLOT_H + 16,\n              }}\n              onPointerEnter={() => setHoverIndex(b.index)}\n              onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}\n              onFocus={() => {\n                setActiveIndex(b.index);\n                setHoverIndex(b.index);\n              }}\n              onBlur={() => setHoverIndex((c) => (c === b.index ? null : c))}\n              onClick={() => toggle(b)}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowLeft\") {\n                  e.preventDefault();\n                  focusByOffset(-1);\n                } else if (e.key === \"ArrowRight\") {\n                  e.preventDefault();\n                  focusByOffset(1);\n                }\n              }}\n            />\n          ) : (\n            // start / running-total bars are display-only, not real controls\n            <div\n              key={b.index}\n              aria-hidden=\"true\"\n              className=\"ns-cwas-hover absolute\"\n              style={{\n                left: LEFT_PAD + b.index * SLOT_W,\n                top: TOP_PAD - 16,\n                width: SLOT_W,\n                height: PLOT_H + 16,\n              }}\n              onPointerEnter={() => setHoverIndex(b.index)}\n              onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}\n            />\n          )\n        )}\n\n        {hovered && (\n          <div\n            aria-hidden=\"true\"\n            className=\"ns-cwas-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm\"\n            style={{\n              left: `${((LEFT_PAD + hovered.index * SLOT_W + SLOT_W / 2) / viewW) * 100}%`,\n              top: `${(Math.max(0, valueToY(Math.max(hovered.from, hovered.to)) - 46) / viewH) * 100}%`,\n              transform: \"translateX(-50%)\",\n            }}\n          >\n            <strong className=\"text-foreground\">\n              {hovered.toggleable ? formatValue(hovered.delta) : formatValue(hovered.to)}\n            </strong>{\" \"}\n            <span className=\"text-ns-muted\">\n              {hovered.toggleable ? `${formatValue(hovered.from)} → ${formatValue(hovered.to)}` : \"running total\"}\n            </span>\n          </div>\n        )}\n      </div>\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-cwas-hit { touch-action: manipulation; }\n.ns-cwas-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/chart-waterfall-ascii-step.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "chart",
      "waterfall",
      "data-viz",
      "ascii",
      "canvas"
    ],
    "instruction": "The registry's first waterfall chart: a start bar, a chain of floating delta bars (each spanning the running cumulative before and after it), and a trailing running-total bar. Every bar fill uses the family's shared 10-step ASCII ramp ' .:-=+*#%@' tiled at a small cell size, with density tracking |delta| relative to the largest bar on the chart — redundant with bar height, exactly like chart-bar-dither's own colour rule (pure var(--foreground) ink, var(--ns-accent) reserved for the hovered or keyboard-focused step). The real mechanic: every intermediate step (not the start, not the total) has a genuine toggle — a transparent hit button sized to its full slot, roving tabindex with ArrowLeft/ArrowRight moving focus between steps, aria-pressed reflecting whether the step is currently included. Activating it (click, or Enter/Space via keyboard) excludes that step's delta from the chain or restores it, and the recompute is rendered as real motion: every bar from that step onward, and the trailing total, eases its floating top/bottom to the newly recomputed cumulative over roughly 420ms, with the displayed numeric label tracking the same lerp — this is what makes it a genuine recompute rather than a tooltip. Hovering or focusing without activating shows a small tooltip with that step's exact contribution and its before/after cumulative, so the numbers are readable before committing to a change. A dashed connector line links each bar's resolved edge to the next bar's start so the chain reads as one continuous bridge even mid-animation. Tokens are read via getComputedStyle at mount and re-read through a MutationObserver on the document root's class attribute, so both themes repaint correctly on toggle with no remount. prefers-reduced-motion snaps every recompute to its final position/value in one paint instead of lerping, and the toggle itself remains fully functional. Zero dependencies."
  },
  "type": "registry:ui"
}