01残り人数を再計算して桁が繰り上がる呼び出し番号
新しい番号が呼ばれると前の番号との差を計算して残り人数の桁が繰り下がり、大きな呼び出し番号はスロットのように桁ごとに新しい値へ切り替わる。
使いどころ病院の診察待ち画面
CALL_NUMBER_ROLL_MSrollDigitsDIGIT_STAGGER_MS

<main class="stage">
<div class="wcn is-demo" id="wcnRoot">
<div class="wcn__board">
<div class="wcn__odometer" id="wcnCallOdo" aria-hidden="true"></div>
<div class="wcn__remainWrap" aria-hidden="true">
<span class="wcn__remainLabel">残り</span>
<div class="wcn__odometer wcn__odometer--small" id="wcnRemainOdo"></div>
<span class="wcn__remainUnit">名</span>
</div>
</div>
<p class="wcn__status" id="wcnStatus" aria-live="polite"></p>
<div class="wcn__controls">
<button type="button" class="wcn__btn" id="wcnReset">最初から</button>
<button type="button" class="wcn__btn wcn__btn--primary" id="wcnNext">次の呼び出し</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 순번·남은인원 두 오도미터 모두 자리별 릴로 굴러간다. 남은인원은 총원에서
// 호출된 인원 수를 뺀 값을 매 호출마다 다시 계산해서 자리 단위로 내려간다.
@include stage($stage-ink);
.wcn {
position: relative;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.wcn__board {
border-radius: $r-card;
display: grid;
gap: $sp-3;
justify-items: center;
}
.wcn__odometer {
display: flex;
gap: $sp-1;
font-variant-numeric: tabular-nums;
}
.wcn__digit {
position: relative;
width: 54px;
height: 80px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-paper, .08);
}
.wcn__odometer--small .wcn__digit {
width: 34px;
height: 52px;
}
.wcn__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wcn__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-style: normal;
font-size: 52px;
font-weight: 700;
color: $subject-blue-light;
}
.wcn__odometer--small .wcn__reel i {
height: 52px;
font-size: 32px;
color: $stage-paper;
}
.wcn__remainWrap {
display: flex;
align-items: center;
gap: $sp-2;
}
.wcn__remainLabel,
.wcn__remainUnit {
font-size: 12px;
font-weight: 600;
color: rgba($stage-paper, .72);
}
.wcn__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .7);
}
.wcn__controls {
display: flex;
gap: $sp-2;
}
.wcn__btn {
border: 1px solid rgba($stage-paper, .3);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wcn__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wcn__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wcn__digit { width: 30px; height: 44px; }
.wcn__reel i { height: 44px; font-size: 28px; }
.wcn__odometer--small .wcn__digit { width: 22px; height: 32px; }
.wcn__odometer--small .wcn__reel i { height: 32px; font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.wcn__reel { transition: none; }
}
// 호출 신호 — 새 번호가 불릴 때마다 보드 전체가 한 번 밝아졌다 가라앉는다.
.wcn__board.is-flash { animation: wcnFlash 900ms ease-out; }
@keyframes wcnFlash {
from { background: rgba($subject-blue-deep, .38); }
to { background: rgba($subject-blue-deep, 0); }
}
@media (prefers-reduced-motion: reduce) {
.wcn__board.is-flash { animation: 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);
}
.wcn {
position: relative;
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.wcn__board {
border-radius: 16px;
display: grid;
gap: 12px;
justify-items: center;
}
.wcn__odometer {
display: flex;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wcn__digit {
position: relative;
width: 54px;
height: 80px;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.wcn__odometer--small .wcn__digit {
width: 34px;
height: 52px;
}
.wcn__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wcn__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-style: normal;
font-size: 52px;
font-weight: 700;
color: #5b8cf8;
}
.wcn__odometer--small .wcn__reel i {
height: 52px;
font-size: 32px;
color: #ffffff;
}
.wcn__remainWrap {
display: flex;
align-items: center;
gap: 8px;
}
.wcn__remainLabel,
.wcn__remainUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.72);
}
.wcn__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.wcn__controls {
display: flex;
gap: 8px;
}
.wcn__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wcn__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wcn__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wcn__digit {
width: 30px;
height: 44px;
}
.wcn__reel i {
height: 44px;
font-size: 28px;
}
.wcn__odometer--small .wcn__digit {
width: 22px;
height: 32px;
}
.wcn__odometer--small .wcn__reel i {
height: 32px;
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.wcn__reel {
transition: none;
}
}
.wcn__board.is-flash {
animation: wcnFlash 900ms ease-out;
}
@keyframes wcnFlash {
from {
background: rgba(10, 79, 232, 0.38);
}
to {
background: rgba(10, 79, 232, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.wcn__board.is-flash {
animation: none;
}
}(function () {
// 기법: 새 순번이 호출될 때마다 残り 대기 인원(총원 - 처리된 인원)을 다시 계산해, 그 차이를
// 자릿수 릴(디지트 릴)로 자리마다 순서대로 굴려 내린다. 순번 숫자 자체도 자리별 릴이며,
// CALL_NUMBER_ROLL_MS 는 한 자리가 다음 값으로 굴러가는 데 걸리는 시간, DIGIT_STAGGER_MS 는
// 자리마다 시작을 늦추는 간격이다 — CSS 트랜지션이 아니라 rAF 로 각 릴의 위치를 계산한다.
var CALL_NUMBER_ROLL_MS = 480;
var DIGIT_STAGGER_MS = 70;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434011);
var TOTAL_PARTIES = 42;
var CALL_BASE = 101;
var callIndex = 0; // 지금까지 호출된 인원 수
var root = document.getElementById('wcnRoot');
var callOdo = document.getElementById('wcnCallOdo');
var remainOdo = document.getElementById('wcnRemainOdo');
var status = document.getElementById('wcnStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// 자릿수 릴: 각 digit 은 {el, from, to, startMs, doneMs}
function buildOdometer(container, digitCount) {
container.innerHTML = '';
var digits = [];
for (var i = 0; i < digitCount; i += 1) {
var d = document.createElement('div');
d.className = 'wcn__digit';
var reel = document.createElement('div');
reel.className = 'wcn__reel';
d.appendChild(reel);
container.appendChild(d);
digits.push({ el: d, reel: reel, value: 0, from: 0, to: 0, start: 0, dur: 0 });
}
return digits;
}
var callDigits = buildOdometer(callOdo, 3);
var remainDigits = buildOdometer(remainOdo, 2);
function paintReel(digit, value) {
var reel = digit.reel;
if (reel.childElementCount !== 10) {
reel.innerHTML = '';
for (var n = 0; n < 10; n += 1) {
var i = document.createElement('i');
i.textContent = String(n);
reel.appendChild(i);
}
}
reel.style.transform = 'translateY(' + (-value * 10) + '%)';
}
function setDigitsImmediate(digits, num) {
var s = String(num);
while (s.length < digits.length) s = '0' + s;
for (var i = 0; i < digits.length; i += 1) {
var v = Number(s[i]);
digits[i].value = v;
digits[i].from = v;
digits[i].to = v;
digits[i].dur = 0;
paintReel(digits[i], v);
}
}
function flash(el) {
if (reduce) return;
el.classList.remove('is-flash');
void el.offsetWidth;
el.classList.add('is-flash');
}
function rollDigits(digits, num, nowMs) {
if (reduce) { setDigitsImmediate(digits, num); return; }
flash(callOdo.parentNode);
var s = String(num);
while (s.length < digits.length) s = '0' + s;
for (var i = 0; i < digits.length; i += 1) {
var v = Number(s[i]);
if (v === digits[i].to) continue;
digits[i].from = digits[i].value;
digits[i].to = v;
digits[i].start = nowMs + i * DIGIT_STAGGER_MS;
digits[i].dur = CALL_NUMBER_ROLL_MS;
}
}
function stepDigits(digits, nowMs) {
for (var i = 0; i < digits.length; i += 1) {
var d = digits[i];
if (d.value === d.to && nowMs >= d.start) continue;
if (nowMs < d.start) continue;
var t = Math.min(1, (nowMs - d.start) / d.dur);
var e = easeInOutCubic(t);
var span = d.to - d.from;
// 두 자리 사이를 최단 방향으로 굴린다(9→0 처럼 넘어가는 경우 고려).
if (Math.abs(span) > 5) span = span > 0 ? span - 10 : span + 10;
var cur = d.from + span * e;
cur = ((cur % 10) + 10) % 10;
d.value = t >= 1 ? d.to : cur;
paintReel(d, cur);
}
}
var callNum = CALL_BASE;
var remain = TOTAL_PARTIES;
setDigitsImmediate(callDigits, 0);
setDigitsImmediate(remainDigits, remain);
function paintStatus() {
status.textContent = '呼び出し番号 ' + callNum + '・残り' + remain + '名';
}
function callNext(nowMs) {
if (remain <= 0) { paintStatus(); return; }
callIndex += 1;
callNum = CALL_BASE + callIndex - 1;
remain = Math.max(0, TOTAL_PARTIES - callIndex);
rollDigits(callDigits, callNum, nowMs);
rollDigits(remainDigits, remain, nowMs);
paintStatus();
}
function resetAll(nowMs) {
callIndex = 0;
callNum = CALL_BASE;
remain = TOTAL_PARTIES;
rollDigits(callDigits, 0, nowMs);
rollDigits(remainDigits, remain, nowMs);
paintStatus();
}
var nextBtn = document.getElementById('wcnNext');
var resetBtn = document.getElementById('wcnReset');
function onNext() {
root.classList.remove('is-demo');
callNext(perfNow());
}
function onReset() {
root.classList.remove('is-demo');
resetAll(perfNow());
}
nextBtn.addEventListener('click', onNext);
nextBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNext(); }
});
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); }
});
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
// 시연 스케줄: 첫 변화는 200ms 뒤, 이후 자연스러운 간격으로 반복.
var demoSchedule = [200, 1200];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
stepDigits(callDigits, now);
stepDigits(remainDigits, now);
if (root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (remain <= 0) { resetAll(now); } else { callNext(now); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) demoIndex = 0, demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; });
}
}
requestAnimationFrame(tick);
}
if (reduce) {
paintStatus();
} else {
requestAnimationFrame(tick);
}
})();// WaitingQueueCallNumberRoll — vanilla 판(01-waiting-queue-call-number-roll-remaining) 의 React 포트.
// 새 순번이 호출될 때마다 남은 대기 인원(총원 - 호출된 인원)을 다시 계산해 자릿수 릴로 굴려 내리고,
// 순번 숫자도 자리마다 CALL_NUMBER_ROLL_MS 동안 굴러간다 — CSS 트랜지션이 아니라 rAF 보간.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueCallNumberRoll.module.scss";
type Box<T> = { current: T };
const CALL_NUMBER_ROLL_MS = 480;
const DIGIT_STAGGER_MS = 70;
const TOTAL_PARTIES = 42;
const CALL_BASE = 101;
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
type Digit = { value: number; from: number; to: number; start: number; dur: number };
function digitsOf(n: number, len: number): number[] {
const s = String(n).padStart(len, "0");
const out: number[] = [];
for (let i = 0; i < s.length; i += 1) out.push(s.charCodeAt(i) - 48);
return out;
}
function flash(el: HTMLElement | null, cls: string, reduce: boolean): void {
if (reduce || !el) return;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
}
export default function WaitingQueueCallNumberRoll() {
seedRand(434011);
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const callDigitsRef = useRef<Digit[]>([{ value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }]) as Box<Digit[]>;
const remainDigitsRef = useRef<Digit[]>([{ value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }]) as Box<Digit[]>;
const callReelRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const remainReelRefs = useRef<Array<HTMLDivElement | null>>([null, null]) as Box<Array<HTMLDivElement | null>>;
const callNumRef = useRef<number>(CALL_BASE) as Box<number>;
const remainRef = useRef<number>(TOTAL_PARTIES) as Box<number>;
const callIndexRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const demoIndexRef = useRef<number>(0) as Box<number>;
const demoScheduleRef = useRef<number[]>([200, 1200]) as Box<number[]>;
const frameRef = useRef<number>(0) as Box<number>;
const boardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function paintReel(el: HTMLDivElement | null, value: number): void {
if (!el) return;
el.style.transform = "translateY(" + (-value * 10) + "%)";
}
function rollDigits(digits: Digit[], num: number, nowMs: number): void {
if (digits === callDigitsRef.current) flash(boardRef.current, styles.isFlash, reduce);
const s = digitsOf(num, digits.length);
if (reduce) {
const refs = digits === callDigitsRef.current ? callReelRefs.current : remainReelRefs.current;
for (let i = 0; i < digits.length; i += 1) {
digits[i].value = s[i]; digits[i].from = s[i]; digits[i].to = s[i]; digits[i].dur = 0;
paintReel(refs[i], s[i]);
}
return;
}
for (let i = 0; i < digits.length; i += 1) {
const v = s[i];
if (v === digits[i].to) continue;
digits[i].from = digits[i].value;
digits[i].to = v;
digits[i].start = nowMs + i * DIGIT_STAGGER_MS;
digits[i].dur = CALL_NUMBER_ROLL_MS;
}
}
function stepDigits(digits: Digit[], refs: Array<HTMLDivElement | null>, nowMs: number): void {
for (let i = 0; i < digits.length; i += 1) {
const d = digits[i];
if (d.value === d.to && nowMs >= d.start) continue;
if (nowMs < d.start) continue;
const t = Math.min(1, (nowMs - d.start) / d.dur);
const e = easeInOutCubic(t);
let span = d.to - d.from;
if (Math.abs(span) > 5) span = span > 0 ? span - 10 : span + 10;
let cur = d.from + span * e;
cur = ((cur % 10) + 10) % 10;
d.value = t >= 1 ? d.to : cur;
paintReel(refs[i], cur);
}
}
function paintStatus(): void {
setStatus("호출 번호 " + callNumRef.current + " · 남은 " + remainRef.current + "명");
}
function callNext(nowMs: number): void {
if (remainRef.current <= 0) { paintStatus(); return; }
callIndexRef.current += 1;
callNumRef.current = CALL_BASE + callIndexRef.current - 1;
remainRef.current = Math.max(0, TOTAL_PARTIES - callIndexRef.current);
rollDigits(callDigitsRef.current, callNumRef.current, nowMs);
rollDigits(remainDigitsRef.current, remainRef.current, nowMs);
paintStatus();
}
function resetAll(nowMs: number): void {
callIndexRef.current = 0;
callNumRef.current = CALL_BASE;
remainRef.current = TOTAL_PARTIES;
rollDigits(callDigitsRef.current, 0, nowMs);
rollDigits(remainDigitsRef.current, remainRef.current, nowMs);
paintStatus();
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paintStatus();
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
if (!reduce) {
stepDigits(callDigitsRef.current, callReelRefs.current, now);
stepDigits(remainDigitsRef.current, remainReelRefs.current, now);
const sched = demoScheduleRef.current;
if (demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (remainRef.current <= 0) { resetAll(now); } else { callNext(now); }
demoIndexRef.current += 1;
if (demoIndexRef.current >= sched.length) {
demoIndexRef.current = 0;
demoScheduleRef.current = sched.map((v) => v + now + 2000);
}
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onNext(): void { stopDemo(); callNext(performance.now()); }
function onReset(): void { stopDemo(); resetAll(performance.now()); }
function onKeyDown(e: { key: string; preventDefault: () => void }, fn: () => void): void {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); }
}
return (
<main className={styles.stage}>
<div className={styles.wcn}>
<div className={styles.board} ref={boardRef}>
<div className={styles.odometer} aria-hidden="true">
{[0, 1, 2].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { callReelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<div className={styles.remainWrap} aria-hidden="true">
<span className={styles.remainLabel}>남은</span>
<div className={[styles.odometer, styles.odometerSmall].join(" ")}>
{[0, 1].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { remainReelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<span className={styles.remainUnit}>명</span>
</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>처음으로</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onNext} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onNext)}>다음 호출</button>
</div>
</div>
</main>
);
}01 残り人数を再計算して桁が繰り上がる呼び出し番号 — 計測 累積面積 16.738% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · 呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板
このデモが属する記事
呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板呼出表示器は、呼び出し番号と残り人数、予想待ち時間を数字で見せる待ち順の案内画面です。平均処理時間が変わると残り人数と掛け合わされた値も一緒に変わって予想待ち時間が数値として更新され、リストは空いた分だけ上に詰まって並び替わり、合計人数はそ この記事のデモをすべて見る


