{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-funnel-stage-drop",
  "title": "Chart Funnel Stage Drop",
  "description": "Canvas funnel chart in ordered-dither ink density, where hovering or focusing a stage animates its own drop-off as ink particles falling into the next stage.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-funnel-stage-drop/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChartFunnelStageDrop — the dithered-chart family's funnel / stage-drop\n// chart (backlog queue #13). Each stage is a tapering trapezoid, its interior\n// filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17\n// ink levels) at a density tracking the stage's share of the top of the\n// funnel — the usual redundant density channel. The mechanic that is new to\n// this family member: hovering or focusing a stage animates its OWN drop-off\n// — the count lost before the next stage — as ink particles trickling out of\n// its bottom edge into the gap toward the next stage, gravity-plus-jitter,\n// particle count proportional to the actual drop, seeded per stage so a\n// replay of the same stage always looks identical. Nothing else in the\n// registry visualizes a delta as falling ink. Pure var(--foreground) ink on\n// var(--background) paper; var(--ns-accent) is reserved for keyboard focus\n// only, matching the rest of the family's colour rule.\n// ---------------------------------------------------------------------------\n\nexport interface ChartFunnelStageDropDatum {\n  label: string;\n  value: number;\n}\n\nexport interface ChartFunnelStageDropProps {\n  /** the funnel stages, in order */\n  data?: ChartFunnelStageDropDatum[];\n  /** heading above the chart */\n  title?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];\nconst LEVELS = 16;\nconst CELL = 5;\n\nconst FUNNEL_W = 380;\nconst MIN_W_FRAC = 0.22;\nconst LABEL_ROW_H = 32;\nconst BAR_H = 44;\nconst GAP_H = 26;\nconst TOP_PAD = 6;\nconst BOTTOM_PAD = 6;\nconst SIDE_PAD = 24;\nconst MAX_PARTICLES = 34;\nconst PARTICLE_PERIOD_MS = 1000;\nconst ENTRANCE_MS = 420;\nconst ENTRANCE_STAGGER = 60;\n\nfunction levelFor(norm: number): number {\n  return Math.round(Math.min(1, Math.max(0, norm)) * LEVELS);\n}\n\nfunction formatValue(v: number): string {\n  const abs = Math.abs(v);\n  if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\\.0$/, \"\")}M`;\n  if (abs >= 1_000) return `${(v / 1_000).toFixed(1).replace(/\\.0$/, \"\")}K`;\n  return Math.round(v).toLocaleString();\n}\n\nfunction easeOutExpo(t: number): number {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\n\nfunction hashStr(s: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\nfunction mulberry32(seed: number) {\n  let a = seed;\n  return function () {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\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\n/** A seeded rain of ink particles falling through a gap between two stage\n *  edges — deterministic per stage label so hovering the same stage twice\n *  looks identical. `phase` is 0..1 loop position. */\nfunction particlesFor(seed: string, count: number, topW: number, botW: number) {\n  const rng = mulberry32(hashStr(seed));\n  return Array.from({ length: count }, () => ({\n    x0: (rng() - 0.5) * topW,\n    x1: (rng() - 0.5) * botW,\n    offset: rng(),\n    jitter: rng() * Math.PI * 2,\n  }));\n}\n\nexport function ChartFunnelStageDrop({ data = [], title = \"Chart\", className = \"\" }: ChartFunnelStageDropProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokens = useTokens();\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [showTable, setShowTable] = useState(false);\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 = `cfs-${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 n = data.length;\n  const top = data[0]?.value ?? 1;\n\n  const stages = useMemo(\n    () =>\n      data.map((d, i) => {\n        const frac = top > 0 ? d.value / top : 0;\n        const w = FUNNEL_W * (MIN_W_FRAC + (1 - MIN_W_FRAC) * frac);\n        const next = data[i + 1];\n        const nextFrac = next && top > 0 ? next.value / top : frac * 0.9;\n        const wBottom = FUNNEL_W * (MIN_W_FRAC + (1 - MIN_W_FRAC) * nextFrac);\n        const drop = next ? Math.max(0, d.value - next.value) : 0;\n        const dropFrac = top > 0 ? drop / top : 0;\n        return { ...d, index: i, wTop: w, wBottom, frac, drop, dropFrac, level: levelFor(frac) };\n      }),\n    [data, top]\n  );\n\n  const rowH = LABEL_ROW_H + BAR_H;\n  const viewW = FUNNEL_W + SIDE_PAD * 2;\n  const viewH = TOP_PAD + n * rowH + Math.max(0, n - 1) * GAP_H + BOTTOM_PAD;\n\n  const rowTop = (i: number) => TOP_PAD + i * (rowH + GAP_H);\n\n  const focusStage = (i: number) => {\n    if (i < 0 || i >= n) return;\n    setActiveIndex(i);\n    document.getElementById(`${uid}-stage-${i}`)?.focus();\n  };\n\n  const mountAtRef = useRef(0);\n  const activeAtRef = useRef<number | null>(null);\n  const rafRef = useRef(0);\n\n  useEffect(() => {\n    mountAtRef.current = performance.now();\n  }, []);\n\n  useEffect(() => {\n    if (hoverIndex !== null) activeAtRef.current = performance.now();\n    else activeAtRef.current = null;\n  }, [hoverIndex]);\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 cx = viewW / 2;\n\n    const draw = () => {\n      ctx.clearRect(0, 0, viewW, viewH);\n      const now = performance.now();\n      let stillAnimating = false;\n\n      stages.forEach((s, i) => {\n        const delay = i * ENTRANCE_STAGGER;\n        const t = reducedRef.current ? 1 : Math.min(1, Math.max(0, (now - mountAtRef.current - delay) / ENTRANCE_MS));\n        if (t < 1) stillAnimating = true;\n        const grow = easeOutExpo(t);\n\n        const yTop = rowTop(i) + LABEL_ROW_H;\n        const yBottom = yTop + BAR_H;\n        const wTop = s.wTop * grow;\n        const wBottom = s.wBottom * grow;\n\n        // trapezoid path\n        ctx.save();\n        ctx.beginPath();\n        ctx.moveTo(cx - wTop / 2, yTop);\n        ctx.lineTo(cx + wTop / 2, yTop);\n        ctx.lineTo(cx + wBottom / 2, yBottom);\n        ctx.lineTo(cx - wBottom / 2, yBottom);\n        ctx.closePath();\n        const isActive = hoverIndex === i;\n        ctx.strokeStyle = isActive ? tokens.accent : tokens.border;\n        ctx.lineWidth = isActive ? 1.5 : 1;\n        ctx.stroke();\n        ctx.clip();\n\n        ctx.fillStyle = tokens.fg;\n        const startCol = Math.floor((cx - FUNNEL_W / 2) / CELL);\n        const endCol = Math.ceil((cx + FUNNEL_W / 2) / CELL);\n        const startRow = Math.floor(yTop / CELL);\n        const endRow = Math.ceil(yBottom / CELL);\n        for (let gy = startRow; gy < endRow; gy++) {\n          for (let gx = startCol; gx < endCol; gx++) {\n            const idx = (((gy % 4) + 4) % 4) * 4 + (((gx % 4) + 4) % 4);\n            if (BAYER[idx] < s.level) ctx.fillRect(gx * CELL, gy * CELL, CELL, CELL);\n          }\n        }\n        ctx.restore();\n\n        // label + value row above the bar\n        ctx.textAlign = \"center\";\n        ctx.font = `10px \"GeistMono\", ui-monospace, monospace`;\n        ctx.fillStyle = tokens.muted;\n        ctx.globalAlpha = grow;\n        ctx.fillText(s.label.toUpperCase(), cx, rowTop(i) + 12);\n        ctx.font = `11px \"GeistMono\", ui-monospace, monospace`;\n        ctx.fillStyle = tokens.fg;\n        ctx.fillText(\n          `${formatValue(s.value)}  (${Math.round(s.frac * 100)}%)`,\n          cx,\n          rowTop(i) + 26\n        );\n        ctx.globalAlpha = 1;\n\n        // the drop-off rain, only in the gap after this stage\n        if (i < n - 1 && s.drop > 0) {\n          const isHot = hoverIndex === i;\n          const count = Math.max(3, Math.round(s.dropFrac * MAX_PARTICLES));\n          const parts = particlesFor(`${uid}:${s.label}`, count, s.wBottom, stages[i + 1].wTop);\n          const activeAt = activeAtRef.current;\n\n          if (isHot && !reducedRef.current && activeAt !== null) {\n            stillAnimating = true;\n            ctx.fillStyle = tokens.fg;\n            parts.forEach((p) => {\n              const elapsed = (now - activeAt + p.offset * PARTICLE_PERIOD_MS) % PARTICLE_PERIOD_MS;\n              const phase = elapsed / PARTICLE_PERIOD_MS;\n              const y = yBottom + phase * GAP_H;\n              const wig = Math.sin(phase * Math.PI * 3 + p.jitter) * 3;\n              const x = cx + p.x0 + (p.x1 - p.x0) * phase + wig;\n              const alpha = phase < 0.12 ? phase / 0.12 : phase > 0.75 ? Math.max(0, (1 - phase) / 0.25) : 1;\n              ctx.globalAlpha = alpha;\n              ctx.beginPath();\n              ctx.arc(x, y, 1.4, 0, Math.PI * 2);\n              ctx.fill();\n            });\n            ctx.globalAlpha = 1;\n          } else if (isHot && reducedRef.current) {\n            // reduced motion: one static frame of evenly-spaced particles,\n            // no animation loop, still communicates the drop-off count\n            ctx.fillStyle = tokens.fg;\n            parts.forEach((p, k) => {\n              const phase = (k + 0.5) / parts.length;\n              const y = yBottom + phase * GAP_H;\n              const x = cx + p.x0 + (p.x1 - p.x0) * phase;\n              ctx.beginPath();\n              ctx.arc(x, y, 1.4, 0, Math.PI * 2);\n              ctx.fill();\n            });\n          }\n        }\n      });\n\n      if (stillAnimating) {\n        rafRef.current = requestAnimationFrame(draw);\n      }\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [stages, tokens, viewW, viewH, n, hoverIndex, uid]);\n\n  const hovered = hoverIndex;\n\n  return (\n    <figure className={`ns-cfs inline-block ${className}`} aria-label={`${title}, funnel 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        <button\n          type=\"button\"\n          onClick={() => setShowTable((s) => !s)}\n          className=\"ns-cfs-toggle rounded-sm border border-border px-2 py-1 font-mono text-[10px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          aria-pressed={showTable}\n        >\n          {showTable ? \"VIEW CHART\" : \"VIEW TABLE\"}\n        </button>\n      </div>\n\n      {showTable ? (\n        <table className=\"ns-cfs-table w-full border-collapse font-mono text-xs\">\n          <caption className=\"sr-only\">{title}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-left text-ns-muted\">\n                Stage\n              </th>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums\">\n                Value\n              </th>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums\">\n                Dropped\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {stages.map((s) => (\n              <tr key={s.label}>\n                <td className=\"border-b border-border px-2 py-1.5 text-foreground\">{s.label}</td>\n                <td className=\"border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums\">\n                  {s.value.toLocaleString()}\n                </td>\n                <td className=\"border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums\">\n                  {s.index < n - 1 ? s.drop.toLocaleString() : \"—\"}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      ) : (\n        <div className=\"relative\" style={{ width: viewW, maxWidth: \"100%\" }}>\n          <canvas\n            ref={canvasRef}\n            aria-hidden=\"true\"\n            className=\"ns-cfsd-canvas\"\n            style={{ width: viewW, height: viewH, maxWidth: \"100%\", display: \"block\" }}\n          />\n\n          {stages.map((s) => (\n            <button\n              key={s.label}\n              type=\"button\"\n              id={`${uid}-stage-${s.index}`}\n              tabIndex={activeIndex === s.index ? 0 : -1}\n              aria-label={\n                s.index < n - 1\n                  ? `${s.label}: ${s.value.toLocaleString()}, ${Math.round(s.frac * 100)}% of top, ${s.drop.toLocaleString()} dropped before next stage`\n                  : `${s.label}: ${s.value.toLocaleString()}, ${Math.round(s.frac * 100)}% of top`\n              }\n              className=\"ns-cfs-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none\"\n              style={{\n                left: 0,\n                top: rowTop(s.index),\n                width: viewW,\n                height: rowH + (s.index < n - 1 ? GAP_H : 0),\n              }}\n              onPointerEnter={() => setHoverIndex(s.index)}\n              onPointerLeave={() => setHoverIndex((c) => (c === s.index ? null : c))}\n              onFocus={() => {\n                setActiveIndex(s.index);\n                setHoverIndex(s.index);\n              }}\n              onBlur={() => setHoverIndex((c) => (c === s.index ? null : c))}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  focusStage(s.index - 1);\n                } else if (e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  focusStage(s.index + 1);\n                }\n              }}\n            />\n          ))}\n\n          {hovered !== null && stages[hovered] && stages[hovered].index < n - 1 && (\n            <div\n              aria-hidden=\"true\"\n              className=\"ns-cfs-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: \"50%\",\n                top: `${((rowTop(hovered) + rowH + GAP_H / 2) / viewH) * 100}%`,\n                transform: \"translate(-50%, -50%)\",\n              }}\n            >\n              <strong className=\"text-foreground\">{stages[hovered].drop.toLocaleString()}</strong>{\" \"}\n              <span className=\"text-ns-muted\">dropped</span>\n            </div>\n          )}\n        </div>\n      )}\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-cfs-hit { touch-action: manipulation; }\n.ns-cfs-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: -2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/chart-funnel-stage-drop.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",
      "funnel",
      "data-viz",
      "dither",
      "canvas",
      "ink"
    ],
    "instruction": "The dithered-chart family's funnel/stage-drop chart (backlog queue #13), a CANVAS member alongside chart-bar-dither and chart-line-dither. Each stage renders as a trapezoid tapering from its own share-of-top width to the next stage's width, filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17 ink levels) at a density tracking that stage's own fraction of the top of the funnel — the usual redundant density channel, pure var(--foreground) ink on var(--background) paper, var(--ns-accent) reserved for the hovered stage's outline and keyboard focus only. The mechanic unique to this family member: hovering or focusing a stage animates its own drop-off — the count lost before the next stage — as a seeded rain of ink particles trickling out of the stage's bottom edge, falling with a sinusoidal horizontal jitter through the gap toward the next stage's top edge, fading in over the first 12% of the fall and out over the last 25%. Particle count is proportional to the actual drop-off as a fraction of the funnel's top value (capped at 34), and the fall is a continuous, looping, gravity-style animation only while that stage is hovered or focused — nothing else in the registry visualizes a delta as literally falling ink. Both the particle offsets and jitter come from a mulberry32 PRNG seeded on the stage's own label, so hovering the same stage twice always reproduces the identical rain, never Math.random(). Each stage has a real, full-width <button> hit target spanning its row plus the gap beneath it, with an aria-label carrying its value, share of top, and (for every stage but the last) its drop-off count, and roving tabindex — ArrowUp/ArrowDown move focus between stages. Tokens are read via getComputedStyle on the document root at mount and re-read through a MutationObserver on its class attribute. On mount, stages unfurl from zero width over 420ms with a 60ms per-stage stagger; prefers-reduced-motion skips the stagger and, on hover or focus, renders one static frame of evenly spaced particles along the fall path instead of animating them, so the drop-off is still communicated without a persistent rAF loop. A VIEW TABLE toggle swaps the chart for a real HTML table listing each stage's value and drop-off. Zero dependencies."
  },
  "type": "registry:ui"
}