레퍼런스(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>
467 lines
20 KiB
TypeScript
467 lines
20 KiB
TypeScript
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} />
|
||
}
|