[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>
This commit is contained in:
Haewon Kam 2026-07-31 18:34:42 +09:00
parent 5917942fe3
commit 15369a016d
4 changed files with 70 additions and 45 deletions

View File

@ -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() {
</div>
{/* 카피는 상단 55%, 데이터 흐름은 하단 밴드. 둘이 겹치지 않게 층을 나눈다. */}
<div className="relative z-10 max-w-4xl mx-auto px-6 w-full text-center pt-[104px] md:pt-[14vh]">
<div className="relative z-10 max-w-4xl mx-auto px-6 w-full text-center pt-[104px] md:pt-[19vh]">
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 캔버스가 맡는다. */}
@ -72,10 +71,6 @@ export function HeroDataFlow() {
</div>
</div>
{/* 순환 링 — 정원. 점선 궤도만 돌고 마디·글자는 고정이다. */}
<div className="relative z-10 mt-auto flex justify-center pb-6 md:pb-6">
<OrbitRing phase={phase} progressRef={progressRef} className="w-[250px] md:w-[290px] h-auto" />
</div>
</section>
)
}

View File

@ -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 (
<Section id="reinforcement" bg="stage">
<motion.div {...fadeUp} className="mb-20">
<SectionHeading
className="text-center md:text-left"
tone="stage"
eyebrow="Reinforcement Learning"
title="협상할수록, 더 좋은 조건으로"
description={
<>
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
</>
}
descriptionClassName="mt-8 max-w-3xl"
/>
<Section id="reinforcement" bg="stage" width="lg">
{/* 순환 링은 원래 히어로에 있었는데, 루프가 의미상 속하는 자리는 여기다 —
"협상할수록 좋아진다"를 말하는 섹션이고, 히어로는 이미 세로 예산이 포화였다. */}
<motion.div {...fadeUp} className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center mb-20">
<div className="lg:col-span-7">
<SectionHeading
className="text-center lg:text-left"
tone="stage"
eyebrow="Reinforcement Learning"
title="협상할수록, 더 좋은 조건으로"
description={
<>
잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과
협력사가 거절한 지점을 <span className="font-semibold text-on-stage">강화학습</span>에 넣습니다. 어디까지가
받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다.
</>
}
descriptionClassName="mt-8"
/>
</div>
<div className="lg:col-span-5 flex justify-center">
<OrbitRing className="w-[280px] md:w-[340px] h-auto" />
</div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">

View File

@ -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

View File

@ -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<number>
/** 밖에서 단계를 주면 그걸 따르고, 없으면 자체 시계로 돈다. */
phase?: DataFlowPhase
/** 캔버스가 매 프레임 써 넣는 루프 진행도 0..1. 없으면 스스로 센다. */
progressRef?: RefObject<number>
className?: string
}) {
const activeIndex = Math.max(
0,
NODES.findIndex((n) => n.key === phase),
)
const headRef = useRef<SVGCircleElement>(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 (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className={className} role="img" aria-label="협상 파이프라인 순환">