{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "network-graph",
  "title": "Network Graph",
  "author": "AlgorizeTech",
  "description": "Concentric rings of avatar nodes around a centre node, with a staggered entrance. Use it for community, integration and social-proof sections.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/NetworkGraphBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, { useEffect, useMemo, useRef } from \"react\";\nimport { springLinear } from \"./motion-curves\";\n\nexport type NetworkNode = {\n  id: string;\n  ring: number; // 1 (inner), 2 (middle), 3 (outer), etc.\n  image?: string;\n  fallback?: string;\n  label?: string;\n  size?: \"sm\" | \"md\" | \"lg\" | (string & {});\n};\n\nexport interface NetworkGraphProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  nodes: NetworkNode[];\n  centerNode?: {\n    image?: string;\n    fallback?: string;\n    label?: string;\n  };\n  ringCount?: number;\n  baseRadius?: number; // Starting radius percentage for the first ring\n  ringGap?: number; // Percentage gap between rings\n  animateIn?: boolean;\n  animateOrbit?: boolean; // Toggles planetary orbital motion\n  orbitSpeed?: number; // Speed multiplier (1 = default, 2 = twice as fast)\n}\n\n// The entrance springs as CSS `linear()` curves, generated from the same\n// stiffness/damping the springs had, so they settle exactly as before.\nconst CENTER_SPRING = springLinear({ stiffness: 200, damping: 20 });\nconst NODE_SPRING = springLinear({ stiffness: 260, damping: 20 });\n\nconst keyframes = `\n@keyframes network-graph-ring-in { from { opacity: 0; scale: 0.8; } }\n@keyframes network-graph-pop-in { from { opacity: 0; scale: 0; } }\n`;\n\nconst sizeMap = {\n  sm: \"w-6 h-6 sm:w-9 sm:h-9\",\n  md: \"w-8 h-8 sm:w-11 sm:h-11\",\n  lg: \"w-10 h-10 sm:w-14 sm:h-14\",\n};\n\nexport const NetworkGraph = React.forwardRef<HTMLDivElement, NetworkGraphProps>(\n  (\n    {\n      nodes,\n      centerNode,\n      ringCount = 3,\n      baseRadius = 15,\n      ringGap = 15,\n      animateIn = true,\n      animateOrbit = true,\n      orbitSpeed = 1,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const rings = Array.from({ length: ringCount }, (_, i) => i + 1);\n\n    // FIXED: Added [nodes] dependency array to useMemo to resolve ts(2554) and eslint warnings\n    const groupedNodes = useMemo(() => {\n      const groups: Record<number, NetworkNode[]> = {};\n      nodes.forEach((node) => {\n        if (!groups[node.ring]) groups[node.ring] = [];\n        groups[node.ring].push(node);\n      });\n      return groups;\n    }, [nodes]);\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          \"relative w-full aspect-square max-w-[800px] mx-auto flex items-center justify-center\",\n          className,\n        )}\n        {...props}\n      >\n        <style>{keyframes}</style>\n\n        {/* Render Rings */}\n        <div className=\"absolute inset-0 flex items-center justify-center pointer-events-none\">\n          {rings.map((ring) => {\n            const radius = baseRadius + (ring - 1) * ringGap;\n            return (\n              <div\n                key={`ring-${ring}`}\n                className=\"absolute rounded-full border border-primary/[0.08]/[0.08]\"\n                style={{\n                  width: `${radius * 2}%`,\n                  height: `${radius * 2}%`,\n                  animation: animateIn\n                    ? `network-graph-ring-in 0.8s cubic-bezier(0, 0, 0.58, 1) ${ring * 0.1}s backwards`\n                    : undefined,\n                }}\n              />\n            );\n          })}\n        </div>\n\n        {/* Render Nodes per Ring with Orbiting Container */}\n        {rings.map((ringIndex) => (\n          <OrbitRing\n            key={`ring-orbit-${ringIndex}`}\n            ringIndex={ringIndex}\n            nodes={groupedNodes[ringIndex] || []}\n            radius={baseRadius + (ringIndex - 1) * ringGap}\n            animateIn={animateIn}\n            animateOrbit={animateOrbit}\n            orbitSpeed={orbitSpeed}\n          />\n        ))}\n\n        {/* Center Node */}\n        {centerNode && (\n          <div className=\"absolute z-20 w-0 h-0 flex items-center justify-center left-1/2 top-1/2\">\n            <div\n              className=\"relative group cursor-pointer\"\n              style={{\n                animation: animateIn\n                  ? `network-graph-pop-in ${CENTER_SPRING.duration}ms ${CENTER_SPRING.easing} backwards`\n                  : undefined,\n              }}\n            >\n              <div className=\"w-12 h-12 sm:w-16 sm:h-16 md:w-20 md:h-20 rounded-full border-[3px] sm:border-[5px] border-white dark:border-[#0a0a0a] shadow-xl overflow-hidden bg-card flex items-center justify-center transition-transform duration-300 group-hover:scale-105\">\n                {centerNode.image ? (\n                  <img\n                    src={centerNode.image}\n                    alt={centerNode.label || \"Center\"}\n                    className=\"w-full h-full object-cover\"\n                  />\n                ) : (\n                  <span className=\"text-xs sm:text-sm font-semibold text-muted-foreground uppercase\">\n                    {centerNode.fallback}\n                  </span>\n                )}\n              </div>\n              {centerNode.label && (\n                <div className=\"absolute top-[calc(100%+10px)] sm:top-[calc(100%+14px)] left-1/2 -translate-x-1/2 whitespace-nowrap px-3 py-1 sm:py-1.5 rounded-full bg-primary text-primary-foreground text-[10px] sm:text-xs font-semibold shadow-sm z-50\">\n                  {centerNode.label}\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    );\n  },\n);\nNetworkGraph.displayName = \"NetworkGraph\";\n\n/**\n * One orbit and the avatars riding it.\n *\n * Rotation is advanced by hand each frame instead of being handed to a\n * `repeat: Infinity` tween, because a tween reads its `duration` only when it\n * STARTS: with a constant `rotate` target the animation never restarts, so a\n * later `orbitSpeed` change was silently ignored unless the whole graph was\n * remounted. Stepping the value per frame makes the speed change apply on the\n * very next frame, from wherever the ring currently is — no jump, no remount.\n */\nfunction OrbitRing({\n  ringIndex,\n  nodes,\n  radius,\n  animateIn,\n  animateOrbit,\n  orbitSpeed,\n}: {\n  ringIndex: number;\n  nodes: NetworkNode[];\n  radius: number;\n  animateIn: boolean;\n  animateOrbit: boolean;\n  orbitSpeed: number;\n}) {\n  const ringRef = useRef<HTMLDivElement>(null);\n\n  // Outer rings take longer per lap, and even rings run backwards for depth.\n  const baseDuration = (35 + ringIndex * 10) / (orbitSpeed || 1);\n  const direction = ringIndex % 2 === 0 ? -1 : 1;\n  const degreesPerSecond = animateOrbit ? (360 / baseDuration) * direction : 0;\n\n  // Read through a ref so the frame callback always sees the latest speed\n  // without being torn down and rebuilt on every render.\n  const speedRef = useRef(degreesPerSecond);\n  useEffect(() => {\n    speedRef.current = degreesPerSecond;\n  });\n\n  // The ring's angle lives outside React so a speed change carries on from\n  // wherever the ring is.\n  const angleRef = useRef(0);\n  const orbiting = degreesPerSecond !== 0;\n\n  useEffect(() => {\n    const el = ringRef.current;\n    if (!el || !orbiting) return;\n\n    let frame = 0;\n    let last = 0;\n    const tick = (now: number) => {\n      // Same frame delta as a motion frame loop: 1/60s on the first frame,\n      // then the real gap clamped to 1–40ms so a stalled tab doesn't jump.\n      const delta = last ? Math.min(Math.max(now - last, 1), 40) : 1000 / 60;\n      last = now;\n      const speed = speedRef.current;\n      // Wrapped at a full turn so the value stays small over a long session.\n      const angle = (angleRef.current + (speed * delta) / 1000) % 360;\n      angleRef.current = angle;\n      el.style.transform = `rotate(${angle}deg)`;\n      // Every avatar on the ring shares one counter-rotation, which is what\n      // keeps faces upright while the ring itself turns.\n      el.style.setProperty(\"--network-graph-counter\", `rotate(${-angle}deg)`);\n      frame = requestAnimationFrame(tick);\n    };\n    const start = () => {\n      if (frame) return;\n      last = 0;\n      frame = requestAnimationFrame(tick);\n    };\n    const stop = () => {\n      cancelAnimationFrame(frame);\n      frame = 0;\n    };\n\n    // Nobody sees a ring turning off screen, so the loop only runs on screen.\n    if (typeof IntersectionObserver === \"undefined\") {\n      start();\n      return stop;\n    }\n    const observer = new IntersectionObserver(([entry]) => {\n      if (entry.isIntersecting) start();\n      else stop();\n    });\n    observer.observe(el);\n    return () => {\n      observer.disconnect();\n      stop();\n    };\n  }, [orbiting]);\n\n  const angleStep = 360 / nodes.length;\n\n  return (\n    <div ref={ringRef} className=\"absolute inset-0 pointer-events-none\">\n      {nodes.map((node, index) => {\n        const angle = angleStep * index - 90;\n        const radian = (angle * Math.PI) / 180;\n        const x = `calc(50% + ${radius * Math.cos(radian)}%)`;\n        const y = `calc(50% + ${radius * Math.sin(radian)}%)`;\n\n        return (\n          <div\n            key={node.id}\n            className=\"absolute z-10 w-0 h-0 flex items-center justify-center pointer-events-auto\"\n            style={{ left: x, top: y }}\n          >\n            {/* 1. Entrance Scale & Opacity Wrapper */}\n            <div\n              style={{\n                animation: animateIn\n                  ? `network-graph-pop-in ${NODE_SPRING.duration}ms ${NODE_SPRING.easing} ${0.2 + ringIndex * 0.1 + index * 0.05}s backwards`\n                  : undefined,\n              }}\n            >\n              {/* 2. Counter-Rotation Wrapper (Keeps Avatars Upright!) */}\n              <div\n                className=\"relative group cursor-pointer\"\n                style={{ transform: \"var(--network-graph-counter, none)\" }}\n              >\n                <div\n                  className={cn(\n                    \"relative rounded-full flex items-center justify-center overflow-hidden border border-white dark:border-[#0a0a0a] shadow-[0_2px_10px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_10px_rgba(0,0,0,0.4)] transition-transform duration-300 group-hover:scale-110 group-hover:z-50 bg-card\",\n                    sizeMap[node.size as keyof typeof sizeMap] ?? sizeMap.md,\n                  )}\n                >\n                  {node.image ? (\n                    <img\n                      src={node.image}\n                      alt={node.label || \"Avatar\"}\n                      className=\"w-full h-full object-cover\"\n                    />\n                  ) : (\n                    <span className=\"text-[10px] sm:text-xs font-semibold text-muted-foreground uppercase tracking-wider\">\n                      {node.fallback}\n                    </span>\n                  )}\n                </div>\n\n                {node.label && (\n                  <div className=\"absolute top-[calc(100%+8px)] left-1/2 -translate-x-1/2 whitespace-nowrap px-2.5 py-1 rounded-md bg-primary text-primary-foreground text-[10px] sm:text-[11px] font-semibold shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50\">\n                    {node.label}\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/NetworkGraphBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/motion-curves.ts",
      "content": "/**\n * Motion curves with no animation library behind them.\n *\n * The components here ship as CSS, GSAP or Motion builds that must move\n * identically, so the curves are computed once, from the same numbers, and\n * handed to whichever engine runs them:\n *\n *   - `cubicBezier(x1, y1, x2, y2)` → an easing function (GSAP accepts any\n *     `(progress) => value` as `ease`), exact for Motion's `ease: [..]` arrays\n *     and for CSS `cubic-bezier()`.\n *   - `spring({ stiffness, damping, mass })` → the analytic solution Motion\n *     Motion uses, as an easing function plus the time it takes to settle, or\n *     as a CSS `linear()` string for a fixed start and end.\n *   - `createSpringFollower` → a value that chases a moving target through the\n *     same spring every frame, like Motion's `useSpring`, for pointer-driven\n *     motion that no fixed curve can describe.\n */\n\n// ---------------------------------------------------------------------------\n// Cubic bezier\n// ---------------------------------------------------------------------------\n\n/**\n * The CSS / Motion cubic-bezier as a function of progress. Newton-Raphson\n * with a bisection fallback, the same solver browsers use.\n */\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === y1 && x2 === y2) return (t: number) => t;\n\n  const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;\n  const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;\n  const c = (p1: number) => 3 * p1;\n  const at = (t: number, p1: number, p2: number) =>\n    ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;\n  const slope = (t: number, p1: number, p2: number) =>\n    3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);\n\n  const solveX = (x: number) => {\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      const err = at(t, x1, x2) - x;\n      if (Math.abs(err) < 1e-7) return t;\n      t -= err / s;\n    }\n    let lo = 0;\n    let hi = 1;\n    t = x;\n    for (let i = 0; i < 30 && hi - lo > 1e-7; i++) {\n      const value = at(t, x1, x2);\n      if (value < x) lo = t;\n      else hi = t;\n      t = (lo + hi) / 2;\n    }\n    return t;\n  };\n\n  return (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : at(solveX(x), y1, y2));\n}\n\n/** Motion's named eases, as the cubic-beziers they are. */\nexport const MOTION_EASE = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n  circIn: [0.55, 0, 1, 0.45],\n  circOut: [0.55, 0, 0.45, 1],\n  circInOut: [0.85, 0, 0.15, 1],\n  backIn: [0.31, 0.01, 0.66, -0.59],\n  backOut: [0.33, 1.53, 0.69, 0.99],\n  anticipate: [0.66, -0.56, 0.34, 1.35],\n} as const;\n\n/**\n * Motion's default layout transition (`layout` / `layoutId`):\n * `{ duration: 0.45, ease: [0.4, 0, 0.1, 1] }`.\n */\nexport const LAYOUT_TRANSITION = { duration: 0.45, ease: [0.4, 0, 0.1, 1] as const };\n\n// ---------------------------------------------------------------------------\n// Spring\n// ---------------------------------------------------------------------------\n\nexport interface SpringOptions {\n  /** Motion default 100. */\n  stiffness?: number;\n  /** Motion default 10. */\n  damping?: number;\n  /** Motion default 1. */\n  mass?: number;\n  /** Starting velocity, in units of the whole distance per second. */\n  velocity?: number;\n  /** How close to the end counts as settled, as a fraction of the distance. */\n  restDelta?: number;\n}\n\n/**\n * Normalised position (0 → 1) of a spring released from 0 towards 1, at time\n * `t` seconds. The closed-form solution Motion's `spring()` generator\n * uses for the under-, critically and over-damped cases.\n */\nexport function springAt(options: SpringOptions = {}) {\n  const { stiffness = 100, damping = 10, mass = 1, velocity = 0 } = options;\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  const omega0 = Math.sqrt(stiffness / mass);\n  // Motion's convention: distance still to cover, velocity toward it negated.\n  const delta = 1;\n  const v0 = -velocity;\n\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (t: number) => {\n      const envelope = Math.exp(-zeta * omega0 * t);\n      return (\n        1 -\n        envelope *\n          (((v0 + zeta * omega0 * delta) / omegaD) * Math.sin(omegaD * t) +\n            delta * Math.cos(omegaD * t))\n      );\n    };\n  }\n  if (zeta === 1) {\n    return (t: number) => 1 - Math.exp(-omega0 * t) * (delta + (v0 + omega0 * delta) * t);\n  }\n  const omegaD = omega0 * Math.sqrt(zeta * zeta - 1);\n  return (t: number) => {\n    const envelope = Math.exp(-zeta * omega0 * t);\n    const freq = Math.min(omegaD * t, 300);\n    return (\n      1 -\n      (envelope *\n        ((v0 + zeta * omega0 * delta) * Math.sinh(freq) + omegaD * delta * Math.cosh(freq))) /\n        omegaD\n    );\n  };\n}\n\n/** Seconds until the spring stays within `restDelta` of its end. */\nexport function springDuration(options: SpringOptions = {}) {\n  const at = springAt(options);\n  const restDelta = options.restDelta ?? 0.001;\n  const step = 1 / 240;\n  let lastOutside = 0;\n  for (let t = 0; t <= 10; t += step) {\n    if (Math.abs(1 - at(t)) > restDelta) lastOutside = t;\n    else if (t - lastOutside > 0.1) break;\n  }\n  return Math.max(lastOutside + step, step);\n}\n\n/**\n * A spring as a duration and an easing function over it — what GSAP takes:\n *\n *   const { duration, ease } = springEase({ stiffness: 300, damping: 20 });\n *   gsap.to(el, { y: 0, duration, ease });\n *\n * Exact for a fixed start and end; a spring that has to react mid-flight to a\n * new target needs `createSpringFollower` instead.\n */\nexport function springEase(options: SpringOptions = {}) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  return {\n    duration,\n    ease: (progress: number) => (progress >= 1 ? 1 : at(progress * duration)),\n  };\n}\n\n/**\n * A spring as a CSS `linear()` easing and its duration in ms, for\n * `transition` / `animation` with a fixed start and end.\n *\n *   const { easing, duration } = springLinear({ stiffness: 400, damping: 30 });\n *   el.style.transition = `transform ${duration}ms ${easing}`;\n */\nexport function springLinear(options: SpringOptions = {}, pointsPerSecond = 60) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  const count = Math.max(Math.ceil(duration * pointsPerSecond), 8);\n  const points: string[] = [];\n  for (let i = 0; i <= count; i++) {\n    const t = (i / count) * duration;\n    points.push(String(Math.round((i === count ? 1 : at(t)) * 10000) / 10000));\n  }\n  return { easing: `linear(${points.join(\", \")})`, duration: Math.round(duration * 1000) };\n}\n\n// ---------------------------------------------------------------------------\n// Spring follower (useSpring equivalent)\n// ---------------------------------------------------------------------------\n\n/** Something that calls back once per frame with the time since the last, in ms. */\nexport interface FrameTicker {\n  add: (callback: (deltaMs: number) => void) => () => void;\n}\n\n/** requestAnimationFrame as a ticker — the library-free default. */\nexport const rafTicker: FrameTicker = {\n  add(callback) {\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      callback(now - last);\n      last = now;\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  },\n};\n\nexport interface SpringFollower {\n  /** Chase a new target from wherever the value is now, keeping its velocity. */\n  set: (target: number) => void;\n  /** Move to a value instantly, with no motion. */\n  jump: (value: number) => void;\n  get: () => number;\n  destroy: () => void;\n}\n\n/**\n * A value that follows its target through a spring, integrating the same\n * equation of motion Motion's `useSpring` solves — so a pointer-driven spring\n * reads the same in every build. Runs only while moving; `onUpdate` receives\n * each new value.\n */\nexport function createSpringFollower(\n  initial: number,\n  onUpdate: (value: number) => void,\n  options: SpringOptions & { restSpeed?: number; ticker?: FrameTicker } = {},\n): SpringFollower {\n  const { stiffness = 100, damping = 10, mass = 1, ticker = rafTicker } = options;\n  const restDelta = options.restDelta ?? 0.01;\n  const restSpeed = options.restSpeed ?? 0.01;\n  let value = initial;\n  let target = initial;\n  let velocity = 0;\n  let stop: (() => void) | null = null;\n\n  const step = (deltaMs: number) => {\n    // Fixed 1ms sub-steps keep the integration stable for stiff springs and\n    // independent of the frame rate.\n    const steps = Math.min(Math.max(Math.round(deltaMs), 1), 250);\n    for (let i = 0; i < steps; i++) {\n      const force = -stiffness * (value - target) - damping * velocity;\n      velocity += (force / mass) * 0.001;\n      value += velocity * 0.001;\n    }\n    if (Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed) {\n      value = target;\n      velocity = 0;\n      stop?.();\n      stop = null;\n    }\n    onUpdate(value);\n  };\n\n  return {\n    set(next) {\n      target = next;\n      if (!stop && value !== target) stop = ticker.add(step);\n    },\n    jump(next) {\n      stop?.();\n      stop = null;\n      value = target = next;\n      velocity = 0;\n      onUpdate(value);\n    },\n    get: () => value,\n    destroy() {\n      stop?.();\n      stop = null;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/motion-curves.ts"
    }
  ],
  "docs": "Named export: NetworkGraph. `nodes` take { id, ring, image?, fallback?, label?, size? } — `ring` assigns the orbit. Tune spacing with `ringCount`, `baseRadius` and `ringGap`; `animateIn` staggers the reveal. Positions are computed by ring, not by force simulation.",
  "categories": [
    "data-display",
    "marketing"
  ],
  "type": "registry:component"
}
