웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
웨이팅 화면은 호출 번호와 남은 인원, 예상 대기 시간을 숫자로 보여 주는 대기 안내 화면이다. 평균 처리 시간이 바뀌면 남은 대기 인원과 곱해진 값도 같이 바뀌어 예상 대기 시간이 숫자로 다시 뜨고, 목록은 빠진 자리만큼 위로 당겨지며 순서를 다시 그리고, 전체 인원 숫자는 그
01 waiting-queue-call-number-roll-remaining
02 waiting-queue-eta-average-service-time
03 waiting-queue-progress-ring-completion-ratio
04 waiting-queue-list-shift-position-spring
05 waiting-queue-noshow-recall-countdown
06 waiting-queue-multi-counter-distribution
07 waiting-queue-total-count-roll-updown
08 waiting-queue-priority-lane-badge-shift
09 waiting-queue-hero-board-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 남은 인원 수를 다시 계산해 자릿수가 굴러가는 호출 번호
- 02 평균 처리 시간과 인원 수를 곱해 다시 계산하는 예상 대기 시간
- 03 완료 비율을 계산해 채워지는 대기열 진행 링
- 04 이동 거리를 계산해 스프링으로 자리 잡는 대기 목록
- 05 재호출까지 남은 시간을 게이지로 줄여가는 노쇼 카운트다운
- 06 창구별 처리 속도를 계산해 분산 배치하는 다중 카운터 현황
- 07 차이만큼 굴러가는 전체 대기 인원 카운터
- 08 우선순위 차이를 계산해 끼어드는 우선 호출 줄
- 09 여러 계산을 한 화면에 모은 대기 순번판 히어로 장면
웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
아홉 개는 화면을 보는 사람이 던지는 질문 순서로 놓았다. 01 은 지금 몇 번인가, 02 는 몇 분 기다리나, 03 은 전체가 어디까지 왔나를 숫자와 링으로 답하고, 04 는 내 앞 번호가 빠질 때 목록이 어떻게 당겨지는지, 05 는 불렀는데 오지 않으면 언제 다시 부르는지를 보여 준다. 06 은 창구가 여럿일 때 어디가 빠른지, 07 은 전체 인원이 늘고 주는 폭, 08 은 우선 대상이 끼어드는 자리를 계산하고, 09 는 그중 세 계산을 한 화면에 겹친다. 대기열 시스템 화면을 처음 만드는 사람이라면 01·02·07 세 개만 먼저 붙여도 번호와 시간과 인원이 한 화면에서 서로 맞물려 움직인다.
패턴별 코드
아홉 항목 각각 값을 계산하는 핵심 구간만 발췌했고, 나머지 마크업과 스타일은 데모 아래 압축 파일 안에 통째로 들어 있다. 번호와 비고 문구는 전부 데모용 가짜 값이고 실제 서버와는 통신하지 않는다.
01남은 인원 수를 다시 계산해 자릿수가 굴러가는 호출 번호
새 번호를 호출하면 rollDigits 가 호출 번호 세 자리와 남은 인원 두 자리를 자릿수마다 DIGIT_STAGGER_MS 씩 늦게 출발시켜 슬롯머신처럼 차례로 굴리고, 남은 인원은 전체 42명에서 호출한 횟수를 뺀 값으로 다시 계산된다. 병원 진료 대기 화면의 호출 번호판처럼 번호가 바뀌는 순간을 눈이 놓치면 안 되는 자리에 쓴다. 01 항목은 누적면적 16.7%·강도 평균 80.6·23프레임 중 10프레임에서 움직였다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
}
}
02평균 처리 시간과 인원 수를 곱해 다시 계산하는 예상 대기 시간
처리 기록의 평균(avgMinutes)에 남은 인원을 곱한 값이 예상 대기 분이고, 처리 버튼을 누를 때마다 이 숫자가 ETA_TWEEN_MS 동안 굴러 새 값에 닿으며 값이 크게 바뀔수록 뒤의 빛이 더 세게 켜진다. 음식점 웨이팅 등록 화면처럼 손님이 몇 분을 기다려야 하는지만 보는 자리에 쓴다. 02 항목은 누적면적 12.8%·강도 평균 70.4·23프레임 중 7프레임에서 움직였다.

<main class="stage">
<div class="wet is-demo" id="wetRoot">
<div class="wet__etaWrap">
<span class="wet__etaGlow" id="wetGlow" aria-hidden="true"></span>
<span class="wet__etaNum" id="wetEtaNum" aria-hidden="true">0</span>
<span class="wet__etaUnit" aria-hidden="true">분 뒤 예상</span>
</div>
<p class="wet__avg" id="wetAvg" aria-hidden="true">건당 평균 0.0분</p>
<p class="wet__status" id="wetStatus" aria-live="polite"></p>
<div class="wet__controls">
<button type="button" class="wet__btn wet__btn--primary" id="wetProcess">다음 처리</button>
<button type="button" class="wet__btn" id="wetReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 처리 로그 평균 × 남은 인원으로 예상 대기(분)을 다시 계산해, 바뀐 값을 숫자
// 보간(easeOutCubic)으로 부드럽게 표시하고 변화 폭만큼 숫자 뒤 발광이 함께 번진다.
@include stage($stage-yellow);
.wet {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 300px);
}
.wet__etaWrap {
border-radius: $r-card;
position: relative;
display: flex;
align-items: baseline;
gap: $sp-2;
padding: $sp-2 0;
}
.wet__etaGlow {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba($subject-orange-deep, .5), rgba($subject-orange-deep, 0) 70%);
transform: translate(-50%, -50%) scale(.85);
opacity: 0;
pointer-events: none;
}
.wet__etaNum {
position: relative;
z-index: 1;
font-size: 64px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $subject-orange-deep;
}
.wet__etaUnit {
position: relative;
z-index: 1;
font-size: 13px;
font-weight: 600;
color: rgba($stage-ink, .72);
}
.wet__avg {
margin: 0;
font-size: 12px;
font-weight: 600;
color: rgba($stage-ink, .6);
}
.wet__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .55);
}
.wet__controls {
display: flex;
gap: $sp-2;
}
.wet__btn {
border: 1px solid rgba($stage-ink, .25);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wet__btn--primary {
background: $subject-orange-deep;
border-color: $subject-orange-deep;
color: $stage-paper;
}
.wet__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wet__etaNum { font-size: 36px; }
.wet__etaGlow { width: 90px; height: 90px; }
}
@media (prefers-reduced-motion: reduce) {
.wet__etaGlow { transition: none; opacity: 0 !important; }
}
// 예상 시간이 다시 계산될 때마다 숫자 판이 한 번 밝아졌다 가라앉는다.
.wet__etaWrap.is-flash { animation: wetFlash 900ms ease-out; }
@keyframes wetFlash {
from { background: rgba($subject-orange-deep, .3); }
to { background: rgba($subject-orange-deep, 0); }
}
@media (prefers-reduced-motion: reduce) {
.wet__etaWrap.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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wet {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 300px);
}
.wet__etaWrap {
border-radius: 16px;
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
padding: 8px 0;
}
.wet__etaGlow {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(201, 48, 0, 0.5), rgba(201, 48, 0, 0) 70%);
transform: translate(-50%, -50%) scale(0.85);
opacity: 0;
pointer-events: none;
}
.wet__etaNum {
position: relative;
z-index: 1;
font-size: 64px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #c93000;
}
.wet__etaUnit {
position: relative;
z-index: 1;
font-size: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.wet__avg {
margin: 0;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
}
.wet__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.55);
}
.wet__controls {
display: flex;
gap: 8px;
}
.wet__btn {
border: 1px solid rgba(23, 20, 26, 0.25);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wet__btn--primary {
background: #c93000;
border-color: #c93000;
color: #ffffff;
}
.wet__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wet__etaNum {
font-size: 36px;
}
.wet__etaGlow {
width: 90px;
height: 90px;
}
}
@media (prefers-reduced-motion: reduce) {
.wet__etaGlow {
transition: none;
opacity: 0 !important;
}
}
.wet__etaWrap.is-flash {
animation: wetFlash 900ms ease-out;
}
@keyframes wetFlash {
from {
background: rgba(201, 48, 0, 0.3);
}
to {
background: rgba(201, 48, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.wet__etaWrap.is-flash {
animation: none;
}
}(function () {
// 기법: 처리된 사례들의 소요 시간 로그에서 평균을 계산하고, 그 평균에 남은 대기 인원을
// 곱해 예상 대기 시간(분)을 다시 계산한다. 값이 바뀔 때마다 ETA_TWEEN_MS 동안 숫자를
// easeOutCubic 으로 보간하고, 변화 폭에 비례해 숫자 뒤 발광을 계산해 함께 전파한다.
// 실제 시각(Date) 은 쓰지 않고 rAF 타임스탬프로만 흐름을 계산하는 가상 시계다.
var ETA_TWEEN_MS = 420;
var SERVICE_LOG_MAX = 12;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434021);
function randServiceMinutes() { return 2.5 + rand() * 3.5; }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var root = document.getElementById('wetRoot');
var etaNumEl = document.getElementById('wetEtaNum');
var glowEl = document.getElementById('wetGlow');
var avgEl = document.getElementById('wetAvg');
var status = document.getElementById('wetStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var INITIAL_REMAIN = 24;
var serviceLog = [];
for (var i = 0; i < 6; i += 1) serviceLog.push(randServiceMinutes());
var remain = INITIAL_REMAIN;
var shownEta = 0;
var etaAnim = null;
function avgMinutes() {
var sum = 0;
for (var i = 0; i < serviceLog.length; i += 1) sum += serviceLog[i];
return sum / serviceLog.length;
}
function targetEta() {
return avgMinutes() * remain;
}
function paintGlow(ratio) {
glowEl.style.opacity = (0.5 * ratio).toFixed(3);
glowEl.style.transform = 'translate(-50%, -50%) scale(' + (0.85 + ratio * 0.5).toFixed(3) + ')';
}
function flash(el) {
if (reduce) return;
el.classList.remove('is-flash');
void el.offsetWidth;
el.classList.add('is-flash');
}
function tweenEta(nowMs, from, to) {
flash(etaNumEl.parentNode);
if (etaAnim) cancelAnimationFrame(etaAnim);
var maxDelta = Math.max(1, avgMinutes() * 4);
var deltaRatio = Math.min(1, Math.abs(to - from) / maxDelta);
if (reduce) {
shownEta = to;
etaNumEl.textContent = Math.round(to);
paintGlow(0);
return;
}
var start = null;
function step(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / ETA_TWEEN_MS);
var e = easeOutCubic(t);
var v = from + (to - from) * e;
shownEta = v;
etaNumEl.textContent = Math.round(v);
paintGlow(deltaRatio * (1 - t));
if (t < 1) { etaAnim = requestAnimationFrame(step); } else { etaAnim = null; paintGlow(0); }
}
etaAnim = requestAnimationFrame(step);
}
function paintStatic() {
avgEl.textContent = '건당 평균 ' + avgMinutes().toFixed(1) + '분';
}
function paintStatus() {
status.textContent = '평균 처리 ' + avgMinutes().toFixed(1) + '분 · 예상 대기 ' + Math.round(targetEta()) + '분';
}
function recalc(nowMs) {
var from = shownEta;
var to = targetEta();
tweenEta(nowMs, from, to);
paintStatic();
paintStatus();
}
function processNext(nowMs) {
if (remain <= 0) { paintStatus(); return; }
serviceLog.push(randServiceMinutes());
if (serviceLog.length > SERVICE_LOG_MAX) serviceLog.shift();
remain = Math.max(0, remain - 1);
recalc(nowMs);
}
function resetAll(nowMs) {
serviceLog = [];
for (var i = 0; i < 6; i += 1) serviceLog.push(randServiceMinutes());
remain = INITIAL_REMAIN;
shownEta = targetEta();
etaNumEl.textContent = Math.round(shownEta);
paintGlow(0);
paintStatic();
paintStatus();
}
var processBtn = document.getElementById('wetProcess');
var resetBtn = document.getElementById('wetReset');
function onProcess() { root.classList.remove('is-demo'); processNext(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
processBtn.addEventListener('click', onProcess);
processBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onProcess(); }
});
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; }
var demoSchedule = [200, 1200];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (remain <= 0) { resetAll(now); } else { processNext(now); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
shownEta = targetEta();
etaNumEl.textContent = Math.round(shownEta);
paintStatic();
paintStatus();
requestAnimationFrame(tick);
})();// WaitingQueueEtaAverageServiceTime — vanilla 판(02-waiting-queue-eta-average-service-time) 의 React 포트.
// 처리 로그 평균(분) × 남은 인원으로 예상 대기 시간을 다시 계산해, 바뀐 값을 easeOutCubic 으로
// 보간하고 변화 폭에 비례한 발광을 같은 값으로 전파한다 — Date 없이 rAF 타임스탬프만 쓰는 가상 시계.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueEtaAverageServiceTime.module.scss";
type Box<T> = { current: T };
const ETA_TWEEN_MS = 420;
const SERVICE_LOG_MAX = 12;
const INITIAL_REMAIN = 24;
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
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 WaitingQueueEtaAverageServiceTime() {
const [etaText, setEtaText] = useState("0");
const [avgText, setAvgText] = useState("건당 평균 0.0분");
const [status, setStatus] = useState("");
const [glow, setGlow] = useState({ opacity: 0, scale: 0.85 });
const [reduce, setReduce] = useState(false);
const randRef = useRef<() => number>(seedRand(434021)) as Box<() => number>;
const logRef = useRef<number[]>([]) as Box<number[]>;
const remainRef = useRef<number>(INITIAL_REMAIN) as Box<number>;
const shownEtaRef = useRef<number>(0) as Box<number>;
const etaAnimRef = 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 etaWrapRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
function randServiceMinutes(): number { return 2.5 + randRef.current() * 3.5; }
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
for (let i = 0; i < 6; i += 1) logRef.current.push(randServiceMinutes());
shownEtaRef.current = targetEta();
setEtaText(String(Math.round(shownEtaRef.current)));
paintStatic();
paintStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function avgMinutes(): number {
const log = logRef.current;
if (log.length === 0) return 0;
let sum = 0;
for (let i = 0; i < log.length; i += 1) sum += log[i];
return sum / log.length;
}
function targetEta(): number { return avgMinutes() * remainRef.current; }
function paintStatic(): void {
setAvgText("건당 평균 " + avgMinutes().toFixed(1) + "분");
}
function paintStatus(): void {
setStatus("평균 처리 " + avgMinutes().toFixed(1) + "분 · 예상 대기 " + Math.round(targetEta()) + "분");
}
function tweenEta(from: number, to: number): void {
flash(etaWrapRef.current, styles.isFlash, reduce);
if (etaAnimRef.current) cancelAnimationFrame(etaAnimRef.current);
const maxDelta = Math.max(1, avgMinutes() * 4);
const deltaRatio = Math.min(1, Math.abs(to - from) / maxDelta);
if (reduce) {
shownEtaRef.current = to;
setEtaText(String(Math.round(to)));
setGlow({ opacity: 0, scale: 0.85 });
return;
}
let start = 0;
function step(ts: number): void {
if (start === 0) start = ts;
const t = Math.min(1, (ts - start) / ETA_TWEEN_MS);
const e = easeOutCubic(t);
const v = from + (to - from) * e;
shownEtaRef.current = v;
setEtaText(String(Math.round(v)));
const ratio = deltaRatio * (1 - t);
setGlow({ opacity: 0.5 * ratio, scale: 0.85 + ratio * 0.5 });
if (t < 1) { etaAnimRef.current = requestAnimationFrame(step); } else { etaAnimRef.current = 0; setGlow({ opacity: 0, scale: 0.85 }); }
}
etaAnimRef.current = requestAnimationFrame(step);
}
function recalc(): void {
const from = shownEtaRef.current;
const to = targetEta();
tweenEta(from, to);
paintStatic();
paintStatus();
}
function processNext(): void {
if (remainRef.current <= 0) { paintStatus(); return; }
logRef.current.push(randServiceMinutes());
if (logRef.current.length > SERVICE_LOG_MAX) logRef.current.shift();
remainRef.current = Math.max(0, remainRef.current - 1);
recalc();
}
function resetAll(): void {
logRef.current = [];
for (let i = 0; i < 6; i += 1) logRef.current.push(randServiceMinutes());
remainRef.current = INITIAL_REMAIN;
shownEtaRef.current = targetEta();
setEtaText(String(Math.round(shownEtaRef.current)));
setGlow({ opacity: 0, scale: 0.85 });
paintStatic();
paintStatus();
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (remainRef.current <= 0) { resetAll(); } else { processNext(); }
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 onProcess(): void { stopDemo(); processNext(); }
function onReset(): void { stopDemo(); resetAll(); }
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.wet}>
<div className={styles.etaWrap} ref={etaWrapRef}>
<span className={styles.etaGlow} aria-hidden="true" style={{ opacity: glow.opacity, transform: "translate(-50%, -50%) scale(" + glow.scale + ")" }} />
<span className={styles.etaNum} aria-hidden="true">{etaText}</span>
<span className={styles.etaUnit} aria-hidden="true">분 뒤 예상</span>
</div>
<p className={styles.avg} aria-hidden="true">{avgText}</p>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onProcess} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onProcess)}>다음 처리</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}02 · 평균 처리 시간과 인원 수를 곱해 다시 계산하는 예상 대기 시간 — 측정 누적면적 12.84% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function avgMinutes() {
var sum = 0;
for (var i = 0; i < serviceLog.length; i += 1) sum += serviceLog[i];
return sum / serviceLog.length;
}
function targetEta() {
return avgMinutes() * remain;
}
function paintGlow(ratio) {
glowEl.style.opacity = (0.5 * ratio).toFixed(3);
glowEl.style.transform = 'translate(-50%, -50%) scale(' + (0.85 + ratio * 0.5).toFixed(3) + ')';
}
03완료 비율을 계산해 채워지는 대기열 진행 링
처리 버튼을 한 번 누르면 1명에서 3명이 한꺼번에 처리된 것으로 치고, 처리 인원을 전체 30명으로 나눈 비율만큼 paintRing 이 strokeDashoffset 을 줄여 링을 채우며 3분의 1 지점과 3분의 2 지점에서 링 색을 한 단계씩 갈아 끼운다. 대기열 전체가 어디까지 왔는지 한눈에 요약하는 판에 쓴다. 03 항목은 누적면적 15.8%·강도 평균 58.1·23프레임 중 11프레임에서 움직였다.

