로고 애니메이션 만들기 9종 — 진행률을 그리는 로딩 로고
로딩 중인 로고는 진행률 0~1 을 무엇으로 바꿔 보여 줄까? 03번은 로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾고, 05번은 노이즈 문턱값이 내려가며 로고가 번져 나오며, 07번은 원 둘레의 점을 로고 외곽선과 짝지어 녹여 넣는다.
진행률을 받아 로고를 완성하는 로딩 화면 아홉 개를 이 자리에서 돌려 보기
01 logo-load-outline-arc-trace
02 logo-load-liquid-wave-fill
04 logo-load-tile-flip-sweep
05 logo-load-noise-threshold-bleed
06 logo-load-comet-orbit-tail
07 logo-load-circle-to-mark-melt
08 logo-load-extrude-layer-stack
09 logo-load-smoothed-percent-count
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더
- 02 로고 모양 안에 물이 차오르며 수면이 출렁이는 로더
- 03 로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더
- 04 로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더
- 05 노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더
- 06 로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더
- 07 원 둘레의 점이 로고 외곽선으로 녹아드는 로더
- 08 진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더
- 09 들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더
로고 애니메이션 만들기 9종 — 외곽선 긋기·물 채우기·점 모으기·타일 뒤집기·노이즈 번짐·혜성 꼬리·모양 녹이기·두께 쌓기·숫자 세기
앞의 둘은 선(01)과 면(02)으로 마크를 채우고, 가운데 셋은 점(03)·타일(04)·화소(05) 단위로 모양을 드러내며, 뒤의 넷은 둘레를 도는 꼬리(06), 원에서 마크로 바뀌는 윤곽(07), 뒤로 쌓이는 두께(08), 들쭉날쭉한 숫자를 고르는 계산(09)을 맡는다. 아홉 장면 모두 <canvas> 2D 한 장에 그리고, 스타일시트에는 무대 비율과 버튼, 포커스 테두리, 아래 진행 띠만 있다. CSS @keyframes 는 한 줄도 없고, 움직임은 requestAnimationFrame 타임스탬프를 쌓아 16ms 고정 스텝을 한 프레임에 최대 6번 푸는 루프가 전부 맡는다.
로고는 직접 그린 기하 마크, 진행은 시드로 뽑은 가상 값
화면의 마크는 꼭짓점이 위를 향한 육각형(중심에서 꼭짓점까지 104px) 안에 바깥 꼭짓점 44px·안쪽 14px 짜리 네 갈래 별 구멍을 뚫은 모양이고, 어느 회사나 브랜드의 로고도 아니다. roundCorners 가 꼭짓점 양옆을 잘라 2차 베지어로 둥글리므로 바깥 외곽선은 42점(둘레 601.7px), 별 구멍은 32점(둘레 260.1px)이 되고, 칠할 때는 evenodd 규칙으로 바깥 안쪽이면서 별 바깥인 곳만 채운다. 진행 값도 진짜 내려받기가 아니다. makeLoad(469) 가 시드 469 의 정수 섞기 난수로 1,300ms 동안 7조각이 도착하는 표를 만들고, 그중 484ms 부터 758ms 까지 274ms 는 받는 양이 49% 에서 멈춰 있다. 01~08 은 도착 사이를 직선으로 이은 loadAt 을, 09 는 도착하는 순간에만 뛰는 계단 reportedAt 을 받아서, 900ms 시점의 값이 앞쪽은 0.585, 09 는 0.49 로 다르다. 소스 어디에도 fetch·Math.random·Date.now 가 없어서 같은 시드로 열면 같은 그림이 같은 시각에 나온다.
렌더 캡처로 잰 아홉 장면의 움직임 크기
프레임 칸은 2초 동안 찍은 24장 사이의 변화 23번 가운데 화면이 실제로 바뀐 횟수다.
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 외곽선 긋기 | 21.6% | 17/23 | 94.0 |
| 02 물 채우기 | 15.5% | 22/23 | 114.8 |
| 03 화소 점 모으기 | 39.6% | 23/23 | 79.0 |
| 04 타일 뒤집기 | 22.2% | 17/23 | 80.7 |
| 05 노이즈 번짐 | 17.7% | 17/23 | 120.1 |
| 06 혜성 꼬리 | 26.9% | 23/23 | 64.3 |
| 07 원에서 마크로 | 16.2% | 20/23 | 92.2 |
| 08 두께 쌓기 | 12.3% | 23/23 | 73.8 |
| 09 퍼센트 세기 | 28.8% | 23/23 | 137.6 |
시연 한 바퀴는 2,000ms 이고 첫 200ms 는 진행률 0, 마지막 500ms 는 다 받은 상태로 머문다. 01·04·05 는 진행률만 보고 그리는 장면이라 이 두 구간에서 화면이 멈춰 17장에 그친 것으로 보이고, 03 은 아직 차례가 오지 않은 점이 흩어진 자리에서 맴돌고 06 은 머리가 계속 돌며 08 은 마크가 좌우로 흔들려서 23장 내내 무언가 바뀐다. 누적면적은 08 이 12.3% 로 가장 작고 03 이 39.6% 로 가장 넓은데, 08 은 층이 앞면 바로 뒤에 겹쳐 새로 칠해지는 면이 좁고 03 은 반지름 128~224px 고리에서 점들이 날아들기 때문으로 보인다.
로고 로딩 화면을 고를 때 어울리는 자리
브랜드 사이트에 처음 들어올 때처럼 로딩 로고가 그대로 첫인상이 되는 곳은 01 의 외곽선 긋기가 무난하고, 앱 설치나 업데이트를 기다리게 하는 화면은 02 의 차오르는 물이 상황을 설명해 준다. 서비스 시작 스플래시는 03, 대시보드 첫 로딩은 04, 포트폴리오 인트로는 05 가 맞다. 결제 처리처럼 기다림이 얼마나 길지 모르는 표시는 06, 로그인 뒤 다음 화면을 기다리는 전환은 07, 게임이나 이벤트 페이지 입구는 08, 파일 내보내기와 보고서 생성처럼 숫자를 오래 지켜보게 되는 대기는 09 가 맡는다.
로딩 로고는 왜 시간이 아니라 진행률에 묶어야 하는가?
시간에 묶은 연출은 1초짜리든 3초짜리든 정해진 길이에 끝난다. 실제 불러오기가 그보다 길면 다 그린 마크가 하염없이 서 있고, 짧으면 그리다 만 채 다음 화면으로 넘어간다. 이 편의 아홉 장면은 거꾸로 0 에서 1 사이 진행 값 하나를 받아 그 값으로 그림을 정하므로, 가상 진행이 274ms 멈춘 구간에서는 01 의 펜도 그 자리에 서고 04 의 파면도 진행을 멈춘다. 로딩 중 로고가 받은 양을 그대로 보여 주면 멈춘 구간이 화면에서도 멈춰 보이는데, 이것을 숨기지 않고 다듬는 쪽이 09 다. 진행 값이 들어오는 길은 targetProgress() 한 줄이고, 끌거나 키로 옮긴 값(manual)이 있으면 그것을, 없으면 가상 표의 loadAt(loadMs) 를 돌려준다. 실서비스에 붙일 때는 이 자리의 가상 값만 내 진행 값으로 바꿔 넣으면 되고, 그림을 그리는 쪽은 손대지 않아도 된다.
0 과 1 사이 숫자 하나가 선 길이·수면 높이·점 개수로 갈라지는 계산
장면마다 진행률 p 를 그림의 어느 양으로 바꾸는지, 그리고 아래 띠가 어떤 값을 알리는지를 표로 모았다.
| 장면 | p 를 바꾸는 식 | 띠가 알리는 값 |
|---|---|---|
| 01 | 그은 길이 = 861.4px × (1 − (1 − p)^1.6) | p |
| 02 | 수위가 스텝마다 p 와의 차를 8% 씩 좁힘 | 수위 |
| 03 | 섞은 순번이 p × 718 보다 작은 점만 목표로 | 1.5px 안에 든 점 비율 |
| 04 | 뒤집힘 = (p × 1,120 − (d − 2) × 60) ÷ 400 | p |
| 05 | 문턱 = 1 − p × 1.07 | p |
| 06 | 꼬리 점 수 = 4 + round(p × 86), 최대 90 | p |
| 07 | 섞기 = 곡선(p), 25·50·75% 에서 0.109·0.5·0.891 | p |
| 08 | 층 수 = p × 14, 소수 부분은 미끄러져 드는 층 | p |
| 09 | 표시값이 계단 값과의 차를 6% 씩 좁힘 | 표시값 |
01 의 곡선은 앞을 빠르게 긋고 끝을 천천히 닫아서 진행률 25·50·75% 에 이미 36.9%(318px)·67.0%(577px)·89.1%(768px) 를 그어 둔다. 띠가 알리는 값이 장면마다 다른 이유는 띠와 화면이 어긋나지 않게 하려는 데 있다. 02 는 물이 진행률보다 늦게 차오르므로(가장 큰 차 19.4%p) 수위를, 03 은 점이 날아가는 중이므로 도착한 점의 비율을 띠에 싣는다. 그 일은 공통 함수 syncBar 가 한다.
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
띠의 길이는 transform: scaleX() 로만 바뀌어 레이아웃을 다시 계산하지 않고, aria-valuenow 는 정수 퍼센트가 달라질 때만 고쳐 써서 화면 읽기 프로그램에 같은 값을 거듭 보내지 않는다.
01로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더
꼭짓점 번호를 같은 간격으로 나눠 찍은 점은 둥근 모서리에 몰려 이웃 간격이 0.22~12.85px, 59.3배까지 벌어지므로, 점 번호 대신 누적 호 길이 표(362칸, 총 861.4px)를 이분 탐색해 9번 비교로 칸을 찾고 그 칸 안에서 남은 거리 비율로 펜 끝을 옮긴다. 바깥 외곽선과 별 구멍을 각각 다른 색으로 긋고 펜 끝에 반지름 18px 빛을 얹으며 진행률 70% 부터 속이 차오르니, 브랜드 사이트 첫 진입 화면처럼 마크를 한 획씩 소개하는 자리에 맞다. 이 장면의 렌더 값은 누적면적 21.6%, 강도 94.0, 17/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="진행률만큼 육각형 마크의 외곽선을 같은 빠르기로 그어 가며 펜 끝이 빛나는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">외곽선을 긋는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 외곽선을 자르는 점 개수 — 외곽선마다 꼭짓점 번호를 같은 간격으로 나눠 찍는다
const TRACE_SAMPLES = 360;
// 펜 끝 빛 번짐 반지름(px)
const PEN_GLOW_PX = 18;
// 진행률을 그은 길이로 바꾸는 곡선의 지수 — 1 이면 그대로, 클수록 앞에서 빨리 긋고 끝에서 천천히 닫는다
const TRACE_EASE_POW = 1.6;
const LINE_W = 3.2;
const TXT_RUN = '외곽선을 긋는 중';
function samplePath() {
let verts = 0;
for (let c = 0; c < LOGO.length; c += 1) {
verts += LOGO[c].length;
}
const out = [];
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
const n = Math.max(8, Math.round((TRACE_SAMPLES * poly.length) / verts));
for (let i = 0; i <= n; i += 1) {
const u = (i / n) * poly.length;
const k = Math.floor(u) % poly.length;
const f = u - Math.floor(u);
const a = poly[k];
const b = poly[(k + 1) % poly.length];
out.push({ x: lerp(a[0], b[0], f), y: lerp(a[1], b[1], f), lift: i === 0, ring: c });
}
}
return out;
}
const TRACE = samplePath();
// 누적 호 길이 표 — cum[i] 는 첫 점에서 i 번째 점까지 실제로 그은 길이. 외곽선이 바뀌는 자리(lift)는 펜을 들고 건너가 0 을 더한다
function arcTable(p) {
const cum = [0];
for (let i = 1; i < p.length; i += 1) {
cum.push(cum[i - 1] + (p[i].lift ? 0 : Math.hypot(p[i].x - p[i - 1].x, p[i].y - p[i - 1].y)));
}
return cum;
}
const CUM = arcTable(TRACE);
const TOTAL = CUM[CUM.length - 1];
// 이분 탐색 — cum[i] <= d 인 가장 큰 i. 표가 오름차순이라 점 360개도 9번 안쪽 비교로 찾는다
function findSeg(d) {
let lo = 0;
let hi = CUM.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (CUM[mid] <= d) {
lo = mid;
}
else {
hi = mid;
}
}
return lo;
}
function traceEase(p) { return 1 - Math.pow(1 - clamp(p, 0, 1), TRACE_EASE_POW); }
// 펜 끝 — 그 칸 안에서 남은 거리 비율만큼 두 점 사이를 잇는다. 그래서 펜은 점 간격과 상관없이 길이에 비례해 움직인다
function penAt(d) {
const i = findSeg(d);
if (i >= TRACE.length - 1) {
return { i: i, x: TRACE[i].x, y: TRACE[i].y };
}
const seg = CUM[i + 1] - CUM[i];
const f = seg > 0 ? clamp((d - CUM[i]) / seg, 0, 1) : 0;
return { i: i, x: lerp(TRACE[i].x, TRACE[i + 1].x, f), y: lerp(TRACE[i].y, TRACE[i + 1].y, f) };
}
const RING_COL = [PAL[6], PAL[1]];
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
function draw() {
clearStage();
const d = traceEase(progress) * TOTAL;
// 다 그은 뒤 마지막 30% 구간에서 속이 차오른다 — 외곽선이 닫혀야 채움이 의미가 있다
const fillA = smooth((progress - 0.7) / 0.3);
if (fillA > 0) {
logoPath();
const g = ctx.createLinearGradient(0, CY - LOGO_R, 0, CY + LOGO_R);
g.addColorStop(0, rgba(PAL[6], 0.9 * fillA));
g.addColorStop(1, rgba(PAL[3], 0.9 * fillA));
ctx.fillStyle = g;
ctx.fill('evenodd');
}
logoGuide(0.16);
const pen = penAt(d);
ctx.lineWidth = LINE_W;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
let ring = TRACE[0].ring;
for (let i = 0; i <= pen.i; i += 1) {
const p = TRACE[i];
if (p.lift) {
if (i > 0) {
ctx.strokeStyle = rgba(RING_COL[ring], 1);
ctx.stroke();
ctx.beginPath();
}
ring = p.ring;
ctx.moveTo(p.x, p.y);
}
else {
ctx.lineTo(p.x, p.y);
}
}
ctx.lineTo(pen.x, pen.y);
ctx.strokeStyle = rgba(RING_COL[ring], 1);
ctx.stroke();
if (progress > 0 && progress < 1) {
const g = ctx.createRadialGradient(pen.x, pen.y, 0, pen.x, pen.y, PEN_GLOW_PX);
g.addColorStop(0, rgba(PAPER, 0.95));
g.addColorStop(0.35, rgba(RING_COL[ring], 0.6));
g.addColorStop(1, rgba(RING_COL[ring], 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(pen.x, pen.y, PEN_GLOW_PX, 0, TAU);
ctx.fill();
}
statRow(112, '진행률', pct(progress));
statRow(160, '그은 길이', Math.round(d) + ' / ' + Math.round(TOTAL) + ' px');
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// OutlineArcTrace - vanilla 판(01-logo-load-outline-arc-trace) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./OutlineArcTrace.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function OutlineArcTrace() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 외곽선을 자르는 점 개수 — 외곽선마다 꼭짓점 번호를 같은 간격으로 나눠 찍는다
const TRACE_SAMPLES = 360;
// 펜 끝 빛 번짐 반지름(px)
const PEN_GLOW_PX = 18;
// 진행률을 그은 길이로 바꾸는 곡선의 지수 — 1 이면 그대로, 클수록 앞에서 빨리 긋고 끝에서 천천히 닫는다
const TRACE_EASE_POW = 1.6;
const LINE_W = 3.2;
const TXT_RUN = '외곽선을 긋는 중';
// 둥근 모서리는 꼭짓점이 빽빽해서 번호 간격으로 찍은 점이 모서리에 몰린다 — 점 번호로 펜을 옮기면 모서리에서 느려지고 곧은 변에서 빨라진다
type TracePt = { x: number; y: number; lift: boolean; ring: number };
function samplePath(): TracePt[] {
let verts = 0;
for (let c = 0; c < LOGO.length; c += 1) { verts += LOGO[c].length; }
const out: TracePt[] = [];
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
const n = Math.max(8, Math.round((TRACE_SAMPLES * poly.length) / verts));
for (let i = 0; i <= n; i += 1) {
const u = (i / n) * poly.length;
const k = Math.floor(u) % poly.length;
const f = u - Math.floor(u);
const a = poly[k];
const b = poly[(k + 1) % poly.length];
out.push({ x: lerp(a[0], b[0], f), y: lerp(a[1], b[1], f), lift: i === 0, ring: c });
}
}
return out;
}
const TRACE: TracePt[] = samplePath();
// 누적 호 길이 표 — cum[i] 는 첫 점에서 i 번째 점까지 실제로 그은 길이. 외곽선이 바뀌는 자리(lift)는 펜을 들고 건너가 0 을 더한다
function arcTable(p: TracePt[]): number[] {
const cum: number[] = [0];
for (let i = 1; i < p.length; i += 1) {
cum.push(cum[i - 1] + (p[i].lift ? 0 : Math.hypot(p[i].x - p[i - 1].x, p[i].y - p[i - 1].y)));
}
return cum;
}
const CUM: number[] = arcTable(TRACE);
const TOTAL = CUM[CUM.length - 1];
// 이분 탐색 — cum[i] <= d 인 가장 큰 i. 표가 오름차순이라 점 360개도 9번 안쪽 비교로 찾는다
function findSeg(d: number): number {
let lo = 0;
let hi = CUM.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (CUM[mid] <= d) { lo = mid; } else { hi = mid; }
}
return lo;
}
function traceEase(p: number): number { return 1 - Math.pow(1 - clamp(p, 0, 1), TRACE_EASE_POW); }
// 펜 끝 — 그 칸 안에서 남은 거리 비율만큼 두 점 사이를 잇는다. 그래서 펜은 점 간격과 상관없이 길이에 비례해 움직인다
function penAt(d: number): { i: number; x: number; y: number } {
const i = findSeg(d);
if (i >= TRACE.length - 1) { return { i: i, x: TRACE[i].x, y: TRACE[i].y }; }
const seg = CUM[i + 1] - CUM[i];
const f = seg > 0 ? clamp((d - CUM[i]) / seg, 0, 1) : 0;
return { i: i, x: lerp(TRACE[i].x, TRACE[i + 1].x, f), y: lerp(TRACE[i].y, TRACE[i + 1].y, f) };
}
const RING_COL: RGB[] = [PAL[6], PAL[1]];
function shown(): number { return progress; }
function step(): void { /* 그은 길이는 진행률만으로 정해진다 — 따로 쌓는 상태가 없다 */ }
function settle(): void { /* 상태가 없어 동작 줄이기에서도 같은 그림이 그 진행률로 바로 나온다 */ }
function onRestart(): void { /* 다시 시작하면 진행률 0 이 곧 빈 외곽선이다 */ }
function draw(): void {
clearStage();
const d = traceEase(progress) * TOTAL;
// 다 그은 뒤 마지막 30% 구간에서 속이 차오른다 — 외곽선이 닫혀야 채움이 의미가 있다
const fillA = smooth((progress - 0.7) / 0.3);
if (fillA > 0) {
logoPath();
const g = ctx.createLinearGradient(0, CY - LOGO_R, 0, CY + LOGO_R);
g.addColorStop(0, rgba(PAL[6], 0.9 * fillA));
g.addColorStop(1, rgba(PAL[3], 0.9 * fillA));
ctx.fillStyle = g;
ctx.fill('evenodd');
}
logoGuide(0.16);
const pen = penAt(d);
ctx.lineWidth = LINE_W;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
let ring = TRACE[0].ring;
for (let i = 0; i <= pen.i; i += 1) {
const p = TRACE[i];
if (p.lift) {
if (i > 0) {
ctx.strokeStyle = rgba(RING_COL[ring], 1);
ctx.stroke();
ctx.beginPath();
}
ring = p.ring;
ctx.moveTo(p.x, p.y);
} else {
ctx.lineTo(p.x, p.y);
}
}
ctx.lineTo(pen.x, pen.y);
ctx.strokeStyle = rgba(RING_COL[ring], 1);
ctx.stroke();
if (progress > 0 && progress < 1) {
const g = ctx.createRadialGradient(pen.x, pen.y, 0, pen.x, pen.y, PEN_GLOW_PX);
g.addColorStop(0, rgba(PAPER, 0.95));
g.addColorStop(0.35, rgba(RING_COL[ring], 0.6));
g.addColorStop(1, rgba(RING_COL[ring], 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(pen.x, pen.y, PEN_GLOW_PX, 0, TAU);
ctx.fill();
}
statRow(112, '진행률', pct(progress));
statRow(160, '그은 길이', Math.round(d) + ' / ' + Math.round(TOTAL) + ' px');
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="진행률만큼 육각형 마크의 외곽선을 같은 빠르기로 그어 가며 펜 끝이 빛나는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">외곽선을 긋는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}01 · 로고 외곽선을 같은 빠르기로 따라 그으며 펜 끝이 빛나는 로더 — 측정 누적면적 21.629% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function findSeg(d) {
let lo = 0;
let hi = CUM.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (CUM[mid] <= d) {
lo = mid;
}
else {
hi = mid;
}
}
return lo;
}
function traceEase(p) { return 1 - Math.pow(1 - clamp(p, 0, 1), TRACE_EASE_POW); }
// 펜 끝 — 그 칸 안에서 남은 거리 비율만큼 두 점 사이를 잇는다. 그래서 펜은 점 간격과 상관없이 길이에 비례해 움직인다
function penAt(d) {
const i = findSeg(d);
if (i >= TRACE.length - 1) {
return { i: i, x: TRACE[i].x, y: TRACE[i].y };
}
const seg = CUM[i + 1] - CUM[i];
const f = seg > 0 ? clamp((d - CUM[i]) / seg, 0, 1) : 0;
return { i: i, x: lerp(TRACE[i].x, TRACE[i + 1].x, f), y: lerp(TRACE[i].y, TRACE[i + 1].y, f) };
}
02로고 모양 안에 물이 차오르며 수면이 출렁이는 로더
수면을 48개 기둥으로 나눠 기둥마다 기준 수위로 당기는 힘과 감쇠 0.985 를 먼저 적용하고, 이웃과의 높이 차에 0.22 를 곱해 주고받는 일을 두 번 돌려 물결을 옆으로 번지게 한 뒤, 전체를 마크 모양으로 잘라 별 구멍만 비워 둔다. 수위가 오른 만큼 물줄기 자리 기둥을 아래로 밀어 출렁임을 새로 만들기 때문에 앱 설치나 업데이트 대기처럼 차오름 자체가 상황 설명이 되는 화면에 어울린다. 측정 결과는 누적면적 15.5%, 강도 114.8, 22/23 프레임이었다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="육각형 마크 안에 진행률만큼 물이 차오르고 수면이 출렁이는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">물이 차오르는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 수면을 나누는 기둥 수
const WAVE_COLS = 48;
// 이웃 기둥과 높이 차를 주고받는 세기 — 0.5 를 넘으면 받은 것보다 더 돌려줘 터진다
const WAVE_SPREAD = 0.22;
// 16ms 스텝마다 기둥 속도에 곱하는 감쇠 — 1 에 가까울수록 오래 출렁인다
const WAVE_DAMP = 0.985;
// 기준 수위가 스텝마다 진행률과의 남은 차이 중 이 비율만큼 따라간다
const FILL_RISE_RATE = 0.08;
// 기둥을 기준 수위로 되돌리는 세기
const WAVE_TENSION = 0.012;
// 수위가 오른 만큼 물줄기 자리 기둥을 아래로 미는 세기
const POUR_KICK = 1.8;
const TXT_RUN = '물이 차오르는 중';
const X0 = CX - 94;
const X1 = CX + 94;
const TOP = CY - LOGO_R - 2;
const BOT = CY + LOGO_R + 2;
const POUR_COL = Math.round(WAVE_COLS * 0.3);
const hs = [];
const vs = [];
const ld = [];
const rd = [];
for (let i = 0; i < WAVE_COLS; i += 1) {
hs.push(0);
vs.push(0);
ld.push(0);
rd.push(0);
}
let level = 0;
let rise = 0;
function shown() { return level; }
// 기둥 파동 한 스텝 — ① 기준 수위로 당기고 감쇠를 곱한 뒤 ② 이웃과 높이 차를 WAVE_SPREAD 만큼 주고받는다(두 번 돌려 옆으로 빨리 번지게)
function waveStep() {
for (let i = 0; i < WAVE_COLS; i += 1) {
vs[i] += -WAVE_TENSION * hs[i];
vs[i] *= WAVE_DAMP;
hs[i] += vs[i];
}
for (let pass = 0; pass < 2; pass += 1) {
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) {
ld[i] = WAVE_SPREAD * (hs[i] - hs[i - 1]);
vs[i - 1] += ld[i];
}
if (i < WAVE_COLS - 1) {
rd[i] = WAVE_SPREAD * (hs[i] - hs[i + 1]);
vs[i + 1] += rd[i];
}
}
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) {
hs[i - 1] += ld[i];
}
if (i < WAVE_COLS - 1) {
hs[i + 1] += rd[i];
}
}
}
}
function step() {
const before = level;
level += (progress - level) * FILL_RISE_RATE;
if (Math.abs(progress - level) < 0.002) {
level = progress;
}
rise = level - before;
// 물줄기가 떨어지는 기둥을 오른 높이(px)만큼 아래로 민다 — 수위가 빨리 오를수록 크게 출렁인다
vs[POUR_COL] -= rise * (BOT - TOP) * POUR_KICK;
waveStep();
}
function flat() {
for (let i = 0; i < WAVE_COLS; i += 1) {
hs[i] = 0;
vs[i] = 0;
}
rise = 0;
}
function settle() {
level = progress;
flat();
}
function onRestart() {
level = 0;
flat();
}
function colX(i) { return X0 + (i * (X1 - X0)) / (WAVE_COLS - 1); }
function surface(baseY, mirror, k, dy) {
ctx.beginPath();
ctx.moveTo(X0 - 2, BOT + 4);
for (let i = 0; i < WAVE_COLS; i += 1) {
const h = hs[mirror ? WAVE_COLS - 1 - i : i];
ctx.lineTo(colX(i), baseY - h * k + dy);
}
ctx.lineTo(X1 + 2, BOT + 4);
ctx.closePath();
}
function draw() {
clearStage();
const baseY = lerp(BOT, TOP, level);
ctx.save();
clipLogo();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(X0 - 4, TOP - 4, X1 - X0 + 8, BOT - TOP + 8);
// 뒤 물결 — 같은 기둥을 거꾸로 읽어 엇갈린 수면을 하나 더 두면 물에 깊이가 생긴다
surface(baseY, true, 0.6, -5);
ctx.fillStyle = rgba(PAL[3], 0.55);
ctx.fill();
surface(baseY, false, 1, 0);
const g = ctx.createLinearGradient(0, baseY - 20, 0, BOT);
g.addColorStop(0, rgba(PAL[6], 1));
g.addColorStop(1, rgba(PAL[0], 1));
ctx.fillStyle = g;
ctx.fill();
// 수면 반짝임 줄
ctx.beginPath();
for (let i = 0; i < WAVE_COLS; i += 1) {
const y = baseY - hs[i];
if (i === 0) {
ctx.moveTo(colX(i), y);
}
else {
ctx.lineTo(colX(i), y);
}
}
ctx.strokeStyle = rgba(PAPER, 0.75);
ctx.lineWidth = 2;
ctx.stroke();
// 물줄기 — 수위가 오르는 동안만, 오르는 빠르기만큼 진하게
if (rise > 0.0004) {
ctx.fillStyle = rgba(PAL[6], clamp(rise * 300, 0.2, 0.9));
ctx.fillRect(colX(POUR_COL) - 2.5, TOP, 5, Math.max(0, baseY - hs[POUR_COL] - TOP));
}
ctx.restore();
logoGuide(0.55);
statRow(112, '진행률', pct(progress));
statRow(160, '수위', pct(level));
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// LiquidWaveFill - vanilla 판(02-logo-load-liquid-wave-fill) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LiquidWaveFill.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LiquidWaveFill() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 수면을 나누는 기둥 수
const WAVE_COLS = 48;
// 이웃 기둥과 높이 차를 주고받는 세기 — 0.5 를 넘으면 받은 것보다 더 돌려줘 터진다
const WAVE_SPREAD = 0.22;
// 16ms 스텝마다 기둥 속도에 곱하는 감쇠 — 1 에 가까울수록 오래 출렁인다
const WAVE_DAMP = 0.985;
// 기준 수위가 스텝마다 진행률과의 남은 차이 중 이 비율만큼 따라간다
const FILL_RISE_RATE = 0.08;
// 기둥을 기준 수위로 되돌리는 세기
const WAVE_TENSION = 0.012;
// 수위가 오른 만큼 물줄기 자리 기둥을 아래로 미는 세기
const POUR_KICK = 1.8;
const TXT_RUN = '물이 차오르는 중';
const X0 = CX - 94;
const X1 = CX + 94;
const TOP = CY - LOGO_R - 2;
const BOT = CY + LOGO_R + 2;
const POUR_COL = Math.round(WAVE_COLS * 0.3);
const hs: number[] = [];
const vs: number[] = [];
const ld: number[] = [];
const rd: number[] = [];
for (let i = 0; i < WAVE_COLS; i += 1) { hs.push(0); vs.push(0); ld.push(0); rd.push(0); }
let level = 0;
let rise = 0;
function shown(): number { return level; }
// 기둥 파동 한 스텝 — ① 기준 수위로 당기고 감쇠를 곱한 뒤 ② 이웃과 높이 차를 WAVE_SPREAD 만큼 주고받는다(두 번 돌려 옆으로 빨리 번지게)
function waveStep(): void {
for (let i = 0; i < WAVE_COLS; i += 1) {
vs[i] += -WAVE_TENSION * hs[i];
vs[i] *= WAVE_DAMP;
hs[i] += vs[i];
}
for (let pass = 0; pass < 2; pass += 1) {
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) { ld[i] = WAVE_SPREAD * (hs[i] - hs[i - 1]); vs[i - 1] += ld[i]; }
if (i < WAVE_COLS - 1) { rd[i] = WAVE_SPREAD * (hs[i] - hs[i + 1]); vs[i + 1] += rd[i]; }
}
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) { hs[i - 1] += ld[i]; }
if (i < WAVE_COLS - 1) { hs[i + 1] += rd[i]; }
}
}
}
function step(): void {
const before = level;
level += (progress - level) * FILL_RISE_RATE;
if (Math.abs(progress - level) < 0.002) { level = progress; }
rise = level - before;
// 물줄기가 떨어지는 기둥을 오른 높이(px)만큼 아래로 민다 — 수위가 빨리 오를수록 크게 출렁인다
vs[POUR_COL] -= rise * (BOT - TOP) * POUR_KICK;
waveStep();
}
function flat(): void {
for (let i = 0; i < WAVE_COLS; i += 1) { hs[i] = 0; vs[i] = 0; }
rise = 0;
}
function settle(): void {
level = progress;
flat();
}
function onRestart(): void {
level = 0;
flat();
}
function colX(i: number): number { return X0 + (i * (X1 - X0)) / (WAVE_COLS - 1); }
function surface(baseY: number, mirror: boolean, k: number, dy: number): void {
ctx.beginPath();
ctx.moveTo(X0 - 2, BOT + 4);
for (let i = 0; i < WAVE_COLS; i += 1) {
const h = hs[mirror ? WAVE_COLS - 1 - i : i];
ctx.lineTo(colX(i), baseY - h * k + dy);
}
ctx.lineTo(X1 + 2, BOT + 4);
ctx.closePath();
}
function draw(): void {
clearStage();
const baseY = lerp(BOT, TOP, level);
ctx.save();
clipLogo();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(X0 - 4, TOP - 4, X1 - X0 + 8, BOT - TOP + 8);
// 뒤 물결 — 같은 기둥을 거꾸로 읽어 엇갈린 수면을 하나 더 두면 물에 깊이가 생긴다
surface(baseY, true, 0.6, -5);
ctx.fillStyle = rgba(PAL[3], 0.55);
ctx.fill();
surface(baseY, false, 1, 0);
const g = ctx.createLinearGradient(0, baseY - 20, 0, BOT);
g.addColorStop(0, rgba(PAL[6], 1));
g.addColorStop(1, rgba(PAL[0], 1));
ctx.fillStyle = g;
ctx.fill();
// 수면 반짝임 줄
ctx.beginPath();
for (let i = 0; i < WAVE_COLS; i += 1) {
const y = baseY - hs[i];
if (i === 0) { ctx.moveTo(colX(i), y); } else { ctx.lineTo(colX(i), y); }
}
ctx.strokeStyle = rgba(PAPER, 0.75);
ctx.lineWidth = 2;
ctx.stroke();
// 물줄기 — 수위가 오르는 동안만, 오르는 빠르기만큼 진하게
if (rise > 0.0004) {
ctx.fillStyle = rgba(PAL[6], clamp(rise * 300, 0.2, 0.9));
ctx.fillRect(colX(POUR_COL) - 2.5, TOP, 5, Math.max(0, baseY - hs[POUR_COL] - TOP));
}
ctx.restore();
logoGuide(0.55);
statRow(112, '진행률', pct(progress));
statRow(160, '수위', pct(level));
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="육각형 마크 안에 진행률만큼 물이 차오르고 수면이 출렁이는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">물이 차오르는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}02 · 로고 모양 안에 물이 차오르며 수면이 출렁이는 로더 — 측정 누적면적 15.474% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 기둥 파동 한 스텝 — ① 기준 수위로 당기고 감쇠를 곱한 뒤 ② 이웃과 높이 차를 WAVE_SPREAD 만큼 주고받는다(두 번 돌려 옆으로 빨리 번지게)
function waveStep() {
for (let i = 0; i < WAVE_COLS; i += 1) {
vs[i] += -WAVE_TENSION * hs[i];
vs[i] *= WAVE_DAMP;
hs[i] += vs[i];
}
for (let pass = 0; pass < 2; pass += 1) {
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) {
ld[i] = WAVE_SPREAD * (hs[i] - hs[i - 1]);
vs[i - 1] += ld[i];
}
if (i < WAVE_COLS - 1) {
rd[i] = WAVE_SPREAD * (hs[i] - hs[i + 1]);
vs[i + 1] += rd[i];
}
}
for (let i = 0; i < WAVE_COLS; i += 1) {
if (i > 0) {
hs[i - 1] += ld[i];
}
if (i < WAVE_COLS - 1) {
hs[i + 1] += rd[i];
}
}
}
}
03로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더
화면 밖 캔버스에 마크를 한 번 칠해 getImageData 로 읽고 6px 격자에서 알파가 127 을 넘는 칸만 목표점으로 뽑으면 브라우저 렌더 기준 718개가 되며, 시드 4693 으로 섞은 순번이 p × 718 보다 작은 점만 바깥 고리에서 출발해 가속 −90(x − 목표) − 11·v 의 스프링으로 붙는다. 감쇠비가 0.58 이라 120px 떨어진 점은 9.1% 지나쳤다 돌아와 272ms 에 처음 1.5px 안에 들고 624ms 부터 그 안에 머무니, 점이 모여 마크가 되는 순간을 시작 신호로 쓰는 서비스 스플래시에 맞다. 렌더에서는 아홉 장면 가운데 가장 넓은 누적면적 39.6% 에 강도 79.0, 23/23 프레임이 나왔다.
<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="마크 화소에서 뽑은 점들이 진행률만큼 스프링으로 제자리에 모이는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">점이 모이는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 화소 표본 간격(px) — 마크 화소를 이 간격의 격자로 훑어 칠해진 칸만 목표점으로 남긴다
const SAMPLE_STEP_PX = 6;
// 목표로 당기는 스프링 세기(1/s²)와 속도에 비례한 감쇠(1/s)
const SPRING_K = 90;
const SPRING_DAMP = 11;
const DOT_R = 2.3;
// 도착 판정 거리(px)
const ARRIVE_PX = 1.5;
const TXT_RUN = '점이 모이는 중';
// 화면 밖 캔버스 — 마크를 한 번 칠해 화소를 읽고 버린다. 매 프레임 읽지 않는다
function sampleTargets() {
const off = document.createElement('canvas');
off.width = W;
off.height = H;
const o = off.getContext('2d');
if (!o) {
return [];
}
o.fillStyle = rgba(PAPER, 1);
o.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
o.moveTo(poly[0][0], poly[0][1]);
for (let i = 1; i < poly.length; i += 1) {
o.lineTo(poly[i][0], poly[i][1]);
}
o.closePath();
}
o.fill('evenodd');
const x0 = Math.floor(CX - LOGO_R);
const y0 = Math.floor(CY - LOGO_R);
const size = Math.ceil(LOGO_R * 2);
const img = o.getImageData(x0, y0, size, size).data;
const out = [];
const half = Math.floor(SAMPLE_STEP_PX / 2);
for (let y = half; y < size; y += SAMPLE_STEP_PX) {
for (let x = half; x < size; x += SAMPLE_STEP_PX) {
// 알파가 절반을 넘는 화소만 — 가장자리 반투명 화소는 버린다
if (img[(y * size + x) * 4 + 3] > 127) {
out.push([x0 + x, y0 + y]);
}
}
}
return out;
}
function makeDots() {
const t = sampleTargets();
const rng = makeRng(4693);
const dots = [];
for (let i = 0; i < t.length; i += 1) {
// 흩어진 자리 — 마크 둘레의 넓은 고리 위
const a = rng() * TAU;
const r = 128 + rng() * 96;
const sx = clamp(CX + Math.cos(a) * r * 1.2, 170, W - 8);
const sy = clamp(CY + Math.sin(a) * r * 0.78, 8, H - 8);
const k = clamp((t[i][1] - (CY - LOGO_R)) / (2 * LOGO_R), 0, 1);
dots.push({ tx: t[i][0], ty: t[i][1], sx: sx, sy: sy, x: sx, y: sy, vx: 0, vy: 0, rank: 0, ph: rng() * TAU, col: mix(PAL[6], PAL[1], k) });
}
// 모이는 순서 — 시드로 섞은 순번. 진행률 p 이면 순번이 p × 점 개수보다 작은 점만 목표로 간다
const order = [];
for (let i = 0; i < dots.length; i += 1) {
order.push(i);
}
for (let i = order.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const s = order[i];
order[i] = order[j];
order[j] = s;
}
for (let k = 0; k < order.length; k += 1) {
dots[order[k]].rank = k;
}
return dots;
}
const DOTS = makeDots();
const N = Math.max(1, DOTS.length);
let arrived = 0;
function shown() { return arrived / N; }
// 아직 차례가 안 온 점은 흩어진 자리 근처에서 천천히 맴돈다
function idleX(d) { return d.sx + Math.cos(clock * 0.0016 + d.ph) * 7; }
function idleY(d) { return d.sy + Math.sin(clock * 0.0021 + d.ph * 1.3) * 5; }
function goal(d, need) { return d.rank < need ? [d.tx, d.ty] : [idleX(d), idleY(d)]; }
function countArrived() {
let n = 0;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
if (Math.hypot(d.x - d.tx, d.y - d.ty) < ARRIVE_PX) {
n += 1;
}
}
arrived = n;
}
// 감쇠 스프링 — 가속 = −k(x − 목표) − d·v, 속도를 먼저 고치는 반암시 오일러(dt 0.016초)
function step() {
const need = progress * N;
const dt = FIXED_DT / 1000;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const g = goal(d, need);
const ax = -SPRING_K * (d.x - g[0]) - SPRING_DAMP * d.vx;
const ay = -SPRING_K * (d.y - g[1]) - SPRING_DAMP * d.vy;
d.vx += ax * dt;
d.vy += ay * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
}
countArrived();
}
function settle() {
const need = progress * N;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const g = goal(d, need);
d.x = g[0];
d.y = g[1];
d.vx = 0;
d.vy = 0;
}
countArrived();
}
function onRestart() {
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
d.x = d.sx;
d.y = d.sy;
d.vx = 0;
d.vy = 0;
}
arrived = 0;
}
function draw() {
clearStage();
logoGuide(0.12);
const need = progress * N;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const on = d.rank < need;
ctx.fillStyle = on ? rgba(d.col, 1) : rgba(PAPER, 0.32);
ctx.beginPath();
ctx.arc(d.x, d.y, on ? DOT_R : DOT_R * 0.7, 0, TAU);
ctx.fill();
}
statRow(112, '진행률', pct(progress));
statRow(160, '모인 점', arrived + ' / ' + DOTS.length);
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// PixelPointGather - vanilla 판(03-logo-load-pixel-point-gather) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PixelPointGather.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PixelPointGather() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 화소 표본 간격(px) — 마크 화소를 이 간격의 격자로 훑어 칠해진 칸만 목표점으로 남긴다
const SAMPLE_STEP_PX = 6;
// 목표로 당기는 스프링 세기(1/s²)와 속도에 비례한 감쇠(1/s)
const SPRING_K = 90;
const SPRING_DAMP = 11;
const DOT_R = 2.3;
// 도착 판정 거리(px)
const ARRIVE_PX = 1.5;
const TXT_RUN = '점이 모이는 중';
type Dot = { tx: number; ty: number; sx: number; sy: number; x: number; y: number; vx: number; vy: number; rank: number; ph: number; col: RGB };
// 화면 밖 캔버스 — 마크를 한 번 칠해 화소를 읽고 버린다. 매 프레임 읽지 않는다
function sampleTargets(): Pt[] {
const off = document.createElement('canvas');
off.width = W;
off.height = H;
const o = off.getContext('2d');
if (!o) { return []; }
o.fillStyle = rgba(PAPER, 1);
o.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
o.moveTo(poly[0][0], poly[0][1]);
for (let i = 1; i < poly.length; i += 1) { o.lineTo(poly[i][0], poly[i][1]); }
o.closePath();
}
o.fill('evenodd');
const x0 = Math.floor(CX - LOGO_R);
const y0 = Math.floor(CY - LOGO_R);
const size = Math.ceil(LOGO_R * 2);
const img = o.getImageData(x0, y0, size, size).data;
const out: Pt[] = [];
const half = Math.floor(SAMPLE_STEP_PX / 2);
for (let y = half; y < size; y += SAMPLE_STEP_PX) {
for (let x = half; x < size; x += SAMPLE_STEP_PX) {
// 알파가 절반을 넘는 화소만 — 가장자리 반투명 화소는 버린다
if (img[(y * size + x) * 4 + 3] > 127) { out.push([x0 + x, y0 + y]); }
}
}
return out;
}
function makeDots(): Dot[] {
const t = sampleTargets();
const rng = makeRng(4693);
const dots: Dot[] = [];
for (let i = 0; i < t.length; i += 1) {
// 흩어진 자리 — 마크 둘레의 넓은 고리 위
const a = rng() * TAU;
const r = 128 + rng() * 96;
const sx = clamp(CX + Math.cos(a) * r * 1.2, 170, W - 8);
const sy = clamp(CY + Math.sin(a) * r * 0.78, 8, H - 8);
const k = clamp((t[i][1] - (CY - LOGO_R)) / (2 * LOGO_R), 0, 1);
dots.push({ tx: t[i][0], ty: t[i][1], sx: sx, sy: sy, x: sx, y: sy, vx: 0, vy: 0, rank: 0, ph: rng() * TAU, col: mix(PAL[6], PAL[1], k) });
}
// 모이는 순서 — 시드로 섞은 순번. 진행률 p 이면 순번이 p × 점 개수보다 작은 점만 목표로 간다
const order: number[] = [];
for (let i = 0; i < dots.length; i += 1) { order.push(i); }
for (let i = order.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const s = order[i];
order[i] = order[j];
order[j] = s;
}
for (let k = 0; k < order.length; k += 1) { dots[order[k]].rank = k; }
return dots;
}
const DOTS: Dot[] = makeDots();
const N = Math.max(1, DOTS.length);
let arrived = 0;
function shown(): number { return arrived / N; }
// 아직 차례가 안 온 점은 흩어진 자리 근처에서 천천히 맴돈다
function idleX(d: Dot): number { return d.sx + Math.cos(clock * 0.0016 + d.ph) * 7; }
function idleY(d: Dot): number { return d.sy + Math.sin(clock * 0.0021 + d.ph * 1.3) * 5; }
function goal(d: Dot, need: number): Pt { return d.rank < need ? [d.tx, d.ty] : [idleX(d), idleY(d)]; }
function countArrived(): void {
let n = 0;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
if (Math.hypot(d.x - d.tx, d.y - d.ty) < ARRIVE_PX) { n += 1; }
}
arrived = n;
}
// 감쇠 스프링 — 가속 = −k(x − 목표) − d·v, 속도를 먼저 고치는 반암시 오일러(dt 0.016초)
function step(): void {
const need = progress * N;
const dt = FIXED_DT / 1000;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const g = goal(d, need);
const ax = -SPRING_K * (d.x - g[0]) - SPRING_DAMP * d.vx;
const ay = -SPRING_K * (d.y - g[1]) - SPRING_DAMP * d.vy;
d.vx += ax * dt;
d.vy += ay * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
}
countArrived();
}
function settle(): void {
const need = progress * N;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const g = goal(d, need);
d.x = g[0];
d.y = g[1];
d.vx = 0;
d.vy = 0;
}
countArrived();
}
function onRestart(): void {
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
d.x = d.sx;
d.y = d.sy;
d.vx = 0;
d.vy = 0;
}
arrived = 0;
}
function draw(): void {
clearStage();
logoGuide(0.12);
const need = progress * N;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const on = d.rank < need;
ctx.fillStyle = on ? rgba(d.col, 1) : rgba(PAPER, 0.32);
ctx.beginPath();
ctx.arc(d.x, d.y, on ? DOT_R : DOT_R * 0.7, 0, TAU);
ctx.fill();
}
statRow(112, '진행률', pct(progress));
statRow(160, '모인 점', arrived + ' / ' + DOTS.length);
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="마크 화소에서 뽑은 점들이 진행률만큼 스프링으로 제자리에 모이는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">점이 모이는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}03 · 로고 화소에서 뽑은 점들이 스프링으로 제자리를 찾는 로더 — 측정 누적면적 39.592% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 감쇠 스프링 — 가속 = −k(x − 목표) − d·v, 속도를 먼저 고치는 반암시 오일러(dt 0.016초)
function step() {
const need = progress * N;
const dt = FIXED_DT / 1000;
for (let i = 0; i < DOTS.length; i += 1) {
const d = DOTS[i];
const g = goal(d, need);
const ax = -SPRING_K * (d.x - g[0]) - SPRING_DAMP * d.vx;
const ay = -SPRING_K * (d.y - g[1]) - SPRING_DAMP * d.vy;
d.vx += ax * dt;
d.vy += ay * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
}
countArrived();
}
04로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더
216px 정사각형을 24px 타일로 나눠 마크에 걸치는 64장만 남기고, 열 + 행으로 정한 대각 단계(13단계)마다 60ms 씩 늦게 출발해 400ms 동안 뒤집히는 1,120ms 시간표를 진행률이 처음부터 끝까지 훑는다. 뒤집히는 타일은 가로 폭이 |cos(πf)| 로 접혔다 펴지고 옆으로 선 순간 명암이 0.45 까지 내려가며 살짝 들려 그림자를 떨어뜨리므로, 칸이 차례로 켜지는 느낌이 잘 맞는 대시보드 첫 로딩에 쓴다. 측정한 값은 누적면적 22.2%, 강도 80.7, 17/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="마크를 타일로 나눠 왼쪽 위부터 대각선 순서로 뒤집어 채우는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">타일이 뒤집히는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 타일 한 변(px)
const TILE_SIZE_PX = 24;
// 대각 단계 하나마다 늦게 출발하는 시간(ms) — 파면 시간표 위의 간격
const SWEEP_DELAY_MS = 60;
// 옆으로 선 순간(폭 0) 얼마나 어둡게 하는가 — 0 이면 명암 없음
const FLIP_SHADE = 0.55;
// 타일 하나가 뒤집히는 시간(ms)
const FLIP_MS = 400;
// 격자 칸 수 — 9 × 24 = 216px 정사각형이 마크(높이 208px)를 덮는다
const GRID = 9;
const TXT_RUN = '타일이 뒤집히는 중';
const GX0 = CX - (GRID * TILE_SIZE_PX) / 2;
const GY0 = CY - (GRID * TILE_SIZE_PX) / 2;
// 타일마다 4 × 4 표본점 중 마크 안에 든 개수 — 하나라도 들면 타일로 쓴다
function makeTiles() {
const out = [];
for (let r = 0; r < GRID; r += 1) {
for (let c = 0; c < GRID; c += 1) {
const x = GX0 + c * TILE_SIZE_PX;
const y = GY0 + r * TILE_SIZE_PX;
let n = 0;
for (let j = 0; j < 4; j += 1) {
for (let i = 0; i < 4; i += 1) {
if (insideLogo(x + ((i + 0.5) / 4) * TILE_SIZE_PX, y + ((j + 0.5) / 4) * TILE_SIZE_PX)) {
n += 1;
}
}
}
if (n > 0) {
out.push({ c: c, r: r, d: c + r, x: x, y: y, cover: n / 16 });
}
}
}
return out;
}
const TILES = makeTiles();
let DMIN = 1e9;
let DMAX = -1e9;
for (let i = 0; i < TILES.length; i += 1) {
DMIN = Math.min(DMIN, TILES[i].d);
DMAX = Math.max(DMAX, TILES[i].d);
}
// 파면 시간표 — 대각 단계 d 의 타일은 (d − DMIN) × SWEEP_DELAY_MS 에 출발해 FLIP_MS 동안 뒤집힌다. 진행률이 이 시간표 전체를 0 → 끝까지 훑는다
const WAVE_MS = (DMAX - DMIN) * SWEEP_DELAY_MS + FLIP_MS;
function flipAt(t, p) {
return clamp((p * WAVE_MS - (t.d - DMIN) * SWEEP_DELAY_MS) / FLIP_MS, 0, 1);
}
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
function draw() {
clearStage();
let flipped = 0;
const span = Math.max(1, DMAX - DMIN);
for (let i = 0; i < TILES.length; i += 1) {
const t = TILES[i];
const raw = flipAt(t, progress);
const f = still ? (raw >= 0.5 ? 1 : 0) : smooth(raw);
const ang = f * Math.PI;
// 가로 폭 = |cos| — 0° 와 180° 에서 제 폭, 90° 에서 옆으로 서서 폭 0
const wide = Math.abs(Math.cos(ang));
const lift = Math.sin(ang);
const shade = 1 - FLIP_SHADE * (1 - wide);
const front = f >= 0.5;
if (front) {
flipped += 1;
}
const cx = t.x + TILE_SIZE_PX / 2;
const cy = t.y + TILE_SIZE_PX / 2;
// 뒤집는 동안 살짝 들려 그림자가 아래로 떨어진다
if (lift > 0.01) {
ctx.fillStyle = rgba([0, 0, 0], 0.35 * lift);
ctx.fillRect(cx - (TILE_SIZE_PX / 2) * wide + 1, t.y + 3 + 5 * lift, TILE_SIZE_PX * wide, TILE_SIZE_PX);
}
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(wide, 0.02) * (1 + 0.08 * lift), 1 + 0.08 * lift);
ctx.translate(-cx, -cy);
if (front) {
ctx.beginPath();
ctx.rect(t.x + 1, t.y + 1, TILE_SIZE_PX - 2, TILE_SIZE_PX - 2);
ctx.clip();
logoPath();
ctx.fillStyle = rgba(shadeRGB(mix(PAL[0], PAL[7], (t.d - DMIN) / span), shade), 1);
ctx.fill('evenodd');
}
else {
ctx.fillStyle = rgba(shadeRGB(mix(PANEL, PAPER, 0.1 + 0.12 * t.cover), shade), 1);
ctx.fillRect(t.x + 1, t.y + 1, TILE_SIZE_PX - 2, TILE_SIZE_PX - 2);
ctx.fillStyle = rgba(PAPER, 0.18 * shade);
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, TAU);
ctx.fill();
}
ctx.restore();
}
logoGuide(0.14);
statRow(112, '진행률', pct(progress));
statRow(160, '뒤집힌 타일', flipped + ' / ' + TILES.length);
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// TileFlipSweep - vanilla 판(04-logo-load-tile-flip-sweep) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TileFlipSweep.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TileFlipSweep() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 타일 한 변(px)
const TILE_SIZE_PX = 24;
// 대각 단계 하나마다 늦게 출발하는 시간(ms) — 파면 시간표 위의 간격
const SWEEP_DELAY_MS = 60;
// 옆으로 선 순간(폭 0) 얼마나 어둡게 하는가 — 0 이면 명암 없음
const FLIP_SHADE = 0.55;
// 타일 하나가 뒤집히는 시간(ms)
const FLIP_MS = 400;
// 격자 칸 수 — 9 × 24 = 216px 정사각형이 마크(높이 208px)를 덮는다
const GRID = 9;
const TXT_RUN = '타일이 뒤집히는 중';
const GX0 = CX - (GRID * TILE_SIZE_PX) / 2;
const GY0 = CY - (GRID * TILE_SIZE_PX) / 2;
type Tile = { c: number; r: number; d: number; x: number; y: number; cover: number };
// 타일마다 4 × 4 표본점 중 마크 안에 든 개수 — 하나라도 들면 타일로 쓴다
function makeTiles(): Tile[] {
const out: Tile[] = [];
for (let r = 0; r < GRID; r += 1) {
for (let c = 0; c < GRID; c += 1) {
const x = GX0 + c * TILE_SIZE_PX;
const y = GY0 + r * TILE_SIZE_PX;
let n = 0;
for (let j = 0; j < 4; j += 1) {
for (let i = 0; i < 4; i += 1) {
if (insideLogo(x + ((i + 0.5) / 4) * TILE_SIZE_PX, y + ((j + 0.5) / 4) * TILE_SIZE_PX)) { n += 1; }
}
}
if (n > 0) { out.push({ c: c, r: r, d: c + r, x: x, y: y, cover: n / 16 }); }
}
}
return out;
}
const TILES: Tile[] = makeTiles();
let DMIN = 1e9;
let DMAX = -1e9;
for (let i = 0; i < TILES.length; i += 1) { DMIN = Math.min(DMIN, TILES[i].d); DMAX = Math.max(DMAX, TILES[i].d); }
// 파면 시간표 — 대각 단계 d 의 타일은 (d − DMIN) × SWEEP_DELAY_MS 에 출발해 FLIP_MS 동안 뒤집힌다. 진행률이 이 시간표 전체를 0 → 끝까지 훑는다
const WAVE_MS = (DMAX - DMIN) * SWEEP_DELAY_MS + FLIP_MS;
function flipAt(t: Tile, p: number): number {
return clamp((p * WAVE_MS - (t.d - DMIN) * SWEEP_DELAY_MS) / FLIP_MS, 0, 1);
}
function shown(): number { return progress; }
function step(): void { /* 뒤집힌 정도는 진행률만으로 정해진다 — 진행률을 되돌리면 타일도 되돌아 눕는다 */ }
function settle(): void { /* 동작 줄이기에서는 draw 가 반쯤 뒤집힌 타일을 앞·뒤 둘 중 하나로 정해 그린다 */ }
function onRestart(): void { /* 진행률 0 이 곧 모두 뒷면 */ }
function draw(): void {
clearStage();
let flipped = 0;
const span = Math.max(1, DMAX - DMIN);
for (let i = 0; i < TILES.length; i += 1) {
const t = TILES[i];
const raw = flipAt(t, progress);
const f = still ? (raw >= 0.5 ? 1 : 0) : smooth(raw);
const ang = f * Math.PI;
// 가로 폭 = |cos| — 0° 와 180° 에서 제 폭, 90° 에서 옆으로 서서 폭 0
const wide = Math.abs(Math.cos(ang));
const lift = Math.sin(ang);
const shade = 1 - FLIP_SHADE * (1 - wide);
const front = f >= 0.5;
if (front) { flipped += 1; }
const cx = t.x + TILE_SIZE_PX / 2;
const cy = t.y + TILE_SIZE_PX / 2;
// 뒤집는 동안 살짝 들려 그림자가 아래로 떨어진다
if (lift > 0.01) {
ctx.fillStyle = rgba([0, 0, 0], 0.35 * lift);
ctx.fillRect(cx - (TILE_SIZE_PX / 2) * wide + 1, t.y + 3 + 5 * lift, TILE_SIZE_PX * wide, TILE_SIZE_PX);
}
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(wide, 0.02) * (1 + 0.08 * lift), 1 + 0.08 * lift);
ctx.translate(-cx, -cy);
if (front) {
ctx.beginPath();
ctx.rect(t.x + 1, t.y + 1, TILE_SIZE_PX - 2, TILE_SIZE_PX - 2);
ctx.clip();
logoPath();
ctx.fillStyle = rgba(shadeRGB(mix(PAL[0], PAL[7], (t.d - DMIN) / span), shade), 1);
ctx.fill('evenodd');
} else {
ctx.fillStyle = rgba(shadeRGB(mix(PANEL, PAPER, 0.1 + 0.12 * t.cover), shade), 1);
ctx.fillRect(t.x + 1, t.y + 1, TILE_SIZE_PX - 2, TILE_SIZE_PX - 2);
ctx.fillStyle = rgba(PAPER, 0.18 * shade);
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, TAU);
ctx.fill();
}
ctx.restore();
}
logoGuide(0.14);
statRow(112, '진행률', pct(progress));
statRow(160, '뒤집힌 타일', flipped + ' / ' + TILES.length);
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="마크를 타일로 나눠 왼쪽 위부터 대각선 순서로 뒤집어 채우는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">타일이 뒤집히는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}04 · 로고를 타일로 쪼개 대각선 파면 순서로 뒤집어 채우는 로더 — 측정 누적면적 22.244% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 파면 시간표 — 대각 단계 d 의 타일은 (d − DMIN) × SWEEP_DELAY_MS 에 출발해 FLIP_MS 동안 뒤집힌다. 진행률이 이 시간표 전체를 0 → 끝까지 훑는다
const WAVE_MS = (DMAX - DMIN) * SWEEP_DELAY_MS + FLIP_MS;
function flipAt(t, p) {
return clamp((p * WAVE_MS - (t.d - DMIN) * SWEEP_DELAY_MS) / FLIP_MS, 0, 1);
}
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
for (let i = 0; i < TILES.length; i += 1) {
const t = TILES[i];
const raw = flipAt(t, progress);
const f = still ? (raw >= 0.5 ? 1 : 0) : smooth(raw);
const ang = f * Math.PI;
// 가로 폭 = |cos| — 0° 와 180° 에서 제 폭, 90° 에서 옆으로 서서 폭 0
const wide = Math.abs(Math.cos(ang));
const lift = Math.sin(ang);
const shade = 1 - FLIP_SHADE * (1 - wide);
const front = f >= 0.5;
05노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더
마크 안 화소 25,952개마다 4겹 값 노이즈와 가장자리 가까움을 반반 섞은 값을 처음에 한 번만 구해 두고, 프레임마다 1 − p × 1.07 로 내려가는 문턱과 비교해 넘은 화소만 칠하고 문턱 바로 위 0.07 폭은 하얗게 달군 띠로 남긴다. 진행률 20% 에서 먼저 드러난 화소는 가장자리에서 평균 3.3px 떨어져 있어(마크 전체 평균 15.3px) 윤곽부터 떠오르므로, 크리에이티브 포트폴리오의 인트로처럼 형체가 서서히 맺히는 연출에 쓴다. 렌더 수치로는 누적면적 17.7%, 강도 120.1, 17/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="노이즈 문턱값이 내려가며 마크가 가장자리부터 번져 나오는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">로고가 번져 나오는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 노이즈 격자 크기 — 화소 좌표에 곱하는 값(1/px). 0.04 면 첫 겹의 격자 한 칸이 25px
const NOISE_SCALE = 0.04;
// 노이즈를 몇 겹 겹치는가 — 겹마다 격자는 2배 촘촘, 세기는 절반
const NOISE_OCTAVES = 4;
// 문턱 바로 아래 띠의 폭(0~1 값 단위) — 이 띠에 든 화소는 밝게 달군다
const EDGE_BAND = 0.07;
// 가장자리에서 가까울수록 값을 얼마나 올리는가 — 0 이면 순수 노이즈, 1 이면 가장자리 거리만
const EDGE_WEIGHT = 0.5;
const TXT_RUN = '로고가 번져 나오는 중';
const BX0 = Math.floor(CX - 92);
const BY0 = Math.floor(CY - LOGO_R - 1);
const BW = 184;
const BH = 210;
// 격자 꼭짓점마다 시드로 정한 값 — 정수 좌표 두 개를 곱해 섞어 0~1
function hash2(ix, iy) {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ 469;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 꼭짓점 네 값을 smoothstep 가중으로 섞는다
function valueNoise(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(hash2(ix, iy), hash2(ix + 1, iy), u);
const b = lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), u);
return lerp(a, b, v);
}
function fbm(x, y) {
let s = 0;
let amp = 0.5;
let f = 1;
let norm = 0;
for (let o = 0; o < NOISE_OCTAVES; o += 1) {
s += amp * valueNoise(x * f, y * f);
norm += amp;
amp *= 0.5;
f *= 2;
}
return s / norm;
}
// 화소에서 가장 가까운 외곽선 변까지의 거리(px)
function edgeDist(x, y) {
let best = 1e9;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0; i < poly.length; i += 1) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const k = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy), 0, 1);
best = Math.min(best, Math.hypot(x - a[0] - dx * k, y - a[1] - dy * k));
}
}
return best;
}
// 미리 구하는 값 — 마크 안 화소마다 노이즈와 가장자리 가까움을 섞어 0~1 로 편다. 한 번만 계산하고 매 프레임은 문턱 비교만 한다
const FIELD = new Float32Array(BW * BH);
const INSIDE = new Uint8Array(BW * BH);
let insideCount = 0;
function buildField() {
const n = new Float32Array(BW * BH);
const e = new Float32Array(BW * BH);
let nMin = 1;
let nMax = 0;
let eMax = 0;
for (let y = 0; y < BH; y += 1) {
for (let x = 0; x < BW; x += 1) {
const px = BX0 + x + 0.5;
const py = BY0 + y + 0.5;
if (!insideLogo(px, py)) {
continue;
}
const i = y * BW + x;
INSIDE[i] = 1;
insideCount += 1;
n[i] = fbm(px * NOISE_SCALE, py * NOISE_SCALE);
e[i] = edgeDist(px, py);
nMin = Math.min(nMin, n[i]);
nMax = Math.max(nMax, n[i]);
eMax = Math.max(eMax, e[i]);
}
}
let fMin = 1e9;
let fMax = -1e9;
for (let i = 0; i < FIELD.length; i += 1) {
if (!INSIDE[i]) {
continue;
}
const nn = (n[i] - nMin) / Math.max(1e-6, nMax - nMin);
FIELD[i] = (1 - EDGE_WEIGHT) * nn + EDGE_WEIGHT * (1 - e[i] / Math.max(1e-6, eMax));
fMin = Math.min(fMin, FIELD[i]);
fMax = Math.max(fMax, FIELD[i]);
}
for (let i = 0; i < FIELD.length; i += 1) {
if (INSIDE[i]) {
FIELD[i] = (FIELD[i] - fMin) / Math.max(1e-6, fMax - fMin);
}
}
}
buildField();
// 문턱 — 진행률 0 이면 1(아무 화소도 못 넘음), 1 이면 −EDGE_BAND(띠까지 전부 넘음). 진행률이 오를수록 내려간다
function threshold(p) { return 1 - clamp(p, 0, 1) * (1 + EDGE_BAND); }
const off = document.createElement('canvas');
off.width = BW;
off.height = BH;
const offCtx = off.getContext('2d');
const img = offCtx ? offCtx.createImageData(BW, BH) : null;
let revealed = 0;
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
function paint(thr) {
if (!img || !offCtx) {
return;
}
const px = img.data;
let shownPx = 0;
for (let i = 0; i < FIELD.length; i += 1) {
const o = i * 4;
if (!INSIDE[i]) {
px[o + 3] = 0;
continue;
}
const v = FIELD[i];
if (v >= thr + EDGE_BAND) {
// 넘은 화소 — 값이 높을수록(가장자리 쪽) 하늘색, 낮을수록 보라
const c = mix(PAL[3], PAL[0], v);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = 255;
shownPx += 1;
}
else if (v >= thr) {
// 띠 — 문턱에 가까울수록 더 하얗게 달군다
const k = (v - thr) / EDGE_BAND;
const c = mix(PAPER, PAL[4], k);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = Math.round(255 * (0.55 + 0.45 * k));
}
else {
px[o + 3] = 0;
}
}
revealed = shownPx;
offCtx.putImageData(img, 0, 0);
ctx.drawImage(off, BX0, BY0);
}
function draw() {
clearStage();
logoPath();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fill('evenodd');
paint(threshold(progress));
logoGuide(0.3);
statRow(112, '진행률', pct(progress));
statRow(160, '드러난 화소', pct(revealed / Math.max(1, insideCount)));
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// NoiseThresholdBleed - vanilla 판(05-logo-load-noise-threshold-bleed) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./NoiseThresholdBleed.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function NoiseThresholdBleed() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 노이즈 격자 크기 — 화소 좌표에 곱하는 값(1/px). 0.04 면 첫 겹의 격자 한 칸이 25px
const NOISE_SCALE = 0.04;
// 노이즈를 몇 겹 겹치는가 — 겹마다 격자는 2배 촘촘, 세기는 절반
const NOISE_OCTAVES = 4;
// 문턱 바로 아래 띠의 폭(0~1 값 단위) — 이 띠에 든 화소는 밝게 달군다
const EDGE_BAND = 0.07;
// 가장자리에서 가까울수록 값을 얼마나 올리는가 — 0 이면 순수 노이즈, 1 이면 가장자리 거리만
const EDGE_WEIGHT = 0.5;
const TXT_RUN = '로고가 번져 나오는 중';
const BX0 = Math.floor(CX - 92);
const BY0 = Math.floor(CY - LOGO_R - 1);
const BW = 184;
const BH = 210;
// 격자 꼭짓점마다 시드로 정한 값 — 정수 좌표 두 개를 곱해 섞어 0~1
function hash2(ix: number, iy: number): number {
let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ 469;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 꼭짓점 네 값을 smoothstep 가중으로 섞는다
function valueNoise(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = lerp(hash2(ix, iy), hash2(ix + 1, iy), u);
const b = lerp(hash2(ix, iy + 1), hash2(ix + 1, iy + 1), u);
return lerp(a, b, v);
}
function fbm(x: number, y: number): number {
let s = 0;
let amp = 0.5;
let f = 1;
let norm = 0;
for (let o = 0; o < NOISE_OCTAVES; o += 1) {
s += amp * valueNoise(x * f, y * f);
norm += amp;
amp *= 0.5;
f *= 2;
}
return s / norm;
}
// 화소에서 가장 가까운 외곽선 변까지의 거리(px)
function edgeDist(x: number, y: number): number {
let best = 1e9;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0; i < poly.length; i += 1) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const k = clamp(((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy), 0, 1);
best = Math.min(best, Math.hypot(x - a[0] - dx * k, y - a[1] - dy * k));
}
}
return best;
}
// 미리 구하는 값 — 마크 안 화소마다 노이즈와 가장자리 가까움을 섞어 0~1 로 편다. 한 번만 계산하고 매 프레임은 문턱 비교만 한다
const FIELD = new Float32Array(BW * BH);
const INSIDE = new Uint8Array(BW * BH);
let insideCount = 0;
function buildField(): void {
const n = new Float32Array(BW * BH);
const e = new Float32Array(BW * BH);
let nMin = 1;
let nMax = 0;
let eMax = 0;
for (let y = 0; y < BH; y += 1) {
for (let x = 0; x < BW; x += 1) {
const px = BX0 + x + 0.5;
const py = BY0 + y + 0.5;
if (!insideLogo(px, py)) { continue; }
const i = y * BW + x;
INSIDE[i] = 1;
insideCount += 1;
n[i] = fbm(px * NOISE_SCALE, py * NOISE_SCALE);
e[i] = edgeDist(px, py);
nMin = Math.min(nMin, n[i]);
nMax = Math.max(nMax, n[i]);
eMax = Math.max(eMax, e[i]);
}
}
let fMin = 1e9;
let fMax = -1e9;
for (let i = 0; i < FIELD.length; i += 1) {
if (!INSIDE[i]) { continue; }
const nn = (n[i] - nMin) / Math.max(1e-6, nMax - nMin);
FIELD[i] = (1 - EDGE_WEIGHT) * nn + EDGE_WEIGHT * (1 - e[i] / Math.max(1e-6, eMax));
fMin = Math.min(fMin, FIELD[i]);
fMax = Math.max(fMax, FIELD[i]);
}
for (let i = 0; i < FIELD.length; i += 1) {
if (INSIDE[i]) { FIELD[i] = (FIELD[i] - fMin) / Math.max(1e-6, fMax - fMin); }
}
}
buildField();
// 문턱 — 진행률 0 이면 1(아무 화소도 못 넘음), 1 이면 −EDGE_BAND(띠까지 전부 넘음). 진행률이 오를수록 내려간다
function threshold(p: number): number { return 1 - clamp(p, 0, 1) * (1 + EDGE_BAND); }
const off = document.createElement('canvas');
off.width = BW;
off.height = BH;
const offCtx = off.getContext('2d');
const img = offCtx ? offCtx.createImageData(BW, BH) : null;
let revealed = 0;
function shown(): number { return progress; }
function step(): void { /* 드러난 화소는 진행률과 미리 구한 값만으로 정해진다 */ }
function settle(): void { /* 상태가 없어 동작 줄이기에서도 같은 그림 */ }
function onRestart(): void { /* 진행률 0 이 곧 빈 마크 */ }
function paint(thr: number): void {
if (!img || !offCtx) { return; }
const px = img.data;
let shownPx = 0;
for (let i = 0; i < FIELD.length; i += 1) {
const o = i * 4;
if (!INSIDE[i]) { px[o + 3] = 0; continue; }
const v = FIELD[i];
if (v >= thr + EDGE_BAND) {
// 넘은 화소 — 값이 높을수록(가장자리 쪽) 하늘색, 낮을수록 보라
const c = mix(PAL[3], PAL[0], v);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = 255;
shownPx += 1;
} else if (v >= thr) {
// 띠 — 문턱에 가까울수록 더 하얗게 달군다
const k = (v - thr) / EDGE_BAND;
const c = mix(PAPER, PAL[4], k);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = Math.round(255 * (0.55 + 0.45 * k));
} else {
px[o + 3] = 0;
}
}
revealed = shownPx;
offCtx.putImageData(img, 0, 0);
ctx.drawImage(off, BX0, BY0);
}
function draw(): void {
clearStage();
logoPath();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fill('evenodd');
paint(threshold(progress));
logoGuide(0.3);
statRow(112, '진행률', pct(progress));
statRow(160, '드러난 화소', pct(revealed / Math.max(1, insideCount)));
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="노이즈 문턱값이 내려가며 마크가 가장자리부터 번져 나오는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">로고가 번져 나오는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}05 · 노이즈 문턱값이 내려가며 로고가 가장자리부터 번져 나오는 로더 — 측정 누적면적 17.708% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function paint(thr) {
if (!img || !offCtx) {
return;
}
const px = img.data;
let shownPx = 0;
for (let i = 0; i < FIELD.length; i += 1) {
const o = i * 4;
if (!INSIDE[i]) {
px[o + 3] = 0;
continue;
}
const v = FIELD[i];
if (v >= thr + EDGE_BAND) {
// 넘은 화소 — 값이 높을수록(가장자리 쪽) 하늘색, 낮을수록 보라
const c = mix(PAL[3], PAL[0], v);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = 255;
shownPx += 1;
}
else if (v >= thr) {
// 띠 — 문턱에 가까울수록 더 하얗게 달군다
const k = (v - thr) / EDGE_BAND;
const c = mix(PAPER, PAL[4], k);
px[o] = c[0];
px[o + 1] = c[1];
px[o + 2] = c[2];
px[o + 3] = Math.round(255 * (0.55 + 0.45 * k));
}
else {
px[o + 3] = 0;
}
}
revealed = shownPx;
offCtx.putImageData(img, 0, 0);
ctx.drawImage(off, BX0, BY0);
}
06로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더
마크를 감싼 둘레 965.6px 둥근 사각 경로를 머리가 1초에 0.55바퀴씩 돌고, 지난 위치를 90칸 고리 버퍼에 남겨 진행률 0·25·50·100% 에서 꼬리를 4·26·47·90점까지 늘리며 i 번째 점은 0.975^i 밝기로 흐려진다. 진행이 멈춰도 머리는 계속 돌기 때문에 결제 처리처럼 기다림이 길어질 수 있는 화면에서 멈춘 화면으로 오해받지 않는다. 23/23 프레임 내내 움직였고 누적면적은 26.9%, 강도는 64.3 이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="마크 둘레를 도는 혜성의 꼬리가 진행률만큼 길어지는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">혜성이 도는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 머리가 도는 빠르기 — 1초에 경로를 몇 바퀴 도는가
const ORBIT_SPEED = 0.55;
// 고리 버퍼 칸 수 = 꼬리 점 최대 개수(진행률 100% 의 꼬리)
const COMET_TAIL_LEN = 90;
// 꼬리 점 하나 뒤로 갈 때마다 밝기에 곱하는 값
const TAIL_DECAY = 0.975;
// 진행률 0 에서도 남기는 꼬리 점 수
const TAIL_MIN = 4;
const TRACK_HALF = 128;
const TRACK_R = 34;
const TXT_RUN = '혜성이 도는 중';
function lineSeg(a, b) {
return { len: Math.hypot(b[0] - a[0], b[1] - a[1]), at: function (k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k)]; } };
}
function arcSeg(c, a0) {
return {
len: (TRACK_R * Math.PI) / 2,
at: function (k) { const a = a0 + (k * Math.PI) / 2; return [c[0] + Math.cos(a) * TRACK_R, c[1] + Math.sin(a) * TRACK_R]; },
};
}
const TL = CX - TRACK_HALF;
const TR = CX + TRACK_HALF;
const TT = CY - TRACK_HALF;
const TB = CY + TRACK_HALF;
const SEGS = [
lineSeg([CX, TT], [TR - TRACK_R, TT]),
arcSeg([TR - TRACK_R, TT + TRACK_R], -Math.PI / 2),
lineSeg([TR, TT + TRACK_R], [TR, TB - TRACK_R]),
arcSeg([TR - TRACK_R, TB - TRACK_R], 0),
lineSeg([TR - TRACK_R, TB], [TL + TRACK_R, TB]),
arcSeg([TL + TRACK_R, TB - TRACK_R], Math.PI / 2),
lineSeg([TL, TB - TRACK_R], [TL, TT + TRACK_R]),
arcSeg([TL + TRACK_R, TT + TRACK_R], Math.PI),
lineSeg([TL + TRACK_R, TT], [CX, TT]),
];
let PERIM = 0;
for (let i = 0; i < SEGS.length; i += 1) {
PERIM += SEGS[i].len;
}
function trackAt(s) {
let r = ((s % PERIM) + PERIM) % PERIM;
for (let i = 0; i < SEGS.length; i += 1) {
if (r <= SEGS[i].len) {
return SEGS[i].at(r / SEGS[i].len);
}
r -= SEGS[i].len;
}
return SEGS[0].at(0);
}
// 한 스텝에 머리가 나아가는 거리(px)
const STEP_LEN = (ORBIT_SPEED * PERIM * FIXED_DT) / 1000;
// 고리 버퍼 — 지난 위치를 칸 하나씩 덮어쓴다. 꼬리를 늘리거나 줄여도 배열을 새로 만들지 않는다
const ringX = new Float32Array(COMET_TAIL_LEN);
const ringY = new Float32Array(COMET_TAIL_LEN);
let ringHead = 0;
let ringCount = 0;
let headS = 0;
function push(p) {
ringX[ringHead] = p[0];
ringY[ringHead] = p[1];
ringHead = (ringHead + 1) % COMET_TAIL_LEN;
ringCount = Math.min(ringCount + 1, COMET_TAIL_LEN);
}
// i 번째로 최근 위치(0 이 머리)
function ringAt(i) {
const k = (ringHead - 1 - i + COMET_TAIL_LEN * 2) % COMET_TAIL_LEN;
return [ringX[k], ringY[k]];
}
function tailCount() {
return Math.min(ringCount, TAIL_MIN + Math.round(clamp(progress, 0, 1) * (COMET_TAIL_LEN - TAIL_MIN)));
}
function shown() { return progress; }
function step() {
headS += STEP_LEN;
push(trackAt(headS));
}
// 동작 줄이기 — 머리는 제자리에 두고 버퍼를 머리 뒤 경로 위치로 한 번에 채워 그 진행률의 꼬리를 그린다
function settle() {
ringHead = 0;
ringCount = 0;
for (let i = COMET_TAIL_LEN - 1; i >= 0; i -= 1) {
push(trackAt(headS - i * STEP_LEN));
}
}
function onRestart() { }
settle();
function draw() {
clearStage();
// 경로
ctx.beginPath();
for (let i = 0; i <= 120; i += 1) {
const p = trackAt((i / 120) * PERIM);
if (i === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
ctx.strokeStyle = rgba(PAPER, 0.12);
ctx.lineWidth = 4;
ctx.stroke();
// 마크 — 진행률만큼 짙어진다
logoPath();
ctx.fillStyle = rgba(PAL[3], 0.18 + 0.62 * clamp(progress, 0, 1));
ctx.fill('evenodd');
logoGuide(0.4);
// 꼬리 — 뒤로 갈수록 TAIL_DECAY 를 거듭 곱한 밝기(i 번째 = TAIL_DECAY^i)
const n = tailCount();
ctx.lineCap = 'round';
for (let i = n - 1; i >= 1; i -= 1) {
const a = ringAt(i);
const b = ringAt(i - 1);
const bright = Math.pow(TAIL_DECAY, i);
ctx.strokeStyle = rgba(mix(PAL[1], PAL[4], i / COMET_TAIL_LEN), bright);
ctx.lineWidth = 2 + 6 * bright;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
const h = ringAt(0);
const g = ctx.createRadialGradient(h[0], h[1], 0, h[0], h[1], 16);
g.addColorStop(0, rgba(PAPER, 1));
g.addColorStop(0.4, rgba(PAL[1], 0.7));
g.addColorStop(1, rgba(PAL[1], 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(h[0], h[1], 16, 0, TAU);
ctx.fill();
statRow(112, '진행률', pct(progress));
statRow(160, '꼬리 점', n + ' / ' + COMET_TAIL_LEN);
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// CometOrbitTail - vanilla 판(06-logo-load-comet-orbit-tail) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CometOrbitTail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CometOrbitTail() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 머리가 도는 빠르기 — 1초에 경로를 몇 바퀴 도는가
const ORBIT_SPEED = 0.55;
// 고리 버퍼 칸 수 = 꼬리 점 최대 개수(진행률 100% 의 꼬리)
const COMET_TAIL_LEN = 90;
// 꼬리 점 하나 뒤로 갈 때마다 밝기에 곱하는 값
const TAIL_DECAY = 0.975;
// 진행률 0 에서도 남기는 꼬리 점 수
const TAIL_MIN = 4;
const TRACK_HALF = 128;
const TRACK_R = 34;
const TXT_RUN = '혜성이 도는 중';
// 둥근 사각 경로 — 위 가운데에서 출발해 시계 방향. 곧은 변과 4분원을 호 길이로 이어 붙인다
type Seg = { len: number; at: (k: number) => Pt };
function lineSeg(a: Pt, b: Pt): Seg {
return { len: Math.hypot(b[0] - a[0], b[1] - a[1]), at: function (k: number): Pt { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k)]; } };
}
function arcSeg(c: Pt, a0: number): Seg {
return {
len: (TRACK_R * Math.PI) / 2,
at: function (k: number): Pt { const a = a0 + (k * Math.PI) / 2; return [c[0] + Math.cos(a) * TRACK_R, c[1] + Math.sin(a) * TRACK_R]; },
};
}
const TL = CX - TRACK_HALF;
const TR = CX + TRACK_HALF;
const TT = CY - TRACK_HALF;
const TB = CY + TRACK_HALF;
const SEGS: Seg[] = [
lineSeg([CX, TT], [TR - TRACK_R, TT]),
arcSeg([TR - TRACK_R, TT + TRACK_R], -Math.PI / 2),
lineSeg([TR, TT + TRACK_R], [TR, TB - TRACK_R]),
arcSeg([TR - TRACK_R, TB - TRACK_R], 0),
lineSeg([TR - TRACK_R, TB], [TL + TRACK_R, TB]),
arcSeg([TL + TRACK_R, TB - TRACK_R], Math.PI / 2),
lineSeg([TL, TB - TRACK_R], [TL, TT + TRACK_R]),
arcSeg([TL + TRACK_R, TT + TRACK_R], Math.PI),
lineSeg([TL + TRACK_R, TT], [CX, TT]),
];
let PERIM = 0;
for (let i = 0; i < SEGS.length; i += 1) { PERIM += SEGS[i].len; }
function trackAt(s: number): Pt {
let r = ((s % PERIM) + PERIM) % PERIM;
for (let i = 0; i < SEGS.length; i += 1) {
if (r <= SEGS[i].len) { return SEGS[i].at(r / SEGS[i].len); }
r -= SEGS[i].len;
}
return SEGS[0].at(0);
}
// 한 스텝에 머리가 나아가는 거리(px)
const STEP_LEN = (ORBIT_SPEED * PERIM * FIXED_DT) / 1000;
// 고리 버퍼 — 지난 위치를 칸 하나씩 덮어쓴다. 꼬리를 늘리거나 줄여도 배열을 새로 만들지 않는다
const ringX = new Float32Array(COMET_TAIL_LEN);
const ringY = new Float32Array(COMET_TAIL_LEN);
let ringHead = 0;
let ringCount = 0;
let headS = 0;
function push(p: Pt): void {
ringX[ringHead] = p[0];
ringY[ringHead] = p[1];
ringHead = (ringHead + 1) % COMET_TAIL_LEN;
ringCount = Math.min(ringCount + 1, COMET_TAIL_LEN);
}
// i 번째로 최근 위치(0 이 머리)
function ringAt(i: number): Pt {
const k = (ringHead - 1 - i + COMET_TAIL_LEN * 2) % COMET_TAIL_LEN;
return [ringX[k], ringY[k]];
}
function tailCount(): number {
return Math.min(ringCount, TAIL_MIN + Math.round(clamp(progress, 0, 1) * (COMET_TAIL_LEN - TAIL_MIN)));
}
function shown(): number { return progress; }
function step(): void {
headS += STEP_LEN;
push(trackAt(headS));
}
// 동작 줄이기 — 머리는 제자리에 두고 버퍼를 머리 뒤 경로 위치로 한 번에 채워 그 진행률의 꼬리를 그린다
function settle(): void {
ringHead = 0;
ringCount = 0;
for (let i = COMET_TAIL_LEN - 1; i >= 0; i -= 1) { push(trackAt(headS - i * STEP_LEN)); }
}
function onRestart(): void { /* 혜성은 계속 돈다 — 진행률이 0 으로 돌아가면 꼬리가 TAIL_MIN 으로 줄어든다 */ }
settle();
function draw(): void {
clearStage();
// 경로
ctx.beginPath();
for (let i = 0; i <= 120; i += 1) {
const p = trackAt((i / 120) * PERIM);
if (i === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
ctx.strokeStyle = rgba(PAPER, 0.12);
ctx.lineWidth = 4;
ctx.stroke();
// 마크 — 진행률만큼 짙어진다
logoPath();
ctx.fillStyle = rgba(PAL[3], 0.18 + 0.62 * clamp(progress, 0, 1));
ctx.fill('evenodd');
logoGuide(0.4);
// 꼬리 — 뒤로 갈수록 TAIL_DECAY 를 거듭 곱한 밝기(i 번째 = TAIL_DECAY^i)
const n = tailCount();
ctx.lineCap = 'round';
for (let i = n - 1; i >= 1; i -= 1) {
const a = ringAt(i);
const b = ringAt(i - 1);
const bright = Math.pow(TAIL_DECAY, i);
ctx.strokeStyle = rgba(mix(PAL[1], PAL[4], i / COMET_TAIL_LEN), bright);
ctx.lineWidth = 2 + 6 * bright;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
const h = ringAt(0);
const g = ctx.createRadialGradient(h[0], h[1], 0, h[0], h[1], 16);
g.addColorStop(0, rgba(PAPER, 1));
g.addColorStop(0.4, rgba(PAL[1], 0.7));
g.addColorStop(1, rgba(PAL[1], 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(h[0], h[1], 16, 0, TAU);
ctx.fill();
statRow(112, '진행률', pct(progress));
statRow(160, '꼬리 점', n + ' / ' + COMET_TAIL_LEN);
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="마크 둘레를 도는 혜성의 꼬리가 진행률만큼 길어지는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">혜성이 도는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}06 · 로고 둘레를 도는 혜성 꼬리가 진행률만큼 길어지는 로더 — 측정 누적면적 26.93% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function push(p) {
ringX[ringHead] = p[0];
ringY[ringHead] = p[1];
ringHead = (ringHead + 1) % COMET_TAIL_LEN;
ringCount = Math.min(ringCount + 1, COMET_TAIL_LEN);
}
// i 번째로 최근 위치(0 이 머리)
function ringAt(i) {
const k = (ringHead - 1 - i + COMET_TAIL_LEN * 2) % COMET_TAIL_LEN;
return [ringX[k], ringY[k]];
}
function tailCount() {
return Math.min(ringCount, TAIL_MIN + Math.round(clamp(progress, 0, 1) * (COMET_TAIL_LEN - TAIL_MIN)));
}
07원 둘레의 점이 로고 외곽선으로 녹아드는 로더
원과 마크 외곽선을 같은 호 길이 간격의 96점으로 다시 뽑은 다음, 원의 시작점을 32개 후보로 돌려 보며 짝지은 거리 제곱 합이 가장 작은 자리(마크 쪽 번호를 27칸 민 자리)를 고르고 느리게-빠르게-느리게 곡선으로 짝끼리 보간한다. 이 정렬 하나로 합이 1,501,470 에서 76,223 으로 19.7배 줄고 가장 멀리 가는 점도 129.1px 에서 32.8px 로 짧아져, 로그인 뒤 원형 스피너가 마크로 녹아드는 전환에 쓸 수 있다. 수치는 누적면적 16.2%, 강도 92.2, 20/23 프레임으로 잡혔다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="원 둘레의 점이 진행률만큼 육각형 마크의 외곽선으로 녹아드는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">원이 로고로 녹는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 바깥 외곽선을 다시 뽑는 점 개수 — 별 구멍은 그 절반
const MORPH_POINTS = 96;
// 시작점 정렬 후보 수 — 로고 점 MORPH_POINTS 개 중 이만큼을 같은 간격으로 골라 시작점으로 놓아 본다
const ALIGN_SEARCH = 32;
// 진행률에 씌우는 느리게-빠르게-느리게 곡선의 지수
const MORPH_EASE = 2.2;
const CIRCLE_R = 66;
const HOLE_R0 = 9;
const TXT_RUN = '원이 로고로 녹는 중';
// 닫힌 외곽선을 같은 호 길이 간격의 n 개 점으로 다시 뽑는다 — 꼭짓점이 몰린 둥근 모서리도 곧은 변과 같은 간격이 된다
function resample(c, n) {
const total = ringLength(c);
const gap = total / n;
const out = [];
let seg = 0;
let segStart = 0;
for (let k = 0; k < n; k += 1) {
const want = k * gap;
let a = c[seg];
let b = c[(seg + 1) % c.length];
let len = Math.hypot(b[0] - a[0], b[1] - a[1]);
while (segStart + len < want && seg < c.length - 1) {
segStart += len;
seg += 1;
a = c[seg];
b = c[(seg + 1) % c.length];
len = Math.hypot(b[0] - a[0], b[1] - a[1]);
}
const f = len > 0 ? clamp((want - segStart) / len, 0, 1) : 0;
out.push([lerp(a[0], b[0], f), lerp(a[1], b[1], f)]);
}
return out;
}
// 원 — 오른쪽(0°)에서 출발해 시계 방향으로 같은 각도 간격
function circlePts(r, n) {
const out = [];
for (let k = 0; k < n; k += 1) {
const a = (k / n) * TAU;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 짝지은 거리 제곱의 합 — 원의 i 번째 점과 로고의 (i + off) 번째 점
function alignCost(a, b, off) {
let s = 0;
for (let i = 0; i < a.length; i += 1) {
const q = b[(i + off) % b.length];
const dx = a[i][0] - q[0];
const dy = a[i][1] - q[1];
s += dx * dx + dy * dy;
}
return s;
}
function bestOffset(a, b) {
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < ALIGN_SEARCH; k += 1) {
const off = Math.round((k * b.length) / ALIGN_SEARCH) % b.length;
const cost = alignCost(a, b, off);
if (cost < bestCost) {
bestCost = cost;
best = off;
}
}
return best;
}
function rotateFrom(b, off) {
const out = [];
for (let i = 0; i < b.length; i += 1) {
out.push(b[(i + off) % b.length]);
}
return out;
}
const SRC_OUT = circlePts(CIRCLE_R, MORPH_POINTS);
const RAW_OUT = resample(LOGO_OUTER, MORPH_POINTS);
const OFF_OUT = bestOffset(SRC_OUT, RAW_OUT);
const DST_OUT = rotateFrom(RAW_OUT, OFF_OUT);
const SRC_HOLE = circlePts(HOLE_R0, MORPH_POINTS / 2);
const RAW_HOLE = resample(LOGO_HOLE, MORPH_POINTS / 2);
const OFF_HOLE = bestOffset(SRC_HOLE, RAW_HOLE);
const DST_HOLE = rotateFrom(RAW_HOLE, OFF_HOLE);
function morphEase(p) {
const x = clamp(p, 0, 1);
return x < 0.5 ? 0.5 * Math.pow(2 * x, MORPH_EASE) : 1 - 0.5 * Math.pow(2 * (1 - x), MORPH_EASE);
}
function blend(a, b, t) {
const out = [];
for (let i = 0; i < a.length; i += 1) {
out.push([lerp(a[i][0], b[i][0], t), lerp(a[i][1], b[i][1], t)]);
}
return out;
}
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
function draw() {
clearStage();
const t = morphEase(progress);
const outer = blend(SRC_OUT, DST_OUT, t);
const hole = blend(SRC_HOLE, DST_HOLE, t);
ctx.beginPath();
contourPath(outer);
contourPath(hole);
const g = ctx.createLinearGradient(0, CY - LOGO_R, 0, CY + LOGO_R);
g.addColorStop(0, rgba(mix(PAL[6], PAL[0], t), 0.92));
g.addColorStop(1, rgba(mix(PAL[3], PAL[7], t), 0.92));
ctx.fillStyle = g;
ctx.fill('evenodd');
ctx.strokeStyle = rgba(PAPER, 0.8);
ctx.lineWidth = 2;
ctx.stroke();
// 짝지은 점 — 섞이는 동안만 보인다
ctx.fillStyle = rgba(PAPER, 0.75 * (1 - t) + 0.15);
for (let i = 0; i < outer.length; i += 1) {
ctx.beginPath();
ctx.arc(outer[i][0], outer[i][1], 1.8, 0, TAU);
ctx.fill();
}
// 아직 원일 때 둘레를 도는 하이라이트 — 녹아들수록 흐려진다
if (t < 0.999) {
const head = Math.floor(clock / 12) % MORPH_POINTS;
ctx.beginPath();
for (let k = 0; k < 20; k += 1) {
const p = outer[(head + k) % MORPH_POINTS];
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
ctx.strokeStyle = rgba(PAL[5], 1 - t);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.stroke();
}
statRow(112, '진행률', pct(progress));
statRow(160, '모양 섞기', pct(t));
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// CircleToMarkMelt - vanilla 판(07-logo-load-circle-to-mark-melt) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CircleToMarkMelt.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CircleToMarkMelt() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 바깥 외곽선을 다시 뽑는 점 개수 — 별 구멍은 그 절반
const MORPH_POINTS = 96;
// 시작점 정렬 후보 수 — 로고 점 MORPH_POINTS 개 중 이만큼을 같은 간격으로 골라 시작점으로 놓아 본다
const ALIGN_SEARCH = 32;
// 진행률에 씌우는 느리게-빠르게-느리게 곡선의 지수
const MORPH_EASE = 2.2;
const CIRCLE_R = 66;
const HOLE_R0 = 9;
const TXT_RUN = '원이 로고로 녹는 중';
// 닫힌 외곽선을 같은 호 길이 간격의 n 개 점으로 다시 뽑는다 — 꼭짓점이 몰린 둥근 모서리도 곧은 변과 같은 간격이 된다
function resample(c: Pt[], n: number): Pt[] {
const total = ringLength(c);
const gap = total / n;
const out: Pt[] = [];
let seg = 0;
let segStart = 0;
for (let k = 0; k < n; k += 1) {
const want = k * gap;
let a = c[seg];
let b = c[(seg + 1) % c.length];
let len = Math.hypot(b[0] - a[0], b[1] - a[1]);
while (segStart + len < want && seg < c.length - 1) {
segStart += len;
seg += 1;
a = c[seg];
b = c[(seg + 1) % c.length];
len = Math.hypot(b[0] - a[0], b[1] - a[1]);
}
const f = len > 0 ? clamp((want - segStart) / len, 0, 1) : 0;
out.push([lerp(a[0], b[0], f), lerp(a[1], b[1], f)]);
}
return out;
}
// 원 — 오른쪽(0°)에서 출발해 시계 방향으로 같은 각도 간격
function circlePts(r: number, n: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < n; k += 1) {
const a = (k / n) * TAU;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 짝지은 거리 제곱의 합 — 원의 i 번째 점과 로고의 (i + off) 번째 점
function alignCost(a: Pt[], b: Pt[], off: number): number {
let s = 0;
for (let i = 0; i < a.length; i += 1) {
const q = b[(i + off) % b.length];
const dx = a[i][0] - q[0];
const dy = a[i][1] - q[1];
s += dx * dx + dy * dy;
}
return s;
}
function bestOffset(a: Pt[], b: Pt[]): number {
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < ALIGN_SEARCH; k += 1) {
const off = Math.round((k * b.length) / ALIGN_SEARCH) % b.length;
const cost = alignCost(a, b, off);
if (cost < bestCost) { bestCost = cost; best = off; }
}
return best;
}
function rotateFrom(b: Pt[], off: number): Pt[] {
const out: Pt[] = [];
for (let i = 0; i < b.length; i += 1) { out.push(b[(i + off) % b.length]); }
return out;
}
const SRC_OUT: Pt[] = circlePts(CIRCLE_R, MORPH_POINTS);
const RAW_OUT: Pt[] = resample(LOGO_OUTER, MORPH_POINTS);
const OFF_OUT = bestOffset(SRC_OUT, RAW_OUT);
const DST_OUT: Pt[] = rotateFrom(RAW_OUT, OFF_OUT);
const SRC_HOLE: Pt[] = circlePts(HOLE_R0, MORPH_POINTS / 2);
const RAW_HOLE: Pt[] = resample(LOGO_HOLE, MORPH_POINTS / 2);
const OFF_HOLE = bestOffset(SRC_HOLE, RAW_HOLE);
const DST_HOLE: Pt[] = rotateFrom(RAW_HOLE, OFF_HOLE);
function morphEase(p: number): number {
const x = clamp(p, 0, 1);
return x < 0.5 ? 0.5 * Math.pow(2 * x, MORPH_EASE) : 1 - 0.5 * Math.pow(2 * (1 - x), MORPH_EASE);
}
function blend(a: Pt[], b: Pt[], t: number): Pt[] {
const out: Pt[] = [];
for (let i = 0; i < a.length; i += 1) { out.push([lerp(a[i][0], b[i][0], t), lerp(a[i][1], b[i][1], t)]); }
return out;
}
function shown(): number { return progress; }
function step(): void { /* 모양은 진행률만으로 정해진다. 도는 하이라이트만 시계(clock)를 따른다 */ }
function settle(): void { /* 동작 줄이기에서는 시계가 멈춰 하이라이트도 서 있다 */ }
function onRestart(): void { /* 진행률 0 이 곧 원 */ }
function draw(): void {
clearStage();
const t = morphEase(progress);
const outer = blend(SRC_OUT, DST_OUT, t);
const hole = blend(SRC_HOLE, DST_HOLE, t);
ctx.beginPath();
contourPath(outer);
contourPath(hole);
const g = ctx.createLinearGradient(0, CY - LOGO_R, 0, CY + LOGO_R);
g.addColorStop(0, rgba(mix(PAL[6], PAL[0], t), 0.92));
g.addColorStop(1, rgba(mix(PAL[3], PAL[7], t), 0.92));
ctx.fillStyle = g;
ctx.fill('evenodd');
ctx.strokeStyle = rgba(PAPER, 0.8);
ctx.lineWidth = 2;
ctx.stroke();
// 짝지은 점 — 섞이는 동안만 보인다
ctx.fillStyle = rgba(PAPER, 0.75 * (1 - t) + 0.15);
for (let i = 0; i < outer.length; i += 1) {
ctx.beginPath();
ctx.arc(outer[i][0], outer[i][1], 1.8, 0, TAU);
ctx.fill();
}
// 아직 원일 때 둘레를 도는 하이라이트 — 녹아들수록 흐려진다
if (t < 0.999) {
const head = Math.floor(clock / 12) % MORPH_POINTS;
ctx.beginPath();
for (let k = 0; k < 20; k += 1) {
const p = outer[(head + k) % MORPH_POINTS];
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
ctx.strokeStyle = rgba(PAL[5], 1 - t);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.stroke();
}
statRow(112, '진행률', pct(progress));
statRow(160, '모양 섞기', pct(t));
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="원 둘레의 점이 진행률만큼 육각형 마크의 외곽선으로 녹아드는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">원이 로고로 녹는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}07 · 원 둘레의 점이 로고 외곽선으로 녹아드는 로더 — 측정 누적면적 16.233% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 짝지은 거리 제곱의 합 — 원의 i 번째 점과 로고의 (i + off) 번째 점
function alignCost(a, b, off) {
let s = 0;
for (let i = 0; i < a.length; i += 1) {
const q = b[(i + off) % b.length];
const dx = a[i][0] - q[0];
const dy = a[i][1] - q[1];
s += dx * dx + dy * dy;
}
return s;
}
function bestOffset(a, b) {
let best = 0;
let bestCost = Infinity;
for (let k = 0; k < ALIGN_SEARCH; k += 1) {
const off = Math.round((k * b.length) / ALIGN_SEARCH) % b.length;
const cost = alignCost(a, b, off);
if (cost < bestCost) {
bestCost = cost;
best = off;
}
}
return best;
}
08진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더
마크를 4px 간격으로 최대 14장 뒤로 쌓되 층 수를 p × 14 의 소수로 받아 새 층이 18px 더 깊은 곳에서 미끄러져 들어오게 하고, 30° 숙인 원근(초점 560)으로 투영해 뒤 층부터 어둡게 칠하는 화가 알고리즘으로 그린다. 다 쌓이면 두께 56px 이 아래 꼭짓점에서 19.5px 벌어져 보이고 좌우 22° 흔들림 끝에서는 옆으로 13.5px 드러나서, 게임이나 이벤트 페이지 진입 화면처럼 입체감이 필요한 자리에 어울린다. 이 장면은 누적면적 12.3%, 강도 73.8 로 작게 움직이지만 23/23 프레임을 채웠다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="진행률만큼 마크 뒤로 층이 쌓여 두께가 생기는 입체 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">두께가 쌓이는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 진행률 100% 에서 쌓이는 층 수
const EXTRUDE_LAYERS = 14;
// 층 사이 깊이 간격(px)
const LAYER_GAP_PX = 4;
// 위에서 내려다보도록 앞으로 숙이는 각(도)
const TILT_DEG = 30;
const FOCAL = 560;
// 좌우로 천천히 흔드는 각(도)과 한 번 흔드는 시간(ms) — 두께가 옆면으로 보이게
const SWAY_DEG = 22;
const SWAY_MS = 2000;
const TXT_RUN = '두께가 쌓이는 중';
// 마크 중심 기준 좌표 — 화면 오른쪽 +x, 아래 +y, 안쪽(뒤) +z
const OUT_L = LOGO_OUTER.map(function (p) { return [p[0] - CX, p[1] - CY]; });
const HOLE_L = LOGO_HOLE.map(function (p) { return [p[0] - CX, p[1] - CY]; });
// 세로축으로 yaw 만큼 돌린 뒤 가로축으로 −TILT_DEG 숙이고, 화면 배율 s = FOCAL / (FOCAL + z) 로 원근 나눗셈
function project3(x, y, z, yaw, pitch) {
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
const s = FOCAL / (FOCAL + z2);
return [CX + x1 * s, CY + y2 * s, s];
}
function layerPath(z, yaw, pitch) {
ctx.beginPath();
const rings = [OUT_L, HOLE_L];
for (let r = 0; r < rings.length; r += 1) {
const c = rings[r];
for (let i = 0; i < c.length; i += 1) {
const p = project3(c[i][0], c[i][1], z, yaw, pitch);
if (i === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
ctx.closePath();
}
}
// 진행률이 정한 층 수(소수) — 3.4 면 셋은 제자리, 넷째는 40% 만큼 뒤에서 미끄러져 들어온다
function layersAt(p) { return clamp(p, 0, 1) * EXTRUDE_LAYERS; }
function shown() { return progress; }
function step() { }
function settle() { }
function onRestart() { }
function draw() {
clearStage();
const yaw = Math.sin((clock / SWAY_MS) * TAU) * SWAY_DEG * DEG;
const pitch = -TILT_DEG * DEG;
const k = layersAt(progress);
const top = Math.ceil(k);
// 뒤 층부터 칠한다(화가 알고리즘) — 깊을수록 어둡게
for (let L = top; L >= 1; L -= 1) {
const part = Math.min(1, k - (L - 1));
const z = L * LAYER_GAP_PX + (1 - part) * 18;
const depth = L / EXTRUDE_LAYERS;
layerPath(z, yaw, pitch);
ctx.fillStyle = rgba(shadeRGB(mix(PAL[3], PAL[7], depth), 0.86 - 0.5 * depth), part);
ctx.fill('evenodd');
ctx.strokeStyle = rgba(shadeRGB(PAL[3], 0.42), 0.8 * part);
ctx.lineWidth = 1;
ctx.stroke();
}
// 앞면
layerPath(0, yaw, pitch);
const g = ctx.createLinearGradient(CX - LOGO_R, CY - LOGO_R, CX + LOGO_R, CY + LOGO_R);
g.addColorStop(0, rgba(PAL[0], 1));
g.addColorStop(1, rgba(PAL[6], 1));
ctx.fillStyle = g;
ctx.fill('evenodd');
ctx.strokeStyle = rgba(PAPER, 0.85);
ctx.lineWidth = 1.6;
ctx.stroke();
statRow(112, '진행률', pct(progress));
statRow(160, '쌓인 층', Math.floor(k) + ' / ' + EXTRUDE_LAYERS);
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// ExtrudeLayerStack - vanilla 판(08-logo-load-extrude-layer-stack) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ExtrudeLayerStack.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ExtrudeLayerStack() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 진행률 100% 에서 쌓이는 층 수
const EXTRUDE_LAYERS = 14;
// 층 사이 깊이 간격(px)
const LAYER_GAP_PX = 4;
// 위에서 내려다보도록 앞으로 숙이는 각(도)
const TILT_DEG = 30;
const FOCAL = 560;
// 좌우로 천천히 흔드는 각(도)과 한 번 흔드는 시간(ms) — 두께가 옆면으로 보이게
const SWAY_DEG = 22;
const SWAY_MS = 2000;
const TXT_RUN = '두께가 쌓이는 중';
// 마크 중심 기준 좌표 — 화면 오른쪽 +x, 아래 +y, 안쪽(뒤) +z
const OUT_L: Pt[] = LOGO_OUTER.map(function (p: Pt): Pt { return [p[0] - CX, p[1] - CY]; });
const HOLE_L: Pt[] = LOGO_HOLE.map(function (p: Pt): Pt { return [p[0] - CX, p[1] - CY]; });
// 세로축으로 yaw 만큼 돌린 뒤 가로축으로 −TILT_DEG 숙이고, 화면 배율 s = FOCAL / (FOCAL + z) 로 원근 나눗셈
function project3(x: number, y: number, z: number, yaw: number, pitch: number): [number, number, number] {
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
const s = FOCAL / (FOCAL + z2);
return [CX + x1 * s, CY + y2 * s, s];
}
function layerPath(z: number, yaw: number, pitch: number): void {
ctx.beginPath();
const rings = [OUT_L, HOLE_L];
for (let r = 0; r < rings.length; r += 1) {
const c = rings[r];
for (let i = 0; i < c.length; i += 1) {
const p = project3(c[i][0], c[i][1], z, yaw, pitch);
if (i === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
ctx.closePath();
}
}
// 진행률이 정한 층 수(소수) — 3.4 면 셋은 제자리, 넷째는 40% 만큼 뒤에서 미끄러져 들어온다
function layersAt(p: number): number { return clamp(p, 0, 1) * EXTRUDE_LAYERS; }
function shown(): number { return progress; }
function step(): void { /* 층 수는 진행률, 흔들림은 시계(clock)로 정해진다 */ }
function settle(): void { /* 동작 줄이기에서는 시계가 멈춰 기울기만 남는다 */ }
function onRestart(): void { /* 진행률 0 이 곧 얇은 앞면 하나 */ }
function draw(): void {
clearStage();
const yaw = Math.sin((clock / SWAY_MS) * TAU) * SWAY_DEG * DEG;
const pitch = -TILT_DEG * DEG;
const k = layersAt(progress);
const top = Math.ceil(k);
// 뒤 층부터 칠한다(화가 알고리즘) — 깊을수록 어둡게
for (let L = top; L >= 1; L -= 1) {
const part = Math.min(1, k - (L - 1));
const z = L * LAYER_GAP_PX + (1 - part) * 18;
const depth = L / EXTRUDE_LAYERS;
layerPath(z, yaw, pitch);
ctx.fillStyle = rgba(shadeRGB(mix(PAL[3], PAL[7], depth), 0.86 - 0.5 * depth), part);
ctx.fill('evenodd');
ctx.strokeStyle = rgba(shadeRGB(PAL[3], 0.42), 0.8 * part);
ctx.lineWidth = 1;
ctx.stroke();
}
// 앞면
layerPath(0, yaw, pitch);
const g = ctx.createLinearGradient(CX - LOGO_R, CY - LOGO_R, CX + LOGO_R, CY + LOGO_R);
g.addColorStop(0, rgba(PAL[0], 1));
g.addColorStop(1, rgba(PAL[6], 1));
ctx.fillStyle = g;
ctx.fill('evenodd');
ctx.strokeStyle = rgba(PAPER, 0.85);
ctx.lineWidth = 1.6;
ctx.stroke();
statRow(112, '진행률', pct(progress));
statRow(160, '쌓인 층', Math.floor(k) + ' / ' + EXTRUDE_LAYERS);
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="진행률만큼 마크 뒤로 층이 쌓여 두께가 생기는 입체 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">두께가 쌓이는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}08 · 진행률만큼 층이 쌓여 두께가 생기는 입체 로고 로더 — 측정 누적면적 12.282% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 세로축으로 yaw 만큼 돌린 뒤 가로축으로 −TILT_DEG 숙이고, 화면 배율 s = FOCAL / (FOCAL + z) 로 원근 나눗셈
function project3(x, y, z, yaw, pitch) {
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const y2 = y * cp - z1 * sp;
const z2 = y * sp + z1 * cp;
const s = FOCAL / (FOCAL + z2);
return [CX + x1 * s, CY + y2 * s, s];
}
09들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더
도착할 때만 뛰는 계단 진행 값을 목표로 두고 표시값이 16ms 마다 남은 차의 6% 씩 따라가며, 새 값이 96ms 넘게 오지 않으면 목표를 조금씩 앞으로 밀되 미는 양에 0.96 을 거듭 곱해 점점 느리게 한다. 시연에서 표시값은 받은 양을 한 번도 넘지 않았고 거꾸로 간 스텝도 0 이며, 불러오기 시작부터 1,536ms 에 100% 에 닿으면 마크가 320ms 동안 한 번 부풀어서 보고서 생성처럼 숫자를 오래 지켜보는 대기 화면에 맞다. 렌더 결과는 누적면적 28.8%, 강도 137.6 으로 아홉 장면 가운데 가장 세고 23/23 프레임이다.

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="계단처럼 들어오는 진행 값을 표시 숫자가 매끄럽게 따라 세고 100% 에서 마크가 한 번 부푸는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p class="pc__status" data-role="status" aria-live="off">숫자가 따라 오르는 중</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">다시 시작</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="pc__fill" data-role="fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
// 아래 끝 진행 띠 — 채움은 transform: scaleX 로만 늘린다(레이아웃 재계산 없음)
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .12);
}
.pc__fill {
display: block;
height: 100%;
background: $subject-mint;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.pc__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: ew-resize;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.12);
}
.pc__fill {
display: block;
height: 100%;
background: #4cd4a6;
transform: scaleX(0);
transform-origin: left center;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.pc__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG = [29, 26, 44];
const PANEL = [46, 41, 68];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r) {
const out = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro, ri) {
const out = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v, cut, steps) {
const out = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x, y) {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c) {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
function makeLoad(seed) {
const rng = makeRng(seed);
const gaps = [];
const sizes = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gs += gaps[k];
ss += sizes[k];
}
const out = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms) {
if (ms <= 0) {
return 0;
}
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) {
return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t));
}
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms) {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) {
v = LOAD[k].v;
}
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const barEl = root.querySelector('[data-role="bar"]');
const fillEl = root.querySelector('[data-role="fill"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const bar = barEl;
const fill = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c) {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) {
ctx.lineTo(c[i][0], c[i][1]);
}
ctx.closePath();
}
function logoPath() {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) {
contourPath(LOGO[c]);
}
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo() {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a) {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y, label, value) {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v) { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress() { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress() { progress = targetProgress(); }
function advanceLoad(dt) {
if (paused || manual >= 0) {
return;
}
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v) {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report() {
if (manual >= 0) {
say(TXT_MANUAL);
}
else if (paused) {
say(TXT_PAUSE);
}
else if (shown() >= 0.999) {
say(TXT_DONE);
}
else {
say(TXT_RUN);
}
}
function render() {
draw();
syncBar(shown());
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
function restart() {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t) {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after() {
updateProgress();
if (still) {
settle();
}
render();
if (still) {
kickStill();
}
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev) {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev) {
wake();
dragging = true;
if (cv.setPointerCapture) {
cv.setPointerCapture(ev.pointerId);
}
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev) {
if (!dragging) {
return;
}
scrubAt(ev);
after();
});
function endDrag() { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d) {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
restart();
}
else if (k === ' ') {
setPaused(!paused);
}
else if (k === 'ArrowRight' || k === 'ArrowUp') {
nudge(KEY_STEP);
}
else if (k === 'ArrowLeft' || k === 'ArrowDown') {
nudge(-KEY_STEP);
}
else if (k === 'Home') {
manual = 0;
}
else if (k === 'End') {
manual = 1;
}
else if (k === 'Escape') {
manual = -1;
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (restartBtn) {
restartBtn.addEventListener('click', function () { wake(); restart(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
// 표시값이 16ms 스텝마다 남은 차이 중 이 비율만큼 따라간다
const PROGRESS_SMOOTH = 0.06;
// 진행이 멈춘 동안 목표를 앞으로 미는 빠르기에 스텝마다 곱하는 값 — 1 보다 작아 점점 느려진다
const STALL_SLOWDOWN = 0.96;
// 100% 에 닿은 뒤 마크가 한 번 부풀었다 가라앉는 시간(ms)
const DONE_PULSE_MS = 320;
// 새 값이 이만큼(ms) 안 오면 멈춤으로 본다
const STALL_AFTER_MS = 96;
// 멈춤에 들어선 첫 스텝에 목표를 미는 양(진행률/스텝)
const CREEP_V0 = 0.0016;
// 민 양의 상한 — 남은 양(1 − 받은 양)의 이 비율까지만
const CREEP_CAP = 0.4;
// 끝(받은 양 100%)이 오면 이 비율로 빨리 닫는다
const FINISH_SMOOTH = 0.25;
const RING_R = 122;
const TXT_RUN = '숫자가 따라 오르는 중';
let reported = 0;
let sameMs = 0;
let creep = 0;
let creepV = 0;
let disp = 0;
let doneAt = -1;
const histR = [];
const histD = [];
const HIST_MAX = 130;
function incoming() { return manual >= 0 ? manual : reportedAt(loadMs); }
function shown() { return disp; }
function step() {
const r = incoming();
if (r !== reported) {
reported = r;
sameMs = 0;
creep = 0;
creepV = CREEP_V0;
}
else {
sameMs += FIXED_DT;
}
// 멈춤 — 가상 진행을 따르는 동안 새 값이 STALL_AFTER_MS 넘게 안 오면 목표를 받은 값보다 조금씩 앞으로 밀되, 미는 빠르기에 STALL_SLOWDOWN 을 거듭 곱해 점점 느려진다
if (sameMs > STALL_AFTER_MS && reported < 1 && manual < 0) {
creep = Math.min(creep + creepV, (1 - reported) * CREEP_CAP);
creepV *= STALL_SLOWDOWN;
}
const target = reported + creep;
// 가상 진행을 따를 때는 거꾸로 가지 않는다 — 기어간 만큼보다 작은 값이 와도 표시값은 그 자리에서 기다린다
if (target >= disp || manual >= 0) {
disp += (target - disp) * (reported >= 1 ? FINISH_SMOOTH : PROGRESS_SMOOTH);
}
if (reported >= 1 && disp > 0.995) {
disp = 1;
if (doneAt < 0) {
doneAt = clock;
}
}
if (disp < 1) {
doneAt = -1;
}
histR.push(reported);
histD.push(disp);
if (histR.length > HIST_MAX) {
histR.shift();
histD.shift();
}
}
function settle() {
reported = incoming();
disp = reported;
creep = 0;
creepV = 0;
sameMs = 0;
doneAt = -1;
}
function onRestart() {
reported = 0;
disp = 0;
creep = 0;
creepV = 0;
sameMs = 0;
doneAt = -1;
histR.length = 0;
histD.length = 0;
}
// 끝 맥동 — 100% 에 닿은 순간부터 DONE_PULSE_MS 동안 사인 반 주기로 0 → 1 → 0
function pulseAt(t) {
if (doneAt < 0 || still) {
return 0;
}
const k = (t - doneAt) / DONE_PULSE_MS;
return k >= 0 && k <= 1 ? Math.sin(Math.PI * k) : 0;
}
function chart(x, y, w, h) {
ctx.strokeStyle = rgba(PAPER, 0.14);
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
const n = histR.length;
if (n < 2) {
return;
}
const lines = [[histR, PAPER, 0.45], [histD, PAL[2], 1]];
for (let s = 0; s < lines.length; s += 1) {
const arr = lines[s][0];
ctx.beginPath();
for (let i = 0; i < n; i += 1) {
const px = x + (i / (HIST_MAX - 1)) * w;
const py = y + h - arr[i] * h;
if (i === 0) {
ctx.moveTo(px, py);
}
else {
ctx.lineTo(px, py);
}
}
ctx.strokeStyle = rgba(lines[s][1], lines[s][2]);
ctx.lineWidth = s === 0 ? 1.2 : 2;
ctx.stroke();
}
}
function draw() {
clearStage();
const k = pulseAt(clock);
// 고리 — 위에서 시계 방향으로 표시값만큼
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(CX, CY, RING_R, 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.12);
ctx.lineWidth = 6;
ctx.stroke();
if (disp > 0) {
ctx.beginPath();
ctx.arc(CX, CY, RING_R, -Math.PI / 2, -Math.PI / 2 + disp * TAU);
ctx.strokeStyle = rgba(PAL[2], 1);
ctx.stroke();
}
// 마크 — 고리와 같은 각도의 부채꼴로 잘라 채운다. 끝 맥동 동안 자기 중심에서 부푼다
ctx.save();
ctx.translate(CX, CY);
ctx.scale(1 + 0.08 * k, 1 + 0.08 * k);
ctx.translate(-CX, -CY);
logoPath();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fill('evenodd');
if (disp > 0) {
ctx.save();
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.arc(CX, CY, RING_R + 20, -Math.PI / 2, -Math.PI / 2 + disp * TAU);
ctx.closePath();
ctx.clip();
logoPath();
ctx.fillStyle = rgba(mix(PAL[2], PAL[6], 0.4), 1);
ctx.fill('evenodd');
ctx.restore();
}
logoGuide(0.5);
ctx.restore();
if (k > 0) {
ctx.beginPath();
ctx.arc(CX, CY, RING_R + 30 * k, 0, TAU);
ctx.strokeStyle = rgba(PAL[2], 0.7 * (1 - k * 0.6));
ctx.lineWidth = 3;
ctx.stroke();
}
// 큰 숫자 — 내림으로 센다. 끝이 오기 전에는 100 을 쓰지 않는다
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText('표시값', LEG_X, 102);
ctx.fillStyle = rgba(PAPER, 0.98);
ctx.font = '700 42px sans-serif';
ctx.fillText(Math.floor(disp * 100) + '%', LEG_X, 144);
statRow(166, '받은 양', pct(reported));
chart(LEG_X, 200, 140, 40);
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
else {
advanceLoad(FIXED_DT);
}
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick() {
stillTimer = 0;
if (!still) {
return;
}
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill() {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) {
return;
}
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill() {
if (stillTimer) {
window.clearTimeout(stillTimer);
stillTimer = 0;
}
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// SmoothedPercentCount - vanilla 판(09-logo-load-smoothed-percent-count) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SmoothedPercentCount.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SmoothedPercentCount() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 마크 중심 — 왼쪽 위는 버튼, 왼쪽은 수치, 왼쪽 아래는 상태 줄이라 마크는 오른쪽으로 비켜 둔다
const CX = 318;
const CY = 148;
// 마크 크기 — 육각형 중심에서 꼭짓점까지(px)
const LOGO_R = 104;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 조각을 얹어 명도 차로 경계가 읽힌다
const BG: RGB = [29, 26, 44];
const PANEL: RGB = [46, 41, 68];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function shadeRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
// 시드 고정 난수 — 같은 시드면 같은 배치가 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 자체 기하 마크 — 꼭짓점이 위를 향한 둥근 육각형 안에 네 갈래 별 모양 구멍. 실존 브랜드와 무관한 도형
function hexVerts(r: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 6; k += 1) {
const a = (-90 + 60 * k) * DEG;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 별 — 바깥 꼭짓점 4개(위·오른쪽·아래·왼쪽)와 그 사이 안쪽 꼭짓점 4개를 번갈아
function starVerts(ro: number, ri: number): Pt[] {
const out: Pt[] = [];
for (let k = 0; k < 8; k += 1) {
const a = (-90 + 45 * k) * DEG;
const r = k % 2 === 0 ? ro : ri;
out.push([CX + Math.cos(a) * r, CY + Math.sin(a) * r]);
}
return out;
}
// 모서리 둥글리기 — 꼭짓점 양옆을 변 길이의 cut 비율만큼 잘라 내고, 꼭짓점을 조절점으로 한 2차 베지어를 steps 칸으로 찍는다
function roundCorners(v: Pt[], cut: number, steps: number): Pt[] {
const out: Pt[] = [];
const n = v.length;
for (let i = 0; i < n; i += 1) {
const p = v[i];
const a = v[(i + n - 1) % n];
const b = v[(i + 1) % n];
const s: Pt = [lerp(p[0], a[0], cut), lerp(p[1], a[1], cut)];
const e: Pt = [lerp(p[0], b[0], cut), lerp(p[1], b[1], cut)];
for (let k = 0; k <= steps; k += 1) {
const t = k / steps;
const u = 1 - t;
out.push([u * u * s[0] + 2 * u * t * p[0] + t * t * e[0], u * u * s[1] + 2 * u * t * p[1] + t * t * e[1]]);
}
}
return out;
}
const LOGO_OUTER: Pt[] = roundCorners(hexVerts(LOGO_R), 0.2, 6);
const LOGO_HOLE: Pt[] = roundCorners(starVerts(44, 14), 0.16, 3);
// 외곽선 두 개 — 짝홀 규칙(evenodd)으로 칠하면 바깥 육각형만 칠해지고 별 자리는 뚫린다
const LOGO: Pt[][] = [LOGO_OUTER, LOGO_HOLE];
// 점이 마크 안인가 — 오른쪽으로 쏜 반직선이 외곽선을 홀수 번 지나면 안(짝홀 규칙, 캔버스 evenodd 와 같은 판정)
function insideLogo(x: number, y: number): boolean {
let inside = false;
for (let c = 0; c < LOGO.length; c += 1) {
const poly = LOGO[c];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const a = poly[i];
const b = poly[j];
if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
}
return inside;
}
// 닫힌 외곽선의 둘레(px)
function ringLength(c: Pt[]): number {
let s = 0;
for (let i = 0; i < c.length; i += 1) {
const a = c[i];
const b = c[(i + 1) % c.length];
s += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
return s;
}
// 가상 진행 — 실제 네트워크 없이 시드 469 로 만든 도착 표(도착 시각 ms, 그때까지 받은 비율). STALL_CHUNK 번째 조각은 받는 양 없이 시간만 흐른다
const LOAD_MS = 1300;
const LOAD_CHUNKS = 7;
const STALL_CHUNK = 3;
type Chunk = { t: number; v: number };
function makeLoad(seed: number): Chunk[] {
const rng = makeRng(seed);
const gaps: number[] = [];
const sizes: number[] = [];
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
gaps.push(0.6 + rng());
sizes.push(0.35 + rng());
}
sizes[STALL_CHUNK] = 0;
gaps[STALL_CHUNK] += 0.9;
let gs = 0;
let ss = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) { gs += gaps[k]; ss += sizes[k]; }
const out: Chunk[] = [{ t: 0, v: 0 }];
let t = 0;
let v = 0;
for (let k = 0; k < LOAD_CHUNKS; k += 1) {
t += (gaps[k] / gs) * LOAD_MS;
v += sizes[k] / ss;
out.push({ t: t, v: v });
}
out[out.length - 1] = { t: LOAD_MS, v: 1 };
return out;
}
const LOAD: Chunk[] = makeLoad(469);
// 도착 사이를 직선으로 잇는 진행 — 01~08 이 받는 값
function loadAt(ms: number): number {
if (ms <= 0) { return 0; }
for (let k = 1; k < LOAD.length; k += 1) {
const a = LOAD[k - 1];
const b = LOAD[k];
if (ms < b.t) { return lerp(a.v, b.v, (ms - a.t) / (b.t - a.t)); }
}
return 1;
}
// 도착할 때만 한 번에 뛰는 진행 — 실제 progress 이벤트처럼 계단으로 들어온다 (09 가 받는 값)
function reportedAt(ms: number): number {
let v = 0;
for (let k = 0; k < LOAD.length; k += 1) {
if (LOAD[k].t <= ms) { v = LOAD[k].v; }
}
return v;
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const barEl = root.querySelector('[data-role="bar"]') as HTMLElement | null;
const fillEl = root.querySelector('[data-role="fill"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl || !barEl || !fillEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const bar: HTMLElement = barEl;
const fill: HTMLElement = fillEl;
const restartBtn = root.querySelector('[data-role="restart"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
function contourPath(c: Pt[]): void {
ctx.moveTo(c[0][0], c[0][1]);
for (let i = 1; i < c.length; i += 1) { ctx.lineTo(c[i][0], c[i][1]); }
ctx.closePath();
}
function logoPath(): void {
ctx.beginPath();
for (let c = 0; c < LOGO.length; c += 1) { contourPath(LOGO[c]); }
}
// 마크 모양으로 자르기 — 이 뒤에 그리는 것은 육각형 안·별 구멍 밖에만 남는다
function clipLogo(): void {
logoPath();
ctx.clip('evenodd');
}
// 마크 자리 안내선 — 다 그려지면 어떤 모양이 될지 미리 보여 주는 옅은 외곽선
function logoGuide(a: number): void {
logoPath();
ctx.strokeStyle = rgba(PAPER, a);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 왼쪽 수치 한 줄 — 이름표 + 값
const LEG_X = 16;
function statRow(y: number, label: string, value: string): void {
ctx.textBaseline = 'alphabetic';
ctx.textAlign = 'left';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, LEG_X, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 14px sans-serif';
ctx.fillText(value, LEG_X, y + 17);
}
function pct(v: number): string { return Math.round(clamp(v, 0, 1) * 100) + '%'; }
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 진행 상태 — loadMs 는 가상 진행의 시계, manual 은 끌기·방향키로 직접 정한 진행률(−1 이면 가상 진행을 따른다)
let loadMs = 0;
let paused = false;
let manual = -1;
let progress = 0;
let clock = 0;
function targetProgress(): number { return manual >= 0 ? manual : loadAt(loadMs); }
function updateProgress(): void { progress = targetProgress(); }
function advanceLoad(dt: number): void {
if (paused || manual >= 0) { return; }
loadMs = Math.min(loadMs + dt, LOAD_MS);
}
// 아래 띠(role="progressbar") — 화면에 그린 값과 같은 값을 띠 길이와 aria-valuenow 로 알린다. 정수 % 가 바뀔 때만 속성을 고친다
let barPct = -1;
function syncBar(v: number): void {
const c = clamp(v, 0, 1);
fill.style.transform = 'scaleX(' + c.toFixed(4) + ')';
const p = Math.round(c * 100);
if (p !== barPct) {
barPct = p;
bar.setAttribute('aria-valuenow', String(p));
}
}
const TXT_DONE = '불러오기 끝 — Enter 로 다시';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
const TXT_MANUAL = '직접 옮기는 중 — Esc 로 돌아가기';
function report(): void {
if (manual >= 0) { say(TXT_MANUAL); }
else if (paused) { say(TXT_PAUSE); }
else if (shown() >= 0.999) { say(TXT_DONE); }
else { say(TXT_RUN); }
}
function render(): void {
draw();
syncBar(shown());
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
function restart(): void {
loadMs = 0;
manual = -1;
setPaused(false);
progress = 0;
onRestart();
}
// 시연 — 2초마다 처음부터. 첫 200ms 는 빈 마크로 두고(첫 변화 200ms 뒤) 1,300ms 동안 불러온 뒤 끝 상태로 머문다
const DEMO_LOOP = 2000;
const DEMO_LEAD = 200;
let demoCycle = -1;
function demoTick(t: number): void {
const cyc = Math.floor(t / DEMO_LOOP);
if (cyc !== demoCycle) {
demoCycle = cyc;
restart();
}
loadMs = clamp(t - cyc * DEMO_LOOP - DEMO_LEAD, 0, LOAD_MS);
}
// 입력 한 번 뒤 — 진행률을 다시 읽고, 동작 줄이기면 움직임 없이 그 진행률의 끝 모습으로 바로 그린다
function after(): void {
updateProgress();
if (still) { settle(); }
render();
if (still) { kickStill(); }
}
// 끌기 — 무대 가로 위치를 진행률로 (왼쪽 40px = 0%, 오른쪽 40px = 100%)
const SCRUB_X0 = 40;
const SCRUB_X1 = 440;
const KEY_STEP = 0.05;
let dragging = false;
function scrubAt(ev: PointerEvent): void {
const p = toScene(ev);
manual = clamp((p[0] - SCRUB_X0) / (SCRUB_X1 - SCRUB_X0), 0, 1);
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
wake();
dragging = true;
if (cv.setPointerCapture) { cv.setPointerCapture(ev.pointerId); }
scrubAt(ev);
after();
});
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
if (!dragging) { return; }
scrubAt(ev);
after();
});
function endDrag(): void { dragging = false; }
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
function nudge(d: number): void {
manual = clamp((manual >= 0 ? manual : progress) + d, 0, 1);
}
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { restart(); }
else if (k === ' ') { setPaused(!paused); }
else if (k === 'ArrowRight' || k === 'ArrowUp') { nudge(KEY_STEP); }
else if (k === 'ArrowLeft' || k === 'ArrowDown') { nudge(-KEY_STEP); }
else if (k === 'Home') { manual = 0; }
else if (k === 'End') { manual = 1; }
else if (k === 'Escape') { manual = -1; }
else { return; }
ev.preventDefault();
wake();
after();
});
if (restartBtn) { restartBtn.addEventListener('click', function (): void { wake(); restart(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
// 표시값이 16ms 스텝마다 남은 차이 중 이 비율만큼 따라간다
const PROGRESS_SMOOTH = 0.06;
// 진행이 멈춘 동안 목표를 앞으로 미는 빠르기에 스텝마다 곱하는 값 — 1 보다 작아 점점 느려진다
const STALL_SLOWDOWN = 0.96;
// 100% 에 닿은 뒤 마크가 한 번 부풀었다 가라앉는 시간(ms)
const DONE_PULSE_MS = 320;
// 새 값이 이만큼(ms) 안 오면 멈춤으로 본다
const STALL_AFTER_MS = 96;
// 멈춤에 들어선 첫 스텝에 목표를 미는 양(진행률/스텝)
const CREEP_V0 = 0.0016;
// 민 양의 상한 — 남은 양(1 − 받은 양)의 이 비율까지만
const CREEP_CAP = 0.4;
// 끝(받은 양 100%)이 오면 이 비율로 빨리 닫는다
const FINISH_SMOOTH = 0.25;
const RING_R = 122;
const TXT_RUN = '숫자가 따라 오르는 중';
let reported = 0;
let sameMs = 0;
let creep = 0;
let creepV = 0;
let disp = 0;
let doneAt = -1;
const histR: number[] = [];
const histD: number[] = [];
const HIST_MAX = 130;
function incoming(): number { return manual >= 0 ? manual : reportedAt(loadMs); }
function shown(): number { return disp; }
function step(): void {
const r = incoming();
if (r !== reported) {
reported = r;
sameMs = 0;
creep = 0;
creepV = CREEP_V0;
} else {
sameMs += FIXED_DT;
}
// 멈춤 — 가상 진행을 따르는 동안 새 값이 STALL_AFTER_MS 넘게 안 오면 목표를 받은 값보다 조금씩 앞으로 밀되, 미는 빠르기에 STALL_SLOWDOWN 을 거듭 곱해 점점 느려진다
if (sameMs > STALL_AFTER_MS && reported < 1 && manual < 0) {
creep = Math.min(creep + creepV, (1 - reported) * CREEP_CAP);
creepV *= STALL_SLOWDOWN;
}
const target = reported + creep;
// 가상 진행을 따를 때는 거꾸로 가지 않는다 — 기어간 만큼보다 작은 값이 와도 표시값은 그 자리에서 기다린다
if (target >= disp || manual >= 0) {
disp += (target - disp) * (reported >= 1 ? FINISH_SMOOTH : PROGRESS_SMOOTH);
}
if (reported >= 1 && disp > 0.995) {
disp = 1;
if (doneAt < 0) { doneAt = clock; }
}
if (disp < 1) { doneAt = -1; }
histR.push(reported);
histD.push(disp);
if (histR.length > HIST_MAX) { histR.shift(); histD.shift(); }
}
function settle(): void {
reported = incoming();
disp = reported;
creep = 0;
creepV = 0;
sameMs = 0;
doneAt = -1;
}
function onRestart(): void {
reported = 0;
disp = 0;
creep = 0;
creepV = 0;
sameMs = 0;
doneAt = -1;
histR.length = 0;
histD.length = 0;
}
// 끝 맥동 — 100% 에 닿은 순간부터 DONE_PULSE_MS 동안 사인 반 주기로 0 → 1 → 0
function pulseAt(t: number): number {
if (doneAt < 0 || still) { return 0; }
const k = (t - doneAt) / DONE_PULSE_MS;
return k >= 0 && k <= 1 ? Math.sin(Math.PI * k) : 0;
}
function chart(x: number, y: number, w: number, h: number): void {
ctx.strokeStyle = rgba(PAPER, 0.14);
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
const n = histR.length;
if (n < 2) { return; }
const lines: [number[], RGB, number][] = [[histR, PAPER, 0.45], [histD, PAL[2], 1]];
for (let s = 0; s < lines.length; s += 1) {
const arr = lines[s][0];
ctx.beginPath();
for (let i = 0; i < n; i += 1) {
const px = x + (i / (HIST_MAX - 1)) * w;
const py = y + h - arr[i] * h;
if (i === 0) { ctx.moveTo(px, py); } else { ctx.lineTo(px, py); }
}
ctx.strokeStyle = rgba(lines[s][1], lines[s][2]);
ctx.lineWidth = s === 0 ? 1.2 : 2;
ctx.stroke();
}
}
function draw(): void {
clearStage();
const k = pulseAt(clock);
// 고리 — 위에서 시계 방향으로 표시값만큼
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(CX, CY, RING_R, 0, TAU);
ctx.strokeStyle = rgba(PAPER, 0.12);
ctx.lineWidth = 6;
ctx.stroke();
if (disp > 0) {
ctx.beginPath();
ctx.arc(CX, CY, RING_R, -Math.PI / 2, -Math.PI / 2 + disp * TAU);
ctx.strokeStyle = rgba(PAL[2], 1);
ctx.stroke();
}
// 마크 — 고리와 같은 각도의 부채꼴로 잘라 채운다. 끝 맥동 동안 자기 중심에서 부푼다
ctx.save();
ctx.translate(CX, CY);
ctx.scale(1 + 0.08 * k, 1 + 0.08 * k);
ctx.translate(-CX, -CY);
logoPath();
ctx.fillStyle = rgba(PANEL, 1);
ctx.fill('evenodd');
if (disp > 0) {
ctx.save();
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.arc(CX, CY, RING_R + 20, -Math.PI / 2, -Math.PI / 2 + disp * TAU);
ctx.closePath();
ctx.clip();
logoPath();
ctx.fillStyle = rgba(mix(PAL[2], PAL[6], 0.4), 1);
ctx.fill('evenodd');
ctx.restore();
}
logoGuide(0.5);
ctx.restore();
if (k > 0) {
ctx.beginPath();
ctx.arc(CX, CY, RING_R + 30 * k, 0, TAU);
ctx.strokeStyle = rgba(PAL[2], 0.7 * (1 - k * 0.6));
ctx.lineWidth = 3;
ctx.stroke();
}
// 큰 숫자 — 내림으로 센다. 끝이 오기 전에는 100 을 쓰지 않는다
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.6);
ctx.font = '500 11px sans-serif';
ctx.fillText('표시값', LEG_X, 102);
ctx.fillStyle = rgba(PAPER, 0.98);
ctx.font = '700 42px sans-serif';
ctx.fillText(Math.floor(disp * 100) + '%', LEG_X, 144);
statRow(166, '받은 양', pct(reported));
chart(LEG_X, 200, 140, 40);
}
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); } else { advanceLoad(FIXED_DT); }
updateProgress();
step();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 — rAF 루프 없이 STILL_TICK_MS 마다 가상 진행만 한 칸 옮기고, 그 진행률의 끝 모습(물결·스프링·뒤집기 없이)을 그린다
const STILL_TICK_MS = 400;
let stillTimer = 0;
function stillTick(): void {
stillTimer = 0;
if (!still) { return; }
advanceLoad(STILL_TICK_MS);
after();
}
function kickStill(): void {
if (!still || stillTimer || paused || manual >= 0 || loadMs >= LOAD_MS) { return; }
stillTimer = window.setTimeout(stillTick, STILL_TICK_MS);
}
function stopStill(): void {
if (stillTimer) { window.clearTimeout(stillTimer); stillTimer = 0; }
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 그 진행률의 끝 모습으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
stopStill();
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
stopStill();
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="계단처럼 들어오는 진행 값을 표시 숫자가 매끄럽게 따라 세고 100% 에서 마크가 한 번 부푸는 로딩 화면. 가로로 끌거나 방향키로 진행률을 직접 옮기고, Enter 로 다시 시작, Space 로 일시정지, Esc 로 가상 진행에 돌아간다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">숫자가 따라 오르는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="restart" type="button">다시 시작</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
<div className={cx("pc__bar")} data-role="bar" role="progressbar" aria-label="로고 불러오기 진행률" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span className={cx("pc__fill")} data-role="fill"></span></div>
</div>
);
}09 · 들쭉날쭉한 실제 진행을 매끄럽게 따라 세고 끝나면 로고가 한 번 부푸는 퍼센트 로더 — 측정 누적면적 28.847% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function step() {
const r = incoming();
if (r !== reported) {
reported = r;
sameMs = 0;
creep = 0;
creepV = CREEP_V0;
}
else {
sameMs += FIXED_DT;
}
// 멈춤 — 가상 진행을 따르는 동안 새 값이 STALL_AFTER_MS 넘게 안 오면 목표를 받은 값보다 조금씩 앞으로 밀되, 미는 빠르기에 STALL_SLOWDOWN 을 거듭 곱해 점점 느려진다
if (sameMs > STALL_AFTER_MS && reported < 1 && manual < 0) {
creep = Math.min(creep + creepV, (1 - reported) * CREEP_CAP);
creepV *= STALL_SLOWDOWN;
}
const target = reported + creep;
// 가상 진행을 따를 때는 거꾸로 가지 않는다 — 기어간 만큼보다 작은 값이 와도 표시값은 그 자리에서 기다린다
if (target >= disp || manual >= 0) {
disp += (target - disp) * (reported >= 1 ? FINISH_SMOOTH : PROGRESS_SMOOTH);
}
if (reported >= 1 && disp > 0.995) {
disp = 1;
if (doneAt < 0) {
doneAt = clock;
}
}
if (disp < 1) {
doneAt = -1;
}
진행률이 거꾸로 가거나 한참 멈추거나 로고 획이 가늘 때 — 로고 로딩 화면이 깨지는 순간
이 절의 수치는 vanilla index.html 의 함수를 그대로 불러 다시 돌린 결과이고, 기기마다 다른 프레임 처리 시간은 재지 않았다.
진행률이 뒤로 갈 때 장면마다 다른 반응
01·04·05 는 상태를 들고 있지 않아 진행률을 끌어서 되돌리면 선과 타일과 화소도 같이 되감긴다. 받은 양이 줄어드는 일은 실제로도 생길 수 있으니, 되감기는 연출이 어색한 자리라면 진행 값을 넘기기 전에 최댓값만 남기는 쪽이 낫다. 09 는 가상 진행을 따르는 동안 목표가 표시값보다 작아지면 그 자리에서 기다리고, 손으로 옮길 때만 뒤로 간다.
274ms 멈춤 동안 숫자가 앞질렀다 되돌아가는 일
멈춘 구간에서 목표를 미는 양에 상한과 감속이 없으면 다음 값이 오기 전에 표시값이 받은 양을 넘었다가 뒤로 물러난다. 09 는 미는 양을 남은 양의 40% 로 묶고 0.96 감속을 걸어 실제로 민 양이 최대 2.44%p 에 그쳤다. 감속을 1 로 풀면 3.68%p 까지 밀리고, 따라가는 빠르기를 0.15 로 올리면 민 목표까지 따라붙어 표시값이 받은 양보다 최대 1.82%p 앞선다. 위에서 말한 「넘지 않는다」 는 기본값 0.06 에서의 결과다. 끝 신호를 받은 뒤에도 6% 로만 좁히면 99% 근처에서 한참 머무르므로 그때만 0.25 로 빠르게 닫고, 숫자는 Math.floor 로 내려 써서 99.5% 에서 100 이 먼저 뜨지 않게 했다.
점 간격이 고르지 않은 외곽선과 이음새의 직선
점 번호로 펜을 옮기면 같은 시간에 0.22px 를 가는 칸과 12.85px 를 가는 칸이 섞여 모서리마다 펜이 멈칫한다. 외곽선 두 개를 한 표로 이을 때 이음새에도 거리를 더하면 바깥 끝에서 별 구멍으로 건너가는 직선이 한 줄 그어지는데, arcTable 은 그 자리에 0 을 더해 펜을 들고 건너뛴다.
획이 가늘면 화소 표본이 비고, 간격을 줄이면 점이 불어난다
03 의 표본은 3px 에서 시작해 6px 마다 칸 하나를 보는 격자라, 6px 보다 가는 획은 칸 중심을 비켜 가면 점을 하나도 받지 못할 수 있다. 간격을 줄이면 점이 빠르게 늘어난다. 글에 적은 718개는 브라우저가 안티에일리어싱까지 칠한 화면에서 센 값이고, 노드에서 같은 판독을 짝홀 판정으로 흉내 내면 721개가 나오며 그 기준으로 4px 간격은 1,621개(약 2.25배), 8px 간격은 400개다. 알파 문턱을 0 보다 큰 값으로 낮추면 반투명 가장자리 화소까지 점이 되고, 판독을 매 프레임 되풀이하면 화소 수만큼 일이 불어나므로 초기화 때 한 번만 읽는다.
물결이 터지는 세기와 멈추지 않는 감쇠
02 의 주고받는 세기를 0.6 으로 올리면 600스텝 안에 수면 높이가 4.3×10^307 까지 치솟는다. 소스 주석이 경고하는 대로 0.5 를 넘기면 받은 것보다 더 돌려주기 때문이고, 감쇠를 1 로 두면 출렁임이 줄지 않는다. 물결을 프레임마다 한 번씩 풀면 주사율이 높은 화면에서 더 빨리 출렁이므로 이 소스는 16ms 고정 스텝으로 푼다.
시간표 끝과 폭 0 의 깜박임
04 의 시간표 길이에 마지막 타일의 뒤집기 시간 400ms 를 넣지 않으면 진행률 100% 에서 마지막 대각 줄이 반쯤 선 채로 끝난다. 가로 폭이 0 이 되는 프레임에는 타일이 사라져 깜박이므로 폭은 0.02 아래로 내리지 않는다. 출발 간격을 30ms 로만 줄여도 시간표가 760ms 로 짧아져 13줄이 한꺼번에 뒤집히고, 진행률 50% 에서 64장이 모두 반쯤 서서 파면이 보이지 않는다.
시작점과 감는 방향이 어긋난 모핑
07 에서 시작점을 맞추지 않으면 점이 최대 129.1px 를 가로질러 모양이 꼬였다가 풀린다. 원은 각도로, 마크는 꼭짓점 번호로 뽑으면 모서리 쪽에 점이 몰려 짝이 어긋나므로 둘 다 호 길이로 다시 뽑았고, 두 외곽선이 반대 방향으로 감기면 어떤 시작점에서도 비용이 줄지 않아 둘 다 시계 방향으로 맞췄다.
미리 구하지 않은 노이즈, 0 에서 멈춘 문턱
05 가 노이즈를 프레임마다 다시 계산하면 화소 25,952개에 4겹씩을 매번 되풀이하게 되니, 값은 처음에 한 번 구하고 프레임에서는 비교만 한다. 문턱의 끝을 0 에 두면 100% 에서도 띠가 남아 가장자리가 달군 채 끝나므로 −0.07 까지 내린다.
꼬리 감쇠가 세면 길이를 늘려도 안 보인다
06 의 감쇠를 0.9 로 세게 하면 밝기가 29점째에 5% 아래로 떨어져, 진행률이 올라 꼬리를 90점까지 늘려도 보이는 길이는 그대로다. 0.975 에서는 그 경계가 119점째라 90점 꼬리가 끝까지 보인다. 지난 위치를 배열 앞에 끼워 넣는 식으로 버퍼를 다루면 스텝마다 90칸을 옮기게 되고, 고리 버퍼는 머리 칸 번호만 돌린다.
층을 칠하는 순서와 기울기
08 에서 앞 층부터 칠하면 뒤 층이 앞면을 덮어 버린다. 기울기를 10° 로 줄이면 아래 꼭짓점 벌어짐이 −0.7px 가 되어 층을 다 쌓아도 두께가 앞면 뒤에 숨고, 층 수를 정수로만 늘리면 열네 번 툭툭 끊겨 쌓인다.
아홉 폴더의 vanilla 소스를 내 진행 값과 내 마크로 바꿔 돌려 보고 싶다면, 내려받은 zip 을 열 때 5t93rr73 을 넣으면 되고 같은 계산을 옮긴 리액트 판도 함께 들어 있다.
물결 감쇠·타일 출발 간격·숫자 따라가는 빠르기를 옮겨 보는 변형과 움직임을 줄인 화면의 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 물결 감쇠 키우기 | 02 WAVE_DAMP 0.985 → 0.94 | 기둥 하나를 밀었을 때 출렁임이 처음 최고점의 10% 아래로 내려가 그대로 머물기까지 182스텝(2,912ms) 이던 것이 52스텝(832ms)으로 짧아진다. 시연 진행에서 수면이 기준 수위를 벗어난 가장 큰 높이도 49.3px 에서 40.0px 로 낮아져 물이 짧게 흔들리고 곧 잔잔해진다. |
| 타일 출발 간격 늘리기 | 04 SWEEP_DELAY_MS 60 → 120 | 시간표가 1,120ms 에서 1,840ms 로 길어지고 한 순간에 함께 뒤집히는 대각 줄이 7개에서 4개로, 진행률 50% 에서 반쯤 선 타일이 42장에서 18장으로 준다. 넓고 부드러운 파면이 좁고 또렷한 사선 물결로 바뀐다. 거꾸로 30ms 로 줄이면 50% 에서 64장이 모두 반쯤 서서 물결이 보이지 않는다. |
| 숫자 따라가는 빠르기 올리기 | 09 PROGRESS_SMOOTH 0.06 → 0.15 | 끌어서 0 에서 50% 로 한 번에 옮겼을 때 남은 차가 10% 로 줄기까지 38스텝(608ms) 걸리던 것이 15스텝(240ms)이 된다. 숫자가 받은 값에 거의 바로 붙는 대신 멈춤 구간에서는 표시값이 받은 양보다 최대 1.82%p 앞선다. |
표에 없는 값도 한 줄씩 바꿔 볼 만하다. 01 의 TRACE_EASE_POW 를 1 로 두면 25·50·75% 에서 정확히 그만큼(215·431·646px)을 그어 곡선 없는 고른 속도가 되고, 1 아래로 내리면 앞이 느리고 끝에서 몰아 닫는다. 03 의 SPRING_DAMP 를 20 으로 올리면 감쇠비가 1.054 가 되어 지나침이 사라지는 대신 1.5px 안에 머물기까지 800ms 가 걸린다.
아래 진행 띠는 role="progressbar"·aria-valuemin="0"·aria-valuemax="100" 과 「로고 불러오기 진행률」 이라는 이름을 갖고, aria-valuenow 에는 진행률이 아니라 그 순간 화면에 그린 값을 싣는다. 그래서 02 는 수위를, 03 은 도착한 점의 비율을, 09 는 표시값을 알려 듣는 숫자와 보이는 그림이 갈라지지 않는다.
키보드만으로도 아홉 장면을 다 다룬다. 캔버스마다 tabindex="0" 과 한국어 설명이 붙어 있어 포커스가 들어가면 Enter 로 처음부터 다시, Space 로 일시정지와 이어 가기, →·↑ 로 5% 앞으로, ←·↓ 로 5% 뒤로, Home 으로 0%, End 로 100% 를 고르고, Esc 를 누르면 손으로 옮긴 값을 놓고 가상 진행으로 돌아간다. 처리하는 키에만 기본 동작을 막아 페이지 스크롤은 그대로 살아 있고, 「다시 시작」·「일시정지」 는 진짜 <button> 이며 일시정지 버튼은 aria-pressed 로 눌림 상태를 알린다. 왼쪽 아래 상태 줄은 자동 시연이 도는 동안 aria-live="off" 로 조용하다가, 사람이 처음 끌거나 키를 누르거나 버튼을 건드리는 순간 polite 로 바뀌어 그 뒤의 상태만 읽어 준다.
prefers-reduced-motion 에서 reduce 를 고른 화면에서는 requestAnimationFrame 루프를 아예 시작하지 않는다. 대신 400ms 마다 setTimeout 이 가상 진행을 한 칸 옮기고, 물결·스프링·뒤집기 같은 중간 과정 없이 그 진행률의 끝 모습과 숫자만 계단식으로 바꿔 그린다. 04 의 타일은 반쯤 선 모습 없이 앞면 아니면 뒷면이고, 09 는 부풀기 없이 받은 값을 그대로 보이며, 06 은 머리를 세워 둔 채 꼬리를 한 번에 채우고, 시간 값 clock 으로 움직이던 03 의 맴돌기와 07 의 하이라이트, 08 의 흔들림도 멈춘다. 이 설정은 matchMedia 의 change 리스너가 지켜보고 있어서, 페이지를 연 채 설정을 켜면 루프를 멈추고 지금 진행률의 끝 모습으로 바꾸며 끄면 루프가 다시 돈다.
회사 로고 애니메이션을 영상 파일 대신 코드로 두면 무엇이 달라지는가?
영상은 정해진 길이로 재생되는 그림이라 불러오기가 멈추든 거꾸로 가든 화면은 제 길대로 흘러간다. 코드로 둔 마크는 진행 값을 받아 그 값의 모습을 그리므로 멈추면 멈추고, 끝나는 순간에 맞춰 09 처럼 한 번 부풀 수도 있다. 진행 띠의 aria-valuenow 와 동작 줄이기 설정 대응, 키보드 조작도 같은 소스 안에 함께 들어간다. 색은 팔레트 상수, 빠르기와 감쇠는 이름 붙은 상수 한 줄이라 브랜드 색이나 분위기에 맞추려고 영상을 다시 뽑을 일도 없다.
시간에 묶어 조각을 맞추는 쪽이 필요하면 인트로 애니메이션 9종, 마크 없이 물리 계산으로 도는 로더는 캔버스 물리 로더 9종, CSS 만으로 꾸미는 로딩 화면은 로딩 화면 디자인 9종 에서 이어 볼 수 있다. 03 처럼 화소를 점으로 뜯는 기법은 글자 화소 표본 타이포그래피, 화소 단위 계산으로 장면을 만드는 쪽은 캔버스 특수효과 9종 이 가깝다.
로고 로딩 화면을 내 사이트 첫 화면에 붙일 때 자주 나오는 질문
실제 불러오기 진행률은 코드의 어느 자리에 넣나?
데모의 진행 값은 makeLoad(469) 가 만든 가상 표이고 네트워크 요청은 하나도 없다. 01~08 은 targetProgress() 가 돌려주는 loadAt(loadMs) 를 내 진행 값(0~1)으로 바꾸면 되고, 09 는 incoming() 안의 reportedAt(loadMs) 자리에 띄엄띄엄 들어오는 값을 그대로 넣는다. 09 의 평활과 멈춤 감속은 바로 그런 계단 값을 다듬으려고 있는 계산이다.
로딩 중 로고를 내 마크 모양으로 바꿀 수 있나?
마크는 LOGO_OUTER = roundCorners(hexVerts(LOGO_R), 0.2, 6) 과 LOGO_HOLE = roundCorners(starVerts(44, 14), 0.16, 3) 두 점 배열이라, 이 두 줄을 내 외곽선 좌표로 바꾸면 아홉 장면이 그 모양을 쓴다. 01 은 호 길이 표 덕분에 어떤 외곽선이든 같은 빠르기로 긋고, 03 은 칠해진 화소를 읽으므로 모양을 가리지 않는다. 07 은 두 외곽선이 원과 같은 시계 방향으로 감겨 있어야 정렬 비용이 제대로 줄어든다.
끝을 알 수 없는 기다림에도 쓸 수 있나?
아홉 장면은 0 에서 1 까지의 진행 값이 있다는 전제로 그린다. 진행 값이 없는 대기라면 06 이 가장 가깝다. 06 의 머리는 진행과 상관없이 스텝마다 STEP_LEN 만큼 돌고 꼬리 길이만 진행 값을 따르므로, 꼬리 쪽에 내가 아는 만큼의 값을 넣고 나머지는 도는 머리에 맡기면 된다.









