07A Progress Ring Whose Letters Fill as the Upload Advances
A simulated upload speed is added up into progress and smoothed with an exponential average for the time left, and each glyph is filled only as far as the progress angle has covered its own angle span, so the ring fills with the letter at the edge half colored.
Best forPhoto upload progress indicator
SPEED_SMOOTHFILE_MBSTATUS_STEP

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
<p class="cf__sr">UPLOADING PHOTOS</p>
<p class="cf__status" data-role="status" aria-live="off">Ready to upload</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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-cream;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 고리에서 무슨 계산이 일어났는지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 낭독용 원문 한 줄 — 화면에서는 숨기고 낭독기에만 읽힌다(원 위 글자는 캔버스라 aria-hidden)
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 최종 배치 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
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: #fff7e6;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 1/60초마다 ease 만큼 다가가는 비율을 dt 초로 환산 — 1 − (1 − ease)^(dt × 60)
function easeStep(ease, dt) { return 1 - Math.pow(1 - ease, dt * 60); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (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 deg(rad) { return rad * 180 / Math.PI; }
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
function roughWidth(ch, px, weight) {
const wide = ch.charCodeAt(0) >= 0x1100;
const base = ch === ' ' ? 0.28 : (wide ? 0.95 : 0.6);
return px * base * (weight >= 700 ? 1.06 : 1);
}
// 글꼴이 다 온 뒤 잰 폭
let glyphWidth = roughWidth;
// 대체 글꼴로 잰 폭 — 09 가 「글꼴이 오기 전에 잰 폭」 을 흉내 낼 때만 쓴다
let fallbackWidth = roughWidth;
// 글자 나누기 — 한 글자씩(서로게이트 쌍도 한 글자)
function glyphsOf(s) { return Array.from(s); }
function widthsOf(gs, px, weight, m) {
return gs.map(function (g) { return m(g, px, weight); });
}
function sum(xs) { let s = 0; for (const x of xs) {
s += x;
} return s; }
function arcTotal(ws, tracking) { return sum(ws) + tracking * Math.max(0, ws.length - 1); }
// 글꼴 축소 몫 — 줄이지 않아도 들어가면 1
function arcScale(ws, px, tracking, r, inner, span) {
const total = arcTotal(ws, tracking);
const need = total / Math.max(1, r - inner * px);
return need > span ? span * r / (total + span * inner * px) : 1;
}
function fitArc(ws, px, tracking, r, inner, span, center, dir, allowShrink) {
return placeArc(ws, px, tracking, r, inner, allowShrink ? arcScale(ws, px, tracking, r, inner, span) : 1, center, dir);
}
// 정한 축소 몫으로 놓기 — 위·아래 반원이 같은 글꼴 크기를 쓰도록 둘 중 작은 몫을 넘겨받는다
function placeArc(ws, px, tracking, r, inner, scale, center, dir) {
const n = ws.length;
const total = arcTotal(ws, tracking);
const need = total / Math.max(1, r - inner * px);
const layR = Math.max(1, r - inner * px * scale);
const theta = [];
let acc = 0;
for (let i = 0; i < n; i += 1) {
theta.push(center + dir * (acc + ws[i] / 2 - total / 2) * scale / layR);
acc += ws[i] + tracking;
}
return { theta: theta, px: px * scale, scale: scale, total: total, layR: layR, need: need };
}
// 같은 각도로 놓기(비교판) — 폭은 보지 않고 전체 각을 글자 수로 똑같이 나눈다
function equalArc(n, spanUsed, center, dir) {
const out = [];
const step = spanUsed / Math.max(1, n);
for (let i = 0; i < n; i += 1) {
out.push(center + dir * (i + 0.5 - n / 2) * step);
}
return out;
}
// 이웃 두 글자가 글자 아래 모서리(반지름 r − 0.5·글자 높이)에서 겹친 길이(px). 양수면 부딪힌다.
// 각 간격 × 아래 모서리 반지름 − 두 글자 반폭 합 이 음수인 만큼이 겹침
const GLYPH_DROP = 0.5;
function innerOverlaps(theta, ws, scale, r, px) {
const rin = r - GLYPH_DROP * px;
const out = [];
for (let i = 0; i + 1 < theta.length; i += 1) {
const room = Math.abs(theta[i + 1] - theta[i]) * rin;
out.push((ws[i] + ws[i + 1]) / 2 * scale - room);
}
return out;
}
function arcTable(rf, drf, th0, th1, step) {
const th = [th0];
const s = [0];
let acc = 0;
const n = Math.max(1, Math.ceil((th1 - th0) / step));
const h = (th1 - th0) / n;
for (let i = 0; i < n; i += 1) {
// 걸음 가운데의 값으로 더한다 — 걸음 끝 값보다 같은 걸음에서 오차가 작다
const m = th0 + (i + 0.5) * h;
const r = rf(m);
const dr = drf(m);
acc += Math.sqrt(r * r + dr * dr) * h;
th.push(th0 + (i + 1) * h);
s.push(acc);
}
return { th: th, s: s };
}
function thetaAt(t, s) {
const last = t.s.length - 1;
if (s <= 0) {
return t.th[0];
}
if (s >= t.s[last]) {
return t.th[last];
}
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (t.s[mid] <= s) {
lo = mid;
}
else {
hi = mid;
}
}
const k = (s - t.s[lo]) / Math.max(1e-9, t.s[hi] - t.s[lo]);
return lerp(t.th[lo], t.th[hi], k);
}
function arcLength(t) { return t.s[t.s.length - 1]; }
// 극좌표 곡선의 접선 각(화면 좌표, y 아래) — 점 (r cosθ, r sinθ) 을 θ 로 미분한 방향
function tangentAngle(r, dr, th) {
return Math.atan2(dr * Math.sin(th) + r * Math.cos(th), dr * Math.cos(th) - r * Math.sin(th));
}
// 장면 시계(s)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [241, 234, 220];
const INK = [44, 38, 58];
const INK_SOFT = [112, 104, 126];
const CREAM = [255, 251, 242];
const LAVENDER = [196, 182, 238];
const MINT = [164, 218, 196];
const BUTTER = [246, 224, 150];
const CORAL = [244, 170, 152];
const SKY = [170, 206, 238];
const PINK = [244, 190, 212];
const MINT_DEEP = [36, 116, 90];
const CORAL_DEEP = [178, 66, 52];
const VIOLET_DEEP = [84, 58, 160];
const BROWN_DEEP = [104, 62, 40];
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 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) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 대체 글꼴 — 09 가 「글꼴이 오기 전에 잰 폭」 을 흉내 낼 때만 쓴다
const FALLBACK_STACK = 'monospace';
// 잰 폭은 100px 기준으로 한 번만 재 두고 크기에 비례해 쓴다. 글꼴이 새로 오면 통째로 비운다
const widthCache = {};
function measureWith(stack) {
return function (ch, px, weight) {
const key = stack + '|' + weight + '|' + ch;
let w = widthCache[key];
if (w === undefined) {
ctx.font = weight + ' 100px ' + stack;
w = ctx.measureText(ch).width;
widthCache[key] = w;
}
return w * px / 100;
};
}
function remeasure() {
for (const k of Object.keys(widthCache)) {
delete widthCache[k];
}
glyphWidth = measureWith(FONT_STACK);
fallbackWidth = measureWith(FALLBACK_STACK);
relayout();
}
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);
}
function clearStage(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px, weight, stack) {
ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + stack;
}
function label(s, x, y, c, a, px, weight, align) {
setFont(px, weight, FONT_STACK);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 하나를 (x, y) 가운데에 rot 라디안 돌려 찍는다. sx·sy 는 가로·세로 배율(음수 sx 는 거울)
function glyphAt(ch, x, y, rot, px, weight, c, a, sx, sy) {
if (a <= 0.01 || px < 0.5) {
return;
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(sx, sy);
setFont(px, weight, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(ch, 0, 0);
ctx.restore();
}
// 기울인 글자 — 글자 자기 좌표에서 가로로 skew(라디안)만큼 민다(윗부분이 앞으로)
function glyphLean(ch, x, y, rot, px, weight, c, a, skew) {
if (a <= 0.01 || px < 0.5) {
return;
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.transform(1, 0, -Math.tan(skew), 1, 0, 0);
setFont(px, weight, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(ch, 0, 0);
ctx.restore();
}
// 원 위 글자 — 중심 (cx, cy), 반지름 r, 각 th. turn 은 글자를 원에 맞춰 세우는 각(위 반원 +π/2, 아래 반원 −π/2)
function ringGlyph(ch, cx, cy, r, th, turn, px, weight, c, a) {
glyphAt(ch, cx + r * Math.cos(th), cy + r * Math.sin(th), th + turn, px, weight, c, a, 1, 1);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 그림자 — 떠 있는 높이 lift(0~1)가 클수록 멀리·흐리게·넓게 떨어진다
function softShadow(x, y, r, lift) {
const off = 4 + 14 * lift;
const g = ctx.createRadialGradient(x + off * 0.4, y + off, r * 0.6, x + off * 0.4, y + off, r * (1.05 + 0.25 * lift));
g.addColorStop(0, rgba(INK, 0.26 - 0.14 * lift));
g.addColorStop(1, rgba(INK, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x + off * 0.4, y + off, r * (1.05 + 0.25 * lift), 0, TAU);
ctx.fill();
}
// 작은 알약 이름표
function pill(s, x, y, bg, fg, a, px) {
setFont(px, 700, FONT_STACK);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 07 — 진행률만큼 글자가 차오르는 원형 진행 고리. 흉내 낸 올리기 속도(시드 난수로 흔들림)를 지수 평균으로 다듬어 남은 시간을 내고,
// 진행 각 Θ = 2π·p 가 글자 i 의 각 구간 [a_i, b_i] 를 덮은 비율 clamp((Θ − a_i) / (b_i − a_i), 0, 1) 로 그 글자를 채울지 고르고(0 이면 건너뜀),
// 실제 채움은 원 중심에서 맨 위(−π/2) ~ Θ 부채꼴로 자른(clip) 안쪽만 칠한다 — 경계는 반지름 방향 선
const PROG_TEXT = 'UPLOADING PHOTOS · ';
const PROG_PX = 22;
const PROG_WEIGHT = 800;
const PROG_R = 92;
const INNER_FIT = 0.5;
const FILE_MB = 24;
// 올리기 속도(MB/s)의 평균과 흔들림 몫, 지수 평균 몫(1/60초마다 새 값 쪽으로 다가가는 비율)
const RATE_MEAN = 8.5;
const RATE_JITTER = 0.55;
const SPEED_SMOOTH = 0.06;
const RATE_SEED = 7;
const START_S = 0.2;
const LOOP_S = 5.4;
const STATUS_STEP = 10;
const STATUS_PROG = '{p}% uploaded · about {s} s left';
const STATUS_DONE = 'Upload done: 100%';
const progGlyphs07 = glyphsOf(PROG_TEXT);
let progRepeat07 = 1;
// 글자 i 의 각 구간 — 고리 맨 위(−π/2)에서 시계 방향으로 잰 시작·끝 각
let progA07 = [];
let progB07 = [];
let progAll07 = [];
let rng07 = makeRng(RATE_SEED);
let done07 = 0;
let speed07 = 0;
let raw07 = 0;
let doneAt07 = -1;
function relayout() {
const ws = widthsOf(progGlyphs07, PROG_PX, PROG_WEIGHT, glyphWidth);
const layR = PROG_R - INNER_FIT * PROG_PX;
progRepeat07 = Math.max(1, Math.floor(TAU * layR / sum(ws)));
const total = sum(ws) * progRepeat07;
progA07 = [];
progB07 = [];
progAll07 = [];
let acc = 0;
for (let k = 0; k < progGlyphs07.length * progRepeat07; k += 1) {
const w = ws[k % progGlyphs07.length];
progA07.push(acc / total * TAU);
progB07.push((acc + w) / total * TAU);
progAll07.push(progGlyphs07[k % progGlyphs07.length]);
acc += w;
}
}
function progress07() { return clamp(done07 / FILE_MB, 0, 1); }
function cover07(i, theta) { return clamp((theta - progA07[i]) / Math.max(1e-6, progB07[i] - progA07[i]), 0, 1); }
function remain07() { return speed07 > 0.01 ? (FILE_MB - done07) / speed07 : 0; }
// 상태 줄은 10% 단위가 바뀔 때만 새로 쓴다 — 남은 시간도 그 순간 값으로 굳혀 낭독기가 프레임마다 숫자를 읽지 않게
let statusStep07 = -1;
function statusFor07() {
const p = progress07();
if (p >= 1) {
simStatus = STATUS_DONE;
statusStep07 = 100;
return;
}
const step = Math.floor(p * 100 / STATUS_STEP) * STATUS_STEP;
if (step === statusStep07) {
return;
}
statusStep07 = step;
simStatus = STATUS_PROG.replace('{p}', String(step)).replace('{s}', String(Math.ceil(remain07())));
}
function reset() {
statusStep07 = -1;
rng07 = makeRng(RATE_SEED);
done07 = 0;
speed07 = 0;
raw07 = 0;
doneAt07 = -1;
}
function update(dt) {
if (sceneT >= START_S && done07 < FILE_MB) {
// 새 순간 속도 — 평균 둘레로 흔들리고, 가끔(난수 0.07 미만) 한 박자 크게 떨어진다
const u = rng07();
raw07 = RATE_MEAN * (1 + RATE_JITTER * (rng07() - 0.5) * 2) * (u < 0.07 ? 0.25 : 1);
speed07 = speed07 <= 0 ? raw07 : speed07 + (raw07 - speed07) * easeStep(SPEED_SMOOTH, dt);
done07 = Math.min(FILE_MB, done07 + raw07 * dt);
if (done07 >= FILE_MB && doneAt07 < 0) {
doneAt07 = sceneT;
}
}
statusFor07();
}
function restPose() {
done07 = FILE_MB;
speed07 = RATE_MEAN;
statusFor07();
}
// 07 화면 — 글자 윤곽(빈 글자) 위로 진행 각까지의 부채꼴 안만 채운 글자를 겹친다. 부채꼴 경계는 원 중심에서 뻗은 선이라
// 한 글자 안에서도 경계가 반지름 방향으로 지나간다. 글자 하나가 다 차면 그 글자만 잠깐 부풀었다 돌아온다
const LABEL_LEFT = 'time left';
const LABEL_SEC = 's';
const P_CX = 240;
const P_CY = 150;
function bindInput(signal) { void signal; }
function progGlyph(i, filled, bump) {
const px = PROG_PX;
const layR = PROG_R - INNER_FIT * px;
const mid = -Math.PI / 2 + (progA07[i] + progB07[i]) / 2;
const rr = layR + INNER_FIT * px;
const x = P_CX + rr * Math.cos(mid);
const y = P_CY + rr * Math.sin(mid);
ctx.save();
ctx.translate(x, y);
ctx.rotate(mid + Math.PI / 2);
ctx.scale(1 + bump, 1 + bump);
setFont(px, PROG_WEIGHT, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (filled) {
ctx.fillStyle = rgba(VIOLET_DEEP, 1);
ctx.fillText(progAll07[i], 0, 0);
}
else {
ctx.lineWidth = 1.1;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.strokeText(progAll07[i], 0, 0);
}
ctx.restore();
}
function draw() {
clearStage(PAPER);
const p = progress07();
const theta = TAU * p;
const rOut = PROG_R + PROG_PX * 0.85;
const rIn = PROG_R - PROG_PX * 0.95;
softShadow(P_CX, P_CY, rOut, 0.2 + 0.5 * p);
disc(P_CX, P_CY, rOut, LAVENDER, 1);
// 진행 띠 — 맨 위에서 Θ 까지
if (theta > 0.001) {
ctx.beginPath();
ctx.arc(P_CX, P_CY, rOut, -Math.PI / 2, -Math.PI / 2 + theta);
ctx.arc(P_CX, P_CY, rIn, -Math.PI / 2 + theta, -Math.PI / 2, true);
ctx.closePath();
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
}
disc(P_CX, P_CY, rIn, CREAM, 1);
ring(P_CX, P_CY, rIn, 1.4, INK, 0.5);
for (let i = 0; i < progAll07.length; i += 1) {
progGlyph(i, false, 0);
}
// 부채꼴로 자른 안쪽에만 채운 글자
ctx.save();
ctx.beginPath();
ctx.moveTo(P_CX, P_CY);
ctx.arc(P_CX, P_CY, rOut + 4, -Math.PI / 2, -Math.PI / 2 + theta);
ctx.closePath();
ctx.clip();
for (let i = 0; i < progAll07.length; i += 1) {
if (cover07(i, theta) <= 0) {
continue;
}
// 다 찬 직후 0.25 라디안 동안 부풀었다 돌아온다
const bump = 0.16 * Math.sin(Math.PI * clamp((theta - progB07[i]) / 0.25, 0, 1));
progGlyph(i, true, bump);
}
ctx.restore();
label(Math.floor(p * 100) + '%', P_CX, P_CY - 10, INK, 1, 34, 800, 'center');
label(LABEL_LEFT + ' ' + Math.ceil(remain07()) + LABEL_SEC, P_CX, P_CY + 22, INK_SOFT, 1, 12, 700, 'center');
// 순간 속도(흐린 막대)와 지수 평균(진한 막대) — 남은 시간은 진한 쪽으로 낸다
fillRound(24, 40, 10, 200, 5, PAPER_DIM, 1);
fillRound(24, 240 - 200 * clamp(raw07 / (RATE_MEAN * 2), 0, 1), 10, 200 * clamp(raw07 / (RATE_MEAN * 2), 0, 1), 5, CORAL, 1);
fillRound(40, 40, 10, 200, 5, PAPER_DIM, 1);
fillRound(40, 240 - 200 * clamp(speed07 / (RATE_MEAN * 2), 0, 1), 10, 200 * clamp(speed07 / (RATE_MEAN * 2), 0, 1), 5, CORAL_DEEP, 1);
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 한 프레임에 넘기는 시간 상한(s) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest() {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still && fontsReady) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
// 글자 폭은 글꼴이 다 온 뒤에 잰다 — 그 전에 잰 폭은 대체 글꼴의 폭이라 글꼴이 바뀌는 순간 자리가 틀어진다.
// 나중에 다른 글꼴이 더 오면(loadingdone) 다시 잰다
let fontsReady = false;
document.fonts.addEventListener('loadingdone', function () {
if (!fontsReady) {
return;
}
remeasure();
draw();
}, { signal: off.signal });
document.fonts.ready.then(function () {
if (off.signal.aborted) {
return;
}
fontsReady = true;
remeasure();
if (still) {
showRest();
}
else {
play();
}
});
return function () {
stopLoop();
off.abort();
};
})();// LetterProgressRing - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LetterProgressRing.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LetterProgressRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 1/60초마다 ease 만큼 다가가는 비율을 dt 초로 환산 — 1 − (1 − ease)^(dt × 60)
function easeStep(ease: number, dt: number): number { return 1 - Math.pow(1 - ease, dt * 60); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (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 deg(rad: number): number { return rad * 180 / Math.PI; }
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 글자 폭 ──────────────────────────────────────────────────
// 글자 하나의 가로 폭(px) — 글꼴 크기 px, 굵기 weight. 화면에서는 캔버스 measureText 로 잰 값으로 바뀐다(dom 쪽).
// 아래 대략 모형은 재기 전 자리 채움일 뿐 — 글자 폭은 반드시 글꼴이 다 온 뒤 다시 잰다
type Measure = (ch: string, px: number, weight: number) => number;
function roughWidth(ch: string, px: number, weight: number): number {
const wide = ch.charCodeAt(0) >= 0x1100;
const base = ch === ' ' ? 0.28 : (wide ? 0.95 : 0.6);
return px * base * (weight >= 700 ? 1.06 : 1);
}
// 글꼴이 다 온 뒤 잰 폭
let glyphWidth: Measure = roughWidth;
// 대체 글꼴로 잰 폭 — 09 가 「글꼴이 오기 전에 잰 폭」 을 흉내 낼 때만 쓴다
let fallbackWidth: Measure = roughWidth;
// 글자 나누기 — 한 글자씩(서로게이트 쌍도 한 글자)
function glyphsOf(s: string): string[] { return Array.from(s); }
function widthsOf(gs: string[], px: number, weight: number, m: Measure): number[] {
return gs.map(function (g: string): number { return m(g, px, weight); });
}
function sum(xs: number[]): number { let s = 0; for (const x of xs) { s += x; } return s; }
// ── 원 위에 글자 놓기 ────────────────────────────────────────
// 글자 i 의 각 = 가운데 각 + 방향 × (앞 글자 폭 합 + 자기 폭/2 − 전체/2) × 축소 / 놓는 반지름.
// 놓는 반지름은 글자 높이의 inner 몫만큼 안쪽 — 글자 아래 모서리(원 중심 쪽)에서 폭이 맞으면 바깥은 저절로 벌어진다.
// 전체 각이 span 을 넘으면 글꼴을 줄인다. 줄이면 글자 높이도 줄어 놓는 반지름이 커지므로
// 축소 = span·r / (전체 폭 + span·inner·px) 로 한 번에 푼다 (전체 폭 × 축소 = span × (r − inner·px·축소))
type ArcFit = { theta: number[]; px: number; scale: number; total: number; layR: number; need: number };
function arcTotal(ws: number[], tracking: number): number { return sum(ws) + tracking * Math.max(0, ws.length - 1); }
// 글꼴 축소 몫 — 줄이지 않아도 들어가면 1
function arcScale(ws: number[], px: number, tracking: number, r: number, inner: number, span: number): number {
const total = arcTotal(ws, tracking);
const need = total / Math.max(1, r - inner * px);
return need > span ? span * r / (total + span * inner * px) : 1;
}
function fitArc(ws: number[], px: number, tracking: number, r: number, inner: number, span: number, center: number, dir: number, allowShrink: boolean): ArcFit {
return placeArc(ws, px, tracking, r, inner, allowShrink ? arcScale(ws, px, tracking, r, inner, span) : 1, center, dir);
}
// 정한 축소 몫으로 놓기 — 위·아래 반원이 같은 글꼴 크기를 쓰도록 둘 중 작은 몫을 넘겨받는다
function placeArc(ws: number[], px: number, tracking: number, r: number, inner: number, scale: number, center: number, dir: number): ArcFit {
const n = ws.length;
const total = arcTotal(ws, tracking);
const need = total / Math.max(1, r - inner * px);
const layR = Math.max(1, r - inner * px * scale);
const theta: number[] = [];
let acc = 0;
for (let i = 0; i < n; i += 1) {
theta.push(center + dir * (acc + ws[i] / 2 - total / 2) * scale / layR);
acc += ws[i] + tracking;
}
return { theta: theta, px: px * scale, scale: scale, total: total, layR: layR, need: need };
}
// 같은 각도로 놓기(비교판) — 폭은 보지 않고 전체 각을 글자 수로 똑같이 나눈다
function equalArc(n: number, spanUsed: number, center: number, dir: number): number[] {
const out: number[] = [];
const step = spanUsed / Math.max(1, n);
for (let i = 0; i < n; i += 1) { out.push(center + dir * (i + 0.5 - n / 2) * step); }
return out;
}
// 이웃 두 글자가 글자 아래 모서리(반지름 r − 0.5·글자 높이)에서 겹친 길이(px). 양수면 부딪힌다.
// 각 간격 × 아래 모서리 반지름 − 두 글자 반폭 합 이 음수인 만큼이 겹침
const GLYPH_DROP = 0.5;
function innerOverlaps(theta: number[], ws: number[], scale: number, r: number, px: number): number[] {
const rin = r - GLYPH_DROP * px;
const out: number[] = [];
for (let i = 0; i + 1 < theta.length; i += 1) {
const room = Math.abs(theta[i + 1] - theta[i]) * rin;
out.push((ws[i] + ws[i + 1]) / 2 * scale - room);
}
return out;
}
// ── 곡선의 호 길이 표 ────────────────────────────────────────
// 극좌표 곡선 r(θ) 의 호 길이 ds = √(r² + (dr/dθ)²) dθ 를 θ 의 작은 걸음 step 으로 더해 누적표를 만든다.
// 거꾸로 「호 길이 s 인 자리의 θ」 는 표를 둘로 나눠 찾고 두 칸 사이를 곧게 잇는다
type ArcTable = { th: number[]; s: number[] };
function arcTable(rf: (th: number) => number, drf: (th: number) => number, th0: number, th1: number, step: number): ArcTable {
const th: number[] = [th0];
const s: number[] = [0];
let acc = 0;
const n = Math.max(1, Math.ceil((th1 - th0) / step));
const h = (th1 - th0) / n;
for (let i = 0; i < n; i += 1) {
// 걸음 가운데의 값으로 더한다 — 걸음 끝 값보다 같은 걸음에서 오차가 작다
const m = th0 + (i + 0.5) * h;
const r = rf(m);
const dr = drf(m);
acc += Math.sqrt(r * r + dr * dr) * h;
th.push(th0 + (i + 1) * h);
s.push(acc);
}
return { th: th, s: s };
}
function thetaAt(t: ArcTable, s: number): number {
const last = t.s.length - 1;
if (s <= 0) { return t.th[0]; }
if (s >= t.s[last]) { return t.th[last]; }
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (t.s[mid] <= s) { lo = mid; } else { hi = mid; }
}
const k = (s - t.s[lo]) / Math.max(1e-9, t.s[hi] - t.s[lo]);
return lerp(t.th[lo], t.th[hi], k);
}
function arcLength(t: ArcTable): number { return t.s[t.s.length - 1]; }
// 극좌표 곡선의 접선 각(화면 좌표, y 아래) — 점 (r cosθ, r sinθ) 을 θ 로 미분한 방향
function tangentAngle(r: number, dr: number, th: number): number {
return Math.atan2(dr * Math.sin(th) + r * Math.cos(th), dr * Math.cos(th) - r * Math.sin(th));
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [241, 234, 220];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [112, 104, 126];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [196, 182, 238];
const MINT: RGB = [164, 218, 196];
const BUTTER: RGB = [246, 224, 150];
const CORAL: RGB = [244, 170, 152];
const SKY: RGB = [170, 206, 238];
const PINK: RGB = [244, 190, 212];
const MINT_DEEP: RGB = [36, 116, 90];
const CORAL_DEEP: RGB = [178, 66, 52];
const VIOLET_DEEP: RGB = [84, 58, 160];
const BROWN_DEEP: RGB = [104, 62, 40];
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 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) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 대체 글꼴 — 09 가 「글꼴이 오기 전에 잰 폭」 을 흉내 낼 때만 쓴다
const FALLBACK_STACK = 'monospace';
// 잰 폭은 100px 기준으로 한 번만 재 두고 크기에 비례해 쓴다. 글꼴이 새로 오면 통째로 비운다
const widthCache: { [key: string]: number } = {};
function measureWith(stack: string): Measure {
return function (ch: string, px: number, weight: number): number {
const key = stack + '|' + weight + '|' + ch;
let w = widthCache[key];
if (w === undefined) {
ctx.font = weight + ' 100px ' + stack;
w = ctx.measureText(ch).width;
widthCache[key] = w;
}
return w * px / 100;
};
}
function remeasure(): void {
for (const k of Object.keys(widthCache)) { delete widthCache[k]; }
glyphWidth = measureWith(FONT_STACK);
fallbackWidth = measureWith(FALLBACK_STACK);
relayout();
}
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);
}
function clearStage(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px: number, weight: number, stack: string): void {
ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + stack;
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
setFont(px, weight, FONT_STACK);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 하나를 (x, y) 가운데에 rot 라디안 돌려 찍는다. sx·sy 는 가로·세로 배율(음수 sx 는 거울)
function glyphAt(ch: string, x: number, y: number, rot: number, px: number, weight: number, c: RGB, a: number, sx: number, sy: number): void {
if (a <= 0.01 || px < 0.5) { return; }
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(sx, sy);
setFont(px, weight, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(ch, 0, 0);
ctx.restore();
}
// 기울인 글자 — 글자 자기 좌표에서 가로로 skew(라디안)만큼 민다(윗부분이 앞으로)
function glyphLean(ch: string, x: number, y: number, rot: number, px: number, weight: number, c: RGB, a: number, skew: number): void {
if (a <= 0.01 || px < 0.5) { return; }
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.transform(1, 0, -Math.tan(skew), 1, 0, 0);
setFont(px, weight, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(ch, 0, 0);
ctx.restore();
}
// 원 위 글자 — 중심 (cx, cy), 반지름 r, 각 th. turn 은 글자를 원에 맞춰 세우는 각(위 반원 +π/2, 아래 반원 −π/2)
function ringGlyph(ch: string, cx: number, cy: number, r: number, th: number, turn: number, px: number, weight: number, c: RGB, a: number): void {
glyphAt(ch, cx + r * Math.cos(th), cy + r * Math.sin(th), th + turn, px, weight, c, a, 1, 1);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 그림자 — 떠 있는 높이 lift(0~1)가 클수록 멀리·흐리게·넓게 떨어진다
function softShadow(x: number, y: number, r: number, lift: number): void {
const off = 4 + 14 * lift;
const g = ctx.createRadialGradient(x + off * 0.4, y + off, r * 0.6, x + off * 0.4, y + off, r * (1.05 + 0.25 * lift));
g.addColorStop(0, rgba(INK, 0.26 - 0.14 * lift));
g.addColorStop(1, rgba(INK, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x + off * 0.4, y + off, r * (1.05 + 0.25 * lift), 0, TAU);
ctx.fill();
}
// 작은 알약 이름표
function pill(s: string, x: number, y: number, bg: RGB, fg: RGB, a: number, px: number): void {
setFont(px, 700, FONT_STACK);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// 07 — 진행률만큼 글자가 차오르는 원형 진행 고리. 흉내 낸 올리기 속도(시드 난수로 흔들림)를 지수 평균으로 다듬어 남은 시간을 내고,
// 진행 각 Θ = 2π·p 가 글자 i 의 각 구간 [a_i, b_i] 를 덮은 비율 clamp((Θ − a_i) / (b_i − a_i), 0, 1) 로 그 글자를 채울지 고르고(0 이면 건너뜀),
// 실제 채움은 원 중심에서 맨 위(−π/2) ~ Θ 부채꼴로 자른(clip) 안쪽만 칠한다 — 경계는 반지름 방향 선
const PROG_TEXT = '사진 올리는 중 · ';
const PROG_PX = 22;
const PROG_WEIGHT = 800;
const PROG_R = 92;
const INNER_FIT = 0.5;
const FILE_MB = 24;
// 올리기 속도(MB/초)의 평균과 흔들림 몫, 지수 평균 몫(1/60초마다 새 값 쪽으로 다가가는 비율)
const RATE_MEAN = 8.5;
const RATE_JITTER = 0.55;
const SPEED_SMOOTH = 0.06;
const RATE_SEED = 7;
const START_S = 0.2;
const LOOP_S = 5.4;
const STATUS_STEP = 10;
const STATUS_PROG = '{p}% 올림 · 남은 시간 약 {s}초';
const STATUS_DONE = '올리기 끝 — 100%';
const progGlyphs07 = glyphsOf(PROG_TEXT);
let progRepeat07 = 1;
// 글자 i 의 각 구간 — 고리 맨 위(−π/2)에서 시계 방향으로 잰 시작·끝 각
let progA07: number[] = [];
let progB07: number[] = [];
let progAll07: string[] = [];
let rng07 = makeRng(RATE_SEED);
let done07 = 0;
let speed07 = 0;
let raw07 = 0;
let doneAt07 = -1;
function relayout(): void {
const ws = widthsOf(progGlyphs07, PROG_PX, PROG_WEIGHT, glyphWidth);
const layR = PROG_R - INNER_FIT * PROG_PX;
progRepeat07 = Math.max(1, Math.floor(TAU * layR / sum(ws)));
const total = sum(ws) * progRepeat07;
progA07 = [];
progB07 = [];
progAll07 = [];
let acc = 0;
for (let k = 0; k < progGlyphs07.length * progRepeat07; k += 1) {
const w = ws[k % progGlyphs07.length];
progA07.push(acc / total * TAU);
progB07.push((acc + w) / total * TAU);
progAll07.push(progGlyphs07[k % progGlyphs07.length]);
acc += w;
}
}
function progress07(): number { return clamp(done07 / FILE_MB, 0, 1); }
function cover07(i: number, theta: number): number { return clamp((theta - progA07[i]) / Math.max(1e-6, progB07[i] - progA07[i]), 0, 1); }
function remain07(): number { return speed07 > 0.01 ? (FILE_MB - done07) / speed07 : 0; }
// 상태 줄은 10% 단위가 바뀔 때만 새로 쓴다 — 남은 시간도 그 순간 값으로 굳혀 낭독기가 프레임마다 숫자를 읽지 않게
let statusStep07 = -1;
function statusFor07(): void {
const p = progress07();
if (p >= 1) { simStatus = STATUS_DONE; statusStep07 = 100; return; }
const step = Math.floor(p * 100 / STATUS_STEP) * STATUS_STEP;
if (step === statusStep07) { return; }
statusStep07 = step;
simStatus = STATUS_PROG.replace('{p}', String(step)).replace('{s}', String(Math.ceil(remain07())));
}
function reset(): void {
statusStep07 = -1;
rng07 = makeRng(RATE_SEED);
done07 = 0;
speed07 = 0;
raw07 = 0;
doneAt07 = -1;
}
function update(dt: number): void {
if (sceneT >= START_S && done07 < FILE_MB) {
// 새 순간 속도 — 평균 둘레로 흔들리고, 가끔(난수 0.07 미만) 한 박자 크게 떨어진다
const u = rng07();
raw07 = RATE_MEAN * (1 + RATE_JITTER * (rng07() - 0.5) * 2) * (u < 0.07 ? 0.25 : 1);
speed07 = speed07 <= 0 ? raw07 : speed07 + (raw07 - speed07) * easeStep(SPEED_SMOOTH, dt);
done07 = Math.min(FILE_MB, done07 + raw07 * dt);
if (done07 >= FILE_MB && doneAt07 < 0) { doneAt07 = sceneT; }
}
statusFor07();
}
function restPose(): void {
done07 = FILE_MB;
speed07 = RATE_MEAN;
statusFor07();
}
// 07 화면 — 글자 윤곽(빈 글자) 위로 진행 각까지의 부채꼴 안만 채운 글자를 겹친다. 부채꼴 경계는 원 중심에서 뻗은 선이라
// 한 글자 안에서도 경계가 반지름 방향으로 지나간다. 글자 하나가 다 차면 그 글자만 잠깐 부풀었다 돌아온다
const LABEL_LEFT = '남은 시간';
const LABEL_SEC = '초';
const P_CX = 240;
const P_CY = 150;
function bindInput(signal: AbortSignal): void { void signal; }
function progGlyph(i: number, filled: boolean, bump: number): void {
const px = PROG_PX;
const layR = PROG_R - INNER_FIT * px;
const mid = -Math.PI / 2 + (progA07[i] + progB07[i]) / 2;
const rr = layR + INNER_FIT * px;
const x = P_CX + rr * Math.cos(mid);
const y = P_CY + rr * Math.sin(mid);
ctx.save();
ctx.translate(x, y);
ctx.rotate(mid + Math.PI / 2);
ctx.scale(1 + bump, 1 + bump);
setFont(px, PROG_WEIGHT, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (filled) {
ctx.fillStyle = rgba(VIOLET_DEEP, 1);
ctx.fillText(progAll07[i], 0, 0);
} else {
ctx.lineWidth = 1.1;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.strokeText(progAll07[i], 0, 0);
}
ctx.restore();
}
function draw(): void {
clearStage(PAPER);
const p = progress07();
const theta = TAU * p;
const rOut = PROG_R + PROG_PX * 0.85;
const rIn = PROG_R - PROG_PX * 0.95;
softShadow(P_CX, P_CY, rOut, 0.2 + 0.5 * p);
disc(P_CX, P_CY, rOut, LAVENDER, 1);
// 진행 띠 — 맨 위에서 Θ 까지
if (theta > 0.001) {
ctx.beginPath();
ctx.arc(P_CX, P_CY, rOut, -Math.PI / 2, -Math.PI / 2 + theta);
ctx.arc(P_CX, P_CY, rIn, -Math.PI / 2 + theta, -Math.PI / 2, true);
ctx.closePath();
ctx.fillStyle = rgba(MINT, 1);
ctx.fill();
}
disc(P_CX, P_CY, rIn, CREAM, 1);
ring(P_CX, P_CY, rIn, 1.4, INK, 0.5);
for (let i = 0; i < progAll07.length; i += 1) { progGlyph(i, false, 0); }
// 부채꼴로 자른 안쪽에만 채운 글자
ctx.save();
ctx.beginPath();
ctx.moveTo(P_CX, P_CY);
ctx.arc(P_CX, P_CY, rOut + 4, -Math.PI / 2, -Math.PI / 2 + theta);
ctx.closePath();
ctx.clip();
for (let i = 0; i < progAll07.length; i += 1) {
if (cover07(i, theta) <= 0) { continue; }
// 다 찬 직후 0.25 라디안 동안 부풀었다 돌아온다
const bump = 0.16 * Math.sin(Math.PI * clamp((theta - progB07[i]) / 0.25, 0, 1));
progGlyph(i, true, bump);
}
ctx.restore();
label(Math.floor(p * 100) + '%', P_CX, P_CY - 10, INK, 1, 34, 800, 'center');
label(LABEL_LEFT + ' ' + Math.ceil(remain07()) + LABEL_SEC, P_CX, P_CY + 22, INK_SOFT, 1, 12, 700, 'center');
// 순간 속도(흐린 막대)와 지수 평균(진한 막대) — 남은 시간은 진한 쪽으로 낸다
fillRound(24, 40, 10, 200, 5, PAPER_DIM, 1);
fillRound(24, 240 - 200 * clamp(raw07 / (RATE_MEAN * 2), 0, 1), 10, 200 * clamp(raw07 / (RATE_MEAN * 2), 0, 1), 5, CORAL, 1);
fillRound(40, 40, 10, 200, 5, PAPER_DIM, 1);
fillRound(40, 240 - 200 * clamp(speed07 / (RATE_MEAN * 2), 0, 1), 10, 200 * clamp(speed07 / (RATE_MEAN * 2), 0, 1), 5, CORAL_DEEP, 1);
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 한 바퀴(LOOP_S)를 넘으면 처음 배치로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 시연이 끝난 최종 배치 한 장만 그린다
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
restPose();
draw();
pushStatus();
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still && fontsReady) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 최종 배치로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
// 글자 폭은 글꼴이 다 온 뒤에 잰다 — 그 전에 잰 폭은 대체 글꼴의 폭이라 글꼴이 바뀌는 순간 자리가 틀어진다.
// 나중에 다른 글꼴이 더 오면(loadingdone) 다시 잰다
let fontsReady = false;
document.fonts.addEventListener('loadingdone', function (): void {
if (!fontsReady) { return; }
remeasure();
draw();
}, { signal: off.signal });
document.fonts.ready.then(function (): void {
if (off.signal.aborted) { return; }
fontsReady = true;
remeasure();
if (still) { showRest(); } else { play(); }
});
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} aria-hidden={true}></canvas>
<p className={cx("cf__sr")}>{'사진 올리는 중'}</p>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'올리기 준비'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 A Progress Ring Whose Letters Fill as the Upload Advances — Measured area 14.951% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Circular Text CSS: 9 Rings Spaced by Letter Width
The post this demo lives in
Circular Text CSS: 9 Rings Spaced by Letter WidthCircular text css puts letters on a ring. Place twelve letters 30 degrees apart and an i floats while a W crowds. So 01 See all demos in this post
If you like this effect - similar demos
Feedback08 Letters Whose Shadow Grows by a Scroll-progress ValueCSS Text Shadow Effects — 9 Patterns
Feedback06 Progress Ring That Fills in Sync With the Scratched RatioScratch Card Reveal: 9 Pixel-Calculated Patterns
Feedback05 Percent ring synced with the number9 CSS Number Counter Animations — Copy-Paste Ready