Ctrl K
ComunidadGuías…

frontend/src/components/landing/HeroConstellation.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { animate, stagger } from 'animejs';
import type { Target } from 'animejs';
import { domainColor } from '@/lib/agent-colors';
import type { AgentDomain } from '@/lib/agent-colors';
import { cn } from '@/lib/cn';

// Full-bleed cinematic take on the reference "Maestro Landing" design: a central
// MAESTRO CORE card wired to six domain-colored subagents pushed to the screen
// edges, over ambient neon spotlights. Purely decorative (aria-hidden); renders
// a static final frame under reduced motion.

interface SubagentSpec {
  domain: AgentDomain;
  label: string;
  side: 'left' | 'right';
  row: 0 | 1 | 2;
}

// Six subagents, three per side. Each owns a domain hue so color carries
// identity — matching the marketplace, agent catalog and live architect graph.
const SUBAGENTS: readonly SubagentSpec[] = [
  { domain: 'software', label: 'SOFTWARE', side: 'left', row: 0 },
  { domain: 'finance', label: 'FINANCE', side: 'left', row: 1 },
  { domain: 'marketing', label: 'MARKETING', side: 'left', row: 2 },
  { domain: 'seo', label: 'SEO', side: 'right', row: 0 },
  { domain: 'research', label: 'RESEARCH', side: 'right', row: 1 },
  { domain: 'data', label: 'DATA', side: 'right', row: 2 },
] as const;

// Tasks the core cycles through — pure decoration, hints at live orchestration.
const TASKS: readonly string[] = [
  'Analyze Q3 market sentiment',
  'Extract competitor pricing',
  'Generate the weekly content calendar',
] as const;

// Fixed design canvas — the diagram is authored at this size and scaled to fit
// the viewport, so the edge-pushed layout never overlaps or overflows.
const DESIGN_W = 1200;
const DESIGN_H = 680;
const CX = DESIGN_W / 2;
const CY = DESIGN_H / 2;

// Node half-extents (px) used to anchor wires at the subagent edges.
const NODE_HALF_W = 96;
// The central orchestrator card the bonds converge onto. There is no core orb:
// every bundle plugs directly into the card-edge segment facing its subagent, so
// the wires read as feeding the rectangle itself (matching the reference sketch).
const CARD_HALF_W = 118; // 236 / 2 — half the orchestrator card width
const CARD_HALF_H = 95; // 190 / 2 — half the orchestrator card height
const INTAKE_INSET = 18; // strand roots land on the flat edge, not the rounded corner

// Pixel center of each subagent on the design canvas. Middle row is pulled a
// touch further inward so its wire clears the outer rows (echoes the reference).
function nodeCenter(spec: SubagentSpec): { x: number; y: number } {
  const inset = spec.row === 1 ? 200 : 150;
  const x = spec.side === 'left' ? inset : DESIGN_W - inset;
  const y = [140, CY, DESIGN_H - 140][spec.row];
  return { x, y };
}

// A "soul-bond" strand: a single smooth cubic Bézier that flows organically out
// of the subagent and into the core — no rigid circuit elbows. `midOffset` shifts
// both control points vertically while the endpoints stay fixed, so a bundle of
// strands with different offsets converges at both ends and opens in the middle.
function bondPath(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  midOffset: number,
): string {
  const dx = x2 - x1;
  const cx1 = x1 + dx * 0.5;
  const cx2 = x2 - dx * 0.5;
  return `M ${x1} ${y1} C ${cx1} ${y1 + midOffset}, ${cx2} ${y2 + midOffset}, ${x2} ${y2}`;
}

// Bonds root along the card's whole core-facing run — never a single point. The
// run is row-aware, always the edges closest to the central core: the TOP row
// uses the inner vertical edge plus half of its bottom (long) edge; the BOTTOM
// row uses the inner vertical edge plus half of its top edge; the MIDDLE row
// uses the inner vertical edge with a small symmetric wrap onto both edges.
const STRANDS_PER_BOND = 18; // many thin threads across the near side
const NODE_HALF_H = 30; // half-height of the card (short edge)
const LONG_EDGE_REACH = NODE_HALF_W; // roots reach the middle of the long edge
const MID_WRAP = 26; // middle-row wrap onto the top/bottom edges (px)

