o2o-negosium-original/landing/app/components/ui/data-flow-canvas.tsx
Haewon Kam 6785c8a6f4 [feat] landing: 랜딩 v2 — 다크 무대 히어로·인터랙티브 협상 데모·디자인 토큰 재정립
레퍼런스(statworx·jitter·infinith) 실측값으로 디자인 시스템을 다시 잡고,
스크롤 재생형 콘솔을 사용자가 직접 협상하는 데모로 교체했다.

디자인 토큰
- 타이포: 굵기·크기·자간을 함께 조정(섹션 제목 44→64px/600→800/자간 -0.042em).
  한글 행간은 1.07 — 라틴 기준 0.95 를 쓰면 네모틀이 꽉 차 줄이 닿는다.
- 모서리: 12가지로 흩어져 있던 값을 card 8px / control 4px / full 셋으로 수렴.
  20~32px 짜리 뭉툭한 카드가 제네릭 SaaS 의 가장 강한 신호였다.
- 다크 무대 토큰 신설(stage/on-stage/counter) + 배경 그라데이션.
- primary-on-stage(#7C8CFF) 추가 — #0101F3 은 무대 배경 대비 2.0:1 로
  WCAG 최소(4.5:1)의 절반도 안 돼 다크 위에서 읽히지 않았다.
- 영문 디스플레이 서체 Playfair Display self-host(latin 서브셋 39KB).

히어로
- 배경 캔버스가 실제 파이프라인을 그린다: 수집→분류→대조→산정→협상→학습.
  분류 단계는 거래 품목(시트)·사용자·협력사로 갈라지고 라벨이 붙는다.
- z 좌표 + 원근 투영. 군집마다 z 평면을 하나씩 줘 격자는 온전한 채 깊이만 생긴다.
- 순환 링(정원, 점선 궤도 회전). 진행도는 ref 로 공유해 60fps 리렌더를 피한다.
- 카피에 진입 애니메이션을 걸지 않는다 — LCP 요소라 opacity:0 으로 시작하면
  하이드레이션 전까지 첫 화면이 빈다.

협상 데모 (신규)
- 역할 선택(공급사/구매 담당자) → 품목·단가 제시 → 턴 협상 → 낙찰/개찰 → 상담 신청.
- 판정 규칙은 실제 엔진과 동일: 앵커링가 이하 낙찰, 협상 카드 3장, 소진 시 개찰.
- 로직은 lib/negotiation-sim.ts 순수 함수로 분리 — 나중에 agent 서비스 연동 시
  컴포넌트를 건드리지 않는다.

카피 감사
- "봇" 21곳 → "에이전트"(로봇 아이콘 3개 포함). 대리인은 조달 실무의 정당한 역할이다.
- "악역은 봇이", "단가를 깎는 악역" 제거 — 협력사를 적으로 규정하는 프레임은
  같은 페이지 FAQ(공급사 만족도 82%)와 모순이고, 동반성장 평가를 받는
  대기업 구매팀 결재 라인에 올릴 수 없다.
- 번역투 최상급(초정밀·압도적·완벽히·최적의)과 데모 대화문 전면 재작성.

버그 수정
- 헤더가 흰 배경 위에 흰 글자 버튼을 그리던 문제. onStage 를 스크롤 수치로 계산해
  하이드레이션 전 스크롤 복원 시 초기값이 굳었다 → IntersectionObserver 로 교체.
- 버튼 transition-all → transition-colors (variant 교체 시 배경 플리커).
- 폼 입력 14px → 16px. iOS 사파리는 16px 미만 입력에 포커스하면 화면을 확대해
  모바일 폼 이탈을 만든다.

정리
- 대체된 히어로 3종·기존 협상 콘솔 삭제(약 760줄).
- 라벨 아이콘·알약 배지·blur(120px) 글로우 블롭 제거.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:08:37 +09:00

467 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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
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 <canvas ref={canvasRef} aria-hidden="true" className={className} />
}