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 (