<main class="stage">
<div class="wpr is-demo" id="wprRoot">
<div class="wpr__ringWrap">
<svg class="wpr__ring" viewBox="0 0 120 120" aria-hidden="true">
<circle class="wpr__track" cx="60" cy="60" r="52"></circle>
<circle class="wpr__fill" id="wprFill" cx="60" cy="60" r="52"></circle>
</svg>
<div class="wpr__pct" id="wprPct" aria-hidden="true">0%</div>
</div>
<p class="wpr__status" id="wprStatus" aria-live="polite"></p>
<div class="wpr__controls">
<button type="button" class="wpr__btn wpr__btn--primary" id="wprProcess">처리 추가</button>
<button type="button" class="wpr__btn" id="wprReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 처리 비율(0~1)을 stroke-dashoffset 으로 환산해 링을 채우고, 비율 구간마다
// 링 색을 세 단계로 계산해 전파한다 — 색은 속성이 아니라 비율값에서 매번 다시 계산된다.
@include stage($stage-orange);
.wpr {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 300px);
}
.wpr__ringWrap {
border-radius: 50%;
position: relative;
width: 168px;
height: 168px;
display: grid;
place-items: center;
}
.wpr__ring {
width: 168px;
height: 168px;
transform: rotate(-90deg);
}
.wpr__track {
fill: none;
stroke: rgba($stage-ink, .18);
stroke-width: 10;
}
.wpr__fill {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 10;
stroke-linecap: round;
}
.wpr__fill.is-early { stroke: $subject-blue-deep; }
.wpr__fill.is-mid { stroke: $stage-ink; }
.wpr__fill.is-late { stroke: $tick-up; }
.wpr__pct {
position: absolute;
font-size: 24px;
font-weight: 700;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
.wpr__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .65);
}
.wpr__controls {
display: flex;
gap: $sp-2;
}
.wpr__btn {
border: 1px solid rgba($stage-ink, .28);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wpr__btn--primary {
background: $stage-ink;
border-color: $stage-ink;
color: $stage-paper;
}
.wpr__btn:focus-visible {
outline: 2px solid $stage-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpr__ringWrap { width: 104px; height: 104px; }
.wpr__ring { width: 104px; height: 104px; }
.wpr__pct { font-size: 19px; }
}
@media (prefers-reduced-motion: reduce) {
.wpr__fill { transition: none; }
}
@media (max-height: 240px) {
.wpr { gap: $sp-2; }
.wpr__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wpr__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}
// 처리 인원이 늘 때마다 링 안쪽이 한 번 밝아졌다 가라앉는다.
.wpr__ringWrap.is-flash { animation: wprFlash 900ms ease-out; }
@keyframes wprFlash {
from { background: rgba($subject-blue-deep, .3); }
to { background: rgba($subject-blue-deep, 0); }
}
@media (prefers-reduced-motion: reduce) {
.wpr__ringWrap.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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wpr {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 300px);
}
.wpr__ringWrap {
border-radius: 50%;
position: relative;
width: 168px;
height: 168px;
display: grid;
place-items: center;
}
.wpr__ring {
width: 168px;
height: 168px;
transform: rotate(-90deg);
}
.wpr__track {
fill: none;
stroke: rgba(23, 20, 26, 0.18);
stroke-width: 10;
}
.wpr__fill {
fill: none;
stroke: #0a4fe8;
stroke-width: 10;
stroke-linecap: round;
}
.wpr__fill.is-early {
stroke: #0a4fe8;
}
.wpr__fill.is-mid {
stroke: #17141a;
}
.wpr__fill.is-late {
stroke: #3ddc97;
}
.wpr__pct {
position: absolute;
font-size: 24px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.wpr__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.65);
}
.wpr__controls {
display: flex;
gap: 8px;
}
.wpr__btn {
border: 1px solid rgba(23, 20, 26, 0.28);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wpr__btn--primary {
background: #17141a;
border-color: #17141a;
color: #ffffff;
}
.wpr__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpr__ringWrap {
width: 104px;
height: 104px;
}
.wpr__ring {
width: 104px;
height: 104px;
}
.wpr__pct {
font-size: 19px;
}
}
@media (prefers-reduced-motion: reduce) {
.wpr__fill {
transition: none;
}
}
@media (max-height: 240px) {
.wpr {
gap: 8px;
}
.wpr__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wpr__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}
.wpr__ringWrap.is-flash {
animation: wprFlash 900ms ease-out;
}
@keyframes wprFlash {
from {
background: rgba(10, 79, 232, 0.3);
}
to {
background: rgba(10, 79, 232, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.wpr__ringWrap.is-flash {
animation: none;
}
}(function () {
// 기법: 처리된 인원 수를 전체 인원으로 나눈 비율을 다시 계산해 원 둘레(2πr)에서 그 비율만큼을
// stroke-dashoffset 으로 채운다. 채워지는 동안 RING_FILL_DURATION_MS 로 easeOutCubic 보간하고,
// 비율 구간(0~1/3, 1/3~2/3, 2/3~1)에 따라 링 색을 세 단계로 계산해 함께 바꾼다.
var RING_FILL_DURATION_MS = 500;
var TOTAL = 30;
var RADIUS = 52;
var CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434031);
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var root = document.getElementById('wprRoot');
var fillEl = document.getElementById('wprFill');
var pctEl = document.getElementById('wprPct');
var status = document.getElementById('wprStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
fillEl.style.strokeDasharray = CIRCUMFERENCE.toFixed(2);
var processed = 0;
var shownRatio = 0;
var ringAnim = null;
function stageOf(ratio) {
if (ratio >= 2 / 3) return 'is-late';
if (ratio >= 1 / 3) return 'is-mid';
return 'is-early';
}
function paintRing(ratio) {
var offset = CIRCUMFERENCE * (1 - ratio);
fillEl.style.strokeDashoffset = offset.toFixed(2);
fillEl.classList.remove('is-early', 'is-mid', 'is-late');
fillEl.classList.add(stageOf(ratio));
pctEl.textContent = Math.round(ratio * 100) + '%';
}
function flash(el) {
if (reduce) return;
el.classList.remove('is-flash');
void el.offsetWidth;
el.classList.add('is-flash');
}
function tweenRing(from, to) {
flash(fillEl.parentNode.parentNode);
if (ringAnim) cancelAnimationFrame(ringAnim);
if (reduce) { shownRatio = to; paintRing(to); return; }
var start = null;
function step(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / RING_FILL_DURATION_MS);
var e = easeOutCubic(t);
var v = from + (to - from) * e;
shownRatio = v;
paintRing(v);
if (t < 1) { ringAnim = requestAnimationFrame(step); } else { ringAnim = null; }
}
ringAnim = requestAnimationFrame(step);
}
function paintStatus() {
status.textContent = '처리 ' + processed + '/' + TOTAL + ' · ' + Math.round((processed / TOTAL) * 100) + '%';
}
function addProcessed() {
if (processed >= TOTAL) { paintStatus(); return; }
var step = 1 + Math.floor(rand() * 3);
processed = Math.min(TOTAL, processed + step);
tweenRing(shownRatio, processed / TOTAL);
paintStatus();
}
function resetAll() {
processed = 0;
shownRatio = 0;
paintRing(0);
paintStatus();
}
var processBtn = document.getElementById('wprProcess');
var resetBtn = document.getElementById('wprReset');
function onProcess() { root.classList.remove('is-demo'); addProcessed(); }
function onReset() { root.classList.remove('is-demo'); resetAll(); }
processBtn.addEventListener('click', onProcess);
processBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onProcess(); }
});
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); }
});
var demoSchedule = [200, 1200];
var demoIndex = 0;
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (processed >= TOTAL) { resetAll(); } else { addProcessed(); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
paintRing(0);
paintStatus();
requestAnimationFrame(tick);
})();// WaitingQueueProgressRingCompletionRatio — vanilla 판(03-waiting-queue-progress-ring-completion-ratio) 의
// React 포트. 처리 인원 / 전체 인원 비율을 다시 계산해 stroke-dashoffset 으로 링을 채우고,
// 비율 구간(0~1/3, 1/3~2/3, 2/3~1)마다 링 색을 계산해 함께 바꾼다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueProgressRingCompletionRatio.module.scss";
type Box<T> = { current: T };
const RING_FILL_DURATION_MS = 500;
const TOTAL = 30;
const RADIUS = 52;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }
function stageOf(ratio: number): string {
if (ratio >= 2 / 3) return styles.isLate;
if (ratio >= 1 / 3) return styles.isMid;
return styles.isEarly;
}
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 WaitingQueueProgressRingCompletionRatio() {
const [offset, setOffset] = useState(CIRCUMFERENCE);
const [stageClass, setStageClass] = useState("");
const [pctText, setPctText] = useState("0%");
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const randRef = useRef<() => number>(seedRand(434031)) as Box<() => number>;
const processedRef = useRef<number>(0) as Box<number>;
const shownRatioRef = useRef<number>(0) as Box<number>;
const ringAnimRef = 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 ringWrapRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function paintRing(ratio: number): void {
setOffset(CIRCUMFERENCE * (1 - ratio));
setStageClass(stageOf(ratio));
setPctText(Math.round(ratio * 100) + "%");
}
function paintStatus(): void {
setStatus("처리 " + processedRef.current + "/" + TOTAL + " · " + Math.round((processedRef.current / TOTAL) * 100) + "%");
}
function tweenRing(from: number, to: number): void {
flash(ringWrapRef.current, styles.isFlash, reduce);
if (ringAnimRef.current) cancelAnimationFrame(ringAnimRef.current);
if (reduce) { shownRatioRef.current = to; paintRing(to); return; }
let start = 0;
function step(ts: number): void {
if (start === 0) start = ts;
const t = Math.min(1, (ts - start) / RING_FILL_DURATION_MS);
const e = easeOutCubic(t);
const v = from + (to - from) * e;
shownRatioRef.current = v;
paintRing(v);
if (t < 1) { ringAnimRef.current = requestAnimationFrame(step); } else { ringAnimRef.current = 0; }
}
ringAnimRef.current = requestAnimationFrame(step);
}
function addProcessed(): void {
if (processedRef.current >= TOTAL) { paintStatus(); return; }
const step = 1 + Math.floor(randRef.current() * 3);
processedRef.current = Math.min(TOTAL, processedRef.current + step);
tweenRing(shownRatioRef.current, processedRef.current / TOTAL);
paintStatus();
}
function resetAll(): void {
processedRef.current = 0;
shownRatioRef.current = 0;
paintRing(0);
paintStatus();
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paintRing(0);
paintStatus();
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (processedRef.current >= TOTAL) { resetAll(); } else { addProcessed(); }
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 onProcess(): void { stopDemo(); addProcessed(); }
function onReset(): void { stopDemo(); resetAll(); }
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.wpr}>
<div className={styles.ringWrap} ref={ringWrapRef}>
<svg className={styles.ring} viewBox="0 0 120 120" aria-hidden="true">
<circle className={styles.track} cx="60" cy="60" r="52" />
<circle
className={[styles.fill, stageClass].join(" ")}
cx="60" cy="60" r="52"
style={{ strokeDasharray: CIRCUMFERENCE.toFixed(2), strokeDashoffset: offset.toFixed(2) }}
/>
</svg>
<div className={styles.pct} aria-hidden="true">{pctText}</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onProcess} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onProcess)}>처리 추가</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}03 · 완료 비율을 계산해 채워지는 대기열 진행 링 — 측정 누적면적 15.848% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stageOf(ratio) {
if (ratio >= 2 / 3) return 'is-late';
if (ratio >= 1 / 3) return 'is-mid';
return 'is-early';
}
function paintRing(ratio) {
var offset = CIRCUMFERENCE * (1 - ratio);
fillEl.style.strokeDashoffset = offset.toFixed(2);
fillEl.classList.remove('is-early', 'is-mid', 'is-late');
fillEl.classList.add(stageOf(ratio));
pctEl.textContent = Math.round(ratio * 100) + '%';
}
04이동 거리를 계산해 스프링으로 자리 잡는 대기 목록
맨 앞 줄을 호출하면 그 줄은 220ms 동안 높이가 0 으로 접히고, 아래 줄들은 접힌 줄 높이에 8px 을 더한 거리(gap)만큼 움직였다가 linear() 곡선을 따라 제자리로 돌아와 출렁이며 자리를 잡는다. 새 맨 앞 줄에는 is-next 가 붙어 살짝 커지므로, 여러 사람이 한 화면을 같이 보는 순번 목록에서 다음 차례를 알릴 때 쓴다. 04 항목은 누적면적 32.8%·강도 평균 82.8·23프레임 중 14프레임에서 움직였다.

