06窓口ごとの処理速度を計算して分散表示する複数カウンター状況
各窓口のこれまでの処理人数と速度を計算して窓口ごとに次の予想処理時刻が違って表示され、最も速い窓口のバーがほのかに光る。
使いどころ複数窓口を同時運用する銀行・役所
BAR_TWEEN_MSfastestIdrecalcAll

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


