[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:
Haewon Kam 2026-08-06 10:24:21 +09:00
parent 63717f5eb9
commit f214fccc49
5 changed files with 73 additions and 582 deletions

View File

@ -1,102 +1,93 @@
import type * as React from "react"
import { useRef, useState } from "react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading" import { CTA_GAP, DISPLAY_LEAD_GAP } from "@/components/ui/section-heading"
import { Typography } from "@/components/ui/typography" 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() { export function HeroDataFlow() {
const [phase, setPhase] = useState<DataFlowPhase>("scatter")
// 순환 링이 캔버스와 같은 시계로 돌도록 진행도를 공유한다.
const progressRef = useRef(0)
return ( return (
<section <section
data-stage-hero data-stage-hero
className="stage-bg relative text-on-stage flex flex-col overflow-hidden" className="stage-bg relative min-h-screen 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
}
> >
<DataFlowCanvas onPhaseChange={setPhase} progressRef={progressRef} className="absolute inset-0 w-full h-full" /> {/* .
16:9 object-cover 62% 38%
{/* 무대 바닥으로 떨어지는 암전 — 아래 라이트 섹션과의 경계를 부드럽게 */} 1.7 (848x1220 )
<div className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" /> . .
(stage-bg) . .
{/* . "" . prefers-reduced-motion . */}
. */} <div
<div aria-hidden className="absolute inset-0 pointer-events-none z-10 hidden md:block"> aria-hidden
{CLUSTER_ANCHORS.map((anchor) => ( className="absolute inset-0 hidden landscape:block motion-reduce:hidden bg-cover bg-center"
<span style={{ backgroundImage: "url(/gifs/hero_loop.jpg)" }}
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 ${ <video
phase === "cluster" ? "opacity-100" : "opacity-0" className="w-full h-full object-cover"
}`} src="/gifs/hero_loop.mp4"
style={{ left: `${anchor.screenX * 100}%`, top: `${anchor.labelY * 100}%` }} poster="/gifs/hero_loop.jpg"
> autoPlay
{anchor.label} muted
</span> loop
))} playsInline
preload="metadata"
/>
</div> </div>
{/* , . {/* . (stage-deep)
pt-[19vh] stop (stage/70 ). */}
px , 798px <div
(1280x720 34px, 7px). 1440x900 . 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"
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>
{/* h1 SectionHeading(h2 ) . {/* 아래 라이트 섹션과의 경계를 부드럽게 */}
CTA . */} <div aria-hidden className="absolute inset-x-0 bottom-0 h-32 bg-linear-to-b from-transparent to-stage pointer-events-none" />
<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`}> {/* pt-[104px] 는 fixed 헤더 회피용 바닥. 그 아래 남은 공간에서 수직 중앙 정렬한다. */}
<Button href="#how-it-works" variant="stage" size="stageRound" className="group"> <div className="relative z-10 w-full flex-1 pt-[104px] pb-16 flex items-center">
<span> </span> <div className="max-w-4xl mx-auto px-6 w-full text-center">
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /> {/* .
</Button> LCP opacity:0 ,
<Button href="#contact-section" variant="stageGhost" size="stageRound"> JS . . */}
{/* 2 .
</Button> . */}
</div> <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>
</div> </div>
</section> </section>
) )
} }

View File

@ -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} />
}

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, useState, type RefObject } from "react" 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"
/** /**
* . * .

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.