<main class="stage">
<div class="wls is-demo" id="wlsRoot">
<ul class="wls__list" role="list" id="wlsList">
<li class="wls__item is-next" data-id="d1">
<span class="wls__ticket">101번</span>
<span class="wls__note">대기 3분</span>
</li>
<li class="wls__item" data-id="d2">
<span class="wls__ticket">102번</span>
<span class="wls__note">대기 9분</span>
</li>
<li class="wls__item" data-id="d3">
<span class="wls__ticket">103번</span>
<span class="wls__note">대기 14분</span>
</li>
<li class="wls__item" data-id="d4">
<span class="wls__ticket">104번</span>
<span class="wls__note">대기 18분</span>
</li>
<li class="wls__item" data-id="d5">
<span class="wls__ticket">105번</span>
<span class="wls__note">대기 22분</span>
</li>
</ul>
<div class="wls__controls">
<button type="button" class="wls__btn" id="wlsReset">되돌리기</button>
<button type="button" class="wls__btn wls__btn--primary" id="wlsCallBtn">호출 완료</button>
</div>
<p class="wls__status" id="wlsStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 앞 순번이 빠지면 아래 행들이 이동할 거리(gap)를 실측해 스프링 감쇠로 자리를 메운다.
// 새로 맨 앞이 된 행은 is-next 로 계산되어 살짝 커지고 테두리가 강조된다.
@include stage($stage-paper);
.wls {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(92vw, 320px);
}
.wls__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.wls__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-ink, .05);
transform-origin: left center;
}
.wls__item.is-next {
background: rgba($subject-blue-deep, .12);
outline: 1.5px solid $subject-blue-deep;
transform: scale(1.03);
}
.wls__ticket {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-ink;
}
.wls__note {
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .55);
}
.wls__controls {
display: flex;
gap: $sp-2;
}
.wls__btn {
border: 1px solid rgba($stage-ink, .25);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wls__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wls__btn:disabled { opacity: .4; cursor: default; }
.wls__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
.wls__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .6);
}
@media (max-width: 340px) {
.wls__ticket { font-size: 13px; }
.wls__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wls__item { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.wls__list { gap: $sp-1; }
.wls__item { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.wls { gap: $sp-2; }
.wls__item { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.wls__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wls__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}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);
}
.wls {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(92vw, 320px);
}
.wls__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wls__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
transform-origin: left center;
}
.wls__item.is-next {
background: rgba(10, 79, 232, 0.12);
outline: 1.5px solid #0a4fe8;
transform: scale(1.03);
}
.wls__ticket {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.wls__note {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.55);
}
.wls__controls {
display: flex;
gap: 8px;
}
.wls__btn {
border: 1px solid rgba(23, 20, 26, 0.25);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wls__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wls__btn:disabled {
opacity: 0.4;
cursor: default;
}
.wls__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.wls__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
}
@media (max-width: 340px) {
.wls__ticket {
font-size: 13px;
}
.wls__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wls__item {
transition: none;
}
}
.wls__list {
gap: 4px;
}
.wls__item {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.wls {
gap: 8px;
}
.wls__item {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.wls__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wls__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 맨 앞 순번이 빠지면 그 행의 높이를 실측해 0 으로 접고, 아래 행들은 접힌 높이만큼
// 위로 이동할 거리를 First/Last 로 계산해 스프링 감쇠(LIST_SHIFT_SPRING_STIFFNESS)로 되돌아온다.
// 이동이 끝난 새 맨 앞 행에는 is-next 를 계산해 붙여 살짝 커지게 한다.
var LIST_SHIFT_SPRING_STIFFNESS = 260;
var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';
var ORDER = ['d1', 'd2', 'd3', 'd4', 'd5'];
var root = document.getElementById('wlsRoot');
var list = document.getElementById('wlsList');
var status = document.getElementById('wlsStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var frontIndex = 0;
function shiftDuration(gapPx) {
return Math.round(60 + 100000 / LIST_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
function visibleRows() {
var all = list.querySelectorAll('.wls__item');
var vis = [];
for (var i = 0; i < all.length; i += 1) {
if (all[i].getBoundingClientRect().height > 0 && all[i].style.display !== 'none') vis.push(all[i]);
}
return vis;
}
function callFront() {
if (frontIndex >= ORDER.length) { status.textContent = '대기 목록이 비었습니다.'; return; }
var id = ORDER[frontIndex];
var row = list.querySelector('.wls__item[data-id="' + id + '"]');
if (!row) return;
var ticket = row.querySelector('.wls__ticket').textContent;
row.classList.remove('is-next');
var below = visibleRows().filter(function (el) { return el !== row; });
var before = below.map(function (el) { return el.getBoundingClientRect().top; });
var gap = row.offsetHeight + 8;
if (reduce) {
row.style.display = 'none';
} else {
var h = row.offsetHeight;
row.style.height = h + 'px';
row.style.overflow = 'hidden';
row.style.boxSizing = 'border-box';
row.style.marginTop = '0px';
row.style.marginBottom = '0px';
row.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 220, easing: 'ease-in', fill: 'forwards' }
).onfinish = function () { row.style.display = 'none'; };
var dur = shiftDuration(gap);
below.forEach(function (el, i) {
el.animate(
[
{ transform: 'translateY(0px)' },
{ transform: 'translateY(' + gap + 'px)' },
{ transform: 'translateY(0px)' }
],
{ duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: 'both' }
);
});
}
frontIndex += 1;
var nextId = ORDER[frontIndex];
var nextTicket = '';
if (nextId) {
var nextRow = list.querySelector('.wls__item[data-id="' + nextId + '"]');
if (nextRow) { nextRow.classList.add('is-next'); nextTicket = nextRow.querySelector('.wls__ticket').textContent; }
}
var callBtn = document.getElementById('wlsCallBtn');
if (callBtn) callBtn.disabled = frontIndex >= ORDER.length;
status.textContent = nextTicket
? ticket + ' 호출 완료 · 다음은 ' + nextTicket
: ticket + ' 호출 완료 · 대기 목록이 비었습니다.';
}
function restoreAll() {
var all = list.querySelectorAll('.wls__item');
for (var i = 0; i < all.length; i += 1) {
all[i].getAnimations().forEach(function (a) { a.cancel(); });
all[i].style.display = '';
all[i].style.height = '';
all[i].style.overflow = '';
all[i].style.marginTop = '';
all[i].style.marginBottom = '';
all[i].classList.remove('is-next');
}
frontIndex = 0;
var firstRow = list.querySelector('.wls__item[data-id="' + ORDER[0] + '"]');
if (firstRow) firstRow.classList.add('is-next');
var callBtn = document.getElementById('wlsCallBtn');
if (callBtn) callBtn.disabled = false;
status.textContent = '대기 목록을 되돌렸습니다.';
}
var callBtn = document.getElementById('wlsCallBtn');
var resetBtn = document.getElementById('wlsReset');
function onCall() { root.classList.remove('is-demo'); callFront(); }
function onReset() { root.classList.remove('is-demo'); restoreAll(); }
callBtn.addEventListener('click', onCall);
callBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCall(); }
});
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); }
});
var demoSchedule = [200, 1200];
var demoIndex = 0;
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (frontIndex >= ORDER.length) { restoreAll(); } else { callFront(); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
status.textContent = '101번이 다음 차례입니다.';
requestAnimationFrame(tick);
})();// WaitingQueueListShiftPositionSpring — vanilla 판(04-waiting-queue-list-shift-position-spring) 의
// React 포트. 맨 앞 순번이 빠지면 아래 행들이 이동할 거리(gap)를 실측해 FLIP + 스프링 감쇠
// (LIST_SHIFT_SPRING_STIFFNESS)로 자리를 메우고, 새 맨 앞 행은 is-next 계산으로 강조한다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueListShiftPositionSpring.module.scss";
type Box<T> = { current: T };
const LIST_SHIFT_SPRING_STIFFNESS = 260;
const SPRING_SETTLE_EASING = "linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)";
type Row = { id: string; ticket: string; note: string };
const ROWS: Row[] = [
{ id: "d1", ticket: "101번", note: "대기 3분" },
{ id: "d2", ticket: "102번", note: "대기 9분" },
{ id: "d3", ticket: "103번", note: "대기 14분" },
{ id: "d4", ticket: "104번", note: "대기 18분" },
{ id: "d5", ticket: "105번", note: "대기 22분" }
];
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / LIST_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
export default function WaitingQueueListShiftPositionSpring() {
const [frontIndex, setFrontIndex] = useState(0);
const [hiddenIds, setHiddenIds] = useState<string[]>([]);
const [status, setStatus] = useState("101번이 다음 차례입니다.");
const [reduce, setReduce] = useState(false);
const rowRefs = useRef<Record<string, HTMLLIElement | null>>({}) as Box<Record<string, HTMLLIElement | null>>;
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>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function visibleRows(excludeId?: string): HTMLLIElement[] {
return ROWS.filter((r) => !hiddenIds.includes(r.id) && r.id !== excludeId)
.map((r) => rowRefs.current[r.id])
.filter((el): el is HTMLLIElement => !!el);
}
function callFront(): void {
if (frontIndex >= ROWS.length) { setStatus("대기 목록이 비었습니다."); return; }
const row = ROWS[frontIndex];
const el = rowRefs.current[row.id];
const below = visibleRows(row.id);
const before = below.map((r) => r.getBoundingClientRect().top);
const gap = (el ? el.offsetHeight : 0) + 8;
if (!reduce && el) {
const h = el.offsetHeight;
el.style.height = h + "px";
el.style.overflow = "hidden";
el.style.boxSizing = "border-box";
el.style.marginTop = "0px";
el.style.marginBottom = "0px";
el.animate(
[{ height: h + "px", opacity: 1 }, { height: "0px", opacity: 0 }],
{ duration: 220, easing: "ease-in", fill: "forwards" }
);
const dur = shiftDuration(gap);
below.forEach((rEl) => {
rEl.animate(
[
{ transform: "translateY(0px)" },
{ transform: "translateY(" + gap + "px)" },
{ transform: "translateY(0px)" }
],
{ duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: "both" }
);
});
}
setHiddenIds((prev) => prev.concat(row.id));
const nextIdx = frontIndex + 1;
setFrontIndex(nextIdx);
const next = ROWS[nextIdx];
setStatus(next ? row.ticket + " 호출 완료 · 다음은 " + next.ticket : row.ticket + " 호출 완료 · 대기 목록이 비었습니다.");
void before;
}
function restoreAll(): void {
Object.keys(rowRefs.current).forEach((id) => {
const el = rowRefs.current[id];
if (!el) return;
el.getAnimations().forEach((a) => a.cancel());
el.style.height = "";
el.style.overflow = "";
el.style.marginTop = "";
el.style.marginBottom = "";
});
setHiddenIds([]);
setFrontIndex(0);
setStatus("대기 목록을 되돌렸습니다.");
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (frontIndexIsDone()) { restoreAll(); } else { callFront(); }
demoIndexRef.current += 1;
if (demoIndexRef.current >= sched.length) {
demoIndexRef.current = 0;
demoScheduleRef.current = sched.map((v) => v + now + 2000);
}
}
frameRef.current = requestAnimationFrame(tick);
}
function frontIndexIsDone(): boolean { return frontIndex >= ROWS.length; }
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce, frontIndex]);
function onCall(): void { stopDemo(); callFront(); }
function onReset(): void { stopDemo(); restoreAll(); }
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.wls}>
<ul className={styles.list} role="list">
{ROWS.map((r, i) => (
<li
key={r.id}
className={[styles.item, i === frontIndex ? styles.itemNext : ""].join(" ")}
style={{ display: hiddenIds.includes(r.id) ? "none" : "" }}
ref={(el: HTMLLIElement | null) => { rowRefs.current[r.id] = el; }}
>
<span className={styles.ticket}>{r.ticket}</span>
<span className={styles.note}>{r.note}</span>
</li>
))}
</ul>
<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(" ")} disabled={frontIndex >= ROWS.length} onClick={onCall} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onCall)}>호출 완료</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}04 · 이동 거리를 계산해 스프링으로 자리 잡는 대기 목록 — 측정 누적면적 32.848% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
row.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 220, easing: 'ease-in', fill: 'forwards' }
).onfinish = function () { row.style.display = 'none'; };
var dur = shiftDuration(gap);
below.forEach(function (el, i) {
el.animate(
[
{ transform: 'translateY(0px)' },
{ transform: 'translateY(' + gap + 'px)' },
{ transform: 'translateY(0px)' }
],
{ duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: 'both' }
);
});
05재호출까지 남은 시간을 게이지로 줄여가는 노쇼 카운트다운
번호가 호출된 시각을 기억해 두고 경과 시간을 RECALL_COUNTDOWN_SEC(60초)로 나눈 남은 비율만큼 게이지 폭을 줄이며, 남은 비율이 34% 이하면 경고 색이 켜지고 0 이 되면 만료 색과 함께 SHAKE_DURATION_MS 동안 좌우로 한 번 떨린다. 호출했는데 대답이 없는 번호를 다시 부를 때가 됐다고 알리는 자리에 쓴다. 05 항목은 누적면적 6.2%·강도 평균 125.1·23프레임 중 23프레임에서 움직였다.

