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


