배경이 설명을 지던 구조를 걷어냈다. 예전 캔버스는 negotium 파이프라인을 직접 그렸지만(입자 240개·6단계 서사·무리 라벨), 이제 배경은 배경으로만 쓴다. 설명은 카피와 아래 섹션이 맡는다. 영상: hero_veo31 0~5초를 크로스페이드 루프로. 그냥 자르면 끝에서 처음으로 튀어서 끝 0.8초를 앞 0.8초와 겹쳐 이음새를 없앴다(결과 4.25초, 첫↔끝 프레임 RMS 18.6/255). 1280x720 h264 631K + 포스터 58K. webm 은 뺐다 — VP9 이 1.0M 으로 h264 보다 커서 둘을 다 실을 이유가 없다. - CLUSTER_ANCHORS 라벨 제거. 좌표 소스가 사라진 라벨은 화면비마다 어긋나기만 하고 아무것도 설명하지 못한다. - DataFlowPhase 를 orbit-ring 으로 이관 후 data-flow-canvas.tsx(500줄) 삭제. 단계 이름을 실제로 쓰는 건 강화학습 섹션의 순환 링뿐이다. - 영상은 가로 화면에서만 튼다. 16:9 를 세로에 object-cover 로 깔면 가로가 62% 잘리고 남은 38% 가 1.7배로 확대된다(848x1220 실측) — 입자가 거대한 보케로 뭉개져 배경이 아니라 노이즈가 된다. 세로에서는 무대 그라데이션만 남긴다. 모바일 대역폭도 아낀다. - prefers-reduced-motion 도 같은 경로로 영상을 감춘다. 포스터가 뒤를 받친다. - 스크림은 색 하나(stage-deep)로 알파만 움직인다. 중간 stop 에서 색을 바꿨더니 그 지점이 가로선으로 읽혔다. - 밴드 개념이 사라져 히어로 레이아웃을 카피 중앙 정렬로 되돌렸다. 직전 커밋의 --band-top 기준 높이 계산은 더 이상 필요 없다. 검증: 1440x860 크롭 7%, 375x812 영상 숨김·제목 헤더 여유 152px, 콘솔 에러 없음. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
6.1 KiB
TypeScript
167 lines
6.1 KiB
TypeScript
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<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>
|
|
)
|
|
}
|