<main class="stage">
<div class="wrc is-demo" id="wrcRoot">
<div class="wrc__panel" id="wrcPanel">
<span class="wrc__ticket" id="wrcTicket" aria-hidden="true">201번</span>
<span class="wrc__badge" id="wrcBadge" aria-hidden="true">대기중</span>
<div class="wrc__track" aria-hidden="true"><div class="wrc__gauge" id="wrcGauge"></div></div>
<span class="wrc__sec" id="wrcSec" aria-hidden="true">60초 남음</span>
</div>
<p class="wrc__status" id="wrcStatus" aria-live="polite"></p>
<div class="wrc__controls">
<button type="button" class="wrc__btn wrc__btn--primary" id="wrcCheckin">확인 완료</button>
<button type="button" class="wrc__btn" id="wrcReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 호출 시점부터 지난 시간을 매 프레임 다시 계산해 게이지 폭을 비율로 줄인다.
// 비율이 0 에 닿는 순간을 계산해 배지·색을 바꾸고 짧은 흔들림을 한 번 재생한다.
@include stage($stage-ink);
.wrc {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 300px);
}
.wrc__panel {
display: grid;
gap: $sp-2;
justify-items: center;
width: 100%;
padding: $sp-4;
border-radius: $r-card;
background: rgba($stage-paper, .06);
}
.wrc__ticket {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-paper;
}
.wrc__badge {
font-size: 11px;
font-weight: 700;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
color: $subject-blue-light;
background: rgba($subject-blue-light, .16);
}
.wrc__badge.is-expired {
color: $tick-down;
background: rgba($tick-down, .18);
}
.wrc__track {
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba($stage-paper, .16);
overflow: hidden;
}
.wrc__gauge {
height: 100%;
width: 100%;
border-radius: $r-pill;
background: $subject-blue-light;
}
.wrc__gauge.is-warn { background: $tick-down; }
.wrc__gauge.is-expired { background: $tick-down; width: 0% !important; }
.wrc__sec {
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .6);
}
.wrc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .7);
}
.wrc__controls {
display: flex;
gap: $sp-2;
}
.wrc__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;
}
.wrc__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wrc__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrc__ticket { font-size: 22px; }
.wrc__panel { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.wrc__gauge { transition: none; }
}
@media (max-height: 240px) {
.wrc { gap: $sp-2; }
.wrc__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wrc__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}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);
}
.wrc {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 300px);
}
.wrc__panel {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.wrc__ticket {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.wrc__badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
color: #5b8cf8;
background: rgba(91, 140, 248, 0.16);
}
.wrc__badge.is-expired {
color: #ff7a5e;
background: rgba(255, 122, 94, 0.18);
}
.wrc__track {
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.wrc__gauge {
height: 100%;
width: 100%;
border-radius: 999px;
background: #5b8cf8;
}
.wrc__gauge.is-warn {
background: #ff7a5e;
}
.wrc__gauge.is-expired {
background: #ff7a5e;
width: 0% !important;
}
.wrc__sec {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
}
.wrc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.wrc__controls {
display: flex;
gap: 8px;
}
.wrc__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;
}
.wrc__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wrc__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrc__ticket {
font-size: 22px;
}
.wrc__panel {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.wrc__gauge {
transition: none;
}
}
@media (max-height: 240px) {
.wrc {
gap: 8px;
}
.wrc__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wrc__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 번호가 호출된 시점(가상 시계 시작점)부터 지금까지 지난 시간을 계산해 남은 비율만큼
// 게이지 바 width 를 줄인다. 비율이 0 에 닿으면 만료로 계산해 배지를 바꾸고 흔들림을 한 번
// 계산해 재생한다. RECALL_COUNTDOWN_SEC 초 동안 진행되며, Date 없이 rAF 타임스탬프만 쓴다.
var RECALL_COUNTDOWN_SEC = 60;
var SHAKE_DURATION_MS = 380;
var root = document.getElementById('wrcRoot');
var panel = document.getElementById('wrcPanel');
var ticketEl = document.getElementById('wrcTicket');
var badgeEl = document.getElementById('wrcBadge');
var gaugeEl = document.getElementById('wrcGauge');
var secEl = document.getElementById('wrcSec');
var status = document.getElementById('wrcStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var CALL_BASE = 201;
var ticketIndex = 0;
var callStartMs = 0;
var expired = false;
var shaken = false;
function ticketNum() { return CALL_BASE + ticketIndex; }
function paintGauge(ratio) {
gaugeEl.style.width = (ratio * 100).toFixed(1) + '%';
gaugeEl.classList.toggle('is-warn', ratio <= 0.34 && ratio > 0);
gaugeEl.classList.toggle('is-expired', ratio <= 0);
}
function shake() {
if (reduce) return;
panel.animate(
[
{ transform: 'translateX(0px)' },
{ transform: 'translateX(-6px)' },
{ transform: 'translateX(6px)' },
{ transform: 'translateX(-4px)' },
{ transform: 'translateX(4px)' },
{ transform: 'translateX(0px)' }
],
{ duration: SHAKE_DURATION_MS, easing: 'ease-out' }
);
}
function startTicket(nowMs) {
ticketEl.textContent = ticketNum() + '번';
badgeEl.textContent = '대기중';
badgeEl.classList.remove('is-expired');
callStartMs = nowMs;
expired = false;
shaken = false;
paintGauge(1);
secEl.textContent = RECALL_COUNTDOWN_SEC + '초 남음';
status.textContent = ticketNum() + '번 호출 · ' + RECALL_COUNTDOWN_SEC + '초 안에 확인해주세요.';
}
function checkin(nowMs) {
status.textContent = ticketNum() + '번 확인 완료.';
ticketIndex += 1;
startTicket(nowMs);
}
function resetAll(nowMs) {
ticketIndex = 0;
startTicket(nowMs);
status.textContent = ticketNum() + '번 호출 · ' + RECALL_COUNTDOWN_SEC + '초 안에 확인해주세요.';
}
function stepGauge(nowMs) {
var elapsedS = (nowMs - callStartMs) / 1000;
var ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
paintGauge(ratio);
var remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
secEl.textContent = remainSec + '초 남음';
if (ratio <= 0 && !expired) {
expired = true;
badgeEl.textContent = '만료 · 재호출 필요';
badgeEl.classList.add('is-expired');
status.textContent = ticketNum() + '번 시간 만료 · 재호출이 필요합니다.';
if (!shaken) { shaken = true; shake(); }
}
}
var checkinBtn = document.getElementById('wrcCheckin');
var resetBtn = document.getElementById('wrcReset');
function onCheckin() { root.classList.remove('is-demo'); checkin(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
checkinBtn.addEventListener('click', onCheckin);
checkinBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCheckin(); }
});
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; }
// 데모 압축: 실제 60초 대신 시연에서는 흐름을 빠르게 계산해 보여준다(시계 자체는 여전히
// rAF 경과 시간 기반 — DEMO_TIME_SCALE 만큼 가속해 같은 비율 계산식을 통과시킨다).
// 렌더 캡처가 2 초뿐이라 60초 카운트다운을 1.5 초 안에 다 보이도록 40 배로 압축한다.
var DEMO_TIME_SCALE = 40;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
var demoNow = root.classList.contains('is-demo') ? callStartMs + (now - callStartMs) * DEMO_TIME_SCALE : now;
stepGauge(demoNow);
if (root.classList.contains('is-demo') && expired) {
checkin(now);
}
}
requestAnimationFrame(tick);
}
startTicket(0);
requestAnimationFrame(tick);
})();// WaitingQueueNoshowRecallCountdown — vanilla 판(05-waiting-queue-noshow-recall-countdown) 의 React 포트.
// 호출 시점부터 지난 시간을 매 프레임 다시 계산해 남은 비율만큼 게이지 width 를 줄이고,
// 비율 0 도달을 계산해 배지를 만료로 바꾸고 흔들림을 한 번 재생한다. Date 없이 rAF 시간만 쓴다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueNoshowRecallCountdown.module.scss";
type Box<T> = { current: T };
const RECALL_COUNTDOWN_SEC = 60;
const SHAKE_DURATION_MS = 380;
const CALL_BASE = 201;
const DEMO_TIME_SCALE = 40;
export default function WaitingQueueNoshowRecallCountdown() {
const [ticketText, setTicketText] = useState(CALL_BASE + "번");
const [badgeText, setBadgeText] = useState("대기중");
const [expired, setExpired] = useState(false);
const [warn, setWarn] = useState(false);
const [gaugeW, setGaugeW] = useState(100);
const [secText, setSecText] = useState(RECALL_COUNTDOWN_SEC + "초 남음");
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const ticketIndexRef = useRef<number>(0) as Box<number>;
const callStartRef = useRef<number>(0) as Box<number>;
const expiredRef = useRef<boolean>(false) as Box<boolean>;
const shakenRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function ticketNum(): number { return CALL_BASE + ticketIndexRef.current; }
function shake(): void {
if (reduce) return;
const panel = panelRef.current;
if (!panel) return;
panel.animate(
[
{ transform: "translateX(0px)" },
{ transform: "translateX(-6px)" },
{ transform: "translateX(6px)" },
{ transform: "translateX(-4px)" },
{ transform: "translateX(4px)" },
{ transform: "translateX(0px)" }
],
{ duration: SHAKE_DURATION_MS, easing: "ease-out" }
);
}
function startTicket(nowMs: number): void {
setTicketText(ticketNum() + "번");
setBadgeText("대기중");
setExpired(false);
setWarn(false);
callStartRef.current = nowMs;
expiredRef.current = false;
shakenRef.current = false;
setGaugeW(100);
setSecText(RECALL_COUNTDOWN_SEC + "초 남음");
setStatus(ticketNum() + "번 호출 · " + RECALL_COUNTDOWN_SEC + "초 안에 확인해주세요.");
}
function checkin(nowMs: number): void {
setStatus(ticketNum() + "번 확인 완료.");
ticketIndexRef.current += 1;
startTicket(nowMs);
}
function resetAll(nowMs: number): void {
ticketIndexRef.current = 0;
startTicket(nowMs);
setStatus(ticketNum() + "번 호출 · " + RECALL_COUNTDOWN_SEC + "초 안에 확인해주세요.");
}
function stepGauge(nowMs: number): void {
const elapsedS = (nowMs - callStartRef.current) / 1000;
const ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
setGaugeW(Math.round(ratio * 1000) / 10);
setWarn(ratio <= 0.34 && ratio > 0);
if (ratio <= 0 && !expiredRef.current) setExpired(true);
const remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
setSecText(remainSec + "초 남음");
if (ratio <= 0 && !expiredRef.current) {
expiredRef.current = true;
setBadgeText("만료 · 재호출 필요");
setStatus(ticketNum() + "번 시간 만료 · 재호출이 필요합니다.");
if (!shakenRef.current) { shakenRef.current = true; shake(); }
}
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
startTicket(0);
function tick(ts: number): void {
if (t0Ref.current === 0) t0Ref.current = ts;
const now = ts - t0Ref.current;
if (!reduce) {
const inDemo = demoRef.current;
const demoNow = inDemo ? callStartRef.current + (now - callStartRef.current) * DEMO_TIME_SCALE : now;
stepGauge(demoNow);
if (inDemo && expiredRef.current) {
checkin(now);
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onCheckin(): void { stopDemo(); checkin(perfNow()); }
function onReset(): void { stopDemo(); resetAll(perfNow()); }
function perfNow(): number { return t0Ref.current === 0 ? 0 : performance.now() - t0Ref.current; }
function onKeyDown(e: { key: string; preventDefault: () => void }, fn: () => void): void {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); }
}
const badgeClass = [styles.badge, expired ? styles.badgeExpired : ""].join(" ").trim();
const gaugeClass = [styles.gauge, warn ? styles.gaugeWarn : "", expired ? styles.gaugeExpired : ""].join(" ").trim();
const rootClass = [styles.wrc, reduce ? "" : "is-demo"].join(" ").trim();
return (
<main className={styles.stage}>
<div className={rootClass} ref={rootRef}>
<div className={styles.panel} ref={panelRef}>
<span className={styles.ticket} aria-hidden="true">{ticketText}</span>
<span className={badgeClass} aria-hidden="true">{badgeText}</span>
<div className={styles.track} aria-hidden="true"><div className={gaugeClass} style={{ width: gaugeW + "%" }} /></div>
<span className={styles.sec} aria-hidden="true">{secText}</span>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onCheckin} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onCheckin)}>확인 완료</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}05 · 재호출까지 남은 시간을 게이지로 줄여가는 노쇼 카운트다운 — 측정 누적면적 6.165% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepGauge(nowMs) {
var elapsedS = (nowMs - callStartMs) / 1000;
var ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
paintGauge(ratio);
var remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
secEl.textContent = remainSec + '초 남음';
if (ratio <= 0 && !expired) {
expired = true;
badgeEl.textContent = '만료 · 재호출 필요';
badgeEl.classList.add('is-expired');
status.textContent = ticketNum() + '번 시간 만료 · 재호출이 필요합니다.';
if (!shaken) { shaken = true; shake(); }
}
}
06창구별 처리 속도를 계산해 분산 배치하는 다중 카운터 현황
창구마다 쌓은 처리 기록의 평균을 구해 막대 길이를 정하는데, 가장 느린 창구가 30% 폭이 되고 평균이 짧을수록 길어지며, 평균이 가장 짧은 창구는 fastestId 로 뽑혀 은은한 빛이 켜진다. 창구가 나란히 여러 개 열려 있는 은행이나 관공서 현황판에 쓴다. 06 항목은 누적면적 2.7%·강도 평균 97.2·23프레임 중 7프레임에서 움직였다.

