From f214fccc49ecc2f00bc015268745fb847cdc6349 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 6 Aug 2026 10:24:21 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20landing:=20=ED=9E=88=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=EC=9D=84=20=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A3=A8=ED=94=84=20=EC=98=81=EC=83=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경이 설명을 지던 구조를 걷어냈다. 예전 캔버스는 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 --- .../app/components/sections/hero-dataflow.tsx | 149 +++--- .../app/components/ui/data-flow-canvas.tsx | 502 ------------------ landing/app/components/ui/orbit-ring.tsx | 4 +- landing/public/gifs/hero_loop.jpg | Bin 0 -> 59218 bytes landing/public/gifs/hero_loop.mp4 | Bin 0 -> 645744 bytes 5 files changed, 73 insertions(+), 582 deletions(-) delete mode 100644 landing/app/components/ui/data-flow-canvas.tsx create mode 100644 landing/public/gifs/hero_loop.jpg create mode 100644 landing/public/gifs/hero_loop.mp4 diff --git a/landing/app/components/sections/hero-dataflow.tsx b/landing/app/components/sections/hero-dataflow.tsx index 13018fe..9d24cf2 100644 --- a/landing/app/components/sections/hero-dataflow.tsx +++ b/landing/app/components/sections/hero-dataflow.tsx @@ -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("scatter") - // 순환 링이 캔버스와 같은 시계로 돌도록 진행도를 공유한다. - const progressRef = useRef(0) - return (
- - - {/* 무대 바닥으로 떨어지는 암전 — 아래 라이트 섹션과의 경계를 부드럽게 */} -
- - {/* 분류 단계에서만 뜨는 무리 라벨. 아이콘만으로는 "무엇이" 분류됐는지 절반만 전달된다. - 좌표는 캔버스가 쓰는 값을 그대로 받아 써서 둘이 어긋날 일이 없다. */} -
- {CLUSTER_ANCHORS.map((anchor) => ( - - {anchor.label} - - ))} + {/* 영상은 가로 화면에서만 튼다. + 16:9 소스를 세로 화면에 object-cover 로 깔면 가로가 62% 잘리고 남은 38% 가 + 1.7배로 확대된다(848x1220 실측) — 데이터 입자가 거대한 보케로 뭉개져서 + 배경이 아니라 노이즈가 된다. 캔버스는 뷰포트에 맞춰 그렸으니 없던 문제다. + 세로에서는 무대 그라데이션(stage-bg)만 남긴다. 그게 브랜드 배경이라 빈 화면이 아니다. + prefers-reduced-motion 에서도 같은 이유로 영상을 감춘다 — 포스터가 뒤를 받친다. */} +
+
- {/* 카피는 밴드 위 영역, 데이터 흐름은 하단 밴드. - 예전엔 pt-[19vh] 로 위치만 잡았는데 그건 경계를 강제하지 못한다 — 카피 높이는 - px 로 고정인데 밴드는 화면 비율로 내려오니, 뷰포트가 798px 보다 낮으면 버튼이 - 밴드를 파고들었다(1280x720 에서 34px, 라벨과는 7px). 1440x900 에서만 안 보였다. - 카피 영역 높이를 밴드 시작선에 묶으면 뷰포트와 무관하게 구조적으로 겹칠 수 없다. - 1.5rem 은 밴드 위 최소 숨통. - pt-[104px] 는 md 이상에서도 유지한다 — 헤더가 fixed 라 이걸 빼면 짧은 뷰포트에서 - 가운데 정렬이 카피를 헤더 밑으로 밀어올린다(620px 높이에서 제목 상단 8px). */} -
- {/* 히어로 카피에는 진입 애니메이션을 걸지 않는다. - 여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고, - 광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 캔버스가 맡는다. */} - {/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다. - 네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */} - - 협력사 전원과 1:1로, -
- 동시에 협상합니다. -
+ {/* 카피 가독성용 스크림. 색은 하나(stage-deep)로 두고 알파만 움직인다 — + 중간 stop 에서 색이 바뀌면 그 지점이 가로선으로 읽힌다(stage/70 을 끼웠을 때 발생). */} +
- {/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은 - 같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */} -

- 품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에 - 최저 투찰가로 낙찰까지 판정합니다. -

+ {/* 아래 라이트 섹션과의 경계를 부드럽게 */} +
-
- - -
+ {/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */} +
+
+ {/* 히어로 카피에는 진입 애니메이션을 걸지 않는다. + 여기가 LCP 요소라 opacity:0 으로 시작하면 하이드레이션 전까지 첫 화면이 비고, + 광고 유입처럼 JS 가 느린 환경에서 그대로 손실이 된다. 모션은 배경 영상이 맡는다. */} + {/* 2톤 헤드라인의 약한 쪽은 별도 회색이 아니라 같은 흰색의 투명도로 낮춘다. + 네이비 위에 라벤더 회색을 얹으면 화면에 색상각이 하나 더 생겨 탁해진다. */} + + 협력사 전원과 1:1로, +
+ 동시에 협상합니다. +
+ + {/* 히어로는 h1 이어야 해서 SectionHeading(h2 고정)을 못 쓴다. 대신 간격만은 + 같은 상수를 물려 최종 CTA 와 리듬이 어긋나지 않게 한다. */} +

+ 품목·목표가·마감일만 정하면 됩니다. 협상 에이전트가 협력사마다 따로 붙어 단가를 조율하고, 마감 시각에 + 최저 투찰가로 낙찰까지 판정합니다. +

+ +
+ + +
-
) } - diff --git a/landing/app/components/ui/data-flow-canvas.tsx b/landing/app/components/ui/data-flow-canvas.tsx deleted file mode 100644 index 2f22a24..0000000 --- a/landing/app/components/ui/data-flow-canvas.tsx +++ /dev/null @@ -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(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