diff --git a/landing/app/app.css b/landing/app/app.css index 55530ca..3c183e1 100644 --- a/landing/app/app.css +++ b/landing/app/app.css @@ -10,38 +10,101 @@ src: url('/fonts/PretendardVariable.woff2') format('woff2'); } +/* 영문 디스플레이 서체 — 이탤릭 가변(400~900), latin 서브셋. + INFINITH 디자인 시스템과 같은 Playfair Display 를 쓴다. */ +@font-face { + font-family: 'Playfair Display'; + font-style: italic; + font-weight: 400 900; + font-display: swap; + src: url('/fonts/PlayfairDisplay-Italic.woff2') format('woff2'); +} + /* - * 랜딩 디자인 토큰 (Toss 스타일 라이트 팔레트). + * 랜딩 디자인 토큰. * 색은 전부 여기서만 정의하고, 컴포넌트는 토큰 클래스(text-ink, bg-primary …)만 쓴다. + * + * 구조는 두 겹이다. + * - 다크 무대(stage) : 히어로·협상 데모. 협상이 벌어지는 순간에만 조명을 끈다. + * - 라이트 본문 : 나머지 섹션. 광고 유입 전환율을 위해 밝게 유지. */ @theme { --font-sans: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + /* 영문 디스플레이 — 라벨·강조 전용. 한글 본문은 절대 이걸로 쓰지 않는다 + (Playfair 는 한글 글리프가 없어 Pretendard 로 폴백되며 톤이 깨진다). */ + --font-display: 'Playfair Display', 'Pretendard Variable', Georgia, serif; - /* 브랜드 */ - --color-primary: #476EFF; - --color-primary-deep: #2F52E0; /* hover */ - --color-primary-soft: #ECF0FF; /* 연한 파랑 배경 — 봇 말풍선, 배지 */ + /* ── 브랜드 액센트 ────────────────────────────────────────────── + 여기 3줄이 브랜드 색상각의 단일 소스다. 딥틸(#005961 / #00434A / #E3F4F5)로 + 되돌리려면 이 3줄만 갈아끼우면 되고, 나머지 토큰·컴포넌트는 손대지 않는다. */ + --color-primary: #0101F3; /* 일렉트릭 블루 — 라이트 배경 전용. 레퍼런스 실측값 */ + /* 다크 무대용 액센트. #0101F3 을 #070E24 위에 얹으면 명암비가 2.0:1 로, + WCAG 최소(4.5:1)의 절반도 안 된다 — 둘 다 어두운 색이라 그렇다. + 아래 값은 무대 배경 대비 약 6:1 이다. 무대 위 파란 글자는 반드시 이걸 쓴다. */ + --color-primary-on-stage: #7C8CFF; + --color-primary-deep: #0101C4; /* hover */ + --color-primary-soft: #EAEAFF; /* 연한 배경 — 에이전트 말풍선, 배지 */ - /* 텍스트. ink 는 다크 섹션에선 배경색으로도 쓴다 */ - --color-ink: #191F28; - --color-ink-soft: #4E5968; /* 보조 본문 */ - --color-ink-muted: #8B95A1; /* 캡션 */ - --color-ink-faint: #B0B8C1; /* 플레이스홀더·각주 */ + /* ── 상대편(공급사) ──────────────────────────────────────────── + 협상은 두 주체가 매 턴 구분돼야 읽힌다. 민트는 블루와 색상각 70° 차이라 + 구분되면서도 같은 한색 계열이라 화면이 따로 놀지 않는다. + NEGO WIZ 로고 민트(#0FFFD6)에서 왔다. */ + --color-counter: #0FFFD6; /* 무대 위 상대측 텍스트·강조 */ + --color-counter-deep: #077A66; /* 라이트 배경 위 텍스트 (흰 바탕 대비 확보용) */ + --color-counter-surface: #0A2F35; /* 무대 위 상대측 말풍선 바닥 */ + + /* ── 다크 무대 ────────────────────────────────────────────────── */ + --color-stage: #070E24; /* 무대 바닥 (그라데이션의 중간값) */ + --color-stage-deep: #03060F; /* 무대 가장자리 — 어두워지며 공간이 뒤로 물러난다 */ + --color-stage-lift: #16205A; /* 무대 상단 광원 — 여기서 빛이 온다 */ + --color-stage-raised: #101A3D; /* 무대 위 카드·상대측 말풍선 */ + --color-stage-line: #1E2A52; /* 무대 위 테두리 */ + --color-on-stage: #FFFFFF; /* 무대 위 1차 텍스트 */ + /* 2·3차 텍스트는 중립 회색 대신 연보라 계열로 둔다. 네이비 위에 색상각이 다른 + 회색을 얹으면 탁해지는데, 같은 한색 가족이면 밝게 올려도 화면이 정돈된다. + #F5F3FF 는 ADO2 디자인 시스템의 연보라 패널 색이다. */ + --color-on-stage-soft: #F5F3FF; /* 무대 위 2차 텍스트 — 2톤 헤드라인의 약한 쪽 */ + --color-on-stage-muted: #C6C0E4; /* 무대 위 라벨·캡션 */ + + /* ── 라이트 본문 텍스트 ──────────────────────────────────────── + 먹색에 브랜드 색조를 섞어둔다. 순수 회색이면 페이지가 따로 논다. */ + --color-ink: #0B1024; + --color-ink-soft: #454E6B; /* 보조 본문 */ + --color-ink-muted: #838CAA; /* 캡션 */ + --color-ink-faint: #AAB1C7; /* 플레이스홀더·각주 */ /* 면·선 */ - --color-surface: #F9FAFB; /* 회색 섹션 배경 */ - --color-surface-neu: #F2F4F7; /* 뉴모피즘 히어로 전용 배경 */ - --color-fill: #F2F4F6; /* 회색 채움 — 탭 트랙, 보조 버튼 */ - --color-fill-hover: #EAECEF; - --color-line: #F2F4F6; /* 섹션 구분선 */ - --color-line-strong: #E5E8EB; /* 카드 테두리 */ + --color-surface: #F7F8FC; /* 회색 섹션 배경 */ + --color-surface-neu: #F1F3F9; /* 보조 섹션 배경 */ + --color-fill: #F0F2F8; /* 회색 채움 — 탭 트랙, 보조 버튼 */ + --color-fill-hover: #E6E9F2; + --color-line: #EEF0F6; /* 섹션 구분선 */ + --color-line-strong: #E0E4EE; /* 카드 테두리 */ --color-positive: #0B8F57; /* 낙찰·성공 강조 */ --color-positive-soft: #E8F7EF; /* 연한 초록 배경 — 낙찰 배지 */ --color-accent: #8F52FF; /* 보라 강조 — 결과 탭·히어로 장식 */ --color-accent-soft: #F4EFFF; /* 연한 보라 배경 */ + + /* ── 모서리 ──────────────────────────────────────────────────── + 레퍼런스 실측은 3.5~7px 이다. 20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 + 가장 강한 신호라, 여기서는 세 값으로만 고정한다. + card — 카드·패널·목업 + control — 탭·배지·입력 등 작은 요소 + full — 버튼·아바타 (Tailwind 기본 rounded-full 사용) */ + --radius-card: 8px; + --radius-control: 4px; +} + +/* 다크 무대 배경 그라데이션. + 단색 배경은 아무리 입자에 원근을 줘도 평면으로 읽힌다. 위쪽에 광원을 두고 + 가장자리로 갈수록 어두워지게 하면 화면 자체에 안팎이 생겨 깊이가 성립한다. */ +.stage-bg { + background: + radial-gradient(120% 78% at 50% 8%, var(--color-stage-lift) 0%, transparent 62%), + radial-gradient(100% 100% at 50% 42%, var(--color-stage) 0%, var(--color-stage-deep) 100%); } html { diff --git a/landing/app/components/sections/comparison.tsx b/landing/app/components/sections/comparison.tsx index af6ea76..0ca929e 100644 --- a/landing/app/components/sections/comparison.tsx +++ b/landing/app/components/sections/comparison.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react" import { AnimatePresence, motion } from "motion/react" -import { Bot, Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" +import { Clock, Database, Handshake, Scale, TrendingUp, UserRound, type LucideIcon } from "lucide-react" import { Section } from "@/components/ui/section" import { SectionHeading } from "@/components/ui/section-heading" @@ -47,7 +47,7 @@ export function Comparison() { -
+ {/* 액티브를 bg-white 로 두면 트랙(bg-fill #F0F2F8) 과 거의 같은 밝기라 + 멀리서 어느 쪽이 켜졌는지 안 보인다. 채움색으로 확실히 갈라준다. */} +
{MODES.map((m) => ( ))} @@ -97,13 +98,13 @@ function CompareCard({ item, mode, index }: { item: CompareItem; mode: Mode; ind return (
@@ -156,14 +157,14 @@ const ITEMS: CompareItem[] = [ { label: "사람", icon: UserRound, - before: "반복 흥정과 감정 노동에 지쳐 고급 인력이 이탈합니다.", - after: "감정 소모는 봇이 맡고, 사람은 전략 업무와 대형 거래에 집중합니다.", + before: "반복 협상에 시간을 뺏겨 전략 업무가 계속 밀립니다.", + after: "반복 협상은 에이전트가 맡고, 담당자는 전략 구매와 대형 건에 집중합니다.", }, { label: "파트너 관계", icon: Handshake, - before: "감정 과열과 실랑이로 장기 파트너십이 흔들립니다.", - after: "악역은 봇이 — 압박 없는 상시 협상으로 관계가 개선됩니다.", + before: "담당자마다 기준이 달라 협력사가 조건을 예측하기 어렵습니다.", + after: "에이전트가 같은 기준으로 상시 응대해 조건이 일관됩니다.", }, { label: "의사 결정", diff --git a/landing/app/components/sections/contact.tsx b/landing/app/components/sections/contact.tsx index b7be376..cebc440 100644 --- a/landing/app/components/sections/contact.tsx +++ b/landing/app/components/sections/contact.tsx @@ -1,6 +1,6 @@ import { useState } from "react" import { AnimatePresence, motion } from "motion/react" -import { Building, CheckCircle2, Loader2, Mail, Phone, Send } from "lucide-react" +import { Check, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Input, Textarea } from "@/components/ui/input" @@ -8,7 +8,6 @@ import { Section } from "@/components/ui/section" import { SectionHeading } from "@/components/ui/section-heading" import { Typography } from "@/components/ui/typography" import { EASE_OUT_EXPO } from "@/lib/motion" -import type { LucideIcon } from "lucide-react" const EMPTY_FORM = { companyName: '', @@ -41,22 +40,13 @@ export function Contact() { const submitting = status === 'submitting' return ( -
-
-
- - } - > +
@@ -70,10 +60,10 @@ export function Contact() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.5, ease: EASE_OUT_EXPO }} - className="space-y-6" + className="space-y-9" > -
- +
+
-
- +
+ - + - * 제출 시 입력하신 정보는 문의 사항 응답 및 도입 검토 목적에 한하여 사용되며, 관련 법령에 의거하여 안전하게 - 보호됩니다. + 입력하신 정보는 문의 응답과 도입 검토 목적으로만 사용됩니다.
-
@@ -162,15 +148,14 @@ export function Contact() { initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.6, ease: EASE_OUT_EXPO }} - className="text-center py-16 px-6 bg-primary/5 rounded-[32px] text-ink break-keep" + className="text-center py-20 px-6 border border-line-strong rounded-card text-ink break-keep" > -
- +
+
-

도입 문의 신청이 접수되었습니다

-

- 작성해주신 담당자 정보({formData.contactName} 님)를 통해 24시간 이내에 전담 혁신 컨설턴트가 유선 혹은 이메일로 - 연락을 드리겠습니다. +

도입 문의 신청이 접수되었습니다

+

+ {formData.contactName} 님께 영업일 기준 하루 안에 연락드리겠습니다.

+
+ +
diff --git a/landing/app/components/sections/footer.tsx b/landing/app/components/sections/footer.tsx index 4125419..e5b8de6 100644 --- a/landing/app/components/sections/footer.tsx +++ b/landing/app/components/sections/footer.tsx @@ -8,7 +8,7 @@ export function Footer() {
- negotium은 협상 봇이 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다. + negotium은 협상 에이전트가 공급사와 1:1로 단가를 조율하고 낙찰까지 처리하는 B2B 구매 협상 자동화 솔루션입니다.

© {new Date().getFullYear()} negotium Co., Ltd. All rights reserved. diff --git a/landing/app/components/sections/header.tsx b/landing/app/components/sections/header.tsx index 833101f..9a723a7 100644 --- a/landing/app/components/sections/header.tsx +++ b/landing/app/components/sections/header.tsx @@ -5,36 +5,78 @@ import { Button } from "@/components/ui/button" import { Logo } from "@/components/ui/logo" export function Header() { - const [scrolled, setScrolled] = useState(false) + // 두 상태는 조건이 다르다. 하나로 묶으면 다크 히어로 위에 흰 바가 떠버린다. + // compact — 조금이라도 스크롤하면 높이를 줄인다. + // onStage — 다크 히어로를 벗어나기 전까지. 이때는 배경을 깔지 않고 로고를 반전시킨다. + const [compact, setCompact] = useState(false) + const [onStage, setOnStage] = useState(true) - // 스크롤 시 헤더를 반투명 블러 배경으로 전환 useEffect(() => { - const handleScroll = () => setScrolled(window.scrollY > 40) + const handleScroll = () => setCompact(window.scrollY > 40) window.addEventListener("scroll", handleScroll, { passive: true }) handleScroll() return () => window.removeEventListener("scroll", handleScroll) }, []) + /* "다크 히어로 위에 있는가"는 스크롤 수치가 아니라 교차 상태다. + scrollY 로 계산하면 하이드레이션 전에 스크롤 위치가 잡힌 경우(ScrollRestoration, + 새로고침, 뒤로가기) 초기 계산이 0 에서 끝나고 이후 스크롤 이벤트가 없으면 + 영영 갱신되지 않는다 — 흰 배경 위에 흰 글자 버튼이 남는다. + IntersectionObserver 는 관찰 시작 즉시 올바른 값을 준다. */ + useEffect(() => { + const stage = document.querySelector("[data-stage-hero]") + if (!stage) { + setOnStage(false) // 다크 히어로가 없는 페이지에서는 항상 라이트 + return + } + // 헤더 높이(약 80px)만큼 위에서 미리 전환되도록 상단 마진을 음수로 준다. + const io = new IntersectionObserver(([entry]) => setOnStage(entry.isIntersecting), { + rootMargin: "-80px 0px 0px 0px", + threshold: 0, + }) + io.observe(stage) + return () => io.disconnect() + }, []) + return (

- + -
+ ) +} + +function RoleCard({ + title, + lead, + detail, + onClick, +}: { + title: string + lead: string + detail: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/landing/app/components/sections/reinforcement.tsx b/landing/app/components/sections/reinforcement.tsx index ec13d4b..4cc9f77 100644 --- a/landing/app/components/sections/reinforcement.tsx +++ b/landing/app/components/sections/reinforcement.tsx @@ -11,18 +11,18 @@ export function Reinforcement() { const fadeUp = useFadeUp() return ( -
+
- 잘 깎는 노하우는 담당자 머릿속에만 남고, 성과는 그날의 감정과 컨디션에 흔들립니다. negotium은 진행된 모든 거래 - 데이터와 파트너 거절 피드백을 강화학습 기술에 투입합니다. - 무턱대고 가격을 후려쳐 상대방의 반발만 사고 결렬로 치닫는 경직된 협상이 아니라, 파트너사가 충분히 받아들일 수 - 있는 범위 내에서 최선의 마진율을 뽑아내도록 조율 페이스를 학습합니다. + 잘 깎는 요령은 담당자 머릿속에만 남고, 결과는 그날 컨디션에 흔들립니다. negotium은 지난 거래 기록과 + 협력사가 거절한 지점을 강화학습에 넣습니다. 어디까지가 + 받아들여지는 선인지를 데이터로 익혀서, 결렬 없이 도달 가능한 최선을 찾아갑니다. } descriptionClassName="mt-8 max-w-3xl" @@ -44,15 +44,15 @@ function PillarCard({ pillar, delay }: { pillar: Pillar; delay: number }) { const fadeUp = useFadeUp(delay) return ( - -
- + +
+
- + {pillar.title} - + {pillar.description}
@@ -68,12 +68,12 @@ const PILLARS: Pillar[] = [ }, { icon: RefreshCw, - title: "지속적인 전략 최적화", - description: "전략 강화 학습을 통해 기준선이 상승하고, 점진적으로 개선됩니다. 시간이 곧 협상력이 됩니다.", + title: "쓸수록 올라가는 기준선", + description: "협상이 쌓일수록 기준선이 올라갑니다. 운영 기간이 그대로 협상력이 됩니다.", }, { icon: GitMerge, - title: "낙찰 성사율 극대화", - description: "무리하게 후려쳐 관계를 깨는 대신, 성사되는 선에서 최대한 끌어냅니다.", + title: "결렬 없는 합의", + description: "성사되지 않을 선까지 밀지 않습니다. 합의 가능한 범위 안에서 최선을 찾습니다.", }, ] diff --git a/landing/app/components/sections/roi-simulator.tsx b/landing/app/components/sections/roi-simulator.tsx index 021b8d5..99ccb32 100644 --- a/landing/app/components/sections/roi-simulator.tsx +++ b/landing/app/components/sections/roi-simulator.tsx @@ -25,7 +25,7 @@ export function ROISimulator() { negotium 도입 시
@@ -65,7 +65,7 @@ export function ROISimulator() { {suppliers}개사
- AI 봇 "밀당"이 동시에 일대일로 자동 조율할 잠재 공급사 수량입니다. + 에이전트가 동시에 1:1로 조율할 협력사 수입니다.
@@ -76,11 +76,11 @@ export function ROISimulator() {
-
+
- 본 시뮬레이션은 글로벌 최저가 스캔(LPS) 시장 표준 및 다수 B2B 자율 협상 데이터의 보수적인 평균 수치(3.1% ~ 4.2% - 절감)를 적용하여 산정되었습니다. 실사 수준의 검증은 파일 연동을 통해 무상 지원됩니다. + 최저가 스캔(LPS) 시세와 B2B 협상 데이터의 보수적 평균(3.1~4.2% 절감)을 적용한 추정치입니다. + 실제 데이터로 검증해 보시려면 파일만 주시면 됩니다.
@@ -96,8 +96,7 @@ export function ROISimulator() { {formatSavings(estimatedSavingsValue)}
- 사각지대에 방치되던 꼬리 거래(Tail Spend)의 상시 병렬 협상 및 인터넷 실시간 앵커가 시세 연동을 통해, 기존 수동 - 흥정으로 놓치고 있던 원가 이탈을 완벽히 방어해냅니다. + 그동안 손이 못 가던 꼬리 거래(Tail Spend)까지 동시에 협상합니다. 수동으로는 놓치던 구간입니다.
@@ -109,7 +108,7 @@ export function ROISimulator() {
- 메일 전송, 유선 흥정, 엑셀 취합 등에 낭비되던 소모적인 절차가 실시간 링크 전송과 AI 봇의 24시간 자율 대응으로 + 메일 전송, 유선 협의, 엑셀 취합에 쓰이던 시간이 링크 한 번과 에이전트 응대로 완전히 생략되어 고부가가치 전략 구매 기획에 전념할 수 있습니다.
diff --git a/landing/app/components/ui/button.tsx b/landing/app/components/ui/button.tsx index 887693d..b0b14d6 100644 --- a/landing/app/components/ui/button.tsx +++ b/landing/app/components/ui/button.tsx @@ -6,7 +6,9 @@ import { cn } from "@/lib/utils" // CTA 버튼 토큰. 랜딩의 CTA 는 전부 앵커 스크롤이라 href 를 주면 로 렌더한다 // (SSG 특성상 하이드레이션 전에도 동작). const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 transition-all cursor-pointer disabled:opacity-50", + /* transition-colors 로 좁힌다. transition-all 이면 헤더 CTA 가 무대↔라이트로 variant 를 + 통째로 바꿀 때 배경이 투명에서 차오르며 플리커로 읽힌다. */ + "inline-flex items-center justify-center gap-2 transition-colors duration-200 cursor-pointer disabled:opacity-50", { variants: { variant: { @@ -14,13 +16,21 @@ const buttonVariants = cva( secondary: "bg-fill hover:bg-fill-hover text-ink-soft hover:text-ink", // 밝은 배경 위 반투명 보조 버튼 (글라스 히어로) glass: "bg-white/70 hover:bg-white text-ink-soft hover:text-ink border border-white shadow-sm", + // 다크 무대 위 1차 CTA + stage: "bg-primary hover:bg-primary-deep text-white", + // 다크 무대 위 2차 CTA — 흰 알약 + stageGhost: "bg-transparent hover:bg-white text-white hover:text-stage border border-stage-line hover:border-white", }, - // radius 는 사이즈 무관 rounded-2xl(16px) 로 통일 — 알약형 금지 + /* 레퍼런스(statworx) 실측: 10.4px / 600 / 알약(radius 39px) / 패딩 12·24 / 높이 33px. + 작고 조밀한 버튼이 확신 있어 보인다 — 크고 굵은 버튼은 설득이 아니라 호소로 읽힌다. + 한글 보정: 10.4px 는 판독이 어려워 12~13px 로 올리고 높이도 터치 타깃까지 확보한다. + (기존 주석의 "알약형 금지" 규칙은 레퍼런스 채택으로 폐기했다.) */ size: { - pill: "px-4.5 py-2.5 text-sm font-semibold rounded-2xl", - md: "px-6 py-2.5 text-xs font-bold rounded-2xl", - lg: "px-8 py-4.5 text-base font-bold rounded-2xl", - xl: "px-10 py-5 text-base font-bold rounded-2xl", + pill: "px-5 py-2.5 text-[12px] font-semibold rounded-full", + md: "px-6 py-3 text-[12px] font-semibold rounded-full", + lg: "px-7 py-3.5 text-[13px] font-semibold rounded-full", + xl: "px-8 py-4 text-[14px] font-semibold rounded-full", + stageRound: "px-7 py-3.5 text-[13px] font-semibold rounded-full", }, }, defaultVariants: { variant: "primary", size: "lg" }, diff --git a/landing/app/components/ui/data-flow-canvas.tsx b/landing/app/components/ui/data-flow-canvas.tsx new file mode 100644 index 0000000..3dbdeda --- /dev/null +++ b/landing/app/components/ui/data-flow-canvas.tsx @@ -0,0 +1,466 @@ +import { useEffect, useRef } from "react" + +/** + * 히어로 배경 데이터 플로우 모션. + * + * negotium 의 실제 파이프라인을 그린다 — 흩어져 있던 거래 품목·사용자·협력사가 + * 분류되고, 시장 최저가·협상 로그와 합류해 협상 카드로 응축되고, 가격 궤적을 그리며 + * 협상이 진행되고, 그 결과가 다시 학습으로 되돌아가 처음으로 돌아간다. + * + * 장식이 아니라 설명이다. 그래서 열린 웨이브가 아니라 닫힌 루프고, + * 앞 두 단계에서는 점이 아니라 실제 글리프(표 행·사람·건물)로 그린다 — + * "무엇이" 분류되는지가 안 보이면 의미가 전달되지 않는다. + * 엔진에 들어가는 순간부터는 글리프가 점으로 추상화된다. + * + * 성능 메모 + * - 글리프는 오프스크린 캔버스 스프라이트를 drawImage 로 블리팅한다. 매 프레임 + * path 를 그리면 디테일 하나 늘 때마다 비용이 붙는다. + * - 점 구간은 색상군마다 Path2D 하나로 묶어 fill 호출을 9회로 제한한다. + * - 화면 밖(IntersectionObserver)·탭 비활성(visibilitychange)이면 rAF 를 멈춘다. + */ + +/** 개체 종류 — 글리프와 색이 곧 데이터 출처다. */ +const T_ITEM = 0 // 거래 품목 — 표 행 글리프, 일렉트릭 블루 (우리 쪽 데이터) +const T_USER = 1 // 사용자 — 사람 글리프, 밝은 청백 +const T_PARTNER = 2 // 협력사 — 건물 글리프, 민트 (상대편 색) +const T_MARKET = 3 // 시장 최저가·로그 — 글리프 없는 점, 저채도 + +/* app.css 의 토큰과 같은 값이어야 한다. 캔버스는 CSS 변수를 못 읽으니 여기서 맞춘다. + --color-primary #0101F3 · 사용자 청백 · --color-counter #0FFFD6 · --color-on-stage-muted */ +const TYPE_RGB = ["1,1,243", "196,206,240", "15,255,214", "106,118,168"] as const +const TYPE_ALPHA = [0.85, 0.8, 0.8, 0.4] as const + +/** 종류별 개수. 합이 전체 입자 수. 분류 단계의 격자 크기와 맞물린다. */ +const COUNTS = [72, 48, 60, 60] as const +const PARTICLE_COUNT = COUNTS.reduce((a, b) => a + b, 0) + +const LOOP_MS = 10_500 + +/** 루프 구간. [시작비율] — 오름차순, 0 으로 시작. 여기가 제품 서사의 강약이다. */ +const PHASES = [ + { key: "scatter", at: 0.0 }, + { key: "cluster", at: 0.15 }, + { key: "stream", at: 0.34 }, + { key: "card", at: 0.52 }, + { key: "negotiate", at: 0.67 }, + { key: "learn", at: 0.86 }, +] as const + +/** 단계별 글리프 표현 강도. 1 = 아이콘, 0 = 점. 엔진에 들어가면서 추상화된다. */ +const PHASE_DETAIL = [1, 1, 0.3, 0, 0, 0] as const + +/* 단계별 격자 정렬 강도. 1 이면 표류를 끈다. + 표는 정렬이 곧 의미다 — 8px 표류가 9px 행 간격을 넘으면 표가 노이즈가 된다. + 카드 응축도 형태가 또렷해야 해서 절반쯤 잡아준다. */ +const PHASE_ORDER = [0, 1, 0, 0.6, 0, 0] as const + +export type DataFlowPhase = (typeof PHASES)[number]["key"] + +/* 구도는 화면 하단 밴드에 앉힌다. 헤드라인·CTA 는 그 위에 서고, 데이터는 무대 바닥을 + 흐르는 그림. 아래 목표 좌표는 전부 밴드 기준 지역 좌표(0..1)로 쓰고 band() 로 옮긴다. */ +const BAND_TOP = 0.50 +const BAND_H = 0.20 /* 아래쪽은 순환 링(정원) 자리로 비워둔다 */ +const band = (localY: number) => BAND_TOP + localY * BAND_H + +/* 카메라 — 소실점과 원근 세기. draw() 와 링 좌표 계산이 같은 값을 써야 어긋나지 않는다. */ +export const VANISH_X = 0.5 +export const VANISH_Y = BAND_TOP + BAND_H * 0.42 +/** 깊이 → 원근 배율. 1 이면 카메라 평면, 작을수록 소실점으로 모인다. */ +export const perspective = (z: number) => 0.42 + 0.58 * z + +/* 분류 단계에서 각 무리가 앉는 자리와 깊이. + 무리마다 z 평면을 하나씩 준다 — 입자마다 z 를 따로 주면 격자가 3D 로 뒤틀려 + 표로 안 읽힌다. 무리 단위로 주면 격자는 온전한 채 그룹 사이에만 깊이가 생긴다. */ +export const CLUSTER_ANCHORS = [ + { key: "item", x: 0.17, z: 0.9, labelY: 0.525, label: "거래 품목" }, + { key: "user", x: 0.45, z: 0.66, labelY: 0.525, label: "사용자" }, + { key: "partner", x: 0.76, z: 0.44, labelY: 0.525, label: "협력사" }, +] as const + +/* 격자 배치 — 종류별 [열, 열간격(x, 화면비), 행간격(y, 밴드지역비)]. + 간격은 글리프보다 확실히 커야 한다. 붙으면 표가 아니라 노이즈로 읽힌다. */ +const GRID = [ + { cols: 9, dx: 0.0205, dy: 0.055 }, // 품목: 9열 × 8행 시트 + { cols: 8, dx: 0.023, dy: 0.088 }, // 사용자 + { cols: 10, dx: 0.025, dy: 0.088 }, // 협력사 +] as const + +/** 결정적 난수 — 새로고침마다 구도가 달라지면 브랜드 자산이 안 된다. */ +function makeRng(seed: number) { + let s = seed >>> 0 + return () => { + s = (s * 1664525 + 1013904223) >>> 0 + return s / 4294967296 + } +} + +const smoothstep = (t: number) => t * t * (3 - 2 * t) + +/* 전환 이징. smoothstep 은 시작·끝이 모두 느려 "흐물흐물" 하게 읽힌다. + expo-out 은 첫 프레임부터 크게 움직이고 길게 안착해서 시원한 인상을 준다 + — 모션 툴 계열 사이트가 공통으로 쓰는 감각이다. */ +const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -11 * t)) + +/* ── 글리프 스프라이트 ───────────────────────────────────────────────────── + CSS 픽셀 기준 크기. 실제 캔버스는 SS 배로 그려서 축소 블리팅한다(선명도). */ +const SPRITE_SS = 3 +const GLYPH_SIZE = [ + [22, 7], // 품목 — 가로 셀 + [11, 11], // 사용자 — 원형 노드 + [10, 17], // 협력사 — 세로 기둥. 행 간격(약 23px)보다 작아야 개체로 읽힌다 +] as const + +function makeSprite(type: number, rgb: string): HTMLCanvasElement { + const [w, h] = GLYPH_SIZE[type] + const c = document.createElement("canvas") + c.width = w * SPRITE_SS + c.height = h * SPRITE_SS + const g = c.getContext("2d")! + g.scale(SPRITE_SS, SPRITE_SS) + g.fillStyle = `rgb(${rgb})` + + /* 형태는 구조만 말하고, 이름은 라벨이 맡는다. + 창문 뚫린 건물·머리+어깨 사람 같은 문자 그대로의 픽토그램은 라벨과 의미가 중복되고 + 클립아트 어휘라 값싸 보인다. 가로 셀 / 원형 노드 / 세로 기둥 세 형태만으로도 + 세 무리는 충분히 구분되고, 훨씬 정돈돼 보인다. */ + if (type === T_ITEM) { + g.fillRect(0, 0, w, h) + } else if (type === T_USER) { + g.beginPath() + g.arc(w / 2, h / 2, w / 2, 0, Math.PI * 2) + g.fill() + } else { + g.fillRect(0, 0, w, h) + } + return c +} + +type Particle = { + type: number + /** 단계별 목표 좌표 [x, y, z] (0..1 정규화). z 는 1 이 카메라 쪽, 0 이 안쪽. */ + targets: [number, number, number][] + /** 글리프 비율 변주 — 품목은 너비, 협력사는 높이가 개체마다 다르다(데이터처럼 보이게) */ + ratio: number + /** 점으로 그려질 때의 반지름 */ + dot: number + /** 글리프 밝기 배수 — 표의 머리행을 밝혀 "표"로 읽히게 한다 */ + emphasis: number + /** 개체별 표류 위상 — 정지 구간에서도 죽어 보이지 않게 */ + drift: number + speed: number +} + +function buildParticles(): Particle[] { + const rnd = makeRng(20260731) + const blob = () => (rnd() + rnd() + rnd()) / 3 - 0.5 + const out: Particle[] = [] + + // 종류를 인덱스 구간으로 고정 배정해 개수를 정확히 맞춘다(격자가 딱 떨어져야 표로 읽힌다). + const types: number[] = [] + COUNTS.forEach((n, type) => { + for (let i = 0; i < n; i++) types.push(type) + }) + + const seen = [0, 0, 0, 0] + + for (let i = 0; i < PARTICLE_COUNT; i++) { + const type = types[i] + const nth = seen[type]++ + const t = i / PARTICLE_COUNT + + // 1) 산개 — 밴드 전역에 표류. + // 깊이 있는 field — 개체마다 z 가 흩어져 원근이 크게 벌어진다. + const scatter: [number, number, number] = [rnd(), band(rnd()), 0.12 + rnd() * 0.88] + + // 2) 분류 — 종류별 격자. 시장가(T_MARKET)는 아직 합류 전이라 흩어진 채 남는다. + let cluster: [number, number, number] + let emphasis = 1 + if (type === T_MARKET) { + cluster = [rnd(), band(rnd()), 0.1 + rnd() * 0.3] // 시장가는 아직 뒤에 머문다 + } else { + const { cols, dx, dy } = GRID[type] + const rows = Math.ceil(COUNTS[type] / cols) + const col = nth % cols + const row = Math.floor(nth / cols) + cluster = [ + CLUSTER_ANCHORS[type].x + (col - (cols - 1) / 2) * dx, + band(0.55 + (row - (rows - 1) / 2) * dy), + CLUSTER_ANCHORS[type].z, // 무리 단위 z — 격자는 온전하고 그룹 사이에만 깊이가 생긴다 + ] + // 시트 머리행은 밝게 — 균일한 격자에 위계가 하나 생기면 "표"로 확정된다. + if (type === T_ITEM && row === 0) emphasis = 1.5 + } + + // 3) 합류 — 하나의 흐름으로. 시장가와 협력사는 위아래에서 합류해 들어온다. + const sx = 0.16 + t * 0.34 + const converge = 1 - (sx - 0.16) / 0.34 // 오른쪽으로 갈수록 좁아진다 + const lane = type === T_MARKET ? -0.34 : type === T_PARTNER ? 0.3 : 0 + // 합류하면서 카메라 쪽으로 딸려 나온다 — 흐름에 전진감이 생긴다. + const stream: [number, number, number] = [sx, band(0.5 + (lane + blob() * 0.5) * converge), 0.3 + (1 - converge) * 0.6] + + // 4) 응축 — 협상 카드 한 장. 테두리에 더 많이 붙여 카드 형태를 읽히게 한다. + const cw = 0.055 + const ch = 0.19 + let card: [number, number, number] + if (rnd() < 0.62) { + const p = rnd() * 4 + const u = rnd() - 0.5 + if (p < 1) card = [0.5 + u * cw * 2, 0.5 - ch, 0.92] + else if (p < 2) card = [0.5 + u * cw * 2, 0.5 + ch, 0.92] + else if (p < 3) card = [0.5 - cw, 0.5 + u * ch * 2, 0.92] + else card = [0.5 + cw, 0.5 + u * ch * 2, 0.92] + } else { + card = [0.5 + (rnd() - 0.5) * cw * 1.8, 0.5 + (rnd() - 0.5) * ch * 1.8, 0.88] + } + card[1] = band(card[1]) + + // 5) 협상 — 계단형 하강 궤적. 6턴으로 끊어 실제 카드 소진을 흉내낸다. + const turn = Math.floor(t * 6) + // 턴이 진행될수록 뒤로 물러난다 — 시간이 흐른 느낌. + const negotiate: [number, number, number] = [0.2 + t * 0.66, band(0.12 + turn * 0.145 + blob() * 0.1), 0.95 - turn * 0.1] + + // 6) 회귀 — 크게 감아 좌측으로 되돌아간다. 루프임을 눈으로 알리는 구간. + const a = Math.PI * (0.06 + t * 0.88) + // 깊은 곳에서 돌아 나와 다시 앞으로 — 루프가 공간을 한 바퀴 돈다. + const learn: [number, number, number] = [0.5 + Math.cos(a) * 0.47, band(0.72 - Math.sin(a) * 0.7), 0.2 + t * 0.7] + + out.push({ + type, + targets: [scatter, cluster, stream, card, negotiate, learn], + emphasis, + ratio: 0.62 + rnd() * 0.5, // 품목 너비·협력사 높이 변주 — 균일하면 데이터가 아니라 무늬가 된다 + dot: type === T_MARKET ? 0.9 + rnd() * 0.6 : 1.2 + rnd() * 1.0, + drift: rnd() * Math.PI * 2, + speed: 0.5 + rnd() * 0.9, + }) + } + return out +} + +export function DataFlowCanvas({ + className = "", + onPhaseChange, + progressRef, +}: { + className?: string + /** 현재 단계를 밖으로 알린다 — 분류 라벨이 모션과 같이 움직이게 하는 용도. */ + onPhaseChange?: (phase: DataFlowPhase) => void + /** 루프 진행도(0..1)를 매 프레임 써 넣는다. state 가 아니라 ref 인 이유는 + 60fps 로 setState 를 부르면 페이지 전체가 매 프레임 리렌더되기 때문이다. */ + progressRef?: { current: number } +}) { + const canvasRef = useRef(null) + // 콜백이 매 렌더 새 함수여도 effect 가 재실행되지 않도록 ref 로 잡아둔다. + const phaseCb = useRef(onPhaseChange) + phaseCb.current = onPhaseChange + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d", { alpha: true }) + if (!ctx) return + + const particles = buildParticles() + const sprites = [makeSprite(T_ITEM, TYPE_RGB[0]), makeSprite(T_USER, TYPE_RGB[1]), makeSprite(T_PARTNER, TYPE_RGB[2])] + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches + + let width = 0 + let height = 0 + let dpr = 1 + let lastPhase = -1 + + const resize = () => { + const rect = canvas.getBoundingClientRect() + dpr = Math.min(window.devicePixelRatio || 1, 2) + width = rect.width + height = rect.height + canvas.width = Math.round(width * dpr) + canvas.height = Math.round(height * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + resize() + + /** + * 헤드라인·본문·CTA 가 놓이는 중앙은 입자를 눌러 가독성을 확보한다. + * 가리는 게 아니라 옅게 만드는 쪽 — 텍스트 뒤로 데이터가 흐르는 인상은 남긴다. + */ + const textSafe = (x: number, y: number) => { + const dx = Math.abs(x - 0.5) / 0.46 + const dy = Math.abs(y - 0.34) / 0.25 + const d = Math.sqrt(dx * dx + dy * dy) + return d < 1 ? smoothstep(d) : 1 + } + + /* 점 구간에서 입자별 알파를 주려면 fill() 을 입자 수만큼 불러야 해서 못 쓴다. + 대신 감쇠를 3단계로 양자화해 Path2D 3개에 나눠 담는다. */ + const BUCKET_ALPHA = [0.1, 0.45, 1] + const bucketOf = (safe: number) => (safe < 0.34 ? 0 : safe < 0.72 ? 1 : 2) + + const draw = (progress: number, time: number) => { + if (progressRef) progressRef.current = progress + ctx.clearRect(0, 0, width, height) + + let idx = PHASES.length - 1 + for (let i = 0; i < PHASES.length; i++) { + const next = i + 1 < PHASES.length ? PHASES[i + 1].at : 1 + if (progress >= PHASES[i].at && progress < next) { + idx = i + break + } + } + + if (idx !== lastPhase) { + lastPhase = idx + phaseCb.current?.(PHASES[idx].key) + } + + const start = PHASES[idx].at + const end = idx + 1 < PHASES.length ? PHASES[idx + 1].at : 1 + const local = (progress - start) / (end - start) + + /* 머무름 먼저, 이동 나중. + 구간이 시작될 때 입자는 이미 그 단계의 모양이고, 캡션도 같은 단계를 말한다. + 앞 절반 동안 형태를 읽을 시간을 주고, 남은 절반에 다음 모양으로 넘어간다. */ + // 형태를 읽을 시간. 너무 길면 늘어지고, 너무 짧으면 라벨을 못 읽는다. + const HOLD = 0.42 + const t = local < HOLD ? 0 : easeOutExpo((local - HOLD) / (1 - HOLD)) + const nextIdx = (idx + 1) % PHASES.length + const detail = PHASE_DETAIL[idx] + (PHASE_DETAIL[nextIdx] - PHASE_DETAIL[idx]) * t + const order = PHASE_ORDER[idx] + (PHASE_ORDER[nextIdx] - PHASE_ORDER[idx]) * t + const wobbleAmp = reduced ? 0 : 0.008 * (1 - order) + + /* 좌표를 한 번만 계산해 두 표현(글리프/점)이 같은 값을 쓰게 한다. + z 는 원근 투영으로 화면 좌표에 반영한다 — 멀수록 화면 중앙으로 모이고 + 작아지고 흐려진다. 이게 없으면 아무리 색을 써도 평면으로 읽힌다. */ + const px = new Float32Array(PARTICLE_COUNT) + const py = new Float32Array(PARTICLE_COUNT) + const ps = new Float32Array(PARTICLE_COUNT) + const pk = new Float32Array(PARTICLE_COUNT) // 원근 배율 (1 = 카메라 평면) + const indices: number[] = [] + + for (let i = 0; i < PARTICLE_COUNT; i++) { + const p = particles[i] + const from = p.targets[idx] + const to = p.targets[nextIdx] + const wobble = Math.sin(time * 0.0004 * p.speed + p.drift) * wobbleAmp + const nx = from[0] + (to[0] - from[0]) * t + wobble + const ny = from[1] + (to[1] - from[1]) * t + wobble * 0.7 + const nz = from[2] + (to[2] - from[2]) * t + + const k = perspective(nz) + const sx = VANISH_X + (nx - VANISH_X) * k + const sy = VANISH_Y + (ny - VANISH_Y) * k + + px[i] = sx * width + py[i] = sy * height + ps[i] = textSafe(sx, sy) + pk[i] = k + indices.push(i) + } + + // 먼 것부터 그린다 — 가까운 개체가 위에 겹쳐야 깊이가 성립한다. + const sorted = indices.sort((a, b) => pk[a] - pk[b]) + + // 점 표현 — 글리프가 완전히 켜지지 않은 동안 항상 깔린다(교차 페이드). + if (detail < 0.97) { + for (let type = 0; type < 4; type++) { + const buckets = [new Path2D(), new Path2D(), new Path2D()] + for (const i of sorted) { + if (particles[i].type !== type) continue + const r = Math.max(0.35, particles[i].dot * pk[i]) + const path = buckets[bucketOf(ps[i] * (0.45 + 0.55 * pk[i]))] + path.moveTo(px[i] + r, py[i]) + path.arc(px[i], py[i], r, 0, Math.PI * 2) + } + // 시장가는 글리프가 없으므로 항상 제 밝기로 그린다. + const fade = type === T_MARKET ? 1 : 1 - detail + for (let b = 0; b < 3; b++) { + ctx.fillStyle = `rgba(${TYPE_RGB[type]},${(TYPE_ALPHA[type] * BUCKET_ALPHA[b] * fade).toFixed(3)})` + ctx.fill(buckets[b]) + } + } + } + + // 글리프 표현 — 스프라이트 블리팅. + if (detail > 0.03) { + for (const i of sorted) { + const type = particles[i].type + if (type === T_MARKET) continue + const [bw, bh] = GLYPH_SIZE[type] + const r = particles[i].ratio + // 품목은 너비가, 협력사는 높이가 개체마다 다르다 — 값이 있는 데이터처럼 보이게. + const gw = (type === T_ITEM ? bw * r : bw) * pk[i] + const gh = (type === T_PARTNER ? bh * r : bh) * pk[i] + ctx.globalAlpha = + Math.min(1, TYPE_ALPHA[type] * particles[i].emphasis) * detail * ps[i] * (0.4 + 0.6 * pk[i]) + ctx.drawImage(sprites[type], px[i] - gw / 2, py[i] - gh / 2, gw, gh) + } + ctx.globalAlpha = 1 + } + } + + /* 정지 프레임으로 고정하는 두 경우. + 1) prefers-reduced-motion — 카드 응축 순간을 보여준다. + 2) ?flow=0.42 같은 쿼리 — 특정 프레임 고정. 디자인 리뷰·OG 이미지 촬영용이고, + 백그라운드 탭에선 브라우저가 rAF 를 아예 안 돌려 스크린샷이 안 되기 때문에도 필요하다. */ + const pinned = new URLSearchParams(window.location.search).get("flow") + const pinnedAt = pinned !== null ? Number(pinned) : null + const still = pinnedAt !== null && Number.isFinite(pinnedAt) ? pinnedAt : reduced ? PHASES[1].at : null + + if (still !== null) { + draw(still, 0) + const onResize = () => { + resize() + draw(still, 0) + } + window.addEventListener("resize", onResize) + return () => window.removeEventListener("resize", onResize) + } + + let raf = 0 + let visible = true + const startedAt = performance.now() + + const tick = (now: number) => { + draw(((now - startedAt) % LOOP_MS) / LOOP_MS, now) + raf = requestAnimationFrame(tick) + } + + const play = () => { + if (!raf) raf = requestAnimationFrame(tick) + } + const pause = () => { + if (raf) cancelAnimationFrame(raf) + raf = 0 + } + + const io = new IntersectionObserver( + ([entry]) => { + visible = entry.isIntersecting + if (visible && !document.hidden) play() + else pause() + }, + { threshold: 0 }, + ) + io.observe(canvas) + + const onVisibility = () => { + if (document.hidden) pause() + else if (visible) play() + } + const onResize = () => resize() + + document.addEventListener("visibilitychange", onVisibility) + window.addEventListener("resize", onResize) + play() + + return () => { + pause() + io.disconnect() + document.removeEventListener("visibilitychange", onVisibility) + window.removeEventListener("resize", onResize) + } + }, []) + + return