[feat] landing: 히어로 배경을 캔버스에서 루프 영상으로 교체
배경이 설명을 지던 구조를 걷어냈다. 예전 캔버스는 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>
This commit is contained in:
parent
63717f5eb9
commit
f214fccc49
@ -1,102 +1,93 @@
|
||||
import type * as React from "react"
|
||||
import { useRef, useState } from "react"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
|
||||
import { Typography } from "@/components/ui/typography"
|
||||
import { BAND_TOP, CLUSTER_ANCHORS, DataFlowCanvas, type DataFlowPhase } from "@/components/ui/data-flow-canvas"
|
||||
|
||||
/** 히어로 최소 높이. 밴드 위에 헤더·카피가 들어갈 세로 예산의 하한(계산은 section 주석 참조). */
|
||||
const HERO_MIN_H = "780px"
|
||||
|
||||
/**
|
||||
* 다크 무대 히어로 — 배경 모션이 negotium 파이프라인 자체를 그린다.
|
||||
* 다크 무대 히어로 — 배경 루프 영상.
|
||||
*
|
||||
* 캡션이 모션과 같이 움직이는 게 핵심이다. 입자가 예뻐서 넣은 게 아니라
|
||||
* 지금 화면에서 무슨 일이 일어나는지 읽히게 하려고 넣었다.
|
||||
* 예전에는 캔버스가 negotium 파이프라인을 직접 그렸고(입자 240개·6단계 서사·무리 라벨),
|
||||
* 배경이 곧 설명이었다. 지금은 배경을 배경으로만 쓴다 — 설명은 카피와 아래 섹션이 맡는다.
|
||||
* 그래서 무리 라벨(CLUSTER_ANCHORS)도 함께 걷어냈다. 좌표 소스가 사라진 라벨은
|
||||
* 화면비가 바뀔 때마다 어긋나기만 하고 아무것도 설명하지 못한다.
|
||||
*
|
||||
* 배경이 의미를 지지 않으므로 카피 가독성이 1순위다. 그래서:
|
||||
* - 영상 위에 스크림을 깐다. 예전 캔버스는 하단 밴드에만 입자가 있어 상단이 비었지만,
|
||||
* 이 영상은 화면 전체에 밝은 요소가 흩어져 흰 글자가 묻힌다.
|
||||
* - 카피는 화면 중앙 정렬. 밴드 개념이 없으니 상하로 나눌 이유도 없다.
|
||||
*/
|
||||
export function HeroDataFlow() {
|
||||
const [phase, setPhase] = useState<DataFlowPhase>("scatter")
|
||||
// 순환 링이 캔버스와 같은 시계로 돌도록 진행도를 공유한다.
|
||||
const progressRef = useRef(0)
|
||||
|
||||
return (
|
||||
<section
|
||||
data-stage-hero
|
||||
className="stage-bg relative text-on-stage flex flex-col overflow-hidden"
|
||||
/* 높이와 밴드 시작선을 같은 식에서 뽑는다. 따로 쓰면 반드시 어긋난다 —
|
||||
밴드를 63vh 로 두고 높이만 키우면, 짧은 화면에서 변수가 실제 밴드보다 위를 가리킨다.
|
||||
밴드 위 공간(0.63×높이)에 헤더(104) + 카피(351) + 여유(24) = 479px 가 들어가야 하므로
|
||||
높이 바닥은 760px 이 하한이다. 780px 로 잡으면 짧은 화면은 살짝 스크롤될 뿐이다. */
|
||||
style={
|
||||
{
|
||||
minHeight: `max(100vh, ${HERO_MIN_H})`,
|
||||
"--band-top": `calc(${BAND_TOP} * max(100vh, ${HERO_MIN_H}))`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="stage-bg relative min-h-screen text-on-stage flex flex-col overflow-hidden"
|
||||
>
|
||||
<DataFlowCanvas onPhaseChange={setPhase} progressRef={progressRef} className="absolute inset-0 w-full h-full" />
|
||||
|
||||
{/* 무대 바닥으로 떨어지는 암전 — 아래 라이트 섹션과의 경계를 부드럽게 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
|
||||
|
||||
{/* 분류 단계에서만 뜨는 무리 라벨. 아이콘만으로는 "무엇이" 분류됐는지 절반만 전달된다.
|
||||
좌표는 캔버스가 쓰는 값을 그대로 받아 써서 둘이 어긋날 일이 없다. */}
|
||||
<div aria-hidden className="absolute inset-0 pointer-events-none z-10 hidden md:block">
|
||||
{CLUSTER_ANCHORS.map((anchor) => (
|
||||
<span
|
||||
key={anchor.key}
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 whitespace-nowrap text-[13px] font-semibold text-on-stage-soft/80 transition-opacity duration-500 ${
|
||||
phase === "cluster" ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
style={{ left: `${anchor.screenX * 100}%`, top: `${anchor.labelY * 100}%` }}
|
||||
>
|
||||
{anchor.label}
|
||||
</span>
|
||||
))}
|
||||
{/* 영상은 가로 화면에서만 튼다.
|
||||
16:9 소스를 세로 화면에 object-cover 로 깔면 가로가 62% 잘리고 남은 38% 가
|
||||
1.7배로 확대된다(848x1220 실측) — 데이터 입자가 거대한 보케로 뭉개져서
|
||||
배경이 아니라 노이즈가 된다. 캔버스는 뷰포트에 맞춰 그렸으니 없던 문제다.
|
||||
세로에서는 무대 그라데이션(stage-bg)만 남긴다. 그게 브랜드 배경이라 빈 화면이 아니다.
|
||||
prefers-reduced-motion 에서도 같은 이유로 영상을 감춘다 — 포스터가 뒤를 받친다. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 hidden landscape:block motion-reduce:hidden bg-cover bg-center"
|
||||
style={{ backgroundImage: "url(/gifs/hero_loop.jpg)" }}
|
||||
>
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
src="/gifs/hero_loop.mp4"
|
||||
poster="/gifs/hero_loop.jpg"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카피는 밴드 위 영역, 데이터 흐름은 하단 밴드.
|
||||
예전엔 pt-[19vh] 로 위치만 잡았는데 그건 경계를 강제하지 못한다 — 카피 높이는
|
||||
px 로 고정인데 밴드는 화면 비율로 내려오니, 뷰포트가 798px 보다 낮으면 버튼이
|
||||
밴드를 파고들었다(1280x720 에서 34px, 라벨과는 7px). 1440x900 에서만 안 보였다.
|
||||
카피 영역 높이를 밴드 시작선에 묶으면 뷰포트와 무관하게 구조적으로 겹칠 수 없다.
|
||||
1.5rem 은 밴드 위 최소 숨통.
|
||||
pt-[104px] 는 md 이상에서도 유지한다 — 헤더가 fixed 라 이걸 빼면 짧은 뷰포트에서
|
||||
가운데 정렬이 카피를 헤더 밑으로 밀어올린다(620px 높이에서 제목 상단 8px). */}
|
||||
<div className="relative z-10 w-full flex-none pt-[104px] md:h-[calc(var(--band-top)-1.5rem)] md:flex md:items-center"><div className="max-w-4xl mx-auto px-6 w-full text-center">
|
||||
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
|
||||
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
|
||||
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 캔버스가 맡는다. */}
|
||||
{/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다.
|
||||
네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */}
|
||||
<Typography variant="stageDisplay">
|
||||
<span className="text-white">협력사 전원과 1:1로,</span>
|
||||
<br />
|
||||
<span className="text-on-stage-soft/60">동시에 협상합니다.</span>
|
||||
</Typography>
|
||||
{/* 카피 가독성용 스크림. 색은 하나(stage-deep)로 두고 알파만 움직인다 —
|
||||
중간 stop 에서 색이 바뀌면 그 지점이 가로선으로 읽힌다(stage/70 을 끼웠을 때 발생). */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none bg-linear-to-b from-stage-deep/90 via-stage-deep/60 to-stage-deep/95"
|
||||
/>
|
||||
|
||||
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은
|
||||
같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */}
|
||||
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
|
||||
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
|
||||
최저 투찰가로 낙찰까지 판정합니다.
|
||||
</p>
|
||||
{/* 아래 라이트 섹션과의 경계를 부드럽게 */}
|
||||
<div aria-hidden className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
|
||||
|
||||
<div className={`${CTA_GAP} flex flex-col sm:flex-row gap-3 justify-center`}>
|
||||
<Button href="#how-it-works" variant="stage" size="stageRound" className="group">
|
||||
<span>직접 협상해 보기</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Button>
|
||||
<Button href="#contact-section" variant="stageGhost" size="stageRound">
|
||||
도입 문의
|
||||
</Button>
|
||||
</div>
|
||||
{/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */}
|
||||
<div className="relative z-10 w-full flex-1 pt-[104px] pb-16 flex items-center">
|
||||
<div className="max-w-4xl mx-auto px-6 w-full text-center">
|
||||
{/* 히어로 카피에는 진입 애니메이션을 걸지 않는다.
|
||||
여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고,
|
||||
광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */}
|
||||
{/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다.
|
||||
네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */}
|
||||
<Typography variant="stageDisplay">
|
||||
<span className="text-white">협력사 전원과 1:1로,</span>
|
||||
<br />
|
||||
<span className="text-on-stage-soft/60">동시에 협상합니다.</span>
|
||||
</Typography>
|
||||
|
||||
{/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은
|
||||
같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */}
|
||||
<p className={`${DISPLAY_LEAD_GAP} text-base sm:text-lg text-on-stage-soft leading-relaxed break-keep max-w-2xl mx-auto`}>
|
||||
품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에
|
||||
최저 투찰가로 낙찰까지 판정합니다.
|
||||
</p>
|
||||
|
||||
<div className={`${CTA_GAP} flex flex-col sm:flex-row gap-3 justify-center`}>
|
||||
<Button href="#how-it-works" variant="stage" size="stageRound" className="group">
|
||||
<span>직접 협상해 보기</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Button>
|
||||
<Button href="#contact-section" variant="stageGhost" size="stageRound">
|
||||
도입 문의
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,502 +0,0 @@
|
||||
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() 로 옮긴다. */
|
||||
/* 히어로 카피도 이 값을 읽어 자기 높이를 잡는다 — 카피가 밴드를 침범하지 않는 건
|
||||
두 숫자가 우연히 안 만나서가 아니라 같은 값을 공유하기 때문이어야 한다. */
|
||||
export const BAND_TOP = 0.63
|
||||
const BAND_H = 0.30 /* 히어로 하단 전체. 순환 링은 강화학습 섹션으로 옮겼다. */
|
||||
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 로 뒤틀려
|
||||
표로 안 읽힌다. 무리 단위로 주면 격자는 온전한 채 그룹 사이에만 깊이가 생긴다.
|
||||
|
||||
x 는 "투영된 뒤 어디에 놓일지"를 먼저 정하고 역산한 값이다.
|
||||
투영은 깊이마다 다른 배율로 중앙으로 당기기 때문에(k = 0.42+0.58z), 세계 좌표를
|
||||
눈대중으로 고르면 가장 깊은 무리가 제일 많이 끌려와 구도가 한쪽으로 쏠린다. */
|
||||
const CLUSTER_PLAN = [
|
||||
{ key: "item", screenX: 0.19, z: 0.9, label: "거래 품목" },
|
||||
{ key: "user", screenX: 0.5, z: 0.66, label: "사용자" },
|
||||
{ key: "partner", screenX: 0.81, z: 0.44, 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
|
||||
|
||||
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
|
||||
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<HTMLCanvasElement>(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
|
||||
/* 글리프 배율. 격자 간격은 dx × width 로 폭에 비례하는데 글리프가 CSS px 고정이면
|
||||
좁은 화면에서 셀보다 커져 서로 겹치고 덩어리가 된다. 같은 기준(폭)에서 파생시킨다. */
|
||||
let glyphScale = 1
|
||||
/* 좁은 화면에서는 카피가 길어져(CTA 가 세로로 쌓인다) 밴드가 CTA 를 침범한다.
|
||||
게다가 375px 에서 3군집을 나란히 두면 군집당 100px 남짓이라 어차피 판독이 안 된다.
|
||||
밴드를 링 뒤쪽으로 내리고, 군집 라벨은 히어로 쪽에서 접는다. */
|
||||
let narrow = false
|
||||
|
||||
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)
|
||||
glyphScale = Math.min(1, Math.max(0.3, width / 1180))
|
||||
narrow = width < 640
|
||||
}
|
||||
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
|
||||
let ny = from[1] + (to[1] - from[1]) * t + wobble * 0.7
|
||||
const nz = from[2] + (to[2] - from[2]) * t
|
||||
|
||||
// 좁은 화면이면 밴드를 통째로 아래(링 뒤)로 옮긴다. 목표 좌표는 그대로 두고 여기서만 remap.
|
||||
if (narrow) ny = 0.62 + ((ny - BAND_TOP) / BAND_H) * 0.3
|
||||
|
||||
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] * Math.max(0.55, glyphScale))
|
||||
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] * glyphScale
|
||||
const gh = (type === T_PARTNER ? bh * r : bh) * pk[i] * glyphScale
|
||||
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 <canvas ref={canvasRef} aria-hidden="true" className={className} />
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react"
|
||||
|
||||
import type { DataFlowPhase } from "@/components/ui/data-flow-canvas"
|
||||
/* 파이프라인 6단계. 원래 히어로 캔버스가 소유하던 타입인데, 히어로가 영상으로 바뀌면서
|
||||
이리로 옮겼다 — 단계 이름을 실제로 쓰는 건 이제 이 링뿐이다. */
|
||||
export type DataFlowPhase = "scatter" | "cluster" | "stream" | "card" | "negotiate" | "learn"
|
||||
|
||||
/**
|
||||
* 파이프라인 순환 링 — 정원.
|
||||
|
||||
BIN
landing/public/gifs/hero_loop.jpg
Normal file
BIN
landing/public/gifs/hero_loop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
landing/public/gifs/hero_loop.mp4
Normal file
BIN
landing/public/gifs/hero_loop.mp4
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user