08받은 바이트 비율만큼 채워지는 다운로드 진행 링
다운로드 버튼을 누르면 지금까지 받은 바이트 수를 전체 크기로 나눠 원형 테두리의 채움 각도를 계산하고, 100%에 닿으면 링이 체크 아이콘으로 바뀐다.
쓰임원본 pdf 내려받기 버튼
lineLenCIRCfinish

<main class="stage">
<div class="pvg is-demo" id="pvgRoot" tabindex="0">
<svg class="pvg__ring" viewBox="0 0 92 92" width="92" height="92" aria-hidden="true">
<circle class="pvg__track" cx="46" cy="46" r="36"></circle>
<circle class="pvg__fill" id="pvgFill" cx="46" cy="46" r="36" transform="rotate(-90 46 46)"></circle>
<line class="pvg__check" id="pvgCheckA" x1="28" y1="52" x2="40" y2="64"></line>
<line class="pvg__check" id="pvgCheckB" x1="28" y1="52" x2="64" y2="30"></line>
</svg>
<p class="pvg__pct" id="pvgPct">0%</p>
<button class="pvg__btn" id="pvgBtn" type="button">다운로드</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pvg__ring { display: block; }
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, .12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: $subject-blue;
stroke-width: 7;
stroke-linecap: round;
transition: opacity $dur-base ease;
}
.pvg__check {
stroke: $subject-blue-deep;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__fill { opacity: 0; }
.pvg--done .pvg__check { opacity: 1; }
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: $subject-ink;
transition: opacity $dur-base ease;
}
.pvg--done .pvg__pct { opacity: 0; }
.pvg__btn {
border: none;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-size: 12px;
font-weight: 700;
background: $subject-ink;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct { transition: none; }
}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);
}
.pvg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pvg__ring {
display: block;
}
.pvg__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 7;
}
.pvg__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 7;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pvg__check {
stroke: #0a4fe8;
stroke-width: 7;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pvg--done .pvg__fill {
opacity: 0;
}
.pvg--done .pvg__check {
opacity: 1;
}
.pvg__pct {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #17141a;
transition: opacity 300ms ease;
}
.pvg--done .pvg__pct {
opacity: 0;
}
.pvg__btn {
border: none;
border-radius: 999px;
padding: 8px 20px;
font-size: 12px;
font-weight: 700;
background: #17141a;
color: #fff;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.pvg__fill, .pvg__check, .pvg__pct {
transition: none;
}
}(function () {
// 기법: 받은 바이트 / 전체 바이트로 링의 채움 비율을 계산해 stroke-dashoffset 으로
// 옮긴다. 100%에 닿으면 링을 지우고 같은 시작점(28,52)에서 뻗어 나온 두 선분의
// stroke-dashoffset 을 0 으로 당겨 체크 아이콘을 그린다.
var root = document.getElementById('pvgRoot');
var fill = document.getElementById('pvgFill');
var pctEl = document.getElementById('pvgPct');
var btn = document.getElementById('pvgBtn');
var checkA = document.getElementById('pvgCheckA');
var checkB = document.getElementById('pvgCheckB');
var R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) {
var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2');
return Math.hypot(x2 - x1, y2 - y1);
}
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
var TOTAL_BYTES = 260000;
var SPEED = 230000;
var LOOP_MS = 2000;
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
function render(ratio) {
fill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl.textContent = Math.round(ratio * 100) + '%';
}
function startDownload(nowT) {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pvg--done');
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
btn.textContent = '받는 중';
render(0);
}
function finish(nowT) {
phase = 'done'; doneAt = nowT;
root.classList.add('pvg--done');
btn.textContent = '완료';
checkAnim = { start: nowT };
}
function resetIdle() {
phase = 'idle'; received = 0;
root.classList.remove('pvg--done');
btn.textContent = '다운로드';
render(0);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
btn.addEventListener('click', function (e) { markActive(); if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault(); markActive();
if (phase !== 'loading') { resetIdle(); startDownload(lastTs || 0); }
} else if (e.key === 'Escape') {
e.preventDefault(); markActive(); resetIdle();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; render(0.5); }
var fired = {};
var SCHEDULE = [
{ t: 100, run: function (t) { if (phase === 'idle') startDownload(t); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (phase === 'loading') {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
var ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
requestAnimationFrame(tick);
}
render(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerDownloadProgressRing.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerDownloadProgressRing() {
const rootRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<SVGCircleElement>(null);
const pctRef = useRef<HTMLParagraphElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, fill = fillRef.current, pctEl = pctRef.current;
const btn = btnRef.current, checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !fill || !pctEl || !btn || !checkA || !checkB) return;
const R = 36, CIRC = 2 * Math.PI * R;
fill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2);
checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2);
checkB.style.strokeDashoffset = lenB.toFixed(2);
const TOTAL_BYTES = 260000;
const SPEED = 230000;
const LOOP_MS = 2000;
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
function render(ratio: number) {
fill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2);
pctEl!.textContent = Math.round(ratio * 100) + "%";
}
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2);
checkB!.style.strokeDashoffset = lenB.toFixed(2);
btn!.textContent = "받는 중";
render(0);
}
function finish(nowT: number) {
phase = "done"; doneAt = nowT;
root!.classList.add(styles.done);
btn!.textContent = "완료";
checkAnim = { start: nowT };
}
function resetIdle() {
phase = "idle"; received = 0;
root!.classList.remove(styles.done);
btn!.textContent = "다운로드";
render(0);
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onClick() { markActive(); if (phase !== "loading") { resetIdle(); startDownload(); } }
btn.addEventListener("click", onClick);
function onKeydown(e: KeyEvt) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault(); markActive();
if (phase !== "loading") { resetIdle(); startDownload(); }
} else if (e.key === "Escape") {
e.preventDefault(); markActive(); resetIdle();
}
}
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; render(0.5); }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 100, run: () => { if (phase === "idle") startDownload(); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
}
if (phase === "loading") {
received = Math.min(TOTAL_BYTES, received + SPEED * (dt / 1000));
const ratio = received / TOTAL_BYTES;
render(ratio);
if (ratio >= 1) finish(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 620 && !userActive) resetIdle();
}
raf = requestAnimationFrame(tick);
}
render(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvg} is-demo`} ref={rootRef} tabIndex={0}>
<svg className={styles.ring} viewBox="0 0 92 92" width={92} height={92} aria-hidden="true">
<circle className={styles.track} cx={46} cy={46} r={36} />
<circle className={styles.fill} ref={fillRef} cx={46} cy={46} r={36} transform="rotate(-90 46 46)" />
<line className={styles.check} ref={checkARef} x1={28} y1={52} x2={40} y2={64} />
<line className={styles.check} ref={checkBRef} x1={28} y1={52} x2={64} y2={30} />
</svg>
<p className={styles.pct} ref={pctRef}>0%</p>
<button className={styles.btn} ref={btnRef} type="button">다운로드</button>
</div>
</main>
);
}08 받은 바이트 비율만큼 채워지는 다운로드 진행 링 — 측정 누적면적 2.737% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui
이 데모가 속한 글
pdf 미리보기 구현 9가지 — 스크롤로 페이지를 계산하는 pdf 뷰어 ui스크롤 값이 페이지 번호를 만드는 걸까, 페이지 번호가 스크롤 값을 만드는 걸까 — 02번 확대 중심은 포인터 좌표로, 04번 다음 페이지는 손을 뗄 때의 속도로, 08번 다운로드 링은 받은 바이트 수로 답한다. p 이 글의 데모 전부 보기


