Radial Flow

PreviousNext

Dynamic radial graph for visualizing networks & hierarchies with animated nodes.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/radialflow.tsx
"use client";

import { useEffect, useState, useRef, useCallback } from "react";
import { motion } from "framer-motion";
import { Badge } from "@/components/ui/badge";

export interface Topic {
  id: string;
  name: string;
  position: { x: number; y: number };
  color: string;
  highlighted: boolean;
}

interface RadialFlowProps {
  topics: Topic[];
  badgeName: string;
  centralDotColor?: string;
}

export function RadialFlow({
  topics,
  badgeName,
  centralDotColor = "#FFFFFF",
}: RadialFlowProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkMobile = () => setIsMobile(window.innerWidth < 768);
    checkMobile();
    window.addEventListener("resize", checkMobile);
    return () => window.removeEventListener("resize", checkMobile);
  }, []);

  const updateDimensions = useCallback(() => {
    if (containerRef.current) {
      setDimensions({
        width: containerRef.current.offsetWidth,
        height: containerRef.current.offsetHeight,
      });
    }
  }, []);

  useEffect(() => {
    updateDimensions();
    const resizeObserver = new ResizeObserver(() =>
      requestAnimationFrame(updateDimensions)
    );
    if (containerRef.current) resizeObserver.observe(containerRef.current);
    return () => resizeObserver.disconnect();
  }, [updateDimensions]);

  const getLabelPosition = useCallback(
    (position: { x: number; y: number }) => {
      if (!isMobile) return position;
      return {
        x:
          position.x < 50
            ? Math.min(position.x + 5, 20)
            : Math.max(position.x - 5, 80),
        y: position.y,
      };
    },
    [isMobile]
  );

  const getPathData = useCallback(
    (topic: Topic) => {
      if (!dimensions.width || !dimensions.height) return "";

      const centerX = dimensions.width / 2;
      const centerY = dimensions.height / 2;
      const pos = getLabelPosition(topic.position);
      const x = (pos.x / 100) * dimensions.width;
      const y = (pos.y / 100) * dimensions.height;

      const controlX =
        pos.x < 50 ? x + (centerX - x) * 0.75 : x - (x - centerX) * 0.75;

      return `M ${x} ${y} Q ${controlX} ${y} ${centerX} ${centerY}`;
    },
    [dimensions, getLabelPosition]
  );

  const generateParticles = useCallback(
    (topic: Topic) => {
      if (!topic.highlighted) return null;

      const pathData = getPathData(topic);
      const eggWidth = 16;
      const eggHeight = 10;

      return (
        <motion.g key={`particle-${topic.id}`}>
          <motion.path
            d={`M -${eggWidth / 2} 0 
             a ${eggWidth / 2} ${eggHeight / 2} 0 1 0 ${eggWidth} 0 
             a ${eggWidth / 2} ${eggHeight / 2} 0 1 0 -${eggWidth} 0`}
            fill={topic.color}
            initial={{ opacity: 0, scale: 0.8 }}
            animate={{
              opacity: [0, 0.8, 0],
              scale: [0.8, 1.2, 0.8],
            }}
            transition={{
              duration: 2.5,
              repeat: Infinity,
              repeatDelay: 1,
              times: [0, 0.5, 1],
            }}
          >
            <animateMotion
              dur="3.5s"
              repeatCount="indefinite"
              path={pathData}
              rotate="auto"
              calcMode="spline"
              keyPoints="0;1"
              keyTimes="0;1"
              keySplines="0.42 0 0.58 1"
            />
          </motion.path>
        </motion.g>
      );
    },
    [getPathData]
  );

  const getTopicLabelClasses = useCallback(
    (topic: Topic) =>
      `absolute transform -translate-x-1/2 -translate-y-1/2 bg-gray-900/90 text-white rounded-md 
     backdrop-blur-sm border transition-all duration-300 px-3 py-2 text-xs sm:text-sm
     ${
       topic.highlighted
         ? "border-yellow-400/30 shadow-glow"
         : "border-gray-800"
     }
     whitespace-normal break-words text-center leading-tight`,
    []
  );

  const getLabelStyle = useCallback(
    (topic: Topic) => ({
      left: `${getLabelPosition(topic.position).x}%`,
      top: `${getLabelPosition(topic.position).y}%`,
      color: topic.color,
      maxWidth: isMobile ? "140px" : "200px",
      minWidth: "80px",
      lineHeight: "1.2",
    }),
    [isMobile, getLabelPosition]
  );

  return (
    <div
      ref={containerRef}
      className="w-full h-full  relative overflow-hidden min-h-[300px]"
    >
      <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-20 mb-8">
        <Badge variant="default" shiny={true}>
          {badgeName}
        </Badge>
      </div>

      {dimensions.width > 0 && (
        <svg
          className="absolute inset-0 w-full h-full"
          viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
        >
          <defs>
            <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="2" result="coloredBlur" />
              <feMerge>
                <feMergeNode in="coloredBlur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>

          {topics.map((topic) => (
            <path
              key={`path-${topic.id}`}
              d={getPathData(topic)}
              stroke={topic.highlighted ? topic.color : "#374151"}
              strokeWidth="1"
              strokeOpacity={topic.highlighted ? 0.4 : 0.2}
              fill="none"
            />
          ))}

          {topics.map((topic) => generateParticles(topic))}

          <motion.circle
            cx={dimensions.width / 2}
            cy={dimensions.height / 2}
            r="4"
            fill={centralDotColor}
            initial={{ scale: 0 }}
            animate={{ scale: 1 }}
            transition={{ duration: 0.5 }}
          />
        </svg>
      )}

      {topics.map((topic) => (
        <div
          key={`label-${topic.id}`}
          className={getTopicLabelClasses(topic)}
          style={getLabelStyle(topic)}
        >
          {topic.name}
        </div>
      ))}
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/radialflow

Usage

import { Radialflow } from "@/components/radialflow"
<Radialflow />