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 로 뒤틀려 표로 안 읽힌다. 무리 단위로 주면 격자는 온전한 채 그룹 사이에만 깊이가 생긴다. 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 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 = [ { 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