<main class="stage">
<div class="wmc is-demo" id="wmcRoot">
<ul class="wmc__list" id="wmcList" role="list"></ul>
<p class="wmc__status" id="wmcStatus" aria-live="polite"></p>
<div class="wmc__controls">
<button type="button" class="wmc__btn wmc__btn--primary" id="wmcNext">다음 처리</button>
<button type="button" class="wmc__btn" id="wmcReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 창구마다 처리 로그 평균(분)을 계산해 막대 길이(빠를수록 김)를 이징 트윈으로 채우고,
// 세 창구 중 평균이 가장 짧은 창구를 매 계산마다 다시 판정해 은은한 발광 클래스를 켠다.
@include stage($stage-yellow);
.wmc {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(92vw, 320px);
}
.wmc__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-2;
width: 100%;
}
.wmc__row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .55);
transition: box-shadow $dur-base $ease-out;
}
.wmc__row.is-fastest {
box-shadow: 0 0 0 1px rgba($subject-orange-deep, .35), 0 0 16px rgba($subject-orange-deep, .28);
}
.wmc__label {
font-size: 11px;
font-weight: 700;
color: $stage-ink;
grid-row: 1 / 3;
}
.wmc__track {
height: 8px;
border-radius: $r-pill;
background: rgba($stage-ink, .12);
overflow: hidden;
}
.wmc__bar {
height: 100%;
border-radius: $r-pill;
background: $subject-orange-deep;
}
.wmc__row.is-fastest .wmc__bar { background: $tick-up; }
.wmc__meta {
font-size: 10px;
font-weight: 600;
color: rgba($stage-ink, .65);
}
.wmc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .78);
text-align: center;
}
.wmc__controls {
display: flex;
gap: $sp-2;
}
.wmc__btn {
border: 1px solid rgba($stage-ink, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wmc__btn--primary {
background: $subject-orange-deep;
border-color: $subject-orange-deep;
color: $stage-paper;
}
.wmc__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wmc__row { grid-template-columns: 46px 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.wmc__bar { transition: none; }
.wmc__row { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.wmc__list { gap: $sp-1; }
.wmc__row { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.wmc { gap: $sp-2; }
.wmc__row { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.wmc__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wmc__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wmc {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(92vw, 320px);
}
.wmc__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
width: 100%;
}
.wmc__row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
transition: box-shadow 300ms ease-out;
}
.wmc__row.is-fastest {
box-shadow: 0 0 0 1px rgba(201, 48, 0, 0.35), 0 0 16px rgba(201, 48, 0, 0.28);
}
.wmc__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
grid-row: 1/3;
}
.wmc__track {
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.wmc__bar {
height: 100%;
border-radius: 999px;
background: #c93000;
}
.wmc__row.is-fastest .wmc__bar {
background: #3ddc97;
}
.wmc__meta {
font-size: 10px;
font-weight: 600;
color: rgba(23, 20, 26, 0.65);
}
.wmc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
text-align: center;
}
.wmc__controls {
display: flex;
gap: 8px;
}
.wmc__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wmc__btn--primary {
background: #c93000;
border-color: #c93000;
color: #ffffff;
}
.wmc__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wmc__row {
grid-template-columns: 46px 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.wmc__bar {
transition: none;
}
.wmc__row {
transition: none;
}
}
.wmc__list {
gap: 4px;
}
.wmc__row {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.wmc {
gap: 8px;
}
.wmc__row {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.wmc__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wmc__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 창구마다 처리 로그(분)를 따로 쌓아 평균 속도를 계산하고, 그 평균으로 다음 예상 처리
// 시각(분 뒤)을 계산해 표시한다. 막대 길이는 평균 속도(빠를수록 김)를 계산해 트윈으로 채우고,
// 세 창구 중 평균이 가장 짧은(=가장 빠른) 창구를 매 계산마다 다시 골라 은은하게 빛낸다.
var BAR_TWEEN_MS = 420;
var SERVICE_LOG_MAX = 8;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var COUNTERS = [
{ id: 'a', label: '1번 창구', seed: 434061, base: 2.4 },
{ id: 'b', label: '2번 창구', seed: 434062, base: 3.6 },
{ id: 'c', label: '3번 창구', seed: 434063, base: 3.0 }
];
COUNTERS.forEach(function (c) {
c.rand = seedRand(c.seed);
c.log = [];
c.processed = 0;
c.shownRatio = 0;
c.anim = 0;
for (var i = 0; i < 4; i += 1) c.log.push(c.base + c.rand() * 1.4);
});
var root = document.getElementById('wmcRoot');
var listEl = document.getElementById('wmcList');
var status = document.getElementById('wmcStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var rows = {};
COUNTERS.forEach(function (c) {
var li = document.createElement('li');
li.className = 'wmc__row';
li.id = 'wmcRow-' + c.id;
li.innerHTML =
'<span class="wmc__label">' + c.label + '</span>' +
'<div class="wmc__track" aria-hidden="true"><div class="wmc__bar" id="wmcBar-' + c.id + '"></div></div>' +
'<span class="wmc__meta" id="wmcMeta-' + c.id + '" aria-hidden="true"></span>';
listEl.appendChild(li);
rows[c.id] = li;
});
function avgMinutes(c) {
if (c.log.length === 0) return 0;
var sum = 0;
for (var i = 0; i < c.log.length; i += 1) sum += c.log[i];
return sum / c.log.length;
}
function maxAvg() {
var m = 0.0001;
COUNTERS.forEach(function (c) { m = Math.max(m, avgMinutes(c)); });
return m;
}
function fastestId() {
var best = COUNTERS[0];
COUNTERS.forEach(function (c) { if (avgMinutes(c) < avgMinutes(best)) best = c; });
return best.id;
}
function paintMeta(c) {
var avg = avgMinutes(c);
var metaEl = document.getElementById('wmcMeta-' + c.id);
metaEl.textContent = c.processed + '명 처리 · 다음 예상 ' + avg.toFixed(1) + '분 뒤';
}
function paintFastest() {
var fid = fastestId();
COUNTERS.forEach(function (c) {
rows[c.id].classList.toggle('is-fastest', c.id === fid);
});
}
function tweenBar(c) {
if (c.anim) cancelAnimationFrame(c.anim);
var barEl = document.getElementById('wmcBar-' + c.id);
var mx = maxAvg();
var to = 1 - Math.min(1, avgMinutes(c) / mx) * 0.7;
var from = c.shownRatio;
if (reduce) { c.shownRatio = to; barEl.style.width = (to * 100).toFixed(1) + '%'; return; }
var start = 0;
function step(ts) {
if (start === 0) start = ts;
var t = Math.min(1, (ts - start) / BAR_TWEEN_MS);
var e = easeOutCubic(t);
var v = from + (to - from) * e;
c.shownRatio = v;
barEl.style.width = (v * 100).toFixed(1) + '%';
if (t < 1) { c.anim = requestAnimationFrame(step); } else { c.anim = 0; }
}
c.anim = requestAnimationFrame(step);
}
function recalcAll() {
COUNTERS.forEach(function (c) { tweenBar(c); paintMeta(c); });
paintFastest();
}
var turnIndex = 0;
function processNext() {
var c = COUNTERS[turnIndex % COUNTERS.length];
turnIndex += 1;
c.log.push(c.base + c.rand() * 1.4);
if (c.log.length > SERVICE_LOG_MAX) c.log.shift();
c.processed += 1;
recalcAll();
status.textContent = c.label + ' ' + c.processed + '명째 처리 · 다음 예상 ' + avgMinutes(c).toFixed(1) + '분 뒤.';
}
function resetAll() {
turnIndex = 0;
COUNTERS.forEach(function (c) {
c.rand = seedRand(c.seed);
c.log = [];
c.processed = 0;
for (var i = 0; i < 4; i += 1) c.log.push(c.base + c.rand() * 1.4);
});
recalcAll();
status.textContent = '창구 현황을 초기화했습니다.';
}
var nextBtn = document.getElementById('wmcNext');
var resetBtn = document.getElementById('wmcReset');
function onNext() { root.classList.remove('is-demo'); processNext(); }
function onReset() { root.classList.remove('is-demo'); resetAll(); }
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 demoSchedule = [200, 1200];
var demoIndex = 0;
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
processNext();
demoIndex += 1;
if (demoIndex >= demoSchedule.length) {
demoIndex = 0;
demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; });
}
}
requestAnimationFrame(tick);
}
recalcAll();
status.textContent = '창구 3곳의 처리 속도를 계산해 다음 예상 시각을 보여줍니다.';
requestAnimationFrame(tick);
})();// WaitingQueueMultiCounterDistribution — vanilla 판(06-waiting-queue-multi-counter-distribution) 의
// React 포트. 창구마다 처리 로그 평균(분)을 계산해 막대 길이를 이징 트윈으로 채우고, 가장 빠른
// 창구를 매 계산마다 다시 판정해 은은한 발광을 켠다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueMultiCounterDistribution.module.scss";
type Box<T> = { current: T };
const BAR_TWEEN_MS = 420;
const SERVICE_LOG_MAX = 8;
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }
type Counter = {
id: string;
label: string;
seed: number;
base: number;
rand: () => number;
log: number[];
processed: number;
shownRatio: number;
anim: number;
};
function makeCounters(): Counter[] {
const defs = [
{ id: "a", label: "1번 창구", seed: 434061, base: 2.4 },
{ id: "b", label: "2번 창구", seed: 434062, base: 3.6 },
{ id: "c", label: "3번 창구", seed: 434063, base: 3.0 }
];
return defs.map((d) => {
const rand = seedRand(d.seed);
const log: number[] = [];
for (let i = 0; i < 4; i += 1) log.push(d.base + rand() * 1.4);
return { ...d, rand, log, processed: 0, shownRatio: 0, anim: 0 };
});
}
function avgMinutes(c: Counter): number {
if (c.log.length === 0) return 0;
let sum = 0;
for (let i = 0; i < c.log.length; i += 1) sum += c.log[i];
return sum / c.log.length;
}
export default function WaitingQueueMultiCounterDistribution() {
const countersRef = useRef<Counter[]>(makeCounters()) as Box<Counter[]>;
const [, forceTick] = useState(0);
const [barPct, setBarPct] = useState<Record<string, number>>({ a: 0, b: 0, c: 0 });
const [meta, setMeta] = useState<Record<string, string>>({ a: "", b: "", c: "" });
const [fastestId, setFastestId] = useState("a");
const [status, setStatus] = useState("창구 3곳의 처리 속도를 계산해 다음 예상 시각을 보여줍니다.");
const [reduce, setReduce] = useState(false);
const demoRef = useRef<boolean>(true) as Box<boolean>;
const turnRef = useRef<number>(0) as Box<number>;
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>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function maxAvg(): number {
let m = 0.0001;
countersRef.current.forEach((c) => { m = Math.max(m, avgMinutes(c)); });
return m;
}
function calcFastestId(): string {
const cs = countersRef.current;
let best = cs[0];
cs.forEach((c) => { if (avgMinutes(c) < avgMinutes(best)) best = c; });
return best.id;
}
function tweenBar(c: Counter): void {
if (c.anim) cancelAnimationFrame(c.anim);
const mx = maxAvg();
const to = 1 - Math.min(1, avgMinutes(c) / mx) * 0.7;
const from = c.shownRatio;
if (reduce) {
c.shownRatio = to;
setBarPct((p) => ({ ...p, [c.id]: to * 100 }));
return;
}
let start = 0;
function step(ts: number): void {
if (start === 0) start = ts;
const t = Math.min(1, (ts - start) / BAR_TWEEN_MS);
const e = easeOutCubic(t);
const v = from + (to - from) * e;
c.shownRatio = v;
setBarPct((p) => ({ ...p, [c.id]: v * 100 }));
if (t < 1) { c.anim = requestAnimationFrame(step); } else { c.anim = 0; }
}
c.anim = requestAnimationFrame(step);
}
function recalcAll(): void {
const cs = countersRef.current;
cs.forEach((c) => {
tweenBar(c);
setMeta((p) => ({ ...p, [c.id]: c.processed + "명 처리 · 다음 예상 " + avgMinutes(c).toFixed(1) + "분 뒤" }));
});
setFastestId(calcFastestId());
}
function processNext(): void {
const cs = countersRef.current;
const c = cs[turnRef.current % cs.length];
turnRef.current += 1;
c.log.push(c.base + c.rand() * 1.4);
if (c.log.length > SERVICE_LOG_MAX) c.log.shift();
c.processed += 1;
recalcAll();
setStatus(c.label + " " + c.processed + "명째 처리 · 다음 예상 " + avgMinutes(c).toFixed(1) + "분 뒤.");
forceTick((n) => n + 1);
}
function resetAll(): void {
turnRef.current = 0;
countersRef.current = makeCounters();
recalcAll();
setStatus("창구 현황을 초기화했습니다.");
forceTick((n) => n + 1);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
recalcAll();
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
processNext();
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(); processNext(); }
function onReset(): void { stopDemo(); resetAll(); }
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.wmc}>
<ul className={styles.list} role="list">
{countersRef.current.map((c) => (
<li key={c.id} className={[styles.row, c.id === fastestId ? styles.rowFastest : ""].join(" ")}>
<span className={styles.label}>{c.label}</span>
<div className={styles.track} aria-hidden="true"><div className={styles.bar} style={{ width: (barPct[c.id] || 0) + "%" }} /></div>
<span className={styles.meta} aria-hidden="true">{meta[c.id]}</span>
</li>
))}
</ul>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onNext} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onNext)}>다음 처리</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}06 · 창구별 처리 속도를 계산해 분산 배치하는 다중 카운터 현황 — 측정 누적면적 2.726% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function avgMinutes(c) {
if (c.log.length === 0) return 0;
var sum = 0;
for (var i = 0; i < c.log.length; i += 1) sum += c.log[i];
return sum / c.log.length;
}
function maxAvg() {
var m = 0.0001;
COUNTERS.forEach(function (c) { m = Math.max(m, avgMinutes(c)); });
return m;
}
function fastestId() {
var best = COUNTERS[0];
COUNTERS.forEach(function (c) { if (avgMinutes(c) < avgMinutes(best)) best = c; });
return best.id;
}
07차이만큼 굴러가는 전체 대기 인원 카운터
사람이 들어오거나 빠지면 applyDelta 가 전체 인원에 차이를 더해 0 에서 99 사이로 자르고, 두 자리 숫자가 COUNT_ROLL_MS 동안 굴러 새 값에 닿으며 늘 때와 줄 때 다른 색이 FLASH_MS 만큼 켜진다. 입구나 접수대 위에 걸어 두는 전체 대기 인원 표시판에 쓴다. 07 항목은 누적면적 12.8%·강도 평균 63.4·23프레임 중 14프레임에서 움직였다.

