import { useEffect, useRef, useState, type RefObject } from "react" /* 파이프라인 6단계. 원래 히어로 캔버스가 소유하던 타입인데, 히어로가 영상으로 바뀌면서 이리로 옮겼다 — 단계 이름을 실제로 쓰는 건 이제 이 링뿐이다. */ export type DataFlowPhase = "scatter" | "cluster" | "stream" | "card" | "negotiate" | "learn" /** * 파이프라인 순환 링 — 정원. * * 앞서 3D 로 눕힌 타원을 썼는데, 깊이는 생기지만 정원의 완결감이 사라진다. * negotium 의 메시지는 "닫힌 루프"라서 완결감 쪽이 맞다. * * 구조는 레퍼런스(infinith)와 같다 — 점선 원만 천천히 돌고, 마디와 글자는 고정이다. * 글자까지 같이 돌면 읽을 수가 없다. */ const NODES: { key: DataFlowPhase; label: string }[] = [ { key: "scatter", label: "Collect" }, { key: "cluster", label: "Classify" }, { key: "stream", label: "Benchmark" }, { key: "card", label: "Anchor" }, { key: "negotiate", label: "Negotiate" }, { key: "learn", label: "Learn" }, ] /* viewBox 는 정사각이 아니다. 라벨이 궤도 바깥 좌우로 뻗기 때문에 가로 여백이 더 필요한데, 정사각으로 두면 "Benchmark" 같은 긴 라벨이 잘린다(SVG 는 viewBox 밖을 그냥 자른다). 가로만 넓히면 링 크기는 유지한 채 라벨 자리를 확보할 수 있다. */ const VB_W = 176 const VB_H = 150 const CX = VB_W / 2 const CY = VB_H / 2 const R = 46 // 궤도 반지름 const R_LABEL = 58 // 라벨은 궤도 바깥에 const rad = (ratio: number) => ratio * Math.PI * 2 - Math.PI / 2 // 12시에서 시계방향 const at = (ratio: number, radius: number) => ({ x: CX + Math.cos(rad(ratio)) * radius, y: CY + Math.sin(rad(ratio)) * radius, }) const LOOP_MS = 10_500 // 히어로 캔버스와 같은 주기 export function OrbitRing({ phase, progressRef, className = "", }: { /** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */ phase?: DataFlowPhase /** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */ progressRef?: RefObject className?: string }) { const headRef = useRef(null) const [ownIndex, setOwnIndex] = useState(0) const activeIndex = phase ? Math.max(0, NODES.findIndex((n) => n.key === phase)) : ownIndex /* 궤도를 도는 머리는 rAF 로 직접 갱신한다 — 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다. 단계 라벨만 바뀔 때(초당 0.5회) 리렌더한다. */ useEffect(() => { let raf = 0 let last = -1 const startedAt = performance.now() const tick = (now: number) => { const p = progressRef ? (progressRef.current ?? 0) : ((now - startedAt) % LOOP_MS) / LOOP_MS const { x, y } = at(p, R) if (headRef.current) { headRef.current.setAttribute("cx", String(x)) headRef.current.setAttribute("cy", String(y)) } if (!phase) { const i = Math.min(NODES.length - 1, Math.floor(p * NODES.length)) if (i !== last) { last = i setOwnIndex(i) } } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [progressRef, phase]) return ( {/* 점선 궤도 — 이 레이어만 돈다 */} {/* 바깥 보조 호 — 정지. 원이 하나면 심심하고, 둘이면 공간이 생긴다. */} {/* 마디 + 라벨 — 고정 레이어 */} {NODES.map((n, i) => { const ratio = i / NODES.length const p = at(ratio, R) const l = at(ratio, R_LABEL) const cos = Math.cos(rad(ratio)) const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle" const active = i === activeIndex return ( {n.label} ) })} {/* 궤도를 도는 머리 */} {/* 가운데 */} {NODES[activeIndex].label} {String(activeIndex + 1).padStart(2, "0")} / {String(NODES.length).padStart(2, "0")} ) }