{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbital-skills",
  "type": "registry:ui",
  "title": "Orbital Skills",
  "description": "An interactive orbital diagram of tools and skills with upright labels and a selectable centre.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/orbital-skills.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nexport interface OrbitalNode {\n  id: string;\n  label: string;\n  /** Orbit index; nodes sharing a value share a ring (lower = closer to the centre). */\n  orbit: number;\n  icon?: React.ReactNode;\n  /** Shown in the centre when the node is selected. */\n  description?: string;\n}\n\nexport interface OrbitalSkillsProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onSelect\"> {\n  nodes: OrbitalNode[];\n  /** Centre heading while nothing is selected. */\n  title?: string;\n  /** Centre hint while nothing is selected. */\n  subtitle?: string;\n  /** Rotation speed of the innermost orbit in degrees per second (outer orbits are slower). */\n  speed?: number;\n  direction?: \"alternate\" | \"clockwise\" | \"counterclockwise\";\n  /** Pause while the pointer is over the diagram or focus is inside it. */\n  pauseOnHover?: boolean;\n  /** Show text labels next to icons (otherwise labels are visually hidden). */\n  showLabels?: boolean;\n  /** Draw the orbit paths. */\n  showRings?: boolean;\n  accent?: string;\n  defaultSelectedId?: string | null;\n  onSelect?: (node: OrbitalNode | null) => void;\n}\n\nconst INNER = 20; // cqw — radius of the first orbit\nconst OUTER = 40; // cqw — radius of the last orbit\n\nfunction Monogram({ label }: { label: string }) {\n  const letters = label\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n  return <span className=\"font-mono text-[0.85em] font-medium tracking-tight\">{letters}</span>;\n}\n\n/**\n * An orbital diagram of skills or tools. Rings rotate on a requestAnimationFrame loop that only\n * writes one CSS variable per ring; labels counter-rotate to stay upright. Nodes are real buttons.\n */\nexport function OrbitalSkills({\n  nodes,\n  title = \"Toolkit\",\n  subtitle = \"Select a node\",\n  speed = 10,\n  direction = \"alternate\",\n  pauseOnHover = true,\n  showLabels = true,\n  showRings = true,\n  accent = \"#FF7448\",\n  defaultSelectedId = null,\n  onSelect,\n  className = \"\",\n  style,\n  ...props\n}: OrbitalSkillsProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const groupLabelId = React.useId();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const ringRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const buttonRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const pausedRef = React.useRef({ hover: false, focus: false });\n  const [selectedId, setSelectedId] = React.useState<string | null>(defaultSelectedId);\n\n  // Distinct orbit values, innermost first, each mapped to a radius.\n  const orbits = [...new Set(nodes.map((n) => n.orbit))].sort((a, b) => a - b);\n  const radiusOf = (rank: number) => (orbits.length === 1 ? (INNER + OUTER) / 2 : INNER + (rank / (orbits.length - 1)) * (OUTER - INNER));\n  const orbitCount = orbits.length;\n\n  React.useEffect(() => {\n    if (reduceMotion || speed === 0) return;\n    const root = rootRef.current;\n    if (!root) return;\n    const angles = Array.from({ length: orbitCount }, () => 0);\n    let frame = 0;\n    let running = false;\n    let visible = true;\n    let last = 0;\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      const paused = pauseOnHover && (pausedRef.current.hover || pausedRef.current.focus);\n      if (!paused) {\n        for (let i = 0; i < orbitCount; i++) {\n          const sign = direction === \"counterclockwise\" ? -1 : direction === \"clockwise\" ? 1 : i % 2 ? -1 : 1;\n          // Outer orbits travel more slowly, like a (very loose) Kepler system.\n          const orbitSpeed = speed * Math.sqrt(INNER / (orbitCount === 1 ? INNER : INNER + (i / (orbitCount - 1)) * (OUTER - INNER)));\n          angles[i] = (angles[i] + sign * orbitSpeed * dt) % 360;\n          ringRefs.current[i]?.style.setProperty(\"--orbit-angle\", `${angles[i].toFixed(3)}deg`);\n        }\n      }\n      frame = requestAnimationFrame(loop);\n    };\n    const start = () => {\n      if (running || !visible || document.hidden) return;\n      running = true;\n      last = 0;\n      frame = requestAnimationFrame(loop);\n    };\n    const stop = () => {\n      running = false;\n      cancelAnimationFrame(frame);\n    };\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) start();\n      else stop();\n    });\n    io.observe(root);\n    const onVisibility = () => (document.hidden ? stop() : start());\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    start();\n    return () => {\n      stop();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [reduceMotion, speed, direction, pauseOnHover, orbitCount]);\n\n  const selected = nodes.find((n) => n.id === selectedId) ?? null;\n\n  const select = (node: OrbitalNode | null) => {\n    setSelectedId(node?.id ?? null);\n    onSelect?.(node);\n  };\n\n  // Arrow keys move focus between nodes in orbit order; Escape clears the selection.\n  const ordered = [...nodes].sort((a, b) => a.orbit - b.orbit);\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && selected) {\n      event.preventDefault();\n      select(null);\n      return;\n    }\n    const step = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    if (!step || !ordered.length) return;\n    const currentId = (event.target as HTMLElement).dataset?.nodeId;\n    const i = ordered.findIndex((n) => n.id === currentId);\n    if (i < 0) return;\n    event.preventDefault();\n    const next = ordered[(i + step + ordered.length) % ordered.length];\n    buttonRefs.current.get(next.id)?.focus();\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative mx-auto aspect-square w-full [container-type:inline-size] ${className}`}\n      style={{ [\"--os-accent\" as string]: accent, ...style }}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\") pausedRef.current.hover = true;\n      }}\n      onPointerLeave={() => {\n        pausedRef.current.hover = false;\n      }}\n      onFocus={() => {\n        pausedRef.current.focus = true;\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) pausedRef.current.focus = false;\n      }}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      {/* Centre: selection details, announced politely. */}\n      <div\n        className=\"absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-center shadow-[0_20px_60px_-30px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900\"\n        style={{ width: \"30cqw\", height: \"30cqw\", backgroundImage: \"radial-gradient(circle at 50% 120%, color-mix(in oklab, var(--os-accent) 22%, transparent), transparent 70%)\" }}\n      >\n        <div aria-live=\"polite\" aria-atomic=\"true\" className=\"w-full px-[3cqw]\">\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.div\n              key={selected?.id ?? \"__idle\"}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(4px)\" }}\n              transition={{ duration: reduceMotion ? 0.01 : 0.22 }}\n              className=\"flex flex-col items-center\"\n            >\n              {selected ? (\n                <>\n                  <span className=\"font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\" style={{ fontSize: \"clamp(8px, 1.9cqw, 10px)\" }}>\n                    Orbit {orbits.indexOf(selected.orbit) + 1}\n                  </span>\n                  <span className=\"mt-[0.6cqw] font-semibold leading-tight tracking-[-0.03em] text-zinc-950 dark:text-zinc-50\" style={{ fontSize: \"clamp(12px, 3.6cqw, 22px)\" }}>\n                    {selected.label}\n                  </span>\n                  {selected.description ? (\n                    <span className=\"mt-[0.8cqw] leading-snug text-zinc-600 dark:text-zinc-400\" style={{ fontSize: \"clamp(9px, 2cqw, 12px)\" }}>\n                      {selected.description}\n                    </span>\n                  ) : null}\n                </>\n              ) : (\n                <>\n                  <span className=\"font-semibold leading-tight tracking-[-0.03em] text-zinc-950 dark:text-zinc-50\" style={{ fontSize: \"clamp(13px, 4cqw, 24px)\" }}>\n                    {title}\n                  </span>\n                  <span className=\"mt-[0.8cqw] font-mono uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\" style={{ fontSize: \"clamp(8px, 1.9cqw, 10px)\" }}>\n                    {subtitle}\n                  </span>\n                </>\n              )}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <span id={groupLabelId} className=\"sr-only\">\n        {title}\n      </span>\n      <div role=\"group\" aria-labelledby={groupLabelId} className=\"absolute inset-0\">\n        {orbits.map((orbit, rank) => {\n          const r = radiusOf(rank);\n          const members = nodes.filter((n) => n.orbit === orbit);\n          // Stagger each ring's starting angle so nodes on neighbouring orbits don't line up.\n          const offset = rank * 37;\n          return (\n            <div\n              key={orbit}\n              ref={(el) => {\n                ringRefs.current[rank] = el;\n              }}\n              className=\"absolute left-1/2 top-1/2\"\n              style={{\n                width: `${r * 2}cqw`,\n                height: `${r * 2}cqw`,\n                transform: \"translate(-50%, -50%) rotate(var(--orbit-angle))\",\n                [\"--orbit-angle\" as string]: \"0deg\",\n              }}\n            >\n              {showRings ? (\n                <>\n                  <span aria-hidden=\"true\" className=\"absolute inset-0 rounded-full border border-dashed border-zinc-900/15 dark:border-white/15\" />\n                  <span aria-hidden=\"true\" className=\"absolute left-1/2 top-0 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--os-accent)] opacity-70\" />\n                </>\n              ) : null}\n              {members.map((node, i) => {\n                const angle = offset + (i / members.length) * 360;\n                const isSelected = node.id === selectedId;\n                return (\n                  <div\n                    key={node.id}\n                    className=\"absolute left-1/2 top-1/2 size-0\"\n                    style={{\n                      transform: `rotate(${angle}deg) translateX(${r}cqw) rotate(${-angle}deg) rotate(calc(-1 * var(--orbit-angle)))`,\n                    }}\n                  >\n                    <button\n                      ref={(el) => {\n                        if (el) buttonRefs.current.set(node.id, el);\n                        else buttonRefs.current.delete(node.id);\n                      }}\n                      type=\"button\"\n                      data-node-id={node.id}\n                      aria-pressed={isSelected}\n                      aria-label={showLabels ? undefined : node.label}\n                      title={showLabels ? undefined : node.label}\n                      onClick={() => select(isSelected ? null : node)}\n                      className={`group/node absolute left-0 top-0 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-[0.45em] whitespace-nowrap rounded-full border py-[0.3em] pl-[0.3em] text-zinc-800 transition-[border-color,background-color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--os-accent)] dark:text-zinc-100 ${\n                        showLabels ? \"pr-[0.8em]\" : \"pr-[0.3em]\"\n                      } ${\n                        isSelected\n                          ? \"border-[var(--os-accent)] bg-white shadow-[0_0_0_3px_color-mix(in_oklab,var(--os-accent)_25%,transparent)] dark:bg-zinc-900\"\n                          : \"border-zinc-900/10 bg-white/90 hover:border-zinc-900/35 dark:border-white/10 dark:bg-zinc-900/90 dark:hover:border-white/35\"\n                      }`}\n                      style={{ fontSize: \"clamp(10px, 2.3cqw, 13px)\" }}\n                    >\n                      <span\n                        aria-hidden=\"true\"\n                        className={`inline-flex size-[2em] items-center justify-center rounded-full transition-colors [&>svg]:size-[1.15em] ${\n                          isSelected ? \"bg-[var(--os-accent)] text-zinc-950\" : \"bg-zinc-900/[0.06] dark:bg-white/[0.08]\"\n                        }`}\n                      >\n                        {node.icon ?? <Monogram label={node.label} />}\n                      </span>\n                      {showLabels ? <span className=\"font-medium tracking-tight\">{node.label}</span> : null}\n                    </button>\n                  </div>\n                );\n              })}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/orbital-skills"
}
