08우선순위 차이를 계산해 끼어드는 우선 호출 줄
우선 순위 대상이 접수되면 남은 순번들과의 우선순위 차이를 계산해 그 자리에 새 배지가 끼어들듯 나타나고, 뒤 순번들은 밀려난 자리만큼 계산되어 내려간다.
쓰임임산부·장애인 우선 호출 표시
PRIORITY_SHIFT_SPRING_STIFFNESSpriorityInsertIndexinsertPriority

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