<main class="stage">
<div class="wtc is-demo" id="wtcRoot">
<div class="wtc__board" id="wtcBoard">
<div class="wtc__odometer" id="wtcOdo" aria-hidden="true"></div>
<span class="wtc__unit" aria-hidden="true">명 대기중</span>
</div>
<p class="wtc__status" id="wtcStatus" aria-live="polite"></p>
<div class="wtc__controls">
<button type="button" class="wtc__btn wtc__btn--primary" id="wtcJoin">입장</button>
<button type="button" class="wtc__btn" id="wtcLeave">퇴장</button>
<button type="button" class="wtc__btn" id="wtcReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 입장·퇴장 차이를 계산해 자릿수 릴을 그 폭만큼 굴리고, 늘었는지 줄었는지에 따라
// 보드 배경에 is-up/is-down 을 계산해 잠깐 켰다 끈다.
@include stage($stage-orange);
.wtc {
position: relative;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.wtc__board {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: rgba($stage-paper, .55);
transition: background $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
}
.wtc__board.is-up {
background: rgba($tick-up, .22);
box-shadow: 0 0 0 1px rgba($tick-up, .45);
}
.wtc__board.is-down {
background: rgba($tick-down, .18);
box-shadow: 0 0 0 1px rgba($tick-down, .45);
}
.wtc__odometer {
display: flex;
gap: $sp-1;
font-variant-numeric: tabular-nums;
}
.wtc__digit {
position: relative;
width: 40px;
height: 58px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-ink, .08);
}
.wtc__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wtc__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 58px;
font-style: normal;
font-size: 38px;
font-weight: 700;
color: $subject-orange-deep;
}
.wtc__board.is-up .wtc__reel i { color: $tick-up; }
.wtc__board.is-down .wtc__reel i { color: $tick-down; }
.wtc__unit {
font-size: 12px;
font-weight: 600;
color: rgba($stage-ink, .72);
}
.wtc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .78);
}
.wtc__controls {
display: flex;
gap: $sp-2;
}
.wtc__btn {
border: 1px solid rgba($stage-ink, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wtc__btn--primary {
background: $subject-orange-deep;
border-color: $subject-orange-deep;
color: $stage-paper;
}
.wtc__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wtc__digit { width: 30px; height: 44px; }
.wtc__reel i { height: 44px; font-size: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.wtc__board { transition: none; }
.wtc__reel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wtc {
position: relative;
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.wtc__board {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.55);
transition: background 220ms ease-out, box-shadow 220ms ease-out;
}
.wtc__board.is-up {
background: rgba(61, 220, 151, 0.22);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.45);
}
.wtc__board.is-down {
background: rgba(255, 122, 94, 0.18);
box-shadow: 0 0 0 1px rgba(255, 122, 94, 0.45);
}
.wtc__odometer {
display: flex;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wtc__digit {
position: relative;
width: 40px;
height: 58px;
overflow: hidden;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
}
.wtc__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wtc__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 58px;
font-style: normal;
font-size: 38px;
font-weight: 700;
color: #c93000;
}
.wtc__board.is-up .wtc__reel i {
color: #3ddc97;
}
.wtc__board.is-down .wtc__reel i {
color: #ff7a5e;
}
.wtc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.wtc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.wtc__controls {
display: flex;
gap: 8px;
}
.wtc__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wtc__btn--primary {
background: #c93000;
border-color: #c93000;
color: #ffffff;
}
.wtc__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wtc__digit {
width: 30px;
height: 44px;
}
.wtc__reel i {
height: 44px;
font-size: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.wtc__board {
transition: none;
}
.wtc__reel {
transition: none;
}
}(function () {
// 기법: 입장·퇴장이 있을 때마다 이전 값과 새 값의 차이를 계산해 그 차이만큼 자릿수 릴을
// 굴린다. 값이 늘면 wtc__board 에 is-up 을, 줄면 is-down 을 계산해 잠깐 켜 색으로 알린다.
var COUNT_ROLL_MS = 420;
var DIGIT_STAGGER_MS = 60;
var FLASH_MS = 520;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434071);
var root = document.getElementById('wtcRoot');
var board = document.getElementById('wtcBoard');
var odo = document.getElementById('wtcOdo');
var status = document.getElementById('wtcStatus');
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; }
function buildOdometer(container, digitCount) {
container.innerHTML = '';
var digits = [];
for (var i = 0; i < digitCount; i += 1) {
var d = document.createElement('div');
d.className = 'wtc__digit';
var reel = document.createElement('div');
reel.className = 'wtc__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 digits = buildOdometer(odo, 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(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 rollDigits(num, nowMs) {
if (reduce) { setDigitsImmediate(num); return; }
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 = COUNT_ROLL_MS;
}
}
function stepDigits(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;
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 TOTAL = 18;
var flashUntil = 0;
setDigitsImmediate(TOTAL);
function paintStatus(delta) {
var word = delta > 0 ? (delta + '명 늘어 ' + TOTAL + '명') : delta < 0 ? ((-delta) + '명 줄어 ' + TOTAL + '명') : (TOTAL + '명 대기중');
status.textContent = word + '.';
}
function applyDelta(delta, nowMs) {
if (delta === 0) { paintStatus(0); return; }
var next = Math.max(0, Math.min(99, TOTAL + delta));
var actualDelta = next - TOTAL;
TOTAL = next;
rollDigits(TOTAL, nowMs);
board.classList.remove('is-up', 'is-down');
if (actualDelta > 0) board.classList.add('is-up');
else if (actualDelta < 0) board.classList.add('is-down');
flashUntil = nowMs + FLASH_MS;
paintStatus(actualDelta);
}
function join(nowMs) { applyDelta(1 + Math.floor(rand() * 3), nowMs); }
function leave(nowMs) { applyDelta(-(1 + Math.floor(rand() * 2)), nowMs); }
function resetAll(nowMs) {
TOTAL = 18;
rollDigits(TOTAL, nowMs);
board.classList.remove('is-up', 'is-down');
paintStatus(0);
}
var joinBtn = document.getElementById('wtcJoin');
var leaveBtn = document.getElementById('wtcLeave');
var resetBtn = document.getElementById('wtcReset');
function onJoin() { root.classList.remove('is-demo'); join(perfNow()); }
function onLeave() { root.classList.remove('is-demo'); leave(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
joinBtn.addEventListener('click', onJoin);
joinBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onJoin(); } });
leaveBtn.addEventListener('click', onLeave);
leaveBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onLeave(); } });
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; }
var demoSchedule = [200, 1200];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
stepDigits(now);
if (flashUntil && now >= flashUntil) { board.classList.remove('is-up', 'is-down'); flashUntil = 0; }
if (root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (demoIndex % 2 === 0) join(now); else leave(now);
demoIndex += 1;
if (demoIndex >= demoSchedule.length) demoIndex = 0, demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; });
}
}
requestAnimationFrame(tick);
}
if (reduce) { paintStatus(0); } else { requestAnimationFrame(tick); }
})();// WaitingQueueTotalCountRollUpdown — vanilla 판(07-waiting-queue-total-count-roll-updown) 의 React 포트.
// 입장·퇴장이 있을 때마다 이전 값과 새 값의 차이를 계산해 자릿수 릴을 그 차이만큼 굴리고,
// 늘었는지 줄었는지를 계산해 보드에 is-up/is-down 을 잠깐 켰다 끈다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueTotalCountRollUpdown.module.scss";
type Box<T> = { current: T };
const COUNT_ROLL_MS = 420;
const DIGIT_STAGGER_MS = 60;
const FLASH_MS = 520;
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;
}
export default function WaitingQueueTotalCountRollUpdown() {
const randRef = useRef<() => number>(seedRand(434071)) as Box<() => number>;
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const [flashClass, setFlashClass] = useState("");
const digitsRef = 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 reelRefs = useRef<Array<HTMLDivElement | null>>([null, null]) as Box<Array<HTMLDivElement | null>>;
const totalRef = useRef<number>(18) as Box<number>;
const flashUntilRef = 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>;
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(num: number, nowMs: number): void {
const digits = digitsRef.current;
const s = digitsOf(num, digits.length);
if (reduce) {
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(reelRefs.current[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 = COUNT_ROLL_MS;
}
}
function stepDigits(nowMs: number): void {
const digits = digitsRef.current;
const refs = reelRefs.current;
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(delta: number): void {
const total = totalRef.current;
const word = delta > 0 ? (delta + "명 늘어 " + total + "명") : delta < 0 ? ((-delta) + "명 줄어 " + total + "명") : (total + "명 대기중");
setStatus(word + ".");
}
function applyDelta(delta: number, nowMs: number): void {
if (delta === 0) { paintStatus(0); return; }
const next = Math.max(0, Math.min(99, totalRef.current + delta));
const actualDelta = next - totalRef.current;
totalRef.current = next;
rollDigits(totalRef.current, nowMs);
setFlashClass(actualDelta > 0 ? styles.boardUp : actualDelta < 0 ? styles.boardDown : "");
flashUntilRef.current = nowMs + FLASH_MS;
paintStatus(actualDelta);
}
function join(nowMs: number): void { applyDelta(1 + Math.floor(randRef.current() * 3), nowMs); }
function leave(nowMs: number): void { applyDelta(-(1 + Math.floor(randRef.current() * 2)), nowMs); }
function resetAll(nowMs: number): void {
totalRef.current = 18;
rollDigits(totalRef.current, nowMs);
setFlashClass("");
paintStatus(0);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paintStatus(0);
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
if (!reduce) {
stepDigits(now);
if (flashUntilRef.current && now >= flashUntilRef.current) {
setFlashClass("");
flashUntilRef.current = 0;
}
const sched = demoScheduleRef.current;
if (demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (demoIndexRef.current % 2 === 0) join(now); else leave(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 onJoin(): void { stopDemo(); join(performance.now()); }
function onLeave(): void { stopDemo(); leave(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.wtc}>
<div className={[styles.board, flashClass].join(" ")}>
<div className={styles.odometer} aria-hidden="true">
{[0, 1].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { reelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<span className={styles.unit}>명 대기중</span>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onJoin} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onJoin)}>입장</button>
<button type="button" className={styles.btn} onClick={onLeave} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onLeave)}>퇴장</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}07 · 차이만큼 굴러가는 전체 대기 인원 카운터 — 측정 누적면적 12.766% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyDelta(delta, nowMs) {
if (delta === 0) { paintStatus(0); return; }
var next = Math.max(0, Math.min(99, TOTAL + delta));
var actualDelta = next - TOTAL;
TOTAL = next;
rollDigits(TOTAL, nowMs);
board.classList.remove('is-up', 'is-down');
if (actualDelta > 0) board.classList.add('is-up');
else if (actualDelta < 0) board.classList.add('is-down');
flashUntil = nowMs + FLASH_MS;
paintStatus(actualDelta);
}
08우선순위 차이를 계산해 끼어드는 우선 호출 줄
우선 접수가 들어오면 priorityInsertIndex 가 기존 줄의 가중치를 위에서부터 훑으며 새 가중치 10 이상인 줄이 이어지는 동안만 내려가 그 바로 뒤를 자리로 정하고, 그 자리에 우선 배지가 달린 줄이 나타나며 아래 줄들은 이동 전후 위치 차이만큼 스프링으로 밀려 내려간다. 우선 호출 대상 표시처럼 일반 순번 사이에 끼워 넣을 줄이 있는 대기 목록에 쓴다. 08 항목은 누적면적 27.4%·강도 평균 82.1·23프레임 중 10프레임에서 움직였다.

<main class="stage">
<div class="wpl is-demo" id="wplRoot">
<ul class="wpl__list" id="wplList" role="list"></ul>
<p class="wpl__status" id="wplStatus" aria-live="polite"></p>
<div class="wpl__controls">
<button type="button" class="wpl__btn wpl__btn--primary" id="wplInsert">우선 접수</button>
<button type="button" class="wpl__btn" id="wplReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 우선순위 차이로 끼어들 자리를 계산하고, 뒤 줄들은 FLIP 실측 거리만큼 스프링 감쇠로
// 내려가며, 새 배지 행은 확대+페이드로 끼어든다.
@include stage($stage-paper);
.wpl {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(92vw, 320px);
}
.wpl__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-2;
width: 100%;
}
.wpl__item {
display: grid;
grid-template-columns: 44px 56px 1fr;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-ink, .05);
box-sizing: border-box;
}
.wpl__item.is-priority {
background: rgba($subject-blue-deep, .12);
outline: 1.5px solid $subject-blue-deep;
}
.wpl__item.is-enter {
opacity: 0;
}
.wpl__badge {
font-size: 10px;
font-weight: 700;
color: $stage-paper;
background: $subject-blue-deep;
border-radius: $r-pill;
padding: $sp-1 0;
text-align: center;
}
.wpl__spacer {
width: 100%;
}
.wpl__ticket {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-ink;
}
.wpl__note {
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .6);
}
.wpl__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .7);
}
.wpl__controls {
display: flex;
gap: $sp-2;
}
.wpl__btn {
border: 1px solid rgba($stage-ink, .25);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wpl__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wpl__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpl__item { grid-template-columns: 38px 48px 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.wpl__item { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.wpl__list { gap: $sp-1; }
.wpl__item { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.wpl { gap: $sp-2; }
.wpl__item { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.wpl__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wpl__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}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);
}
.wpl {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(92vw, 320px);
}
.wpl__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
width: 100%;
}
.wpl__item {
display: grid;
grid-template-columns: 44px 56px 1fr;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
box-sizing: border-box;
}
.wpl__item.is-priority {
background: rgba(10, 79, 232, 0.12);
outline: 1.5px solid #0a4fe8;
}
.wpl__item.is-enter {
opacity: 0;
}
.wpl__badge {
font-size: 10px;
font-weight: 700;
color: #ffffff;
background: #0a4fe8;
border-radius: 999px;
padding: 4px 0;
text-align: center;
}
.wpl__spacer {
width: 100%;
}
.wpl__ticket {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.wpl__note {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
}
.wpl__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.7);
}
.wpl__controls {
display: flex;
gap: 8px;
}
.wpl__btn {
border: 1px solid rgba(23, 20, 26, 0.25);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wpl__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wpl__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpl__item {
grid-template-columns: 38px 48px 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.wpl__item {
transition: none;
}
}
.wpl__list {
gap: 4px;
}
.wpl__item {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.wpl {
gap: 8px;
}
.wpl__item {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.wpl__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wpl__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 새 우선 대상의 우선순위 값과 기존 각 줄의 우선순위 값을 비교해 끼어들 자리(insertIndex)를
// 계산한다. 그 자리 뒤에 있던 줄들이 화면에서 실제로 얼마나 내려가는지(FLIP)를 실측해
// PRIORITY_SHIFT_SPRING_STIFFNESS 로 감쇠시키며 되돌리고, 새 배지는 확대+페이드로 끼어든다.
var PRIORITY_SHIFT_SPRING_STIFFNESS = 260;
var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';
function shiftDuration(gapPx) {
return Math.round(60 + 100000 / PRIORITY_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
var ORDER = [
{ id: 'r1', ticket: '108번', note: '일반 접수', weight: 0 },
{ id: 'r2', ticket: '109번', note: '일반 접수', weight: 0 },
{ id: 'r3', ticket: '110번', note: '일반 접수', weight: 0 },
{ id: 'r4', ticket: '111번', note: '일반 접수', weight: 0 }
];
var priorityCount = 0;
var root = document.getElementById('wplRoot');
var listEl = document.getElementById('wplList');
var status = document.getElementById('wplStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function buildRow(row) {
var li = document.createElement('li');
li.className = 'wpl__item' + (row.weight > 0 ? ' is-priority' : '');
li.id = 'wplRow-' + row.id;
function cell(cls, text) {
var sp = document.createElement('span');
sp.className = cls;
if (text) sp.textContent = text;
li.appendChild(sp);
}
if (row.weight > 0) cell('wpl__badge', '우선'); else cell('wpl__spacer', '');
cell('wpl__ticket', row.ticket);
cell('wpl__note', row.note);
return li;
}
ORDER.forEach(function (row) { listEl.appendChild(buildRow(row)); });
function priorityInsertIndex(newWeight) {
var idx = 0;
for (var i = 0; i < ORDER.length; i += 1) {
if (ORDER[i].weight >= newWeight) idx = i + 1; else break;
}
return idx;
}
function paintStatus(row, idx) {
status.textContent = row.ticket + ' 우선 접수 · ' + (idx + 1) + '번째 자리로 끼어들었습니다.';
}
function insertPriority() {
priorityCount += 1;
var newRow = { id: 'p' + priorityCount, ticket: (301 + priorityCount - 1) + '번', note: '우선 호출', weight: 10 };
var idx = priorityInsertIndex(newRow.weight);
var below = Array.prototype.slice.call(listEl.children, idx);
var beforeTops = below.map(function (el) { return el.getBoundingClientRect().top; });
var li = buildRow(newRow);
li.classList.add('is-enter');
if (idx >= listEl.children.length) listEl.appendChild(li); else listEl.insertBefore(li, listEl.children[idx]);
ORDER.splice(idx, 0, newRow);
if (!reduce) {
requestAnimationFrame(function () {
var afterTops = below.map(function (el) { return el.getBoundingClientRect().top; });
below.forEach(function (el, i) {
var dy = beforeTops[i] - afterTops[i];
if (Math.abs(dy) < 0.5) return;
el.animate(
[{ transform: 'translateY(' + dy + 'px)' }, { transform: 'translateY(0px)' }],
{ duration: shiftDuration(Math.abs(dy)), easing: SPRING_SETTLE_EASING }
);
});
li.animate(
[{ opacity: 0, transform: 'scale(.85)' }, { opacity: 1, transform: 'scale(1)' }],
{ duration: 260, easing: 'ease-out' }
);
li.classList.remove('is-enter');
});
} else {
li.classList.remove('is-enter');
}
paintStatus(newRow, idx);
}
function resetAll() {
priorityCount = 0;
ORDER = [
{ id: 'r1', ticket: '108번', note: '일반 접수', weight: 0 },
{ id: 'r2', ticket: '109번', note: '일반 접수', weight: 0 },
{ id: 'r3', ticket: '110번', note: '일반 접수', weight: 0 },
{ id: 'r4', ticket: '111번', note: '일반 접수', weight: 0 }
];
listEl.innerHTML = '';
ORDER.forEach(function (row) { listEl.appendChild(buildRow(row)); });
status.textContent = '대기 목록을 초기화했습니다.';
}
var insertBtn = document.getElementById('wplInsert');
var resetBtn = document.getElementById('wplReset');
function onInsert() { root.classList.remove('is-demo'); insertPriority(); }
function onReset() { root.classList.remove('is-demo'); resetAll(); }
insertBtn.addEventListener('click', onInsert);
insertBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onInsert(); } });
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); } });
status.textContent = '우선 대상이 접수되면 계산된 자리로 끼어듭니다.';
var demoSchedule = [200, 1200];
var demoIndex = 0;
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (priorityCount >= 2) { resetAll(); } else { insertPriority(); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// WaitingQueuePriorityLaneBadgeShift — vanilla 판(08-waiting-queue-priority-lane-badge-shift) 의
// React 포트. 우선순위 차이로 끼어들 자리를 계산하고, 뒤 줄들은 FLIP 실측 거리만큼 스프링
// 감쇠로 내려가며, 새 배지 행은 확대+페이드로 끼어든다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueuePriorityLaneBadgeShift.module.scss";
type Box<T> = { current: T };
const PRIORITY_SHIFT_SPRING_STIFFNESS = 260;
const SPRING_SETTLE_EASING = "linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)";
type Row = { id: string; ticket: string; note: string; weight: number };
function initialOrder(): Row[] {
return [
{ id: "r1", ticket: "108번", note: "일반 접수", weight: 0 },
{ id: "r2", ticket: "109번", note: "일반 접수", weight: 0 },
{ id: "r3", ticket: "110번", note: "일반 접수", weight: 0 },
{ id: "r4", ticket: "111번", note: "일반 접수", weight: 0 }
];
}
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / PRIORITY_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
export default function WaitingQueuePriorityLaneBadgeShift() {
const [order, setOrder] = useState<Row[]>(initialOrder);
const [status, setStatus] = useState("우선 대상이 접수되면 계산된 자리로 끼어듭니다.");
const [reduce, setReduce] = useState(false);
const rowRefs = useRef<Record<string, HTMLLIElement | null>>({}) as Box<Record<string, HTMLLIElement | null>>;
const orderRef = useRef<Row[]>(order) as Box<Row[]>;
const priorityCountRef = 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>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function priorityInsertIndex(newWeight: number): number {
const rows = orderRef.current;
let idx = 0;
for (let i = 0; i < rows.length; i += 1) {
if (rows[i].weight >= newWeight) idx = i + 1; else break;
}
return idx;
}
function insertPriority(): void {
priorityCountRef.current += 1;
const newRow: Row = { id: "p" + priorityCountRef.current, ticket: (301 + priorityCountRef.current - 1) + "번", note: "우선 호출", weight: 10 };
const idx = priorityInsertIndex(newRow.weight);
const next = orderRef.current.slice();
next.splice(idx, 0, newRow);
orderRef.current = next;
setOrder(next);
if (!reduce) {
requestAnimationFrame(() => {
const list = document.getElementById("wplList");
if (!list) return;
const below = Array.prototype.slice.call(list.children, idx + 1);
below.forEach((el: Element, i: number) => {
const li = el as HTMLElement;
const li2 = rowRefs.current[next[idx + 1 + i] ? next[idx + 1 + i].id : ""];
void li;
if (!li2) return;
const gap = li2.offsetHeight + 8;
li2.animate(
[
{ transform: "translateY(" + -gap + "px)" },
{ transform: "translateY(0px)" }
],
{ duration: shiftDuration(gap), easing: SPRING_SETTLE_EASING }
);
});
const newEl = rowRefs.current[newRow.id];
if (newEl) {
newEl.animate(
[{ opacity: 0, transform: "scale(.85)" }, { opacity: 1, transform: "scale(1)" }],
{ duration: 260, easing: "ease-out" }
);
}
});
}
setStatus(newRow.ticket + " 우선 접수 · " + (idx + 1) + "번째 자리로 끼어들었습니다.");
}
function resetAll(): void {
priorityCountRef.current = 0;
orderRef.current = initialOrder();
setOrder(orderRef.current);
setStatus("대기 목록을 초기화했습니다.");
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (priorityCountRef.current >= 2) { resetAll(); } else { insertPriority(); }
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 onInsert(): void { stopDemo(); insertPriority(); }
function onReset(): void { stopDemo(); resetAll(); }
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.wpl}>
<ul className={styles.list} id="wplList" role="list">
{order.map((row) => (
<li
key={row.id}
className={[styles.item, row.weight > 0 ? styles.itemPriority : ""].join(" ")}
ref={(el: HTMLLIElement | null) => { rowRefs.current[row.id] = el; }}
>
{row.weight > 0 ? <span className={styles.badge}>우선</span> : <span className={styles.spacer} />}
<span className={styles.ticket}>{row.ticket}</span>
<span className={styles.note}>{row.note}</span>
</li>
))}
</ul>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onInsert} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onInsert)}>우선 접수</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}08 · 우선순위 차이를 계산해 끼어드는 우선 호출 줄 — 측정 누적면적 27.413% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function priorityInsertIndex(newWeight) {
var idx = 0;
for (var i = 0; i < ORDER.length; i += 1) {
if (ORDER[i].weight >= newWeight) idx = i + 1; else break;
}
return idx;
}
function paintStatus(row, idx) {
status.textContent = row.ticket + ' 우선 접수 · ' + (idx + 1) + '번째 자리로 끼어들었습니다.';
}
09여러 계산을 한 화면에 모은 대기 순번판 히어로 장면
호출 버튼 하나가 01 의 번호 굴림과 04 의 줄 접힘·스프링과 07 의 인원 카운터를 한 번의 호출 함수 안에서 함께 출발시키고, 접수 버튼은 새 줄을 맨 아래에 붙이면서 인원 숫자만 굴린다. 매장 입구 대기 순번 화면처럼 번호와 목록과 인원이 한꺼번에 움직여야 하는 완성 장면에 쓴다. 09 항목은 누적면적 26.1%·강도 평균 59.5·23프레임 중 18프레임에서 움직였다.