// Energy-pulse dash pattern: a short bright packet (FLOW_DASH) plus a long gap,
// so pulses read as discrete sparks flowing along the bond into the core.
const FLOW_DASH = 3;
const FLOW_PERIOD = 49; // FLOW_DASH + gap; also the per-loop strokeDashoffset step

// Ordered "near perimeter" polyline for a subagent: the run of card edge that
// faces the core, walked so strand param t (0..1) flows evenly along it.
function nearPerimeter(spec: SubagentSpec): { x: number; y: number }[] {
  const c = nodeCenter(spec);
  const inward = spec.side === 'left' ? -1 : 1; // toward the card center
  const innerX = spec.side === 'left' ? c.x + NODE_HALF_W : c.x - NODE_HALF_W;
  const top = c.y - NODE_HALF_H;
  const bot = c.y + NODE_HALF_H;
  if (spec.row === 0) {
    // Top subagent: down the inner edge, then out along the bottom (long) edge.
    return [
      { x: innerX, y: top },
      { x: innerX, y: bot },
      { x: innerX + inward * LONG_EDGE_REACH, y: bot },
    ];
  }
  if (spec.row === 2) {
    // Bottom subagent: in along the top (long) edge, then down the inner edge.
    return [
      { x: innerX + inward * LONG_EDGE_REACH, y: top },
      { x: innerX, y: top },
      { x: innerX, y: bot },
    ];
  }
  // Middle subagent: inner edge with a small symmetric wrap onto both edges.
  return [
    { x: innerX + inward * MID_WRAP, y: top },
    { x: innerX, y: top },
    { x: innerX, y: bot },
    { x: innerX + inward * MID_WRAP, y: bot },
  ];
}

// Ordered segment of the orchestrator card's perimeter facing a given subagent.
// Strand core-ends spread along this segment (fan-in) the same way their roots
// spread along the subagent's near perimeter (fan-out) — so each bundle plugs
// into the whole facing edge of the rectangle instead of a single point.
function coreIntake(spec: SubagentSpec): { x: number; y: number }[] {
  const left = CX - CARD_HALF_W;
  const right = CX + CARD_HALF_W;
  const top = CY - CARD_HALF_H;
  const bot = CY + CARD_HALF_H;
  if (spec.row === 1) {
    // Middle rows plug into the whole vertical edge on their side.
    const x = spec.side === 'left' ? left : right;
    return [
      { x, y: CY - 42 },
      { x, y: CY + 42 },
    ];
  }
  // Top/bottom rows plug into their half of the horizontal (top/bottom) edge.
  const y = spec.row === 0 ? top : bot;
  return spec.side === 'left'
    ? [
        { x: left + INTAKE_INSET, y },
        { x: CX - 8, y },
      ]
    : [
        { x: CX + 8, y },
        { x: right - INTAKE_INSET, y },
      ];
}

// Point at fraction t (0..1) along a polyline, distributed by arc length.
function pointAlong(
  points: { x: number; y: number }[],
  t: number,
): { x: number; y: number } {
  const lengths = points
    .slice(1)
    .map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
  const total = lengths.reduce((a, b) => a + b, 0);
  let d = t * total;
  for (let i = 0; i < lengths.length; i++) {
    if (d <= lengths[i] || i === lengths.length - 1) {
      const u = lengths[i] === 0 ? 0 : d / lengths[i];
      return {
        x: points[i].x + (points[i + 1].x - points[i].x) * u,
        y: points[i].y + (points[i + 1].y - points[i].y) * u,
      };
    }
    d -= lengths[i];
  }
  return points[points.length - 1];
}

