09여러 계산을 한 화면에 모은 대기 순번판 히어로 장면
01 호출 번호 굴림, 04 목록 자리 이동, 07 전체 인원 카운터를 한 화면 위에 동시에 배치해 번호가 호출되고 목록이 당겨지고 인원 수가 바뀌는 완성 장면을 만든다.
쓰임매장 입구 대기 순번 표시 화면
HERO_LIST_SPRING_STIFFNESSHERO_CALL_ROLL_MSflashTotal

<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 포함 · 웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
이 데모가 속한 글
웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판웨이팅 화면은 호출 번호와 남은 인원, 예상 대기 시간을 숫자로 보여 주는 대기 안내 화면이다. 평균 처리 시간이 바뀌면 남은 대기 인원과 곱해진 값도 같이 바뀌어 예상 대기 시간이 숫자로 다시 뜨고, 목록은 빠진 이 글의 데모 전부 보기