<main class="stage">
<div class="whb is-demo" id="whbRoot">
<div class="whb__head">
<div class="whb__odometer" id="whbCallOdo" aria-hidden="true"></div>
<div class="whb__countWrap" aria-hidden="true">
<div class="whb__odometer whb__odometer--small" id="whbTotalOdo"></div>
<span class="whb__countUnit">명 대기중</span>
</div>
</div>
<ul class="whb__list" id="whbList" role="list"></ul>
<p class="whb__status" id="whbStatus" aria-live="polite"></p>
<div class="whb__controls">
<button type="button" class="whb__btn whb__btn--primary" id="whbCall">다음 호출</button>
<button type="button" class="whb__btn" id="whbJoin">입장</button>
<button type="button" class="whb__btn" id="whbReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심(합성): 호출 번호 릴(01) · 목록 FLIP 스프링(04) · 인원 카운터 릴+플래시(07) 가
// 하나의 호출 계산으로 동시에 움직이는 히어로 장면.
@include stage($stage-ink);
.whb {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(94vw, 340px);
}
.whb__head {
display: flex;
align-items: center;
gap: $sp-4;
}
.whb__odometer {
display: flex;
gap: $sp-1;
font-variant-numeric: tabular-nums;
}
.whb__digit {
position: relative;
width: 34px;
height: 50px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-paper, .08);
}
.whb__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.whb__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
font-style: normal;
font-size: 32px;
font-weight: 700;
color: $subject-blue-light;
}
.whb__odometer--small .whb__digit {
width: 22px;
height: 32px;
}
.whb__odometer--small .whb__reel i {
height: 32px;
font-size: 20px;
color: $stage-paper;
}
.whb__countWrap {
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
transition: background $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
}
.whb__countWrap.is-up {
background: rgba($tick-up, .2);
box-shadow: 0 0 0 1px rgba($tick-up, .4);
}
.whb__countWrap.is-down {
background: rgba($tick-down, .18);
box-shadow: 0 0 0 1px rgba($tick-down, .4);
}
.whb__countUnit {
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .72);
}
.whb__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-2;
width: 100%;
}
.whb__item {
display: grid;
grid-template-columns: 64px 1fr;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .07);
box-sizing: border-box;
}
.whb__item.is-next {
background: rgba($subject-blue-deep, .18);
outline: 1.5px solid $subject-blue-deep;
transform: scale(1.02);
}
.whb__ticket {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-paper;
}
.whb__note {
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .6);
}
.whb__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .7);
}
.whb__controls {
display: flex;
gap: $sp-2;
}
.whb__btn {
border: 1px solid rgba($stage-paper, .3);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.whb__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.whb__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.whb__digit { width: 26px; height: 40px; }
.whb__reel i { height: 40px; font-size: 26px; }
.whb__odometer--small .whb__digit { width: 18px; height: 26px; }
.whb__odometer--small .whb__reel i { height: 26px; font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.whb__reel { transition: none; }
.whb__countWrap { transition: none; }
.whb__item { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.whb__list { gap: $sp-1; }
.whb__item { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.whb { gap: $sp-2; }
.whb__item { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.whb__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.whb__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}
@media (max-height: 240px) {
.whb__item { min-height: 20px; }
.whb__digit { height: 32px; }
.whb__reel i { height: 32px; font-size: 22px; }
}
// 접수가 늘어 줄이 한 줄 더 생겨도 보드가 커지지 않게 — 보이는 줄 수를 고정한다.
.whb__list { max-height: 112px; overflow: hidden; }
@media (max-height: 240px) {
.whb__list { max-height: 96px; }
}
// 언어마다 글자 길이가 달라도 버튼·상태 줄이 줄바꿈으로 키를 늘리지 않게 한다.
.whb__btn { white-space: nowrap; padding-left: $sp-2; padding-right: $sp-2; }
.whb__status { white-space: nowrap; }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);
}
.whb {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(94vw, 340px);
}
.whb__head {
display: flex;
align-items: center;
gap: 16px;
}
.whb__odometer {
display: flex;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.whb__digit {
position: relative;
width: 34px;
height: 50px;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.whb__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.whb__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
font-style: normal;
font-size: 32px;
font-weight: 700;
color: #5b8cf8;
}
.whb__odometer--small .whb__digit {
width: 22px;
height: 32px;
}
.whb__odometer--small .whb__reel i {
height: 32px;
font-size: 20px;
color: #ffffff;
}
.whb__countWrap {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 8px;
transition: background 220ms ease-out, box-shadow 220ms ease-out;
}
.whb__countWrap.is-up {
background: rgba(61, 220, 151, 0.2);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.4);
}
.whb__countWrap.is-down {
background: rgba(255, 122, 94, 0.18);
box-shadow: 0 0 0 1px rgba(255, 122, 94, 0.4);
}
.whb__countUnit {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.72);
}
.whb__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
width: 100%;
}
.whb__item {
display: grid;
grid-template-columns: 64px 1fr;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.07);
box-sizing: border-box;
}
.whb__item.is-next {
background: rgba(10, 79, 232, 0.18);
outline: 1.5px solid #0a4fe8;
transform: scale(1.02);
}
.whb__ticket {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.whb__note {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
}
.whb__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.whb__controls {
display: flex;
gap: 8px;
}
.whb__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.whb__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.whb__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.whb__digit {
width: 26px;
height: 40px;
}
.whb__reel i {
height: 40px;
font-size: 26px;
}
.whb__odometer--small .whb__digit {
width: 18px;
height: 26px;
}
.whb__odometer--small .whb__reel i {
height: 26px;
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.whb__reel {
transition: none;
}
.whb__countWrap {
transition: none;
}
.whb__item {
transition: none;
}
}
.whb__list {
gap: 4px;
}
.whb__item {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.whb {
gap: 8px;
}
.whb__item {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.whb__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.whb__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}
@media (max-height: 240px) {
.whb__item {
min-height: 20px;
}
.whb__digit {
height: 32px;
}
.whb__reel i {
height: 32px;
font-size: 22px;
}
}
.whb__list {
max-height: 112px;
overflow: hidden;
}
@media (max-height: 240px) {
.whb__list {
max-height: 96px;
}
}
.whb__btn {
white-space: nowrap;
padding-left: 8px;
padding-right: 8px;
}
.whb__status {
white-space: nowrap;
}(function () {
// 기법(합성): HERO_CALL_ROLL_MS 호출 번호 굴림(01) + HERO_LIST_SPRING_STIFFNESS 목록 자리 이동(04)
// + HERO_COUNT_ROLL_MS 전체 인원 카운터(07) 를 한 화면에서 같은 호출 하나로 동시에 계산한다.
// 호출되면: 맨 앞 행이 접히며 사라지고(04), 뒤 행들이 스프링 감쇠로 올라오고(04), 호출 번호가
// 굴러가고(01), 대기 인원이 그 차이만큼 굴러 내려간다(07). 입장 버튼은 인원만 올린다(07).
var HERO_CALL_ROLL_MS = 480;
var HERO_DIGIT_STAGGER_MS = 70;
var HERO_COUNT_ROLL_MS = 420;
var HERO_LIST_SPRING_STIFFNESS = 260;
var HERO_FLASH_MS = 520;
var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434091);
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function shiftDuration(gapPx) {
return Math.round(60 + 100000 / HERO_LIST_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
var HERO_CALL_BASE = 501;
var HERO_TOTAL_START = 18;
var HERO_ROWS_START = [
{ id: 'h1', ticket: '501번', note: '대기 2분' },
{ id: 'h2', ticket: '502번', note: '대기 6분' },
{ id: 'h3', ticket: '503번', note: '대기 11분' },
{ id: 'h4', ticket: '504번', note: '대기 15분' },
{ id: 'h5', ticket: '505번', note: '대기 19분' }
];
var root = document.getElementById('whbRoot');
var callOdo = document.getElementById('whbCallOdo');
var totalOdo = document.getElementById('whbTotalOdo');
var listEl = document.getElementById('whbList');
var status = document.getElementById('whbStatus');
var countWrap = document.querySelector('.whb__countWrap');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var callNum = HERO_CALL_BASE;
var frontIndex = 0;
var total = HERO_TOTAL_START;
var nextTicket = 506;
// ---- (01/07 공용) 자릿수 릴 ----
function buildOdometer(container, digitCount) {
container.innerHTML = '';
var digits = [];
for (var i = 0; i < digitCount; i += 1) {
var d = document.createElement('div');
d.className = 'whb__digit';
var reel = document.createElement('div');
reel.className = 'whb__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 totalDigits = buildOdometer(totalOdo, 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 rollDigits(digits, num, nowMs, rollMs) {
if (reduce) { setDigitsImmediate(digits, num); return; }
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 * HERO_DIGIT_STAGGER_MS;
digits[i].dur = rollMs;
}
}
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;
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);
}
}
// ---- (07) 인원 플래시 ----
var flashUntil = 0;
function flashTotal(dir, nowMs) {
countWrap.classList.remove('is-up', 'is-down');
if (dir > 0) countWrap.classList.add('is-up');
else if (dir < 0) countWrap.classList.add('is-down');
flashUntil = nowMs + HERO_FLASH_MS;
}
// ---- (04) 목록 ----
var rows = {};
function fillRow(li, ticket, note) {
var a = document.createElement('span');
a.className = 'whb__ticket';
a.textContent = ticket;
var n = document.createElement('span');
n.className = 'whb__note';
n.textContent = note;
li.appendChild(a);
li.appendChild(n);
}
HERO_ROWS_START.forEach(function (r, i) {
var li = document.createElement('li');
li.className = 'whb__item' + (i === 0 ? ' is-next' : '');
li.id = 'whbRow-' + r.id;
fillRow(li, r.ticket, r.note);
listEl.appendChild(li);
rows[r.id] = li;
});
function paintNextClass() {
var ids = Object.keys(rows);
ids.forEach(function (id, i) {
rows[id].classList.toggle('is-next', i === frontIndex);
});
}
function callFront(nowMs) {
var ids = Object.keys(rows);
if (frontIndex >= ids.length) return;
var id = ids[frontIndex];
var el = rows[id];
var gap = el.offsetHeight + 8;
if (!reduce) {
var h = el.offsetHeight;
el.style.height = h + 'px';
el.style.overflow = 'hidden';
el.style.boxSizing = 'border-box';
el.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 220, easing: 'ease-in', fill: 'forwards' }
);
var below = [];
for (var i = frontIndex + 1; i < ids.length; i += 1) below.push(rows[ids[i]]);
var dur = shiftDuration(gap);
below.forEach(function (rEl) {
rEl.animate(
[{ transform: 'translateY(' + gap + 'px)' }, { transform: 'translateY(0px)' }],
{ duration: dur, delay: 0, easing: SPRING_SETTLE_EASING, fill: 'both' }
);
});
}
el.style.display = 'none';
frontIndex += 1;
// (01) 호출 번호 굴림 + (07) 인원 차이 계산
var servedTicket = HERO_ROWS_START.concat(pendingRows)[0];
void servedTicket;
callNum = HERO_CALL_BASE + (HERO_ROWS_START.length - listVisibleCount());
rollDigits(callDigits, callNum, nowMs, HERO_CALL_ROLL_MS);
var nextTotal = Math.max(0, total - 1);
var dir = nextTotal - total;
total = nextTotal;
rollDigits(totalDigits, total, nowMs, HERO_COUNT_ROLL_MS);
flashTotal(dir, nowMs);
paintNextClass();
paintStatus();
}
var pendingRows = [];
function listVisibleCount() {
var ids = Object.keys(rows);
var n = 0;
ids.forEach(function (id) { if (rows[id].style.display !== 'none') n += 1; });
return n;
}
function paintStatus() {
status.textContent = '호출 ' + callNum + '번 · 대기 ' + total + '명';
}
function joinQueue(nowMs) {
var ticket = nextTicket;
nextTicket += 1;
var id = 'j' + ticket;
var li = document.createElement('li');
li.className = 'whb__item';
li.id = 'whbRow-' + id;
fillRow(li, ticket + '번', '대기 접수');
listEl.appendChild(li);
rows[id] = li;
if (!reduce) {
li.animate(
[{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'translateY(0px)' }],
{ duration: 240, easing: 'ease-out' }
);
}
var nextTotal = Math.min(99, total + 1);
var dir = nextTotal - total;
total = nextTotal;
rollDigits(totalDigits, total, nowMs, HERO_COUNT_ROLL_MS);
flashTotal(dir, nowMs);
paintStatus();
}
function resetAll(nowMs) {
frontIndex = 0;
total = HERO_TOTAL_START;
callNum = HERO_CALL_BASE;
nextTicket = 506;
pendingRows = [];
Object.keys(rows).forEach(function (id) {
var el = rows[id];
el.getAnimations().forEach(function (a) { a.cancel(); });
el.remove();
delete rows[id];
});
HERO_ROWS_START.forEach(function (r, i) {
var li = document.createElement('li');
li.className = 'whb__item' + (i === 0 ? ' is-next' : '');
li.id = 'whbRow-' + r.id;
fillRow(li, r.ticket, r.note);
listEl.appendChild(li);
rows[r.id] = li;
});
rollDigits(callDigits, callNum, nowMs, HERO_CALL_ROLL_MS);
rollDigits(totalDigits, total, nowMs, HERO_COUNT_ROLL_MS);
countWrap.classList.remove('is-up', 'is-down');
paintStatus();
}
setDigitsImmediate(callDigits, HERO_CALL_BASE);
setDigitsImmediate(totalDigits, HERO_TOTAL_START);
paintStatus();
var callBtn = document.getElementById('whbCall');
var joinBtn = document.getElementById('whbJoin');
var resetBtn = document.getElementById('whbReset');
function onCall() { root.classList.remove('is-demo'); callFront(perfNow()); }
function onJoin() { root.classList.remove('is-demo'); joinQueue(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
callBtn.addEventListener('click', onCall);
callBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCall(); } });
joinBtn.addEventListener('click', onJoin);
joinBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onJoin(); } });
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; }
var demoSchedule = [200, 900, 1500];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
stepDigits(callDigits, now);
stepDigits(totalDigits, now);
if (flashUntil && now >= flashUntil) { countWrap.classList.remove('is-up', 'is-down'); flashUntil = 0; }
if (root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (demoIndex % 2 === 0) callFront(now); else joinQueue(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); }
})();// WaitingQueueHeroBoardScene — vanilla 판(09-waiting-queue-hero-board-scene) 의 React 포트.
// HERO_CALL_ROLL_MS 호출 번호 굴림(01) + HERO_LIST_SPRING_STIFFNESS 목록 자리 이동(04) +
// HERO_COUNT_ROLL_MS 전체 인원 카운터(07) 를 한 화면에서 같은 호출 하나로 동시에 계산한다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueHeroBoardScene.module.scss";
type Box<T> = { current: T };
const HERO_CALL_ROLL_MS = 480;
const HERO_DIGIT_STAGGER_MS = 70;
const HERO_COUNT_ROLL_MS = 420;
const HERO_LIST_SPRING_STIFFNESS = 260;
const HERO_FLASH_MS = 520;
const SPRING_SETTLE_EASING = "linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)";
const HERO_CALL_BASE = 501;
const HERO_TOTAL_START = 18;
type Row = { id: string; ticket: string; note: string };
function heroRowsStart(): Row[] {
return [
{ id: "h1", ticket: "501번", note: "대기 2분" },
{ id: "h2", ticket: "502번", note: "대기 6분" },
{ id: "h3", ticket: "503번", note: "대기 11분" },
{ id: "h4", ticket: "504번", note: "대기 15분" },
{ id: "h5", ticket: "505번", note: "대기 19분" }
];
}
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;
}
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / HERO_LIST_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
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;
}
export default function WaitingQueueHeroBoardScene() {
const randRef = useRef<() => number>(seedRand(434091)) as Box<() => number>;
const [rows, setRows] = useState<Row[]>(heroRowsStart);
const [frontIndex, setFrontIndex] = useState(0);
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const [flashClass, setFlashClass] = useState("");
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 totalDigitsRef = 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 totalReelRefs = useRef<Array<HTMLDivElement | null>>([null, null]) as Box<Array<HTMLDivElement | null>>;
const callNumRef = useRef<number>(HERO_CALL_BASE) as Box<number>;
const totalRef = useRef<number>(HERO_TOTAL_START) as Box<number>;
const nextTicketRef = useRef<number>(506) as Box<number>;
const flashUntilRef = 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, 900, 1500]) as Box<number[]>;
const frameRef = useRef<number>(0) as Box<number>;
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, rollMs: number): void {
const s = digitsOf(num, digits.length);
if (reduce) {
const refs = digits === callDigitsRef.current ? callReelRefs.current : totalReelRefs.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 * HERO_DIGIT_STAGGER_MS;
digits[i].dur = rollMs;
}
}
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 flashTotal(dir: number, nowMs: number): void {
setFlashClass(dir > 0 ? styles.countUp : dir < 0 ? styles.countDown : "");
flashUntilRef.current = nowMs + HERO_FLASH_MS;
}
function paintStatus(): void {
setStatus("호출 " + callNumRef.current + "번 · 대기 " + totalRef.current + "명");
}
function callFront(nowMs: number): void {
const el = document.getElementById("whbRow-" + rows[frontIndex]);
const gap = el ? el.offsetHeight + 8 : 40;
if (frontIndex >= rows.length) return;
if (!reduce && el) {
const h = el.offsetHeight;
el.style.height = h + "px";
el.style.overflow = "hidden";
el.style.boxSizing = "border-box";
el.animate(
[{ height: h + "px", opacity: 1 }, { height: "0px", opacity: 0 }],
{ duration: 220, easing: "ease-in", fill: "forwards" }
);
const list = document.getElementById("whbList");
if (list) {
const below = Array.prototype.slice.call(list.children, frontIndex + 1);
const dur = shiftDuration(gap);
below.forEach((b: Element) => {
(b as HTMLElement).animate(
[{ transform: "translateY(" + gap + "px)" }, { transform: "translateY(0px)" }],
{ duration: dur, easing: SPRING_SETTLE_EASING, fill: "both" }
);
});
}
}
const nextRows = rows.slice();
nextRows.splice(frontIndex, 1);
setRows(nextRows);
callNumRef.current = HERO_CALL_BASE + (HERO_ROWS_START_LEN - nextRows.length);
rollDigits(callDigitsRef.current, callNumRef.current, nowMs, HERO_CALL_ROLL_MS);
const nextTotal = Math.max(0, totalRef.current - 1);
const dir = nextTotal - totalRef.current;
totalRef.current = nextTotal;
rollDigits(totalDigitsRef.current, totalRef.current, nowMs, HERO_COUNT_ROLL_MS);
flashTotal(dir, nowMs);
setFrontIndex(frontIndex);
paintStatus();
}
const HERO_ROWS_START_LEN = 5;
function joinQueue(nowMs: number): void {
const ticket = nextTicketRef.current;
nextTicketRef.current += 1;
const id = "j" + ticket;
setRows((prev) => prev.concat({ id, ticket: ticket + "번", note: "대기 접수" }));
if (!reduce) {
requestAnimationFrame(() => {
const el = document.getElementById("whbRow-" + id);
if (el) {
el.animate(
[{ opacity: 0, transform: "translateY(6px)" }, { opacity: 1, transform: "translateY(0px)" }],
{ duration: 240, easing: "ease-out" }
);
}
});
}
const nextTotal = Math.min(99, totalRef.current + 1);
const dir = nextTotal - totalRef.current;
totalRef.current = nextTotal;
rollDigits(totalDigitsRef.current, totalRef.current, nowMs, HERO_COUNT_ROLL_MS);
flashTotal(dir, nowMs);
void randRef;
paintStatus();
}
function resetAll(nowMs: number): void {
setRows(heroRowsStart());
setFrontIndex(0);
totalRef.current = HERO_TOTAL_START;
callNumRef.current = HERO_CALL_BASE;
nextTicketRef.current = 506;
rollDigits(callDigitsRef.current, callNumRef.current, nowMs, HERO_CALL_ROLL_MS);
rollDigits(totalDigitsRef.current, totalRef.current, nowMs, HERO_COUNT_ROLL_MS);
setFlashClass("");
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(totalDigitsRef.current, totalReelRefs.current, now);
if (flashUntilRef.current && now >= flashUntilRef.current) {
setFlashClass("");
flashUntilRef.current = 0;
}
const sched = demoScheduleRef.current;
if (demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (demoIndexRef.current % 2 === 0) callFront(now); else joinQueue(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, rows, frontIndex]);
function onCall(): void { stopDemo(); callFront(performance.now()); }
function onJoin(): void { stopDemo(); joinQueue(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.whb}>
<div className={styles.head}>
<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.countWrap, flashClass].join(" ")} aria-hidden="true">
<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) => { totalReelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<span className={styles.countUnit}>명 대기중</span>
</div>
</div>
<ul className={styles.list} id="whbList" role="list">
{rows.map((r, i) => (
<li key={r.id} id={"whbRow-" + r.id} className={[styles.item, i === frontIndex ? styles.itemNext : ""].join(" ")}>
<span className={styles.ticket}>{r.ticket}</span>
<span className={styles.note}>{r.note}</span>
</li>
))}
</ul>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={rows.length === 0} onClick={onCall} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onCall)}>다음 호출</button>
<button type="button" className={styles.btn} onClick={onJoin} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onJoin)}>입장</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}09 · 여러 계산을 한 화면에 모은 대기 순번판 히어로 장면 — 측정 누적면적 26.094% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function joinQueue(nowMs) {
var ticket = nextTicket;
nextTicket += 1;
var id = 'j' + ticket;
var li = document.createElement('li');
li.className = 'whb__item';
li.id = 'whbRow-' + id;
fillRow(li, ticket + '번', '대기 접수');
listEl.appendChild(li);
rows[id] = li;
if (!reduce) {
li.animate(
[{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'translateY(0px)' }],
{ duration: 240, easing: 'ease-out' }
);
}
var nextTotal = Math.min(99, total + 1);
var dir = nextTotal - total;
total = nextTotal;
rollDigits(totalDigits, total, nowMs, HERO_COUNT_ROLL_MS);
flashTotal(dir, nowMs);
paintStatus();
}
값이 반응을 바꾸는 곳 — 자릿수 굴림 속도·재호출 대기 시간·목록 이동 탄성
번호판이 얼마나 부산하게 느껴지는지는 항목 개수가 아니라 이 표의 세 수치와 세 상수가 정한다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 호출 번호 굴림 | 16.7% | 80.6 | 10/23 |
| 02 예상 대기 시간 | 12.8% | 70.4 | 7/23 |
| 03 완료 비율 링 | 15.8% | 58.1 | 11/23 |
| 04 목록 스프링 | 32.8% | 82.8 | 14/23 |
| 05 재호출 카운트다운 | 6.2% | 125.1 | 23/23 |
| 06 다중 창구 | 2.7% | 97.2 | 7/23 |
| 07 전체 인원 카운터 | 12.8% | 63.4 | 14/23 |
| 08 우선 호출 줄 | 27.4% | 82.1 | 10/23 |
| 09 히어로 순번판 | 26.1% | 59.5 | 18/23 |
05 와 06 이 10% 아래인 것은 컴포넌트 편에서 정상이다 — 05 는 가는 막대 하나가 줄어들 뿐이라 면적은 작아도 게이지가 매 프레임 줄어 23/23 으로 계속 움직이고, 06 은 막대 셋이 짧게 늘었다 멈추는 정도라 프레임 수도 적다. 01·02·03 은 값이 바뀔 때 숫자판이 잠깐 밝아지는 is-flash 층이 얹혀 있는데, 이 층은 계산이 아니라 표현이고 그만큼 측정 면적을 보강한다. CALL_NUMBER_ROLL_MS 는 번호 한 자리가 굴러 도착하는 시간을, RECALL_COUNTDOWN_SEC 는 호출 뒤 재호출까지 기다리는 초를, LIST_SHIFT_SPRING_STIFFNESS 는 목록이 자리를 잡는 시간을 정하는데, 04 의 이동 시간은 60 + 100000 / 탄성 + 간격 항 으로 계산되어 탄성이 클수록 짧아진다. 05 는 데모 재생에서만 60초를 40배로 압축해 짧은 캡처 안에 만료까지 보여 주고, 사용자가 버튼을 누르면 그 압축이 꺼져 실제 초 단위로 돌아간다.
어디서 깨지는가 — 인원 급증 시 자릿수 겹침·재호출 타이머 밀림·우선 호출 끼어들기 충돌
01 의 호출 번호판은 세 자리(buildOdometer(callOdo, 3))로 만들어져 있고 rollDigits 는 번호를 문자열로 바꿔 앞에서 세 글자만 읽기 때문에, 번호가 네 자리로 넘어가면 1000 의 앞 세 글자인 100 이 판에 뜬다. 07·09 의 두 자리 판은 99 에서 값을 막지만 01 의 호출 번호에는 그런 상한이 없으니, 인원이 갑자기 불어 자릿수가 늘 수 있는 판이라면 자릿수를 값에서 계산해 만들어야 한다. 번호를 연달아 호출하면 아직 도는 자릿수는 from 을 지금 보이는 값으로 갈아 끼워 거기서 이어 굴러가므로 앞 굴림과 겹쳐도 값이 튀지는 않는다.
05 는 호출 시각과 매 프레임의 경과 시간 차로 남은 시간을 매번 새로 계산하므로 프레임이 몇 번 빠져도 시간이 밀리지 않지만, 브라우저가 탭을 쉬게 하는 동안 requestAnimationFrame 이 멈추면 돌아온 첫 프레임에서 경과 시간이 한꺼번에 커져 게이지가 단숨에 만료 상태로 넘어간다 — 코드 구조에서 나오는 동작이다. 08 은 줄을 넣을 때마다 아래 줄에 새 animate 를 얹는데 앞선 애니메이션을 취소하는 cancel() 호출이 0건이고, 줄 수의 상한도 없어서 우선 접수를 연달아 누르면 앞선 이동의 중간 위치가 다음 계산에 섞이며 목록이 계속 길어진다. 종이 순번표 양식처럼 번호와 비고를 한 줄에 놓는 이 구성에 실제 현장 규모의 접수를 붙일 때는 줄 수 상한과 이동 중 취소를 같이 넣는 편이 안전하다. 이 세 가지 한계까지 포함한 아홉 개 소스를 그대로 열어 보려면 내려받은 압축 파일을 풀 때 비밀번호 auwdqs3v 을 입력한다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 04 | LIST_SHIFT_SPRING_STIFFNESS | 260 → 140 | 값을 늘리면 앞 순번이 빠질 때 목록이 빠르고 뻣뻣하게 자리를 잡고, 값을 줄이면 출렁이듯 천천히 흔들리며 자리를 잡는다 |
| 01 | CALL_NUMBER_ROLL_MS | 480 → 240 | 값을 줄이면 새 번호가 호출될 때 자릿수가 거의 순간적으로 바뀌고, 값을 늘리면 슬롯머신처럼 천천히 굴러가며 바뀐다 |
| 05 | RECALL_COUNTDOWN_SEC | 60 → 30 | 값을 늘리면 응답이 없어도 재호출까지 여유 있게 기다리고, 값을 줄이면 짧은 시간 안에 게이지가 다 줄어 재호출 경고가 뜬다 |
04 의 이동 시간은 간격 항을 뺀 기본 값이 탄성 260 에서 약 445ms, 140 에서 약 774ms 로 늘어난다. prefers-reduced-motion: reduce 분기는 matchMedia 로 아홉 항목 전부에 있어서, 동작 줄임 설정을 켠 방문자에게는 자동 재생이 시작되지 않고 01·07·09 는 requestAnimationFrame 루프 자체를 돌리지 않고 05 는 루프만 돌 뿐 게이지를 계산하지 않아 가득 찬 채로 있으며, 버튼을 누르면 애니메이션 없이 결과가 바로 반영되고 aria-live="polite" 상태 줄도 함께 알린다. 키보드 경로는 아홉 항목 전부에 있다 — keydown 리스너가 합쳐서 20개이고 버튼이 셋인 07·09 가 3개씩, 나머지는 2개씩이며 Enter 와 Space 로 조작한다. 상태 줄(aria-live)은 항목마다 하나씩 아홉 개이고, 목록 마크업이 있는 04·06·08·09 네 곳에는 role="list" 가 붙어 있다.
웨이팅 화면은 대기표 시스템처럼 평균 처리 시간만으로 순서를 예측할 수 있는가?
시간은 가능하고 순서는 따로 정해 두어야 한다 — 02 는 최근 처리 기록의 평균에 남은 인원을 곱하는 계산 하나로 예상 대기 분을 만들지만, 누가 먼저 불리는지는 이 평균과 상관없이 이미 들어온 번호 순이거나 08 처럼 가중치가 정한다. 평균은 최근 12건만 남기고 오래된 것부터 버리므로 갑자기 느려진 창구가 있으면 몇 건 처리하는 사이 예상 시간이 따라 올라온다. 번호와 단계를 한 줄로 진행시키는 화면이라면 진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기 와 비교해 보면 좋고, 05 처럼 숫자가 줄다가 한계에서 색이 바뀌는 게이지가 필요하면 게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식, 시간 제한 카운트다운이 더 궁금하다면 qr 코드 만료 카운트다운 css 애니메이션 9가지 의 계산을 이어서 읽을 만하다.
웨이팅 화면을 현장 화면에 올리기 전에 자주 묻는 질문
대기표 발급기 화면에도 이 순번판을 그대로 붙일 수 있나요?
표시 쪽은 붙일 수 있다 — 아홉 항목 모두 번호와 인원을 받아 그리기만 하고 발급이나 인쇄는 하지 않으니, 발급기 쪽에서 번호를 넘겨 주는 자리에 01 의 rollDigits 나 07 의 applyDelta 호출을 걸면 된다.
예상 대기 시간을 평균 말고 다른 값으로 계산해도 되나요?
된다 — 02 의 targetEta 는 평균에 남은 인원을 곱하는 한 줄이라, 그 줄만 다른 식으로 바꾸어도 숫자가 굴러가는 부분은 그대로 재사용된다.
재호출까지 60초가 너무 길면 어디를 고치나요?
RECALL_COUNTDOWN_SEC 값을 줄이면 된다 — 변형 표의 05 처럼 30 으로 바꾸면 게이지 폭 계산과 남은 초 문구가 모두 같은 상수에서 나오므로 다른 곳을 건드릴 필요가 없다.