interface Filament {
  d: string;
  width: number;
  opacity: number;
}

interface Wire {
  filaments: Filament[];
  // Single centerline down the middle of the bundle — the track energy pulses
  // travel along from the subagent into the core.
  flowD: string;
  color: string;
  x1: number;
  y1: number;
  x2: number;
  y2: number;
}

const WIRES: Wire[] = SUBAGENTS.map((spec) => {
  const c = nodeCenter(spec);
  const fromX = spec.side === 'left' ? c.x + NODE_HALF_W : c.x - NODE_HALF_W;
  // Every bundle plugs into the card-edge segment facing this subagent: strand
  // roots spread along the subagent's near perimeter (fan-out) and their core-ends
  // spread along the intake segment (fan-in) at the same param t.
  const perimeter = nearPerimeter(spec);
  const intake = coreIntake(spec);
  // No core orb to arc around anymore, so bonds run straight into the edge.
  const bow = 0;
  const filaments = Array.from({ length: STRANDS_PER_BOND }, (_, j) => {
    const t = j / (STRANDS_PER_BOND - 1); // 0..1 along both runs
    const centerBias = 1 - Math.abs(t - 0.5) * 2; // fuller mid-run, faint at ends
    const root = pointAlong(perimeter, t);
    const coreEnd = pointAlong(intake, t);
    return {
      d: bondPath(root.x, root.y, coreEnd.x, coreEnd.y, bow),
      width: 0.5 + 0.8 * centerBias, // thin throughout; mid strands a touch fuller
      opacity: 0.3 + 0.5 * centerBias, // faintest at run ends, brightest mid-run
    };
  });
  // Centerline of the bundle (root midpoint → intake midpoint) for the flowing pulse.
  const mid = pointAlong(perimeter, 0.5);
  const intakeMid = pointAlong(intake, 0.5);
  const flowD = bondPath(mid.x, mid.y, intakeMid.x, intakeMid.y, bow);
  return {
    filaments,
    flowD,
    color: domainColor(spec.domain).accentHex,
    x1: fromX,
    y1: c.y,
    x2: intakeMid.x,
    y2: intakeMid.y,
  };
});

function prefersReducedMotion(): boolean {
  return (
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );
}

