diff --git a/landing/app/components/sections/hero-dataflow.tsx b/landing/app/components/sections/hero-dataflow.tsx
index 6dd2fe1..a41be0a 100644
--- a/landing/app/components/sections/hero-dataflow.tsx
+++ b/landing/app/components/sections/hero-dataflow.tsx
@@ -4,7 +4,6 @@ import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Typography } from "@/components/ui/typography"
import { CLUSTER_ANCHORS, DataFlowCanvas, type DataFlowPhase } from "@/components/ui/data-flow-canvas"
-import { OrbitRing } from "@/components/ui/orbit-ring"
/**
* 다크 무대 히어로 — 배경 모션이 negotium 파이프라인 자체를 그린다.
@@ -44,7 +43,7 @@ export function HeroDataFlow() {
{/* 카피는 상단 55%, 데이터 흐름은 하단 밴드. 둘이 겹치지 않게 층을 나눈다. */}
-
+
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 캔버스가 맡는다. */}
@@ -72,10 +71,6 @@ export function HeroDataFlow() {
- {/* 순환 링 — 정원. 점선 궤도만 돌고 마디·글자는 고정이다. */}
-
-
-
)
}
diff --git a/landing/app/components/sections/reinforcement.tsx b/landing/app/components/sections/reinforcement.tsx
index 4cc9f77..ad7660b 100644
--- a/landing/app/components/sections/reinforcement.tsx
+++ b/landing/app/components/sections/reinforcement.tsx
@@ -4,6 +4,7 @@ import { GitMerge, RefreshCw, Scale, type LucideIcon } from "lucide-react"
import { Section } from "@/components/ui/section"
import { SectionHeading } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography"
+import { OrbitRing } from "@/components/ui/orbit-ring"
import { useFadeUp } from "@/lib/motion"
/** 강화학습 섹션 — 협상할수록 좋아진다는 3개 필러 카드. */
@@ -11,22 +12,29 @@ export function Reinforcement() {
const fadeUp = useFadeUp()
return (
-
-
-
- 잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
- 협력사가 거절한 지점을 강화학습에 넣습니다. 어디까지가
- 받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
- >
- }
- descriptionClassName="mt-8 max-w-3xl"
- />
+
+ {/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 —
+ "협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */}
+
+
+
+ 잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
+ 협력사가 거절한 지점을 강화학습에 넣습니다. 어디까지가
+ 받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
+ >
+ }
+ descriptionClassName="mt-8"
+ />
+
+
+
+
diff --git a/landing/app/components/ui/data-flow-canvas.tsx b/landing/app/components/ui/data-flow-canvas.tsx
index 603e0c7..6db037f 100644
--- a/landing/app/components/ui/data-flow-canvas.tsx
+++ b/landing/app/components/ui/data-flow-canvas.tsx
@@ -58,8 +58,8 @@ export type DataFlowPhase = (typeof PHASES)[number]["key"]
/* 구도는 화면 하단 밴드에 앉힌다. 헤드라인·CTA 는 그 위에 서고, 데이터는 무대 바닥을
흐르는 그림. 아래 목표 좌표는 전부 밴드 기준 지역 좌표(0..1)로 쓰고 band() 로 옮긴다. */
-const BAND_TOP = 0.575
-const BAND_H = 0.155 /* 위는 카피, 아래는 순환 링(정원) 자리 */
+const BAND_TOP = 0.63
+const BAND_H = 0.30 /* 히어로 하단 전체. 순환 링은 강화학습 섹션으로 옮겼다. */
const band = (localY: number) => BAND_TOP + localY * BAND_H
/* 카메라 — 소실점과 원근 세기. draw() 와 링 좌표 계산이 같은 값을 써야 어긋나지 않는다. */
@@ -81,14 +81,6 @@ const CLUSTER_PLAN = [
{ key: "partner", screenX: 0.81, z: 0.44, label: "협력사" },
] as const
-export const CLUSTER_ANCHORS = CLUSTER_PLAN.map((c) => ({
- ...c,
- /** 투영 전 세계 좌표 — 투영을 거치면 screenX 에 놓인다 */
- x: VANISH_X + (c.screenX - VANISH_X) / perspective(c.z),
- /** 라벨은 격자 위. 투영 후 y 라서 화면 좌표 그대로 쓴다 */
- labelY: VANISH_Y + (0.5 - VANISH_Y) * perspective(c.z),
-}))
-
/* 격자 배치 — 종류별 [열, 열간격(x, 화면비), 행간격(y, 밴드지역비)].
간격은 글리프보다 확실히 커야 한다. 붙으면 표가 아니라 노이즈로 읽힌다. */
const GRID = [
@@ -97,6 +89,24 @@ const GRID = [
{ cols: 10, dx: 0.025, dy: 0.088 }, // 협력사
] as const
+export const CLUSTER_ANCHORS = CLUSTER_PLAN.map((c, i) => {
+ const { cols, dy } = GRID[i]
+ const rows = Math.ceil(COUNTS[i] / cols)
+ /* 라벨은 격자 맨 윗줄 바로 위. 격자 중심은 밴드 지역 0.55 이므로 거기서 행 수만큼
+ 올라간 뒤 band() 로 세계 좌표로 옮기고 투영한다.
+ (지역 좌표를 화면 좌표로 착각하면 밴드를 옮길 때마다 라벨이 엉뚱한 곳으로 간다.) */
+ const topLocal = 0.55 - ((rows - 1) / 2) * dy - dy * 1.6
+ const k = perspective(c.z)
+ return {
+ ...c,
+ /** 투영 전 세계 좌표 — 투영을 거치면 screenX 에 놓인다 */
+ x: VANISH_X + (c.screenX - VANISH_X) / k,
+ /** 투영 후 화면 y */
+ labelY: VANISH_Y + (band(topLocal) - VANISH_Y) * k,
+ }
+})
+
+
/** 결정적 난수 — 새로고침마다 구도가 달라지면 브랜드 자산이 안 된다. */
function makeRng(seed: number) {
let s = seed >>> 0
@@ -369,7 +379,7 @@ export function DataFlowCanvas({
const nz = from[2] + (to[2] - from[2]) * t
// 좁은 화면이면 밴드를 통째로 아래(링 뒤)로 옮긴다. 목표 좌표는 그대로 두고 여기서만 remap.
- if (narrow) ny = 0.6 + ((ny - BAND_TOP) / BAND_H) * 0.22
+ if (narrow) ny = 0.62 + ((ny - BAND_TOP) / BAND_H) * 0.3
const k = perspective(nz)
const sx = VANISH_X + (nx - VANISH_X) * k
diff --git a/landing/app/components/ui/orbit-ring.tsx b/landing/app/components/ui/orbit-ring.tsx
index 6ba742f..2167adf 100644
--- a/landing/app/components/ui/orbit-ring.tsx
+++ b/landing/app/components/ui/orbit-ring.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, type RefObject } from "react"
+import { useEffect, useRef, useState, type RefObject } from "react"
import type { DataFlowPhase } from "@/components/ui/data-flow-canvas"
@@ -37,37 +37,49 @@ const at = (ratio: number, radius: number) => ({
y: CY + Math.sin(rad(ratio)) * radius,
})
+const LOOP_MS = 10_500 // 히어로 캔버스와 같은 주기
+
export function OrbitRing({
phase,
progressRef,
className = "",
}: {
- phase: DataFlowPhase
- /** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1 */
- progressRef: RefObject
+ /** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */
+ phase?: DataFlowPhase
+ /** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */
+ progressRef?: RefObject
className?: string
}) {
- const activeIndex = Math.max(
- 0,
- NODES.findIndex((n) => n.key === phase),
- )
const headRef = useRef(null)
+ const [ownIndex, setOwnIndex] = useState(0)
- // 궤도를 도는 머리는 rAF 로 직접 갱신한다. 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다.
+ const activeIndex = phase ? Math.max(0, NODES.findIndex((n) => n.key === phase)) : ownIndex
+
+ /* 궤도를 도는 머리는 rAF 로 직접 갱신한다 — 60fps 로 setState 를 부르면 페이지가 매 프레임 리렌더된다.
+ 단계 라벨만 바뀔 때(초당 0.5회) 리렌더한다. */
useEffect(() => {
let raf = 0
- const tick = () => {
- const p = progressRef.current ?? 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])
+ }, [progressRef, phase])
return (