o2o-negosium-original/landing/app/components/ui/orbit-ring.tsx
Haewon Kam 15369a016d [fix] landing: 히어로 여백 확보 — 순환 링을 강화학습 섹션으로 이동
히어로에 헤드라인·서브·CTA 2개·군집 밴드·순환 링이 다 들어가 세로 예산이 포화라
위를 벌리면 아래가 터지는 상태였다. 링을 옮겨 자리를 만든다.

- 순환 링을 강화학습 섹션으로. "협상할수록 좋아진다"를 말하는 자리라
  루프가 의미상 속하는 곳이기도 하다. 텍스트 좌 / 링 우 2단 배치.
- OrbitRing 에 자체 시계 모드 추가 — progressRef·phase 를 안 주면 스스로 돈다.
  덕분에 히어로 캔버스와 무관한 섹션에서도 단독으로 쓸 수 있다.
- 히어로 상단 여백 14vh → 19vh, 밴드를 하단 전체(0.63/0.30)로 확장.
- 군집 라벨 y 계산 버그 수정. 밴드 지역 좌표(0.5)를 화면 좌표로 착각해
  밴드를 옮길 때마다 라벨이 엉뚱한 곳으로 갔다. 이제 행 수·행간에서
  격자 상단을 실제로 계산해 투영한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:34:42 +09:00

165 lines
5.9 KiB
TypeScript

import { useEffect, useRef, useState, type RefObject } from "react"
import type { DataFlowPhase } from "@/components/ui/data-flow-canvas"
/**
* 파이프라인 순환 링 — 정원.
*
* 앞서 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<number>
className?: string
}) {
const headRef = useRef<SVGCircleElement>(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 (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className={className} role="img" aria-label="협상 파이프라인 순환">
<defs>
<linearGradient id="orbitGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--color-primary-on-stage)" stopOpacity="0.9" />
<stop offset="55%" stopColor="var(--color-on-stage-soft)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-counter)" stopOpacity="0.55" />
</linearGradient>
</defs>
{/* 점선 궤도 — 이 레이어만 돈다 */}
<g className="animate-[spin_22s_linear_infinite] motion-reduce:animate-none" style={{ transformOrigin: `${CX}px ${CY}px` }}>
<circle cx={CX} cy={CY} r={R} fill="none" stroke="url(#orbitGrad)" strokeWidth="0.6" strokeDasharray="2.2 2.4" />
</g>
{/* 바깥 보조 호 — 정지. 원이 하나면 심심하고, 둘이면 공간이 생긴다. */}
<circle
cx={CX}
cy={CY}
r={R + 9}
fill="none"
stroke="var(--color-on-stage-soft)"
strokeOpacity="0.1"
strokeWidth="0.4"
/>
{/* 마디 + 라벨 — 고정 레이어 */}
{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 (
<g key={n.key}>
<circle
cx={p.x}
cy={p.y}
r={active ? 2.6 : 1.4}
fill={active ? "var(--color-primary-on-stage)" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.45}
style={{ transition: "r 240ms ease-out, opacity 240ms ease-out" }}
/>
<text
x={l.x}
y={l.y + 1.6}
textAnchor={anchor}
fontSize="5"
fontWeight={active ? 700 : 500}
fill={active ? "#FFFFFF" : "var(--color-on-stage-soft)"}
opacity={active ? 1 : 0.5}
style={{ transition: "opacity 240ms ease-out" }}
>
{n.label}
</text>
</g>
)
})}
{/* 궤도를 도는 머리 */}
<circle ref={headRef} cx={CX} cy={CY - R} r="2" fill="#FFFFFF" opacity="0.95" />
{/* 가운데 */}
<text x={CX} y={CY - 1} textAnchor="middle" fontSize="7" fontWeight="700" fill="#FFFFFF">
{NODES[activeIndex].label}
</text>
<text
x={CX}
y={CY + 8.5}
textAnchor="middle"
fontSize="4.2"
fontWeight="500"
fill="var(--color-on-stage-soft)"
opacity="0.5"
>
{String(activeIndex + 1).padStart(2, "0")} / {String(NODES.length).padStart(2, "0")}
</text>
</svg>
)
}