06残り容量・速度から再計算する予想時間
毎ティック、残り容量を再計算した瞬間速度で割り直してETA表示を更新し、一時停止すると文言が止まり、受信完了で「受信完了」に変わる(349のリトライ待ちカウントダウンとは異なる実演 — こちらは失敗後の待機ではなく、今埋まっている速度から毎瞬残り秒数を再計算する)。
使いどころ大容量ストリーミングの読み込み、ダウンロード完了予測表示
performance.now--fill-pctsetInterval

<main class="stage">
<div class="etaCard is-demo" id="etaCard">
<div class="track" id="track" role="progressbar" aria-label="受信済み容量" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="fill" id="fill"></div>
</div>
<p class="eta" id="eta" aria-live="polite">計算中</p>
<p class="detail" id="detail">0.0MB / 48.0MB · 0.0MB/s</p>
<button class="pauseBtn" id="pauseBtn" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 의 instSpeed() 가 합성파로 순간 속도를 만들고, tick() 이
// "남은 용량 ÷ 그 순간 속도" 를 매 틱 다시 나눠 ETA 문구를 새로 계산한다 — 저장된 카운트다운이 아니다.
@include stage($stage-paper);
.etaCard {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: 240px;
}
.track {
position: relative;
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba($subject-ink, .1);
overflow: hidden;
}
.fill {
--fill-pct: 0%;
position: absolute;
inset: 0;
width: var(--fill-pct);
border-radius: $r-pill;
background: $color;
transition: width $duration $easing;
}
.eta {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $subject-ink;
}
.detail {
margin: 0;
font-size: 11px;
color: rgba($subject-ink, .6);
}
.pauseBtn {
appearance: none;
border: none;
margin-top: $sp-1;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-cream;
background: $subject-ink;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
&[aria-pressed="true"] { background: $subject-orange-deep; }
}
@media (max-width: 340px) {
.etaCard { width: 190px; }
.eta { font-size: 12px; }
.detail, .pauseBtn { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 채움 막대 너비 전환만 끈다. ETA 문구는 값이 곧 정보라 계속 갱신돼야 한다
@media (prefers-reduced-motion: reduce) {
.fill { 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);
}
.etaCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 240px;
}
.track {
position: relative;
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.fill {
--fill-pct: 0%;
position: absolute;
inset: 0;
width: var(--fill-pct);
border-radius: 999px;
background: #4cd4a6;
transition: width 300ms ease-out;
}
.eta {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
}
.detail {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.6);
}
.pauseBtn {
appearance: none;
border: none;
margin-top: 4px;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.pauseBtn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.pauseBtn[aria-pressed=true] {
background: #c93000;
}
@media (max-width: 340px) {
.etaCard {
width: 190px;
}
.eta {
font-size: 12px;
}
.detail, .pauseBtn {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}(function () {
var TOTAL_MB = 48;
var LOOP_MS = 7000; // 이 시간 안에 전체를 받는 기준 사이클
var TICK_MS = 120;
var BASE_SPEED_MBPS = TOTAL_MB / (LOOP_MS / 1000);
var SPEED_VARIANCE = 0.55; // 속도 흔들림 비율
var etaCard = document.getElementById('etaCard');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var etaEl = document.getElementById('eta');
var detail = document.getElementById('detail');
var pauseBtn = document.getElementById('pauseBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var loadedMb = 0;
var lastMs = 0;
var paused = false;
// 결정론적 합성파로 순간 전송 속도를 흉내낸다 — 매 틱 다른 속도라 ETA 가 매번 다시 계산된다
function instSpeed(ms) {
var w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
return Math.max(0.15, BASE_SPEED_MBPS * w);
}
function paint(speed) {
var pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(pct)));
detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
if (paused) {
etaEl.textContent = '一時停止中';
} else if (loadedMb >= TOTAL_MB) {
etaEl.textContent = '受信完了';
} else {
// 남은 용량 ÷ 지금 속도 = 예상 시간 — 저장해 둔 카운트다운이 아니라 매 틱 새로 나눈 값
var remaining = TOTAL_MB - loadedMb;
var etaSec = remaining / speed;
etaEl.textContent = '約 ' + Math.ceil(etaSec) + '秒';
}
}
function tick(nowMs) {
var dtMs = lastMs === 0 ? 0 : nowMs - lastMs;
lastMs = nowMs;
var speed = instSpeed(nowMs);
if (!paused && loadedMb < TOTAL_MB) {
loadedMb = Math.min(TOTAL_MB, loadedMb + speed * (dtMs / 1000));
}
if (loadedMb >= TOTAL_MB && !paused) {
loadedMb = 0; // 데모 루프 — 완료 뒤 처음부터 다시
}
paint(speed);
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (etaCard.classList.contains('is-demo')) {
etaCard.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
pauseBtn.addEventListener('click', function () {
human();
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
pauseBtn.textContent = paused ? 'ダウンロードを再開' : '一時停止';
paint(instSpeed(performance.now() - start));
if (!timer) {
lastMs = performance.now() - start;
timer = window.setInterval(loop, TICK_MS);
}
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 채움 막대 전환만 멈추고 남은 시간 문구는 한 값으로 고정해 보여준다
loadedMb = TOTAL_MB * 0.4;
paint(BASE_SPEED_MBPS);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingCountdownEta.module.scss';
const TOTAL_MB = 48;
const LOOP_MS = 7000; // 이 시간 안에 전체를 받는 기준 사이클
const TICK_MS = 120;
const BASE_SPEED_MBPS = TOTAL_MB / (LOOP_MS / 1000);
const SPEED_VARIANCE = 0.55; // 속도 흔들림 비율
// 결정론적 합성파로 순간 전송 속도를 흉내낸다 — 매 틱 다른 속도라 ETA 가 매번 다시 계산된다
function instSpeed(ms: number) {
const w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
return Math.max(0.15, BASE_SPEED_MBPS * w);
}
export default function BufferingCountdownEta() {
const cardRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const etaRef = useRef<HTMLParagraphElement>(null);
const detailRef = useRef<HTMLParagraphElement>(null);
const pauseBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const card = cardRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const etaEl = etaRef.current;
const detail = detailRef.current;
const pauseBtn = pauseBtnRef.current;
if (!card || !track || !fill || !etaEl || !detail || !pauseBtn) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let loadedMb = 0;
let lastMs = 0;
let paused = false;
function paint(speed: number) {
if (!fill || !track || !detail || !etaEl) return;
const pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(pct)));
detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
if (paused) {
etaEl.textContent = '일시정지됨';
} else if (loadedMb >= TOTAL_MB) {
etaEl.textContent = '받기 완료';
} else {
// 남은 용량 ÷ 지금 속도 = 예상 시간 — 저장해 둔 카운트다운이 아니라 매 틱 새로 나눈 값
const remaining = TOTAL_MB - loadedMb;
const etaSec = remaining / speed;
etaEl.textContent = '약 ' + Math.ceil(etaSec) + '초 남음';
}
}
function tick(nowMs: number) {
const dtMs = lastMs === 0 ? 0 : nowMs - lastMs;
lastMs = nowMs;
const speed = instSpeed(nowMs);
if (!paused && loadedMb < TOTAL_MB) {
loadedMb = Math.min(TOTAL_MB, loadedMb + speed * (dtMs / 1000));
}
if (loadedMb >= TOTAL_MB && !paused) {
loadedMb = 0; // 데모 루프 — 완료 뒤 처음부터 다시
}
paint(speed);
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (card && card.classList.contains(styles['is-demo'])) {
card.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
function onPause() {
human();
if (!pauseBtn) return;
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
pauseBtn.textContent = paused ? '이어받기' : '일시정지';
paint(instSpeed(performance.now() - start));
if (!timer) {
lastMs = performance.now() - start;
timer = window.setInterval(loop, TICK_MS);
}
}
pauseBtn.addEventListener('click', onPause);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 채움 막대 전환만 멈추고 남은 시간 문구는 한 값으로 고정해 보여준다
loadedMb = TOTAL_MB * 0.4;
paint(BASE_SPEED_MBPS);
}
return () => {
if (timer !== null) window.clearInterval(timer);
pauseBtn.removeEventListener('click', onPause);
};
}, []);
return (
<div className={`${styles.etaCard} ${styles['is-demo']}`} ref={cardRef}>
<div
className={styles.track}
ref={trackRef}
role="progressbar"
aria-label="받은 용량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
>
<div className={styles.fill} ref={fillRef} />
</div>
<p className={styles.eta} ref={etaRef} aria-live="polite">계산 중</p>
<p className={styles.detail} ref={detailRef}>0.0MB / 48.0MB · 0.0MB/s</p>
<button className={styles.pauseBtn} ref={pauseBtnRef} type="button" aria-pressed="false">일시정지</button>
</div>
);
}06 残り容量・速度から再計算する予想時間 — 計測 累積面積 1.794% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · バッファ 表示 アニメーション 9種 — スピナーから画質バッジまで
このデモが属する記事
バッファ 表示 アニメーション 9種 — スピナーから画質バッジまでこのバッファ 表示 9種のうち、1つは再生位置とバッファ位置を二重リングで一緒に描き、1つは波形をバッファされた分だけ明るく塗りつぶし、1つはバッファ不足で画質が下がったバッジを表示します。 この記事のデモをすべて見る

