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 オリジナル · SCSS と React は zip に同梱 · 呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板
このデモが属する記事
呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板呼出表示器は、呼び出し番号と残り人数、予想待ち時間を数字で見せる待ち順の案内画面です。平均処理時間が変わると残り人数と掛け合わされた値も一緒に変わって予想待ち時間が数値として更新され、リストは空いた分だけ上に詰まって並び替わり、合計人数はそ この記事のデモをすべて見る


