히어로에 헤드라인·서브·CTA 2개·군집 밴드·순환 링이 다 들어가 세로 예산이 포화라 위를 벌리면 아래가 터지는 상태였다. 링을 옮겨 자리를 만든다. - 순환 링을 강화학습 섹션으로. "협상할수록 좋아진다"를 말하는 자리라 루프가 의미상 속하는 곳이기도 하다. 텍스트 좌 / 링 우 2단 배치. - OrbitRing 에 자체 시계 모드 추가 — progressRef·phase 를 안 주면 스스로 돈다. 덕분에 히어로 캔버스와 무관한 섹션에서도 단독으로 쓸 수 있다. - 히어로 상단 여백 14vh → 19vh, 밴드를 하단 전체(0.63/0.30)로 확장. - 군집 라벨 y 계산 버그 수정. 밴드 지역 좌표(0.5)를 화면 좌표로 착각해 밴드를 옮길 때마다 라벨이 엉뚱한 곳으로 갔다. 이제 행 수·행간에서 격자 상단을 실제로 계산해 투영한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|