export default function HeroConstellation() {
  const outerRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const cardGlowRef = useRef<HTMLDivElement>(null);
  const nodeRefs = useRef<HTMLDivElement[]>([]);
  const pathRefs = useRef<SVGPathElement[]>([]);
  const flowRefs = useRef<SVGPathElement[]>([]);
  // Guards the one-shot entrance so a resize never replays the fly-in.
  const hasEnteredRef = useRef(false);

  const [scale, setScale] = useState(1);
  const [taskIndex, setTaskIndex] = useState(0);
  const [reduced, setReduced] = useState(false);

  // Fresh each render so a resize never animates stale nodes.
  nodeRefs.current = [];
  pathRefs.current = [];
  flowRefs.current = [];

  useEffect(() => {
    setReduced(prefersReducedMotion());
  }, []);

  // Scale the fixed-size diagram to fit the available viewport (never up-scaled).
  useEffect(() => {
    const el = outerRef.current;
    if (!el) return;
    const measure = () => {
      const rect = el.getBoundingClientRect();
      setScale(Math.min(1, rect.width / DESIGN_W, rect.height / DESIGN_H));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // Rotate the core's task text on a timer (fade handled via re-mount + CSS).
  useEffect(() => {
    const id = window.setInterval(() => {
      setTaskIndex((i) => (i + 1) % TASKS.length);
    }, 2800);
    return () => window.clearInterval(id);
  }, []);

  // Drive the entrance + looping glows once reduced-motion and scale are known.
  useEffect(() => {
    if (scale <= 0) return;
    const paths = pathRefs.current.filter(Boolean);
    const flows = flowRefs.current.filter(Boolean);
    const nodes = nodeRefs.current.filter(Boolean);
    const leftNodes = nodes.filter((_, i) => SUBAGENTS[i]?.side === 'left');
    const rightNodes = nodes.filter((_, i) => SUBAGENTS[i]?.side === 'right');
    const card = cardRef.current;
    const cardGlow = cardGlowRef.current;

    // Draw every wire from full length → 0 so it renders complete when static.
    paths.forEach((p) => {
      const len = p.getTotalLength();
      p.style.strokeDasharray = String(len);
      p.style.strokeDashoffset = reduced ? '0' : String(len);
    });

    // Energy pulses only exist while motion is allowed; hidden under reduced motion.
    flows.forEach((p) => {
      p.style.opacity = reduced ? '0' : '1';
    });

    if (reduced) {
      nodes.forEach((n) => {
        n.style.opacity = '1';
        n.style.transform = 'none';
      });
      if (card) card.style.opacity = '1';
      return;
    }

    const entered = hasEnteredRef.current;
    hasEnteredRef.current = true;

    // Start each node one viewport-width off its own side so it flies in from
    // beyond the edge. Scale cancels the canvas transform, so on-screen travel
    // equals one viewport width regardless of the fit scale.
    const offset = typeof window !== 'undefined' ? window.innerWidth / scale : DESIGN_W;

    // Entrance choreography: nodes fly in slowly by row; each bond draws in as its
    // own node closes in, finishing just after it lands — so no cable ever hangs in
    // empty space before its agent arrives.
    const NODE_START = 260; // first row begins
    const NODE_STAGGER = 150; // gap between rows on each side
    const NODE_DURATION = 1200; // slower, more graceful fly-in

    const instances: ReturnType<typeof animate>[] = [];

    if (!entered) {
      instances.push(
        animate(paths, {
          strokeDashoffset: 0,
          duration: 900,
          // Begin drawing as the bond's own node closes in (~65% through its flight)
          // and finish just after it lands. Filaments of one bond share the delay.
          delay: (_target?: Target, idx = 0) => {
            const bondIndex = Math.floor(idx / STRANDS_PER_BOND);
            const row = SUBAGENTS[bondIndex]?.row ?? 0;
            return NODE_START + row * NODE_STAGGER + NODE_DURATION * 0.65;
          },
          ease: 'inOutQuad',
        }),
        animate(leftNodes, {
          opacity: [0, 1],
          translateX: [-offset, 0],
          duration: NODE_DURATION,
          ease: 'outCubic',
          delay: stagger(NODE_STAGGER, { start: NODE_START }),
        }),
        animate(rightNodes, {
          opacity: [0, 1],
          translateX: [offset, 0],
          duration: NODE_DURATION,
          ease: 'outCubic',
          delay: stagger(NODE_STAGGER, { start: NODE_START }),
        }),
      );
      if (card) {
        instances.push(
          animate(card, {
            opacity: [0, 1],
            scale: [0.86, 1],
            duration: 700,
            ease: 'outBack',
          }),
        );
      }
    }

    // Continuous energy: bright packets travel each bond's centerline from the
    // subagent into the core. Dash period is fixed, so one offset step loops
    // seamlessly on bonds of any length. Starts after the fly-in on first mount
    // (so no pulse rides a cable before its agent lands), immediately thereafter.
    if (flows.length) {
      const flowStart = entered
        ? 0
        : NODE_START + 2 * NODE_STAGGER + NODE_DURATION;
      instances.push(
        animate(flows, {
          strokeDashoffset: [0, -FLOW_PERIOD], // negative → travels toward the core
          duration: 1600,
          ease: 'linear',
          loop: true,
          delay: stagger(140, { start: flowStart }),
        }),
      );
    }

    if (cardGlow) {
      instances.push(
        animate(cardGlow, {
          opacity: [
            { to: 0.85, duration: 1500 },
            { to: 0.35, duration: 1500 },
          ],
          ease: 'inOutSine',
          loop: true,
        }),
      );
    }

    return () => instances.forEach((a) => a.pause());
  }, [reduced, scale]);

  const nodes = useMemo(() => SUBAGENTS.map((spec) => ({ spec, c: nodeCenter(spec) })), []);

  return (
    <div
      ref={outerRef}
      aria-hidden
      className="pointer-events-none absolute inset-0 select-none overflow-hidden"
    >
      {/* Only the core carries an ambient light — the subagents stay crisp so
          just their neon frames and labels glow. */}
      <div className="pointer-events-none absolute inset-0">
        {/* Center core spotlight — soft white bloom, gently pulsing. */}
        <div
          className="absolute left-1/2 top-1/2 h-[26rem] w-[34rem] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[90px] animate-pulse-glow"
          style={{
            background:
              'radial-gradient(circle, rgba(255,255,255,0.5) 0%, rgba(211,203,192,0.24) 42%, transparent 72%)',
            mixBlendMode: 'screen',
          }}
        />
      </div>

      {/* Fixed-size diagram canvas, scaled to fit and centered. */}
      <div
        className="absolute left-1/2 top-1/2"
        style={{
          width: DESIGN_W,
          height: DESIGN_H,
          transform: `translate(-50%, -50%) scale(${scale})`,
          transformOrigin: 'center',
        }}
      >
        {/* Wire layer — under the nodes so cables appear to plug in. */}
        <svg
          className="absolute inset-0"
          width={DESIGN_W}
          height={DESIGN_H}
          viewBox={`0 0 ${DESIGN_W} ${DESIGN_H}`}
          fill="none"
        >
          <defs>
            <filter id="wireGlow" x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="3" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
            {/* Wide, soft bloom for the domain sector glows pooling where each
                bundle meets the card edge. */}
            <filter id="sectorGlow" x="-200%" y="-200%" width="500%" height="500%">
              <feGaussianBlur stdDeviation="10" />
            </filter>
            {/* Each strand fades from its domain hue at the agent into a bright
                white at the core — six threads merging into one soul. */}
            {WIRES.map((wire, i) => (
              <linearGradient
                key={i}
                id={`bond-${i}`}
                gradientUnits="userSpaceOnUse"
                x1={wire.x1}
                y1={wire.y1}
                x2={wire.x2}
                y2={wire.y2}
              >
                <stop offset="0%" stopColor={wire.color} stopOpacity={0.95} />
                <stop offset="70%" stopColor={wire.color} stopOpacity={0.9} />
                <stop offset="100%" stopColor="#ffffff" stopOpacity={0.95} />
              </linearGradient>
            ))}
          </defs>
          {WIRES.map((wire, i) => (
            <g key={i} filter="url(#wireGlow)">
              {/* Multi-filament soul-bond: thin neon strands that converge at both
                  ends and open in the middle. Each is drawn in on entrance. */}
              {wire.filaments.map((f, j) => (
                <path
                  key={j}
                  ref={(node) => {
                    if (node) pathRefs.current[i * STRANDS_PER_BOND + j] = node;
                  }}
                  d={f.d}
                  stroke={`url(#bond-${i})`}
                  strokeWidth={f.width}
                  strokeLinecap="round"
                  opacity={f.opacity}
                />
              ))}
            </g>
          ))}
          {/* Energy-pulse layer: one bright packet-track per bond, animated in the
              entrance effect so sparks stream from each subagent into the core. */}
          <g filter="url(#wireGlow)">
            {WIRES.map((wire, i) => (
              <path
                key={i}
                ref={(node) => {
                  if (node) flowRefs.current[i] = node;
                }}
                d={wire.flowD}
                stroke={wire.color}
                strokeWidth={2}
                strokeLinecap="round"
                strokeDasharray={`${FLOW_DASH} ${FLOW_PERIOD - FLOW_DASH}`}
                style={{ opacity: 0 }}
              />
            ))}
          </g>
          {/* Colored sector glows: each bundle pools its domain hue where it meets
              the card edge, so the rectangle reads as fed by all six domains at once. */}
          {WIRES.map((wire, i) => (
            <circle
              key={`sector-${i}`}
              cx={wire.x2}
              cy={wire.y2}
              r={16}
              fill={wire.color}
              filter="url(#sectorGlow)"
              style={{ mixBlendMode: 'screen', opacity: 0.75 }}
            />
          ))}
        </svg>

        {/* Subagent nodes. Outer wrapper centers on the anchor; the inner box is
            the anime.js target so its transform never fights centering. */}
        {nodes.map(({ spec, c }, i) => {
          const color = domainColor(spec.domain);
          return (
            <div
              key={spec.domain}
              className="absolute -translate-x-1/2 -translate-y-1/2"
              style={{ left: c.x, top: c.y }}
            >
              <div
                ref={(node) => {
                  if (node) nodeRefs.current[i] = node;
                }}
                className={cn(
                  'relative w-[184px] rounded-xl border-2 bg-surface/85 px-4 py-3 backdrop-blur-sm',
                  color.borderSelected,
                )}
                style={{
                  opacity: 0,
                  boxShadow: `0 0 0 1.5px ${color.accentHex}, 0 0 14px -1px ${color.accentHex}, inset 0 0 10px -6px ${color.accentHex}`,
                }}
              >
                <div className="flex items-center justify-between">
                  <span
                    className="font-mono text-[10px] font-semibold tracking-[0.22em]"
                    style={{ color: color.accentHex, opacity: 0.75 }}
                  >
                    AGENT
                  </span>
                  <span
                    className="h-1.5 w-1.5 rounded-full animate-pulse-glow"
                    style={{ backgroundColor: color.accentHex }}
                  />
                </div>
                <div
                  className="mt-1 font-sans text-[16px] font-semibold"
                  style={{ color: color.accentHex, textShadow: `0 0 14px ${color.accentHex}` }}
                >
                  {spec.label}
                </div>
              </div>
            </div>
          );
        })}

        {/* Central MAESTRO CORE card. */}
        <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
          <div
            ref={cardRef}
            className="relative flex h-[190px] w-[236px] flex-col justify-between rounded-2xl border-[1.5px] border-white/70 bg-surface/25 px-6 py-4 text-center"
            style={{
              opacity: 0,
              boxShadow:
                '0 0 60px -12px rgba(255,255,255,0.35), 0 0 90px -20px rgba(211,203,192,0.35), 0 0 90px -20px rgba(34,211,238,0.3)',
            }}
          >
            {/* Slowly rotating multicolor halo behind the card — a round,
                blurred conic sweep so it reads as an energy aura, not a square. */}
            <span
              aria-hidden
              className="pointer-events-none absolute -inset-8 -z-10 rounded-full opacity-50 blur-lg animate-[spin_9s_linear_infinite]"
              style={{
                background:
                  'conic-gradient(from 0deg, transparent, rgba(211,203,192,0.55), transparent 28%, rgba(34,211,238,0.55), transparent 58%, rgba(255,92,200,0.55), transparent)',
              }}
            />
            {/* Soft white glow, pulsing with anime.js. */}
            <span
              ref={cardGlowRef}
              aria-hidden
              className="pointer-events-none absolute inset-0 -z-10 rounded-2xl bg-primary blur-2xl"
              style={{ opacity: 0.35 }}
            />
            <div className="font-mono text-[10px] font-semibold tracking-[0.2em] text-white/55">
              MAESTRO CORE
            </div>
            <div
              className="mt-1.5 font-sans text-[22px] font-bold text-white"
              style={{ textShadow: '0 0 18px rgba(255,255,255,0.55)' }}
            >
              Orchestrator
            </div>
            <p
              key={taskIndex}
              className={cn(
                'mt-3 border-t border-white/10 pt-2.5 font-mono text-[11px] leading-snug text-slate-300',
                !reduced && 'animate-fade-in',
              )}
            >
              {TASKS[taskIndex]}
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}