呼出表示器 実装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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 残り人数を再計算して桁が繰り上がる呼び出し番号
- 02 平均処理時間と人数を掛けて再計算する予想待ち時間
- 03 完了比率を計算して満ちる待ち行列の進捗リング
- 04 移動距離を計算してスプリングで収まる待ちリスト
- 05 再呼び出しまでの残り時間をゲージで減らすノーショーカウントダウン
- 06 窓口ごとの処理速度を計算して分散表示する複数カウンター状況
- 07 差ぶん繰り上がる合計待ち人数カウンター
- 08 優先順位の差を計算して割り込む優先呼び出しレーン
- 09 複数の計算を一画面に集めた順番板ヒーロー場面
呼出表示器 実装9パターン — 待ち順と予想時間を計算で動かす順番板
九つは、画面を見る人が投げる質問の順に並べました。01 は今何番か、02 は何分待つか、03 は全体がどこまで進んだかを数字とリングで答え、04 は自分の前の番号が抜けたときリストがどう詰まるか、05 は呼んでも来ないときいつ呼び直すかを見せます。06 は窓口が複数あるときどこが速いか、07 は全体の人数が増減する幅、08 は優先対象が割り込む位置を計算し、09 はそのうち三つの計算を一画面に重ねます。順番表示を初めて作るなら、01・02・07 の三つを先に貼るだけでも、番号と時間と人数が一画面で噛み合って動きます。
パターン別コード
九項目それぞれ、値を計算する中心の部分だけを抜き出しました。残りのマークアップとスタイルは、デモの下の zip の中にそのまま入っています。番号と備考の文言はすべてデモ用のダミーで、実際のサーバーとは通信しません。
01残り人数を再計算して桁が繰り上がる呼び出し番号
新しい番号を呼ぶと rollDigits が、呼び出し番号の3桁と残り人数の2桁を、桁ごとに 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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">1組あたり平均 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 = '1組あたり平均 ' + 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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回押すと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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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フレームすべてで動きました。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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の範囲に収め、2桁の数字が 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 の呼び出し番号板は3桁(buildOdometer(callOdo, 3))で作られていて、rollDigits は番号を文字列にして先頭の3文字だけを読みます。番号が4桁になると、1000 の先頭3文字の 100 が板に出ます。07 と 09 の2桁の板は99で値を止めますが、01 の呼び出し番号にはそういう上限がありません。人数が急に増えて桁が増えうる板では、桁数を値から計算して作る必要があります。番号を続けて呼ぶと、まだ回っている桁は from を今見えている値に差し替え、そこから続けて転がるので、前の繰り上がりと重なっても値が飛ぶことはありません。
05 は呼び出し時刻と毎フレームの経過時間の差から残り時間をそのつど計算し直すので、フレームが何回か抜けても時間はずれません。ただ、ブラウザがタブを休ませているあいだ requestAnimationFrame が止まると、戻った最初のフレームで経過時間が一気に大きくなり、ゲージが一息で期限切れの状態へ移ります。コードの構造から出てくる動作です。08 は行を入れるたびに下の行へ新しい animate を重ねますが、前のアニメーションを取り消す cancel() の呼び出しが0件で、行数の上限もありません。優先受付を続けて押すと、前の移動の途中の位置が次の計算に混ざり、リストが伸び続けます。
紙の順番札のように番号と備考を一行に置くこの構成へ、実際の現場の規模の受付をつなぐときは、行数の上限と移動中の取り消しを一緒に入れるほうが安全です。この三つの限界まで含めた九つのソースをそのまま開いて確かめるには、ダウンロードした zip を解凍するときにパスワード auwdqs3v を入力してください。
変形3つとアクセシビリティ
| 項目 | 定数 | 値 | 変わり方 |
|---|---|---|---|
| 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 はループを続けますが、ゲージの補間をせず paintGauge(1) で即座に塗ります)。ボタンを押すとアニメーションなしで結果がすぐ反映され、aria-live="polite" の状態行も一緒に読み上げます。キーボードの経路も九項目すべてにあり、keydown リスナーは合計20個で、ボタンが三つの 07 と 09 が3個ずつ、残りが2個ずつです。操作は Enter と Space で行います。状態行(aria-live)は項目ごとに一つで九つ、リストのマークアップがある 04・06・08・09 の四か所には role="list" が付いています。
呼出表示器はクリニック 順番表示のように平均処理時間だけで待ち順を予測できるか?
時間は予測できますが、順番は別に決めておく必要があります。02 は直近の処理記録の平均に残り人数を掛ける一つの計算で予想待ち分を出しますが、誰が先に呼ばれるかは、この平均とは関係なく、すでに入った番号の順か、08 のような重みで決まります。平均は直近12件だけを残して古いものから捨てるので、急に遅くなった窓口があれば、数件処理するうちに予想時間がそれに追いついて上がります。待ち時間 表示を作るなら、番号と段階を一行で進める画面ではステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階と見比べるとよく、05 のように数字が減って限界で色が変わるゲージが必要ならcss プログレスバー 9選 — ゲーム風に満ちる9つの方式、制限時間のカウントダウンがもっと気になるならqr コード アニメーション css 9選 — 残り時間の計算を続けて読む価値があります。呼び出し表示 の一覧を現場に出す前に、まず自分の窓口数と人数の上限を決めておくと、桁の重なりや行の伸びを先に避けられます。
呼出表示器を現場の画面に載せる前によくある質問
呼出表示灯のような番号発行機の画面にもこの順番板をそのまま貼れますか
表示側なら貼れます。九項目はどれも番号と人数を受け取って描くだけで、発行や印刷はしないので、発行機側が番号を渡す場所に 01 の rollDigits や 07 の applyDelta の呼び出しをつなげば動きます。
予想待ち時間を平均以外の値で計算してもよいですか
できます。02 の targetEta は平均に残り人数を掛ける一行なので、その行だけ別の式に替えても、数字が転がる部分はそのまま再利用できます。
再呼び出しまでの60秒が長すぎるときはどこを直しますか
RECALL_COUNTDOWN_SEC の値を下げます。変形の表の 05 のように30にすると、ゲージの幅の計算も残り秒数の文言も同じ定数から出るので、ほかの場所には手を入れなくて済みます。









