09A Percent Loader That Smoothly Follows Uneven Real Progress and Pulses the Logo at the End
It treats progress values that arrive in steps as the target and has the displayed value close only a PROGRESS_SMOOTH share of the remaining gap every 16ms step; while progress stalls it nudges the target forward but keeps multiplying that nudge by STALL_SLOWDOWN so it slows down, and at 100% the logo swells once and settles over DONE_PULSE_MS.
Best forWaiting for a file export or report build
PROGRESS_SMOOTHSTALL_SLOWDOWNDONE_PULSE_MS

<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="A loading screen where the displayed number smoothly counts after progress that arrives in steps, and the mark swells once at 100%. Drag sideways or use the arrow keys to move the progress yourself; press Enter to restart, Space to pause, and Esc to return to the simulated load"></canvas>
<p class="pc__status" data-role="status" aria-live="off">Number catching up</p>
<div class="pc__tools">
<button class="pc__btn" data-role="restart" type="button">Restart</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
<div class="pc__bar" data-role="bar" role="progressbar" aria-label="Logo loading progress" 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 은 끌기·방향키로 직접 정한 Progress(−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 = 'Done loading — Enter to restart';
const TXT_PAUSE = 'Paused — Space to resume';
const TXT_MANUAL = 'Moving it by hand — Esc to go back';
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;
// 멈춤에 들어선 첫 스텝에 목표를 미는 양(Progress/스텝)
const CREEP_V0 = 0.0016;
// 민 양의 상한 — 남은 양(1 − Received)의 이 비율까지만
const CREEP_CAP = 0.4;
// 끝(Received 100%)이 오면 이 비율로 빨리 닫는다
const FINISH_SMOOTH = 0.25;
const RING_R = 122;
const TXT_RUN = 'Number catching up';
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('Shown', 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, 'Received', 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 A Percent Loader That Smoothly Follows Uneven Real Progress and Pulses the Logo at the End — Measured area 28.847% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Logo Loading Animation: 9 Progress-Driven Marks
The post this demo lives in
Logo Loading Animation: 9 Progress-Driven MarksWhat should a loading logo turn 0-to-1 progress into? 03 springs pixel-sampled points into place, 05 lowers a noise thre See all demos in this post
If you like this effect - similar demos
Loaders06 Footer loader that fetches the next page at the end of the listPage Loading Animation CSS: 9 Route-Change Demos
Loaders03 A Percent Counter That Eases After the Real ValueSplash Screen Animation: 9 Canvas Opening Scenes
Loaders08 Chunked pause and resume that only re-fills the sent partFile Upload Progress Bar Loader: 9 UI Demos