{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-donut-chart",
  "type": "registry:block",
  "title": "Dither Donut Chart",
  "description": "Interactive pixelated dither donut chart with real-time spring physics.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/components/dither-charts/DitherDonutChart.tsx",
      "type": "registry:component",
      "target": "components/amicro/DitherDonutChart.tsx",
      "content": "import React, { useEffect, useRef, useState, useMemo } from 'react';\r\nimport { motion, useSpring, useTransform, useReducedMotion } from 'motion/react';\r\nimport { Users } from 'lucide-react';\r\nimport { useCanvasSetup } from '../../hooks/useCanvasSetup';\r\n\r\ntype Plan = { name: string; color: string; base: number };\r\nconst PLANS: Plan[] = [\r\n  { name: 'Unlimited', color: '#FFFFFF', base: 1240 },\r\n  { name: '30-day pass', color: '#E2E8F0', base: 980 },\r\n  { name: '10-class pack', color: '#CBD5E1', base: 620 },\r\n  { name: 'Drop-in', color: '#94A3B8', base: 410 },\r\n  { name: 'Student', color: '#64748B', base: 300 },\r\n];\r\n\r\ntype Period = { name: string; mult: number };\r\nconst PERIODS: Period[] = [\r\n  { name: 'Week', mult: 0.42 },\r\n  { name: 'Month', mult: 1 },\r\n  { name: 'Quarter', mult: 2.6 },\r\n  { name: 'Year', mult: 8.4 },\r\n];\r\n\r\nconst smoothstep = (min: number, max: number, value: number) => {\r\n  const x = Math.max(0, Math.min(1, (value - min) / (max - min)));\r\n  return x * x * (3 - 2 * x);\r\n};\r\n\r\nconst hash = (x: number, y: number) => {\r\n  let h = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;\r\n  return h - Math.floor(h);\r\n};\r\n\r\nconst hexToRgba = (hex: string, alpha: number) => {\r\n  const r = parseInt(hex.slice(1, 3), 16);\r\n  const g = parseInt(hex.slice(3, 5), 16);\r\n  const b = parseInt(hex.slice(5, 7), 16);\r\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\r\n};\r\n\r\nfunction AnimatedNumber({ value }: { value: number }) {\r\n  const prefersReducedMotion = useReducedMotion();\r\n  const spring = useSpring(value, { stiffness: 190, damping: 27, mass: 0.7 });\r\n  const display = useTransform(spring, (current) =>\r\n    Math.round(current).toLocaleString('en-US')\r\n  );\r\n\r\n  useEffect(() => {\r\n    if (prefersReducedMotion) {\r\n      spring.jump(value);\r\n    } else {\r\n      spring.set(value);\r\n    }\r\n  }, [value, spring, prefersReducedMotion]);\r\n\r\n  return <motion.span className=\"tabular-nums\">{display}</motion.span>;\r\n}\r\n\r\nconst drawRoundedWedge = (ctx: CanvasRenderingContext2D, cx: number, cy: number, rIn: number, rOut: number, aStart: number, aEnd: number, cr: number) => {\r\n  const sweep = aEnd - aStart;\r\n  const maxCr = Math.min(cr, (rOut - rIn) / 2, (sweep * rIn) / 2);\r\n  if (sweep <= 0.001) return;\r\n  const crIn = maxCr;\r\n  const crOut = maxCr;\r\n\r\n  const aStartIn = aStart + crIn / rIn;\r\n  const aEndIn = aEnd - crIn / rIn;\r\n  const aStartOut = aStart + crOut / rOut;\r\n  const aEndOut = aEnd - crOut / rOut;\r\n\r\n  ctx.moveTo(cx + rIn * Math.cos(aStartIn), cy + rIn * Math.sin(aStartIn));\r\n  ctx.arc(cx, cy, rIn, aStartIn, aEndIn);\r\n  ctx.arcTo(\r\n    cx + rIn * Math.cos(aEnd), cy + rIn * Math.sin(aEnd),\r\n    cx + rOut * Math.cos(aEnd), cy + rOut * Math.sin(aEnd),\r\n    crIn\r\n  );\r\n  ctx.arcTo(\r\n    cx + rOut * Math.cos(aEnd), cy + rOut * Math.sin(aEnd),\r\n    cx + rOut * Math.cos(aEndOut), cy + rOut * Math.sin(aEndOut),\r\n    crOut\r\n  );\r\n  ctx.arc(cx, cy, rOut, aEndOut, aStartOut, true);\r\n  ctx.arcTo(\r\n    cx + rOut * Math.cos(aStart), cy + rOut * Math.sin(aStart),\r\n    cx + rIn * Math.cos(aStart), cy + rIn * Math.sin(aStart),\r\n    crOut\r\n  );\r\n  ctx.arcTo(\r\n    cx + rIn * Math.cos(aStart), cy + rIn * Math.sin(aStart),\r\n    cx + rIn * Math.cos(aStartIn), cy + rIn * Math.sin(aStartIn),\r\n    crIn\r\n  );\r\n};\r\n\r\ninterface DitherDonutChartProps {\r\n  theme?: 'dark' | 'light';\r\n  compact?: boolean;\r\n}\r\n\r\nexport function DitherDonutChart({ theme = 'dark', compact = false }: DitherDonutChartProps) {\r\n  const [periodIndex, setPeriodIndex] = useState(1); // Month\r\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\r\n\r\n  // Use shared perf hook — no getBoundingClientRect or matchMedia per frame\r\n  const { canvasRef, rect, isVisible, reducedMotion } = useCanvasSetup();\r\n\r\n  const period = PERIODS[periodIndex];\r\n\r\n  const { values, shares, total } = useMemo(() => {\r\n    let newTotal = 0;\r\n    const newValues = PLANS.map((plan, i) => {\r\n      const w = 0.78 + 0.4 * (0.5 + 0.5 * Math.sin(i * 1.9 + periodIndex * 1.3));\r\n      const val = Math.round(plan.base * period.mult * w);\r\n      newTotal += val;\r\n      return val;\r\n    });\r\n    const newShares = newValues.map(v => v / newTotal);\r\n    return { values: newValues, shares: newShares, total: newTotal };\r\n  }, [periodIndex]);\r\n\r\n  const timeRef = useRef(0);\r\n  const requestRef = useRef<number>();\r\n  const morphStartTimeRef = useRef<number>(0);\r\n  const fromSharesRef = useRef<number[]>([]);\r\n  const targetSharesRef = useRef<number[]>([]);\r\n  const dispSharesRef = useRef<number[]>([]);\r\n\r\n  const hoverRef = useRef(hoverIndex);\r\n  useEffect(() => { hoverRef.current = hoverIndex; }, [hoverIndex]);\r\n\r\n  useEffect(() => {\r\n    if (dispSharesRef.current.length === 0) {\r\n       dispSharesRef.current = [...shares];\r\n       fromSharesRef.current = [...shares];\r\n       targetSharesRef.current = [...shares];\r\n    } else {\r\n       fromSharesRef.current = [...dispSharesRef.current];\r\n       targetSharesRef.current = [...shares];\r\n       morphStartTimeRef.current = performance.now();\r\n    }\r\n  }, [shares]);\r\n\r\n  useEffect(() => {\r\n    const draw = () => {\r\n      // Skip draw when not visible — saves CPU when off-screen or tab hidden\r\n      if (!isVisible.current) {\r\n        requestRef.current = requestAnimationFrame(draw);\r\n        return;\r\n      }\r\n\r\n      const canvas = canvasRef.current;\r\n      if (!canvas) return;\r\n      const ctx = canvas.getContext('2d');\r\n      if (!ctx) return;\r\n\r\n      // Use cached rect from ResizeObserver — no reflow\r\n      const { width: logW, height: logH } = rect.current;\r\n      if (logW === 0 || logH === 0) {\r\n        requestRef.current = requestAnimationFrame(draw);\r\n        return;\r\n      }\r\n\r\n      timeRef.current += reducedMotion ? 0 : 0.02;\r\n\r\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\r\n      const logicalSize = 200;\r\n\r\n      ctx.save();\r\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\r\n      ctx.scale((logW * dpr) / logicalSize, (logH * dpr) / logicalSize);\r\n\r\n      let t = 0;\r\n      if (reducedMotion) {\r\n        t = 1;\r\n      } else if (morphStartTimeRef.current > 0) {\r\n        t = (performance.now() - morphStartTimeRef.current) / 500;\r\n        if (t > 1) t = 1;\r\n      } else {\r\n        t = 1;\r\n      }\r\n\r\n      const e = 1 - Math.pow(2, -10 * t);\r\n      for (let i = 0; i < targetSharesRef.current.length; i++) {\r\n        dispSharesRef.current[i] = fromSharesRef.current[i] + (targetSharesRef.current[i] - fromSharesRef.current[i]) * e;\r\n      }\r\n\r\n      let startAngle = -Math.PI / 2;\r\n      const gap = 0.07;\r\n      const currentHover = hoverRef.current;\r\n\r\n      for (let i = 0; i < dispSharesRef.current.length; i++) {\r\n        const share = dispSharesRef.current[i];\r\n        if (share === 0) continue;\r\n\r\n        let sweep = share * Math.PI * 2;\r\n        let aStart = startAngle + gap / 2;\r\n        let aEnd = startAngle + sweep - gap / 2;\r\n\r\n        if (aEnd < aStart) aEnd = aStart;\r\n\r\n        ctx.save();\r\n        const isHovered = currentHover === i;\r\n        const isAnyHovered = currentHover !== null;\r\n\r\n        if (isHovered) {\r\n          const mid = (aStart + aEnd) / 2;\r\n          ctx.translate(Math.cos(mid) * 6, Math.sin(mid) * 6);\r\n        }\r\n\r\n        ctx.beginPath();\r\n        drawRoundedWedge(ctx, 100, 100, 55, 86, aStart, aEnd, 6);\r\n        ctx.clip();\r\n\r\n        ctx.globalAlpha = isHovered ? 1.0 : (isAnyHovered ? 0.3 * 0.72 : 0.72);\r\n        ctx.fillStyle = PLANS[i].color;\r\n\r\n        if (isHovered) {\r\n            ctx.shadowColor = hexToRgba(PLANS[i].color, 0.55);\r\n            ctx.shadowBlur = 5;\r\n            ctx.shadowOffsetX = 0;\r\n            ctx.shadowOffsetY = 0;\r\n        }\r\n\r\n        const cell = 4.6;\r\n        const t2 = timeRef.current;\r\n        for (let x = 14; x <= 186; x += cell) {\r\n          for (let y = 14; y <= 186; y += cell) {\r\n            const dx = x - 100;\r\n            const dy = y - 100;\r\n            const dist = Math.sqrt(dx*dx + dy*dy);\r\n            if (dist < 55 - cell || dist > 86 + cell) continue;\r\n\r\n            let a = Math.atan2(dy, dx);\r\n            let normalizedA = a - aStart;\r\n            while (normalizedA < 0) normalizedA += Math.PI * 2;\r\n            while (normalizedA >= Math.PI * 2) normalizedA -= Math.PI * 2;\r\n            if (normalizedA > (aEnd - aStart)) continue;\r\n\r\n            const fullness = smoothstep(0.62, 1.0, (dist - 55) / (86 - 55));\r\n            const waveRaw = reducedMotion ? 0 :\r\n              Math.sin(dist * 0.1 - t2) + Math.sin(a * 3 + t2 * 1.5) + Math.sin(dx * 0.05 + dy * 0.05 + t2 * 2);\r\n            const wave = smoothstep(-1.5, 1.5, waveRaw);\r\n            const jitter = hash(x, y);\r\n\r\n            const size = cell * ((isHovered ? 0.46 : 0.34) + 0.36 * fullness + 0.26 * wave) * (0.78 + 0.42 * jitter);\r\n\r\n            ctx.fillRect(x - size/2, y - size/2, size, size);\r\n          }\r\n        }\r\n\r\n        ctx.restore();\r\n        startAngle += sweep;\r\n      }\r\n\r\n      ctx.restore();\r\n      requestRef.current = requestAnimationFrame(draw);\r\n    };\r\n\r\n    requestRef.current = requestAnimationFrame(draw);\r\n    return () => {\r\n      if (requestRef.current) cancelAnimationFrame(requestRef.current);\r\n    };\r\n  }, [reducedMotion]);\r\n\r\n  if (compact) {\r\n    return (\r\n      <div className=\"relative w-full h-full flex items-center justify-center p-2\">\r\n        <div className=\"relative w-[130px] h-[130px]\">\r\n          <canvas ref={canvasRef} className=\"w-full h-full block\" />\r\n        </div>\r\n      </div>\r\n    );\r\n  }\r\n\r\n  return (\r\n    <div className={`relative w-full rounded-3xl p-6 transition-colors border ${\r\n      theme === 'dark' ? 'bg-[#181818] border-white/5 text-white' : 'bg-white border-neutral-200 text-black shadow-lg'\r\n    }`}>\r\n      {/* Header */}\r\n      <div className=\"flex items-center justify-between mb-4\">\r\n        <div className=\"flex items-center gap-2\">\r\n          <div className={`p-2 rounded-xl ${theme === 'dark' ? 'bg-blue-500/10 text-blue-400' : 'bg-blue-50 text-blue-600'}`}>\r\n            <Users className=\"w-4 h-4\" />\r\n          </div>\r\n          <div>\r\n            <h4 className=\"text-sm font-bold\">Plan Distribution</h4>\r\n            <p className={`text-[11px] ${theme === 'dark' ? 'text-neutral-400' : 'text-neutral-500'}`}>\r\n              Dithered canvas chart with real-time spring physics\r\n            </p>\r\n          </div>\r\n        </div>\r\n\r\n        {/* Period Filter Tabs */}\r\n        <div className={`flex items-center p-1 rounded-full border text-xs font-medium ${\r\n          theme === 'dark' ? 'bg-[#131313] border-white/10' : 'bg-neutral-100 border-neutral-200'\r\n        }`}>\r\n          {PERIODS.map((p, idx) => (\r\n            <button\r\n              key={p.name}\r\n              onClick={() => setPeriodIndex(idx)}\r\n              className={`px-2.5 py-1 rounded-full transition-all cursor-pointer ${\r\n                periodIndex === idx\r\n                  ? (theme === 'dark' ? 'bg-blue-600 text-white' : 'bg-blue-600 text-white')\r\n                  : (theme === 'dark' ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 hover:text-black')\r\n              }`}\r\n            >\r\n              {p.name}\r\n            </button>\r\n          ))}\r\n        </div>\r\n      </div>\r\n\r\n      {/* Main Content Layout */}\r\n      <div className=\"flex flex-col sm:flex-row items-center gap-6\">\r\n        {/* Canvas Donut */}\r\n        <div className=\"relative w-[180px] h-[180px] shrink-0\">\r\n          <canvas ref={canvasRef} className=\"w-full h-full block\" />\r\n        </div>\r\n\r\n        {/* Plan Breakdown List */}\r\n        <div className=\"flex-1 w-full space-y-2\">\r\n          {PLANS.map((plan, idx) => {\r\n            const val = values[idx];\r\n            const pct = Math.round(shares[idx] * 100);\r\n            const isHovered = hoverIndex === idx;\r\n\r\n            return (\r\n              <div\r\n                key={plan.name}\r\n                onMouseEnter={() => setHoverIndex(idx)}\r\n                onMouseLeave={() => setHoverIndex(null)}\r\n                className={`flex items-center justify-between p-2 rounded-xl transition-all cursor-pointer border ${\r\n                  isHovered\r\n                    ? (theme === 'dark' ? 'bg-white/10 border-white/20' : 'bg-neutral-100 border-neutral-300')\r\n                    : 'border-transparent hover:bg-white/5'\r\n                }`}\r\n              >\r\n                <div className=\"flex items-center gap-2.5\">\r\n                  <div className=\"w-2.5 h-2.5 rounded-full\" style={{ backgroundColor: plan.color }} />\r\n                  <span className=\"text-xs font-medium\">{plan.name}</span>\r\n                </div>\r\n                <div className=\"flex items-center gap-3 text-xs\">\r\n                  <span className={`font-semibold ${theme === 'dark' ? 'text-neutral-300' : 'text-neutral-700'}`}>\r\n                    {val.toLocaleString()}\r\n                  </span>\r\n                  <span className={`text-[10px] w-8 text-right font-mono ${theme === 'dark' ? 'text-neutral-500' : 'text-neutral-400'}`}>\r\n                    {pct}%\r\n                  </span>\r\n                </div>\r\n              </div>\r\n            );\r\n          })}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  );\r\n}\r\n"
    }
  ]
}